langgraph教程系列-06-让流程可暂停 - 持久化与 checkpoint

本文是「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 APIget_history 获取轨迹,stream 从历史重跑
  • Checkpointer 选型:MemorySaver(开发)、SQLiteSaver(单机)、PostgresSaver(生产)

v2 有了时间线,可以随时查看、回溯、回放。但它还有个问题------只能自己跑,不能等人。用户说"第 2 轮查得不行,我手动改一下再跑"怎么办?能不能在关键节点暂停,让人介入审批?

答案是能,而且需要 interrupt。下一篇,人在回路登场。

赞或收藏 关注 我们下次再见

相关推荐
玉鸯3 小时前
多 Agent 系统通信的实现原理与最佳实践
llm·agent·mcp
Tsonglew3 小时前
OpenWorker 代码解剖:一个 AI 同事"敢让它干活"的工程学
agent·ai编程
测试开发技术5 小时前
AI 测试提效 | 告别手工写脚本,分享我的 Playwright + Skill 批量生成 UI 自动化脚本方案
自动化测试·人工智能·ui·自动化·agent·skill·ai测试
喜欢的名字被抢了5 小时前
langgraph教程系列-05-给 agent 装上手脚 - 工具调用
agent·教程·langgraph
leeyi6 小时前
Embedder 接口 + 缓存层源码:一个把 key 撑大 3 倍的实现(第71篇-E57)
aigc·agent·ai编程
JaydenAI6 小时前
[Agent的评估-07]整合MEAI针对自然语言处理相关的评估器
ai·nlp·agent·evaluation·maf
CZW6 小时前
RAG系统核心解析:文档向量化与检索的实践探索
agent
code_371496 小时前
Agent 设计及实现 demo
agent
极客密码7 小时前
DeepSeek V4-Flash 正式版发布:Agent 基准、价格与 Codex 接入保姆级教程
agent·ai编程·deepseek