1. 为什么你“看起来没超配额”却被打429:限流的真实维度
先说一个我最近被问炸了的场景:有人拿着exceeded retry limit, last status: 429 too many requests的报错来找我,开口第一句永远是“我的配额明明还剩一大半,为什么一直429?”
这个问题的根源,是把“LLM Request Quota”理解成了一个单一的、简单的数字。但真实的限流体系里,配额根本不是一个数,而是一组相互独立的数。你盯着面板上那个“剩余额度”看,看到的只是总池子的水位,而429限制的,往往是另一个完全不同的维度。
1.1 面板上的剩余配额,只是“总池子”,不是“水龙头大小”
几乎所有主流LLM服务商都会给账号设置至少两层限制:
第一层是总配额,也就是你账号在某个计费周期内可用的请求数或token总量,比如每月多少百万token。这一层超了,通常报错不是429而是403或者专门的quota exceeded提示。
第二层是速率配额,包括每分钟请求数(RPM,Requests Per Minute)、每分钟token数(TPM,Tokens Per Minute),有些服务还会限制并发连接数。这一层超了,返回的就是我们熟悉的429 Too Many Requests。
问题就出在这里:很多人把“总配额还剩很多”当成了“不会限流”。实际上,总配额和速率配额是两套独立计量的系统。你可以总配额还剩60%,但因为某个瞬间发请求太猛,把RPM打爆了,照样被429拦下来。这就像你家这个月水费还剩很多,但水龙头管径就那么大,你非要同时开十个水龙头,管网压力不够,物业照样给你关阀。
1.2 RPM/TPM/并发三个维度最容易被同时打爆
具体来说,速率限制通常会拆成三个互相独立的维度:
- RPM:每分钟最多允许多少次HTTP请求。这个维度最容易在脚本循环调用、批量任务、并发工作流里被打爆。
- TPM:每分钟最多消耗多少token。很多人只看请求次数,忽略了token量。一段很长的上下文请求,一次就能吃掉几百甚至几千token,一分钟内只要来几个大请求,TPM就到底了。
- 并发度(Concurrency):同一时刻允许同时在途的请求数。即使你每分钟请求总数不高,但如果你一次性发出50个并行请求,而并发上限是10,后面40个直接吃429。
这三个维度不是共享同一个计数器的。RPM没爆,不代表TPM没爆;TPM没爆,不代表并发没爆。排查的时候如果只盯着一个维度看,很容易得出“我没超限”的错觉。
我见过一个很典型的案例:一个小团队用脚本批量跑文本分类,任务量不大,总配额只用了30%,但脚本用ThreadPoolExecutor开了20个线程并发调用,服务端并发上限只有5。结果就是持续不断地收到429,而他们怎么查配额面板都查不出问题,因为RPM和TPM都没超过,光是并发这一项就够让请求全部被拒。
1.3 一个组织一个池子:共享配额下的“隐形队友”
还有一个特别容易让人误判的,是配额的作用域。
如果你用的是个人API Key,限流只看你这把Key;但如果你在一个组织、团队或企业工作区里,服务商很多时候按整个组织维度来做速率限制。也就是说,你看到面板上“剩余配额”是全组共享的,你自己发的请求不多,不代表你同事的CI任务、测试脚本、数据分析程序没在跑。
我遇到过更夸张的情况:一个团队白天大家各自调试,晚上部署了定时任务,凌晨四点所有任务同时启动,直接撞上组织级RPM上限。白天单个成员怎么测都正常,一到凌晨集体429,最后看组织级监控才发现是共享池在凌晨被瞬间榨干。
所以这里先记住一个关键结论:429和“剩余配额”之间没有必然的线性关系。你做排查的时候,如果只盯着总剩余量,大概率会走弯路。
2. codex报错“exceeded retry limit”拆解:客户端重试链路的真相
当你在codex这类基于LLM的CLI Agent工具里看到exceeded retry limit, last status: 429 too many requests, request id: 021788这类报错时,大部分人只读懂了“429”,然后就开始骂服务商。但这段报错信息其实包含了好几层意思,值得拆开看。
2.1 报错三元组到底在说什么
这段报错本质上是客户端在告诉你三件事:
- exceeded retry limit:客户端自己内部为重试设置了次数上限(比如默认尝试3到5次),现在重试次数已经用完了,决定放弃。
- last status: 429 too many requests:最后一次HTTP请求的服务端返回状态是429。也就是说,服务端确实响应了,只是不愿意处理你的请求。
- request id: 021788:服务端为这次请求生成的唯一标识,用于后续日志追踪。
这三件事组合在一起,说明一个完整的过程:客户端最初发送的请求被服务端以429拒绝,之后客户端按自己的重试策略反复重试,但服务端在这段时间内持续返回429,直到客户端的重试次数耗尽,最终把这个组合错误抛给你。
看到这里你应该已经意识到一个问题:429本身不是工具bug,而是服务端在履行限流职责。但为什么明明重试了好几次还是429?这才是需要深挖的点。
2.2 codex的重试逻辑为什么容易“越撞越狠”
大多数LLM API客户端(包括codex)内部默认的重试策略,通常是固定退避或简单指数退避。比如第一次失败后等1秒,第二次等2秒,第三次等4秒,最多重试3到5次。
这个策略在“服务端短暂过载”的场景下是有效的。但如果限流的原因是持续性的——比如你的请求速率一直高于限额,或者组织级的其他请求已经占满了整个池子——那么你等1秒、2秒、4秒之后继续冲,服务端依然在限流状态,重试只会不断地撞在枪口上。
更麻烦的是,重试本身会消耗配额,而且重试请求的token计数并不少。
以codex这类Agent工具为例,每次工具调用请求都会携带完整的对话上下文。你看起来只是“重新发了一次请求”,但如果上下文已经积累了大量的系统提示、历史工具返回结果,一次请求可能就是几千token。重试3次,等于变相把TPM消耗翻了3倍。这就形成了一个恶性循环:
请求被限流 → 客户端重试 → 重试消耗更多token配额 → TPM更快到达上限 → 后续请求继续被限流。
我见过有人在跑codex批量处理任务时,明明是几百个文件的小任务,结果因为持续重试,把一天的token配额在半小时内烧掉了大半,而且最后什么都没跑完。这才是“exceeded retry limit”最阴险的地方——它不仅告诉你请求失败了,还暗示你可能已经造成了额外的配额损耗。
2.3 Request ID的真正用法:别浪费这个字段
报错信息里的request id是一个非常有价值的排查线索,但绝大多数人直接忽略了它。
这个ID是服务端日志系统的索引。当你联系技术支持、提交工单,或者在服务商后台查看请求日志时,这个ID能直接定位到具体是哪一个请求被限流、该请求在每个限流维度上是多少、当时的全局状态是什么。
另外,如果你自己在上层做了代理网关,建议在网关层把入站请求统一加上一个内部追踪ID,并把上游返回的request id记录到日志里。这样排查问题时,你就能把“客户端的某次重试”和“服务端的某个被拒请求”一一对应起来,而不是面对一堆报错文本瞎猜。
3. 别猜了,用响应头一步步定位是哪一层限流在拦你
前面讲了限流的多个维度,但真到了排查现场,你不能靠“猜”来判断是哪一层出了问题。正确的做法是直接看服务端返回的响应头(HTTP Headers)。
大多数主流LLM服务商在返回429时,会附带一组x-ratelimit-*的响应头,用来告诉你当前的限流状态。这些字段是排查问题的金钥匙。
3.1 第一步:拿到完整的响应头
最简单的方式是直接用curl模拟一次请求,并把响应头打印出来:
curl -i https://api.example.com/v1/chat/completions \ -H "Authorization: Bearer $YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hello"}]}'重点看返回头里带x-ratelimit前缀的字段。以常见的OpenAI风格响应头为例,你会看到类似这样的信息:
HTTP/2 429 x-ratelimit-limit-requests: 500 x-ratelimit-remaining-requests: 0 x-ratelimit-limit-tokens: 60000 x-ratelimit-remaining-tokens: 32000 retry-after: 20这几个字段的含义如下:
x-ratelimit-limit-requests:当前时间窗口内允许的请求总数。x-ratelimit-remaining-requests:当前时间窗口内还剩多少次请求额度。x-ratelimit-limit-tokens:当前时间窗口内允许消耗的token总数。x-ratelimit-remaining-tokens:当前时间窗口内还剩多少token额度。retry-after:服务端建议你等待多少秒后再重试。
如果你用的是其他服务商,字段名可能略有差异(比如有的叫ratelimit-*),但基本思路一致。关键就是看“谁归零了”。
3.2 第二步:根据响应头字段锁定限流类型
拿到响应头之后,判断逻辑很简单,我列一个对照表,直接对着看就行:
| 现象 | 限流类型 | 解决办法方向 |
|---|---|---|
remaining-requests为0或接近0 | RPM或并发限流 | 降低请求频率、加节流器、减少并发 |
remaining-tokens为0或接近0 | TPM限流 | 缩短上下文、减少重试、拆分大请求 |
| 两个值都还有余量但依然429 | 并发/负载保护/共享池 | 查组织级用量、查出口IP维度限制 |
有retry-after字段 | 服务端明确要求等待 | 等待指定秒数,别盲重试 |
这个表格的价值在于,它能立刻帮你缩小排查范围。比如你发现remaining-requests是0,那就别再去翻什么上下文累积、token消耗了,直接检查自己是不是在短时间内发太多请求。反之,如果remaining-requests还剩很多但remaining-tokens是0,那问题大概率出在单次请求token量太大。
3.3 第三步:做最小复现实验确认触发条件
光看一次响应头还不够,我建议你做一个最小复现实验来确认触发条件:
- 单请求验证:只发一个最简单的请求,如果正常返回,说明账号没有被封禁、网络链路没问题。
- 低频连续请求:每隔3秒发一次,连续发10次,看第几次开始返回429。如果稳定在第N次失败,基本能算出当前限流阈值。
- 并发请求验证:写一个10并发的脚本,一次性发出20个请求,观察失败比例。如果单发正常、并发就429,那大概率是并发度限制。
- 观察
retry-after值:如果每次429都带回一个固定的retry-after,比如10秒,那说明限流周期是固定的,你可以在客户端里直接把它当成等待基准。
这套流程走下来,基本能确认是RPM、TPM、并发还是共享池的问题。不要跳过这一步直接改代码,因为不同根因对应的解法完全不同——改错了地方,429只会换一种形式继续烦你。
4. 工程化解决方案:请求整形、退避重试与配额规划
定位到根因之后,就该给解决方案了。这一节我讲四个层次的做法,从轻到重,越往后越适合团队级、长期化的场景。
4.1 给请求加一个“水龙头”:客户端节流器的实现思路
如果你发现问题是“突发请求太多”,最简单的解决办法不是减少总请求量,而是在客户端加一个节流器(Rate Limiter),把突发流量变成均匀流速。
推荐用令牌桶(Token Bucket)思路实现。核心逻辑是:桶里固定放N个令牌,每发出一个请求拿走一个令牌,令牌会按固定速率不断补充。请求来的时候如果桶里没令牌了,就排队等,而不是立刻发出去打爆服务端限额。
下面是一个极简的Python实现参考,基于threading,不需要额外依赖:
import threading import time class RateLimiter: def __init__(self, rate_per_minute): self.interval = 60.0 / rate_per_minute self.lock = threading.Lock() self.next_allowed = time.monotonic() def wait(self): with self.lock: now = time.monotonic() if now < self.next_allowed: sleep_time = self.next_allowed - now self.next_allowed = self.next_allowed + self.interval time.sleep(sleep_time) else: self.next_allowed = now + self.interval用法很简单:
limiter = RateLimiter(rate_per_minute=200) def safe_request(payload): limiter.wait() # 这里再执行实际的API调用 resp = client.chat.completions.create(**payload) return resp带宽按你账号的RPM上限来设置,建议留出10%到20%的余量。比如账号上限是每分钟300次,就把节流器设在每分钟250次左右,防止一些瞬时尖峰超过这个数。
这里有个关键细节:多线程安全。上面用lock保护next_allowed是必须的,否则并发环境下多个线程同时请求,节流器形同虚设。我自己踩过这个坑,没加锁之前并发100个请求,实际发出的速率能超过设定值好几倍。
4.2 重试不是无脑重试:指数退避与抖动
如果600ms内请求还是不可避免地撞上了限流,重试策略就必须配好。很多人用固定间隔重试,结果就是所有客户端整齐划一地同时撞击,造成“重试风暴”。
正确的做法是指数退避加抖动(Exponential Backoff with Jitter)。核心思路是:第一次重试前等base * 1 + random_jitter,第二次等base * 2 + random_jitter,第三次base * 4 + random_jitter,第二次等base * 2 + random_jitter,第三次base * 4 + random_jitter。加上随机抖动是为了让同一时间被限流的多个客户端不要同步重试。
参考实现:
import random import time def retry_with_backoff(func, max_retries=4, base_delay=1.0): for attempt in range(max_retries): try: return func() except RateLimitError as e: if attempt == max_retries - 1: raise # 优先尊重服务端建议的等待时间 retry_after = e.response.headers.get("retry-after") if retry_after: delay = float(retry_after) else: delay = base_delay * (2 ** attempt) + random.uniform(0, base_delay) print(f"请求被限流,等待 {delay:.2f} 秒后重试") time.sleep(delay) return None这里有一个我认为非常重要的原则:如果服务端返回了retry-after,一定要优先尊重它,而不是按自己的退避算法来。因为服务端知道当前限流池的恢复时间,它给的值通常比你自己算出来的更准确。只有当它没给retry-after时,才退回指数退避策略。
还需要给重试设上限。不要无限重试下去,默认3到5次就够了。重试也是一种成本,尤其是携带大上下文的重试。
4.3 配额规划与监控:别等爆了才看面板
工程上真正让人省心的做法,是提前建一套配额监控和告警机制,而不是等429刷屏了再去救火。
具体来说,我建议在代理层把以下指标落进日志和监控:
- 每分钟实际请求数(对照RPM上限)
- 每分钟实际token消耗量(对照TPM上限)
- 429错误率
- 平均响应延迟和P95延迟
- 每次请求的
remaining-requests和remaining-tokens
监控告警阈值可以参考这样一个经验值:
| 指标 | 建议告警阈值 | 说明 |
|---|---|---|
| 请求速率使用率 | 达到上限的75% | 预留缓冲,防止突发尖峰 |
| token速率使用率 | 达到上限的75% | 同上 |
| 429错误率 | 超过5% | 已经影响到正常业务 |
| 重试占比 | 超过10% | 说明节流策略已经失效,需要调参 |
这套监控到位之后,你就能在限流发生之前看到趋势。比如某个定时任务每天早上9点触发,导致请求速率从50%一路飙到90%,这时候你提前把任务拆散或者调低并发,都比收到429告警再处理要舒服得多。
4.4 长期解法:集中网关、多Key拆分与套餐升级
如果你的团队持续和429作斗争,说明单靠客户端节流已经不太够了,需要往更上层走。
比较推荐的方案是做一个集中式API网关,所有调用LLM的请求统一走一个入口。在这个网关上做的事情包括:
- 统一的全局节流器,避免每个客户端各用各的节流器、合起来还是超限。
- 统一的退避策略和重试上限,避免每个客户端各自重试导致的重试风暴。
- 统一的配额计量和日志,谁在什么时候消耗了多少额度,一眼看清楚。
- 多Key自动负载均衡,把请求分散到多把API Key或不同账号上。
第四点尤其适合“总配额充足但单Key速率受限”的场景。比如你的业务需要每分钟2000次请求,但单Key上限只有500 RPM,那4把Key各配一个负载均衡权重,比买更高套餐要便宜得多,而且更灵活。如果用的是OpenAI这类按组织计费的服务,需要确认组织维度限流是否允许通过多Key分散来规避。
最后是套餐升级。不是每个问题都适合用客户端优化来解决。如果业务形态就是高并发、大吞吐,那该升级套餐就升级套餐,该申请更高速率限制就去申请。这本质上是成本换稳定性的取舍,不用回避。
5. 容易被忽略的隐藏配额消耗与同类错误陷阱
最后这一节,我梳理几个就算你做好了上面所有事情,依然可能踩中的“隐性坑”。这些坑我在实际项目里都见过,而且每一个都曾经让人排查到怀疑人生。
5.1 上下文累积:你以为的500token实际是5000token
LLM API的token计量方式和普通HTTP请求完全不一样。普通API,你发多少数据就计多少量;但对话式API的token计量要算上整个对话上下文。
以codex这类Agent为例,一次工具调用请求里,携带的内容包括:
- 系统提示词(通常就几百token)
- 整个对话历史(随轮次增长)
- 用户最新指令
- 工具调用的中间结果(比如读取的文件内容、命令输出)
也就是说,如果你的Agent在处理一个大型代码库任务,持续跑了20多轮,每一轮的上下文可能已经从几百token膨胀到了几万token。这时候哪怕你一分钟内只发10个请求,TPM也可能瞬间爆掉。
一个非常反直觉的现象是:你并没有提高请求频率,429却越来越频繁。因为上下文在增长,每次请求的token消耗在增大,同一频率下TPM消耗速率在持续上升。
排查方法也比较特殊:你不光要统计“请求次数”,还要在日志里记录每次请求的token用量。如果发现单次请求token量持续走高,那就得考虑在客户端层面做上下文裁剪(truncation)、对话压缩,或者及时启动新会话。
5.2 重试风暴:429引发的连锁雪崩
另一个隐藏陷阱是,重试机制本身可能把一个小范围限流放大成整个系统的雪崩。
设想这样一个场景:你有10个并发任务,每个任务在执行过程中都会调用LLM。某个瞬间所有任务同时发出请求,服务端返回429。接着,每个任务的重试逻辑都开始启动,它们各自等1秒、2秒、4秒,然后几乎在同一时间又撞上去。因为重试请求携带的是同样的上下文,token消耗量很大,TPM被快速消耗殆尽,429从“偶尔出现”变成“持续出现”。
这个问题在分布式系统里尤其严重。每个微服务各自实现重试逻辑,没有一个统一的全局视角,重试风暴就不可避免。
解法就是我上一节说的集中式网关。网关要做的不只是限流,还要把所有下游的重试请求排进同一个全局队列。一个请求失败后,由网关决定什么时候重试,而不是由上游服务各自决定。这样即使有10个服务在同时重试,网关也能把它们排成一条有序的队,而不是10条乱撞。
5.3 出口IP与共享网络环境:你看不见的共享限额
这块是我发现最少有人注意到的盲区。部分服务商的限流不仅基于API Key,还会参考你的出口IP和网络环境。
举个例子:如果你所在的公司或团队统一通过一个NAT网关出口访问外部API,那么在这个出口IP下的所有请求——不管用的是谁的API Key、哪个部门的产品——会共享同一个IP维度的连接数或请求频率限制。
这个坑的迷惑性很强。因为你自己测试的时候,API Key剩余配额明明很充足,但一到办公室的办公网环境就开始429。你以为是Key的问题,换了一把新Key还是一样。这时候如果把网络切换到另一个出口——比如用手机热点测一下——发现一切正常,那基本可以确认是出口IP维度的限流。
遇到这种情况,工程上的处理办法通常是:
- 联系服务商确认是否有IP维度的限流,以及限额是多少。
- 如果是团队共用出口,推动IT部门为API调用单独划分一个出口网段。
- 如果业务必须走固定出口IP做白名单验证,就要提前评估出口IP的共享负荷。
一句话总结:API Key只是明面上的维度,网络出口是暗地里的一层。排查时如果Key维度查不出问题,不妨换一个网络环境做个对照实验。
5.4 面板延迟与统计口径:监控数字可以骗人
最后一个坑,是配额面板的统计延迟。
很多服务商的配额面板并不是实时更新的。你在面板上看到的“剩余额度”,可能已经是几分钟前甚至更早的数据。尤其是TPM这类速率指标,面板通常只显示“当前周期累计值”,不会实时展示“这一分钟还剩多少”。
这就导致了一个很尴尬的情况:你在面板上看到剩余额度充足,但实际那一刻的RPM或TPM已经被打满了,只是面板还没刷新出来。等面板刷新出来,发现确实超了,你的线上服务已经报了一堆429了。
所以我的建议是:不要依赖面板做实时判断,要以响应头里的x-ratelimit-remaining-*字段为准。这个字段是服务端实时计算的值,比任何面板都准确。把关键限流头写进日志,做成每分钟聚合图表,这才是真正靠谱的运维依据。
最后再分享一个我自己的习惯:每次新对接一个LLM服务商,我做的第一件事不是写业务代码,而是先用curl把响应头完整拉一遍,看看每一层的限流阈值到底是多少,然后再根据阈值倒推节流器参数、重试参数和告警阈值。这个习惯帮我省下了无数次线上排障的时间。
如果你现在正被429折腾得头疼,先把报错里的request id、响应头里的x-ratelimit-*和retry-after这三样东西扒出来,对照这篇文章逐步排查。大多数“看起来莫名其妙”的429,最后都会被拆解成某一个具体维度的限额问题。说白了,限流机制没有那么多阴谋,它只是在用HTTP状态码提醒你——你的请求节奏,该重新设计一下了。