跳到主要内容

内容阅读

LangGraph 持久化拆解:Checkpointer 到底帮你存了什么

Agent阿新聊ai

TL;DR: checkpointer 在每个 superstep 边界给整张图的状态拍快照,按 thread_id 归档。它给你四件事:断点续跑、时间旅行、分叉、以及人审(interrupt)的底层支撑。同时它有明确边界:只存图状态,不存节点局部变量,快照粒度意味着节点级重跑,副作用必须自己保证幂等。

五分钟版本:快照怎么工作

给图挂一个 checkpointer,编译,然后用 thread_id 调用:

from langgraph.graph import StateGraph, MessagesState, START
from langgraph.checkpoint.memory import InMemorySaver

def chatbot(state: MessagesState) -> dict:
    return {"messages": [("assistant", "收到:" + state["messages"][-1].content)]}

g = StateGraph(MessagesState)
g.add_node("chatbot", chatbot)
g.add_edge(START, "chatbot")
app = g.compile(checkpointer=InMemorySaver())

config = {"configurable": {"thread_id": "user-42"}}
app.invoke({"messages": [("user", "你好")]}, config)
app.invoke({"messages": [("user", "我刚才说了什么?")]}, config)

print(app.get_state(config).values["messages"])
# 两次调用的消息都在,第二次执行时模型看得到第一轮内容

第二次 invoke 没有传历史消息,模型却能「记得」第一轮,因为 checkpointer 按 thread_id 找到上次的快照,新输入被追加进去。对话记忆在最简形态下就是这么来的,不玄。值得把这个过程拆开看:第一次 invoke 结束时,那次执行的最终状态作为一张快照存进了 user-42 这个 thread;第二次 invoke 到来,运行时先按 thread_id 把链尾快照读出来恢复成当前状态,再把新的输入合并进去,然后才开始执行图。模型「记得」,是因为它的输入里真的有第一轮的消息,记忆是被持久化机制还原出来的,不是模型自己的能力。

顺带一条并发纪律:这套「读链尾、追加新快照」的机制默认同一个 thread 上的 invoke 是串行的。两个请求同时打同一个 thread,两条链节交错写入,谁覆盖谁取决于完成顺序,会话状态会变得不可解释。同一个 thread 同一时刻只跑一次 invoke,这条约束要在入口层用锁或队列保证,而不是指望运行时替你仲裁。把同一用户的连续消息排队串行处理,是最简单也最够用的实现。

每跨过一个 superstep 边界,LangGraph 把当时的通道状态、待写入、下一步该跑哪些节点一起存下来。快照是完整的,不是增量日志,所以任何一张快照都足以从中间恢复。这一点和第 02 篇讲的推导模型是配套的:增量更新序列是运行时的组织方式,而每张快照已经把折叠做完了,恢复时不需要从头重放整条历史,读一张全量快照就位。存储换时间,这是设计者明着做的取舍。

一个 thread 就是一条快照链。每次执行的每个边界往链上挂一节,链尾就是「现在」,链中任何一节都是「历史上的某个时刻」。四件核心能力(恢复、回放、时间旅行、分叉)全部是对这条链的四种读法,没有新增任何概念。

快照里到底有什么

官方对快照内容的描述是通道状态、待写入、下一步就绪的节点。这三样东西值得逐个拆开,因为每一个都对应一种你可能遇到的现象。

通道状态是所有 key 的当前值,这是快照的主体。第 02 篇说过,通道值必须是可序列化的,原因就在这里:它要被整体写进数据库、整体读回来。你塞进 state 的每个字段都会出现在每张快照里,快照体积和 state 大小成正比,这是后面「表膨胀」问题的直接源头。反过来这也是一条设计杠杆:砍掉 state 里一个不必要的大字段,收益不是省一点内存,是乘以快照数量的存储节省。

待写入是合并进行中的那些更新。superstep 边界的语义是「上一轮节点的更新正在应用」,快照把这一刻完整定格,包括还没落定通道的写入。它的意义是恢复的完整性:崩溃发生在合并中途,恢复时这批写入不会凭空消失也不会重复应用两份。这也是 durability 三档真正调节的东西:写入什么时候对存储可见。

下一步就绪的节点(回放代码里的 snap.next)是调度状态的定格。恢复时运行时不需要重新计算「该跑谁」,快照里直接写着。这解释了为什么恢复是精确的:不只是数据在,执行进度也在。回放时打印 snap.next,你看到的就是每个历史时刻图「正要做什么」,排查时它是最有用的一列。

除此之外每张快照还带元数据(步数计数、来源等),get_state_history 返回的对象上都能取到。排查长流程时用步数定位到「第 N 步的那张快照」,比按时间戳翻快多了。

先记住一个前提,后面会反复回来:快照记录的是状态,不是节点执行过程。节点执行到一半挂掉,恢复的是节点开始前的那张快照,整个节点从头再跑。这意味着快照机制对你的代码有一个隐含要求,节点的副作用要经得起重跑,这个要求在「它没存什么」一节展开。

thread_id:一条会话的身份证

同一个编译好的图,用不同 thread_id 调用就是互不干扰的会话。生产里 thread_id 通常对应「一个用户的一个业务流程」,比如一个订单的审批会话。这个映射值得花心思定:thread_id 既是隔离单位,也是你出事时翻现场的检索键。两个实操细节:thread_id 长度上限 255 字符;把业务对象的 id 直接编进 thread_id(订单号、工单号),排查时按业务 id 一查就到,比内部生成的随机串好用得多。

命名规范跟着两条走。加业务前缀(ticket-order-),不同业务流程的 thread 在表里一眼可分,出问题时按前缀圈范围。反过来,别把敏感信息编进去:手机号、身份证号进了 thread_id,就等于落进了 checkpoint 表,跟着备份和导出到处走。thread_id 里只放内部 id,这条和「secrets 不进 state」是同一个原则的两个入口。

多租户场景有一条必须立的规矩:thread_id 只负责隔离数据,不负责权限。按 thread_id 读写没有任何内置的身份校验,谁拿着 id 谁就能读到那条会话。所以用户 A 不能读用户 B 会话的校验,必须在你自己的入口层做:从登录态推导出合法的 thread_id 范围,或者校验「这个 thread 归这个用户」。把 thread_id 当成秘密来依赖,等于门没锁。安全边界的完整讨论在第 16 篇。

拿客服系统举个例子。用户 A 的售后工单走审批,thread_id 是 ticket-80123。三天后用户 A 自己的另一个请求携带了 ticket-80124(别人的工单号拼出来的 id),入口层如果不校验「这个 ticket 属不属当前登录用户」,checkpointer 会忠实地把 80124 的会话现场交给这个请求。数据没有泄露给陌生人?泄露了,而且完全符合框架的语义。校验逻辑放哪、怎么统一做,第 16 篇给完整方案,这里只要记住:thread_id 是地址,不是钥匙。

实现选择上,InMemorySaver 只用于开发和测试,进程一重启数据全没,生产必须换 SqliteSaver、PostgresSaver 这类落盘实现。两段代码看清切换成本,逻辑零改动,只换 checkpointer:

# 开发与测试:进程内或单机落盘
# pip install langgraph-checkpoint-sqlite
from langgraph.checkpoint.sqlite import SqliteSaver

with SqliteSaver.from_conn_string("checkpoints.db") as cp:
    app = g.compile(checkpointer=cp)
# 生产:Postgres,多实例共享
# pip install langgraph-checkpoint-postgres
from langgraph.checkpoint.postgres import PostgresSaver

with PostgresSaver.from_conn_string(DB_URI) as cp:
    cp.setup()  # 首次使用前建表,只需执行一次
    app = g.compile(checkpointer=cp)

Postgres 版首次使用前要跑一次 checkpointer.setup() 建表。多实例部署必须用共享落盘的实现:两个 Pod 各自内存里存快照,同一个 thread 的两次请求落在不同实例上,会话直接断片,这类事故的表象是「用户偶尔失忆」,非常难查。开发期图省事用内存版,上生产忘了换,是最常见的翻车方式之一。

thread 的生命周期也值得定一下。一条链跟着一个业务流程走:流程开始时第一次 invoke 创建了它,流程结束(终态节点跑完)之后链就不再增长。要不要在业务结束时清理或归档,属于运维策略,但「一个业务流程一个 thread」这个粒度本身要在设计期定死。两种常见错误粒度:一个用户永远一个 thread,不同业务的上下文互相污染,历史越滚越大;每次请求都新 thread,跨请求的流程根本续不上,等于没挂 checkpointer。判断标准回到业务形状:断点续跑和会话连续性的需求覆盖到哪里,thread 的粒度就划到哪里。

四件事的用法

恢复。 进程重启后,拿着同一个 thread_id 再 invoke,从最后一张快照继续。上面代码把两次 invoke 换成两次部署之间的两次调用,效果一样。把它写成运维预案的形式:服务崩了,重启,用户的下一次请求自动从断点接着走,不需要任何人做数据修复。前提有二:快照在共享存储里(多实例),节点副作用幂等(下一节)。恢复语义的粒度是节点,崩溃时正在跑的那个节点会整个重跑一次,这是第 02 篇边界模型直接推出的结论,也是官方反复劝「别把五个步骤塞进一个节点」的原因。

恢复过程值得在脑子里过一遍,因为它能解释一半的「恢复后怪现象」。运行时按 thread_id 取链尾快照,重建通道状态,读出 snap.next 里就绪的节点,把这些节点当全新的执行重新跑。三个环节各有各的坑:取链尾,多实例下如果实现没换成共享存储,取到的是某个实例的本地图;重建通道,旧 schema 的状态遇到新代码,字段对不上;重新调度,重跑的节点带着副作用,幂等没做就是重复执行。恢复不是魔法,是这三个环节的机械执行,哪个环节缺了准备,哪个环节出事故。

回放。 快照历史可以列出来,每张都有 checkpoint_id 和当时待执行的节点:

for snap in app.get_state_history(config):
    print(snap.config["configurable"]["checkpoint_id"], snap.next)

get_state_history 返回的就是那条快照链,从新到旧。snap.next 是该快照之后即将执行的节点,两个信息拼起来就是完整的执行时间线:哪个节点在什么状态下被调度过。客户说上周三那次跑错了参数,你翻的不是日志,是这条链,逐张快照看通道里存了什么,错误参数是哪个节点写入的一眼可见。这是把第 02 篇「可重放」的承诺兑现成排查动作。

回放还有一个不显眼的价值:它让客服和支持流程有了「证据链」。用户的投诉是「你们的 agent 把我的地址改错了」,有了快照链,你能精确指出第几步、哪个节点、依据什么输入改的地址,是上游数据就错,还是模型理解偏了。没有这条链,同样的投诉只能回复「我们查一下」,然后靠日志工程师拼半天。把回放当作客服系统的能力来建设,而不只是开发的自查工具,是很多团队事后才意识到的事。

一个使用成本要知道:链随着每次执行增长,回放遍历的是整条历史。老 thread 上做全链遍历会越来越慢,生产里更常见的做法是只看链尾几节(最近发生了什么),需要深挖历史时再按 checkpoint_id 定点取。

时间旅行。 从历史快照重新执行。把 input 传 None、指定 checkpoint_id,图从那个点继续跑:

states = list(app.get_state_history(config))
old = states[-1].config["configurable"]["checkpoint_id"]
app.invoke(None, {"configurable": {"thread_id": "user-42", "checkpoint_id": old}})

日常运维不需要它,它的主场是调试和验证假设:「如果当时这个状态不一样,后面会不会就对了」。拿一个真实排查走一遍:深度研究 agent 产出的报告明显跑题,你怀疑是检索节点给的素材就偏了。做法是翻出那次的快照链,找到检索节点执行前的那张快照,从那里继续跑,换上正确素材,看后续节点能不能产出合格报告。能,说明问题确实在检索;不能,问题在下游。一次时间旅行把「问题在哪一段」二分掉了,这比通读代码快得多。从旧快照继续跑会形成新的执行路径,原链不受影响,所以这是安全的实验,不会污染线上数据。

分叉。 从历史快照出发换一个输入重新执行,生成一条新的 checkpoint 链,原历史不受影响。和时间旅行的区别在于换不换输入:时间旅行是「从那个点继续」,分叉是「从那个点重来一次,但条件不同」。想做「同一个请求换个提示词再跑一遍,对比结果」时用它,第 15 篇的回归测试会把它用成常规武器:对同一批历史输入,新旧版本代码各跑一条链,对比轨迹差异。

分叉的操作顺序值得背下来:先用回放定位到合适的分叉点(通常是「错误发生前的那个边界」),从那张快照出发,换上新的输入或改过的状态继续执行,得到一条独立的新链。两边各有一条完整时间线,对比就是逐节点看差异。它甚至能当轻量级的灰度用:同一条 thread 的历史现场,让新版本代码跑一遍分叉链,确认行为符合预期,再放心让线上流量走新代码。注意每次分叉都会复制一份现场并产生新快照,拿它做批量实验时存储消耗是线性叠加的,实验完清理掉实验链。

另外 0.2.34 之后持久化写入有 durability 三档:exit(默认,superstep 结束写)、async(异步写,崩溃可能丢最后一步)、sync(同步写,最稳最慢)。大多数场景默认档就够,金融类流程才需要动它。选择的依据是「丢最后一步」的代价:多花几分钟重跑无所谓,就用默认;最后一步涉及付款发贷,才值得为 sync 付延迟。把 async 档的丢失场景想具体:批量摘要任务崩溃,丢的是最后一份摘要,重跑就好,async 的性能收益白拿;付款流程崩溃,丢的是「已扣款」这个事实落档前的瞬间,恢复后节点重跑,幸好有幂等键兜住,但惊险程度完全不同。先想业务能接受丢到哪,再选档。

它没存什么,这才是容易出事的

节点局部变量不进快照。 快照只包含图状态。节点函数里定义的中间变量、数据库连接、打开的文件句柄,恢复后都不在。节点必须设计成「从 state 出发、把结果写回 state」的纯函数风格,恢复才有意义。这条要求换个说法就是:节点要能在「什么都不在内存里」的前提下,仅凭 state 完成自己的工作。需要连接就现场建,需要上下文就从 state 读,跑完把该留的写回 state,其余全部丢掉。做不到这点的节点,恢复之后就是一个带着旧记忆新跑的进程,行为不可预测。

快照粒度是节点,副作用在里面。 节点中途挂掉,恢复时整个节点重跑。节点里调了外部 API、发了邮件,恢复后就发两次。这条没有框架能替你解决,副作用要幂等,或者干脆把副作用挪出节点。两条路线:一是幂等键,调外部接口时带上业务唯一标识(工单号加步骤号),重复请求被对端识别后拒绝或返回原结果,前提是对端支持;二是把节点里的「立即执行」改成「往队列里投递」,由带去重键的消费者执行,把恰好一次的责任交给专门的组件。用 interrupt() 做人审时同理:interrupt 之前的副作用要当作「可能已经发生过」来写,因为恢复后节点会从第一行重跑,第 06 篇的六个坑里有三个源自这里。

拿一笔付款把幂等键路线走具体。节点要调支付接口,转账 5000 元给供应商 A。写法不是「调接口,成功就记下已付款」,而是「带上 pay-<订单号>-step3 这样的幂等键调接口」。第一次执行,接口成功,你还没来得及把结果写回 state 进程挂了。恢复后节点重跑,第二次调用带着同一个幂等键到达,对端识别出重复,返回第一次的结果或者明确拒绝。你把返回值写回 state,流程继续,钱只付了一次。注意两个细节:幂等键的构造要用业务标识而不是运行时随机数(随机数重跑就变了,等于没设);对端不支持幂等键时,这条路走不通,只能选队列路线或者接受至少一次语义。

state 里有什么,快照里就有什么。 你把 API key 临时塞进 state,它就跟着每一张快照落到数据库里。这不是猜测,持久化的定义就是如此:序列化通道状态到存储,key 在通道里,就在盘上。之后的每一次回放、导出、备份,它都在场。状态设计时先想一句:这些字段被持久化、被回放、被导出,我能接受吗。凭证走配置和密钥管理,永远不进 state,第 16 篇会把它升级成一条完整的权限设计。

把三条边界合起来看,checkpointer 的契约其实非常清晰:它保存并还原「图的公开状态」,节点执行过程中的私有世界(内存、连接、副作用)一概不管。这个设计让快照机制保持简单可靠,也让责任划分明确:状态归框架,过程归你。设计节点时把「恢复后从零开始,只有 state」当作默认假设,三条边界的坑就都能提前避开。

最后把四件事和边界放回同一张图里看。恢复、回放、时间旅行、分叉,能力全部来自「每边界全量快照加 thread 归档」这一个机制;局部变量不进快照、节点级重跑、state 即快照内容,限制全部来自同一个机制。能力和限制不是两件事,是同一件事的两面,所以不存在「只享受恢复不付幂等代价」的用法。想通了这一点,checkpointer 就从「需要背的规则清单」变成了「可以从头推导的机制」,这也是本系列反复强调的学法:记结论会过期,会推导就能自愈。

快照会长大:提前打的预防针

完整快照策略的账单在存储侧。每个边界一张全量,thread 越长、图越大,单张快照越大,链越长,表越大。这不是不选它的理由,是要提前规划的理由:给 checkpoint 表定清理策略(保留多久、按 thread 还是按时间清),监控表体积,别等数据库磁盘报警才知道它在你不知道的地方长了半年。第 13 篇就是一次真实的表膨胀事故复盘,结论先剧透一句:清理策略要在上线那天就有,而不是出事那天再补。

开发期有个低成本的习惯值得养成:本地就用 Postgres 版而不是内存版跑几天真实流程,亲眼看看表长多快、单张快照多大。这些数字决定你对清理策略的紧迫程度的判断,内存版永远给不了你这个体感。

清理策略设计时问两个问题。保留多久:checkpoint 链的业务价值随时间衰减,审批流程结束后链只剩审计价值,按行业合规要求定保留期就行。按什么清:按 thread(流程终态后整体归档或删除)最贴合业务语义,按时间(TTL)实现最简单,两者可以组合。监控别只看表大小,看增速:一张今天 2GB 的表不可怕,一周翻一倍才可怕。把增速画进监控面板,膨胀在变成事故前是可见的。

反向的边界同样重要:如果整个应用只跑单轮调用、没有多步流程,checkpointer 带来的只有成本没有收益,别挂。判断依据回到第 01 篇的判断线:没有断点续跑需求、没有人审、没有回放需求的应用,快照链没人消费,只是白白落盘。

有几类系统要特别点名。纯单轮问答和生成接口,每次请求独立,挂 checkpointer 纯属浪费。跑在 serverless 函数里的短任务,生命周期以秒计,状态恢复的需求不存在。已经有 Temporal 这类 durable execution 引擎在管长任务的系统,再叠一层 checkpointer 是两套恢复语义打架,选边的事第 19 篇有完整分析。还有一类容易误判:用了 LangSmith Deployment 这类托管平台,部署形态变了(第 17 篇),但 checkpointer 的纪律一条都不少,存储照样要运维、副作用照样要幂等。

恢复语义的三条推论

四件事的用法都建立在同一条恢复语义上:恢复 = 读取快照重建通道 + 按 snap.next 重新调度。从这条语义能推出三条工程结论,提前记住能省掉不少排查时间。

第一,恢复后是新代码跑旧状态。 快照里的通道值是当时按当时的 schema 存的,部署了新版本之后恢复,新代码拿到的是旧结构的状态。字段改了名、加了必填 key、改了类型,恢复路径都可能出问题。state schema 演进要按数据库迁移对待:加字段给默认值,别改已有 key 的类型,重大重构考虑新 thread 而不是兼容旧链。第 13 篇的事故复盘里有真实的翻车现场。

第二,恢复的完整性由 durability 档位决定。 exit 档在 superstep 结束时写,崩溃最多丢「正在执行的那一步」;async 档写入在后台排队,崩溃可能多丢最后一步;sync 档写入完成才继续,最稳最慢。三档的区别不是「会不会丢」,是「丢到哪个边界」,选档就是把业务对「重跑代价」的容忍度翻译成配置。

第三,checkpointer 不解决业务记忆。 thread 内的状态恢复和「用户的长期偏好」是两件事,后者属于跨 thread 的 Store。挂着 checkpointer 的 agent 聊了三个月,换个 thread 就全忘了,这不是 bug,是边界。三套记忆的分工是第 04 篇的主题,这里只立界碑:快照管流程现场,不管业务记忆。

权衡

checkpointer 引入了一个必须运维的有状态组件:数据库连接、表膨胀、备份都跟着来。它本质上是一个持久化中间件,要有对应的运维纪律:连接池交给成熟的数据库客户端,表有清理策略,版本升级跟着 patch 也要过一遍变更记录,2025 年 langgraph-checkpoint-postgres 就出过 patch 版本夹带破坏性变更的事故,细节在第 13 篇。把「多养一个有状态组件」的代价放到第 01 篇的四问判断线里称一称,答案应该是清晰的:四问里有答「是」的,这个组件的代价远小于自研;四问全否,别挂。

上线前的五条检查:thread_id 规范定过且带业务 id;生产实现是落盘版且多实例共享;setup() 在部署流程里只跑一次的保障;关键副作用全部过了幂等审查或移出节点;checkpoint 表的清理策略和监控存在。五条齐了,checkpointer 就是资产;缺任何一条,它都可能在某个深夜变成事故源。

下一篇预告一句话:checkpointer 让「不忘事」成立之后,token 账单的问题立刻浮出来,历史全都在,模型就全都看。第 04 篇算的就是这笔账,以及三套记忆各管一段的边界。

延伸阅读


原文出处:91ai / LangGraph 生产实战系列

评论

0
登录后可以参与评论和讨论。

还没有评论

欢迎留下第一条评论,帮助这篇内容更快形成讨论。