ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LangGraph持久化执行全解析:从Checkpoint到断点续跑

LangGraph持久化执行全解析:从Checkpoint到断点续跑

最近在啃 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进程内存本地调试、单元测试、文档示例全部丢失
SqliteSaverSQLite 文件单机单进程、本地/轻量服务文件仍在
PostgresSaverPostgreSQL多实例共享、生产环境数据保留

选型没有绝对正确,只看场景。自己跑 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 在执行到一半时抛了异常。整个流程按照我的理解是这样的:

  1. 进入节点 A 之前,langGraph 生成一个 checkpoint;
  2. 节点 A 正常执行完,执行结果写入状态,再生成一个 checkpoint;
  3. 节点 B 正常执行完,继续生成 checkpoint;
  4. 进入节点 C 之前先存 checkpoint,然后 C 开始执行;
  5. C 执行到一半失败;
  6. 你带着同一个 thread_id 重新 invoke;
  7. langGraph 找到该线程最新的 checkpoint,发现 next 里写着 C;
  8. 直接从 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 的其他章节会顺很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进