LangGraph 体系实战(下):中断恢复、流式、子图与设计模式
9. 中断
本章重点:
- 动态断点的使用(主要是:HITL、并行中断)、使用规范
- 动态断点执行过程的参数变化
- 静态断点的概念:实际是断点超步
- 静态断点的使用:编译时断点、调用时断点
LangGraph 提供了两种中断机制:
-
动态中断:在图的任意节点中调用
interrupt()函数实现它可以放在代码的任意位置,并且可以根据应用逻辑设置条件触发,所以是动态的。
动态中断提供了人机交互接口,使得调用者可以人为干预计算图的运行,是业务逻辑的一部分。
-
静态中断:在编译或调用状态图时通过
interrupt_before和interrupt_after参数设置断点(只能控制 节点前/后 断点)它是在运行前确定的,不能根据业务逻辑条件触发,所以是静态的。
静态中断主要用于调试,不是业务逻辑的一部分。
9.1. 动态中断
9.1.1. 概述
LangGraph 的动态中断机制允许用户在图执行过程中设置暂停点,等待外部输入后再继续执行,提供了人机交互接口。
当中断触发时,LangGraph 会通过持久化机制保存当前图状态,并无限期等待直到用户恢复执行。
中断通过在任意图节点中调用 interrupt() 函数实现,该函数可以接收任何可 JSON 序列化的值,并将该值暴露给调用方。
中断触发后,调用方根据 interrupt() 暴露的信息生成反馈,重新调用图时通过 Command 将反馈传递给计算图,恢复图的运行。
恢复运行后,调用方通过 Command 传递的反馈会作为 interrupt() 函数的返回值,参与后续计算。(此时会把中断节点重新执行一遍)
9.1.2. 启用中断
-
配置检查点存储器
-
设置
thread_id如果要确保中断后可以恢复执行,就需要保存运行图的完整状态,所以必须启用可恢复执行。(就是配置checkpoint
-
在需要中断的位置调用
interrupt()
9.1.3. 恢复中断
满足以下要求:
-
基于相同的配置再次调用计算图
-
将输入替换为
Command()实例即可恢复运行。
LangGraph 会从中断节点继续运行,该节点会被再次从头执行。
通过 Command 实例的 resume 属性将用户反馈传递给计算图,中断节点重新运行时,传递给 resume 属性的值将会作为 interrupt() 函数的返回值。
9.1.4. 常见使用模式
根据中断发生的位置、业务场景以及执行结构,动态中断可以衍生出多种常见的使用模式。本节将介绍以下模式:
- 基础 HITL 模式【根基】:状态图触发一次中断,获取人类输入后继续执行。
- 多个并行中断【拓展】:多个并行任务分别产生中断,并根据 中断
ID接收各自的恢复数据。 - 审批模式:根据人类审批(批准、拒绝或其它处理方式),决定后续执行路径。
- 审核与编辑模式:将模型生成的内容交给人类检查,并允许人类直接修改后继续处理。
- 工具执行审批模式:在调用工具或执行具有副作用的操作之前,由人类确认是否允许执行。
- 单节点串行中断模式:在同一个节点内多次触发中断,上一个中断恢复后才能触发下一个。
- 人类输入验证模式:对人类输入进行校验;当输入不符合要求时,再次中断并要求重新输入。
需要注意,这些模式并不是完全互斥的。例如,工具执行审批本质上也是审批模式的一种,只是它专门应用于工具调用场景。
9.1.4.1. 基础HITL模式【重点】
9.1.4.1.1. 什么是HITL
HITL(Human In The Loop) 即人在环,是一种经典的人机协同设计模式,它允许计算图在运行过程中暂停,等待人工审批、修正意见或补充信息,然后从暂停位置恢复执行。
9.1.4.1.2. 基础HITL模式的实现
调用 interrupt() 可以暂停状态图的执行,并等待外部输入后恢复运行。因此它是实现 HITL 的核心机制。
不过,仅仅调用 interrupt() 并不一定构成 HITL。只有当中断后的决策、输入或修改确实由人类完成时,才能称为 HITL;如果恢复数据完全由程序自动提供,本质上仍然是普通的中断与恢复机制。
示例如下
# ---- 本章导入 ----
from typing import TypedDict
from langgraph.graph import StateGraph,START,END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver
#1. 声明状态
class OverAllState(TypedDict):
username:str
#2. 声明节点
def node_a(state:OverAllState)->OverAllState:
username = interrupt("请输入您的姓名")
return {
"username":username
}
#3. 构建图
builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a",node_a)
builder.add_edge(START,"node_a")
builder.add_edge("node_a",END)
#4. 想要使用中断 => 必须配置检查点
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
from IPython.display import display
display(graph)
config = {"configurable":{"thread_id":"123"}}
interrupt_res = graph.invoke({},config=config)
print(interrupt_res)
prompt = interrupt_res['__interrupt__'][0].value
print(prompt)
username = input(prompt)
# 5. 恢复图的中断执行
resume_res = graph.invoke(Command(resume=username),config=config)
print(resume_res)
运行结果如下

============================== -> interrupt_res <- ==============================
{'__interrupt__': [Interrupt(value='请输入您的姓名', id='3543beca616d469fe1ccd35250ef5acf')]}
============================== -> resumed_res <- ==============================
{'username': '小黄'}
9.1.4.2. 多个并行中断【重点】
示例如下:同一个超步中有多个并行节点都存在中断,此时resume需要返回字典值才可以继续运行
# ---- 本章导入 ----
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt
class OverAllState(TypedDict):
username: str
age: int
# (node_a 与上文定义完全相同,略)
def node_b(state: OverAllState) -> OverAllState:
age = interrupt("请输入您的年龄")
return {
"age": age
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge(START, "node_a")
builder.add_edge(START, "node_b")
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
display(graph)
config = {"configurable": {"thread_id": "123"}}
interrupted_res = graph.invoke({}, config=config)
print('=' * 30, '-> interrupt_res <-', '=' * 30)
print(interrupted_res)
# 构建 中断ID -> 恢复指令 的映射
resume_map = {}
for i in interrupted_res['__interrupt__']:
user_input = input(f"{i.value}: ")
if "年龄" in i.value:
resume_map[i.id] = int(user_input)
else:
resume_map[i.id] = user_input
resumed_res = graph.invoke(Command(resume=resume_map), config=config)
print('=' * 30, '-> resumed_res <-', '=' * 30)
print(resumed_res)
运行结果如下

============================== -> interrupt_res <- ==============================
{'__interrupt__': [Interrupt(value='请输入您的年龄', id='2770f00729006c756d481fffa712bb27'), Interrupt(value='请输入您的姓名', id='db7e81e1d4f5cab611f07e0469e99854')]}
============================== -> resumed_res <- ==============================
{'username': '小黄', 'age': 19}
9.1.4.3. 审批模式
示例如下
# ---- 本章导入 ----
from time import sleep
from typing import TypedDict, Literal
from langchain_core.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek
from dotenv import load_dotenv
load_dotenv(override=True)
model =ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking":{
"type":"disabled"
}
}
)
#1. 声明状态
class OverAllState(TypedDict):
topic:str
poem:str
is_approved:bool
#2. 声明节点
def approve_node(state:OverAllState) -> Command[Literal["llm_node","default_node"]]:
is_approved = interrupt("是否同意调用模型?")
goto = "llm_node" if is_approved else "default_node"
return Command(
goto=goto,
update={"is_approved":is_approved}
)
def llm_node(state:OverAllState) -> OverAllState:
topic = state["topic"]
res = model.invoke([HumanMessage(content=f"帮我写一首关于{topic}主题的七言绝句,只写诗句,不需要赏析")]).content
return {
"poem":res
}
def default_node(state:OverAllState) -> OverAllState:
return {
"poem":"请求被拒绝"
}
#3. 构建图
builder = StateGraph(state_schema=OverAllState)
builder.add_node("approve_node",approve_node)
builder.add_node("llm_node",llm_node)
builder.add_node("default_node",default_node)
builder.add_edge(START,"approve_node")
builder.add_edge("llm_node",END)
builder.add_edge("default_node",END)
#4. 添加检查点后端
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
display(graph)
#5. 首次执行 -> 触发中断
config = {"configurable":{"thread_id":"234"}}
interrupt_res = graph.invoke({"topic":"菊花"},config=config)
print(interrupt_res)
#6. 人工审核
user_approved = input("是否同意调用模型?(y/n)").strip().lower() == 'y'
# 重新调用图
approved_res = graph.invoke(Command(resume=user_approved),config=config)
print(approved_res)
#7. 审核不通过
config1 = {"configurable":{"thread_id":"456"}}
interrupt_res = graph.invoke({"topic":"牡丹花"},config=config1)
print(interrupt_res)
user_approved = input("是否同意调用模型?(y/n)").strip().lower() == 'y'
# 重新调用图
approved_res = graph.invoke(Command(resume=user_approved),config=config1)
print(approved_res)
运行结果如下

{'topic': '菊花', '__interrupt__': [Interrupt(value='是否同意调用模型?', id='91edf41ecf736a242c18fd61d40ad5b4')]}
{'topic': '菊花', 'poem': '《咏菊》\n西风猎猎卷霜华,独抱秋心隐士家。\n冷艳不争桃李色,寒香偏入野人茶。', 'is_approved': True}
{'topic': '牡丹花', '__interrupt__': [Interrupt(value='是否同意调用模型?', id='47958afc1277b7ce6bf850a5f27f2e46')]}
{'topic': '牡丹花', 'poem': '请求被拒绝', 'is_approved': False}
9.1.4.4. 审核与编辑模式
示例如下
# ---- 本章导入 ----
from langchain.messages import HumanMessage
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
class OverAllState(TypedDict):
topic: str
poem: str
reviewed_poem: str
def llm_node(state: OverAllState) -> OverAllState:
topic = state['topic']
res = model.invoke([HumanMessage(content=f"帮我写一首关于 {topic} 的七言绝句,只给出诗句,不要赏析")]).content
return {
"poem": res
}
def review_node(state: OverAllState) -> OverAllState:
reviewed_poem = interrupt({
"instruction": "请审核并修改大模型生成的七言绝句",
"poem": state['poem']
})
return {
"reviewed_poem": reviewed_poem
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("llm_node", llm_node)
builder.add_node("review_node", review_node)
builder.add_edge(START, "llm_node")
builder.add_edge("llm_node", "review_node")
builder.add_edge("review_node", END)
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
display(graph)
config = {"configurable": {"thread_id": "review_test"}}
interrupted_res = graph.invoke({"topic": "布偶猫"}, config=config)
print('=' * 30, '-> interrupt_res <-', '=' * 30)
print(interrupted_res)
print("原始诗句:")
print(interrupted_res['__interrupt__'][0].value['poem'])
user_review = input("请审核并修改诗句(直接回车保留原诗): ")
if not user_review.strip():
user_review = interrupted_res['__interrupt__'][0].value['poem']
reviewed_res = graph.invoke(Command(resume=user_review), config=config)
print('=' * 30, '-> reviewed_res <-', '=' * 30)
print(reviewed_res)
运行结果如下

============================== -> interrupt_res <- ==============================
{'topic': '布偶猫', 'poem': '《布偶猫》\n冰眸玉骨雪团身,蓝影衔春卧月轮。\n偶启朱唇呼入梦,云鬟一枕醉红尘。', '__interrupt__': [Interrupt(value={'instruction': '请审核并修改大模型生成的七言绝句', 'poem': '《布偶猫》\n冰眸玉骨雪团身,蓝影衔春卧月轮。\n偶启朱唇呼入梦,云鬟一枕醉红尘。'}, id='de13581234c57fb8b5d9620295b8239e')]}
============================== -> reviewed_res <- ==============================
{'topic': '布偶猫', 'poem': '《布偶猫》\n冰眸玉骨雪团身,蓝影衔春卧月轮。\n偶启朱唇呼入梦,云鬟一枕醉红尘。', 'reviewed_poem': '已审核:《布偶猫》\n冰眸玉骨雪团身,蓝影衔春卧月轮。\n偶启朱唇呼入梦,云鬟一枕醉红尘。'}
9.1.4.5. 工具执行审批模式
示例如下
# ---- 本章导入 ----
from typing import Literal
from langgraph.graph.message import MessagesState
from langchain.messages import HumanMessage, ToolMessage
from langchain.tools import tool
from loguru import logger
load_dotenv(override=True)
@tool(parse_docstring=True)
def get_weather(city: str) -> str:
"""
查询指定城市的当日天气
Args:
city: 城市名称
"""
is_approved = interrupt({
"action": "get_weather",
"question": "是否同意查询天气?"
})
logger.info("is_approved: {}", is_approved)
if is_approved:
return f"{city} 今天天气不错"
else:
return "用户拒绝查询天气"
tools_by_name = {"get_weather": get_weather}
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
model_with_tools = model.bind_tools([get_weather])
def llm_node(state: MessagesState) -> MessagesState:
messages = state['messages']
response = model_with_tools.invoke(messages)
return {
"messages": [response]
}
def tool_node(state: MessagesState) -> MessagesState:
last_msg = state['messages'][-1]
tool_msgs = []
for tool_call in last_msg.tool_calls:
tool = tools_by_name[tool_call["name"]]
logger.info("工具 {} 被调用, 对应的 tool_call: {}", tool_call["name"], tool_call)
tool_res = tool.invoke(tool_call["args"])
tool_msg = ToolMessage(
name = tool_call["name"],
content = tool_res,
tool_call_id = tool_call["id"]
)
tool_msgs.append(tool_msg)
return {
"messages": tool_msgs
}
def router(state: MessagesState) -> Literal["tool_node", END]:
if state['messages'][-1].tool_calls:
return "tool_node"
return END
builder = StateGraph(state_schema=MessagesState)
builder.add_node("llm_node", llm_node)
builder.add_node("tool_node", tool_node)
builder.add_edge(START, "llm_node")
builder.add_conditional_edges("llm_node", router, path_map=["tool_node", END])
builder.add_edge("tool_node", "llm_node")
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
display(graph)
config = {"configurable": {"thread_id": "tool_test"}}
interrupted_res = graph.invoke({"messages": [HumanMessage("今天北京天气如何?")]}, config=config)
print('=' * 30, '-> interrupt_res <-', '=' * 30)
for msg in interrupted_res['messages']:
msg.pretty_print()
print('=' * 30, '-> interrupt_info <-', '=' * 30)
print(interrupted_res['__interrupt__'])
user_approved = input("是否同意查询天气?(y/n): ").strip().lower() == 'y'
approved_res = graph.invoke(Command(resume=user_approved), config=config)
print('=' * 30, '-> approved_res <-', '=' * 30)
for msg in approved_res['messages']:
msg.pretty_print()
运行结果如下

2026-06-16 14:51:50.662 | INFO | __main__:tool_node:59 - 工具 get_weather 被调用, 对应的 tool_call: {'name': 'get_weather', 'args': {'city': '北京'}, 'id': 'call_00_EGxsgveRV6mFkXsTtN4H9369', 'type': 'tool_call'}
============================== -> interrupt_res <- ==============================
================================ Human Message =================================
今天北京天气如何?
================================== Ai Message ==================================
好的,我来查询一下今天北京的天气情况。
Tool Calls:
get_weather (call_00_EGxsgveRV6mFkXsTtN4H9369)
Call ID: call_00_EGxsgveRV6mFkXsTtN4H9369
Args:
city: 北京
============================== -> interrupt_info <- ==============================
[Interrupt(value={'action': 'get_weather', 'question': '是否同意查询天气?'}, id='907128d6bef87a8473833ec0c90589ea')]
…(输出此处省略 19 行)
今天北京的天气**不错**哦!☀️ 是个适合出行的好天气。如果需要更详细的天气信息(如温度、风力等),可以随时告诉我,我帮你进一步查询!
9.1.4.6. 单节点串行中断模式
示例如下
class OverAllState(TypedDict):
username: str # 姓名
age: int # 年龄
gender: Literal["male", "female"] # 性别
def get_info_node(state: OverAllState) -> OverAllState:
username = interrupt("请输入您的用户名:")
age = interrupt("请输入您的年龄:")
gender = interrupt("请输入您的性别:(male/female)")
return {
"username": username,
"age": age,
"gender": gender
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("get_info_node", get_info_node)
builder.add_edge(START, "get_info_node")
builder.add_edge("get_info_node", END)
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "seq_interrupt_test"}}
username_interrupted_res = graph.invoke({}, config=config)
print('=' * 30, '-> username_interrupted_res <-', '=' * 30)
print(username_interrupted_res)
user_name = input("请输入您的用户名:")
age_interrupted_res = graph.invoke(Command(resume=user_name), config=config)
print('=' * 30, '-> age_interrupted_res <-', '=' * 30)
print(age_interrupted_res)
user_age = input("请输入您的年龄:")
gender_interrupted_res = graph.invoke(Command(resume=int(user_age)), config=config)
print('=' * 30, '-> gender_interrupted_res <-', '=' * 30)
print(gender_interrupted_res)
user_gender = input("请输入您的性别:(male/female): ")
resumed_res = graph.invoke(Command(resume=user_gender), config=config)
print('=' * 30, '-> resumed_res <-', '=' * 30)
print(resumed_res)
运行结果如下
============================== -> username_interrupted_res <- ==============================
{'__interrupt__': [Interrupt(value='请输入您的用户名:', id='0359f8a0b1ca357d2d092d5c55d80ec1')]}
============================== -> age_interrupted_res <- ==============================
{'__interrupt__': [Interrupt(value='请输入您的年龄:', id='0359f8a0b1ca357d2d092d5c55d80ec1')]}
============================== -> gender_interrupted_res <- ==============================
{'__interrupt__': [Interrupt(value='请输入您的性别:(male/female)', id='0359f8a0b1ca357d2d092d5c55d80ec1')]}
============================== -> resumed_res <- ==============================
{'username': '小黄', 'age': 15, 'gender': 'male'}
中断恢复时,整个被中断的节点函数都会重新运行。
单个节点中串行地多次调用 interrupt() 函数时,检查点存储器会记录历史的 resume 信息,LangGraph 运行时会读取这些信息,并在 interrupt() 函数中维护索引,按照节点内调用 interrupt() 函数的顺序,逐个取出历史 resume 的值,并将它们作为 interrupt() 函数的返回值。
所以,已经被恢复的 interrupt() 不会被重复触发。并且,由此可以推断,我们要保证历史 resume 可以被正确应用,就应保证中断恢复前后的多次 interrupt() 相对顺序保持不变。
当历史 resume 耗尽后,本次恢复运行时传入的 resume 会作为本次中断的返回值,然后节点函数继续运行。
所有中断都触发并恢复后,计算图正常结束。
9.1.5. 使用规范【重点】
9.1.5.1. 不要用try/catch包裹interrupt()调用
中断的触发是通过抛出 GraphInterrupt 异常实现的,如果用 try/catch 包裹,则底层运行时无法感知断点,不会中断计算图。
正确用法
def node_a(state: State):
# ✅ 正确用法:先打断点,再处理异常
interrupt("What's your name?")
try:
fetch_data() # 这一步可能失败
except Exception as e:
print(e)
return state
错误用法
def node_a(state: State):
# ❌ 错误用法,用 try/catch 包裹 interrupt(),这将会导致 GraphInterrupt 被捕获而无法触发中断
try:
interrupt("What's your name?")
fetch_data() # 这一步可能失败
except Exception as e:
print(e)
return state
9.1.5.2. interrupt的调用顺序应该是固定且必现的
上文提到:
- 恢复运行时整个节点函数都会重新运行,而非精确地从断点继续
- 同一节点中存在多个断点时:历史恢复记录被记录在检查点中,并在恢复运行时按顺序加载
一旦中断恢复前后的断点顺序、数量不完全一致,将会导致语义混乱,程序执行结果无法满足预期。这条规范就是为了避免这样的情况。
正确用法
def node_a(state: State):
# ✅ 正确用法,断点顺序固定
name = interrupt("What's your name?")
age = interrupt("What's your age?")
city = interrupt("What's your city?")
return {
"name": name,
"age": age,
"city": city
}
错误用法
1. 可能跳过其中部分断点
def node_a(state: State):
# ❌ 错误用法,以状态为条件,可能跳过部分断点
name = interrupt("What's your name?")
# 第一次运行到此处时,可能不会触发分支内的断点
# 恢复时,resume 本应作为 city,但如果分支内断点触发,将被错误地作为 age,导致语义混乱
if state.get("needs_age"):
age = interrupt("What's your age?")
city = interrupt("What's your city?")
return {"name": name, "city": city}
断点以状态为条件,如果中断恢复前后状态发生了变化,断点的顺序和数量可能发生变化,导致语义混乱。
2. 基于不确定的数据循环,并在其中设置断点
def node_a(state: State):
# ❌ 错误用法:基于不确定的数据循环
# 每次调用时的断点数量可能不同
results = []
for item in state.get("dynamic_list", []): # List might change between runs
result = interrupt(f"Approve {item}?")
results.append(result)
return {"results": results}
基于状态中可变的值循环,并在循环中设置断点,如果中断恢复前后状态发生变化,断点数量可能变化,导致语义混乱。
9.1.5.3. 不要在interrupt()中传递复杂类型
LangGraph 运行时会将 interrupt() 函数接收到的参数经过 JSON 序列化 之后传递给调用者。
如果传递不支持 JSON 序列化 的复杂类型,如函数,将会抛出异常,如下所示
TypeError: Type is not msgpack serializable: Interrupt
正确用法
1. 简单类型
def node_a(state: State):
# ✅ 正确用法:传递可以序列化的简单类型数据
name = interrupt("What's your name?")
count = interrupt(42)
approved = interrupt(True)
return {"name": name, "count": count, "approved": approved}
2. 结构化数据
def node_a(state: State):
# ✅ 正确用法:传递仅包含基本数据类型的可序列化字典
response = interrupt({
"question": "Enter user details",
"fields": ["name", "email", "age"],
"current_values": state.get("user", {})
})
return {"user": response}
错误用法
1. 函数
def validate_input(value):
return len(value) > 0
def node_a(state: State):
# ❌ 错误用法:传递函数
# 函数不支持 JSON 序列化
response = interrupt({
"question": "What's your name?",
"validator": validate_input # 此处会导致失败
})
return {"name": response}
2. 类的实例
class DataProcessor:
def __init__(self, config):
self.config = config
def node_a(state: State):
processor = DataProcessor({"mode": "strict"})
# ❌ 错误用法:传递类的实例
# 此处的自定义实例不支持 JSON 序列化
response = interrupt({
"question": "Enter data to process",
"processor": processor # 这将会导致失败
})
return {"result": response}
9.1.5.4. 断点之前的副作用操作必须是幂等的
不能把 新增 的操作放在断点之前,因为每次恢复断点都会重新执行整个节点
- 副作用操作:修改了外部环境的操作,如
- 写数据库
- 写文件
- 发送邮件或消息
- 幂等性:多次执行的结果等价于一次执行。
我们知道,中断恢复时,断点所在的函数会被重复执行,所以,如果断点之前存在不满足幂等性的副作用操作,将会导致多次调用结果不一致。
如:写数据操作不满足幂等性,可能导致写入多条重复记录。
正确用法
1. 在断点之前使用满足幂等性的操作
def node_a(state: State):
# ✅ 正确用法:使用幂等的 upsert 操作
# 多次运行结果一致
db.upsert_user(
user_id=state["user_id"],
status="pending_approval"
)
approved = interrupt("Approve this change?")
return {"approved": approved}
upsert 的语义是:写入或更新,如果有相同记录则覆盖更新,否则新增。满足幂等性。
2. 将副作用操作放在断点之后
def node_a(state: State):
# ✅ 正确用法:将副作用操作放在断点之后
# 确保副作用操作只运行一次
approved = interrupt("Approve this change?")
if approved:
db.create_audit_log(
user_id=state["user_id"],
action="approved"
)
return {"approved": approved}
上述代码将副作用操作置于断点之后,之后中断触发并恢复运行之后,副作用操作才会被执行,后者只会被执行一次。
3. 将副作用操作和断点置于不同的节点
def approval_node(state: State):
# ✅ 正确用法:当前节点只做中断
approved = interrupt("Approve this change?")
return {"approved": approved}
def notification_node(state: State):
# ✅ 正确用法:副作用操作发生在独立的节点中
# 当前节点独立于中断,所以副作用操作,不会因为中断恢复而重复执行
if (state.approved):
send_notification(
user_id=state["user_id"],
status="approved"
)
return state
这种方式更彻底,将中断和副作用操作完全隔离。
错误用法
1. 在断点之前向数据库中新增数据
def node_a(state: State):
# ❌ 错误用法:在断点之前向数据库中新增数据
# 中断恢复会导致数据重复
audit_id = db.create_audit_log({
"user_id": state["user_id"],
"action": "pending_approval",
"timestamp": datetime.now()
})
approved = interrupt("Approve this change?")
return {"approved": approved, "audit_id": audit_id}
2. 将数据追加到已存在的列表
def node_a(state: State):
# ❌ 错误用法:在断点之前向列表追加数据
# 这将会在中断恢复时导致列表数据重复
db.append_to_history(state["user_id"], "approval_requested")
approved = interrupt("Approve this change?")
return {"approved": approved}
9.1.6. 中断触发和恢复前后检查点的内容展示
9.1.6.1. 基础HITL模式
9.1.6.1.1. 示例代码
class OverAllState(TypedDict):
username: str
age: int
def node_a(state: OverAllState) -> OverAllState:
username = interrupt("请输入您的姓名")
age = interrupt("请输入您的年龄")
return {
"username": username,
"age": age
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", END)
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "123"}}
username_interrupted_res = graph.invoke({}, config=config)
print('=' * 30, '-> username_interrupted_res <-', '=' * 30)
print(username_interrupted_res)
username_interrupted_his = list(graph.get_state_history(config=config))
print('=' * 30, '-> username_interrupted_his <-', '=' * 30)
print(username_interrupted_his)
age_interrupted_res = graph.invoke(Command(resume="小黄"), config=config)
print('=' * 30, '-> age_interrupted_res <-', '=' * 30)
print(age_interrupted_res)
age_interrupted_his = list(graph.get_state_history(config=config))
print('=' * 30, '-> age_interrupted_his <-', '=' * 30)
print(age_interrupted_his)
resumed_res = graph.invoke(Command(resume=123), config=config)
print('=' * 30, '-> resumed_res <-', '=' * 30)
print(resumed_res)
resumed_his = list(graph.get_state_history(config=config))
print('=' * 30, '-> resumed_his <-', '=' * 30)
print(resumed_his)
9.1.6.1.2. 运行结果分析
9.1.6.1.2.1. 第一次中断
调用结果
============================== -> username_interrupted_res <- ==============================
{'__interrupt__': [Interrupt(value='请输入您的姓名', id='488d1fa95a4053d41ab09786f5260ae9')]}
历史检查点
============================== -> username_interrupted_his <- ==============================
[
StateSnapshot(
values={},
next=('node_a',),
config={
'configurable': {
}
},
metadata={
'source': 'loop',
'step': 0,
'parents': {}
},
created_at='2026-06-17T08:19:59.195818+00:00',
…(输出此处省略 54 行)
)
]
当前工作流只有一个节点,所以只有一个真正执行用户自定义节点的超步,对应的超步编号为 1
中断触发时,检查点存储器中记录的最新检查点超步为 0,中断信息只能和这个超步绑定
中断信息存了两份:
-
StateSnapshot的tasks属性下记录的PregelTask实例的interrupts字段记录了当前任务触发的中断信息。 -
StateSnapshot的interrupts属性记录了当前超步发生的所有中断
当存在多个并行中断时
- 中断信息被记录在各自所属任务的
PregelTask实例中 - 同时也会被汇总在
StateSnapshot的tasks属性中
9.1.6.1.2.2. 第一次恢复
调用结果
============================== -> age_interrupted_res <- ==============================
{'__interrupt__': [Interrupt(value='请输入您的年龄', id='488d1fa95a4053d41ab09786f5260ae9')]}
历史检查点
============================== -> age_interrupted_his <- ==============================
[
StateSnapshot(
values={},
next=('node_a',),
config={
'configurable': {
}
},
metadata={
'source': 'loop',
'step': 0,
'parents': {}
},
…(输出此处省略 55 行)
)
]
checkpoint_id、任务id、中断id 和历史检查点完全一致,只是 Interrupt 实例的 value 属性变成了第二次中断的值。
由此可见,恢复运行时检查点的更新确实是覆盖写入,历史中断信息不会被保留。
并且,中断恢复时传递的 resume 在 graph.get_state_history() 时也不可见。
9.1.6.1.2.3. 第二次恢复
调用结果
============================== -> resumed_res <- ==============================
{'username': '小黄', 'age': 123}
历史检查点
============================== -> resumed_his <- ==============================
[
StateSnapshot(
values={
'username': '小黄',
'age': 123
},
next=(),
config={
'configurable': {
}
},
metadata={
'source': 'loop',
…(输出此处省略 82 行)
)
]
两个断点全部恢复运行后,编号为 1 的超步终于完成,任务实例的 result 被更新,状态被更新,最后一个超步的 values 是更新后的状态值,计算图运行完成。
9.1.6.2. 多个并行中断触发时的检查点
等同一个超步都执行完,然后把有断电的合并在一起,一并交给用户回复,回复时需要是字典对应不同断点
9.1.6.2.1. 示例代码
class OverAllState(TypedDict):
username: str
age: int
# (node_a 与上文定义完全相同,略)
# (node_b 与上文定义完全相同,略)
builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge(START, "node_a")
builder.add_edge(START, "node_b")
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
display(graph)
config = {"configurable": {"thread_id": "123"}}
interrupted_res = graph.invoke({}, config=config)
print('=' * 30, '-> interrupt_res <-', '=' * 30)
print(interrupted_res)
list(graph.get_state_history(config=config))
# 6. 恢复执行
resume_map = {}
for i in interrupted_res['__interrupt__']:
user_input = input(f"{i.value}:")
if "年龄" in i.value:
resume_map[i.id] = int(user_input)
else:
resume_map[i.id] = user_input
resumed_res = graph.invoke(Command(resume=resume_map),config=config)
print(resumed_res)
list(graph.get_state_history(config=config))
9.1.6.2.2. 结果分析

计算图返回值
============================== -> interrupt_res <- ==============================
{
'__interrupt__': [
Interrupt(
value='请输入您的姓名',
id='0cae1932a74087f02711c6f1677b4b17'
),
Interrupt(
value='请输入您的年龄',
id='cd216e1ad6907c2432636ff5f32cfcf3'
)
]
}
同一个超步中并行触发的中断信息被放在列表中返回。
历史检查点列表
[
StateSnapshot(
values={},
next=(
'node_a',
'node_b'
),
config={
'configurable': {
'thread_id': '123',
'checkpoint_ns': '',
'checkpoint_id': '1f16a329-8b50-69e0-8000-7bdce1ba7379'
}
},
metadata={
'source': 'loop',
'step': 0,
'parents': {}
},
created_at='2026-06-17T09:54:56.810630+00:00',
parent_config={
'configurable': {
'thread_id': '123',
'checkpoint_ns': '',
'checkpoint_id': '1f16a329-8b4e-6085-bfff-3100dea319cc'
}
},
tasks=(
PregelTask(
id='689d8040-4b34-02a2-eb32-d901bbb3c67c',
name='node_a',
path=(
'__pregel_pull',
'node_a'
),
error=None,
interrupts=(
Interrupt(
value='请输入您的姓名',
id='0cae1932a74087f02711c6f1677b4b17'
),
),
state=None,
result=None
),
PregelTask(
id='034cbe3c-e0a6-025a-ce03-46436ce38fc3',
name='node_b',
path=(
'__pregel_pull',
'node_b'
),
error=None,
interrupts=(
Interrupt(
value='请输入您的年龄',
id='cd216e1ad6907c2432636ff5f32cfcf3'
),
),
state=None,
result=None
)
),
interrupts=(
Interrupt(
value='请输入您的姓名',
id='0cae1932a74087f02711c6f1677b4b17'
),
Interrupt(
value='请输入您的年龄',
id='cd216e1ad6907c2432636ff5f32cfcf3'
)
)
),
StateSnapshot(
values={},
next=(
'__start__',
),
config={
'configurable': {
'thread_id': '123',
'checkpoint_ns': '',
'checkpoint_id': '1f16a329-8b4e-6085-bfff-3100dea319cc'
}
},
metadata={
'source': 'input',
'step': -1,
'parents': {}
},
created_at='2026-06-17T09:54:56.809574+00:00',
parent_config=None,
tasks=(
PregelTask(
id='3df213dc-5fa0-a8aa-b1dc-428da9010be6',
name='__start__',
path=(
'__pregel_pull',
'__start__'
),
error=None,
interrupts=(),
state=None,
result={}
),
),
interrupts=()
)
]
如前所述,编号为 1 的超步执行时中断触发,此时检查点后端中存储的最新的超步编号为 0,因此中断信息记录在超步 0 的 StateSnapshot 实例中。
-
快照实例的
interrupts属性记录了并行触发的两个中断的信息。 -
并且,各自的中断信息被记录在对应的任务实例中。
9.1.6.3. 同一超步部分任务触发中断时的检查点
一个超步中某个任务触发中断,不会导致其它并行任务的终止,正常运行的任务结果、中断任务的中断信息都被记录在检查点后端
9.1.6.3.1. 示例代码
class OverAllState(TypedDict):
username: str
age: int
# (node_a 与上文定义完全相同,略)
def node_b(state: OverAllState) -> OverAllState:
age = 12
return {
"age": age
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge(START, "node_a")
builder.add_edge(START, "node_b")
builder.add_edge("node_a", END)
builder.add_edge("node_b", END)
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
display(graph)
config = {"configurable": {"thread_id": "123"}}
interrupted_res = graph.invoke({}, config=config)
print('=' * 30, '-> interrupt_res <-', '=' * 30)
print(interrupted_res)
list(graph.get_state_history(config=config))
9.1.6.3.2. 结果分析

计算图返回值
============================== -> interrupt_res <- ==============================
{
'age': 12,
'__interrupt__': [
Interrupt(
value='请输入您的姓名',
id='bbff88193340fd0ed196be702608e385'
)
]
}
历史检查点列表
============================== -> state_history <- ==============================
[
StateSnapshot(
values={},
next=(
'node_a',
'node_b'
),
config={
'configurable': {
'thread_id': '123',
'checkpoint_ns': '',
'checkpoint_id': '1f16a32c-b47b-6ca9-8000-79ee3c65e7ce'
}
},
metadata={
'source': 'loop',
'step': 0,
'parents': {}
},
created_at='2026-06-17T09:56:21.658113+00:00',
parent_config={
'configurable': {
'thread_id': '123',
'checkpoint_ns': '',
'checkpoint_id': '1f16a32c-b479-6766-bfff-901ed37f684d'
}
},
tasks=(
PregelTask(
id='e16690fc-c0f8-e9fc-e30d-706a64f24209',
name='node_a',
path=(
'__pregel_pull',
'node_a'
),
error=None,
interrupts=(
Interrupt(
value='请输入您的姓名',
id='bbff88193340fd0ed196be702608e385'
),
),
state=None,
result=None
),
PregelTask(
id='b628264b-d4ca-c09f-5197-6eb1c953b947',
name='node_b',
path=(
'__pregel_pull',
'node_b'
),
error=None,
interrupts=(),
state=None,
result={
'age': 12
}
)
),
interrupts=(
Interrupt(
value='请输入您的姓名',
id='bbff88193340fd0ed196be702608e385'
),
)
),
StateSnapshot(
values={},
next=(
'__start__',
),
config={
'configurable': {
'thread_id': '123',
'checkpoint_ns': '',
'checkpoint_id': '1f16a32c-b479-6766-bfff-901ed37f684d'
}
},
metadata={
'source': 'input',
'step': -1,
'parents': {}
},
created_at='2026-06-17T09:56:21.657164+00:00',
parent_config=None,
tasks=(
PregelTask(
id='5dc4ad26-1baa-7c18-2580-153a19d4c175',
name='__start__',
path=(
'__pregel_pull',
'__start__'
),
error=None,
interrupts=(),
state=None,
result={}
),
),
interrupts=()
)
]
由上可知,同一个超步中:
-
发生中断的任务,其中断信息被记录在任务实例和快照的
interrupts字段中 -
未触发中断的任务正常结束,其结果被记录在任务实例的
result字段中
底层负责执行节点逻辑的是超步的第二阶段,如果存在多个并行任务,则会将任务提交到线程池,在独立的线程中运行,负责执行第二阶段的 PregelRunner 实例会收集提交后的 future 对象,并等待所有任务完成。
当中断触发时,对应的线程会抛出 GraphInterrupt,对应的 future 对象会收集异常。
PregelRunner 实例在 future 对应的任务完成后,收集中断信息或任务执行结果,写入检查点存储器。
所以一个超步中某个任务触发中断,不会导致其它并行任务的终止,正常运行的任务结果、中断任务的中断信息都被记录在检查点后端。
9.2. 静态断点
注意:
- 写法是断节点,但底层实际是断超步。把超步前后进行断点
- 如果一个超步里面有多个并行节点,你只对其中一个节点设置 interrupt_after,仍然要等整个超步全部跑完,才会触发断点。不会只跑完这一个节点就立刻停。
LangGraph 提供了用于调试的 静态断点。
它是在状态图编译或调用时设置的,不会在运行时动态触发,不属于业务逻辑,所以称为 静态 断点。
动态断点提供了接收人类反馈的接口,而静态断点只是暂停计算图的运行,不能向调用者传递信息,也不能接收调用者的反馈。
9.2.1. 用法说明
-
静态断点也需要基于检查点恢复,所以必须配置检查点,启用可恢复运行机制。
-
计算图会在
interrupt_before指定的节点执行之前产生中断,暂停计算 -
计算图会在
interrupt_after指定的节点执行之后产生中断,暂停计算 -
计算图运行到断点位置会中断,和动态断点不同的是,它会在超步边界而非内部中断。
断点前的超步:三个阶段全部完成;断点后的超步:三个阶段都没有开始
(三个阶段:Plan(规划执行超步中哪些节点)→ Execute(执行节点)→ Update(更新State)) -
静态断点不会返回任何中断信息,只会将当前最新的状态返回。因此,我们可以用静态断点查看每个超步边界的中间状态。
-
传入相同的配置并将
None作为输入,再次调用计算图,会从断点位置继续运行。 -
支持在两个阶段设置静态断点
-
状态图编译时
graph = builder.compile( checkpointer=checkpointer, interrupt_before=["node_a", "node_b"], interrupt_after=["node_a", "node_b"] ) -
计算图调用时
first_res = graph.invoke( {}, config=config, interrupt_before=["node_a", "node_b"], interrupt_after=["node_a", "node_b"] )
断点都是在运行时生效,计算图调用时设置的断点优先级更高。
如果调用时传入的断点列表不为空,则会覆盖编译时配置。
-
9.2.2. 底层原理
-
编译时设置的静态断点保存在编译图的
interrupt_before_nodes和interrupt_after_nodes中。运行时采用调用参数优先、编译配置兜底的规则。
调用时传入非空节点列表会完全覆盖编译配置,而
None或空列表会回退到编译配置。 -
interrupt_before在超步的第一阶段:当前超步的任务列表计算完成后、节点任务开始执行前进行检查。- 如果检查点中的状态和上一次静态断点或图的初始状态相比,产生了变化
- 并且当前超步的任务列表和
interrupt_before列表存在交集
则抛出
GraphInterrupt。 -
interrupt_after在超步的第三阶段:当前超步的任务全部执行完成、写入应用到通道并创建检查点之后进行检查。- 如果检查点中的状态和上一次静态断点或图的初始状态相比,产生了变化
- 并且当前超步的任务列表和
interrupt_after列表存在交集
则抛出
GraphInterrupt。 -
如果存在这样的拓扑图
node_a -> node_b那么在
node_a之后 和node_b之前 设置断点只会中断一次。因为二者对应同一个超步边界,如果同时配置:
-
node_a的after先触发; -
恢复运行时检查点中记录的上次断点可见状态会被更新,和最新状态保持一致
-
而产生中断之前已经保存了检查点,恢复运行时会从
node_b的超步开始 -
node_b的超步第一阶段,检查点和上次静态断点相比,没有变化,所以不会中断 -
因此,
node_a之后 和node_b之前 设置断点只会中断一次
-
9.2.3. 示例
9.2.3.1. 编译图时设置断点【推荐】
后续调用这个图的时候,不需要在写 断点参数。
9.2.3.1.1. 顺序结构设置断点
示例如下
class OverAllState(TypedDict):
final_res: str
def node_a(state: OverAllState) -> OverAllState:
logger.info("node_a 被执行")
return {
"final_res": "node_a 运行的中间结果"
}
def node_b(state: OverAllState) -> OverAllState:
logger.info("node_b 被执行")
return {
"final_res": "node_b 运行的中间结果"
}
def node_c(state: OverAllState) -> OverAllState:
logger.info("node_c 被执行")
return {
"final_res": "node_c 运行的中间结果"
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", "node_c")
builder.add_edge("node_c", END)
checkpointer = InMemorySaver()
# 编译时写好静态断点规则
graph = builder.compile(
checkpointer=checkpointer,
interrupt_before=["node_a", "node_b"],
interrupt_after=["node_a", "node_b"]
)
display(graph)
config = {"configurable": {"thread_id": "123"}}
logger.info("{}-> 第一次执行 <-{}", "=" * 10, "=" * 10)
first_res = graph.invoke({}, config=config)
logger.info("第一次执行结果:{}", first_res)
logger.info("{}-> 第二次执行 <-{}", "=" * 10, "=" * 10)
second_res = graph.invoke(None, config=config)
logger.info("第二次执行结果:{}", second_res)
logger.info("{}-> 第三次执行 <-{}", "=" * 10, "=" * 10)
third_res = graph.invoke(None, config=config)
logger.info("第三次执行结果:{}", third_res)
logger.info("{}-> 第四次执行 <-{}", "=" * 10, "=" * 10)
final_res = graph.invoke(None, config=config)
logger.info("第四次执行结果:{}", final_res)
logger.info("{}-> 完毕 <-{}", "=" * 10, "=" * 10)
运行结果如下

编译时设置的断点在渲染拓扑结构时被感知,图中对应结点新增了断点标记:
__interrupt = before,after
这表示该节点之前和之后都设置了断点。
日志如下
(此处 loguru 运行日志 12 行已省略:只有时间戳与调用栈,无知识点)
由日志可知,node_a 和 node_b 之间只产生了一次中断,和分析相符。
9.2.3.1.2. 存在并行节点时设置断点
- start——A——B。A、B前后分别有设置断点,最后只会断点三个地方:A前面,A后面,B后面。(A后面和B前面属于同一个位置,所以断点是同一个)
- start——A、start——B。A、B前后分别有设置断点,最后只会断点两个地方:AB前面、AB后面。(A前、B前属于同一个位置,A后、B后属于同一个位置)
为了确定断点是 以节点 为边界还是 以超步 为边界设置的,我们在存在并行节点的计算图中测试
示例如下
class OverAllState(TypedDict):
final_res: str
# (node_a 与上文定义完全相同,略)
# (node_b 与上文定义完全相同,略)
# (node_c 与上文定义完全相同,略)
def node_d(state: OverAllState) -> OverAllState:
logger.info("node_d 被执行")
return {}
def node_e(state: OverAllState) -> OverAllState:
logger.info("node_e 被执行")
return {}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)
builder.add_node("node_d", node_d)
builder.add_node("node_e", node_e)
builder.add_edge(START, "node_a")
builder.add_edge(START, "node_d")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_d", "node_e")
builder.add_edge(["node_b", "node_e"], "node_c")
builder.add_edge("node_c", END)
checkpointer = InMemorySaver()
graph = builder.compile(
checkpointer=checkpointer,
interrupt_before=["node_a", "node_b"],
interrupt_after=["node_a", "node_b"]
)
display(graph)
config = {"configurable": {"thread_id": "123"}}
logger.info("{}-> 第一次执行 <-{}", "=" * 10, "=" * 10)
first_res = graph.invoke({}, config=config)
logger.info("第一次执行结果:{}", first_res)
logger.info("{}-> 第二次执行 <-{}", "=" * 10, "=" * 10)
second_res = graph.invoke(None, config=config)
logger.info("第二次执行结果:{}", second_res)
logger.info("{}-> 第三次执行 <-{}", "=" * 10, "=" * 10)
third_res = graph.invoke(None, config=config)
logger.info("第三次执行结果:{}", third_res)
logger.info("{}-> 第四次执行 <-{}", "=" * 10, "=" * 10)
final_res = graph.invoke(None, config=config)
logger.info("第四次执行结果:{}", final_res)
logger.info("{}-> 完毕 <-{}", "=" * 10, "=" * 10)
运行结果如下

(此处 loguru 运行日志 14 行已省略:只有时间戳与调用栈,无知识点)
node_a 和 node_d 属于同一个超步
node_b 和 node_e 属于同一个超步
我们只在 node_a 和 node_b 前后设置了超步,但它们的并行节点也被中断了。
显然,中断发生在超步边界,而非节点边界,与分析相符。
9.2.3.2. 调用时设置断点
每次调用都需要传 断点 参数。
注意:恢复调用时的断点参数要和前面执行的断点参数一致,不然 断点后执行节点的断点规则只会按照最后一次调用时的断点规则来
调用时的断点配置只对本次调用生效。
9.2.3.2.1. 正确用法:恢复调用时设置相同的断点
示例如下
class OverAllState(TypedDict):
final_res: str
# (node_a 与上文定义完全相同,略)
# (node_b 与上文定义完全相同,略)
# (node_c 与上文定义完全相同,略)
builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", "node_c")
builder.add_edge("node_c", END)
checkpointer = InMemorySaver()
graph = builder.compile(
checkpointer=checkpointer
)
display(graph)
config = {"configurable": {"thread_id": "123"}}
logger.info("{}-> 第一次执行 <-{}", "=" * 10, "=" * 10)
first_res = graph.invoke(
{},
config=config,
interrupt_before=["node_a", "node_b"],
interrupt_after=["node_a", "node_b"]
)
logger.info("第一次执行结果:{}", first_res)
logger.info("{}-> 第二次执行 <-{}", "=" * 10, "=" * 10)
second_res = graph.invoke(
None,
config=config,
interrupt_before=["node_a", "node_b"],
interrupt_after=["node_a", "node_b"]
)
logger.info("第二次执行结果:{}", second_res)
logger.info("{}-> 第三次执行 <-{}", "=" * 10, "=" * 10)
third_res = graph.invoke(
None,
config=config,
interrupt_before=["node_a", "node_b"],
interrupt_after=["node_a", "node_b"]
)
logger.info("第三次执行结果:{}", third_res)
logger.info("{}-> 第四次执行 <-{}", "=" * 10, "=" * 10)
final_res = graph.invoke(
None,
config=config,
interrupt_before=["node_a", "node_b"],
interrupt_after=["node_a", "node_b"]
)
logger.info("第四次执行结果:{}", final_res)
logger.info("{}-> 完毕 <-{}", "=" * 10, "=" * 10)
运行结果如下

(此处 loguru 运行日志 12 行已省略:只有时间戳与调用栈,无知识点)
我们期望在 node_a 和 node_b 前后中断,此时运行结果和预期相符。
9.2.3.2.2. 错误用法:恢复调用时不再设置断点
示例如下
class OverAllState(TypedDict):
final_res: str
# (node_a 与上文定义完全相同,略)
# (node_b 与上文定义完全相同,略)
# (node_c 与上文定义完全相同,略)
builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_node("node_c", node_c)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", "node_c")
builder.add_edge("node_c", END)
checkpointer = InMemorySaver()
graph = builder.compile(
checkpointer=checkpointer
)
display(graph)
config = {"configurable": {"thread_id": "123"}}
logger.info("{}-> 第一次执行 <-{}", "=" * 10, "=" * 10)
first_res = graph.invoke(
{},
config=config,
interrupt_before=["node_a", "node_b"],
interrupt_after=["node_a", "node_b"]
)
logger.info("第一次执行结果:{}", first_res)
logger.info("{}-> 第二次执行 <-{}", "=" * 10, "=" * 10)
second_res = graph.invoke(
None,
config=config,
interrupt_before=["node_a", "node_b"],
interrupt_after=["node_a", "node_b"]
)
logger.info("第二次执行结果:{}", second_res)
logger.info("{}-> 第三次执行 <-{}", "=" * 10, "=" * 10)
third_res = graph.invoke(None, config=config)
logger.info("第三次执行结果:{}", third_res)
logger.info("{}-> 第四次执行 <-{}", "=" * 10, "=" * 10)
final_res = graph.invoke(None, config=config)
logger.info("第四次执行结果:{}", final_res)
logger.info("{}-> 完毕 <-{}", "=" * 10, "=" * 10)
运行结果如下

(此处 loguru 运行日志 12 行已省略:只有时间戳与调用栈,无知识点)
本例中,我们只在初次调用和第一次恢复运行时设置断点。
由日志可知,按照期望,第二次恢复运行之后,node_b 执行完成后还应中断一次,然而,因为本次没有设置断点,因此计算图不再中断,node_b 和 node_c 都在本次调用完成,计算图结束。
在第四次调用时,计算图已经运行完毕,恢复运行实际上不会执行任何逻辑,只是将计算图的最终状态返回。
10. 工具调用节点ToolNode
本章重点:
- ToolNode替代传统的 tool_node节点
- 工具方法中,可以通过 Command 来修改State内容。类似之前 tool_node节点的return
wrap_tool_call是工具方法调用前的拦截器(给每个工具写一个调用前的统一AOP操作)- 其余工具节点功能参考官网学习
10.1. 工具节点的实现
工具是 Agent 扩展外部能力的重要组件之一。本节以最基础的 ReAct 式工具调用循环为例,分别介绍手动实现工具节点和使用预构建 ToolNode 两种方式。
手动实现有助于理解工具调用的基本流程:模型生成工具调用请求,工具节点根据名称查找并执行工具,再将执行结果封装成与原工具调用对应的ToolMessage。不过,实际项目还需要处理参数校验、异常处理、运行时注入、并行执行以及 Command 传播等问题。
ToolNode 对这些通用逻辑进行了封装,适合在需要自定义图结构和工具调用流程的场景中使用。
10.1.1. 手动处理工具调用(古法方式,了解即可)
示例如下
# ---- 本章导入 ----
from typing import Literal
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import MessagesState
from langchain.messages import HumanMessage, ToolMessage
from langchain.tools import tool
from langchain_deepseek import ChatDeepSeek
from loguru import logger
from dotenv import load_dotenv
load_dotenv(override=True)
@tool(parse_docstring=True)
def get_weather(city: str) -> str:
"""
查询指定城市的当日天气
Args:
city: 城市名称
"""
return f"{city} 今天天气不错"
@tool(parse_docstring=True)
def get_news(home_or_abroad: bool) -> str:
"""
查询国内外新闻
Args:
home_or_abroad: 查询国内还是国外新闻,True: 国内新闻,False:国外新闻
"""
if home_or_abroad:
return "Kimi 新模型发布"
return "Anthropic 暂停新模型访问"
tools_by_name = {
"get_weather": get_weather,
"get_news": get_news
}
tools = [get_weather, get_news]
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
model_with_tools = model.bind_tools(tools=tools)
# (llm_node 与上文定义完全相同,略)
def tool_node(state: MessagesState) -> MessagesState:
last_msg = state['messages'][-1]
if not last_msg.tool_calls:
return {}
tool_msgs = []
for tool_call in last_msg.tool_calls:
tool = tools_by_name[tool_call["name"]]
logger.info("工具 {} 被调用, 对应的 tool_call: {}", tool_call["name"], tool_call)
tool_res = tool.invoke(tool_call["args"])
tool_msg = ToolMessage(
name = tool_call["name"],
content = tool_res,
tool_call_id = tool_call["id"]
)
tool_msgs.append(tool_msg)
return {
"messages": tool_msgs
}
# (router 与上文定义完全相同,略)
builder = StateGraph(state_schema=MessagesState)
builder.add_node("llm_node", llm_node)
builder.add_node("tool_node", tool_node)
builder.add_edge(START, "llm_node")
builder.add_conditional_edges("llm_node", router, path_map=["tool_node", END])
builder.add_edge("tool_node", "llm_node")
graph = builder.compile()
from IPython.display import display
display(graph)
res = graph.invoke({"messages": [HumanMessage("今天北京天气如何?国内有哪些新闻")]})
for msg in res['messages']:
msg.pretty_print()
注意
上面的手动实现会按照
tool_calls的顺序串行执行工具,主要用于展示底层流程。ToolNode在同步调用中可通过执行器并行处理多个工具调用,在异步调用中则使用asyncio.gather()并发执行。
运行结果如下

2026-06-22 09:59:05.258 | INFO | __main__:tool_node:67 - 工具 get_weather 被调用, 对应的 tool_call: {'name': 'get_weather', 'args': {'city': '北京'}, 'id': 'call_00_lpemFmfsKxTtEKjweosB0896', 'type': 'tool_call'}
2026-06-22 09:59:05.259 | INFO | __main__:tool_node:67 - 工具 get_news 被调用, 对应的 tool_call: {'name': 'get_news', 'args': {'home_or_abroad': True}, 'id': 'call_01_DDLZCKnovq6EtdrD6XXw2202', 'type': 'tool_call'}
================================ Human Message =================================
今天北京天气如何?国内有哪些新闻
================================== Ai Message ==================================
好的,我来查询今天北京的天气和国内新闻。
Tool Calls:
get_weather (call_00_lpemFmfsKxTtEKjweosB0896)
Call ID: call_00_lpemFmfsKxTtEKjweosB0896
Args:
city: 北京
get_news (call_01_DDLZCKnovq6EtdrD6XXw2202)
Call ID: call_01_DDLZCKnovq6EtdrD6XXw2202
…(输出此处省略 19 行)
如果你想了解更多详情,可以继续问我!
10.1.2. 用ToolNode处理工具调用
上一节的工具节点完成了以下工作:读取最后一条 AIMessage 中的 tool_calls,根据工具名称查找并执行对应工具,再把执行结果封装成与原工具调用一一对应的 ToolMessage。
这种实现适合帮助我们理解工具调用的基本流程,但它只覆盖了最简单的场景。实际项目还需要处理:
无效工具名、参数校验、异常处理、运行时参数注入、Command 传播 以及多个工具调用的并行执行等问题。
LangGraph 提供了预构建的 ToolNode。只需传入工具列表,它便可以完成上述所有操作。
示例如下
# ---- 本章导入 ----
from langgraph.prebuilt.tool_node import ToolNode
from langchain.messages import HumanMessage
load_dotenv(override=True)
@tool(parse_docstring=True)
# (get_weather 与上文定义完全相同,略)
@tool(parse_docstring=True)
# (get_news 与上文定义完全相同,略)
tools = [get_weather, get_news]
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
model_with_tools = model.bind_tools(tools=tools)
# (llm_node 与上文定义完全相同,略)
# (router 与上文定义完全相同,略)
builder = StateGraph(state_schema=MessagesState)
builder.add_node("llm_node", llm_node)
builder.add_node("tool_node", ToolNode(tools=tools))
builder.add_edge(START, "llm_node")
builder.add_conditional_edges("llm_node", router, path_map=["tool_node", END])
builder.add_edge("tool_node", "llm_node")
graph = builder.compile()
display(graph)
res = graph.invoke({"messages": [HumanMessage("今天北京天气如何?国内有哪些新闻?")]})
for msg in res['messages']:
msg.pretty_print()
运行结果如下

================================ Human Message =================================
今天北京天气如何?国内有哪些新闻?
================================== Ai Message ==================================
好的,我来同时查询北京天气和国内新闻。
Tool Calls:
get_weather (call_00_BBHYjTwSiWbGolSrp7yb9812)
Call ID: call_00_BBHYjTwSiWbGolSrp7yb9812
Args:
city: 北京
get_news (call_01_3XEu887comx2s1SMtw8h3282)
Call ID: call_01_3XEu887comx2s1SMtw8h3282
Args:
home_or_abroad: True
…(输出此处省略 19 行)
如果您想了解更多详细内容,随时可以继续问我!
10.2. 进阶用法
10.2.1. ToolRuntime介绍
ToolRuntime 是专门面向工具调用的运行时对象。
按照官方规范:
- 工具函数中存在名为
runtime - 并且类型标注为
ToolRuntime的参数时
底层运行时会在调用工具前自动注入 ToolRuntime 实例。根据源码实现,某些不规范的写法也能注入运行时实例,但并不规范,不推荐。
需要注意,ToolRuntime 与图节点中使用的 langgraph.runtime.Runtime 并不是同一个类型。前者额外提供了当前图状态、运行配置和工具调用 ID 等工具调用专属信息。
当前版本,ToolRuntime 的核心定义如下:
@dataclass
class ToolRuntime(_DirectlyInjectedToolArg, Generic[ContextT, StateT]):
state: StateT
context: ContextT
config: RunnableConfig
stream_writer: StreamWriter
tool_call_id: str | None
store: BaseStore | None
state:图状态,短期记忆context:运行时上下文config:运行时配置,包含元数据stream_writer:自定义流式输出写入器tool_call_id:工具调用IDstore:长期记忆
借助 runtime,工具可以访问上述所有资源。
10.2.2. 在工具中更新状态
工具不仅可以返回普通结果,还可以返回 Command(update=...),将业务结果写入图状态。
当工具由模型发起调用时,消息历史中的 AIMessage.tool_calls 必须紧跟与之匹配的 ToolMessage。
当工具返回普通结果时,ToolNode 会帮我们完成运行结果到 ToolMessage 的转换,并追加到消息列表。
但工具返回 Command 时,上述操作需要开发者完成,此时还应在 Command.update 的消息字段中写入对应的 ToolMessage。
如果同一轮并行执行的多个工具可能更新同一个普通状态字段,需要为该字段配置合适的归并函数;否则可能出现并发更新冲突。本例中的两个工具分别更新 weather_res 和 news_res,因此不存在该问题。
示例如下
# ---- 本章导入 ----
from langgraph.prebuilt.tool_node import ToolNode, ToolRuntime
from langgraph.types import Command
load_dotenv(override=True)
class OverAllState(MessagesState):
weather_res: str
news_res: str
@tool(parse_docstring=True)
def get_weather(city: str, runtime: ToolRuntime) -> Command:
"""
查询指定城市的当日天气
Args:
city: 城市名称
"""
res = f"{city} 今天天气不错"
tool_call_id = runtime.tool_call_id
tool_msg = ToolMessage(tool_call_id=tool_call_id, content=res)
return Command(
update = {
"weather_res": res,
"messages": [tool_msg]
}
)
@tool(parse_docstring=True)
def get_news(home_or_abroad: bool, runtime: ToolRuntime) -> Command:
"""
查询国内外新闻
Args:
home_or_abroad: 查询国内还是国外新闻,True: 国内新闻,False:国外新闻
"""
if home_or_abroad:
res = "Kimi 新模型发布"
else:
res = "Anthropic 暂停新模型访问"
tool_call_id = runtime.tool_call_id
tool_msg = ToolMessage(tool_call_id=tool_call_id, content=res)
return Command(
update = {
"news_res": res,
"messages": [tool_msg]
}
)
tools = [get_weather, get_news]
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
model_with_tools = model.bind_tools(tools=tools)
def llm_node(state: OverAllState) -> OverAllState:
messages = state['messages']
response = model_with_tools.invoke(messages)
return {
"messages": [response]
}
def router(state: OverAllState) -> Literal["tool_node", END]:
if state['messages'][-1].tool_calls:
return "tool_node"
return END
builder = StateGraph(state_schema=OverAllState)
builder.add_node("llm_node", llm_node)
builder.add_node("tool_node", ToolNode(tools=tools))
builder.add_edge(START, "llm_node")
builder.add_conditional_edges("llm_node", router, path_map=["tool_node", END])
builder.add_edge("tool_node", "llm_node")
graph = builder.compile()
display(graph)
res = graph.invoke({"messages": [HumanMessage("今天北京天气如何?国内有哪些新闻?")]})
print('=' * 30, '-> messages <-', '=' * 30)
for msg in res.pop('messages'):
msg.pretty_print()
print('=' * 30, '-> res without messages <-', '=' * 30)
print(res)
运行结果如下

============================== -> messages <- ==============================
================================ Human Message =================================
今天北京天气如何?国内有哪些新闻?
================================== Ai Message ==================================
好的,我来同时查询北京天气和国内新闻。
Tool Calls:
get_weather (call_00_hbEppxygz7DLDwaxg8X19090)
Call ID: call_00_hbEppxygz7DLDwaxg8X19090
Args:
city: 北京
get_news (call_01_wkG4j5Hjq4wkyTOaxzxT5267)
Call ID: call_01_wkG4j5Hjq4wkyTOaxzxT5267
Args:
…(输出此处省略 20 行)
============================== -> res without messages <- ==============================
{'weather_res': '北京 今天天气不错', 'news_res': 'Kimi 新模型发布'}
10.2.3. 工具节点执行与容错机制
ToolNode 提供同步工具调用包装器 wrap_tool_call,用于在工具执行前后插入自定义逻辑。
它与 LangChain Agent 中间件 wrap_tool_call 的核心机制相同:接收当前工具调用请求 request 和真正执行工具的回调 execute,可以选择不调用、调用一次或多次 execute(request),并最终返回 ToolMessage 或 Command。
因此,wrap_tool_call 可以用于重试、缓存、请求改写、短路返回和自定义控制流等场景。异步工具链路还可以使用对应的 awrap_tool_call。
此处通过重试和缓存机制的实现,介绍 wrap_tool_call 的用法。
10.2.3.1. wrap_tool_call实现重试机制
示例如下
# ---- 本章导入 ----
from dataclasses import dataclass
load_dotenv(override=True)
import random
@tool(parse_docstring=True)
def get_weather(city: str) -> str:
"""
查询指定城市的当日天气
Args:
city: 城市名称
"""
# 70% 概率因网络波动而调用失败
rand_int = random.randint(1,10)
if rand_int < 8:
raise ConnectionError("网络波动失败")
return f"{city} 今天天气不错"
tools = [get_weather]
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
model_with_tools = model.bind_tools(tools=tools)
@dataclass
class UserContext:
max_attempts: int
# (llm_node 与上文定义完全相同,略)
# (router 与上文定义完全相同,略)
def wrap_tool_call(request, execute):
max_attempts = request.runtime.context.max_attempts
tool_call_id = request.runtime.tool_call_id
tool_msg = ""
for i in range(max_attempts):
try:
tool_msg = execute(request)
break
except ConnectionError as e:
logger.info("工具调用失败,当前调用次数: {}, 调用次数上限: {}, 异常信息: {}", i + 1, max_attempts, e)
if not tool_msg:
tool_msg = ToolMessage(
tool_call_id = tool_call_id,
content = "调用次数到达上限,调用失败"
)
return tool_msg
builder = StateGraph(state_schema=MessagesState, context_schema=UserContext)
builder.add_node("llm_node", llm_node)
builder.add_node("tool_node", ToolNode(tools=tools, wrap_tool_call=wrap_tool_call))
builder.add_edge(START, "llm_node")
builder.add_conditional_edges("llm_node", router, path_map=["tool_node", END])
builder.add_edge("tool_node", "llm_node")
graph = builder.compile()
display(graph)
res = graph.invoke(
{"messages": [HumanMessage("今天北京天气如何?")]},
context = UserContext(max_attempts=3))
for msg in res['messages']:
msg.pretty_print()
运行结果如下

按照当前设计,每次工具调用有 70% 的概率失败,最多尝试 3 次。仍然可能最终失败,如下所示。
2026-06-22 11:12:21.039 | INFO | __main__:wrap_tool_call:71 - 工具调用失败,当前调用次数: 1, 调用次数上限: 3, 异常信息: 网络波动失败
2026-06-22 11:12:21.042 | INFO | __main__:wrap_tool_call:71 - 工具调用失败,当前调用次数: 1, 调用次数上限: 3, 异常信息: 网络波动失败
2026-06-22 11:12:21.046 | INFO | __main__:wrap_tool_call:71 - 工具调用失败,当前调用次数: 1, 调用次数上限: 3, 异常信息: 网络波动失败
================================ Human Message =================================
今天北京天气如何?
================================== Ai Message ==================================
好的,我来查询一下北京今天的天气情况。
Tool Calls:
get_weather (call_00_iYUeyQlDB0NuAAkcU1Zy3110)
Call ID: call_00_iYUeyQlDB0NuAAkcU1Zy3110
Args:
city: 北京
================================= Tool Message =================================
…(输出此处省略 12 行)
非常抱歉给您带来不便!如果还有其他问题需要帮助,请随时告诉我。
也有可能最终成功,如下
2026-06-22 14:03:22.371 | INFO | __main__:wrap_tool_call:70 - 工具调用失败,当前调用次数: 1, 调用次数上限: 3, 异常信息: 网络波动失败
================================ Human Message =================================
今天北京天气如何?
================================== Ai Message ==================================
好的,我来帮你查询一下今天北京的天气情况。
Tool Calls:
get_weather (call_00_IXWZ2mg3lxI09gzm9BbZ4314)
Call ID: call_00_IXWZ2mg3lxI09gzm9BbZ4314
Args:
city: 北京
================================= Tool Message =================================
Name: get_weather
…(输出此处省略 4 行)
如果你需要更详细的天气信息(比如具体温度、风向等),可以告诉我,我再帮你进一步查询。
10.2.3.2. wrap_tool_call实现缓存机制
示例如下
# ---- 本章导入 ----
import json
load_dotenv(override=True)
@tool(parse_docstring=True)
# (get_weather 与上文定义完全相同,略)
tools = [get_weather]
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
model_with_tools = model.bind_tools(tools=tools)
# (llm_node 与上文定义完全相同,略)
# (router 与上文定义完全相同,略)
global_cache = dict()
def wrap_tool_call(request, execute):
tool_name = request.tool_call["name"]
tool_args = json.dumps(request.tool_call["args"])
tool_call_id = request.runtime.tool_call_id
cache_key = (tool_name, tool_args)
cache = global_cache.get(cache_key)
if cache:
logger.info("{} 调用命中缓存", tool_name)
tool_msg = ToolMessage(
tool_call_id = tool_call_id,
content = cache
)
else:
tool_msg = execute(request)
logger.info("将 {} 的调用结果写入缓存", tool_name)
global_cache[cache_key] = tool_msg.content
return tool_msg
builder = StateGraph(state_schema=MessagesState)
builder.add_node("llm_node", llm_node)
builder.add_node("tool_node", ToolNode(tools=tools, wrap_tool_call=wrap_tool_call))
builder.add_edge(START, "llm_node")
builder.add_conditional_edges("llm_node", router, path_map=["tool_node", END])
builder.add_edge("tool_node", "llm_node")
graph = builder.compile()
display(graph)
print('=' * 30, '-> 第一次调用 - 北京 <-', '=' * 30)
res = graph.invoke({"messages": [HumanMessage("今天北京天气如何?")]})
for msg in res['messages']:
msg.pretty_print()
print('=' * 30, '-> 第二次调用 - 北京 <-', '=' * 30)
res = graph.invoke({"messages": [HumanMessage("今天北京天气如何?")]})
for msg in res['messages']:
msg.pretty_print()
print('=' * 30, '-> 第三次调用 - 杭州 <-', '=' * 30)
res = graph.invoke({"messages": [HumanMessage("今天杭州天气如何?")]})
for msg in res['messages']:
msg.pretty_print()
注意
这里使用的
global_cache只是进程内缓存,主要用于演示:它没有TTL、容量限制、并发保护和持久化能力,也只缓存普通ToolMessage的内容。生产环境应根据数据时效性选择Redis等缓存系统,并把用户、权限、地域、工具版本等会影响结果的因素纳入缓存键;具有副作用的工具通常不应直接缓存。
运行结果如下

============================== -> 第一次调用 - 北京 <- ==============================
2026-06-22 14:05:29.845 | INFO | __main__:wrap_tool_call:67 - 将 get_weather 的调用结果写入缓存
================================ Human Message =================================
今天北京天气如何?
================================== Ai Message ==================================
好的,我来查询一下北京今天的天气情况。
Tool Calls:
get_weather (call_00_RWpYgZGeskrexH2dOWol2713)
Call ID: call_00_RWpYgZGeskrexH2dOWol2713
Args:
city: 北京
================================= Tool Message =================================
Name: get_weather
…(输出此处省略 42 行)
杭州今天天气不错!☀️ 看起来是一个适合出行或者户外活动的好日子。如果你有出门的计划,可以放心安排哦~不过如果需要更具体的温度、风力等信息,建议可以再查一下实时的天气预报。😊
11. 流式执行
本章重点:
- 流式执行的七个模式,以及模式之间的区别、使用
- astream_events的作用和使用方式:展示执行任务过程中的所有事件(如:提示词、检索、LLM 生成、工具调用 等事件
11.1. 概述
11.1.1. 什么是流式执行
流式执行(Streaming Execution),是指程序在任务尚未全部完成时,就将执行过程中已经产生的中间结果、状态变化或事件持续输出给调用方,而不是等待整个任务结束后一次性返回最终结果。
11.1.2. 什么是LangGraph的流式执行
LangGraph 的流式执行是指,状态图计算过程中,将节点输出、状态更新、消息增量、自定义事件或调试信息等写入流式队列,并在特定的时机将流式队列中的信息返还给调用者。
在同步执行中,这些数据最终进入内部的同步流式队列;在异步执行中,则进入对应的异步队列。调用方通过迭代器逐条消费这些数据,因此不必等到整张图执行完毕后再获得反馈。
11.1.3. LangGraph的流式执行API
LangGraph 提供了两套流式执行 API
11.1.3.1. stream/astream
获取 LangGraph 执行过程中的业务数据或运行时数据
stream:同步APIastream:异步API
调用方通过 stream_mode 选择需要消费的内容,例如状态、节点更新、模型消息、自定义数据、检查点或任务事件。这是本章的重点。
stream与astream的区别
| 维度 | stream() |
astream() |
|---|---|---|
| 调用方式 | 同步迭代 for chunk in graph.stream(...) |
异步迭代 async for chunk in graph.astream(...) |
| 运行环境 | 普通 Python 脚本 | asyncio 事件循环(Jupyter、FastAPI 等) |
| 参数与输出 | 相同 stream_mode,语义一致 |
相同 stream_mode,语义一致 |
| 适用场景 | 同步批处理、CLI 工具 | async 服务端、结合其他异步 IO |
11.1.3.2. astream_events
获取图运行过程中产生的 Runnable 标准事件(组件生命周期、父子调用关系、输入输出等)
11.2. stream/astream
astream 是 stream 的异步版本。二者使用相同的流模式,且在参数一致时,输出数据的语义和封装格式一致;区别主要在于调用方分别使用同步迭代和异步迭代进行消费。
前面的章节我们都是通过 invoke 调用状态图。实际上,invoke 会在内部消费 stream,并把流式执行结果汇总为最终返回值;ainvoke 与 astream 的关系同理。
11.2.1. 输出格式版本说明
从 LangGraph 1.1 开始,stream/astream 支持两种输出格式版本:
v1:当前默认格式。输出形态会随参数变化:- 单个流模式通常直接返回该模式的数据;
- 多个流模式返回
(mode, data); - 启用子图流式输出后,还会增加命名空间信息。
v2:在v1基础上做了封装,统一返回StreamPart字典,固定包含type、ns和data三个字段,增强了输出的可读性,简化了解析难度、并补充了校验机制。
本节基于 v1 展开。示例刻意把 stream_mode 写成列表,即使每次只指定一种模式,也会得到 (mode, data) 二元组,便于观测模式名称,并且可以无缝扩展到指定多种模式的情况。
11.2.2. stream_mode
流式输出支持不同的输出模式 stream_mode,这规定了流式输出的内容(在 LangGraph 中称为 chunk)。stream_mode 可传入单个字符串或模式列表。
| stream_mode | 返回的 chunk 内容 | 特点 | 适用场景 |
|---|---|---|---|
| values | 每一个超步 (super-step)执行完成后的完整 State 全量快照 | 每次返回全部状态字段,数据量大;跑完一个超步才输出 | 需要查看每一步完整状态快照,调试状态整体变化 |
| updates | 节点执行产生的增量状态更新 | 只返回当前节点 return 的字段,不返回不变字段;轻量 | 只关心每个节点输出,不需要冗余字段 |
| messages | State 中 messages 字段的 LLM 增量 token |
不等节点执行完毕,大模型每吐出一个 token 就推送;仅针对消息对象 | 聊天对话,前端实现打字机流式效果 |
| checkpoints | 检查点事件 | 保存每一步状态,支持中断、回溯 | 人机交互、会话持久化、断点调试 |
| tasks | 任务生命周期事件(任务开始、结束、异常) | 不返回业务 state 数据;包含通道、异常信息 | 运行时监控、任务埋点追踪 |
| debug | checkpoints + tasks 合并信息 | 信息最全,附带超步编号、时间戳;数据量最大 | 复杂工作流深度排错,定位循环、跳转问题 |
| custom | 通过 stream_writer 在节点内主动推送的自定义数据 |
和 State 无关,自由自定义推送内容 | 自定义进度提示、阶段通知 |
为便于观察每种模式的数据结构,本节每次只启用一种模式,但仍使用列表形式传参(得到 (mode, data) 二元组)。
11.2.2.1. values
示例如下
# ---- 本章导入 ----
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class OverAllState(TypedDict):
initial_state: str
node_a_output: str
node_b_output: str
def node_a(state: OverAllState) -> OverAllState:
return {
"node_a_output": "节点A的输出"
}
def node_b(state: OverAllState) -> OverAllState:
return {
"node_b_output": "节点B的输出"
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", END)
graph = builder.compile()
for chunk in graph.stream(
{"initial_state": "初始状态"},
stream_mode=["values"],
):
print(chunk)
运行结果如下
('values', {'initial_state': '初始状态'})
('values', {'initial_state': '初始状态', 'node_a_output': '节点A的输出'})
('values', {'initial_state': '初始状态', 'node_a_output': '节点A的输出', 'node_b_output': '节点B的输出'})
11.2.2.2. updates
示例如下
class OverAllState(TypedDict):
initial_state: str
node_a_output: str
node_b_output: str
def node_a(state: OverAllState) -> OverAllState:
return {
"node_a_output": "节点A的输出"
}
def node_b(state: OverAllState) -> OverAllState:
return {
"node_b_output": "节点B的输出"
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", END)
graph = builder.compile()
for chunk in graph.stream(
{"initial_state": "初始状态"},
stream_mode=["updates"],
):
print(chunk)
运行结果如下
('updates', {'node_a': {'node_a_output': '节点A的输出'}})
('updates', {'node_b': {'node_b_output': '节点B的输出'}})
11.2.2.3. messages
示例如下
# ---- 本章导入 ----
from langgraph.graph import StateGraph, START, END, MessagesState
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek
from dotenv import load_dotenv
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
def llm_node(state: MessagesState) -> MessagesState:
messages = state["messages"]
response = model.invoke(messages)
return {
"messages": [response],
}
builder = StateGraph(state_schema=MessagesState)
builder.add_node("llm_node", llm_node)
builder.add_edge(START, "llm_node")
builder.add_edge("llm_node", END)
graph = builder.compile()
for chunk in graph.stream(
{
"messages":[HumanMessage(content="你好!")]
},
stream_mode=["values","messages"],
):
print(chunk)
运行结果如下
('values', {'messages': [HumanMessage(content='你好!', additional_kwargs={}, response_metadata={}, id='<uid>')]})
('messages', (AIMessageChunk(content='', additional_kwargs={}, response_metadata={'model_provider': 'deepseek'}, id='<uid>', tool_calls=[], invalid_tool_calls=[], tool_call_chunks=[]), {'langgraph_step': 1, 'langgraph_node': 'llm_node', 'langgraph_triggers': ('branch:to:llm_node',), 'langgraph_path': ('__pregel_pull', 'llm_node'), 'langgraph_checkpoint_ns': 'llm_node:<uid>', 'checkpoint_ns': 'llm_node:<uid>', 'ls_provider': 'deepseek', 'ls_model_name': 'deepseek-v4-flash', 'ls_model_type': 'chat', 'ls_temperature': None}))
('messages', (AIMessageChunk(content='你好', additional_kwargs={}, response_metadata={'model_provider': 'deepseek'}, id='<uid>', tool_calls=[], invalid_tool_calls=[], tool_call_chunks=[]), {'langgraph_step': 1, 'langgraph_node': 'llm_node', 'langgraph_triggers': ('branch:to:llm_node',), 'langgraph_path': ('__pregel_pull', 'llm_node'), 'langgraph_checkpoint_ns': 'llm_node:<uid>', 'checkpoint_ns': 'llm_node:<uid>', 'ls_provider': 'deepseek', 'ls_model_name': 'deepseek-v4-flash', 'ls_model_type': 'chat', 'ls_temperature': None}))
('messages', (AIMessageChunk(content='!', additional_kwargs={}, response_metadata={'model_provider': 'deepseek'}, id='<uid>', tool_calls=[], invalid_tool_calls=[], tool_call_chunks=[]), {'langgraph_step': 1, 'langgraph_node': 'llm_node', 'langgraph_triggers': ('branch:to:llm_node',), 'langgraph_path': ('__pregel_pull', 'llm_node'), 'langgraph_checkpoint_ns': 'llm_node:<uid>', 'checkpoint_ns': 'llm_node:<uid>', 'ls_provider': 'deepseek', 'ls_model_name': 'deepseek-v4-flash', 'ls_model_type': 'chat', 'ls_temperature': None}))
('messages', (AIMessageChunk(content='很高兴', additional_kwargs={}, response_metadata={'model_provider': 'deepseek'}, id='<uid>', tool_calls=[], invalid_tool_calls=[], tool_call_chunks=[]), {'langgraph_step': 1, 'langgraph_node': 'llm_node', 'langgraph_triggers': ('branch:to:llm_node',), 'langgraph_path': ('__pregel_pull', 'llm_node'), 'langgraph_checkpoint_ns': 'llm_node:<uid>', 'checkpoint_ns': 'llm_node:<uid>', 'ls_provider': 'deepseek', 'ls_model_name': 'deepseek-v4-flash', 'ls_model_type': 'chat', 'ls_temperature': None}))
('messages', (AIMessageChunk(content='见到', additional_kwargs={}, response_metadata={'model_provider': 'deepseek'}, id='<uid>', tool_calls=[], invalid_tool_calls=[], tool_call_chunks=[]), {'langgraph_step': 1, 'langgraph_node': 'llm_node', 'langgraph_triggers': ('branch:to:llm_node',), 'langgraph_path': ('__pregel_pull', 'llm_node'), 'langgraph_checkpoint_ns': 'llm_node:<uid>', 'checkpoint_ns': 'llm_node:<uid>', 'ls_provider': 'deepseek', 'ls_model_name': 'deepseek-v4-flash', 'ls_model_type': 'chat', 'ls_temperature': None}))
('messages', (AIMessageChunk(content='你', additional_kwargs={}, response_metadata={'model_provider': 'deepseek'}, id='<uid>', tool_calls=[], invalid_tool_calls=[], tool_call_chunks=[]), {'langgraph_step': 1, 'langgraph_node': 'llm_node', 'langgraph_triggers': ('branch:to:llm_node',), 'langgraph_path': ('__pregel_pull', 'llm_node'), 'langgraph_checkpoint_ns': 'llm_node:<uid>', 'checkpoint_ns': 'llm_node:<uid>', 'ls_provider': 'deepseek', 'ls_model_name': 'deepseek-v4-flash', 'ls_model_type': 'chat', 'ls_temperature': None}))
('messages', (AIMessageChunk(content=',', additional_kwargs={}, response_metadata={'model_provider': 'deepseek'}, id='<uid>', tool_calls=[], invalid_tool_calls=[], tool_call_chunks=[]), {'langgraph_step': 1, 'langgraph_node': 'llm_node', 'langgraph_triggers': ('branch:to:llm_node',), 'langgraph_path': ('__pregel_pull', 'llm_node'), 'langgraph_checkpoint_ns': 'llm_node:<uid>', 'checkpoint_ns': 'llm_node:<uid>', 'ls_provider': 'deepseek', 'ls_model_name': 'deepseek-v4-flash', 'ls_model_type': 'chat', 'ls_temperature': None}))
('messages', (AIMessageChunk(content='有什么', additional_kwargs={}, response_metadata={'model_provider': 'deepseek'}, id='<uid>', tool_calls=[], invalid_tool_calls=[], tool_call_chunks=[]), {'langgraph_step': 1, 'langgraph_node': 'llm_node', 'langgraph_triggers': ('branch:to:llm_node',), 'langgraph_path': ('__pregel_pull', 'llm_node'), 'langgraph_checkpoint_ns': 'llm_node:<uid>', 'checkpoint_ns': 'llm_node:<uid>', 'ls_provider': 'deepseek', 'ls_model_name': 'deepseek-v4-flash', 'ls_model_type': 'chat', 'ls_temperature': None}))
('messages', (AIMessageChunk(content='可以', additional_kwargs={}, response_metadata={'model_provider': 'deepseek'}, id='<uid>', tool_calls=[], invalid_tool_calls=[], tool_call_chunks=[]), {'langgraph_step': 1, 'langgraph_node': 'llm_node', 'langgraph_triggers': ('branch:to:llm_node',), 'langgraph_path': ('__pregel_pull', 'llm_node'), 'langgraph_checkpoint_ns': 'llm_node:<uid>', 'checkpoint_ns': 'llm_node:<uid>', 'ls_provider': 'deepseek', 'ls_model_name': 'deepseek-v4-flash', 'ls_model_type': 'chat', 'ls_temperature': None}))
('messages', (AIMessageChunk(content='帮', additional_kwargs={}, response_metadata={'model_provider': 'deepseek'}, id='<uid>', tool_calls=[], invalid_tool_calls=[], tool_call_chunks=[]), {'langgraph_step': 1, 'langgraph_node': 'llm_node', 'langgraph_triggers': ('branch:to:llm_node',), 'langgraph_path': ('__pregel_pull', 'llm_node'), 'langgraph_checkpoint_ns': 'llm_node:<uid>', 'checkpoint_ns': 'llm_node:<uid>', 'ls_provider': 'deepseek', 'ls_model_name': 'deepseek-v4-flash', 'ls_model_type': 'chat', 'ls_temperature': None}))
('messages', (AIMessageChunk(content='你的', additional_kwargs={}, response_metadata={'model_provider': 'deepseek'}, id='<uid>', tool_calls=[], invalid_tool_calls=[], tool_call_chunks=[]), {'langgraph_step': 1, 'langgraph_node': 'llm_node', 'langgraph_triggers': ('branch:to:llm_node',), 'langgraph_path': ('__pregel_pull', 'llm_node'), 'langgraph_checkpoint_ns': 'llm_node:<uid>', 'checkpoint_ns': 'llm_node:<uid>', 'ls_provider': 'deepseek', 'ls_model_name': 'deepseek-v4-flash', 'ls_model_type': 'chat', 'ls_temperature': None}))
('messages', (AIMessageChunk(content='吗', additional_kwargs={}, response_metadata={'model_provider': 'deepseek'}, id='<uid>', tool_calls=[], invalid_tool_calls=[], tool_call_chunks=[]), {'langgraph_step': 1, 'langgraph_node': 'llm_node', 'langgraph_triggers': ('branch:to:llm_node',), 'langgraph_path': ('__pregel_pull', 'llm_node'), 'langgraph_checkpoint_ns': 'llm_node:<uid>', 'checkpoint_ns': 'llm_node:<uid>', 'ls_provider': 'deepseek', 'ls_model_name': 'deepseek-v4-flash', 'ls_model_type': 'chat', 'ls_temperature': None}))
('messages', (AIMessageChunk(content='?', additional_kwargs={}, response_metadata={'model_provider': 'deepseek'}, id='<uid>', tool_calls=[], invalid_tool_calls=[], tool_call_chunks=[]), {'langgraph_step': 1, 'langgraph_node': 'llm_node', 'langgraph_triggers': ('branch:to:llm_node',), 'langgraph_path': ('__pregel_pull', 'llm_node'), 'langgraph_checkpoint_ns': 'llm_node:<uid>', 'checkpoint_ns': 'llm_node:<uid>', 'ls_provider': 'deepseek', 'ls_model_name': 'deepseek-v4-flash', 'ls_model_type': 'chat', 'ls_temperature': None}))
('messages', (AIMessageChunk(content='无论是', additional_kwargs={}, response_metadata={'model_provider': 'deepseek'}, id='<uid>', tool_calls=[], invalid_tool_calls=[], tool_call_chunks=[]), {'langgraph_step': 1, 'langgraph_node': 'llm_node', 'langgraph_triggers': ('branch:to:llm_node',), 'langgraph_path': ('__pregel_pull', 'llm_node'), 'langgraph_checkpoint_ns': 'llm_node:<uid>', 'checkpoint_ns': 'llm_node:<uid>', 'ls_provider': 'deepseek', 'ls_model_name': 'deepseek-v4-flash', 'ls_model_type': 'chat', 'ls_temperature': None}))
…(输出此处省略 15 行)
('messages', (AIMessageChunk(content='', additional_kwargs={}, response_metadata={}, id='<uid>', tool_calls=[], invalid_tool_calls=[], tool_call_chunks=[], chunk_position='last'), {'langgraph_step': 1, 'langgraph_node': 'llm_node', 'langgraph_triggers': ('branch:to:llm_node',), 'langgraph_path': ('__pregel_pull', 'llm_node'), 'langgraph_checkpoint_ns': 'llm_node:<uid>', 'checkpoint_ns': 'llm_node:<uid>', 'ls_provider': 'deepseek', 'ls_model_name': 'deepseek-v4-flash', 'ls_model_type': 'chat', 'ls_temperature': None}))
('values', {'messages': [HumanMessage(content='你好!', additional_kwargs={}, response_metadata={}, id='<uid>'), AIMessage(content='你好!很高兴见到你,有什么可以帮你的吗?无论是聊天、解答问题,还是需要帮助,都可以告诉我哦!😊', additional_kwargs={}, response_metadata={'finish_reason': 'stop', 'model_name': 'deepseek-v4-flash', 'system_fingerprint': 'fp_8b330d02d0_prod0820_fp8_kvcache_20260402', 'model_provider': 'deepseek'}, id='<uid>', tool_calls=[], invalid_tool_calls=[], usage_metadata={'input_tokens': 6, 'output_tokens': 28, 'total_tokens': 34, 'input_token_details': {'cache_read': 0}, 'output_token_details': {}})]})
11.2.2.4. checkpoints
checkpoints 模式依赖检查点存储器。它输出的是运行时构造的检查点事件,其结构接近 get_state() 返回的状态快照.
示例如下
# ---- 本章导入 ----
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command
class OverAllState(TypedDict):
initial_state: str
parallel_node_a_1: str
parallel_node_a_2: str
node_b_output: str
def parallel_node_a_1(state: OverAllState) -> OverAllState:
return {
"parallel_node_a_1": "并行节点A-1的输出"
}
def parallel_node_a_2(state: OverAllState) -> OverAllState:
return {
"parallel_node_a_2": "并行节点A-2的输出"
}
def node_b(state: OverAllState) -> OverAllState:
interrupt("hello")
return {
"node_b_output": "节点B的输出"
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("parallel_node_a_1", parallel_node_a_1)
builder.add_node("parallel_node_a_2", parallel_node_a_2)
builder.add_node("node_b", node_b)
builder.add_edge(START, "parallel_node_a_1")
builder.add_edge(START, "parallel_node_a_2")
builder.add_edge(["parallel_node_a_1", "parallel_node_a_2"], "node_b")
builder.add_edge("node_b", END)
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "123"}}
for chunk in graph.stream(
{"initial_state": "初始状态"},
stream_mode=["checkpoints"],
config=config
):
print(chunk)
print('=' * 30, '-> 中断前后分界线 <-', '=' * 30)
for chunk in graph.stream(
Command(resume=""),
stream_mode=["checkpoints"],
config=config
):
print(chunk)
运行结果如下
# === 第 1 个检查点:input 阶段(step=-1),仅有 __start__ 任务 ===
(
'checkpoints',
{
'config': {
'configurable': {
'checkpoint_ns': '',
'thread_id': '123',
'checkpoint_id': '1f17e909-a431-6009-bfff-dd033caf8a09'
}
},
'parent_config': None,
'values': {},
'metadata': {
'source': 'input',
'step': -1,
'parents': {}
},
'next': ['__start__'],
'tasks': [
{
'id': '6f4add75-8430-fd5b-46d5-93f40b5f142a',
'name': '__start__',
'interrupts': (),
'state': None
}
]
}
)
# === 第 2 个检查点:loop 阶段(step=0),两个并行节点待执行 ===
(
'checkpoints',
{
'config': {
'configurable': {
'checkpoint_ns': '',
'thread_id': '123',
'checkpoint_id': '1f17e909-a436-66f0-8000-61f51460d18e'
}
},
'parent_config': {
'configurable': {
'checkpoint_ns': '',
'thread_id': '123',
'checkpoint_id': '1f17e909-a431-6009-bfff-dd033caf8a09'
}
},
'values': {
'initial_state': '初始状态'
},
'metadata': {
'source': 'loop',
'step': 0,
'parents': {}
},
'next': ['parallel_node_a_1', 'parallel_node_a_2'],
'tasks': [
{
'id': '0aa4a4d7-1e7f-f02f-25ef-46c088c7e1e0',
'name': 'parallel_node_a_1',
'interrupts': (),
'state': None
},
{
'id': 'd3670671-0bdb-6574-be1e-915be93b239e',
'name': 'parallel_node_a_2',
'interrupts': (),
'state': None
}
]
}
)
# === 第 3 个检查点:loop 阶段(step=1),并行节点执行完毕,node_b 待执行 ===
(
'checkpoints',
{
'config': {
'configurable': {
'checkpoint_ns': '',
'thread_id': '123',
'checkpoint_id': '1f17e909-a439-67c6-8001-f441fbddc2f9'
}
},
'parent_config': {
'configurable': {
'checkpoint_ns': '',
'thread_id': '123',
'checkpoint_id': '1f17e909-a436-66f0-8000-61f51460d18e'
}
},
'values': {
'initial_state': '初始状态',
'parallel_node_a_1': '并行节点A-1的输出',
'parallel_node_a_2': '并行节点A-2的输出'
},
'metadata': {
'source': 'loop',
'step': 1,
'parents': {}
},
'next': ['node_b'],
'tasks': [
{
'id': 'cc97ac77-de6f-470b-d3c5-d0a94ea5534e',
'name': 'node_b',
'interrupts': (),
'state': None
}
]
}
)
============================== -> 中断前后分界线 <- ==============================
# === 第 4 个检查点:中断发生时(step=1),node_b 的 interrupts 字段包含中断信息 ===
(
'checkpoints',
{
'config': {
'configurable': {
'checkpoint_ns': '',
'thread_id': '123',
'checkpoint_id': '1f17e909-a439-67c6-8001-f441fbddc2f9'
}
},
'parent_config': {
'configurable': {
'thread_id': '123',
'checkpoint_ns': '',
'checkpoint_id': '1f17e909-a436-66f0-8000-61f51460d18e'
}
},
'values': {
'initial_state': '初始状态',
'parallel_node_a_1': '并行节点A-1的输出',
'parallel_node_a_2': '并行节点A-2的输出'
},
'metadata': {
'source': 'loop',
'step': 1,
'parents': {}
},
'next': ['node_b'],
'tasks': [
{
'id': 'cc97ac77-de6f-470b-d3c5-d0a94ea5534e',
'name': 'node_b',
'interrupts': (
{
'value': 'hello',
'id': '486f817f7367de26620067969c8780eb'
},
),
'state': None
}
]
}
)
# === 第 5 个检查点:resume 后(step=2),node_b 执行完毕,next 为空 ===
(
'checkpoints',
{
'config': {
'configurable': {
'checkpoint_ns': '',
'thread_id': '123',
'checkpoint_id': '1f17e926-5137-6203-8002-4e1f7ffb6aa1'
}
},
'parent_config': {
'configurable': {
'checkpoint_ns': '',
'thread_id': '123',
'checkpoint_id': '1f17e909-a439-67c6-8001-f441fbddc2f9'
}
},
'values': {
'initial_state': '初始状态',
'parallel_node_a_1': '并行节点A-1的输出',
'parallel_node_a_2': '并行节点A-2的输出',
'node_b_output': '节点B的输出'
},
'metadata': {
'source': 'loop',
'step': 2,
'parents': {}
},
'next': [],
'tasks': []
}
)
查看历史检查点列表
list(graph.get_state_history(config=config))
运行结果如下
[
StateSnapshot(
values={
'initial_state': '初始状态',
'parallel_node_a_1': '并行节点A-1的输出',
'parallel_node_a_2': '并行节点A-2的输出',
'node_b_output': '节点B的输出'
},
next=(),
config={
'configurable': {
}
},
metadata={
'source': 'loop',
…(输出此处省略 152 行)
)
]
| 对比维度 | get_state_history() |
checkpoints 流 |
|---|---|---|
| 排序方式 | 时间倒序(最新在前) | 超步编号升序(正向执行顺序) |
顶层 interrupts |
有 | 无(中断信息附着在 tasks[].interrupts 中) |
中断时序说明:检查点 chunk 在超步第一阶段输出,中断在第二阶段触发。因此中断发生时最新检查点仍为上一步(step=1),任务中断信息尚为空;恢复执行后第一阶段重新输出 step=1 检查点时,中断信息已被记录。
11.2.2.5. tasks
tasks 模式以任务为单位输出开始事件和结果事件。它比 updates 更偏向运行时观测:除了任务结果,还会暴露任务输入、触发通道、异常和中断信息。
示例如下
# (OverAllState 与上文定义完全相同,略)
# (parallel_node_a_1 与上文定义完全相同,略)
# (parallel_node_a_2 与上文定义完全相同,略)
def node_b(state: OverAllState) -> OverAllState:
return {
"node_b_output": "节点B的输出"
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("parallel_node_a_1", parallel_node_a_1)
builder.add_node("parallel_node_a_2", parallel_node_a_2)
builder.add_node("node_b", node_b)
builder.add_edge(START, "parallel_node_a_1")
builder.add_edge(START, "parallel_node_a_2")
builder.add_edge(["parallel_node_a_1", "parallel_node_a_2"], "node_b")
builder.add_edge("node_b", END)
graph = builder.compile()
for chunk in graph.stream(
{"initial_state": "初始状态"},
stream_mode=["tasks"]
):
print(chunk)
运行结果如下
(
'tasks',
{
'id': '<uid>',
'name': 'parallel_node_a_1',
'input': {
'initial_state': '初始状态'
},
'triggers': (
'branch:to:parallel_node_a_1',
)
}
)
(
…(输出此处省略 66 行)
}
)
任务 chunk 分为两类:
| 事件类型 | 产生时机 | 关键字段 |
|---|---|---|
| 任务开始事件 | 任务准备完毕,尚未执行 | input(任务输入)、triggers(触发通道) |
| 任务结果事件 | 任务执行完成/失败/中断后 | error(异常,None=正常)、result、interrupts |
并行任务时:所有开始事件(第一阶段)全部输出后,才出现结果事件(第二阶段)。
11.2.2.6. debug
示例如下
# (OverAllState 与上文定义完全相同,略)
# (parallel_node_a_1 与上文定义完全相同,略)
# (parallel_node_a_2 与上文定义完全相同,略)
# (node_b 与上文定义完全相同,略)
builder = StateGraph(state_schema=OverAllState)
builder.add_node("parallel_node_a_1", parallel_node_a_1)
builder.add_node("parallel_node_a_2", parallel_node_a_2)
builder.add_node("node_b", node_b)
builder.add_edge(START, "parallel_node_a_1")
builder.add_edge(START, "parallel_node_a_2")
builder.add_edge(["parallel_node_a_1", "parallel_node_a_2"], "node_b")
builder.add_edge("node_b", END)
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "123"}}
for chunk in graph.stream(
{"initial_state": "初始状态"},
stream_mode=["debug"],
config=config
):
print(chunk)
print('=' * 30, '-> 中断前后分界线 <-', '=' * 30)
for chunk in graph.stream(
Command(resume=""),
stream_mode=["debug"],
config=config
):
print(chunk)
运行结果如下
(
'debug',
{
'step': -1,
'timestamp': '2026-06-23T06:02:59.369405+00:00',
'type': 'checkpoint',
'payload': {
'config': {
'configurable': {
}
},
'parent_config': None,
'values': {},
'metadata': {
'source': 'input',
…(输出此处省略 336 行)
}
)
查看历史检查点
list(graph.get_state_history(config=config))
结果如下
[
StateSnapshot(
values={
'initial_state': '初始状态',
'parallel_node_a_1': '并行节点A-1的输出',
'parallel_node_a_2': '并行节点A-2的输出',
'node_b_output': '节点B的输出'
},
next=(),
config={
'configurable': {
}
},
metadata={
'source': 'loop',
…(输出此处省略 152 行)
)
]
debug 模式 = checkpoints + tasks 的统一封装,外层增加调试字段:
| 字段 | 含义 |
|---|---|
step |
超步编号 |
timestamp |
chunk 生成时间 |
type |
checkpoint / task / task_result |
payload |
对应模式下 chunk 的原始内容 |
chunk 输出顺序:每个超步第一阶段按 准备任务 → 检查点日志 → 任务日志 的顺序生成,因此任务 chunk 位于前后两个超步的检查点 chunk 之间。中断恢复时对应超步会重新执行,任务 chunk 会再次生成。
具体来说,step=2 的超步发生了中断,恢复运行时,step=2 会重新执行,其第一阶段会再次准备任务,并生成这一阶段的所有任务 chunk。
11.2.2.7. custom
astream打印的内容,完全由每个节点自己写入(类似代码中写 info.log(“xxx”)
custom 模式允许节点或工具主动写出自定义数据,调用方通过 stream/astream 消费这些数据。
它适合输出进度、阶段说明、外部 API 的增量结果等非状态数据。
11.2.2.7.1. 节点中写出内容
节点中写出内容通过 Runtime 实例的 stream_writer(也可通过 get_stream_writer() 获取,二者等效)。当 stream_mode 包含 custom 时,底层会定义 stream_writer 并注入 Runtime,相关源码:
定义 stream_writer
if "custom" in stream_modes:
def stream_writer(c: Any) -> None:
stream.put(
(
tuple(
get_config()[CONF][CONFIG_KEY_CHECKPOINT_NS].split(
NS_SEP
)[:-1]
),
"custom",
c,
)
)
创建 Runtime 实例
runtime = Runtime(
context=_coerce_context(self.context_schema, context),
store=store,
stream_writer=stream_writer,
previous=None,
)
示例如下
# ---- 本章导入 ----
from langgraph.runtime import Runtime
class OverAllState(TypedDict):
initial_state: str
node_a_output: str
node_b_output: str
def node_a(state: OverAllState, runtime: Runtime) -> OverAllState:
stream_writer = runtime.stream_writer
stream_writer("节点 A 正在执行...")
return {
"node_a_output": "节点A的输出"
}
def node_b(state: OverAllState, runtime: Runtime) -> OverAllState:
stream_writer = runtime.stream_writer
stream_writer("节点 B 正在执行...")
return {
"node_b_output": "节点B的输出"
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", END)
graph = builder.compile()
for chunk in graph.stream(
{"initial_state": "初始状态"},
stream_mode=["custom"],
):
print(chunk)
运行结果如下
('custom', '节点 A 正在执行...')
('custom', '节点 B 正在执行...')
11.2.2.7.2. 工具中写出内容
工具中写出内容通过 ToolRuntime 实例的 stream_writer,用法与节点中一致。
示例如下
# ---- 本章导入 ----
from typing import Literal
from langgraph.prebuilt.tool_node import ToolNode, ToolRuntime
from langchain.tools import tool
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
@tool(parse_docstring=True)
def get_weather(city: str, runtime: ToolRuntime) -> str:
"""
根据城市查询当日天气
Args:
city: 城市名称
"""
stream_writer = runtime.stream_writer
stream_writer(f"正在查询 {city} 今天的天气...")
return f"{city} 今天天气不错"
tools = [get_weather]
model_with_tools = model.bind_tools(tools=tools)
def llm_node(state: MessagesState, runtime: Runtime) -> MessagesState:
messages = state["messages"]
response = model_with_tools.invoke(messages)
stream_writer = runtime.stream_writer
stream_writer("正在执行 llm_node...")
return {
"messages": [response]
}
def router(state: MessagesState) -> Literal["tool_node", END]:
last_msg = state["messages"][-1]
if last_msg.tool_calls:
return "tool_node"
return END
builder = StateGraph(state_schema=MessagesState)
builder.add_node("llm_node", llm_node)
builder.add_node("tool_node", ToolNode(tools=tools))
builder.add_edge(START, "llm_node")
builder.add_conditional_edges("llm_node", router, path_map=["tool_node", END])
builder.add_edge("tool_node", "llm_node")
graph = builder.compile()
for chunk in graph.stream(
{"messages": [HumanMessage("今天北京天气如何?")]},
stream_mode=["custom"]
):
print(chunk)
运行结果如下
('custom', '正在执行 llm_node...')
('custom', '正在查询 北京 今天的天气...')
('custom', '正在执行 llm_node...')
11.2.3. 底层机制
以 LangGraph 1.1.2 的 stream() 为例,流式处理分为三个阶段:
| 阶段 | 说明 |
|---|---|
| 创建队列 | stream() 调用 SyncQueue() 创建 FIFO 队列,接收运行时写入的 chunk |
| 生产 | 根据 stream_mode,在运行时的不同阶段将原始数据写入队列 |
| 消费 | 在两处调用 _output() 函数,按 FIFO 从队列取出数据 yield 给调用者:① runner.tick() 交还控制权时;② 超步循环结束后清空收尾 |
对 messages 和 custom 等需及时输出的模式,框架启用等待器,队列有新数据时立即交还控制权。
11.3. astream_events
astream_events:吐出全链路结构化事件,链上每一步(提示词、检索、LLM 生成、工具调用)的
start/stream/end事件都会下发。用处:适合 Agent、astream_eventsRAG、调试、展示中间过程(比如 “正在联网搜索”)
astream_events 用于异步获取图运行过程中产生的 Runnable 标准事件。它关注的是可执行组件的生命周期、父子调用关系、输入输出和元数据,而不是单纯返回图状态。
Runnable接口
Runnable 是 LangChain 与 LangGraph 生态的统一可执行对象协议(langchain_core.runnables),统一了模型、工具、Agent、节点和编译图等组件的调用方式。
| 方法 | 说明 |
|---|---|
invoke() / ainvoke() |
同步/异步调用 |
stream() / astream() |
同步/异步流式执行 |
astream_events() |
异步获取标准事件 |
batch() |
批量执行 |
常见 Runnable 组件:CompiledStateGraph、PromptTemplate、ChatModel、Tool、Retriever、RunnableSequence。
Runnable事件
Runnable 执行过程会被框架转换为标准化事件,遵循 on_<type>_<phase> 命名规则:
| 阶段 | 事件后缀 | 含义 |
|---|---|---|
| 开始 | _start |
组件开始执行 |
| 中间 | _stream |
产生中间结果 |
| 结束 | _end |
组件执行结束 |
类型(<type>)可为 chain、chat_model、llm、tool、retriever、prompt 等。并非所有类型都有三种事件——例如工具通常只有 on_tool_start 和 on_tool_end。
实际运行拿到的 Runnable 事件 示例如下
{
'event': 'on_chain_start',
'data': {
'input': {
'initial_state': '初始状态'
}
},
'name': 'LangGraph',
'tags': [],
'metadata': {},
'parent_ids': []
}
11.3.1. astream_events的版本
从 LangGraph 1.2.0 开始,astream_events 支持三个版本:
| 版本 | 基础 | 特点 | 入口 |
|---|---|---|---|
v1 |
Runnable 标准事件 |
parent_ids 始终为空列表 |
astream_events(version="v1") |
v2(默认) |
Runnable 标准事件 |
parent_ids 可表达完整父级运行链,改进父子调用关系 |
astream_events(version="v2") |
v3 |
Pregel 原始流(= stream/astream 底层) |
全新协议,通过类型化投影消费(见下表),独立于 v1/v2 | astream_events(version="v3") / stream_events(version="v3") |
v3 从底层 stream/astream 获取 Pregel 原始流数据,经事件路由器与 StreamTransformer 生成类型化投影。投影之间可独立/并发消费:
| 投影 | 用法 |
|---|---|
stream |
遍历每个协议事件 |
stream.messages |
流式传输聊天模型的消息。 |
stream.values |
流式传输状态快照 |
stream.output |
等待计算图的最终输出 |
stream.subgraphs |
观测子图的运行 |
stream.interrupts |
观测 HITL 的中断信息 |
stream.interrupted |
检查运行是否因人工输入而中断 |
stream.extensions |
消费自定义流的投影 |
不同投影之间可以独立或并发消费,读取 stream.messages 不会消耗 stream.values 、 stream.subgraphs 或 stream.output 所需的事件。
Pregel 流即 stream/astream 的底层支持,上文 7 种 stream_mode 分别对应其中的 7 类流式数据。v3 并非 v1/v2 的封装——二者数据协议和内部管线独立,但共享同一组公开 API 入口和底层图运行时。

当前版本(LangGraph 1.1.2)仅支持 astream_events 的 v1/v2,默认版本为 v2。
11.3.2. astream_events的用法
事件流不是我们研究的重点,此处仅提供最简示例。
示例如下
底层也会调用 astream 来真正触发计算图的运行。
class OverAllState(TypedDict):
initial_state: str
node_a_output: str
node_b_output: str
def node_a(state: OverAllState) -> OverAllState:
return {
"node_a_output": "节点A的输出"
}
def node_b(state: OverAllState) -> OverAllState:
return {
"node_b_output": "节点B的输出"
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("node_a", node_a)
builder.add_node("node_b", node_b)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", END)
graph = builder.compile()
async for chunk in graph.astream_events(
{"initial_state": "初始状态"},
version="v2"
):
print(chunk)
运行结果如下
{
'event': 'on_chain_start',
'data': {
'input': {
'initial_state': '初始状态'
}
},
'name': 'LangGraph',
'tags': [],
'run_id': '019ef40f-2522-7212-9ad6-cb83c499652d',
'metadata': {},
'parent_ids': []
}
{
'event': 'on_chain_start',
'data': {
'input': {
'initial_state': '初始状态'
}
},
'name': 'node_a',
'tags': [
'graph:step:1'
],
'run_id': '019ef40f-2524-7f83-9501-9248f93fd0a4',
'metadata': {
'langgraph_step': 1,
'langgraph_node': 'node_a',
'langgraph_triggers': (
'branch:to:node_a',
),
'langgraph_path': (
'__pregel_pull',
'node_a'
),
'langgraph_checkpoint_ns': 'node_a:9a47e3e1-ccab-85cc-f837-f3c986bafd1f'
},
'parent_ids': [
'019ef40f-2522-7212-9ad6-cb83c499652d'
]
}
{
'event': 'on_chain_stream',
'run_id': '019ef40f-2524-7f83-9501-9248f93fd0a4',
'name': 'node_a',
'tags': [
'graph:step:1'
],
'metadata': {
'langgraph_step': 1,
'langgraph_node': 'node_a',
'langgraph_triggers': (
'branch:to:node_a',
),
'langgraph_path': (
'__pregel_pull',
'node_a'
),
'langgraph_checkpoint_ns': 'node_a:9a47e3e1-ccab-85cc-f837-f3c986bafd1f'
},
'data': {
'chunk': {
'node_a_output': '节点A的输出'
}
},
'parent_ids': [
'019ef40f-2522-7212-9ad6-cb83c499652d'
]
}
{
'event': 'on_chain_end',
'data': {
'output': {
'node_a_output': '节点A的输出'
},
'input': {
'initial_state': '初始状态'
}
},
'run_id': '019ef40f-2524-7f83-9501-9248f93fd0a4',
'name': 'node_a',
'tags': [
'graph:step:1'
],
'metadata': {
'langgraph_step': 1,
'langgraph_node': 'node_a',
'langgraph_triggers': (
'branch:to:node_a',
),
'langgraph_path': (
'__pregel_pull',
'node_a'
),
'langgraph_checkpoint_ns': 'node_a:9a47e3e1-ccab-85cc-f837-f3c986bafd1f'
},
'parent_ids': [
'019ef40f-2522-7212-9ad6-cb83c499652d'
]
}
{
'event': 'on_chain_stream',
'run_id': '019ef40f-2522-7212-9ad6-cb83c499652d',
'name': 'LangGraph',
'tags': [],
'metadata': {},
'data': {
'chunk': {
'node_a': {
'node_a_output': '节点A的输出'
}
}
},
'parent_ids': []
}
{
'event': 'on_chain_start',
'data': {
'input': {
'initial_state': '初始状态',
'node_a_output': '节点A的输出'
}
},
'name': 'node_b',
'tags': [
'graph:step:2'
],
'run_id': '019ef40f-2526-70a3-8ad1-04ce18598b65',
'metadata': {
'langgraph_step': 2,
'langgraph_node': 'node_b',
'langgraph_triggers': (
'branch:to:node_b',
),
'langgraph_path': (
'__pregel_pull',
'node_b'
),
'langgraph_checkpoint_ns': 'node_b:1fc56ad3-e5a9-e2a2-4c3e-b8cffec34d9c'
},
'parent_ids': [
'019ef40f-2522-7212-9ad6-cb83c499652d'
]
}
{
'event': 'on_chain_stream',
'run_id': '019ef40f-2526-70a3-8ad1-04ce18598b65',
'name': 'node_b',
'tags': [
'graph:step:2'
],
'metadata': {
'langgraph_step': 2,
'langgraph_node': 'node_b',
'langgraph_triggers': (
'branch:to:node_b',
),
'langgraph_path': (
'__pregel_pull',
'node_b'
),
'langgraph_checkpoint_ns': 'node_b:1fc56ad3-e5a9-e2a2-4c3e-b8cffec34d9c'
},
'data': {
'chunk': {
'node_b_output': '节点B的输出'
}
},
'parent_ids': [
'019ef40f-2522-7212-9ad6-cb83c499652d'
]
}
{
'event': 'on_chain_end',
'data': {
'output': {
'node_b_output': '节点B的输出'
},
'input': {
'initial_state': '初始状态',
'node_a_output': '节点A的输出'
}
},
'run_id': '019ef40f-2526-70a3-8ad1-04ce18598b65',
'name': 'node_b',
'tags': [
'graph:step:2'
],
'metadata': {
'langgraph_step': 2,
'langgraph_node': 'node_b',
'langgraph_triggers': (
'branch:to:node_b',
),
'langgraph_path': (
'__pregel_pull',
'node_b'
),
'langgraph_checkpoint_ns': 'node_b:1fc56ad3-e5a9-e2a2-4c3e-b8cffec34d9c'
},
'parent_ids': [
'019ef40f-2522-7212-9ad6-cb83c499652d'
]
}
{
'event': 'on_chain_stream',
'run_id': '019ef40f-2522-7212-9ad6-cb83c499652d',
'name': 'LangGraph',
'tags': [],
'metadata': {},
'data': {
'chunk': {
'node_b': {
'node_b_output': '节点B的输出'
}
}
},
'parent_ids': []
}
{
'event': 'on_chain_end',
'data': {
'output': {
'initial_state': '初始状态',
'node_a_output': '节点A的输出',
'node_b_output': '节点B的输出'
}
},
'run_id': '019ef40f-2522-7212-9ad6-cb83c499652d',
'name': 'LangGraph',
'tags': [],
'metadata': {},
'parent_ids': []
}
事件分类:纵向按生命周期分为 on_xxx_start(开始)、on_xxx_stream(中间结果)、on_xxx_end(结束);横向按 Runnable 类型分为以下事件:
| 组件类型 | 事件前缀 | name 示例 |
|---|---|---|
| 模型 | on_chat_model_* / on_llm_* |
'[model name]' |
| 工具 | on_tool_*(仅 start/end) |
'some_tool' |
| 检索器 | on_retriever_* |
'[retriever name]' |
| 提示词模板 | on_prompt_* |
'[template_name]' |
| 通用(含编译图、普通节点) | on_chain_* |
'format_docs' 等 |
关键字段:
| 字段 | 含义 |
|---|---|
event |
事件类型 |
run_id |
当前 Runnable 运行实例的唯一 ID |
name |
运行实例名称 |
metadata |
运行相关元数据 |
data |
事件输入/输出/中间结果 |
parent_ids |
从根运行实例到父运行实例的 ID 链(最外层 LangGraph 为空,子节点包含父 run_id) |
12. 子图
本章重点:
- 父图嵌入子图的两种方式:方法嵌入(节点方法中去调用子图),节点嵌入(把子图注册成节点)
- 子图的持久化三种策略 区别和使用:
Per-invocation(默认)、Per-thread、Stateless- 学会实现父图+子图的流式输出
- 子图动态路由:子图直接指定返回到父图某个节点(不推荐这样使用
状态图节点中调用另外的状态图或直接将另外的状态图作为其节点则后者为子图。
注意:
为节点命名时特别注意避开 Mermaid 关键字,否则拓扑结构渲染可能失败,常见的 Mermaid 关键字如下
subgraph
end
graph
flowchart
classDef
12.1. 两种子图嵌入方式
| 方式 | 用法 | 适用场景 | 通信方式 |
|---|---|---|---|
| 节点函数中调用子图 | 在节点函数内 subgraph.invoke() |
父子图状态完全隔离 | 手动做输入输出映射 |
| 子图直接作为父图节点 | add_node("name", compiled_subgraph) |
父子图共享状态字段 | 通过共享字段自动通信 |
12.1.1. 在节点函数中调用子图
作为节点函数调用:
- State区别:子图的OverAllState和父图的OverAllState没有直接关系,节点函数中转化成子图需要的参数即可。
- 中断区别:节点函数下,子图中断,父图是不知道中断的具体位置,所以父图是把调用子图的函数重新执行一遍。因为子图父图的config不同,同理父图也无法知道子图的执行过程,除非调用时指定config。
如果父图和子图状态完全隔离,可以用这种方式。
12.1.1.1. 用法
示例如下
# ---- 本章导入 ----
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
# 构建子图
class SubgraphState(TypedDict):
raw_text: str # 未清洗文本
stripped_text: str # 去除收尾空格的文本
punctuated_text: str # 句尾添加句号的文本
def subgraph_strip_node(state: SubgraphState) -> SubgraphState:
raw_text = state["raw_text"]
stripped_text = raw_text.strip()
return {
"stripped_text": stripped_text
}
def subgraph_punctuate_node(state: SubgraphState) -> SubgraphState:
stripped_text = state["stripped_text"]
punctuated_text = stripped_text + "。"
return {
"punctuated_text": punctuated_text
}
builder = StateGraph(state_schema=SubgraphState)
builder.add_node("subgraph_strip_node", subgraph_strip_node)
builder.add_node("subgraph_punctuate_node", subgraph_punctuate_node)
builder.add_edge(START, "subgraph_strip_node")
builder.add_edge("subgraph_strip_node", "subgraph_punctuate_node")
builder.add_edge("subgraph_punctuate_node", END)
subgraph = builder.compile()
# 构建父图
class ParentState(TypedDict):
input_text: str # 输入的未清洗的文本
cleaned_text: str # 清洗后的文本
def call_subgraph(state: ParentState) -> ParentState:
input_text = state["input_text"]
res = subgraph.invoke({"raw_text": input_text})
cleaned_text = res["punctuated_text"]
return {
"cleaned_text": cleaned_text
}
builder = StateGraph(state_schema=ParentState)
builder.add_node("call_subgraph", call_subgraph)
builder.add_edge(START, "call_subgraph")
builder.add_edge("call_subgraph", END)
parent_graph = builder.compile()
input_text = " LangGraph 真有意思 "
res = parent_graph.invoke({"input_text": input_text})
cleaned_text = res["cleaned_text"]
print("=" * 30, "-> 原始文本 <-", "=" * 30)
print(input_text)
print("=" * 30, "-> 清洗后的文本 <-", "=" * 30)
print(cleaned_text)
from IPython.display import display, Image
display(
Image(
parent_graph
.get_graph(xray=True)
.draw_mermaid_png()
)
)
调用 get_graph() 时传递的 xray=True 表示自动解析并递归获取子图的拓扑结构。
运行结果如下
============================== -> 原始文本 <- ==============================
LangGraph 真有意思
============================== -> 清洗后的文本 <- ==============================
LangGraph 真有意思。

注意:
当前方式的子图信息依赖于 LangGraph 对于节点函数代码的解析,解析的关键代码如下
res = subgraph.invoke({"raw_text": input_text})
这是推荐的写法,调用子图时最好不要链式调用,否则解析失败,LangGraph 可能无法感知子图,这不仅会导致 display 渲染时不包含子图的拓扑结构,还会导致启用检查点存储器时,无法查看子图检查点快照。
错误写法示例如下
当前案例看不到效果,下文的 Multi-turn 案例能看到差别
assistant_response = subgraph.invoke(
{"messages": messages}
)["messages"][-1].content
此时,LangGraph 无法感知这里的子图 subgraph
正确写法如下
subgraph_response = subgraph.invoke(
{"messages": messages}
)
assistant_response = subgraph_response["messages"][-1].content
12.1.1.2. 查看子图
在上述代码的基础上执行以下代码
list(parent_graph.get_subgraphs())
结果如下
[('call_subgraph',
<langgraph.graph.state.CompiledStateGraph at 0x19bd9e84ec0>)]
12.1.2. 子图直接作为父图的节点
作为节点:
- 子图的OverAllState必须是父图的OverAllState的子集(变量名称、变量规则 等等要和父图相同)
- 中断区别:节点下,子图中断,父图知道中断的具体位置,所以父图是精准的从子图某一行开始继续执行。因为子图父图的config相同,同理父图也知道子图的执行过程,共用同一个config。
12.1.2.1. 用法
示例如下
# 定义全局共享状态
class OverAllState(TypedDict):
raw_text: str # 未清洗文本
cleaned_text: str # 清洗后的文本
# 构建子图
def subgraph_strip_node(state: OverAllState) -> OverAllState:
raw_text = state["raw_text"]
stripped_text = raw_text.strip()
return {
"cleaned_text": stripped_text
}
def subgraph_punctuate_node(state: OverAllState) -> OverAllState:
cleaned_text = state["cleaned_text"]
punctuated_text = cleaned_text + "。"
return {
"cleaned_text": punctuated_text
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("subgraph_strip_node", subgraph_strip_node)
builder.add_node("subgraph_punctuate_node", subgraph_punctuate_node)
builder.add_edge(START, "subgraph_strip_node")
builder.add_edge("subgraph_strip_node", "subgraph_punctuate_node")
builder.add_edge("subgraph_punctuate_node", END)
subgraph = builder.compile()
# 构建父图
builder = StateGraph(state_schema=OverAllState)
builder.add_node("subgraph_node", subgraph)
builder.add_edge(START, "subgraph_node")
builder.add_edge("subgraph_node", END)
parent_graph = builder.compile()
raw_text = " LangGraph 真有意思 "
res = parent_graph.invoke({"raw_text": raw_text})
cleaned_text = res["cleaned_text"]
print("=" * 30, "-> 原始文本 <-", "=" * 30)
print(raw_text)
print("=" * 30, "-> 清洗后的文本 <-", "=" * 30)
print(cleaned_text)
display(
Image(
parent_graph
.get_graph(xray=True)
.draw_mermaid_png()
)
)
运行结果如下
============================== -> 原始文本 <- ==============================
LangGraph 真有意思
============================== -> 清洗后的文本 <- ==============================
LangGraph 真有意思。

12.1.2.2. 查看子图
示例如下
list(parent_graph.get_subgraphs())
运行结果如下
[('subgraph_node',
<langgraph.graph.state.CompiledStateGraph at 0x297c50a2520>)]
12.2. 子图持久化
12.2.1. 查看子图检查点快照
本小节讲述:如何从父图中查询子图的检查点信息
本节测试都依赖于以下示例
# ---- 本章导入 ----
from langgraph.checkpoint.memory import InMemorySaver
# 构建子图
# (SubgraphState 与上文定义完全相同,略)
# (subgraph_strip_node 与上文定义完全相同,略)
# (subgraph_punctuate_node 与上文定义完全相同,略)
builder = StateGraph(state_schema=SubgraphState)
builder.add_node("subgraph_strip_node", subgraph_strip_node)
builder.add_node("subgraph_punctuate_node", subgraph_punctuate_node)
builder.add_edge(START, "subgraph_strip_node")
builder.add_edge("subgraph_strip_node", "subgraph_punctuate_node")
builder.add_edge("subgraph_punctuate_node", END)
subgraph = builder.compile()
# 构建父图
class ParentState(TypedDict):
input_text: str # 输入的未清洗的文本
cleaned_text: str # 清洗后的文本
# (call_subgraph 与上文定义完全相同,略)
builder = StateGraph(state_schema=ParentState)
builder.add_node("call_subgraph", call_subgraph)
builder.add_edge(START, "call_subgraph")
builder.add_edge("call_subgraph", END)
checkpointer=InMemorySaver()
parent_graph = builder.compile(checkpointer=checkpointer)
input_text = " LangGraph 真有意思 "
config={"configurable": {"thread_id": "observe_checkpoint_1"}}
res = parent_graph.invoke(
{"input_text": input_text},
config=config
)
cleaned_text = res["cleaned_text"]
print("=" * 30, "-> 原始文本 <-", "=" * 30)
print(input_text)
print("=" * 30, "-> 清洗后的文本 <-", "=" * 30)
print(cleaned_text)
display(
Image(
parent_graph
.get_graph(xray=True)
.draw_mermaid_png()
)
)
运行结果如下
============================== -> 原始文本 <- ==============================
LangGraph 真有意思
============================== -> 清洗后的文本 <- ==============================
LangGraph 真有意思。

12.2.1.1. 在父图检查点快照中查看子图信息
在父图中查看父图的所有检查点快照get_state_history,参数只有thread_id(可以看到调用子图的节点是哪个,此时显示的子图信息有限,state只有调用子图的thread_id+命名空间
获取父图历史检查点列表
histories = list(parent_graph.get_state_history(config=config))
histories
结果如下:早执行的打印在下面
[
StateSnapshot(
values={
'input_text': ' LangGraph 真有意思 ',
'cleaned_text': 'LangGraph 真有意思。'
},
next=(),
config={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': '',
'checkpoint_id': '1f16fd86-3406-6072-8001-1e8b9691a9cf'
}
},
metadata={
'source': 'loop',
'step': 1,
'parents': {}
},
created_at='2026-06-24T14:24:19.397838+00:00',
parent_config={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': '',
'checkpoint_id': '1f16fd86-33fb-6847-8000-8914ff1be716'
}
},
tasks=(),
interrupts=()
),
StateSnapshot(
values={
'input_text': ' LangGraph 真有意思 '
},
next=(
'call_subgraph',
),
config={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': '',
'checkpoint_id': '1f16fd86-33fb-6847-8000-8914ff1be716'
}
},
metadata={
'source': 'loop',
'step': 0,
'parents': {}
},
created_at='2026-06-24T14:24:19.393533+00:00',
parent_config={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': '',
'checkpoint_id': '1f16fd86-33fa-61a4-bfff-538c9fb00687'
}
},
tasks=(
PregelTask(
id='9923d4be-9845-576d-785f-5b1d6da9a94f',
name='call_subgraph',
path=(
'__pregel_pull',
'call_subgraph'
),
error=None,
interrupts=(),
state={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': 'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f'
}
},
result={
'cleaned_text': 'LangGraph 真有意思。'
}
),
),
interrupts=()
),
StateSnapshot(
values={},
next=(
'__start__',
),
config={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': '',
'checkpoint_id': '1f16fd86-33fa-61a4-bfff-538c9fb00687'
}
},
metadata={
'source': 'input',
'step': -1,
'parents': {}
},
created_at='2026-06-24T14:24:19.392958+00:00',
parent_config=None,
tasks=(
PregelTask(
id='1b333b41-1e2d-1770-8dab-a09a47002aef',
name='__start__',
path=(
'__pregel_pull',
'__start__'
),
error=None,
interrupts=(),
state=None,
result={
'input_text': ' LangGraph 真有意思 '
}
),
),
interrupts=()
)
]
可以看到,超步为 0 的快照下的 tasks 字段下只有一个任务实例,该任务实例在编号为 1 的超步执行,其中的 state 字段记录了子图检查点的 thread_id 和 checkpoint_ns,如下所示:
state={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': 'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f'
}
}
-
thread_id:整个线程的唯一ID,和父图一致 -
checkpoint_ns:子图命名空间,格式为<子图所属父图节点名称>:<子图所属父图任务ID>我们在介绍
LangGraph中提到,父图的checkpoint_ns通常为空字符串,在子图嵌套系统中该字段排上了用场:- 父图的
checkpoint_ns仍未空字符串 - 子图的
checkpoint_ns遵循以上格式
当嵌套层级不止一层时,命名空间将会通过
|拼接,具体来说,假设拓扑结构如下:父图 └── call_subgraph # 调用子图 A └── call_inner_graph # 子图 A 调用子图 B那么命名空间为
父图: "" 子图 A: call_subgraph:<task_id_1> 子图 B: call_subgraph:<task_id_1>|call_inner_graph:<task_id_2>依次类推。
- 父图的
12.2.1.2. 在父图检查点快照中展开子图最新快照
先从父图的所有检查点快照中get_state_history,找到调用子图的那个检查点的config(有thread_id+checkpoint_ns+checkpoint_id),最后通过get_state(config, subgraphs)获取这个检查点所有信息,在state中包含StateSnapshot(只能获取到子图最后的检查点,看不到过程检查点)
我们可以在父图的 state 字段中展开子图快照,也就是将 state 字段的值替换为子图的最新检查点快照
12.2.1.2.1. 获取父图检查点配置
超步为 0 的检查点记录了子图的配置,所以我们要获取这个快照的父图配置,观察检查点快照列表可知:快照列表按照超步编号降序排列,且最小超步为 -1,取列表的倒数第二个元素即可拿到超步编号为 0 的检查点快照。
代码如下
parent_config = histories[-2].config
parent_config
结果如下
{
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': '',
'checkpoint_id': '1f16fd86-33fb-6847-8000-8914ff1be716'
}
}
12.2.1.2.2. 展开子图快照
在调用 get_state() 时传递带有 checkpoint_id 的父图配置,并传递 subgraphs=True 即可展开子图快照。
示例如下
parent_graph.get_state(config=parent_config, subgraphs=True)
运行结果如下
StateSnapshot(
values={
'input_text': ' LangGraph 真有意思 '
},
next=(
'call_subgraph',
),
config={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': '',
'checkpoint_id': '1f16fd86-33fb-6847-8000-8914ff1be716'
}
},
metadata={
'source': 'loop',
'step': 0,
'parents': {}
},
created_at='2026-06-24T14:24:19.393533+00:00',
parent_config={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': '',
'checkpoint_id': '1f16fd86-33fa-61a4-bfff-538c9fb00687'
}
},
tasks=(
PregelTask(
id='9923d4be-9845-576d-785f-5b1d6da9a94f',
name='call_subgraph',
path=(
'__pregel_pull',
'call_subgraph'
),
error=None,
interrupts=(),
state=StateSnapshot(
values={
'raw_text': ' LangGraph 真有意思 ',
'stripped_text': 'LangGraph 真有意思',
'punctuated_text': 'LangGraph 真有意思。'
},
next=(),
config={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': 'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f',
'checkpoint_id': '1f16fd86-3404-640e-8002-84e2d98a88dc',
'checkpoint_map': {
'': '1f16fd86-33fb-6847-8000-8914ff1be716',
'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f': '1f16fd86-3404-640e-8002-84e2d98a88dc'
}
}
},
metadata={
'source': 'loop',
'step': 2,
'parents': {
'': '1f16fd86-33fb-6847-8000-8914ff1be716'
}
},
created_at='2026-06-24T14:24:19.397114+00:00',
parent_config={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': 'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f',
'checkpoint_id': '1f16fd86-3401-694f-8001-2c6a2666b501',
'checkpoint_map': {
'': '1f16fd86-33fb-6847-8000-8914ff1be716',
'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f': '1f16fd86-3401-694f-8001-2c6a2666b501'
}
}
},
tasks=(),
interrupts=()
),
result={
'cleaned_text': 'LangGraph 真有意思。'
}
),
),
interrupts=()
)
可以看到,state 字段已展开为子图的最新检查点快照。
12.2.1.3. 获取子图配置,查看完整子图检查点列表
先从父图的所有检查点快照中get_state_history,找到调用子图的那个检查点的tasks-PregelTask-state-configurable(有thread_id+checkpoint_id),最后通过get_state_history(config)获取这个检查点所有信息(整个子图的调用过程的检查点)
某些场景下,我们希望查看完整的子图检查点快照列表,此时只需要将包含 thread_id 和子图命名空间 checkpoint_ns 的配置信息传递给 get_state_history() 即可实现。
12.2.1.3.1. 获取子图检查点配置
示例如下
subgraph_config = histories[-2].tasks[0].state
subgraph_config
结果如下
{
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': 'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f'
}
}
12.2.1.3.2. 查看完整子图检查点快照列表
示例如下
list(parent_graph.get_state_history(config=subgraph_config))
结果如下
[
StateSnapshot(
values={
'raw_text': ' LangGraph 真有意思 ',
'stripped_text': 'LangGraph 真有意思',
'punctuated_text': 'LangGraph 真有意思。'
},
next=(),
config={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': 'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f',
'checkpoint_id': '1f16fd86-3404-640e-8002-84e2d98a88dc',
'checkpoint_map': {
'': '1f16fd86-33fb-6847-8000-8914ff1be716',
'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f': '1f16fd86-3404-640e-8002-84e2d98a88dc'
}
}
},
metadata={
'source': 'loop',
'step': 2,
'parents': {
'': '1f16fd86-33fb-6847-8000-8914ff1be716'
}
},
created_at='2026-06-24T14:24:19.397114+00:00',
parent_config={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': 'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f',
'checkpoint_id': '1f16fd86-3401-694f-8001-2c6a2666b501',
'checkpoint_map': {
'': '1f16fd86-33fb-6847-8000-8914ff1be716',
'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f': '1f16fd86-3401-694f-8001-2c6a2666b501'
}
}
},
tasks=(),
interrupts=()
),
StateSnapshot(
values={
'raw_text': ' LangGraph 真有意思 ',
'stripped_text': 'LangGraph 真有意思'
},
next=(
'subgraph_punctuate_node',
),
config={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': 'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f',
'checkpoint_id': '1f16fd86-3401-694f-8001-2c6a2666b501',
'checkpoint_map': {
'': '1f16fd86-33fb-6847-8000-8914ff1be716',
'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f': '1f16fd86-3401-694f-8001-2c6a2666b501'
}
}
},
metadata={
'source': 'loop',
'step': 1,
'parents': {
'': '1f16fd86-33fb-6847-8000-8914ff1be716'
}
},
created_at='2026-06-24T14:24:19.396020+00:00',
parent_config={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': 'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f',
'checkpoint_id': '1f16fd86-33ff-6ad9-8000-0fb11208046d',
'checkpoint_map': {
'': '1f16fd86-33fb-6847-8000-8914ff1be716',
'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f': '1f16fd86-33ff-6ad9-8000-0fb11208046d'
}
}
},
tasks=(
PregelTask(
id='d37272d8-d167-4fb9-6e31-466a58bf91cc',
name='subgraph_punctuate_node',
path=(
'__pregel_pull',
'subgraph_punctuate_node'
),
error=None,
interrupts=(),
state=None,
result={
'punctuated_text': 'LangGraph 真有意思。'
}
),
),
interrupts=()
),
StateSnapshot(
values={
'raw_text': ' LangGraph 真有意思 '
},
next=(
'subgraph_strip_node',
),
config={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': 'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f',
'checkpoint_id': '1f16fd86-33ff-6ad9-8000-0fb11208046d',
'checkpoint_map': {
'': '1f16fd86-33fb-6847-8000-8914ff1be716',
'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f': '1f16fd86-33ff-6ad9-8000-0fb11208046d'
}
}
},
metadata={
'source': 'loop',
'step': 0,
'parents': {
'': '1f16fd86-33fb-6847-8000-8914ff1be716'
}
},
created_at='2026-06-24T14:24:19.395239+00:00',
parent_config={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': 'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f',
'checkpoint_id': '1f16fd86-33fe-685b-bfff-d0a73977e807',
'checkpoint_map': {
'': '1f16fd86-33fb-6847-8000-8914ff1be716',
'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f': '1f16fd86-33fe-685b-bfff-d0a73977e807'
}
}
},
tasks=(
PregelTask(
id='9d9a1009-3212-b789-f40a-3d8f213ce4fb',
name='subgraph_strip_node',
path=(
'__pregel_pull',
'subgraph_strip_node'
),
error=None,
interrupts=(),
state=None,
result={
'stripped_text': 'LangGraph 真有意思'
}
),
),
interrupts=()
),
StateSnapshot(
values={},
next=(
'__start__',
),
config={
'configurable': {
'thread_id': 'observe_checkpoint_1',
'checkpoint_ns': 'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f',
'checkpoint_id': '1f16fd86-33fe-685b-bfff-d0a73977e807',
'checkpoint_map': {
'': '1f16fd86-33fb-6847-8000-8914ff1be716',
'call_subgraph:9923d4be-9845-576d-785f-5b1d6da9a94f': '1f16fd86-33fe-685b-bfff-d0a73977e807'
}
}
},
metadata={
'source': 'input',
'step': -1,
'parents': {
'': '1f16fd86-33fb-6847-8000-8914ff1be716'
}
},
created_at='2026-06-24T14:24:19.394769+00:00',
parent_config=None,
tasks=(
PregelTask(
id='5516615f-62ca-2dc4-d41e-6a8f20a981bb',
name='__start__',
path=(
'__pregel_pull',
'__start__'
),
error=None,
interrupts=(),
state=None,
result={
'raw_text': ' LangGraph 真有意思 '
}
),
),
interrupts=()
)
]
-
上述快照的命名空间都不是空字符串,且保持一致,全部属于同一个子图
-
metadata['parents']记录了父检查点的信息,格式为<父检查点命名空间>:<父检查点ID> -
config['configurable']['checkpoint_map']记录了父图和当前子图检查点命名空间和ID的映射关系,包含两个条目,格式如下<父图检查点命名空间>:<父图检查点ID>, <当前子图检查点命名空间>:<当前子图检查点ID>
12.2.2. 持久化策略
本节讨论的持久化策略以父图启用检查点存储器为前提。子图支持三种策略:
第一个默认策略是:子图不添加检查点存储;第二个策略是给子图打开检查点策略;第三个是关闭子图的检查点。
| 策略 | 编译参数 | 检查点保存 | 中断恢复 | 多轮记忆 |
|---|---|---|---|---|
Per-invocation(默认) |
checkpointer=None 或省略 |
✓ | ✓ | ✗(同 thread_id 再次调用不加载历史) |
Per-thread |
checkpointer=True |
✓ | ✓ | ✓(同 thread_id 调用加载历史) |
Stateless |
checkpointer=False |
✗ | ✗ | ✗ |
三种策略的唯一区别在于子图编译时的
checkpointer参数,其余代码完全相同。下面按场景组织,每个场景展示一份完整代码,策略切换只需修改一行。
12.2.2.1. 场景一:中断(interrupt)
子图节点中使用 interrupt() 中断执行,等待用户输入后恢复。
# ---- 本章导入 ----
from langgraph.types import interrupt, Command
from loguru import logger
# 定义全局共享状态
class OverAllState(TypedDict):
raw_text: str # 未清洗文本
cleaned_text: str # 清洗后的文本
# 构建子图
def subgraph_strip_node(state: OverAllState) -> OverAllState:
logger.info("子图 subgraph_strip_node 节点执行了")
raw_text = state["raw_text"]
stripped_text = raw_text.strip()
return {
"cleaned_text": stripped_text
}
def subgraph_punctuate_node(state: OverAllState) -> OverAllState:
logger.info("子图 subgraph_punctuate_node 节点执行了")
cleaned_text = state["cleaned_text"]
punctuation = interrupt("您希望在句尾添加的标点符号是?[。/,/!/?/;]")
punctuation1 = interrupt("您希望在句尾添加的标点符号是?[。/,/!/?/;]")
punctuated_text = cleaned_text + punctuation + punctuation1
return {
"cleaned_text": punctuated_text
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("subgraph_strip_node", subgraph_strip_node)
builder.add_node("subgraph_punctuate_node", subgraph_punctuate_node)
builder.add_edge(START, "subgraph_strip_node")
builder.add_edge("subgraph_strip_node", "subgraph_punctuate_node")
builder.add_edge("subgraph_punctuate_node", END)
# ==================================================
# 【策略切换点】只需修改这一行
# subgraph = builder.compile() # Per-invocation(默认)
# subgraph = builder.compile(checkpointer=True) # Per-thread
subgraph = builder.compile(checkpointer=False) # Stateless(中断不可用)
# ==================================================
# 构建父图
builder = StateGraph(state_schema=OverAllState)
builder.add_node("subgraph_node", subgraph)
builder.add_edge(START, "subgraph_node")
builder.add_edge("subgraph_node", END)
checkpointer = InMemorySaver()
parent_graph = builder.compile(checkpointer=checkpointer)
raw_text = " LangGraph 真有意思 "
config = {"configurable": {"thread_id": "123"}}
# 首次调用
interrupted_res = parent_graph.invoke(
{"raw_text": raw_text},
config=config
)
print("=" * 30, "-> 中断信息 <-", "=" * 30)
print(interrupted_res)
display(
Image(
parent_graph
.get_graph(xray=True)
.draw_mermaid_png()
)
)
# 恢复调用
parent_graph.invoke(
Command(resume="!"),
config=config
)
res = parent_graph.invoke(
Command(resume="!"),
config=config
)
cleaned_text = res["cleaned_text"]
print("=" * 30, "-> 原始文本 <-", "=" * 30)
print(raw_text)
print("=" * 30, "-> 清洗后的文本 <-", "=" * 30)
print(cleaned_text)
运行结果(Per-invocation / Per-thread 均可)
2026-06-25 09:41:40.629 | INFO | __main__:subgraph_strip_node:15 - 子图 subgraph_strip_node 节点执行了
2026-06-25 09:41:40.630 | INFO | __main__:subgraph_punctuate_node:24 - 子图 subgraph_punctuate_node 节点执行了
============================== -> 中断信息 <- ==============================
{'raw_text': ' LangGraph 真有意思 ', '__interrupt__': [Interrupt(value='您希望在句尾添加的标点符号是?[。/,/!/?/;]', id='a4c6479870430be1a857af43cc7eff99')]}
2026-06-25 09:41:40.633 | INFO | __main__:subgraph_punctuate_node:24 - 子图 subgraph_punctuate_node 节点执行了
============================== -> 原始文本 <- ==============================
LangGraph 真有意思
============================== -> 清洗后的文本 <- ==============================
LangGraph 真有意思!

策略差异:三种策略下中断机制行为一致,
Per-invocation和Per-thread均支持中断恢复,Stateless不支持中断。
| 策略 | 编译参数 | 中断 |
|---|---|---|
| Per-invocation(默认) | subgraph = builder.compile() |
✅ |
| Per-thread | subgraph = builder.compile(checkpointer=True) |
✅ |
| Stateless | subgraph = builder.compile(checkpointer=False) |
❌ |
12.2.2.2. 场景二:多轮对话
父图多次调用子图进行 LLM 对话,验证子图是否保留历史消息。
# ---- 本章导入 ----
from langgraph.graph import StateGraph, START, END, MessagesState
from langchain.messages import HumanMessage, SystemMessage, AIMessage
from langchain_deepseek import ChatDeepSeek
import sys
# 删除 Loguru 默认的 stderr sink
logger.remove()
# 重新添加支持颜色的 sink
logger.add(sys.stdout, colorize=True)
from dotenv import load_dotenv
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 构建子图
def llm_node(state: MessagesState) -> MessagesState:
messages = state["messages"]
logger.info("=" * 30)
logger.info("调用子图的 llm_node 节点,当前的 messages: ")
for index, message in enumerate(messages, start=1):
logger.opt(colors=True).info(
"\n<cyan><bold>[消息 {}]</bold></cyan>\n"
"<yellow>类型:</yellow><magenta>{}</magenta>\n"
"<yellow>内容:</yellow><green>{}</green>",
index,
message.type,
message.content
)
logger.info("=" * 30)
response = model.invoke(input=messages)
ai_msg = AIMessage(content=response.content)
return {
"messages": [ai_msg]
}
builder = StateGraph(state_schema=MessagesState)
builder.add_node("llm_node", llm_node)
builder.add_edge(START, "llm_node")
builder.add_edge("llm_node", END)
# ==================================================
# 【策略切换点】只需修改这一行
subgraph = builder.compile() # Per-invocation(默认):无记忆
# subgraph = builder.compile(checkpointer=True) # Per-thread:有记忆
# ==================================================
# 构建父图
class OverAllState(TypedDict):
user_input: str # 用户提问
assistant_response: str # 助手回答
def call_subgraph(state: OverAllState) -> OverAllState:
user_input = state["user_input"]
messages = [
SystemMessage("尽可能用简短的语言回答"),
HumanMessage(user_input)
]
subgraph_response = subgraph.invoke(
{"messages": messages}
)
assistant_response = subgraph_response["messages"][-1].content
return {
"assistant_response": assistant_response
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("call_subgraph", call_subgraph)
builder.add_edge(START, "call_subgraph")
builder.add_edge("call_subgraph", END)
checkpointer = InMemorySaver()
parent_graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "multi-turn-demo"}}
first_invoke = parent_graph.invoke(
{"user_input": "我是老王,从现在开始,你是小王"},
config = config
)
print("=" * 30, "-> 第一次调用 <-", "=" * 30)
print(first_invoke)
second_invoke = parent_graph.invoke(
{"user_input": "我是谁?你是谁?"},
config = config
)
print("=" * 30, "-> 第二次调用 <-", "=" * 30)
print(second_invoke)
display(
Image(
parent_graph
.get_graph(xray=True)
.draw_mermaid_png()
)
)
Per-invocation 运行结果(无记忆)
2026-06-25 09:43:14.012 | INFO | __main__:llm_node:31 - ==============================
2026-06-25 09:43:14.013 | INFO | __main__:llm_node:32 - 调用子图的 llm_node 节点,当前的 messages:
2026-06-25 09:43:14.014 | INFO | __main__:llm_node:34 -
[消息 1]
类型:system
内容:尽可能用简短的语言回答
2026-06-25 09:43:14.014 | INFO | __main__:llm_node:34 -
[消息 2]
类型:human
内容:我是老王,从现在开始,你是小王
2026-06-25 09:43:14.015 | INFO | __main__:llm_node:42 - ==============================
============================== -> 第一次调用 <- ==============================
{'user_input': '我是老王,从现在开始,你是小王', 'assistant_response': '好的,老王。我是小王,您有什么吩咐?'}
2026-06-25 09:43:14.814 | INFO | __main__:llm_node:31 - ==============================
2026-06-25 09:43:14.815 | INFO | __main__:llm_node:32 - 调用子图的 llm_node 节点,当前的 messages:
2026-06-25 09:43:14.815 | INFO | __main__:llm_node:34 -
[消息 1]
类型:system
内容:尽可能用简短的语言回答
2026-06-25 09:43:14.816 | INFO | __main__:llm_node:34 -
[消息 2]
类型:human
内容:我是谁?你是谁?
2026-06-25 09:43:14.816 | INFO | __main__:llm_node:42 - ==============================
============================== -> 第二次调用 <- ==============================
{'user_input': '我是谁?你是谁?', 'assistant_response': '你是用户,我是DeepSeek,一个由深度求索公司开发的AI助手。'}

第二次调用时子图 messages 被重置,无法获知第一次调用时传递的信息。Per-invocation 模式无法在子图中进行多轮对话。
Per-thread 运行结果(有记忆)——仅子图编译改为 builder.compile(checkpointer=True):
2026-06-25 10:42:37.121 | INFO | __main__:llm_node:31 - ==============================
2026-06-25 10:42:37.122 | INFO | __main__:llm_node:32 - 调用子图的 llm_node 节点,当前的 messages:
2026-06-25 10:42:37.123 | INFO | __main__:llm_node:34 -
[消息 1]
类型:system
内容:尽可能用简短的语言回答
2026-06-25 10:42:37.124 | INFO | __main__:llm_node:34 -
[消息 2]
类型:human
内容:我是老王,从现在开始,你是小王
2026-06-25 10:42:37.124 | INFO | __main__:llm_node:42 - ==============================
============================== -> 第一次调用 <- ==============================
{'user_input': '我是老王,从现在开始,你是小王', 'assistant_response': '(严肃点头)好,老王,有啥指示?'}
2026-06-25 10:42:37.940 | INFO | __main__:llm_node:31 - ==============================
2026-06-25 10:42:37.941 | INFO | __main__:llm_node:32 - 调用子图的 llm_node 节点,当前的 messages:
2026-06-25 10:42:37.942 | INFO | __main__:llm_node:34 -
[消息 1]
类型:system
内容:尽可能用简短的语言回答
2026-06-25 10:42:37.943 | INFO | __main__:llm_node:34 -
[消息 2]
类型:human
内容:我是老王,从现在开始,你是小王
2026-06-25 10:42:37.944 | INFO | __main__:llm_node:34 -
[消息 3]
类型:ai
内容:(严肃点头)好,老王,有啥指示?
2026-06-25 10:42:37.945 | INFO | __main__:llm_node:34 -
[消息 4]
类型:system
内容:尽可能用简短的语言回答
2026-06-25 10:42:37.945 | INFO | __main__:llm_node:34 -
[消息 5]
类型:human
内容:我是谁?你是谁?
2026-06-25 10:42:37.946 | INFO | __main__:llm_node:42 - ==============================
============================== -> 第二次调用 <- ==============================
{'user_input': '我是谁?你是谁?', 'assistant_response': '老王。小王。'}

历史消息被保留,上下文连续。Per-thread 模式适用于子图多轮对话场景。
策略差异
| 策略 | 编译参数 | 多轮记忆 |
|---|---|---|
| Per-invocation(默认) | subgraph = builder.compile() |
❌ 每次调用状态重置 |
| Per-thread | subgraph = builder.compile(checkpointer=True) |
✅ 跨调用状态连续 |
12.2.2.3. 场景三:多次调用同一子图(了解即可,不推荐)
子图共用父图的 config 拿到地址(thread_id + checkpoint_ns),然后拿地址去 checkpointer(账本)里把历史查出来
父图的一个节点内循环多次调用同一子图。
①Per-invocation(默认)每次调用都是全新的子图;
②Per-thread下一次调用会被上一次同节点内的调用影响到,因为有记忆。
logger.remove()
logger.add(sys.stdout, colorize=True)
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 构建子图
# (llm_node 与上文定义完全相同,略)
builder = StateGraph(state_schema=MessagesState)
builder.add_node("llm_node", llm_node)
builder.add_edge(START, "llm_node")
builder.add_edge("llm_node", END)
# ==================================================
# 【策略切换点】只需修改这一行
subgraph = builder.compile() # Per-invocation(默认):每次调用状态隔离
# subgraph = builder.compile(checkpointer=True) # Per-thread:历史相互干扰
# ==================================================
# 构建父图
class OverAllState(TypedDict):
user_inputs: list[str] # 用户提问
assistant_responses: str # 助手回答
def call_subgraph(state: OverAllState) -> OverAllState:
user_inputs = state["user_inputs"]
assistant_responses = []
for user_input in user_inputs:
subgraph_response = subgraph.invoke(
{
"messages": [
SystemMessage("用最简短的话回答用户的提问"),
HumanMessage(user_input)
]
}
)
assistant_response = subgraph_response["messages"][-1].content
assistant_responses.append(assistant_response)
return {
"assistant_responses": assistant_responses
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("call_subgraph", call_subgraph)
builder.add_edge(START, "call_subgraph")
builder.add_edge("call_subgraph", END)
checkpointer = InMemorySaver()
parent_graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "multi-call-same-demo"}}
first_response = parent_graph.invoke(
{
"user_inputs": [
"5*5等于几",
"10*10等于几"
]
},
config = config
)
print("=" * 30, "-> 第一次运行结果 <-", "=" * 30)
print(first_response)
second_response = parent_graph.invoke(
{
"user_inputs": [
"再+1呢?",
"再+10000呢?"
]
},
config = config
)
print("=" * 30, "-> 第二次运行结果 <-", "=" * 30)
print(second_response)
display(
Image(
parent_graph
.get_graph(xray=True)
.draw_mermaid_png()
)
)
Per-thread 运行结果
2026-07-14 11:53:58.056 | INFO | __main__:llm_node:29 - ==============================
2026-07-14 11:53:58.056 | INFO | __main__:llm_node:30 - 调用子图的 llm_node 节点,当前的 messages:
2026-07-14 11:53:58.057 | INFO | __main__:llm_node:32 -
[消息 1]
类型:system
内容:用最简短的话回答用户的提问
2026-07-14 11:53:58.057 | INFO | __main__:llm_node:32 -
[消息 2]
类型:human
内容:5*5等于几
2026-07-14 11:53:58.058 | INFO | __main__:llm_node:40 - ==============================
2026-07-14 11:53:58.652 | INFO | __main__:llm_node:29 - ==============================
2026-07-14 11:53:58.652 | INFO | __main__:llm_node:30 - 调用子图的 llm_node 节点,当前的 messages:
2026-07-14 11:53:58.653 | INFO | __main__:llm_node:32 -
[消息 1]
类型:system
内容:用最简短的话回答用户的提问
2026-07-14 11:53:58.653 | INFO | __main__:llm_node:32 -
[消息 2]
类型:human
内容:10*10等于几
2026-07-14 11:53:58.654 | INFO | __main__:llm_node:40 - ==============================
============================== -> 第一次运行结果 <- ==============================
{'user_inputs': ['5*5等于几', '10*10等于几'], 'assistant_responses': ['5*5等于25。', '10*10等于100。']}
2026-07-14 11:53:59.230 | INFO | __main__:llm_node:29 - ==============================
2026-07-14 11:53:59.231 | INFO | __main__:llm_node:30 - 调用子图的 llm_node 节点,当前的 messages:
2026-07-14 11:53:59.231 | INFO | __main__:llm_node:32 -
[消息 1]
类型:system
内容:用最简短的话回答用户的提问
2026-07-14 11:53:59.232 | INFO | __main__:llm_node:32 -
[消息 2]
类型:human
内容:5*5等于几
2026-07-14 11:53:59.233 | INFO | __main__:llm_node:32 -
[消息 3]
类型:ai
内容:5*5等于25。
2026-07-14 11:53:59.234 | INFO | __main__:llm_node:32 -
[消息 4]
类型:system
内容:用最简短的话回答用户的提问
2026-07-14 11:53:59.234 | INFO | __main__:llm_node:32 -
[消息 5]
类型:human
内容:再+1呢?
2026-07-14 11:53:59.235 | INFO | __main__:llm_node:40 - ==============================
2026-07-14 11:53:59.766 | INFO | __main__:llm_node:29 - ==============================
2026-07-14 11:53:59.767 | INFO | __main__:llm_node:30 - 调用子图的 llm_node 节点,当前的 messages:
2026-07-14 11:53:59.767 | INFO | __main__:llm_node:32 -
[消息 1]
类型:system
内容:用最简短的话回答用户的提问
2026-07-14 11:53:59.768 | INFO | __main__:llm_node:32 -
[消息 2]
类型:human
内容:10*10等于几
2026-07-14 11:53:59.768 | INFO | __main__:llm_node:32 -
[消息 3]
类型:ai
内容:10*10等于100。
2026-07-14 11:53:59.769 | INFO | __main__:llm_node:32 -
[消息 4]
类型:system
内容:用最简短的话回答用户的提问
2026-07-14 11:53:59.769 | INFO | __main__:llm_node:32 -
[消息 5]
类型:human
内容:再+10000呢?
2026-07-14 11:53:59.769 | INFO | __main__:llm_node:40 - ==============================
============================== -> 第二次运行结果 <- ==============================
{'user_inputs': ['再+1呢?', '再+10000呢?'], 'assistant_responses': ['25+1=26。', '10100']}

每次调用子图时 messages 状态都是独立的。Per-invocation 适用于父图中多次调用同一子图的场景,调用间互不干扰。
Per-thread 行为(改为 builder.compile(checkpointer=True)):同一节点内的多次子图调用,历史按调用位置独立——即第一次子图调用的历史是连续的,第二次子图调用的历史也是连续的,但两次之间独立。如果调用顺序发生变化,检查点历史会相互干扰。
策略差异
| 策略 | 编译参数 | 多次调用同一子图 |
|---|---|---|
| Per-invocation(默认) | subgraph = builder.compile() |
✅ 每次调用完全隔离 |
| Per-thread | subgraph = builder.compile(checkpointer=True) |
❌ 历史按位置关联,可能干扰 |
12.2.2.4. 场景四:多次调用不同子图(了解即可,不推荐)
父图的一个节点内调用两个不同的子图。
logger.remove()
logger.add(sys.stdout, colorize=True)
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# ==================================================
# 【策略切换点】两个子图的编译参数
# ==================================================
# 构建水果子图
def fruit_node(state: MessagesState) -> MessagesState:
messages = state["messages"]
logger.info("=" * 30)
logger.info("调用水果子图的 fruit_node 节点,当前的 messages: ")
for index, message in enumerate(messages, start=1):
logger.opt(colors=True).info(
"\n<cyan><bold>[消息 {}]</bold></cyan>\n"
"<yellow>类型:</yellow><magenta>{}</magenta>\n"
"<yellow>内容:</yellow><green>{}</green>",
index,
message.type,
message.content
)
logger.info("=" * 30)
response = model.invoke(input=messages)
ai_msg = AIMessage(content=response.content)
return {
"messages": [ai_msg]
}
builder = StateGraph(state_schema=MessagesState)
builder.add_node("fruit_node", fruit_node)
builder.add_edge(START, "fruit_node")
builder.add_edge("fruit_node", END)
fruit_subgraph = builder.compile(checkpointer=True)
# 构建蔬菜子图
def vegetable_node(state: MessagesState) -> MessagesState:
messages = state["messages"]
logger.info("=" * 30)
logger.info("调用蔬菜子图的 vegetable_node 节点,当前的 messages: ")
for index, message in enumerate(messages, start=1):
logger.opt(colors=True).info(
"\n<cyan><bold>[消息 {}]</bold></cyan>\n"
"<yellow>类型:</yellow><magenta>{}</magenta>\n"
"<yellow>内容:</yellow><green>{}</green>",
index,
message.type,
message.content
)
logger.info("=" * 30)
response = model.invoke(input=messages)
ai_msg = AIMessage(content=response.content)
return {
"messages": [ai_msg]
}
builder = StateGraph(state_schema=MessagesState)
builder.add_node("vegetable_node", vegetable_node)
builder.add_edge(START, "vegetable_node")
builder.add_edge("vegetable_node", END)
vegetable_subgraph = builder.compile(checkpointer=True)
# 构建父图
class OverAllState(TypedDict):
fruit: str # 水果
vegetable: str # 蔬菜
fruit_introduction: str # 水果介绍
vegetable_introduction: str # 蔬菜介绍
def call_subgraph(state: OverAllState) -> OverAllState:
fruit = state["fruit"]
vegetable = state["vegetable"]
fruit_messages = [
SystemMessage("用最简短的话介绍用户输入的水果"),
HumanMessage(fruit)
]
vegetable_messages = [
SystemMessage("用最简短的话介绍用户输入的蔬菜"),
HumanMessage(vegetable)
]
fruit_response = fruit_subgraph.invoke({"messages": fruit_messages})
vegetable_response = vegetable_subgraph.invoke({"messages": vegetable_messages})
fruit_introduction = fruit_response["messages"][-1].content
vegetable_introduction = vegetable_response["messages"][-1].content
return {
"fruit_introduction": fruit_introduction,
"vegetable_introduction": vegetable_introduction
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("call_subgraph", call_subgraph)
builder.add_edge(START, "call_subgraph")
builder.add_edge("call_subgraph", END)
checkpointer = InMemorySaver()
parent_graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "multi-call-diff-demo"}}
first_response = parent_graph.invoke(
{
"fruit": "桑葚",
"vegetable": "西兰苔"
},
config = config
)
print("=" * 30, "-> 第一次运行结果 <-", "=" * 30)
print(first_response)
second_response = parent_graph.invoke(
{
"fruit": "香蕉",
"vegetable": "西兰花"
},
config = config
)
print("=" * 30, "-> 第二次运行结果 <-", "=" * 30)
print(second_response)
display(
Image(
parent_graph
.get_graph(xray=True)
.draw_mermaid_png()
)
)
运行结果
2026-06-25 11:37:49.176 | INFO | __main__:fruit_node:32 - ==============================
2026-06-25 11:37:49.176 | INFO | __main__:fruit_node:33 - 调用水果子图的 fruit_node 节点,当前的 messages:
2026-06-25 11:37:49.177 | INFO | __main__:fruit_node:35 -
[消息 1]
类型:system
内容:用最简短的话介绍用户输入的水果
2026-06-25 11:37:49.178 | INFO | __main__:fruit_node:35 -
[消息 2]
类型:human
内容:桑葚
2026-06-25 11:37:49.178 | INFO | __main__:fruit_node:43 - ==============================
2026-06-25 11:37:49.872 | INFO | __main__:vegetable_node:61 - ==============================
2026-06-25 11:37:49.873 | INFO | __main__:vegetable_node:62 - 调用蔬菜子图的 vegetable_node 节点,当前的 messages:
2026-06-25 11:37:49.873 | INFO | __main__:vegetable_node:64 -
[消息 1]
类型:system
内容:用最简短的话介绍用户输入的蔬菜
2026-06-25 11:37:49.874 | INFO | __main__:vegetable_node:64 -
[消息 2]
类型:human
内容:西兰苔
2026-06-25 11:37:49.874 | INFO | __main__:vegetable_node:72 - ==============================
============================== -> 第一次运行结果 <- ==============================
{'fruit': '桑葚', 'vegetable': '西兰苔', 'fruit_introduction': '紫黑多汁的浆果,味酸甜。', 'vegetable_introduction': '西兰苔是西兰花与芥蓝杂交的蔬菜,以鲜嫩花茎和球花为食。'}
...(第二次调用时水果、蔬菜子图各自保留历史消息,按调用位置独立)
{'fruit': '香蕉', 'vegetable': '西兰花', 'fruit_introduction': '黄色弯月形水果,软糯香甜。', 'vegetable_introduction': '西兰花,绿色花球状蔬菜,营养丰富。'}

本例在同一个节点中多次调用不同子图,通过专有状态保证了水果一定发给 fruit_subgraph、蔬菜一定发给 vegetable_subgraph,一定程度上可以确保不同子图的检查点历史不相互干扰。
但这种靠调用顺序区分的方式不够稳健——如果调用顺序变化,检查点历史也会错乱。官方推荐的做法见下一节。
12.2.2.5. 场景五:独立节点调用不同子图(推荐)
为每个子图准备独立的父图节点,这样它们的命名空间是各自的节点名,完全独立。
# ---- 本章导入 ----
from typing import TypedDict, Literal
from collections.abc import Sequence
logger.remove()
logger.add(sys.stdout, colorize=True)
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 构建水果子图
# (fruit_node 与上文定义完全相同,略)
builder = StateGraph(state_schema=MessagesState)
builder.add_node("fruit_node", fruit_node)
builder.add_edge(START, "fruit_node")
builder.add_edge("fruit_node", END)
fruit_subgraph = builder.compile(checkpointer=True)
# 构建蔬菜子图
# (vegetable_node 与上文定义完全相同,略)
builder = StateGraph(state_schema=MessagesState)
builder.add_node("vegetable_node", vegetable_node)
builder.add_edge(START, "vegetable_node")
builder.add_edge("vegetable_node", END)
vegetable_subgraph = builder.compile(checkpointer=True)
# 构建父图
# (OverAllState 与上文定义完全相同,略)
def call_fruit_subgraph(state: OverAllState) -> OverAllState:
fruit = state["fruit"]
fruit_messages = [
SystemMessage("用最简短的话介绍用户输入的水果"),
HumanMessage(fruit)
]
fruit_response = fruit_subgraph.invoke({"messages": fruit_messages})
fruit_introduction = fruit_response["messages"][-1].content
return {
"fruit_introduction": fruit_introduction
}
def call_vegetable_subgraph(state: OverAllState) -> OverAllState:
vegetable = state["vegetable"]
vegetable_messages = [
SystemMessage("用最简短的话介绍用户输入的蔬菜"),
HumanMessage(vegetable)
]
vegetable_response = vegetable_subgraph.invoke({"messages": vegetable_messages})
vegetable_introduction = vegetable_response["messages"][-1].content
return {
"vegetable_introduction": vegetable_introduction
}
def router(state: OverAllState) -> Sequence[Literal["call_fruit_subgraph", "call_vegetable_subgraph", END]]:
next_nodes = []
if state.get("fruit"):
next_nodes.append("call_fruit_subgraph")
if state.get("vegetable"):
next_nodes.append("call_vegetable_subgraph")
if not next_nodes:
next_nodes.append(END)
return next_nodes
builder = StateGraph(state_schema=OverAllState)
builder.add_node("call_fruit_subgraph", call_fruit_subgraph)
builder.add_node("call_vegetable_subgraph", call_vegetable_subgraph)
builder.add_conditional_edges(START, router, path_map=["call_fruit_subgraph", "call_vegetable_subgraph", END])
builder.add_edge("call_fruit_subgraph", END)
builder.add_edge("call_vegetable_subgraph", END)
checkpointer = InMemorySaver()
parent_graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "independent-nodes-demo"}}
first_response = parent_graph.invoke(
{
"fruit": "桑葚",
"vegetable": "西兰苔"
},
config = config
)
print("=" * 30, "-> 第一次运行结果 <-", "=" * 30)
print(first_response)
second_response = parent_graph.invoke(
{
"fruit": "香蕉",
"vegetable": "西兰花"
},
config = config
)
print("=" * 30, "-> 第二次运行结果 <-", "=" * 30)
print(second_response)
display(
Image(
parent_graph
.get_graph(xray=True)
.draw_mermaid_png()
)
)
运行结果
2026-06-25 11:59:10.331 | INFO | __main__:fruit_node:33 - ==============================
2026-06-25 11:59:10.334 | INFO | __main__:fruit_node:34 - 调用水果子图的 fruit_node 节点,当前的 messages:
2026-06-25 11:59:10.335 | INFO | __main__:fruit_node:36 -
[消息 1]
类型:system
内容:用最简短的话介绍用户输入的水果
2026-06-25 11:59:10.334 | INFO | __main__:vegetable_node:62 - ==============================
2026-06-25 11:59:10.335 | INFO | __main__:fruit_node:36 -
[消息 2]
类型:human
内容:桑葚
2026-06-25 11:59:10.337 | INFO | __main__:fruit_node:44 - ==============================
2026-06-25 11:59:10.337 | INFO | __main__:vegetable_node:63 - 调用蔬菜子图的 vegetable_node 节点,当前的 messages:
2026-06-25 11:59:10.341 | INFO | __main__:vegetable_node:65 -
[消息 1]
类型:system
内容:用最简短的话介绍用户输入的蔬菜
2026-06-25 11:59:10.341 | INFO | __main__:vegetable_node:65 -
[消息 2]
类型:human
内容:西兰苔
2026-06-25 11:59:10.342 | INFO | __main__:vegetable_node:73 - ==============================
============================== -> 第一次运行结果 <- ==============================
{'fruit': '桑葚', 'vegetable': '西兰苔', 'fruit_introduction': '桑葚是桑树的果实,味甜可食,常被用于制酱或入药。', 'vegetable_introduction': '西兰苔是西兰花和芥蓝的杂交品种。'}
...(第二次调用时,水果子图和蔬菜子图各自保留独立的历史)
{'fruit': '香蕉', 'vegetable': '西兰花', 'fruit_introduction': '香蕉是软甜水果,富含钾,能缓解便秘。', 'vegetable_introduction': '一种绿色花状蔬菜,营养丰富。'}

每个子图有独立的父图节点,命名空间为各自的节点名,完全隔离。这是 Per-thread 模式下多次调用不同子图的推荐做法。
12.2.2.6. 场景六:Stateless 模式
子图完全无状态,不保存检查点,中断和多轮对话均不适用。适用于纯计算、无需追踪历史的场景。
# ---- 本章导入 ----
from typing import TypedDict, Annotated
from operator import add
# 构建子图
class SubgraphState(TypedDict):
raw_text: str
clean_texts: Annotated[list[str], add]
def strip_node(state: SubgraphState) -> SubgraphState:
raw_text = state["raw_text"]
clean_text = raw_text.strip()
return {
"clean_texts": [clean_text]
}
builder = StateGraph(state_schema=SubgraphState)
builder.add_node("strip_node", strip_node)
builder.add_edge(START, "strip_node")
builder.add_edge("strip_node", END)
# ==================================================
# 【策略切换点】只需修改这一行
subgraph = builder.compile(checkpointer=False) # Stateless:无检查点
# subgraph = builder.compile(checkpointer=True) # Per-thread:有状态(对比用)
# ==================================================
# 构建父图
class ParentState(TypedDict):
input_texts: list[str]
output_texts: Annotated[list[str], add]
def call_subgraph(state: ParentState) -> ParentState:
input_texts = state["input_texts"]
output_texts = []
for input_text in input_texts:
res = subgraph.invoke({"raw_text": input_text})
output_texts += res["clean_texts"]
return {
"output_texts": output_texts
}
builder = StateGraph(state_schema=ParentState)
builder.add_node("call_subgraph", call_subgraph)
builder.add_edge(START, "call_subgraph")
builder.add_edge("call_subgraph", END)
checkpointer = InMemorySaver()
parent_graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "stateless-demo"}}
first_response = parent_graph.invoke(
{
"input_texts": [" LangGraph 真有意思 ", " 我喜欢 LangChain "]
},
config = config
)
print("=" * 30, "-> 第一次运行结果 <-", "=" * 30)
print(first_response)
second_response = parent_graph.invoke(
{
"input_texts": [" Hello, LangGraph ", " Hello, LangChain "]
},
config = config
)
print("=" * 30, "-> 第二次运行结果 <-", "=" * 30)
print(second_response)
display(
Image(
parent_graph
.get_graph(xray=True)
.draw_mermaid_png()
)
)
Stateless 运行结果
============================== -> 第一次运行结果 <- ==============================
{'input_texts': [' LangGraph 真有意思 ', ' 我喜欢 LangChain '], 'output_texts': ['LangGraph 真有意思', '我喜欢 LangChain']}
============================== -> 第二次运行结果 <- ==============================
{'input_texts': [' Hello, LangGraph ', ' Hello, LangChain '], 'output_texts': ['LangGraph 真有意思', '我喜欢 LangChain', 'Hello, LangGraph', 'Hello, LangChain']}

渲染的图结构中,子图节点内部没有展开子图拓扑——无状态模式的子图内部状态不可访问。
Per-thread 对比结果(仅改为 builder.compile(checkpointer=True)):
============================== -> 第一次运行结果 <- ==============================
{'input_texts': [' LangGraph 真有意思 ', ' 我喜欢 LangChain '], 'output_texts': ['LangGraph 真有意思', '我喜欢 LangChain']}
============================== -> 第二次运行结果 <- ==============================
{'input_texts': [' Hello, LangGraph ', ' Hello, LangChain '], 'output_texts': ['LangGraph 真有意思', '我喜欢 LangChain', 'LangGraph 真有意思', 'Hello, LangGraph', '我喜欢 LangChain', 'Hello, LangChain']}

Per-thread 模式下第二次调用结果出现了重复(子图的历史 clean_texts 被累积),拓扑结构中也展开了子图。Stateless 不会累积历史,每次调用都是干净的。
12.2.2.7. Per-invocation和Per-thread的设计哲学
Per-thread:子图共用父图的 config 拿到地址(thread_id + checkpoint_ns),然后拿地址去 checkpointer(账本)里把历史查出来
默认子图检查点命名空间为 <节点名称>:<任务ID>。两种模式的核心差异在于对 <任务ID> 的处理:
| 维度 | Per-invocation |
Per-thread |
|---|---|---|
| 命名空间 | <节点名称>:<任务ID> |
<节点名称>(去掉 :<任务ID>) |
| 设计意图 | <任务ID> 每次不同 → 每次调用状态独立 |
<节点名称> 不变 → 跨多次调用状态连续 |
| 同节点多次调用 | :<任务ID>|1, :<任务ID>|2 … |
|1, |2 … |
| 注意事项 | — | 调用顺序变化会导致检查点历史相互干扰;get_state_history() 存在 Bug,父图检查点仍记录 :<任务ID> 格式,需额外处理 |
12.2.2.8. 总结
| 特性 | Per-invocation (default) |
Per-thread |
Stateless |
|---|---|---|---|
checkpointer= |
None |
True |
False |
| 中断 | ✅ | ✅ | ❌ |
| 多轮对话 | ❌ | ✅ | ❌ |
| 多次调用不同子图 | ✅ | ⚠️最好通过节点隔离 | ✅ |
| 多次调用相同子图 | ✅ | ❌子图历史相互干扰 | ✅ |
| 观测子图检查点快照 | ⚠️可以观测,但每次调用独立 | ✅ | ❌ |
12.3. 子图流式运行
通过以下方式实现 父图+子图 流式执行:
for chunk in parent_graph.stream( {"input_text": input_text}, subgraphs=True, stream_mode=["updates"] ): print(chunk)
只需要在父图调用 stream 时传递 subgraphs=True 即可,这样父图和子图的流数据都会汇入同一个流队列,再通过命名空间区分来源。
需要注意的是:subgraphs=True 是在原有父图流数据的基础上,额外加入子图内部产生的流数据,并不会用子图数据替代父图数据。它会同时流式返回父图和所有子图的输出。
12.3.1. 分析chunk格式
chunk 格式为
(namespace, stream_mode, data)
此处的命名空间不同于检查点命名空间,是流处理内部专门用于区分父图和子图流数据的字段。
我们通过模式为 updates 的简单案例查看数据格式
示例如下
# 构建子图
# (SubgraphState 与上文定义完全相同,略)
# (subgraph_strip_node 与上文定义完全相同,略)
# (subgraph_punctuate_node 与上文定义完全相同,略)
builder = StateGraph(state_schema=SubgraphState)
builder.add_node("subgraph_strip_node", subgraph_strip_node)
builder.add_node("subgraph_punctuate_node", subgraph_punctuate_node)
builder.add_edge(START, "subgraph_strip_node")
builder.add_edge("subgraph_strip_node", "subgraph_punctuate_node")
builder.add_edge("subgraph_punctuate_node", END)
subgraph = builder.compile()
# 构建父图
class ParentState(TypedDict):
input_text: str # 输入的未清洗的文本
cleaned_text: str # 清洗后的文本
# (call_subgraph 与上文定义完全相同,略)
builder = StateGraph(state_schema=ParentState)
builder.add_node("call_subgraph", call_subgraph)
builder.add_edge(START, "call_subgraph")
builder.add_edge("call_subgraph", END)
parent_graph = builder.compile()
input_text = " LangGraph 真有意思 "
for chunk in parent_graph.stream(
{"input_text": input_text},
subgraphs=True,
stream_mode=["updates"]
):
print(chunk)
display(
Image(
parent_graph
.get_graph(xray=True)
.draw_mermaid_png()
)
)
运行结果如下
(
(
'call_subgraph:4daa7290-d5c9-ae96-849e-d2f1dafadd17',
),
'updates',
{
'subgraph_strip_node': {
'stripped_text': 'LangGraph 真有意思'
}
}
)
(
(
'call_subgraph:4daa7290-d5c9-ae96-849e-d2f1dafadd17',
),
'updates',
{
'subgraph_punctuate_node': {
'punctuated_text': 'LangGraph 真有意思。'
}
}
)
(
(),
'updates',
{
'call_subgraph': {
'cleaned_text': 'LangGraph 真有意思。'
}
}
)

流数据命名空间规则:
需要明白两个子图的 命名空间差别
| 来源 | 命名空间 | 备注 |
|---|---|---|
| 父图 | ()(空元组) |
|
| 子图(常规) | (<父图节点名>:<父图任务ID>,) |
|
| 子图(Per-thread) | (<父图节点名>,) |
与检查点命名空间一致 |
12.3.2. 多轮对话消息流处理
示例如下
# ---- 本章导入 ----
from langchain.messages import HumanMessage, SystemMessage
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 构建子图
class SubgraphState(MessagesState):
system_prompt: SystemMessage
def llm_mode(state: SubgraphState) -> SubgraphState:
system_prompt = state["system_prompt"]
messages = state["messages"]
response = model.invoke([system_prompt] + messages)
return {
"messages": [response]
}
builder = StateGraph(state_schema=SubgraphState)
builder.add_node("llm_mode", llm_mode)
builder.add_edge(START, "llm_mode")
builder.add_edge("llm_mode", END)
subgraph = builder.compile(checkpointer=True)
# 构建父图
class ParentState(TypedDict):
user_input: str
assistant_response: str
def call_subgraph(state: ParentState) -> ParentState:
user_input = state["user_input"]
response = subgraph.invoke(
{
"system_prompt": SystemMessage("你是个善解人意的助手"),
"messages": [HumanMessage(user_input)]
}
)
assistant_response = response["messages"][-1].content
return {
"assistant_response": assistant_response
}
builder = StateGraph(state_schema=ParentState)
builder.add_node("call_subgraph", call_subgraph)
builder.add_edge(START, "call_subgraph")
builder.add_edge("call_subgraph", END)
checkpointer = InMemorySaver()
parent_graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "subgraph_stream_test"}}
print("\n", "=" * 30, "-> 第一次运行结果 <-", "=" * 30)
# chunk 形如
# (('call_subgraph',), 'messages', (AIMessageChunk(content='', additional_kwargs={}, response_metadata={'model_provider': 'deepseek'}, id='lc_run--019efd65-c54e-7012-912e-29ff4f84328e', tool_calls=[], invalid_tool_calls=[], tool_call_chunks=[]), {'thread_id': 'subgraph_stream_test', 'langgraph_step': 1, 'langgraph_node': 'llm_mode', 'langgraph_triggers': ('branch:to:llm_mode',), 'langgraph_path': ('__pregel_pull', 'llm_mode'), 'langgraph_checkpoint_ns': 'call_subgraph|llm_mode:55577c3e-22da-877a-c77b-f16bdbcd2c6c', 'checkpoint_ns': 'call_subgraph:9c469391-f094-1c1b-c5d7-6578f1603151', 'ls_provider': 'deepseek', 'ls_model_name': 'deepseek-v4-flash', 'ls_model_type': 'chat', 'ls_temperature': None}))
for chunk in parent_graph.stream(
{
"user_input": "花儿为什么这样红?"
},
config=config,
subgraphs=True,
stream_mode=["messages"]
):
print(chunk[2][0].content, end="", flush=True)
print("\n", "=" * 30, "-> 第二次运行结果 <-", "=" * 30)
for chunk in parent_graph.stream(
{
"user_input": "刚才我们聊了什么?"
},
config=config,
subgraphs=True,
stream_mode=["messages"]
):
print(chunk[2][0].content, end="", flush=True)
display(
Image(
parent_graph
.get_graph(xray=True)
.draw_mermaid_png()
)
)
运行结果如下
============================== -> 第一次运行结果 <- ==============================
“花儿为什么这样红”是一个经典的问题,可以从不同角度来理解:
1. **自然科学的视角**:花朵的颜色主要由花瓣中的色素决定,尤其是**花青素**(anthocyanins)。花青素在酸性环境下呈现红色,在碱性环境下偏向蓝色。某些花朵(如玫瑰、山茶花)细胞液偏酸性,因此吸收阳光中的蓝绿光,反射红光,让我们看到鲜艳的红色。此外,红色也能吸引蜂鸟等传粉动物,帮助植物繁殖。
2. **文化艺术的视角**:这是一首传唱已久的中国民歌(《花儿为什么这样红》),由雷振邦作曲,是电影《冰山上的来客》的插曲。歌词用“花儿”比喻纯洁、热烈的情感,红色象征着爱情、勇气或革命的赤诚。
3. **诗意隐喻**:就像“花儿为什么这样红”可以指代生命的热情、青春的绽放,或者经风雨后更加绚烂的坚韧。红色的花朵也常被赋予“美好而短暂”的哲学意味。
如果你指的是特定语境(比如学生物的朋友、教孩子、写赏析),我可以把回答调整得更具体。🌹
============================== -> 第二次运行结果 <- ==============================
我们刚才聊了“花儿为什么这样红”这个话题。我分别从自然科学(花青素与酸碱性)、文化艺术(经典民歌《花儿为什么这样红》)以及诗意隐喻三个角度做了解答。如果你有更多想深入的方向,随时告诉我~
实际测试可以看到模型返回的消息是 逐token 返回的。

12.4. 子图动态路由
在子图中可以通过 Command 动态路由到父图节点,只要将参数 graph 的值设置为 Command.PARENT 即可,如下所示
return Command(
update={
"fruits": fruits,
"vegetables": vegetables
},
goto="router_node",
graph=Command.PARENT
)
示例如下
# ---- 本章导入 ----
from langgraph.types import Command
# ==================== 子图 ====================
class SubState(TypedDict):
data: str
def sub_node(state: SubState) -> Command:
"""子图节点:执行后动态路由回父图的 parent_router"""
logger.info("[子图] sub_node 执行")
return Command(
update={"data": state["data"] + " → 子图"},
goto="node_b", # 路由到父图节点
graph=Command.PARENT, # 指定目标为父图
)
sub_builder = StateGraph(state_schema=SubState)
sub_builder.add_node("sub_node", sub_node)
sub_builder.add_edge(START, "sub_node")
sub_graph = sub_builder.compile()
# ==================== 父图 ====================
class ParentState(TypedDict):
data: str
def node_a(state: ParentState) -> ParentState:
print("[父图] node_a 执行")
return {"visited_a": True}
def node_b(state: ParentState) -> ParentState:
print("[父图] node_b 执行")
return {"visited_b": True}
parent_builder = StateGraph(state_schema=ParentState)
parent_builder.add_node(
"sub_graph", sub_graph
)
parent_builder.add_node("node_a", node_a)
parent_builder.add_node("node_b", node_b)
parent_builder.add_edge(START, "sub_graph")
parent_builder.add_edge("node_a", "node_b")
parent_builder.add_edge("node_b", END)
parent_graph = parent_builder.compile()
result = parent_graph.invoke({"data": "初始"})
print(f"\n最终结果: {result}")
from IPython.display import display
display(parent_graph)
要点:
-
子图通过
Command(goto="parent_router", graph=Command.PARENT)将执行流动态路由回父图的指定节点 -
子图返回值类型注解不能用
Command[Literal["parent_router"]]的形式,因为子图并不知道父图节点,静态类型校验会报错ValueError: Found edge ending at unknown node `parent_router` -
添加子图节点时通过
destinations声明下游节点,便于渲染图结构时正确描绘拓扑关系builder.add_node( "sub_graph", sub_graph, destinations=("parent_router",) )不加
destinations不会影响动态路由,只是渲染的图结构不能正确描绘拓扑。
13. 运行图设计模式
本章重点:熟悉图的六大设计模式的区别、适用场景
13.1. 概述
LangGraph 官方总结了常用的状态图设计模式,如下
| 模式 | 图结构 | 运行时动态性 | 核心 LangGraph 能力 |
|---|---|---|---|
Prompt Chaining |
顺序链 | 低 | 静态边、条件边 |
Parallelization |
固定 Fan-out/Fan-in |
低 | 并行超步、汇聚 |
Routing |
条件分支 | 中 | 结构化输出、条件边 |
Orchestrator-worker |
动态 Fan-out/Fan-in |
高 | Send、WorkerState、Reducer |
Evaluator-optimizer |
反馈循环 | 中 | 条件边、循环、反馈状态 |
Agent |
自主决策循环 | 最高 | MessagesState、工具调用、ToolNode |
13.2. 实现
13.2.1. Prompt Chaining:提示词链
每个人物环环相扣的时候适用
13.2.1.1. 核心思想
将一个复杂任务拆分成若干个顺序执行的小任务:
输入
↓
任务 A
↓
任务 B
↓
任务 C
↓
输出
后一个节点依赖前一个节点的结果。
适合:
- 翻译 → 校对 → 润色
- 生成内容 → 检查一致性 → 修订
- 提取信息 → 分类 → 格式化
- 需求分析 → 生成代码 → 代码解释
官方示例是:
生成笑话
↓
检查是否合格
├─ 合格 → END
└─ 不合格 → 改进笑话 → 最终润色 → END
所以 Prompt Chaining 不一定只是简单的直线,也可以在中间加入一个 Gate,质量门控节点。
Prompt Chaining 的核心是:
将复杂任务拆解为可独立验证的阶段,并把每一阶段的结果记录下来向后传递。
它的优点是执行过程稳定且易于调试;缺点是流程固定,无法处理未知数量或高度动态的任务。
13.2.1.2. 示例
示例如下
# ---- 本章导入 ----
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langchain.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek
from dotenv import load_dotenv
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 图状态
class OverAllState(TypedDict):
topic: str
joke: str
improved_joke: str
final_joke: str
# 节点
def generate_joke(state: OverAllState) -> OverAllState:
"""第一次调用大模型,生成初始笑话"""
msg = model.invoke(
[HumanMessage(content=f"写一个关于“{state['topic']}”的简短笑话")]
)
return {"joke": msg.content}
def check_punchline(state: OverAllState) -> Literal["pass", "no_pass"]:
"""判断笑话中是否包含包袱"""
# 简单判断:笑话中是否包含问号或感叹号
if "?" in state["joke"] or "!" in state["joke"]:
return "pass"
return "no_pass"
def improve_joke(state: OverAllState) -> OverAllState:
"""第二次调用大模型,通过添加双关语改进笑话"""
msg = model.invoke(
[HumanMessage(content=f"通过添加双关语,让下面这个笑话变得更有趣:\n{state['joke']}")]
)
return {"improved_joke": msg.content}
def polish_joke(state: OverAllState) -> OverAllState:
"""第三次调用大模型,对笑话进行最终润色"""
msg = model.invoke(
[HumanMessage(content=f"为下面这个笑话添加一个出人意料的反转:\n{state['improved_joke']}")]
)
return {"final_joke": msg.content}
# 构建工作流
builder = StateGraph(state_schema=OverAllState)
# 添加节点
builder.add_node("generate_joke", generate_joke)
builder.add_node("improve_joke", improve_joke)
builder.add_node("polish_joke", polish_joke)
# 添加边,连接各个节点
builder.add_edge(START, "generate_joke")
builder.add_conditional_edges(
"generate_joke",
check_punchline,
{
"no_pass": "improve_joke",
"pass": END,
},
)
builder.add_edge("improve_joke", "polish_joke")
builder.add_edge("polish_joke", END)
# 编译工作流
graph = builder.compile()
# 调用工作流
response = graph.invoke({"topic": "猫"})
print(response)
# 绘制拓扑结构
from IPython.display import display
display(graph)
运行结果如下
{
'topic': '猫',
'joke': '一只猫去面试,面试官问:“你有什么特长?” \n猫淡定地回答:“我能用尾巴画圆。” \n面试官惊讶:“真的吗?演示一下。” \n猫转身,尾巴甩了几下,画出一个完美的圆。 \n面试官鼓掌:“太好了!你被录取了!” \n第二天,猫上班了,发现它被送到了数学实验室——负责画π。',
'improved_joke': '加了一点数学梗和双关后,版本如下:\n\n---\n\n一只猫去面试,面试官问:“你有什么特长?” \n猫淡定地回答:“我能用尾巴画圆。” \n面试官惊讶:“真的吗?演示一下。” \n猫转身,尾巴甩了几下,画出一个完美的圆。 \n面试官鼓掌:“太好了!你被录取了!” \n\n第二天,猫上班了,发现它被送到了数学实验室——**负责画π**。 \n\n猫叹了口气,说:“**原来我的‘圆’满人生,终究逃不过π的‘鼠’命。”** \n\n(双关解析: \n1. “π”音同“派”,既指圆周率,也暗示“被派遣”去画圆。 \n2. “鼠命”谐音“宿命”,同时猫抓老鼠,这里暗指猫被困在数学“鼠”洞(无限不循环小数)里,永远画不完。)',
'final_joke': '---\n\n**反转版:** \n第二天,猫上班了,发现它被送到了数学实验室——**负责画π**。 \n\n猫叹了口气,说:“原来我的‘圆’满人生,终究逃不过π的‘鼠’命。” \n\n话音刚落,实验室主任推门进来,是个戴眼镜的仓鼠。仓鼠拍了拍猫的尾巴,微笑道:“别担心,我们用的是**有理数**——你每天只用画前100位,剩下的**无限循环**,由我们**鼠**类负责演算。” \n\n猫愣了一下,突然跳起来:“等等!你是说——我画圆,你们算剩下的尾巴?” \n\n仓鼠点头:“对,这叫……**π的‘分工’合作**。” \n\n猫沉默三秒,忽然掏出手机:“那我得先给抓老鼠的同行打个电话——告诉他们,**‘有理’走遍天下,无理寸步难行**。” \n\n(双关补充: \n1. **“有理数”** 双关:既指数学上有理数(可终止或循环),也暗示“有道理的反转”——猫的“无限宿命”被仓鼠强行打断。 \n2. **“剩下的尾巴”** 双关:猫的尾巴画圆,但仓鼠们负责“补完”无限小数,字面与数学双关。 \n3. **“有理走遍天下”** 化用俗语,暗讽“有理数”才是猫的出路,而“无理”的π已变成职场协作梗。)'
}

13.2.2. Parallelization:并行化
每个任务不同且独立的时候适用
13.2.2.1. 核心思想
把相互独立的任务同时执行,最后汇总结果:
┌→ 任务 A ─┐
输入 → Fan-out ──┼→ 任务 B ─┼→ 聚合 → 输出
└→ 任务 C ─┘
官方将并行化分成两种用途:
-
任务拆分
- 一个节点检查关键词
- 一个节点检查格式
- 一个节点检查事实准确性
-
多次独立判断
- 多个模型或多个提示词分别评分
- 最后投票、平均或综合判断
前者主要提升速度,后者主要提升置信度。
13.2.2.2. 示例
示例如下
# ---- 本章导入 ----
from typing import TypedDict
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 图状态
class OverAllState(TypedDict):
topic: str
joke: str
story: str
poem: str
combined_output: str
# 节点
def call_model_1(state: OverAllState) -> OverAllState:
"""第一次调用大模型,生成笑话"""
msg = model.invoke(
[HumanMessage(content=f"写一个关于“{state['topic']}”的简短的笑话")]
)
return {"joke": msg.content}
def call_model_2(state: OverAllState) -> OverAllState:
"""第二次调用大模型,生成故事"""
msg = model.invoke(
[HumanMessage(content=f"写一个关于“{state['topic']}”的简短的故事")]
)
return {"story": msg.content}
def call_model_3(state: OverAllState) -> OverAllState:
"""第三次调用大模型,生成诗歌"""
msg = model.invoke(
[HumanMessage(content=f"写一首关于“{state['topic']}”的简短的诗")]
)
return {"poem": msg.content}
def aggregator(state: OverAllState) -> OverAllState:
"""将笑话、故事和诗歌合并为单一输出"""
combined = f"下面是一个关于“{state['topic']}”的故事、笑话和诗歌!\n\n"
combined += f"故事:\n{state['story']}\n\n"
combined += f"笑话:\n{state['joke']}\n\n"
combined += f"诗歌:\n{state['poem']}"
return {"combined_output": combined}
# 构建工作流
builder = StateGraph(state_schema=OverAllState)
# 添加节点
builder.add_node("call_model_1", call_model_1)
builder.add_node("call_model_2", call_model_2)
builder.add_node("call_model_3", call_model_3)
builder.add_node("aggregator", aggregator)
# 添加边,连接各个节点
builder.add_edge(START, "call_model_1")
builder.add_edge(START, "call_model_2")
builder.add_edge(START, "call_model_3")
builder.add_edge(["call_model_1", "call_model_2", "call_model_3"], "aggregator")
builder.add_edge("aggregator", END)
graph = builder.compile()
# 调用工作流
response = graph.invoke({"topic": "猫"})
print(response)
display(graph)
运行结果如下
{
'topic': '猫',
'joke': '“为什么猫总是赢不了电脑游戏?”\n“因为鼠标(鼠)一出现,它就光顾着抓屏(屏幕)了……”',
'story': '# 猫的故事\n\n老张退休那年,在楼下捡到一只瘦骨嶙峋的橘猫。\n\n他本不爱猫,但看它可怜,便每天在楼道口放一碗剩饭。猫很警惕,非要等他走远了才肯吃。就这样喂了三个月,猫终于允许他靠近一臂的距离。\n\n半年后的一天夜里,老张突发心梗,倒在客厅地板上,手机摔在茶几底下,怎么也够不着。意识模糊间,他感到一团温热的东西贴上了他的胸口——是那只橘猫。它不知怎么钻进了他家门。\n\n猫用头蹭他的手,蹭他的脸,见他没反应,便冲进屋里的每一个房间,歇斯底里地叫。最后它跳上窗台,用爪子疯狂拍打玻璃,叫声惊动了楼下乘凉的邻居。\n\n120赶到时,老张已经在地上躺了将近一个小时。医生说,再晚十分钟,就悬了。\n\n出院后,橘猫正式住进了老张家。老张给它取名“富贵”,逢人就说:“我家富贵啊,有九条命,分了我一条。”\n\n富贵永远听不懂这些话。它只是每天黄昏,准时跳上老张的膝盖,把一团温暖塞进他的怀里,呼噜呼噜地睡去。',
'poem': '## 《猫》\n\n黄昏被毛茸茸地踩碎,\n肚皮起伏如钟摆,\n眼睫间,星辰坠落。\n当琥珀瞳孔转动,\n整片夜色都来蜷卧。',
'combined_output': '下面是一个关于“猫”的故事、笑话和诗歌!\n\n故事:\n# 猫的故事\n\n老张退休那年,在楼下捡到一只瘦骨嶙峋的橘猫。\n\n他本不爱猫,但看它可怜,便每天在楼道口放一碗剩饭。猫很警惕,非要等他走远了才肯吃。就这样喂了三个月,猫终于允许他靠近一臂的距离。\n\n半年后的一天夜里,老张突发心梗,倒在客厅地板上,手机摔在茶几底下,怎么也够不着。意识模糊间,他感到一团温热的东西贴上了他的胸口——是那只橘猫。它不知怎么钻进了他家门。\n\n猫用头蹭他的手,蹭他的脸,见他没反应,便冲进屋里的每一个房间,歇斯底里地叫。最后它跳上窗台,用爪子疯狂拍打玻璃,叫声惊动了楼下乘凉的邻居。\n\n120赶到时,老张已经在地上躺了将近一个小时。医生说,再晚十分钟,就悬了。\n\n出院后,橘猫正式住进了老张家。老张给它取名“富贵”,逢人就说:“我家富贵啊,有九条命,分了我一条。”\n\n富贵永远听不懂这些话。它只是每天黄昏,准时跳上老张的膝盖,把一团温暖塞进他的怀里,呼噜呼噜地睡去。\n\n笑话:\n“为什么猫总是赢不了电脑游戏?”\n“因为鼠标(鼠)一出现,它就光顾着抓屏(屏幕)了……”\n\n诗歌:\n## 《猫》\n\n黄昏被毛茸茸地踩碎,\n肚皮起伏如钟摆,\n眼睫间,星辰坠落。\n当琥珀瞳孔转动,\n整片夜色都来蜷卧。'
}

13.2.3. Routing:路由
需要根据输入情况走不同分支时适用
13.2.3.1. 核心思想
先识别输入类型,再把请求送到专门的处理流程:
┌→ 退款流程
输入 → 路由判断节点 ─┼→ 售价咨询流程
└→ 商品推荐流程
它适合不同类型请求需要不同处理逻辑的场景,例如:
- 售前、售后、退款分类
- 普通问答、代码生成、数据分析
- 文本、图片、音频请求分类
- 不同领域知识库的选择
13.2.3.2. 设计原则
最佳实践:
LLM节点负责语义判断;- 路由函数负责把结构化结果映射到节点;
- 业务节点负责真正处理任务。
不要让路由 LLM 直接返回任意节点名,否则模型输出和图结构会过度耦合,也难以进行类型检查和异常兜底。
13.2.3.3. 示例
示例如下
# ---- 本章导入 ----
from pydantic import BaseModel, Field
from typing import Literal,TypedDict
from langchain.messages import HumanMessage, SystemMessage
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 定义用于结构化输出的 Schema,作为路由判断依据
class Route(BaseModel):
step: Literal["poem", "story", "joke"] = Field(
None,
description="路由流程中的下一执行步骤",
)
# 为大模型添加结构化输出能力
router = model.with_structured_output(Route)
# 图状态
class OverAllState(TypedDict):
input: str
decision: str
output: str
# 节点
def model_call_1(state: OverAllState) -> OverAllState:
"""生成故事"""
result = model.invoke(
[HumanMessage(content=state["input"])]
)
return {"output": result.content}
def model_call_2(state: OverAllState) -> OverAllState:
"""生成笑话"""
result = model.invoke(
[HumanMessage(content=state["input"])]
)
return {"output": result.content}
def model_call_3(state: OverAllState) -> OverAllState:
"""生成诗歌"""
result = model.invoke(
[HumanMessage(content=state["input"])]
)
return {"output": result.content}
def model_call_router(state: OverAllState) -> OverAllState:
"""将用户输入路由到合适的节点"""
# 调用具有结构化输出能力的大模型,完成路由判断
decision = router.invoke(
[
SystemMessage(
content=(
"根据用户的请求,将其路由到 story、joke 或 poem。"
"请求编写故事时返回 story,请求编写笑话时返回 joke,"
"请求编写诗歌时返回 poem。"
)
),
HumanMessage(content=state["input"]),
]
)
return {"decision": decision.step}
# 条件边函数:根据路由决策选择下一个节点
def route_decision(
state: OverAllState
) -> Literal[
"model_call_1",
"model_call_2",
"model_call_3",
END
]:
# 返回接下来要执行的节点名称
if state["decision"] == "story":
return "model_call_1"
elif state["decision"] == "joke":
return "model_call_2"
elif state["decision"] == "poem":
return "model_call_3"
return END
# 构建工作流
builder = StateGraph(OverAllState)
# 添加节点
builder.add_node("model_call_1", model_call_1)
builder.add_node("model_call_2", model_call_2)
builder.add_node("model_call_3", model_call_3)
builder.add_node("model_call_router", model_call_router)
# 添加边,连接各个节点
builder.add_edge(START, "model_call_router")
builder.add_conditional_edges(
"model_call_router",
route_decision,
{
# route_decision 返回的名称:接下来要执行的节点名称
"model_call_1": "model_call_1",
"model_call_2": "model_call_2",
"model_call_3": "model_call_3",
},
)
builder.add_edge("model_call_1", END)
builder.add_edge("model_call_2", END)
builder.add_edge("model_call_3", END)
# 编译工作流
graph = builder.compile()
# 调用工作流
state = graph.invoke({"input": "写一个关于猫的笑话"})
print(state["output"])
# 显示工作流图
display(graph)
运行结果如下
这是一个关于猫的冷笑话,希望你喜欢:
**猫为什么不喜欢洗澡?**
**因为它不想变成“落汤包”(肉包)。**
(笑点解析:谐音梗,把“落汤猫”说成“落汤包”,而猫胖胖的确实有点像包子。)

13.2.4. Orchestrator-worker:编排器—工作节点
一个大任务拆分成多个独立相同的小任务时适用(使用Send
13.2.4.1. 核心思想
编排器先分析任务,动态生成若干子任务,再创建对应数量的 Worker:
┌→ Worker 1 ─┐
输入 → Orchestrator ─────┼→ Worker 2 ─┼→ Synthesizer → 输出
├→ Worker 3 ─┤
└→ Worker N ─┘
它通常包含三个组件:
| 组件 | 职责 |
|---|---|
Orchestrator(编排器) |
分析任务、制定计划、生成子任务列表 |
Workers(工作节点) |
分别处理各子任务,通常并行执行 |
Synthesizer(聚合节点) |
汇总所有 Worker 结果,生成最终答案 |
适合任务数量无法预先确定的场景,例如:
- 根据主题动态生成报告章节
- 修改未知数量的代码文件
- 为多个数据源分别执行分析
- 将长文档动态拆分为若干部分处理
13.2.4.2. 和Parallelization的区别
| 模式 | 任务数量 |
|---|---|
Parallelization |
编译图时已确定(静态) |
Orchestrator-worker |
运行时动态计算 |
13.2.4.3. 本质
Orchestrator-worker 本质上就是 Map-Reduce,我们在前面的课程中已经实现过了
Orchestrator:生成 Map 任务
Send:动态分发
Worker:执行 Map
Reducer:合并中间结果
Synthesizer:执行 Reduce
13.2.4.4. 示例
示例如下
# ---- 本章导入 ----
from typing import Annotated, List, TypedDict
from collections.abc import Sequence
from langchain_core.messages import SystemMessage, HumanMessage
from langgraph.types import Send
from operator import add
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 结构化输出的 Schema
class Section(BaseModel):
name: str = Field(
description="报告的章节名称",
)
description: str = Field(
description="本节主题和核心思想的概述",
)
class Sections(BaseModel):
sections: List[Section] = Field(
description="报告的章节列表",
)
# 编排器
planner = model.with_structured_output(Sections)
# 图状态
class OverAllState(TypedDict):
topic: str # 报告主题
sections: list[Section] # 报告章节列表
completed_sections: Annotated[
list, add
] # 所有工作节点并行写入该字段
final_report: str # 最终报告
# 工作节点状态
class WorkerState(TypedDict):
section: Section
completed_sections: Annotated[list, add]
# 节点
def orchestrator(state: OverAllState) -> OverAllState:
"""编排器:生成报告编写计划"""
# 生成报告章节规划
report_sections = planner.invoke(
[
SystemMessage(content="为这份报告生成一个章节规划。"),
HumanMessage(content=f"报告主题如下:{state['topic']}"),
]
)
return {"sections": report_sections.sections}
def model_call(state: WorkerState) -> WorkerState:
"""工作节点:编写报告中的一个章节"""
# 生成章节内容
section = model.invoke(
[
SystemMessage(
content=(
"根据提供的章节名称和章节描述编写报告内容。"
"不要在每个章节前添加额外的开场说明。"
"使用 Markdown 格式。"
)
),
HumanMessage(
content=(
f"章节名称:{state['section'].name}\n"
f"章节描述:{state['section'].description}"
)
),
]
)
# 将生成的章节写入已完成章节列表
return {"completed_sections": [section.content]}
def synthesizer(state: OverAllState) -> OverAllState:
"""将所有章节合成为完整报告"""
# 获取所有已完成的章节
completed_sections = state["completed_sections"]
# 将已完成章节拼接为最终报告
completed_report_sections = "\n\n---\n\n".join(completed_sections)
return {"final_report": completed_report_sections}
# 条件边函数:为规划中的每个章节创建一个 model_call 工作节点
def assign_workers(state: OverAllState) -> Sequence[Send]:
"""为规划中的每个章节分配一个工作节点"""
# 使用 Send API 并行启动各个章节的编写任务
return [Send("model_call", {"section": s}) for s in state["sections"]]
# 构建工作流
builder = StateGraph(state_schema=OverAllState)
# 添加节点
builder.add_node("orchestrator", orchestrator)
builder.add_node("model_call", model_call)
builder.add_node("synthesizer", synthesizer)
# 添加边,连接各个节点
builder.add_edge(START, "orchestrator")
builder.add_conditional_edges(
"orchestrator",
assign_workers,
["model_call"],
)
builder.add_edge("model_call", "synthesizer")
builder.add_edge("synthesizer", END)
# 编译工作流
graph = builder.compile()
# 调用工作流
state = graph.invoke(
{"topic": "撰写一份关于大语言模型缩放定律的报告"}
)
from IPython.display import Markdown, display
# 显示工作流图
display(graph)
# 渲染报告
Markdown(state["final_report"])
运行结果如下

引言
缩放定律是深度学习与人工智能领域中的核心理论基础之一。它揭示了模型性能与关键资源(如参数量、数据规模、计算算力)之间普遍存在的幂律关系,即当模型规模、训练数据量或计算预算成比例扩大时,模型的表现能力会呈现出可预测性的提升。这一发现最早源自对语言模型的系统性实证研究,随后在计算机视觉、多模态学习等众多任务中被广泛验证,深刻改变了现代AI研究的范式。
缩放定律的重要性体现在多个层面。从学术研究角度看,它不仅为“更大的模型、更多的数据”提供了科学依据,还催生了以GPT、PaLM、LLaMA为代表的大规模预训练模型浪潮。从工程实践角度看,缩放定律为资源分配提供了量化指导——研究人员可以据此预测模型性能对计算预算的依赖关系,从而制定更高效的训练策略。此外,在探索模型能力涌现性的前沿领域,缩放定律更是理解大语言模型行为边界的关键工具。因此,深入剖析缩放定律的理论内涵、实验验证与局限性,对于把握AI发展的底层逻辑具有不可替代的价值。
本报告将系统梳理缩放定律的研究脉络。后续章节首先阐述语言模型缩放的基本原理与核心公式;随后通过经典实验案例展示缩放定律的实证表现,并讨论其在不同任务与架构中的泛化能力;在此基础上,分析缩放定律的物理极限与开放挑战,包括数据效率、规模收益递减等问题;最后总结缩放定律对AI未来发展的战略启示。全文力求在学术严谨性与可读性之间取得平衡,为读者提供一幅关于缩放定律的全局图景。
缩放定律的基本概念
…(此处示例文本共 145 行,与 LangGraph 无关,已省略)
13.2.5. Evaluator-optimizer:评估器—优化器
需要保证任务质量时适用(用循环一直到达标再出循环
13.2.5.1. 核心思想
一个节点生成结果,另一个节点评估结果;不合格就携带反馈重新生成:
┌──────────────────────┐
↓ │
输入 → Generator → Evaluator ────┤
├─ 不合格 ─┘
└─ 合格 → END
适合:
- 翻译质量迭代
- 代码生成与代码审查
- 文案生成与合规检查
SQL生成与语法验证- 报告生成与事实检查
- 人工审批后修改
这种模式适用于存在明确质量标准,但通常需要多轮修改才能满足标准的任务。评估者既可以是 LLM,也可以是规则、测试程序或人类。
13.2.5.2. 必要的控制
为了避免无限循环,必须引入递归限制,可以是
- 优雅退出的主动方法
- 异常中断的被动方法
我们在介绍循环结构时已经系统讲解过了。
13.2.5.3. 示例
示例如下
recursion_limit:LangGraph 图执行的最大超级步数(super-step)上限,属于 invoke 的 config 参数。
super-step:一次完整的图执行迭代,从一个节点开始执行,走完所有节点、条件跳转,直到再次回到某个节点(形成循环),算 1 个 recursion 计数。 每次图走完一轮循环,LangGraph 内部计数器 +1;计数器达到 recursion_limit,直接抛出
GraphRecursionError,终止图运行。
# ---- 本章导入 ----
from langchain_core.messages import HumanMessage
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 图状态
class OverAllState(TypedDict):
joke: str
topic: str
feedback: str
funny_or_not: str
# 结构化输出 Schema
class Feedback(BaseModel):
grade: Literal["好笑", "不好笑"] = Field(
description="判断这个笑话是否好笑。",
)
feedback: str = Field(
description="如果笑话不好笑,请给出具体的改进建议。",
)
evaluator = model.with_structured_output(Feedback)
# 节点:生成笑话
def model_call_generator(state: OverAllState) -> OverAllState:
if state.get("feedback"):
prompt = f"""
写一个关于“{state['topic']}”的笑话。
请参考下面的改进建议:
{state['feedback']}
"""
else:
prompt = f"写一个关于“{state['topic']}”的笑话"
msg = model.invoke([HumanMessage(content=prompt)])
return {"joke": msg.content}
# 节点:评估笑话
def model_call_evaluator(state: OverAllState) -> OverAllState:
grade = evaluator.invoke([HumanMessage(content=f"""
请评估下面这个笑话是否好笑:
{state['joke']}
""")])
return {
"funny_or_not": grade.grade,
"feedback": grade.feedback,
}
# 条件路由
def route_joke(state: OverAllState) -> Literal["accept", "reject_and_feedback", END]:
if state["funny_or_not"] == "好笑":
return "accept"
elif state["funny_or_not"] == "不好笑":
return "reject_and_feedback"
return END
# 构建图
builder = StateGraph(state_schema=OverAllState)
builder.add_node("model_call_generator", model_call_generator)
builder.add_node("model_call_evaluator", model_call_evaluator)
builder.add_edge(START, "model_call_generator")
builder.add_edge("model_call_generator", "model_call_evaluator")
builder.add_conditional_edges(
"model_call_evaluator",
route_joke,
{
"accept": END,
"reject_and_feedback": "model_call_generator",
},
)
graph = builder.compile()
# 执行
try:
state = graph.invoke({"topic": "猫"}, config={"recursion_limit":5})
print("最终笑话:")
print(state["joke"])
print(f"\n评估结果:{state['funny_or_not']}")
except Exception as e:
print(f"达到最大递归次数或出错:{e}")
# 绘图(jupyter)
from IPython.display import display, Image
img = graph.get_graph().draw_mermaid_png()
display(Image(img))
运行结果如下
# 关于猫的笑话
一只猫去应聘当保安。
面试官问:“你有什么特长?”
猫说:“我精通‘猫步’,走路悄无声息,可以抓到任何小偷。”
面试官点头:“不错。那面对危险情况,你会怎么处理?”
猫淡定地说:“我会用‘九条命’精神,即使失败一次,还有八次机会总结经验。”
面试官很满意:“那我们决定录用你。不过,我们有个夜班岗位,你介意夜间工作吗?”
猫“喵”了一声:“正合我意,白天我要晒着太阳睡觉的。”
然后面试官又问:“最后一个问题——你如果发现仓库里有老鼠,会怎么处置?”
猫瞪大了眼睛:“老鼠?那是我请来的线人!没有老鼠,我怎么假装抓到战绩,让你们给我加罐头?”
面试官:“……你被开除了。”
猫耸耸肩:“无所谓,反正我还有八条命去找下一份工作。”

13.2.6. Agent:智能体循环
不属于LangGraph,而是由智能体自行决定调用什么工具走什么流程(类似现在的agent 小龙虾这些
13.2.6.1. 核心思想
前面的运行图设计模式都属于 Workflow,Agent 与 Workflow 最大的不同是:
开发者不再预先确定每一步具体执行什么,而是让
LLM根据当前消息和工具结果决定下一步行为。
基本拓扑是:
┌─────────────────┐
↓ │
输入 → LLM 决策 → 是否调用工具
├─ 是 → Tool ┘
└─ 否 → END
Agent 适合问题求解步骤和工具调用顺序无法预先确定的场景。开发者仍然负责提供工具集合、系统提示词、权限和边界,但具体执行过程由模型动态决定。
13.2.6.2. 示例
示例如下
# ---- 本章导入 ----
from typing import Literal
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.prebuilt.tool_node import ToolNode
from langchain.tools import tool
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
@tool(parse_docstring=True)
def get_weather(city: str) -> str:
"""
根据城市查询当日天气
Args:
city: 城市名称
"""
return f"{city} 今天天气不错"
tools = [get_weather]
model_with_tools = model.bind_tools(tools=tools)
def model_node(state: MessagesState) -> MessagesState:
messages = state["messages"]
response = model_with_tools.invoke(input=messages)
return {
"messages": [response]
}
# (router 与上文定义完全相同,略)
builder = StateGraph(state_schema=MessagesState)
builder.add_node("model_node", model_node)
builder.add_node("tool_node", ToolNode(tools=tools))
builder.add_edge(START, "model_node")
builder.add_conditional_edges("model_node", router, path_map=["tool_node", END])
builder.add_edge("tool_node", "model_node")
graph = builder.compile()
response = graph.invoke({"messages": [HumanMessage(content="今天北京天气怎么样?")]})
for msg in response["messages"]:
msg.pretty_print()
display(graph)
运行结果如下
================================ Human Message =================================
今天北京天气怎么样?
================================== Ai Message ==================================
好的,我来帮你查询一下今天北京的天气情况。
Tool Calls:
get_weather (call_00_3kFtzQHjGl7VJkfBi5280445)
Call ID: call_00_3kFtzQHjGl7VJkfBi5280445
Args:
city: 北京
================================= Tool Message =================================
Name: get_weather
北京 今天天气不错
================================== Ai Message ==================================
今天北京的**天气不错**哦!☀️
看起来是个适合出门活动的好天气,不用太担心下雨或恶劣天气的问题。如果你有具体的户外活动计划,应该会比较顺利。
请问还有其他需要帮忙的吗?😊

本文来自博客园,作者:不吃紫菜,遵循CC 4.0 BY-SA版权协议,
转载请附上原文出处链接:https://www.cnblogs.com/buchizicai/p/23594645及本声明;
本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

上篇把图建起来,下篇讲它怎么在生产里跑:用户要中途确认、服务重启不能丢进度、前端要一个字一个字吐出来、多个 Agent 各管一摊。5 章覆盖动态中断与人在回路、ToolNode 工具接法、stream / astream / astream_events 三种流式粒度、子图嵌入与 compile(checkpointer=True) 导致的历史串味、以及六大运行图设计模式。
浙公网安备 33010602011771号