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

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 的长期记忆。两者解决的问题不同。

一次简化后的执行过程如下:

graph LR A[用户输入] --> B[图开始执行] B --> C[节点一完成] C --> D[保存 Checkpoint 一] D --> E[节点二完成] E --> F[保存 Checkpoint 二] F --> G[本轮结束]

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() 创建表和索引。

项目中的接入做了三件事:

  1. 统一解析 PostgreSQL 连接配置;
  2. 使用 AsyncPostgresSaver.from_conn_string() 创建异步 Checkpointer;
  3. 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()

实际启动顺序不是"先启动,报错后再建表",而是:

graph TD A[读取数据库配置] --> B{配置是否存在} B -->|否| C[终止启动并输出明确错误] B -->|是| D[创建 AsyncPostgresSaver] D --> E[执行 setup 初始化 Schema] E --> F{初始化是否成功} F -->|否| G[终止启动] F -->|是| H[启动 LangGraph Runtime]

这个顺序的好处是尽早失败。如果连接串错误、依赖缺失或数据库不可用,发布阶段就能发现,而不是等第一个用户请求到来时才报错。

初始化完成后,实际可以看到 Checkpoint、节点写入、Blob 和迁移记录等相关表。业务代码不直接操作这些表,而是继续通过 LangGraph Checkpointer 接口读写。

五、最关键的取舍:为什么没有直接导入旧 Pickle

这是整个迁移中最需要解释的地方。

5.1 直接复制二进制看似完整,实际上风险最大

旧 Pickle 中保存的是 Runtime 内部对象和序列化状态。直接回灌存在几个问题:

  • 新旧依赖版本可能不同;
  • 内部对象结构可能发生变化;
  • 历史状态中可能包含不再需要的中间字段;
  • 很难验证某个二进制对象是否能在新 Runtime 中安全恢复;
  • 迁移失败时,不容易定位具体是哪一个 Thread、哪一个 Checkpoint 出错。

因此,我们没有做"逐 Checkpoint 二进制复制",而是迁移产品真正需要的语义。

5.2 实际迁移的是可继续使用的 Thread

迁移脚本通过仍在运行的旧 Runtime 调用标准 HTTP API:

  1. 从线程所有权数据库枚举需要迁移的 Thread;
  2. 分页获取完整 history
  3. 获取最新 state
  4. 从 State 和 History 中重建用户可见的 Human/AI 消息;
  5. 保留标题、产物引用和上传文件引用等必要字段;
  6. 在新 Runtime 创建同 ID Thread;
  7. 通过 update_state 写入 Seed State。
graph LR A[旧 Pickle Runtime] -->|读取 state| B[迁移导出器] A -->|分页读取 history| B B --> C[重建可见消息与必要元数据] C --> D[迁移 Bundle] D --> E[新 PostgreSQL Runtime] E --> F[创建同 ID Thread] F --> G[写入 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:

graph TD A[备份旧状态目录] --> B[保持旧 Runtime 可读] B --> C[导出 Thread State 和 History] C --> D[准备 PostgreSQL] D --> E[部署新 Release] E --> F[执行 Schema Setup] F --> G[启动 PostgreSQL Runtime] G --> H[导入迁移 Bundle] H --> I{业务验证是否通过} I -->|是| J[完成切换] I -->|否| K[停止新 Runtime] K --> L[切回旧 Release 和旧状态目录]

几个关键原则:

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

后续补充的内部深健康探针同时检查:

  1. 新建 PostgreSQL 连接并执行轻量 select 1
  2. 创建或复用固定的探针 Thread;
  3. 调用真实 Thread state 接口;
  4. 调用真实 Thread history 接口;
  5. 任意一项失败则返回不健康状态。
graph TD A[触发内部深健康检查] --> B[PostgreSQL 执行 select 1] A --> C[调用 Thread 创建接口] C --> D[读取 State] C --> E[读取 History] B --> F{全部检查是否成功} D --> F E --> F F -->|是| G[返回 Healthy] F -->|否| H[返回 503 和失败项]

这个接口只供服务器本机和部署脚本使用,不对公网暴露;错误响应也不返回数据库 URI、密码或完整 Traceback。

它解决的是一个非常具体的盲区:数据库可能已经恢复,但 Runtime 仍持有失效连接。此时单独执行一次新连接 select 1 成功还不够,必须再走一遍 Runtime 当前的 state/history 读链路。

八、性能验证:不是"换了数据库,感觉更快"

8.1 隔离对照实验

为了避免直接影响正式测试服务,我们分别启动两组独立 Runtime:

  • 旧组:本地 Pickle Checkpointer;
  • 新组:AsyncPostgresSaver
  • 两组使用不同端口和运行目录;
  • 都写入 6 轮中长对话;
  • 每条消息额外附加 1024 bytes Payload;
  • 测量 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、模型请求还是操作系统缓存。独立端口、相同数据、相同行为、同一采样口径,才能让结果可解释。

十一、面试复盘速记

如果面试官问这次改造,可以按下面顺序回答:

  1. 为什么迁移? 本地 Pickle 在长会话写入、历史读取和分享快照时出现明显 Runtime 内存放大。
  2. 为什么选 PostgreSQL? LangGraph 有官方 Postgres Checkpointer,适合持久化、长时间运行的 Workflow 和 Agent,也能把状态从发布目录中拆出来。
  3. 为什么不用直接复制 Pickle? 二进制结构与 Runtime 版本耦合,难验证、难定位失败,因此按产品语义迁移 State 和可见 History。
  4. 旧数据怎么保留? 旧 Runtime 继续提供读取能力,迁移脚本分页导出 State/History,再向新 Runtime 写入 Seed State。
  5. 如何避免重复覆盖? 默认跳过已经有消息的目标 Thread,强制覆盖必须显式开启,并输出逐 Thread 报告。
  6. 如何发布? 先备份和导出,再准备 PostgreSQL、执行 Schema Setup、启动新 Runtime、导入、验收;失败则切回旧 Release 和旧状态目录。
  7. 如何证明有效? 做 Pickle/PostgreSQL 隔离对照,并在真实测试环境采样常见用户动作。
  8. 还有什么未解决? Checkpoint 仍会增长,需要后续保留策略;Thread Registry 仍是独立链路;高可用数据库和连接恢复也需要继续治理。

十二、总结

这次工作让我真正理解了 Agent 持久化改造的边界:

换存储后端只是第一步,真正困难的是决定迁什么、如何切换、失败后怎么退,以及怎样证明新方案确实解决了问题。

最终我们没有追求"完整复制所有内部状态",而是选择了更可控的路径:使用官方 PostgreSQL Checkpointer 保存新状态,通过旧 Runtime 导出可见历史和最新 State,保留回滚能力,再用真实数据验证内存收益。

这套方法不只适用于 LangGraph。任何有状态 Agent 系统在更换存储后端时,都应该先回答四个问题:

  1. 哪些状态是用户真正依赖的?
  2. 哪些内部状态可以安全重建?
  3. 切换失败后如何恢复?
  4. 用什么指标证明迁移值得?

参考资料

相关推荐
早点睡觉1495 小时前
Agent 工程实习复盘 04|长对话越聊越容易崩?Request-only Summary 与 Token 预算治理
agent
武子康6 小时前
GPT-Red:自动化红队如何形成 Agent 安全数据飞轮(4 个闭环 + 6 类评测指标 + 5 类风险误读)
人工智能·openai·agent
阿里云大数据AI技术6 小时前
DataWorks Data Agent 实战课堂(二):一句话搞定数据集成+处理,实现端到端数据流水线
人工智能·agent
像我这样帅的人丶你还7 小时前
MCP + npm:给五年前的老系统接上AI
前端·javascript·agent
牧艺7 小时前
从 Tool Calling 到 MCP Server:把业务能力做成 Agent 可复用接口
agent·全栈·mcp
码上解惑8 小时前
从模型接入到应用运行:智能体开发平台的整体架构设计
人工智能·agent·智能体·spring ai
FakeKesh19 小时前
学了一周Python,我决定手搓一个最小的AI Agent循环(附源码)
agent
程序员秋天9 小时前
用Spring AI实现多轮对话记忆,别再让AI每次都"失忆"
agent·ai编程
Xzh042311 小时前
智能体通信协议
agent