首页/LangGraph 框架指南/第 16 章 容错:重试、超时与兜底
LangGraph 框架指南 · 第 16 章

第 16 章 容错:重试、超时与兜底

本章目标:让图在故障面前活下来。掌握三个层次的容错手段——节点级重试(RetryPolicy)、节点级超时与异常兜底、以及"错误重跑"(failure + replay 组合拳)。LLM 应用调的是不稳定的外部服务,容错…

约 17 分钟6,726 字5 个代码示例

本章目标:让图在故障面前活下来。掌握三个层次的容错手段——节点级重试(RetryPolicy)、节点级超时与异常兜底、以及"错误重跑"(failure + replay 组合拳)。LLM 应用调的是不稳定的外部服务,容错不是可选项。

16.1 LLM 工作流的故障面

传统代码的故障源相对收敛(机器挂、依赖挂),LLM 工作流的故障面宽得多:

故障源 典型表现 频率
模型服务 429 限流、5xx、超时 高(外部服务,天然不稳)
结构化输出 模型返回不合 schema 的 JSON 中(温度、prompt 问题)
工具执行 下游 API 挂、参数幻觉 中
幻觉行为 无限循环调工具、路由死循环 低但致命(recursion_limit 爆炸)
进程级 断电、OOM、部署重启 低

对应四个层次的治理,本章按"由小到大"讲:

  1. 瞬时故障:重试解决(16.2)
  2. 慢故障:超时解决(16.3)
  3. 确定故障:兜底节点解决(16.4)
  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"])

兜底节点的设计要点:

  1. 降级要有业务语义。"跳过"要留痕(bg_report 写明原因)、计分规则要自洽(该环节 0 分而不是 -100)、下游消费方要知道这是降级产物。
  2. 区分"可降级"与"不可降级"。背景核查可以跳过;"发 offer"步骤挂了绝不能静默跳过——不可降级的失败应当让图失败,交给 16.5 的重跑。哪些环节可降级,是业务决策,写进代码评审清单。
  3. 与 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 一条时间线试跑,成功再切回主线。

把四层手段串成决策树,作为本章的收束:

flowchart TD F[节点失败] --> Q1{瞬时故障?} Q1 -->|是| R[RetryPolicy 重试] Q1 -->|否| Q2{慢故障/挂死?} Q2 -->|是| T[超时切断 → 转入异常处理] Q2 -->|否| Q3{业务上可降级?} Q3 -->|是| FB[兜底节点降级继续] Q3 -->|否| FAIL[图失败] R -->|重试耗尽| Q3 T --> Q3 FAIL --> FIX[修复后从检查点重跑]

常见坑与排错

  1. 重试加在确定性失败上。schema 解析错误、代码 bug 被 RetryPolicy 反复重试——每次都失败,白等几十秒才抛出。确认 retry_on 只覆盖瞬时性异常;确定性失败让它快速失败,走兜底或修复。

  2. 重试节点不重入安全。节点发了一半通知后失败、重试再发一遍——用户收到两条。副作用动作要么幂等(带去重键),要么挪到节点末尾确保"发完了才算成功"。

  3. 只有 SDK 超时没有节点超时。SDK 超时管单次请求,节点内部如果有多次调用 + 自己的重试,总时长可能远超预期。给节点套总闸(16.3 的 wait_for)。

  4. 兜底节点吞掉一切。catch-all 式的兜底把真正的 bug 也降级掉了——图永远"成功",问题永远被掩盖。兜底必须留痕(log 字段、监控计数),并区分"预期的降级"与"意外的失败"。

  5. 重跑时忘修根因。从检查点重跑只是"再试一次",如果外部依赖还是坏的,会再失败一次。重跑前先确认故障已排除——就像别对着 503 疯狂刷新。

  6. 同线程重跑撞上未结束的运行。失败的 invoke 若还在重试循环中(慢故障),此时又发起 stream 续跑——同线程并发(12.3 的禁区)。确认旧运行真正终止再重跑。

  7. 在 reducer/路由函数里做容错。reducer 要纯(第 3 章)、路由函数要哑(第 4 章)——try/catch 放这两个地方会污染可重放性。容错属于节点和编译期策略,不属於状态合并与路由。

动手练习

  1. 把 16.2 的 demo 改成"永远失败"(flaky 函数每次都 raise),配合 max_attempts=3:测量整个 invoke 耗时,验证退避节奏(约 1s + 2s)。再把 retry_on 收紧为只重试 ConnectionError,抛 ValueError 验证不再重试。

  2. 给 16.4 的图加上"不可降级"分支:evaluate 节点失败时(模拟 raise),让图整体失败;然后修复问题,从失败检查点重跑,验证前面的 background_check 环节没有重复执行(log 里只有一条记录)。

  3. 综合演练:一个四节点图(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 共同的纪律:副作用幂等或后置