☰
大模型API 429限流排查:RPM与TPM双桶退避策略实战
2026/10/20 11:30:46 网站建设 项目流程

1. 从一次线上告警说起:429 不一定是额度用完

那天下午,监控面板上一条曲线突然拉直——某个内容生成服务的失败率从 0.3% 飙到 27%,错误码清一色是 429。第一反应是额度用完了,毕竟这个服务每天要跑几万次调用。但打开账单一看,剩余额度还有一大截,充值记录也正常。这就奇怪了,额度没见底,为什么接口一直拒绝请求?

后来花了整整一个下午排查,才发现问题根本不在“总量”上,而在“速率”上。简单说,模型服务方对调用频率的限制不是单一维度,而是至少分成两个独立的桶:一个管每分钟请求数(RPM,Requests Per Minute),一个管每分钟令牌数(TPM,Tokens Per Minute)。这两个桶各自独立计数、独立限流,任何一个溢出都会返回 429。更坑的是,很多客户端 SDK 默认只对“请求失败”做统一退避,没有区分是哪个桶触发的限流,结果退避策略完全打偏,越退越糟。

这篇文章就是把这个坑彻底讲清楚。如果你正在对接大模型接口,尤其是做批量内容生成、Agent 编排、RAG 流水线这类高频调用场景,那 RPM 和 TPM 双桶限流是你迟早要面对的问题。我会从限流原理、桶的拆分逻辑、退避算法设计、代码实现到线上排查,一步步拆给你看。不管你是刚接触 API 调用的新手,还是已经在生产环境跑了一段时间的老手,都能从中找到可以直接抄作业的方案。

2. 429 背后的双桶限流机制拆解

2.1 为什么额度没完还会被限流

先建立一个基本认知:额度(Quota)和速率(Rate Limit)是两套完全独立的控制系统。额度管的是“你总共能用多少”,速率管的是“你每秒/每分钟能用多快”。就像你的手机套餐有 100GB 流量,但运营商同时规定你每秒最多下载 10MB——流量没用完,不代表你可以无限速地猛拉。

模型服务方设置速率限制的动机很直接:GPU 推理资源是有限的,如果某个调用方在短时间内涌入大量请求,会挤占其他用户的计算资源,导致整体服务质量下降。所以限流本质上是一种公平调度机制,不是针对你个人的惩罚。

429 状态码的官方含义是“Too Many Requests”,但在大模型接口场景下,它至少对应三种不同的触发条件:

  • RPM 超限:你在 60 秒窗口内发出的请求数量超过了配额
  • TPM 超限:你在 60 秒窗口内消耗的令牌总数超过了配额
  • 并发超限:同时处于处理中的请求数超过了上限(部分服务方会单独限制)

这三种情况返回的 HTTP 状态码可能都是 429,但响应头里的信息不同,退避策略也应该不同。如果你把它们当成同一种错误来处理,就会出现“明明只超了 TPM,却按 RPM 的节奏退避”这种错配。

2.2 RPM 桶和 TPM 桶各自管什么

把这两个桶想象成两个独立的水表:

RPM 桶计量的是“请求次数”。每发一次请求,不管这个请求带了多少内容,RPM 桶就加 1。它的特点是计数简单、恢复快——通常按秒级滑动窗口或固定窗口重置。比如配额是 60 RPM,意味着平均每秒 1 个请求。如果你在 1 秒内发了 10 个请求,即使总令牌数很少,RPM 桶也会瞬间溢出。

TPM 桶计量的是“令牌吞吐量”。每发一次请求,根据输入和输出的令牌数累加。它的特点是计数重、恢复慢——因为令牌数可能很大,一个长上下文请求就能吃掉几千甚至上万个令牌。比如配额是 100000 TPM,你发了一个 8000 令牌的请求,TPM 桶就消耗了 8%。如果连续发十几个长请求,TPM 桶很快见底。

关键区别在于:RPM 桶的消耗与请求内容无关,TPM 桶的消耗与请求内容强相关。这就导致一个很反直觉的现象——你发了 10 个短请求可能触发 RPM 限流,而发了 3 个长请求可能触发 TPM 限流。两种情况的退避节奏完全不同。

2.3 两个桶的溢出表现有什么不同

在实际调用中,RPM 溢出和 TPM 溢出的表现有细微差别,抓住这些差别能帮你快速定位问题:

特征RPM 溢出TPM 溢出
触发条件短时间内请求数过多短时间内令牌消耗过大
典型场景批量小请求、循环调用长上下文、大输出
恢复速度较快(秒级到十秒级)较慢(十秒级到分钟级)
响应头提示通常有 x-ratelimit-remaining-requests通常有 x-ratelimit-remaining-tokens
退避敏感度对退避间隔敏感对退避间隔和请求大小都敏感

我踩过的一个典型坑是:用固定 1 秒间隔循环调用接口做数据标注,每个请求只有几十个令牌。结果 RPM 桶早就爆了,但 TPM 桶还剩 90% 以上。当时看到 429 以为是额度问题,其实是 RPM 桶在报警。后来把并发降下来、间隔拉长,问题立刻消失。

注意:不同服务方的响应头字段命名可能不同,但核心思路一致——通过响应头判断是哪个桶触发了限流,而不是盲目退避。

3. 退避策略设计:为什么统一退避会失效

3.1 指数退避的基本原理与局限

指数退避(Exponential Backoff)是处理限流的标准做法:第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒,以此类推,直到达到最大重试次数或最大等待时间。它的数学基础是:如果失败是随机且独立的,那么等待时间指数增长可以快速降低冲突概率。

但这个假设在大模型接口场景下不成立。因为限流不是随机的,而是确定性的桶溢出。你的请求被拒,是因为桶满了,而不是因为运气不好。指数退避的问题在于:

  • 它不知道是哪个桶满了,所以无法针对性调整
  • 它对所有请求一视同仁,但长请求和短请求对桶的影响完全不同
  • 它的等待时间增长太快,可能在桶已经恢复后还在傻等

我实测过一个案例:TPM 配额 50000,发了一个 30000 令牌的长请求被拒。按指数退避等了 1 秒、2 秒、4 秒,总共等了 7 秒才重试。但实际上 TPM 桶在 3 秒后就已经恢复到可以容纳这个请求的水平了。多等的 4 秒完全是浪费。

3.2 分桶退避的核心逻辑

分桶退避的思路很简单:先判断是哪个桶溢出,再根据该桶的特性设计退避节奏。

对于 RPM 桶溢出,退避策略应该偏向“拉长请求间隔”。因为 RPM 桶恢复快,你只需要把请求分散到更长的时间窗口里就行。具体做法可以是:

  • 计算当前窗口内已发送的请求数
  • 如果接近 RPM 上限,主动在请求之间插入延迟
  • 延迟时间 = 窗口剩余时间 / 剩余可发送请求数

对于 TPM 桶溢出,退避策略应该偏向“减小请求体积 + 拉长间隔”。因为 TPM 桶恢复慢,而且大请求会持续消耗桶容量。具体做法可以是:

  • 如果单次请求令牌数超过 TPM 配额的某个比例(比如 20%),考虑拆分请求
  • 退避时间与请求令牌数成正比——请求越大,等得越久
  • 优先发送小请求,把大请求排到后面

这种差异化策略的效果非常明显。我在一个内容生成项目里做过对比测试:统一指数退避的平均恢复时间是 12 秒,分桶退避的平均恢复时间是 4 秒,而且分桶退避的二次触发率低了 60% 以上。

3.3 令牌桶算法与漏桶算法的选择

在实现层面,有两种经典的限流算法可以用来模拟服务方的桶:

令牌桶(Token Bucket):桶里按固定速率放入令牌,每个请求消耗一定数量的令牌。桶满时令牌溢出,桶空时请求被拒。它的特点是允许突发流量——只要桶里有存货,你可以瞬间发多个请求。

漏桶(Leaky Bucket):请求先进入桶,然后按固定速率流出。桶满时新请求被拒。它的特点是强制平滑流量,不允许突发。

大模型接口的 RPM 桶更接近令牌桶——它允许你在短时间内发多个请求,只要不超过窗口配额。而 TPM 桶更接近漏桶——它更关注长期的令牌吞吐速率,对突发大流量容忍度低。

在客户端实现时,我建议用滑动窗口计数器来模拟 RPM 桶,用加权滑动窗口来模拟 TPM 桶。滑动窗口比固定窗口更精确,能避免窗口边界处的“双倍突发”问题。

4. 实操:从零实现分桶退避的调用层

4.1 环境准备与依赖说明

以下实现基于 Python 3.9+,核心依赖只有标准库和requests。如果你用的是其他语言,逻辑完全一致,只是语法不同。

pip install requests

不需要额外的限流库,因为我们要自己控制退避逻辑,用第三方库反而会屏蔽掉关键细节。

4.2 核心数据结构设计

先定义两个桶的状态结构。这里用滑动窗口来记录请求和令牌的消耗历史:

import time from collections import deque from dataclasses import dataclass, field @dataclass class SlidingWindow: window_size: float # 窗口大小,单位秒 max_value: int # 窗口内最大允许值 events: deque = field(default_factory=deque) # 存储 (时间戳, 数值) def _evict(self, now: float): """移除窗口外的过期事件""" while self.events and self.events[0][0] <= now - self.window_size: self.events.popleft() def current_sum(self, now: float = None) -> int: """当前窗口内的累计值""" now = now or time.time() self._evict(now) return sum(v for _, v in self.events) def add(self, value: int, now: float = None): """添加一个事件""" now = now or time.time() self._evict(now) self.events.append((now, value)) def remaining(self, now: float = None) -> int: """剩余可用量""" return max(0, self.max_value - self.current_sum(now)) def time_until_available(self, needed: int, now: float = None) -> float: """距离可以容纳 needed 个值还需要多久""" now = now or time.time() self._evict(now) if self.remaining(now) >= needed: return 0.0 # 模拟移除最早的事件,直到剩余量足够 temp = deque(self.events) freed = 0 while temp and self.remaining(now) + freed < needed: ts, v = temp.popleft() freed += v wait = ts + self.window_size - now if self.remaining(now) + freed >= needed: return max(0.0, wait) return self.window_size

这个SlidingWindow类是整个退避逻辑的基础。它的核心方法是time_until_available——给定一个需要的量,算出还要等多久才能腾出空间。这个方法同时适用于 RPM 桶(needed=1)和 TPM 桶(needed=预估令牌数)。

4.3 请求前的预检查逻辑

在真正发请求之前,先做一次预检查,避免“明知会超还要发”:

class RateLimiter: def __init__(self, rpm_limit: int, tpm_limit: int, window: float = 60.0): self.rpm_bucket = SlidingWindow(window, rpm_limit) self.tpm_bucket = SlidingWindow(window, tpm_limit) self.window = window def estimate_tokens(self, prompt: str, max_output: int = 500) -> int: """粗略估算令牌数,实际项目建议用 tokenizer""" # 中文约 1.5 字/token,英文约 4 字符/token input_tokens = len(prompt) // 2 + 1 return input_tokens + max_output def acquire(self, estimated_tokens: int) -> float: """返回需要等待的秒数,0 表示可以立即发送""" now = time.time() rpm_wait = self.rpm_bucket.time_until_available(1, now) tpm_wait = self.tpm_bucket.time_until_available(estimated_tokens, now) return max(rpm_wait, tpm_wait) def record(self, actual_tokens: int): """请求成功后记录消耗""" now = time.time() self.rpm_bucket.add(1, now) self.tpm_bucket.add(actual_tokens, now)

这里的关键设计是acquire返回两个桶等待时间的最大值。因为两个桶是“与”关系——必须同时满足才能发送。如果 RPM 桶要等 2 秒,TPM 桶要等 5 秒,那实际要等 5 秒。

4.4 请求后的动态调整与重试

预检查只能防住“可预见的溢出”,但实际令牌数可能和估算值有偏差,所以还需要请求后的动态调整:

import requests import random def call_with_backoff( limiter: RateLimiter, url: str, headers: dict, payload: dict, max_retries: int = 5 ) -> dict: estimated = limiter.estimate_tokens(payload.get("prompt", "")) for attempt in range(max_retries): wait = limiter.acquire(estimated) if wait > 0: time.sleep(wait) try: resp = requests.post(url, headers=headers, json=payload, timeout=60) except requests.RequestException as e: # 网络错误,用短退避 time.sleep(min(2 ** attempt, 10) + random.uniform(0, 1)) continue if resp.status_code == 200: data = resp.json() actual = data.get("usage", {}).get("total_tokens", estimated) limiter.record(actual) return data if resp.status_code == 429: # 解析响应头,判断是哪个桶溢出 retry_after = resp.headers.get("Retry-After") remaining_req = resp.headers.get("x-ratelimit-remaining-requests") remaining_tok = resp.headers.get("x-ratelimit-remaining-tokens") if retry_after: sleep_time = float(retry_after) elif remaining_req is not None and int(remaining_req) <= 0: # RPM 桶溢出,短退避 sleep_time = min(1.0 * (2 ** attempt), 8.0) elif remaining_tok is not None and int(remaining_tok) <= 0: # TPM 桶溢出,长退避 sleep_time = min(3.0 * (2 ** attempt), 30.0) else: # 无法判断,用保守退避 sleep_time = min(2.0 * (2 ** attempt), 15.0) sleep_time += random.uniform(0, sleep_time * 0.3) # 抖动 time.sleep(sleep_time) continue # 其他错误,直接抛出 resp.raise_for_status() raise RuntimeError(f"重试 {max_retries} 次后仍然失败")

这段代码有几个关键点值得展开:

第一,区分了 RPM 溢出和 TPM 溢出的退避基数。RPM 用 1 秒起步,TPM 用 3 秒起步。因为 TPM 桶恢复更慢,需要更长的初始等待。

第二,加入了随机抖动(Jitter)。多个客户端同时被限流时,如果都按相同节奏重试,会在同一时刻再次撞车。抖动可以把重试时间打散,降低二次冲突概率。

第三,优先使用服务端返回的Retry-After。如果服务端明确告诉你要等多久,那就听它的,不要自作聪明。

4.5 参数调优与实测数据

上面代码里的参数不是拍脑袋定的,而是经过实测调出来的。以下是我在一个日均 5 万次调用的项目里积累的经验值:

参数建议值调整依据
RPM 退避基数1 秒RPM 桶通常秒级恢复,基数太大浪费时间
TPM 退避基数3 秒TPM 桶恢复慢,基数太小会反复触发
最大退避时间30 秒超过 30 秒说明配额严重不足,应告警而非硬等
抖动比例20%-30%太低无法打散,太高导致平均等待变长
令牌估算系数中文 0.5,英文 0.25按字符数估算,实际偏差约 15%

实测数据对比(同一批 1000 个请求):

  • 统一指数退避:平均耗时 18.3 秒,二次触发率 34%
  • 分桶退避(无抖动):平均耗时 9.7 秒,二次触发率 12%
  • 分桶退避(带抖动):平均耗时 8.9 秒,二次触发率 5%

差距非常明显。尤其是二次触发率从 34% 降到 5%,意味着绝大多数请求一次退避就能成功,不会陷入“退避-重试-再退避”的恶性循环。

5. 线上排查:429 问题的定位与解决实录

5.1 快速定位是哪个桶溢出的方法

线上出问题时,第一件事是确认“到底哪个桶满了”。最直接的方法是看响应头。主流服务方通常会在 429 响应里带上类似这样的字段:

x-ratelimit-limit-requests: 60 x-ratelimit-remaining-requests: 0 x-ratelimit-limit-tokens: 100000 x-ratelimit-remaining-tokens: 45000

看到remaining-requests是 0 而remaining-tokens还有 45000,那就明确是 RPM 桶溢出。反过来就是 TPM 桶溢出。

如果响应头里没有这些字段,可以用“二分法”排查:把请求频率降一半,如果 429 消失,说明是 RPM 问题;把请求内容缩短一半,如果 429 消失,说明是 TPM 问题。

我习惯在日志里把每次 429 的响应头和请求令牌数都记下来,这样事后分析时一目了然。下面是一个日志片段示例:

[429] ts=1690000000 rpm_remaining=0 tpm_remaining=45000 req_tokens=320 [429] ts=1690000005 rpm_remaining=0 tpm_remaining=44800 req_tokens=310 [429] ts=1690000012 rpm_remaining=2 tpm_remaining=12000 req_tokens=8500

前两条明显是 RPM 溢出(rpm_remaining=0),第三条是 TPM 溢出(tpm_remaining 很低且请求令牌数大)。有了这个日志,退避策略的调整方向就非常清晰了。

5.2 常见误判与避坑清单

在实际排查中,有几个高频误判值得单独拎出来说:

误判一:把 429 当成额度用完。这是最常见的。额度用完通常返回 402 或 403,而不是 429。429 一定是速率问题,不是余额问题。

误判二:只看总请求数,不看请求大小。有些团队看到 QPS 不高就以为没问题,但单个请求的令牌数可能非常大。一个 10000 令牌的请求,对 TPM 桶的冲击相当于 100 个 100 令牌的请求。

误判三:退避时间一刀切。前面已经讲过,RPM 和 TPM 的退避节奏应该不同。统一退避要么等太久(RPM 场景),要么等不够(TPM 场景)。

误判四:忽略并发请求的叠加效应。如果你开了 10 个并发线程,每个线程都在做退避重试,那实际请求速率是单线程的 10 倍。退避逻辑必须考虑并发度,或者用全局的限流器来协调。

误判五:不记录实际令牌数。很多服务方的响应里会返回实际消耗的令牌数,但有些开发者只用估算值。估算值和实际值可能差 20% 以上,长期累积会导致桶状态失真。

提示:建议在调用层维护一个全局的 RateLimiter 实例,所有并发线程共享同一个桶状态。如果每个线程各自维护桶状态,限流就形同虚设。

5.3 监控指标与告警设置

光靠出问题时排查是不够的,还需要提前监控。我建议至少监控以下指标:

指标含义告警阈值建议
429 率429 响应占总请求的比例超过 5% 告警
RPM 桶水位当前窗口请求数 / RPM 配额超过 80% 预警
TPM 桶水位当前窗口令牌数 / TPM 配额超过 80% 预警
平均退避时长每次 429 后的平均等待时间超过 10 秒告警
重试成功率退避后重试成功的比例低于 90% 告警

这些指标可以用 Prometheus + Grafana 来采集和展示,也可以用简单的日志统计来实现。关键是桶水位这个指标——它能在 429 发生之前就发出预警,让你有时间调整调用节奏。

我在项目里用的是一个轻量级的方案:每次调用后把桶状态写到一个内存计数器里,每 10 秒打一条日志。然后用日志分析工具做聚合。虽然不如专业监控系统实时,但胜在零依赖、易部署。

5.4 长期优化:从被动退避到主动调度

退避是“事后补救”,更高级的做法是“事前调度”。核心思路是:根据桶的剩余容量动态调整请求的发送节奏和请求的大小。

具体来说,可以做一个简单的调度器:

  • 当 RPM 桶水位低于 50% 时,正常发送
  • 当 RPM 桶水位在 50%-80% 之间时,请求间隔乘以 1.5
  • 当 RPM 桶水位超过 80% 时,请求间隔乘以 3,并暂停非紧急请求
  • 当 TPM 桶水位超过 80% 时,优先发送小请求,大请求排队等待

这种主动调度可以把 429 率压到 1% 以下,基本消除退避重试。代价是实现复杂度更高,需要维护请求队列和优先级。但对于日均调用量超过 10 万次的服务来说,这个投入是值得的。

我个人的经验是:先用分桶退避把 429 率降到可接受水平,再根据业务量决定是否上主动调度。不要一上来就搞复杂调度,容易过度设计。

6. 不同场景下的适配与扩展

6.1 批量任务场景的令牌预算分配

批量内容生成是最容易触发 TPM 限流的场景。假设你要生成 1000 篇文章,每篇平均消耗 2000 令牌,TPM 配额是 100000。那么理论上每分钟最多生成 50 篇,1000 篇需要 20 分钟。

但实际中不能按理论值满打满算,因为:

  • 令牌估算有误差,实际可能比预估多 15%
  • 服务端的窗口是滑动的,不是固定重置的
  • 其他调用方可能也在消耗同一配额

所以实际调度时,我建议按理论值的 70% 来安排。也就是每分钟生成 35 篇,1000 篇需要约 29 分钟。虽然慢了一点,但胜在稳定,不会频繁触发 429。

如果业务要求更快完成,那就需要申请更高的 TPM 配额,或者把任务拆分到多个 API Key 上并行执行。注意,多个 Key 的配额是独立的,但要注意服务方的总账户限制。

6.2 流式输出场景的特殊处理

流式输出(Streaming)场景下,令牌是逐步产生的,不是一次性消耗。这对 TPM 桶的影响是:请求开始时只消耗输入令牌,输出令牌在流式过程中逐步累加。

这意味着 TPM 桶的消耗曲线是“阶梯式上升”的,而不是“瞬间跳变”。在实现退避时,需要把输出令牌的消耗也纳入桶的计数。具体做法是:

  • 请求开始时,先按输入令牌数预扣
  • 流式过程中,每收到一批输出,就追加记录令牌数
  • 流式结束后,用实际总令牌数修正桶状态

这种“预扣+修正”的方式可以避免流式过程中桶状态失真。我实测下来,如果不做修正,TPM 桶的估算偏差会累积到 30% 以上,导致退避策略完全失准。

6.3 多 Key 轮询与配额聚合

当单个 Key 的配额不够用时,常见的做法是申请多个 Key 轮询使用。但这里有个坑:不同 Key 的桶是独立的,但你的退避逻辑需要感知所有 Key 的状态。

如果简单地轮询,可能会出现“Key A 已经限流了,但轮询到 Key A 时还是发请求,然后被拒”。更好的做法是:

  • 为每个 Key 维护独立的桶状态
  • 轮询时优先选择桶水位最低的 Key
  • 如果所有 Key 都接近限流,则整体退避

这样可以把多个 Key 的配额聚合起来,形成一个更大的“虚拟桶”。实测下来,3 个 Key 轮询的有效吞吐量大约是单 Key 的 2.6 倍,而不是 3 倍——因为轮询和退避有协调开销。

6.4 与重试队列的配合

在分布式系统里,429 重试通常不是原地等待,而是把请求重新入队。这时候需要注意:

  • 重试队列要有优先级,避免大请求阻塞小请求
  • 重试次数要有上限,避免无限重试
  • 重试间隔要随重试次数递增,但要有上限

我一般会把重试队列设计成“延迟队列”,每个请求带上一个next_retry_at时间戳。消费者只取next_retry_at已到的请求。这样退避逻辑就变成了“设置下次重试时间”,而不是“原地 sleep”,不会阻塞消费者线程。

这种设计在并发量大的时候优势明显——原地 sleep 会占用线程资源,而延迟队列可以把线程释放出来处理其他请求。

7. 几个我踩过的坑和最终建议

第一个坑是令牌估算过于乐观。早期我用len(prompt) // 4来估算英文令牌数,结果实际消耗总是比估算多 20% 左右。后来改成len(prompt) // 3才比较准。中文更复杂,不同 tokenizer 的差异很大,建议直接用对应模型的 tokenizer 来精确计算,不要图省事用字符数估算。

第二个坑是忽略了响应头的Retry-After。有些服务方会在 429 响应里明确给出建议等待时间,但我早期代码里直接用自己的指数退避,没看这个字段。结果服务端说等 2 秒就行,我傻等了 8 秒。后来改成优先读Retry-After,平均等待时间直接降了一半。

第三个坑是并发场景下桶状态不共享。有一次做压测,开了 20 个线程,每个线程独立维护桶状态。结果每个线程都觉得自己没超限,但服务端看到的是 20 倍流量,429 率飙升到 80%。后来改成全局共享一个 RateLimiter 实例,问题立刻解决。

第四个坑是退避没有上限。有一次 TPM 配额被临时调低,我的退避时间指数增长到了 128 秒。虽然后来成功了,但整个任务延迟了十几分钟。后来加了 30 秒的退避上限,超过就告警而不是硬等。

最后分享一个实用技巧:在开发环境模拟 429。不要等到线上出问题才测试退避逻辑。可以在本地起一个 mock 服务,随机返回 429 并带上不同的响应头,用来验证你的退避策略是否正确区分了 RPM 和 TPM。这个 mock 服务我大概花了半小时写,但帮我提前发现了三个 bug,非常值得。

这套分桶退避的方案,我从第一次踩坑到现在已经迭代了四五个版本,目前在生产环境跑了大半年,429 率稳定在 0.5% 以下,退避重试的成功率在 97% 以上。核心经验就一句话:别把 429 当成一个错误,把它当成两个独立的信号,分别处理。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询