type
Post
status
Published
date
Apr 6, 2026
slug
gatewaytwo
summary
tags
gateway
category
icon
password
RPM = Requests Per Minute TPM = Tokens Per Minute
摘要
新增功能:
- API Key 拥有 tenant(租户) 识别入口
- 请求进入前已经有 RPM / TPM / token budget 的准入判断
- 请求处理过程中已经开始记录 usage(设备的使用)
- 请求结束后,系统会对 token 消耗做归因和状态回写
LLM Access Gateway 到这里已经开始具备治理型接入层的基本形态。一、问题定义
很多所谓的 LLM 网关项目,本质上只是把上游模型接口接起来,再把请求转发出去。
这类系统当然也能跑,也可以提供一个看起来像样的统一接口:
POST /v1/chat/completions
stream=true
- 返回 JSON 或 SSE
但如果系统里没有下面这些东西:
- 谁在调用
- 这个调用属于哪个 tenant
- 这个 tenant 还能调多少次
- 这个 tenant 当前还能消耗多少 token
- 这次请求最终消耗了什么
那么它更接近一个“模型请求代理层”,而不是一个“治理型接入层”。
二、最小治理闭环
这一阶段补上了一条最小但完整的治理闭环:
.png%3FspaceId%3Dd89f2367-b54a-81cd-9c59-00032f2f4f1e?table=block&id=33af2367-b54a-8008-b883-d6b709b8a92b&t=33af2367-b54a-8008-b883-d6b709b8a92b)
如果展开成文字,这条链路对应的是:
这条链路意味着一件很重要的事:
网关开始不只负责“把请求送到上游”,还负责决定“这个请求是否允许发生,以及发生后如何被记录”。
这就是治理入口和普通代理层的根本差异。
三、为什么它已经不只是转发器
如果把“普通模型转发器”和“治理型接入层”做一个最小对比,差异其实非常明确:
维度 | 普通模型转发器 | 治理型接入层 |
身份识别 | 没有或很弱 | 有 API Key / tenant |
准入判断 | 基本没有 | 有 RPM / TPM / budget |
请求记录 | 很少或没有 | 有 usage 生命周期 |
系统职责 | 把请求发出去 | 决定谁能调、还能调多少、如何记账 |
也就是说,真正的分界线在于系统是否已经把:
- 身份
- 配额
- 账本
收进了自己的边界。
从这一阶段开始,
LLM Access Gateway 处理的已经不只是“请求转发”,而是“请求治理”。四、数据模型:tenant、api_key 与 request_usage
这一阶段最基础、但也最重要的设计,在数据模型边界。
初始化 schema(纲要)位于:
migrations/001_init.sql
当前阶段最关键的三张表如下。
4.1 tenants(租户)
tenants 不是简单的租户名映射表,它本身就承载了治理字段:enabled
rpm_limit
tpm_limit
token_budget
这意味着 tenant 不只是业务归属概念,还是限额与预算的归属单元。
4.2 api_keys
api_keys 表的关键点有两个:- 不存 raw key,只存
key_hash
- 保存
key_prefix作为可安全展示的标识
这意味着系统不会把原始密钥直接落库,同时又保留了一点足够用于审计和排障的信息。
更重要的是,这张表把 API Key 与 tenant 做了显式关联,因此 Bearer key 一旦校验成功,tenant 也会被同时解析出来。
4.3 request_usages
request_usages 是把系统从“转发器”推进成“治理层”的另一个关键点。它记录的不是抽象指标,而是一次次真实请求的最小账单,包括:
request_id
tenant_id
api_key_id
model
stream
status
prompt_tokens
completion_tokens
total_tokens
可以把这三张表的职责压缩成下面这张表:
表 | 承载的信息 | 作用 |
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 | 请求生命周期与资源账本 |
当这三类数据被系统同时持有时,网关的职责就已经明显不同于普通转发器了。
五、API Key 如何被解析成可治理的 principal(委托人)
这一阶段最重要的一条代码主链,是 Bearer key(持有者) 如何被解析成真正可治理的 principal。
5.1 路由层只负责建立边界
在
internal/api/router.go 中,POST /v1/chat/completions 和 GET /v1/models 都先经过 requireAPIKey。这一层做的事情很克制:
- 从请求头读取
Authorization
- 调用 auth service 做鉴权
- 成功则把 principal 写入 context
- 失败则直接返回
401
也就是说,路由层并不关心数据库细节,它只负责把“是否允许进入后续逻辑”收口成一个清晰边界。
5.2 Auth service 负责语义归一
在
internal/auth/service.go 中,auth service 会:- 解析
Bearer <token>
- 对 raw key 做 SHA-256 哈希
- 去 store 层查
key_hash
- 将查找到的
tenant、api_key_id、api_key_prefix组装成Principal
这里有两个关键点。
第一,principal 携带的不只是 tenant,还有
api_key_id。这使后面的限流、usage 归因和审计具备了更细粒度的基础。第二,service 会把
ErrAPIKeyNotFound 和 sql.ErrNoRows 统一映射成 ErrInvalidAPIKey。这意味着“无效 key”这条语义不会因为底层 store 的差异而漂移成 500。5.3 Store 层在鉴权阶段一并解析 tenant 治理信息
在
internal/store/mysql/auth_store.go 中,auth store 并不是只查 API Key 本身,而是直接把下面这些信息一起查出来:api_key_id
key_prefix
tenant.id
tenant.name
tenant.rpm_limit
tenant.tpm_limit
tenant.token_budget
这一条解析链可以压缩成四步:
- 读取
Authorization: Bearer <token>
- 对 raw key 做 SHA-256 哈希
- 按
key_hash查询 API Key 与 tenant
- 生成
Principal(tenant, api_key_id, key_prefix)
鉴权成功时,治理所需的最小上下文已经齐了。
后面的治理服务不需要再做一次“这个 key 属于谁”的重复查询。
六、RPM / TPM / token budget 的最小实现
如果说多租户 API Key 解决的是“谁在调”,那么 quota 解决的就是“这个人还能调多少”。
当前阶段的最小治理逻辑主要集中在:
internal/service/governance/service.go
6.1 token budget:总量约束
在
BeginRequest 中,治理服务会先根据 tenant 的 token_budget 做总量检查:- 查询当前 tenant 已累计使用的
total_tokens
- 估算本次请求的 prompt tokens
- 如果相加超过 budget,则直接返回
ErrBudgetExceeded
这说明 budget 是一个真正的请求进入前治理动作,而非事后统计。
6.2 RPM / TPM:分钟级准入限制
治理服务并把实现分钟级计数这件事交给
Limiter 接口。当前实现包括:
- Redis 优先:
internal/service/governance/redis_limiter.go
- MySQL fallback:作为降级兜底继续可用
这一层做的是 tenant 维度的:
- RPM 计数
- TPM 计数
并且用分钟粒度 key 管理窗口:
这说明当前 quota 开始具备更接近真实网关的分钟级计数路径。
6.3 completion tokens:前后分段入账
这一阶段还有一个重要细节:
BeginRequest只基于 prompt side 做准入判断
- completion tokens 在请求完成后再单独记录
也就是:
步骤 | 检查项 | 超限结果 |
1 | token_budget | 403 |
2 | rpm_limit | 429 |
3 | tpm_limit | 429 |
4 | usage 初始化 | 写入 started |
5 | 请求放行 | 进入 chat 链路 |
这意味着系统已经开始区分:
- 请求前只能基于输入做保守估算
- 输出 token 只有在请求结束后才能更可靠地记账
这是治理型网关该有的思路,不把 token 使用量当成一个静态数字处理。
七、usage 记录如何落地
quota 解决的是“能不能进”,usage 解决的是“进了以后怎么记”。
7.1 请求开始即写入 started
在治理服务的
BeginRequest 中,会先往 request_usages 写入一条记录:status = started
prompt_tokens = estimated prompt tokens
total_tokens = estimated prompt tokens
这一步很关键,它让每次请求从一开始就有落库记录,不是只有成功请求才被记账。
7.2 非流式完成后优先使用 provider usage
对于非流式请求,
CompleteNonStream 会:- 优先使用 provider 返回的 usage
- 如果 provider 没给 usage,则本地估算 completion tokens
- 回写
succeeded
这表明系统选择了更务实的平衡方案,不完全依赖 provider,也不彻底否定 provider
7.3 流式完成后通过 chunk 聚合估算 usage
对于流式请求,
ObserveStreamChunk 会持续累计 chunk 内容,CompleteStream 在流结束后再估算 completion tokens。原因很直接:
SSE 场景天然没有一个可直接拿来落库的完整 usage 对象。
因此,usage 记录在流式路径里也是治理逻辑的一部分。
7.4 失败请求也会回写 failed
一旦请求在处理中失败,tracker 会把 usage 状态更新为
failed。这说明
request_usages 是一张“请求生命周期记录表”。对于一个治理入口来说,这种区分很重要,因为:
succeeded用于资源结算
failed用于治理判断与排障分析
两者都不能缺。
7.5 usage 生命周期
为了不让这部分过度图示化,可以直接把 usage 生命周期压成下面三行:
started:请求已进入治理链路,并完成 usage 初始化
succeeded:请求成功完成,并回写最终 token 使用量
failed:请求中途失败,并保留失败记录
这说明 usage 记录就是请求生命周期本身的一部分。
八、为什么到这里它已经不只是转发器
如果把当前阶段的系统职责压缩成一句话,就是:
网关现在不仅负责“把请求转发给模型”,还负责“确认谁有资格调用、调用是否超限、调用消耗如何被记录”。
这背后有三个本质变化。
8.1 系统开始拥有“身份”
有了 tenant 和 API Key 之后,请求是一个有归属的调用动作。
8.2 系统开始拥有“决策”
有了 RPM / TPM / token budget 之后,网关会主动做准入判断。
8.3 系统开始拥有“账本”
有了
request_usages 之后,网关开始沉淀资源消耗记录。有了这三点,它开始像一个真正的治理入口。
九、验证项与测试基线
这一阶段最关键的测试场景在下面这张表:
场景 | 预期结果 | 说明 |
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 中可见 | 请求生命周期被记录 |
与这些行为直接对应的测试主要位于:
internal/api/router_test.go
internal/service/governance/service_test.go
结论
一句话概括这一阶段:
给模型网关补上多租户 API Key、RPM / TPM / token budget 和 usage 记录之后,它解决的问题已经从“怎么把请求发出去”变成了“谁可以调用、还能调用多少、调用后如何被记账”。
从这一刻开始,
LLM Access Gateway 开始成为一个真正意义上的治理入口。如何本地验证?
先在一个终端启动网关服务,然后才能在另一个终端进行如下验证:
10.1 拒绝分支验证示例
- invalid key →
401
- 验证命令:
验证结果示例图:

- RPM exceeded →
429

- TPM exceeded →
429

- budget exceeded →
403

10.2 usage 记录查询结果

这类 SQL 结果很有价值,它能直接证明:
系统不只是会拒绝和放行,还会记账。
10.3 本地 seed 输出
go run ./cmd/devinit 的输出本身就能证明当前系统初始化出来的 tenant 治理参数:
如果测试结果全部和示例结果一致,恭喜你本阶段功能无误,可以进入下一阶段!
分享
