Skip to main content

限流的三个维度

"限流"在大模型场景里不是一个阈值。先说清一件事:供应商公布的限额,和你自己网关的自我保护,是两回事。供应商侧公布的是速率维度(OpenAI 实际有五类:RPM、TPM、RPD、TPD、IPM),"并发数"并不是供应商会公布的指标,而是自建网关的自我保护线。下面三个维度里,前两个是供应商的、第三个是你自己的——只盯供应商的额度,自己的服务会先被自己打垮。而且它们互相独立:只做其中一个,另外两个会以另一种方式炸掉。

RPM每分钟请求数TPM每分钟 token 数并发数同时在途请求三个维度各自独立:请求很轻时先撞 RPM,请求很重时先撞 TPM,长连接场景先撞并发第 1 次失败等 1s第 2 次等 2s + 抖动第 3 次等 4s + 抖动抖动是必须的:不然一批同时失败的请求会在同一秒一起重试
图:限流的三个维度,以及失败后的退避节奏

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"是同一个标准。