一次 429 如何拖垮整条 Agent 链路:重试、退避与故障切换复盘
用固定种子的离线模拟拆解 429 重试风暴,比较立即重试、指数退避和带抖动总预算策略,并给出网关与 Agent 的安全状态机。

429 看起来像一个请求的问题,实际可能是一个乘法器。
一个 Agent 任务先调用模型规划,再调用工具,再让模型整理结果。如果每一层都在收到 429 后“马上再试一次”,一次用户请求就可能变成数十次上游请求。更多请求又让限流窗口恢复得更慢,新的 Agent 步骤继续叠加,最后变成重试风暴。
本篇不复述一句“请使用指数退避”就结束,而是做三件事:
- 复盘一条合成的多步骤 Agent 故障时间线;
- 用 Python 标准库运行三种策略的离线压力模拟;
- 把 429 分类、退避、渠道切换和预算状态写成可审计的状态机。
所有请求次数、恢复时间和结果均来自固定参数的 synthetic Fixture。没有发送线上模型请求,也不代表任何供应商的实际限额、可用性或性能。
先看故障是怎样放大的
假设一个请求需要完成:
用户请求
→ 规划模型
→ 搜索工具
→ 结果整理模型
→ 最终回复
如果每个阶段都允许最多 3 次重试,最坏情况不是 3 次,而是:
3 × 3 × 3 = 27 次模型或工具调用
这还没有计算:
- 客户端 SDK 的重试;
- LLM Gateway 的重试;
- 任务队列的重新投递;
- 用户点击“再试一次”;
- 多个 Agent 分支并行运行。
合成事故时间线
这是一条用于说明机制的事件时间线,不是线上事故记录:
| 时间 | 事件 | 错误动作 | 直接后果 |
|---|---|---|---|
00:00 | 120 个 Agent 子请求同时进入 | 每个请求都打到同一渠道 | 上游开始返回 429 |
00:00–00:10 | 第一轮 429 | 客户端立即重试 | 同一时间窗口请求数翻倍 |
00:10–00:500 | 第二、三轮 429 | 网关与 SDK 各自重试 | 限流桶持续被打满 |
00:500–00:1750 | 同步指数退避 | 所有客户端使用相同 delay | 下一轮仍然形成尖峰 |
00:2000 | 上游恢复 | 少量带抖动请求成功 | 部分 Agent 完成 |
00:2500 | 总预算到期 | 其余请求停止继续重试 | 系统恢复可预测性 |

关键问题不是“429 要不要处理”,而是谁负责重试、最多重试多少次、在哪个时间预算内、是否会与其他层重复重试。
429 不是一种错误
OpenAI 当前错误文档把 429 至少分成两种常见语义:
- 发送请求过快,触发 request rate limit;
- 额度、信用或月度支出上限耗尽。
前者可能适合等待后重试,后者通常不是等待几百毫秒就能解决的问题。Anthropic 的官方限流文档还区分请求数、输入 Token、输出 Token等容量维度,并通过响应头提供当前限制、剩余量和重置时间。Google 的 429 文档也区分 Pay-as-you-go 容量不足与 Provisioned Throughput 行为。
因此不要写成:
if status == 429:
retry()
至少先分类:
| 结果 | 是否默认重试 | 原因 |
|---|---|---|
| 瞬时 RPM/TPM/并发不足 | 有限等待后可重试 | 可能随窗口恢复 |
| 明确的 Retry-After | 按服务端建议等待 | 不应覆盖服务端节奏 |
| 账户额度或预算耗尽 | 不重试 | 等待不会增加额度 |
| 请求体超限、上下文超限 | 不重试原请求 | 需要改输入或模型 |
| 无效参数、认证失败、权限拒绝 | 不重试 | 重试不会修复配置 |
| 安全拒绝或不支持的能力 | 不跨模型盲目重试 | 可能扩大风险或改变语义 |
429 的 status code 只是信号,真正的决策还需要错误类型、响应头、账户预算、请求幂等性和剩余总时间。
三种策略的离线模拟
模拟参数固定如下:
- 120 个客户端在
t=0同时发起; - 上游在
2,000ms后恢复; - 每个客户端最多 4 次尝试;
- 100ms 为一个碰撞桶;
- 总重试预算为
2,500ms; - 使用随机种子
107,保证结果可复现。
模拟只把 t >= 2,000ms 的请求视为成功。它不代表真实供应商算法,只用于比较三种调度形状。
策略 A:立即重试
delay = 0
结果:
- 总请求数:
480 - 完成数:
0 - 完成率:
0% - 限流窗口内峰值:
480 - 最后一次尝试:
0ms
120 个首发请求和每个请求的三次重试全部挤在同一个时间点。这不是恢复,是把 429 转换成更大的 429。
策略 B:指数退避,但没有抖动
delay = 250 × 2^(attempt - 1)
结果:
- 总请求数:
480 - 完成数:
0 - 完成率:
0% - 限流窗口内峰值:
120 - 最后一次尝试:
1,750ms
它降低了瞬间请求峰值,但所有客户端仍在 250ms、750ms 和 1,750ms 同时醒来。同步退避只是把一个尖峰分成几个整齐的尖峰。
策略 C:指数退避 + 抖动 + 总预算
base = 250 × 2^(attempt - 1)
delay = min(base + random(0, 250), 1500)
如果下一次尝试超过总预算,就放弃这次重试。
结果:
- 总请求数:
480 - 完成数:
94 - 完成率:
78.3% - 限流窗口内峰值:
120 - 最后一次尝试:
2,438ms

这个结果不能证明某个固定 delay 在生产中“最好”。它只说明三件可复现的事情:
- 立即重试会把所有请求锁在同一时间点;
- 没有 jitter 的指数退避会保留客户端同步性;
- jitter 和总时间预算结合后,部分请求可以等到恢复窗口,而不会无限扩大尾延迟。
峰值碰撞仍是 120,因为 120 个首发请求本来就在同一时刻发出。真实系统还需要入口限流、排队、令牌桶或并发上限,不能指望重试策略修复一个已经过载的入口。
模拟脚本核心
def next_delay(attempt, rng):
base = 250 * (2 ** (attempt - 1))
return min(base + rng.randrange(0, 251), 1500)
next_at = current_at + next_delay(attempt, rng)
if next_at <= total_budget_ms:
schedule(next_at)
脚本只用 Python 标准库,运行时不访问网络。实际结果来自本地执行,而不是根据公式猜测。
正确状态机:先分类,再等待,再切换
一个可靠的网关或 Agent 客户端至少需要这些状态:
START
→ SEND
→ SUCCESS
→ CLASSIFY_ERROR
├─ invalid/auth/permission/safety → FAIL
├─ quota/budget exhausted → FAIL + alert
├─ transient 429/5xx/timeout → WAIT
└─ stream already started → STOP transparent replay
→ WAIT_WITH_JITTER
→ CHECK_DEADLINE_AND_BUDGET
├─ expired → FAIL
└─ available → RETRY_SAME_CAPABILITY
→ if deployment unhealthy → FALLBACK_COMPATIBLE_CHANNEL
→ if no safe candidate → FAIL

为什么先尝试同模型、同能力渠道
如果主渠道暂时 429,最安全的替代通常是:
- 同一个模型;
- 相同的工具调用能力;
- 相同的上下文和输出格式;
- 相同的数据区域和保留政策;
- 另一个健康的部署或 API Key。
只有在备用模型通过同样硬约束、且业务接受语义降级时,才考虑跨模型 fallback。不能把“便宜模型可用”当作“语义兼容”。
LiteLLM 的官方文档把 num_retries 后的模型组 fallback 与同一模型部署负载均衡分开,这个边界很重要:重试次数、cooldown 和跨模型回退应分别记录。
流式请求有一条额外红线
在第一个可见 Token 已经发给客户端后,网关不能简单地从头重放整个请求:
- 客户端可能已经展示了部分文本;
- 工具参数可能只发送了一半;
- 再次执行工具可能产生副作用;
- 两次响应会造成重复内容和重复计费。
因此可采用:
- 首 Token 前失败:在 deadline 内有限重试或切换同能力渠道;
- 首 Token 后连接断开:记录 partial stream,按产品协议返回可恢复状态;
- 工具已执行:必须使用幂等键、事务或人工确认,禁止透明重放副作用操作。
Nbility 当前代码实际做了什么
根据同步后的 /opt/data/nbility 代码,当前 relay 主循环:
- 使用
common.RetryTimes控制最多尝试次数; - 每轮通过 group、model、request path 和用量状态选择渠道;
- 按优先级选择渠道层级,同层按 weight 分配;
- 失败后由
shouldRetry和shouldRetryTaskRelay判断是否继续; - 可根据配置的 HTTP 状态码范围重试;
504、524和坏响应体等特殊错误可被明确排除;- 记录
use_channel和错误信息,并可按错误策略自动禁用渠道; - 对异步任务轮询中的 429 保持任务等待,而不是立即把任务标记失败。
当前默认的 HTTP 重试范围并不是“只有 429”:代码默认包含多个 1xx、3xx、部分 4xx 和 5xx,同时排除 504、524 等状态。生产上应该结合上游语义和业务幂等性调整,而不是看到“有 retry”就假设存在完整指数退避和 jitter。
**重要边界:**当前主 relay 循环展示的是重试次数、渠道选择、状态码过滤和错误记录;本文模拟的指数退避、jitter、总时间预算和入口并发控制不应被写成 Nbility 已经具备的能力。
典型的“看起来合理”错误
错误一:每一层都重试
客户端重试一次,SDK 重试两次,网关再重试三次,Agent 步骤再重试三次。调用次数不是 1 + 2 + 3 + 3,而可能是乘法组合。
解决方式:
- 明确唯一的重试拥有者;
- 其他层只传播错误和剩余 deadline;
- 在 trace 中记录 retry attempt、owner 和 parent request ID。
错误二:把所有 429 当作暂时拥塞
额度耗尽、账户欠费、模型不支持或请求超出预算,都不应该在网关里盲目睡眠后再次发送。
错误三:跨模型 fallback 不检查能力
视觉请求不能静默切换到纯文本模型。要求严格 JSON Schema 的工具调用,也不能只因为 HTTP 200 就认为备用模型兼容。
错误四:没有总预算
“最多 3 次重试”不等于有时限。三个长退避可能让用户等待几十秒,而 Agent 上游还在占用并发。每次重试前都检查:
remaining_deadline > estimated_backoff + minimum_attempt_time
错误五:重试已开始的流
这会造成重复文本、重复工具调用和账单难以对账。流式请求必须记录是否已发送首 Token。
一份可执行的上线清单
- 只指定一个重试拥有者
- 区分 RPM、TPM、并发、额度和预算错误
- 优先读取并尊重
Retry-After或供应商响应头 - 使用指数退避和 jitter,避免客户端同步唤醒
- 设置最大尝试次数和总时间预算
- 在入口处限制并发,避免把过载推迟到上游
- 只对可恢复错误重试
- 认证、权限、无效参数和安全拒绝不重试
- 首 Token 后禁止透明重放整个流
- fallback 前验证模型能力、区域、工具和 Schema
- 为工具副作用设置幂等键或事务保护
- 记录 retry owner、attempt、deadline、渠道和最终状态
- 用 cooldown 或熔断避免持续命中坏渠道
- 用固定 Fixture 做回放,再用影子流量和小比例灰度验证
结论
429 事故真正暴露的通常不是“上游太小”,而是系统没有定义重试责任和失败边界。
可靠的处理顺序是:
- 识别 429 的具体语义;
- 在入口限制并发,避免新请求继续挤入;
- 选择一个重试拥有者;
- 尊重
Retry-After,使用带 jitter 的退避; - 检查总预算和幂等性;
- 优先切换同模型、同能力、同合规域的健康渠道;
- 只有在明确允许时才跨模型降级;
- 流已开始或无安全候选时,明确失败而不是透明重放。
Nbility 当前已经提供统一入口、渠道优先级、权重、状态码重试规则、渠道记录和计费边界。更完整的重试预算、jitter、熔断和 Agent 级 trace 需要在网关或调用方继续实现与验证。用户可以在 Nbility 使用统一模型入口,遇到渠道策略问题可通过工单中心申请小额测试额度。


