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

ChatGPT 图像 2026年9月30日 23_58_22

方案一句话:输出 token 未知,就用预估值预扣占坑(admit),在途期间由预留层替它出现在窗口里(in-flight),流结束用真实值接班兑平(settle)。窗口本身用 Redis 分桶滑动窗口(7 桶 × 10s,恒覆盖 ≥60s)承载,多实例共享、O(1) 成本。

目录


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

四个必须记住的细节:

  1. DECRBY 以 HDEL==1 为条件,不是以 HGET 存在为条件:settle(异步)与 recycle(低频)会竞争同一 reservation,只有 claim 成功的一方退款,退款恰好发生一次
  2. recycle 不写 settled 幂等键:迟到的 settle SET NX 通过 → 明细已 nil → 不退款、不写 done 桶;EMA 照常可更新,真实 usage 由计费/审计链路照记。曾考虑过「按迟到距离分档写桶 + recycle 留退款时刻标记」的方案,评估后放弃——为一个低频边界增加每请求维度的标记 key 和分档逻辑,复杂度收益不划算;边界损失靠 reservation timeout 留足余量压制
  3. recycle 的 HSCAN 游标放应用层分批:每批只对过期条目进小 Lua,不要在单个 Lua 里扫整个 HASH(大 key 会阻塞 Redis,重蹈 HGETALL 覆辙)
  4. 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 即可算,不需要再回 Redis
  • x-ratelimit-reset-tokens:与 Retry-After 同源——滑出量足够那一刻的时间戳

Redis 故障降级:软约束(TPM/RPM)→ 短时 fail-open + 本地内存令牌桶粗兜底(限流短时失灵优于全部不可用);硬约束(预算/计费)→ fail-close(503),宁可拒绝不可超卖——两套策略按约束类型选择,不可混用。

8. 已知近似与局限(诚实清单)

结构性偏保守的:

  1. 分桶求和 ≠ 精确 60s 窗口(分桶近似滑动窗口):7 桶覆盖 60~70s,恒 ≥ 60s,只有多算一个方向(稳态最多高估 ≈17%)
  2. 挂死请求靠明细 ts + recycler 显式退款,退款前(reservation timeout 内)窗口虚高
  3. 幂等键/明细账的 TTL 都取偏大值(幂等键 30 分钟、明细账 2h,防重复投递与漏退款)

双向存在的:

  1. 预扣值 ≠ 真实值:EMA 预估可能高估(短暂保守)也可能低估(预扣 2K 实际 8K,在途期间窗口被低估)。控制手段是 safety factor(EMA×2,经验系数需线上校准)+ inflight 预扣占位 + settle 兑平,而非"误差天然朝保守"

主动接受的局限:

  1. EMA 在 Redis(定点整数):热 key 上是集中读写点,但 O(1) 单 key、更新在异步路径,量级无压力;若未来走 §9 本地缓存模式,EMA 退化为本地副本(settle 返回值刷新)
  2. Redis 故障时的降级策略需明确:软约束 TPM/RPM 可短时 fail-open + 本地兜底限流;硬预算/计费链路原则上 fail-close
  3. 图片 token 与 Provider 计费规则相关,预估只能 estimate(见 §4)
  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 节

参考

posted @ 2026-09-30 21:17  alin_qu  阅读(19)  评论(0)    收藏  举报