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 超时、取消与结构化并发 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。