LangGraph 子图(Subgraph)详解
目录
-
[模式二:最简嵌套 + 状态合并的双重触发](#模式二:最简嵌套 + 状态合并的双重触发)
1. 什么是子图?
在 LangGraph 中,一个编译好的 StateGraph 不仅可以独立执行,还可以作为另一个图的节点 使用。这种用法称为子图(Subgraph)。
父图(Parent Graph)
┌────────────────────────────────────────┐
│ │
│ START → parent_node → subgraph → END │
│ │ │
│ ┌──────┴──────┐ │
│ │ 子图内部逻辑 │ │
│ │ node1→node2 │ │
│ └─────────────┘ │
└────────────────────────────────────────┘
为什么需要子图?
| 优势 | 说明 |
|---|---|
| 模块化 | 将复杂逻辑拆分为独立子图,各自维护 |
| 可复用 | 同一个子图可在多个父图中使用 |
| 团队协作 | 不同团队开发不同的子图,最后组装 |
| 测试便利 | 子图可单独测试,再集成到父图 |
| 逻辑隔离 | 子图的私有数据不影响父图 |
有三种子图集成模式
根据父子图的状态结构是否一致,有三种不同的实现方式:
| 模式 | 文件 | 父子图状态 | 集成方式 |
|---|---|---|---|
| 共享状态 | SubGraphSimple.py |
有重叠字段 | 子图直接作为节点添加到父图 |
| 最简嵌套 | SubGraphHello.py |
完全相同 | 子图直接作为节点,注意双重合并 |
| 代理节点 | SubGraphPro.py |
完全不同 | 通过父图代理节点手动调用子图 |
2. 模式一:子图作为父图节点(共享状态)
2.1 适用场景
父图和子图的状态存在重叠的共享字段,且子图可以直接操作这些字段。
2.2 状态定义
class ParentState(TypedDict):
parent_messages: list # 与子图共享的数据
class SubgraphState(TypedDict):
parent_messages: list # 与父图共享的数据(字段名相同!)
sub_message: str # 子图私有数据(父图中不存在的字段)
关键规则: 父子图的共享字段字段名必须相同,LangGraph 会自动传递匹配的字段。
2.3 构建子图
def build_subgraph() -> StateGraph:
sub_builder = StateGraph(SubgraphState)
sub_builder.add_node("sub_node", subgraph_node)
sub_builder.add_edge(START, "sub_node")
sub_builder.add_edge("sub_node", END)
return sub_builder.compile()
2.4 构建父图(子图作为节点)
def build_parent_graph(compiled_subgraph) -> StateGraph:
builder = StateGraph(ParentState)
# 父图中的普通节点
builder.add_node("parent_node", parent_node)
# 将编译好的子图作为一个节点添加到父图
builder.add_node("subgraph_node", compiled_subgraph)
# 定义执行顺序
builder.add_edge(START, "parent_node")
builder.add_edge("parent_node", "subgraph_node")
builder.add_edge("subgraph_node", END)
return builder.compile()
关键: builder.add_node("subgraph_node", compiled_subgraph) 中的 compiled_subgraph 是一个编译好的 StateGraph 对象(即 CompiledStateGraph),LangGraph 会自动识别并处理子图逻辑。
2.5 执行流程
父图 invoke()
│
▼
parent_node: 向 parent_messages 追加 "message from 父亲 node"
│
↓ (子图节点被触发,内部调用 subgraph.invoke(state))
│
├── sub_node: 向 parent_messages 追加 "message from subgraph"
│ 设置子图私有数据 sub_message = "subgraph private message"
│ (私有数据在父图最终状态中不可见)
│
▼
父图返回最终状态(只有 ParentState 中定义的字段)
2.6 测试输出
初始状态: {'parent_messages': ['我是父消息']}
执行后最终状态:
{'parent_messages': [
'我是父消息',
'message from 父亲 node',
'message from subgraph updateO(∩_∩)O'
]}
注意:sub_message 字段在最终输出中不可见 ------因为父图状态 ParentState 中没有定义这个字段。
3. 模式二:最简嵌套 + 状态合并的双重触发
3.1 适用场景
父子图使用完全相同的状态结构,是最简单的嵌套方式。
3.2 双重状态合并(核心难点)
这是子图使用中最容易绕晕的特性。当子图作为父图节点时,状态合并会发生两次:
第1次合并:父图将初始状态传递给子图(子图入口处)
第2次合并:子图执行完毕,将结果返回给父图(父图节点返回处)
3.3 具体示例
class AtguiguState(TypedDict):
messages: Annotated[list[str], add] # 使用 add 合并策略
# 子图节点:返回一条新消息
def sub_node(state: AtguiguState) -> AtguiguState:
return {"messages": ["response from subgraph"]}
# 子图:START → sub_node → END
subgraph = StateGraph(AtguiguState)
subgraph.add_node("sub_node", sub_node)
subgraph.add_edge(START, "sub_node")
subgraph.add_edge("sub_node", END)
subgraph = subgraph.compile()
# 父图:START → subgraph_node → END
builder = StateGraph(AtguiguState)
builder.add_node("subgraph_node", subgraph)
builder.add_edge(START, "subgraph_node")
builder.add_edge("subgraph_node", END)
graph = builder.compile()
result = graph.invoke({"messages": ["main-graph"]})
print(result)
# 输出: {'messages': ['main-graph', 'main-graph', 'response from subgraph']}
3.4 为什么结果是 3 条消息?
让我们逐步追踪状态的变化:
初始状态: messages = ["main-graph"]
=== 第1层:父图 invokve() 开始 ===
父图执行 subgraph_node(即子图)
=== 第2层:子图 invokve() 开始 ===
子图收到状态: messages = ["main-graph"](来自父图的当前状态)
子图执行 sub_node,返回: messages = ["response from subgraph"]
子图内部合并(add 策略):
子图输入: ["main-graph"] + 节点返回: ["response from subgraph"]
= ["main-graph", "response from subgraph"]
子图结束,返回: messages = ["main-graph", "response from subgraph"]
=== 第2层:子图执行完毕 ===
================================
父图收到子图返回: messages = ["main-graph", "response from subgraph"]
父图再次合并(add 策略):
父图原有: ["main-graph"] + 子图返回: ["main-graph", "response from subgraph"]
= ["main-graph", "main-graph", "response from subgraph"]
最终结果: {'messages': ['main-graph', 'main-graph', 'response from subgraph']}
3.5 双重合并的图解
父图状态: messages=["main-graph"]
│
▼
┌─ 子图节点 ──────────────────────────────┐
│ 子图收到: messages=["main-graph"] │ ← 第1次传递
│ │
│ sub_node 返回: ["response from subgraph"]│
│ │
│ 子图内合并(add): │
│ ["main-graph"] + ["response from subgraph"]│
│ = ["main-graph", "response from subgraph"]│ ← 第1次合并
│ │
└──────────┬───────────────────────────────┘
│
▼ 子图返回结果给父图
父图收到: messages=["main-graph", "response from subgraph"]
│
父图内合并(add):
["main-graph"] + ["main-graph", "response from subgraph"]
= ["main-graph", "main-graph", "response from subgraph"]
← 第2次合并
3.6 如何避免双重合并?
如果不需要双重合并,有下列方案:
方案1:子图不持有共享字段的 Reducer
class SubState(TypedDict):
result: str # 没有 Reducer,直接覆盖父图的对应字段
方案2:使用代理节点手动调用(见模式三)
方案3:子图使用不同的字段名
class ParentState(TypedDict):
messages: Annotated[list, add]
class SubState(TypedDict):
sub_messages: Annotated[list, add] # 不同字段名
4. 模式三:代理节点模式(状态不同时)
4.1 适用场景
父子图的状态结构完全不同(无重叠字段),无法通过字段名自动匹配。
4.2 核心思路
不是把子图直接加到父图里,而是:
-
在父图中创建一个代理节点
-
代理节点手动构建子图所需的输入状态
-
代理节点调用
compiled_subgraph.invoke(sub_input) -
代理节点提取子图返回结果,映射回父图字段
4.3 状态定义(完全不同)
# 父图状态:业务层
class ParentState(TypedDict):
user_query: str # 用户输入
final_answer: str | None # 最终结果
# 子图状态:分析层(与父图完全不同的字段)
class SubgraphState(TypedDict):
analysis_input: str # 分析输入
analysis_result: str # 分析结果
intermediate_steps: list # 中间步骤
4.4 代理节点的三步骤
def call_subgraph_proxy(state: ParentState) -> ParentState:
"""代理节点:状态转换 → 调用子图 → 结果映射"""
# 步骤1:父图状态 → 子图输入
subgraph_input = {
"analysis_input": state["user_query"], # 提取父图数据
"intermediate_steps": [], # 初始化子图私有字段
"analysis_result": ""
}
# 步骤2:手动调用子图
subgraph_response = compiled_subgraph.invoke(subgraph_input)
# 步骤3:子图输出 → 父图状态
return {
"user_query": state["user_query"],
"final_answer": subgraph_response["analysis_result"] # 映射
}
4.5 执行流程
父图 invoke()
│
▼
call_subgraph_proxy(state)
│
├── 构建子图输入:
│ ParentState → SubgraphState
│ user_query → analysis_input
│
├── 手动调用子图:
│ compiled_subgraph.invoke(sub_input)
│ │
│ ├── 子图执行分析逻辑
│ ├── 生成 intermediate_steps 和 analysis_result
│ │
│ └── 返回 subgraph_response
│
├── 映射结果回父图:
│ analysis_result → final_answer
│
▼
父图返回最终状态
4.6 测试输出
父图初始状态:
{
'user_query': '请分析Python中StateGraph的使用场景',
'final_answer': None
}
父图最终状态:
{
'user_query': '请分析Python中StateGraph的使用场景',
'final_answer': '针对「请分析Python中StateGraph的使用场景」的分析结果:这是子图处理后的内容'
}
子图处理后的最终答案:
针对「请分析Python中StateGraph的使用场景」的分析结果:这是子图处理后的内容
4.7 代理节点模式 vs 直接子图节点
| 对比 | 直接子图节点(模式一/二) | 代理节点(模式三) |
|---|---|---|
| 状态结构要求 | 父子图必须共享相同字段名 | 父子图任意结构 |
| 状态传递 | 自动传递匹配字段 | 手动转换 |
| 调用方式 | add_node("name", compiled_subgraph) |
在代理函数内手动 .invoke() |
| 灵活性 | 低(受限于字段匹配) | 高(可以任意转换) |
| 代码复杂度 | 低 | 中 |
| 适合场景 | 父子图功能接近 | 父子图完全独立 |
5. 三种模式对比总结
| 维度 | SubGraphSimple | SubGraphHello | SubGraphPro |
|---|---|---|---|
| 文件 | 共享状态 | 最简嵌套 | 代理节点 |
| 父子图状态 | 部分重叠 | 完全相同 | 完全不同 |
| 父图额外节点 | 有(parent_node) | 无 | 有(proxy) |
| 子图私有数据 | ✅ 支持 | ❌(共享完整状态) | ✅ 完全隔离 |
| 双重合并 | 否(无 Reducer) | ✅ 是(add Reducer) | 否(手动调用) |
| 复杂度 | ⭐⭐ | ⭐ | ⭐⭐⭐ |
| 灵活性 | ⭐⭐ | ⭐ | ⭐⭐⭐ |
选择指南
父子图状态结构是否相同?
├── 完全相同 → 用模式二(SubGraphHello)
│ 注意双重合并的坑
│
├── 部分重叠 → 用模式一(SubGraphSimple)
│ 共享字段名必须一致
│
└── 完全不同 → 用模式三(SubGraphPro)
代理节点手动转换状态
6. 子图的核心机制
6.1 子图调用的本质
当父图中的子图节点被触发时,LangGraph 等价于调用了:
# 父图中的子图节点本质上等效于:
subgraph_node_result = compiled_subgraph.invoke(current_parent_state)
这意味着子图有自己的 invoke、stream、内部状态管理、checkpoint 等能力。
6.2 子图的输入输出
| 子图作为父图节点(模式一/二) | 代理节点手动调用(模式三) | |
|---|---|---|
| 子图输入 | 父图当前状态的匹配字段 | 代理节点构造的任意 dict |
| 子图输出 | 合并回父图状态(自动) | 代理节点手动映射 |
| 私有数据 | 子图状态中的非共享字段被丢弃 | 完全由代理节点控制 |
6.3 子图的适用场景
-
多 Agent 系统 :每个 Agent 是一个子图,由 Supervisor 父图协调
-
模块化业务逻辑 :不同业务模块封装为独立子图
-
复用已有图:将一个已有的图不加修改地嵌入到更大图中
-
测试隔离:子图可独立测试、独立开发