银河技术头像
关注
高并发 LLM 网关如何实时计费:从 Token 预留到断流对账的生产架构封面图

高并发 LLM 网关如何实时计费:从 Token 预留到断流对账的生产架构

从请求数到 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,不同模型、输入输出方向、缓存命中状态和工具调用的价格可能完全不同。因此生产系统至少需要同时控制两个维度:

  1. 速率维度:以 Token 为单位控制 TPM,保护上游容量并限制突发流量。
  2. 预算维度:以最小货币单位控制日、月或合同周期预算,防止费用失控。

本文把预算统一表示为 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 四条必须守住的系统不变量

后续所有数据结构和代码都围绕四条不变量设计:

  1. 同一个 request_id 最多产生一次有效预留。
  2. 同一个 request_id 最多完成一次有效结算或取消。
  3. 已结算请求不能再取消,已取消请求不能再结算。
  4. Redis 的在线余额与不可变用量流水允许短暂不一致,但必须能够被对账任务发现并修复。
  5. 在 Provider 请求可能发出之前,必须已有可恢复的持久化执行记录;恢复任务绝不能仅凭 Redis 的 RESERVED 状态盲目重发 Provider 请求。

只要其中任意一条没有被代码强制保证,高并发和重试最终都会把偶发问题放大为账务问题。


二、生产架构:同步做决策,异步做账

通过

拒绝

客户端请求

API Gateway

Redis 原子预留

LLM Provider

429 或 402

流式转发与 usage 解析

Redis 幂等结算

Kafka 用量事件

用量账本与账单

对账与差异修正

一次请求的完整生命周期如下:

  1. 网关完成身份认证,得到可信的 tenant_iduser_id 和套餐策略。
  2. 网关规范化请求参数,强制注入输出上限,估算输入 Token,并加载价格快照。
  3. 根据输入估算和输出上限计算 reserve_tokensreserve_units
  4. Redis Lua 同时检查周期预算和 TPM 令牌桶,通过后原子预留。
  5. 网关把请求转发给 Provider,并将 SSE 数据原样返回客户端。
  6. 流结束后优先使用 Provider 返回的 usage,计算实际 Token 和实际费用。
  7. Redis Lua 根据 actual - reserved 的差额退款或补扣。
  8. 网关发布不可变 usage 事件。消费者按 request_id 幂等写入账本。
  9. 后台任务扫描悬挂预留,并将内部账本与 Provider 账单进行对账。

这里刻意没有追求 Redis 与 Kafka 之间的分布式事务。两边都以 request_id 实现幂等,失败后可以安全重试,对账任务负责识别单边成功。相比在在线链路中引入两阶段提交,这种设计更容易维护,也更符合网关的延迟目标。

但这不等于可以把 Finalizer.Submit 放进进程内 channel。正确顺序是:Redis 预留成功后,先在关系型数据库事务中写入 quota_execution 和 outbox 事件,再允许派发 Provider 请求。数据库提交失败则调用 cancel.lua;只有数据库提交成功的请求才可进入 Provider。这样,网关即使在派发、流式转发或 Redis 结算的任一步崩溃,worker 都有一条可重放的持久化任务。


三、状态机:不要只存一个“是否扣过”的布尔值

单个请求至少需要以下状态:

原子预留成功

获得实际 usage

确认上游未产生费用

usage 未知或预留超时

对账获得实际用量

确认未计费

RESERVED

SETTLED

CANCELED

REVIEW

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_idcycle_idrequest_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

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--