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.
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:
# 三个原语都挂在 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.
因为有些保证只有执擎层给得了:重试要在「本次尝试的写入被清空」之后才安全;错误处理器要和检查点(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.
三者的组合顺序是固定的(官方文档原文):当节点的一次尝试抛出任何异常——包括超时抛出的 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.
不想每个节点重复写?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_attempts | int | 3 | 最大尝试次数(含第一次) |
initial_interval | float | 0.5 | 首次重试前等待秒数 |
backoff_factor | float | 2.0 | 每次重试后间隔的乘数 |
max_interval | float | 128.0 | 两次重试之间的最大秒数 |
jitter | bool | True | 是否加随机抖动 |
retry_on | 异常类型 / 序列 / 可调用 | default_retry_on | 哪些异常可重试 |
| Parameter | Type | Default | Meaning |
|---|---|---|---|
max_attempts | int | 3 | Max attempts, including the first |
initial_interval | float | 0.5 | Seconds before the first retry |
backoff_factor | float | 2.0 | Multiplier applied after each retry |
max_interval | float | 128.0 | Maximum seconds between retries |
jitter | bool | True | Add random jitter to the interval |
retry_on | exception type(s) / callable | default_retry_on | Which exceptions are retryable |
默认行为:故意保守
Default behavior: intentionally conservative
默认的 default_retry_on 重试除以下类型(及其子类)之外的所有异常:ValueError、TypeError、ArithmeticError、ImportError、LookupError、NameError、SyntaxError、RuntimeError、ReferenceError、StopIteration、StopAsyncIteration、OSError——这些几乎总是程序 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()}
重试意味着节点可能执行多次。查询类操作随便重试;有副作用的操作(扣款、发邮件)重试前要想清楚:副作用会不会重复发生?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_node 的 timeout= 参数接受秒数、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"
)
什么算「进展」?
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.
给同步(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:
error: NodeError 参数(和 runtime: Runtime 同一套注入机制)拿到失败节点名(error.node)和异常对象(error.error)。NodeError 是只有这两个字段的冻结数据类;不需要上下文时,签名可以简化为 (state) 或 (state, runtime)。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).error_handler per node.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).
官方博客的完整实现骨架(节选关键部分,业务调用省略):
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 face | Reach for | Why |
|---|---|---|
| Transient errors: 5xx, rate limits, connection resets | RetryPolicy | "Fine in a moment" errors are absorbed by backoff retries |
| External calls that may hang forever | TimeoutPolicy(run_timeout) | A hard "never wait more than N seconds" bound |
| Variable-length work where "stopped producing" = broken | TimeoutPolicy(idle_timeout) | Progress-aware cap that doesn't kill healthy long tasks |
| Cleanup / fallback / alerting after retries fail | error_handler | Receives NodeError; updates state or routes to a recovery node |
| Multi-step flows with real side effects | All three + a compensation node (Saga) | Each step self-heals; if not, the whole flow rolls back |
| Programming bugs (ValueError etc.) | None — fix the code | The 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
① 版本门槛:按节点超时与节点级错误处理器需要 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.
retry_on 故意排除 ValueError/TypeError 等;如果你把 retry_on 放宽成「重试一切」,bug 会被退避拖成慢性失败,更难排查。refresh_on="heartbeat" 收紧信号来源。compile(checkpointer=…) 不是可选项——失败来源与补偿进度都存在检查点里。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.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.compile(checkpointer=…) is not optional — failure provenance and compensation progress live in the checkpoint.10学习资源清单
10Resources
· 官方文档 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
· 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.
主页