第 9 章 · 容错与重规划:出错了怎么办

一句话结论:agent 系统里失败是常态,不是异常。协调设计的一半功力,在于「出错后怎么优雅地重来」——而这里最关键的一个认知是:重试,要重试「正确的那一层」,而不是盲目从头再来。


1. 先承认一个残酷事实

单个 LLM 调用有概率出错,工具(搜索、爬虫、代码执行)更不稳定,agent 多轮叠加后,整体失败率会指数级累积。DeerFlow 团队给过一个刺眼的数字:行业平均任务完成率只有 42%——超过一半的 agent 任务半途而废。

所以一个只会「正常路径」的协调系统,是不能上生产的。真正的功夫在异常路径。


2. 先看「最蠢的容错」:从头再来

def run_research(question):
    try:
        return full_pipeline(question)     # 从头跑一遍完整流程
    except Exception as e:
        log(e)
        return full_pipeline(question)     # 失败?从头再来一遍

问题:

  1. 浪费:跑到第 20 步崩了,前 19 步的成果全丢,重来等于白干。
  2. 未必能好:如果失败原因是确定性的(比如某个搜索 API 一直挂),重来一百次也没用。
  3. 可能死循环:重试失败 → 再重试 → 再失败,无限循环,烧钱烧时间。

3. 思考的分叉:容错的「粒度」和「位置」

维度一:重试「哪一层」?

层重试对象代价适用
工具层单次搜索/爬虫调用最小网络抖动、超时
步骤层某一个 Plan 步骤中等某个步骤跑崩了
计划层重新规划(re-plan)较大发现「方向/信息不够」
任务层整个流程重来最大几乎不用

方向 A:无脑在最外层重试——就是上面的「从头再来」,最糟。

方向 B:分层重试——在哪一层失败,就在哪一层重试,用最小的代价恢复。

  • 工具超时 → 工具层重试(指数退避)。
  • 某步骤结果不对 → 步骤层重跑这一个步骤(检查点帮你只重跑这一格)。
  • 执行完发现信息不够 → 计划层重新规划(re-plan),而不是整个任务重来。

为什么 DeerFlow 走 B:因为「检查点(checkpoint)」让它能精确到「某一格」地恢复,而不是「整张图」地重来。这正是第 4 章埋下的伏笔——状态图 + checkpoint,是分层容错的物理基础。


4. 伪代码:重规划(re-plan)的循环

DeerFlow 1.x 有一个很关键的机制:执行到一半,如果发现「上下文/信息不够」,就回头重新规划,但用 max_plan_iterations 限制次数,防止死循环。

def research_team_node(state):
    for step in iterate_steps(state["plan"]):
        result = route(step).run(step)
        state["step_results"][step.id] = result
 
    # 执行完,判断「信息够不够出报告」
    if insufficient(state["step_results"]):
        if state["replan_count"] < max_plan_iterations:   # ① 限制次数
            state["replan_count"] += 1
            state["feedback"] = "资料不足,请补充这些方向..."   # ② 带上原因
            return {"loop_back": "planner"}                 # ③ 回头重新规划
        else:
            return {"loop_back": "reporter"}                # ④ 次数用尽,硬着头皮出报告
    return {"loop_back": "reporter"}

这个循环里有四个值得记住的细节:

  1. 限制次数(max_plan_iterations):重规划必须封顶,否则「信息永远不够 → 无限重规划」就是新的死循环。所有循环都需要收敛条件,包括容错循环本身。
  2. 带上原因(feedback):回头重规划时,要告诉 Planner「为什么要重来、缺什么」。否则 Planner 会重产出和上次一样的计划,重规划就白做了。失败的信号,要带着上下文传回去。
  3. 层级的正确性:它重规划的是「计划」,不是「整个任务」。前面步骤的成果在 step_results 里,不会丢。
  4. 兜底(次数用尽硬出报告):宁可出一份「标注了不足」的报告,也不允许系统无限循环下去。「降级完成」永远优于「永不完成」。

5. 一个容易被忽视的容错维度:状态持久化

分层容错能落地,前提是每一步的状态都存下来了。这就是 checkpoint 的另一个角色:

# 带 checkpointer 的图:每一步执行后,状态自动存盘
app = graph.compile(checkpointer=checkpointer)
 
# 崩了之后,从「最后一次成功的状态」继续,而不是从头
resume_state = checkpointer.get_latest(thread_id)
app.invoke(None, config={"configurable": {"thread_id": thread_id}})  # 从现场恢复

这个能力和第 8 章的「人机中断恢复」是同一套机制——checkpoint 同时支撑了「容错恢复」和「人机异步协作」。一套基础设施,两处关键能力,这就是状态图的价值密度所在。


思考题

Q:重规划时,我反复强调「要带上失败原因传给 Planner」。如果做不到这一点(比如失败原因很难结构化描述),退而求其次,还有什么办法保证「重来不是同样的错」?

参考答案:

如果无法精确描述「为什么失败」,至少有三种「模糊但有效」的兜底:

  1. 注入变化(vary the retry):强制下一次尝试与上次不同——换模型、换工具、换搜索关键词、加一个「这次换个角度」的提示。只要不是一模一样地重来,就有概率跳出局部失败。
  2. 降低目标(降级):把「查全 10 个公司」降级成「查清 3 个最关键的」,宁可交付一个范围缩小但完整的结果,也不要一个范围很大但残缺的结果。
  3. 显式失败(fail loud):如果实在不行,明确报错并说明卡在哪,而不是给出一个看似完成的假结果。回到第 6 章的教训——「假成功」比「真失败」危险得多。

核心心法:重试的目的不是「重复」,而是「改变」。 如果一次重试和上次完全一样,它就只是把失败又演了一遍。容错设计的灵魂,在于保证「每一次重来都带着新的信息或新的变化」。