《LangGraph 设计与实现》完整目录
- 前言
- 第1章 为什么需要理解 LangGraph
- 第2章 架构总览
- 第3章 StateGraph 图构建 API
- 第4章 Channel 状态管理与 Reducer
- 第5章 图编译:从 StateGraph 到 CompiledStateGraph
- 第6章 Pregel 执行引擎
- 第7章 任务调度与并行执行
- 第8章 Checkpoint 持久化
- 第9章 中断与人机协作(当前)
- 第10章 Command 与高级控制流
- 第11章 子图与嵌套
- 第12章 Send 与动态并行
- 第13章 流式输出与调试
- 第14章 Runtime 与 Context
- 第15章 Store 与长期记忆
- 第16章 预构建 Agent 组件
- 第17章 多 Agent 模式实战
- 第18章 设计模式与架构决策
第9章 中断与人机协作
9.1 引言
在 AI Agent 的实际应用中,纯自动化的执行流程往往不够。当 Agent 需要进行高风险操作(如转账、删除数据)、当决策需要人类专业判断(如医疗诊断确认)、或者当信息不足需要用户补充时,系统必须能够优雅地暂停执行,等待人类介入,然后从暂停点恢复。
这就是 LangGraph 中断(Interrupt)机制要解决的问题。与传统的回调或轮询方案不同,LangGraph 的中断是一种持久化的暂停 ------执行状态被完整保存到 Checkpoint 中,进程可以终止、重启,甚至迁移到另一台机器上,只要提供正确的 thread_id,就能从中断点恢复执行。
本章将从源码层面深入分析中断机制的完整实现:从 interrupt() 函数如何抛出异常、到 GraphInterrupt 如何被 Pregel 循环捕获、再到恢复时如何通过 Command(resume=...) 将值传回节点。我们还将探讨 interrupt_before/interrupt_after 的配置式中断,以及多中断场景下的匹配策略。
为了充分理解 LangGraph 中断机制的精妙设计,有必要先反思传统的暂停/恢复方案。最简单的做法是在每个可能中断的位置设置回调函数,让用户代码手动管理暂停逻辑。这种方法的问题在于:它将中断的责任分散到了业务代码中,增加了认知负担;而且回调模式难以持久化------一旦进程终止,回调的上下文就丢失了。另一种方案是使用协程或 async/await,但这要求所有节点函数都是异步的,而且 Python 的协程状态无法可靠地序列化。LangGraph 选择了第三种路径:基于异常的暂停加重新执行的恢复。这种方式让节点函数保持简单(可以是普通同步函数),同时通过 Checkpoint 实现真正的持久化暂停。代价是恢复时需要重新执行整个节点,但通过 scratchpad 的索引追踪,已解决的中断可以立即返回缓存值,实际的重复执行开销极小。
:::tip 本章要点
- interrupt() 函数:理解其基于异常的暂停机制和 scratchpad 的索引追踪
- Interrupt 数据类型:掌握中断值的结构及其基于命名空间的确定性 ID 生成
- interrupt_before/interrupt_after:区分编译时配置的声明式中断与运行时的命令式中断
- 暂停与恢复机制:深入 Pregel 循环如何捕获中断、保存状态、以及恢复执行
- 与 Checkpoint 的配合:理解中断信息如何作为 pending_writes 持久化
- 多中断与按 ID 恢复:掌握同一节点中多个中断的索引匹配和按 ID 精确恢复 :::
9.2 Interrupt 数据类型
9.2.1 Interrupt 类定义
Interrupt 是一个不可变的数据类,用于封装中断信息:
python
# langgraph/types.py
@final
@dataclass(init=False, slots=True)
class Interrupt:
value: Any
"""The value associated with the interrupt."""
id: str
"""The ID of the interrupt. Can be used to resume the interrupt directly."""
def __init__(self, value: Any, id: str = _DEFAULT_INTERRUPT_ID, **deprecated_kwargs):
self.value = value
if (
(ns := deprecated_kwargs.get("ns", MISSING)) is not MISSING
and (id == _DEFAULT_INTERRUPT_ID)
and (isinstance(ns, Sequence))
):
self.id = xxh3_128_hexdigest("|".join(ns).encode())
else:
self.id = id
@classmethod
def from_ns(cls, value: Any, ns: str) -> Interrupt:
return cls(value=value, id=xxh3_128_hexdigest(ns.encode()))
两个核心属性:
value :中断携带的值,可以是任意类型。它被传递给客户端,用于展示中断原因或请求用户输入。例如 "请确认是否执行转账操作?" 或 {"question": "请选择以下选项", "options": ["A", "B", "C"]}。
id :中断的唯一标识符。注意它不是随机生成的 UUID,而是通过 xxh3_128_hexdigest 对检查点命名空间进行哈希计算得出的确定性 ID。这意味着同一个执行路径上的同一个中断,在不同运行中会生成相同的 ID。
python
# 中断 ID 的确定性生成
Interrupt.from_ns(
value="请确认操作",
ns=conf[CONFIG_KEY_CHECKPOINT_NS], # 例如 "agent:task-id-123"
)
# id = xxh3_128_hexdigest(b"agent:task-id-123")
这个设计选择服务于多中断场景下的精确恢复:客户端可以通过中断 ID 指定要恢复哪个中断。由于 xxh3_128_hexdigest 是一个非密码学的高速哈希函数,ID 的计算几乎没有性能开销。128 位的输出空间足够大,碰撞概率可以忽略不计。
@final 装饰器和 slots=True 的使用也值得注意。@final 禁止了子类化,这确保了 Interrupt 的行为在整个系统中是一致和可预测的------没有用户代码可以通过继承来改变中断的序列化或比较行为。slots=True 则通过使用 __slots__ 而非 __dict__ 来存储属性,减少了内存开销并略微提升了属性访问速度。这些都是面向高性能场景的微优化,体现了 LangGraph 在底层基础设施上的精细打磨。
9.2.2 GraphInterrupt 异常
GraphInterrupt 是中断机制的传播载体:
python
# langgraph/errors.py
class GraphBubbleUp(Exception):
"""所有需要向上冒泡的异常的基类"""
pass
class GraphInterrupt(GraphBubbleUp):
"""Raised when a subgraph is interrupted,
suppressed by the root graph. Never raised directly."""
def __init__(self, interrupts: Sequence[Interrupt] = ()) -> None:
super().__init__(interrupts)
GraphInterrupt 继承自 GraphBubbleUp,这是 LangGraph 中所有需要跨层传播的异常的基类。关键设计:GraphInterrupt 在根图中被抑制,不会泄漏到用户代码中。它只是一种内部的控制流机制。
写入 pending_writes] end
9.3 interrupt() 函数深度解析
9.3.1 核心实现
interrupt() 函数是用户在节点中触发中断的唯一入口:
python
# langgraph/types.py
def interrupt(value: Any) -> Any:
from langgraph._internal._constants import (
CONFIG_KEY_CHECKPOINT_NS, CONFIG_KEY_SCRATCHPAD,
CONFIG_KEY_SEND, RESUME,
)
from langgraph.config import get_config
from langgraph.errors import GraphInterrupt
conf = get_config()["configurable"]
# 1. 追踪中断索引
scratchpad = conf[CONFIG_KEY_SCRATCHPAD]
idx = scratchpad.interrupt_counter()
# 2. 查找之前保存的恢复值
if scratchpad.resume:
if idx < len(scratchpad.resume):
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
return scratchpad.resume[idx]
# 3. 查找当前的恢复值(来自 Command(resume=...))
v = scratchpad.get_null_resume(True)
if v is not None:
assert len(scratchpad.resume) == idx
scratchpad.resume.append(v)
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
return v
# 4. 没有恢复值,抛出中断
raise GraphInterrupt((
Interrupt.from_ns(
value=value,
ns=conf[CONFIG_KEY_CHECKPOINT_NS],
),
))
这个函数的精妙之处在于它的双重身份 :第一次调用时它是一个"暂停器"(抛出异常),恢复后重新执行时它是一个"值提供器"(返回恢复值)。从用户的角度看,interrupt() 就像一个普通的阻塞式输入函数------调用它时程序暂停,收到输入后继续。但在底层,这两次调用实际上发生在不同的进程生命周期中,中间可能经历了数分钟甚至数天的等待。Checkpoint 机制弥合了这种时间断裂,使得节点函数的编写者不需要关心持久化的细节。
理解这个函数的关键在于认识到它的四个分支:第一个分支(步骤 1)通过 scratchpad 的 interrupt_counter 追踪当前是第几个中断调用;第二个分支(步骤 2)检查是否有之前保存的恢复值列表,如果当前索引在范围内则直接返回;第三个分支(步骤 3)尝试获取新提供的恢复值;第四个分支(步骤 4)在没有任何恢复值时抛出异常。这种分层的查找策略使得多中断场景可以正确工作------前面的中断返回已缓存的值,最新的中断返回新提供的值,再后面的中断抛出异常等待用户输入。
9.3.2 PregelScratchpad 的角色
PregelScratchpad 是每个任务执行期间的临时工作区:
python
@dataclasses.dataclass(**_DC_KWARGS)
class PregelScratchpad:
step: int
stop: int
call_counter: Callable[[], int]
interrupt_counter: Callable[[], int] # 追踪当前任务的中断索引
get_null_resume: Callable[[bool], Any]
resume: list[Any] # 之前保存的恢复值列表
subgraph_counter: Callable[[], int]
interrupt_counter 是一个闭包,每次调用自增并返回当前索引。它在每个任务开始执行时被初始化为从零开始的计数器。使用闭包而非实例变量的原因是每个任务需要独立的计数器------在并行执行的场景中,多个任务可能同时包含 interrupt() 调用,它们的索引必须互不干扰。这使得同一个节点中的多个 interrupt() 调用可以按顺序匹配恢复值:
python
# 节点中的多个中断
def review_node(state):
# 第一次 interrupt: idx=0
name = interrupt("请输入您的姓名")
# 第二次 interrupt: idx=1
age = interrupt("请输入您的年龄")
return {"name": name, "age": age}
9.3.3 执行流程的四个阶段
让我们详细追踪每个阶段:
阶段 1 - 首次执行 :节点调用 interrupt(value),scratchpad.resume 为空列表,idx 为 0。没有找到恢复值,抛出 GraphInterrupt。
阶段 2 - 异常处理 :GraphInterrupt 被 Pregel 循环捕获。中断信息作为 INTERRUPT 类型的 pending write 保存到检查点中。根图抑制异常,将中断信息作为输出返回给用户。
阶段 3 - 恢复输入 :用户通过 Command(resume=value) 提供恢复值。Pregel 循环从检查点恢复状态,将恢复值注入到 scratchpad 中。
阶段 4 - 重新执行 :节点从头开始重新执行。当再次遇到 interrupt() 调用时,发现 scratchpad.resume[0] 存在,直接返回恢复值而不抛出异常。节点正常完成执行。
9.4 interrupt_before 与 interrupt_after
9.4.1 声明式中断配置
除了在节点内部调用 interrupt() 的命令式中断,LangGraph 还支持在编译时声明式地配置中断:
python
# 编译时配置
graph = builder.compile(
checkpointer=InMemorySaver(),
interrupt_before=["human_review"], # 执行前中断
interrupt_after=["tool_call"], # 执行后中断
)
# 也支持通配符
graph = builder.compile(
checkpointer=InMemorySaver(),
interrupt_before="*", # 所有节点执行前中断
)
9.4.2 调度时的中断判断
在 PregelLoop.tick() 方法中,中断判断发生在任务准备之后、实际执行之前(interrupt_before)和执行之后(interrupt_after):
python
# langgraph/pregel/_loop.py
def tick(self) -> bool:
# 准备下一步任务
self.tasks = prepare_next_tasks(...)
# 执行前中断检查
if self.interrupt_before and should_interrupt(
self.checkpoint, self.interrupt_before, self.tasks.values()
):
self.status = "interrupt_before"
raise GraphInterrupt()
# ... 执行任务 ...
def after_tick(self) -> None:
# 应用写入
self.updated_channels = apply_writes(...)
# 保存检查点
self._put_checkpoint({"source": "loop"})
# 执行后中断检查
if self.interrupt_after and should_interrupt(
self.checkpoint, self.interrupt_after, self.tasks.values()
):
self.status = "interrupt_after"
raise GraphInterrupt()
should_interrupt 函数检查当前步骤的任务列表中是否包含匹配中断配置的节点。当配置值为 "*" 时匹配所有节点,否则按名称精确匹配。这个检查在任务准备完成后进行,意味着 interrupt_before 不会阻止任务的创建,只是阻止它们的执行。任务已经被创建这一点很重要------它意味着恢复后不需要重新进行任务准备,可以直接开始执行已准备好的任务。
9.4.3 声明式 vs 命令式中断的对比
| 特性 | 声明式 | 命令式 |
|---|---|---|
| 定义位置 | compile() 参数 |
节点函数内部 |
| 粒度 | 节点级别 | 代码行级别 |
| 携带数据 | 无自定义数据 | 可携带任意值 |
| 恢复方式 | invoke(None, config) |
Command(resume=value) |
| 条件中断 | 不支持(始终中断匹配的节点) | 支持(通过条件判断控制是否调用) |
| 多次中断 | 每个步骤最多一次 | 同一节点内可调用多次 |
| 恢复数据 | 不携带恢复数据 | 恢复值作为返回值传递 |
| 适用场景 | 调试审查和安全审批 | 交互式数据收集和条件审批 |
9.5 恢复机制的完整链路
9.5.1 Command(resume=...) 的处理
当用户通过 Command(resume=value) 恢复执行时,PregelLoop._first() 方法负责处理恢复逻辑:
python
# langgraph/pregel/_loop.py - _first() 方法
if input_is_command:
if (resume := cast(Command, self.input).resume) is not None:
if not self.checkpointer:
raise RuntimeError("Cannot use Command(resume=...) without checkpointer")
# 判断是否是按 ID 恢复(dict 映射)
if resume_is_map := (
isinstance(resume, dict)
and all(is_xxh3_128_hexdigest(k) for k in resume)
):
self.config[CONF][CONFIG_KEY_RESUME_MAP] = resume
else:
# 简单恢复:检查是否有多个待处理中断
if len(self._pending_interrupts()) > 1:
raise RuntimeError(
"When there are multiple pending interrupts, "
"you must specify the interrupt id when resuming."
)
恢复支持两种模式:
简单恢复 :Command(resume=value),将值传递给下一个未处理的中断。只在单一中断场景下有效。
按 ID 恢复 :Command(resume={interrupt_id: value}),通过中断 ID 精确指定要恢复哪个中断。这在多中断场景下是必须的。
9.5.2 pending_interrupts 的追踪
_pending_interrupts() 方法通过分析 checkpoint_pending_writes 来确定哪些中断尚未被恢复:
python
def _pending_interrupts(self) -> set[str]:
pending_interrupts: dict[str, str] = {}
pending_resumes: set[str] = set()
for task_id, write_type, value in self.checkpoint_pending_writes:
if write_type == INTERRUPT:
pending_interrupts[task_id] = value[0].id
elif write_type == RESUME:
pending_resumes.add(task_id)
# 已恢复的中断 ID
resumed_interrupt_ids = {
pending_interrupts[task_id]
for task_id in pending_resumes
if task_id in pending_interrupts
}
# 返回尚未恢复的中断 ID
return {
interrupt_id
for interrupt_id in pending_interrupts.values()
if interrupt_id not in resumed_interrupt_ids
}
这个方法的逻辑是:遍历所有 pending writes,找出所有 INTERRUPT 类型的写入和所有 RESUME 类型的写入,然后通过 task_id 关联,排除已经有对应 RESUME 的 INTERRUPT。
9.5.3 RESUME 写入的保存
当 interrupt() 函数成功找到恢复值时,它不仅返回值,还通过 CONFIG_KEY_SEND 将 RESUME 写入发送到 Pregel 循环:
python
# interrupt() 函数中
if scratchpad.resume:
if idx < len(scratchpad.resume):
# 写入 RESUME 标记,表示此中断已处理
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
return scratchpad.resume[idx]
这个 RESUME 写入被持久化到检查点中,确保即使在恢复过程中再次中断,之前已处理的中断值也不会丢失。
9.6 中断的抑制与传播
9.6.1 根图的抑制逻辑
GraphInterrupt 在根图中被抑制,这发生在 PregelLoop._suppress_interrupt 方法中:
python
# langgraph/pregel/_loop.py
def _suppress_interrupt(self, exc_type, exc_value, traceback) -> bool | None:
# 持久化当前状态(exit 模式)
if self.durability == "exit" and (
not self.is_nested or exc_value is not None
or all(NS_END not in part for part in self.checkpoint_ns)
):
self._put_checkpoint(self.checkpoint_metadata)
self._put_pending_writes()
# 只有根图才抑制中断
suppress = isinstance(exc_value, GraphInterrupt) and not self.is_nested
if suppress:
# 将中断信息写入输出流
# 用户通过 stream 接收到 __interrupt__ 事件
...
return suppress
关键设计:子图中的 GraphInterrupt 不会被抑制,它会向上冒泡到父图。父图负责决定如何处理子图的中断。这种分层处理策略确保了嵌套图中的中断不会被子图默默吞掉------只有最外层的根图有权决定是否将中断暴露给用户。在 exit 持久化模式下,子图在中断时会额外执行一次检查点保存和 pending writes 保存,确保即使在最激进的延迟持久化策略下,中断状态也不会丢失。
抑制逻辑中的 not self.is_nested 检查至关重要。is_nested 通过检查配置中是否存在 CONFIG_KEY_TASK_ID 来判断------只有作为父图任务一部分执行的图才是嵌套的。独立执行的根图(即使配置中携带了 checkpoint_ns)不被视为嵌套的,因为 __init__ 中会清理外部传入的命名空间。
9.6.2 中断信息的输出
中断信息最终以 __interrupt__ 键出现在输出中:
python
# 用户视角
for chunk in graph.stream(input, config):
print(chunk)
# 输出:
# {'__interrupt__': (Interrupt(value='请确认操作', id='45fda847...'),)}
需要注意的是,中断信息以元组形式包装在 __interrupt__ 键中。元组而非列表的选择是有意的------元组是不可变的,防止用户代码意外修改中断信息。每个元组元素是一个 Interrupt 对象,包含 value(中断传递的值)和 id(用于精确恢复的标识符)两个属性。
在 v2 版本的流式接口中,中断信息也会出现在 ValuesStreamPart 的 interrupts 字段中,提供了更加结构化的访问方式:
python
# v2 stream
async for part in graph.astream(input, version="v2"):
if part["type"] == "values":
if part["interrupts"]:
print("中断:", part["interrupts"])
9.7 人机协作模式
9.7.1 审批模式(Approval)
最经典的人机协作模式。Agent 在执行高风险操作前请求人类审批:
python
from langgraph.types import interrupt, Command
def tool_executor(state):
tool_call = state["pending_tool_call"]
# 请求人类审批
approval = interrupt({
"action": tool_call["name"],
"args": tool_call["args"],
"question": "是否允许执行此操作?"
})
if approval == "approved":
result = execute_tool(tool_call)
return {"result": result}
else:
return {"result": "操作已被用户拒绝"}
# 使用
config = {"configurable": {"thread_id": "thread-1"}}
graph.invoke({"query": "删除所有记录"}, config)
# -> 中断: {'action': 'delete_all', 'question': '是否允许执行此操作?'}
graph.invoke(Command(resume="approved"), config)
# -> 执行操作并返回结果
9.7.2 信息补充模式(Information Gathering)
当 Agent 需要用户提供额外信息时:
python
def gather_info(state):
if not state.get("user_preference"):
preference = interrupt("请选择您偏好的方案: A) 快速 B) 经济 C) 平衡")
return {"user_preference": preference}
# 基于用户偏好继续处理
return process_with_preference(state)
9.7.3 多步交互模式
同一节点中可以有多个中断点,实现多轮对话:
python
def multi_step_form(state):
name = interrupt("请输入您的姓名")
email = interrupt("请输入您的邮箱")
confirm = interrupt(f"确认信息: {name}, {email}. 是否正确?")
if confirm == "yes":
return {"name": name, "email": email, "confirmed": True}
else:
return {"confirmed": False}
恢复时每次只解决一个新的中断,但已解决的中断的值会被缓存在 scratchpad 中自动返回。这意味着节点的执行看起来是"渐进式推进"的------每次恢复都让节点向前推进一步,直到所有中断都被解决。以下是完整的交互流程:
python
# 第一次中断
graph.invoke(input, config) # -> 请输入姓名
# 恢复第一个中断,触发第二个
graph.invoke(Command(resume="Alice"), config) # -> 请输入邮箱
# 恢复第二个中断,触发第三个
graph.invoke(Command(resume="alice@example.com"), config) # -> 确认信息...
# 最终确认
graph.invoke(Command(resume="yes"), config) # -> 完成
9.7.4 并行中断与按 ID 恢复
当图中有多个并行执行的节点同时中断时,需要使用按 ID 恢复:
python
# 多个节点同时中断
# node_a: interrupt("需要 A 的输入") -> id="abc123..."
# node_b: interrupt("需要 B 的输入") -> id="def456..."
# 一次性恢复所有中断
graph.invoke(
Command(resume={
"abc123...": "A 的值",
"def456...": "B 的值",
}),
config,
)
按 ID 恢复的机制在设计上支持"部分恢复"------你可以选择只恢复一部分中断,让其余的中断继续保持挂起状态。这在需要渐进式审批的场景中很有用:例如,一个并行执行的风控系统中有三个独立的检查节点同时中断,风控人员可以先处理最紧急的一个,然后再逐一处理其余的。
如果尝试不使用 ID 映射来恢复多个中断,系统会拒绝操作并给出明确的错误提示:
python
if len(self._pending_interrupts()) > 1:
raise RuntimeError(
"When there are multiple pending interrupts, "
"you must specify the interrupt id when resuming."
)
id=abc123"| INT_A[中断 A] B -->|"interrupt('输入B')
id=def456"| INT_B[中断 B] INT_A -.->|等待| RESUME INT_B -.->|等待| RESUME RESUME["Command(resume={
'abc123': 'val_a',
'def456': 'val_b'
})"] RESUME --> A2[Node A 恢复] RESUME --> B2[Node B 恢复] A2 --> END((结束)) B2 --> END end
9.8 时间旅行与 Checkpoint 的配合
9.8.1 时间旅行回退
中断机制与 Checkpoint 的配合不仅限于暂停/恢复,还支持"时间旅行"------回退到任意历史检查点并从那里重新执行:
python
# 查看历史状态
states = list(graph.get_state_history(config))
for state in states:
print(f"Step {state.metadata['step']}: {state.values}")
# 回退到特定检查点
target_config = states[2].config # 回退到第3个检查点
graph.invoke(None, target_config) # 从该点恢复执行
9.8.2 RESUME 写入的清理
在时间旅行场景下,存在一个微妙的问题:如果回退到一个已经有 RESUME 写入的检查点,这些旧的恢复值应该被清理,否则 interrupt() 会错误地返回旧值而不是重新中断。
python
# PregelLoop._first()
if self.is_replaying and (
(self.is_nested and configurable.get(CONFIG_KEY_CHECKPOINT_NS, "")
in configurable.get(CONFIG_KEY_CHECKPOINT_MAP, {}))
or not (
(input_is_command and cast(Command, self.input).resume is not None)
or configurable.get(CONFIG_KEY_RESUMING, False)
)
):
# 时间旅行时清理旧的 RESUME 写入
self.checkpoint_pending_writes = [
w for w in self.checkpoint_pending_writes if w[1] != RESUME
]
条件判断非常精细:只有在真正的时间旅行(而非正常恢复)时才清理 RESUME 写入。正常恢复场景下,之前的 RESUME 值必须保留,以支持多中断的顺序恢复。这个区分的逻辑是相当复杂的:对于外层图,通过检查输入是否为 Command(resume=...) 来判断是否是恢复操作;对于子图,通过 CONFIG_KEY_RESUMING 标志来判断;对于时间旅行到子图检查点的情况,通过检查子图自身的命名空间是否出现在 checkpoint_map 中来识别。这些条件的精心组合确保了在各种边界情况下 RESUME 写入的正确处理------既不会在恢复时误删导致重复中断,也不会在时间旅行时残留导致跳过中断。
9.9 中断与错误处理的交互
9.9.1 中断期间的异常处理
在复杂的工作流中,中断和错误处理可能交织在一起。LangGraph 对这种交互有明确的处理策略。
当一个节点在中断之前抛出了异常,异常通过正常的重试机制处理(如果配置了 RetryPolicy)。如果重试耗尽,错误被记录为 ERROR 类型的 pending write,与中断信息共存在检查点中。恢复时,系统会首先检查是否有未处理的错误,如果有,则在恢复前将错误状态清除。
更微妙的情况是:如果一个节点在第一个 interrupt() 返回恢复值之后、但在到达第二个 interrupt() 之前抛出了异常。在这种情况下,第一个中断的恢复值已经被缓存在 scratchpad 中。如果用户重新发起恢复(可能修复了导致异常的外部条件),节点会重新执行,第一个 interrupt() 会从缓存中返回之前的值,然后代码继续执行到异常点。这种行为确保了恢复值的幂等性------一旦用户提供了某个中断的恢复值,它就会被持久化,后续的重新执行不需要用户再次提供。
9.9.2 超时与中断的关系
LangGraph 的图执行有 recursion_limit 限制(默认 25 步)。如果图在达到步骤限制时仍有未处理的中断,系统会抛出 GraphRecursionError 而非将中断暴露给用户。这是一个安全措施------防止无限循环的恢复/中断链消耗过多资源。开发者在设计多步骤的人机协作流程时,需要确保 recursion_limit 足够大以容纳所有可能的交互步骤。
9.9.3 中断的幂等性保证
LangGraph 的中断机制提供了一定程度的幂等性保证。具体来说:
对于同一个检查点,多次发送相同的 Command(resume=value) 不会导致异常或不一致的状态。第一次恢复时,值被写入 RESUME pending write 并驱动节点执行;如果由于网络问题导致客户端没有收到响应而重试,第二次恢复时系统检测到 RESUME 已经存在(通过 put_writes 的 ON CONFLICT DO NOTHING 语义),不会重复处理。
然而,需要注意的是,这种幂等性仅限于检查点级别。如果第一次恢复已经成功推进了图的状态(创建了新的检查点),第二次使用同一个配置发送恢复命令实际上是在最新的检查点上操作,而该检查点可能已经没有待处理的中断了。客户端应该通过检查返回的检查点 ID 来判断操作是否实际生效。
9.10 设计决策分析
9.9.1 为什么选择异常机制而非回调?
LangGraph 选择通过抛出异常来实现中断,而非传统的回调或 Promise 模式。这个选择有深层原因:
- 保持节点函数的简单性:节点函数可以是普通的同步函数,不需要感知异步框架
- 确定性恢复:通过重新执行整个节点函数,确保恢复后的状态与首次执行时完全一致
- 与 Checkpoint 天然配合:异常中断时,执行栈被丢弃,所有状态通过 Checkpoint 保存,实现了真正的持久化暂停
9.9.2 为什么重新执行整个节点?
恢复时不是从中断点继续执行,而是重新执行整个节点。这个设计的考量:
- 简化实现:不需要保存 Python 执行栈的状态(这在 CPython 中几乎不可能可靠地实现)
- 确保一致性:重新执行可以拾取可能已变化的外部状态
- 多中断支持:通过 scratchpad 的索引追踪,已解决的中断直接返回缓存值,新的中断正常触发
代价是:中断前的副作用(如 API 调用、数据库写入、文件操作等)会被重复执行。这意味着在包含 interrupt() 的节点中,所有位于 interrupt() 之前的操作都必须是幂等的,或者需要通过额外的逻辑来检测和跳过已执行的操作。这是使用 interrupt() 时最重要的注意事项之一。在实践中,一个常见的模式是将非幂等的操作放在 interrupt() 之后------这样它们只在恢复后执行一次。或者,使用检查点中的状态来记录已完成的操作,在重新执行时根据状态跳过已完成的步骤。
9.9.3 为什么中断 ID 是确定性的?
中断 ID 通过对检查点命名空间哈希生成,而非随机 UUID。这带来两个好处:
- 可预测性:客户端可以预知中断 ID,便于构建自动化的恢复流程
- 幂等性:同一执行路径上的同一中断总是产生相同的 ID,防止重复处理
9.9.4 WRITES_IDX_MAP 的负索引设计
特殊写入(ERROR=-1, SCHEDULED=-2, INTERRUPT=-3, RESUME=-4)使用负索引,这是一个精巧的设计:
python
WRITES_IDX_MAP = {ERROR: -1, SCHEDULED: -2, INTERRUPT: -3, RESUME: -4}
普通写入从零开始正向编号,特殊写入使用固定的负数索引。在数据库的主键约束 (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) 下,这保证了普通写入可以有任意数量且索引自增不冲突,而特殊写入每种类型最多一个且通过 UPSERT 语义保证最新值覆盖旧值。这种正负分区的索引设计避免了为特殊写入单独创建表或列的复杂性,在同一个统一的写入表中优雅地容纳了两种不同语义的数据。从数据库优化的角度看,负索引的绝对值很小(最大为 4),这意味着它们在 B-tree 索引中总是位于叶子节点的最前面,查询效率极高。
9.10.5 声明式中断的局限性与适用场景
interrupt_before 和 interrupt_after 看似简单,但在设计复杂工作流时需要理解它们的局限性。声明式中断是无条件的------只要配置了节点名,每次执行到该节点都会中断。这意味着它不适用于"只在特定条件下才需要人工审核"的场景。对于条件性中断,应该在节点内部使用 interrupt() 函数,并用 if 语句控制是否调用。
声明式中断也不携带任何上下文信息。当客户端收到由 interrupt_before 触发的中断时,它只知道"某个节点即将执行",但不知道为什么需要暂停或者用户应该做什么。这与 interrupt(value) 形成对比------后者可以传递一个描述性的消息来指导用户的操作。
那么声明式中断适用于什么场景?它最适合以下情况:在开发和调试阶段,需要在每个关键节点前后暂停以检查状态;在安全敏感的流程中,需要对所有外部操作进行人工审批;在演示和教学场景中,需要单步执行图以展示每个步骤的行为。在这些场景下,无条件暂停和无需携带数据恰恰是优势而非限制。
9.11 小结
��章深入���析了 LangGraph 的中断与人机协作机制。我们看到,这套机制远非简单的"暂停/继续",而是一个经过精心设计的、可靠的持久化交互框架:
- interrupt() 函数通过异常机制实现暂停,通过 scratchpad 的索引追踪实现恢复时的精确值匹配。这个函数巧妙地让同一个函数调用在不同的执行生命周期中扮演截然不同的角色------首次调用时作为暂停器抛出异常,恢复后重新执行时作为值提供器返回缓存数据
- Interrupt 数据类型使用基于命名空间哈希的确定性标识符,支持精确的按标识符恢复,彻底解决了并行中断和多中断场景下的歧义问题
- interrupt_before 和 interrupt_after 提供了编译时的声明式中断配置,适用于不需要携带自定义数据的简单审查和调试��景
- 与 Checkpoint 的深度集成确保中断状态可以跨进程、跨机器、跨时间持久化,基于 pending writes 的 RESUME 写入管理机制支持多中断的顺序恢复和并行中断的按标识符精确恢复
- 时间旅行中对恢复写入的精细清理逻辑体现了系统在复杂边界条件和极端场景下的健壮性设计
中断机制是 LangGraph 区别于简单 DAG 执行器的标志性特性之一。从更宏观的角度看,中断机制将 LangGraph 从一个"批处理图引擎"提升为一个"交互式计算平台"------它支持的不仅仅是自动化的端到端执行,更是人与机器之间的持续协作。这种能力在 AI Agent 的实际部署中至关重要:现实世界中的决策往往不能完全委托给机器,需要在关键节点引入人类的判断和确认。LangGraph 的中断机制让这种混合决策模式变得自然而优雅,而无需开发者编写复杂的状态管理和会话持久化代码。
在下一章中,我们将看到 Command 类型如何提供更强大的高级控制流能力------不仅能恢复中断,还能在节点内部直接控制图的执行路径。