最近在啃 langGraph 的官方文档时,我在 durable execution 这一章停下来看了很久。这个词直译成“持久化执行”没什么问题,但“持久化”三个字很容易让人误会,以为它只是把内存里的状态往数据库里存一份。真正把这套机制理解透之后,我发现它其实是 langGraph 最值得先搞懂的核心能力:有了它,一个复杂的 Agent 任务在中间某个节点崩溃,只要带着同一个线程 ID 重新调用,就能从最近的检查点继续跑,而不是从头再来。这篇文章就是我的学习笔记,把持久化执行、checkpoint、线程这些概念按我消化过的理解重新讲一遍,附上能直接跑的代码和踩坑记录,给正在学 langGraph、想把多步 Agent 流程做稳的同学做个参考。
1. 先翻译明白:durable execution 到底要解决什么问题
1.1 “持久化”修饰的不是状态,而是整个执行过程
官方文档里有一句话让我印象很深:持久化能力让图支持无限步的顺序执行,并且可以回放(replay)、回退(rewind)、分叉(fork)。这里的“持久化”并不是说“顺手把结果存一下”,而是说整个执行过程都可以被归档、被恢复、被重新导航。
我自己的类比是单机游戏存档。玩游戏的时候你不会只保存当前血量和金币,还要保存你走到哪张地图、哪个剧情节点、哪些任务已经完成。Agent 也是一样——如果你只把最终答案写进数据库,任务在中间断掉之后,你是没法从断点继续的。真正需要保存的是“执行进度”:当前执行到哪个节点、下一步该去哪、哪些路由已经决定、哪些输入还没被处理。
所以读英文文档第一遍的时候,我会把 durable execution 意译成“断点续跑”,比“持久化执行”更能抓住重点。它不是给 Agent 加“记忆”的功能,而是给整个工作流加上存档和读档的能力。
1.2 几个术语的翻译对照,绕晕过很多人
文档章节里会交替出现几个英文词,第一次看很容易被绕进去:durable execution、persistence、checkpoint、checkpointer、thread。我整理了一张对照表,方便自己回头看:
| 英文术语 | 字面翻译 | 我建议的理解 |
|---|---|---|
| durable execution | 持久化执行 | 断点续跑、可恢复执行 |
| persistence | 持久化 | 把状态与执行信息写入介质的能力 |
| checkpoint | 检查点 | 某个时刻的完整运行快照 |
| checkpointer | 检查点存储器 | 负责保存和读取快照的后端组件 |
| thread / thread_id | 线程 / 线程 ID | 一串连续执行的身份标识,和并发线程没关系 |
尤其是 thread 这个词,在并发编程里见过太多次了,容易先入为主。在 langGraph 里它没有那么玄,就是给一次完整的、可被恢复的执行过程起个唯一名字。后面所有 checkpoint 都会挂在这个 ID 下面。
2. State、Checkpoint、Thread:撑起持久化执行的三根柱子
2.1 State:图里流动的共享快递箱
State 是 langGraph 图执行时在节点之间传递的公共状态。你可以把它理解成一个快递箱,每个节点会往里面放东西,下一个节点会从里面取东西。
定义一个 State 通常是定义它的类型结构,最常见的方式是用 TypedDict:
from typing_extensions import TypedDict class AgentState(TypedDict): messages: list remaining_steps: int图里的每个节点函数都会接收当前的 state,然后返回一个字典,表示要更新进去的字段:
def call_llm(state: AgentState) -> dict: # 这里假设调用了大模型,把新消息追加进 messages return {"messages": ["模型输出..."]}刚开始写的时候容易忽略一个点:State 的合并规则不是无脑覆盖。如果多个节点并发写同一个字段,到底谁覆盖谁,是由字段对应的 reducer 决定的。如果没有定义 reducer,后写入的会覆盖先写入的。这个看起来是细节,但对持久化执行来说很重要——因为恢复执行时,框架需要知道自己应该把这些部分写入合并到什么状态上。合并规则一旦混乱,checkpoint 恢复出来的状态也一定不对。
2.2 Checkpoint:不仅存 State,还存控制信息
如果你以为 checkpoint 只是把 State 复制一份存起来,那还没完全理解它。一份 checkpoint 里至少包含这几样东西:
- state values:当前所有通道的状态值,也就是快递箱里的内容;
- next:接下来要执行哪些节点,注意它是一个数组,因为图可能并行;
- pending writes:节点已经返回、但还没合并进 State 的写入,比如条件边的路由结果;
- metadata:这个 checkpoint 的生成时间、执行步数、以及唯一的 checkpoint ID。
也就是说,checkpoint 不光是数据的快照,它还是控制流的快照。好的,那恢复执行的时候,langGraph 才能知道“我执行到一半,接下来应该从哪个节点继续”。
2.3 Thread:每一次可恢复执行的身份 ID
Thread 是连接所有 checkpoint 的线索。你启动一次图执行,传进去一个带有 thread_id 的 config,这一整次执行产生的所有 checkpoint 都会挂在同一个 thread 名下。
实际使用的时候是这样:
config = {"configurable": {"thread_id": "thread-1"}} graph.invoke({"messages": []}, config)第二次、第三次调用只要还用这个 thread_id,langGraph 就会把上一次结束时的状态当作本次的起始状态。这在多轮对话场景里特别实用:每一轮用户输入对应一次 invoke,但 thread_id 不变,Agent 就能带上上下文。
这里有个容易忽略的前提:只有在你编译图时传入了 checkpointer,thread 才真正有意义。如果没有 checkpointer,你传再多次 thread_id,状态也不会被保存下来。
3. checkpointer 选型与一个能跑通的最小示例
3.1 三种常见 checkpointer 的对比
langGraph 官方提供了多种 checkpointer 实现,不同实现的差异主要就是“检查点写到哪里、重启后还在不在”。我整理了最常见的三种:
| 实现 | 存储位置 | 适合场景 | 重启后 |
|---|---|---|---|
| MemorySaver | 进程内存 | 本地调试、单元测试、文档示例 | 全部丢失 |
| SqliteSaver | SQLite 文件 | 单机单进程、本地/轻量服务 | 文件仍在 |
| PostgresSaver | PostgreSQL | 多实例共享、生产环境 | 数据保留 |
选型没有绝对正确,只看场景。自己跑 Demo 用 MemorySaver 最省事,因为它不需要任何外部依赖。但如果你在本地启动一个服务,希望重启后还能延续之前的对话,那就得换成 SqliteSaver。生产环境多个后端实例同时跑,必须用 Postgres 这类共享存储,否则每个实例各自维护一份 checkpoint,跨实例恢复根本无从谈起。
3.2 最小可运行示例:看看持久化到底做了什么
我用一个很简单的图来演示,两个节点分别给 total 加 1 和加 2,然后编译图时挂上 MemorySaver:
from typing_extensions import TypedDict from langgraph.graph import StateGraph from langgraph.checkpoint.memory import MemorySaver class State(TypedDict): total: int def add_one(state: State) -> dict: return {"total": state["total"] + 1} def add_two(state: State) -> dict: return {"total": state["total"] + 2} builder = StateGraph(State) builder.add_node("add_one", add_one) builder.add_node("add_two", add_two) builder.add_edge("add_one", "add_two") builder.set_entry_point("add_one") builder.set_finish_point("add_two") checkpointer = MemorySaver() graph = builder.compile(checkpointer=checkpointer) config = {"configurable": {"thread_id": "thread-1"}} print(graph.invoke({"total": 0}, config)) # 输出 {'total': 3} print(graph.invoke({"total": 0}, config)) # 输出 {'total': 6}第二次调用时,传入的初始值仍然是{"total": 0},但输出结果是 6。原因就是同一个 thread_id 下,第一次执行结束时 total 已经变成 3,第二次会从 3 继续往后加。这个例子虽然简单,但把持久化的行为展示得很直观:invoke 的输入不是每次都得从零开始,只要线程存在,状态就会延续。
如果你想体验“重启不丢”,把 MemorySaver 换成 SqliteSaver 即可:
from langgraph.checkpoint.sqlite import SqliteSaver with SqliteSaver.from_conn_string("checkpoints.db") as sqlite_checkpointer: graph = builder.compile(checkpointer=sqlite_checkpointer) config = {"configurable": {"thread_id": "thread-1"}} print(graph.invoke({"total": 0}, config))关掉进程再跑一次,只要数据库文件还在,thread-1 的历史就在。注意 SQLite 的同步接口在异步图里会阻塞事件循环,异步环境下要用对应的异步版本。
3.3 生产环境选 Postgres 的几个原因
我自己在项目里是把 checkpoint 放在 Postgres 里的,理由很简单:我们有多台后端实例,请求会负载均衡到不同机器。如果用 SQLite,A 机器写入的 checkpoint,B 机器根本读不到。Postgres 是共享存储,每个实例连同一个库,checkpoint 就是全局可见的。
连接方式大概是这个套路:
from langgraph.checkpoint.postgres import PostgresSaver DSN = "postgresql://user:password@localhost:5432/langgraph" with PostgresSaver.from_conn_string(DSN) as postgres_checkpointer: graph = builder.compile(checkpointer=postgres_checkpointer)使用之前需要按文档初始化对应的表结构,不同版本的库建表脚本位置可能不同。我在踩过坑之后养成了一个习惯:先去看该版本源码里自带的初始化 SQL,再决定要不要手动建表,不要默认库会自动建表。
4. 断点续跑的恢复规则:为什么失败后不用从头再来
4.1 节点失败后的完整执行流程
持久化执行最核心的价值就是崩溃恢复。假设图里有三个节点 A、B、C,C 在执行到一半时抛了异常。整个流程按照我的理解是这样的:
- 进入节点 A 之前,langGraph 生成一个 checkpoint;
- 节点 A 正常执行完,执行结果写入状态,再生成一个 checkpoint;
- 节点 B 正常执行完,继续生成 checkpoint;
- 进入节点 C 之前先存 checkpoint,然后 C 开始执行;
- C 执行到一半失败;
- 你带着同一个 thread_id 重新 invoke;
- langGraph 找到该线程最新的 checkpoint,发现 next 里写着 C;
- 直接从 C 恢复执行,成功后再往后走。
也就是说,失败恢复并不是把整张图从头跑一遍,而是从失败的那个节点重新执行。这个能力在生产里非常实用,尤其是节点内部是耗时的大模型调用时,“续跑”比“重跑”节省的可不只是一点点。
4.2 条件边为什么不会重复触发
读文档的时候,我一开始对“恢复后条件边也能正确工作”这句话没有太大感觉,直到自己跑了一个带条件路由的例子才明白。
条件边需要执行一个路由函数来判断走哪个分支,比如“如果结果包含某个关键词就走 A,否则走 B”。如果恢复时重新执行这个路由函数,结果可能和原来不一样,因为节点重跑后的输出可能变了,甚至路由函数本身有随机性。那整个执行轨迹就乱套了。
langGraph 的处理方式是把条件边的路由结果当作 pending write 先保存进 checkpoint。恢复的时候,框架读的是已经提交过的路由结果,而不是重新调用路由函数。这样就能保证每条边在整个恢复流程中只会被确定一次,不会因为重跑而出现路径漂移。
4.3 next 是一个列表:并行节点恢复的真相
查看 checkpoint 内容时,next 字段往往被忽略。它是一个数组,不是单个节点。图一旦走到并行分支,next 里就会有多个节点,比如["branch_a", "branch_b"]。
这种情况下的恢复会稍微复杂一点:langGraph 会恢复所有还没完成的并行节点,其中某个节点失败了,就重跑这个节点;其他没失败的节点保持原结果不动。理解 next 是数组,你才能看懂为什么恢复出来有时候会同时跑好几个节点。
5. 时间旅行实战:把 checkpoint 历史当调试资产
5.1 get_state_history 看到的是一次执行的全过程
持久化执行带来的额外红利是“时间旅行”。你可以用get_state_history拉出某个线程的全部 checkpoint:
config = {"configurable": {"thread_id": "thread-1"}} history = list(graph.get_state_history(config)) for snapshot in history: print(snapshot.values, snapshot.next)打印出来的是一长串快照,按时间倒序排列。你可以看到 total 从 0 变成 1、再变成 3,也能看到每一步之后 next 指向哪个节点。这份历史本质上就是一次 Agent 执行的完整日志,而且比普通日志更结构化。
5.2 从历史 checkpoint 分叉出一条新路径
时间旅行不只是用来看,还可以用来改。你可以从历史的某一个 checkpoint 继续执行,相当于回到过去,改变输入,然后走一条全新的分支。
举个实际用法:假设你在调试中发现 Agent 在第 10 步开始走偏,你不想从头跑一遍,只需要回到第 9 步完成之后的那个 checkpoint,手动修正状态里的某个字段,再继续 invoke:
target_snapshot = history[-3] # 取某个较早的快照 branch_config = target_snapshot.config res = graph.invoke({"total": 100}, branch_config)这样 langGraph 会从目标 checkpoint 分叉出一条新的执行轨迹,而不是覆盖原来的历史。这种能力在排查 Agent 决策问题时非常顺手。
5.3 我的实际调试习惯
我调试多步 Agent 的时候,经常遇到一个问题:最终结果不对,但看一两行日志根本判断不了是哪一步出了问题。以前只能加日志重跑,太慢了。
现在我的习惯是:跑完之后直接拉一遍get_state_history,逐个 checkpoint 看状态字段的变化,重点找“某个字段从正常变成异常”的那个节点。找到之后再从该节点的前一个 checkpoint 分叉,改输入继续跑,验证自己的猜测。整个过程完全不需要重跑完整流程,省下的时间非常可观。
6. 文档里几个容易被绕晕的细节
6.1 持久化不等于永久记忆
这是我在理解文档时纠正最大的一个误区。不要看到“持久化”就以为 Agent 会自动记住用户偏好。
持久化解决的是“执行过程可恢复”的问题,它提供的是落盘能力。但往 State 里存什么字段、什么时候更新、存多久,这些仍然是你的业务逻辑。比如你想让 Agent 记住用户喜欢的语言,你需要在 State 里定义一个preferences字段,然后在某个节点里显式把用户偏好写进去,最后由 checkpointer 把它存成 checkpoint。持久化本身不会替你决定哪些信息值得保存。
6.2 pending_writes 和 pending_inputs 的区别
这两个概念长得很像,含义完全不同。
- pending_inputs:还没被任何节点消费的输入数据。比如中断恢复后需要注入的额外输入、外部事件带进来的消息。
- pending_writes:节点已经返回、但还没有合并到 State 中的写入。比如条件边的路由结果、某些异步节点的返回值。
我自己的记忆方式是:一个在入口排队,一个在出口暂存。读文档看到这两个词,先停下来想一分钟,别混着看。
6.3 修改 State schema 的历史包袱
变更 State 字段定义时,旧的 checkpoint 可能会读不出来,或者读取时报字段不存在的错。原因很简单:你修改的是 schema,但历史 checkpoint 里存的还是旧 schema 的数据。
遇到这种情况,我通常的解决办法是:小改动尽量做字段兼容,或者跑一个数据迁移脚本,把旧 checkpoint 重新写一遍;大改动就直接换新的 thread_id 或者清理历史。这个点不会在文档显眼位置写,但实际工作中一定会碰到。
7. 踩坑记录:持久化执行常见问题与排查清单
7.1 常见问题速查表
我根据自己的实践整理了一个速查表,遇到问题直接对着查:
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 调用 invoke 时状态没有延续 | 编译图时没传 checkpointer | 在compile(checkpointer=...)里传入 checkpointer |
| 服务重启后状态全部丢失 | 用了 MemorySaver | 换成 SqliteSaver 或 PostgresSaver |
| 多台实例之间状态不共享 | 每台实例用了本地存储 | 改用 Postgres 等共享存储 |
| 恢复执行后条件路由结果变了 | 路由结果没有作为 pending write 保存 | 检查该版本是否启用了 resumability,升级到新版 |
| 老 checkpoint 读取报错 | 修改了 State schema | 做数据迁移或清理旧线程 |
| checkpoint 历史无限增长,执行变慢 | 每个节点都会生成新 checkpoint | 定期清理历史,或用独立线程处理短任务 |
7.2 忘传 thread_id,状态不会保存
第一次上手的时候,我以为只要加了 checkpointer,状态就会自动保存。实际上每次 invoke 都得在 config 里带 thread_id,否则 langGraph 不知道把 checkpoint 挂到哪条执行线上。
这个坑很隐蔽,因为不传 thread_id 不一定报错,但你会发现第二次调用完全不带上次的上下文。排查的时候先检查 config,再看 checkpointer,别一上来就怀疑图写错了。
7.3 checkpoint 增长的控制思路
每执行到一个节点边界就会多一份 checkpoint,长时间运行的任务会产生大量快照。开发时无所谓的,生产环境就要考虑历史数据的清理策略了。
我的经验是:按业务类型区分线程生命周期。短期任务直接复用固定 thread_id,定期清理;需要长期保留历史来支持时间旅行调试的任务,单独用一组 thread_id,并设置保留策略。这个问题越早设计越好,跑起来之后再清数据就麻烦。
7.4 检查清单之外的一点体会
我在实际项目里用下来,持久化执行给我省下的最值钱的东西,不是崩溃恢复本身,而是“每一次执行都留痕”。当 Agent 行为不符合预期,我能打开历史快照一步步看它从哪一步开始走歪,而不是靠猜。这个能力在开发阶段几乎是不可替代的。
建议你把官方文档里 durable execution 那章配合示例代码读三遍:第一遍理解概念,第二遍跑通 MemorySaver 和 SqliteSaver,第三遍用 get_state_history 做一次真正的时间旅行。三遍下来,你再回头看 langGraph 的其他章节会顺很多。