第 16 章 容错:重试、超时与兜底
本章目标:让图在故障面前活下来。掌握三个层次的容错手段——节点级重试(RetryPolicy)、节点级超时与异常兜底、以及"错误重跑"(failure + replay 组合拳)。LLM 应用调的是不稳定的外部服务,容错…
本章目标:让图在故障面前活下来。掌握三个层次的容错手段——节点级重试(RetryPolicy)、节点级超时与异常兜底、以及"错误重跑"(failure + replay 组合拳)。LLM 应用调的是不稳定的外部服务,容错不是可选项。
16.1 LLM 工作流的故障面
传统代码的故障源相对收敛(机器挂、依赖挂),LLM 工作流的故障面宽得多:
| 故障源 | 典型表现 | 频率 |
|---|---|---|
| 模型服务 | 429 限流、5xx、超时 | 高(外部服务,天然不稳) |
| 结构化输出 | 模型返回不合 schema 的 JSON | 中(温度、prompt 问题) |
| 工具执行 | 下游 API 挂、参数幻觉 | 中 |
| 幻觉行为 | 无限循环调工具、路由死循环 | 低但致命(recursion_limit 爆炸) |
| 进程级 | 断电、OOM、部署重启 | 低 |
对应四个层次的治理,本章按"由小到大"讲:
- 瞬时故障:重试解决(16.2)
- 慢故障:超时解决(16.3)
- 确定故障:兜底节点解决(16.4)
- 已发生的中途失败:重跑解决(16.5)
16.2 节点级重试:RetryPolicy
最高频的故障是瞬时的:模型 429 了、下游抖了一下。这种故障的正确反应不是绕过而是等一会儿再来一次。
LangGraph 把重试做成编译期策略,按节点粒度挂载:
from typing import Annotated, TypedDict
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.pregel import RetryPolicy
class ScreenState(TypedDict):
resume_text: str
score: Annotated[int, operator.add]
log: Annotated[list[str], operator.add]
# 模拟一个不稳定的模型调用:前两次失败,第三次成功
_attempts = {"count": 0}
def flaky_evaluate(state: ScreenState) -> dict:
_attempts["count"] += 1
if _attempts["count"] < 3:
raise ConnectionError(f"模型服务暂时不可用(第 {_attempts['count']} 次)")
return {"score": 75, "log": ["evaluate 成功(第 3 次尝试)"]}
def report(state: ScreenState) -> dict:
return {"log": [f"最终得分 {state['score']}"]}
builder = StateGraph(ScreenState)
builder.add_node(
"evaluate",
flaky_evaluate,
retry=RetryPolicy(initial_interval=1.0, # 首次重试等 1 秒
backoff_factor=2.0, # 指数退避:1s → 2s
max_attempts=3), # 最多尝试 3 次
)
builder.add_node("report", report)
builder.add_edge(START, "evaluate")
builder.add_edge("evaluate", "report")
builder.add_edge("report", END)
graph = builder.compile()
result = graph.invoke({"resume_text": "李思远 ..."})
print(result["log"])
# ['evaluate 成功(第 3 次尝试)', '最终得分 75']
前两次抛出的 ConnectionError 被引擎静默吞掉、按退避策略重试,第三次成功——图正常走完,调用方完全无感。
RetryPolicy 的参数语义:
| 参数 | 含义 |
|---|---|
initial_interval |
首次重试前等待秒数 |
backoff_factor |
每次重试后等待时长的乘数(指数退避) |
max_attempts |
总尝试次数上限(含首次) |
retry_on |
哪些异常才重试(默认仅限"可重试"的通用异常) |
retry_on 值得展开一句。默认策略故意不重试所有异常:参数错误、逻辑 bug 这类确定性失败,重试一万次也是同样的错,盲目重试只会拖长故障、烧掉配额。默认只对网络类/限流类"值得再来一次"的异常生效。如果你的自定义异常需要被重试,显式传 retry_on= 自定义判断函数。
重试期间发生的事:节点整体失败、引擎捕获、按策略等待重试。注意重试的是"整个节点"——节点内的计数器、已发出的部分副作用不会回滚。含副作用的节点要考虑重入安全(与第 14 章 interrupt 的重入纪律同源)。
16.3 超时:别让一个节点拖死整张图
重试治"失败得快"的病,治不了"失败得慢"的:模型卡死、下游 hang 住——节点不抛错也不返回,整个图陪着挂。超时是这类问题的解法。
LangGraph 节点本身没有内建 timeout 参数(重试有、超时没有——设计上把"等多久"留给了节点的实现方式),主流做法是在节点内部控制:
import asyncio
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(
model="gpt-4o-mini",
timeout=30, # LangChain 模型自带的请求超时
max_retries=2, # SDK 层自动重试
)
async def evaluate(state) -> dict:
# 节点内部再包一层总闸:整个节点 45 秒拿不下就放弃
try:
result = await asyncio.wait_for(
llm.ainvoke(build_prompt(state)),
timeout=45,
)
return {"score": parse_score(result)}
except asyncio.TimeoutError:
raise RuntimeError("evaluate 节点超时")
三层超时的分工想清楚,别叠床架屋:
- SDK 层(
timeout=30):单次 HTTP 请求超时,管"这一次调用" - 节点层(
asyncio.wait_for):节点总预算,管"重试加起来不能超过多少" - 调用层(外层服务的整体 deadline):业务承诺,管"整个任务多久必须给用户交代"
节点超时抛出的异常会进入 16.2 的重试策略管辖——超时后重试一次是合理的(可能只是网络抖动)。两层配合:瞬时故障重试解决,慢性故障超时切断。
16.4 兜底:fail-safe 节点
有些失败不该重试也不该中断整图:某份简历的背景核查挂了,凭什么整批评估报废?业务上的正确反应是——降级处理,继续走。
模式:条件边做异常分流,兜底节点产出"体面的失败":
from typing import Annotated, TypedDict
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command
class ScreenState(TypedDict):
resume_text: str
bg_report: str
score: Annotated[int, operator.add]
log: Annotated[list[str], operator.add]
SERVICE_OK = False # 改成 True 模拟下游恢复
def background_check(state: ScreenState) -> Command:
try:
if not SERVICE_OK:
# 模拟不稳定的下游:真实场景这里是外部调用
raise ConnectionError("背景核查服务不可用")
return Command(
update={"bg_report": "无风险记录", "score": 5, "log": ["background_check 成功"]},
goto="evaluate", # 成功:正常流转
)
except ConnectionError as e:
# 失败信息写入状态,流向兜底节点
return Command(
update={"log": [f"background_check 失败:{e}"]},
goto="bg_fallback", # 异常分流:去兜底节点
)
def bg_fallback(state: ScreenState) -> dict:
# 降级策略:标记"未核查",流程继续
return {
"bg_report": "背景核查暂不可用(已降级跳过)",
"score": 0, # 不加分也不扣分
"log": ["bg_fallback:降级处理,流程继续"],
}
def evaluate(state: ScreenState) -> dict:
return {"score": 80, "log": ["evaluate 完成"]}
builder = StateGraph(ScreenState)
builder.add_node("background_check", background_check) # 返回 Command:不挂出边
builder.add_node("bg_fallback", bg_fallback)
builder.add_node("evaluate", evaluate)
builder.add_edge(START, "background_check")
builder.add_edge("bg_fallback", "evaluate")
builder.add_edge("evaluate", END)
graph = builder.compile()
result = graph.invoke({"resume_text": "李思远 ..."})
print(result["bg_report"]) # 背景核查暂不可用(已降级跳过)
print(result["score"]) # 80(背景分 0 + 评估 80)
print(result["log"])
兜底节点的设计要点:
- 降级要有业务语义。"跳过"要留痕(bg_report 写明原因)、计分规则要自洽(该环节 0 分而不是 -100)、下游消费方要知道这是降级产物。
- 区分"可降级"与"不可降级"。背景核查可以跳过;"发 offer"步骤挂了绝不能静默跳过——不可降级的失败应当让图失败,交给 16.5 的重跑。哪些环节可降级,是业务决策,写进代码评审清单。
- 与 ToolNode 的错误回传呼应(第 8 章):工具级失败默认回传给模型自愈,节点级失败才走兜底分流。粒度选对,别把模型能自愈的失败升级成图级兜底。
16.5 失败之后:重跑而不是从头来
最后一层:图真的失败了(重试耗尽、超时、不可降级),怎么办?
没有持久化时只有一条路:修好问题、从头 invoke——30 份简历重新评一遍,前面成功的 16 份白跑。挂了 checkpointer 之后,正解是第 12、13 章的组合拳:
# 失败发生后:
config = {"configurable": {"thread_id": "batch-042"}}
snap = graph.get_state(config)
print(snap.next) # ('evaluate_17',) —— 失败点:第 17 份简历的评估节点
# 修好外部依赖后,从失败点重跑(输入 None = 用检查点原状态)
for chunk in graph.stream(None, config, stream_mode="updates"):
print(chunk)
# 从 evaluate_17 继续,前 16 份的结果直接从检查点恢复,不重算
要点在于理解"失败时检查点停在哪":节点失败没有产生新检查点,线程的最新检查点停在失败节点即将执行前——snap.next 就是失败点。修好依赖后从那里续跑,等于把失败节点"再执行一次"。
这套机制的产品化形态,还可以配合第 13 章 fork:不确定修复是否有效时,先从失败检查点 fork 一条时间线试跑,成功再切回主线。
把四层手段串成决策树,作为本章的收束:
常见坑与排错
-
重试加在确定性失败上。schema 解析错误、代码 bug 被 RetryPolicy 反复重试——每次都失败,白等几十秒才抛出。确认
retry_on只覆盖瞬时性异常;确定性失败让它快速失败,走兜底或修复。 -
重试节点不重入安全。节点发了一半通知后失败、重试再发一遍——用户收到两条。副作用动作要么幂等(带去重键),要么挪到节点末尾确保"发完了才算成功"。
-
只有 SDK 超时没有节点超时。SDK 超时管单次请求,节点内部如果有多次调用 + 自己的重试,总时长可能远超预期。给节点套总闸(16.3 的 wait_for)。
-
兜底节点吞掉一切。catch-all 式的兜底把真正的 bug 也降级掉了——图永远"成功",问题永远被掩盖。兜底必须留痕(log 字段、监控计数),并区分"预期的降级"与"意外的失败"。
-
重跑时忘修根因。从检查点重跑只是"再试一次",如果外部依赖还是坏的,会再失败一次。重跑前先确认故障已排除——就像别对着 503 疯狂刷新。
-
同线程重跑撞上未结束的运行。失败的 invoke 若还在重试循环中(慢故障),此时又发起 stream 续跑——同线程并发(12.3 的禁区)。确认旧运行真正终止再重跑。
-
在 reducer/路由函数里做容错。reducer 要纯(第 3 章)、路由函数要哑(第 4 章)——try/catch 放这两个地方会污染可重放性。容错属于节点和编译期策略,不属於状态合并与路由。
动手练习
-
把 16.2 的 demo 改成"永远失败"(flaky 函数每次都 raise),配合
max_attempts=3:测量整个 invoke 耗时,验证退避节奏(约 1s + 2s)。再把retry_on收紧为只重试ConnectionError,抛ValueError验证不再重试。 -
给 16.4 的图加上"不可降级"分支:
evaluate节点失败时(模拟 raise),让图整体失败;然后修复问题,从失败检查点重跑,验证前面的 background_check 环节没有重复执行(log 里只有一条记录)。 -
综合演练:一个四节点图(parse → search → evaluate → report),search 是不稳定的(随机 30% 失败)。要求:search 瞬时失败重试 2 次;重试耗尽后降级(报告标注"检索不可用");整个流程无论怎样都能出 report。分别触发三条路径验证。
本章速查
| API / 概念 | 一句话 |
|---|---|
RetryPolicy(initial_interval, backoff_factor, max_attempts) |
节点级重试策略,compile 前挂到 add_node |
retry_on |
只重试瞬时异常;确定性失败快速失败 |
| 指数退避 | 等待时长 ×backoff_factor 递增,避免雪崩式重试 |
| 节点超时 | 引擎不管,节点内 asyncio.wait_for 自设总闸 |
| 三层超时 | SDK 单请求 → 节点总预算 → 调用方 deadline |
| 兜底节点 | try/except + Command 分流:可降级失败降级继续 |
| 失败重跑 | 失败线程最新检查点停在失败点前;修复后 stream(None, config) 续跑 |
| 四层决策 | 瞬时重试 → 慢故障超时 → 可降级兜底 → 不可降级失败后重跑 |
| 重入安全 | 重试与 interrupt 共同的纪律:副作用幂等或后置 |