LLM 网关限流设计:把限流做成记账——预扣、在途、结算三段式

方案一句话:输出 token 未知,就用预估值预扣占坑(admit),在途期间由预留层替它出现在窗口里(in-flight),流结束用真实值接班兑平(settle)。窗口本身用 Redis 分桶滑动窗口(7 桶 × 10s,恒覆盖 ≥60s)承载,多实例共享、O(1) 成本。
目录
- 0. 背景:要解决什么问题
- 1. 全景架构图(ASCII,RPM 与 TPM 分开画)
- 2. 为什么这么设计(决策链)
- 3. 数据结构
- 4. TPM:admit + reserve(Lua,每请求一次)
- 5. TPM:settle(Lua,请求结束/失败时,异步)
- 6. RPM:TPM 的退化版
- 7. 参数与权衡
- 8. 已知近似与局限(诚实清单)
- 9. 高 QPS 优化(可选)
- 参考
0. 背景:要解决什么问题
0.0 LLM 限流和传统 API 限流不一样在哪
传统 API 限流(登录、支付接口)用固定窗口计数器或令牌桶就够了,因为三个前提都成立:单个请求成本固定、消耗量请求时已知、请求生命周期短(毫秒级)。LLM 网关把这三个前提全打破了:
传统 API LLM 网关
───────────────────── ─────────────────────────────
单请求成本固定 单请求成本跨 3~4 个数量级:
(一次查询=一次计数) 闲聊 "hi" ≈ 十位数 token;
带历史的多轮对话 ≈ 数千;
长文档分析/RAG/代码仓问答 ≈ 数万~十几万
(逼近模型上下文上限 128K~200K)
└─ 同一 key 的请求成本差 1000 倍以上;
RPM 只防海量轻请求,TPM 只防重请求,
两者互补,必须同时限、取交集
消耗量请求时已知 输出 token 在请求时未知:
(就是 1) 要等流式响应结束(几十秒后)才知道
└─ 限流决策却必须在请求进来的一瞬间做
毫秒级返回 流式请求在途几十秒:
同一时刻大量子请求"在飞行",
它们未来的 token 消耗都不在计数器里
└─ 并发突发下必然超卖(盲区问题)
成本=次数 成本=prefill/decode 的 GPU 算力:
token 才是真实负载,请求个数不是
所以 LLM 限流要解决的是:用"现在就知道的部分"(input 实测 + output 历史)替"还不知道的部分"(在途输出)占位,等真实值出来再兑平——这就是预估 + 预扣 + 校正三段式的由来。再加上网关是多实例部署,计数还必须跨副本共享(否则总量 = 副本数 × 配额)。
0.1 两个限流指标
| 指标 | 含义 | 上游为何要限 |
|---|---|---|
| RPM (Requests Per Minute) | 每分钟请求数 | 保护调度/接入层:请求个数可预知、成本固定 |
| TPM (Tokens Per Minute) | 每分钟 token 吞吐 | 保护算力:prefill/decode 是真 GPU 成本,token 才是真实负载 |
上游(OpenAI/Bedrock 等)同时挂这两个指标,网关作为多租户入口必须替每个 key 维护同样的配额,否则自己先被上游 429 集体打回。
0.2 术语约定(admit / in-flight / settle / spent)
先统一四个贯穿全文的动词/名词(借自并发系统的 admission control 与 LiteLLM 等开源实现的叫法),后文所有图和 Lua 脚本都按此读:
| 术语 | 含义 | 为什么不用更熟悉的词 |
|---|---|---|
| admit(准入) | 请求到达时"读额度 + 判断 + 预扣写入"的原子动作 | 不是 check:check 只读不写;admit 是放行同时就把坑占上 |
| in-flight(在途) | 已 admit、还在流式、尚未 settle 的请求 | 限流器对它只能看到预估值,看不到真实值 |
| settle(结算) | 流结束后"真实 usage 落桶 + 还预扣坑位"的校正动作 | 不是记录/record:它本质是一换一的兑平,且要幂等 |
| spent(已耗) | 窗口内已完成请求的真实 token 之和 | 限流判断公式 spent + inflight + est 里的三兄弟 |
0.3 三个核心矛盾(设计的出发点)
矛盾① token 数在限流决策时未知
输入 token 要编码才知道(可本地估),输出 token 要等流式响应
结束才知道(几十秒后)——而限流判断必须在请求进来的一瞬间做。
└─ 引出:预估(input实测+output历史EMA)+ 在途预留 + 事后结算
矛盾② 多实例共享计数 vs 热路径性能
网关多副本,本地计数各算各的 → 总量 = 副本数 × 配额,集体超卖。
但每请求一次 Redis RT 又拖延迟。
└─ 引出:Redis 共享 + Lua 原子,admit 压到 1 次 EVALSHA
矛盾③ 软约束 vs 硬约束
TPM/RPM 超一点不会破产(最终一致即可);日/月预算超了就是真金白银
(不可超卖)。当一套计数器设计,是所有翻车的起点。
└─ 引出:本方案允许有限的近似误差,靠 safety factor + inflight
预扣 + settle 校正控制超卖风险,只服务软约束;
计费/预算走精确的独立链路
0.4 由矛盾推导出的方案骨架
token 未知(矛盾①)──▶ 预扣制:admit 预扣预估额度占坑
│ 流结束用真实 usage 替换(结算)
│ 失败退款 / 挂死由 recycler 显式退款(ts 超时)
▼
多实例(矛盾②)──▶ 计数器放 Redis,分桶 key(下标寻址隐式按时间
│ 过滤 + TTL 自动回收,免 ZSET 的扫描和清理)
▼
软约束(矛盾③)──▶ 允许有限预估误差:分桶边界、挂死退款前的虚高等结构
性误差单向偏保守;预估误差双向存在,由
safety factor + 预扣 + settle 兑平控制,
不修到最后一位
一句话:因为输出 token 未知,所以预扣;因为多实例,所以 Redis;因为软约束,所以允许近似 + 事后校正。 后文每一节都是这三句话的展开。
0.5 TPM 的预估策略:EMA(指数移动平均)——全景图 ⓪ 的展开
★ 本方案的核心差异点:相比我调研的部分开源实现,我这里做了动态预估优化——多数主流预扣实现使用静态或简单可配置的预扣值(如默认 4096 / 500 档,LiteLLM 等也支持按模型配置),本方案改用 EMA 从该 key 的真实 usage 在线学习预扣刻度;其余部分(预扣/对账两段式、分桶窗口)都是成熟模式的组合。
预估只存在于 TPM 链路:RPM 的消耗量 admit 时已知(恒为 1 个请求),当场计数即可;TPM 的输出 token 未知,才需要"预扣多少"的刻度(对应 §1.3 的核心差异)。它在 TPM 链路里的位置(对应 §1.2 图的 ⓪):
settle(真实usage=780) 下一个请求 admit 的 ⓪
┌─────────────────────────────────┐ 更新后 ┌──────────────────────────────┐
│ Redis: tpm:ema:{key}:{model} │ ──────▶ │ est_output = min(effective_max_│
│ 定点整数(真实均值×1000) │ │ output, EMA×2),再判 room │
│ new=(700×old+300×780×1000)/1000 │ └──────────────────────────────┘
└─────────────────────────────────┘
▲ settle Lua 内顺手更新(异步路径) admit Lua 内顺手 GET 并计算
——零额外 RT(本来就各有一次 EVALSHA)
每个 (key, model) 只维护一个 Redis 定点整数(tpm:ema:{key}:{model},真实均值×1000,避免 Lua 浮点精度问题):settle 的 Lua 里顺手更新,admit 的 Lua 里顺手读取并计算 est——两者都嵌在本来就有的 EVALSHA 里,零额外 RT、零额外命令往返。
为什么放 Redis 而不是实例本地内存:admit/settle 本来就要各发一次 EVALSHA,"放本地省网络"是伪论据——热路径的 Redis RT 早已存在,多一个嵌在脚本里的 O(1) GET/INCRBY 不增加往返。放 Redis 的收益是实打实的:多 Pod 下同一 key 的预扣刻度全局一致,Pod 重启/路由漂移不丢历史。真正需要本地化的场景只有一个:走 §9 的"admit 零 RT 本地缓存"模式时,EMA 退化为本地缓存副本(由 settle 返回值刷新),那才是真正省网络的分支。
为什么必须估、又不能估死:
| 预估方式 | 结果 |
|---|---|
| 按用户 max_tokens(4096) | 实际输出常只有 300 → 系统性高估 10 倍 → 大量误杀 429 |
| 固定值(如 1000) | 长输出用户漏扣 → 超卖 |
| EMA(本方案) | 贴着该 key 真实水平滚动,突变几个请求就跟上 |
参数直觉:
- 新数据权重 0.3 → 半衰期 ≈ 2 个请求:用户从闲聊切到长文生成,约 5 个请求内收敛;但单次离群长输出只把 ema 抬高 30%×离群幅度,随后指数回落——平滑与敏捷的平衡点
- 使用时 ×2(经验 safety factor,不是推导值,需线上校准):EMA 是均值,约一半请求会超过它;翻倍大致罩住长尾。系数宁可偏大——高估的代价(短暂保守)远小于低估的代价(超卖被 429)。上线后要观察误杀率 vs 超卖率的数据再调:误杀多就降系数,超卖多就升
- 漏估的部分由 settle 校正兑平(§5),所以预估只需数量级正确,不需要精确
为什么 EMA 恰好适配这个场景:对预估器的硬约束其实只有三条——更新必须 O(1)((key×model) 组合可达几十万,一个 Redis 整数 key、一行算术);必须流式单条喂入且新 key 无冷启动(每条独立更新,初值给全局默认即用);必须跟得上分布突变又不能被离群值带偏(新数据权重 0.3、半衰期约 2 个请求,偶发 10 万 token 离群只抬升 30% 后指数回落)。EMA 一个变量全部天然满足。
更关键的一条软约束:误差代价不对称 + settle 兜底,使预估只需数量级正确。高估 → 短暂保守(可忍);低估 → 漏扣由 settle 补记(可修)。既然错了能修,就不该为精度付复杂度——这决定了"够用的算法里选最简的"是正确策略,EMA 正是那个最简解。
EMA 的正确演进方向不是"换更精的算法",而是"分维度多存几个 EMA"(按 model/task-type 拆桶)——先用最简版本上线,误杀率数据说话。
一句话:预估不需要准,只需要不离谱——因为 settle 是最终裁判,EMA 的职责是让裁判少出场。
1. 全景架构图(ASCII,RPM 与 TPM 分开画)
1.0 先看请求时间线:为什么需要预留层(理解后面两张图的钥匙)
先把同一个 key 的几个请求铺在时间轴上,看限流器在每个时刻"看得见什么、看不见什么":
时间 ▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶▶
请求A ├─ admit ─┤═══════════ SSE 流式(输出 token 逐字逐句生成)═══┤─ settle ─┤
预扣4400 ◀──────────── 这30秒里 A 的真实消耗还没落地 ────▶ 真实3900落桶
进inflight 还掉inflight的占位
请求B ├─ admit ─┤════════ SSE ══════════┤─ settle ─┤
预扣5500 真实5200
请求C ├─ admit ─┤── ... ──
▲
│
C 的 admit 判断时刻(关键!)
C 看到的窗口:
spent = 之前已 settle 的所有请求的真实 token ← 看得见(在 done 桶里)
inflight = A 的 4400(在流式) + B 的 5500(在流式) ← 看得见,
因为 admit 时预扣进了 inflight
+ C 自己的 est
判断:spent + inflight + est(C) > TPM limit ?
反例:如果只有 done 桶、没有 inflight ——
C 看到的窗口 = spent(A、B 都不在里面!)
→ C 觉得额度充裕放行,D、E、F 也这么觉得
→ A、B 流结束后近万 token 同时落桶 → 超卖、被上游 429
└─ 这就是"在途盲区",inflight 预留层存在的全部理由
每个请求的一生只有三个状态,且任一时刻只占其一(互斥换岗):
┌─────────────┐ admit: 预估值占位 ┌──────────────┐ settle: 真实值接班
│ 还没进来 │ ──────────────────▶ │ inflight │ ─────────────▶ ┌────────┐
│ (不计数) │ HSET 预扣 │ 预扣值替身 │ INCRBY 落桶 │ done │
└─────────────┘ │ (只被后续 │ + HDEL 还坑 │ 真实值 │
│ admit 读到) │ │ 为主 │
└──────────────┘ └────────┘
挂死(网关崩溃永远等不到 settle)──▶ 明细 ts 超过 reservation timeout,由低频 recycle 任务显式退款,坑位最终必被归还
记住这张时间线,后面两张图里的每个部件都能对上号:RPM 图没有 inflight(消耗量 admit 时已知,当场记入 done);TPM 图的所有额外部件(预估/inflight/settled/settle 队列/EMA 反馈)都是在服务这条时间线上"30 秒的未知区间"。
1.1 RPM:分桶滑动窗口计数
先澄清一点:RPM 和 TPM 用的是同一套分桶设计(7 桶 × 10s,key 名含时间,下标寻址过滤 + TTL 回收)。标题里说它"简单",指的是链路简单——没有预估/预扣/settle 这三层,不是"精确滑动窗口"。
为什么用分桶:滑动窗口的本质需求是"按时间过滤 + 求和"。把时间编码进 key 名(rpm:done:{key}:{idx},idx = floor(now/10s))之后,"过滤"变成了免费的地址寻址(只读当前下标往回 7 个 key,老桶天然够不着),"求和"变成 7 次 O(1) GET,"清理"变成 TTL 自动过期——相比 ZSET 按请求存记录的方案(每次 ZRANGE 拉全窗口成员回传累加 + 定期 ZREMRANGEBYSCORE 清理,成本随 QPS 线性变差),分桶的成本是常数,且零清理代码。代价是它是近似窗口:桶数决定误差方向——6 桶(含当前未走完的桶)有效覆盖只有 50~60s,恒短于 60s,误差是漏算方向(稳态最多放行约 1.2 倍配额);本方案取 7 桶(idx-6..idx),有效覆盖 60~70s,恒 ≥ 60s,误差只有多算一个方向(最老桶最多带入 60~70s 前的量,稳态最多高估约 1/6 ≈ 17%,偏保守):
精确滑动窗口(ZSET 实现):任意时刻回看恰好 60s,需显式按时间过滤 + 定期清理
分桶滑动窗口(本方案):7 桶 × 10s 求和,覆盖 60~70s 恒 ≥60s(单向多算、偏保守),零清理
▲
└─ RPM 与 TPM 共用这套机制,只是桶里计的量不同:
RPM 桶里 INCRBY 1(请求数),TPM 桶里 INCRBY 真实token
RPM 能省掉那三层的原因:请求进来时消耗量已知(恒为 1),当场判断、当场记账、无在途状态。TPM 的所有额外复杂度(预估、inflight 预留、幂等结算、退款)都是"输出 token 未知"这一个事实引出来的。
┌──────────────────────────────────────────────────────┐
│ REDIS (shared counters) │
│ │
│ RPM bucket keys: rpm:done:{key}:{idx} (string) │
│ ≥60s 分桶近似滑动窗口 = 7 buckets x 10s each │
│ │
│ ┌───────┬───────┬───────┬───────┬───────┬───────┬───────┐
│ │bkt[-6]│bkt[-5]│bkt[-4]│bkt[-3]│bkt[-2]│bkt[-1]│bkt[0] │
│ │ 0 │ 12 │ 0 │ 30 │ 28 │ 25 │ 4 │
│ │60-70s │50-60s │40-50s │30-40s │20-30s │10-20s │now-10s│
│ │ ago │ ago │ ago │ ago │ ago │ ago │ ago │
│ └───────┴───────┴───────┴───────┴───────┴───────┴───────┘
│ (sample: request count per bucket, SUM = 99) │
│ │
│ bucketIdx = floor(now / 10s), TTL = 120s │
│ all timestamps from redis.call('TIME') │
└──────────────▲───────────────────────────────────────┘
│ admit (EVALSHA, atomic)
┌──────────┐ ┌───────────────────────┴──────────────────────────────────────┐
│ │ │ 网关实例 ×N(无状态,可水平扩展) │ ┌─────────┐
│ Client │──▶│ │───▶│ 上游 LLM │
│ │ │ admit(1次 EVALSHA,完整脚本见 §6): │ └─────────┘
│ │ │ idx = floor(TIME / 10) ← 过滤藏在下标里 │
│ │ │ count = GET rpm:done:{key}:{idx-6..idx} 之 SUM = 99 │
│ │ │ if count >= rpm_limit ────────▶ 429 ──▶ Client │
│ │ │ else INCRBY rpm:done:{key}:{idx} 1 + EXPIRE 120s │
│ │◀──│ 通过 ▶ 透传(没有 settle:请求失败默认不退名额) │
└──────────┘ └──────────────────────────────────────────────────────────┘
时间轴(单个 RPM 请求):
t0 ─────────────▶ t1
│ admit │ INCRBY bkt[0]
│ count>=limit? │ (记账即完成,无在途状态)
▲ ▲
│消耗量已知=1, │此后它永远在 done 窗口里
│当场判断当场计数 │
1.2 TPM:分桶窗口 + 在途预留 + 幂等结算(输出 token 未知,必须预扣→校正)
┌──────────────────────────────────────────────────────┐
│ REDIS (shared counters) │
│ │
│ ① done buckets: tpm:done:{key}:{idx} (string) │
│ ≥60s 分桶近似滑动窗口 = 7 buckets x 10s each │
│ │
│ ┌───────┬───────┬───────┬───────┬───────┬───────┬───────┐
│ │bkt[-6]│bkt[-5]│bkt[-4]│bkt[-3]│bkt[-2]│bkt[-1]│bkt[0] │
│ │ 0 │ 170K │ 0 │ 420K │ 380K │ 350K │ 15K │
│ │60-70s │50-60s │40-50s │30-40s │20-30s │10-20s │now-10s│
│ │ ago │ ago │ ago │ ago │ ago │ ago │ ago │
│ └───────┴───────┴───────┴───────┴───────┴───────┴───────┘
│ (sample: token count per bucket, SUM = 1.335M) │
│ │
│ ② inflight_sum: tpm:inflight_sum:{key} (string) │
│ 在途预扣总量计数器:admit INCRBY / settle DECRBY │
│ 热路径只 GET 这一个值,O(1),与在途请求数无关 │
│ inflight:{key} HASH 降级为明细账(对账/退款/审计) │
│ 整键 TTL 仅 GC;reservation 超时靠 │
│ ts + recycle 判定(§3.1) │
│ │
│ ③ settle idempotency: 请求级幂等键 │
│ SET settled:{key}:{predeductId} 1 NX EX 1800 │
│ (SET NX 返回 nil = 已结算过 → 拦截) │
│ │
│ bucketIdx = floor(now / 10s) │
│ all timestamps from redis.call('TIME') │
└────────▲─────────────────────────▲───────────────────┘
│ admit │ settle
│ (EVALSHA) │ (EVALSHA, 异步)
┌──────────┐ ┌─────────────────────────────┴─────────────────┴──────────────────┐
│ │ │ 网关实例 ×N(无状态,可水平扩展) │ ┌─────────┐
│ Client │──▶│ │───▶│ 上游 LLM │
│ │ │ ⓪预估:output 由 admit Lua 内读 Redis EMA 算;input 本地 │ └────┬────┘
│ │ │ input : tiktoken 编码;图片内嵌解文件头/URL 不下载 │ │ SSE 流
│ │ │ output: min(max_tokens, EMA×2),room 只判放行│ │ include_
│ │ │ (EMA 在 Redis,多 Pod 一致、重启不丢) │ │ include_
│ │ │ │③的真实usage ─▶ 更新EMA ─▶ 反哺下下个请求的预扣 │ │ usage)
│ │ │ │ (闭环:预估越用越准,见右下盒子) │ │
│ │ │ ①admit(1次 EVALSHA,完整脚本见 §4) │ │
│ │ │ idx = floor(TIME / 10) ← 过滤藏在下标里 │ │
│ │ │ spent = GET tpm:done:{key}:{idx-6..idx} 之 SUM │ │
│ │ │ inflight = GET tpm:inflight_sum:{key}(O(1) 聚合计) │ │
│ │ │ if spent+inflight+est > limit ▶ 429 ──▶ Client │ │
│ │ │ 通过 ▶ INCRBY inflight_sum +est + HSET 明细账 │ │
│ │ │ │◀──────┘
│ │◀──│ ②透传响应(不等 settle,不拖 TTFT) │
│ │ │ │ ┌──────────────┐
│ │ │ ③settle(异步队列;流结束才触发,不在响应路径上等) │───▶│ Redis 内 EMA │
│ │ │ 本质「一换一」:真实账换预估坑 │ │ EMA 输出均值 │
│ │ │ 例:admit 预扣 4400 → 流结束真实 780: │ │ (定点整数×1000,│
│ │ │ a. INCRBY done桶 +780 ← 真实消耗记账 │ │ 队列无排序) │
│ │ │ (未来60s内的限流判断都能看到这780) │ │ ▲ │ │
│ │ │ b. HDEL 明细 claim 成功 → DECRBY inflight_sum 4400 │ │ │ ▼ │
│ │ │ (不还则一笔消耗记两遍→key被冤枉429) │ │ ③真实usage喂入│
│ │ │ 视图:done +780,inflight -4400(多估的3620兑平) │ │ EMA更新 ────┤
│ │ │ 正常 : settle(id, actual=780) │ │ 反哺⓪预扣系数 │
│ │ │ 调用前失败: settle(id, 0) → 记0+还坑 = 全额退款 │ │ (预估越用越准)│
│ │ │ 流中断 : usage未知 → 按已观测部分保守结算 │ └──────────────┘
│ │ │ (Provider已消耗,不退全额) │
│ │ │ 幂等 : SET NX 幂等键失败 → 拦截(防异步重复投递) │
│ │ │ 挂死 : 崩溃没settle → ts超时由recycler退款 │
└──────────┘ └────────────────────────────────────────────────────────────┘
时间轴(单个 TPM 请求的生命周期):
t0 ────────▶ t1 ────────▶ t2 ═══════▶ t3 ──────────▶ t4
│ │ │ SSE流式 │ │
│ admit │ 上游开始 │ (几十秒) │ 流结束 │ settle(异步)
│ 预扣est │ 转发 │ │ 回完客户端 │ 真实usage落桶
│ 进inflight │ │ │ (不等settle) │ 释放inflight
▲ ▲ ▲
│此刻输出未知 │期间窗口里它是"inflight" │此后窗口里它是"done"
│只能预估占坑 │(盲区被预留层覆盖) │(预扣→真实值替换)
1.3 两者对比(为什么 RPM 可以这么简、TPM 必须那么繁)
共同点:同一套分桶滑动窗口(7 桶 × 10s,下标寻址隐式过滤 + TTL 自动回收),RPM 桶里计请求数、TPM 桶里计真实 token。差异全部来自"admit 时消耗量是否已知":
RPM TPM
┌──────────────────────────┐ ┌──────────────────────────────┐
│ admit 时消耗量已知(=1) │ │ admit 时输出 token 未知 │
│ ▼ │ │ ▼ │
│ 当场计数,一步完成 │ │ 只能预估 → 预扣 → 占坑 │
│ │ │ │ ▼ │
│ 无预估/无预留/无settle │ │ 流式期间存在"在途盲区" │
│ │ │ │ (历史统计对飞行中请求一无所知) │
│ 失败默认不退名额 │ │ ▼ │
│ (丢了也只多算 1 个请求) │ │ 必须有 inflight 预留层覆盖盲区 │
│ │ │ ▼ │
│ │ │ 流结束 settle:真实值替换预扣 │
│ │ │ + 幂等锁防双记 + 失败退款 + │
│ │ │ ts 超时由 recycle 退款 │
└──────────────────────────┘ └──────────────────────────────┘
Redis 数据结构:7 桶 string Redis 数据结构:7 桶 + inflight_sum 计数器
+ inflight 明细 HASH + 请求级 settled 幂等键
1.4 回看:图里的每个机制如何兑平 §0 的问题(呼应)
§0 提出的问题 图中的解法(看哪里)
───────────────────────────── ─────────────────────────────────────────
输出 token 在决策时未知 ──▶ ⓪预估:admit Lua 内按 Redis EMA×2 占位
(限流决策瞬间 vs 流结束才知) (TPM 图·网关实例层 ⓪)
在途请求是计数盲区 ──▶ tpm:inflight HASH 预留层:
(并发突发下超卖) admit 预扣占坑 → settle 真实值替换
(TPM 图·Redis ② + 时间轴 t2══▶t3 段:
盲区被 inflight 覆盖)
预扣值≠真实值 ──▶ settle 幂等结算:真实 usage 落桶
+ HDEL 释放预留;失败传 0 即退款
(TPM 图·网关实例层 ③)
多实例共享计数 ──▶ 计数全在 Redis,admit/settle 均
EVALSHA 原子,网关实例无状态可水平扩
(两图·Redis 层 + 网关×N)
预估不准长期累积 ──▶ settle 喂回 Redis 内 (key,model) 的 EMA,
预估值随真实数据收敛(反馈闭环)
(TPM 图·右上角回喂箭头)
挂死请求(崩溃没 settle) ──▶ 明细 ts 超过 reservation timeout
(provider_timeout×1.5+margin),
低频 recycle 任务显式退款
(TPM 图·Redis ② 注释)
重复结算(异步消息重投) ──▶ 请求级幂等键 settled:{key}:{id}:
SET NX EX 1800,SET 失败即拦截
(TPM 图·Redis ③)
热路径性能(1次 RT 都嫌多) ──▶ RPM:8 ops 一步完成;TPM:O(1) 分桶
求和 + O(1) inflight_sum + settle 异步
不拖 TTFT
(两图·admit 步骤;QPS 估算见 §2.8)
一句话:TPM 图比 RPM 图多出来的每一个部件(预估、inflight、settled、settle 队列、EMA 统计),都对应 §0.3 里"输出 token 未知"这个矛盾引出的一个具体子问题;RPM 没有这些部件,因为它消耗量已知、无在途状态。
2. 为什么这么设计(决策链)
每一条设计决策都对应一个被生产环境验证过的坑:
2.1 为什么用 Redis 而不是本地内存
- 网关多副本部署,本地计数各算各的,总量 = 副本数 × 配额,必然集体超卖
- Redis 是最便宜的跨副本共享点;代价是每次 admit 1 次 RT,用「软约束容忍百毫秒级视图滞后 + 可选本地读缓存」消化(见 §9)
2.2 为什么用分桶而不是 ZSET 滑动窗口
直觉方案是 ZSET(member=请求,score=时间戳),但有两个硬伤:
- ZCOUNT 只数成员个数,不能对 score 求和。要"按时间过滤 + 对 token 求和"只能 ZRANGE 拉回 Lua 循环累加,高 QPS 时每次传输几十 KB,Redis 带宽先死
- ZSET 一个 key 只能有一个 score,时间和金额二选一,语义拧巴
分桶方案:10 秒一桶 × 7 桶,窗口恒 ≥ 60s,每桶一个 string,GET 七次求和,O(1) 级开销。代价是窗口边界不精确(单向多算,最坏多计入 10s 前的量),但 TPM 是软约束,且多算方向偏保守,可接受。
2.3 为什么必须有"在途预留"
这是整个设计里最容易被砍掉、也最不能砍掉的一层。反例:只统计已完成请求的真实 token(纯事后滑动窗口)——
窗口内已消耗:900K / 1M TPM(统计很准)
此刻并发进来 50 个请求,每个真实输出 3K token
→ 纯统计视角:剩 100K,放行 33 个
→ 实际:全放行后窗口冲到 1.05M,超卖 5%
历史/已完成统计对"正在飞行中的请求"一无所知,而 TPM 的典型失败模式恰恰是并发突发——所有在途请求的输出 token 都还没落地。预留层就是把在途请求从盲区里捞出来:admit 时按预估值占坑,settle 时用真实值替换。
2.4 为什么预估用 EMA 而不是 max_tokens
- 用户设
max_tokens=4096的请求,实际输出常常只有 300 token,按上限预扣 = 系统性高估 10 倍以上 → 大量误杀 429 - EMA(指数移动平均):每个 (key, model) 在 Redis 维护一个定点整数(
tpm:ema:{key}:{model},真实均值×1000,规避 Lua 浮点),settle 的 Lua 里一步更新——new = (700×old + 300×真实输出×1000)/1000(sample 同样按定点 ×1000 参与,否则几十个请求后 ema 趋近 0);admit 的 Lua 里读取并用ema × 2(经验 safety factor,均值之上留余量覆盖长尾,系数需线上校准;漏扣部分由 settle 校正兜底)。无队列、无排序、无冷启动切换(初值取全局默认);读写都嵌在已有 EVALSHA 里,零额外 RT,多 Pod 全局一致、重启不丢 - 数据来源免费——settle 记录的真实 usage 本来就在那次 Redis 调用里,顺手喂一行;流量突变时 EMA 几个请求就跟上
- 预估值必须
min(预估值, 配额上限)封顶(LiteLLM PR #29191 教训:固定保底值让小配额 key 第一个请求就 429)
2.5 为什么结算要幂等 + 异步
- settle 由异步任务/消息触发,重复投递是常态:请求级幂等键
SET settled:{key}:{predeductId} 1 NX EX 1800(SET NX 失败即拦截)保证同一 predeductId 只结算一次,杜绝双记;每个键独立过期,不随热 key 续期 - 幂等 TTL 取 30 分钟,是内存与 EMA 污染的折中:每请求一个键,按 ~130B/键算——24h TTL 在 1000 QPS 下要常驻 ~8600 万键 ≈ 11 GB(撑爆内存);1800s 只需 ~180 万键 ≈ 234MB。TTL 过短的代价是迟到重复消息会重复更新 EMA(不重复退款/写桶),但超过 30 分钟还在重投的消息本身说明投递链路异常——更长的去重交给消息队列侧(消费位点 / 业务层 requestId 幂等),不要放在 Redis 每请求一个键上
- 先给客户端回完流,settle 后台跑,不拖 TTFT;最坏情况(崩溃没 settle)由 reservation timeout + recycler 显式退款兜底,窗口虚高 ≤ reservation timeout,方向偏保守,安全
2.6 为什么预算类硬约束不走这套
RPM/TPM 是软约束(超一点不破产,最终一致即可);日额度/月预算是硬约束(计费,不可超卖)。本方案的结构性误差(分桶边界、挂死退款前的窗口虚高)单向偏保守;预估误差双向存在,由 safety factor + settle 校正兑平,对软约束可控;硬约束必须单独走精确的 Redis Lua 原子预扣链路,两者不可混用一套计数器。
2.7 对比全量存储:为什么分桶 + 预扣的效率高得多
另一个候选方案是"全量存储":把窗口内每个请求记成一条记录(ZSET:member=请求,score=时间戳),判断时按时间过滤。两种方案每次 admit 的成本对比(TPM 场景):
| 全量存储(ZSET 按请求记) | 本方案(分桶 + inflight_sum 计数器 + inflight 明细账) | |
|---|---|---|
| 窗口求和 | ZRANGE 拉全窗口成员回 Lua 循环累加(ZCOUNT 只数个数,不能对 score 求和,必须回传) |
7 次 O(1) GET |
| 数据传输量 | 窗口内每个请求都回传:60s × 500 QPS = 3 万成员,按 50B/条 ≈ 1.5 MB / 次 | 7 个桶值 + 在途 HASH(并发在途通常 <几百条)≈ 几 KB / 次 |
| 在途统计 | ZSET 里请求级记录没有"预扣/已结算"状态,需另建结构或扫全量 | GET inflight_sum 一个 O(1) 计数器;HASH 明细仅对账/退款/审计,不进热路径 |
| 清理 | ZREMRANGEBYSCORE 定期删老成员(不删无限膨胀);结算时还要 ZREM 定位成员 |
零清理代码:老桶 TTL 自动过期,inflight 结算 HDEL、挂死由 recycle 显式退款(ts 判定) |
| 单次复杂度 | O(窗口成员数)——随 QPS 线性变差 | O(1):热路径只碰固定 7 桶 + 1 个 inflight_sum 计数器(明细账仅供对账/退款路径) |
| 内存 | 窗口内每请求一条记录,QPS 越高占越多 | 固定 7 桶 string + 在途 HASH,与历史 QPS 无关 |
核心差异:全量存储的成本随 QPS 和窗口长度线性增长(O(N)),分桶的成本是常数(O(1))。QPS 越高,全量方案自己先把自己打死——Redis 单线程,每次 admit 传 1.5 MB 时带宽和 Lua 执行时间先到项,而限流器本身又恰恰是 QPS 最高时最不能慢的组件。
2.8 单请求完整成本与容量上限(量级估算)
先看每个请求在 Redis 上到底发生什么。热路径的命令数与执行成本是常数,与在途请求数无关——在途统计读 O(1) 的 inflight_sum 计数器,不扫明细 HASH:
| 阶段 | 命令清单 | 命令数 | 路径 |
|---|---|---|---|
| TPM admit | TIME + HEXISTS(幂等前置) + 7×GET(done桶) + GET(inflight_sum) + GET(ema) + HSETNX(明细) + INCRBY(sum) + 2×PEXPIRE | ≈ 15 | 同步,拖 TTFT |
| TPM settle | TIME + HGET + SET NX(幂等) + HDEL + DECRBY + GET(sum) + INCRBY(落桶) + EXPIRE + GET/SET/EXPIRE(ema) | ≈ 11~12 | 异步,不占延迟 |
| RPM admit | TIME + 7×GET + INCRBY(计数) + EXPIRE | ≈ 10 | 同步 |
| RPM settle | 无(消耗量已知,当场记完) | 0 | — |
注:挂死项回收不在 admit 热路径上(旧设计每次 HGETALL 扫整个 HASH,几千在途请求时 Lua 执行时间 O(inflight) 线性膨胀、阻塞 Redis 单线程);改为低频后台任务(reclaim Lua):对超时条目 HDEL 成功(claim)才 DECRBY inflight_sum 兑平,不计入常态成本;Lua 内的循环累加是纯脚本计算,不算命令。
容量推导(单 Redis,粗略量级,非基准测试):
前提:Redis 单线程串行执行命令,简单命令吞吐 ≈ 10万 ops/s 量级
(瓶颈在命令串行化,不在带宽:单 admit 传输几 KB,7k QPS ≈ 20~40 MB/s,
远低于千兆网卡)
仅 TPM : (15 admit + 12 settle) ≈ 27 ops/请求 → 10万 ÷ 27 ≈ 3~4k QPS
仅 RPM : 10 ops/请求 → ≈ 1万 QPS
TPM+RPM 同开 : (27 + 10) ≈ 37 ops/请求 → ≈ 2~3k QPS
(生产常态:两个限流器共用一个 Redis)
延迟侧:单 EVALSHA 执行 ~0.1-0.3ms,中低千级 QPS 内排队可忽略,
逼近上限时命令排队才开始推高 P99
结论:2~3k 只是「命令数 ÷ 假设吞吐」推出的量级参考,不是容量结论,不要当数字承诺。真实上限取决于 Lua 执行时间(脚本内每条命令串行放大)、热 key 单线程串行化、网络 RTT 与脚本排队——唯一可信来源是 Lua 压测:观察 SLOWLOG、Lua latency、Redis CPU、admit P99、热 key 分布。买来的是多副本精确共享。往上扩的路径(对应 §9):
| 手段 | 预期 | 成本 |
|---|---|---|
| 读侧本地缓存(spent 每 100ms 刷本地) | admit 零 RT,Redis 只接异步预扣/结算,可上万~十万级 | 百毫秒视图滞后(软约束可接受) |
| 按 key 分片到多 Redis | 线性扩展(每 key 全套结构带 hash tag 同 slot,分片天然友好,见 §3) | 运维复杂度 |
| 本地内存令牌桶(软约束零 Redis) | 单实例 10 万+ QPS | 多副本精度下降(各算各的) |
注:万级 QPS 的生产网关(见调研文档第 2 节)正是选了"本地内存令牌桶做 TPM/RPM,Redis 只留硬约束"——Redis 方案的定位是中低万级 QPS 以下的多副本精确共享,不是极限吞吐。
3. 数据结构
按 key(或 key+model)粒度:
tpm:done:{key}:{bucketIdx} # 已完成 token 桶,string,INCRBY
# bucketIdx = floor(now / 10s),10 秒一桶,TTL = 120s 自动过期
tpm:inflight_sum:{key} # 在途预扣总量,string;admit INCRBY / settle DECRBY
# 热路径唯一读写的在途结构,O(1),与在途请求数无关
# 带 TTL、admit 时与明细账同参数续期(防计数器丢失后永久虚高);
# drift 检测:recycle 每轮用明细账求和与之比对,偏差超阈值告警;
# 修复由带 lock/version 的独立 repair 流程执行(检测不修复)
tpm:inflight:{key} # 在途预留明细账,HASH(不在热路径上)
# field = predeductId
# value = "{预留token数}|{创建时间戳ms}"
# 仅用于 settle 对账、挂死退款扫描、审计
# 整键 TTL 只是 GC(兜底孤儿 key,每次 admit HSET 后 PEXPIRE 续期)
# ——单个 reservation 的超时不靠它,靠 value 里的 ts + recycle 判定;
# 整键 TTL 必须 ≥ reservation timeout + recycle 周期,否则整键
# 过期会带着未退款条目消失 → inflight_sum 泄漏(虚高,偏保守方向,
# 由 drift 检测 + 带 lock/version 的独立 repair 修复)
tpm:settled:{key}:{predeductId} # 请求级幂等键,string,SET ... NX EX 1800
# 防同一 predeductId 重复结算;随请求独立过期,不随热 key 续期
tpm:ema:{key}:{model} # 输出 token 的 EMA,string,定点整数(真实均值×1000)
# settle Lua 内更新(异步),admit Lua 内读取算 est,零额外 RT
# 多 Pod 全局一致、重启不丢;TTL 7 天,冷 key 自动回收
rpm:done:{key}:{bucketIdx} # RPM 同构,桶里 INCRBY 1
命名注意(Redis Cluster):
- 同一个用户 key 的所有结构(done 桶 ×7、inflight_sum、inflight、settled、ema,以及 §6.2 合并脚本后的 RPM 桶)必须落在同一 slot,Lua 多 key 原子性才成立——生产部署把用户 key 包进 hash tag,如
tpm:{keyA}:done:{idx}、tpm:{keyA}:ema:{model}、rpm:{keyA}:done:{idx}(本文为可读性省略 tag 写成tpm:done:{key}:{idx})。EMA 按 model 分 key,但 hash tag 仍取用户 key,保证与 admit/settle 同 slot - 桶 key 建议显式放进 KEYS:本文为简洁在 Lua 里拼接桶 key 名;同 hash tag 下能跑,但 Cluster 规范做法是把 7 个桶 key 逐个声明在 KEYS 里(客户端能校验、复制/迁移语义更清晰),生产实现应显式传入
- Redis 版本要求 ≥ 5:脚本先
TIME再写入,Redis 5 起 effect replication 才能正确复制这类"非确定命令后写"的脚本(5 之前用 Verbatim 复制会出错)
时间统一取 redis.call('TIME')(Redis 侧时钟),避免多实例机器时钟漂移。
3.1 三个脚本 = 状态机的三次迁移(职责总览)
reservation 的生命周期是一个三状态状态机,每个脚本负责一次迁移,单一职责、可独立压测和打点:
admit.lua : NEW → RESERVED(占位)
├─ HEXISTS reservation 幂等前置:同一 predeductId 已在 → 直接放行
│ (必须在 quota 判断前,否则满窗口时重试被错 429)
├─ check spent 7×GET done 桶求和
├─ check inflight_sum GET 聚合计数器(O(1),不扫明细)
├─ HSETNX reservation 同一 predeductId 重试只写一次(admit 幂等)
└─ INCRBY inflight_sum 仅 HSETNX 成功才加;两个 key 同步 PEXPIRE 续期
settle.lua : RESERVED → SETTLED(兑平)
├─ HGET reservation 先读回 + 校验(任何写操作之前完成可能失败的解析)
├─ SET NX settled 幂等闸门(重复投递在此拦截)
├─ HDEL reservation 返回 1 = claim 成功
├─ DECRBY inflight_sum 仅 claim 成功才退(与 recycle 互斥);归零则 DEL 防冷 key 残留
├─ INCRBY done actual 仅正常结算(claim 成功)才写;迟到 settle 不写
│ ——TPM 窗口只反映最近 ~60s 的准入视图
└─ update EMA 只喂真实 output token(emaSample 与 actualTokens 分离)
recycle.lua : RESERVED → EXPIRED(挂死退款)
├─ HGET reservation
├─ check timeout ts 超过 reservation timeout
├─ HDEL reservation 返回 1 = claim 成功
└─ DECRBY inflight_sum 仅 claim 成功才退(与 settle 互斥);归零则 DEL
四个必须记住的细节:
- DECRBY 以 HDEL==1 为条件,不是以 HGET 存在为条件:settle(异步)与 recycle(低频)会竞争同一 reservation,只有 claim 成功的一方退款,退款恰好发生一次
- recycle 不写 settled 幂等键:迟到的 settle SET NX 通过 → 明细已 nil → 不退款、不写 done 桶;EMA 照常可更新,真实 usage 由计费/审计链路照记。曾考虑过「按迟到距离分档写桶 + recycle 留退款时刻标记」的方案,评估后放弃——为一个低频边界增加每请求维度的标记 key 和分档逻辑,复杂度收益不划算;边界损失靠 reservation timeout 留足余量压制
- recycle 的 HSCAN 游标放应用层分批:每批只对过期条目进小 Lua,不要在单个 Lua 里扫整个 HASH(大 key 会阻塞 Redis,重蹈 HGETALL 覆辙)
- claim 三行在 settle/recycle 各出现一次:Redis Lua 不能互相调用,两处必须同步维护
4. TPM:admit + reserve(Lua,每请求一次)
-- KEYS[1]=tpm:done:{key} 前缀
-- KEYS[2]=tpm:inflight_sum:{key} KEYS[3]=tpm:inflight:{key}
-- KEYS[4]=tpm:ema:{key}:{model}(定点整数,真实均值×1000)
-- ARGV[1]=effective_max_output ARGV[2]=limit ARGV[3]=predeductId
-- ARGV[4]=明细账整键 GC TTL(ms),≥ reservation timeout + recycle 周期
-- ARGV[5]=inputEst(input+图片的本地估算) ARGV[6]=ema 默认值(×1000)
local t = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)
local curIdx = math.floor(now / 10000)
-- 0) 幂等前置:同一 predeductId 已预留过 → 直接返回"已准入"。
-- 必须放在 quota 判断之前——否则同一请求的超时重试撞上满窗口
-- 会被错误 429(预留明明还在,客户端却被告知没名额)
if redis.call('HEXISTS', KEYS[3], ARGV[3]) == 1 then
return {1, -1} -- 已准入(幂等重试);remaining 语义不适用,取负数作哨兵
end
-- 1) 已完成:7 个桶求和(idx-6..idx,恒覆盖 ≥60s;近似窗口,
-- 单向多算方向——最老桶最多带入 60~70s 前的量,偏保守)
local spent = 0
for i = 0, 6 do
spent = spent + tonumber(redis.call('GET', KEYS[1] .. ':' .. (curIdx - i)) or '0')
end
-- 2) 在途:O(1) 读聚合计数器(热路径不扫 HASH 明细)
-- 下限保护:计数器意外为负(丢 key 后 settle DECRBY 新建负值、
-- 或兑平 bug)时按 0 处理,防“负在途 → 超放”;真实值留给
-- drift 检测 + 独立 repair 流程 + -3 打点告警
local inflight = tonumber(redis.call('GET', KEYS[2]) or '0')
if inflight < 0 then inflight = 0 end
-- 3) output 预估:EMA×2,再 min(effective_max_output) 封顶
-- (EMA 就在 Redis,嵌在本次 EVALSHA 里读,零额外 RT)
-- 预估只回答"这个请求预计消耗多少",绝不拿剩余额度反向裁剪——
-- 剩余额度(spent+inflight 与 limit 的差)在步骤 4 参与"能不能
-- 放行"。若把 estOut 压到剩余额度内,窗口快满时 estOut→0,
-- 准入形同只查 input,预扣层失效
local ema = tonumber(redis.call('GET', KEYS[4]) or ARGV[6])
local limit = tonumber(ARGV[2])
local estOut = math.floor(ema * 2 / 1000)
if estOut > tonumber(ARGV[1]) then estOut = tonumber(ARGV[1]) end
-- 按"配额上限"封顶(不是剩余额度!):小配额 key 若 inputEst+EMA×2
-- 恒 > limit,窗口再空也 429,请求永远进不来 → EMA 永远学不到真实
-- 值,冷启动死锁。cap 到 limit-inputEst 只影响预估刻度,不破坏
-- "剩余额度只判放行、不裁剪预估"的原则
local cap = limit - tonumber(ARGV[5])
if cap < 0 then cap = 0 end
if estOut > cap then estOut = cap end
local est = tonumber(ARGV[5]) + estOut
-- 4) 准入:软约束,控制在配额内
-- 返回 {判定, remaining}:remaining = 放行后的剩余额度,
-- 网关直接用作 x-ratelimit-remaining-tokens,零额外成本
if spent + inflight + est > limit then
return {0, limit - spent - inflight} -- 429 + 当前剩余
end
-- 5) 预扣:计数器 +1(O(1),与在途数无关),明细账留一笔供对账/退款
-- PEXPIRE 续的是整键 GC TTL;reservation 的超时由 value 里的 ts
-- 交给 recycle 判定,两个概念不要混
-- admit 幂等:HSETNX 只有第一次写入成功才 INCRBY——重试同一
-- predeductId 不会重复加预扣(settle 只退一次,双加会永久虚高)
if redis.call('HSETNX', KEYS[3], ARGV[3], est .. '|' .. now) == 1 then
redis.call('INCRBY', KEYS[2], est)
redis.call('PEXPIRE', KEYS[3], ARGV[4])
redis.call('PEXPIRE', KEYS[2], ARGV[4])
end
return {1, limit - spent - inflight - est}
预估的组成——注意全部是 estimate,最终以 Provider usage settle 兑平:
output 部分:admit Lua 内计算(读 Redis EMA,零额外 RT)
est_output = min(effective_max_output, EMA×2, max(limit−inputEst, 0))
——按配额上限(limit)封顶是防小配额 key 冷启动死锁:
inputEst+EMA×2 恒 > limit 的 key,窗口再空也 429,EMA 永远
学不到真实值——这是刻度修正,不是剩余额度裁剪
——**剩余额度参与"能不能放行",不参与"预计消耗多少"**:
admit 判据是 spent + inflight + inputEst + est_output ≤ limit。
若拿剩余额度反向裁剪 estOut,窗口快满时 estOut→0,准入形同
只查 input——预扣层失效,末端超卖上界从"EMA 误差"(受 ×2
safety factor 压制)退化成"每请求 × effective_max_output"。
代价是窗口快满时会有更多 429(预估照实算,放不下就拒)——
这正是准入控制应有的语义;硬约束另走精确链路(§2.6)
effective_max_output = 用户显式传的 max_tokens;未传时 fallback 到
模型/Provider 的输出上限或网关配置的默认档——绝不能取"无限",
否则 EMA×2 会直接失去 max_tokens 这道封顶
input/图片部分:网关本地估算,经 ARGV[5] 传入
input 实测(tiktoken 本地编码)
图片:已知尺寸的内嵌图按 Provider 公式估算;
URL 图片不下载、尺寸未知 → 用保守默认档占位。
图片 token 与模型/Provider 的视觉计费规则相关,
无法精确预知,靠 settle 校正兑平
挂死回收(低频后台任务,不在热路径上):
inflight_sum 是聚合计数器,没有"整键 TTL 过期 = 自动退款"的隐含语义,
挂死退款必须由回收任务显式兑平:
周期性(如每 30s)执行 recycle.lua(claim 语义同 settle,见 §3.1):
HSCAN 游标在应用层分批 → 每批对 ts 超过 TTL 的条目进小 Lua:
HDEL 返回 1(claim 成功)才 DECRBY inflight_sum——
recycler 与 settle 并发处理同一 reservation 时,退款恰好一次
recycle 不写 settled 幂等键:迟到的 settle 明细已 nil、不退款、
也不写 done 桶(迟到 usage → 计费/审计 + EMA,不重新影响 TPM
窗口;不双重退、不污染当前窗口)
invariant 校验(detection only,本轮不修复):
a) inflight_sum < 0 → 兑平 bug(双重退款),打点告警
b) 明细账求和 ≠ inflight_sum → 计数器漂移(丢 key/过期/异常),
偏差超阈值打点告警
repair 是独立流程:HSCAN 只是检测,不是修复快照——发现 drift 后
先告警,修复由带 lock/version 的独立 repair 任务执行
(多副本下防止两个 repair 并发重建打架;repair 期间 inflight_sum
照常被 admit/settle 读写,不能拿"扫那一刻的明细和"直接覆盖,
必须带版本比对、只修确认的漂移)
(settle 归零只 DEL 不抹负值,负值留给告警路径)
reservation timeout(recycle 的 ts 判定阈值,与明细账整键 GC TTL
是两个概念)必须 > 真实请求生命周期(provider_timeout + settle
异步延迟 + 安全余量):120s 只覆盖"流式 30s 级"场景;SSE 3 分钟
长流应取 240~300s,否则预扣提前释放 → 超卖;更长任务改
lease/heartbeat 续期
5. TPM:settle(Lua,请求结束/失败时,异步)
-- KEYS[1]=tpm:done:{key} 前缀 KEYS[2]=tpm:inflight_sum:{key}
-- KEYS[3]=tpm:inflight:{key} KEYS[4]=tpm:settled:{key}:{predeductId}
-- KEYS[5]=tpm:ema:{key}:{model}
-- ARGV[1]=predeductId ARGV[2]=actualTokens
-- ARGV[3]=幂等键TTL(s) ARGV[4]=ema默认值(×1000) ARGV[5]=EMA TTL(s),如 7 天
-- ARGV[6]=emaSample:真实 output token(usage 正常返回才传,退款/保守结算不传→不更新 EMA)
-- 1) 先读回并校验预扣值,任何写操作之前完成可能失败的解析
-- 预扣值以明细账为 source of truth(HGET 读回),不由调用方传参;
-- 明细缺失(被 recycle 提前退掉)= 迟到 settle:不退款(HDEL
-- claim 不到),也不写 done 桶——真实 usage 只进计费/审计链路
-- 与 EMA,不再重新影响 TPM 窗口
local v = redis.call('HGET', KEYS[3], ARGV[1])
if v and string.match(v, '^(%d+)|') == nil then
return -2 -- 明细格式异常:不写任何状态,交给告警/对账处理
end
local normalSettle = false
-- 2) 幂等闸门:SET NX 失败 = 已结算过(防异步重复投递)。
-- 请求级独立 EXPIRE,不随热 key 续期、无老 requestId 积累问题
if redis.call('SET', KEYS[4], 1, 'NX', 'EX', ARGV[3]) == false then
return -1
end
local t = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)
local bucketIdx = math.floor(now / 10000)
-- 3) 释放预留:claim 语义(与 recycle 互斥,防双重退款,见 §3.1)
-- 只有 HDEL 成功(claim 到)的一方才 DECRBY;归零顺手 DEL,
-- 不让冷 key 残留 "0"
local neg = 0
if v then
local reserved = tonumber(string.match(v, '^(%d+)|'))
if redis.call('HDEL', KEYS[3], ARGV[1]) == 1 then
normalSettle = true -- claim 到 = 正常结算路径
-- 计数器不存在(过期/丢失)就直接 DEL 后跳过退款:
-- 直接 DECRBY 会新建一个没有 TTL 的负数 key,admit 读到负值
-- 超放,直到 repair——负值路径在这里堵死
if redis.call('EXISTS', KEYS[2]) == 1 then
redis.call('DECRBY', KEYS[2], reserved)
local cur = tonumber(redis.call('GET', KEYS[2]) or '0')
if cur == 0 then
redis.call('DEL', KEYS[2]) -- 归零删除,防冷 key 残留 "0"
elseif cur < 0 then
neg = -3 -- 变负:不 DEL、不抹掉证据,返回 -3 由网关打点告警(repair 兜底)
end
end
end
end
-- 4) 真实 token 落"结束时刻"桶(工程近似:长流的量其实跨时间产生)。
-- 只有正常结算(reservation 还在)才写 TPM 窗口;迟到 settle 一律
-- 不写——TPM 窗口是"最近 60s 的准入控制视图",计费/审计账本才是
-- "最终真实消耗"的记录,两者不能因为"真实 token 都要入账"而混写。
-- (边界代价:略超 reservation timeout 的流,其消耗不进当前窗口
-- ——按丢窗口、不丢账处理;边界占比靠 reservation timeout 留足
-- 余量压制,不为低频边界再加标记/分档的复杂度)
if normalSettle then
redis.call('INCRBY', KEYS[1] .. ':' .. bucketIdx, ARGV[2])
redis.call('EXPIRE', KEYS[1] .. ':' .. bucketIdx, 120)
end
-- 5) EMA 更新(定点整数×1000):只喂真实 output token。
-- 退款(actual=0)、流中断保守结算等场景不传 emaSample,
-- 避免把 0 / 部分估计污染进均值
if ARGV[6] and ARGV[6] ~= '' then
local ema = tonumber(redis.call('GET', KEYS[5]) or ARGV[4])
ema = math.floor((700 * ema + 300 * tonumber(ARGV[6]) * 1000) / 1000)
redis.call('SET', KEYS[5], ema)
redis.call('EXPIRE', KEYS[5], ARGV[5])
end
if neg ~= 0 then return neg end
return 1
关键细节:
- 结算落当前桶(仅正常结算):长流式请求的 token 全量记在结束时刻——这是工程近似而非严格正确:真实消耗跨时间产生,长 SSE 的量本应摊到流经的各桶。选结束时刻是因为实现简单且偏差可控(一次性占满当前窗口,偏保守);若流普遍超长、该近似伤及精度,可按分片到达时间分桶记账——复杂度换精度,一般不必要
- 迟到 settle 一律不写 done 桶:reservation 已被 recycle 退掉的迟到 settle 不退款、不写桶(迟到 usage → 计费/审计 + EMA,不重新影响 TPM 窗口)。边界代价:略超 reservation timeout 的流(240s 被退、260s settle 到)其消耗不进当前窗口——按「丢窗口、不丢账」处理,占比靠 reservation timeout 留足余量压制;不为这个低频边界加标记/分档的复杂度。概念上彻底分开:TPM 窗口 = 最近一分钟的准入控制视图,计费账本 = 最终真实消耗,两者不可混写
- 失败结算必须分三类,不能一律传 0:
① Provider 调用前失败(上游连不上、请求没发出去)→actual=0,全额退款;
② 正常返回,拿到 usage → 按 actual 结算;
③ 已调用 Provider 但 usage 未知(客户端中途断开、流半程失败)→ 不能退全额——Provider 端可能已生成一半 token,真实消耗已发生,按 §7.1 保守结算公式:input 实测 + 已收 output × 1.1;一字未出的按 input 实测折算(拿不到实测就用预扣里的 inputEst),宁可多扣不超卖 - 幂等键请求级独立过期:
SET settled:{key}:{id} 1 NX EX 1800,每个请求一个 key、独立 TTL,不存在"整 SET 随热 key 不断续期 → 老 requestId 永不过期"的内存积累问题。30 分钟盖住常规投递重试窗口;TTL 也不能无限拉长——每键 ~130B,24h TTL 在 1000 QPS 下 ≈ 11 GB 内存。超长延迟的重复消息靠 MQ 消费位点 / 业务幂等兜,不靠 Redis - claim 语义防双重退款:settle(异步)与挂死回收任务(低频)可能并发处理同一 reservation——脚本内先
HDEL,返回 1 才DECRBY,谁 claim 到谁退款,同一预留的释放恰好发生一次;预扣值由HGET明细账读回(source of truth),不信任调用方传参 - EMA 更新嵌在同一次 Lua 里:第 5 步只是顺手多两个 O(1) 命令,settle 是异步路径、不在响应链上,对延迟零影响;TTL 取 7 天,冷 key 自动回收
- EMA 只喂真实 output token(
emaSample与actualTokens分离):退款的actual=0、流中断的保守估计值都不能进 EMA,否则均值被 0/部分值污染,预扣刻度失真——只有 Provider 正常返回 usage 时才更新 - EMA 定点缩放必须有单测:sample 传的是原始值,脚本内 ×1000 再参与加权——单测连续喂固定 sample=780,ema 应收敛到约 780000(定点);曾踩过的坑:加权时漏乘 1000,几十个请求后预扣趋近 0,在途盲区悄悄回来
6. RPM:TPM 的退化版
请求个数在 admit 时已知(恒为 1),无需预估、无需预留:
-- KEYS[1] = rpm:done:{key} 前缀(不含桶下标)
-- ARGV[1] = rpm_limit, ARGV[2] = bucket TTL(s)
local t = redis.call('TIME')
local now = t[1] -- 秒级时间戳,取自 Redis 时钟
local idx = math.floor(now / 10) -- 当前桶下标,10s 一桶
-- 1) 求和:只寻址最近 7 个桶(时间过滤就藏在这里)
local count = 0
for i = 0, 6 do
count = count + tonumber(redis.call('GET', KEYS[1] .. ':' .. (idx - i)) or '0')
end
-- 2) 判断
if count >= tonumber(ARGV[1]) then
return 0 -- 429
end
-- 3) 计数:当前桶 +1,并续 TTL
redis.call('INCRBY', KEYS[1] .. ':' .. idx, 1)
redis.call('EXPIRE', KEYS[1] .. ':' .. idx, ARGV[2])
return 1
网关侧调用(一次 RT):
EVALSHA <sha> 1 rpm:done:keyA 100 120
# 返回 1 放行,返回 0 则回 429
# 没有 settle 环节;请求失败是否退还 RPM 名额,按业务定,默认不退
6.1 为什么不需要显式过滤时间——过滤藏在下标里
桶的时间就编码在 key 名里。假设 now = 1727686323,则 idx = 172768632,脚本只会碰这 7 个 key:
rpm:done:keyA:172768632 ← bkt[0] 当前 10s
rpm:done:keyA:172768631 ← bkt[-1]
rpm:done:keyA:172768630 ← bkt[-2]
rpm:done:keyA:172768629 ← bkt[-3]
rpm:done:keyA:172768628 ← bkt[-4]
rpm:done:keyA:172768627 ← bkt[-5]
rpm:done:keyA:172768626 ← bkt[-6] 最老桶(覆盖 60~70s)
10 秒后 idx 变成 172768633,求和范围整体右移一格——172768626 从寻址范围里自然滑出,但它在 Redis 里还活着(TTL 120s 未到),只是永远不会再被读。老数据靠两层机制免费出局:
| ZSET 滑动窗口 | 分桶(本方案) | |
|---|---|---|
| 时间过滤 | ZCOUNT key (now-60) +inf,每次按 score 比较,显式过滤 |
key 名含时间,只寻址 7 个 key,隐式过滤 |
| 老数据清理 | 需 ZREMRANGEBYSCORE 定期删(不删则无限膨胀) |
EXPIRE 120s 自动删,零清理代码 |
| 单次成本 | 扫描窗口内全部成员(高 QPS 下几十 KB 传输) | 7 次 O(1) GET |
唯一残留近似:7 桶之和覆盖 60~70s,恒 ≥ 60s——误差只有多算一个方向(最老桶 bkt[idx-6] 最多带入 60~70s 前的量,稳态最多高估 ≈17%,偏保守)。若 6 桶则相反:有效覆盖 50~60s 恒短于 60s,只有漏算方向,稳态最多放行约 1.2 倍配额——这就是取 7 桶的原因。要更高精度:对最老桶按"已走过的比例"加权,或改 1s 桶(floor(now/1)、i = 0, 69),通常不必要。
6.2 RPM 与 TPM 的联动顺序
- 判定顺序:先 RPM、再 TPM(RPM 是整数比较,更便宜)。生产实现建议合并为同一次 EVALSHA:RPM 桶 key 一并传入(同样带该用户的 hash tag,与 tpm 结构同 slot,见 §3),先判 RPM、再判 TPM room,任一被拒整体 return 0、不落任何计数——不存在"TPM 拒了但 RPM 名额已耗"的回滚问题。本文为讲清链路把 §4/§6 分开画,实现合并即可
- TPM admit 成功后请求失败 →
settle(id, 0)全额回滚 TPM 预扣(§5 的失败路径) - RPM 名额默认不退(消耗量已知、当场已记);可选"失败也退 RPM",按业务定。若担心客户端重试风暴,可配置"429 也计入 RPM"
- 429 响应应带标准头(见 §7 限流响应小节),客户端退避而非裸重试
7. 参数与权衡
| 参数 | 建议值 | 说明 |
|---|---|---|
| 桶宽×桶数 | 10s × 7 桶 | 7 桶保证恒覆盖 ≥60s、误差单向偏保守(6 桶会恒 <60s、单向漏算,见 §6.1);要更准可 1s × 70 |
| reservation timeout | max(provider_timeout × 1.5, 240s) | recycle 判定挂死的阈值(明细账 ts 与之比较),必须 > 真实请求生命周期(provider_timeout + settle 异步延迟 + 安全余量),否则 SSE 长流的预扣被提前释放 → 超卖。120s 只够"流式 30s 级"场景;3 分钟长流取 240~300s;更长任务改 lease/heartbeat 续期。注意与明细账整键 TTL(GC 用,须 ≥ 本值 + recycle 周期)区分 |
| inflight_sum 归零 | DEL | 归零时 DEL,防冷 key 残留 "0";负值不 DEL(保留证据)——settle 返回 -3 网关打点,drift 告警 + 带 lock/version 的独立 repair 修复(HSCAN 只检测不修复,repair 须防并发重建打架)。两条防负值路径:settle 对不存在的 key 跳过 DECRBY(防新建负数 key);admit 读到负值按 0 处理(防超放) |
| admit 超限 | 硬拒 429 | 也可做"软超 5% 内放行 + 打点",按业务定 |
| settle 时效 | 异步 | 不拖 TTFT;窗口短暂虚高(偏保守方向) |
| 单 key 内存 | 稳态几 KB(7 桶 + 计数器 + EMA + inflight 明细) | 常态无压力;注意 settled 幂等键是按请求数线性增长的:1800s TTL、1000 QPS ≈ 180 万键 ~234MB,可接受;TTL 若拉到 24h 同 QPS 下 ≈ 11 GB,不可行 |
Provider 口径抽象(ProviderRateLimitPolicy):不同上游(OpenAI / Bedrock / 百炼 / 火山)对 TPM 的计量口径并不相同——input/output 是否分开计、cached input 打几折、image/reasoning token 怎么折算,都可能不同。网关层增加一个 ProviderRateLimitPolicy 接口,职责:
- 定义该 Provider 的 token 口径换算(input/output 权重、cache/image/reasoning token 折算规则)
- 提供 usage → 窗口计数量的映射,settle 落桶前先过这一层
- 预扣端使用同一套口径的估算公式,保证预扣与结算可比
不做这层抽象,"统一 token 计数"在多 Provider 网关上会系统性算错。
7.1 中断结算、tokenizer 偏差与 429 响应
流中断且 usage 未知的保守结算公式(与 §5 失败三分类同一口径):结算值 = input 实测 + 已接收 output token × 1.1(SSE 已收部分基本已生成,10% 覆盖在途分片/压缩差);一字未出时下界取 input 实测(拿不到实测就用预扣里的 inputEst 折算)。该结算值落 done 桶但不喂 EMA(emaSample 不传,见 §5)。注意:只要请求已发到 Provider,就不能按 0 消耗全额退款——即使客户端一字未收,Provider 端也可能已生成并被计费;settle(id, 0) 只适用于「请求未发出 / Provider 调用前失败」这类确定无消耗的路径。
inputEst 的 tokenizer 系统偏差:tiktoken 只对 OpenAI 系准确;非 OpenAI 模型应换用各自 tokenizer(或 chars/4 近似)——预扣口径与结算口径必须同源,系统性偏差由 settle 校正兑平,只是短暂偏向一侧。
429 响应头(客户端退避的依据,防重试风暴):
x-ratelimit-remaining-requests/x-ratelimit-remaining-tokens:admit 返回{判定, remaining}的第二项直接带回(spent/inflight 脚本里已算过,零额外成本;注意脚本的 0/1 判定不能直接当 remaining 用——文档与代码要一致,返回值就定义为二元组)Retry-After:按"需要滑出多少量才够放行"推算,不是固定 10s——固定取当前桶剩余时间,被拒时往往滑出一个桶还不够,客户端掐点重试再撞 429,反而催生重试风暴。推算:缺口 = est − remaining,从最老桶(最早过期)开始逐桶累计"到期即滑出"的量,累计 ≥ 缺口时取该桶的剩余存活时间(上界即桶 TTL 120s);网关拿到 admit 返回的 remaining 即可算,不需要再回 Redisx-ratelimit-reset-tokens:与 Retry-After 同源——滑出量足够那一刻的时间戳
Redis 故障降级:软约束(TPM/RPM)→ 短时 fail-open + 本地内存令牌桶粗兜底(限流短时失灵优于全部不可用);硬约束(预算/计费)→ fail-close(503),宁可拒绝不可超卖——两套策略按约束类型选择,不可混用。
8. 已知近似与局限(诚实清单)
结构性偏保守的:
- 分桶求和 ≠ 精确 60s 窗口(分桶近似滑动窗口):7 桶覆盖 60~70s,恒 ≥ 60s,只有多算一个方向(稳态最多高估 ≈17%)
- 挂死请求靠明细 ts + recycler 显式退款,退款前(reservation timeout 内)窗口虚高
- 幂等键/明细账的 TTL 都取偏大值(幂等键 30 分钟、明细账 2h,防重复投递与漏退款)
双向存在的:
- 预扣值 ≠ 真实值:EMA 预估可能高估(短暂保守)也可能低估(预扣 2K 实际 8K,在途期间窗口被低估)。控制手段是 safety factor(EMA×2,经验系数需线上校准)+ inflight 预扣占位 + settle 兑平,而非"误差天然朝保守"
主动接受的局限:
- EMA 在 Redis(定点整数):热 key 上是集中读写点,但 O(1) 单 key、更新在异步路径,量级无压力;若未来走 §9 本地缓存模式,EMA 退化为本地副本(settle 返回值刷新)
- Redis 故障时的降级策略需明确:软约束 TPM/RPM 可短时 fail-open + 本地兜底限流;硬预算/计费链路原则上 fail-close
- 图片 token 与 Provider 计费规则相关,预估只能 estimate(见 §4)
- 跨 Provider 的 token 计量口径不统一,需 ProviderRateLimitPolicy 抽象(见 §7)
对软约束而言,"允许有限误差 + 事后校正"是正确的偏法;真正的精确性只留给计费/预算链路。
9. 高 QPS 优化(可选)
admit 每次 1 次 Redis RT 是本方案唯一的延迟成本。万级 QPS 时:
- 读侧本地缓存:spent 总量每 100ms 从 Redis 刷一次本地,admit 本地判断 + 异步同步预扣。软约束容忍百毫秒视图滞后
- pipeline 合并:同 key 并发 admit 用连接池 + EVALSHA 复用脚本(免每次传脚本体)
- 参考:万级 QPS 生产网关对软约束直接用本地内存令牌桶(零 Redis),Redis 只留给硬约束——见《ai-gateway-tpm-限流真实实现.md》第 2 节
参考
- LiteLLM reservation model: https://docs.litellm.ai/docs/proxy/io_token_rate_limits
- LiteLLM 预扣保底值 bug 修复: https://github.com/BerriAI/litellm/pull/29191
- 多租户网关软硬约束拆分: https://jishuzhan.net/article/2076854919138254849
- LiteLLM rate limit v3 源码(静态预估常量): https://github.com/BerriAI/litellm/blob/main/litellm/proxy/hooks/parallel_request_limiter_v3.py
- LiteLLM 可配置预估输出 token: https://github.com/BerriAI/litellm/pull/36143
- tokengate 两段式记账: https://github.com/jackChallis/tokengate
- Shepherd Model Gateway 租户限流(订房隐喻): https://lightseek.org/smg/concepts/reliability/tenant-rate-limiting
- link-assistant-router 预扣源码注释: https://docs.rs/link-assistant-router/latest/src/link_assistant_router/token_reservation.rs.html
- Vercel AI Gateway 预检成本日志: https://vercel.com/academy/slack-agents/slack-ai-gateway
- Envoy AI Gateway token 限流(事后口径): https://github.com/envoyproxy/ai-gateway/blob/main/examples/token_ratelimit/token_ratelimit.yaml

浙公网安备 33010602011771号