RSS

第 8 章 / 共 14 章

持久化:checkpointer、thread 与时间旅行

约 8 分钟 更新于

8.1 「明天再批」这四个字的技术含量

客服主管说:超过 500 的退款我来批,但我可能明天上午才看。

翻译成技术要求:这段流程要在某一步停住,进程可以关掉、可以重启、可以换一台机器,明天还能从停住的那一步继续,而且前面查到的订单信息、模型的推理过程一样都不能丢。

你自己实现的话,要做三件事:把 state 序列化落库、记住停在哪个节点、恢复时把 state 读回来并跳到那个节点。这三件事加起来就是 checkpointer。

8.2 加一行就有

from langgraph.checkpoint.memory import InMemorySaver

graph = builder.compile(checkpointer=InMemorySaver())

config = {"configurable": {"thread_id": "ticket-4821"}}
graph.invoke({"steps": [], "amount": 0.0}, config)

thread_id 是一段独立会话的标识。同一个 thread_id 的多次调用共享同一份状态历史,不同 thread_id 之间完全隔离。对退款助手来说,一条工单一个 thread。

三种 saver 的选择:

Saver装在哪用在哪
InMemorySaver随 langgraph 一起装试验、单元测试
SqliteSaveruv add langgraph-checkpoint-sqlite本地开发、单机小工具
PostgresSaveruv add langgraph-checkpoint-postgres生产
ModuleNotFoundError: No module named 'langgraph.checkpoint.sqlite'

很多教程写 from langgraph.checkpoint.sqlite import SqliteSaver 却不提要单独装包。

8.3 状态历史长什么样

跑完一次带中断的流程之后(完整例子在第 9 章),读它的历史:

for snap in graph.get_state_history(config):
    print(len(snap.values["steps"]), snap.next, snap.config["configurable"]["checkpoint_id"][:8])

真实输出(新 → 旧):

3  ()             1f1a9f95
2  ('publish',)   1f1a9f95
1  ('review',)    1f1a9f95
0  ('write',)     1f1a9f95
0  ('__start__',) 1f1a9f95

每一行是一个 checkpoint。next 是下一步该跑哪个节点——最上面那条 next=() 表示已经跑完了。

第8章:持久化:checkpointer、thread 与时间旅行
图 8.1:一次运行留下一串检查点。next 为空表示跑完;next 有值表示停在那里等着继续。恢复的本质就是挑一个检查点,从它的 next 接着走。

get_state(config) 拿最新的那个,返回一个 StateSnapshot,常用的三个字段是 values(当前状态)、next(下一步)、interrupts(如果停在中断上)。

8.4 时间旅行,以及一个必须知道的陷阱

「回到某一步重跑」的用途很实在:模型在第三步判错了,你想改一下输入从第三步重来,而不是整条工单重跑。

拿到目标检查点的 config 就能重放:

history = list(graph.get_state_history(config))
target = [h for h in history if h.next == ("review",)][-1]
graph.invoke(None, target.config)      # 从这一步接着跑

但这里有个陷阱,本机实测到的。如果那一步是个人工审批中断,你想换个答案重跑:

graph.invoke(Command(resume="no"), target.config)

结果是 approved: True —— 你传的 "no" 没有生效,沿用了原来那次的 "yes"。

对照实验:在一个全新的 thread 上传 resume="no",得到的是 approved: False。所以不是 resume 值本身的问题,而是那个历史检查点上已经挂着上次运行的写入记录,重放时直接用了旧值。

正确做法是先分叉出一个新检查点:

fork = graph.update_state(target.config, None)   # 分叉,不改任何值
graph.invoke(Command(resume="no"), fork)          # 这次是 approved: False ✅
做法传入实测结果
直接用历史 config 重放resume="no"approved: True(旧值)
先 update_state 分叉再重放resume="no"approved: False(正确)

记住这条判据:重放(replay)只是「再跑一遍同样的路」,分叉(fork)才是「换一个答案走另一条路」。要换答案,必须先 update_state。

update_state(config, values, as_node=...) 也可以直接改状态值,as_node 用来指定「假装是哪个节点写的」,从而决定接下来走哪条边。

8.5 改了图,老会话还能恢复吗

这是上线之后一定会遇到的问题。官方的规则很具体:

  • 已经跑完的线程(没有中断):你可以改整张图的拓扑——增删改节点和边都行。
  • 正停在中断上的线程:除了重命名和删除节点之外的改动都支持。(因为那条线程正准备进入某个节点,节点没了就无处可去。)
  • state 增删键:完全向前向后兼容。
  • state 重命名键:已有线程会丢掉那个键的历史值。
  • state 键的类型不兼容地改变:可能让旧线程出问题。

实践含义:上线前把 state 的键名想清楚。加字段随时可以,改名的代价是所有在途会话丢数据。

8.6 持久化不是免费的

第 11 章会展开讲成本,这里先埋一个数字:社区有人提了一个仍然开着的 issue,实测报告 checkpoint 序列化带来约 85% 的存储膨胀和 37.8% 的 token 开销(这是报告者自己的测量,官方未确认)。

结论不是「别用 checkpointer」,而是:state 里放什么,直接决定了持久化的代价。第 6.5 节那三条经验在这里兑现。

另外,invoke / stream 上有个 durability 参数,取值是 'sync' / 'async' / 'exit'(本机实测确认)——分别是每步同步落盘、异步落盘、只在退出时落盘。落盘越勤,崩溃时丢得越少,但也越慢。

8.7 练习与检查点