第 6 篇把 interrupt 讲透了,留了个尾巴:MemorySaver 把档案放在进程内存里,进程一退,所有挂起待审的单子当场蒸发——审批流"挂起等人"的天性,撞上"重启即失"的存储,生产环境根本没法用。第 7 篇结尾又补了一句:子图与多代理挂上持久化,才真正具备生产形态。本篇是第 8 篇,把这两笔账一起结清:checkpointer 的完整机制——快照怎么打、线程怎么管、时间旅行怎么用。
版本口径与全系列一致:langgraph 1.2.x,PyPI 独立发布,机制以官方持久化文档与 Checkpointers 文档为准,时间旅行部分对照官方 Time travel 文档。全文按"机制 → 落盘 → 读档 → 历史 → 时间旅行 → 选型 → 翻译流实战 → 总结"展开。
1、checkpointer 每超步存一档
先钉定义,官方一句话:checkpointer 在每个超步边界给整张 State 打一份快照,按 thread 组织存放。超步是第 2 篇立过的概念——图的一轮推进,updates 流里一个片段就是一个超步。第 2 篇还留过一个伏笔:StateGraph 接收的是类而不是实例,形状与数据分离——正因如此,运行时才知道快照该"拍什么":每个超步跑完,把黑板全量连同元数据序列化进档案柜。挂载方式第 6 篇见过:compile 时传 checkpointer 参数,图本身的代码一行不改。
挂上之后它默默支撑四件事,官方列得明白,对照如下:
| 能力 | 靠档做什么 |
|---|---|
| 中断恢复 | 第 6 篇的 interrupt 停机存档、恢复取档 |
| 跨调用记忆 | 同一 thread 再次 invoke,接着上次的黑板跑 |
| 时间旅行 | 回到任意历史档,重跑或改道 |
| 故障容错 | 进程崩溃后从最近一档续跑 |
第 6 篇把 thread_id 叫"档案抽屉号",这里把口径补全:thread 是 checkpointer 的主键,存档取档全按它索引;一条 thread 里按超步顺序摞着一串档,每份档官方叫 checkpoint,代码里是一个 StateSnapshot 对象。两节点的写作图(第 2 篇的 make_outline → write_article)跑一遍,落档节奏示意如下:
解读:输入落板先存起点档,之后每跑完一个超步再各存一档;连同最初那份空档,官方口径两节点图共 4 份。档里存的不止黑板值,还有 next(下一拍待跑谁)、父档指针(上一份是谁)等元数据——一串档首尾相连构成可回溯的链,后面所有玩法都建立在这条链上。
2、SqliteSaver 落盘持久化
MemorySaver(官方新名 InMemorySaver,两者是同一实现的别名)重启即失,第一站先换成落盘的 SqliteSaver。它在独立包里,pip install langgraph-checkpoint-sqlite 单独安装。建图沿用第 2 篇的两节点写作图(builder 五步不重复),换掉的只有 compile 这一段,代码如下:
from langgraph.checkpoint.sqlite import SqliteSaver
with SqliteSaver.from_conn_string("checkpoints.db") as cp:
cp.setup() # 首次建表,重复执行无害
graph = builder.compile(checkpointer=cp)
cfg = {"configurable": {"thread_id": "demo-1"}}
result = graph.invoke({"topic": "LangGraph 持久化"}, cfg)
解读三件事。其一,from_conn_string 接一个库文件路径,返回的连接用 with 管理,退出时干净收尾。其二,setup() 建出存档需要的表,首次运行必做,之后重复执行也无害,官方示例就把它写在开头。其三,挂载之后图的行为与内存版完全一致:每超步自动写库,节点代码零感知。
真正要验证的是"重启不失"。把上面脚本跑一遍,退出进程,再起一个新进程,什么都不 invoke、纯读档:
# 新进程:不跑图,直接读上次的档
with SqliteSaver.from_conn_string("checkpoints.db") as cp:
graph = builder.compile(checkpointer=cp)
cfg = {"configurable": {"thread_id": "demo-1"}}
print(graph.get_state(cfg).values.get("article"))
两次运行之间进程退出过、内存清零过,article 照样打印出来——档案躺在 checkpoints.db 文件里,进程死活与它无关。thread_id 对上号才读得到,换个号读到的就是空档。一句提醒:SQLite 是单机单文件口径,同一个库文件别被多个进程同时写,并发场景按第 6 节的选型上 Postgres。
3、get_state 读取当前状态
第 2 节已经偷用了本节的主角:graph.get_state(config),传入带 thread_id 的 config,返回这条 thread 最新的一份 StateSnapshot。示例如下:
snap = graph.get_state(cfg)
print(snap.values) # 黑板全量
print(snap.next) # 待跑节点;跑完是空元组 ()
StateSnapshot 的常用字段整理如下:
| 字段 | 含义 |
|---|---|
values |
该时刻的黑板全量 |
next |
下一拍待跑的节点,跑完为空 |
config |
这份档自己的 config,含 checkpoint_id |
metadata |
档的来历:第几步、哪个节点写入 |
created_at |
落档时间 |
tasks |
中断与并行场景的任务明细 |
三个用法要点。其一,get_state 纯读不触发执行,随便调、调多少次都无副作用,是第一调试利器。其二,第 6 篇的图中断挂起时,invoke 返回里的 __interrupt__ 看的是"问了什么",get_state 看的是"黑板此刻长什么样、停在哪一步"——next 里那个待跑节点,就是恢复后要从头重跑的节点。其三,config 里再带一个 checkpoint_id,读到的就是那份历史档——这正是下一节的入口。
4、get_state_history 全历史
一条 thread 的全部档案,用 graph.get_state_history(config) 拿:返回可迭代的 StateSnapshot 序列,官方口径最新的排最前,沿时间倒序往回走。代码如下:
for s in graph.get_state_history(cfg):
print(s.next, s.config["configurable"]["checkpoint_id"][:8])
输出节选如下(档 ID 每次运行不同,取前 8 位示意):
() 9d3f01aa
('write_article',) 6b2c77e1
('make_outline',) 41e88d05
解读:从下往上读就是正向时间线——最初还有空档与输入档垫底,中段档停在 write_article 之前,终点档 next 为空。比逐份打印更常用的是按条件定位:在 history 里找 next 等于 ("write_article",) 的那一份,拿到的正是 make_outline 刚跑完的时刻。把它记成句式:想回到哪个节点之前,就找 next 等于它的档。第 5 节与第 7 节全靠这行定位。
5、时间旅行:回放与分叉
档案链攒齐,时间旅行就两种动作,官方口径:回放(replay)从历史档重跑,分叉(fork)改了状态再重跑。共同规则一句话:档之前的节点不重跑——结果已经在档里;档之后的节点真重跑——模型调用、API 请求、中断全部重新触发。
回放的写法:invoke 的输入传 None,config 换成那份历史档自己的 config。代码如下:
history = list(graph.get_state_history(cfg))
before_b = next(s for s in history
if s.next == ("write_article",)) # 定位档
r = graph.invoke(None, before_b.config) # 从这重跑
print(r["article"])
解读:make_outline 没有重跑,write_article 真的又执行了一遍,跑完在历史尾部追加新档。两个容易想偏的点。其一,回放不是"读缓存"——下游节点是重新执行,LLM 节点会再花一次调用费,结果还可能与上次不同。其二,从终点档(next 为空)回放无事发生,没有下游可跑。
分叉多一步改状态:update_state 在指定档上盖一份"改过的新档",再从新档续跑。代码如下:
fork_cfg = graph.update_state(
before_b.config, values={"outline": "手改的新提纲"})
r2 = graph.invoke(None, fork_cfg) # 按新提纲重写
print(r2["article"])
解读:update_state 不回滚线程、不改写任何旧档——它在指定档的基础上新增一份档,原历史原封不动;invoke(None) 从新档出发,write_article 按改过的 outline 重跑。update 的值走第 3 篇的 reducer 合并规则;个别场景(并行分支写同键、给全新线程铺状态)要显式传 as_node 指明"这次更新算哪个节点写的",官方文档另有专节。
时间旅行不改历史,只在历史上长新枝。
这条线从此长成一棵树:同一条 thread 里,原终点档还在,分叉出的新档成了最新档。第 6 篇的 interrupt 在回放与分叉时同样会重新触发,分叉后等一次新的 Command(resume=...) 即可——两篇的机制在这里严丝合缝。
6、checkpointer 选型
三个官方实现覆盖从开发到生产,对比如下:
| 实现 | 来源包 | 档案存放 | 适用场景 |
|---|---|---|---|
| InMemorySaver | langgraph 自带 | 进程内存 | 开发调试、单元测试 |
| SqliteSaver | langgraph-checkpoint-sqlite |
单个库文件 | 本地实验、单机脚本 |
| PostgresSaver | langgraph-checkpoint-postgres |
Postgres 实例 | 生产多进程并发 |
选型口径:本系列示例全用内存版省事,本篇第 2 节起换 SQLite 验证落盘;多进程部署、多人同时用,直接上 Postgres——官方自家生产环境(LangSmith)跑的就是它。用法与 SQLite 同构,连接字符串换成 Postgres URI,代码如下:
from langgraph.checkpoint.postgres import PostgresSaver
with PostgresSaver.from_conn_string(
"postgresql://user:pwd@localhost:5432/mydb") as cp:
cp.setup() # 首次建表
graph = builder.compile(checkpointer=cp)
三个补充。其一,两个库都有 Async 变体(AsyncSqliteSaver、AsyncPostgresSaver),配 ainvoke 与 astream 用。其二,官方文档还列了 MongoDB、Azure Cosmos DB 等实现,接口同构。其三,落盘时机有 durability 三档——sync 每步同步写最稳,async 边跑边写,exit 退出才写最快,按"崩溃时能忍受丢失多少"来挑。另有一条 Postgres 专属限制:thread_id 最长 255 字符,别拿长文本当编号。
7、实战:可回放的翻译流
机制凑齐,合体做一个真东西:可回放的翻译工作流——抽术语、初译、按风格润色三步;跑完先回看第 2 步的状态,再从第 2 步改风格参数重跑第 3 步。风格做成黑板字段 style,是全篇点睛的一笔:参数上了黑板,才有"改参数分叉"这个动作。State 与三个节点如下:
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class TState(TypedDict):
source: str # 原文
style: str # 风格提示:分叉时要改的就是它
terms: list # 术语表
draft: str # 初译
final: str # 定稿
def extract(s: TState) -> dict:
return {"terms": ["LangGraph", "checkpoint"]}
def translate(s: TState) -> dict:
return {"draft": f"直译:{s['source']}(保留 {s['terms']})"}
def polish(s: TState) -> dict:
return {"final": f"[{s['style']}] {s['draft']}"}
builder = StateGraph(TState)
builder.add_node("extract", extract)
builder.add_node("translate", translate)
builder.add_node("polish", polish)
builder.add_edge(START, "extract")
builder.add_edge("extract", "translate")
builder.add_edge("translate", "polish")
builder.add_edge("polish", END)
解读:三步直线,没有循环没有分支,翻译流本身平淡无奇,看点全在它被 checkpointer 盯着跑——空档与输入档打底,三个节点各落一档,共 5 份。挂上 SqliteSaver 跑第一遍,再回看、分叉一气呵成:
with SqliteSaver.from_conn_string("translate.db") as cp:
cp.setup()
graph = builder.compile(checkpointer=cp)
cfg = {"configurable": {"thread_id": "tr-1"}}
r = graph.invoke({"source": "persistence pays off",
"style": "正式", "terms": [], "draft": "",
"final": ""}, cfg)
print(r["final"]) # 第一遍:正式版
history = list(graph.get_state_history(cfg))
step2 = next(s for s in history if s.next == ("polish",))
print(step2.values["draft"]) # 回看第 2 步的黑板
fork = graph.update_state(step2.config,
values={"style": "口语"})
r2 = graph.invoke(None, fork) # 只重跑 polish
print(r2["final"]) # 第二遍:口语版
输出如下:
[正式] 直译:persistence pays off(保留 ['LangGraph', 'checkpoint'])
直译:persistence pays off(保留 ['LangGraph', 'checkpoint'])
[口语] 直译:persistence pays off(保留 ['LangGraph', 'checkpoint'])
走读一遍:第一遍跑完落在"正式"版;history 定位 next 等于 polish 的档——正是初译刚完、润色未跑的第 2 步,draft 原样可读;update_state 在这份档上把 style 改成"口语",invoke(None) 从新档续跑,只有 polish 重新执行,前两步的结果直接复用。三个工程点。其一,真实场景把 translate 与 polish 换成模型调用(第 5 篇口径),改 style 就成了换提示词重试——模型抽风时不必整条流水线从头再来,从出问题的那一步分叉即可。其二,分叉后原"正式"版终点档仍在历史里,get_state_history 随时找回,A/B 两版共存。其三,回放与分叉都会重新触发下游的模型调用,重跑不是免费的,批量分叉前先算调用费。
8、总结
本篇把第 1 篇预告的"持久化"超能力兑现成一组固定动作:compile 挂 checkpointer,每超步自动落一档,thread_id 是档案抽屉的主键;SqliteSaver 配 setup() 落盘,重启不失;get_state 读当前档,get_state_history 倒序列全历史,next 等于目标节点的那份档就是回溯锚点;invoke(None, 历史档.config) 回放,update_state 加 invoke(None) 分叉——不改历史,只长新枝。第 6 篇的三件套——checkpointer、thread_id、interrupt——里最没存在感的 checkpointer,本篇给了完整交代:interrupt 只是站在它肩上的那个暂停键。
练习两道。练习一:把第 6 篇的邮件审批流从 MemorySaver 换成 SqliteSaver,中断挂起后强制退出进程、重跑脚本、再用 Command(resume=...) 续跑——验证"挂起等人"能扛住重启,这正是第 6 篇结尾留下的生产化课题。练习二:给本篇翻译流加第四步"审校"接在 polish 之后,跑完后从"初译刚完"的档分叉,同时改 style 与 terms 两个字段,观察只有 translate 之后的部分重跑。对于准备把 Agent 跑进生产的读者,建议今天就把手头项目的 checkpointer 从内存版换成 SQLite——十行代码的改动,换来的是崩溃可恢复、挂起可重启、历史可回放。

