429RetryLLM GatewayAgent ReliabilityNbility

一次 429 如何拖垮整条 Agent 链路:重试、退避与故障切换复盘

用固定种子的离线模拟拆解 429 重试风暴,比较立即重试、指数退避和带抖动总预算策略,并给出网关与 Agent 的安全状态机。

一次 429 如何拖垮整条 Agent 链路:重试、退避与故障切换复盘

429 看起来像一个请求的问题,实际可能是一个乘法器。

一个 Agent 任务先调用模型规划,再调用工具,再让模型整理结果。如果每一层都在收到 429 后“马上再试一次”,一次用户请求就可能变成数十次上游请求。更多请求又让限流窗口恢复得更慢,新的 Agent 步骤继续叠加,最后变成重试风暴。

本篇不复述一句“请使用指数退避”就结束,而是做三件事:

  1. 复盘一条合成的多步骤 Agent 故障时间线;
  2. 用 Python 标准库运行三种策略的离线压力模拟;
  3. 把 429 分类、退避、渠道切换和预算状态写成可审计的状态机。

所有请求次数、恢复时间和结果均来自固定参数的 synthetic Fixture。没有发送线上模型请求,也不代表任何供应商的实际限额、可用性或性能。

先看故障是怎样放大的

假设一个请求需要完成:

用户请求
  → 规划模型
  → 搜索工具
  → 结果整理模型
  → 最终回复

如果每个阶段都允许最多 3 次重试,最坏情况不是 3 次,而是:

3 × 3 × 3 = 27 次模型或工具调用

这还没有计算:

  • 客户端 SDK 的重试;
  • LLM Gateway 的重试;
  • 任务队列的重新投递;
  • 用户点击“再试一次”;
  • 多个 Agent 分支并行运行。

合成事故时间线

这是一条用于说明机制的事件时间线,不是线上事故记录:

时间事件错误动作直接后果
00:00120 个 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 要不要处理”,而是谁负责重试、最多重试多少次、在哪个时间预算内、是否会与其他层重复重试

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

它降低了瞬间请求峰值,但所有客户端仍在 250ms750ms1,750ms 同时醒来。同步退避只是把一个尖峰分成几个整齐的尖峰。

策略 C:指数退避 + 抖动 + 总预算

base = 250 × 2^(attempt - 1)
delay = min(base + random(0, 250), 1500)

如果下一次尝试超过总预算,就放弃这次重试。

结果:

  • 总请求数:480
  • 完成数:94
  • 完成率:78.3%
  • 限流窗口内峰值:120
  • 最后一次尝试:2,438ms

立即重试、同步退避和带抖动退避的合成结果对比

这个结果不能证明某个固定 delay 在生产中“最好”。它只说明三件可复现的事情:

  1. 立即重试会把所有请求锁在同一时间点;
  2. 没有 jitter 的指数退避会保留客户端同步性;
  3. 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 主循环:

  1. 使用 common.RetryTimes 控制最多尝试次数;
  2. 每轮通过 group、model、request path 和用量状态选择渠道;
  3. 按优先级选择渠道层级,同层按 weight 分配;
  4. 失败后由 shouldRetryshouldRetryTaskRelay 判断是否继续;
  5. 可根据配置的 HTTP 状态码范围重试;
  6. 504524 和坏响应体等特殊错误可被明确排除;
  7. 记录 use_channel 和错误信息,并可按错误策略自动禁用渠道;
  8. 对异步任务轮询中的 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 事故真正暴露的通常不是“上游太小”,而是系统没有定义重试责任和失败边界。

可靠的处理顺序是:

  1. 识别 429 的具体语义;
  2. 在入口限制并发,避免新请求继续挤入;
  3. 选择一个重试拥有者;
  4. 尊重 Retry-After,使用带 jitter 的退避;
  5. 检查总预算和幂等性;
  6. 优先切换同模型、同能力、同合规域的健康渠道;
  7. 只有在明确允许时才跨模型降级;
  8. 流已开始或无安全候选时,明确失败而不是透明重放。

Nbility 当前已经提供统一入口、渠道优先级、权重、状态码重试规则、渠道记录和计费边界。更完整的重试预算、jitter、熔断和 Agent 级 trace 需要在网关或调用方继续实现与验证。用户可以在 Nbility 使用统一模型入口,遇到渠道策略问题可通过工单中心申请小额测试额度。

官方与第一方资料

相关文章

用 Nbility 跑通你的 Agent 工作流

获取 API Key,统一接入 OpenAI 兼容模型和开发工具。

管理 API Key