☰
API 429限流排查指南:RPM/TPM双桶限流与退避重试策略
2026/10/14 5:14:34 网站建设 项目流程

连着三个晚上被 grok-4.7 的 429 告警吵醒,第一反应是账号额度刷完了。结果登录控制台一看,套餐余额还剩八成,再翻日志,每一条 429 响应体里都没提余额不足,反而响应头里的 remaining 字段在疯狂往下跳。后来才确认,这根本不是额度问题,是 RPM 和 TPM 双桶限流在同时限制。这篇文章把这次的排查过程、响应头读取代码、退避策略和最终落地的调用封装都整理出来,后端同学如果也在被 429 折磨,可以直接抄作业。

1. 先搞清楚:grok-4.7 的 429 到底谁在拦你

1.1 两种限流桶:请求桶和令牌桶

先说名词。RPM 是 Requests Per Minute,也就是每个自然分钟内允许发起多少次请求;TPM 是 Tokens Per Minute,指每分钟最多能消耗多少 token。很多服务商的网关会在入口同时放两个“桶”:一个桶装请求次数,一个桶装 token 令牌。每次请求从应用发到服务端时,网关会先同时检查这两个桶里还有没有余量,任何一个桶空了,直接回 429 Too Many Requests。

用一个生活类比来理解:请求桶像地铁闸机,每分钟能放行固定人数;token 桶像单向隧道,每小时能通过固定车流量。如果闸机放行 100 个人,但这 100 个人全开着双层大客车进隧道,隧道会堵死;反过来,隧道再宽,闸机如果只放 10 个人,实际吞吐也上不去。大模型请求的消耗差异实在太大了,有的请求只回几十个 token,有的请求光上下文就带着几千甚至上万 token,只看请求次数没法反映真实负载,只看 token 数又控制不了高频短请求。双桶各管一摊,短板决定成败。

实际排查时,我习惯先把 429 响应头的原始值打出来看一眼。如果remaining-requests是 0,说明这一分钟请求数撞顶了;如果remaining-tokens是 0,那就是 token 桶空了。很多时候两个值同时为 0,说明网关在那一瞬间已经彻底挤爆。

1.2 为什么“额度还剩一半”却还是 429

这里有一个特别容易踩的坑:控制台展示的额度和网关限流是完全不同的两个维度。套餐额度是月总量,比如这个月买了 1000 万 token 配额,它只关心你累计用了多少;而 RPM 和 TPM 是分钟级的瞬时速率控制,只关心你在 60 秒这一小段时间内跑得多猛。

举个例子就明白了。假设你的套餐还有 980 万 token,但 TPM 上限是 2 万。你跑一个批量总结任务,同时并发 20 个请求,每个请求输入是 5000 token 的上下文,20 个并发瞬间就是 10 万 token,一分钟内 TPM 直接打到 0。此时月度额度还剩 980 万,但 429 照样疯狂出现。控制台只展示总量,不会每秒钟刷新“当前这一分钟的 token 消耗”,所以你很容易误以为余额耗尽,然后跑去充值续费,结果一分钱没少花,问题还在。

另外要留意错误类型。额度不足一般返回 402 或 403,服务过载一般返回 503,而 429 在大多数网关实现里就是限流专用状态码。只要看到 429,优先往速率控制上想,而不是先怀疑余额。

2. 用响应头定位:是 RPM 还是 TPM 触了顶

2.1 响应头里的一手情报

服务端其实已经把限流状态写在了响应头里,只是很多人没有去读。我这次排查时发现,grok-4.7 这类接口通常会返回一组x-ratelimit-*字段,不同服务商字段名会有细微差别,但含义基本等价。常见的字段如下:

响应头字段含义使用场景
x-ratelimit-limit-requests当前窗口请求数上限判断 RPM 桶容量
x-ratelimit-remaining-requests当前窗口剩余请求数判断是否要撞 RPM 桶
x-ratelimit-limit-tokens当前窗口 token 上限判断 TPM 桶容量
x-ratelimit-remaining-tokens当前窗口剩余 token 数判断是否要撞 TPM 桶
ratelimit-reset距窗口重置还有多少秒计算需要等待多久
retry-after服务端建议等待时间(秒)退避最优先参考值

遇到 429 时,不要只盯响应体里的 message,响应体往往是给人看的,响应头才是给程序用的。如果服务端没返回这些自定义头,也可以用开源的网关中间件或抓包工具看原始响应,只要限流逻辑在,一定有地方把余量信息暴露出来。

2.2 一行代码读出当前余量

我用 Python 做演示,读响应头其实非常简单。先发起一个普通请求,然后把响应头的几个关键字段打出来:

import requests resp = requests.post( "https://api.example.com/v1/grok-4.7/chat", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={"prompt": "你好,请介绍一下你自己"}, ) print("status:", resp.status_code) print("limit_requests:", resp.headers.get("x-ratelimit-limit-requests")) print("remaining_requests:", resp.headers.get("x-ratelimit-remaining-requests")) print("limit_tokens:", resp.headers.get("x-ratelimit-limit-tokens")) print("remaining_tokens:", resp.headers.get("x-ratelimit-remaining-tokens")) print("ratelimit-reset:", resp.headers.get("ratelimit-reset")) print("retry-after:", resp.headers.get("retry-after"))

为了保证代码可以复用,我一般会封装一个解析函数,顺便处理字段缺失和非法值:

def parse_ratelimit(headers): def _get(name): val = headers.get(name) if val is None: return -1 try: return int(float(val)) except ValueError: return -1 return { "limit_requests": _get("x-ratelimit-limit-requests"), "remaining_requests": _get("x-ratelimit-remaining-requests"), "limit_tokens": _get("x-ratelimit-limit-tokens"), "remaining_tokens": _get("x-ratelimit-remaining-tokens"), "reset_seconds": _get("ratelimit-reset"), "retry_after": _get("retry-after"), }

判断是哪个桶满了,逻辑也很直白:

rl = parse_ratelimit(resp.headers) if rl["remaining_requests"] == 0: print("请求桶打满,RPM 触顶") if rl["remaining_tokens"] == 0: print("令牌桶打满,TPM 触顶")

注意响应头字段名不区分大小写,headers.get本身就兼容。我遇到过remaining-tokens返回字符串"0",所以解析时统一转成 int,避免遇到"0"时被当成假值处理。

3. 退避重试:不要暴力 sleep,要按桶余量反推

3.1 指数退避加抖动,先保命

最粗暴的重试是收到 429 后固定time.sleep(1)再重试。这个做法在单个请求时看着没问题,但你一旦跑批量任务,几十个并发同时命中限流,大家又一起在 1 秒后醒来,还是会同时打向网关,这就是典型的“惊群效应”。网关刚缓过一口气,又被同一批请求拍回去,429 只会更严重。

正确做法是指数退避加抖动。每次重试的等待时间按指数增长,同时加入随机偏移,把请求时间打散。公式大概是:

delay = min(base * 2 ** attempt, max_delay) * (1 + random_jitter)

代码实现:

import random def next_delay(attempt, base=0.5, max_delay=60.0, jitter=0.25): exp = min(base * (2 ** attempt), max_delay) return exp * (1 + random.uniform(0, jitter))

attempt从 0 开始,第一次失败等 0.5 秒左右,第二次 1 秒,第三次 2 秒,直到达到max_delay。上限一定要设,我见过有人不加max_delay,指数涨到 512 秒,任务直接超时,比限流还致命。抖动比例我习惯取 0.25,也就是在基数上增加最多 25% 的随机量,既打散时间点,又不会让等待时间长得离谱。

3.2 读 Retry-After:对方已经告诉你等多久

如果响应头里有Retry-After,它的优先级最高。服务端既然给了这个值,说明它已经预期到你会立刻重试,并且帮你算好了最短等待时间。有它就别自己瞎算,直接用它。部分网关返回的是秒数,部分可能返回 HTTP 日期格式,如果是日期需要先用datetime解析再换算成时间戳。

如果Retry-After缺失,就利用ratelimit-reset和余量信息计算。ratelimit-reset表示距离窗口重置还有多少秒,等它归零,桶就会重新装满。计算等待时间的函数我这样写:

import random def calc_wait(rl, attempt, max_delay=60.0): if rl.get("retry_after", 0) > 0: return rl["retry_after"] + random.uniform(0, 1) if rl.get("remaining_requests", 0) == 0 or rl.get("remaining_tokens", 0) == 0: if rl.get("reset_seconds", 0) > 0: return rl["reset_seconds"] + random.uniform(0, 1) # 没有重置时间时,按一个完整窗口等 return 60 + random.uniform(0, 2) return min(0.5 * (2 ** attempt), max_delay)

这里加了 0 到 1 秒的抖动,是因为多个请求即使都算出了同一个等待时间,也会因为抖动的存在分散开,避免下一轮重试再次集体撞车。

3.3 并发调用时如何全局节流

退避策略是单请求层面的,真正要解决问题还得做全局节流。最简单的做法是给程序加一个信号量,限制同时飞出去的请求数量。Python 的threading.Semaphore或者asyncio.Semaphore都行。比如一个批量任务有 200 条数据,直接全量并发会瞬间打满 RPM,但把并发数限制在 5 到 8 个,429 数量会显著下降。

更精细一点,可以维护一个进程级的“下一可用时间”变量。每个请求发出前先检查当前时间是否早于这个变量,如果还早,就 sleep 到那个时间;请求结束后,根据响应头里的reset_seconds更新下一可用时间。这个方法尤其适合单实例应用,代码逻辑简单,效果直观。多实例部署还需要把状态放到 Redis 之类的共享存储中,这个放到后面排查部分再展开。

4. 一套可直接抄的带限流保护的调用封装

4.1 核心调用代码框架

把响应头解析和退避策略合并到同一个调用函数里,外面只暴露一个chat()方法。这样业务侧完全不需要知道限流逻辑的存在,调用方只传 prompt,重试、等待、判断桶余量全部封装在内部。

import time import random import requests def parse_ratelimit(headers): def _get(name): val = headers.get(name) if val is None: return -1 try: return int(float(val)) except ValueError: return -1 return { "limit_requests": _get("x-ratelimit-limit-requests"), "remaining_requests": _get("x-ratelimit-remaining-requests"), "limit_tokens": _get("x-ratelimit-limit-tokens"), "remaining_tokens": _get("x-ratelimit-remaining-tokens"), "reset_seconds": _get("ratelimit-reset"), "retry_after": _get("retry-after"), } def calc_wait(rl, attempt, max_delay=60.0): if rl.get("retry_after", 0) > 0: return rl["retry_after"] + random.uniform(0, 1) if rl.get("remaining_requests", 0) == 0 or rl.get("remaining_tokens", 0) == 0: if rl.get("reset_seconds", 0) > 0: return rl["reset_seconds"] + random.uniform(0, 1) return 60 + random.uniform(0, 2) return min(0.5 * (2 ** attempt), max_delay) def call_grok(prompt, api_key, max_attempts=4): url = "https://api.example.com/v1/grok-4.7/chat" headers = {"Authorization": f"Bearer {api_key}"} payload = {"prompt": prompt} wait_time = 0 for attempt in range(max_attempts): if wait_time > 0: time.sleep(wait_time) resp = requests.post(url, headers=headers, json=payload) rl = parse_ratelimit(resp.headers) if resp.status_code == 200: return resp.json(), rl if resp.status_code == 429: wait_time = calc_wait(rl, attempt) print( f"[429] attempt={attempt} wait={wait_time:.1f}s " f"remaining_req={rl.get('remaining_requests')} " f"remaining_tok={rl.get('remaining_tokens')}" ) continue if resp.status_code >= 500: wait_time = min(1 * (2 ** attempt), 30) continue resp.raise_for_status() raise RuntimeError("grok-4.7 调用重试次数耗尽")

调用方式:

data, rl = call_grok("帮我写一个快速排序", api_key="YOUR_API_KEY") print(data)

max_attempts我一般设为 3 到 4 次。超过这个次数还失败,说明服务端可能处于长时间过载,继续重试只会浪费请求量。

4.2 请求前预估 Token 数,提前削峰

TPM 限流对长上下文请求尤其敏感,所以有必要在发请求前先估算 token 消耗,而不是等撞桶了再退避。估算不可能精确,但用于排队和拆批已经足够。我常用的粗略估算规则是:中文一个汉字大约 1.5 到 2 个 token,英文一个单词大约 1.3 个 token,代码一个字符大约 0.25 个 token。系统提示词按固定长度静态计算,每次请求前把上下文长度加起来就行。

def estimate_tokens(text, lang="zh"): if lang == "zh": return int(len(text) * 1.8) return int(len(text) * 1.3)

假设限制是每分钟 60000 token,每个请求预估消耗 8000 token,那这一分钟最多发 7 个请求。批量任务按这个预算拆批,再配合信号量限制并发,4259 的概率会低很多。估算值不要直接当成精确数去卡上限,最好留出 20% 到 30% 的余量,因为实际生成结果的 token 数在请求前很难预测。

4.3 日志与监控:让下一次 429 提前暴露

限流问题是突发的,但一定有前兆。每次请求结束后,我都会把解析出来的remaining_requests和remaining_tokens写进结构化日志,用 JSON 格式输出:

call completed status=200 remaining_requests=12 remaining_tokens=3400 reset_seconds=23

这样在日志平台里搜索remaining_tokens为 0 或者接近 0 的请求,就能快速定位是哪条链路把桶打爆了。还可以做一个简单的监控指标:统计每分钟内 429 响应占总请求数的比例,超过 5% 就告警。另一个比较实用的指标是remaining_tokens / limit_tokens低于 20% 时触发预警,提示应该降速或者扩容。

5. 实战中的坑与排查记录

5.1 明明没打满 RPM,却被 TPM 扣住了

有一次跑提取任务,我设置并发 20,每条请求只有两个短问题,RPM 远没到上限,但 429 依然不断。看日志才发现remaining_tokens在每次请求后都在快速下降,几分钟就归零。原因是一个请求携带了多轮对话历史,光上下文就有近万 token,20 个并发瞬间就是 20 万 token,TPM 桶被直接击穿。

那次的教训是:批量任务里,并发数不能按请求条数算,要按总 token 吞吐算。后来我把并发限制改成同时最多 3 个请求,又根据预估 token 做了排队,429 基本消失。如果你在并发场景下遇到 429,先看remaining_tokens是不是主要瓶颈,是的话优先降低并发,而不是单纯加长退避时间。

5.2 多个实例都各自退避,为什么还是 429

单进程的全局节流只在进程内有效。如果服务部署了 3 个实例,每个实例本地允许 5 个并发,加起来就是 15 个并发,很容易超过网关 RPM。而且每个实例只能看到自己的响应头,无法知道另外两个实例已经消耗了多少配额。

这种场景需要把限流状态放到共享存储里,比如 Redis。每次调用前从 Redis 里取一个原子递减的计数,如果计数已经是 0,就说明整个服务集群的本地配额已经耗尽,需要等待。窗口重置时间要以网关返回的reset_seconds为准,而不是本地时钟。没有 Redis 的话,更简单的方案是所有模型请求统一走一个代理服务,由代理做单点限流,业务侧只和代理交互。

5.3 Retry-After 缺失或为 0?用 RPS 剩余量自算等待

有段时间我调试的网关在返回 429 时根本不提供Retry-After,只给remaining字段为 0,ratelimit-reset也没有。这种情况下,唯一能依赖的就是自己掌握的窗口长度。如果响应头里有limit-requests且窗口默认是 60 秒,可以按当前已消耗的比例估算剩余时间,但严格来说并不精确。

最稳妥的兜底逻辑是直接等待一个完整窗口,也就是 60 秒后重试。虽然会浪费一点时间,但不会继续撞桶。代码里我留了这样一个分支:

def wait_when_no_header(rl, default_window=60.0): if rl.get("reset_seconds", 0) > 0: return rl["reset_seconds"] + random.uniform(0.5, 1.5) return default_window + random.uniform(0, 2)

如果连limit-requests都没有,那就只能根据经验设置一个保守的默认窗口,比如 60 秒,再加随机抖动。

5.4 降级与熔断:不能无限重试

重试必须设置上限。429 如果持续出现,说明服务端可能正处于异常状态,无限重试只会把服务端的压力放大。我通常设定最多重试 3 次,超过后转入降级流程:要么把请求放进异步队列,等限流恢复后再慢慢消费;要么直接换一个限制更宽松的模型版本;要么在短时间内对相同前缀的请求做结果缓存,减少重复调用。

熔断逻辑也很重要。连续 5 次请求都返回 429 时,可以打开一个本地开关,让后续请求直接进入快速失败流程,不再发到网关。过了一段时间再放少量探测请求,如果成功就把开关关掉,恢复流量。

最后说一个个人习惯:遇到任何 AI 接口的 429,我第一件事永远是打印响应头,第二件事才是看控制台。响应头里那两个 remaining 字段,比任何文档都诚实。这次在 grok-4.7 上总结出来的双桶限流识别和退避策略,后来换到其他几家接口上也完全通用,只要把字段名对应上就行。限流这事,提前做预案永远比重试后补救省心得多。

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

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

立即咨询