☰
LangGraph持久化执行实战:用Checkpointer实现Agent断点续跑
2026/10/22 6:18:57 网站建设 项目流程

做Agent应用最怕的不是模型返回慢,而是跑到一半进程崩了,所有状态归零。LangGraph 的持久化执行能力,专门解决有状态长任务的恢复与续跑问题。之前我在做一个多步数据清洗 Agent 时,如果不给图执行加检查点,每次容器被杀都得从头跑一遍,既浪费 token 又容易把中间结果搞丢。把持久化接入之后,整个问题的性质变了:不再是"全部重来",而是"从断点继续"。这篇内容我会把 LangGraph 的持久化执行从原理到实践完整拆一遍,重点放在检查点机制、存储选型、恢复流程和常见的坑上,希望对正在用或准备用这个框架的人有帮助。

1. 为什么 LangGraph 要把执行变成可持久化执行

1.1 短命进程和长任务之间的矛盾

一个真实的 Agent 工作流经常要跑几十秒甚至几分钟。以我做过的一个资料整理 Agent 为例,它需要先解析用户问题,然后并行搜索多个信息源,再对搜到的内容做去重和摘要,最后按固定格式生成报告。这个过程中间会产生非常多的状态:原始查询词、每次搜索的返回结果、候选文档列表、摘要草稿、错误重试次数等等。如果这些状态仅仅存在于 Python 进程的局部变量里,只要进程退出,或者容器被重新调度,所有中间产出立刻灰飞烟灭。

这就是短命进程和长任务之间的核心矛盾。服务器重启、代码发布、资源耗尽导致的 OOM、一次异常超时,都可能中断一个正在运行的工作流。没有持久化的时候,你只能把所有节点设计成幂等重跑,或者自己把每个中间结果手动存到数据库,节点之间靠外部存储互相传递进度。这种做法不是不行,但会把业务逻辑搞得非常分散,每个节点都要关心“数据存到哪了”和“下一步从哪取”。LangGraph 的持久化执行改变了这个局面:框架把状态管理从业务代码里抽出来,统一记录运行到哪一步、状态长什么样。

1.2 多节点协作时的状态同步问题

在一个多节点图里,节点之间不是孤立运行的。常见的模式是:A 节点产出结果,B 节点消费结果,C 节点又同时依赖 A 和 B 的输出。如果不做状态持久化,节点只能通过函数入参把数据传来传去,于是你会被迫定义大量“中间传递对象”。一旦图的关系复杂起来,代码会变得让人非常头疼,每个节点都要考虑“上游有没有给我传够字段”。

持久化执行从根本上改变了这种协作方式。所有节点共享同一个全局状态容器,节点只需要声明自己需要状态里的哪些字段,产出结果后框架自动把这些字段合并回状态。节点之间不需要显式传递数据,状态管理由框架负责。我接入持久化之后,节点函数明显变得更干净了,几乎不关心调用顺序,只关心输入状态和输出结果,这让后续调试和扩展都轻松很多。

1.3 从状态机视角理解持久化的切入点

LangGraph 的图执行本质上就是一个状态机。节点是状态转移动作,边是转移路径,状态字典是当前机器状态。理解这一点之后,持久化执行就很好解释了:它就是在状态机运行过程中,把“当前状态 + 执行位置”按固定间隔保存到外部存储。这个过程类似游戏存档,恢复就是读档。

但注意,这和传统缓存是完全不同的两件事。缓存保存的是“某个输入对应某个输出”,而 checkpoint 保存的是“整个图的运行进度”。这意味着恢复之后,你可以直接从上次保存的位置继续执行,而不是从头开始重新计算。这种“运行位置”信息才是持久化执行的精髓。它带来的能力包括断点续跑、回放历史、手动修改中间状态后重新运行,后面我会详细展开。

2. 核心原理:Checkpointer、序列化与恢复机制

2.1 Checkpointer 保存的不只是数据,而是运行位置

很多人第一次接触 Checkpointer 时,容易把它当成“中间结果缓存”。实际差别很大。Checkpointer 在每个超级步骤完成后触发保存。这里“超级步骤”指一轮可以并行执行的节点集合。比如有三个互相独立的节点,LangGraph 会并行跑完它们,然后暂停并执行一次持久化写入。这样设计的好处是尽量减少磁盘写入次数,又能保证在任意一个节点崩溃时,最多回退到上一轮并行执行结束的位置。

Checkpoint 里保存的内容包括状态本身、已经执行完的节点列表、待执行节点、父节点关系和元数据。有了这些信息,框架才能在恢复时回答一个关键问题:“下一步该跑哪个节点”。如果只保存状态而丢失执行位置,你只能从头开始,无法实现真正的断点续跑。我最初自己实现状态存储时,只把 state 存了下来,恢复后图不知道跑到哪一步,后来才发现“执行位置”才是那个不能丢的东西。

2.2 状态序列化:如何把 Python 对象变成可恢复的字节流

状态字典并不总是由简单类型组成。可能有字符串、整数、列表,也可能有自定义的 Pydantic 模型,甚至第三方库的对象。Checkpointer 要写入数据库,必须先把内存对象序列化成字节流或者 JSON 结构。LangGraph 的做法是为每个字段类型配置序列化方式,内置类型默认可以 JSON 序列化,自定义类型则需要提供对应的序列化逻辑。

这里有个容易被忽视的坑:如果在状态里塞了无法序列化的对象,比如打开的文件句柄、数据库连接、线程锁,保存 checkpoint 时就会直接抛异常。我在一个图像处理 Demo 里踩过这个坑,当时把某个 OpenCV 对象放进了状态,结果一跑就报错。后来我改成了“状态里只放可序列化数据,复杂对象放外部存储,状态中只保留引用 ID”,问题立刻消失。这个习惯我一直保留到今天,因为即使某个版本支持更多类型,序列化开销和跨版本兼容性依然是隐患。

另外还要考虑状态模式升级问题。如果旧的 checkpoint 是在旧版状态模式下保存的,项目更新后新增了字段或删除了字段,反序列化时可能失败。比较好的做法是给状态模式加版本字段,迁移时写兼容逻辑,或者在读取 checkpoint 后先做一次字段校验。

2.3 恢复、时间旅行与分支执行

持久化执行带来的额外红利是时间旅行。你可以通过读取接口拿到某个 thread_id 的最新状态,也可以用更新接口手动修改状态里某个字段,然后再从修改后的状态继续执行。打个比方,这就像你在一个决策树里走到某个错误分支后,回到分岔点改掉错误选择,再走另一边。对调试 Agent 特别有用。

我之前调试一个多步工具调用流程时,经常遇到这种情况:前几步都正确,只有某个中间字段值不对,导致后续节点全乱了。如果没有时间旅行,我只能把整个流程重跑一遍,既耗时又容易因为外部接口波动出现新问题。有了手动更新状态之后,我可以直接把那个字段改成正确值,然后从该节点继续往下跑,几秒钟就能验证后续逻辑。时间旅行也让 LangGraph 的状态层更像一个数据库,你可以查询历史版本、对比不同分支结果,这在生产环境排障时价值非常大。

3. 存储选型:内存、SQLite 还是 PostgreSQL

3.1 三种 Checkpointer 的对比

LangGraph 生态里最常见的三种 Checkpointer 是内存版、SQLite 版和 PostgreSQL 版。它们没有绝对的好坏,主要看部署形态。

  • 内存版:保存到内存字典,速度最快,配置最简单,但进程重启后数据全部丢失,而且不能跨进程共享。
  • SQLite 版:保存到本地文件,单机多线程场景下够用,不需要额外部署服务,适合开发环境和单实例生产。
  • PostgreSQL 版:保存到独立数据库,支持多个实例共享同一套状态,适合多副本部署的线上环境。
能力内存版SQLite 版PostgreSQL 版
读写速度最快较快中等
跨进程共享不支持单机内支持多实例支持
进程重启恢复丢失保留保留
运维成本无低需要维护数据库
典型场景本地调试、单次测试单机生产、小型项目多副本、高可用环境

我不建议直接用内存版做生产,因为一旦进程重启,所有会话状态全丢了,用户体验会非常不可控。SQLite 适合先把功能跑通,后期再平滑迁移。

3.2 生产环境为什么优先考虑共享数据库

如果 Agent 服务是单实例部署,SQLite 完全够用。但一旦上了多副本,同一个用户请求可能会被负载均衡到不同实例。如果每个实例各自持有 checkpoint,那么同一个对话在不同实例上就会状态不一致,这比没有持久化更麻烦。

PostgreSQL 作为共享存储可以从根上解决这个问题。所有实例连接同一个数据库,同一个 thread_id 的状态读写永远落在同一份数据上,不会出现各说各话。同时数据库自带事务能力,能比较好地保证并发写入时的数据一致性。需要注意,这里说的状态存储只保存“图运行状态”,不意味着要把业务数据也塞进去。业务大表保持独立,不要让 checkpoint 数据拖累业务查询。

3.3 配置最小示例

接入 Checkpointer 的代码非常简洁。以 SQLite 为例:

import sqlite3 from langgraph.checkpoint.sqlite import SqliteSaver conn = sqlite3.connect("checkpoints.db", check_same_thread=False) checkpointer = SqliteSaver(conn) # 假设 builder 已经组装好节点和边 graph = builder.compile(checkpointer=checkpointer)

注意check_same_thread=False这个参数很关键。LangGraph 的执行可能在多个线程中进行,SQLite 默认连接不允许跨线程使用,不设置这个参数会在运行时抛错。连接对象的生命周期需要你自己管理,程序退出前记得关闭。

使用 PostgreSQL 版时,通常是传入一个带连接参数的构建函数,代码结构类似,但底层连接池和事务处理更贴近生产需求。具体 API 会随版本略有变化,建议以官方文档中的写法为准。

4. 实操全流程:构建一个可恢复的多步工作流

4.1 定义状态模式与节点函数

这里用一个“信息检索 + 摘要 + 通知”的流程做例子。先定义状态模式:

from typing import TypedDict class AgentState(TypedDict): query: str search_results: list summary: str notified: bool

三个节点分别负责搜索、摘要和通知。每个节点函数接收 state,返回需要更新的字段字典:

def search_node(state): query = state["query"] # 模拟搜索过程 results = ["result_a", "result_b", "result_c"] return {"search_results": results} def summarize_node(state): results = state["search_results"] summary = " + ".join(results) return {"summary": summary} def notify_node(state): # 模拟发送通知 print("notify:", state["summary"]) return {"notified": True}

这种设计非常直白,节点只依赖状态字段,不关心上游是谁,只要状态里有需要的数据就能跑。

4.2 编译时接入 Checkpointer 并跑通第一轮

组装图和编译:

from langgraph.graph import StateGraph, START, END builder = StateGraph(AgentState) builder.add_node("search", search_node) builder.add_node("summarize", summarize_node) builder.add_node("notify", notify_node) builder.add_edge(START, "search") builder.add_edge("search", "summarize") builder.add_edge("summarize", "notify") builder.add_edge("notify", END) graph = builder.compile(checkpointer=checkpointer) config = {"configurable": {"thread_id": "demo-001"}} result = graph.invoke({"query": "LangGraph persistence"}, config=config) print(result)

这里config里的thread_id就是会话标识。相同 thread_id 的多次调用共享同一份状态,不同 thread_id 之间完全隔离。第一次执行后,状态里应该已经包含search_results、summary和notified。

执行完成后可以调用graph.get_state(config)查看当前状态和下一步信息。如果你发现某个字段不对,也可以用它来做检查。

4.3 模拟崩溃恢复与断点续跑

真实场景里进程崩溃很难现场复现,但我们可以通过代码模拟恢复逻辑。假设某个节点在写入结果之前异常退出,并且我们已经把异常信息保存在外部日志中。恢复时,关键操作是使用同一个thread_id重新调用图。

config = {"configurable": {"thread_id": "demo-001"}} # 重新执行时,框架会先读取该 thread_id 对应的 checkpoint # 如果最后一个 checkpoint 位于 search 之后、summarize 之前, # 那么这次调用会直接从 summarize 节点开始,而不是重新搜索 result = graph.invoke({"query": "LangGraph persistence"}, config=config)

当然,这种“自动续跑”有个前提:节点函数必须是可重入的。如果某个节点在 checkpoint 保存后已经产生了副作用,恢复时被重新执行,副作用就会被重复执行。我在后面常见问题部分会专门讲这个问题。

如果你不想直接续跑,而是希望手动修改某一个中间字段后再运行,可以用更新接口:

graph.update_state(config, {"summary": "manual_corrected_summary"})

执行后,后续节点会基于修改后的 summary 继续跑。这就是时间旅行的典型用法,调试体验很好。

4.4 基于 thread_id 的会话级记忆

持久化执行做一个天然的聊天记忆系统。只要在状态里加入一个消息列表,并在每次调用后把新消息追加进去,这个列表就会随 checkpoint 一起保存。同一个用户使用同一个 thread_id 时,每次调用都能读到历史消息。

实现时通常需要配合 reducer。LangGraph 里更新同一个字段时,默认是覆盖式赋值,但消息列表应该累积追加。示例:

from typing import Annotated from operator import add class ChatState(TypedDict): messages: Annotated[list, add] query: str

加上add这个 reducer 之后,节点返回的新消息不会覆盖旧消息,而是追加到列表尾部。这样每次执行都可以把用户输入、Agent 输出追加到 messages 中,再配合总结类节点把超长历史压缩,就形成了一个可以长期运行的记忆系统。需要注意的是,状态不会无限膨胀,因为每次保存 checkpoint 都会把整个状态写一遍,所以一定要给消息列表加上裁剪逻辑。

5. 常见问题与排查技巧实录

5.1 并发写冲突:同一个线程被并发调用

生产环境里最令人头疼的问题,就是同一时刻有多个请求带着同一个 thread_id 触发图执行。两个请求同时读同一个 checkpoint,分别执行,然后都想把新状态写回去。后写入的人可能覆盖先写入的人,导致状态丢失或者逻辑跳变。

我遇到这个问题的场景是用户连续点了两次按钮,前端同时发出两个请求。解决方案有三种:第一,在应用层给同一个 thread_id 加分布式锁,保证同一时间只有一个请求在推进这个会话;第二,用队列把针对同一个 thread_id 的请求串行化;第三,依赖数据库的事务与版本号机制,写入时校验冲突。哪怕用了 PostgreSQL,应用层的串行化约束也比盲目依赖数据库更可靠。

5.2 状态序列化失败的兜底策略

如果你在状态里放了复杂对象,并且没有配置序列化方式,第一次执行可能正常,因为节点还没做 checkpoint;等到需要持久化时,就会看到序列化报错。排查方式很简单:先检查状态里所有字段的类型,再把不必要进入状态的复杂对象移除。

如果确实需要在节点间传递复杂对象,我建议只传对象 ID,让节点从外部存储(Redis、对象存储或数据库)取对象。这样 checkpoint 还是干净的可序列化数据。另一个兜底办法是给自定义字段写序列化回调,框架允许你为字段类型注册转换函数,把对象变成字典,再在读取时还原。不过这类方案会增加维护成本,能不用就不用。

5.3 断点恢复后副作用重复执行

这是持久化执行最隐蔽的坑。假设你的节点里有一步调用了通知服务,比如发邮件或者推送消息。checkpoint 保存在这个节点执行完之后,但外部服务的调用已经发生了。如果后面的节点报错,恢复后回到该节点重跑,通知就会再发一次。用户收到两条邮件,投诉就来了。

我的应对策略是“把副作用集中在末端节点,并让末端节点幂等”。例如状态里增加一个notification_sent布尔字段,节点执行前先检查,如果已经发送就直接跳过。更进一步,给每条通知生成唯一业务 ID,接收方根据 ID 去重。这样即使节点重复执行,也不会造成重复的外部影响。

5.4 Checkpoint 存储膨胀与清理

每一轮超级步骤都会写入一个完整 checkpoint,几十个字段的大状态写几十次,数据库表很快就会膨胀。尤其是长时间运行的多轮对话,如果状态里保存了不断累积的消息列表,存储增长会非常快。

建议给 checkpoint 表建立清理任务,定期删除超过保留时间的旧版本。在应用层面,也可以只保留最新的 N 个 checkpoint,而不是无限保留。时间旅行虽然好用,但也不是每个历史版本都要留一辈子。具体保留多少,取决于你的调试需求和存储成本,我个人一般保留最近 20 到 50 个版本,超过的定时清掉。

6. 个人体会:持久化执行带来的架构习惯改变

6.1 把状态模式设计成“数据 DTO”

长期用下来,我最大的体会是持久化执行会倒逼你重新设计状态模式。以前写节点函数时,我会随手就把各种对象往 state 里塞,反正是内存数据。但一旦有了 checkpoint 和序列化约束,状态模式就变成了一个需要前后端对齐的“数据 DTO”。它的边界必须清晰,字段必须可控,类型必须可序列化。

这个转变其实是好事。状态模式清晰之后,图里数据的流转路径一目了然,新成员接手项目时只需要读状态定义,就能理解整个流程大概在做什么。相比之下,那些把所有临时变量都堆在状态里的做法,虽然跑起来没问题,但遇到恢复问题时完全是灾难。

6.2 调试和故障恢复的小技巧

最后分享一个我实际用得很顺手的调试方法。当某个节点因为外部接口返回异常导致后续结果不对时,我不会把整个图重跑一遍,而是利用时间旅行把那个节点的中间状态打印出来,手动修正后再从修正位置继续跑。这个流程听着简单,但非常节省时间。

另外建议在开发阶段给每个节点加一点可观测性,比如输出节点名、耗时和执行序号。当你的 checkpoint 和日志对上号之后,排查问题就像在看一部有进度条的影片,一切都能被解释清楚。持久化执行不是银弹,它把原来隐性的状态问题放大到了存储层,但只要你处理好序列化、幂等和并发这几个关键点,LangGraph 就能从一个小玩具变成一个真正扛得住生产环境的工具箱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询