《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章 设计模式与架构决策
第11章 子图与嵌套
11.1 引言
在前面的章节中,我们已经深入了解了单个图的全部运行机制:Channel 如何承载状态,Pregel 循环如何调度任务,Checkpoint 如何持久化状态,Command 如何控制流程。然而,当系统复杂度增长到一定程度时,单个扁平的图将变得难以维护。正如软件工程中函数调用和模块化的必要性,LangGraph 的子图(Subgraph)机制允许将一个复杂的图分解为可组合、可复用的子单元。
子图在 LangGraph 中不是简单的"函数调用"。它涉及到命名空间隔离、检查点嵌套、状态映射、跨图通信等一系列精密的工程机制。一个子图拥有自己独立的 Channel 空间、自己的 Checkpoint 历史、自己的执行循环------同时又通过精心设计的接口与父图保持协调。
理解子图机制的关键在于认识到它解决的根本问题:复杂性管理。一个完整的客服系统可能包含意图识别、知识检索、工具调用、人工审批、对话管理等多个功能模块。如果将所有这些逻辑放在一个扁平的图中,节点数量会迅速膨胀到难以维护的地步,而且不同模块之间的状态容易产生意外的耦合。子图提供了自然的封装边界:每个子图定义自己的状态 Schema、自己的执行逻辑和自己的检查点历史,通过明确定义的输入/输出接口与外部交互。这种封装不仅提升了代码的可维护性,还支持了模块的独立开发和测试------一个子图可以单独编译和运行,只有在嵌入到父图中时才需要关心状态映射的问题。
本章将从源码层面剖析 LangGraph 的子图体系:从如何将一个编译后的图作为节点添加到另一个图中,到命名空间如何隔离子图的状态,再到 Checkpoint 如何在嵌套层级中工作,以及 ParentCommand 如何实现跨图的控制流传递。在实际应用中,子图模式是构建多 Agent 系统的核心范式------每个 Agent 可以被封装为一个独立的子图,由协调器(主管)图统一编排和管理。理解子图的内部运作机制,是从"能用 LangGraph"进阶到"深度掌握 LangGraph"的关键一步。
:::tip 本章要点
- 图作为节点 :理解
add_node(name, compiled_graph)的内部机制和Pregel作为Runnable的协议 - 命名空间隔离 :深入
NS_SEP/NS_END分隔符构成的层级命名空间体系 - Checkpoint 命名空间 :掌握
"parent|child"格式的检查点命名空间和checkpoint_map的跨层级映射 - ParentCommand 跨图通信:理解子图如何通过异常冒泡向父图发送控制指令
- 状态映射:掌握父图与子图之间的状态传递和转换机制
- 嵌套 Agent 架构:通过实际案例了解多层嵌套图的设计模式 :::
11.2 图作为节点
11.2.1 Pregel 的 Runnable 协议
LangGraph 中的编译后的图(CompiledStateGraph)是 Pregel 的子类,而 Pregel 实现了 LangChain 的 Runnable 接口。这意味着一个编译后的图可以像普通函数一样被调用,也可以作为另一个图的节点:
python
# 创建子图
sub_builder = StateGraph(SubState)
sub_builder.add_node("process", process_fn)
sub_builder.add_edge(START, "process")
sub_builder.add_edge("process", END)
sub_graph = sub_builder.compile()
# 将子图作为父图的节点
parent_builder = StateGraph(ParentState)
parent_builder.add_node("sub_agent", sub_graph)
parent_builder.add_edge(START, "sub_agent")
parent_builder.add_edge("sub_agent", END)
parent_graph = parent_builder.compile(checkpointer=InMemorySaver())
当 add_node 接收到一个 Runnable(包括编译后的图)时,它通过 coerce_to_runnable 将其包装为 PregelNode:
python
# langgraph/graph/state.py - add_node
if isinstance(action, Runnable):
node = action.get_name()
# ...
self.nodes[node] = StateNodeSpec(
coerce_to_runnable(action, name=node, trace=False),
metadata,
input_schema=input_schema or self.state_schema,
# ...
)
11.2.2 子图的 Checkpointer 继承
子图的 checkpointer 行为通过 Checkpointer 类型控制:
python
Checkpointer = None | bool | BaseCheckpointSaver
三种取值对应三种行为:
None(默认):继承父图的 checkpointer。子图的检查点存储在与父图相同的存储后端中,使用独立的命名空间True:显式启用检查点。效果与None相同但语义更明确False:禁用检查点。即使父图有 checkpointer,子图也不保存检查点
python
# 子图继承父图的 checkpointer
sub_graph = sub_builder.compile() # checkpointer=None
# 子图显式禁用 checkpointer
sub_graph = sub_builder.compile(checkpointer=False)
值得注意的是,checkpointer=None(继承)和 checkpointer=True(显式启用)在当前版本中的效果几乎相同------两者都会让子图使用父图的 checkpointer。区别在于语义明确性:True 明确表示开发者期望子图有持久化能力,而 None 则表示"跟随父图的决策"。当父图也没有 checkpointer 时,None 会导致子图同样没有持久化,而 True 在这种情况下也不会创建一个 checkpointer(因为没有可继承的存储后端)。False 是唯一可以主动阻断继承链的选项------当你确定某个子图不需要持久化(例如一个无状态的数据转换子图),使用 False 可以避免不必要的检查点写入开销。
在 Pregel 的执行过程中,checkpointer 通过 CONFIG_KEY_CHECKPOINTER 从父图传递到子图:
python
# langgraph/_internal/_constants.py
CONFIG_KEY_CHECKPOINTER = "__pregel_checkpointer"
11.2.3 子图探测与 subgraphs 属性
PregelExecutableTask 中的 subgraphs 字段记录了当前任务包含的子图:
python
@dataclass(**_T_DC_KWARGS)
class PregelExecutableTask:
name: str
input: Any
proc: Runnable
writes: deque[tuple[str, Any]]
config: RunnableConfig
triggers: Sequence[str]
retry_policy: Sequence[RetryPolicy]
cache_key: CacheKey | None
id: str
path: tuple[str | int | tuple, ...]
writers: Sequence[Runnable] = ()
subgraphs: Sequence[PregelProtocol] = ()
这使得父图的执行引擎可以感知子图的存在,从而在流式输出、调试信息生成和状态快照查询时正确地处理子图的事件和状态。这种显式的子图追踪(而非在运行时动态探测)使得 get_state(subgraphs=True) 可以精确知道哪些任务包含子图,并按需加载它们的状态。
11.3 命名空间隔离
11.3.1 NS_SEP 与 NS_END
LangGraph 使用两个分隔符构建层级命名空间:
python
# langgraph/_internal/_constants.py
NS_SEP = "|" # 层级分隔符,分隔图的嵌套层级
NS_END = ":" # 命名空间与任务ID的分隔符
一个完整的检查点命名空间看起来像这样:
arduino
"agent:task-id-abc|tool_executor:task-id-def|sub_tool:task-id-ghi"
^ ^ ^ ^ ^ ^
节点名 任务ID 节点名 任务ID 节点名 任务ID
每一层由 node_name:task_id 组成,层与层之间用 | 分隔。这种编码方式不仅标识了子图的嵌套层级,还通过 task_id 区分了同一个节点的不同执行实例。这在并行 map 场景中至关重要:当 Send 创建了多个同类子图的并行实例时,每个实例通过不同的 task_id 获得独立的命名空间,从而拥有互不干扰的检查点和状态。
命名空间的编码规则是完全确定性的:给定相同的图结构和执行路径,生成的命名空间字符串总是相同的。这个特性使得从中断恢复时可以精确定位到子图的检查点------不需要额外的映射表,命名空间本身就包含了足够的信息来重建图的嵌套结构。
11.3.2 命名空间的构建
在 PregelLoop.__init__ 中,命名空间从配置中提取并解析:
python
# langgraph/pregel/_loop.py
class PregelLoop:
def __init__(self, ...):
# 检测是否是嵌套图
self.is_nested = CONFIG_KEY_TASK_ID in self.config.get(CONF, {})
# 构建命名空间
scratchpad = config[CONF].get(CONFIG_KEY_SCRATCHPAD)
if isinstance(scratchpad, PregelScratchpad):
if cnt := scratchpad.subgraph_counter():
# 追加子图计数器到命名空间
self.config = patch_configurable(
self.config,
{
CONFIG_KEY_CHECKPOINT_NS: NS_SEP.join((
config[CONF][CONFIG_KEY_CHECKPOINT_NS],
str(cnt),
))
},
)
# 解析命名空间为元组
self.checkpoint_ns = (
tuple(
cast(str, self.config[CONF][CONFIG_KEY_CHECKPOINT_NS]).split(NS_SEP)
)
if self.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS)
else ()
)
subgraph_counter 是 PregelScratchpad 中的一个闭包计数器。当同一个节点的同一次执行中启动多个子图时(例如通过 Send 的并行 map),每个子图获得不同的命名空间后缀。
11.3.3 根图的特殊处理
当一个非嵌套图的配置中已经存在 checkpoint_ns 时(例如通过外部配置传入),根图会清理它:
python
if not self.is_nested and config[CONF].get(CONFIG_KEY_CHECKPOINT_NS):
self.config = patch_configurable(
self.config,
{CONFIG_KEY_CHECKPOINT_NS: "", CONFIG_KEY_CHECKPOINT_ID: None},
)
这确保了根图总是在空命名空间 "" 下运行,避免外部配置污染。这个清理逻辑看似简单,但对于系统的健壮性至关重要:在 LangGraph Platform 等部署环境中,外部系统可能在配置中设置了 checkpoint_ns 来追踪请求来源,如果不清理这些值,根图可能会错误地将自己当作子图运行,导致中断抑制等行为异常。
checkpoint_ns = ''"] ROOT --> AGENT["agent:task-abc
checkpoint_ns = 'agent:task-abc'"] AGENT --> TOOL["tool:task-def
checkpoint_ns = 'agent:task-abc|tool:task-def'"] TOOL --> SUB["sub:task-ghi
checkpoint_ns = 'agent:task-abc|tool:task-def|sub:task-ghi'"] ROOT --> REVIEWER["reviewer:task-xyz
checkpoint_ns = 'reviewer:task-xyz'"] end style ROOT fill:#e8f5e9 style AGENT fill:#e3f2fd style TOOL fill:#fff3e0 style SUB fill:#fce4ec style REVIEWER fill:#e3f2fd
11.4 Checkpoint 命名空间
11.4.1 checkpoint_map:跨层级的检查点映射
checkpoint_map 是一个从命名空间到检查点 ID 的映射,它在父图和子图之间传递检查点关联信息:
python
# langgraph/_internal/_constants.py
CONFIG_KEY_CHECKPOINT_MAP = "checkpoint_map"
当父图执行子图时,它将自己的命名空间和检查点 ID 加入到 checkpoint_map 中传递给子图:
python
# 在 PregelLoop._first() 中
metadata["parents"] = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {})
子图在初始化时,使用 checkpoint_map 来定位自己的起始检查点:
python
# PregelLoop.__init__
if (
CONFIG_KEY_CHECKPOINT_MAP in self.config[CONF]
and self.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS)
in self.config[CONF][CONFIG_KEY_CHECKPOINT_MAP]
):
self.checkpoint_config = patch_configurable(
self.config,
{
CONFIG_KEY_CHECKPOINT_ID: self.config[CONF][
CONFIG_KEY_CHECKPOINT_MAP
][self.config[CONF][CONFIG_KEY_CHECKPOINT_NS]]
},
)
11.4.2 子图检查点的存储
子图的检查点与父图存储在同一个 CheckpointSaver 中,但通过不同的 checkpoint_ns 隔离。以 InMemorySaver 为例:
python
# InMemorySaver 的存储结构
# storage[thread_id][checkpoint_ns][checkpoint_id] = (checkpoint, metadata, parent_id)
# 父图的检查点
storage["thread-1"][""]["ck-1"] = (parent_checkpoint, ...)
# 子图的检查点
storage["thread-1"]["agent:task-abc"]["ck-2"] = (child_checkpoint, ...)
# 子子图的检查点
storage["thread-1"]["agent:task-abc|tool:task-def"]["ck-3"] = (grandchild_checkpoint, ...)
(根图)"] NS1["checkpoint_ns = 'agent:task-abc'
(子图)"] NS2["checkpoint_ns = 'agent:task-abc|tool:task-def'
(子子图)"] end NS0 --> CK0["ck-001: {channel_values: {...}}"] NS0 --> CK1["ck-002: {channel_values: {...}}"] NS1 --> CK2["ck-003: {channel_values: {...}}"] NS1 --> CK3["ck-004: {channel_values: {...}}"] NS2 --> CK4["ck-005: {channel_values: {...}}"] end
11.4.3 恢复时的层级协调
当从中断恢复时,父图需要知道子图的检查点 ID 以便正确恢复。这通过 CONFIG_KEY_RESUMING 和 CONFIG_KEY_REPLAY_STATE 标志实现:
python
# PregelLoop._first() - 将恢复和重放标志传递给子图
if not self.is_nested:
replay_state: ReplayState | None = None
if self.is_replaying:
replay_checkpoint_id = self.checkpoint["id"]
if (
self.checkpoint_metadata.get("source") == "update"
and self.prev_checkpoint_config
):
replay_checkpoint_id = self.prev_checkpoint_config[CONF].get(
CONFIG_KEY_CHECKPOINT_ID, replay_checkpoint_id
)
replay_state = ReplayState(replay_checkpoint_id)
self.config = patch_configurable(
self.config,
{
CONFIG_KEY_RESUMING: is_resuming,
CONFIG_KEY_REPLAY_STATE: replay_state,
},
)
ReplayState 追踪父图的检查点 ID 上界,使得子图在恢复时可以找到对应的历史检查点,而不需要重新执行已完成的步骤。
11.4.4 exit 模式下的子图处理
在 durability="exit" 模式下,检查点只在图退出时保存。对于嵌套图,需要特殊处理:
python
def _suppress_interrupt(self, exc_type, exc_value, traceback):
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()
子图在正常完成时不保存检查点(exit 模式),但在中断或错误时会保存,确保可以从中断点恢复。这种条件性保存的逻辑体现了 LangGraph 在性能与可靠性之间的务实平衡:对于正常完成的子图,其结果已经被父图的检查点捕获,不需要额外的子图级检查点;但对于中断或出错的子图,如果不保存检查点,恢复时就需要从头重新执行子图的所有步骤,这可能涉及昂贵的 LLM 调用或外部 API 请求。条件 all(NS_END not in part for part in self.checkpoint_ns) 的检查用于识别特殊的命名空间配置,确保在这些边界情况下也能正确触发检查点保存。
11.5 ParentCommand 机制
11.5.1 从子图到父图的控制流
在第10章中我们已经介绍了 ParentCommand 的基本概念。在子图的上下文中,这个机制变得更加重要:
python
# 子图节点返回指向父图的 Command
def child_node(state):
result = process(state)
return Command(
graph=Command.PARENT,
update={"child_result": result},
goto="parent_handler"
)
11.5.2 异常冒泡的完整链路
子图不抑制此异常 CL->>PT: 异常冒泡到父图任务 PT->>PT: 捕获 ParentCommand PT->>PL: 将 cmd 转换为父图写入 Note over PL: cmd.update -> 父图状态更新
cmd.goto -> 父图路由写入 PL->>PL: apply_writes() PL->>PL: prepare_next_tasks()
11.5.3 多层嵌套的传递
Command.PARENT 只向上传递一层。这是一个有意的设计约束,而非实现上的限制。限制为一层传递的原因在于保持各层级图的封装性------中间层的图应该明确知道其子图要与父图通信,并有机会对通信内容进行过滤或转换。如果允许无限穿透,子图就可以绕过中间层直接影响祖父图的状态,破坏了层级封装的原则。如果确实需要跨越多层传递,中间层的子图必须显式处理和转发:
python
# 孙图节点
def grandchild_node(state):
return Command(graph=Command.PARENT, update={"msg": "from grandchild"})
# 这只到达子图,不会自动到达祖父图
# 子图节点(需要显式转发)
def child_node(state):
# 如果需要进一步向上传递
return Command(
graph=Command.PARENT,
update={"msg": state.get("msg", "from child")}
)
11.6 状态映射
11.6.1 输入映射
当父图调用子图时,子图的输入是父图的当前状态。如果父图和子图的 Schema 不同,状态映射在 StateNodeSpec 的 input_schema 中定义:
python
# 父图的状态
class ParentState(TypedDict):
query: str
context: list[str]
result: str
# 子图的状态
class ChildState(TypedDict):
query: str
intermediate: list[str]
# 添加子图时指定输入 schema
parent_builder.add_node("child", child_graph, input_schema=ChildState)
只有在父子图 Schema 中同名的字段会被自动传递。这是通过 _get_updates 函数中的 output_keys 过滤实现的:
python
# attach_node 中
if key == START:
output_keys = [k for k, v in self.builder.schemas[self.builder.input_schema].items()
if not is_managed_value(v)]
else:
output_keys = list(self.builder.channels) + [...]
def _get_updates(input):
if isinstance(input, dict):
return [(k, v) for k, v in input.items() if k in output_keys]
11.6.2 输出映射
子图的输出同样通过 Schema 匹配映射回父图。子图的最终输出(由 output_schema 定义的通道值)被作为节点的返回值,然后经过 _get_updates 过滤,只有与父图 Schema 匹配的字段才会被写入父图的 Channel。
{query, context, result}"] CS["子图状态
{query, intermediate}"] PS -->|"输入映射
同名字段: query"| CS CS -->|"输出映射
同名字段: query"| PS NOTE1["context, result
对子图不可见"] NOTE2["intermediate
对父图不可见"] end style NOTE1 fill:#fff3e0,stroke:#e65100 style NOTE2 fill:#fff3e0,stroke:#e65100
11.6.3 完全隔离的子图状态
子图拥有完全独立的 Channel 空间。即使父图和子图有同名的 Channel,它们也是物理隔离的------子图的写入不会直接影响父图的 Channel,只有在子图完成后,通过输出映射才会将结果传递回父图。
这种隔离通过命名空间实现:子图的 Channel 值存储在不同的 checkpoint_ns 下,与父图的值物理分离。这意味着即使父图和子图都有一个叫做 messages 的 Channel,它们也是完全独立的------子图向 messages 写入的值不会出现在父图的 messages 中,反之亦然。只有在子图完成执行后,通过输出映射机制,子图的最终状态中与父图 Schema 匹配的字段才会被写回父图的相应 Channel。
这种设计相比共享状态空间的方案有明显优势:子图的内部实现细节不会泄漏到父图的命名空间中,减少了意外的状态冲突;子图可以自由地使用临时的内部 Channel 而不担心与父图或其他子图产生冲突;子图的 Schema 可以完全独立于父图定义,只需要在输入/输出接口处保持兼容。
11.7 嵌套 Agent 架构
11.7.1 基本的主管-工人模式
最常见的嵌套模式是主管(Supervisor)协调多个工人(Worker)子图:
python
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, Send
class SupervisorState(TypedDict):
query: str
results: Annotated[list[str], operator.add]
final_answer: str
class WorkerState(TypedDict):
query: str
result: str
# 创建工人子图
def create_worker(name: str):
builder = StateGraph(WorkerState)
builder.add_node("process", lambda state: {"result": f"{name}: {state['query']}"})
builder.add_edge(START, "process")
builder.add_edge("process", END)
return builder.compile()
research_worker = create_worker("研究员")
analysis_worker = create_worker("分析师")
# 主管图
supervisor_builder = StateGraph(SupervisorState)
def supervisor(state: SupervisorState) -> Command:
"""决定分派给哪些工人"""
return Command(
goto=[
Send("research", {"query": state["query"]}),
Send("analysis", {"query": state["query"]}),
]
)
def synthesize(state: SupervisorState) -> dict:
return {"final_answer": " + ".join(state["results"])}
supervisor_builder.add_node("supervisor", supervisor)
supervisor_builder.add_node("research", research_worker)
supervisor_builder.add_node("analysis", analysis_worker)
supervisor_builder.add_node("synthesize", synthesize)
supervisor_builder.add_edge(START, "supervisor")
supervisor_builder.add_edge("research", "synthesize")
supervisor_builder.add_edge("analysis", "synthesize")
supervisor_builder.add_edge("synthesize", END)
graph = supervisor_builder.compile(checkpointer=InMemorySaver())
11.7.2 递归嵌套
子图可以再次包含子图,形成任意深度的嵌套。每一层都有独立的命名空间和检查点:
python
# Level 3: 最内层工具
tool_graph = create_tool_graph()
# Level 2: Agent 使用工具
agent_builder = StateGraph(AgentState)
agent_builder.add_node("llm", llm_node)
agent_builder.add_node("tools", tool_graph)
agent_builder.add_edge(START, "llm")
agent_builder.add_conditional_edges("llm", should_use_tool, {"yes": "tools", "no": END})
agent_builder.add_edge("tools", "llm")
agent_graph = agent_builder.compile()
# Level 1: 主管协调多个 Agent
supervisor_builder = StateGraph(SupervisorState)
supervisor_builder.add_node("agent_a", agent_graph)
supervisor_builder.add_node("agent_b", agent_graph)
supervisor_builder.add_node("coordinator", coordinator_fn)
# ...
这种多层嵌套的架构在企业级 Agent 系统中很常见:顶层的协调器负责任务分配和结果汇总,中层的 Agent 负责特定领域的推理和决策,底层的工具子图负责与外部系统的交互。每一层都可以独立开发、测试和迭代,只要保持层间接口(即输入/输出 Schema)的稳定即可。
此时的命名空间示例:
bash
根图: ""
agent_a: "agent_a:task-1"
agent_a/tools: "agent_a:task-1|tools:task-2"
agent_b: "agent_b:task-3"
agent_b/tools: "agent_b:task-3|tools:task-4"
11.7.3 带中断的嵌套图
子图中的中断会向上冒泡到根图,形成一个完整的暂停/恢复链:
python
# 子图中的中断
def child_review(state):
decision = interrupt({
"context": state["proposal"],
"question": "是否批准子任务?"
})
return {"decision": decision}
# 创建子图
child_builder = StateGraph(ChildState)
child_builder.add_node("review", child_review)
child_builder.add_edge(START, "review")
child_builder.add_edge("review", END)
child_graph = child_builder.compile()
# 父图
parent_builder = StateGraph(ParentState)
parent_builder.add_node("prepare", prepare_fn)
parent_builder.add_node("child", child_graph)
parent_builder.add_node("finalize", finalize_fn)
parent_builder.add_edge(START, "prepare")
parent_builder.add_edge("prepare", "child")
parent_builder.add_edge("child", "finalize")
parent_builder.add_edge("finalize", END)
graph = parent_builder.compile(checkpointer=InMemorySaver())
执行和恢复:
python
config = {"configurable": {"thread_id": "t1"}}
# 第一次执行:子图中断
result = graph.invoke({"proposal": "..."}, config)
# result 包含中断信息
# 恢复:值传递到子图的 interrupt()
result = graph.invoke(Command(resume="approved"), config)
# 子图恢复执行,完成后返回父图继续
子图不抑制 CG-->>PG: GraphInterrupt 传播 PG->>CP: 保存父图 checkpoint (ns="") PG->>CP: 保存子图 checkpoint (ns="child:task-x") PG->>CP: 保存 INTERRUPT pending write PG-->>U: {__interrupt__: [...]} U->>PG: invoke(Command(resume="approved"), config) PG->>CP: 加载父图 checkpoint PG->>CG: 恢复 child 子图 CG->>CP: 加载子图 checkpoint CG->>CG: 重新执行 review 节点 CG->>CG: interrupt() 返回 "approved" CG-->>PG: 子图完成,返回结果 PG->>PG: finalize 节点执行 PG-->>U: 最终结果
11.8 子图与流式输出
11.8.1 CONFIG_KEY_STREAM 传递
父图的流式输出协议通过 CONFIG_KEY_STREAM 传递给子图,使得子图的事件可以出现在父图的输出流中:
python
# langgraph/_internal/_constants.py
CONFIG_KEY_STREAM = "__pregel_stream"
# PregelLoop.__init__ - 合并流
if self.stream is not None and CONFIG_KEY_STREAM in config[CONF]:
self.stream = DuplexStream(self.stream, config[CONF][CONFIG_KEY_STREAM])
DuplexStream 将本地流和父图传递的流合并,使得子图的事件同时出现在两个流中。这种双工流的设计确保了无论从父图还是直接从子图观察,都能看到完整的事件序列。合并的实现非常简洁------DuplexStream 的回调函数遍历所有注册的流,根据每个流关注的 stream_mode 选择性地转发事件。这意味着如果父图只订阅了 "values" 模式的输出,子图的 "debug" 事件不会被不必要地传递给父图。
流式输出在嵌套图中的传播需要特别注意事件的归属问题。客户端如何区分一个 "updates" 事件是来自父图的某个节点还是来自子图的内部节点?答案就在事件的命名空间前缀中------每个事件都带有产生它的图的完整命名空间路径。通过解析这个路径,客户端可以构建出嵌套图执行的完整拓扑视图,精确地将每个事件定位到其来源层级和节点。
11.8.2 命名空间前缀
子图的流式事件带有命名空间前缀,客户端可以通过命名空间区分事件来源:
python
# 流式输出示例
async for part in graph.astream(input, version="v2"):
print(f"ns={part['ns']}, type={part['type']}")
# 输出:
# ns=(), type=updates <- 父图事件
# ns=('child:task-abc',), type=updates <- 子图事件
11.9 子图的 checkpoint_ns 与调试
11.9.1 get_state 与子图状态
LangGraph 提供了查看子图状态的能力:
python
# 获取父图状态
state = graph.get_state(config)
# 获取子图状态
state = graph.get_state(config, subgraphs=True)
# state.tasks 中的 task.state 包含子图的 StateSnapshot
11.9.2 子图的时间旅行
子图的检查点可以独立进行时间旅行:
python
# 列出子图的检查点历史
child_config = {
"configurable": {
"thread_id": "t1",
"checkpoint_ns": "child:task-abc",
}
}
history = list(graph.get_state_history(child_config))
这得益于命名空间隔离------每个命名空间有独立的检查点链,可以独立浏览和回退。在调试复杂的嵌套 Agent 系统时,这种能力极为有用:你可以深入到某个特定子图的执行历史中,逐步检查它在每个步骤的状态变化和决策过程,而不需要关心父图或其他并行子图的状态。这种层级化的状态审计能力对于理解和诊断多 Agent 系统的行为至关重要------当最终结果不符合预期时,你可以沿着嵌套层级逐层深入,精确定位问题发生在哪个子图的哪个步骤。结合 stream_mode="debug" 的流式输出,开发者可以获得嵌套图执行的完整可观测性。
需要注意的是,当使用 get_state(config, subgraphs=True) 查看子图状态时,StateSnapshot.tasks 中的每个 PregelTask 的 state 字段可以是 StateSnapshot(子图的完整状态快照)或 RunnableConfig(指向子图状态的配置,可以用它再次查询获取完整状态)。这种延迟加载的设计避免了在查看父图状态时无条件加载所有子图的状态数据,减少了不必要的数据库查询和内存消耗。
input"] --> P2["step 0
prepare"] --> P3["step 1
child开始"] P3 -.->|中断| P4["step 1
(中断)"] P4 -->|恢复| P5["step 2
finalize"] end subgraph "子图 (ns='child:task-abc')" C1["step -1
input"] --> C2["step 0
review"] C2 -.->|中断| C3["step 0
(中断)"] C3 -->|恢复| C4["step 1
完成"] end end P3 -.->|启动子图| C1 C4 -.->|返回父图| P5 style P4 fill:#f9f,stroke:#333 style C3 fill:#f9f,stroke:#333
11.10 设计决策分析
11.10.1 为什么选择命名空间隔离而非独立存储?
子图的检查点与父图存储在同一个 CheckpointSaver 中,通过 checkpoint_ns 隔离,而不是为每个子图创建独立的存储实例。这带来了几个优势:
- 事务一致性:在支持事务的存储后端(如 Postgres)中,父图和子图的检查点可以在同一个数据库事务中写入,保证了要么全部保存成功要么全部回滚,避免了跨存储实例时的分布式一致性问题
- 查询便利性 :通过同一个
thread_id就可以查询到所有嵌套层级的检查点,支持从根图到任意子图的深度状态审查 - 资源效率:避免为每个子图层级创建独立的数据库连接或存储实例,这在深度嵌套的场景下可以显著节省系统资源
- 统一的生命周期管理 :
delete_thread可以一次性清理所有嵌套层级的检查点和写入数据,不会留下孤立的子图数据
11.10.2 为什么 ParentCommand 使用异常而非返回值?
子图向父图传递控制使用异常冒泡机制。另一种可能的设计是将 Command 作为子图的特殊返回值。选择异常的原因:
- 即时性:异常立即中断子图的执行,不需要等待当前步骤完成
- 与中断的统一性 :
GraphBubbleUp基类统一了中断和跨图通信的传播机制 - 可跨越多层 :虽然
Command.PARENT只上传一层,但异常机制本身支持多层冒泡
11.10.3 为什么子图重新执行而非从缓存恢复?
当父图从中断恢复时,子图是重新执行的(从子图的最新检查点恢复),而不是直接返回缓存的结果。这保证了:
- 正确性:子图可能依赖外部状态(如数据库、API),重新执行可以获取最新数据
- 中断恢复的一致性:如果中断发生在子图内部,必须重新进入子图才能正确恢复
- 检查点的粒度:子图有自己的检查点,恢复时只需重新执行从最后一个子图检查点开始的步骤
11.10.4 subgraph_counter 的作用
PregelScratchpad.subgraph_counter 是一个递增计数器,用于区分同一个节点在同一次执行中启动的多个子图实例。例如:
python
# 通过 Send 并行启动同一个子图的多个实例
def dispatcher(state):
return [Send("worker", {"task": t}) for t in state["tasks"]]
每个 Send 创建一个独立的任务,每个任务的子图需要不同的命名空间。subgraph_counter 确保每次调用子图时获得递增的命名空间后缀。
11.11 完整示例:层级审批系统
下面是一个将本章所有概念结合在一起的完整示例:
python
from typing import Annotated, Literal, TypedDict
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt
from langgraph.checkpoint.memory import InMemorySaver
# --- 子图:部门审批 ---
class DeptState(TypedDict):
request: str
dept_decision: str
def dept_review(state: DeptState) -> dict:
decision = interrupt({
"request": state["request"],
"question": f"部门是否批准此请求?"
})
return {"dept_decision": decision}
dept_builder = StateGraph(DeptState)
dept_builder.add_node("review", dept_review)
dept_builder.add_edge(START, "review")
dept_builder.add_edge("review", END)
dept_graph = dept_builder.compile()
# --- 父图:审批流程 ---
class ApprovalState(TypedDict):
request: str
dept_decision: str
final_decision: str
log: Annotated[list[str], operator.add]
def prepare(state: ApprovalState) -> dict:
return {"log": [f"收到请求: {state['request']}"]}
def dept_approval(state: ApprovalState):
# 子图自动处理中断和恢复
pass # 子图作为节点直接执行
def final_review(state: ApprovalState) -> Command[Literal["approved", "rejected"]]:
if state["dept_decision"] == "approved":
return Command(
update={"final_decision": "approved", "log": ["最终批准"]},
goto="approved"
)
else:
return Command(
update={"final_decision": "rejected", "log": ["最终拒绝"]},
goto="rejected"
)
def on_approved(state: ApprovalState) -> dict:
return {"log": ["执行审批通过流程"]}
def on_rejected(state: ApprovalState) -> dict:
return {"log": ["通知申请人被拒绝"]}
parent_builder = StateGraph(ApprovalState)
parent_builder.add_node("prepare", prepare)
parent_builder.add_node("dept", dept_graph) # 子图作为节点
parent_builder.add_node("final_review", final_review)
parent_builder.add_node("approved", on_approved)
parent_builder.add_node("rejected", on_rejected)
parent_builder.add_edge(START, "prepare")
parent_builder.add_edge("prepare", "dept")
parent_builder.add_edge("dept", "final_review")
parent_builder.add_edge("approved", END)
parent_builder.add_edge("rejected", END)
graph = parent_builder.compile(checkpointer=InMemorySaver())
(interrupt)"] DR --> DE((End)) end DEPT --> FR[final_review] FR -->|"Command(goto='approved')"| APP[approved] FR -->|"Command(goto='rejected')"| REJ[rejected] APP --> E((END)) REJ --> E style DR fill:#f9f,stroke:#333 style FR fill:#9cf,stroke:#333
使用流程:
python
config = {"configurable": {"thread_id": "approval-1"}}
# 1. 启动审批流程(子图中断)
result = graph.invoke({"request": "采购10台服务器"}, config)
# -> 中断: {"request": "采购10台服务器", "question": "部门是否批准此请求?"}
# 2. 部门批准(恢复子图,继续到 final_review)
result = graph.invoke(Command(resume="approved"), config)
# -> 最终结果: {final_decision: "approved", log: [...]}
# 3. 查看完整执行历史
for state in graph.get_state_history(config):
print(f"Step {state.metadata['step']}: next={state.next}")
11.12 常见陷阱与最佳实践
11.12.1 状态 Schema 不匹配
最常见的子图问题是父图和子图的 Schema 不匹配导致数据丢失。当父图的状态包含子图不需要的字段时,这些字段在子图执行期间保持不变,子图完成后它们的值被保留在父图中。但如果子图的输出中包含父图不认识的字段,这些字段会被静默丢弃。
一个常见的误解是:如果父图和子图都定义了同名的 Channel 但使用了不同的 reducer,数据会被"转换"。实际上不会------状态映射只基于字段名匹配,不关心 reducer 的差异。如果父图的 messages 使用 operator.add(追加),子图的 messages 使用 LastValue(覆盖),子图的最终 messages 值会被传回父图,然后通过父图的 operator.add reducer 追加到已有的消息列表中。这种行为可能不是你期望的------你可能期望子图的消息替换父图的消息。解决方案是在子图的输出 Schema 中使用不同的字段名,然后在父图的后续节点中进行显式的数据整合。
11.12.2 子图的性能考量
每次调用子图都会创建一个新的 PregelLoop 实例,涉及到配置解析、命名空间构建、Channel 初始化和检查点加载等开销。对于频繁调用的轻量级子图,这些固定开销可能比子图本身的业务逻辑更大。在这种情况下,考虑将子图的逻辑内联为普通节点函数可能更高效。
子图的检查点写入也是一个性能考量点。在默认的 async 持久化模式下,每个子图的每个步骤都会触发一次检查点写入。对于深度嵌套的图(三层或更多),检查点写入的频率可能非常高。如果子图的执行是确定性的且不需要中断恢复,可以通过 checkpointer=False 禁用子图的检查点来提升性能。
11.12.3 调试嵌套图的技巧
调试嵌套图的困难在于错误消息和日志可能不包含足够的上下文信息。以下几个技巧可以帮助诊断问题:
首先,使用 stream_mode="debug" 可以获得最详细的执行日志,包括每个子图的每个步骤的输入、输出和中间状态。其次,get_state(config, subgraphs=True) 在发生中断或错误后非常有用------它可以显示整个嵌套图的完整状态树,帮助定位问题发生在哪一层。最后,在开发阶段可以给每个子图添加 interrupt_before="*" 配置,实现单步执行来逐步检查行为。
11.13 小结
本章深入剖析了 LangGraph 的子图与嵌套机制。我们看到,子图不是简单的"图中图",而是一个涉及多个子系统协调的复杂工程:
- 图作为节点 :编译后的图通过
Runnable协议无缝嵌入父图,PregelExecutableTask的subgraphs字段支持运行时的子图感知 - 命名空间隔离 :
NS_SEP(|)和NS_END(:)构建的层级命名空间确保每个子图拥有完全独立的通道空间和检查点历史,即使父图和子图有同名通道也不会产生冲突 - Checkpoint 嵌套 :
checkpoint_map在父子图之间传递检查点关联,使得恢复时可以正确定位子图的检查点。同一个 CheckpointSaver 通过checkpoint_ns实现物理隔离 - 跨图通信 :
ParentCommand通过GraphBubbleUp异常机制实现子图到父图的控制流传递,与中断机制共享冒泡基础设施 - 状态映射:父图和子图之间的状态传递通过输入输出模式的字段名匹配自动完成,只有在两个模式中同时存在的同名字段会被传递,子图的内部状态对父图完全不可见
- 流式输出整合 :
DuplexStream合并父图和子图的输出流,命名空间前缀帮助客户端精确区分每个事件的来源层级和节点
子图机制是构建复杂 Agent 系统的关键能力。通过合理的分层和组合,开发者可以将大型系统分解为可管理、可复用、可独立测试的子单元,同时保持完整的状态持久化和人机协作能力。这种"分而治之"的架构模式是 LangGraph 从简单的节点/边图引擎进化为企业级 Agent 编排框架的标志性特征。
回顾本章的内容,子图机制的复杂性主要来源于两个方面的张力:一是隔离性与协作性的平衡------子图需要独立运行以避免状态污染,但又需要与父图共享数据和协调执行;二是持久化的一致性------嵌套的检查点必须在多个层级之间保持正确的关联和恢复顺序。LangGraph 通过命名空间隔离、checkpoint_map 映射和 GraphBubbleUp 异常传播等机制,在这两个维度上达到了实用而优雅的平衡。理解这些底层机制,是构建可靠的、可维护的、可扩展的多 Agent 系统的必要技术基础。