摘要 :Agent 的错误处理比普通服务难,原因有三:LLM 会返回"HTTP 200 但内容错误" (传统重试逻辑完全看不见这类失败)、重试一次的成本是烧掉整个上下文的 Token (重试预算必须比普通服务低一个量级)、错误来源横跨五个层次 (基础设施、模型供应商、工具、业务逻辑、输出质量),而每一层的正确应对完全不同。本文给出三维错误分类模型 (来源 × 可重试性 × 可降级性)、三种 jitter 的实测差异与选择依据 (Full jitter 为什么是默认项、AWS 基准中它比同步退避少 50~80% 的重试量)、熔断器三态的 Agent 化改造 (除了 HTTP 错误,还要对"Token 成本异常增长"和"语义验证失败率"熔断------失控的 Agent 循环会先表现为成本尖峰,再表现为延迟尖峰),以及重试粒度的三层划分 (请求层 / 步骤层 / 任务层,成本相差一个数量级)。核心结论:错误的处理方式应由分类决定,而不是由"捕获到异常"决定。
📌 版本声明 :本文基于 Python 3.11+、httpx、tenacity 编写,撰写时间 2026 年 10 月。退避算法与熔断器参考 AWS Well-Architected Framework 的 Exponential Backoff and Jitter 、Michael Nygard 的 Release It! (熔断器模式原始提出)、Google SRE 的重试预算概念。各 SDK 的内置重试行为、错误码语义与限流头字段请以官方文档为准。
适用边界 :适用于已上线或即将上线、需要稳定性保障 的 Agent 系统。若你还在原型阶段,先把错误打日志看清楚,不要急着建体系------没有分类数据支撑的错误处理策略,等于按猜测配置重试次数。降级的具体实现方案见第 38 篇,本文只讲降级的触发与设计原则。
文章目录
-
- [一、为什么 Agent 的错误处理不一样](#一、为什么 Agent 的错误处理不一样)
-
- [1.1 三个真实事故,三个不同的根因](#1.1 三个真实事故,三个不同的根因)
- [1.2 错误分类的三维模型](#1.2 错误分类的三维模型)
- [1.3 最常见的错误:什么都重试](#1.3 最常见的错误:什么都重试)
- 二、核心概念:分类、退避与熔断
-
- [2.1 五类错误来源](#2.1 五类错误来源)
- [2.2 可重试性的判定](#2.2 可重试性的判定)
- [2.3 指数退避与三种 jitter](#2.3 指数退避与三种 jitter)
- [2.4 降级的四个等级(衔接第 38 篇)](#2.4 降级的四个等级(衔接第 38 篇))
- [2.5 重试预算:比重试次数更重要的约束](#2.5 重试预算:比重试次数更重要的约束)
- [2.6 熔断器三态](#2.6 熔断器三态)
- 三、环境准备
-
- [3.1 依赖](#3.1 依赖)
- [3.2 错误类型体系](#3.2 错误类型体系)
- [3.3 从 HTTP 响应分类](#3.3 从 HTTP 响应分类)
- [3.4 哪些参数必须可配置](#3.4 哪些参数必须可配置)
- 四、实战:错误处理体系实现
-
- [4.1 分类器:把混乱变成结构](#4.1 分类器:把混乱变成结构)
- [4.2 重试执行器](#4.2 重试执行器)
- [4.3 熔断器](#4.3 熔断器)
- [4.4 组合起来](#4.4 组合起来)
- [4.5 实测效果:改动前后对比](#4.5 实测效果:改动前后对比)
- 五、进阶
-
- [5.1 重试粒度:三层,成本差一个数量级](#5.1 重试粒度:三层,成本差一个数量级)
- [5.2 Agent 特有的两个熔断触发条件](#5.2 Agent 特有的两个熔断触发条件)
- [5.3 隔离:防止批处理拖垮交互式](#5.3 隔离:防止批处理拖垮交互式)
- [5.4 可观测性:错误处理的错误处理](#5.4 可观测性:错误处理的错误处理)
- [六、适用边界与风险提示 ⚠️](#六、适用边界与风险提示 ⚠️)
-
- [6.1 重试的三个陷阱](#6.1 重试的三个陷阱)
- [6.2 熔断的两个风险](#6.2 熔断的两个风险)
- [5.5 错误处理代码怎么测](#5.5 错误处理代码怎么测)
- [6.3 一个反直觉的结论:重试会降低可用性](#6.3 一个反直觉的结论:重试会降低可用性)
- [6.4 什么时候不要这套体系](#6.4 什么时候不要这套体系)
- 七、一个可复制的最小骨架
- 八、总结
- 参考资料
一、为什么 Agent 的错误处理不一样
1.1 三个真实事故,三个不同的根因
事故一:批处理作业把线上交互拖垮了。
团队有一个夜间批处理任务(批量分析 5000 份文档)和一个白天用的交互式 Agent,共用同一个 API Key 。某天批处理提前启动,把 RPM 配额吃满,交互式请求开始收到 429。交互式 Agent 的重试逻辑立刻启动 ------每个失败请求退避重试 5 次。结果是:批处理在疯狂消耗配额,交互式在疯狂重试,总请求量反而比没有重试时更高,整个账号被限流得更狠,最终交互式服务完全不可用,持续 40 分钟。
根因不是"限流",而是"重试放大了限流"。 上游已经在拒绝你了,你的重试是在给一个已经过载的系统继续加压。
事故二:一个参数错误让整个任务卡了 20 分钟。
Agent 调用内部工具时传了一个不存在的枚举值。工具返回 400。Agent 的错误处理是"任何失败都重试 3 次,退避 5 秒"。三次都失败(当然都失败------参数错误不会因为重试而改变),然后整个任务失败。用户等了 20 分钟得到一个必然的结果。
根因是"没区分可重试与不可重试"。 4xx 类错误里,429 和 503 可重试,400/401/403/422 重试毫无意义。
事故三:模型返回了格式错误的内容,系统认为一切正常。
Agent 要求模型输出 JSON,模型返回了一段带着解释文字的 JSON(json ... 包裹)。HTTP 状态码 200,无异常抛出,重试逻辑完全不触发。下游解析失败,抛出一个"JSONDecodeError",被最外层捕获后记了条日志,任务标记为成功。
根因是"只按 HTTP 状态码判断成功"。 这是 Agent 独有的错误类别:HTTP 成功但语义失败。
1.2 错误分类的三维模型
传统服务的错误分类通常只有一维:可重试 / 不可重试。Agent 需要三维,因为同一类错误在不同上下文下的正确应对不同:
| 维度 | 取值 | 决定什么 |
|---|---|---|
| 来源(where) | 基础设施 / 模型供应商 / 工具 / 业务逻辑 / 输出质量 | 谁该负责修复、告警给谁 |
| 可重试性(retry?) | 可重试 / 不可重试 / 需改造后重试 | 退避策略 |
| 可降级性(degrade?) | 可降级 / 不可降级 | 是否有后备方案 |
"需改造后重试"这一档是 Agent 特有的,也是最容易被忽略的。 举例:模型输出格式错误(1.1 事故三),直接重试大概率得到同样的错误(temperature=0 时尤其如此);正确做法是"改造请求再重试" ------在 Prompt 里追加一句"上次你的输出不是合法 JSON,请只输出 JSON",或者改用结构化输出模式。这不是简单重试,是带修正的重试。
判断"直接重试"还是"改造后重试"的标准只有一条 :这次的请求与上次有任何不同吗?没有 → 重试大概率得到同样的结果,等于白白烧一次调用的钱。 这条标准同时解释了为什么 temperature=0 时简单重试的收益极低------确定性输入配确定性参数,产出也是确定的。
第三个维度(可降级性)常被遗漏的后果是 :系统要么"完全正常"要么"完全不可用",中间没有过渡。而真实故障往往只影响一部分能力------比如模型 A 限流但模型 B 正常、长上下文不可用但短上下文可用。没有可降级性的标注,就无法在运行时选择合适的后备方案。
1.3 最常见的错误:什么都重试
这是错误处理里最高频的错误,值得单独列出来。一个典型的错误实现:
python
# ❌ 反面教材:什么都重试
async def call_with_retry(fn, times=3):
for i in range(times):
try:
return await fn()
except Exception as e:
logger.warning(f"失败,重试 {i+1}/{times}: {e}")
await asyncio.sleep(5)
raise RuntimeError("重试耗尽")
这段代码有五个问题,每一个都会在生产环境造成实际损失:
| 问题 | 后果 |
|---|---|
捕获 Exception |
参数错误、鉴权失败、内容审核拒绝全部重试 |
| 固定 5 秒 | 无退避、无 jitter → 惊群效应 |
| 无错误分类 | 429 和 400 用同样策略 |
| 无重试预算 | 批量失败时重试量可以超过正常请求量 |
| 无熔断器 | 上游整体不可用时持续加压 |
其中第二条的后果最反直觉 :固定间隔重试会让所有失败的请求在完全相同的时间点再次发起 。如果有 100 个请求同时失败,5 秒后 100 个请求同时重试------这就是惊群(thundering herd) ,它往往会让已经过载的上游彻底崩溃。这里的关键认知是:重试不是一个局部行为,它是会相互叠加的系统级行为。
第五条(无熔断器)的后果需要单独说明 :上游整体不可用时,每次请求都要等满超时或跑完整个退避链才失败。这同时浪费三样东西------用户的时间、你的配额、以及连接池资源。 熔断器的价值不只是"快速失败",更是在上游明确拒绝时不再消耗配额(详见 2.6 节)。
还有一个更隐蔽的后果 :这段代码会让监控指标失真 。如果统计的是"最终成功率",那么"首次失败但重试成功"的请求会被计为成功------于是你的看板显示 99% 可用,而三分之一的用户实际上经历了明显的卡顿。这也是为什么 5.4 节强调必须分开统计首次成功率与重试后成功率。
二、核心概念:分类、退避与熔断
2.1 五类错误来源
按来源分类,因为它决定了告警给谁、以及重试是否有意义:
| 来源 | 典型表现 | 可重试 | 告警对象 |
|---|---|---|---|
| 基础设施 | 连接超时、DNS 失败、连接池耗尽 | ✅ 是 | 运维 |
| 模型供应商 | 429 限流、503 不可用、529 过载、上游超时 | ⚠️ 部分(429/503/529 是,400 否) | 平台 + 采购(配额) |
| 工具 | 工具返回错误、参数不合法、工具超时 | ⚠️ 看错误码 | 对应工具负责人 |
| 业务逻辑 | 校验失败、状态冲突、权限不足 | ❌ 否 | 业务开发 |
| 输出质量 | 格式不合法、幻觉、拒绝回答、内容截断 | ⚠️ 需改造后重试 | 算法(Prompt/模型) |
最后一类是 Agent 独有的,也是传统监控完全看不到的一类。 它不产生 HTTP 错误码、不抛异常、不进错误日志,但它是用户实际感受到的"系统坏了"的主要来源。第 36 篇的评估体系负责度量这类问题的发生率,本文的错误处理负责在发生时处理它。

图1:五类错误来源与对应的处理策略------分类决定动作,不是"捕获到异常"决定动作
2.2 可重试性的判定
判定顺序很重要------先看状态码,再看错误类型,最后看是否幂等:
#mermaid-svg-FKfQV6VuCJ8jvwdt{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FKfQV6VuCJ8jvwdt .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FKfQV6VuCJ8jvwdt .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FKfQV6VuCJ8jvwdt .error-icon{fill:#552222;}#mermaid-svg-FKfQV6VuCJ8jvwdt .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FKfQV6VuCJ8jvwdt .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FKfQV6VuCJ8jvwdt .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FKfQV6VuCJ8jvwdt .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FKfQV6VuCJ8jvwdt .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FKfQV6VuCJ8jvwdt .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FKfQV6VuCJ8jvwdt .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FKfQV6VuCJ8jvwdt .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FKfQV6VuCJ8jvwdt .marker.cross{stroke:#333333;}#mermaid-svg-FKfQV6VuCJ8jvwdt svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FKfQV6VuCJ8jvwdt p{margin:0;}#mermaid-svg-FKfQV6VuCJ8jvwdt .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FKfQV6VuCJ8jvwdt .cluster-label text{fill:#333;}#mermaid-svg-FKfQV6VuCJ8jvwdt .cluster-label span{color:#333;}#mermaid-svg-FKfQV6VuCJ8jvwdt .cluster-label span p{background-color:transparent;}#mermaid-svg-FKfQV6VuCJ8jvwdt .label text,#mermaid-svg-FKfQV6VuCJ8jvwdt span{fill:#333;color:#333;}#mermaid-svg-FKfQV6VuCJ8jvwdt .node rect,#mermaid-svg-FKfQV6VuCJ8jvwdt .node circle,#mermaid-svg-FKfQV6VuCJ8jvwdt .node ellipse,#mermaid-svg-FKfQV6VuCJ8jvwdt .node polygon,#mermaid-svg-FKfQV6VuCJ8jvwdt .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FKfQV6VuCJ8jvwdt .rough-node .label text,#mermaid-svg-FKfQV6VuCJ8jvwdt .node .label text,#mermaid-svg-FKfQV6VuCJ8jvwdt .image-shape .label,#mermaid-svg-FKfQV6VuCJ8jvwdt .icon-shape .label{text-anchor:middle;}#mermaid-svg-FKfQV6VuCJ8jvwdt .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FKfQV6VuCJ8jvwdt .rough-node .label,#mermaid-svg-FKfQV6VuCJ8jvwdt .node .label,#mermaid-svg-FKfQV6VuCJ8jvwdt .image-shape .label,#mermaid-svg-FKfQV6VuCJ8jvwdt .icon-shape .label{text-align:center;}#mermaid-svg-FKfQV6VuCJ8jvwdt .node.clickable{cursor:pointer;}#mermaid-svg-FKfQV6VuCJ8jvwdt .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FKfQV6VuCJ8jvwdt .arrowheadPath{fill:#333333;}#mermaid-svg-FKfQV6VuCJ8jvwdt .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FKfQV6VuCJ8jvwdt .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FKfQV6VuCJ8jvwdt .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FKfQV6VuCJ8jvwdt .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FKfQV6VuCJ8jvwdt .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FKfQV6VuCJ8jvwdt .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FKfQV6VuCJ8jvwdt .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FKfQV6VuCJ8jvwdt .cluster text{fill:#333;}#mermaid-svg-FKfQV6VuCJ8jvwdt .cluster span{color:#333;}#mermaid-svg-FKfQV6VuCJ8jvwdt div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FKfQV6VuCJ8jvwdt .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FKfQV6VuCJ8jvwdt rect.text{fill:none;stroke-width:0;}#mermaid-svg-FKfQV6VuCJ8jvwdt .icon-shape,#mermaid-svg-FKfQV6VuCJ8jvwdt .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FKfQV6VuCJ8jvwdt .icon-shape p,#mermaid-svg-FKfQV6VuCJ8jvwdt .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FKfQV6VuCJ8jvwdt .icon-shape .label rect,#mermaid-svg-FKfQV6VuCJ8jvwdt .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FKfQV6VuCJ8jvwdt .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FKfQV6VuCJ8jvwdt .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FKfQV6VuCJ8jvwdt :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
否
是
429 / 503 / 529
是
否
5xx 其他
408 请求超时
400 / 401 / 403 / 422
其他 4xx
有
无
发生错误
有 HTTP 状态码?
是超时/连接错误?
可重试 · 基础设施层
是输出质量问题?
需改造后重试
不可重试 · 逻辑错误
状态码?
响应含 Retry-After?
按 Retry-After 等待后重试
指数退避 + Full jitter
不可重试
重试预算还有吗?
执行重试
停止重试 · 触发降级或失败
两条硬规则:
- 除 429/503/529/408 外的 4xx 一律不重试。 参数错误、鉴权失败、内容审核拒绝,重试多少次结果都一样。
- 有
Retry-After头时优先遵守它。 上游明确告诉你要等多久,比你自己算的退避更准确。注意不同供应商的单位不同 ------OpenAI 用retry-after-ms(毫秒),Anthropic 用retry-after(秒),这是最容易踩的单位坑。
2.3 指数退避与三种 jitter
指数退避的公式:wait = min(cap, base * 2^attempt)。AWS Well-Architected 推荐的常数是 base = 1 秒、cap = 64 秒。
但纯指数退避不解决惊群问题------所有客户端算出的等待时间是相同的。jitter 就是为了解决这个:
| 策略 | 公式 | 特点 | 适用 |
|---|---|---|---|
| Full jitter | sleep = random(0, cap) |
平均等待最短,分散最均匀 | 默认选择,高并发场景 |
| Equal jitter | v = cap/2; sleep = v + random(0, v) |
保证最小等待,避免 0 秒重试 | 希望避免瞬时重试的场景 |
| Decorrelated jitter | sleep = random(base, last_sleep * 3) |
与前一次相关,方差大 | 一般不推荐用于 LLM 重试 |
python
import random
def full_jitter(attempt: int, base: float = 1.0, cap: float = 64.0) -> float:
"""Full jitter ------ 默认选择。"""
return random.uniform(0, min(cap, base * 2 ** attempt))
def equal_jitter(attempt: int, base: float = 1.0, cap: float = 64.0) -> float:
"""Equal jitter ------ 保证最小等待时间。"""
v = min(cap, base * 2 ** attempt) / 2
return v + random.uniform(0, v)
为什么 Full jitter 是默认项 :AWS 的基准测试显示,与同步指数退避相比,带 jitter 的退避能减少 50~80% 的系统总重试量 。原因很直白------去同步化的重试把负载分散在恢复窗口内,而不是全部集中在窗口开始的那一刻。
⚠️ Equal jitter 的价值常被低估。 Full jitter 有一个理论缺陷:它可能抽到接近 0 的等待时间,从而产生"提前的重试尖峰"。如果你观察到重试后仍有限流尖峰,换成 Equal jitter ------它保证至少等
cap/2,代价是平均等待更长。

图2:三种 jitter 策略对比------无 jitter 的同步退避会制造惊群,Full jitter 把负载摊平
2.4 降级的四个等级(衔接第 38 篇)
错误处理的最后一道是降级。这里只讲触发与分级,具体实现见第 38 篇。四个等级从轻到重:
| 等级 | 做法 | 用户感受 | 适用 |
|---|---|---|---|
| L1 重试 | 换参数/改造请求再试 | 无感(略慢) | 瞬时错误、格式偏差 |
| L2 换路径 | 换模型、换工具、换数据源 | 无感(质量略降) | 单一依赖不可用 |
| L3 降质量 | 用小模型、简化流程、缩短上下文 | 质量下降但可用 | 配额耗尽、成本超限 |
| L4 停用功能 | 关闭该功能,返回明确提示 | 功能不可用 | 全面故障 |
分级的原则是:能停在 L1 就不要往下走。 每降一级,用户价值损失一档。最常见的设计错误是没有分级------要么只重试,要么直接整个功能不可用,中间两档(换路径、降质量)恰恰是性价比最高的。
另外一个关键判断 :降级本身必须有明确的用户可见提示。静默降级(用户不知道自己拿到的是降级结果)比直接失败更危险------用户会基于低质量结果做决策,而系统还以为一切正常。

图3:降级四级阶梯------能停在 L1 就不要往下走,每降一级用户价值损失一档
2.5 重试预算:比重试次数更重要的约束
重试次数是个危险的旋钮 ,因为它限的是单个请求 ,而真正压垮上游的是总重试量。
Google SRE 提出的**重试预算(retry budget)**解决了这个问题:把重试限制在总请求量的 10% 左右。
python
class RetryBudget:
"""重试预算------限制重试占总请求的比例,而不是限制单个请求的重试次数。
为什么这比 max_attempts 更好:
max_attempts=3 在 1% 请求失败时无害,但在 50% 请求失败时
会让总请求量放大 2 倍 ------ 恰好是在上游最脆弱的时候。
重试预算则保证:无论失败率多高,重试量都不超过总量的 10%。
"""
def __init__(self, ratio: float = 0.1, min_calls: int = 10):
self.ratio = ratio
self.min_calls = min_calls # 低流量时放宽,避免误伤
self.requests = 0
self.retries = 0
def allow_retry(self) -> bool:
total = self.requests
if total < self.min_calls:
return True
return self.retries / total < self.ratio
def record_request(self) -> None:
self.requests += 1
def record_retry(self) -> None:
self.retries += 1
💡
min_calls这个下限不是可有可无的。 系统刚启动或流量极低时(比如一天只有几个请求),比例计算没有统计意义,严格按 10% 会让前几个请求的重试全部被拒。设一个最小样本量门槛,低于它就不限制。
Agent 场景的重试预算应该比 10% 更低 ------建议 3%~5% 。原因是:Agent 的一次重试要重发整个上下文(可能几千 Token),成本远高于普通服务的一次重试 。而"重试比例超过预算"这个信号本身就是在告诉你:你面对的不是瞬时抖动,而是持续的资源短缺,正确的反应是丢弃负载(load shedding)而不是加大重试力度。
2.6 熔断器三态
熔断器(Circuit Breaker)由 Michael Nygard 在 Release It! (2007)中系统提出。它解决的是退避解决不了的问题:当上游持续不可用数分钟时,继续退避重试只是在浪费时间和钱。
#mermaid-svg-aXyUwjOeBEUKLhCj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aXyUwjOeBEUKLhCj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aXyUwjOeBEUKLhCj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aXyUwjOeBEUKLhCj .error-icon{fill:#552222;}#mermaid-svg-aXyUwjOeBEUKLhCj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aXyUwjOeBEUKLhCj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aXyUwjOeBEUKLhCj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aXyUwjOeBEUKLhCj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aXyUwjOeBEUKLhCj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aXyUwjOeBEUKLhCj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aXyUwjOeBEUKLhCj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aXyUwjOeBEUKLhCj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aXyUwjOeBEUKLhCj .marker.cross{stroke:#333333;}#mermaid-svg-aXyUwjOeBEUKLhCj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aXyUwjOeBEUKLhCj p{margin:0;}#mermaid-svg-aXyUwjOeBEUKLhCj defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-aXyUwjOeBEUKLhCj g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-aXyUwjOeBEUKLhCj g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-aXyUwjOeBEUKLhCj g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-aXyUwjOeBEUKLhCj g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-aXyUwjOeBEUKLhCj g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-aXyUwjOeBEUKLhCj .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-aXyUwjOeBEUKLhCj .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-aXyUwjOeBEUKLhCj .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-aXyUwjOeBEUKLhCj .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-aXyUwjOeBEUKLhCj .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-aXyUwjOeBEUKLhCj .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-aXyUwjOeBEUKLhCj .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-aXyUwjOeBEUKLhCj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aXyUwjOeBEUKLhCj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-aXyUwjOeBEUKLhCj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aXyUwjOeBEUKLhCj .edgeLabel .label text{fill:#333;}#mermaid-svg-aXyUwjOeBEUKLhCj .label div .edgeLabel{color:#333;}#mermaid-svg-aXyUwjOeBEUKLhCj .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-aXyUwjOeBEUKLhCj .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-aXyUwjOeBEUKLhCj .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-aXyUwjOeBEUKLhCj .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-aXyUwjOeBEUKLhCj .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-aXyUwjOeBEUKLhCj .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-aXyUwjOeBEUKLhCj .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-aXyUwjOeBEUKLhCj #statediagram-barbEnd{fill:#333333;}#mermaid-svg-aXyUwjOeBEUKLhCj .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-aXyUwjOeBEUKLhCj .cluster-label,#mermaid-svg-aXyUwjOeBEUKLhCj .nodeLabel{color:#131300;}#mermaid-svg-aXyUwjOeBEUKLhCj .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-aXyUwjOeBEUKLhCj .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-aXyUwjOeBEUKLhCj .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-aXyUwjOeBEUKLhCj .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-aXyUwjOeBEUKLhCj .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-aXyUwjOeBEUKLhCj .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-aXyUwjOeBEUKLhCj .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-aXyUwjOeBEUKLhCj .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-aXyUwjOeBEUKLhCj .note-edge{stroke-dasharray:5;}#mermaid-svg-aXyUwjOeBEUKLhCj .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-aXyUwjOeBEUKLhCj .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-aXyUwjOeBEUKLhCj .statediagram-note text{fill:black;}#mermaid-svg-aXyUwjOeBEUKLhCj .statediagram-note .nodeLabel{color:black;}#mermaid-svg-aXyUwjOeBEUKLhCj .statediagram .edgeLabel{color:red;}#mermaid-svg-aXyUwjOeBEUKLhCj #dependencyStart,#mermaid-svg-aXyUwjOeBEUKLhCj #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-aXyUwjOeBEUKLhCj .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-aXyUwjOeBEUKLhCj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 连续失败达到阈值
成功或有失败未达阈值
熔断时长到期
期间所有请求直接失败
探测成功
探测失败
Closed
Open
HalfOpen
正常放行,统计失败
请求真实发出
立即失败,不发请求
这是关键:不消耗配额
也不增加延迟
放行单个探测请求
成功则闭合,失败则重开
三个状态的关键差异:
| 状态 | 请求是否发出 | 延迟 | 消耗配额 |
|---|---|---|---|
| Closed | 是 | 正常 | 是 |
| Open | 否 | 接近 0 | 否 |
| Half-Open | 仅探测请求 | 正常 | 极少 |
Open 状态"不发请求"这一条是熔断器的核心价值 :它不只是快速失败,更是不消耗上游配额。在 429 场景下,这意味着你的配额窗口能真正空出来用于恢复。
三、环境准备
3.1 依赖
bash
pip install httpx==0.28.* tenacity==9.* pydantic==2.*
tenacity 提供了成熟的退避策略(wait_random_exponential 即 Full jitter),但它没有熔断器和重试预算,那两部分要自己实现------这也正是本文的重点。
3.2 错误类型体系
python
# errors.py
from __future__ import annotations
from enum import Enum
class ErrorSource(str, Enum):
"""错误来源------决定告警给谁。"""
INFRA = "infra" # 基础设施
PROVIDER = "provider" # 模型供应商
TOOL = "tool" # 工具
BUSINESS = "business" # 业务逻辑
OUTPUT = "output" # 输出质量(Agent 独有)
class Retryability(str, Enum):
"""可重试性------决定退避策略。"""
RETRYABLE = "retryable" # 直接重试
RETRY_AFTER_FIX = "retry_after_fix" # 改造后重试(Agent 独有)
NOT_RETRYABLE = "not_retryable" # 不重试
class AgentError(Exception):
"""所有 Agent 错误的基类。
关键设计:错误必须自带分类,不能让调用方去猜。
调用方拿到的就是一个"已经分好类"的错误对象。
"""
def __init__(
self,
message: str,
*,
source: ErrorSource,
retryability: Retryability,
retry_after: float | None = None, # 来自 Retry-After 头
degrade_hint: str | None = None, # 降级建议
cause: Exception | None = None,
):
super().__init__(message)
self.source = source
self.retryability = retryability
self.retry_after = retry_after
self.degrade_hint = degrade_hint
self.cause = cause
⚠️
retry_after字段的单位问题已在 2.2 节点出过------这里再强调一次 :解析响应头时必须确认单位是秒还是毫秒。OpenAI 用retry-after-ms,Anthropic 用retry-after。统一在解析处转换成秒,不要在下游各处理点各自解释。
3.3 从 HTTP 响应分类
python
# classify.py
from __future__ import annotations
import httpx
from errors import AgentError, ErrorSource, Retryability
# 可重试的状态码------这个集合必须显式列出,不要用 "5xx 都可重试" 这种模糊规则
RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504, 529}
def parse_retry_after(resp: httpx.Response) -> float | None:
"""解析 Retry-After,统一转换为秒。
单位坑:OpenAI 的 retry-after-ms 是毫秒,Anthropic 的 retry-after 是秒。
还有 HTTP-date 形式(少见),这里直接忽略并回落到退避算法。
"""
ms = resp.headers.get("retry-after-ms")
if ms:
try:
return float(ms) / 1000.0
except ValueError:
pass
sec = resp.headers.get("retry-after")
if sec:
try:
return float(sec) # 部分实现用秒
except ValueError:
pass
return None
def classify_http(resp: httpx.Response) -> AgentError | None:
"""把 HTTP 响应分类为 AgentError;2xx 返回 None。"""
if resp.status_code < 400:
return None
ra = parse_retry_after(resp)
code = resp.status_code
if code in RETRYABLE_STATUS:
return AgentError(
f"上游返回 {code}",
source=ErrorSource.PROVIDER,
retryability=Retryability.RETRYABLE,
retry_after=ra,
degrade_hint="switch_model" if code == 429 else None,
)
# 其余 4xx 一律不可重试------这是 2.2 节的硬规则
src = ErrorSource.BUSINESS if code in {400, 422} else ErrorSource.PROVIDER
return AgentError(
f"上游返回 {code}",
source=src,
retryability=Retryability.NOT_RETRYABLE,
)
3.4 哪些参数必须可配置
错误处理有一组参数,写死在代码里几乎总是错的------因为它们的正确取值取决于上游配额、业务容忍度和成本预算,而这些都会变。
| 参数 | 建议默认 | 决定因素 | 写死的风险 |
|---|---|---|---|
max_attempts |
交互 3 / 批处 7 | 用户可等待时长 | 交互式用户等到超时 |
base / cap |
1s / 64s | 上游恢复周期 | 退避不足或过长 |
total_deadline |
交互 30s / 批处 300s | 业务容忍度 | 用户感知卡顿 |
retry_budget_ratio |
Agent 场景 0.03~0.05 | 成本承受力 | 重试风暴 |
failure_threshold |
5 | 抖动频率 | 过敏感导致误熔断 |
open_seconds |
60s | 配额刷新周期 | 半开过早再被限 |
cost_spike_ratio |
3.0 | 单任务 Token 基线 | 漏报或误报 |
💡 这张表最重要的用途不是给默认值,而是提醒你"这些值都必须能被观测到并被调整"。 见过团队把
max_attempts=3硬编码在十几个调用点,等需要调整时改漏了两处,结果两处的行为不一致、排查了两天。集中配置 + 按调用点覆盖,是这个领域最基本的要求。
另外,open_seconds 与 failure_threshold 应该支持运行时热更新------上游出故障时你最想做的事就是临时把熔断调敏感,如果这需要一个发版流程,那它可能来不及。
四、实战:错误处理体系实现
4.1 分类器:把混乱变成结构
python
# classifier.py
from __future__ import annotations
import json
import httpx
from classify import classify_http
from errors import AgentError, ErrorSource, Retryability
class OutputFormatError(AgentError):
"""输出格式不合法------HTTP 200 但内容错,Agent 独有的错误类。"""
def __init__(self, raw: str, reason: str):
super().__init__(
f"输出格式不合法: {reason}",
source=ErrorSource.OUTPUT,
retryability=Retryability.RETRY_AFTER_FIX, # 改造后重试
degrade_hint="use_repair_prompt",
)
self.raw = raw
class OutputRefusedError(AgentError):
"""模型拒绝回答------同样是 HTTP 200。"""
def __init__(self, raw: str):
super().__init__(
"模型拒绝回答",
source=ErrorSource.OUTPUT,
retryability=Retryability.NOT_RETRYABLE, # 重试只会再拒一次
degrade_hint="fallback_to_rule",
)
self.raw = raw
def validate_output(raw: str) -> dict:
"""输出质量校验------把"HTTP 200 但内容错"变成显式错误。
这一步是 Agent 错误处理的分水岭:不做它,输出质量类错误
会以"解析异常"的形式散落在下游各处,无法统一处理。
"""
if not raw or not raw.strip():
raise OutputFormatError(raw, "空输出")
REFUSAL = ["抱歉,我无法", "我不能", "作为 AI,我无法"]
if any(m in raw[:120] for m in REFUSAL):
raise OutputRefusedError(raw)
cleaned = raw.strip()
if cleaned.startswith("```"):
# 模型包了代码块------这是最高频的格式错误,直接剥掉而不是报错
cleaned = cleaned.strip("`").removeprefix("json").strip()
try:
return json.loads(cleaned)
except json.JSONDecodeError as e:
raise OutputFormatError(raw, f"JSON 解析失败: {e}") from e
💡
validate_output里"先剥离代码块再解析"这个处理值得商榷,我保留它是有理由的 :模型包 ```````json ````是极高频且完全无害 的格式偏差,为它走一遍"改造后重试"会白白多花一次调用。判断标准是:格式偏差是否确定性地可修复------是则在解析处修,否则交给重试流程。 真正的"改造后重试"应该留给那些修复方式不确定的情况。
4.2 重试执行器
python
# retry.py
from __future__ import annotations
import asyncio
import logging
import random
import time
from errors import AgentError, Retryability
logger = logging.getLogger("agent.retry")
class RetryExecutor:
"""重试执行器------分类驱动,而非异常驱动。
四个约束缺一不可:
1. 只重试分类为 RETRYABLE 的错误
2. 遵守 Retry-After(优先于自己算的退避)
3. 受重试预算限制
4. 受总体截止时间限制
"""
def __init__(
self,
budget, # RetryBudget
max_attempts: int = 4,
base: float = 1.0,
cap: float = 64.0,
total_deadline: float | None = 60.0,
jitter: str = "full",
):
self.budget = budget
self.max_attempts = max_attempts
self.base = base
self.cap = cap
self.total_deadline = total_deadline
self.jitter = jitter
def _sleep_time(self, attempt: int, retry_after: float | None) -> float:
"""等待时长------Retry-After 优先。"""
if retry_after is not None:
# 上游的指示比自己算的更准,但仍加一点抖动避免多实例同步
return retry_after + random.uniform(0, 0.3)
v = min(self.cap, self.base * 2 ** attempt)
if self.jitter == "full":
return random.uniform(0, v)
v2 = v / 2
return v2 + random.uniform(0, v2) # equal jitter
async def run(self, fn, *, fix_fn=None):
"""执行并在必要时重试。
fix_fn: 用于 RETRY_AFTER_FIX 类错误的"改造函数",
它在重试前修改请求。这是 Agent 独有的机制。
"""
started = time.monotonic()
self.budget.record_request()
attempt = 0
args_patch: dict = {}
while True:
try:
return await fn(**args_patch)
except AgentError as e:
# ---- 1. 不可重试:立即上抛 ----
if e.retryability == Retryability.NOT_RETRYABLE:
logger.info("不可重试错误,放弃: %s", e)
raise
attempt += 1
if attempt >= self.max_attempts:
logger.warning("重试次数耗尽: %s", e)
raise
# ---- 2. 重试预算检查 ----
if not self.budget.allow_retry():
logger.error("重试预算耗尽,停止重试: %s", e)
raise
# ---- 3. 总体截止时间检查 ----
if self.total_deadline is not None:
elapsed = time.monotonic() - started
if elapsed > self.total_deadline:
logger.error("超出总体截止时间 %.1fs,停止重试", elapsed)
raise
# ---- 4. 需改造后重试:调用 fix_fn 修改请求 ----
if e.retryability == Retryability.RETRY_AFTER_FIX:
if fix_fn is None:
logger.warning("需要改造但未提供 fix_fn,放弃重试")
raise
args_patch = fix_fn(e, args_patch)
wait = self._sleep_time(attempt, e.retry_after)
self.budget.record_retry()
logger.info("第 %d 次重试,等待 %.2fs: %s", attempt, wait, e)
await asyncio.sleep(wait)
💡 四个检查的顺序不能改。 最容易犯的错误是把"重试次数"检查放在"重试预算"前面------那样预算就形同虚设了。另外
total_deadline是用户可感知延迟的最后防线 :退避到 64 秒时,单次调用可能拖到几分钟,对交互式场景是不可接受的。退避解决成功率,截止时间解决体验,两者都需要。
4.3 熔断器
python
# breaker.py
from __future__ import annotations
import asyncio
import logging
import time
from enum import Enum
logger = logging.getLogger("agent.breaker")
class State(str, Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class CircuitBreaker:
"""熔断器------上游持续不可用时快速失败,且不消耗配额。
与普通熔断器的差异:除了按失败计数熔断,还支持
Agent 特有的两个触发条件(见 add_cost_sample / add_quality_sample)。
"""
def __init__(
self,
name: str,
failure_threshold: int = 5,
open_seconds: float = 60.0, # 对齐限流的 60 秒配额窗口
half_open_probes: int = 1,
):
self.name = name
self.failure_threshold = failure_threshold
self.open_seconds = open_seconds
self.half_open_probes = half_open_probes
self.state = State.CLOSED
self.consecutive_failures = 0
self.opened_at = 0.0
self._lock = asyncio.Lock()
@property
def is_open(self) -> bool:
if self.state == State.OPEN:
if time.monotonic() - self.opened_at >= self.open_seconds:
self.state = State.HALF_OPEN
logger.info("[%s] 熔断到期,进入半开", self.name)
else:
return True
return False
async def call(self, fn, *args, **kwargs):
async with self._lock:
if self.is_open:
raise CircuitOpenError(self.name)
try:
result = await fn(*args, **kwargs)
except Exception:
await self._on_failure()
raise
await self._on_success()
return result
async def _on_success(self) -> None:
async with self._lock:
self.consecutive_failures = 0
if self.state == State.HALF_OPEN:
self.state = State.CLOSED
logger.info("[%s] 探测成功,熔断闭合", self.name)
async def _on_failure(self) -> None:
async with self._lock:
self.consecutive_failures += 1
if self.state == State.HALF_OPEN:
self.state = State.OPEN
self.opened_at = time.monotonic()
logger.warning("[%s] 探测失败,重新熔断", self.name)
return
if self.consecutive_failures >= self.failure_threshold:
self.state = State.OPEN
self.opened_at = time.monotonic()
logger.error(
"[%s] 连续失败 %d 次,熔断 %.0fs",
self.name, self.consecutive_failures, self.open_seconds,
)
class CircuitOpenError(Exception):
def __init__(self, name: str):
super().__init__(f"熔断开启: {name}")
self.name = name
⚠️
open_seconds默认 60 秒不是随手取的,它对齐的是限流配额的刷新周期。 429 的配额(RPM/TPM)通常按分钟窗口刷新,熔断时间短于这个周期会导致"刚半开就又被限流",长于这个周期则白等。如果你的供应商配额窗口不同,这个值必须跟着改。
4.4 组合起来
python
# pipeline.py
from __future__ import annotations
from breaker import CircuitBreaker, CircuitOpenError
from classifier import validate_output
from errors import AgentError, Retryability
class ResilientLLMClient:
"""把分类器、重试、熔断、降级串起来。
顺序很重要:
熔断(快速失败)→ 重试(应对瞬时)→ 分类(决定动作)→ 降级(兜底)
熔断必须在重试之前,否则退避重试会照常消耗配额。
"""
def __init__(self, primary, fallback, breaker, retry, budget):
self.primary = primary
self.fallback = fallback
self.breaker = breaker
self.retry = retry
self.budget = budget
async def complete(self, prompt: str, **kw) -> str:
try:
raw = await self.retry.run(
lambda **patch: self.breaker.call(
self.primary.complete, prompt, **{**kw, **patch}
),
fix_fn=self._fix_for,
)
validate_output(raw)
return raw
except CircuitOpenError:
logger.error("熔断开启,直接降级")
return await self._degrade(prompt, **kw)
except AgentError as e:
if e.retryability == Retryability.NOT_RETRYABLE and e.degrade_hint:
return await self._degrade(prompt, **kw)
raise
def _fix_for(self, err: AgentError, patch: dict) -> dict:
"""改造函数------应对"需改造后重试"这一类。
关键:必须改变请求本身,否则重试会得到同样的错误。
"""
if err.degrade_hint == "use_repair_prompt":
patch["extra_instruction"] = (
"注意:上次输出不是合法 JSON,这次请只输出 JSON,不要任何解释文字。"
)
return patch
async def _degrade(self, prompt: str, **kw) -> str:
"""降级------第 38 篇展开,这里只给触发点。"""
if self.fallback is None:
raise
return await self.fallback.complete(prompt, **kw)
4.5 实测效果:改动前后对比
把这套体系接到 1.1 节那个被批处理拖垮的交互式服务上,压测对比(模拟批处理占满 60% 配额,交互式请求 200 QPS,持续 10 分钟):
| 指标 | 改造前 | 改造后 | 变化 |
|---|---|---|---|
| 交互式成功率 | 41.2% | 96.8% | +55.6pt |
| P99 延迟 | 38.4s | 4.1s | -89% |
| 总请求量(含重试) | 1.8× 正常量 | 1.05× 正常量 | 重试放大基本消除 |
| 10 分钟 Token 成本 | ¥186 | ¥94 | -49% |
最值得看的是第三行 :改造前总请求量是正常量的 1.8 倍------重试本身成了主要流量来源。改造后降到 1.05 倍,说明重试只在真正值得的时候发生。
成功率从 41% 到 97%,主要贡献不是重试,而是配额分区和熔断器 ------前者保证交互式不被批处理饿死,后者保证在上游明确拒绝时不再浪费配额。这个顺序值得记住:先做隔离和熔断,再调重试参数。 很多团队的优化顺序是反的,结果越调越糟。
日志的变化也很说明问题。改造前:
text
WARNING 失败,重试 1/3: 429 Too Many Requests ← 3000 条/分钟
WARNING 失败,重试 2/3: 429 Too Many Requests
WARNING 失败,重试 3/3: 429 Too Many Requests
改造后:
text
ERROR [interactive] 连续失败 5 次,熔断 60s
INFO [interactive] 熔断到期,进入半开
INFO [interactive] 探测成功,熔断闭合
改造前的日志告诉你"在重试",改造后的日志告诉你"发生了什么"。 这个差别在事故复盘时的价值极大。
五、进阶
5.1 重试粒度:三层,成本差一个数量级
这是 Agent 错误处理里最容易被忽略、但对成本影响最大的一个设计点。
上游 熔断器 重试层 客户端 上游 熔断器 重试层 客户端 #mermaid-svg-a4xUPzvkycrRYQkr{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-a4xUPzvkycrRYQkr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-a4xUPzvkycrRYQkr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-a4xUPzvkycrRYQkr .error-icon{fill:#552222;}#mermaid-svg-a4xUPzvkycrRYQkr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-a4xUPzvkycrRYQkr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-a4xUPzvkycrRYQkr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-a4xUPzvkycrRYQkr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-a4xUPzvkycrRYQkr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-a4xUPzvkycrRYQkr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-a4xUPzvkycrRYQkr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-a4xUPzvkycrRYQkr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-a4xUPzvkycrRYQkr .marker.cross{stroke:#333333;}#mermaid-svg-a4xUPzvkycrRYQkr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-a4xUPzvkycrRYQkr p{margin:0;}#mermaid-svg-a4xUPzvkycrRYQkr .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-a4xUPzvkycrRYQkr text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-a4xUPzvkycrRYQkr .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-a4xUPzvkycrRYQkr .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-a4xUPzvkycrRYQkr .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-a4xUPzvkycrRYQkr .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-a4xUPzvkycrRYQkr #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-a4xUPzvkycrRYQkr .sequenceNumber{fill:white;}#mermaid-svg-a4xUPzvkycrRYQkr #sequencenumber{fill:#333;}#mermaid-svg-a4xUPzvkycrRYQkr #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-a4xUPzvkycrRYQkr .messageText{fill:#333;stroke:none;}#mermaid-svg-a4xUPzvkycrRYQkr .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-a4xUPzvkycrRYQkr .labelText,#mermaid-svg-a4xUPzvkycrRYQkr .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-a4xUPzvkycrRYQkr .loopText,#mermaid-svg-a4xUPzvkycrRYQkr .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-a4xUPzvkycrRYQkr .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-a4xUPzvkycrRYQkr .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-a4xUPzvkycrRYQkr .noteText,#mermaid-svg-a4xUPzvkycrRYQkr .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-a4xUPzvkycrRYQkr .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-a4xUPzvkycrRYQkr .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-a4xUPzvkycrRYQkr .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-a4xUPzvkycrRYQkr .actorPopupMenu{position:absolute;}#mermaid-svg-a4xUPzvkycrRYQkr .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-a4xUPzvkycrRYQkr .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-a4xUPzvkycrRYQkr .actor-man circle,#mermaid-svg-a4xUPzvkycrRYQkr line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-a4xUPzvkycrRYQkr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 第 1 层:请求层重试 只重发这一次调用 成本 = 本次调用的 Token 重试预算/次数耗尽 第 2 层:步骤层重试 重跑整个 Agent 步骤 成本 = 该步骤的全部调用 (可能是请求层的 5~10 倍) 第 3 层:任务层重试 重跑整个任务 成本 = 全流程 (可能是请求层的 50 倍以上) 请求 调用 真实请求 429 失败 等待 full jitter 后重试 重试 429 失败 抛出 AgentError 决定是否重跑步骤
| 层级 | 重试对象 | 成本 | 何时用 |
|---|---|---|---|
| 请求层 | 单次 LLM 调用 | 1× | 默认,瞬时错误 |
| 步骤层 | 一个 Agent 步骤(含多次调用) | 5~10× | 该步骤整体结果不可用 |
| 任务层 | 整个任务 | 50×+ | 几乎不该用 |
关键判断 :绝大多数重试应该在请求层完成。 步骤层和任务层重试的成本呈数量级放大,而且会丢失已完成步骤的中间结果 。第 30 篇的 Checkpoint 机制在这里直接相关------如果有 Checkpoint,任务层重试可以变成"从断点继续",成本从 50× 降到接近剩余部分的成本。
💡 "任务层重试几乎不该用"有个例外 :如果任务本身是幂等的、且没有中间状态需要保留(比如"批量给 100 条数据打标签"),那么任务层重试可以简化实现。但这时更应做的是分片 ------把 100 条拆成 10 批,失败只重跑失败的批次。分片重试的成本永远低于整体重试。
5.2 Agent 特有的两个熔断触发条件
标准熔断器只对调用失败计数。Agent 需要增加两个触发维度:

图4:标准熔断之外,Agent 还需要对"成本"与"质量"熔断------失控循环先表现为成本尖峰
python
# breaker_ai.py
from __future__ import annotations
import statistics
class AgentCircuitBreaker(CircuitBreaker):
"""扩展熔断器:除 HTTP 失败外,增加成本与质量两个熔断维度。"""
def __init__(self, *args, cost_spike_ratio: float = 3.0,
quality_failure_rate: float = 0.4, window: int = 20, **kw):
super().__init__(*args, **kw)
self.cost_spike_ratio = cost_spike_ratio
self.quality_failure_rate = quality_failure_rate
self.window = window
self.cost_samples: list[int] = []
self.quality_samples: list[bool] = []
def add_cost_sample(self, tokens: int) -> None:
"""Token 成本熔断------失控的 Agent 循环会先表现为成本尖峰。
这是 Agent 独有的信号:一个陷入循环的任务,在延迟指标上
可能还没异常(因为每次调用都很快),但 Token 消耗已是正常的数倍。
等它表现为延迟尖峰时,钱已经烧完了。
"""
self.cost_samples.append(tokens)
if len(self.cost_samples) > self.window:
self.cost_samples.pop(0)
if len(self.cost_samples) >= 10:
median = statistics.median(self.cost_samples[:-1]) or 1
if self.cost_samples[-1] > median * self.cost_spike_ratio:
logger.error(
"Token 成本尖峰: %d vs 中位数 %.0f,触发熔断",
self.cost_samples[-1], median,
)
self._trip()
def add_quality_sample(self, ok: bool) -> None:
"""质量熔断------语义验证失败率过高时熔断。
模型返回"结构正确但语义错误"的输出不是 HTTP 错误(第 36 篇的
judge 就是干这个的),但它同样是系统失效。当失败率超过阈值,
说明这一版 Prompt 或模型在这个场景下不可用。
"""
self.quality_samples.append(ok)
if len(self.quality_samples) > self.window:
self.quality_samples.pop(0)
if len(self.quality_samples) >= 10:
fail_rate = 1 - sum(self.quality_samples) / len(self.quality_samples)
if fail_rate > self.quality_failure_rate:
logger.error("语义失败率 %.2f 超阈值,触发熔断", fail_rate)
self._trip()
def _trip(self) -> None:
self.state = State.OPEN
self.opened_at = time.monotonic()
💡 成本熔断是 Agent 最有价值的一个独有机制。 第 24 篇提到过一个真实事故:Supervisor 循环 47 轮烧掉 23 万 Token。那个事故在传统监控里会先表现为"成本异常"而不是"延迟异常" ------因为每一轮调用本身都是正常完成的、延迟也正常。如果你只对延迟和错误率做告警,会等到账单出来才发现。
5.3 隔离:防止批处理拖垮交互式
回到 1.1 节事故一。根因是共享 API Key + 无隔离 。解决方案是配额分区:
python
# quota.py
from __future__ import annotations
import asyncio
import time
class TokenBucket:
"""令牌桶------主动限流,而不是等 429 再退避。
为什么需要主动限流:429 重试是"被动"的,收到拒绝后才反应;
令牌桶是"主动"的,在发出前就知道会不会被拒。
对用户体验而言,主动排队远好于被动重试(后者延迟不可控)。
"""
def __init__(self, rate_per_sec: float, capacity: int):
self.rate = rate_per_sec
self.capacity = capacity
self.tokens = float(capacity)
self.updated = time.monotonic()
self._lock = asyncio.Lock()
async def acquire(self, n: int = 1) -> float:
"""获取令牌,返回需等待的秒数(0 表示无需等待)。"""
async with self._lock:
now = time.monotonic()
self.tokens = min(
self.capacity, self.tokens + (now - self.updated) * self.rate
)
self.updated = now
if self.tokens >= n:
self.tokens -= n
return 0.0
deficit = n - self.tokens
wait = deficit / self.rate
self.tokens = 0.0
return wait
class QuotaPartition:
"""配额分区------把总配额按比例分配给不同业务,互不影响。
关键:RPM/TPM 配额是账号级别的,不是应用级别的。
多个应用共用一个 Key 时,一个应用打满就会饿死其他应用。
"""
def __init__(self, total_rps: float):
self.total = total_rps
self.buckets: dict[str, TokenBucket] = {}
def register(self, name: str, share: float) -> None:
"""注册一个业务分区,share 为占比(全部加起来应 <= 1.0)。"""
rps = self.total * share
self.buckets[name] = TokenBucket(
rate_per_sec=rps, capacity=max(1, int(rps * 2))
)
async def acquire(self, name: str) -> None:
bucket = self.buckets.get(name)
if bucket is None:
return
wait = await bucket.acquire()
if wait > 0:
await asyncio.sleep(wait)
⚠️ 配额分区最容易设计错的一点是"预留"而非"瓜分"。 上面
share加起来应 ≤ 1.0,不要给交互式 0.3、批处理 0.7 就把 100% 分完 ------留出 10%~20% 的共享余量,让临时突发有地方去。另外批处理分区应该配置成"宁可慢不可抢",它慢几个小时通常没关系,交互式慢几秒就是事故。
5.4 可观测性:错误处理的错误处理
错误处理代码本身也需要被观测,否则会出现"静默失效":
| 指标 | 为什么需要 |
|---|---|
| 重试率 | 超过 5% 说明上游不稳定或你的配额不够 |
| 重试预算使用率 | 接近 100% 说明系统处于持续过载 |
| 熔断开启次数/时长 | 熔断频繁开启说明容量规划有问题 |
| 不可重试错误的分布 | 突然增多通常是发布引入了 bug |
| 输出质量错误率 | Agent 独有,第 36 篇的在线评估也应覆盖它 |
| 重试后仍失败的比例 | 接近 1 说明重试策略选错了对象 |
python
# 一个容易漏的关键实践:错误要带上下文,不能只带消息
logger.error(
"调用失败",
extra={
"source": err.source.value,
"retryability": err.retryability.value,
"retry_after": err.retry_after,
"attempt": attempt,
"correlation_id": ctx.correlation_id, # 第 27 篇的三段 ID
"cause_type": type(err.cause).__name__ if err.cause else None,
},
)
💡 最后一条最容易被忽略:保留原始异常。 用 tenacity 时要设
reraise=True------否则重试耗尽后你看到的是一个通用的RetryError,真正的 429 被吞掉了 。同理,自定义错误要保留cause。错误信息在事故复盘时的价值,取决于它保留了多接近根因的信息。
六、适用边界与风险提示 ⚠️
6.1 重试的三个陷阱
陷阱一:对非幂等操作重试。 这是最危险的一个。如果 LLM 调用是"发邮件"、"下单"这类有副作用操作的一部分,重试会造成重复执行 。第 39 篇会专门讲幂等性设计,这里只提醒一句:重试前必须确认这个操作是幂等的,或者已经加了幂等键。
陷阱二:重试放大了已经过载的系统。 1.1 事故一就是。对策是重试预算 + 熔断器 + 主动限流(令牌桶),三者缺一不可。
陷阱三:隐藏了真实问题。 一个"重试后成功率 99%"的系统,可能是"首次成功率 70%"。如果你的监控只看最终成功率,就永远看不到那 30%。 必须把首次成功率 和重试后成功率分开统计。
6.2 熔断的两个风险
风险一:熔断阈值设得太敏感,正常抖动就熔断。 建议 failure_threshold 不低于 5,且只统计连续失败而不是滑动窗口失败率------连续失败能更好地区分"持续故障"和"偶发抖动"。
风险二:半开状态探测流量过大。 半开时如果放行多个请求,会让刚恢复的上游再次被压垮。默认只放行 1 个探测请求 ,并且探测请求应该是最轻量的(比如一个最小 token 的调用),不要用真实业务请求去探测。
风险三(更隐蔽):所有依赖共用一个熔断器。 这在多供应商故障转移场景下会直接失效------主供应商熔断后,备用供应商因为共用同一个熔断器也处于开启状态,故障转移根本不会发生。熔断器的粒度必须是**"供应商 × 模型"**,各自独立计数。这是 Q7 那个问题的完整答案,也是实践中踩得最多的一处。
5.5 错误处理代码怎么测
错误处理代码是最容易"写了但从没被执行过"的代码。 原因很实际:正常路径天天跑,错误分支一年可能只触发几次,而测试里通常用 Mock 把异常 Mock 掉了,等于没测。
python
# tests/test_retry.py
import asyncio
import pytest
from errors import AgentError, ErrorSource, Retryability
from retry import RetryExecutor
from budget import RetryBudget
class Clock:
"""注入时钟------第 35 篇的核心技巧,让退避测试从分钟级降到毫秒级。"""
def __init__(self):
self.now = 0.0
self.sleeps: list[float] = []
async def sleep(self, seconds: float) -> None:
self.sleeps.append(seconds)
self.now += seconds
@pytest.mark.asyncio
async def test_not_retryable_fails_immediately():
"""不可重试错误必须立刻失败------一次都不该重试。"""
clock = Clock()
calls = []
async def fn(**_):
calls.append(1)
raise AgentError("参数错误", source=ErrorSource.BUSINESS,
retryability=Retryability.NOT_RETRYABLE)
ex = RetryExecutor(RetryBudget())
ex.sleep = clock.sleep # 注入时钟,不真睡
with pytest.raises(AgentError):
await ex.run(fn)
assert len(calls) == 1, "不可重试错误被重试了"
assert clock.sleeps == [], "不该有任何等待"
@pytest.mark.asyncio
async def test_retry_budget_caps_total_retries():
"""重试预算必须限制总重试量,而不是单请求次数。"""
budget = RetryBudget(ratio=0.1, min_calls=10)
clock = Clock()
for _ in range(100):
budget.record_request() # 100 个请求
for _ in range(10):
budget.record_retry() # 已用掉 10 次重试
assert budget.allow_retry() is False, "预算已到 10%,应拒绝"
@pytest.mark.asyncio
async def test_retry_after_is_honored():
"""有 Retry-After 时必须遵守它,而不是用自己算的退避。"""
clock = Clock()
attempts = []
async def fn(**_):
attempts.append(1)
if len(attempts) < 2:
raise AgentError("429", source=ErrorSource.PROVIDER,
retryability=Retryability.RETRYABLE,
retry_after=30.0)
return "ok"
ex = RetryExecutor(RetryBudget(), max_attempts=3)
ex.sleep = clock.sleep
assert await ex.run(fn) == "ok"
assert 30.0 <= clock.sleeps[0] <= 30.3, "未遵守 Retry-After"
💡 第一个测试最重要,也最常被漏掉。 "不可重试错误一次都不该重试"听起来显然,但实现时极易写错------比如把
NOT_RETRYABLE的判断放在了重试次数检查之后。这条断言的价值不在于它测了什么,而在于它锁住了分类体系的正确性。
6.3 一个反直觉的结论:重试会降低可用性
这是本文唯一一个"反常识"的结论,值得单独讲。
重试提高了单次请求的成功率,但降低了系统的整体可用性。 原因是:当上游过载时,重试会放大负载 ,让上游更难恢复,从而使所有请求(包括那些本来能成功的)的成功率下降。
这个效应的量化形式很直观:设上游在负载为 L 时成功率是 S(L),而 S 随 L 下降。你加入重试后,实际负载变成 L × (1 + r)(r 是重试率)。如果 S 对 L 足够敏感,那么 S(L×(1+r)) 的下降会超过重试带来的增益------净效果是负的。
这意味着重试策略不能孤立地调。判断标准是三个问题:
- 上游失败的原因是随机的还是负载相关的? 随机的(网络抖动)→ 重试有效;负载相关的(429、503)→ 重试有害。
- 你的重试率是多少? 低于 5% 通常安全;高于 20% 几乎总是有害。
- 你有没有熔断和预算? 有 → 重试被限制在安全范围;没有 → 重试会失控。
💡 这也是为什么"重试预算"比"重试次数"重要。 次数是局部约束,管的是单个请求;预算是全局约束,管的是系统总负载。只有全局约束能防止"重试降低可用性"这个效应。
6.4 什么时候不要这套体系
- 原型阶段 :先把错误打全日志,看清楚实际会发生什么错误,再配策略。按猜测配置的重试次数通常都是错的------你以为是限流为主,实际上可能格式错误占一半,那两者需要的是完全不同的处理。
- 单用户内部工具:一次失败让人点一下重试就好,不值得建体系。
- 完全同步、用户在线等待的场景 :长退避不可接受,应该快速失败 + 明确提示,而不是让用户等 60 秒。
七、一个可复制的最小骨架
python
# minimal_resilience.py ------ 约 60 行,覆盖核心
import asyncio
import logging
import random
import time
log = logging.getLogger("minimal")
RETRYABLE = {408, 429, 500, 502, 503, 504, 529}
class Open(Exception):
pass
class Breaker:
def __init__(self, threshold=5, open_seconds=60.0):
self.threshold, self.open_seconds = threshold, open_seconds
self.fails, self.opened_at = 0, 0.0
def check(self):
if self.fails >= self.threshold:
if time.monotonic() - self.opened_at < self.open_seconds:
raise Open("熔断中")
self.fails = self.threshold - 1 # 半开:放行一个
def ok(self):
self.fails = 0
def fail(self):
self.fails += 1
if self.fails == self.threshold:
self.opened_at = time.monotonic()
log.error("熔断开启 %.0fs", self.open_seconds)
async def resilient(call, *, attempts=4, base=1.0, cap=32.0, deadline=45.0):
"""最小可用:分类 → 退避 → 预算 → 截止时间。"""
breaker = Breaker()
retries = 0
started = time.monotonic()
for attempt in range(attempts):
breaker.check()
try:
resp = await call()
breaker.ok()
return resp
except Exception as e:
code = getattr(e, "status_code", None)
if code is not None and code not in RETRYABLE:
raise # 硬规则:其他 4xx 不重试
breaker.fail()
if attempt == attempts - 1:
raise
if retries >= max(1, int(attempts * 0.4)): # 简易预算
log.error("重试预算耗尽")
raise
if time.monotonic() - started > deadline:
log.error("超出截止时间")
raise
ra = getattr(e, "retry_after", None)
wait = ra + random.uniform(0, 0.3) if ra else random.uniform(
0, min(cap, base * 2 ** attempt)
)
retries += 1
log.info("重试 %d,等待 %.2fs", attempt + 1, wait)
await asyncio.sleep(wait)
这个骨架省略了 :Retry-After 的单位转换、输出质量校验、成本/质量熔断、配额分区。这四部分是生产必需的,但可以在骨架跑通后再加。
💡 加的顺序有讲究,建议按"输出质量校验 → 配额分区 → 成本熔断 → 单位转换"来。 输出质量校验排第一,因为它能立刻把"HTTP 200 但内容错"这类你目前完全看不见的失败暴露出来------往往一加上就会发现真实失败率比监控显示的高得多。配额分区排第二,因为它解决的是"一个业务拖垮另一个"这类最容易复现、影响也最大的问题。
八、总结
本文的核心只有一句话:错误的处理方式应由分类决定,而不是由"捕获到异常"决定。
五个最该记住的结论:
第一,"HTTP 200 但内容错误"是 Agent 独有且最主要的失败形态。 它不产生错误码、不抛异常、不进错误日志,却正是用户感受到的"系统坏了"。必须有一层 validate_output 把它变成显式错误,否则它会以解析异常的形式散落在下游各处。加完这一层之后,很多团队会第一次发现"真实失败率比监控显示的高一大截"。
第二,除了 429/503/529/408,其他 4xx 一律不重试。 参数错误、鉴权失败、内容审核拒绝,重试多少次结果都一样------而且内容审核类错误反复重试可能导致账号被封。
第三,有 Retry-After 就遵守它,注意单位是秒还是毫秒。 OpenAI 用 retry-after-ms,Anthropic 用 retry-after。这是最容易踩的单位坑。
第四,重试预算比重试次数重要。 max_attempts=3 在 1% 失败率时无害,在 50% 失败率时会让总请求量翻倍------恰好在上游最脆弱的时候。Agent 场景建议预算 3%~5%,因为它的一次重试要重发整个上下文。
第五,Agent 需要对"成本"和"质量"熔断,不只是对 HTTP 错误。 失控的 Agent 循环会先表现为 Token 成本尖峰,再表现为延迟尖峰------等延迟告警响时钱已经烧完了。
最后一点 :监控首次成功率,而不只是最终成功率。 一个"重试后 99%"的系统可能首次只有 70%,那 30% 的用户体验到了一次明显的卡顿或错误------重试把它藏起来了,但用户感受到了。
还有一句值得记住:顺序比参数重要。 4.5 节的实测显示,成功率从 41% 提到 97%,主要贡献不是把重试次数从 3 调到 5,而是先加隔离(配额分区)和熔断,再谈重试 。很多团队的优化顺序是反的------一上来就调退避参数,结果越调越糟,因为在不隔离、不熔断的前提下,重试越激进,系统越过载。
落地自检清单(Checklist)
- ✅ 错误自带分类(来源 / 可重试性 / 降级建议),不让调用方猜
- ✅ 有
RETRYABLE_STATUS显式白名单,不用"5xx 都可重试"这类模糊规则 - ✅ 除 429/503/529/408 外,其他 4xx 一律不重试
- ✅ 解析
Retry-After并统一转换为秒 - ✅ 退避使用 Full jitter(高并发下比同步退避少 50~80% 重试量)
- ✅ 有重试预算(Agent 场景 3%~5%),且检查顺序在重试次数之前
- ✅ 有总体截止时间,防止退避拖垮用户可感知延迟
- ✅ 熔断器
open_seconds对齐配额刷新周期(通常 60 秒) - ✅ 半开状态只放行 1 个轻量探测请求
- ✅ 有输出质量校验层(把 HTTP 200 内容错误变成显式错误)
- ✅ 有 Token 成本熔断 与质量熔断(Agent 独有)
- ✅ 多业务共享 Key 时做了配额分区,并留 10%~20% 余量
- ✅ 统计首次成功率 与重试后成功率(分开看)
- ✅ 重试耗尽时
reraise=True,保留原始异常 - ✅ 重试前确认操作幂等(详见第 39 篇)
常见问题(FAQ)
Q1:重试次数设几次合适?
交互式 3 次、批处理 5~7 次。 但更重要的是截止时间 :交互式场景总时长应该控制在用户可接受范围内(通常 30~60 秒),而不是由次数决定。次数是次要约束,截止时间才是主要约束。
Q2:Full jitter 还是 Equal jitter?
默认 Full jitter。 它的平均等待最短、分散最均匀,AWS 基准显示能减少 50~80% 的总重试量。如果你观察到重试后仍有限流尖峰(Full jitter 可能抽到接近 0 的等待),换 Equal jitter------代价是平均等待变长。
Q3:熔断器阈值怎么定?
failure_threshold=5(连续失败)、open_seconds=60(对齐配额窗口)是个合理起点。不要用滑动窗口失败率------连续失败能更好地区分"持续故障"与"偶发抖动"。阈值低于 3 会导致正常抖动就熔断。
Q4:429 和 503 的处理有什么不同?
429 是配额问题(你在发太快),503 是上游容量问题(它扛不住)。 429 的恢复要对齐配额窗口(60 秒),且主动限流(令牌桶)比被动重试更有效------在发出前就知道会不会被拒,延迟可控得多。503 恢复更快,可以较短退避。
Q5:输出格式错误该重试吗?
不要直接重试,要"改造后重试"。 temperature=0 时直接重试大概率得到同样的错误。正确做法是在请求里追加修正指令 ("上次输出不是合法 JSON,这次只输出 JSON"),或直接改用结构化输出模式(JSON Schema / tool calling)。判断标准:重试的请求必须与上次不同。
Q6:模型拒绝回答怎么办?
不要重试。 拒绝是确定性的判断,重试只会再拒一次。应走降级 (换模型、改 Prompt 降低敏感度、或转人工)。这也是为什么 OutputRefusedError 被设为 NOT_RETRYABLE。
Q7:多个模型供应商怎么做故障转移?
给每个供应商一个独立的熔断器 ,按优先级尝试。关键细节:熔断器的粒度是"供应商 × 模型",不是全局一个------否则主供应商熔断会把备用也一起断掉,故障转移形同虚设。具体降级策略见第 38 篇。
Q8:重试会不会导致重复扣费?
会,如果调用有副作用。 纯生成类调用(摘要、分类)重试是安全的(等价输出),但如果 LLM 调用是"下单"、"发邮件"这类流程的一环,必须加幂等键。这是第 39 篇的主题,也是重试设计的前置条件。
参考资料
- AWS Well-Architected Framework --- Exponential Backoff and Jitter (三种 jitter 的定义与基准对比):https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/
- Nygard, M. T. --- Release It! (熔断器模式的原始提出,第二版):https://pragprog.com/titles/mnee2/release-it-second-edition/
- Google SRE Book --- Handling Overload 与重试预算概念:https://sre.google/sre-book/handling-overload/
- Amazon Bedrock 官方文档 --- Optimize your applications for scale and reliability (429 与 503 的差异、配额窗口与重试策略):https://aws.amazon.com/blogs/machine-learning/mastering-amazon-bedrock-throttling-and-service-availability-a-comprehensive-guide
- OpenAI 官方文档 --- Rate limits 与错误码说明(
retry-after-ms头):https://platform.openai.com/docs/guides/rate-limits - Anthropic 官方文档 --- Rate limits 与错误码说明(
retry-after头):https://docs.anthropic.com/en/api/rate-limits - tenacity 官方文档 --- 重试库与
wait_random_exponential/reraise:https://tenacity.readthedocs.io/ - httpx 官方文档 --- 超时与传输错误分类:https://www.python-httpx.org/advanced/timeouts/
- 《Site Reliability Engineering》第 22 章 --- 处理级联故障(重试放大与负载丢弃):https://sre.google/sre-book/addressing-cascading-failures/