本文是「LangGraph 教程系列」第 6 篇。写作时基于 langgraph 1.2.10、langchain 1.3.14、langchain-openai 1.4.1、Python 3.12+。配套代码仓库 https://github.com/wxj006007/deep-research-assistant ,本篇对应 tag
v2。
上一篇 v1.1 让研究助手能真上网查了,每次 invoke 都是从头跑到尾------plan → search → evaluate → (loop) → synthesize,跑完就散场。你无法:
- 查看中途状态(查了几轮、每轮的结果是什么)
- 暂停后继续(用户说"等一下我再回来")
- 回溯到历史状态重跑(发现方向错了回滚)
- 多会话共享记忆(thread 级短期记忆)
这些问题不是 bug,是设计选择。v1 的图是无状态 的,invoke 只返回最终结果,中间过程不留痕迹。有状态需要什么?需要 checkpoint。
这一篇就来接入 checkpointer,给 agent 装上时间线。
一、v1.1 撞的墙,执行过程不留痕
假设用户在问一个复杂问题,跑了 5 轮检索才找到答案。过程中他想知道:"刚才那两轮都查了什么?怎么突然判定资料够了?"------没有 checkpoint,这问题没法回答。
更极端的场景是,用户看到"第 3 轮查的方向好像不对",想撤销到第 2 轮重新来。无状态图做不到这个,它只有"运行中"和"已结束"两种状态,中间态全部蒸发。
还有一个真实需求:多轮对话。比如用户先问"LangGraph 是什么",助手查了三轮给出答案;紧接着用户追问"它在生产环境怎么用",同一个助手凭什么记住上一轮的上下文?线程级短期记忆就是靠 checkpoint 实现的。
二、checkpoint 机制的核心概念
2.1 什么是 checkpoint
Checkpoint 在每个超级步(superstep)结束后自动保存 state 快照。LangGraph 的 superstep 是原子执行单元:节点串行或并行执行→state 合并→reducer 累积→保存 checkpoint→进入下一轮。
snapshot 包含什么:
- values:当前 state 的所有字段值
- config:配置元数据,最重要的是 thread_id
- metadata:附加信息,如创建时间、备注等
2.2 Thread 隔离
thread_id 是 checkpoint 的主键,不同 thread 之间 state 完全隔离:
python
# Thread A 的运行
thread_a = {"configurable": {"thread_id": "thread-a"}}
graph.invoke({"question": "A 的问题"}, config=thread_a)
# Thread B 的运行
thread_b = {"configurable": {"thread_id": "thread-b"}}
graph.invoke({"question": "B 的问题"}, config=thread_b)
两个线程的 checkpoint 独立存储,互不干扰。这是多租户系统的基础:同一张图可以服务多个用户,每个用户有自己的记忆。
2.3 Checkpoint 与 History
单次 invoke 只返回最终 state,但 get_history(thread_config) 能获取完整执行轨迹:
python
history = list(graph.get_history(thread_a))
for snapshot in history:
print(f"round={snapshot.values['round']}, verdict={snapshot.values['verdict']}")
每个 snapshot 代表一个超级步结束时的 state 快照,按执行顺序排列。你可以用它做调试、审计、回放。
三、v2 的图结构
#mermaid-svg-maV73m3pLhFIj3gP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-maV73m3pLhFIj3gP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-maV73m3pLhFIj3gP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-maV73m3pLhFIj3gP .error-icon{fill:#552222;}#mermaid-svg-maV73m3pLhFIj3gP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-maV73m3pLhFIj3gP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-maV73m3pLhFIj3gP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-maV73m3pLhFIj3gP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-maV73m3pLhFIj3gP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-maV73m3pLhFIj3gP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-maV73m3pLhFIj3gP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-maV73m3pLhFIj3gP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-maV73m3pLhFIj3gP .marker.cross{stroke:#333333;}#mermaid-svg-maV73m3pLhFIj3gP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-maV73m3pLhFIj3gP p{margin:0;}#mermaid-svg-maV73m3pLhFIj3gP .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-maV73m3pLhFIj3gP .cluster-label text{fill:#333;}#mermaid-svg-maV73m3pLhFIj3gP .cluster-label span{color:#333;}#mermaid-svg-maV73m3pLhFIj3gP .cluster-label span p{background-color:transparent;}#mermaid-svg-maV73m3pLhFIj3gP .label text,#mermaid-svg-maV73m3pLhFIj3gP span{fill:#333;color:#333;}#mermaid-svg-maV73m3pLhFIj3gP .node rect,#mermaid-svg-maV73m3pLhFIj3gP .node circle,#mermaid-svg-maV73m3pLhFIj3gP .node ellipse,#mermaid-svg-maV73m3pLhFIj3gP .node polygon,#mermaid-svg-maV73m3pLhFIj3gP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-maV73m3pLhFIj3gP .rough-node .label text,#mermaid-svg-maV73m3pLhFIj3gP .node .label text,#mermaid-svg-maV73m3pLhFIj3gP .image-shape .label,#mermaid-svg-maV73m3pLhFIj3gP .icon-shape .label{text-anchor:middle;}#mermaid-svg-maV73m3pLhFIj3gP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-maV73m3pLhFIj3gP .rough-node .label,#mermaid-svg-maV73m3pLhFIj3gP .node .label,#mermaid-svg-maV73m3pLhFIj3gP .image-shape .label,#mermaid-svg-maV73m3pLhFIj3gP .icon-shape .label{text-align:center;}#mermaid-svg-maV73m3pLhFIj3gP .node.clickable{cursor:pointer;}#mermaid-svg-maV73m3pLhFIj3gP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-maV73m3pLhFIj3gP .arrowheadPath{fill:#333333;}#mermaid-svg-maV73m3pLhFIj3gP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-maV73m3pLhFIj3gP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-maV73m3pLhFIj3gP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-maV73m3pLhFIj3gP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-maV73m3pLhFIj3gP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-maV73m3pLhFIj3gP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-maV73m3pLhFIj3gP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-maV73m3pLhFIj3gP .cluster text{fill:#333;}#mermaid-svg-maV73m3pLhFIj3gP .cluster span{color:#333;}#mermaid-svg-maV73m3pLhFIj3gP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-maV73m3pLhFIj3gP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-maV73m3pLhFIj3gP rect.text{fill:none;stroke-width:0;}#mermaid-svg-maV73m3pLhFIj3gP .icon-shape,#mermaid-svg-maV73m3pLhFIj3gP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-maV73m3pLhFIj3gP .icon-shape p,#mermaid-svg-maV73m3pLhFIj3gP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-maV73m3pLhFIj3gP .icon-shape .label rect,#mermaid-svg-maV73m3pLhFIj3gP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-maV73m3pLhFIj3gP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-maV73m3pLhFIj3gP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-maV73m3pLhFIj3gP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 保存 snapshot
StateGraph with MemorySaver
continue
finish
START
plan
search
evaluate
synthesize
END
Checkpointer
Memory
图结构没变!唯一的差异是在 compile 时传入 checkpointer:
python
from langgraph.checkpoint.memory import MemorySaver
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)
工具接入像这样:对内部透明,对外部有感知。这是 LangGraph 的设计哲学。
四、动手实现
4.1 环境准备
bash
pip install langgraph==1.2.10 langchain==1.3.14 langchain-openai==1.4.1 python-dotenv
内存版 checkpointer 不需要额外依赖,直接导入即可。持久化版本需要安装相应驱动:
- SQLite:
pip install sqlite3(内置) - PostgreSQL:
pip install psycopg2-binary
4.2 State,保持不变
python
class ResearchState(TypedDict):
question: str
current_query: str
executed_queries: Annotated[list[str], operator.add]
docs: Annotated[list[Doc], operator.add]
round: int
verdict: str
gap: str
answer: str
State 结构不需要改,checkpoint 只管存,不管语义。
4.3 Node 实现,和 v1.1 一模一样
plan_node、search_node、evaluate_node、synthesize_node 都没变,唯一的变化是图多了个 checkpointer 属性。这也是 checkpoint 设计的巧妙之处:节点无需感知持久化。你不用在每个 node 里手动 save/checkpoint,框架在 superstep 结束后自动处理。
4.4 编译图时注入 checkpointer
关键的一行代码:
python
def build_graph():
builder = StateGraph(ResearchState)
# ... add nodes and edges ...
checkpointer = MemorySaver() # 内存版本
return builder.compile(checkpointer=checkpointer)
就这么简单。compare 到之前不带 checkpointer 的版本,唯一新增的就是这 3 行。
五、核心 API 用法
5.1 带 config 的 invoke
python
# 默认:无 config,每次 invoke 独立
result = graph.invoke({"question": "你好"})
# 指定 thread_id,启用持久化
thread_config = {"configurable": {"thread_id": "user-123"}}
result = graph.invoke({"question": "你好"}, config=thread_config)
不提供 thread_id,checkpointer 不会生效(或者说每次都新建一个 thread)。必须显式指定 thread_id,才能复用历史状态。
5.2 查看历史记录
python
thread_config = {"configurable": {"thread_id": "research-thread-1"}}
history = list(graph.get_history(thread_config))
print(f"共 {len(history)} 个历史状态")
for i, snapshot in enumerate(history):
state = snapshot.values
print(f"状态 {i+1}: round={state['round']}, verdict='{state['verdict']}', docs={len(state['docs'])} 条")
get_history 返回生成器,每个元素是一个 CheckpointTuple,包含 values、config、metadata。
5.3 从历史状态继续执行
这是 checkpoint 最实用的功能之一:中断后恢复。
python
# 获取第 2 个状态的 snapshot
history = list(graph.get_history(thread_config))
second_snapshot = history[1]
# 从那个状态继续执行
for state in graph.stream(second_snapshot.values, stream_mode="values"):
print(f"继续执行 → round={state['round']}")
注意这里是 stream second_snapshot.values(state 内容),而不是用 second_snapshot.config。你想"从历史状态重启",但不想要历史记录累加到新 session 里。
5.4 多会话隔离
python
thread_a = {"configurable": {"thread_id": "thread-A"}}
thread_b = {"configurable": {"thread_id": "thread-B"}}
# Thread A
for state in graph.stream({"question": "A 的问题"}, stream_mode="values", config=thread_a):
pass
print(f"Thread A 最终 round: {state['round']}")
# Thread B
for state in graph.stream({"question": "B 的问题"}, stream_mode="values", config=thread_b):
pass
print(f"Thread B 最终 round: {state['round']}")
# 验证独立
history_a = len(list(graph.get_history(thread_a)))
history_b = len(list(graph.get_history(thread_b)))
print(f"Thread A 历史状态数:{history_a}")
print(f"Thread B 历史状态数:{history_b}")
输出会显示两个线程各自完成了自己的执行流程,彼此不影响。这就是多租户能力的基础。
5.5 Delete 历史记录
python
graph.delete(thread_config) # 删除指定 thread 的所有历史
谨慎使用,删除后无法恢复。适合"清除用户隐私数据"或"测试环境重置"场景。
六、常用 Checkpointer 实现
6.1 MemorySaver(内存版)
python
from langgraph.checkpoint.memory import MemorySaver
checkpointer = MemorySaver()
优点:
- 零依赖,开箱即用
- 响应快,无 IO 开销
- 适合开发调试、单元测试
缺点:
- 进程重启后数据丢失
- 单进程隔离,不支持跨进程共享
适用场景:本地开发、教学演示、自动化测试。
6.2 PostgresSaver(PostgreSQL 版)
python
from langgraph.checkpoint.postgres import PostgresSaver
conn_str = "postgresql://user:pass@localhost:5432/dbname"
checkpointer = PostgresSaver(conn_str)
优点:
- 持久化存储,进程重启不丢数据
- 支持多进程/多实例共享
- 支持并发控制、事务隔离
缺点:
- 依赖 PostgreSQL 数据库
- 需要 DBA 运维(备份、索引、扩缩容)
适用场景:生产环境、多实例部署、需审计的场景。
6.3 SQLiteSaver(SQLite 版)
python
from langgraph.checkpoint.sqlite import SqliteSaver
conn_str = "sqlite:///checkpoints.db"
checkpointer = SqliteSaver.from_conn_string(conn_str)
优点:
- 文件型存储,零依赖
- 轻量级,适合单机
- 支持基本 CRUD
缺点:
- 并发写性能一般
- 不适合分布式部署
适用场景:单机测试、小规模部署、离线 Demo。
七、实践建议
7.1 Thread ID 设计策略
thread_id 怎么选?常见模式:
- 用户级 :
thread_id = user_id,每个用户一个会话 - 对话级 :
thread_id = f"{user_id}:{conversation_id}",每个对话一个线程 - 消息级 :
thread_id = message_id,每条消息独立(失去记忆意义)
推荐对话级,平衡记忆范围和复杂度。如果业务需要跨对话记忆,考虑用 Store(第 8 篇讲)。
7.2 Checkpoint 大小优化
每次 superstep 都保存全量 state,数据量大时会占空间。优化策略:
- 压缩:对 doc.content 等大字段 gzip 压缩
- 裁剪:只保留关键字段,过滤 debug info
- 过期:设置 TTL,定期清理旧记录
PostgresSaver 支持自定义 metadata,可以加 created_at 字段配合定时任务清理。
7.3 并发安全
多线程同时写同一个 thread_id 可能冲突。LangGraph 内置乐观锁:检测到 conflict 会抛异常,调用方需重试。
python
import time
for attempt in range(3):
try:
graph.invoke(input, config=thread_config)
break
except Exception as e:
if attempt == 2:
raise
time.sleep(0.1 * (2 ** attempt)) # exponential backoff
生产环境建议加 retry 逻辑,避免瞬间失败。
7.4 Debugging 技巧
checkpoint 的最大好处是可调:
python
# 1. 查看完整历史
history = list(graph.get_history(thread_config))
for snap in history:
print(json.dumps(snap.values, indent=2, default=str))
# 2. 定位问题 state
problem_snap = next(s for s in history if s.values['round'] == 2)
# 修复逻辑
problem_snap.values['gap'] = "补充查询词"
# 3. 从问题点重跑
for state in graph.stream(problem_snap.values, stream_mode="updates"):
print(state)
这种"状态快照 - 修改 - 重放"的能力,是调试复杂 agent 工作流的利器。
八、跑起来
bash
python -m src.v2_checkpoint
示例输出:
问题:为什么 LangGraph 需要 checkpoint?它和人在回路是什么关系?
======================================================================
【第一轮运行】
第 1 轮 | 当前 verdict: insufficient
第 2 轮 | 当前 verdict: sufficient
最终回答长度:523 字符
======================================================================
【查看 checkpoint 历史】
共 3 个历史状态
状态 1: round=1, verdict='insufficient', docs=2 条,已完成=False
状态 2: round=2, verdict='sufficient', docs=4 条,已完成=False
状态 3: round=2, verdict='sufficient', docs=4 条,已完成=True
======================================================================
【演示多会话隔离】
Thread A:
Thread A 最终 round: 2
Thread B:
Thread B 最终 round: 2
Thread A 历史状态数:3
Thread B 历史状态数:3
✓ 两个线程的状态完全独立,互不影响
九、本篇小结
回到"持久化与 checkpoint"这块,v1.1 撞的墙是无状态导致无法追溯、无法恢复、无法共享记忆,解法是接入 MemorySaver。我们强调了几个关键点:
- Checkpoint 自动保存:superstep 结束后框架自动存 snapshot,节点无需感知
- Thread 隔离:thread_id 主键,多租户基础
- History API :
get_history获取轨迹,stream从历史重跑 - Checkpointer 选型:MemorySaver(开发)、SQLiteSaver(单机)、PostgresSaver(生产)
v2 有了时间线,可以随时查看、回溯、回放。但它还有个问题------只能自己跑,不能等人。用户说"第 2 轮查得不行,我手动改一下再跑"怎么办?能不能在关键节点暂停,让人介入审批?
答案是能,而且需要 interrupt。下一篇,人在回路登场。
赞或收藏 关注 我们下次再见