合集

Claude Agent SDK 实战

已更新 27 章
阅读进度 78%

第 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不要盲重试加预算/拆任务
注入/异常调用读到敏感文件、危险命令停止审计、隔离、改权限

这张表决定了“重试”是不是安全的。你不分类,就会把“权限拒绝”也重试三次,浪费钱还卡住。

重试前先问三个问题:

  1. 这个动作幂等吗? 不幂等,不要自动重试。
  2. 失败是暂时的吗? 5xx/超时可以重试;配置/权限/预算不要。
  3. 重试的钱和时间值得吗? 一次重试可能比原任务还贵。

20.2 重试前先问:这个动作幂等吗

幂等:同样的操作执行一次和执行两次,结果一样。

  • 读文件、搜索、查天气:幂等,可以重试;
  • 改文件、发消息、退款、下单:不幂等,重试可能重复副作用。

对不幂等的操作,三种做法:

  1. 幂等键:退款请求带 request_id,服务端去重;
  2. 检查点:重试前先看“这一步是不是已经做了”;
  3. 人工确认:不确定就停下来问人,不要自动重试。

幂等键的例子:

python
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 是一个最小包装:

python
@dataclass
class RetryPolicy:
    attempts: int = 3
    base_delay: float = 1.0
    max_delay: float = 10.0
    budget_usd: float = 0.3

先看“单次运行”怎么收异常:

python
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 先保存,异常时也返回它;异常按类型分类,决定外层是重试、恢复还是停。

核心循环:

python
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):

text
{'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;整次用外层计时。

python
import anyio


async def call_with_timeout():
    with anyio.fail_after(60):
        return await run_once(prompt, options)

整次任务超时:

python
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 可能重复这个工具调用。

所以超时后要先做清理/检查:

  1. 看最后一条消息是不是完整的;
  2. 看文件/副作用有没有发生;
  3. 决定“补结果”还是“从更早的完整轮次恢复”。

超时不是“结束”,是“进入恢复流程”。


20.5 resume 不是重试

resume 做的是:重新加载 transcript,再发一遍。

它不是“从失败点继续执行”,也不是“撤销上一次”。

最常见的坑:

text
最后一条: tool_use read_file(id=t9)
[resume]
[assistant] tool_use read_file(id=t9)   # 模型以为没执行,重发

因为历史里有一个没有结果的工具调用。恢复时要么补上结果,要么从更早的完整轮次恢复。

正确顺序:

  1. 先验证世界:测试、diff、副作用;
  2. 再决定恢复点:最后一条完整轮次;
  3. 补配对:tool_use / tool_result 成对;
  4. 再 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,失败/退避/恢复路径未真实触发。 说明:这是演示包装,不是生产级重试库;生产环境要结合幂等键、检查点和告警。