type
Post
status
Published
date
Nov 16, 2025
slug
gateway003
summary
tags
gateway
category
icon
password
我一开始以为,先把 OpenAI-compatible 请求转出去,治理可以后补。再往下做我才发现,如果系统连谁在调、能不能进、进来后怎么记都没收进边界,网关就还只是一个会转发的入口层。这样往后再讲 health、fallback、observability,很容易发虚。
这篇我只想把一件事情说清楚:网关第一次能把 identity、quota、ledger 接到同一条请求链里,开始回答三件事:
- 谁能进
- 还能进多少
- 进来以后怎么算账
这次实际接上的,也就是三段:
- 在
requireAPIKey后把 Bearer key 解析成 principal,并把 tenant 治理字段带进 context
- 在
Service.BeginRequest里把token_budget / rpm_limit / tpm_limit接到 chat 前的准入
- 在
requestTracker.Fail / CompleteNonStream / CompleteStream里把一次请求从started回写到succeeded / failed
做这一步之前,这个系统最多只能把请求转出去;做完之后,它至少能把主体、准入和生命周期收进同一条链。请求进入链路时就要开始生效。
把 API Key 变成可治理的主体
为什么?因为 quota 和 usage 都必须挂到一个明确主体上,否则后面的治理只是没有归属的统计。
我先关注的不是一个
401 或 200,而是请求进来以后,系统能不能稳定拿到治理最需要的那组上下文:tenant、api_key_id 和配额参数。因为这层如果不稳,后面的 quota 和 usage 记账都挂不到一个明确主体上。所以我想说的是,治理的第一步不是拒绝请求,而是先知道这是谁的请求。migrations/001_init.sql
最关键的三张表就是这三张:
表 | 关键字段 | 这一步留下什么 |
tenants | enabled、rpm_limit、tpm_limit、token_budget | 治理参数归属单元 |
api_keys | key_hash、key_prefix、tenant_id | 身份入口与租户绑定 |
request_usages | request_id、tenant_id、api_key_id、status、tokens | 请求生命周期与资源账本 |
这里最关键的代码我只看这几处:
internal/api/router.go:POST /v1/chat/completions和GET /v1/models先经过requireAPIKey
internal/auth/service.go:解析Bearer <token>,做 SHA-256 哈希,查key_hash,组装Principal
internal/store/mysql/auth_store.go:把api_key_id、key_prefix、tenant和 tenant 治理字段一起查出来
这一步我关注的是:鉴权一过,治理最小上下文就一起带进来了。后面的限流、预算和 usage 归因,不需要再猜这个请求属于谁。
tenant
api_key_id
key_prefix
rpm_limit
tpm_limit
token_budget
只有请求先被认成一个明确主体,后面的限流、预算和 usage 归因才有地方落。
再把准入前移到 chat 入口前
为什么?因为治理不能只在请求结束后统计,它必须在请求进入 chat 链路之前先决定能不能放行。
我一开始也容易把 quota 想成“请求结束后再统计一下”。再往下看我才发现,关键不是最后记了多少,而是请求进去之前,系统就得先回答这次能不能放行。quota 是准入动作,不只是事后报表。
我主要看的逻辑在:
internal/service/governance/service.go里的Service.BeginRequest
这里先做的是三类检查:
token_budget
rpm_limit
tpm_limit
分钟级计数当前实现是:
- Redis 优先:
internal/service/governance/redis_limiter.go
- MySQL fallback:降级兜底继续可用
分钟窗口 key 也已经固定下来了:
准入步骤表:
步骤 | 检查项 | 超限结果 |
1 | token_budget | 403 |
2 | rpm_limit | 429 |
3 | tpm_limit | 429 |
4 | usage 初始化 | 写入 started |
5 | 请求放行 | 进入 chat 链路 |
我想说的是:
请求进入前只能基于 prompt side 做保守估算;completion tokens 只能在请求结束后再记账。
前面先保守估,后面再回写,这样 admission 和 accounting 才不会混在一起。请求前管准入,请求后管账本,这条线必须先分开。
再把 usage 从成功统计补成账本
为什么?因为 quota 只回答“能不能进”,但一个治理入口还必须知道请求进来以后经历了什么。
quota 只回答“能不能进”,不回答“进来以后发生了什么”。这也是我为什么没有把 usage 当成附带统计,而是单独补成一层账本。所以我想说的是,usage 不只是成功请求的 token 汇总,它应该记录请求生命周期。
我这里故意只留三种状态,先把生命周期立住,不先把状态机写花。所以我想说的是,状态少一点没关系,关键是每个请求都能从开始走到明确结果。
started
succeeded
failed
对应的处理也只保留这三件事:
1. 请求开始先写 started
为什么?只有成功请求才有记录,会让失败和中断都从账本里消失。
Service.BeginRequest 会先往 request_usages 写一条记录。所以我想说的是,请求一进治理链路,就应该先有一条生命周期记录。status = started
prompt_tokens = estimated prompt tokens
total_tokens = estimated prompt tokens
2. 非流式完成后回写 succeeded
为什么?因为非流式请求结束时,provider usage 是最直接的 token 来源,但也要准备好没有 usage 的情况。
requestTracker.CompleteNonStream 会:- 优先使用 provider 返回的 usage
- provider 没给 usage 时再本地估算 completion tokens
- 回写
succeeded
我想说的是,非流式路径先尊重 provider usage,缺失时再降级估算,这样账本不会因为上游少给字段就断掉。
3. 流式和失败请求也单独落账
为什么?因为 stream 没有一个天然完整的 usage 对象,失败请求也不能从账本里消失。
ObserveStreamChunk会累计 chunk 内容
requestTracker.CompleteStream会在流结束后估算 completion tokens
requestTracker.Fail会把失败请求回写成failed
我想说的是,
request_usages 不是成功请求统计表,而是请求生命周期记录表。没有这层账本,网关最多只是知道自己拦了什么;有了这层账本,它才知道一条请求从进来到结束到底经历了什么。所以我想说的是,账本让治理从“准入判断”继续走到了“生命周期记录”。
我只核对两件事:拒绝语义和账本落账
为什么?因为这篇只需要确认治理入口最小链条有没有立住。
HTTP 语义看准入,SQL 账本看生命周期:
- 拒绝分支的 HTTP 语义是不是已经稳定了
request_usages能不能把主体、状态和 token 使用量一起记下来
验证项收成这张表:
场景 | 预期结果 | 我关注的点 |
missing api key | 401 | 鉴权边界有效 |
invalid api key | 401 | 无效 key 语义稳定 |
disabled key / tenant | 401 | 状态控制生效 |
rate limit exceeded | 429 | RPM 治理生效 |
token rate limit exceeded | 429 | TPM 治理生效 |
budget exceeded | 403 | token budget 治理生效 |
valid request | 200 | 准入后的正常路径可用 |
request usage inserted / updated | DB 中可见 | usage 生命周期被记录 |
自动化测试主要在:
internal/api/router_test.go
internal/service/governance/service_test.go
自动化里我还专门把 lifecycle 核对过三次。所以我想说的是,这里不是只看一次请求能不能通,而是在看
started -> succeeded / failed 这条账本链有没有站住。internal/service/governance/service_test.go:TestBeginRequestInsertsStartedUsageRecord会核对req-1被写成tenant_id=9 / api_key_id=7 / status=started
- 同文件的
TestCompleteNonStreamUsesProviderUsageWhenPresent会核对非流式回写succeeded,并保留 provider 返回的 token usage
TestCompleteStreamAggregatesChunkContent会核对流式回写succeeded,并把 chunk 聚合后的 token usage 落下来
有一个语义我这里也故意收得很清楚:missing / invalid / disabled 我都放在
401 这一侧,不把它们抬成 403。因为对这个入口来说,这三类都还是“没资格进入后续链路”;403 留给 token_budget 这种已经识别出主体、但在治理阶段被拒绝的请求。401 和 403 的分界也在服务治理主线。invalid key -> 401
无效 key 是入口鉴权最基础的拒绝语义,不能放行,也不能泄漏成内部错误。这一步把身份入口守住。
RPM exceeded -> 429
为什么?因为 RPM 是最直接的请求频率准入,必须发生在 chat 处理之前。
这里我关注的是 RPM 这道门已经立在 chat 处理之前了。请求频率已经从配置变成了准入条件。
TPM exceeded -> 429
模型网关不能只管请求次数,还要在进入模型调用前管 token 消耗速度。
TPM 这一条说明它管的不只是请求次数,还会提前管 token 消耗速度。网关开始把 token 消耗也纳入准入判断。
budget exceeded -> 403
token budget 已经不是展示字段,而是会影响请求能不能进入后续链路的治理阈值。
这一条把
token_budget 从“配置项”变成了会参与准入的治理阈值。主体被识别出来以后,预算可以继续决定它能不能进。X-Request-Id
后面要把响应、账本和排障串起来,客户端手里必须先拿到一个能继续核对的键。
这篇不展开 trace,也不展开可观测性。我这里只借一条 chat completion 的原始响应头,先把“这次请求是谁”说清楚。所以我想说的是,
request_id 先从内部字段变成了客户端也能拿到的线索。我关注的是客户端手里已经拿到了一个继续往下核对的键。到这一步,
request_id 已经不是进程里的内部细节了。后面要做 request correlation,入口这里已经先留下了线头。request_usages 查询结果
只有 SQL 账本能说明请求不只是被放行了,还被系统按主体和状态记录了下来。
我看是
request_id / tenant_id / api_key_id / status / tokens 能不能一起核对上。只有这些一起落下来,我才会把它叫账本,不只是统计表。账本的价值在“记录能把主体、状态和消耗放到同一行”。总结
身份给了主体,配额给了准入,账本给了生命周期;这三件事接上之后,网关才开始具备最小治理入口。
我的网关对请求链第一次开始回答“谁能进、还能进多少、进来以后怎么算账”。
分享
