Mingyu's Library主页
AI 每日深度 · 2026-07-30AI Daily Deep Dive · 2026-07-30

LangGraph 容错工程
重试、超时与 Saga 补偿

Fault Tolerance in LangGraph
Retries, Timeouts & Saga Compensation

生产环境的 Agent 会遇到原型永远碰不到的故障:限流、断连、卡死的 HTTP 调用、付了款却订不上票。LangGraph 1.2 把「重试、超时、错误处理」做成了图引擎的一等公民——本文讲清这三个原语是什么、怎么用、怎么组合出经典的 Saga 补偿模式。

Production agents hit failures that prototypes never see: rate limits, dropped connections, hung HTTP calls, payments captured for bookings that never complete. LangGraph 1.2 makes retries, timeouts, and error handling first-class citizens of the graph engine — this guide explains what the three primitives are, how to use them, and how they compose into the classic Saga compensation pattern.

调研时间:2026-07-30 · 主要来源:LangChain 官方博客(2026-06-04)与官方文档 · 适用版本:langgraph ≥ 1.2(超时与错误处理器当前为 alpha)

Researched: 2026-07-30 · Primary sources: the official LangChain blog (2026-06-04) and official docs · Applies to: langgraph ≥ 1.2 (timeouts and error handlers currently in alpha)

01开篇速览:30 秒版本

01Overview: The 30-Second Version

LangGraph 把一个 Agent 建模为一张由「节点(node)」组成的图:一个节点调用模型,一个节点执行工具,再加上你想包在这个循环外面的确定性逻辑。因为图的执行由 LangGraph 掌控,所以「某一步失败了怎么办」也顺理成章由它来处理。容错(fault tolerance)说的就是这件事:某一步出错时,系统能自动重试、及时止损、优雅收场,而不是整个任务崩掉从头再来。

LangGraph models an agent as a graph of discrete steps called nodes: one node calls the model, one runs tool calls, plus any deterministic logic you wrap around that loop. Because LangGraph controls execution, it is also the natural place to handle what happens when any step fails. That is what fault tolerance means here: when a step errors out, the system retries automatically, cuts losses on hung calls, and exits gracefully — instead of the whole run collapsing and starting over.

LangGraph 1.2 提供三个可组合的原语,全部通过 add_node() 直接挂在节点上,让容错配置和它保护的业务逻辑写在一起:

LangGraph 1.2 gives you three composable primitives, all attached directly to a node via add_node(), so the fault-tolerance config lives right next to the logic it protects:

① RetryPolicy — 自动重试
对「等一会儿再试大概率就好了」的瞬时错误(网络抖动、5xx、限流),按指数退避自动重试。
① RetryPolicy — automatic retries
For transient errors ("try again in a moment and it'll probably work" — network blips, 5xx responses, rate limits), retry automatically with exponential backoff.
② TimeoutPolicy — 超时上限
给单次节点执行设上限:要么硬性墙钟时间(run_timeout),要么「多久没有进展就算卡死」(idle_timeout)。
② TimeoutPolicy — attempt caps
Cap a single node attempt: either a hard wall-clock limit (run_timeout) or "how long without observable progress counts as hung" (idle_timeout).
③ error_handler — 错误处理器
重试全部耗尽之后才运行的收尾函数,拿到失败上下文,可以更新状态、路由到补偿节点——Saga 模式的入口。
③ error_handler — error handlers
A recovery function that runs only after all retries are exhausted. It receives the failure context and can update state or route to a compensation node — the entry point of the Saga pattern.
原型 Demo 只有顺利路径 跑通 ✓ 生产环境 限流·断连·卡死 跑了几小时的任务 中途失败 → 全部重来? RetryPolicy TimeoutPolicy error_handler
原型只需要顺利路径;生产环境需要三层容错原语兜底(依据:LangChain 官方博客)
Prototype happy path only works ✓ Production rate limits · resets · hangs hours-long run fails halfway → start over? RetryPolicy TimeoutPolicy error_handler
A prototype only needs the happy path; production needs three layers of fault-tolerance primitives (source: LangChain official blog)
# 三个原语都挂在 add_node 上(来源:官方博客)
from langgraph.graph import StateGraph
from langgraph.types import RetryPolicy, TimeoutPolicy

(
    StateGraph(State)
    .add_node(
        "call_llm",
        call_llm,
        retry_policy=RetryPolicy(max_attempts=4, backoff_factor=2.0),
        timeout=TimeoutPolicy(run_timeout=30, idle_timeout=5),
        error_handler=handle_model_failure,
    )
)
# All three primitives attach via add_node (source: official blog)
from langgraph.graph import StateGraph
from langgraph.types import RetryPolicy, TimeoutPolicy

(
    StateGraph(State)
    .add_node(
        "call_llm",
        call_llm,
        retry_policy=RetryPolicy(max_attempts=4, backoff_factor=2.0),
        timeout=TimeoutPolicy(run_timeout=30, idle_timeout=5),
        error_handler=handle_model_failure,
    )
)

02背景与动机:为什么需要引擎级容错

02Why It Exists: Engine-Level Fault Tolerance

写 Agent 的「顺利路径」通常是容易的部分。官方博客一针见血地指出:让它在生产环境活下来的错误处理样板代码——重试、超时、回退——往往比业务逻辑本身还长。没有一等公民支持时,你会在每个节点里重复写同一段包装:

Writing the happy path of an agent is usually the easy part. As the official blog puts it, the error-handling boilerplate that makes it survive production — retries, timeouts, fallbacks — is often longer than the business logic itself. Without first-class support, you end up writing the same wrapper inside every node:

def call_llm(state):
    # 约 25 行「带退避的重试,只重试 5xx、
    # 不重试 4xx、每次尝试打日志、带抖动地 sleep」……
    ...
def call_llm(state):
    # ~25 lines of "retry with backoff, but only on 5xx,
    # don't retry on 4xx, log each attempt, sleep with jitter"
    ...

更深一层的动机是任务的价值在变高。官方博客的原话:Agent 正在获得更多自主权,它们在订机票、提工单、执行支付、调用内部服务——这些动作后果重、难回滚。演示里 1% 的瞬时失败率无伤大雅;放到一个有几十步、有真实世界副作用的生产 Agent 里,失败率会快速复利放大。一个跑了几小时的任务在中途撞上不可恢复错误,「放弃整个运行、彻底重来」不是可持续的运营方式。

The deeper motivation is that the stakes are rising. In the blog's own words: agents are taking on more autonomy and more power to act — booking flights, filing tickets, executing payments, calling internal services. These actions are increasingly high-consequence and difficult to reverse. A 1% transient failure rate is a minor inconvenience in a demo; in a production agent with dozens of steps and real-world consequences, it compounds quickly. When a run that has been going for hours hits an unrecoverable error halfway through, "abandon the run and completely start over" is not a sustainable way to operate.

✦ 为什么放在引擎里,而不是节点里自己 try/except? ✦ Why put this in the engine instead of try/except inside each node?

因为有些保证只有执擎层给得了:重试要在「本次尝试的写入被清空」之后才安全;错误处理器要和检查点(checkpoint,即每一步执行后保存的持久化状态快照)原子地衔接,进程崩溃后恢复时才能「继续处理错误」而不是「重跑失败节点」。这些语义在节点内部的 try/except 里根本做不到。

Because some guarantees only the engine can provide: a retry is only safe after the failed attempt's writes have been cleared; an error handler must hand off atomically with the checkpoint (the persisted state snapshot saved after each step), so that a crashed process resumes into "continue handling the error" rather than "re-run the failed node." A try/except inside the node simply cannot express these semantics.

03核心概念:三原语如何咬合

03Core Concepts: How the Three Primitives Mesh

先把几个术语说成人话,再看它们的固定组合顺序。

First, the vocabulary in plain words; then the fixed order in which the pieces compose.

节点(node)与超步(superstep)
节点是图里的一步(调模型、跑工具、走业务逻辑)。LangGraph 运行时把「一轮并行执行的节点」称为一个超步(superstep)——同一执行周期内一起跑的一批任务。
官方博客注:“in LangGraph, we call an 'execution cycle' a 'superstep'.”
Node & superstep
A node is one step in the graph (call the model, run a tool, execute business logic). The LangGraph runtime calls one round of parallel node execution a superstep — the batch of tasks running in the same execution cycle.
Blog note: "in LangGraph, we call an 'execution cycle' a 'superstep'."
检查点(checkpoint)与持久执行(durable execution)
每个逻辑步骤执行完,状态就存进持久化存储;进程崩了、重启了,从最后一个检查点继续,而不是从头再来。容错三原语都建立在这个地基上。
Checkpoint & durable execution
After every logical step, state is saved to persistent storage; if the process crashes or restarts, the run resumes from the last checkpoint instead of from the beginning. All three fault-tolerance primitives build on this foundation.

三者的组合顺序是固定的(官方文档原文):当节点的一次尝试抛出任何异常——包括超时抛出的 NodeTimeoutError——由重试策略决定是否重试;只有重试全部耗尽后,错误处理器才运行。

The composition order is fixed (per the official docs): when a node attempt raises any exception — including NodeTimeoutError from a timeout — the retry policy decides whether to retry. Only after retries are exhausted does the error handler run.

节点尝试执行 TimeoutPolicy 计时中 抛异常 / 超时 清空本次尝试的写入 NodeTimeoutError 也走这里 RetryPolicy 判断 retry_on 匹配吗? 可重试 → 退避后再次尝试(未达 max_attempts) 重试耗尽 error_handler 运行 拿到 NodeError,可 Command 路由补偿 更新状态 / goto 补偿节点 而不是中止整张图
固定组合顺序:异常 → 清写入 → 重试判断 → 重试耗尽 → 错误处理器(依据:官方文档 Fault tolerance)
Node attempt runs TimeoutPolicy ticking raises / times out Attempt writes cleared NodeTimeoutError included RetryPolicy decides does retry_on match? retryable → back off, try again (until max_attempts) retries exhausted error_handler runs gets NodeError, may Command-route update state / goto recovery instead of aborting the graph
The fixed composition order: exception → clear writes → retry decision → exhaustion → error handler (source: official Fault tolerance docs)
✦ 一处配置默认值 ✦ Configure defaults once

不想每个节点重复写?set_node_defaults(retry_policy=…, error_handler=…) 给全图设默认值,单个节点的同名参数永远优先覆盖默认。

Don't want to repeat yourself on every node? set_node_defaults(retry_policy=…, error_handler=…) sets graph-wide defaults; a per-node parameter of the same name always wins.

04RetryPolicy:把「再试一次」变成声明

04RetryPolicy: Declaring "Just Try Again"

瞬时失败是任何非平凡图里最常见的失败:LLM 供应商返回 5xx、向量库连接被重置、下游 HTTP 服务短暂不可用。这些本质上都是「过一会儿再试大概率就好」的错误。RetryPolicy单次节点尝试生效,支持指数退避、可选抖动(jitter,给等待时间加随机量,避免一群请求同时重试再次压垮服务)和可配置的「哪些异常算可重试」判断:

Transient failures are the most common kind in any non-trivial graph: an LLM provider returns a 5xx, a vector store hits a connection reset, a downstream HTTP service is briefly unavailable. Fundamentally these are "try again in a moment" errors. A RetryPolicy applies per node attempt, with exponential backoff, optional jitter (a random component added to the wait so a crowd of clients doesn't retry in lockstep and re-overwhelm the service), and a configurable predicate for which exceptions count as retryable:

from langgraph.types import RetryPolicy

policy = RetryPolicy(
    initial_interval=0.5,     # 首次重试前等 0.5 秒
    backoff_factor=2.0,       # 每次重试后间隔 ×2
    max_interval=128.0,       # 间隔上限 128 秒
    max_attempts=3,           # 含首次,总共最多 3 次
    jitter=True,              # 加随机抖动
    retry_on=(ConnectionError, TimeoutError),  # 或传一个可调用对象
)
from langgraph.types import RetryPolicy

policy = RetryPolicy(
    initial_interval=0.5,     # wait 0.5s before the first retry
    backoff_factor=2.0,       # multiply the interval after each retry
    max_interval=128.0,       # cap the interval at 128s
    max_attempts=3,           # 3 attempts total, including the first
    jitter=True,              # add random jitter
    retry_on=(ConnectionError, TimeoutError),  # or a callable
)

代码来自官方博客与官方文档,参数默认值见下表。

Code from the official blog and docs; defaults in the table below.

参数类型默认含义
max_attemptsint3最大尝试次数(含第一次)
initial_intervalfloat0.5首次重试前等待秒数
backoff_factorfloat2.0每次重试后间隔的乘数
max_intervalfloat128.0两次重试之间的最大秒数
jitterboolTrue是否加随机抖动
retry_on异常类型 / 序列 / 可调用default_retry_on哪些异常可重试
ParameterTypeDefaultMeaning
max_attemptsint3Max attempts, including the first
initial_intervalfloat0.5Seconds before the first retry
backoff_factorfloat2.0Multiplier applied after each retry
max_intervalfloat128.0Maximum seconds between retries
jitterboolTrueAdd random jitter to the interval
retry_onexception type(s) / callabledefault_retry_onWhich exceptions are retryable

默认行为:故意保守

Default behavior: intentionally conservative

默认的 default_retry_on 重试除以下类型(及其子类)之外的所有异常:ValueErrorTypeErrorArithmeticErrorImportErrorLookupErrorNameErrorSyntaxErrorRuntimeErrorReferenceErrorStopIterationStopAsyncIterationOSError——这些几乎总是程序 bug,重试一百次也不会自己好。对 requests/httpx 等主流 HTTP 库的异常,只重试 5xx 状态码。超时抛出的 NodeTimeoutError 默认可重试。

The default default_retry_on retries any exception except the following (and their subclasses): ValueError, TypeError, ArithmeticError, ImportError, LookupError, NameError, SyntaxError, RuntimeError, ReferenceError, StopIteration, StopAsyncIteration, OSError — these are almost always programming bugs that no number of retries will fix. For exceptions from popular HTTP libraries such as requests and httpx, it only retries 5xx status codes. NodeTimeoutError from timeouts is retryable by default.

自定义重试逻辑与查看当前尝试次数

Custom retry logic & inspecting the attempt count

想扩展默认判断,导入 default_retry_on 再包一层;想在重试时切换到备用方案,在节点里读 runtime.execution_info.node_attempt(从 1 开始计数,没配重试策略时也可用,恒为 1):

To extend the default predicate, import default_retry_on and wrap it; to switch to a fallback on retries, read runtime.execution_info.node_attempt inside the node (1-indexed; available even without a retry policy, where it is always 1):

from langgraph.types import RetryPolicy, default_retry_on

def custom_retry_on(exc: BaseException) -> bool:
    if isinstance(exc, MyCustomError):
        return False          # 自家错误不重试
    return default_retry_on(exc) # 其余沿用默认

# 第 2 次尝试起改走备用 API(官方文档示例)
def my_node(state: State, runtime: Runtime) -> State:
    if runtime.execution_info.node_attempt > 1:
        return {"result": call_fallback_api()}
    return {"result": call_primary_api()}
from langgraph.types import RetryPolicy, default_retry_on

def custom_retry_on(exc: BaseException) -> bool:
    if isinstance(exc, MyCustomError):
        return False          # never retry our own error
    return default_retry_on(exc) # defer to defaults otherwise

# Switch to a fallback API from the 2nd attempt on (docs example)
def my_node(state: State, runtime: Runtime) -> State:
    if runtime.execution_info.node_attempt > 1:
        return {"result": call_fallback_api()}
    return {"result": call_primary_api()}
⚠ 重试的隐含前提:节点最好是幂等的 ⚠ The implicit assumption: nodes should be idempotent

重试意味着节点可能执行多次。查询类操作随便重试;有副作用的操作(扣款、发邮件)重试前要想清楚:副作用会不会重复发生?LangGraph 会清空失败尝试的状态写入,但清不掉已经发出去的真实世界动作——这正是第 7 节 Saga 模式要解决的问题。

Retrying means a node may execute more than once. Read-only calls retry freely; for side-effecting operations (charging a card, sending an email), think first: can the side effect happen twice? LangGraph clears the failed attempt's state writes, but it cannot un-send a real-world action — which is exactly the problem the Saga pattern in section 7 addresses.

05TimeoutPolicy:两种「太久了」

05TimeoutPolicy: Two Kinds of "Too Long"

官方博客给超时下了个精准定义:超时其实是「这次尝试挂太久了,按瞬时失败处理」。没有显式超时,一个卡住的 HTTP 调用或冻结的子进程能让整张图无限期挂起add_nodetimeout= 参数接受秒数、timedelta,或一个区分两种限制的 TimeoutPolicy:

The blog gives a precise framing: a timeout is really just "the attempt is treated as a transient failure because it's been hanging too long." Without one, a stuck HTTP call or a frozen subprocess can hang a graph run indefinitely. The timeout= parameter on add_node accepts a number of seconds, a timedelta, or a TimeoutPolicy that distinguishes two limits:

from langgraph.types import TimeoutPolicy

TimeoutPolicy(
    run_timeout=30.0,    # 单次尝试的硬性墙钟上限
    idle_timeout=5.0,    # 「无可观测进展」的最长时间
    refresh_on="auto",   # 或 "heartbeat"
)
from langgraph.types import TimeoutPolicy

TimeoutPolicy(
    run_timeout=30.0,    # hard wall-clock cap on a single attempt
    idle_timeout=5.0,    # max time without observable progress
    refresh_on="auto",   # or "heartbeat"
)
run_timeout = 30s(从不重置) 流式输出 状态写入 工具回调 30s 到 → 超时! idle_timeout = 5s(每次进展信号重置) ↻ 重置 ↻ 重置 ↻ 重置 静默 5s → 判定卡死! 长时间但持续流式输出的工作不会触发 idle_timeout;真正挂死的调用才会。
run_timeout 是绝对上限;idle_timeout 只抓「没有进展」的卡死(依据:官方文档 Timeouts 一节)
run_timeout = 30s (never refreshed) stream chunk state write tool callback 30s up → timeout! idle_timeout = 5s (resets on every progress signal) ↻ reset ↻ reset ↻ reset 5s silent → declared hung! Long-running but actively-streaming work never trips idle_timeout; a truly hung call does.
run_timeout is an absolute cap; idle_timeout only catches "no progress" hangs (source: official docs, Timeouts section)

什么算「进展」?

What counts as "progress"?

默认 refresh_on="auto" 下,以下任意信号都会重置空闲时钟:状态写入、流式输出的 chunk(LangChain 模型会自动发出)、子任务调度、runtime 的 stream-writer 调用、以及节点或其后代发出的任何 LangChain 回调事件(LLM token、工具调用、chain 开始/结束等)。两个都设时,谁先到谁触发

Under the default refresh_on="auto", any of these resets the idle clock: state writes, streamed output chunks (emitted automatically by LangChain LLMs), child-task scheduling, runtime stream-writer calls, and any LangChain callback event from the node or its descendants (LLM tokens, tool calls, chain start/end). If you set both limits, whichever fires first cancels the attempt.

如果你的长任务不产生这些自然信号(比如纯计算循环),两个办法:把 refresh_on 设为 "heartbeat",只认显式心跳;然后在循环里调 runtime.heartbeat() 手动报平安——它在空闲计时之外是空操作,可以无条件调用:

If your long task emits none of these natural signals (say, a pure compute loop), two moves: set refresh_on="heartbeat" so only explicit heartbeats count, then call runtime.heartbeat() inside the loop to check in — it's a no-op outside an idle-timed attempt, so you can call it unconditionally:

async def long_running_node(state: State, runtime: Runtime) -> State:
    for batch in fetch_batches():
        process(batch)
        runtime.heartbeat()   # 手动重置空闲时钟
    return {"result": "done"}

builder.add_node("long_running_node", long_running_node,
    timeout=TimeoutPolicy(idle_timeout=30, refresh_on="heartbeat"))
async def long_running_node(state: State, runtime: Runtime) -> State:
    for batch in fetch_batches():
        process(batch)
        runtime.heartbeat()   # manually reset the idle clock
    return {"result": "done"}

builder.add_node("long_running_node", long_running_node,
    timeout=TimeoutPolicy(idle_timeout=30, refresh_on="heartbeat"))

超时触发后:NodeTimeoutError

When it fires: NodeTimeoutError

超时触发时,本次尝试被取消,LangGraph 抛出带结构化上下文的 NodeTimeoutError:node(哪个节点)、elapsed(耗时)、kind("idle" 还是 "run" 触发)、以及配置的两个上限值。它默认可重试,和 retry_policy 直接配合:每次新尝试超时时钟重新计,失败尝试的写入在下次重试前被清空。另外,用 Send 动态派发节点(map-reduce 场景)时,可以在 Send(…, timeout=…) 上按次覆盖目标节点的静态超时。

When a timeout fires, the attempt is cancelled and LangGraph raises NodeTimeoutError with structured context: node (which node), elapsed (seconds), kind ("idle" or "run"), plus the configured limits. It is retryable by default and composes with retry_policy out of the box: the clock resets on each new attempt, and writes from a timed-out attempt are cleared before the next retry. When dispatching nodes dynamically with Send (map-reduce patterns), you can pass timeout= on the Send itself to override the target node's static timeout for that one push.

⚠ 超时只支持 async 节点 ⚠ Timeouts are async-only

给同步(sync)节点配 timeout 会在编译期直接被拒绝。要包裹阻塞式 I/O,官方建议在 async 节点里用 asyncio.to_thread

A sync node with a timeout is rejected at compile time. To wrap blocking I/O, the docs recommend asyncio.to_thread inside an async node.

06error_handler:重试救不了之后

06error_handler: After Retries Can't Save You

重试解决「5 秒后大概率能好」的问题,但解决不了「已经试了六次、支付服务商还是挂着」的问题。这时你需要跑一段收尾逻辑:把订单标记为失败并通知客户、回滚已经提交的部分副作用、发布 payment.failed 事件让系统其他部分响应、降级到更便宜的模型、写死信队列、或者只是路由到一句「非常抱歉」。这就是 error_handler 的职责:

Retries handle "this will probably work in 5 seconds." They don't handle "we've tried six times and the payment provider is still down." At that point you need cleanup logic: mark the order failed and notify the customer, roll back partial side effects already committed, publish a payment.failed event for the rest of the system, fall back to a cheaper model, write to a dead-letter queue, or just route to a "we apologize" message. That is the error_handler's job:

from langgraph.errors import NodeError
from langgraph.types import Command

def payment_error_handler(state: State, error: NodeError) -> Command:
    return Command(
        update={"status": f"compensated: {error.error}"},
        goto="finalize",   # 路由到指定节点,而不是中止整图
    )

builder.add_node("charge_payment", charge_payment,
    retry_policy=RetryPolicy(max_attempts=3, retry_on=ConnectionError),
    error_handler=payment_error_handler)
from langgraph.errors import NodeError
from langgraph.types import Command

def payment_error_handler(state: State, error: NodeError) -> Command:
    return Command(
        update={"status": f"compensated: {error.error}"},
        goto="finalize",   # route to a node instead of aborting
    )

builder.add_node("charge_payment", charge_payment,
    retry_policy=RetryPolicy(max_attempts=3, retry_on=ConnectionError),
    error_handler=payment_error_handler)

官方博客特别强调了这套机制的五个设计细节,理解它们才算真正会用:

The blog calls out five design details of the wiring; understanding them is what separates using the feature from really using it:

只在重试耗尽后触发(没配重试策略则立即触发)。这是它有用的关键——想在每次异常时都跑逻辑,直接在节点里 try/except 就行了,不需要这个特性。
失败上下文注入:处理器通过类型注解声明 error: NodeError 参数(和 runtime: Runtime 同一套注入机制)拿到失败节点名(error.node)和异常对象(error.error)。NodeError 是只有这两个字段的冻结数据类;不需要上下文时,签名可以简化为 (state)(state, runtime)
转移是原子的:原节点失败时,它的 ERROR 写入被提交到检查点,处理器作为同一超步内的新任务被调度。若宿主进程在处理器执行中途崩溃,恢复时重新调度的是处理器,而不是原来那个失败节点——关键流程一旦进入补偿分支就不会「倒回」正常分支。失败来源(provenance)也被检查点化:恢复后处理器看到的是同一个 NodeError。
与同超步的其他节点互不等待:节点失败时,处理器立即与该超步内其他还在跑的节点并行调度,互不阻塞。
处理器不能再配处理器:避免无限递归。处理器自己抛异常,该异常按「节点没有处理器」的方式向上传播。每个节点最多一个 error_handler。
It only fires after retries are exhausted (or immediately if no retry policy is set). This is the property that makes it useful — if you wanted to run on every exception, a try/except inside the node would do.
Failure context is injected: the handler declares an error: NodeError parameter by type annotation (the same injection pattern as runtime: Runtime) and gets the failing node's name (error.node) and the exception (error.error). NodeError is a frozen dataclass with exactly those two fields; handlers that don't need context can use simpler signatures like (state) or (state, runtime).
The transition is atomic: when the node fails, its ERROR write is committed to the checkpoint and the handler is scheduled as a new task in the same superstep. If the host process crashes mid-handler, the resumed run re-schedules the handler, not the original failing node — once a critical flow enters the compensation branch it never falls back into the regular path. Failure provenance is checkpointed too: after resume, the handler sees the same NodeError.
It doesn't wait for sibling nodes: the handler is scheduled immediately alongside whatever else was already running in that superstep; neither waits for the other.
No handlers for handlers: you can't attach an error handler to an error handler, so there is no infinite recursion. If the handler itself raises, the exception propagates as if the node had no handler. At most one error_handler per node.
✦ 两个边界情况(官方文档) ✦ Two edge cases (from the docs)

interrupt() 不走错误处理器:节点内的 interrupt()(人机协同暂停)通过 GraphBubbleUp 机制冒泡,绕过重试策略与错误处理器,图正常暂停。子图异常会浮到父节点:包裹子图的节点若配有 error_handler,会以子图的异常作为 error.error 触发。

interrupt() bypasses error handlers: an interrupt() raised inside a node (human-in-the-loop pause) bubbles up via the GraphBubbleUp mechanism, skipping both retry policies and error handlers — the graph pauses as usual. Subgraph exceptions surface to the parent: if a node wrapping a subgraph has an error handler, it fires with the subgraph's exception in error.error.

07Saga 补偿实战:订机票

07Saga in Practice: Booking a Flight

三个原语的真正威力体现在有副作用的多步流程里。先解释术语:Saga 模式是分布式系统处理失败的标准做法——当一串操作没法包进同一个数据库事务时,给每一步都准备一个「反向操作」(补偿),某步失败就把已经做过的步骤按逆序撤销,保证整体「要么全成、要么全退」。

The real power of the three primitives shows up in multi-step workflows with side effects. First, the term: the Saga pattern is the standard way distributed systems handle failure when a sequence of operations can't be wrapped in a single database transaction — every step gets a reverse operation (a compensation), and if any step fails, the steps that already ran are undone in reverse order, keeping the whole thing all-or-nothing.

以官方博客的机票预订为例:它不是一个动作,而是一串——占座、扣款、出票,每步都调外部系统,每步都可能失败。天真做法(整个流程失败就整体重试)很快就崩:如果占座成功但扣款或出票失败,重跑整个流程会再占一个座,原来的预订卡在坏状态。正确做法:每步各自重试;某步重试耗尽,就只撤销已经执行过的步骤(把失败那步也算进去,因为它的实际结果未知)。

Take the blog's flight-booking example: it's not one action but a sequence — reserve a seat, process payment, issue a ticket. Each step talks to an external system; any can fail. The naive approach (retry the whole flow) breaks down fast: if the seat reservation went through but payment or ticketing fails, re-running the whole flow reserves another seat while the original booking is stuck in a bad state. The right approach: retry each step individually, and when a step exhausts its retries, undo only the steps that already ran (counting the failed step too, since its actual outcome is unknown).

① reserve_seat 占座 ✓ (12A) ② process_payment 扣款 ✓ ③ issue_ticket 出票 ✗ 重试 3 次均失败 error_handler compensate 节点 查 state["completed"],逆序撤销 void_ticket → refund_payment → release_seat 只撤销真正执行过的步骤,逆序 结果:要么「座位+扣款+出票」全部成功,要么全部退回 —— 不留半截预订
机票预订 Saga:第③步重试耗尽后,error_handler 路由到 compensate 节点逆序撤销(依据:官方博客示例)
① reserve_seat seat held ✓ (12A) ② process_payment charged ✓ ③ issue_ticket fails ✗ after 3 retries error_handler compensate node inspect state["completed"], undo in reverse void_ticket → refund_payment → release_seat undo only steps that actually ran, in reverse Outcome: seat + charge + ticket all succeed, or all roll back — no half-finished booking
The flight-booking saga: after step ③ exhausts retries, the error_handler routes to a compensate node that undoes in reverse (source: official blog example)

官方博客的完整实现骨架(节选关键部分,业务调用省略):

The skeleton of the blog's full implementation (key parts; business calls elided):

class BookingState(TypedDict, total=False):
    seat: str; payment_ref: str; ticket_no: str
    completed: Annotated[list[str], operator.add]  # 逐步累积「做过什么」

def to_compensate(state: BookingState, error: NodeError) -> Command:
    """任何一步重试耗尽 → 路由到补偿节点(把失败节点也记进去)"""
    return Command(update={"completed": [f"FAILED:{error.node}"]},
                   goto="compensate")

def compensate(state) -> Command:
    # 逆序撤销真正执行过的步骤
    if "issue_ticket" in state["completed"]: void_ticket(state)
    if "process_payment" in state["completed"]: refund_payment(state)
    if "reserve_seat" in state["completed"]: release_seat(state)
    return Command(goto=END)

graph = (
    StateGraph(BookingState)
    # 所有步骤共享同一重试策略和同一兜底目标;可按步覆盖
    .set_node_defaults(retry_policy=RETRYABLE, error_handler=to_compensate)
    .add_node("reserve_seat", reserve_seat)
    .add_node("process_payment", process_payment)
    .add_node("issue_ticket", issue_ticket)
    .add_node("compensate", compensate)
    .add_edge(START, "reserve_seat")
    .add_edge("reserve_seat", "process_payment")
    .add_edge("process_payment", "issue_ticket")
    .add_edge("issue_ticket", END)
    .compile(checkpointer=checkpointer)   # 检查点让补偿在崩溃后也能续跑
)
class BookingState(TypedDict, total=False):
    seat: str; payment_ref: str; ticket_no: str
    completed: Annotated[list[str], operator.add]  # accumulates what ran

def to_compensate(state: BookingState, error: NodeError) -> Command:
    """Any retry-exhausted step → route to compensation (record the failed node too)"""
    return Command(update={"completed": [f"FAILED:{error.node}"]},
                   goto="compensate")

def compensate(state) -> Command:
    # Undo only the steps that actually ran, in reverse order
    if "issue_ticket" in state["completed"]: void_ticket(state)
    if "process_payment" in state["completed"]: refund_payment(state)
    if "reserve_seat" in state["completed"]: release_seat(state)
    return Command(goto=END)

graph = (
    StateGraph(BookingState)
    # All steps share one retry policy and one fallback target; per-step overrides possible
    .set_node_defaults(retry_policy=RETRYABLE, error_handler=to_compensate)
    .add_node("reserve_seat", reserve_seat)
    .add_node("process_payment", process_payment)
    .add_node("issue_ticket", issue_ticket)
    .add_node("compensate", compensate)
    .add_edge(START, "reserve_seat")
    .add_edge("reserve_seat", "process_payment")
    .add_edge("process_payment", "issue_ticket")
    .add_edge("issue_ticket", END)
    .compile(checkpointer=checkpointer)   # checkpoints let compensation survive crashes
)

这套写法给了你三样东西:每步独立的退避重试;任何一步重试耗尽后原子地进入 compensate;以及持久化的 completed 清单,让补偿只撤销真正需要撤销的部分。配合检查点,即使进程在补偿中途崩溃,恢复后也会继续补偿,不会「假装无事发生」。

This wiring buys you three things: per-step backoff retries; an atomic transition into compensate once any step exhausts retries; and a persisted completed list so compensation undoes exactly what needs undoing. With checkpointing, even if the process crashes mid-compensation, the resumed run continues compensating rather than pretending nothing happened.

08组合与选型:什么时候用哪个

08Composition & Alternatives: Which Tool When

三原语内部的选型,一张表说清:

Choosing among the three primitives, in one table:

你面对的失败用什么理由
5xx、限流、连接重置等瞬时错误RetryPolicy「等一会儿就好」型错误,退避重试即可消化
外部调用可能永久卡死TimeoutPolicy(run_timeout)绝不等超过 N 秒的硬约束
任务时长不定,但「停止产出」即异常TimeoutPolicy(idle_timeout)流式/分批任务用进度感知,不误杀长任务
重试耗尽后需要收尾/降级/告警error_handler拿到 NodeError,更新状态或路由兜底节点
多步流程带真实副作用三者组合 + 补偿节点(Saga)每步自愈,救不了就整体回退
程序 bug(ValueError 等)都不用——修代码默认策略故意不重试这类异常
The failure you faceReach forWhy
Transient errors: 5xx, rate limits, connection resetsRetryPolicy"Fine in a moment" errors are absorbed by backoff retries
External calls that may hang foreverTimeoutPolicy(run_timeout)A hard "never wait more than N seconds" bound
Variable-length work where "stopped producing" = brokenTimeoutPolicy(idle_timeout)Progress-aware cap that doesn't kill healthy long tasks
Cleanup / fallback / alerting after retries failerror_handlerReceives NodeError; updates state or routes to a recovery node
Multi-step flows with real side effectsAll three + a compensation node (Saga)Each step self-heals; if not, the whole flow rolls back
Programming bugs (ValueError etc.)None — fix the codeThe default policy deliberately refuses to retry these

和「自己写 try/except」以及外部工作流引擎比

Versus hand-rolled try/except and external workflow engines

vs 手写包装:手写能做到退避重试,但做不到「失败写入自动清空」「处理器与检查点原子衔接」「崩溃后恢复到补偿分支」这些引擎级语义,而且每个节点重复 25 行样板。vs Temporal 这类通用持久化工作流引擎:社区中常见的做法是二者结合或二选一——Temporal 提供跨语言、跨服务的工业级持久执行,但要引入独立集群和它自己的编程模型;LangGraph 的容错原语内建在 Agent 框架里,状态、检查点、LLM 流式信号(如 token 流重置 idle 时钟)天然打通,对「以 LLM 为中心的图」摩擦小得多。这属于社区经验总结,选型请结合团队现状判断。

Versus hand-rolled wrappers: your own code can do backoff retries, but not the engine-level semantics — failed-attempt writes cleared automatically, handler/checkpoint atomic handoff, crash recovery that resumes into the compensation branch — and you repeat 25 lines of boilerplate per node. Versus general durable-workflow engines like Temporal: a common community position is to combine them or pick one — Temporal offers industrial cross-language, cross-service durable execution but brings its own cluster and programming model; LangGraph's primitives are built into the agent framework, so state, checkpoints, and LLM streaming signals (e.g. token streams refreshing the idle clock) are natively integrated, with far less friction for LLM-centric graphs. This is community synthesis, not official guidance — weigh it against your team's context.

09常见坑与限制

09Pitfalls & Limits

⚠ 官方文档列明的硬限制(截至 1.2 alpha) ⚠ Hard limits stated in the official docs (as of 1.2 alpha)

版本门槛:按节点超时与节点级错误处理器需要 langgraph>=1.2,当前为 alpha,API 可能微调;重试策略在更早版本已可用。② Python 限定:超时与错误处理器在 JS/TS SDK 中不可用;重试策略 Python 和 TypeScript 都支持。③ 超时只支持 async 节点,sync 节点配超时在编译期报错。④ 每节点最多一个处理器,处理器不能再挂处理器。⑤ 处理器自身抛异常会向上传播,如同节点没有处理器。

Version gate: per-node timeouts and node-level error handlers require langgraph>=1.2, currently alpha — APIs may shift; retry policies exist in earlier versions. ② Python only: timeouts and error handlers are not in the JS/TS SDK; retry policies work in both Python and TypeScript. ③ Timeouts are async-only; sync nodes with a timeout are rejected at compile time. ④ One handler per node, and no handlers on handlers. ⑤ Handler failures bubble up as if the node had no handler.

别重试程序 bug。默认 retry_on 故意排除 ValueError/TypeError 等;如果你把 retry_on 放宽成「重试一切」,bug 会被退避拖成慢性失败,更难排查。
重试 ≠ 免疫副作用重复。清空的是状态写入,不是已发出的真实请求。对不幂等的外部调用,要么在服务端做幂等键,要么把「检测是否已发生」写进节点逻辑。
idle_timeout 依赖信号源。默认 auto 模式下,「话痨的下游」(不断发回调的子任务)会一直重置时钟,让 idle_timeout 形同虚设——官方为此提供 refresh_on="heartbeat" 收紧信号来源。
interrupt() 与错误路径互不相干。别指望用 error_handler 捕获人机协同暂停;它走的是另一条冒泡机制。
补偿逻辑本身要防御性编程。error_handler / 补偿节点里再失败,异常直接上抛;退款、释放资源这类调用要自带重试或落死信队列。
没有检查点,Saga 的「崩溃续补」不成立compile(checkpointer=…) 不是可选项——失败来源与补偿进度都存在检查点里。
Don't retry programming bugs. The default retry_on deliberately excludes ValueError/TypeError and friends; widening it to "retry everything" turns bugs into slow-motion failures stretched by backoff, which are harder to debug.
Retries don't make side effects safe. What gets cleared is state writes, not real requests already sent. For non-idempotent external calls, use idempotency keys server-side or make the node check "did this already happen."
idle_timeout is only as good as its signals. Under the default auto mode, a chatty subordinate (a child task emitting constant callbacks) keeps resetting the clock, making idle_timeout toothless — that's exactly why refresh_on="heartbeat" exists to narrow the refresh source.
interrupt() and the error path never meet. Don't expect an error_handler to catch human-in-the-loop pauses; they bubble up through a separate mechanism.
Write compensation logic defensively. If the error handler or compensation node itself fails, the exception propagates; refunds and resource releases should carry their own retries or land in a dead-letter queue.
No checkpointer, no crash-safe saga. compile(checkpointer=…) is not optional — failure provenance and compensation progress live in the checkpoint.

10学习资源清单

10Resources

官方一手来源(优先读)
· 博客《Fault Tolerance in LangGraph》(2026-06-04,本文主要依据):langchain.com/blog/fault-tolerance-in-langgraph
· 官方文档 Fault tolerance(参数表与边界情况最全):docs.langchain.com/oss/python/langgraph/fault-tolerance
· 官方文档 Durable execution(检查点与优雅停机):docs.langchain.com/oss/python/langgraph/durable-execution
· 官方文档 Persistence(线程与检查点概念):docs.langchain.com/oss/python/langgraph/persistence
· GitHub Releases(跟进 1.2 alpha 进展):github.com/langchain-ai/langgraph/releases
Official first-party sources (read these first)
· Blog: "Fault Tolerance in LangGraph" (2026-06-04, this document's main source): langchain.com/blog/fault-tolerance-in-langgraph
· Docs: Fault tolerance (fullest parameter tables and edge cases): docs.langchain.com/oss/python/langgraph/fault-tolerance
· Docs: Durable execution (checkpoints and graceful shutdown): docs.langchain.com/oss/python/langgraph/durable-execution
· Docs: Persistence (threads and checkpoint concepts): docs.langchain.com/oss/python/langgraph/persistence
· GitHub releases (track 1.2 alpha progress): github.com/langchain-ai/langgraph/releases

继续深入的自然路径:先在自己的图里给「调外部服务的节点」统一挂上 set_node_defaults(retry_policy=…),观察一周生产日志;再对最贵的长任务加 idle_timeout;最后才给有副作用的链路上 Saga——按痛感顺序引入,而不是一次性全上。

A natural path to go deeper: first attach set_node_defaults(retry_policy=…) to every external-service node in your own graph and watch a week of production logs; then add idle_timeout to your most expensive long tasks; only then wire Saga onto side-effecting chains — adopt in order of pain, not all at once.