【LangGraph】第三篇:LangGraph 子图高级特性 ------ 短期记忆与中断行为
- 前言
-
- 一、自动传播短期记忆(Checkpoint)
-
- [1.1 基础示例:带持久化的子图](#1.1 基础示例:带持久化的子图)
- [1.2 检查点的层级结构](#1.2 检查点的层级结构)
- 二、子图中的中断(Interrupt)行为
-
- [2.1 子图作为节点时的中断恢复](#2.1 子图作为节点时的中断恢复)
-
- [2.1.1 **输出**:](#2.1.1 输出:)
- [2.1.2 **查看中断状态**:](#2.1.2 查看中断状态:)
- [2.1.3 **恢复执行**:](#2.1.3 恢复执行:)
- [2.1.4 **输出**:](#2.1.4 输出:)
- [2.2 执行流程详解](#2.2 执行流程详解)
- [2.3 更复杂的情况:主图节点内调用子图](#2.3 更复杂的情况:主图节点内调用子图)
- [2.4 中断行为对比表](#2.4 中断行为对比表)
- 三、流式输出的重要参数:subgraphs=True
-
- [3.1 基础用法与输出格式](#3.1 基础用法与输出格式)
-
- [3.1.1 **默认输出**:](#3.1.1 默认输出:)
- [3.1.2 **subgraphs=True 输出**:](#3.1.2 subgraphs=True 输出:)
- [3.2 输出格式详解](#3.2 输出格式详解)
- [3.3 实际应用:前端展示子图进度](#3.3 实际应用:前端展示子图进度)
-
- [3.3.1 **输出效果**:](#3.3.1 输出效果:)
- [3.3.2 **前端应用场景**:](#3.3.2 前端应用场景:)
- [3.4 嵌套子图的路径示例](#3.4 嵌套子图的路径示例)
- [3.5 高级技巧:条件开启 subgraphs](#3.5 高级技巧:条件开启 subgraphs)
- [3.6 注意事项](#3.6 注意事项)
- 四、最佳实践总结
-
- [4.1 何时使用子图的中断?](#4.1 何时使用子图的中断?)
- [4.2 避免中断前的副作用](#4.2 避免中断前的副作用)
- [4.3 访问子图的中断状态](#4.3 访问子图的中断状态)
- [4.4 为子图单独配置 checkpointer](#4.4 为子图单独配置 checkpointer)
- 五、全文总结
- [六、 参考资料](#六、 参考资料)
上一章------>【LangGraph】节点内调用与状态隔离

前言
当你将子图嵌入主图后,持久化(Checkpoint)和人工中断(Interrupt)的表现会有一些微妙但重要的变化
LangGraph 会自动将主图的 checkpointer 传播到子图,使得子图中的每一步也能被自动保存
但在子图中使用
interrupt()时,恢复行为与普通节点不同:子图节点会重新执行整个子图(从开始节点),而不仅仅是中断的那个子节点本文通过代码示例详细解释这些行为,帮助你在生产环境中避免踩坑!!!
一、自动传播短期记忆(Checkpoint)
如果主图编译时指定了 checkpointer,那么主图会为整个工作流(包括所有子图)自动保存状态快照
你不需要在子图编译时单独传入 checkpointer------LangGraph 会自动传播
1.1 基础示例:带持久化的子图
python
from langgraph.graph import START, StateGraph
from langgraph.checkpoint.memory import InMemorySaver
from typing import TypedDict
class State(TypedDict):
foo: str
# 子图:将 foo 加上 "bar"
def subgraph_node_1(state: State):
return {"foo": state["foo"] + "bar"}
sub_builder = StateGraph(State)
sub_builder.add_node(subgraph_node_1)
sub_builder.add_edge(START, "subgraph_node_1")
sub_graph = sub_builder.compile()
# 主图:直接使用子图作为节点
builder = StateGraph(State)
builder.add_node("node_1", sub_graph)
builder.add_edge(START, "node_1")
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
# 执行
config = {"configurable": {"thread_id": "test"}}
result = graph.invoke({"foo": "hello"}, config)
print(result) # {'foo': 'hellobar'}
# 查看历史检查点
states = list(graph.get_state_history(config))
print(f"共有 {len(states)} 个检查点") # 子图内的节点也会产生检查点
关键点:
- 子图中的
subgraph_node_1执行后,会自动产生一个检查点 - 你可以使用
graph.get_state_history(config)看到子图内的步骤,但状态快照的next字段会反映子图内部的状态机 - 这种自动传播机制保证了即使工作流嵌套多层,整个执行过程仍可回放和恢复
1.2 检查点的层级结构
当使用子图作为节点时,检查点会形成层级结构:
python
# 查看所有检查点的详细信息
for state in graph.get_state_history(config):
print(f"Step: {state.metadata.get('step', 'N/A')}")
print(f" Next: {state.next}")
print(f" Values: {state.values}")
print(f" Source: {state.metadata.get('source', 'N/A')}")
print()
输出示例:
text
Step: 2
Next: ()
Values: {'foo': 'hellobar'}
Source: loop
Step: 1
Next: ('node_1',)
Values: {'foo': 'hello'}
Source: loop
Step: 0
Next: ('__start__',)
Values: {'foo': ''}
Source: input
注意 :子图内部的检查点不会直接出现在主图的历史中,但它们被嵌入在子图节点的执行记录中
要查看子图内部的详细检查点,需要使用 subgraphs=True 参数
二、子图中的中断(Interrupt)行为
子图内部同样可以使用 interrupt() 来暂停执行,等待人工输入
但由于子图作为一个整体嵌入在主图节点中,恢复时的行为与普通节点不同
2.1 子图作为节点时的中断恢复
下面的例子中,子图包含两个节点:sub_node1 和 sub_node2。sub_node2 中调用 interrupt()
python
from langgraph.graph import START, StateGraph
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command
from typing_extensions import TypedDict
class State(TypedDict):
foo: str
# 子图
def subgraph_node_1(state: State):
print("sub_node_1 执行")
return {}
def subgraph_node_2(state: State):
print("sub_node_2 执行")
value = interrupt("请输入值:")
return {"foo": state["foo"] + value}
sub_builder = StateGraph(State)
sub_builder.add_node(subgraph_node_1)
sub_builder.add_node(subgraph_node_2)
sub_builder.add_edge(START, "subgraph_node_1")
sub_builder.add_edge("subgraph_node_1", "subgraph_node_2")
subgraph = sub_builder.compile()
# 主图:子图作为节点
builder = StateGraph(State)
builder.add_node("node_1", subgraph)
builder.add_edge(START, "node_1")
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "1"}}
# 第一次调用,会在 subgraph_node_2 中断
print("=== 第一次执行 ===")
graph.invoke({"foo": ""}, config)
2.1.1 输出:
text
=== 第一次执行 ===
sub_node_1 执行
sub_node_2 执行
2.1.2 查看中断状态:
python
# 查看主图状态
parent_state = graph.get_state(config)
print(f"主图是否中断: {len(parent_state.tasks) > 0 and len(parent_state.tasks[0].interrupts) > 0}")
# 查看子图状态(只能在中断时才能访问)
sub_state = graph.get_state(config, subgraphs=True).tasks[0].state
print(f"子图状态: {sub_state.values}")
print(f"子图中断值: {sub_state.tasks[0].interrupts[0].value}")
2.1.3 恢复执行:
python
# 恢复执行,传入 "bar"
print("\n=== 恢复执行 ===")
result = graph.invoke(Command(resume="bar"), config)
print(f"最终结果: {result}")
2.1.4 输出:
text
=== 恢复执行 ===
sub_node_2 执行
最终结果: {'foo': 'bar'}
关键发现 :sub_node_2 打印了两次!这是因为子图作为节点恢复时,整个子图会从 START 重新执行 ,直到再次遇到 interrupt()
但在恢复时,interrupt() 会直接返回传入的值,所以不会重新询问用户
2.2 执行流程详解
让我们详细分析恢复时的执行流程:
text
第一次执行:
1. 主图开始 → 进入 node_1(子图)
2. 子图开始 → sub_node_1 执行
3. 子图继续 → sub_node_2 执行,遇到 interrupt(),暂停
恢复执行:
1. 主图恢复 → 重新进入 node_1(子图)
2. 子图从 START 重新执行 → sub_node_1 被跳过(状态机记录已完成)
3. 子图继续 → sub_node_2 重新执行函数体,interrupt() 返回 "bar"
4. 子图完成 → 返回主图
5. 主图完成
重要 :
如果你在 interrupt() 之前有副作用操作(比如写数据库、发消息),这些操作会在恢复时再次执行
因此,我们要确保中断前的代码具有幂等性
2.3 更复杂的情况:主图节点内调用子图
如果我们采用方式一(在主图节点函数内手动调用子图),那么恢复行为更加复杂:不仅子图中断节点会重新执行,整个主图节点函数也会重新执行(包括调用子图的代码)
python
# 子图定义同上(省略)
# 主图:节点内调用子图
def node_1(state: State):
print("node_1 执行")
response = subgraph.invoke({"foo": state["foo"]})
return {"foo": response["foo"]}
builder = StateGraph(State)
builder.add_node("node_1", node_1)
builder.add_edge(START, "node_1")
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "2"}}
print("=== 第一次执行 ===")
graph.invoke({"foo": ""}, config)
print("\n=== 恢复执行 ===")
result = graph.invoke(Command(resume="bar"), config)
print(f"最终结果: {result}")
输出:
text
=== 第一次执行 ===
node_1 执行
sub_node_1 执行
sub_node_2 执行
=== 恢复执行 ===
node_1 执行
sub_node_2 执行
最终结果: {'foo': 'bar'}
分析:
- 第一次执行到子图中断
- 恢复时,不仅子图的
sub_node_2重新执行,主图的node_1也重新从头执行(再次打印node_1) - 子图的
sub_node_1没有重新执行,因为子图状态机记录了它已经完成
结论:在方式一(节点内调用)中使用中断,恢复后主图节点会重跑,这意味着节点内中断前的任何代码(包括准备参数、日志等)都会再次运行
2.4 中断行为对比表
| 场景 | 恢复时重新执行的代码 | 副作用风险 |
|---|---|---|
| 普通节点中断 | 仅中断节点 | 低 |
| 子图作为节点中断 | 子图从 START 重新执行(跳过已完成节点) | 中 |
| 节点内调用子图中断 | 主图节点 + 子图从 START 重新执行 | 高 |
三、流式输出的重要参数:subgraphs=True
默认情况下,graph.stream() 只会输出主图节点的更新
当我们希望前端也能看到子图内部的执行过程(比如子图中也有多个步骤或 LLM 调用),必须设置 subgraphs=True
3.1 基础用法与输出格式
python
# 不使用 subgraphs=True(默认)
print("=== 默认输出 ===")
for chunk in graph.stream({"parent": "parent"}):
print(chunk)
# 使用 subgraphs=True
print("\n=== subgraphs=True 输出 ===")
for chunk in graph.stream({"parent": "parent"}, subgraphs=True):
print(chunk)
3.1.1 默认输出:
text
{'parent_node1': {'parent': '这里是parent'}}
{'parent_node2': {'parent': '这里是parent这是sub'}}
3.1.2 subgraphs=True 输出:
text
((), {'parent_node1': {'parent': '这里是parent'}})
(('parent_node2:1f0d4d2b-7a6e-6dd7-8000-5cc1931477df',), {'sub_node1': {'sub1': '这里是sub1'}})
(('parent_node2:1f0d4d2b-7a6e-6dd7-8000-5cc1931477df',), {'sub_node2': {'sub2': '这里是sub1和sub2这里是parent这里是sub1'}})
((), {'parent_node2': {'parent': '这里是sub1和sub2这里是parent这里是sub1'}})
3.2 输出格式详解
每个 chunk 是一个元组 (path, update_dict):
path:元组,标识当前更新属于哪个(嵌套的)子图update_dict:字典,包含节点名和状态更新
路径规则:
| path 值 | 含义 |
|---|---|
() |
更新来自主图节点 |
('node_name:uuid',) |
更新来自一级子图 |
('node1:uuid1', 'node2:uuid2') |
更新来自二级嵌套子图 |
UUID 的作用 :路径中的 UUID 标识子图的执行实例
即使同一个子图节点被多次调用(例如在循环中),你也能区分不同执行实例的输出
3.3 实际应用:前端展示子图进度
我们可以遍历流式输出,根据 path 的长度和内容,动态生成层级缩进:
python
def display_stream_with_indent(graph, inputs):
"""带缩进的流式输出展示"""
for path, update in graph.stream(inputs, subgraphs=True):
indent = " " * len(path) # 每深入一层缩进两格
for node_name, node_update in update.items():
# 提取节点名(去掉 UUID)
clean_name = node_name.split(':')[0] if ':' in node_name else node_name
print(f"{indent} 节点 {clean_name} 更新:")
for key, value in node_update.items():
print(f"{indent} {key}: {value}")
# 使用示例
display_stream_with_indent(graph, {"parent": "parent"})
3.3.1 输出效果:
text
节点 parent_node1 更新:
parent: 这里是parent
节点 sub_node1 更新:
sub1: 这里是sub1
节点 sub_node2 更新:
sub2: 这里是sub1和sub2这里是parent这里是sub1
节点 parent_node2 更新:
parent: 这里是sub1和sub2这里是parent这里是sub1
3.3.2 前端应用场景:
javascript
// 前端 JavaScript 示例
async function streamWithSubgraphs(inputs) {
const response = await fetch('/api/stream', {
method: 'POST',
body: JSON.stringify({ ...inputs, subgraphs: true })
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = JSON.parse(decoder.decode(value));
const [path, update] = chunk;
// 根据路径深度决定缩进
const depth = path.length;
const indent = ' '.repeat(depth);
// 更新 UI
for (const [nodeName, nodeUpdate] of Object.entries(update)) {
addLogToUI(`${indent}🔄 ${nodeName}`, nodeUpdate);
}
}
}
3.4 嵌套子图的路径示例
假设主图中有一个节点 sub_agent,它内部又调用了一个 tool_graph 子图:
python
# 主图 → sub_agent(子图)→ tool_graph(嵌套子图)
# 路径示例:
# ((), {'main_node': {...}}) # 主图节点
# (('sub_agent:abc-123',), {'inner_node': {...}}) # 一级子图
# (('sub_agent:abc-123', 'tool_graph:def-456'), {...}) # 二级嵌套子图
路径构建规则 :每进入一层子图,LangGraph 就会在路径元组末尾追加一个 (子图所在父节点名:UUID)
3.5 高级技巧:条件开启 subgraphs
在实际项目中,你可以根据需求动态决定是否开启 subgraphs:
python
def stream_graph(graph, inputs, verbose=False):
"""统一的流式输出接口"""
kwargs = {"subgraphs": True} if verbose else {}
for chunk in graph.stream(inputs, **kwargs):
if verbose:
path, update = chunk
yield {
"depth": len(path),
"path": path,
"update": update
}
else:
yield {"update": chunk}
# 使用示例
for event in stream_graph(graph, inputs, verbose=True):
print(event)
3.6 注意事项
-
subgraphs=True只影响流式输出 ,不会改变最终invoke的返回值,也不会影响状态合并逻辑 -
嵌套子图支持:如果子图内部还有子图(嵌套子图),LangGraph 会递归地为每一层生成路径,输出所有层级的节点更新
-
性能考虑 :开启
subgraphs=True可能会产生大量事件(因为子图内部的每一个节点都会单独输出)。如果只需要关心子图的最终结果,可以仅在调试或需要详细进度时开启 -
中断时的路径信息 :在子图中使用
interrupt()时,subgraphs=True同样有效:用户可以从外部看到中断发生在哪个子图的哪个节点上,路径信息会明确标出
四、最佳实践总结
4.1 何时使用子图的中断?
- 将某个需要人工审批的子流程独立封装,比如"支付确认"、"文档审核"等
- 注意:子图的中断会暂停整个主图的执行,直到人工恢复
4.2 避免中断前的副作用
无论哪种子图调用方式,都要确保 interrupt() 调用之前的代码是幂等的,或者不产生可观测的副作用(如发送邮件、扣款)
推荐做法:
python
# 不推荐:中断前有副作用
def bad_example(state: State):
send_email(state["user"]) # 副作用:恢复时会再次发送
value = interrupt("确认发送?")
return {"result": value}
# 推荐:将副作用放在中断后
def good_example(state: State):
value = interrupt("确认发送?")
send_email(state["user"]) # 副作用:只在确认后执行一次
return {"result": value}
4.3 访问子图的中断状态
python
# 主图暂停后,获取子图的中断信息
snapshot = graph.get_state(config, subgraphs=True)
# 获取子图状态
sub_state = snapshot.tasks[0].state
print(f"子图状态: {sub_state.values}")
# 获取中断值
interrupt_value = sub_state.tasks[0].interrupts[0].value
print(f"中断值: {interrupt_value}")
# 获取中断消息
interrupt_message = sub_state.tasks[0].interrupts[0].resumable
print(f"是否可恢复: {interrupt_message}")
4.4 为子图单独配置 checkpointer
虽然在大多数情况下自动传播已经足够,但如果需要子图使用不同的持久化后端(例如子图的数据要存到另一个数据库)
我们可以在编译子图时显式传入自己的 checkpointer:
python
# 子图使用独立的 checkpointer
sub_checkpointer = RedisCheckpointer() # 假设使用 Redis
sub_graph = sub_builder.compile(checkpointer=sub_checkpointer)
# 主图使用默认的 checkpointer
main_checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=main_checkpointer)
# 子图会使用自己的 checkpointer,主图使用主图的
注意 :
这种情况下,子图的检查点不会出现在主图的历史中,需要单独查询子图的 checkpointer
五、全文总结
本系列三篇文章全面覆盖了 LangGraph 子图的方方面面:
| 文章 | 核心内容 |
|---|---|
| 第一篇 | 方式一:节点内调用子图(状态独立,手动转换),流式输出控制 |
| 第二篇 | 方式二:子图作为节点添加(状态共享,自动传递),简洁集成 |
| 第三篇 | 持久化自动传播,子图中断的恢复行为(重跑分析),流式输出详解 |
关键要点回顾:
- Checkpoint 自动传播:主图的 checkpointer 会自动传播到子图,无需手动配置
- 中断恢复行为:子图作为节点恢复时会从 START 重新执行(跳过已完成节点),节点内调用方式会重跑整个主图节点
- subgraphs=True :开启后可以看到子图内部的详细执行过程,输出格式为
(path, update_dict) - 副作用处理:中断前的代码必须是幂等的,或不产生可观测副作用
子图是 LangGraph 实现大型、可维护、可复用 Agent 的利器。希望你能根据自己的项目需求,灵活选择合适的子图集成方式,并正确处理持久化和中断场景。
六、 参考资料
以下是一些参考资料,可以帮助我们更深入的了解和学习:
okok~子图的分享到此为止,觉得有用可以点点赞啊(老大,我们真的能挣大钱吗)
