限流这件事,绝大多数团队都是被 429 打脸之后才开始认真对待的。

半夜跑批处理,第二天发现白天的用户全在超时;或者上线一个活动,流量翻了三倍,接口开始大面积报错。这些问题的根子往往不是"配额不够",而是限流做在了错的地方。

这篇文章讲三件事:把概念分清楚、把限流放在正确的位置、把被限流时的体验做对。

一、先分清三个概念

RPM、TPM、并发数经常被混着说,但它们限制的是完全不同的东西。

指标限制什么单位
RPM请求的次数次 / 分钟
TPM请求的体量token / 分钟
并发数同时在处理的数量个

最容易踩的坑是 RPM 和 TPM 的关系:一个长上下文请求只占 1 次 RPM,却可能吃掉几万 TPM。

换算关系很简单:

所需 TPM ≈ 平均单次 token 数 × 目标 RPM

每次请求平均 3000 token,想跑到 100 RPM,就需要 30 万 TPM。反过来更常见——TPM 不够时 RPM 根本跑不满:

TPM 100,000 ÷ 3,000 token ≈ 33 RPM

所以采购时只谈 RPM 不谈 TPM,等于没谈。

并发数和 RPM 也不是一回事

并发数管的是"同时在飞"的请求。假设平均每次请求耗时 3 秒,那么 20 并发大约能支撑:

20 ÷ 3 秒 × 60 ≈ 400 RPM

注意这里的变量是响应时间。模型变慢,同样的并发数能扛的 RPM 就掉下来了——这也是为什么压测不能只测成功率,必须同时看 p95 延迟。

二、限流应该做在哪一层

请求从用户到模型要经过三层,每层都可以限流:

  1. 业务层:按用户、租户、接口分别限
  2. 网关层:统一入口,做全局配额和排队
  3. 上游层:供应商给的硬配额,你控制不了

三层是层层收紧的关系,实际能力由最紧的那一层决定。

最常见的错误是只在业务层限流、网关层不管。结果是某个内部跑批任务绕过了业务层的限制,一晚上把当天配额吃光,第二天白天所有用户一起报错。

正确的做法是在网关层留一道兜底:不管请求从哪来,全局总并发不超过配额的一定比例(建议 80%),剩下的留给突发。

一个经验:把配额当成"预算"而不是"上限"。花到 80% 就该告警,而不是等到 100% 被拒。

三、四种限流算法,怎么选

算法特点适合
固定窗口实现最简单,但窗口边界可能放过双倍流量内部粗粒度限制
滑动窗口解决临界问题,内存开销中等对外 API 的精确限流
令牌桶允许一定突发,长期平均可控大模型接口最常用
漏桶严格匀速流出,不吸收突发保护昂贵或脆弱的下游

选择的思路是看你想不想允许突发:

  • 用户手动触发、交互式场景:允许突发,用令牌桶
  • 后台批处理、对下游压力敏感:要求匀速,用漏桶

令牌桶的直觉是:桶里按固定速率攒令牌,请求来了就取一个,取不到就拒绝或排队。桶容量决定了"最多能突发多少"。

分布式环境下还有一个坑:限流状态要放 Redis 之类的中心存储,不能每个实例各限各的。否则 10 个实例各限 100 RPM,实际就是 1000 RPM。

四、被限流了,用户该看到什么

这一节最容易被忽略,但它直接决定用户是"等一会儿再试"还是"打电话投诉"。

三条底线:

  • 必须返回 429,并带上 Retry-After
  • 不要静默排队——用户以为页面卡死了
  • 不要返回 200 加空结果——那是骗人,下游会当成正常数据入库

还要区分两种失败,它们的处理方式完全不同:

情况状态码用户该做什么
触发了速率限制429等一会儿重试,会自动恢复
配额已用尽402 / 403充值或升级,重试没用

前端文案也应该跟着区分。把 429 显示成"系统错误"是最糟的做法——用户会反复刷新,反而加重限流。

五、客户端该怎么做

服务端限流做对了,客户端还得配合,否则重试风暴会把问题放大。

指数退避加抖动是标准做法:

import random
import time

def backoff(attempt, base=1.0, cap=30.0, retry_after=None):
    if retry_after is not None:          # 服务端明确说了等多久,就听它的
        return min(retry_after, cap)
    delay = min(base * (2 ** attempt), cap)
    return delay * (0.5 + random.random())   # 抖动,避免所有客户端同时重试

for attempt in range(4):
    resp = call_api()
    if resp.status_code != 429:
        break
    time.sleep(backoff(attempt, retry_after=resp.headers.get("Retry-After")))

几个要点:

  • 抖动是必须的。没有抖动,所有客户端会在同一时刻一起重试,形成新的尖峰
  • 只重试幂等的请求。写操作重试前想清楚会不会重复扣费
  • 加熔断。连续失败到阈值就快速失败,别让请求堆在队列里
  • 设置总超时。重试次数乘上单次超时,不能超过用户能接受的总时长

六、怎么估自己需要多少配额

别拍脑袋,从业务量倒推:

峰值 RPM = 日请求量 ÷ 86400 × 峰值系数 × 60
峰值 TPM = 峰值 RPM × 平均单次 token 数

峰值系数指"最忙那一分钟"相对平均值的倍数。经验区间是 3~5 倍:面向企业的业务偏低,面向消费者的活动型业务偏高。

举个例子:日请求 10 万次,平均每次 2500 token,峰值系数取 4。

日请求 100,000 → 平均 QPS ≈ 1.16
峰值 RPM ≈ 1.16 × 4 × 60 ≈ 278
峰值 TPM ≈ 278 × 2500 ≈ 695,000

也就是说需要大约 300 RPM / 70 万 TPM。

最后再留 30% 余量——因为模型变慢会让并发占用时间变长,实际能承载的 RPM 会比理论值低。

七、盯住这几个指标

限流不是配完就完事,得有指标能看出来"快撑不住了":

  • 429 比例:超过 1% 就说明配额偏紧或客户端重试太激进
  • 重试率:正常应低于 5%,突增通常意味着上游不稳
  • p95 延迟:限流生效前,延迟会先涨——它是最早的预警信号
  • 配额使用率的峰值:拿 TPM 的分钟峰值除以配额,超过 80% 就该扩容
  • 单次 token 数:突然上涨通常是上下文或检索出了问题,而不是流量涨了

把这几项放到同一个看板上,限流问题基本都能在用户投诉之前发现。

结语

限流的目标不是"把请求挡住",而是在压力下有秩序地服务尽可能多的人。

判断标准很简单:把你的限流打开,然后问三个问题——用户知道自己该等多久吗?内部任务会不会吃掉对外配额?配额见底之前有人知道吗?

三个都能答"是",限流才算做完了。