第 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) # 失败?从头再来一遍问题:
- 浪费:跑到第 20 步崩了,前 19 步的成果全丢,重来等于白干。
- 未必能好:如果失败原因是确定性的(比如某个搜索 API 一直挂),重来一百次也没用。
- 可能死循环:重试失败 → 再重试 → 再失败,无限循环,烧钱烧时间。
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"}这个循环里有四个值得记住的细节:
- 限制次数(
max_plan_iterations):重规划必须封顶,否则「信息永远不够 → 无限重规划」就是新的死循环。所有循环都需要收敛条件,包括容错循环本身。 - 带上原因(feedback):回头重规划时,要告诉 Planner「为什么要重来、缺什么」。否则 Planner 会重产出和上次一样的计划,重规划就白做了。失败的信号,要带着上下文传回去。
- 层级的正确性:它重规划的是「计划」,不是「整个任务」。前面步骤的成果在
step_results里,不会丢。 - 兜底(次数用尽硬出报告):宁可出一份「标注了不足」的报告,也不允许系统无限循环下去。「降级完成」永远优于「永不完成」。
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」。如果做不到这一点(比如失败原因很难结构化描述),退而求其次,还有什么办法保证「重来不是同样的错」?
参考答案:
如果无法精确描述「为什么失败」,至少有三种「模糊但有效」的兜底:
- 注入变化(vary the retry):强制下一次尝试与上次不同——换模型、换工具、换搜索关键词、加一个「这次换个角度」的提示。只要不是一模一样地重来,就有概率跳出局部失败。
- 降低目标(降级):把「查全 10 个公司」降级成「查清 3 个最关键的」,宁可交付一个范围缩小但完整的结果,也不要一个范围很大但残缺的结果。
- 显式失败(fail loud):如果实在不行,明确报错并说明卡在哪,而不是给出一个看似完成的假结果。回到第 6 章的教训——「假成功」比「真失败」危险得多。
核心心法:重试的目的不是「重复」,而是「改变」。 如果一次重试和上次完全一样,它就只是把失败又演了一遍。容错设计的灵魂,在于保证「每一次重来都带着新的信息或新的变化」。