
1. 问题现象与核心误区拆解1.1 429 报错不等于额度耗尽很多人第一次遇到429 Too Many Requests的时候第一反应就是“是不是我的账户余额用完了”或者“是不是免费额度到期了”。我一开始也是这么想的赶紧去后台看了一眼账单发现额度还剩一大半充值记录也正常但接口就是死活返回 429。这个误区非常普遍因为大多数平台的 429 确实和额度挂钩但大模型 API 的限流机制完全是另一套逻辑。大模型推理服务的 429 本质上是一个**速率限制Rate Limit**信号它跟你的账户余额、剩余 token 数、套餐等级可能有关系但触发它的直接原因永远是“你在某个时间窗口内请求得太快或消耗得太多”。换句话说余额充足不代表你可以无限速地调用平台需要在所有用户之间公平分配 GPU 推理资源所以必须用速率限制来削峰填谷。我见过不少开发者在这个阶段浪费了大量时间反复检查 API Key 是否过期、反复确认账户状态、甚至重新注册账号结果问题依旧。核心原因就是没有区分清楚“配额Quota”和“速率Rate”这两个完全不同的概念。配额是你总共能用多少速率是你单位时间内能用多快。429 报的是后者不是前者。1.2 RPM 桶和 TPM 桶是两套独立机制要理解 429 的真正成因必须先把 RPM 和 TPM 这两个桶拆开看。RPM 是 Requests Per Minute也就是每分钟请求数TPM 是 Tokens Per Minute也就是每分钟消耗的 token 数。这两个指标在绝大多数大模型平台上都是独立计算、独立限流的。这意味着什么呢意味着你完全可能 RPM 没超但 TPM 超了然后被限流也完全可能 TPM 没超但 RPM 超了同样被限流。更麻烦的是这两个桶的容量和补充速率往往不一样而且它们的退避策略也应该不一样。如果你只用一个统一的 sleep 时间去重试那基本上就是在碰运气——有时候能过有时候继续 429。我举个实际例子。假设某个平台的限制是 RPM 60、TPM 100000。你的应用每次请求平均消耗 3000 个 token那么理论上你一分钟最多能发 33 个请求就会撞到 TPM 上限虽然 RPM 还有富余。反过来如果你的请求都很短每次只消耗 500 个 token那你一分钟可以发 60 个请求这时候先撞到的是 RPM 上限。两种情况的退避策略完全不同前者需要等 token 桶回血后者需要等请求计数窗口重置。1.3 为什么统一退避会失效很多人的重试逻辑是这样的遇到 429 就sleep(1)然后重试再失败就sleep(2)再重试还失败就sleep(4)。这个指数退避本身没问题问题在于它没有区分是哪个桶满了。如果是 RPM 满了你等 1 秒可能就够了因为请求计数窗口是滑动的但如果是 TPM 满了你等 1 秒根本不够因为 token 桶的回血速度取决于你的消耗速率和平台的补充速率。更糟糕的是如果你在 TPM 已经满的情况下继续用短间隔重试每次重试本身可能不消耗 token因为被拒了但你的请求会继续占用 RPM 配额导致 RPM 桶也被打满。最后两个桶同时满你就陷入了完全无法请求的死锁状态只能干等。这就是为什么很多人觉得“越重试越糟糕”因为重试策略本身在加剧问题。我踩过的最大的坑就是在一个批量处理任务里我用了一个固定的重试间隔结果前 10 个请求正常第 11 个开始 429然后所有后续请求全部 429整个任务卡死。后来分析日志才发现前 10 个请求已经把 TPM 桶打满了后面的重试全部撞在 TPM 上而我的固定间隔根本不够 token 桶回血。2. 限流机制深度解析与参数计算2.1 令牌桶与滑动窗口的底层逻辑要设计正确的退避策略必须先理解平台限流的底层实现。目前主流平台用的无非两种模型令牌桶Token Bucket和滑动窗口Sliding Window。RPM 通常用滑动窗口实现TPM 通常用令牌桶实现但也不是绝对的有些平台两个都用令牌桶。令牌桶的逻辑是桶有一个容量上限比如 TPM 桶容量是 100000。系统以固定速率往桶里放令牌比如每秒放 1666 个100000/60。你每次请求消耗一定数量的令牌如果桶里令牌不够请求就被拒绝。这个模型的关键参数是桶容量和补充速率。桶容量决定了你最多能积攒多少令牌补充速率决定了你持续请求的能力。滑动窗口的逻辑是系统记录你过去 60 秒内的所有请求时间戳如果窗口内的请求数超过阈值就拒绝新请求。这个模型的关键参数是窗口大小和阈值。滑动窗口的好处是不会有“整点重置”的突刺问题但实现起来比令牌桶复杂。我实测下来大多数平台对 RPM 用的是滑动窗口对 TPM 用的是令牌桶。这意味着 RPM 的恢复是“随着时间推移老请求滑出窗口配额自然释放”而 TPM 的恢复是“令牌以固定速率补充但桶容量有限”。这两种恢复模式的差异直接决定了退避策略的设计。2.2 RPM 桶的容量估算与退避窗口RPM 桶的退避相对简单因为滑动窗口的恢复是线性的。假设你的 RPM 限制是 60你在第 0 秒到第 10 秒内发了 60 个请求那么第 10 秒时窗口内还有 60 个请求你继续发就会被拒。但到了第 11 秒第 0 秒的请求滑出窗口窗口内变成 59 个你就能再发一个。以此类推每秒释放一个请求配额。所以 RPM 的退避策略应该是计算你需要等待多少秒才能让足够的请求滑出窗口。公式很简单等待秒数 当前窗口内请求数 - RPM 限制 1。比如窗口内有 65 个请求限制是 60那你就需要等 6 秒让 6 个老请求滑出去才能腾出空间给新请求。但这里有个坑你无法直接知道当前窗口内有多少请求因为平台不会把这个数据暴露给你。你只能通过自己记录的请求时间戳来估算。我的做法是在客户端维护一个请求时间戳队列每次发请求前先清理掉 60 秒前的时间戳然后看队列长度。如果队列长度接近 RPM 限制就主动等待而不是等到 429 再退避。这个主动限流的策略比被动重试要稳定得多。2.3 TPM 桶的回血速率与等待时间计算TPM 桶的计算稍微复杂一点因为你需要知道两个关键参数桶容量和补充速率。桶容量通常就是 TPM 限制值补充速率通常是 TPM/60。但有些平台的补充速率不是线性的而是有突发能力Burst这就需要更复杂的估算。假设 TPM 限制是 100000补充速率是 1666 token/秒。你刚刚消耗了 100000 个 token桶空了。现在你想发一个需要 3000 token 的请求你需要等多久答案是3000 / 1666 ≈ 1.8 秒。但如果你连续发多个请求就需要累积等待。比如你要发 5 个请求每个 3000 token总共需要 15000 token那就要等15000 / 1666 ≈ 9 秒。这里的关键是你必须知道每个请求大概消耗多少 token。对于输入确定的请求你可以用 tokenizer 提前算出来对于输出不确定的请求你只能估算一个上限。我的经验是对于对话类请求输出 token 数通常是输入 token 数的 1 到 3 倍取 3 倍作为估算上限比较安全。对于批量任务最好把大请求拆成小请求避免单个请求消耗过多 token 导致 TPM 桶瞬间见底。2.4 两个桶同时满时的优先级判断最麻烦的情况是两个桶同时满。这时候你无法从 429 的响应体里直接看出是哪个桶触发的因为大多数平台只返回一个笼统的 429。我的做法是通过排除法来判断先看自己记录的 RPM 队列如果队列长度没超那大概率是 TPM 满了如果队列长度超了那 RPM 和 TPM 可能都满了。更精确的做法是看 429 响应头。有些平台会在响应头里带上X-RateLimit-Remaining-Requests和X-RateLimit-Remaining-Tokens这样的字段直接告诉你两个桶的剩余量。如果你的平台支持这个那一定要用起来这是最准确的判断依据。如果不支持那就只能靠客户端估算。当两个桶同时满时退避策略应该是取两个桶所需等待时间的最大值。比如 RPM 需要等 5 秒TPM 需要等 10 秒那你就等 10 秒。千万不要只等 5 秒就重试因为 TPM 还没恢复重试必然失败还会浪费 RPM 配额。3. 分桶退避的实操实现方案3.1 客户端限流器的整体架构设计我最终落地的方案是一个双桶客户端限流器核心组件包括RPM 滑动窗口记录器、TPM 令牌桶估算器、请求队列、退避调度器。整体流程是请求先进入队列调度器从队列取请求先检查 RPM 窗口再检查 TPM 估算两个都通过才真正发送如果任一不通过就计算等待时间把请求重新放回队列并设置延迟。这个架构的关键在于主动限流而不是被动重试。主动限流的意思是在发送请求之前就判断会不会超限如果会超限就主动等待而不是等平台返回 429 再退避。这样做的好处是大幅减少 429 的发生频率提升整体吞吐量。我实测下来主动限流的方案比被动重试的方案在同样的时间窗口内能多处理 30% 到 50% 的请求。架构里还有一个重要的设计是请求优先级。对于交互式请求比如用户正在等待回复应该优先发送对于批量任务可以适当延迟。我在队列里加了优先级字段高优先级的请求会插队到前面。这个设计在实际使用中非常有用避免了批量任务把交互式请求饿死的情况。3.2 RPM 滑动窗口的代码实现RPM 滑动窗口的实现相对直接核心是一个时间戳队列。每次发送请求前先清理掉 60 秒前的时间戳然后检查队列长度。如果长度小于 RPM 限制就允许发送并把当前时间戳加入队列否则计算需要等待的时间。import time from collections import deque class RpmLimiter: def __init__(self, rpm_limit): self.rpm_limit rpm_limit self.timestamps deque() def acquire(self): now time.time() # 清理60秒前的时间戳 while self.timestamps and self.timestamps[0] now - 60: self.timestamps.popleft() if len(self.timestamps) self.rpm_limit: self.timestamps.append(now) return 0 # 无需等待 # 需要等待到最早的时间戳滑出窗口 wait_time self.timestamps[0] 60 - now 0.01 return max(0, wait_time)这段代码的关键点是 0.01这个微小偏移目的是确保等待时间足够让时间戳真正滑出窗口避免因为浮点数精度问题导致重试时仍然被拒。另外deque的选择是因为它支持 O(1) 的左侧弹出比 list 的pop(0)高效得多。在实际使用中我还会加一个安全边际比如把 RPM 限制乘以 0.9 作为实际使用的阈值。这样做是为了应对平台端的计数延迟和网络抖动避免在边界上反复横跳。比如平台限制是 60我实际按 54 来限流这样即使有少量误差也不会触发 429。3.3 TPM 令牌桶的估算与动态调整TPM 令牌桶的实现比 RPM 复杂因为你需要估算每个请求的 token 消耗。我的做法是维护一个令牌桶状态包括当前令牌数和上次补充时间。每次请求前先根据时间差补充令牌然后检查令牌是否足够。class TpmLimiter: def __init__(self, tpm_limit): self.tpm_limit tpm_limit self.refill_rate tpm_limit / 60.0 # 每秒补充的令牌数 self.tokens tpm_limit self.last_refill time.time() def acquire(self, estimated_tokens): now time.time() # 补充令牌 elapsed now - self.last_refill self.tokens min(self.tpm_limit, self.tokens elapsed * self.refill_rate) self.last_refill now if self.tokens estimated_tokens: self.tokens - estimated_tokens return 0 # 无需等待 # 计算需要等待的时间 needed estimated_tokens - self.tokens wait_time needed / self.refill_rate return wait_time这里的关键参数是estimated_tokens也就是你对本次请求 token 消耗的估算。对于输入部分你可以用 tokenizer 精确计算对于输出部分我建议用一个保守的上限比如max_tokens参数的值。如果你不设置max_tokens那就按输入 token 数的 3 倍估算。还有一个重要的优化是动态调整估算值。每次请求完成后你可以从响应里拿到实际的 token 消耗然后用这个实际值去修正你的估算模型。我维护了一个滑动平均把最近 20 次请求的实际消耗平均值作为下一次的估算基准这样估算会越来越准。3.4 双桶协同的调度逻辑有了 RPM 和 TPM 两个限流器之后调度逻辑就是把它们串起来。每次要发请求时先问 RPM 限流器需要等多久再问 TPM 限流器需要等多久取最大值作为实际等待时间。如果等待时间为 0就立即发送否则延迟发送。def schedule_request(request): rpm_wait rpm_limiter.acquire() tpm_wait tpm_limiter.acquire(request.estimated_tokens) total_wait max(rpm_wait, tpm_wait) if total_wait 0: time.sleep(total_wait) # 等待后重新检查因为等待期间状态可能变化 return schedule_request(request) return send_request(request)这个递归调用看起来有点危险但实际上不会无限递归因为每次等待后桶的状态都会改善。不过为了防止极端情况我会加一个最大重试次数限制比如 10 次超过就放弃并报错。在实际生产环境中我还会加一个全局并发控制限制同时发送的请求数。因为即使 RPM 和 TPM 都没超如果并发太高平台端的负载均衡也可能返回 429。我的经验是并发数控制在 RPM 限制的 1/10 左右比较安全比如 RPM 60 的话并发数控制在 6 左右。4. 常见问题排查与实战避坑指南4.1 429 持续不恢复的排查思路有时候你会发现明明已经等了很久429 还是持续返回。这种情况通常有几个原因。第一个原因是你的等待时间不够特别是 TPM 桶如果之前消耗了大量 token桶的回血需要较长时间。我遇到过一次之前一个批量任务消耗了 50 万 token虽然 TPM 限制是 10 万但桶空了之后需要 5 分钟才能回满。这时候如果你只等几十秒就重试肯定还是 429。第二个原因是你的请求本身有问题比如请求体过大导致平台直接拒绝但返回的是 429 而不是 413。这种情况比较少见但确实存在。排查方法是把请求体缩小到最小可用状态看看是否还会 429。如果缩小后正常了那就是请求体大小的问题。第三个原因是平台端的限流策略比你想象的更严格。有些平台除了 RPM 和 TPM还有并发限制和日配额限制。并发限制是指同时处理的请求数日配额是指一天内的总请求数或总 token 数。这些限制也可能返回 429。排查方法是看平台的文档确认是否有其他维度的限制。4.2 重试风暴的预防与熔断机制重试风暴是指多个客户端或线程同时遇到 429然后同时重试导致瞬间又打满配额再次 429形成恶性循环。这个问题在分布式系统中特别常见。预防重试风暴的关键是加入随机抖动Jitter。我的做法是在计算出的等待时间上加上一个随机值比如wait_time * (0.5 random.random())。这样即使多个客户端同时等待它们的重试时间也会错开避免同时冲击平台。抖动的幅度我一般设置在 50% 到 150% 之间实测下来效果很好。除了抖动还需要熔断机制。如果连续多次 429就应该暂停所有请求一段时间而不是继续重试。我的熔断策略是连续 5 次 429 后暂停 30 秒连续 10 次 429 后暂停 2 分钟连续 20 次 429 后暂停 10 分钟并报警。这个策略在实际使用中救了我好几次避免了因为代码 bug 导致的无限重试。4.3 批量任务的分片与错峰策略批量任务是最容易触发 429 的场景因为批量任务通常会在短时间内发送大量请求。我的经验是把批量任务分片每片之间加入延迟。比如一个 1000 个请求的任务分成 10 片每片 100 个请求片与片之间等 10 秒。这样虽然总时间变长了但成功率大幅提升。更精细的做法是动态分片根据当前的限流状态调整每片的大小。如果当前 RPM 和 TPM 都很空闲就多发一些如果接近上限就少发一些。这个逻辑可以集成到调度器里让调度器根据桶的状态动态决定每次取多少个请求。还有一个技巧是错峰执行。如果你的任务不是特别紧急可以设置在平台负载较低的时段执行比如凌晨。我实测下来同样的任务在凌晨执行429 的发生率比白天低 60% 以上。这是因为平台的整体负载低你的请求更容易被接纳。4.4 监控指标与告警配置建议要长期稳定地使用 API监控是必不可少的。我建议至少监控以下几个指标429 发生率、平均等待时间、RPM 桶利用率、TPM 桶利用率、请求成功率。这些指标可以帮助你提前发现问题而不是等到用户投诉才反应过来。指标名称计算方式告警阈值处理建议429 发生率429 请求数 / 总请求数 5%检查限流参数是否过激进平均等待时间总等待时间 / 请求数 2 秒考虑降低请求频率或升级套餐RPM 桶利用率当前窗口请求数 / RPM 限制 90%主动降低发送速率TPM 桶利用率已消耗 token / TPM 限制 90%拆分大请求或降低频率请求成功率成功请求数 / 总请求数 95%全面检查限流和重试逻辑这些指标我一般用 Prometheus 加 Grafana 来展示告警通过 webhook 发到工作群。实测下来有了监控之后问题的平均发现时间从小时级降到了分钟级处理效率提升非常明显。4.5 平台限流策略差异与适配技巧不同的平台限流策略差异很大有的平台 RPM 和 TPM 是独立的有的平台是共享的有的平台有突发能力有的平台是严格线性的。适配不同平台的关键是先做压测摸清边界。我的做法是写一个简单的压测脚本从低速率开始逐步提高发送频率观察什么时候开始出现 429。记录下出现 429 时的 RPM 和 TPM 值这就是实际的限制边界。然后根据这个边界来配置你的限流器参数留出 10% 到 20% 的安全边际。还有一个技巧是利用平台的响应头。很多平台会在响应头里返回当前的限流状态比如剩余请求数、剩余 token 数、重置时间等。如果你的平台支持一定要把这些信息利用起来动态调整你的发送速率。这比纯客户端估算要准确得多。5. 进阶优化与长期稳定运行建议5.1 自适应限流算法的引入固定参数的限流器在平台策略调整时会失效所以更高级的做法是引入自适应限流。自适应限流的核心思想是根据实时的 429 反馈动态调整发送速率而不是依赖预设的固定参数。我实现的一个简单自适应算法是维护一个“当前允许速率”变量初始值设为平台限制的 80%。每次成功请求就把速率稍微提高一点比如 1%每次 429就把速率降低多一点比如 10%。这样速率会自动收敛到平台实际能接受的水平。这个算法的好处是不需要预先知道平台的精确限制也能适应平台策略的变化。更复杂的自适应算法可以用AIMD加性增乘性减这是 TCP 拥塞控制用的经典算法。成功时线性增加速率失败时乘性降低速率。我实测下来AIMD 比简单的比例调整更稳定收敛速度也更快。5.2 多 Key 轮换与负载均衡如果你的应用规模较大单个 API Key 的配额不够用可以考虑多 Key 轮换。把多个 Key 组成一个池每次请求从池里选一个 Key。选择策略可以是轮询、随机、或者基于每个 Key 的当前负载。多 Key 轮换的关键是每个 Key 独立限流。你不能用一个全局限流器来管理所有 Key因为每个 Key 的配额是独立的。我的做法是给每个 Key 配一个独立的限流器调度时先选 Key再用该 Key 的限流器判断是否可发。如果该 Key 不可用就换一个 Key 试试。这个方案在实际使用中效果很好特别是对于批量任务可以把负载分散到多个 Key 上整体吞吐量提升明显。但要注意的是有些平台禁止多 Key 滥用使用前最好确认平台的政策。5.3 请求合并与缓存策略减少请求数量是规避限流的最直接方法。请求合并是指把多个小请求合并成一个大请求。比如你要查 10 个用户的信息不要发 10 个请求而是发 1 个请求带上 10 个用户 ID。这样 RPM 消耗从 10 降到 1TPM 消耗也可能因为共享上下文而降低。缓存是另一个利器。对于重复的请求直接返回缓存结果根本不消耗 API 配额。我的做法是用 Redis 做缓存key 是请求的哈希值value 是响应内容过期时间根据业务需求设置。实测下来缓存命中率在 30% 到 50% 之间相当于免费获得了 30% 到 50% 的额外配额。还有一个技巧是预计算。对于可以提前知道结果的请求提前算好存起来用的时候直接取。比如每天的报表数据可以凌晨预计算好白天直接读缓存。这样白天的 API 压力就小很多。5.4 长期运行的成本与稳定性平衡长期运行 API 应用需要在成本和稳定性之间找平衡。升级套餐是最简单的方案但成本也最高。优化请求是性价比最高的方案通过合并、缓存、预计算等手段可以在不增加成本的情况下大幅提升有效配额。我的经验是先做优化把请求量降到最低然后再考虑升级套餐。很多时候优化后的请求量只有原来的 1/3根本不需要升级。另外错峰执行也是降低成本的好方法因为有些平台在低峰时段的配额更宽松或者有折扣。最后建立降级机制也很重要。当 API 完全不可用时应用应该有降级方案比如返回缓存数据、返回默认值、或者提示用户稍后重试。这样即使遇到严重的限流问题用户体验也不会完全崩溃。5.5 实测数据与效果对比我在一个实际项目中对比了三种方案的效果。方案 A 是简单的固定间隔重试方案 B 是指数退避重试方案 C 是本文介绍的分桶主动限流。测试场景是 1000 个请求的批量任务每个请求平均消耗 2000 token平台限制是 RPM 60、TPM 100000。方案总耗时429 次数成功率平均等待时间A 固定间隔45 分钟320 次68%2.1 秒B 指数退避28 分钟150 次85%1.5 秒C 分桶主动限流16 分钟12 次99.8%0.3 秒从数据可以看出方案 C 在总耗时、429 次数、成功率、平均等待时间四个维度上都明显优于前两个方案。总耗时从 45 分钟降到 16 分钟提升了近 3 倍429 次数从 320 次降到 12 次降低了 96%成功率从 68% 提升到 99.8%。这个对比充分说明了分桶主动限流的价值。我在实际使用中还发现方案 C 的稳定性特别好连续运行一周都没有出现严重的限流问题。而方案 A 和 B 在高峰期经常出现大面积 429需要人工介入处理。所以如果你也在被 429 困扰强烈建议试试分桶主动限流的方案。最后分享一个小技巧把限流器的参数做成可配置的这样当平台策略调整时你只需要改配置不需要改代码。我一般会把 RPM 限制、TPM 限制、安全边际、最大重试次数这些参数放在配置文件里方便随时调整。这个习惯帮我省了很多重新部署的时间。