Agent 错误处理体系:分类、重试、降级与熔断设计

摘要 :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
不可重试
重试预算还有吗?
执行重试
停止重试 · 触发降级或失败

两条硬规则:

  1. 除 429/503/529/408 外的 4xx 一律不重试。 参数错误、鉴权失败、内容审核拒绝,重试多少次结果都一样。
  2. 有 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)) 的下降会超过重试带来的增益------净效果是负的。

这意味着重试策略不能孤立地调。判断标准是三个问题:

  1. 上游失败的原因是随机的还是负载相关的? 随机的(网络抖动)→ 重试有效;负载相关的(429、503)→ 重试有害。
  2. 你的重试率是多少? 低于 5% 通常安全;高于 20% 几乎总是有害。
  3. 你有没有熔断和预算? 有 → 重试被限制在安全范围;没有 → 重试会失控。

💡 这也是为什么"重试预算"比"重试次数"重要。 次数是局部约束,管的是单个请求;预算是全局约束,管的是系统总负载。只有全局约束能防止"重试降低可用性"这个效应。

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 篇的主题,也是重试设计的前置条件。


参考资料

  1. AWS Well-Architected Framework --- Exponential Backoff and Jitter (三种 jitter 的定义与基准对比):https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/
  2. Nygard, M. T. --- Release It! (熔断器模式的原始提出,第二版):https://pragprog.com/titles/mnee2/release-it-second-edition/
  3. Google SRE Book --- Handling Overload 与重试预算概念:https://sre.google/sre-book/handling-overload/
  4. 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
  5. OpenAI 官方文档 --- Rate limits 与错误码说明(retry-after-ms 头):https://platform.openai.com/docs/guides/rate-limits
  6. Anthropic 官方文档 --- Rate limits 与错误码说明(retry-after 头):https://docs.anthropic.com/en/api/rate-limits
  7. tenacity 官方文档 --- 重试库与 wait_random_exponential / reraise:https://tenacity.readthedocs.io/
  8. httpx 官方文档 --- 超时与传输错误分类:https://www.python-httpx.org/advanced/timeouts/
  9. 《Site Reliability Engineering》第 22 章 --- 处理级联故障(重试放大与负载丢弃):https://sre.google/sre-book/addressing-cascading-failures/
相关推荐
武子康43 分钟前
固定时间步已经开了,为什么游戏回放还会走出不同位置?
人工智能·后端·agent
VIP_CQCRE1 小时前
内容自动化越跑越乱?先补上状态、去重和失败恢复
python·ai·工作流·幂等设计·内容自动化
EnCi Zheng1 小时前
Agent自动化系统怎么搭-从Super-agent架构到事件驱动调度的完整指南
自动化·agent·领导力·调度系统·super-agent
GJGCY2 小时前
券商舆情智能体如何设计?事件聚类去重、主体消歧与风险事件抽取技术解析
经验分享·ai·金融·自动化·证券·智能体
大模型真好玩2 小时前
DeepSeek Super-Agent核心特性详解与实战指南:把操作系统思想搬进智能体
人工智能·agent·deepseek
whyutianict_vv2 小时前
零基础转 AI 运维:投入清单、技术路线与核对表
linux·运维·人工智能·ai
明月_清风2 小时前
为什么 Skill 很难商业化?Bot 才是 AI 时代真正的商业机会
人工智能·后端·agent
10年前端老司机2 小时前
Agent 商业化困局:热闹的发布会,冷清的付费客户
前端·后端·agent
合合技术团队2 小时前
企业级Agent服务平台选型指南:从需求匹配到场景落地,文档解析工具怎么选?
大数据·人工智能·agent·多模态文本智能技术·企业级agent服务平台