DAY 39 / PHASE 4 · PRODUCTION

Agent 错误恢复与韧性

Retry · Idempotency · Checkpoint · Circuit Breaker

2026-06-18 · BigCat

Demo 里的 agent 永远成功;production 里的 agent 大部分时间在处理失败。

// WHY THIS MATTERS

Agent 的 happy path 谁都会写:while 循环 + tool call + 模型决定停。但生产环境里,90% 的工程量在 happy path 之外——网络抖、rate limit、tool 超时、模型吐半个 JSON、第 38 步崩了前 37 步白跑、retry 把同一封邮件发了三遍。这些不是 prompt 能解决的,是 harness 的韧性设计。资深分布式工程师对这套东西很熟(retry / 幂等 / saga / 熔断 / checkpoint),但 agent 把它们重新搅在一起:因为多了一个非确定性的决策层,很多传统 retry 直觉会失效——模型自己会"再调一次",模型自己会因为一个错误而放弃。这一期讲四件最容易出线上事故的事:retry 的两层结构、副作用工具的幂等与补偿、长任务的 checkpoint 续跑、超时熔断与部分失败的降级。

// 01

Retry 的两层结构:别把传输错误和语义错误混在一个 retry 里

论断:agent 的 retry 必须分层——传输层(确定性、程序重试)和语义层(非确定性、喂回模型)混在一起是头号 bug。

背景与原理

agent 里有两种"失败",处理方式完全相反。

头号 bug 是把两层搞反:把 429 当语义错误喂回模型(模型不会退避,继续猛打依赖),或把"文件不存在"当传输错误程序重试(死循环)。判别只有一句话:错误是否与输入无关、且只是瞬时的?是→程序退避;否→喂回模型。

一次 tool 调用失败了,往哪走? │ ┌─────────┴──────────┐ ▼ ▼ ┌─────────────────┐ ┌──────────────────┐ │ 传输层 / 瞬时 │ │ 语义层 / 确定 │ │ 429·503·timeout │ │ 参数错·文件不存在 │ └────────┬────────┘ └────────┬─────────┘ ▼ ▼ 程序退避 + full jitter error → tool_result 重试 N 次后 fail-fast 喂回模型,让它换法子 (模型看不到) (程序不重试)

实战示例

import time, random, anthropic

TRANSIENT = (anthropic.RateLimitError, anthropic.APITimeoutError,
             anthropic.InternalServerError)

def call_with_backoff(fn, max_retries=5):
    for attempt in range(max_retries):
        try:
            return fn()
        except TRANSIENT as e:        # 传输层:确定性瞬时故障 → 程序退避
            if attempt == max_retries - 1: raise
            # full jitter:随机化整个退避窗口,避免 thundering herd
            time.sleep(random.uniform(0, min(60, 2 ** attempt)))
        # 注意:语义错误(tool 报错)不在这里 catch——
        # 它们走 tool_result 喂回模型,由模型决定下一步
失败模式:把所有 exception 一把 catch + retry。结果:一个永久错误(401 Unauthorized、参数非法)被退避重试 5 次,每次都失败,只是把故障延迟 60 秒才暴露,还白烧了配额。retry 只对"瞬时且确定"的故障有意义;permanent error 必须 fail-fast
进阶资源 · Marc Brooker / AWS Exponential Backoff And Jitter, aws.amazon.com/blogs/architecture · AWS Builders' Library Timeouts, retries, and backoff with jitter, aws.amazon.com/builders-library
// 02

幂等与补偿:agent 一定会重复执行,副作用工具必须幂等

论断:agent 的 retry / 续跑安全性 100% 取决于 tool 的幂等性,而不是 retry 逻辑写得多漂亮。

背景与原理

retry 和断点续跑有个隐藏前提:被重试的操作可以安全地执行多次。读操作天然幂等(读两遍文件没事),但写操作不是:create_order / send_email / charge_card 重试一次 = 客户被扣两次款。agent 比传统系统更危险,因为它的"重试"有两个来源:程序层 backoff,和模型层——模型看到 tool_result 含糊(超时,不知道成没成功)时,会"保险起见再调一次"。这恰恰是最危险的时刻,因为首次可能已经成功了。

两个工程手段:

实战示例

# 1) 幂等键:让重试安全。key 由 task 派生,而非随机
def book_flight(args):
    key = f"book:{args['trip_id']}:{args['flight_no']}"   # 确定性 key
    return api.post("/bookings", json=args,
                    headers={"Idempotency-Key": key})       # 服务端去重

# 2) 补偿:每个副作用 step 登记一个 undo,失败时逆序回滚
saga = []
try:
    r1 = book_flight(...);  saga.append(lambda: cancel_flight(r1.id))
    r2 = charge_card(...);  saga.append(lambda: refund(r2.id))
    r3 = book_hotel(...)                  # ← 这一步失败
except Exception:
    for undo in reversed(saga):     # 逆序补偿:先退款,再退票
        undo()
    raise
失败模式:依赖"模型不会重复调用"。当 tool_result 是 network timeout(真不知道服务端执行了没)时,模型经常会再调一次——而幂等键是唯一可靠防线。不能靠 prompt 叮嘱模型"不要重复",那是把安全性建在非确定性的决策层上。
进阶资源 · Stripe Idempotent requests, docs.stripe.com/api/idempotent_requests · Stripe Designing robust APIs with idempotency, stripe.com/blog/idempotency
// 03

Checkpoint 与断点续跑:agent state 必须外置,内存 loop 是玩具

论断:长任务必须能从 checkpoint 续跑而非从头重来;前提是 agent state 外置可持久化。

背景与原理

一个跑 40 步、半小时的 agent,在第 38 步进程崩了(OOM、部署重启、抢占式机器被回收)。如果 state 只活在内存的 messages 数组里,前 37 步全部白跑——而那 37 步的 token 和副作用已经花掉了。生产 agent 必须把 state 持久化到进程外:每完成一步就 checkpoint 一次,崩溃后从最后一个 checkpoint 恢复。这就是 durable execution 的核心。两种落地:

关键是 checkpoint 的粒度:太细(每个 token)开销大,太粗(只在结束)等于没有。实践上以"完成一次模型调用或一个有副作用的 step"为单位。且 checkpoint 必须配合幂等(§2)——恢复时最后那个 step 可能只执行了一半。

实战示例

import json, os
def agent(task, ckpt_path):
    if os.path.exists(ckpt_path):                    # 续跑:从 checkpoint 恢复
        s = json.load(open(ckpt_path)); msgs, step = s["msgs"], s["step"]
    else:
        msgs, step = [{"role":"user","content":task}], 0
    while step < MAX_STEPS:
        r = client.messages.create(model=MODEL, messages=msgs,
                                   tools=SCHEMAS, max_tokens=4096)
        msgs.append({"role":"assistant","content":r.content})
        if r.stop_reason == "end_turn": break
        results = run_tools(r.content)               # 执行工具(须幂等!)
        msgs.append({"role":"user","content":results}); step += 1
        json.dump({"msgs":msgs,"step":step}, open(ckpt_path,"w"))  # 每步落盘
失败模式:checkpoint 了 state 但 tool 不幂等——崩溃发生在"tool 已执行、checkpoint 未写"之间,恢复后重跑该 tool,副作用执行两遍。checkpoint 和幂等是一对,缺一不可。另一个坑:把不可序列化的对象(file handle、socket)塞进 state,恢复时直接炸。
进阶资源 · LangChain Persistence / Checkpointers, docs.langchain.com/.../persistence · Temporal Durable Execution meets AI, temporal.io/blog
// 04

超时、熔断与部分失败:区分"可降级"和"必须停"

论断:默认让任何 tool 失败就整体 fail 是反模式;成熟 agent 对每类失败有明确的降级策略。

背景与原理

三个相关机制:

Circuit Breaker 三态机 ┌────────┐ 连续失败≥N ┌────────┐ │ CLOSED │ ───────────▶ │ OPEN │ │ 正常放行│ │ 快速失败│ └────────┘ ◀─────────── └───┬────┘ ▲ 试探成功 │ 冷却 reset_after │ ▼ │ ┌──────────────────────┐ └──── │ HALF-OPEN 放一个试探 │ 试探失败→OPEN └──────────────────────┘

实战示例

class CircuitBreaker:
    def __init__(self, fail_max=5, reset_after=30):
        self.fail_max, self.reset_after = fail_max, reset_after
        self.fails, self.opened_at = 0, None
    def call(self, fn):
        if self.opened_at:                          # OPEN
            if time.time() - self.opened_at < self.reset_after:
                raise CircuitOpen("依赖熔断中,快速失败")
            self.opened_at = None                   # → HALF-OPEN:放一个试探
        try:
            out = fn(); self.fails = 0              # 成功 → CLOSED
            return out
        except Exception:
            self.fails += 1
            if self.fails >= self.fail_max:
                self.opened_at = time.time()       # → OPEN:跳闸
            raise
失败模式:(1)只有单 tool 超时、没有 task 级总 deadline——agent 在死循环里每步都不超时,却永远跑不完,直到烧光预算。必须有顶层时限。(2)把部分失败硬编码成"任何 worker 失败 = 整体失败",让一个 flaky 的边缘 worker 拖垮整个高价值任务。降级策略应由 orchestrator 按子任务重要性决定,而非一刀切。
进阶资源 · Martin Fowler CircuitBreaker, martinfowler.com/bliki/CircuitBreaker · HumanLayer 12-Factor Agents(own your control flow / errors), github.com/humanlayer/12-factor-agents

// 综合实战 · 把玩具 agent 改造成可恢复 agent

拿你手上任何一个 100 行的 demo agent,按这张 checklist 逐项加固——每一项都对应上面一个失败模式:

  1. 分层 retry:给 messages.create 和网络型 tool 套 call_with_backoff(full jitter);语义错误包进去,原样喂回模型。
  2. 给副作用工具加幂等键:列出所有"写"类 tool,每个带一个从任务派生的 Idempotency-Key;不能改服务端的,就自己存一张"已执行 key"表去重。
  3. 每步 checkpoint:把 {msgs, step} 每轮落盘(或 LangGraph checkpointer);启动时先尝试恢复。验证:跑到一半 kill -9,重启应从断点继续而非从头。
  4. 三层超时 + 一个熔断:单 tool 超时、task 顶层 deadline、对最常挂的那个外部依赖包一个 CircuitBreaker
  5. 部分失败降级:若有并行 fan-out,把 gather 改成"收集成功 + 标注失败",让上层决定继续还是停。
  6. 注一行账:retry 次数、熔断跳闸、checkpoint 恢复都打点——韧性出问题时,没有这几个指标你根本看不见。

做完你会发现:代码从 100 行涨到 300+ 行,多出来的 200 行没有一行是"功能",全是韧性——这正是 demo agent 和 production agent 的真实差距。

// ENGLISH GLOSSARY

Transient vs Permanent Error
瞬时故障(429/超时,可退避重试)vs 永久故障(401/参数错,应 fail-fast)。判别是 retry 设计的起点。
Full Jitter
把退避时间随机化到整个 [0, backoff] 窗口,避免客户端同步化反复撞击。AWS Marc Brooker 推荐。
Idempotency Key
客户端生成的唯一键,让服务端识别重试、对同一操作只执行一次。副作用工具的安全前提。
Saga / Compensation
无全局事务时,用显式"补偿动作"逆序撤销已成功步骤的副作用。
Checkpoint
把 agent state 持久化到进程外的快照,崩溃后从此处续跑而非重来。
Durable Execution
让长任务可崩溃恢复的执行模型(LangGraph checkpointer、Temporal)。
Circuit Breaker
下游连续失败即"跳闸"快速失败的三态机:closed / open / half-open。
Partial Failure
并行子任务中部分失败。成熟做法是降级而非整批 fail。
Deadline
任务顶层时限。防止"每步都不超时却永远跑不完"的隐性死循环。

// 深入思考

把传输层 retry 完全藏在程序里、不让模型知道,会不会让模型对"环境有多脆"形成错误认知?什么时候反而该告诉它?
多数时候该藏——模型不需要也不该为 429 退避负责,把它暴露反而污染推理。但有两个例外值得告诉模型:一是退避后仍永久失败(依赖真挂了),此时让模型知道"这条路堵死了",它能换一个工具或方案;二是退避导致显著延迟且模型在做时间敏感决策时。原则:传输层故障的"过程"对模型隐藏,但"最终结论"(成功/永久失败)必须作为 tool_result 让模型看到,否则它会在错误前提上继续规划。
幂等键要"从任务派生"。但 agent 的任务描述是自然语言、每次措辞不同,怎么派生出稳定的 key?
不要用自然语言任务做 key,用结构化的操作语义做。key 应来自工具调用的规范化参数,而非上层指令:charge:{user_id}:{order_id}:{amount} 这种由确定字段拼出的 key,无论模型怎么措辞、重试几次都一样。如果操作本身缺乏天然唯一标识(如"发一条鼓励消息"),就在 agent 进入该 step 时由 harness 生成一个 key 并写进 checkpoint——续跑时复用 checkpoint 里的 key。本质:幂等键的稳定性来自 harness 层的确定性派生,而不是模型输出。
checkpoint + 幂等能让 agent 崩溃后续跑。但如果崩溃本身是模型决策导致的死循环,续跑只会忠实地重演死循环。怎么破?
这是 durable execution 的盲区:它保证"忠实恢复",但忠实恢复一个坏状态毫无价值。需要在 checkpoint 之上加循环检测:记录最近 K 步的 (tool, 规范化参数) 指纹,连续重复就强制 break 或升级给人。更深一层,续跑时不该盲目 replay,而应在恢复点注入一条 meta 信息:"你在此处崩溃过/循环过,换个策略"。区分两类崩溃很关键——基础设施崩溃(OOM/重启)该原样续跑,逻辑崩溃(死循环/反复失败)该带反思续跑。
熔断保护下游,但对 agent 有个副作用:跳闸后那个工具"消失"了。模型会怎么反应?这对 tool registry 设计有什么启示?
模型会困惑——它上一轮还在用的工具,这轮突然报 CircuitOpen。如果只回一个干巴巴的异常,模型可能反复重试(撞在熔断上)或直接放弃任务。更好的做法是把熔断状态翻译成模型能用的语义:"X 服务暂时不可用,预计 30s 后恢复,请改用 Y 或先做其它步骤"。这指向一个设计原则:tool 的可用性也是一种动态状态,harness 应把它作为 context 的一部分主动告知模型,而不是只在调用失败时被动报错。熔断本质是把"基础设施健康度"提升为"模型可见的决策输入"。
这一期全是分布式系统二十年前就解决的老问题。agent 真的带来了"新"的韧性挑战,还是只是把旧方案换个壳?
八成是旧方案,但有一成是真新的,且恰恰是最难的那一成。旧的部分:retry/幂等/saga/熔断/checkpoint 完全照搬。新的部分来自非确定性决策层:传统系统的控制流是程序写死的,可以静态分析、形式化验证;agent 的控制流由模型即时生成,你无法预知它下一步调什么。于是"重试是否安全"从一个可证明的属性,退化成一个概率问题——模型可能在最坏的时刻重复操作。这让幂等从"最佳实践"变成"生死线"。另一个新问题:传统补偿事务的步骤是已知的,agent 的步骤是涌现的,你得为"还没发生、但模型可能做"的副作用预先设计补偿。

// 延伸阅读