跳到主要内容

内容阅读

LangGraph Checkpoint 不是免费的:一次生产事故复盘

Agent阿新聊ai

TL;DR: checkpointer 上线三个月后的三类典型事故:checkpoint 表膨胀拖垮数据库、子包 patch 升级夹带破坏性变更、state schema 演进后旧快照反序列化失败。这篇按事故复盘的格式把它们拆开,数据来自公开 issue 和工程博客,结论部分给出对应的生产纪律。

素材说明:以下三个事故样本来自 LangGraph 仓库公开 issue 和工程师博客(链接在文末),按复盘格式整理,非作者亲历项目。

三个月后的三类事故

这三起事故有一个共同的出场时间:上线后三个月左右。不是巧合。checkpoint 的写入量和使用量成正比,第一个月数据还小,什么问题都显不出来;数据是悄悄涨的,涨到某个临界点(磁盘、锁、序列化耗时),问题一夜之间全部出现。所以「我们跑了两个月没事」不是安全的证据,可能只是还没到时候。反过来说,这三类问题的预防手段全部便宜且成熟,属于「知道就会做」的类型,这篇的目标就是把它们变成常识。

先建立正确的心智模型,三起事故都从这里推导出来:checkpointer 不是一个透明缓存,是一个跟着你的业务一起演化的数据库。它有表、有写入放大、有 schema、有版本兼容问题,传统数据库运维的所有纪律,它一条都少不了。而它又有一个特殊之处:写入模式由框架决定(每个 superstep 存一次),存储内容由你的 state 决定,版本兼容由一个拆得很细的包家族决定。这三件事分别对应三起事故。

还有一个让问题更容易被忽视的架构事实:checkpoint 的写入路径和你的业务写入路径通常落在同一个数据库里。业务表按预期容量做了规划,checkpoint 表常常不在规划里,于是当它悄悄长成数据库里最大的表时,性能代价是所有业务查询一起分摊的:慢的不只是 agent,是整个库。复盘这类事故时,DBA 的第一反应往往是「这张表是谁的」,这个反应本身就说明它在架构图上缺席很久了。

上线方案评审时,checkpointer 常常一句话带过:「挂个 PostgresSaver」。先把这个选择本身说清:官方提供的后端按「开发到生产」排列,内存版只用于测试和本地实验(进程没了数据就没了),SQLite 版适合单机轻量场景,生产的多实例部署基本只有 Postgres 系是稳妥起点,社区还有 Redis 等其他后端。选型本身不复杂,复杂的是选完之后的运维责任,也就是这篇的主题。把「挂个」两个字后面的东西摊开:表会怎么长、包会怎么坑、schema 会怎么断,以及各自的上保险方式。

事故一:表膨胀,Postgres 磁盘告警

现象。 多轮对话 agent 上线三个月,checkpoint 相关表占掉数百 GB,数据库整体性能下滑,慢查询告警。最迷惑人的地方是:业务量并没有涨多少,表却涨得比业务快得多。发现路径通常是数据库侧先报警,DBA 顺着慢查询找过来,发现最大的几张表没人在容量规划里见过。发展过程中还有前兆:自动清理任务开始跟不上写入速度,索引膨胀,普通业务查询的执行计划开始劣化,这些在磁盘告警之前几周就有迹象,只是没人把它们和「agent 上线了」联系起来。

机制。 checkpointer 的写入模型是「每个 superstep 存一张完整快照」。一个 30 轮的对话线程,每轮 2 到 4 个 superstep,就是 60 到 120 张快照,每张包含全量消息历史。注意两层放大:快照数量随轮数线性涨,每张快照的大小随消息历史涨,乘在一起,存储是平方级增长。消息越聊越长,快照越大,第 40 张快照比第 4 张大得多,而它保存的信息主要是「历史又重复了一遍」。社区 issue #7714 里的实测口径是:序列化膨胀让存储增加约 85%,每轮模型调用的 token 开销额外增加约 37.8%(作者声称值,未见官方复测,但方向与快照模型一致)。

为什么存全量而不是增量?这是个设计取舍:全量快照的恢复语义最简单,任何一张快照都是完整现场,回放和分叉(第 03 篇的能力)都建立在「每张快照自足」之上;增量的写入小,但恢复要沿链条回放拼接,链条上任何一环损坏都影响恢复。框架选择了简单可靠的那端,把控制体积的责任交给了使用者,这正是「state 小快照就小」是使用者的第一责任的原因。1.2 的 DeltaChannel 可以理解为官方开始把增量做成选项,但语义上它仍是框架管理的增量,不是让你手写 diff。

修复。 两层:给 thread 定生命周期(结束后清理或归档 checkpoints),运行中的 thread 设置保留窗口;消息侧做裁剪和摘要(checkpointer 存多少取决于 state 有多大,state 小快照就小,具体手法在第 04 篇的 token 账单里有完整讨论)。两层的关系是:thread 生命周期管「存多少份」,state 瘦身管「每份多大」,只做后者不做前者,平方曲线还在,只是斜率小了。1.2 版本的 DeltaChannel(beta)把快照从全量改为增量存储,是框架层面的缓解,可以关注但生产采用要自己评估 beta 风险。

把账算具体一点,膨胀速度就有了直觉。假设一个对话 state 平均 20 KB(几十条消息的体量很轻易到这个数),一条 60 张快照的 thread 就是 1.2 MB;一天一万个会话,就是 12 GB,一个月 360 GB,而业务量只是「每天一万个会话」。这个数学没有夸张成分,每个因子都偏保守。它解释了为什么三个月是个典型的爆雷时点:前两个月增长在预算噪音里,第三个月撞线。冷热分层是常用解法:热数据留 Postgres(按保留窗口),过窗的归档到对象存储,需要回放历史时再取,checkpoint 的读取频率本来就极低(出了事才读),归档几乎不损失什么。反过来,如果你的 state 只有几 KB、会话平均三轮就结束、日会话量一千,同样能算出「一年不到 1 GB」的结论,说明你暂时不需要为这个操心。容量数学的用处不是吓人,是把「要不要现在处理」变成一道算术题。

教训。 上线方案评审时,「checkpoint 数据怎么清理」应该和「日志怎么轮转」同等级别地被问到。没有答案,就是给三个月后的磁盘告警签了字。这个问题还有一个隐藏价值:答它的过程会逼你定 thread 的生命周期策略,而 thread 该活多久本来就是业务决策(一个客服会话有意义保留一个月还是一年),framework 替你决定不了。

存储之外还有一层隐性代价要算:序列化本身耗时,且耗时随 state 变大而变长。每个 superstep 边界都要把全量 state 序列化写一次,thread 越长这一步越慢,用户感知是「对话越到后面每轮响应越慢」。这时查模型延迟、查网络都没用,慢的是存档动作本身。这也解释了为什么 state 瘦身(裁剪、摘要)同时是存储优化和延迟优化,它砍掉的是每个 superstep 都要重复支付的序列化成本。并发量大的系统还有第三层:所有会话的 checkpoint 写入汇成一条独立的写入流,和业务写入抢同一个库的连接和磁盘,容量规划时它要按「每秒会话数 × 每 superstep 一次写」单独估算,是数据量大时的真实负载。评估是否把 checkpoint 拆到独立数据库实例,就看这条流的规模。

事故二:patch 版本升级,序列化打挂

现象。 例行依赖升级,langgraph-checkpoint-postgres 从 2.0.21 升到 2.0.22(一个 patch 版本),metadata 序列化行为变更,存量快照读写报错,服务启动即炸。升级前一切正常,升级这个动作本身就是全部变更。

机制。 LangGraph 的包拆得很细:langgraphlanggraph-checkpointlanggraph-checkpoint-postgreslanggraph-sdk 各自独立发版。拆分本身有道理:checkpointer 后端是插件化的(内存、SQLite、Postgres 各自一个包,社区还有别的后端),不用 Postgres 的用户不应该被迫装 Postgres 驱动。但拆包把「一次升级」变成了「一组升级」,兼容矩阵从主包一个变成了一族,风险面宽了不止一倍。主包的版本纪律好(1.0 起承诺无破坏变更),但子包的 patch 版本曾经夹进过行为变更。这里的风险模型和直觉相反:语义化版本承诺「patch 不破坏」,团队基于这个信任建立了「锁主版本、放开子版本」的习惯,而事故恰好从这个信任的缝隙里进来。锁了主版本、放开子版本的项目,一次 pip install -U 就中招。另外要注意:checkpoint 数据是持久的,今天写入的快照要被未来任何版本的代码读出来,这是比普通依赖升级苛刻得多的兼容要求,普通依赖升坏了顶多功能报错,checkpoint 兼容坏了连历史数据一起陪葬。

修复。 降级回 2.0.21 恢复服务,然后把所有 langgraph 系子包全部锁死精确版本。

防复发。 三条纪律:langgraph 全家族包锁精确版本;升级当作变更管理走灰度(先在预发环境用生产快照的副本验证读写);订阅仓库 release,升级前看子包 changelog 而不是只看主包。三条里最值钱的是第二条:用生产快照的副本做升级验证,等于把「序列化兼容性」这件事从祈祷变成测试。操作成本不高(导出一些真实快照,在预发环境的新版本代码里读写一遍),而它验证的恰好是其他测试都覆盖不到的那一环。

把这个验证流程固化成升级手册,大致四步:从生产导出有代表性的快照样本(覆盖不同 thread 类型、不同 schema 版本);在预发环境装新版本,对样本做读、写、再读三个动作;断言读出的 state 与原始数据一致、写下的新快照旧版本代码也能读;都过了,生产再灰度。多实例滚动发布时还有个更隐蔽的窗口:新旧实例同时在跑,旧实例写下的快照新实例要能读,反方向也是,这等于把兼容性验证从「一次升级」扩展到「发布期间的每一秒」,没有快照样本验证的团队,这个窗口完全靠运气。依赖管理工具层面,自动升级类工具(自动合并依赖 PR 的机器人)对 langgraph 家族应该排除或要求人工确认,例行升级的便利抵不过一次自动合并带来的启动失败。

事故三:state schema 改了,旧快照全废

现象。 给 state 加了新字段、删了一个旧字段,部署新版本后,恢复历史会话的请求全部报反序列化错误。改 schema 的代码变更本身毫无危险感(加个字段删个字段,普通业务代码天天干),危险全部潜伏在存量的快照数据里。这类事故有个标志性的发现方式:测试环境和预发环境全绿,因为它们的数据库是新近建的、快照都是新 schema 写的;一上生产,存量数据触发报错。「环境越真实越出错」是数据版本问题的典型签名,遇到它先想「哪份旧数据还在被新代码读」。

机制。 快照里的 state 按 schema 序列化存储,schema 演进没有官方迁移工具。这是官方文档明确承认的限制:新代码读旧快照没有版本化保障。删字段、改类型是最危险的操作;加带默认值的字段通常安全,但序列化格式变更同样可能出问题。这里的深层原因是:schema 兼容是数据工程问题,不是框架能替你解决的问题,框架不知道你的业务里「删除 status 字段」是「不再需要」还是「改名了」还是「挪到嵌套结构里了」,三种意图对应三种兼容策略,只有你知道。把它当成框架缺陷去等官方方案,等不来;把它当成自己业务的数据契约来管理,问题立刻从无解变成有流程。

缓解。 治本是接受「checkpoint 是短期资产」这个定位:长期存活的 thread 才需要 schema 兼容纪律,把 thread 生命周期控制住(见事故一),schema 演进的暴露面自然小。工程上再加两条:state 里放一个自写的 schema_version 字段,恢复节点读快照时先检查版本做兼容处理;字段只加不删,废弃字段先停止写入、下个版本再删。

schema_version 的用法说具体些。state 的 TypedDict 里加一个整型字段,节点写入时带上当前版本号;系统的恢复入口(无论是用户继续会话还是调试回放)在读出快照后先看版本号,旧版本就走一个升级函数,把旧结构映射成新结构再交给图继续跑。这个函数就是你自己写的、只服务你自己业务的迁移工具,官方没有的东西在这里补上。它的存在还倒逼一个健康习惯:每次 schema 变更都要回答「旧数据怎么变成新数据」,答不出来就说明这次变更是破坏性的,得按破坏性的流程走(灰度、兼容窗口、沟通)。

字段演进有几条具体纪律。改名等于删加,是最危险的操作,拆成「加新字段、双写、停写旧字段、下版本删除」四步走;类型收窄(string 改 enum)要看存量数据里有没有收不进去的值;嵌套结构的变更按子结构单独版本化,别让一个深层小改动把顶层版本顶上去,让所有旧快照都「过期」。这些是数据工程的通识,但在「state 就是个 TypedDict」的开发体验里,人很容易忘记它同时是一份持久化的数据契约。

三条放在一起是一条完整的演进协议:版本号让「旧数据」变成可识别的东西而不是报错,只加不删让「识别」永远来得及,短期资产让「需要兼容的窗口」有限。缺任何一条,另两条都撑不住。

把清理做成日常运维

三个事故里最可预防的是第一个,但它需要从「出了事再清」变成「日常运维」。几件具体的事:

清理机制上,按 thread 生命周期定时清理或归档,脚本化、进调度,和日志轮转放在一起管理。手工清理等于没有清理,因为它依赖「有人记得」。清理策略要有例外通道(法务要求保留的会话、正在排查问题的 thread),例外本身也要有过期时间,不然例外清单会慢慢长成第二张膨胀表。

容量观测上,监控 checkpoint 表的大小增长率和最大 thread 尺寸两个指标。增长率突然变陡,往往意味着某个新上线的流程 state 变大了(比如有人往 state 里塞了整份文档),在磁盘告警之前它就是最好的早期信号。告警阈值按「按当前增速还能撑多久」设,而不是按绝对大小设,前者在流量变化时依然有效。

评审习惯上,每个新图上线前问一句:它的 state 里最大字段可能多大、写多频、thread 活多久。三问都有数,容量模型就能算出来;三问都答不上,这个图的 checkpoint 成本就是未知的,未知成本等于无上限成本。

清理工程上有三个容易踩的细节。一是批量删,一次删几百万行会把表锁住、把业务拖住,按批次循环删、避开业务高峰,清理脚本的日志留痕;二是删除要有任务清单以外的人员授权流程,「清了不该清的」和「没清」同样是事故,而且更难恢复;三是用户数据删除请求(注销、隐私合规)要求删干净该用户的所有会话,checkpoint 按 thread_id 组织,没有「用户到 thread」的映射就找不到该删哪些,这层映射要在业务库里自己建,合规要求来之前建好,来之后再建就晚了。

备份问题也在这里一并回答:checkpoint 要不要备份?判断依据是「丢掉它损失什么」。进行中的长任务丢了 checkpoint 就得从头跑,恢复点没了;已完成的会话丢了 checkpoint,影响的是回放、审计和续聊。前者按恢复点目标(RPO)的要求纳入备份,后者多数业务可以接受不备份(有业务库的数据在)。这也是「checkpoint 是短期资产」定位的另一半:定位清楚哪些 checkpoint 是资产、哪些是缓存,备份和清理的策略就都有了依据。归档的格式要一次性定对:导出的快照要带上 schema 版本、序列化时的包版本这些元信息,否则半年后从归档里拿出来的数据,没人知道它该被什么版本的代码读,归档就变成了垃圾场。

怎么判断 checkpointer 开始成为问题

怎么判断 checkpointer 开始成为问题

事故都有前兆,checkpointer 的问题在拖垮一切之前会在四个位置留痕迹。一是超步边界变慢:对话越长的 thread 每轮响应越慢,且慢的部分和模型无关(看 tracing 里的节点耗时,模型调用没变长、边界间隙在变长,就是序列化在膨胀)。二是表增长率:checkpoint 表每天增长多少 GB 有曲线,斜率突然变陡对应着某次上线(往往是某个新图往 state 里塞了大对象)。三是数据库维护任务失速:自动清理、索引维护的耗时逐周上涨,说明清理速度追不上脏数据产生速度。四是锁等待:checkpoint 写入和业务写入抢锁的频率上升,表现为无规律的性能毛刺。四个信号里任何一个持续两周以上,就该坐下来算容量账了,等到告警再动手,选择余地已经很小。排查这四个信号不需要额外的工具,节点耗时来自 tracing(第 14 篇),表增长率和锁等待来自数据库的常规监控视图,把两边的面板放在一起看,checkpointer 的问题和业务问题就能分开归因,这一步做完,「agent 慢」这种含糊的投诉就能落到具体环节上。

四个信号都有监控时,checkpointer 从盲盒变回了普通组件。这也呼应第 14 篇的观点:可观测性不是调试时的奢侈行为,是对「系统在变坏」这件事的早期预警投资。

thread 生命周期是总开关

回头看三起事故,会发现一个共同的缓解杠杆:thread 活多久。生命周期短,膨胀有上限(事故一)、需要兼容的旧快照少(事故三)、备份范围小(运维一节);patch 升级的事故(事故二)虽然不直接由它决定,但活跃快照少,验证样本和灰度面都小。把 thread 生命周期当成第一位的运维参数,是这份复盘最重要的结论。

难的地方在于它是个业务决策,技术团队定不了:客服会话保留一个月还是一年,涉及用户预期、合规要求、纠纷处理周期。工程团队该做的是把这个决策需要的事实摆出来:保留一年,checkpoint 存储按容量数学估算出来是这么多钱、schema 兼容要维护这么多版本、备份窗口这么大。决策有了价格标签,才会有人认真做,而不是默认「永远保留」,后者是所有选项里最贵的那个。

生命周期策略还要照顾「会话重开」的场景:用户回到一个已归档的 thread 继续聊,系统要先走恢复流程(从归档取回快照、做 schema 兼容检查、重新挂到热存储),然后才能接续对话。这条恢复路径要做成一等公民的功能而不是运维手工操作,否则每次「重开会话」都是一次工单。设计归档窗口时留一个缓冲段(比如归档后一周内可直接回热),能把大部分重开请求挡在快速路径里,这是用少量存储换大量体验的常见划算交易。

还有一层容易被漏掉的乘法:子图。第 07 篇讲过子图的 checkpoint 有自己的命名空间,落到存储上,每层子图的 superstep 也在产生快照记录。图里嵌了两层子图、每层若干节点,一次业务操作的快照写入次数是所有层级 superstep 的总和。用 Send 展开并行(第 08 篇)时任务数还会把这条乘法再放大。评估容量时按「一次操作的快照数 = 全图所有层级的 superstep 数」来算,比只数主图节点准确得多。

复盘总结

三起事故指向同一个根因:把 checkpointer 当成了透明的魔法,而它实际是一个跟着你的业务一起演化的数据库。每次评审问三个问题就够:表多大、多快、谁清理;哪个包锁版本、怎么灰度;schema 怎么演进、旧快照怎么办。答不上来任何一条,checkpointer 就是下个季度的故障储备。评审里这三个问题要有明确的回答责任人:由提交图设计的人准备答案,评审人对照检查,而不是评审现场即兴想,「即兴」的答案撑不到三个月。

把三个问题展开成一张可以贴在评审清单里的版本:

  1. 容量与清理:thread 的生命周期是多久?过期后快照是删还是归档?谁负责执行,脚本还是人?表的增长率有监控吗?
  2. 依赖与升级:langgraph 家族的每个包锁的是精确版本吗?升级有灰度流程吗?灰度时用真实快照的副本验证过序列化兼容吗?
  3. schema 演进:state 的变更是只加不删吗?有 schema_version 吗?恢复路径处理过版本不匹配的快照吗?

这三问的成本是每次评审五分钟,收益是避开三类各有完整复盘的事故。落地上再补两条。一是把这篇的三段复盘写进团队的运维手册,事故样本最大的价值是让没踩过坑的人在评审时有具体的画面,「表会膨胀」是抽象的,「三百 GB 和磁盘告警」是具体的,具体的才挡得住侥幸。二是每季度做一次恢复演练:从归档或备份里取真实快照,在当前版本代码里恢复、回放,验证归档可用、兼容没断、流程没烂。演练十五分钟,换的是「备份和归档真的可用」这个平时无法验证的假设。三起事故对应的三条演练可以合成一次:恢复几条归档 thread(验事故三的兼容)、跑一遍清理脚本干跑(验事故一的策略)、在演练环境升级一次子包(验事故二的手册)。

第 03 篇从机制角度讲 checkpointer 存了什么,第 17 篇从成本角度算部署账,这篇补的是运维视角:机制、成本、运维三者对齐,checkpointer 才是一个可以放心依赖的组件,而不是一个三个月后爆炸的盲盒。

延伸阅读


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

评论

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

还没有评论

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