限流的三个维度
"限流"在大模型场景里不是一个阈值。先说清一件事:供应商公布的限额,和你自己网关的自我保护,是两回事。供应商侧公布的是速率维度(OpenAI 实际有五类:RPM、TPM、RPD、TPD、IPM),"并发数"并不是供应商会公布的指标,而是自建网关的自我保护线。下面三个维度里,前两个是供应商的、第三个是你自己的——只盯供应商的额度,自己的服务会先被自己打垮。而且它们互相独立:只做其中一个,另外两个会以另一种方式炸掉。
1. 三个维度
RPM(每分钟请求数) —— 限制调用次数。请求很轻时先撞这个。
TPM(每分钟 token 数) —— 限制处理量。请求很重(长上下文、长输出)时先撞这个,即使请求次数很少。
并发数 —— 限制同时在途的请求。流式接口尤其容易撞这个:一个流式请求会占用连接几十秒,并发一满就排队。
三个维度要分别计数。一个常见的翻车现场:只按 RPM 限流,结果用户改成长文档问答,几百次请求把 TPM 额度一次性打满,后续全部请求在别处被拒。
2. 在哪一层做
在网关层做,不要散在各处。
散在业务代码里的限流有三个问题:计数不共享(多实例部署时形同虚设)、维度不全(通常只做了 RPM)、以及超限行为不一致(有的抛错有的静默)。
集中到一个网关之后,还能顺带做成本配额、审计和降级——这几件事本来就该在同一处,因为它们都需要"这次调用属于谁、用了多少"这同一份信息。
2.1 计数要带租户前缀
一个具体但高发的 bug:所有用户共用一个 Redis key,结果一个重度用户把所有人一起限流了。
正确做法是每个计数 key 都带用户或租户前缀:
rate:{tenantId}:rpm:{windowMinute}
rate:{tenantId}:tpm:{windowMinute}
inflight:{tenantId}
3. 计数怎么实现
两种常见形态,按用途选:
固定窗口计数。 简单,但在窗口边界会有"两倍突发"(前一分钟末尾 + 后一分钟开头)。
滑动窗口。 平滑,没有边界突发;代价是要存每次请求的时间戳(Redis 有序集合,成员是时间戳,用 ZREMRANGEBYSCORE 清窗口外,再 ZCARD 计数)。
令牌桶。 状态极小(两个数:当前令牌数 + 上次补充时间,O(1)),而且允许可控的突发——桶里攒着令牌时可以一次性放过一小批。这一点在 LLM 场景常常是想要的:用户点一次发送可能并发触发几个工具调用。
选型只看两条:要不要允许突发,以及能不能接受存全量时间戳。
无论哪种,检查和累加必须原子完成。非原子实现在并发下会超发,而且超发的量随并发度上升。Redis 侧用 Lua 脚本包裹,同时注意被拒绝的请求不该消耗额度(先判断再累加,而不是先加后判断):
-- KEYS[1] 计数 key,ARGV[1] 上限,ARGV[2] 本次成本,ARGV[3] 窗口秒数
local used = tonumber(redis.call('GET', KEYS[1]) or '0')
if used + tonumber(ARGV[2]) > tonumber(ARGV[1]) then return 0 end
used = redis.call('INCRBY', KEYS[1], ARGV[2])
if used == tonumber(ARGV[2]) then
redis.call('EXPIRE', KEYS[1], ARGV[3])
end
return 1
上面这段是最简的固定窗口实现(也就继承了边界突发),生产上要平滑就换成滑动窗口或令牌桶,原子性要求是一样的。另外,并发维度是唯一需要显式释放的额度:正常结束、异常、客户端断连三条路径都要减一,漏一条就会永久泄漏名额——这是三个维度里最容易出 bug 的一个。
TPM 的"本次成本"用请求的估算 token 数(响应回来之后再用实际用量修正一次)。
3.1 预算与配额
按次限流之外,通常还要一层"预算":这个用户今天/本月最多花多少。
做法是在调用前估算成本并预扣,调用后按实际用量冲正。预扣必须原子,冲正要有对账任务——只预扣不对账,账面额度会和实际账单越差越远。
4. 失败之后:退避与抖动
被限流(429)或上游出错时,重试是必须的,但重试的方式决定了是在缓解问题还是在制造问题。
4.1 指数退避
每次失败后等待时间翻倍:1s → 2s → 4s → 8s,到达上限后停止。
4.2 抖动是必须的
不加抖动的指数退避有个致命问题:一批同时失败的请求会在同一秒一起重试,形成新的尖峰,然后再次一起失败。这在批量任务和定时任务里尤其明显。
做法是给等待时间加随机量:
const wait = Math.min(base * 2 ** attempt, maxWait)
const jittered = wait * (0.5 + Math.random() * 0.5)
4.3 尊重上游给的信号
多数供应商会在响应里带上"多久之后可以重试"(Retry-After 头或响应体里的提示)。这个信号优先于自己算的退避时间——它反映的是上游真实状态。
4.4 幂等性
重试的前提是这次操作可以重复执行。读操作天然幂等;写操作(创建订单、发消息)必须有幂等键,否则重试会变成重复下单。
判断标准很简单:如果这个请求被执行两次会造成实际影响,就不要盲目重试。要么加幂等键,要么交给人工。
5. 哪些错不该重试
区分两类,是重试逻辑里最该写清楚的部分:
| 该重试 | 不该重试 |
|---|---|
| 429 限流 | 400 参数错误 |
| 5xx 服务端错误 | 401 鉴权失败 |
| 超时 | 404 资源不存在 |
| 连接中断 | 内容审核拦截 |
对不该重试的错误做重试,只会把一次失败变成多次失败,顺带推高账单。
6. 降级
限流或上游故障时的兜底,比"直接报错"好得多。常见形态:
换更便宜/更小的模型。 强模型不可用时降级到弱模型,保证服务不中断。代价是质量下降,所以必须记录"这次是哪台模型真正服务的"——否则质量悄悄下滑而没人发现。
返回缓存结果。 对可缓存的查询类型(翻译、摘要、固定模板生成)有效。
明确告知。 什么都做不了时,给用户一个清楚的状态和预计恢复时间,比转圈或 500 页面好。
降级的触发次数本身是个重要指标:降级率持续不为零,说明容量规划有问题,而不是降级逻辑有问题。
7. 一个检查清单
- 三个维度(RPM / TPM / 并发)是否都做了,是否带租户前缀
- 限流与预算是否在同一层,计数是否原子
- 重试是否有退避 + 抖动,是否尊重
Retry-After - 重试是否区分了幂等与非幂等操作
- 不该重试的错误是否被排除
- 降级是否记录了"实际服务的模型"
这几条里最容易缺的是最后一条——它不影响系统跑起来,但决定了出问题时能不能定位。这和《给大模型链路装仪表》里那条"两分钟内拿到 trace"是同一个标准。