LangGraph 持久化与时间旅行

QuibblerAgentQuibblerAgent 2026-11-04 约 22 分钟 3 次阅读

第 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)跑一遍,落档节奏示意如下:

flowchart LR I[输入] --> K0[(起点档)] K0 -->|超步一| A[节点 A] A --> K1[(档一)] K1 -->|超步二| B[节点 B] B --> K2[(档二)] K2 --> E([END])

解读:输入落板先存起点档,之后每跑完一个超步再各存一档;连同最初那份空档,官方口径两节点图共 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——十行代码的改动,换来的是崩溃可恢复、挂起可重启、历史可回放。

相关推荐

获取系统SDK版本、判断手机ROM
精选Code

获取系统SDK版本、判断手机ROM

Build获取系统SDK版本Android中部分API的使用,需要在特定的SDK版本之后才能使用,因此在兼容老版本SDK的时候,经常需要判断API的版本。各种Android版本的对应关系参考《Android各版本对应的SDK版本》判断手机ROM有时候需要判断手机系统的ROM,检测ROM是MIUI、EMUI还是Flyme,可以使用getprop命令,去系统build.prop文件(关于build.p

4.2k
AndroidStudio中各种中文乱码问题
精选Code

AndroidStudio中各种中文乱码问题

AndroidStudio中各种中文乱码问题1、编译Java错误信息乱码在出现这个Annotation processors must be explicitly declared now...问题的时候,正好也发现AndroidStudio Build Output错误信息都乱码。经常遇到各种问题,习惯了,现在遇到的问题,都是以后的答案。 1.1、修改项目build.gradle(无效)在整个p

2.8k
PydanticAI 详解:类型安全的 Python AI SDK
精选AI

PydanticAI 详解:类型安全的 Python AI SDK

PydanticAI 详解:类型安全的 Python AI SDK写 LLM 应用最疼的两件事:模型返回的 JSON 不合预期、换一家模型商要改一串代码。pydantic/pydantic-ai(PydanticAI)用 Pydantic 团队的老本行回答了第一件——输出用 BaseModel 定义、运行前先校验,错误从运行时提前到编码时;第二件则用"模型即字符串"回答:换个厂商只改一个参数。如今

75