《AI Agent 开发入门》14.1 分类错误与重试

把 Agent 遇到的错误分成可重试与不可重试两类:网络抖动、限流 429、临时 5xx 走指数退避加抖动重试,参数错误 400、权限 403、校验失败直接判死。给出 classify 与 with_retry 两个函数,讲清工具错误回灌给模型换策略、模型错误交给重试的分工,并用脚本化假调用真跑「前两次失败第三次成功」,打印退避间隔与实际尝试次数。

14.1 分类错误与重试

前面十三章,minagent 已经能跑循环、调工具、管记忆、编排工作流、拉起多 Agent。但它一直有个隐含假设:每一次调用都会成功。真实世界不是这样——模型服务会限流,网络会抖动,第三方 API 会抽风。一个只在顺境里能跑的 Agent,上线第一天就会挂在第一个 429 上。

本节把 minagent 推进到 minagent/resilience/retry.py:先给错误分类,只对「重试能救」的错误做退避重试,再把「工具错误」与「模型错误」分流处理。

14.1.1 两类错误:重试能救的,和重试救不了的

处理错误的第一步不是「怎么重试」,而是「该不该重试」。盲目重试有两类代价:对不可重试的错误重试,只是把同一次失败拖长几分钟,还多烧了额度;对可重试的错误不重试,则把一个本可自愈的抖动变成了用户可见的失败。

判据其实很朴素:这次失败是「暂时的」还是「确定的」。

类别典型表现例子处理
可重试环境瞬时异常,重来大概率不同网络抖动、连接重置、限流 429、临时 5xx退避后重试
不可重试请求本身有问题,重来必然一样参数错误 400、权限 403、校验失败、模型拒答立即上报

关键洞察是:HTTP 状态码本身就是一份现成的分类表。4xx 里除了 408(请求超时)和 429(限流),几乎都是「你的请求错了」——重试一百次也是同样的错。5xx 则相反,是「服务端这次没扛住」,重试是标准解法。把这份直觉固化成一张表,分类函数就八九不离十了。

还有一层微妙之处:同一个状态码,在不同场景下可能该归到不同类别。比如 403 权限不足,对「当前这个 token」是确定的失败,但如果 Agent 手里有一组可轮换的凭证,换一个 token 重试就可能成功——这时 403 反而成了「可重试」。所以分类不是绝对的,它取决于你有没有改变条件的能力。本节先按「无轮换能力」的保守口径分类,把「有条件可重试」留给调用方在外层处理。

14.1.2 状态码速查表与 classify

先把常见状态码归个类,这张表是后面 classify 函数的直接依据:

状态码含义归类理由
400请求格式错Fatal请求本身有问题
401 / 403未认证 / 无权限Fatal凭证或权限问题
404资源不存在Fatal重试也不会出现
408请求超时Retryable网络层瞬时问题
409冲突Fatal状态冲突,需业务处理
429限流Retryable等一会儿就能过
500 / 502服务端错误Retryable服务端瞬时故障
503 / 504不可用 / 网关超时Retryable典型的临时故障

用一个小的标签类型表达分类结果,比返回字符串更安全——调用方拿到的是类型,拼错了立刻报错:

from __future__ import annotations
from dataclasses import dataclass

@dataclass(frozen=True)
class Retryable:
    reason: str

@dataclass(frozen=True)
class Fatal:
    reason: str

class ToolError(Exception):
    def __init__(self, message: str, status: int | None = None):
        super().__init__(message)
        self.status = status

class ModelError(Exception):
    def __init__(self, message: str, status: int | None = None):
        super().__init__(message)
        self.status = status

RETRY_STATUS = {408, 429, 500, 502, 503, 504}

def classify(exc: Exception) -> Retryable | Fatal:
    status = getattr(exc, "status", None)
    if status in RETRY_STATUS:
        return Retryable(f"HTTP {status}")
    if isinstance(exc, (ConnectionError, TimeoutError)):
        return Retryable(type(exc).__name__)
    return Fatal(type(exc).__name__)

classify 先看 status,再看异常类型。ToolError / ModelError 都带 status 字段,因为无论错在工具还是模型,底层的失败信号常常是同一个 HTTP 状态码——一个限流 429 可能来自模型网关,也可能来自工具背后的第三方服务。用同一张 RETRY_STATUS 表覆盖两者,逻辑才不会分叉。

默认落到 Fatal 是刻意的保守选择:分类器认不出的错误,宁可判死也不要乱重试。因为「多试几次」的坏处(放大副作用、拖长故障)通常比「少试几次」更严重。拿六种典型异常喂给它,真跑:

ToolError      status=429   -> Retryable (HTTP 429)
ModelError     status=503   -> Retryable (HTTP 503)
ToolError      status=403   -> Fatal     (ToolError)
ToolError      status=400   -> Fatal     (ToolError)
ConnectionError status=None -> Retryable (ConnectionError)
ValueError     status=None  -> Fatal     (ValueError)

六个样本全部判对:429、503 是可重试;403、400 是确定失败;ConnectionError 被类型规则捕获为可重试;裸的 ValueError(既没状态码也不是已知网络异常)落到默认分支,判为 Fatal——宁可不重试。

14.1.3 with_retry:指数退避 + 抖动

分类做完,重试器就短了。它要满足三件事:只在 Retryable 上重试、退避间隔指数增长、加入抖动避免惊群:

import random

def with_retry(fn, *, max_attempts=4, base=0.5, cap=8.0, jitter=0.2,
               sleep=None, rng=None):
    rng = rng or random.Random(0)
    for attempt in range(1, max_attempts + 1):
        try:
            return fn(), attempt
        except Exception as exc:
            tag = classify(exc)
            if isinstance(tag, Fatal) or attempt == max_attempts:
                raise
            delay = min(cap, base * 2 ** (attempt - 1))
            delay *= 1 + rng.uniform(-jitter, jitter)
            print(f"  attempt {attempt} 失败({tag.reason})→ 退避 {delay:.3f}s")
            if sleep:
                sleep(delay)

三个参数各有讲究:

参数作用取值建议
base首次退避的基准时长0.5–1s,够短以免拖慢
cap单次退避上限8–30s,防指数失控
jitter退避的随机波动比例0.1–0.3,打散重试尖峰

base * 2 ** (attempt - 1) 是指数退避:第 1 次失败等 base,第 2 次等 2×base,第 3 次等 4×base——因为越往后越可能是持续性的服务过载,急着再来只会火上浇油。cap 封顶,防止指数在多次重试后涨到分钟级。jitter 是关键:如果一千个客户端同时被限流,又都精确地等 0.5 秒后重发,就会形成第二波、第三波尖峰;把每次的等待乘上 1 ± jitter 的随机因子,能把重试打散。sleep 可注入,是为了测试时把「真等」换成「记录」,rng 可注入则是为了让演示可复现。

注意 with_retry 返回的是 (结果, 尝试次数)——把尝试次数一并交出来,第 15 章做可观测性时可以直接记进 span。

14.1.4 真跑:前两次失败、第三次成功

脚本化一个「前两次抛 503、第三次成功」的假调用,看退避是不是按指数走、尝试次数是不是停在 3:

calls = {"n": 0}
def flaky_call():
    calls["n"] += 1
    if calls["n"] <= 2:
        raise ModelError("upstream unavailable", status=503)
    return {"ok": True, "attempt": calls["n"]}

result, attempts = with_retry(flaky_call)
print("结果:", result, "总尝试:", attempts)

真跑输出(Python 3.14.6):

  attempt 1 失败(HTTP 503)→ 退避 0.569s
  attempt 2 失败(HTTP 503)→ 退避 1.103s
结果: {'ok': True, 'attempt': 3} 总尝试: 3

两次退避是 0.569s 与 1.103s——基准 0.5s 与 1.0s 各叠了 ±20% 的抖动,正好呈指数关系。第三次成功,attempts 停在 3,没有多跑。再补一个不可重试的例子:

def bad_call():
    raise ToolError("bad argument: query must be str", status=400)

try:
    with_retry(bad_call)
except ToolError as e:
    print("不可重试:", e, "| 尝试次数:", 1)
不可重试: bad argument: query must be str | 尝试次数: 1

400 被判为 Fatal,with_retry 在第一次失败时就原样抛出,一次都没多试。这就是分类的价值:同样的重试器,遇到 503 会耐心重试,遇到 400 则立刻止损。

14.1.5 工具错误与模型错误:两条处理路径

到这里有个容易混淆的点:错误该在哪里被重试? 答案取决于错误出在哪一层。

模型错误(调用模型 API 时网络挂掉、被限流)发生在 Agent 的「思考」环节,除了重试没有别的办法——Agent 无法自己修复一个连不上的模型服务。这类错误交给 with_retry 包住模型调用即可。

工具错误(工具参数错、工具内部抛异常)则不同:它是「思考的产物」出了问题,而模型有能力根据错误信息换一种策略。这时正确的做法不是重试同一个工具调用,而是把错误回灌给模型——按第 4 章的契约,ToolRegistry.call 出错时返回 {"error": ...} 而不是抛异常,正是为了让错误进入消息流:

def dispatch(tc):
    fn = TOOLS.get(tc.name)
    if fn is None:
        return f'{{"error": "unknown tool {tc.name}"}}'
    return json.dumps(fn(**tc.arguments), ensure_ascii=False)

用假模型脚本化一次「先参数错、看到 error 后改对」的轨迹,真跑:

[step 0] tool search({'query': 42}) -> {"error": "query 必须是字符串"}
[step 1] tool search({'query': 'GIL'}) -> {"hits": ["关于「GIL」的 3 条结果"]}
final: GIL 是 CPython 的全局解释器锁。
模型第二次看到的工具结果: {"error": "query 必须是字符串"}

模型第一次把 query 写成数字 42,工具返回参数错误;这个 error 作为一条 tool 消息回到模型,模型据此把参数改成字符串再试一次,成功。同一件事,用重试解决和用「回灌 + 换策略」解决,是两条完全不同的路径:

错误来源该谁修手段例子
模型调用重试器with_retry 包住 API 调用限流 429、网关 503
工具调用模型自己把 error 回灌,让它换参数/换工具参数类型错、工具不存在
参数校验不该发生第 4 章的前置校验拦截缺必填字段

把工具错误丢进 with_retry 是常见的反模式:参数错重试一百次还是参数错,只是白白多跑一百次。能靠重试解决的是「环境问题」,能靠换策略解决的是「决策问题」——分不清这两者,重试就会变成掩饰 bug 的噪音。

14.1.6 重试预算:别让退避吃掉所有时间

退避不是免费的。4 次重试、base 0.5、cap 8,最坏情况单次调用要等 0.5 + 1 + 2 = 3.5 秒;如果 Agent 一轮并发 10 个工具,尾部延迟会被最慢的那个拖住。所以生产里通常再加一道总预算:给一次 run 设一个墙钟上限,重试累计等待超过它就放弃。

还有一种比「重试」更进一层的手段叫熔断(circuit breaker):如果某个依赖连续失败 N 次,就先「断开」,一段时间内直接拒绝请求、不再重试,等冷却后再放少量探测请求。它和重试是互补的——重试解决「偶发抖动」,熔断解决「依赖整体挂掉」。没有熔断,一个彻底宕机的下游会被无休止的重试持续冲击;有了熔断,Agent 能快速失败、把资源让给还能用的路径。熔断的状态机(闭合 / 打开 / 半开)是卷二的内容,卷一先知道它的存在即可。

还有一类错误无论多可重试都不该自动重试:已经产生副作用的写操作。一个「扣款」请求超时了,你不知道服务端到底扣没扣——此时重试可能扣两次。这类操作的正确答案不是「重试」也不是「放弃」,而是「带上幂等键重试」,让服务端能识别出这是同一个操作。这正是 14.3 要解决的问题。换句话说,「能不能重试」的最终判据,是「这次调用是不是幂等的」。

14.1.7 四个常见反模式

把重试写错,比不写重试更糟。下面四个坑最典型,前三个本节已经用代码挡掉,第四个要等 14.3:

反模式表现后果正解
无差别重试所有异常都重试参数错也重试到超时先 classify
无上限重试while True 直到成功故障时打爆下游服务设 max_attempts
无抖动重试所有客户端同步退避重试风暴形成尖峰加 jitter
对写操作重试扣款超时后直接重发重复扣款加幂等键

第一条和第二条是「分类」与「上限」的缺失,第三条是「抖动」的缺失,第四条则是「幂等」的缺失。一个可上线的重试器,这四条一条都不能少——它们分别对应本节和下一节要建的机制。

至此,14.1 给 minagent 装上了第一层韧性:先分类、再重试、错误分流。它保证 Agent 在「暂时性故障」面前能自愈,同时不把「确定性错误」拖成慢性病。

真实模型下会怎样:本节的 503、429 都是脚本注入的,classify 面对的 status 也由我们手动填好。真实 Claude API 会把状态码放在 anthropic.APIStatusError.status_code 上,限流时还会带 retry-after 头——这个头才是权威的退避时间,比任何指数公式都准,真实适配器应优先读它、只在缺失时退回本节的计算。此外真实错误里有一类本节没覆盖的:模型返回了 stop_reason="max_tokens" 或内容被安全策略截断,它们既不是异常也不是成功,需要调用方判断要不要「换个说法重发」——这类语义级重试留到卷三讲。

阅读导航:上一节:13.3 协作模式对照 · 下一节:14.2 超时、取消与结构化并发 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距