← 返回首页

LangGraph 持久化实测:状态、人工介入与时间旅行

LangGraphAI Agent持久化

TL;DR:LangGraph 的持久化不是给图加一个「记忆开关」,而是把每一步的状态快照写进一张以 (thread_id, checkpoint_ns, checkpoint_id) 为主键的表里。我实际跑了 langgraph 1.2.16 配 langgraph-checkpoint-sqlite 3.1.2,测到三件官方文档没写透的事:SqliteSaver.from_conn_string() 返回的是上下文管理器而不是 saver,直接丢给 compile() 会炸;按官方 time-travel 页给 update_state() 只传 thread_id + checkpoint_id 会抛 KeyError: 'checkpoint_ns';文档建议「定期 prune 旧 checkpoint」,而 SqliteSaver.prune() 在 3.1.2 里函数体就是一句 raise NotImplementedError。

环境与这次实测的口径

所有数字都是我在这台机器上跑出来的,不是抄来的。

组件版本出处
Python3.13.14本机 sys.version
langgraph1.2.16PyPI 与 GitHub Release langgraph==1.2.16(发布时间 2026-10-16T22:11:28Z)双源一致
langgraph-checkpoint4.3.0PyPI
langgraph-checkpoint-sqlite3.1.2PyPI
langchain-core1.6.12PyPI
系统Windows 11本机

口径声明:本文所有「实测」指本机 Python 进程内真实执行并记录的数值;涉及官方行为描述的,一律标注为官方文档原文。吞吐数字是单进程、单线程、SQLite 本地文件的场景,不代表 Postgres 后端或生产并发下的表现。版本与发布时间截至 2026-10-19,请以官方 Release 页为准。

checkpointer 到底往 SQLite 里写了什么

先别急着写业务代码,我建了一个只有一个节点的图,把库打开看结构:

import sqlite3
from typing import Annotated, TypedDict
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.sqlite import SqliteSaver

class S(TypedDict):
    n: Annotated[int, operator.add]

def d(s): return {"n": 1}

g = StateGraph(S)
g.add_node("d", d)
g.add_edge(START, "d")
g.add_edge("d", END)

conn = sqlite3.connect("ckpt.sqlite", check_same_thread=False)
app = g.compile(checkpointer=SqliteSaver(conn))
app.invoke({"n": 0}, {"configurable": {"thread_id": "t-1"}})

表的定义是这样的(实测 sqlite_master 输出):

CREATE TABLE checkpoints (
    thread_id TEXT NOT NULL,
    checkpoint_ns TEXT NOT NULL DEFAULT '',
    checkpoint_id TEXT NOT NULL,
    parent_checkpoint_id TEXT,
    type TEXT,
    checkpoint BLOB,
    metadata BLOB,
    PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
)

关键点有两个。第一,主键是三元组,不是 (thread_id, checkpoint_id) —— 后面那个 KeyError 就是从这儿来的。第二,实测每跑一轮 invoke,这张表多 3 行:100 次 invoke 对应 300 行 checkpoint、500 行 writes;200 次对应 600 行。也就是说一个「输入 → 节点 → 结束」的最小图,每个 superstep 都会留一条快照,不是只在结尾存一次。

同一 thread_id 跨进程续跑:不是「记住」,是「回放」

很多人以为开了 checkpointer 图就「记住」了。实际机制是:下次 invoke 时它从库里把上一条 checkpoint 读出来当初始状态,再把新写入叠加到 reducer 上。

我开了三个完全独立的 Python 进程,每个进程只跑一次 invoke 就退出:

独立进程 #1 -> {'n': 1}
独立进程 #2 -> {'n': 2}
独立进程 #3 -> {'n': 3}

进程之间没有任何共享内存,靠的只是同一个 SQLite 文件和同一个 thread_id。这就是官方文档说的「fault tolerance」落地的样子 —— 服务重启、容器重建都不丢状态。反过来说,只要 thread_id 换了,状态就断了:不开 checkpointer 的图,连着 invoke 两次都返回 {'n': 1},第二次看不到第一次的结果。

人工介入:interrupt 与 Command(resume=)

interrupt() 让节点停在中途等人类确认。实测行为:

def ask(s):
    ans = interrupt({"question": "confirm?", "seen": s["v"]})
    return {"v": 10 + int(ans)}

app.invoke({"v": 1}, cfg)              # 返回体里带一条 '__interrupt__'
app.get_state(cfg).next                # ('ask',)  还没走完
app.invoke(Command(resume=5), cfg)     # {'v': 16}

这里有个容易踩的点:没有 checkpointer 的图调用 interrupt() 不会报错,它会把 Interrupt 对象塞进返回值就结束了,你永远等不到恢复的机会。判断有没有真的挂起,要看 get_state(cfg).next 和 tasks[].interrupts,不能只看 invoke 有没有抛异常。

时间旅行:官方文档的示例少了两个字符

官方 time-travel 页给的读法是这样:

config = {"configurable": {"thread_id": "1", "checkpoint_id": "1ef663ba-..."}}
graph.get_state(config)

实测 get_state() 这么传没问题,返回 {'n': 1}。但把同一个 config 交给 update_state() 做分叉,直接炸:

KeyError: 'checkpoint_ns'

根因在 langgraph/checkpoint/sqlite/__init__.py 第 432 行,它无条件取 config["configurable"]["checkpoint_ns"],而手写 config 时没人会想到补这个字段。补上就好了:

cfg_ok = {"configurable": {"thread_id": "t7", "checkpoint_ns": "", "checkpoint_id": cid}}
app.update_state(cfg_ok, {"n": 100})     # 实测生效,n 从 1 变成 101

我的建议是别手写,直接用 get_state_history() 返回的 config 原样传回 —— 那个对象里 checkpoint_ns 是齐全的(实测值为空字符串)。实测分叉后历史从 9 条变 10 条,原分支还在:time travel 是开新分支,不是覆盖历史。

清理:官方建议 prune,SQLite 后端直接抛异常

官方 persistence 页在「Checkpoints growing unboundedly」一节说,长对话会让 checkpoint 累积、抬高延迟和存储成本,建议定期 prune 或设保留策略,示例用的是 PostgresSaver。

我在 SQLite 后端上试了一下:

app.checkpointer.prune(["t7"])
# NotImplementedError(异常消息为空)

翻源码,SqliteSaver.prune() 的签名和基类一致,但函数体最后一行就是 raise NotImplementedError,连消息都没有。也就是说本地开发用 SQLite 时,官方给的清理方案是不可用的。实测可用的替代是 delete_thread(),签名是 (thread_id: str) -> None,调用后 checkpoints 表从 10 行变成 0 行。

性能账:checkpointer 不是免费的

同一张图,各跑 100 次 invoke,实测:

配置100 次耗时吞吐
不开 checkpointer0.121 s827.7 次/秒
SqliteSaver(本地文件)1.293 s77.3 次/秒

差约 10.7 倍。存储侧:100 轮 331,776 B(约 3.3 KB/轮),200 轮 475,136 B(约 2.4 KB/轮)—— 文件按 4 KB 页增长,所以短期数字会跳。

延迟有没有随历史变长而恶化?实测首轮 52.17 ms、第 25 轮 28.88 ms、第 50 轮 29.93 ms、第 100 轮 28.32 ms、第 200 轮 27.59 ms(节点里我故意放了 20 ms 的 sleep,所以净值大约 8 ms)。到 600 行 checkpoint 为止没有观察到读放大。但行数会一直涨,长跑任务还是得自己定清理策略。

并发写同一个 thread:不报错,但更新会消失

这是我这轮测到最危险的一条。16 个线程用 barrier 对齐后同时 invoke 同一个 thread_id:

16 threads in 0.24s | exceptions = 0
history len: 48        # 16 × 3,每条都落盘了
final n = 1            # 期望 16

没有任何异常,48 条 checkpoint 全部写进去了,但最终状态是 1 —— 16 次更新里有 15 次(93.75%)在业务视角凭空消失。原因是它们都从同一个父 checkpoint 分叉,get_state() 只返回最新那一条。SQLite 后端不会替你做互斥。

结论很直接:同一个 thread_id 必须串行写。要并发就用不同的 thread_id,或者在上层加队列。官方文档把 checkpointer 定位为「thread-scoped memory」,thread 本身就是并发边界。

踩坑录:8 个真实报错与解决方案

以下每一条都是我在这台机器上真实触发并记录的,不是拼出来的典型报错。

报错一:TypeError: Invalid checkpointer provided. Expected an instance of BaseCheckpointSaver, True, False, or None. Received _GeneratorContextManager.

原因:SqliteSaver.from_conn_string() 被 @contextmanager 装饰,返回的是上下文管理器。解决:要么用 with SqliteSaver.from_conn_string("ckpt.sqlite") as saver:,要么自己建连接 SqliteSaver(sqlite3.connect(path, check_same_thread=False))。注意 check_same_thread=False 是官方实现里带的参数,别省。

报错二:ValueError: Checkpointer requires one or more of the following 'configurable' keys: thread_id, checkpoint_ns, checkpoint_id

原因:装了 checkpointer 却没在 config 里传 thread_id。解决:每次 invoke 都传 {"configurable": {"thread_id": "..."}},并把 thread_id 当成业务主键来设计(会话 id、任务 id),别随手用时间戳。

报错三:langgraph.errors.InvalidUpdateError: At key 'n': Can receive only one value per step. Use an Annotated key to handle multiple values.

原因:两条并行分支往同一个没有 reducer 的 key 写值。解决:给 state 字段加 reducer,例如 Annotated[list, operator.add];官方对该错误的说明页是 docs.langchain.com/oss/python/langgraph/errors/INVALID_CONCURRENT_GRAPH_UPDATE。

报错四:RuntimeError: Cannot use Command(resume=...) without checkpointer

原因:图没编译进 checkpointer 就想恢复中断点。解决:编译阶段必须传 checkpointer;前半句 interrupt() 不报错,会让人误以为流程是对的,直到恢复这一步才炸。

报错五:RuntimeError: checkpointer=True cannot be used for root graphs.

原因:报错一的信息里把 True 列成了合法值,照着传 compile(checkpointer=True) 就会撞上这条。解决:根图必须传实例;True 是给子图用的。

报错六:KeyError: 'checkpoint_ns'

原因:见「时间旅行」一节,手写 config 漏了 checkpoint_ns。解决:直接复用 get_state_history() 返回的 config。

报错七:NotImplementedError(消息为空)

原因:SqliteSaver.prune() 未实现。解决:改用 delete_thread() 按会话清理,或自己写 SQL 按 checkpoint 时间范围删。

报错八:RuntimeError: Called get_config outside of a runnable context

原因:在图节点之外直接调用 interrupt()。解决:interrupt() 只能在节点函数体内调用,需要外部审批就拆成「节点里 interrupt + 外部 Command(resume=)」两段。

FAQ

Q:checkpointer 和 store 该用哪个?

A:官方文档的划分是:checkpointer 存「一个 thread 的图状态」,用于会话连续性、人工介入、time travel 和容错;store 存「应用自定义数据」,用于跨 thread 的用户偏好与长期知识。本文测的是前者。

Q:SQLite 能上生产吗?

A:官方 checkpointers 页对 langgraph-checkpoint-sqlite 的定位原话是「Ideal for experimentation and local workflows. Needs to be installed separately.」,即面向实验与本地工作流。本文的吞吐与并发结论都基于 SQLite,不要直接外推到 Postgres 后端。

Q:历史 checkpoint 一直涨怎么办?

A:官方建议定期 prune,但 SQLite 后端未实现(见报错七)。实测可用 delete_thread() 按 thread 清理;策略上建议按业务会话设定生命周期,会话结束后一段时间后整体删除。

Q:一个 thread 能被两个请求同时写吗?

A:不建议。实测 16 线程并发写同一 thread 零异常,但 93.75% 的更新丢失。需要并发就换 thread_id,或在应用层串行化。

Q:为什么我 interrupt() 之后程序就结束了,没等我输入?

A:大概率是图没编译 checkpointer,或者你只看 invoke 的返回值没看 get_state().next。实测这种情况下 interrupt() 不抛异常,恢复时才会在报错四那里炸。

这次没覆盖到的边界

我跑了单机单进程的 SQLite 场景。以下内容本文没有实测,不做断言:Postgres 后端的并发与吞吐、子图(checkpoint_ns 非空)的行为、异步 AsyncSqliteSaver、以及托管运行时自动接管的持久化。这几块建议以官方文档为准。

想继续往下挖的话,可以先看站内这两篇:一篇讲执行层的调度与队列(n8n Task Runner 架构解剖:external 外置模式与 queue 队列模式),一篇讲检索层为什么取不准(RAG 落地:向量库选型与「检索不准」根因排查);再往上选路线时,微调、RAG 还是提示工程:三条路线的决策树 给的是成本维度的答案。三篇合起来,差不多覆盖了「agent 怎么跑起来 → 状态怎么存 → 知识从哪来 → 该不该自己训」这条链路。

联盟声明:本文正文不含任何商品链接;站点推荐位中的商品链接为联盟链接,我可能因此获得佣金。本文所有数值均来自本机实测或标注出处的官方文档,未接受任何厂商赞助。

👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务

👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速

📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测

🔗 精选推荐工具

使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):

☁️ DigitalOcean 云服务器 ⚡ Vultr 高性能 VPS 🤖 QoderWork 中国版(推荐有奖) ☁️ 阿里云爆款 AI 产品 📚 WordPress 实用书单 🔍 WordPress SEO 书单 🌐 虚拟主机书单 🐳 Docker 书单 🐧 Linux 书单 🐍 Python 书单 💰 联盟营销书单 💵 被动收入书单 🖥️ 服务器书单 ☁️ 云计算书单 🚀 DevOps 书单 🤖 小米 MiMo 开放平台
← 返回首页