从请求数到 Token:高并发场景下的实时计费与配额控制架构
一套可以进入生产环境的 LLM 网关方案:请求前原子预留、派发前持久化执行意图、请求后幂等结算,流式断连可追踪,账务流水可审计。
一次“限流正常,预算却失控”的事故
某企业客户在夜间批量执行长文档总结。网关为该租户配置了每分钟 60 次请求,监控显示 QPS 从未超限,第二天的费用却超过日预算数十倍。
复盘后发现,一次普通问答只消耗数百 Token,而一次长文档总结可能包含数万输入 Token,并继续生成数千输出 Token。请求数相同,不代表计算量相同,更不代表成本相同。按照请求数限流,只能回答“调用了多少次”,无法回答下面这些真正与生产风险有关的问题:
- 这一分钟最多允许消耗多少 Token?
- 这个计费周期还剩多少预算?
- 流式输出尚未结束时,应该预留多少额度?
- 客户端断开连接后,上游已经生成的内容是否仍会计费?
- Redis 返回超时,但扣减可能已经执行,重试会不会重复扣款?
- Provider 没有返回 usage 时,这一单应该退款、估算还是挂账?
问题的本质不是“把请求数换成 Token 数”这么简单,而是要在输出量未知、请求可重试、流可能中断、多个模型价格不同的前提下,同时满足实时拦截、最终精确和可审计性。
本文给出一套完整落地方案。核心链路使用 Redis + Lua 完成原子预留、结算与取消,Go 网关负责流式代理和 Provider 适配,Kafka 与关系型数据库保存不可变用量流水,后台任务处理悬挂预留与账单对账。
一、先分清三个系统,否则代码一定会越写越乱
LLM 网关中的“限流”“计量”和“计费”经常被混在一个 Redis 计数器里。小规模时似乎能工作,一旦出现重试、退款、价格调整或月度账单,就会发现没有任何一个数字可以作为可信依据。
| 系统 | 回答的问题 | 一致性目标 | 合适的存储 |
|---|---|---|---|
| 实时配额 | 这次请求现在能不能发? | 低延迟、原子决策、允许短暂负债 | Redis + Lua |
| 用量计量 | 这次请求实际用了什么资源? | 至少一次投递、幂等入账、保留原始证据 | Kafka + 数据库 |
| 财务计费 | 应该向谁收多少钱? | 可复算、可追溯、价格版本不可变 | 关系型数据库 / 数仓 |
Redis 是在线决策层,不应该成为最终账本。Redis 中的余额可以通过对账修正,账务流水却必须可重放、可复算,并能解释每一笔费用的来源。
1.1 Token 配额与费用预算不是同一个维度
同样是 100 万 Token,不同模型、输入输出方向、缓存命中状态和工具调用的价格可能完全不同。因此生产系统至少需要同时控制两个维度:
- 速率维度:以 Token 为单位控制 TPM,保护上游容量并限制突发流量。
- 预算维度:以最小货币单位控制日、月或合同周期预算,防止费用失控。
本文把预算统一表示为 billing_unit。示例中 1 billing_unit = 10^-6 USD,即 1 微美元。所有计算都使用整数,不在扣减链路中使用浮点数。
假设某价格快照规定输入价格为每百万 Token input_price_micros 微美元,费用计算为:
input_cost_micros = ceil(input_tokens × input_price_micros / 1_000_000)
价格必须在请求准入时固定为 pricing_version。即使请求执行期间平台调价,这次请求仍按准入时的价格快照结算,避免同一请求前后使用不同价格。
1.2 四条必须守住的系统不变量
后续所有数据结构和代码都围绕四条不变量设计:
- 同一个
request_id最多产生一次有效预留。 - 同一个
request_id最多完成一次有效结算或取消。 - 已结算请求不能再取消,已取消请求不能再结算。
- Redis 的在线余额与不可变用量流水允许短暂不一致,但必须能够被对账任务发现并修复。
- 在 Provider 请求可能发出之前,必须已有可恢复的持久化执行记录;恢复任务绝不能仅凭 Redis 的
RESERVED状态盲目重发 Provider 请求。
只要其中任意一条没有被代码强制保证,高并发和重试最终都会把偶发问题放大为账务问题。
二、生产架构:同步做决策,异步做账
一次请求的完整生命周期如下:
- 网关完成身份认证,得到可信的
tenant_id、user_id和套餐策略。 - 网关规范化请求参数,强制注入输出上限,估算输入 Token,并加载价格快照。
- 根据输入估算和输出上限计算
reserve_tokens与reserve_units。 - Redis Lua 同时检查周期预算和 TPM 令牌桶,通过后原子预留。
- 网关把请求转发给 Provider,并将 SSE 数据原样返回客户端。
- 流结束后优先使用 Provider 返回的 usage,计算实际 Token 和实际费用。
- Redis Lua 根据
actual - reserved的差额退款或补扣。 - 网关发布不可变 usage 事件。消费者按
request_id幂等写入账本。 - 后台任务扫描悬挂预留,并将内部账本与 Provider 账单进行对账。
这里刻意没有追求 Redis 与 Kafka 之间的分布式事务。两边都以 request_id 实现幂等,失败后可以安全重试,对账任务负责识别单边成功。相比在在线链路中引入两阶段提交,这种设计更容易维护,也更符合网关的延迟目标。
但这不等于可以把 Finalizer.Submit 放进进程内 channel。正确顺序是:Redis 预留成功后,先在关系型数据库事务中写入 quota_execution 和 outbox 事件,再允许派发 Provider 请求。数据库提交失败则调用 cancel.lua;只有数据库提交成功的请求才可进入 Provider。这样,网关即使在派发、流式转发或 Redis 结算的任一步崩溃,worker 都有一条可重放的持久化任务。
三、状态机:不要只存一个“是否扣过”的布尔值
单个请求至少需要以下状态:
REVIEW 不一定要立即写入 Redis,也可以由悬挂预留集合和数据库任务状态表达。关键是不能把“客户端断开”“网关超时”直接等价为“Provider 没有计费”。只有在能够确认请求没有被上游接受时,才能全额取消预留。
Redis 配额状态与数据库执行状态要分开建模。前者只回答“资金是否已预留/结算”,后者回答“能否派发、是否已派发、是否需要核查”。推荐的执行状态为:
READY -> DISPATCHING -> STREAMING -> FINALIZING -> DONE
\-> REVIEW
READY -> CANCELED (仅限尚未派发)
READY 由包含 outbox 写入的数据库事务创建。worker 以 SELECT ... FOR UPDATE SKIP LOCKED 或带版本号的条件更新领取 READY 任务;同一 request_id 同时只能有一个 worker 转为 DISPATCHING。若进程在 DISPATCHING 后崩溃,恢复任务必须进入 REVIEW 或携带 Provider 支持的幂等键查询,不得把它重新当作 READY 再发送一次。
3.1 严格模式与弹性模式
预留策略决定系统能否真正守住预算:
- 严格预付费模式:
reserve_tokens = estimated_input + max_output_tokens。输入按非缓存价格、输出按完整上限预留。它最保守,但能够最大程度避免预算超卖。 - 弹性后付费模式:使用租户与模型维度的 P95/P99 输出长度预留,结算时允许余额形成短暂负数。体验更好,但不能承诺绝不超预算。
EWMA 或历史平均值可以用于弹性模式,不能替代严格模式中的输出硬上限。平均值天然会低估尾部请求,拿它作为预付费硬配额的唯一预留依据,仍会在并发下超卖。
四、Redis Key 设计:让同一租户的多 Key 操作落在同一槽
Redis Cluster 中,Lua 脚本访问的所有 Key 必须位于同一 Hash Slot。下面所有 Key 都使用 {tenant_id} 作为 Hash Tag:
| Key | 类型 | 作用 |
|---|---|---|
llm:q:{tenant}:budget:{cycle} |
Hash | 当前计费周期剩余预算 |
llm:q:{tenant}:rate |
Hash | TPM 令牌桶状态 |
llm:q:{tenant}:req:{request_id} |
Hash | 单请求预留和结算状态 |
llm:q:{tenant}:pending:{cycle} |
ZSet | 按超时时间排列的悬挂预留 |
预算 Key 带 cycle_id,例如 2026-09。周期切换时创建新 Key,旧周期 Key 保留到所有请求结算和对账完成。这样跨月长请求不会把上个月的差额错误地记到新周期。
示例初始化命令:
HSET 'llm:q:{tenant_123}:budget:2026-09' \
remaining_units 500000000 \
pricing_version price_2026_09_01
HSET 'llm:q:{tenant_123}:rate' \
tokens 200000 \
capacity 200000 \
refill_per_sec 3333 \
last_ms 1789776000000
这表示周期预算还剩 500 美元,TPM 桶容量为 20 万 Token,平均每秒补充 3333 Token。示例数值仅用于说明,真实值应由套餐控制面下发。
不要把用户输入直接拼进
{}。tenant_id、cycle_id和request_id必须使用服务端生成或校验后的安全字符集,否则可能破坏 Hash Tag 或制造 Key 注入问题。
五、完整 Lua 实现:预留、结算、取消全部幂等
Redis 脚本适合做短小、固定复杂度的原子状态转换。下面三段脚本均为 O(1),没有对滑动窗口执行 ZRANGE 0 -1,因此延迟不会随着窗口内请求数增长。
5.1 原子预留 reserve.lua
-- KEYS[1] budget key
-- KEYS[2] rate bucket key
-- KEYS[3] request state key
-- KEYS[4] pending reservation zset
--
-- ARGV[1] request_id
-- ARGV[2] owner_token: one gateway invocation keeps the same random token on retry
-- ARGV[3] reserve_units
-- ARGV[4] reserve_tokens
-- ARGV[5] now_ms
-- ARGV[6] record_ttl_seconds
-- ARGV[7] reservation_timeout_ms
-- ARGV[8] pricing_version
-- ARGV[9] model
-- ARGV[10] canonical request sha256
local request_id = ARGV[1]
local owner_token = ARGV[2]
local reserve_units = tonumber(ARGV[3])
local reserve_tokens = tonumber(ARGV[4])
local now_ms = tonumber(ARGV[5])
local record_ttl = tonumber(ARGV[6])
local reservation_timeout_ms = tonumber(ARGV[7])
local max_request_value = 9000000000000
if not reserve_units or not reserve_tokens or not now_ms or not record_ttl or
not reservation_timeout_ms or reserve_units < 0 or reserve_tokens <= 0 or
reserve_units > max_request_value or reserve_tokens > max_request_value or
record_ttl <= 0 or reservation_timeout_ms <= 0 then
return redis.error_reply('INVALID_RESERVATION')
end
local existing_status = redis.call('HGET', KEYS[3], 'status')
if existing_status then
local existing_owner = redis.call('HGET', KEYS[3], 'owner_token') or ''
local existing_hash = redis.call('HGET', KEYS[3], 'request_hash') or ''
local remaining = tonumber(redis.call('HGET', KEYS[1], 'remaining_units') or '-1')
local rate_left = tonumber(redis.call('HGET', KEYS[2], 'tokens') or '-1')
if existing_hash ~= ARGV[10] then
return {
6
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/Ring7852/article/details/166010754




