作者:互联网 时间: 2026-07-22 08:16:10

我们原来使用本地 Pickle 文件保存 LangGraph Checkpoint。随着会话轮次和 Checkpoint 数量增加,写入新状态、读取历史、生成分享快照等操作会给 LangGraph Runtime 带来明显的内存压力。
最终方案是:
Gateway -> LangGraph Runtime 调用拓扑;AsyncPostgresSaver 接入异步运行时;state/history,重建可继续对话的 Seed State;在测试服务器的对照实验中:
1101 MB 降到 28 MB;648 MB 降到 1 MB;18.8 MB。这次工作的重点不是“把文件换成数据库”,而是同时处理四个问题:状态语义、历史迁移、发布回滚和可验证性。
根据 LangGraph 官方 Checkpointer 文档,Checkpointer 会在每个 super-step 边界保存一次图状态快照。多个 Checkpoint 按 thread_id 组织成一条 Thread,使系统能够继续对话、恢复中断、查看历史状态和进行容错恢复。
大白话理解:
官方文档将 Checkpointer 定位为 Thread 范围内的短期记忆,用于会话连续性、Human-in-the-loop、Time Travel 和故障恢复;Store 则更适合跨 Thread 的长期记忆。两者解决的问题不同。
一次简化后的执行过程如下:
Checkpoint 不是“每轮只存一条聊天消息”。一次 Run 可能经过模型节点、工具节点、路由节点和子图,每个 super-step 都可能产生状态快照和节点写入。长会话里,Checkpoint 数量和状态体积都会增长。
这也是为什么“本地只有一个 Pickle 文件”不代表它很轻。文件只是外观,真正影响资源占用的是读取、反序列化、历史遍历和状态复制方式。
旧方案能工作,但逐渐暴露出三个问题。
会话恢复、历史查询和分享快照都不只读取最后一条消息:
state 要恢复最新图状态;history 要遍历多个历史快照;在当时的本地 Pickle 实现和部署形态下,这些操作会触发大量对象反序列化,内存峰值明显高于实际业务数据大小。
发布目录切换、工作目录变化、异常退出和多进程误启动,都可能让 Runtime 读到不同的状态目录,或者让运维人员难以判断哪个文件才是权威数据。
PostgreSQL 不能自动解决所有并发和状态语义问题,但它至少把 Checkpoint 从发布目录和单机文件中拆了出来,使存储位置、连接方式和数据表更明确。
后续一次事故中,系统包自动升级导致 PostgreSQL 重启。LangGraph HTTP 进程仍然存活,/ok 也返回 200,但进程内原有的数据库连接已经关闭,真实的 state/history 请求持续失败。
这件事说明:
LangGraph 官方提供 langgraph-checkpoint-postgres,用于把 LangGraph State 持久化到 PostgreSQL。官方 README 特别强调:第一次使用必须执行 setup() 创建表和索引。
项目中的接入做了三件事:
AsyncPostgresSaver.from_conn_string() 创建异步 Checkpointer;下面是脱敏后的核心逻辑:
复制代码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 中保存的是 Runtime 内部对象和序列化状态。直接回灌存在几个问题:
因此,我们没有做“逐 Checkpoint 二进制复制”,而是迁移产品真正需要的语义。
迁移脚本通过仍在运行的旧 Runtime 调用标准 HTTP API:
history;state;update_state 写入 Seed State。这里的目标不是复刻每一步历史执行轨迹,而是保留四种产品能力:
这是一种“按产品语义迁移”,而不是“按存储格式迁移”。
只读取最新 State 不一定够,因为某些线程的最新消息快照可能经过裁剪,完整可见历史仍然存在于历史 Checkpoint 中。
只读取 History 也不够,因为标题、产物引用、上传文件引用等字段通常以最新 State 为准。
因此迁移逻辑需要合并两类信息:
复制代码Seed State
= 用户可见历史消息
+ 最新标题和必要元数据
+ 产物与上传文件引用
文件引用本身不等于文件内容。迁移 Thread State 时,还必须确保底层用户文件存储没有随着发布目录被清空或迁走。
导入时不能无条件覆盖目标 Thread:
force 才允许强制处理;imported / skipped / failures 报告。真实 Cutover 中确实出现过部分 Thread 导出或导入失败。因此不能只看“迁移脚本退出码为 0”,必须检查汇总数字和失败明细。
Checkpoint 迁移不是一次普通代码发布,它同时依赖代码、数据库、环境配置和旧状态目录。
我们采用分阶段 Cutover:
几个关键原则:
第一次切换时保留旧 .langgraph_api,即使新方案写入 PostgreSQL,也不立刻清理旧 Pickle。回滚需要的不只是旧代码,还包括旧版本能理解的旧状态。
新版本已经把 PostgreSQL Checkpointer 作为启动依赖。出现问题时,不能只删除连接配置后继续运行新版本,而应切回迁移前 Release。
真实发布中出现过“新代码先上线、数据库配置后补”的顺序错误,导致新 Release 无法启动。补救办法是临时在备用端口启动旧 Release,仅用于导出历史,然后再恢复新 Runtime。
这类问题让我意识到:发布文档不是代码的附属品。对涉及状态的改造,Cutover 和回滚本身就是功能的一部分。
浅健康检查只能回答:进程是否存在、HTTP 是否能响应。
但 Checkpoint 链路真正依赖的是:
复制代码Gateway
→ LangGraph Thread API
→ Checkpointer
→ PostgreSQL
后续补充的内部深健康探针同时检查:
select 1;state 接口;history 接口;这个接口只供服务器本机和部署脚本使用,不对公网暴露;错误响应也不返回数据库 URI、密码或完整 Traceback。
它解决的是一个非常具体的盲区:数据库可能已经恢复,但 Runtime 仍持有失效连接。此时单独执行一次新连接 select 1 成功还不够,必须再走一遍 Runtime 当前的 state/history 读链路。
为了避免直接影响正式测试服务,我们分别启动两组独立 Runtime:
AsyncPostgresSaver;1024 bytes Payload;结果如下:
| 场景 | 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:
1100.32 MB → 27.63 MB;647.26 MB → 0.29 MB。这说明优化命中的主要是 Checkpoint 相关的读取和持久化路径,而不是 Gateway 的偶然波动。
隔离 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 内存放大。
这些数字能证明:在当时的代码版本、数据规模和部署形态下,PostgreSQL Checkpointer 显著降低了 Checkpoint 路径的 Runtime 内存峰值。
但它们不能证明:
LangGraph 官方文档也提醒,长对话的 Checkpoint 会持续增长,需要考虑清理或保留策略。存储后端迁移解决的是当前最突出的 Runtime 内存问题,不等于可以无限保存所有历史。
这类改造不能只测“能不能启动”。最终测试覆盖了五层:
| 层次 | 主要验证内容 |
|---|---|
| 配置层 | 新配置优先级、旧配置兼容、缺失配置时明确失败 |
| Schema 层 | 启动前确实调用异步 setup() |
| Runtime 层 | LangGraph 配置加载自定义 Checkpointer |
| 迁移层 | 可见消息重建、元数据保留、已有消息默认跳过 |
| 产品层 | State、History、继续对话、分享快照仍然可用 |
| 运维层 | Cutover、失败报告、回滚和深健康检查 |
还专门覆盖了几个容易漏掉的边界:
Checkpoint 迁入 PostgreSQL,不代表 LangGraph dev 的 Thread/Run Registry 也自动迁入 PostgreSQL。前一篇会话目录丢失事故,正是这个边界没有被充分认识造成的。
用户真正关心的是能看到历史、能继续聊天、标题和产物还在。为了复制所有内部 Checkpoint 而承担高版本兼容风险,不一定值得。
只有数据备份,没有能读取它的旧 Release,仍然不能完成可靠回滚。
端口存活、HTTP 200、数据库 select 1 都只是局部信号。关键业务依赖 State 和 History,就应该直接探测这两条链路。
如果只看切换后的单次内存值,很难判断变化来自 Checkpointer、Gateway、模型请求还是操作系统缓存。独立端口、相同数据、相同行为、同一采样口径,才能让结果可解释。
如果面试官问这次改造,可以按下面顺序回答:
这次工作让我真正理解了 Agent 持久化改造的边界:
最终我们没有追求“完整复制所有内部状态”,而是选择了更可控的路径:使用官方 PostgreSQL Checkpointer 保存新状态,通过旧 Runtime 导出可见历史和最新 State,保留回滚能力,再用真实数据验证内存收益。
这套方法不只适用于 LangGraph。任何有状态 Agent 系统在更换存储后端时,都应该先回答四个问题: