概述
什么是可恢复执行
在工作流、Agent、任务编排系统里,可恢复执行 Durable Execution 指的是:
把一次任务执行过程中的关键进度、状态、结果保存到可靠存储中,使任务可以在中断、失败、等待外部输入后继续执行。
它解决的是"执行过程能否恢复"的问题。
例如:
text
普通执行:
开始 -> A -> B -> C
如果执行到 B 后程序挂了,重启后可能只能从 A 重新开始。
可恢复执行:
开始 -> A 保存检查点
-> B 保存检查点
-> C
如果执行到 B 后挂了,恢复时可以从已保存的位置继续。
所以它关注的不是"数据长期保存"本身,而是:
执行过程本身具有可恢复性。
它关心的是:系统能否记住"任务执行到了哪里、当前状态是什么、接下来应该继续执行什么"。
LangGraph的持久化机制
什么是LangGraph的持久化机制
LangGraph 的持久化机制 (Persistence)可以理解为:
在图执行过程中,将每个关键阶段的图状态保存为检查点,并按照线程进行组织。
这里的"持久化"并不是简单地把某个变量保存到文件里,而是保存一次图执行所需的状态信息,使得未来可以基于检查点继续执行。
LangGraph 的持久化机制围绕以下几个问题展开:
-
保存什么?
保存图在某个时刻的状态快照,也就是
Checkpoint。 -
保存到哪里?
保存到
Checkpointer管理的存储后端中。 开发时可以使用基于内存的InMemorySaver,生产环境中通常会使用数据库等可靠存储,如PostgresSaver。 -
如何区分不同会话?
通过
thread_id区分不同的执行线程。 同一个thread_id表示同一条持久化执行线,也可以理解为同一个会话。 -
开发者看到的是什么?
开发者通常不会直接操作底层
Checkpoint,而是通过get_state()和get_state_history()查看Checkpoint的开发者视图StateSnapshot。
因此,本章后续会围绕四个核心问题展开:
markdown
如何启用可恢复执行?
如何配置持久化模式?
如何查看历史检查点?
如何利用检查点进行恢复、回放和分叉?
核心组件
本节对核心组件进行梳理,不必死记硬背,用到查阅即可。
LangGraph 的持久化机制依赖一系列核心组件,如下:
| 概念 | 作用 |
|---|---|
State |
用户定义的图状态结构,负责节点间信息传递。LangGraph 会将 State 转换为底层 Channel ;运行时真正负责节点间通信的是 Channel。 |
Channel |
LangGraph 用于节点间通信的底层机制,节点从 Channel 读数据、向 Channel 写更新;既包括承载 State 字段更新的状态通道,也包括用于分支、任务调度等行为的内部通道。 |
Checkpoint |
检查点,超步边界上的底层状态快照,保存 Channel 的值、版本等信息。 |
CheckpointMetadata |
与 Checkpoint 关联的元数据,如超步编号 step 、父检查点 ID parents 等。 |
Checkpointer |
检查点存储器,负责存储检查点、元数据、配置等信息。 |
thread |
此处的线程不同于操作系统的线程,是指 LangGraph 中一条逻辑上的、可持久化的执行线,也可以理解为会话。一个会话可以包含多次调用,并在执行过程中生成多个检查点。因此,要将多次调用组织在一个会话中,必须为该 thread 配置 checkpointer。 |
thread_id |
thread 的唯一标识,用于区分不同的会话。复用同一个 thread_id ,就表示多次调用共享同一条持久化执行线,也就是共享同一个会话历史。此处的 thread/thread_id 和 LangChain Agent 中提到的 thread/thread_id 是同一概念。 |
checkpoint_ns |
检查点命名空间 namespace ,用于区分父图和子图的 checkpoint 。根图通常是空字符串 "" ,子图会有自己的 namespace。这个字段在子图持久化时很重要,下文会有专门的篇幅讲解子图相关用法。 |
checkpoint_id |
Checkpoint 的唯一标识。 |
StateSnapshot |
开发者通过 get_state() / get_state_history() 看到的状态快照对象。它不是原始 Checkpoint ,而是 LangGraph 基于检查点和运行时信息封装出来的开发者视图,包含当前状态值、下一步待执行节点、任务、config 、metadata、中断信息等内容。 |
小结
本节只需要先记住:
Checkpointer负责保存检查点
thread_id负责唯一标识会话
checkpoint_id负责定位某个具体历史状态
StateSnapshot是开发者查看检查点时看到的对象。
LangGraph的可恢复执行
什么是LangGraph的可恢复执行
LangGraph 的可恢复执行(Durable Execution )依托于其持久化机制(Persistence)。
可以简单理解为:
持久化机制负责保存执行状态; 可恢复执行负责利用这些状态继续执行。
Persistence 是基础,Durable Execution 是建立在 Persistence 之上的能力。
在没有持久化机制时,一次图执行通常只存在于当前进程中。 如果程序退出、节点中断、人工审批暂停,无法恢复原来的执行进度。
而启用持久化之后,LangGraph 可以把执行过程中的状态保存为检查点。 后续只要使用相同的 thread_id,就可以回到会话对应的历史状态。
使用场景
从使用场景看,可恢复执行主要支持以下几类能力:
| 场景 | 说明 |
|---|---|
| 多轮会话 | 同一个 thread_id 下的多次调用可以共享历史状态。 |
| 中断恢复 | 图执行到人工确认等节点时可以中断,之后再继续。 |
| 失败恢复 | 程序异常退出后,可以基于已经保存的检查点继续执行,避免重复计算。 |
Time Travel |
可以回到某个历史检查点,重放 或分叉执行。 |
因此,LangGraph 的可恢复执行是一组由持久化机制支撑的能力集合。
小结
我们只需要知道
LangGraph会通过Checkpointer持久化保存图执行过程中产生的Checkpoint,并通过thread_id找回同一会话的检查点历史,从而在已有状态基础上继续执行。
启用可恢复执行
步骤
LangGraph 内置了持久化机制,但对于本地运行的 Graph API ,只有在编译图时传入 checkpointer,运行过程中的状态才会被记录下来,后续才能基于已有检查点在发生故障时恢复运行或进行检查点回溯。
启用可恢复执行通常需要两步:
- 在编译图时传入检查点存储器对象
checkpointer - 在调用图时传递带有
thread_id的配置对象
底层的持久化机制会按照 thread_id 将运行时检查点记录在编译时传入的 checkpointer 中。
检查点实现
LangGraph 提供了检查点存储器基类:langgraph.checkpoint.base.BaseCheckpointSaver
还提供了一系列检查点存储器实现,采用了不同的检查点后端,它们都继承了基类,整体作用是一致的:负责存取检查点,并记录节点执行的中间结果。
可以将 检查点后端 理解为:
负责保存检查点数据的存储介质。
检查点后端必须基于某个检查点实现,常见检查点实现和后端的对应关系如下
| 类名 | 包名 | 检查点后端 |
|---|---|---|
InMemorySaver |
langgraph-checkpoint |
内存(In-memory) |
SqliteSaver |
langgraph-checkpoint-sqlite |
SQLite |
PostgresSaver |
langgraph-checkpoint-postgres |
PostgreSQL |
MongoDBSaver |
langgraph-checkpoint-mongodb |
MongoDB |
RedisSaver |
langgraph-checkpoint-redis |
Redis |
在这些实现中,InMemorySaver 更适合学习、调试和本地实验;SQLite 适合轻量级本地持久化;PostgreSQL 、MongoDB 、Redis 等数据库后端更适合需要跨进程、跨服务保存状态的场景。
基于内存的检查点存储器
LangGraph 提供了基于内存的检查点存储器 InMemorySaver 。它将检查点数据保存在当前 Python 进程的内存中,进程结束则数据丢失,适合快速开发、调试。
在 Jupyter 场景下,只要 Jupyter 内核没有被重启,并且 InMemorySaver() 实例没有被重新创建,内存中的检查点数据就会继续存在。
反之,如果重新执行完整代码,导致 checkpointer = InMemorySaver() 被重新执行,那么之前保存在内存中的检查点也会被清空。
示例如下
python
from dotenv import load_dotenv
from langchain_deepseek import ChatDeepSeek
from langgraph.graph.message import MessagesState
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.messages import HumanMessage
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 1. 声明状态
class OverAllState(MessagesState):
output: str
# 2. 声明节点
def llm_mode(state: OverAllState) -> OverAllState:
messages = state["messages"]
res = model.invoke(messages)
return {
"messages": [res]
}
def output_node(state: OverAllState) -> OverAllState:
return {
"output": state["messages"][-1].content
}
# 3. 构建图
builder = StateGraph(OverAllState)
builder.add_node("llm_mode", llm_mode)
builder.add_node("output_node", output_node)
builder.add_edge(START, "llm_mode")
builder.add_edge("llm_mode", "output_node")
builder.add_edge("output_node", END)
# 定义并在编译时传递Checkpointer
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
# 定义配置对象
config = {"configurable": {"thread_id": "chapter_6_6-2-2"}}
# 调用时传递
graph.invoke(
{"messages": [HumanMessage(content="你好,我是小明,请介绍一下你自己")]},
config=config
)
res = graph.invoke(
{"messages": HumanMessage(content="我是谁?")},
config=config
)
print(res["output"])
print("--------------------------------")
config = {"configurable": {"thread_id": "chapter_6_6-2-2-3"}}
res = graph.invoke(
{"messages": HumanMessage(content="我是谁?")},
config=config
)
print(res["output"])
基于持久化数据库的检查点存储器
本节将 PostgreSQL 作为检查点后端,对应的 Checkpointer 实现为 PostgresSaver。
和 InMemorySaver 不同,PostgresSaver 会将检查点保存到 PostgreSQL 数据库中。只要数据库中的记录没有被删除,即使 Python 程序结束、连接对象重建,历史检查点也仍然存在。
在学习 LangChain 时,我们已经介绍了 PostgreSQL 的部署和 PostgresSaver 的用法。此处直接给出案例,不再赘述。
示例如下
python
from dotenv import load_dotenv
from langchain_deepseek import ChatDeepSeek
from langgraph.graph.message import MessagesState
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.postgres import PostgresSaver
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 1. 声明状态
class OverAllState(MessagesState):
output: str
# 2. 声明节点
def llm_mode(state: OverAllState) -> OverAllState:
messages = state["messages"]
print("--------------------------------")
print(messages)
print("--------------------------------")
res = model.invoke(messages)
return {
"messages": [res]
}
def output_node(state: OverAllState) -> OverAllState:
return {
"output": state["messages"][-1].content
}
# 3. 构建图
builder = StateGraph(OverAllState)
builder.add_node("llm_mode", llm_mode)
builder.add_node("output_node", output_node)
builder.add_edge(START, "llm_mode")
builder.add_edge("llm_mode", "output_node")
builder.add_edge("output_node", END)
# 4. 配置检查点存储器
DB_URL = "postgresql://langgraph_user:a149789@localhost:5432/langgraph_db?sslmode=disable"
with PostgresSaver.from_conn_string(DB_URL) as checkpointer:
# 5. 第一次使用PostgresSaver作为检查点时,需要调用方法setup()来创建表结构
checkpointer.setup()
graph = builder.compile(checkpointer=checkpointer)
# 指定线程ID
config = {
"configurable": {
"thread_id": "chapter-03-02"
}
}
# 调用图
# result = graph.invoke({"messages": [HumanMessage(content="你好,我是小明,请介绍一下你自己。")]}, config=config)
result = graph.invoke({"messages": [HumanMessage(content="我是谁?")]}, config=config)
print(result)
# 上面连接的检查点位置一样、线程ID一样,关闭重启多少回,都能记住之前的对话
根据上面的流程,描述写主检查点和写中间结果的过程
- 输入结束,写主检查点A
- 超步1:llm_node return
- 写中间结果,挂在A上(messages 这次追加的内容)
- 超步1结束,合并,写主检查点B(parent = A,里面已含AI回复)
- 超步2:output_node return
- 写中间结果,挂在B上(output)
- 超步2结束,合并,写主检查点C(parent = B,里面已含 output)
概述
启用 Checkpointer 后,LangGraph 会在执行过程中保存检查点。
检查点写入越及时 ,系统在异常中断后可恢复的状态越完整 ,容灾能力越强 ;但更及时的持久化通常也意味着更多中间写入 ,或额外的 阻塞式等待 ,带来额外的性能开销和响应延迟(从调用者的角度考虑)。
LangGraph 支持三种持久化模式,采用不同的检查点保存时机,在容灾能力和性能开销、响应时效性之间作取舍。
-
exit:退出模式。只在计算图正常结束、异常退出、被中断(如Human-In-The-Loop中断)时保存检查点。不能处理中途进程崩溃的场景。这种模式响应性能开销最小,但容灾能力最弱。 -
async:异步模式。默认模式,顾名思义,检查点在后台异步写入。它会在每个超步结束后写入完整检查点(主检查点) ,并在图中任务执行完毕后记录中间结果。
和
exit模式相比,增加了性能开销,但写入操作发生在后台,不会引入明显的响应延迟,同时提升了容灾能力。 -
sync:同步模式。和异步模式唯一的区别在于,
LangGraph会在进入下一个超步之前等待当前主检查点的写入任务完成。它的容灾能力最强,但在
async模式的基础上增加了响应延迟。
总结
| 模式 | 写入时机 | 性能开销 | 响应延迟 | 容灾能力 |
|---|---|---|---|---|
exit |
运行退出时写入 | 低 | 最低 | 最弱 |
async |
每个超步末尾写入主检查点,任务完成后写入中间结果,后台异步写入 | 高 | 较高 | 较强 |
sync |
和 async 区别在于,进入下一个超步之前等待当前主检查点的写入任务完成 |
高 | 最高 | 最强 |
示例
持久化模式影响的是检查点写入时机和故障恢复能力,通常不会影响 正常情况下的业务输出。
也就是说,同一张图在正常执行完成时,使用 exit 、async 或 sync ,最终返回结果通常是一样的。真正的区别主要体现在异常中断 或进程崩溃 后恢复执行时。
durability控制的是检查点写入策略,不改变图本身的执行逻辑。
要真正看到不同模式的差异
- 可以在学习使用场景后自行设计案例
- 或调试源码,观察不同模式下的写入时机
示例如下
python
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import MessagesState
from langgraph.checkpoint.memory import InMemorySaver
from langchain_deepseek import ChatDeepSeek
from langchain.messages import HumanMessage
from dotenv import load_dotenv
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
class OverAllState(MessagesState):
output: str
def llm_node(state: OverAllState) -> OverAllState:
messages = state["messages"]
res = model.invoke(messages)
return {
"messages": [res]
}
def output_node(state: OverAllState) -> OverAllState:
return {
"output": state["messages"][-1].content
}
builder = StateGraph(state_schema=OverAllState)
builder.add_node("llm_node", llm_node)
builder.add_node("output_node", output_node)
builder.add_edge(START, "llm_node")
builder.add_edge("llm_node", "output_node")
builder.add_edge("output_node", END)
# 定义并在编译时传递 Checkpointer
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
# 定义配置对象
config = {"configurable": {"thread_id": "chapter_tmp"}}
# 调用时传递
res=graph.invoke(
{"messages": [HumanMessage("你好")]},
config=config,
durability="async" # sync / exit
)
print(res["output"])
print('=' * 30, '-> 完整消息列表 <-', '=' * 30)
for msg in res["messages"]:
msg.pretty_print()
from IPython.display import display
display(graph)
查看历史检查点
LangGraph 在配置检查点存储器后,会把同一个 thread_id 下的执行过程保存为一组检查点。通过这些检查点,我们可以查看图运行的中间状态,也可以为后续的检查点回溯(重放、分叉)和失败恢复做准备。
本节主要介绍两个常用方法:
graph.get_state_history(config):查看指定会话的完整历史检查点。graph.get_state(config):查看指定会话的最新 检查点,或者查看某个指定checkpoint_id对应的检查点。
查看完整历史检查点列表
示例
python
from typing import TypedDict
from dotenv import load_dotenv
from langchain_deepseek import ChatDeepSeek
from langgraph.graph.message import MessagesState
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.postgres import PostgresSaver
from loguru import logger
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 1. 声明状态
class OverAllState(TypedDict):
topic: str
poem: str
joke: str
final_output: str
# 1.1 输入状态
class InputState(TypedDict):
topic: str
# 1.2 输出状态
class OutputState(TypedDict):
final_output: str
# 2. 声明节点
def node_poem(state: InputState) -> OverAllState:
logger.info("node_poem正在执行.....")
topic = state["topic"]
poem = model.invoke([HumanMessage(content=f"写一首关于{topic}的诗")]).content
return {
"poem": poem
}
def node_joke(state: InputState) -> OverAllState:
logger.info("node_joke正在执行.....")
topic = state["topic"]
joke = model.invoke([HumanMessage(content=f"写一篇关于{topic}的笑话")]).content
return {
"joke": joke
}
def node_output(state: OverAllState) -> OutputState:
logger.info("node_output正在执行.....")
poem = state["poem"]
joke = state["joke"]
final_output = f"诗:{poem}\n笑话:{joke}"
return {
"final_output": final_output
}
# 3. 构建图的结构
builder = StateGraph(input_schema=InputState, output_schema=OutputState, state_schema=OverAllState)
# 3.1 添加节点
builder.add_node("node_poem", node_poem)
builder.add_node("node_joke", node_joke)
builder.add_node("node_output", node_output)
# 3.2 添加边
builder.add_edge(START, "node_poem")
builder.add_edge(START, "node_joke")
builder.add_edge("node_poem", "node_output")
builder.add_edge("node_joke", "node_output")
builder.add_edge("node_output", END)
# 4. 添加检查点
checkpointer = InMemorySaver()
config = {
"configurable": {
"thread_id": "123"
}
}
# 4. 添加检查点
# 4. 配置检查点存储器
DB_URL = "postgresql://langgraph_user:a149789@localhost:5432/langgraph_db?sslmode=disable"
with PostgresSaver.from_conn_string(DB_URL) as checkpointer:
# 5. 第一次使用PostgresSaver作为检查点时,需要调用方法setup()来创建表结构
# checkpointer.setup()
graph = builder.compile(checkpointer=checkpointer)
# 生成graph图片
with open("graph.png", "wb") as f:
f.write(graph.get_graph().draw_mermaid_png())
# 指定线程ID
config = {
"configurable": {
"thread_id": "456"
}
}
# result = graph.invoke({"topic": "莲花"}, config=config)
# print(result)
history_checkpoints = list(graph.get_state_history(config=config))
print(history_checkpoints)
# for checkpoint in history_checkpoints:
# print(checkpoint.values)
# print("--------------------------------")
由于 node_poem 和 node_joke 都从 START 出发,并且彼此没有依赖关系,所以它们会被调度到同一个超步中执行。实际日志顺序可能不同,例如可能先打印 node_joke ,也可能先打印 node_poem。

分析
StateSnapshot
get_state_history() 返回的是一个历史检查点迭代器。将其转换为列表后,可以看到一组 StateSnapshot 对象。
StateSnapshot 是检查点的开发者视图,字段含义如下
-
values:当前检查点的状态值 -
next:从该检查点继续执行时,下一步将要执行的节点,即归属于下一个超步的节点 -
config:当前检查点配置。常见结构如下:json{ "configurable": { "thread_id": "...", "checkpoint_ns": "...", "checkpoint_id": "..." } }其中
-
configurable:用于记录检查点的可配置信息,可能包含用户自定义字段,它一定包含以下三个字段:thread_id:会话唯一标识checkpoint_ns:检查点命名空间。根图的命名空间通常为空字符串"";子图会使用非空命名空间。checkpoint_id:检查点唯一标识
这三个字段可以唯一标识一条检查点记录,在大多数检查点存储器实现中,写入检查点后端时都会将它们作为检查点的唯一键
-
-
metadata:检查点元数据,本节只需要关注step字段,后者是当前检查点对应的超步编号 -
created_at:检查点创建时间 -
parent_config:父检查点、即上一个检查点的配置 -
tasks:当前检查点关联的待执行任务信息,元素类型通常是PregelTask。tasks通常和next对应,表示从当前检查点继续执行时,下一步将要运行的任务。需要注意的是 ,
tasks中还可能包含这些任务已经成功(result)或失败(error)的任务记录,这是在下一个超步执行过程中记录的中间结果。在失败恢复场景中,可以避免重复执行已成功节点。 -
interrupts:当前图的中断信息,中断机制详见下文
检查点主要信息说明
本例中检查点按照如下顺序返回
python
Step 2 → Step 1 → Step 0 → Step -1
即
python
最新检查点 → 最旧检查点
其中:
-
step=-1这是输入检查点,
metadata["source"] == "input",这表示该检查点是在运行时初始化时被记录的,尚未进入图计算循环。此时用户输入还没有真正变成普通业务节点可消费的状态,因此
pythonvalues={}从这个检查点继续执行,下一步是内部启动节点
__start__,因此:pythonnext=("__start__",) -
step=0这是图计算循环的第一个检查点,
metadata["source"] == "loop",这表示检查点是在图计算循环中被写入的。此时,
__start__已经把用户输入写入状态,所以values中已经可以看到状态信息,如下:pythonvalues={ "topic": "猫咪" }从这个检查点继续执行,
node_poem和node_joke将在下一个超步执行,因此:pythonnext=("node_poem", "node_joke") -
step=1这是图计算循环的第二个检查点,同样
metadata["source"] == "loop"。同时,它对应第一个执行图节点计算逻辑的超步。
此时,
node_poem和node_joke的输出已经到状态中,因此:pythonvalues={ "topic": "猫咪", "poem": "...", "joke": "..." }从这个检查点开始,下游的汇总节点
node_output满足执行条件,因此:pythonnext=("node_output",) -
step=2这是图计算循环的第三个检查点,同样
metadata["source"] == "loop"此时,
node_output已经执行完成,最终输出也已经写入状态,因此:pythonvalues={ "topic": "猫咪", "poem": "...", "joke": "...", "final_output": "..." }此时,图已经执行结束,所以:
pythonnext=()
tasks字段说明
tasks 是一个元组,其中的每个元素都是一个 PregelTask 实例
tasks 表示:如果从当前检查点继续执行,下一步要运行哪些任务。比如在 step=0 的检查点中:
python
next=("node_poem", "node_joke")
tasks=(
PregelTask(name="node_poem", ...),
PregelTask(name="node_joke", ...),
)
这表示从该检查点继续执行时,下一步会运行 node_poem 和 node_joke 节点,底层执行的是它们的同名任务。
不过,查看历史检查点时,PregelTask 实例的 result 字段可能非空,如:
python
PregelTask(
name="node_poem",
result={
"poem": "..."
}
)
这看起来像是"上一个检查点中提前保存了下一个超步的执行结果"。实际上:
-
完整的检查点(主检查点)是在超步末尾保存的
-
但在一个超步内部,
LangGraph还会记录节点级别的计算结果 -
checkpoint["id"]会在保存主检查点前由create_checkpoint()推进为新的ID,然后用这个ID写入主检查点(假设当前超步为S1)。下一轮(超步为S2)计算循环中,任务执行完毕后的中间结果会用相同的ID写入。因此,下一轮(超步为
S2)任务的计算结果绑定到了"当前超步(超步为S1)的主检查点"。 -
从检查点后端检索时,会按照
checkpoint_id将主检查点和中间结果组织起来 -
因此,检索到的历史检查点列表中,主检查点(超步为
S1)和下一个超步(超步为S2)的中间结果会被放在同一个StateSnapshot中 -
如果同一个超步里有多个并行任务,其中部分任务已经成功,另一个任务失败,那么成功任务的结果可以被保存下来;
-
后续恢复时,
LangGraph可以复用这些已成功任务的结果,避免重复执行已经成功的节点。
在` 失败恢复** 一节,我们将看到某个超步中部分任务执行成功、部分任务失败导致计算异常终止,恢复运行时已成功任务未被重新执行。
查看最新检查点
如果只想查看当前线程的最新检查点,可以使用:
python
latest_history_checkpoint = graph.get_state(config=config)
print(latest_history_checkpoint)
示例如下
python
last_checkpoint = graph.get_state(config=config)
print(last_checkpoint)
根据ID查看指定检查点
如果希望查看某个历史检查点,可以在 configurable 中额外传入 checkpoint_id:
python
target_config = {
"configurable": {
"thread_id": "123",
"checkpoint_id": "某个历史 checkpoint_id"
}
}
snapshot = graph.get_state(config=target_config)
print(snapshot)
这样返回的就不是最新检查点,而是指定 checkpoint_id 对应的历史检查点。
根据ID查看特定检查点
获取历史检查点列表后,可以从某个历史快照中取出它的 checkpoint_id ,然后调用 graph.get_state() 查看该检查点。
示例如下
python
print('=' * 30, '-> 根据ID查看特定检查点 <-', '=' * 30)
checkpointe_id = history_checkpoints[3].config["configurable"]["checkpoint_id"]
print(f"checkpointe_id: {checkpointe_id}")
target_config = {
"configurable": {
"thread_id": config["configurable"]["thread_id"],
"checkpoint_id": checkpointe_id
}
}
specific_history_checkpoint = graph.get_state(config=target_config)
print(specific_history_checkpoint)
当前场景下,我们已经获取了完整检查点列表,然后从中取了某个检查点的 checkpoint_id ,然后调用 get_state() 查到了它对应的快照。
这看起来有些"绕",因为我们本来就已经拿到了完整的历史快照。但在实际场景中,这个方法仍然很有意义。
例如,在后续的 检查点回溯-分叉 场景中,我们经常会基于历史检查点创建新的检查点,拿到该检查点的、带有 checkpoint_id 的配置信息 config 。此时就可以将 config 传递给 graph.get_state(),从而便捷而精确地查看该检查点的快照。
使用场景
多轮对话
最基础的使用场景是多轮对话。前文示例已经实现了一个简单的多轮对话流程,这里不再展开。
中断恢复
中断恢复需要结合 LangGraph 的中断机制理解,见下文。
失败后恢复运行
用法说明
任务失败的原因可能是偶发的外部因素,如网络波动、秘钥过期、第三方服务异常;也可能是程序自身的BUG。
如果是前者,故障发生后,修复外部问题,直接重试即可;如果是后者,需要更改代码修复BUG后,再基于历史检查点恢复运行。
要在失败后基于历史检查点恢复运行,需要满足以下条件:
- 启用检查点存储器
- 如果是
InMemorySaver,不要重建检查点存储器对象,否则历史检查点丢失,无法恢复 - 如果希望跨进程、服务重启后仍可恢复,应使用基于
SQLite、Postgres等持久化检查点后端的存储器
- 如果是
- 再次运行时用
None作为计算图的状态输入 - 传递的配置信息应包含
thread_id而不能包含checkpoint_id
此时,LangGraph 会根据 thread_id 从 checkpointer 读取该会话的最新检查点,并从该检查点继续推进。
如果某个超步中有多个并行任务,其中一部分任务成功,另一部分任务失败,那么已经成功完成的任务结果会作为 pending_writes 被保存。恢复运行时,已经成功完成的任务不会重复执行。
示例
模拟失败
本例使用如下结构:
text
START
-> node_change_topic
-> node_poem
-> node_joke
-> node_output
-> END
运行时,node_change_topic 会把输入主题 "猫咪" 改写为 "猫咪: 布偶猫" ,然后并行执行 node_poem 和 node_joke ,最后由 node_output 汇总输出。
示例如下
python
from typing import TypedDict
from dotenv import load_dotenv
from langchain_deepseek import ChatDeepSeek
from langgraph.graph.message import MessagesState
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.postgres import PostgresSaver
from loguru import logger
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 1. 声明状态
class OverAllState(TypedDict):
topic: str
poem: str
joke: str
final_output: str
# 1.1 输入状态
class InputState(TypedDict):
topic: str
# 1.2 输出状态
class OutputState(TypedDict):
final_output: str
topics = ["布偶猫", "狸花猫", "金渐层"]
topic_index = 0
# 2. 定义节点
def node_change_topic(state: InputState) -> OverAllState:
global topic_index
logger.info("node_change_topic正在执行.....topic_index: {}".format(topic_index))
sub_topic = topics[topic_index]
topic_index += 1
topic_index %= len(topics)
return {
"topic": f"{state['topic']}:{sub_topic}"
}
# 同一个超步位置,成功运行的节点
def node_poem(state: OverAllState) -> OverAllState:
logger.info("node_poem正在执行.....")
poem = model.invoke([HumanMessage(content=f"写一首关于{state['topic']}的诗")]).content
return {
"poem": poem
}
def node_joke(state: OverAllState) -> OverAllState:
logger.info("node_joke正在执行.....")
import time
time.sleep(5)
raise Exception("人为抛出异常")
joke = model.invoke([HumanMessage(content=f"写一个关于{state['topic']}的笑话")]).content
return {
"joke": joke
}
def node_output(state: OverAllState) -> OutputState:
logger.info("node_output正在执行.....")
return {
"final_output": f"topic: {state['topic']}\npoem: {state['poem']}\njoke: {state['joke']}"
}
# 3. 构建图
builder = StateGraph(state_schema=OverAllState, initial_state=InputState, output_schema=OutputState)
builder.add_node("node_change_topic", node_change_topic)
builder.add_node("node_poem", node_poem)
builder.add_node("node_joke", node_joke)
builder.add_node("node_output", node_output)
builder.add_edge(START, "node_change_topic")
builder.add_edge("node_change_topic", "node_poem")
builder.add_edge("node_change_topic", "node_joke")
builder.add_edge("node_poem", "node_output")
builder.add_edge("node_joke", "node_output")
builder.add_edge("node_output", END)
# 4. 编译图
DB_URL = "postgresql://langgraph_user:a149789@localhost:5432/langgraph_db?sslmode=disable"
with PostgresSaver.from_conn_string(DB_URL) as checkpointer:
# checkpointer.setup()
graph = builder.compile(checkpointer=checkpointer)
# 保存图结构
with open("graph.png", "wb") as f:
f.write(graph.get_graph().draw_mermaid_png())
config = {
"configurable": {
"thread_id": "666"
}
}
# result = graph.invoke({"topic": "莲花"}, config=config)
# print(result)
history_checkpoints = list(graph.get_state_history(config=config))
print(history_checkpoints)
可以看到,失败后的检查点中(step=1)记录了当前超步相关任务的执行情况:
node_poem执行成功,结果记录在result字段中。node_joke执行失败,result为空,异常信息记录在error字段中。
本例中,node_poem 和 node_joke 属于同一个超步。虽然 node_joke 失败了,但 node_poem 已成功,其计算结果会被持久化机制保存下来。因此,后续恢复运行时,node_poem 不会重复执行。
修复BUG之后恢复运行
修复 node_joke 中的人为异常后,重新编译计算图,并基于最新检查点恢复运行。
示例如下
python
from typing import TypedDict
from dotenv import load_dotenv
from langchain_deepseek import ChatDeepSeek
from langgraph.graph.message import MessagesState
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.postgres import PostgresSaver
from loguru import logger
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 1. 声明状态
class OverAllState(TypedDict):
topic: str
poem: str
joke: str
final_output: str
# 1.1 输入状态
class InputState(TypedDict):
topic: str
# 1.2 输出状态
class OutputState(TypedDict):
final_output: str
topics = ["布偶猫", "狸花猫", "金渐层"]
topic_index = 1
# 2. 定义节点
def node_change_topic(state: InputState) -> OverAllState:
global topic_index
logger.info("node_change_topic正在执行.....topic_index: {}".format(topic_index))
sub_topic = topics[topic_index]
topic_index += 1
topic_index %= len(topics)
return {
"topic": f"{state['topic']}:{sub_topic}"
}
# 同一个超步位置,成功运行的节点
def node_poem(state: OverAllState) -> OverAllState:
logger.info("node_poem正在执行.....")
poem = model.invoke([HumanMessage(content=f"写一首关于{state['topic']}的诗")]).content
return {
"poem": poem
}
def node_joke(state: OverAllState) -> OverAllState:
logger.info("node_joke正在执行.....")
import time
# time.sleep(5)
# raise Exception("人为抛出异常")
joke = model.invoke([HumanMessage(content=f"写一个关于{state['topic']}的笑话")]).content
return {
"joke": joke
}
def node_output(state: OverAllState) -> OutputState:
logger.info("node_output正在执行.....")
return {
"final_output": f"topic: {state['topic']}\npoem: {state['poem']}\njoke: {state['joke']}"
}
# 3. 构建图
builder = StateGraph(state_schema=OverAllState, initial_state=InputState, output_schema=OutputState)
builder.add_node("node_change_topic", node_change_topic)
builder.add_node("node_poem", node_poem)
builder.add_node("node_joke", node_joke)
builder.add_node("node_output", node_output)
builder.add_edge(START, "node_change_topic")
builder.add_edge("node_change_topic", "node_poem")
builder.add_edge("node_change_topic", "node_joke")
builder.add_edge("node_poem", "node_output")
builder.add_edge("node_joke", "node_output")
builder.add_edge("node_output", END)
# 4. 编译图
DB_URL = "postgresql://langgraph_user:a149789@localhost:5432/langgraph_db?sslmode=disable"
with PostgresSaver.from_conn_string(DB_URL) as checkpointer:
# checkpointer.setup()
graph = builder.compile(checkpointer=checkpointer)
# 保存图结构
with open("graph.png", "wb") as f:
f.write(graph.get_graph().draw_mermaid_png())
config = {
"configurable": {
"thread_id": "666"
}
}
# result = graph.invoke(None, config=config)
# print(result)
# history_checkpoints = list(graph.get_state_history(config=config))
# print(history_checkpoints)
这里需要注意两点:
- 重新编译计算图时,仍然使用原来的
checkpointer对象。 - 恢复运行时,输入参数传入
None,并且配置中只传入thread_id。
运行结果如下
运行结果中可以观察到:
node_joke被重新执行。node_output被执行。node_poem没有被重新执行。
最终输出中的七言绝句和历史检查点中 node_poem.result 记录的内容一致,说明 node_poem 的执行结果被成功复用了。
Time Travel
Time Travel 直译为 时间旅行 ,在当前场景下太过生硬,从技术实现和使用场景来看,译为 检查点回溯 更为合理。
所谓检查点回溯,是指基于某个历史检查点,重新执行后续流程,或者在该检查点基础上修改状态并创建新的执行分支。
检查点回溯有两种形式,根据是否更改历史状态区分:
Replay:检查点重放,回到某个历史检查点,沿着原先的执行路径重新执行后续节点。Fork:检查点分叉,回到某个历史检查点,修改状态,从该位置创建一条新的执行分支。
二者的共同点是:
检查点之前的节点不会重新执行,检查点之后的节点会重新执行。
二者的区别是:
Replay不修改历史状态;Fork会基于历史检查点应用新的状态更新,并创建新的检查点分支。
Replay
Replay 模式和失败恢复很接近,但二者的触发方式和语义不同。
- 失败恢复通常基于最新检查点继续运行,配置中只包含
thread_id,不包含checkpoint_id。 Replay则是显式传入某个历史检查点的配置,配置中包含checkpoint_id。LangGraph会据此从该历史检查点开始重放后续步骤。
需要注意:
Replay不是简单读取历史缓存,而是重新执行该检查点之后的节点。
因此,检查点之后的 LLM 调用、API 请求、工具调用、中断 等都会重新触发,最终结果可能和原始结果不同。
Replay和Fork的区别与相似之处
同一 thread_id 下的检查点树:
Replay: Fork:
A A
│ │
B B
├──→ C ──→ D (旧) ├──→ C ──→ D (旧分支)
│ │
└──→ C' ──→ D' (重跑产生) └──→ B' ──→ C'' ──→ D'' (新分支)
↑ ↑
从 B 之后重跑 从 B 改状态后分叉
新旧轨迹相似 新旧轨迹不同
核心区别一句话 :Replay 是从选定点往后重跑出相似的新轨迹 ;Fork 是在选定点改状态后长出不同的新枝 。两者都不删旧记录,都在同一 thread_id 下追加,最终历史都是一棵树。
Replay案例:
python
from typing import TypedDict
from dotenv import load_dotenv
from langchain_deepseek import ChatDeepSeek
from langgraph.graph.message import MessagesState
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.postgres import PostgresSaver
from loguru import logger
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 1. 声明状态
class OverAllState(TypedDict):
topic: str
poem: str
joke: str
final_output: str
# 1.1 输入状态
class InputState(TypedDict):
topic: str
# 1.2 输出状态
class OutputState(TypedDict):
final_output: str
topics = ["布偶猫", "狸花猫", "金渐层"]
topic_index = 2
# 2. 定义节点
def node_change_topic(state: InputState) -> OverAllState:
global topic_index
logger.info("node_change_topic正在执行.....topic_index: {}".format(topic_index))
sub_topic = topics[topic_index]
topic_index += 1
topic_index %= len(topics)
return {
"topic": f"{state['topic']}:{sub_topic}"
}
# 同一个超步位置,成功运行的节点
def node_poem(state: OverAllState) -> OverAllState:
logger.info("node_poem正在执行.....")
poem = model.invoke([HumanMessage(content=f"写一首关于{state['topic']}的诗")]).content
return {
"poem": poem
}
def node_joke(state: OverAllState) -> OverAllState:
logger.info("node_joke正在执行.....")
import time
# time.sleep(5)
# raise Exception("人为抛出异常")
joke = model.invoke([HumanMessage(content=f"写一个关于{state['topic']}的笑话")]).content
return {
"joke": joke
}
def node_output(state: OverAllState) -> OutputState:
logger.info("node_output正在执行.....")
return {
"final_output": f"topic: {state['topic']}\npoem: {state['poem']}\njoke: {state['joke']}"
}
# 3. 构建图
builder = StateGraph(state_schema=OverAllState, input_schema=InputState, output_schema=OutputState)
builder.add_node("node_change_topic", node_change_topic)
builder.add_node("node_poem", node_poem)
builder.add_node("node_joke", node_joke)
builder.add_node("node_output", node_output)
builder.add_edge(START, "node_change_topic")
builder.add_edge("node_change_topic", "node_poem")
builder.add_edge("node_change_topic", "node_joke")
builder.add_edge("node_poem", "node_output")
builder.add_edge("node_joke", "node_output")
builder.add_edge("node_output", END)
# 4. 编译图
DB_URL = "postgresql://langgraph_user:a149789@localhost:5432/langgraph_db?sslmode=disable"
with PostgresSaver.from_conn_string(DB_URL) as checkpointer:
# checkpointer.setup()
graph = builder.compile(checkpointer=checkpointer)
# 保存图结构
with open("graph.png", "wb") as f:
f.write(graph.get_graph().draw_mermaid_png())
config = {
"configurable": {
"thread_id": "999",
}
}
# 如果想要实现Replay的效果,状态填写为None,config填写为之前某一个检查点的config
res = graph.invoke({"topic": "猫咪"}, config=config)
print(res)
# 获取到检查点历史
history_checkpoints = list(graph.get_state_history(config=config))
# print(history_checkpoints)
# next=('node_poem', 'node_joke')
for checkpoint in history_checkpoints:
if checkpoint.next == ('node_poem', 'node_joke'):
res = graph.invoke(None, config=checkpoint.config)
print(res)
Fork
6.5.4.2.1. 用法说明
Fork 依赖状态图的 update_state() 方法
示例如下
python
change_input_config = graph.update_state(
config=before_router_config.config,
values={"user_input": "帮我写一个关于布偶猫的笑话"},
as_node=START
)
其中:
-
config:历史检查点的配置信息,通常包含thread_id、checkpoint_ns和checkpoint_id。LangGraph会据此检索历史检查点 -
values:要应用到该检查点上的状态更新 -
as_node:指定这次状态更新应当被视为哪个节点产生的输出。LangGraph会按照该节点的写入逻辑,将values写入状态通道,并据此决定后续从哪些节点继续执行。基于分叉检查点运行时,并不是重新执行
as_node本身,而是从它的后继节点继续推进如
START所属超步编号为0,则恢复时从编号为1的超步开始运行。
注意
update_state()不会修改原来的历史检查点,而是基于某个历史检查点创建一个新的检查点。这个新检查点相当于一条新的执行分支。
准备检查点历史
准备运行图并运行一次
示例如下
python
from typing import TypedDict, Annotated, Literal
from dotenv import load_dotenv
from langchain_deepseek import ChatDeepSeek
from langgraph.graph.message import MessagesState
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.postgres import PostgresSaver
from loguru import logger
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 1. 声明状态
# 1.1 全局状态
class OverAllState(TypedDict):
username: str
user_input: str
output: str
# 1.2 私有状态
class StructuredOutputState(TypedDict):
topic: Annotated[str, "主题"]
mode: Annotated[Literal["poem", "joke"], "模式"]
# 让大模型使用我们的结构化输出
model_with_structure = model.with_structured_output(schema=StructuredOutputState)
# 2. 声明节点
def router_node(state: OverAllState) -> StructuredOutputState:
logger.info("router_node节点已经执行....")
user_input = state["user_input"]
res = model_with_structure.invoke(
[
HumanMessage(content=user_input)
]
)
logger.info(f"路由结果为: {res}")
return res
# 这是路由函数,不需要按照节点进行添加
def router(state: StructuredOutputState) -> Literal["node_poem", "node_joke", "node_default"]:
logger.info("router节点已经执行....")
mode = state["mode"]
if mode == "poem":
logger.info("路由到诗歌节点....")
return "node_poem"
elif mode == "joke":
logger.info("路由到笑话节点....")
return "node_joke"
else:
logger.info("路由到默认节点....")
return "node_default"
# 2.1 写诗的节点
def node_poem(state: StructuredOutputState) -> OverAllState:
logger.info("写诗节点已经执行....")
poem = model.invoke([
HumanMessage(content=f"写一首关于 {state['topic']} 的诗歌, 不要赏析,只写诗句的部分")
]).content
return {
"output": poem
}
# 2.2 写笑话的节点
def node_joke(state: StructuredOutputState) -> OverAllState:
logger.info("写笑话节点已经执行....")
joke = model.invoke([
HumanMessage(content=f"写一个关于 {state['topic']} 的笑话")
]).content
return {
"output": joke
}
# 2.3 默认节点
def node_default(state: StructuredOutputState) -> OverAllState:
logger.info("默认节点已经执行....")
return {
"output": "对不起,我无法处理这个请求"
}
# 3. 声明图
builder = StateGraph(OverAllState)
builder.add_node("router_node", router_node)
builder.add_node("node_poem", node_poem)
builder.add_node("node_joke", node_joke)
builder.add_node("node_default", node_default)
builder.add_edge(START, "router_node")
builder.add_conditional_edges("router_node", router, path_map={
"node_poem": "node_poem",
"node_joke": "node_joke",
"node_default": "node_default"
})
builder.add_edge("node_poem", END)
builder.add_edge("node_joke", END)
builder.add_edge("node_default", END)
# pg checkpoint持久化
DB_URL = "postgresql://langgraph_user:a149789@localhost:5432/langgraph_db?sslmode=disable"
with PostgresSaver.from_conn_string(DB_URL) as checkpointer:
# checkpointer.setup()
graph = builder.compile(checkpointer=checkpointer)
# 保存图结构
with open("graph.png", "wb") as f:
f.write(graph.get_graph().draw_mermaid_png())
config = {
"configurable": {
"thread_id": "1209",
}
}
res = graph.invoke({"username": "小王", "user_input": "写一首关于荷花的诗歌"}, config=config)
print(res)
执行逻辑如下:
router_node根据用户输入提取结构化结果,例如{"topic": "布偶猫", "mode": "poem"}。router根据mode决定后续分支。- 如果
mode == "poem",进入node_poem。 - 如果
mode == "joke",进入node_joke。 - 如果无法识别,则进入
node_default。
运行结果如下

获取历史检查点列表
示例如下
python
history_checkpoints = list(graph.get_state_history(config=config))
print(history_checkpoints)
获取 router_node 之前的检查点
示例如下
router_node 的上一个检查点的 next 字段为 ('router_node',),据此筛选检查点
python
before_router_checkpoint = next(h for h in history_checkpoints if h.next == ('router_node',))
before_router_checkpoint
该检查点表示:
用户输入已经写入状态,下一步将要执行
router_node。
从这个检查点分叉,本文采用两种方案(还可以有别的方案):
-
修改输入,让
router_node重新执行pythonfrom typing import TypedDict, Annotated, Literal from dotenv import load_dotenv from langchain_deepseek import ChatDeepSeek from langgraph.graph.message import MessagesState from langgraph.graph import StateGraph, START, END from langgraph.checkpoint.memory import InMemorySaver from langchain_core.messages import HumanMessage from langgraph.checkpoint.postgres import PostgresSaver from loguru import logger load_dotenv(override=True) model = ChatDeepSeek( model="deepseek-v4-flash", extra_body={ "thinking": { "type": "disabled" } } ) # 1. 声明状态 # 1.1 全局状态 class OverAllState(TypedDict): username: str user_input: str output: str # 1.2 私有状态 class StructuredOutputState(TypedDict): topic: Annotated[str, "主题"] mode: Annotated[Literal["poem", "joke"], "模式"] # 让大模型使用我们的结构化输出 model_with_structure = model.with_structured_output(schema=StructuredOutputState) # 2. 声明节点 def router_node(state: OverAllState) -> StructuredOutputState: logger.info("router_node节点已经执行....") user_input = state["user_input"] res = model_with_structure.invoke( [ HumanMessage(content=user_input) ] ) logger.info(f"路由结果为: {res}") return res # 这是路由函数,不需要按照节点进行添加 def router(state: StructuredOutputState) -> Literal["node_poem", "node_joke", "node_default"]: logger.info("router节点已经执行....") mode = state["mode"] if mode == "poem": logger.info("路由到诗歌节点....") return "node_poem" elif mode == "joke": logger.info("路由到笑话节点....") return "node_joke" else: logger.info("路由到默认节点....") return "node_default" # 2.1 写诗的节点 def node_poem(state: StructuredOutputState) -> OverAllState: logger.info("写诗节点已经执行....") poem = model.invoke([ HumanMessage(content=f"写一首关于 {state['topic']} 的诗歌, 不要赏析,只写诗句的部分") ]).content return { "output": poem } # 2.2 写笑话的节点 def node_joke(state: StructuredOutputState) -> OverAllState: logger.info("写笑话节点已经执行....") joke = model.invoke([ HumanMessage(content=f"写一个关于 {state['topic']} 的笑话") ]).content return { "output": joke } # 2.3 默认节点 def node_default(state: StructuredOutputState) -> OverAllState: logger.info("默认节点已经执行....") return { "output": "对不起,我无法处理这个请求" } # 3. 声明图 builder = StateGraph(OverAllState) builder.add_node("router_node", router_node) builder.add_node("node_poem", node_poem) builder.add_node("node_joke", node_joke) builder.add_node("node_default", node_default) builder.add_edge(START, "router_node") builder.add_conditional_edges("router_node", router, path_map={ "node_poem": "node_poem", "node_joke": "node_joke", "node_default": "node_default" }) builder.add_edge("node_poem", END) builder.add_edge("node_joke", END) builder.add_edge("node_default", END) # pg checkpoint持久化 DB_URL = "postgresql://langgraph_user:a149789@localhost:5432/langgraph_db?sslmode=disable" with PostgresSaver.from_conn_string(DB_URL) as checkpointer: # checkpointer.setup() graph = builder.compile(checkpointer=checkpointer) # 保存图结构 with open("graph.png", "wb") as f: f.write(graph.get_graph().draw_mermaid_png()) config = { "configurable": { "thread_id": "1209", } } # res = graph.invoke({"username": "小王", "user_input": "写一首关于荷花的诗歌"}, config=config) # print(res) # 获取历史检查点列表 history_checkpoints = list(graph.get_state_history(config=config)) # print(history_checkpoints) # 获取某一个节点的位置 before_router_checkpoint = next(h for h in history_checkpoints if h.next == ('router_node',)) """ StateSnapshot(values={'username': '小王', 'user_input': '写一首关于荷花的诗歌'}, next=('router_node',), config={'configurable': {'thread_id': '1209', 'checkpoint_ns': '', 'checkpoint_id': '1f1cf761-4de9-6385-8000-a126f5268c97'}}, metadata={'step': 0, 'source': 'loop', 'parents': {}}, created_at='2026-10-24T06:42:28.841766+00:00', parent_config={'configurable': {'thread_id': '1209', 'checkpoint_ns': '', 'checkpoint_id': '1f1cf761-4de4-655d-bfff-5d98de992467'}}, tasks=(PregelTask(id='134ca874-60da-83cd-f816-845dcdab8861', name='router_node', path=('__pregel_pull', 'router_node'), error=None, interrupts=(), state=None, result={'topic': '荷花', 'mode': 'poem'}),), interrupts=()) """ change_input_config = graph.update_state( config=before_router_checkpoint.config, values={"user_input": "帮我写一个关于布偶猫的笑话"}, # as_node=START ) # print(change_input_config) # 查看当前新建的检查点 # print(graph.get_state(change_input_config)) # Fork,从当前新建的检查点开始运行 res = graph.invoke(None, config=change_input_config) print(res) # checkpoints = list(graph.get_state_history(config=config)) # print(checkpoints)由运行结果可知:
user_input已经从"帮我写一首关于布偶猫的七言绝句"变成"帮我写一个关于布偶猫的笑话"。而username没有被更新,仍然保持原值"小黄"。说明values是 状态更新 而非完整的 状态替换。router_node开始的节点全部重新运行。由于新的输入被识别为笑话任务,因此后续进入node_joke。
改状态且跳过某些节点
更新配置
如果不希望重新执行 router_node ,而是直接指定 router_node 的输出,可以把 as_node 设置为 "router_node"。
示例如下
python
change_input_config = graph.update_state(
config=before_router_checkpoint.config,
# values={"user_input": "帮我写一个关于布偶猫的笑话"},
# as_node=START
values={"topic": "狸花猫", "mode": "笑话"},
as_node="router_node"
)
# Fork,从当前新建的检查点开始运行
res = graph.invoke(None, config=change_input_config)
print(res)
-
此处把
{"topic": "狸花猫", "mode": "笑话"}当作router_node的输出。 -
因此,后续运行时,
router_node本身不会被重新执行,从它的后继节点继续推进。 -
由于
router_node挂载了条件边:pythonbuilder.add_conditional_edges( "router_node", router, path_map=["node_poem", "node_joke", "node_default"] )所以当
update_state()把values视为router_node的输出时,条件路由函数router也会根据这次更新后的状态计算后续分支。这里把
mode设置为"笑话",router函数命中兜底分支node_default。
可以看到:
-
update_state()创建了一个新的检查点,返回的skip_router_config指向这个新检查点。 -
日志中出现了:
text路由函数已执行 路由至兜底节点这说明
router_node的节点函数没有被重新执行,但与router_node条件边相关的路由函数被执行了。 -
update_state()底层会根据as_node找到对应图节点的writers。对于
router_node,writers包括状态写入器和条件边对应的路由逻辑。 -
执行
update_state()时,传入的values会被当作router_node的输出,交给该节点的writers处理。状态写入器会把
values转换为对状态Channel的写入;条件边对应的路由逻辑会根据当前状态计算后继分支,并生成类似branch:to:node_default这样的触发通道写入。 -
随后,
update_state()会把这些写入应用到检查点中,并保存一个新的检查点。因此,状态更新和路由结果已经记录在
skip_router_config指向的新检查点中。后续再调用: -
时,运行图会基于这个新检查点继续执行,被触发的后继节点也会继续运行。
综上,graph.update_state() 的作用可以理解为:
不执行指定节点的节点函数本身,而是把传入的
values当作该节点已经产生的输出,执行该节点对应的writers,应用状态写入和可能存在的路由写入,然后生成一个新的检查点。
因此,在本例中可以看到:router_node 的节点逻辑没有被执行,但它挂载的路由函数被执行,状态更新也已经生效。
重新运行
示例如下
python
graph.invoke(None, skip_router_config)
运行结果如下
json
2026-06-11 16:49:42.501 | INFO | __main__:node_default:73 - node_default 已执行
{
"username": "小黄",
"user_input": "帮我写一首关于布偶猫的七言绝句",
"output": "无法处理的任务类型"
}
和分析一致,触发了兜底分支。
这种方式常用于测试场景。例如:
- 跳过不稳定的大模型路由节点。
- 手动指定路由结果,测试不同分支。
- 修改历史状态,验证后续节点能否正常运行。
- 基于同一个历史检查点探索多条可能的执行路径。
不过在正式业务流程中,应谨慎使用这种方式。因为它是在伪造某个节点的输出,如果伪造的状态不符合后续节点的预期,可能导致逻辑混乱,甚至抛出运行时异常。
图记忆管理
Agent 的三种记忆:
-
短期记忆 :通过运行时状态
State访问,并由检查点存储器Checkpointer保存,它按照thread_id组织,可以实现线程内的记忆共享。 -
长期记忆 :通过长期记忆存储器
Store访问和存储。数据通常按照元组类型的命名空间组织,以键值对的形式存储,提供跨会话的记忆共享。- 可以按照命名空间+可选的过滤条件精确检索
- 也可以通过语义模糊匹配。
-
运行时上下文 :通过上下文对象
Context访问,只对本次调用生效,不会被持久化。它更适合传递本次运行所需的外部依赖或调用参数,如用户名、模型配置、数据库连接、权限信息等。
我们不止一次提到,Agent 底层就是一个简易的 ReAct 架构的 LangGraph 状态图,所以 Agent 的记忆机制本质上就是 LangGraph 状态图的记忆机制。
生产环境建议用基于持久化数据库的记忆存储器,如 PostgresSaver 和 PostgresStore。
短期记忆
只要编译图时启用了 Checkpointer ,并且调用图时复用同一个 thread_id ,LangGraph 就可以在多次调用之间共享同一会话线程下的状态。
我们一直在用,不在赘述。
长期记忆
长期记忆存储器在编译图时通过 store 参数传递,图节点中可以通过 Runtime 对象访问。
本节会在一个案例中同时用到短期记忆和长期记忆,两者均使用 PostgreSQL 作为持久化后端------短期记忆通过 PostgresSaver ,长期记忆通过 PostgresStore,数据不会随进程退出而丢失。
7.2.1. 准备长期记忆
示例如下
python
from typing import Final, Tuple
from langgraph.store.postgres import PostgresStore
DB_URL = "postgresql://langgraph_user:a149789@localhost:5432/langgraph_db?sslmode=disable"
# 创建存储对象
with PostgresStore.from_conn_string(DB_URL) as store:
# 1. 创建表
store.setup()
# 2. 构建永久记忆数据
# 2.1 构建命名空间
USER_NS: Final[Tuple[str]] = ("users",)
PREFERENCES_KEY: Final[str] = "preferences"
namespace1 = (*USER_NS, "Alice")
namespace2 = (*USER_NS, "Bob")
namespace3 = (*USER_NS, "Black")
value1 = {
"course": "计算机组成原理",
"sports": "跑步",
"food": "紫光园奶皮子酸奶"
}
value2 = {
"course": "数字电路与模拟电路",
"sports": "跑步",
"food": "奶皮子糖葫芦"
}
value3 = {
"course": "数字电路与模拟电路",
"sports": "羽毛球",
"food": "紫光园奶皮子酸奶"
}
# 3. 写入永久记忆数据
store.put(namespace1, PREFERENCES_KEY, value1)
store.put(namespace2, PREFERENCES_KEY, value2)
store.put(namespace3, PREFERENCES_KEY, value3)
# 4. 读取永久记忆数据
for item in store.search(USER_NS):
print(item)
这里使用 USERS_NS 作为顶层命名空间,store.search(USERS_NS) 会查询所有以 ("users",) 开头的记忆数据。返回的每条数据都是一个 Item 实例。
需要注意:
namespace使用元组是硬性约束,天然表达层级结构。本例使用两层命名空间("users", "Alice"),清晰区分了领域(users)**和**实体(用户名) ,而具体存储什么类型的数据则由key参数表达(如"preferences")。(\*USERS_NS, "Alice")的写法表示对已有元组进行解包,再拼接新的命名空间片段。PostgresStore将长期记忆持久化到PostgreSQL中,程序重启后数据不会丢失,适合生产环境。- 如果希望支持语义检索,需要在
Store中配置索引和embedding函数。未配置索引时,search()只能按照命名空间和过滤条件检索。
访问长期记忆
长期记忆存储器可以通过节点函数中的 runtime.store 访问。
示例如下
python
from typing import TypedDict, Annotated, Literal, Final, Tuple, List
from dotenv import load_dotenv
from langchain_deepseek import ChatDeepSeek
from langgraph.graph.message import MessagesState, AnyMessage
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.store.postgres import PostgresStore
from langgraph.runtime import Runtime
from loguru import logger
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 声明全局状态
class OverAllState(MessagesState):
username: str
user_input: str
output: str
preferences: dict[str, str]
# 2. 声明节点
# 2.1 路由函数,如果状态中没有用户偏好,则从长期记忆中查询,否则直接执行大模型节点
def router(state: OverAllState) -> Literal["check_preferences_node", "llm_node"]:
if not state.get("preferences"):
logger.info("没有用户偏好,从长期记忆中查询")
return "check_preferences_node"
logger.info("有用户偏好,直接执行大模型节点")
return "llm_node"
# 检查长期记忆节点
def check_preferences_node(state: OverAllState, runtime: Runtime) -> OverAllState:
logger.info("从长期记忆中查询用户偏好")
# 1. 拼接命名空间
username = state["username"]
USER_NS: Final[Tuple[str]] = ("users",)
namespace = (*USER_NS, username)
# 长期记忆存储器可以通过节点函数中的 runtime.store 访问
# 2. 查询永久记忆数据
item = runtime.store.get(namespace, "preferences")
if item:
logger.info(f"查询到用户{username}偏好{item.value},返回给大模型节点")
return {
"preferences": item.value
}
else:
logger.info(f"没有用户{username}偏好,返回空字典")
return {"preferences": {}}
# 大模型节点
def llm_node(state: OverAllState, runtime: Runtime) -> OverAllState:
logger.info("执行大模型节点")
# 检查是否存在长期记忆
preferences = state.get("preferences", {})
user_input = state.get("user_input", "")
human_prompt = (f"这是用户的偏好:{preferences}\n这是用户的问题:{user_input}")
system_prompt = "请根据用户的偏好,解决用户的需求"
# [[SystemMessage, HumanMessage, AIMessage], HumanMessage(...)]
# 三元表达式要选出整份消息列表。写在 [] 里面时,已有历史会变成 [[旧消息...]]
messages: List[AnyMessage] = (
[SystemMessage(content=system_prompt)]
if not state.get("messages")
else state["messages"]
)
model_response = model.invoke(
messages + [HumanMessage(content=human_prompt)]
).content
return {
"output": model_response,
"messages": messages + [HumanMessage(content=human_prompt), AIMessage(content=model_response)]
}
# 构件图
builder = StateGraph(state_schema=OverAllState)
builder.add_node("check_preferences_node", check_preferences_node)
builder.add_node("llm_node", llm_node)
builder.add_conditional_edges(
START,
router,
path_map={"check_preferences_node": "check_preferences_node", "llm_node": "llm_node"}
)
builder.add_edge("check_preferences_node", "llm_node")
builder.add_edge("llm_node", END)
DB_URL = "postgresql://langgraph_user:a149789@localhost:5432/langgraph_db?sslmode=disable"
# 编译时传递短期记忆和长期记忆存储器(均使用 PostgreSQL)
with PostgresSaver.from_conn_string(DB_URL) as checkpointer, \
PostgresStore.from_conn_string(DB_URL) as store:
# 幂等的,第一次初始化调用一次就可以了
checkpointer.setup()
graph = builder.compile(checkpointer=checkpointer, store=store)
# with open("graph.png", "wb") as f:
# f.write(graph.get_graph().draw_mermaid_png())
config = {"configurable": {"thread_id": "493"}}
res = graph.invoke({"username": "Alice", "user_input": "我有点无聊,和我聊聊天吧"}, config=config)
print('=' * 30, '-> <-', '=' * 30)
print(res)
# 第二次调用, 复用同一个thread_id, 观察短期记忆缓存效果
# 第二次和第一次的thread_id相同,检查点先恢复上一轮状态
# 在把新传入的{"user_input": "推荐一下酸奶"}盖上去
# 没传递的字段保持原值,所以username仍是Alice
# preferences也还在
new_res = graph.invoke({"user_input": "推荐一下酸奶"}, config=config)
new_messages = new_res.pop("messages")
print('=' * 30, '-> <-', '=' * 30)
print(new_res)
运行流程如下:
- 第一次调用时,状态中还没有
preferences。 - 路由函数会路由至
check_preferences_node。 check_preferences_node从长期记忆存储器中读取用户偏好。- 读取到的偏好会写入图状态,成为当前会话线程中的短期记忆。
- 后续节点可以直接从状态中读取
preferences。
再次调用
在同一个 with 块中再次调用计算图,并复用同一个 thread_id ,可以看到短期记忆的效果。该代码已包含在上节的 with 代码块末尾。
第二次运行前,状态中已经包含:
usernamepreferencesmessages- 上一次调用生成的
output
因此路由函数会直接判断:
python
logger.info("用户偏好已存在,不必查询")
因此,本次调用不会重新访问长期记忆存储器,而是直接复用当前会话状态中缓存的用户偏好。
这正是短期记忆和长期记忆协作的典型模式:
第一次调用从长期记忆中读取数据,并写入短期记忆;后续同一会话线程内优先复用短期记忆,避免重复查询长期记忆。
运行时上下文
在某些场景下,我们希望在调用图时传入一些仅对当次调用有效的信息,比如当前登录用户、请求来源、调用方标识等。这些信息不适合放入图状态(图状态会被持久化并在同一会话中跨调用共享),而应该通过**运行时上下文(Runtime Context)**传递。
运行时上下文的特点是:
仅对本次调用生效,不会被持久化,也不会在同一会话的下一次调用中自动恢复。
运行时上下文的使用方式:
- 初始化状态图时,使用
context_schema定义上下文类型。 - 调用图时通过
context参数传入上下文对象。 - 节点或路由函数中通过
runtime.context访问上下文。
访问运行时上下文
下面通过一个简单的客服机器人案例演示运行时上下文的用法。
场景 :不同的用户调用同一个机器人,机器人根据运行时上下文中的用户名 和会员等级,生成不同风格的回复。
图结构:
text
START -> llm_node -> END
示例如下
python
from dataclasses import dataclass
from typing import TypedDict, Annotated, Literal, Final, Tuple, List
from dotenv import load_dotenv
from langchain_deepseek import ChatDeepSeek
from langgraph.graph.message import MessagesState, AnyMessage
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.store.postgres import PostgresStore
from langgraph.runtime import Runtime
from loguru import logger
load_dotenv(override=True)
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={
"thinking": {
"type": "disabled"
}
}
)
# 1. 定义运行时的环境上下文
@dataclass
class UserContext:
username: str
membership_level: str
# 2. 定义状态
class OverAllState(MessagesState):
user_input: str
output: str
# 3. 定义节点
def llm_node(state: OverAllState, runtime: Runtime[UserContext]) -> OverAllState:
# 1. 获取环境上下文,判断当前的用户等级
runtime_context = runtime.context
if runtime.context:
level = runtime_context.membership_level
username = runtime_context.username
logger.info(f"当前用户{username}等级为{level}")
if level == "VIP":
system_prompt = f"你是高级客户助理,当前VIP用户是{username},请使用尊称'您',语气热情周到,回复末尾加上'VIP🏅服务'"
else:
system_prompt = f"你是普通客户助理,当前用户是{username},请使用尊称'您',请友好简洁回复问题"
else:
system_prompt = f"你是普通客户助理,请友好简洁回复问题"
user_input = state["user_input"]
messages = state.get("messages", [])
response = model.invoke([SystemMessage(content=system_prompt)] + messages + [HumanMessage(content=user_input)])
return OverAllState(
output=response.content,
messages=messages + [HumanMessage(content=user_input), response]
# messages=[response]
)
# 4. 构件图
builder = StateGraph(state_schema=OverAllState, context_schema=UserContext)
builder.add_node("llm_node", llm_node)
builder.add_edge(START, "llm_node")
builder.add_edge("llm_node", END)
# 5. 编译图
graph = builder.compile()
config = {"configurable": {"thread_id": "495"}}
# 6. 运行图
res = graph.invoke(
{"user_input": "你好,帮我查一下最近有什么优惠活动?"},
config=config,
context={"username": "Alice", "membership_level": "VIP"},
)
print(res)
分析
- 第一次调用 传入了
context=UserContext(username="Alice", membership_level="VIP"),节点通过runtime.context读取到用户名和会员等级,生成了VIP风格的回复,末尾带上了🎖️VIP专属服务标识。 - 第二次调用 使用了相同的
thread_id,但没有传入context。此时runtime.context为None,节点走入了默认风格分支,回复中不再有VIP标识。
这个对比可以清晰地看到:
运行时上下文仅对当次调用生效,不会被持久化。即使使用相同的
thread_id,下一次调用也不会自动继承上一次的上下文。
小结
运行时上下文适合传递以下信息:
| 适合放入运行时上下文 | 不适合(应放入图状态) |
|---|---|
| 当前登录用户信息 | 需要在多轮对话间共享的数据 |
| 请求来源(Web / API / 小程序) | 需要在检查点中恢复的执行进度 |
| 调用方标识、Trace ID | 需要跨调用持久化的业务数据 |
| 本次调用的功能开关 | 节点间需要传递的计算结果 |
简单来说:
跨调用共享、需要持久化的数据 → 放入 State;仅当次调用有效的信息 → 放入 Runtime Context。
Node总结:LangGraph的节点
节点的本质
从用户视角看,LangGraph 的节点通常是一个可调用对象,最常见的是同步或异步 Python 函数:
python
def sync_node(state: State) -> State:
...
async def async_node(state: State) -> State:
...
除此之外,节点也可以是符合要求的其他可调用对象或 Runnable 实例。
从源码实现角度看,节点最终会被转换或包装为可运行对象,并作为 PregelNode.bound 保存。
节点函数的完整形态
状态图节点的第一个位置参数用于接收图状态。
此外,LangGraph 还可以在运行时注入运行时配置 、运行时对象 和流式写入器等参数。
普通图节点通常使用以下四个参数:
-
state:输入节点的图状态。state是位置传参,因此参数名称不重要,但通常约定命名为state -
config:状态图的运行时配置,它是一个RunnableConfig实例,底层运行时在节点函数执行前通过关键字传参动态注入。可以通过
config访问当前线程的thread_id、超步序号、递归限制等配置信息。如下pythonconfig["configurable"]["thread_id"] config["recursion_limit"] config["metadata"] -
runtime:状态图的运行时对象,可以通过它访问运行时上下文、长期记忆存储器等信息。底层运行时在节点函数执行前通过关键字传参动态注入。 -
writer:流式写入器,通常用于自定义流式输出。详见流处理章节。
典型写法如下:
python
def node(state: State, config: RunnableConfig, runtime: Runtime[Context]) -> State:
...
- 编译图时传入
checkpointer,并在调用时传入config,可以在节点中通过config间接访问当前调用的配置信息,如thread_id。 - 编译图时传入
store后,可以在节点中通过runtime.store访问长期记忆存储器。 - 初始化状态图时传入
context_schema,并在调用时传入context后,可以在节点中通过runtime.context访问运行时上下文。
节点的触发和执行
7.4.3.1. 节点的触发
7.4.3.1.1. 用户视角
从用户层面看,节点由图中的控制流关系(普通边、条件边和动态控制指令)触发:
- 普通边决定固定的后继节点。
- 条件边根据路由函数的返回值决定后继节点。
- 可以返回普通节点名称
- 也可以返回
Send实例,从而动态派发多个并行任务,常用于Map-Reduce场景。
Command.goto可以在节点返回时动态指定后继节点。
7.4.3.1.2. 源码视角
从源码实现角度看,LangGraph 会将图状态和节点之间的触发关系组织为通道。
其中,状态字段通常对应状态通道,节点之间的控制流关系则通过触发通道或屏障通道表示。对于普通的单节点触发关系,目标节点通常会订阅如下形式的内部通道:
text
branch:to:<node_name>
例如,对于普通边:
python
builder.add_edge("node_a", "node_b")
运行时 node_a 的写入器会向以下通道写入数据:
text
branch:to:node_b
而 node_b 会将该通道注册为自己的触发通道。
条件边和 Command.goto 最终也会根据目标节点生成相应的触发通道写入。Send 则会生成动态派发任务所需的 Send 数据包。
对于多个前驱节点共同汇聚到同一个节点的情况,底层还可能使用类似下面的屏障通道:
text
join:<node_a>+<node_b>:<node_c>
用于等待指定的前驱节点全部完成。
当某个节点订阅的触发通道在当前超步中产生新的写入后,该节点会在下一超步被调度。
7.4.3.2. 节点的执行
7.4.3.2.1. 用户视角
从用户层面看,节点执行过程可以理解为:
LangGraph从当前图状态中读取节点需要的字段,并构造节点输入。- 调用节点函数,执行其业务逻辑。
- 节点函数返回状态更新、
Command或其他受支持的结果。 LangGraph将节点返回值转换为状态通道和控制通道的写入条目。- 如果当前节点挂载了条件边,则执行条件边路由逻辑,根据包含当前节点更新的状态决定后继节点。
- 当前超步的所有任务执行完毕后,
LangGraph汇总这些写入,并根据各状态字段的Reducer规则更新图状态。
节点通常只需要返回发生变化的字段,而不需要返回完整状态。例如:
python
def node_a(state: State) -> State:
return {"count": state["count"] + 1}
其中:
python
{"count": state["count"] + 1}
表示对状态的更新,而不是一份必须包含所有字段的完整状态。
7.4.3.2.2. 源码视角
从源码实现角度看,节点业务逻辑、状态写入逻辑和控制流写入逻辑会被组合成一个可顺序执行的可运行对象。
编译节点时,LangGraph 会创建一个 PregelNode。
相关源码位于 CompiledStateGraph 类的 attach_node() 方法中,如下
python
self.nodes[key] = PregelNode(
triggers=[branch_channel],
# read state keys and managed values
channels=("__root__" if is_single_input else input_channels),
# coerce state dict to schema class (eg. pydantic model)
mapper=mapper,
# publish to state keys
writers=[ChannelWrite(write_entries)],
metadata=node.metadata,
retry_policy=node.retry_policy,
cache_policy=node.cache_policy,
bound=node.runnable, # type: ignore[arg-type]
)
其中:
bound保存节点的业务逻辑;writers保存节点执行完成后需要调用的写入器;triggers保存能够触发该节点的通道。
可以近似表示为:
text
PregelNode
├── bound:节点业务逻辑
├── writers:状态和控制流写入器
└── triggers:能够触发该节点的通道
因此,从整体上看,节点执行并不是简单地"调用一个函数并返回结果",而是:
执行业务逻辑,生成状态和控制流写入,并通过目标节点触发通道或动态任务写入,为下一超步生成待调度任务,持续推动计算图运行。
附录A. PostgreSQL的部署与配置
A.1. PostgreSQL简介
A.1.1. 什么是PostgreSQL
PostgreSQL (简称 PG )是一款开源的关系型数据库管理系统(RDBMS) ,诞生于1996年,至今已有近30年的发展历史。它以稳定性、可靠性、ACID事务支持 和强大的扩展能力著称,是目前生产环境中使用最广泛的开源关系型数据库之一。
PostgreSQL 的主要特点包括:
ACID事务 :完整支持原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),确保数据操作的可靠性;- 丰富的数据类型 :不仅支持传统的整数、浮点数、字符串,还原生支持
JSON/JSONB、数组、范围类型等,使其可以同时承担"关系型"和"文档型"数据库的角色; - 可扩展性:支持自定义数据类型、函数、索引类型,拥有丰富的扩展生态;
- 跨平台 :支持
Windows、Linux、macOS等主流操作系统。
在 LangGraph 的上下文中,PostgreSQL 主要被用作检查点(Checkpoint)的持久化后端 ,同时也支持作为长期记忆存储使用。
A.1.2. 为什么LangGraph需要PostgreSQL
在之前的 6.2.4节 中,我们提到"在学习 LangChain 时,我们已经介绍了 PostgreSQL 的部署和 PostgresSaver 的用法"。但如果你没有学习过 LangChain 课程,或者希望在本课程中获得完整的知识闭环,本附录将从零开始介绍 PostgreSQL 的部署与使用。
回顾一下第 6.2.2节 中检查点后端的对比:
| 后端 | 适用场景 |
|---|---|
InMemorySaver |
学习、调试、本地实验;进程结束数据丢失 |
SQLite |
轻量级本地持久化;单进程场景 |
PostgreSQL |
生产环境;跨进程、跨服务共享状态 |
MongoDB |
生产环境;偏好文档模型 |
Redis |
生产环境;高吞吐、低延迟缓存场景 |
InMemorySaver 将检查点保存在当前 Python 进程的内存中。每次重启程序,内存中的数据就会丢失------这对于需要跨多次调用记住对话历史的多轮对话场景显然是不够的。
PostgresSaver 则将检查点写入 PostgreSQL 数据库。与程序进程的生命周期解耦:
- 多次运行同一个
thread_id的对话,历史消息会持续累积; - 重启
Python程序后,只要数据库中的记录还在,历史检查点就能被恢复; - 多个
Python进程可以共享同一个PostgreSQL实例,实现跨进程的状态共享。
PostgresSaver 来自 langgraph-checkpoint-postgres 包,本节附录将完整介绍如何部署和配置它所需的 PostgreSQL 环境。
本文使用的连接信息
为与 6.2.4节 的代码保持一致,本附录统一使用以下连接信息:
| 配置项 | 值 |
|---|---|
Host |
localhost |
Port |
5432 (PostgreSQL 默认端口) |
Database |
langgraph_db |
User |
langgraph_user |
Password |
123456 |
SSL Mode |
disable(本地开发环境关闭 SSL) |
| 连接URL | postgresql://langgraph_user:123456@localhost:5432/langgraph_db?sslmode=disable |
注意 :这里的
sslmode=disable表示关闭SSL连接,仅适用于本地开发和测试环境。生产环境中建议启用SSL并修改为强密码。
PostgreSQL的安装与部署
本节介绍两种安装方式:Docker 部署(推荐,简单快速)和 Windows 直接安装(无需额外安装 Docker)。
A.2.1. 方式一:使用Docker部署(推荐)
Docker 部署的优势在于:无需修改系统环境,安装卸载干净,版本切换方便,一条命令即可启动。
如果你还没有安装
Docker,请先访问 Docker Desktop for Windows 下载并安装。安装完成后确保Docker Engine处于运行状态(桌面右下角Docker图标为绿色)。
A.2.1.1. 拉取PostgreSQL镜像
打开 PowerShell 或 CMD 终端,执行:
bash
docker pull postgres:16
这里选择 PostgreSQL 16 版本,是当前最新的稳定大版本,与 langgraph-checkpoint-postgres==3.0.5 兼容。
拉取完成后,可通过以下命令查看本地已有的镜像:
bash
docker images | findstr postgres
A.2.1.2. 运行PostgreSQL容器
执行以下命令启动容器:
bash
docker run -d `
--name langgraph-postgres `
-e POSTGRES_DB=langgraph_db `
-e POSTGRES_USER=langgraph_user `
-e POSTGRES_PASSWORD=123456 `
-p 5432:5432 `
postgres:16
Windows
CMD用户注意 :上述换行使用了PowerShell的反引号。如果在 **`CMD`** 中执行,需要将替换为^,或写成一整行:
bashdocker run -d --name langgraph-postgres -e POSTGRES_DB=langgraph_db -e POSTGRES_USER=langgraph_user -e POSTGRES_PASSWORD=123456 -p 5432:5432 postgres:16
各参数含义:
| 参数 | 含义 |
|---|---|
-d |
后台运行(daemon 模式),终端关闭后容器不会停止 |
--name langgraph-postgres |
容器名称,方便后续管理 |
-e POSTGRES_DB=langgraph_db |
创建容器时自动创建名为 langgraph_db 的数据库 |
-e POSTGRES_USER=langgraph_user |
自动创建用户 langgraph_user |
-e POSTGRES_PASSWORD=123456 |
设置该用户的密码为 123456 |
-p 5432:5432 |
将容器的 5432 端口映射到宿主机的 5432 端口 |
注意 :通过环境变量自动创建的数据库和用户,其权限已自动配置好,无需再手动授权。如果使用手动安装方式,则需要额外执行授权步骤(见 A.3.2节)。
A.2.1.3. 验证容器运行状态
bash
# 查看正在运行的容器
docker ps
# 如果看不到 langgraph-postgres,查看所有容器(包括已停止的)
docker ps -a
# 查看容器日志
docker logs langgraph-postgres
如果容器没有运行,可以通过以下命令启动:
bash
docker start langgraph-postgres
常用容器管理命令:
bashdocker stop langgraph-postgres # 停止容器 docker start langgraph-postgres # 启动容器 docker restart langgraph-postgres # 重启容器 docker rm langgraph-postgres # 删除容器(需要先停止)
A.2.1.4. Docker Compose方式(可选)
如果更习惯使用 Docker Compose 管理容器,可以创建 docker-compose.yml 文件:
yaml
version: '3.8'
services:
postgres:
image: postgres:16
container_name: langgraph-postgres
environment:
POSTGRES_DB: langgraph_db
POSTGRES_USER: langgraph_user
POSTGRES_PASSWORD: 123456
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data # 持久化数据,容器删除后数据不丢失
volumes:
pgdata:
然后在 docker-compose.yml 所在目录执行:
bash
docker compose up -d
volumes 配置将数据库文件映射到宿主机,即使容器被删除,数据也不会丢失------这对于保存检查点数据非常重要。
A.2.2. 方式二:Windows直接安装
如果你不想安装 Docker ,也可以直接在 Windows 上安装 PostgreSQL。
A.2.2.1. 下载安装包
访问 PostgreSQL Windows 官方下载页 ,或直接访问 EDB安装包下载页 ,选择 PostgreSQL 16.x 版本、Windows x86-64 架构的安装包下载。
建议下载带有
pgAdmin(图形化管理工具)的完整安装包。
A.2.2.2. 安装步骤
- 运行安装程序 :双击下载的
.exe文件,点击Next; - 选择安装目录 :建议保持默认路径
C:\Program Files\PostgreSQL\16\; - 选择安装组件 :勾选
PostgreSQL Server、pgAdmin 4(可选)、Command Line Tools(必选,需要psql命令行工具); - 设置超级用户密码 :安装程序会要求为
postgres超级用户设置密码(记好这个密码); - 设置端口 :保持默认
5432; - 选择区域设置 :保持默认
Default locale; - 安装:等待进度条完成。
A.2.2.3. 验证安装
安装完成后,打开 CMD 或 PowerShell:
bash
# 检查 psql 是否可用
psql --version
# 使用超级用户连接数据库
psql -h localhost -p 5432 -U postgres
输入安装时设置的 postgres 密码,如果能进入 psql 交互界面(显示 postgres=# 提示符),说明安装成功。
A.2.2.4. Windows服务管理
PostgreSQL 安装后会自动注册为 Windows 服务(服务名:postgresql-x64-16),默认开机自启。
常用服务管理操作:
bash
# 查看服务状态(PowerShell)
Get-Service postgresql-x64-16
# 手动启动服务
net start postgresql-x64-16
# 手动停止服务
net stop postgresql-x64-16
也可以通过 services.msc(服务管理器)图形界面管理。
A.2.3. 安装Python依赖
无论选择哪一种安装方式,都需要安装 Python 侧的依赖包:
bash
pip install langgraph-checkpoint-postgres==3.0.5
pip install psycopg[binary]
| 包名 | 作用 |
|---|---|
langgraph-checkpoint-postgres |
LangGraph 的 PostgreSQL 检查点后端,提供 PostgresSaver |
psycopg[binary] |
Python 的 PostgreSQL 驱动,负责底层数据库通信 |
langgraph-checkpoint-postgres内部依赖psycopg,但建议显式安装以确保版本正确。[binary]后缀表示安装包含预编译二进制文件的版本,避免从源码编译的麻烦。
A.3. PostgreSQL基本操作
本节介绍 PostgreSQL 的常用操作。掌握这些内容有助于理解 PostgresSaver 在数据库中做了什么,也方便日常排查问题。
A.3.1. 连接PostgreSQL
A.3.1.1. 使用psql命令行工具
psql 是 PostgreSQL 自带的命令行客户端。
连接命令格式:
bash
psql -h <主机> -p <端口> -U <用户名> -d <数据库名>
对应本附录的连接信息:
bash
psql -h localhost -p 5432 -U langgraph_user -d langgraph_db
# 输入密码: 123456
连接成功后,终端提示符变为 langgraph_db=>,可以输入 SQL 命令。
Docker 用户 :如果主机上没有安装
psql,可以直接进入容器内执行:
bashdocker exec -it langgraph-postgres psql -U langgraph_user -d langgraph_db
常用 psql 元命令:
| 命令 | 说明 |
|---|---|
\l |
列出所有数据库 |
\dt |
列出当前数据库的所有表 |
\d table_name |
查看表结构 |
\q |
退出 psql |
A.3.1.2. 使用Python连接
在 Python 中使用 psycopg 连接:
python
import psycopg
DB_URL = "postgresql://langgraph_user:123456@localhost:5432/langgraph_db?sslmode=disable"
conn = psycopg.connect(DB_URL)
cur = conn.cursor()
# 查看 PostgreSQL 版本信息
cur.execute("SELECT version();")
result = cur.fetchone()
print(f"PostgreSQL 版本:{result[0]}")
cur.close()
conn.close()
运行结果如下
PostgreSQL 版本:PostgreSQL 16.x on x86_64-pc-linux-gnu, compiled by gcc ...
注意 :如果在
pip install psycopg时没有加[binary]后缀,可能会因为缺少编译环境而报错。遇到这种情况执行pip install psycopg[binary]即可。
A.3.2. 创建数据库和用户
Docker 用户请注意 :通过
docker run的-e环境变量启动容器时,数据库和用户已经自动创建并配置好权限,可以跳过本节 ,直接阅读 A.3.3节。
如果你是手动安装的 *PostgreSQL*,启动时 不会**自动创建 **langgraph_db数据库和 langgraph_user 用户,需要手动创建。
A.3.2.1. 创建数据库
首先用超级用户 postgres 连接:
bash
psql -h localhost -p 5432 -U postgres
然后创建数据库:
sql
CREATE DATABASE langgraph_db;
查看是否创建成功:
sql
\l
输出中如果能找到 langgraph_db 条目,则说明创建成功。
A.3.2.2. 创建用户并授权
在同一个 psql 会话中继续执行:
sql
-- 创建用户
CREATE USER langgraph_user WITH PASSWORD '123456';
-- 赋予该用户对 langgraph_db 的所有权限
GRANT ALL PRIVILEGES ON DATABASE langgraph_db TO langgraph_user;
PostgreSQL 15+版本默认收紧了对public模式的权限管理。如果后续PostgresSaver在创建表时报权限错误,需要额外执行:
sql\c langgraph_db GRANT ALL ON SCHEMA public TO langgraph_user;
退出 psql,用新用户测试连接:
bash
psql -h localhost -p 5432 -U langgraph_user -d langgraph_db
# 输入密码: 123456
如果能成功连接,说明用户和数据库配置正确。
A.3.3. 基本CRUD操作
了解基本的 SQL 增删改查有助于查看和理解 PostgresSaver 内部的数据存储。
A.3.3.1. 创建表
sql
CREATE TABLE IF NOT EXISTS messages (
id SERIAL PRIMARY KEY,
thread_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
SERIAL PRIMARY KEY:自增主键,PostgreSQL会自动为新插入的行分配唯一的id;TEXT:变长文本类型,适合存储聊天消息内容;TIMESTAMP DEFAULT CURRENT_TIMESTAMP:时间戳类型,默认为插入时的当前时间。
A.3.3.2. 插入数据
sql
INSERT INTO messages (thread_id, role, content)
VALUES ('chapter_6_6.2.4', 'user', '你好,我是老王');
INSERT INTO messages (thread_id, role, content)
VALUES ('chapter_6_6.2.4', 'assistant', '老王你好!有什么可以帮你的?');
A.3.3.3. 查询数据
sql
-- 查询所有记录
SELECT * FROM messages;
-- 按 thread_id 筛选(最常用,对应 LangGraph 中的 thread)
SELECT * FROM messages WHERE thread_id = 'chapter_6_6.2.4';
-- 按时间排序
SELECT * FROM messages WHERE thread_id = 'chapter_6_6.2.4' ORDER BY created_at;
A.3.3.4. 更新和删除数据
sql
-- 更新某条记录的内容
UPDATE messages SET content = '你好,我是老王,今年30岁。'
WHERE id = 1;
-- 删除某条记录
DELETE FROM messages WHERE id = 2;
-- 删除整个表(注意:操作不可逆)
DROP TABLE IF EXISTS messages;
A.3.4. 查看PostgresSaver创建的表结构
这是整个附录中最实用的部分------连接回 6.2.4节 ,看看 checkpointer.setup() 在 PostgreSQL 中到底创建了什么。
A.3.4.1. 运行setup()后查看表
首先运行一次 6.2.4节 的代码(确保 setup() 已执行),然后在 psql 中查看:
sql
\c langgraph_db
\dt
运行结果如下
List of relations
Schema | Name | Type | Owner
--------+-----------------------+-------+----------------
public | checkpoint_blobs | 数据表 | langgraph_user
public | checkpoint_migrations | 数据表 | langgraph_user
public | checkpoint_writes | 数据表 | langgraph_user
public | checkpoints | 数据表 | langgraph_user
可以看到 setup() 在数据库中创建了四张表:
| 表名 | 用途 |
|---|---|
checkpoints |
存储完整的检查点快照(状态、元数据等) |
checkpoint_writes |
存储每个检查点对应的通道写入记录 |
checkpoint_blobs |
存储大型二进制数据(如图片、文件等,如果状态中包含的话) |
checkpoint_migrations |
记录数据库迁移版本,LangGraph 内部使用,用于管理表结构变更 |
A.3.4.2. 检查点表结构说明
使用 \d 命令查看每张表的实际结构。
(1)checkpoints 表
sql
\d checkpoints
栏位 | 类型 | 可空的 | 预设
----------------------+-------+----------+-------------
thread_id | text | not null |
checkpoint_ns | text | not null | ''::text
checkpoint_id | text | not null |
parent_checkpoint_id | text | |
type | text | |
checkpoint | jsonb | not null |
metadata | jsonb | not null | '{}'::jsonb
索引:
"checkpoints_pkey" PRIMARY KEY, btree (thread_id, checkpoint_ns, checkpoint_id)
"checkpoints_thread_id_idx" btree (thread_id)
关键字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
thread_id |
text |
线程ID,对应 config 中的 thread_id,用于区分不同对话 |
checkpoint_ns |
text |
检查点命名空间,默认空字符串,用于子图等场景隔离不同层级的检查点 |
checkpoint_id |
text |
检查点唯一ID,由 LangGraph 内部生成 |
parent_checkpoint_id |
text |
父检查点ID,构建检查点链,用于 Time Travel 回退 |
type |
text |
检查点类型 |
checkpoint |
jsonb |
检查点核心数据(状态快照),以 JSON 格式存储 |
metadata |
jsonb |
检查点元数据(创建时间、来源、步骤序号等),预设为 '{}'::jsonb |
- 主键为 (thread_id, checkpoint_ns, checkpoint_id) 联合主键。
- 另有
thread_id单列索引,加速按线程筛选查询。
(2)checkpoint_writes 表
sql
\d checkpoint_writes
栏位 | 类型 | 可空的 | 预设
---------------+---------+----------+----------
thread_id | text | not null |
checkpoint_ns | text | not null | ''::text
checkpoint_id | text | not null |
task_id | text | not null |
idx | integer | not null |
channel | text | not null |
type | text | |
blob | bytea | not null |
task_path | text | not null | ''::text
索引:
"checkpoint_writes_pkey" PRIMARY KEY, btree (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
"checkpoint_writes_thread_id_idx" btree (thread_id)
关键字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
thread_id |
text |
线程ID |
checkpoint_ns |
text |
命名空间,与 checkpoints 表对应 |
checkpoint_id |
text |
所属检查点ID |
task_id |
text |
产生写入的节点任务ID |
idx |
integer |
写入顺序索引 |
channel |
text |
写入的目标通道名(如 messages 、__pregel_tasks) |
type |
text |
写入数据类型 |
blob |
bytea |
写入的二进制数据 |
task_path |
text |
任务路径,用于追踪节点在子图中的层级关系 |
- 主键为 (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) 联合主键,确保同一任务下的写入顺序唯一。
(3)checkpoint_blobs 表
sql
\d checkpoint_blobs
栏位 | 类型 | 可空的 | 预设
---------------+-------+----------+----------
thread_id | text | not null |
checkpoint_ns | text | not null | ''::text
channel | text | not null |
version | text | not null |
type | text | not null |
blob | bytea | |
索引:
"checkpoint_blobs_pkey" PRIMARY KEY, btree (thread_id, checkpoint_ns, channel, version)
"checkpoint_blobs_thread_id_idx" btree (thread_id)
关键字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
thread_id |
text |
线程ID |
checkpoint_ns |
text |
命名空间 |
channel |
text |
通道名 |
version |
text |
数据版本标识,同一通道可以有多个版本 |
type |
text |
数据类型 |
blob |
bytea |
二进制数据本体 |
- 主键为 (thread_id, checkpoint_ns, channel, version) 联合主键。
- 与
checkpoint_writes不同,checkpoint_blobs以 (channel, version) 维度管理数据版本,不直接关联某个具体的checkpoint_id,允许多个检查点共享同一通道的大型二进制数据。
(4)checkpoint_migrations 表
sql
\d checkpoint_migrations
栏位 | 类型 | 可空的 | 预设
------+---------+----------+------
v | integer | not null |
索引:
"checkpoint_migrations_pkey" PRIMARY KEY, btree (v)
关键字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
v |
integer |
当前数据库迁移版本号 |
- 单列主键,仅一条记录,
setup()执行时自动检查并更新。 - 该表由
LangGraph内部管理,用户无需手动操作。未来LangGraph版本升级时,若表结构有变更,setup()会根据此版本号自动执行对应的迁移SQL。
四张表的关系:
checkpoints存储每次graph.invoke()产生的检查点快照;checkpoint_writes存储每个检查点下各节点任务的通道写入记录(一条检查点通常对应多条写入);checkpoint_blobs存储通道级别的大型二进制数据,以 (thread_id, checkpoint_ns, channel, version) 为维度,可跨检查点复用;checkpoint_migrations仅用于数据库表结构版本管理。
LangGraph通过thread_id+checkpoint_ns+checkpoint_id联合定位和恢复状态。
A.3.4.3. 写入检查点数据查询
运行 6.2.4节 的示例代码后,可以直接在数据库中查看写入的数据:
sql
SET client_encoding = 'UTF8';
-- 查看指定线程的检查点数量
SELECT thread_id, count(*)
FROM checkpoints
WHERE thread_id = 'chapter-03-02'
GROUP BY thread_id;
-- 查看所有检查点的基本信息(按创建时间排序)
SELECT checkpoint_id, parent_checkpoint_id, checkpoint_ns, metadata
FROM checkpoints
WHERE thread_id = 'chapter-03-02'
ORDER BY metadata;
-- 查看最新检查点的消息通道写入数据
SELECT c.checkpoint_id, c.checkpoint_ns, cw.channel, cw.task_id, cw.type
FROM checkpoints c
JOIN checkpoint_writes cw
ON c.thread_id = cw.thread_id
AND c.checkpoint_ns = cw.checkpoint_ns
AND c.checkpoint_id = cw.checkpoint_id
WHERE c.thread_id = 'chapter-03-02'
AND cw.channel = 'messages'
ORDER BY cw.idx;
-- 查看最新检查点的完整状态 JSON
SELECT checkpoint_id, checkpoint
FROM checkpoints
WHERE thread_id = 'chapter-03-02'
ORDER BY metadata DESC
LIMIT 1;
通过这些查询,可以直观地理解 PostgresSaver 是如何将 LangGraph 的检查点数据持久化到 PostgreSQL 中的。
Windows 终端编码问题 : 在中文
Windows的CMD或PowerShell中使用psql查询checkpoint(jsonb)字段时,如果数据中包含 emoji 等 4 字节UTF-8字符,可能会报错:
错误: 编码"UTF8"的字符0x0xf0 0x9f 0xa4 0x94在编码"GBK"没有相对应值这是因为
Windows终端默认编码为GBK,无法处理 4 字节UTF-8字符。解决方案:
sql-- 查询前先切换客户端编码 SET client_encoding = 'UTF8';或在
PowerShell中先执行chcp 65001切换到UTF-8编码,再启动psql。
补充说明
setup()是幂等的------多次调用不会重复创建表,也不会清空已有数据;- 实际项目中,建议将
setup()作为独立的数据库初始化/迁移步骤执行,而不是每次启动应用时都调用(这也是 6.2.4节 代码注释中提到的建议); - 如果需要主动清理某个线程的检查点数据,可以使用
checkpointer.delete_thread(thread_id)方法,或者从数据库层面删除对应记录; - 关于连接池:如果需要在生产环境中处理高并发请求,建议使用
psycopg_pool提供的连接池功能,而不是每次创建新连接。