资讯详情

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

📅 2026/10/24 2:47:07 | 华诺云谱 👁 阅读
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 通常是定义它的类型结构最常见的方式是用 TypedDictfrom 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每一次可恢复执行的身份 IDThread 是连接所有 checkpoint 的线索。你启动一次图执行传进去一个带有 thread_id 的 config这一整次执行产生的所有 checkpoint 都会挂在同一个 thread 名下。实际使用的时候是这样config {configurable: {thread_id: thread-1}} graph.invoke({messages: []}, config)第二次、第三次调用只要还用这个 thread_idlangGraph 就会把上一次结束时的状态当作本次的起始状态。这在多轮对话场景里特别实用每一轮用户输入对应一次 invoke但 thread_id 不变Agent 就能带上上下文。这里有个容易忽略的前提只有在你编译图时传入了 checkpointerthread 才真正有意义。如果没有 checkpointer你传再多次 thread_id状态也不会被保存下来。3. checkpointer 选型与一个能跑通的最小示例3.1 三种常见 checkpointer 的对比langGraph 官方提供了多种 checkpointer 实现不同实现的差异主要就是“检查点写到哪里、重启后还在不在”。我整理了最常见的三种实现存储位置适合场景重启后MemorySaver进程内存本地调试、单元测试、文档示例全部丢失SqliteSaverSQLite 文件单机单进程、本地/轻量服务文件仍在PostgresSaverPostgreSQL多实例共享、生产环境数据保留选型没有绝对正确只看场景。自己跑 Demo 用 MemorySaver 最省事因为它不需要任何外部依赖。但如果你在本地启动一个服务希望重启后还能延续之前的对话那就得换成 SqliteSaver。生产环境多个后端实例同时跑必须用 Postgres 这类共享存储否则每个实例各自维护一份 checkpoint跨实例恢复根本无从谈起。3.2 最小可运行示例看看持久化到底做了什么我用一个很简单的图来演示两个节点分别给 total 加 1 和加 2然后编译图时挂上 MemorySaverfrom 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(checkpointercheckpointer) 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(checkpointersqlite_checkpointer) config {configurable: {thread_id: thread-1}} print(graph.invoke({total: 0}, config))关掉进程再跑一次只要数据库文件还在thread-1 的历史就在。注意 SQLite 的同步接口在异步图里会阻塞事件循环异步环境下要用对应的异步版本。3.3 生产环境选 Postgres 的几个原因我自己在项目里是把 checkpoint 放在 Postgres 里的理由很简单我们有多台后端实例请求会负载均衡到不同机器。如果用 SQLiteA 机器写入的 checkpointB 机器根本读不到。Postgres 是共享存储每个实例连同一个库checkpoint 就是全局可见的。连接方式大概是这个套路from langgraph.checkpoint.postgres import PostgresSaver DSN postgresql://user:passwordlocalhost:5432/langgraph with PostgresSaver.from_conn_string(DSN) as postgres_checkpointer: graph builder.compile(checkpointerpostgres_checkpointer)使用之前需要按文档初始化对应的表结构不同版本的库建表脚本位置可能不同。我在踩过坑之后养成了一个习惯先去看该版本源码里自带的初始化 SQL再决定要不要手动建表不要默认库会自动建表。4. 断点续跑的恢复规则为什么失败后不用从头再来4.1 节点失败后的完整执行流程持久化执行最核心的价值就是崩溃恢复。假设图里有三个节点 A、B、CC 在执行到一半时抛了异常。整个流程按照我的理解是这样的进入节点 A 之前langGraph 生成一个 checkpoint节点 A 正常执行完执行结果写入状态再生成一个 checkpoint节点 B 正常执行完继续生成 checkpoint进入节点 C 之前先存 checkpoint然后 C 开始执行C 执行到一半失败你带着同一个 thread_id 重新 invokelangGraph 找到该线程最新的 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拉出某个线程的全部 checkpointconfig {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手动修正状态里的某个字段再继续 invoketarget_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 的其他章节会顺很多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑