目录
- 前言
- 一、为什么要持久化:两种「记住」要分清
- 二、Checkpointer:把每一步存下来
- [三、多会话隔离:thread_id 就是隔离墙](#三、多会话隔离:thread_id 就是隔离墙)
- [四、断点恢复 / 时间旅行:读历史、回滚、fork](#四、断点恢复 / 时间旅行:读历史、回滚、fork)
- 五、Store:跨线程的长期记忆
- [六、生产级持久化:SQLite Checkpointer](#六、生产级持久化:SQLite Checkpointer)
- 七、工程化要点
- [附:「Deep Agents 等价实现」贴士](#附:「Deep Agents 等价实现」贴士)
- 参考文献
前言
主线:Agent Runtime → Harness 。上一篇我们把「状态」立住了------StateGraph 里流动的
messages会跨轮累积。但那份记忆是进程内 的:进程一关,全没了。本篇给状态装上「存档」:既能重启续跑,又能回到过去、改掉一步、开一条新分支------官方把这套能力叫时间旅行(time travel)。

一、为什么要持久化:两种「记住」要分清
第 3 篇埋过一个坑,这里正面接住它:多轮对话的「记忆」是 messages 状态跨轮累积的结果,存在图的状态里,进程一结束就烟消云散。真实系统里,「记住」至少要扛住三件事:
- 服务重启 / 崩溃:跑了一半的对话不能丢,重启后还得接着聊。
- 多会话隔离:同一套 Agent 服务 N 个用户,A 的聊天记录不能串到 B 那里。
- 跨会话长期记忆:用户上一周的偏好(「我喜欢简洁回答」),这一周还得记得------这跟「当前这轮聊到哪」是两码事。
前两件交给 Checkpointer(检查点存储) ,第三件交给 Store(长期记忆存储)。这是 LangGraph 运行时给「状态」配的两个独立后仓,一句话区分:
| Checkpointer | Store | |
|---|---|---|
| 存什么 | 图每跑一步的完整状态快照 | 任意键值对(业务自己定义) |
| 隔离维度 | 按 thread_id(一个会话一份) |
不认 thread_id,全局共享 |
| 用途 | 断点恢复、时间旅行、多会话隔离 | 跨会话长期记忆、偏好、知识 |
| 类比 | 游戏存档(每关一个档) | 云端账号资料(换设备也在) |
这一篇把两个都讲透,先 Checkpointer,后 Store。
二、Checkpointer:把每一步存下来
Checkpoint(检查点) 就是图每跑一步后的状态快照。不挂 checkpointer 时,LangGraph 跑完就完了,中间每一步的状态不留底;挂上之后,每步结束都落一份档。所谓断点恢复,就是「从某个档接着跑」。
接入方式极其省事------还记得第 3 篇那行 create_agent 吗?它直接把 checkpointer 透传给底层的 StateGraph.compile,你只要多传一个参数:
python
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(model, checkpointer=InMemorySaver())
命名收敛 :官方从 LangGraph 1.0 起把内存版 checkpointer 收敛到
InMemorySaver,MemorySaver仍是它的别名(InMemorySaver is MemorySaver为True),照旧教程写的MemorySaver不会报错。本系列统一用InMemorySaver。
内存版适合演示、单机、测试;真生产要落盘,后面第六节用 SQLite。先看内存版能带来什么。
三、多会话隔离:thread_id 就是隔离墙
挂上 checkpointer 后,状态按 thread_id 分门别类地存------这个 thread_id 写在调用时的 config 里,就是「会话 ID」。同一个 agent 服务所有用户,靠它天然隔离,你不用写任何「按用户分表」的逻辑。
完整真源见 code/05-持久化与断点恢复/checkpointer_basics.py:
python
import sys
from langchain.agents import create_agent
from langchain_core.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek
from langgraph.checkpoint.memory import InMemorySaver
# Windows 控制台默认 GBK,模型回复若带 emoji,print 会抛 UnicodeEncodeError;强制 UTF-8 输出
sys.stdout.reconfigure(encoding="utf-8")
model = ChatDeepSeek(model="deepseek-chat")
# 同一个 agent,挂上 Checkpointer ------ 状态开始按 thread_id 分门别类地存
checkpointer = InMemorySaver()
agent = create_agent(
model,
system_prompt="你是简洁的助手,记住用户告诉你的名字。",
checkpointer=checkpointer,
)
cfg_a = {"configurable": {"thread_id": "A"}}
cfg_b = {"configurable": {"thread_id": "B"}}
# 两个会话各记各的名字
agent.invoke({"messages": [HumanMessage(content="我叫小明,请记住。")]}, cfg_a)
agent.invoke({"messages": [HumanMessage(content="我叫小红,请记住。")]}, cfg_b)
# 再问:互不干扰
r_a = agent.invoke({"messages": [HumanMessage(content="我叫什么名字?")]}, cfg_a)
r_b = agent.invoke({"messages": [HumanMessage(content="我叫什么名字?")]}, cfg_b)
print("线程 A 记得:", r_a["messages"][-1].content)
print("线程 B 记得:", r_b["messages"][-1].content)
# 读当前快照:values(状态值)+ next(接下来要跑的节点)
snap = agent.get_state(cfg_a)
print("线程 A 快照 next =", snap.next)
print("线程 A 消息条数 =", len(snap.values["messages"]))
跑一遍:
线程 A 记得: 你叫小明呀!😊
线程 B 记得: 你叫小红。
线程 A 快照 next = ()
线程 A 消息条数 = 4
两个会话各记各的名字,互不串台------这就是多会话隔离。最后两行还带出一个新 API:get_state(config) 读当前快照 。它返回一个 StateSnapshot,两个字段最有信息量:
values:此刻的完整状态(上面的messages列表);next:接下来要跑的节点。这里next = ()空元组,表示「跑完了、没有下一步」。
next 是读快照最该盯着看的字段------它告诉你「这张档停在了图的哪个位置」。
四、断点恢复 / 时间旅行:读历史、回滚、fork
get_state 只读「当前」这张档。要回放过去,LangGraph 给了一套四件套 ,官方统称 time travel(时间旅行):
| API | 干什么 |
|---|---|
get_state(config) |
读当前快照 |
get_state_history(config) |
读历史快照(最近的在前) |
Replay(续跑) :invoke(None, config) |
input=None 表示「不塞新输入」,从 config 指向的 checkpoint 接着往下跑 |
Fork(分叉) :update_state(config, values, as_node=...) |
改某个历史状态,开出一条新分支 |
这套 API 最值钱的用法是两件事:回滚重写 (把模型答错的最后一句改掉)和 fork 对照 (从同一个过去,开出两条不同未来)。用一个带股价工具的 agent 演示,完整真源见 code/05-持久化与断点恢复/time_travel.py:
python
import sys
from langchain.agents import create_agent
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool
from langchain_deepseek import ChatDeepSeek
from langgraph.checkpoint.memory import InMemorySaver
sys.stdout.reconfigure(encoding="utf-8")
@tool
def get_stock_price(symbol: str) -> str:
"""查询某只股票的实时价格。"""
return {"AAPL": "320.5 元", "MSFT": "450.2 元"}.get(symbol, f"{symbol}:100.0 元(默认)")
model = ChatDeepSeek(model="deepseek-chat").bind_tools([get_stock_price])
agent = create_agent(
model,
tools=[get_stock_price],
system_prompt="你是金融助手,回答要简洁。",
checkpointer=InMemorySaver(),
)
cfg = {"configurable": {"thread_id": "t1"}}
agent.invoke({"messages": [HumanMessage(content="查一下 AAPL 的股价是多少?")]}, cfg)
# 1) 读历史:最近的快照在前,next 是「接下来要跑的节点」
snaps = list(agent.get_state_history(cfg))
print("=== 历史快照(新 → 旧) ===")
for s in snaps:
if not s.values["messages"]:
print(f" next={s.next} (空)")
continue
last = s.values["messages"][-1]
if isinstance(last, AIMessage) and last.tool_calls:
desc = f"AIMessage(tool_calls={last.tool_calls[0]['name']})"
else:
desc = f"{type(last).__name__} :: {str(last.content)[:30]!r}"
print(f" next={s.next} 最后一条={desc}")
# 2) 回滚重写「坏回复」:回到最终回答之前的快照(next==('model',)),
# 用 as_node='model' 把最后一句覆盖成我们想要的
before_final = next(s for s in snaps if s.next == ("model",))
fixed = agent.update_state(
before_final.config,
{"messages": [AIMessage(content="(已重写)AAPL 最新股价 320.5 元。")]},
as_node="model",
)
print("\n=== 回滚重写后 ===")
print("最后一条:", agent.get_state(fixed).values["messages"][-1].content)
# 3) fork 出「换用另一数据源」的对照分支:回到工具调用前的快照(next==('tools',)),
# 用 as_node='tools' 注入另一条工具结果,再从那里继续跑
before_tool = next(s for s in snaps if s.next == ("tools",))
tool_call_id = before_tool.values["messages"][-1].tool_calls[0]["id"]
forked = agent.update_state(
before_tool.config,
{"messages": [ToolMessage(content="(另一数据源)AAPL:315.8 元", tool_call_id=tool_call_id, name="get_stock_price")]},
as_node="tools",
)
out = agent.invoke(None, forked)
print("\n=== fork 分支 ===")
print("最后一条:", out["messages"][-1].content)
跑一遍:
=== 历史快照(新 → 旧) ===
next=() 最后一条=AIMessage :: 'AAPL 当前股价为 320.5 元。'
next=('model',) 最后一条=ToolMessage :: '320.5 元'
next=('tools',) 最后一条=AIMessage(tool_calls=get_stock_price)
next=('model',) 最后一条=HumanMessage :: '查一下 AAPL 的股价是多少?'
next=('__start__',) (空)
=== 回滚重写后 ===
最后一条: (已重写)AAPL 最新股价 320.5 元。
=== fork 分支 ===
最后一条: AAPL 当前股价为 **315.8 元**(数据来源:另一数据源)。
拆一下这段历史。一轮「查股价」跑了 4 步,对应 4 张快照(get_state_history 最近的在前):
next=()------ 最终回答前,next空 = 已跑完;next=('model',)------ 工具结果已回来,下一步要跑 model;next=('tools',)------ 模型已决定调工具,下一步要跑 tools;next=('model',)------ 只有人类消息,下一步跑 model;next=('__start__',)------ 一张空档(图还没开始)。
回滚重写 发生在「最终回答之前」那张档(next==('model',),最后一条是 ToolMessage)。update_state(..., as_node="model") 的意思是:把这次更新当作 model 节点产出的 ,于是图认为「model 已经答完了」,next 变成空元组,最后一句被我们写的 AIMessage 顶掉。as_node 不是装饰------它决定了「改完这一步,接下来该跑谁」。这里选 model,正是「重写的是 model 的输出」。
fork 对照 则回到更早的「工具调用之前」(next==('tools',))。as_node="tools" 表示「假装 tools 节点产出了这条 ToolMessage」,于是 next 变成 ('model',)------图会接着让模型基于新注入的工具结果 再答一次。最后 invoke(None, forked)(Replay)把这条分支跑完,得到「315.8 元」。原来的 thread 还停在「320.5 元」,分叉出的新分支是「315.8 元」------同一个过去,两条不同未来,这就是 fork。换用另一工具时同理:注入那条工具的返回结果、以它的名字开分支即可。
小坑:
get_state_history返回的next是元组 (('model',)),不是列表;判断时别写成== ["model"]。另外历史里总有一张空的起始档(next=('__start__',)),遍历时记得跳过空messages。
五、Store:跨线程的长期记忆
Checkpointer 按 thread_id 隔离,天生「每个会话一份」;但跨会话的长期记忆 (用户偏好、领域知识)不该跟着 thread 走------它该是所有会话共享的。这就要 Store。
Store 是一份全局键值库 ,与 thread_id 无关。接口极简:put 写、get 读、search 按命名空间前缀批量取。抽象基类叫 BaseStore,内存版实现是 InMemoryStore。完整真源见 code/05-持久化与断点恢复/store_memory.py:
python
import sys
from langgraph.store.memory import InMemoryStore
sys.stdout.reconfigure(encoding="utf-8")
store = InMemoryStore()
# 会话 A(thread A)把「用户偏好」写进 Store ------ Store 不认 thread_id,是一份全局 KV
store.put(("preferences", "user_1"), "style", {"tone": "简洁", "language": "中文"})
print("会话 A 写入:", store.get(("preferences", "user_1"), "style").value)
# 会话 B(thread B)------不同的 thread,读的是同一个 Store
item = store.get(("preferences", "user_1"), "style")
print("会话 B 读到:", item.value)
# search:按命名空间前缀批量取
store.put(("preferences", "user_1"), "topic", {"interest": "AI Agent"})
results = store.search(("preferences", "user_1"))
print("search 结果:", [(r.key, r.value) for r in results])
跑一遍:
会话 A 写入: {'tone': '简洁', 'language': '中文'}
会话 B 读到: {'tone': '简洁', 'language': '中文'}
search 结果: [('style', {'tone': '简洁', 'language': '中文'}), ('topic', {'interest': 'AI Agent'})]
三个要点:
namespace是元组 :("preferences", "user_1"),约定「大类 / 小类」的分层,方便search按前缀批量取------这正是「一个用户的所有偏好」这类查询的落点。value是任意 dict:结构由你的业务定义,Store 不关心语义。- 与 Checkpointer 的边界 :Checkpointer 管「这一轮聊到哪」,Store 管「跨轮、跨会话都成立的事实」。前者线程内、自动写,后者全局、你手动写(讲长期记忆的那篇会看到 Deep Agents 的
MemoryMiddleware把 Store 封装成「自动写」,本篇先立住底层的 Store 语义)。
六、生产级持久化:SQLite Checkpointer
InMemorySaver 进程一关就丢。真上生产,把 checkpointer 换成 SQLite,状态就落到磁盘文件,重启不丢。
一个必须知道的坑 :SQLite checkpointer 在 LangGraph 0.2 起被拆到了独立包 langgraph-checkpoint-sqlite ------它不是 pip install langgraph 自带的「零依赖」。装它:
bash
pip install langgraph-checkpoint-sqlite
用法用 SqliteSaver.from_conn_string(...) 连一个 .sqlite 文件。完整真源见 code/05-持久化与断点恢复/sqlite_persist.py:
python
import sys
from langchain.agents import create_agent
from langchain_core.messages import HumanMessage
from langchain_deepseek import ChatDeepSeek
from langgraph.checkpoint.sqlite import SqliteSaver
sys.stdout.reconfigure(encoding="utf-8")
model = ChatDeepSeek(model="deepseek-chat")
cfg = {"configurable": {"thread_id": "user_1"}}
# 阶段一:写入(连一次 SQLite,用完即关)
with SqliteSaver.from_conn_string("checkpoints.sqlite") as saver:
agent = create_agent(
model,
system_prompt="你是简洁的助手,记住用户的名字。",
checkpointer=saver,
)
agent.invoke({"messages": [HumanMessage(content="我叫小明。")]}, cfg)
print("阶段一:已写入 checkpoints.sqlite")
# 阶段二:重新连接(模拟进程重启)再问
with SqliteSaver.from_conn_string("checkpoints.sqlite") as saver:
agent2 = create_agent(
model,
system_prompt="你是简洁的助手,记住用户的名字。",
checkpointer=saver,
)
r = agent2.invoke({"messages": [HumanMessage(content="我叫什么名字?")]}, cfg)
print("阶段二(重启后)记得:", r["messages"][-1].content)
跑一遍:
阶段一:已写入 checkpoints.sqlite
阶段二(重启后)记得: 你叫小明。
两个 with 块各自连了一次 SQLite、各建了一个 agent,中间相当于「进程重启」。第二次仍答出「小明」------因为状态已经被持久化到 checkpoints.sqlite 这个文件里,换进程、换 agent 实例,只要 thread_id 对得上,就接得上。
七、工程化要点
① 选型阶梯:内存 → SQLite → Postgres / Redis。 演示 / 单机 / 测试用 InMemorySaver;单实例落盘用 SQLite;多实例 / 多副本 / 高可用必须上 Postgres 或 Redis。后两者 LangGraph 都提供了现成实现(langgraph-checkpoint-postgres / langgraph-checkpoint-redis),降级路径是「换连接串」+ 一行 docker-compose,业务代码里只是换一个 from_conn_string 的串,其余不动。
② SQLite 的单写限制。 SQLite 是文件库,天然「多读、单写」:多个副本同时写同一份 .sqlite 会锁冲突。所以多实例 / 多副本部署时,不要共享一个 SQLite 文件,切 Postgres / Redis(它们天然支持并发写、水平扩展)。
③ as_node 决定「改完往哪走」。 update_state 的 as_node 不是可有可无的标注,它决定更新后 next 指向谁。重写模型输出用 as_node="model",注入工具结果用 as_node="tools",写错了图会停在错误的位置甚至路由报错。
④ 子图的分层命名空间 checkpoint_ns。 单图里一个 thread_id 一串快照;一旦引入子图(讲子图与多智能体编排的那篇),父子图共享 checkpoint 时靠 checkpoint_ns 给各层加命名空间前缀,避免父子状态串位。这里先记住有这个概念,讲子图时再展开。
⑤ 为长期记忆埋线。 本篇的 Store 是「手动读写」的底层 KV;到讲长期记忆的那篇,Deep Agents 的 MemoryMiddleware 会把它封装成「自动读写的记忆文件」------但底层存的还是这套 Store 语义。先把「Checkpointer = 线程内、Store = 跨线程」这条边界记牢,后面不会混。
(本文代码实测于 langgraph 1.2.14 / langchain-core 1.6.9 / langchain-deepseek 1.1.1 / langgraph-checkpoint-sqlite 3.1.1;完整真源见 code/05-持久化与断点恢复/ 下 4 个 .py。)
附:「Deep Agents 等价实现」贴士
Deep Agents 等价实现
本文手写的「checkpointer + store」持久化,在 Deep Agents 套件层是同样两个参数------
create_deep_agent把checkpointer/store原样透传给底层create_agent:
pythonfrom deepagents import create_deep_agent agent = create_deep_agent( model=..., tools=[...], checkpointer=saver, # 会话状态:SQLite / Postgres / Redis store=store, # 长期记忆:InMemoryStore / Postgres 等 )套件还在此基础上内置了
MemoryMiddleware,把长期记忆自动落到记忆文件(讲长期记忆的那篇展开)。需要精确控制「回滚到哪个 checkpoint、怎么 fork」时,再回到本文的底层写法。
参考文献
1 LangGraph 官方文档 · Persistence(Checkpointer / Store)--- https://docs.langchain.com/oss/python/langgraph/persistence
2 LangGraph 官方文档 · Time Travel(Replay / Fork / update_state)--- https://docs.langchain.com/oss/python/langgraph/time-travel
3 LangGraph API 参考 · langgraph-checkpoint-sqlite --- https://reference.langchain.com/python/langgraph-checkpoint-sqlite/
4 LangGraph API 参考 · InMemorySaver --- https://reference.langchain.com/python/langgraph/checkpoints/memory/

🎯 更多专栏系列文章可以查看博客主页📑 👍 若文章对你有所触动,恳请点赞 ⭐ 关注 ⭐ 收藏