Agent 工程实习复盘 03|从 Pickle 到 PostgreSQL:LangGraph Checkpoint 迁移、内存治理与可回滚发布

本文来自我在实习期间参与的一次真实 Agent 系统改造。项目名称、服务器地址、账号和内部配置均已脱敏,保留通用架构、排查过程、实现取舍和测试数据。
一、先说结论:这次改造解决了什么
我们原来使用本地 Pickle 文件保存 LangGraph Checkpoint。随着会话轮次和 Checkpoint 数量增加,写入新状态、读取历史、生成分享快照等操作会给 LangGraph Runtime 带来明显的内存压力。
最终方案是:
- 保留原来的
Gateway -> LangGraph Runtime调用拓扑; - 将 Checkpoint 后端从本地 Pickle 切换为 PostgreSQL;
- 使用
AsyncPostgresSaver接入异步运行时; - 不直接导入旧 Pickle 二进制,而是通过旧 Runtime 导出
state/history,重建可继续对话的 Seed State; - 增加 Schema 初始化、迁移报告、Cutover、回滚和深健康检查;
- 用隔离 Benchmark 和真实环境采样验证内存变化。
在测试服务器的对照实验中:
- 写入一轮中长对话的进程树 RSS 增量约从
1101 MB降到28 MB; - 生成分享快照的 RSS 增量约从
648 MB降到1 MB; - 正式测试环境执行写入、状态刷新、历史加载和分享时,最大总内存增量约
18.8 MB。
这次工作的重点不是"把文件换成数据库",而是同时处理四个问题:状态语义、历史迁移、发布回滚和可验证性。
二、Checkpoint 到底保存什么
根据 LangGraph 官方 Checkpointer 文档,Checkpointer 会在每个 super-step 边界保存一次图状态快照。多个 Checkpoint 按 thread_id 组织成一条 Thread,使系统能够继续对话、恢复中断、查看历史状态和进行容错恢复。
大白话理解:
- Thread:一次连续会话的逻辑容器;
- Checkpoint:这个会话在某个执行步骤结束后的状态快照;
- State:当前消息、下一步节点、元数据以及业务扩展字段;
- Store:跨 Thread 保存的长期数据,例如用户偏好,不等同于 Checkpoint;
- Thread Registry:运行时维护的 Thread/Run 目录,也不等同于 Checkpoint。
官方文档将 Checkpointer 定位为 Thread 范围内的短期记忆,用于会话连续性、Human-in-the-loop、Time Travel 和故障恢复;Store 则更适合跨 Thread 的长期记忆。两者解决的问题不同。
一次简化后的执行过程如下:
Checkpoint 不是"每轮只存一条聊天消息"。一次 Run 可能经过模型节点、工具节点、路由节点和子图,每个 super-step 都可能产生状态快照和节点写入。长会话里,Checkpoint 数量和状态体积都会增长。
这也是为什么"本地只有一个 Pickle 文件"不代表它很轻。文件只是外观,真正影响资源占用的是读取、反序列化、历史遍历和状态复制方式。
三、为什么本地 Pickle 在这个场景里出了问题
旧方案能工作,但逐渐暴露出三个问题。
3.1 状态增长后,读取路径容易放大内存
会话恢复、历史查询和分享快照都不只读取最后一条消息:
state要恢复最新图状态;history要遍历多个历史快照;- 分享快照要从 State 和 History 中重建用户可见消息;
- 新一轮写入还可能携带不断增长的消息数组。
在当时的本地 Pickle 实现和部署形态下,这些操作会触发大量对象反序列化,内存峰值明显高于实际业务数据大小。
3.2 本地文件与进程生命周期绑定太紧
发布目录切换、工作目录变化、异常退出和多进程误启动,都可能让 Runtime 读到不同的状态目录,或者让运维人员难以判断哪个文件才是权威数据。
PostgreSQL 不能自动解决所有并发和状态语义问题,但它至少把 Checkpoint 从发布目录和单机文件中拆了出来,使存储位置、连接方式和数据表更明确。
3.3 "进程活着"不等于"状态链路可用"
后续一次事故中,系统包自动升级导致 PostgreSQL 重启。LangGraph HTTP 进程仍然存活,/ok 也返回 200,但进程内原有的数据库连接已经关闭,真实的 state/history 请求持续失败。
这件事说明:
健康检查必须覆盖用户真正依赖的状态读写链路,不能只检查端口和 HTTP 进程。
四、方案一:接入 AsyncPostgresSaver
LangGraph 官方提供 langgraph-checkpoint-postgres,用于把 LangGraph State 持久化到 PostgreSQL。官方 README 特别强调:第一次使用必须执行 setup() 创建表和索引。
项目中的接入做了三件事:
- 统一解析 PostgreSQL 连接配置;
- 使用
AsyncPostgresSaver.from_conn_string()创建异步 Checkpointer; - Runtime 启动前执行 Schema Setup,失败则阻止服务继续启动。
下面是脱敏后的核心逻辑:
python
from contextlib import asynccontextmanager
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
@asynccontextmanager
async def generate_checkpointer():
async with AsyncPostgresSaver.from_conn_string(
resolve_postgres_uri()
) as checkpointer:
yield checkpointer
async def initialize_schema():
async with generate_checkpointer() as checkpointer:
await checkpointer.setup()
实际启动顺序不是"先启动,报错后再建表",而是:
这个顺序的好处是尽早失败。如果连接串错误、依赖缺失或数据库不可用,发布阶段就能发现,而不是等第一个用户请求到来时才报错。
初始化完成后,实际可以看到 Checkpoint、节点写入、Blob 和迁移记录等相关表。业务代码不直接操作这些表,而是继续通过 LangGraph Checkpointer 接口读写。
五、最关键的取舍:为什么没有直接导入旧 Pickle
这是整个迁移中最需要解释的地方。
5.1 直接复制二进制看似完整,实际上风险最大
旧 Pickle 中保存的是 Runtime 内部对象和序列化状态。直接回灌存在几个问题:
- 新旧依赖版本可能不同;
- 内部对象结构可能发生变化;
- 历史状态中可能包含不再需要的中间字段;
- 很难验证某个二进制对象是否能在新 Runtime 中安全恢复;
- 迁移失败时,不容易定位具体是哪一个 Thread、哪一个 Checkpoint 出错。
因此,我们没有做"逐 Checkpoint 二进制复制",而是迁移产品真正需要的语义。
5.2 实际迁移的是可继续使用的 Thread
迁移脚本通过仍在运行的旧 Runtime 调用标准 HTTP API:
- 从线程所有权数据库枚举需要迁移的 Thread;
- 分页获取完整
history; - 获取最新
state; - 从 State 和 History 中重建用户可见的 Human/AI 消息;
- 保留标题、产物引用和上传文件引用等必要字段;
- 在新 Runtime 创建同 ID Thread;
- 通过
update_state写入 Seed State。
这里的目标不是复刻每一步历史执行轨迹,而是保留四种产品能力:
- 用户能看到原来的主要对话历史;
- 用户可以继续在同一个 Thread 中对话;
- 会话标题和预览仍能恢复;
- 分享快照所需的可见消息仍然存在。
这是一种"按产品语义迁移",而不是"按存储格式迁移"。
5.3 为什么同时读取 State 和 History
只读取最新 State 不一定够,因为某些线程的最新消息快照可能经过裁剪,完整可见历史仍然存在于历史 Checkpoint 中。
只读取 History 也不够,因为标题、产物引用、上传文件引用等字段通常以最新 State 为准。
因此迁移逻辑需要合并两类信息:
text
Seed State
= 用户可见历史消息
+ 最新标题和必要元数据
+ 产物与上传文件引用
文件引用本身不等于文件内容。迁移 Thread State 时,还必须确保底层用户文件存储没有随着发布目录被清空或迁走。
5.4 幂等与失败留痕
导入时不能无条件覆盖目标 Thread:
- Thread 不存在时创建;
- Thread 已存在但没有消息时允许写入;
- Thread 已有消息时默认跳过;
- 只有显式指定
force才允许强制处理; - 每个失败 Thread 单独记录错误;
- 最终输出
imported / skipped / failures报告。
真实 Cutover 中确实出现过部分 Thread 导出或导入失败。因此不能只看"迁移脚本退出码为 0",必须检查汇总数字和失败明细。
六、可回滚发布:旧 Runtime 和新 Runtime 短暂并存
Checkpoint 迁移不是一次普通代码发布,它同时依赖代码、数据库、环境配置和旧状态目录。
我们采用分阶段 Cutover:
几个关键原则:
6.1 不删除旧状态目录
第一次切换时保留旧 .langgraph_api,即使新方案写入 PostgreSQL,也不立刻清理旧 Pickle。回滚需要的不只是旧代码,还包括旧版本能理解的旧状态。
6.2 回滚必须切回旧代码
新版本已经把 PostgreSQL Checkpointer 作为启动依赖。出现问题时,不能只删除连接配置后继续运行新版本,而应切回迁移前 Release。
6.3 代码和配置要按正确顺序到位
真实发布中出现过"新代码先上线、数据库配置后补"的顺序错误,导致新 Release 无法启动。补救办法是临时在备用端口启动旧 Release,仅用于导出历史,然后再恢复新 Runtime。
这类问题让我意识到:发布文档不是代码的附属品。对涉及状态的改造,Cutover 和回滚本身就是功能的一部分。
七、为什么必须增加深健康检查
浅健康检查只能回答:进程是否存在、HTTP 是否能响应。
但 Checkpoint 链路真正依赖的是:
text
Gateway
→ LangGraph Thread API
→ Checkpointer
→ PostgreSQL
后续补充的内部深健康探针同时检查:
- 新建 PostgreSQL 连接并执行轻量
select 1; - 创建或复用固定的探针 Thread;
- 调用真实 Thread
state接口; - 调用真实 Thread
history接口; - 任意一项失败则返回不健康状态。
这个接口只供服务器本机和部署脚本使用,不对公网暴露;错误响应也不返回数据库 URI、密码或完整 Traceback。
它解决的是一个非常具体的盲区:数据库可能已经恢复,但 Runtime 仍持有失效连接。此时单独执行一次新连接 select 1 成功还不够,必须再走一遍 Runtime 当前的 state/history 读链路。
八、性能验证:不是"换了数据库,感觉更快"
8.1 隔离对照实验
为了避免直接影响正式测试服务,我们分别启动两组独立 Runtime:
- 旧组:本地 Pickle Checkpointer;
- 新组:
AsyncPostgresSaver; - 两组使用不同端口和运行目录;
- 都写入 6 轮中长对话;
- 每条消息额外附加
1024 bytesPayload; - 测量 LangGraph 和 Gateway 整棵进程树的 RSS;
- 分别执行写入、读取 State、读取 History 和生成分享快照。
结果如下:
| 场景 | Pickle | PostgreSQL | 结果 |
|---|---|---|---|
| 空闲基线 RSS | 1398.26 MB |
324.29 MB |
新组基线低约 1073.97 MB |
| 写入一轮中长对话 | 1101.35 MB |
28.40 MB |
从 GB 级降到几十 MB |
| 读取当前 State | 10.05 MB |
0.52 MB |
读取峰值明显下降 |
| 读取 History | 7.54 MB |
1.09 MB |
历史查询更平稳 |
| 生成分享快照 | 648.03 MB |
1.06 MB |
分享路径收益最明显 |
这里的数据是"动作期间相对基线的 RSS 增量",不是数据库大小,也不是单条消息大小。
进一步拆分进程后,主要差异集中在 LangGraph Runtime:
- 写入场景中,LangGraph 增量约
1100.32 MB → 27.63 MB; - 分享场景中,LangGraph 增量约
647.26 MB → 0.29 MB。
这说明优化命中的主要是 Checkpoint 相关的读取和持久化路径,而不是 Gateway 的偶然波动。
8.2 正式测试环境真实操作采样
隔离 Benchmark 只能说明方向,因此又在正式测试环境做了一轮只读采样,不重启服务、不修改正式配置。
采样间隔为 0.2s,用户依次执行写入、刷新 State、加载 History 和生成分享快照:
| 阶段 | Runtime 增量 | PostgreSQL 增量 | 总增量 |
|---|---|---|---|
| 写入新对话 | 6.184 MB |
1.027 MB |
6.953 MB |
| 刷新 State | 2.059 MB |
21.906 MB |
18.817 MB |
| 加载 History | 0.000 MB |
0.000 MB |
0.000 MB |
| 生成分享快照 | 0.000 MB |
2.199 MB |
2.199 MB |
整体总基线约 1509.039 MB,总峰值约 1531.348 MB。常见业务操作没有再复现旧方案下的 Runtime 内存放大。
8.3 数据应该怎样解读
这些数字能证明:在当时的代码版本、数据规模和部署形态下,PostgreSQL Checkpointer 显著降低了 Checkpoint 路径的 Runtime 内存峰值。
但它们不能证明:
- PostgreSQL 在任何数据规模下都一定更快;
- 数据库自身没有缓存和内存成本;
- 换后端后就不需要历史保留策略;
- 所有状态膨胀问题都已经消失。
LangGraph 官方文档也提醒,长对话的 Checkpoint 会持续增长,需要考虑清理或保留策略。存储后端迁移解决的是当前最突出的 Runtime 内存问题,不等于可以无限保存所有历史。
九、测试矩阵:我最后验证了什么
这类改造不能只测"能不能启动"。最终测试覆盖了五层:
| 层次 | 主要验证内容 |
|---|---|
| 配置层 | 新配置优先级、旧配置兼容、缺失配置时明确失败 |
| Schema 层 | 启动前确实调用异步 setup() |
| Runtime 层 | LangGraph 配置加载自定义 Checkpointer |
| 迁移层 | 可见消息重建、元数据保留、已有消息默认跳过 |
| 产品层 | State、History、继续对话、分享快照仍然可用 |
| 运维层 | Cutover、失败报告、回滚和深健康检查 |
还专门覆盖了几个容易漏掉的边界:
- History 分页超过单页上限后继续读取;
- History 游标没有推进时立即失败,避免死循环;
- State 已经包含完整可见消息时不重复重建;
- 迁移后的目标 Thread 已有消息时默认跳过;
- 健康检查失败时不泄露数据库连接信息;
- PostgreSQL 正常但 Runtime State 接口失败时仍返回 503。
十、这次迁移中最重要的五个认识
10.1 Checkpoint 和 Thread Registry 是两套数据
Checkpoint 迁入 PostgreSQL,不代表 LangGraph dev 的 Thread/Run Registry 也自动迁入 PostgreSQL。前一篇会话目录丢失事故,正是这个边界没有被充分认识造成的。
10.2 迁移的是业务能力,不一定是全部底层历史
用户真正关心的是能看到历史、能继续聊天、标题和产物还在。为了复制所有内部 Checkpoint 而承担高版本兼容风险,不一定值得。
10.3 备份必须和可回滚代码绑定
只有数据备份,没有能读取它的旧 Release,仍然不能完成可靠回滚。
10.4 健康检查要覆盖真实依赖
端口存活、HTTP 200、数据库 select 1 都只是局部信号。关键业务依赖 State 和 History,就应该直接探测这两条链路。
10.5 性能优化必须有对照实验
如果只看切换后的单次内存值,很难判断变化来自 Checkpointer、Gateway、模型请求还是操作系统缓存。独立端口、相同数据、相同行为、同一采样口径,才能让结果可解释。
十一、面试复盘速记
如果面试官问这次改造,可以按下面顺序回答:
- 为什么迁移? 本地 Pickle 在长会话写入、历史读取和分享快照时出现明显 Runtime 内存放大。
- 为什么选 PostgreSQL? LangGraph 有官方 Postgres Checkpointer,适合持久化、长时间运行的 Workflow 和 Agent,也能把状态从发布目录中拆出来。
- 为什么不用直接复制 Pickle? 二进制结构与 Runtime 版本耦合,难验证、难定位失败,因此按产品语义迁移 State 和可见 History。
- 旧数据怎么保留? 旧 Runtime 继续提供读取能力,迁移脚本分页导出 State/History,再向新 Runtime 写入 Seed State。
- 如何避免重复覆盖? 默认跳过已经有消息的目标 Thread,强制覆盖必须显式开启,并输出逐 Thread 报告。
- 如何发布? 先备份和导出,再准备 PostgreSQL、执行 Schema Setup、启动新 Runtime、导入、验收;失败则切回旧 Release 和旧状态目录。
- 如何证明有效? 做 Pickle/PostgreSQL 隔离对照,并在真实测试环境采样常见用户动作。
- 还有什么未解决? Checkpoint 仍会增长,需要后续保留策略;Thread Registry 仍是独立链路;高可用数据库和连接恢复也需要继续治理。
十二、总结
这次工作让我真正理解了 Agent 持久化改造的边界:
换存储后端只是第一步,真正困难的是决定迁什么、如何切换、失败后怎么退,以及怎样证明新方案确实解决了问题。
最终我们没有追求"完整复制所有内部状态",而是选择了更可控的路径:使用官方 PostgreSQL Checkpointer 保存新状态,通过旧 Runtime 导出可见历史和最新 State,保留回滚能力,再用真实数据验证内存收益。
这套方法不只适用于 LangGraph。任何有状态 Agent 系统在更换存储后端时,都应该先回答四个问题:
- 哪些状态是用户真正依赖的?
- 哪些内部状态可以安全重建?
- 切换失败后如何恢复?
- 用什么指标证明迁移值得?