Lazy loaded image
网关项目demo03丨有了身份、配额和账本后,网关开始具备最小治理入口
Words 2775Read Time 7 min
2025-11-16
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 都必须挂到一个明确主体上,否则后面的治理只是没有归属的统计。
我先关注的不是一个 401200,而是请求进来以后,系统能不能稳定拿到治理最需要的那组上下文:tenantapi_key_id 和配额参数。因为这层如果不稳,后面的 quota 和 usage 记账都挂不到一个明确主体上。所以我想说的是,治理的第一步不是拒绝请求,而是先知道这是谁的请求。
  • migrations/001_init.sql
最关键的三张表就是这三张:
关键字段
这一步留下什么
tenants
enabledrpm_limittpm_limittoken_budget
治理参数归属单元
api_keys
key_hashkey_prefixtenant_id
身份入口与租户绑定
request_usages
request_idtenant_idapi_key_idstatustokens
请求生命周期与资源账本
这里最关键的代码我只看这几处:
  • internal/api/router.goPOST /v1/chat/completionsGET /v1/models 先经过 requireAPIKey
  • internal/auth/service.go:解析 Bearer <token>,做 SHA-256 哈希,查 key_hash,组装 Principal
  • internal/store/mysql/auth_store.go:把 api_key_idkey_prefixtenant 和 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 会:
  1. 优先使用 provider 返回的 usage
  1. provider 没给 usage 时再本地估算 completion tokens
  1. 回写 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.goTestBeginRequestInsertsStartedUsageRecord 会核对 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 这种已经识别出主体、但在治理阶段被拒绝的请求。401403 的分界也在服务治理主线。

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 能不能一起核对上。只有这些一起落下来,我才会把它叫账本,不只是统计表。账本的价值在“记录能把主体、状态和消耗放到同一行”。

总结

身份给了主体,配额给了准入,账本给了生命周期;这三件事接上之后,网关才开始具备最小治理入口。
我的网关对请求链第一次开始回答“谁能进、还能进多少、进来以后怎么算账”。
回到首页