第 20 章
可靠性:失败了,怎么不把事情弄得更糟
重试不是万能药,resume 也不是时光倒流。
一个退款 Agent 在第 12 轮超时。
重试策略自动跑了一遍。它从最近的会话恢复,模型看到“退款工具调用没有结果”,又调了一次。
用户收到两笔退款,财务来找我。
这不是模型的问题,是重试策略的问题。读操作可以 blindly retry;有副作用的操作不行。
这一章讲失败分类、重试、超时、恢复、降级。
20.1 失败不是一种
“Agent 失败了”这句话没有信息量。至少要分到下面几类:
| 失败 | 典型信号 | 能重试吗 | 先做什么 |
|---|---|---|---|
| API 5xx / 网络超时 | ProcessError / TimeoutError | 可以,退避 | 确认这次调用有没有副作用 |
| CLI 崩溃/连不上 | CLIConnectionError | 可以,重启 | 检查会话、文件、端口 |
| 进程非零退出 | ResultError | 看 subtype | 先看已经拿到的 ResultMessage |
| 工具报错 | tool_result.is_error | 不一定 | 让模型换路,不要原样重试 |
| 权限拒绝 | DENIED / PermissionResultDeny | 不要 | 改权限或问人 |
| 预算/轮数耗尽 | error_max_budget_usd / error_max_turns | 不要盲重试 | 加预算/拆任务 |
| 注入/异常调用 | 读到敏感文件、危险命令 | 停止 | 审计、隔离、改权限 |
这张表决定了“重试”是不是安全的。你不分类,就会把“权限拒绝”也重试三次,浪费钱还卡住。
重试前先问三个问题:
- 这个动作幂等吗? 不幂等,不要自动重试。
- 失败是暂时的吗? 5xx/超时可以重试;配置/权限/预算不要。
- 重试的钱和时间值得吗? 一次重试可能比原任务还贵。
20.2 重试前先问:这个动作幂等吗
幂等:同样的操作执行一次和执行两次,结果一样。
- 读文件、搜索、查天气:幂等,可以重试;
- 改文件、发消息、退款、下单:不幂等,重试可能重复副作用。
对不幂等的操作,三种做法:
- 幂等键:退款请求带
request_id,服务端去重; - 检查点:重试前先看“这一步是不是已经做了”;
- 人工确认:不确定就停下来问人,不要自动重试。
幂等键的例子:
def refund(order_id: str, amount: int, request_id: str):
# 同一个 request_id 重复调用,服务端只退一次
return api.post("/refunds", json={
"order_id": order_id,
"amount": amount,
"request_id": request_id,
})request_id 要由调用方生成,并在重试时保持不变;不要每次重试都生成新的。
一句话:重试计划,不要重试副作用。
20.3 重试策略
ch20_reliability.py 是一个最小包装:
@dataclass
class RetryPolicy:
attempts: int = 3
base_delay: float = 1.0
max_delay: float = 10.0
budget_usd: float = 0.3先看“单次运行”怎么收异常:
async def run_once(prompt, options):
result = None
try:
async for msg in query(prompt=prompt, options=options):
if isinstance(msg, ResultMessage):
result = msg
return result, None
except ResultError as e:
# 进程非零退出;result 可能已经拿到了
return result, f"ResultError:{e.subtype}"
except (ProcessError, CLIConnectionError, TimeoutError) as e:
return result, f"{type(e).__name__}: {str(e)[:200]}"这里有两个关键点:result 先保存,异常时也返回它;异常按类型分类,决定外层是重试、恢复还是停。
核心循环:
async def retry_loop(prompt, options, policy):
sid = None
for attempt in range(1, policy.attempts + 1):
used_resume = sid is not None
opts = replace(options, resume=sid) if used_resume else options
result, err = await run_once(prompt, opts)
if result is not None:
sid = result.session_id or sid
...
delay = min(policy.base_delay * (2 ** (attempt - 1)), policy.max_delay)
delay += random.uniform(0, 0.3) # jitter
await asyncio.sleep(delay)四个细节:
- 指数退避:1s、2s、4s……别立刻重试;
- jitter:加随机抖动,避免多个客户端同时重试;
- 上限:
max_delay防止退避到几分钟; - FATAL 不重试:未知异常、配置错误,重试只会重复失败。
实际 happy path(2026-10-22,SDK 0.2.168 + CLI 2.1.296 + deepseek-flash):
{'attempt': 1, 'subtype': 'success', 'cost': 0.030297, 'error': None, 'resume': False}
result: success 6 0.030297这次没有失败,所以只验证了“正常路径不重试”;失败/退避/恢复路径没有真实触发。代码里保留了这些分支,但别把“能编译”当成“验证过”。
重试也要算预算:第 1 次不等待,第 2 次 1s + jitter,第 3 次 2s + jitter,第 4 次 4s + jitter。如果单次成本 $0.1,3 次重试最坏 $0.4;再加工具/审批时间,可能比任务本身贵。重试前先设 budget_usd 和 attempts,别无限重试。
20.4 超时:别把进程杀在半路
超时有两种:
- 单次调用超时:一次模型/工具调用最多等多久;
- 整次任务超时:整个 run 最多多久。
单次超时可以用 anyio.fail_after / asyncio.wait_for;整次用外层计时。
import anyio
async def call_with_timeout():
with anyio.fail_after(60):
return await run_once(prompt, options)整次任务超时:
async def run_with_deadline():
with anyio.fail_after(300):
return await run_with_retry(prompt, options, policy)超时后不要直接重试;先走 20.5 的恢复检查。
但“杀进程”有代价:
- 工具可能执行到一半(文件写了一半、退款请求已发出);
- 会话最后一条可能是
tool_use,没有tool_result; - 下次
resume可能重复这个工具调用。
所以超时后要先做清理/检查:
- 看最后一条消息是不是完整的;
- 看文件/副作用有没有发生;
- 决定“补结果”还是“从更早的完整轮次恢复”。
超时不是“结束”,是“进入恢复流程”。
20.5 resume 不是重试
resume 做的是:重新加载 transcript,再发一遍。
它不是“从失败点继续执行”,也不是“撤销上一次”。
最常见的坑:
最后一条: tool_use read_file(id=t9)
[resume]
[assistant] tool_use read_file(id=t9) # 模型以为没执行,重发因为历史里有一个没有结果的工具调用。恢复时要么补上结果,要么从更早的完整轮次恢复。
正确顺序:
- 先验证世界:测试、diff、副作用;
- 再决定恢复点:最后一条完整轮次;
- 补配对:
tool_use/tool_result成对; - 再 resume。
resume 是“读档”,不是“时光倒流”。读档之后,世界可能已经变了。
20.6 降级:不是所有失败都要重试
有些失败,重试没用;降级更有用:
- 只读模式:写工具/命令被禁,先让 Agent 查清楚;
- 小模型:复杂任务失败,先用便宜模型做分类/摘要;
- 排队:外部服务限流,进队列,别硬打;
- 人工接管:高风险操作,转审批;
- 熔断:连续失败到阈值,停止重试,报警。
降级的目标不是“成功”,是“别把系统拖垮”。
只读模式在写工具连续失败时禁写/命令,代价是任务可能做不完;小模型在复杂任务超时时先做分类/摘要,代价是质量下降;排队适合外部限流,代价是时延变长;人工接管适合高风险操作,代价是等人;熔断在连续失败 N 次时停止重试/报警,代价是任务失败。
降级要显式记录:用户/运维要知道“这次是降级跑出来的”,不要静默降低质量。
20.7 真实故障复盘
拿前面几章的真实失败做分类:
- 第 15 章:Stop 要求测试,12 轮撞墙;根因是
Bash不在白名单,Stop 又要求跑测试;修法是给测试命令权限,或改 Stop 条件。 - 第 13 章:8 轮
error_max_turns;根因是max_turns太紧;修法是 12 轮或拆任务。 - 第 10 章:10 轮 $0.17;根因是找文件、跑测试多花了轮次;修法是工具面/提示/Stop。
- 第 14 章:未跑;不是失败,是覆盖缺口(1M 上下文 + 缺长 fixture)。
这些都不是“模型随机性”。大多数“Agent 不稳定”,是配置、循环、权限、预算的问题。
如果你要补故障注入,至少覆盖五类:中途 kill CLI(会话可恢复、检查点可用)、工具里 sleep 超过阈值(超时、清理、不重复副作用)、mock 返回 529(退避重试、超次数失败)、工具抛异常/is_error(模型换路、不原样重试)、拒绝 Bash/Edit(停止、提示改权限/问人)。
这次没有做故障注入;上面是计划清单,不是已验证结果。别把“代码里有重试”当成“故障时一定恢复”。
20.8 重试、恢复、降级怎么选
一张决策表:
| 情况 | 动作 | 理由 |
|---|---|---|
| 读操作超时 | 重试 | 幂等,代价低 |
| 写操作超时,未知是否执行 | 先查状态,再决定 | 可能重复副作用 |
| 进程崩溃,会话完整 | resume | 读档继续 |
会话最后一条是 tool_use | 补结果/回退到完整轮次 | 避免重复调用 |
| 权限拒绝 | 不重试;改权限/问人 | 重试不会变 |
| 预算/轮数耗尽 | 不重试;加预算/拆任务 | 重试只会再撞 |
| 连续失败到阈值 | 熔断/降级 | 别拖垮系统 |
| 高风险写操作 | 人工确认 | 自动重试代价太大 |
一句话:能重试的只有“幂等 + 暂时性”的失败;其余要么恢复,要么降级,要么停。
20.9 上线前检查清单
- 每个外部调用有超时吗?
- 重试有上限、退避、jitter 吗?
- 有副作用的操作幂等吗?有幂等键吗?
-
resume前验证世界了吗? -
tool_use/tool_result成对吗? - 预算/轮数有刹车吗?
- 连续失败会熔断/降级吗?
- 失败会留下可审计的记录吗?
八条里过不了五条,别让它自动重试。
20.10 Harness 视角:失败处理是第五问
重试/恢复不是“外挂”,是 Harness 的“失败怎么办”:状态机决定能不能重复执行,resume 决定能不能续接,幂等决定重试安不安全,降级决定要不要停。
第 22–24 章的项目,会把状态机/幂等/审批持久化接起来。
20.11 下一章:安全
可靠性解决“失败时不更糟”,安全解决“被攻击时拿不走东西”。
第 21 章讲威胁模型、提示注入、密钥、沙箱、工具供应链和审计。
本章代码 重试/恢复包装:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/sdkagent/ch20_reliability.py 实测环境:SDK 0.2.168 + CLI 2.1.296 +
deepseek-flash;只验证了 happy path,失败/退避/恢复路径未真实触发。 说明:这是演示包装,不是生产级重试库;生产环境要结合幂等键、检查点和告警。