AI智能体项目实战记录(17):LangGraph 记忆、会话历史持久化之 PostgreSQL 选型说明及简单安装
1. 引言
1.1 现状:三份存储各管一摊
目前项目的持久化大致是这样:
| 数据 | 当前存储 | 作用 |
|---|---|---|
| LangGraph 图状态(多轮记忆) | checkpoints.sqlite | 按thread_id保存messages等状态 |
| 会话历史(左侧列表/详情) | conversations.sqlite | 按user_id查询会话列表与消息 |
| 登录鉴权、调用日志 | MySQL | 用户、角色、菜单、ai_invoke_log等 |
| 知识库向量 | Milvus | 文档切片向量检索 |
单机、低并发时这套组合跑得很稳。但它有三个隐患:
- 多实例记忆分裂:每个副本一份 SQLite,同一个
thread_id看到的历史不一样; - 备份分散:两个 SQLite 文件 + MySQL + Milvus,恢复时要对四个地方;
- SQLite 写锁:多 worker 或 checkpoint 写入频繁时容易出现
database is locked。
1.2 目标
引入一个 PostgreSQL,先把最需要并发与可靠性的两类数据迁过去:
- LangGraph checkpoint(图状态、多轮记忆);
- 会话历史(
conversations)。
后续如果愿意,再把登录鉴权的 MySQL 也迁到 PG,最终形成:
PostgreSQL:业务库 + LangGraph checkpoint + 会话历史 Milvus: 知识库向量(暂不迁移)1.3 结论先行
| 问题 | 结论 |
|---|---|
| LangGraph 记忆用 PG 合不合适 | 合适,有官方AsyncPostgresSaver,多实例友好(适配代码见第 18 篇) |
| 会话历史迁 PG 合不合适 | 合适,表结构简单,SQL 改造成本低 |
| 登录鉴权 MySQL 迁 PG 合不合适 | 可以,但建议单独排期、用 pgloader + 灰度,不要和本次一起动 |
| 用 PG 替换 Milvus 做向量库合不合适 | 能力上可以,本项目不建议,性能与资源不匹配(详见第 6 章) |
2. 选型:为什么是 PostgreSQL
2.1 候选对比
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| SQLite | 零依赖、单文件、接入最简单 | 多实例记忆分裂、写锁竞争、无网络访问 | 仅适合单机开发 |
| MySQL | 团队熟悉、现有业务已在用 | LangGraph没有官方 checkpointer,需社区实现或自研 | 不选 |
| PostgreSQL | 官方AsyncPostgresSaver、ACID、JSONB、扩展丰富(pgvector/pg_trgm 等) | 多一套实例要运维 | 选它 |
| Redis | 快、适合短期记忆、TTL 方便 | 持久化弱于关系库、内存成本高、审计查询不方便 | 可作为短期缓存,不作为唯一存储 |
| MongoDB | 文档模型天然适合消息 | LangGraph 适配非官方、团队不熟、事务弱于 PG | 不选 |
2.2 LangGraph 官方适配
LangGraph 官方维护的 checkpointer 只有两个:
langgraph-checkpoint-sqlite(AsyncSqliteSaver)langgraph-checkpoint-postgres(AsyncPostgresSaver)
也就是说,想从 SQLite 平滑升级到网络数据库,PG 是唯一官方路径。MySQL 需要自己实现BaseCheckpointSaver,要处理 checkpoint/writes/blob 的 upsert、父子关系、序列化与并发,正确性风险很高。
具体的依赖安装、AsyncPostgresSaver初始化、配置切换与回滚,见第 18 篇。
2.3 为什么不是继续用 SQLite
checkpoints.sqlite是本地文件,多副本部署时无法共享;- 同一台机器多 worker 共享一个文件,写操作串行,容易出现锁等待;
- 备份只能拷文件,无法按
thread_id做在线清理; - checkpoint 每个 super-step 都写一次,文件增长很快,长期运行需要手工
VACUUM。
2.4 PG 的额外收益
- JSONB:会话消息可以直接存 JSONB,既能查又能索引;
- 扩展性:后续要全文检索可以上
pg_trgm/pg_jieba,要向量可以上pgvector; - ACID + MVCC:多实例并发写不同
thread_id互不阻塞; - 运维标准:
pg_dump/pg_restore/ WAL 归档 / 监控都有成熟方案。
3. 安装:docker-compose(生产环境或者多节点实例不建议使用)
3.1 目录结构
chat_py_server/ └── deploy/ └── postgres/ ├── docker-compose.yml └── init/ # 可选:初始化 SQL3.2 docker-compose.yml
services:postgres:image:postgres:16-bullseye# 兼容老内核/老 Docker;默认 postgres:16 在部分环境会报 popen failurecontainer_name:postgresrestart:unless-stoppedenvironment:POSTGRES_USER:pguserPOSTGRES_PASSWORD:"123456"# 开发记得改密码POSTGRES_DB:pgdbTZ:Asia/ShanghaiPGDATA:/var/lib/postgresql/dataports:-"5432:5432"volumes:-pgdata:/var/lib/postgresql/data# - ./init:/docker-entrypoint-initdb.d # 有初始化sql再打开# ========== 资源限制(整机4G,开发环境) ==========deploy:resources:limits:cpus:'1.0'memory:1536Mreservations:cpus:'0.2'memory:512M# ========== PG 运行参数,适配小内存 4G服务器 ==========command:>postgres -c shared_buffers=384MB -c effective_cache_size=1152MB -c work_mem=16MB -c maintenance_work_mem=64MB -c max_connections=100 -c checkpoint_completion_target=0.9 -c wal_buffers=16MB -c default_statistics_target=100 -c idle_in_transaction_session_timeout=30000 -c autovacuum=on -c autovacuum_max_workers=2 -c autovacuum_naptime=60shealthcheck:test:["CMD-SHELL","pg_isready -U pguser -d pgdb"]interval:10stimeout:5sretries:5volumes:pgdata:3.3 启动与验证
cddeploy/postgresdockercompose up-ddockercomposepsdockercompose logs-fpostgres进入容器验证:
dockerexec-itpostgres psql-Upguser-dpgdb-c"select version();"dockerexec-itpostgres psql-Upguser-dpgdb-c"\l"健康检查通过后,docker compose ps的STATUS会显示healthy。
3.4 关键参数解释
| 参数 | 作用 | 本项目取值说明 |
|---|---|---|
shared_buffers | PG 自己用的共享缓存 | 384MB,约占内存 25% |
effective_cache_size | 告诉优化器系统可用缓存 | 1152MB,引导索引选择 |
work_mem | 单次排序/哈希操作内存 | 16MB,避免复杂查询打爆内存 |
maintenance_work_mem | 维护操作(VACUUM/建索引)内存 | 64MB,小内存机器够用;以后建向量索引要调大 |
max_connections | 最大连接数 | 100,够多个 worker 池使用 |
checkpoint_completion_target | checkpoint 写盘平滑度 | 0.9,减少 IO 抖动 |
wal_buffers | WAL 写缓冲 | 16MB |
idle_in_transaction_session_timeout | 事务空闲超时 | 单位是毫秒,30000 = 30 秒,防止连接挂着不提交 |
autovacuum* | 自动清理死元组 | 开启,2 个 worker,60 秒一次 |
3.5 安全与运维注意
- 密码:
123456只是开发示例,生产必须改成强密码,并通过环境变量或 secret 注入; - 端口:生产不要直接把 5432 暴露到公网,建议
127.0.0.1:5432:5432或只在内网网段开放; - 数据卷:
pgdata必须持久化,容器重建不能丢; - 资源限制:
deploy.resources在 Docker Compose v2 生效;老版本请改用mem_limit/cpus; - 时区:容器设置了
TZ=Asia/Shanghai,业务字段建议统一用TIMESTAMPTZ。
3.6 启动报错:popen failure/initdb找不到postgres
如果docker compose up -d后容器反复重启,日志出现:
popen failure: Operation not permitted initdb: error: program "postgres" is needed by initdb but was not found in the same directory as "/usr/lib/postgresql/16/bin/initdb"原因:不是镜像里真的缺postgres二进制,而是initdb通过popen去执行同目录的postgres --version时被系统拒绝了。常见触发条件:
- Docker Engine / libseccomp 版本较老,默认 seccomp 策略拦截了新 glibc 使用的
clone3,返回EPERM; - 宿主机是受限容器(LXC/OpenVZ)或开启了额外安全策略;
- 极低内存/进程数限制下,
fork/exec失败(有的环境报Cannot allocate memory)。
首选修复:换-bullseye镜像
官方默认的postgres:16基于 Debian bookworm,glibc 较新;postgres:16-bullseye基于 Debian 11,在旧内核/旧 Docker 上更兼容。本文的 compose 已改为:
image:postgres:16-bullseye首次启动失败后,数据卷里可能留下半初始化的目录,切换镜像前先清理:
# 确认没有重要数据后再执行dockercompose down-vdockercompose up-d如果仍然报错,按顺序排查:
升级 Docker Engine / Docker Desktop 与 libseccomp;
确认镜像架构与宿主机一致:
dockerinfo--format'{{.Architecture}}'uname-m临时放宽 seccomp(仅用于验证,不建议长期):
security_opt:-seccomp:unconfined暂时去掉
deploy.resources限制,排除内存/进程数导致的fork失败;重新拉取镜像:
docker pull postgres:16-bullseye。
4. 与 MySQL 对比
4.1 功能与运维对比
| 维度 | MySQL 8 | PostgreSQL 16 |
|---|---|---|
| LangGraph checkpointer | 无官方实现 | 官方AsyncPostgresSaver |
| JSON 支持 | JSON(部分索引需虚拟列) | JSONB,可直接建 GIN 索引 |
| Upsert | ON DUPLICATE KEY UPDATE | ON CONFLICT ... DO UPDATE |
| 自增主键 | AUTO_INCREMENT | GENERATED ... AS IDENTITY/serial |
| 布尔 | TINYINT(1) | boolean |
| 大小写 | 表名受系统影响,可能大小写敏感 | 未加引号的标识符统一小写 |
| 并发模型 | InnoDB MVCC | MVCC + 更丰富的锁粒度 |
| 扩展 | 插件生态偏商业 | pgvector、pg_trgm、pg_jieba等扩展丰富 |
| 运维 | 成熟,团队更熟 | 成熟,标准工具链 |
4.2 性能上谁更好
对本项目这种 OLTP + 小数据量 + JSON 读写的场景,两者差距远小于"用错索引/连接池"带来的差距:
ai_invoke_log是纯插入,MySQL 和 PG 都能轻松处理;- 会话历史是"按
user_id查列表 + 按thread_id读 JSON",PG 的JSONB+ 复合索引更顺手; - checkpointer 是频繁 upsert + blob 读写,PG 的 MVCC 与
ON CONFLICT更匹配。
结论:不是因为 MySQL 性能不行,而是因为 LangGraph 官方只适配了 PG。
4.3 什么情况下不建议迁 MySQL
- 现有 MySQL 已经稳定,团队没有 PG 运维经验;
- 登录鉴权涉及用户、角色、菜单、日志多张表,迁移窗口和回滚方案成本高;
- 只是为了"技术统一",没有明确的并发或多实例诉求。
如果确实要迁,建议单独排期,步骤:
- 建表:把 MySQL DDL 转成 PG(
TINYINT→SMALLINT/BOOLEAN,DATETIME→TIMESTAMPTZ,反引号 → 双引号); - 导数据:用
pgloader做 MySQL → PG 的一次性迁移,或导出 CSV 再COPY; - 换驱动:
db_service._build_url()从mysql+pymysql换成postgresql+psycopg,去掉charset参数; - 改 SQL 方言:
ON DUPLICATE KEY、LIMIT offset,size、IFNULL、日期函数等; - 灰度/双读:先读 PG、写双份,观察一段时间再切写;
- 保留回滚:旧 MySQL 至少保留一个发布周期。
db_service的改动示意:
def_build_url()->URL:returnURL.create("postgresql+psycopg",username=setting.pg_user,password=setting.pg_password,host=setting.pg_host,port=setting.pg_port,database=setting.pg_database,)4.4 迁移时的常见坑
| 坑 | 说明 |
|---|---|
| 表名大小写 | PG 未加引号会转小写,Java 侧如果用了驼峰表名要统一 |
TINYINT(1) | 迁成boolean后,Java/Python 映射要调整 |
AUTO_INCREMENT | 改用GENERATED BY DEFAULT AS IDENTITY |
| 反引号 | PG 用双引号,且不建议在 DDL 里使用引号 |
ON DUPLICATE KEY | 改ON CONFLICT,冲突列必须有唯一索引 |
| 日期函数 | DATE_FORMAT→to_char,DATE_SUB→interval |
| 排序规则 | 中文排序/大小写行为与 MySQL 不同,需要验证 |
5. 能不能用 PG 替换 Milvus 做向量库
5.1 能力上可以
PG 通过pgvector扩展可以做向量检索:
CREATEEXTENSIONIFNOTEXISTSvector;CREATETABLEdoc_chunks(idVARCHAR(64)PRIMARYKEY,nameVARCHAR(1024),doc_strTEXT,vector vector(768));CREATEINDEXidx_doc_chunks_vectorONdoc_chunksUSINGhnsw(vector vector_cosine_ops);当前集合是id / name / doc_str / vector、768 维、COSINE、DISKANN,迁到 pgvector 用vector(768)+vector_cosine_ops可以对上。
5.2 但本项目不建议换,原因如下
(1)内存模型不匹配
- pgvector 的 HNSW 索引基本常驻内存;
- 1M 条 768 维 float32 向量,原始数据约 3GB,加上索引与行开销更大;
- 当前 PG 容器限制是1536MB,
shared_buffers=384MB,连索引都放不下; - Milvus 当前用的是DISKANN(磁盘索引),本身就是为"大于内存"的数据设计的。
(2)索引构建资源不够
当前maintenance_work_mem=64MB。建 HNSW 索引远不够,需要临时调到几百 MB 甚至 GB 级,且构建期间会占满 CPU,影响 checkpoint 写入与业务查询。
(3)同库混跑会互相拖累
如果 PG 同时承担:
- LangGraph checkpoint 写入;
- 会话历史读写;
- 向量检索与索引构建;
那么一次索引重建或大批量导入,就可能让对话请求的 P99 明显抖动。专业向量库的价值就在于资源隔离。
(4)规模与并发
| 数据量 | pgvector | Milvus |
|---|---|---|
| < 100 万 chunk | 很轻松 | 轻松 |
| 100 万 ~ 1000 万 | 需要调参、分区、独立实例 | 轻松 |
| > 1000 万 | 吃紧 | 合适 |
| > 1 亿 | 不建议 | 分布式强项 |
(5)高级能力差距
- Milvus 支持多种索引、分区键、标量过滤、稀疏向量/混合检索;
- pgvector 的过滤是 SQL
WHERE,和向量索引组合时可能出现"先过滤后检索"或"先检索后过滤"的选择问题,召回与性能需要仔细调; - 中文全文检索 PG 还要额外装分词扩展。
(6)迁移成本不低
不是改连接串,而是:
- 重写
milvus_service.search / query / create_collection / delete_by_filter; - Milvus 的
filter表达式翻译成 SQL; - 从 Milvus 导出向量与字段,再写入 PG;
- 重建索引,并用真实语料验证 recall@5 与 P95 延迟。
5.3 什么时候才值得换
- 知识库规模 < 100 万 chunk;
- QPS 不高(几十以内);
- 想彻底去掉 Milvus,减少一套 etcd + MinIO 的运维;
- 能接受把 PG 的内存调大(至少 4~8GB),并接受索引构建期间的影响;
- 做了基准测试,recall 与延迟达标。
5.4 建议
PG 只做 checkpoint + 业务 + 会话历史,向量继续用 Milvus。这是当前资源与规模下最稳的组合。等数据量和团队运维能力都到位,再评估 pgvector 或 pgvectorscale。
6. 一次完整落地步骤
① 启动 PG docker compose up -d 确认 healthy ② 安装 PG 依赖并改造 checkpointer 详见第 18 篇《LangGraph 适配 PostgreSQL》 ③ 改配置 CHECKPOINT_BACKEND=postgres PG_HOST / PG_PORT / PG_USER / PG_PASSWORD / PG_DATABASE ④ 启动服务 观察日志:已启用 PostgreSQL 记忆持久化 ⑤ 验证 一轮对话 → 重启 → 同 thread_id 继续追问 ⑥ 迁会话历史(可选) 建 conversations 表 → 改 conversation_service 内部实现 ⑦ 清理与备份 定期删除旧 thread 的 checkpoint;pg_dump 备份8. 运维要点
8.1 备份与恢复
# 逻辑备份dockerexecpostgres pg_dump-Upguser-dpgdb-Fc>pgdb_$(date+%F).dump# 恢复dockerexec-ipostgres pg_restore-Upguser-dpgdb<pgdb_2026-01-01.dump# 数据卷备份(停机或低峰)dockerrun--rm-vpgdata:/var/lib/postgresql/data-v$(pwd):/backup alpine\tarczf /backup/pgdata.tar.gz-C/var/lib/postgresql/data.8.2 checkpoint 清理
checkpoint 的清理方式(adelete_thread/ 按时间清理 / 关联表注意事项)属于 LangGraph 适配的一部分,统一放在第 18 篇说明。
8.3 连接池
- 每个 worker 一份连接池;
- 总连接数 ≈
worker 数 × 池最大连接数,要小于max_connections; - 例:4 worker × 10 = 40,
max_connections=100足够; - 不要把池调得过大,连接本身也占内存。
8.4 监控
-- 当前连接selectstate,count(*)frompg_stat_activitygroupbystate;-- 表膨胀selectrelname,n_live_tup,n_dead_tupfrompg_stat_user_tablesorderbyn_dead_tupdesclimit10;-- 长事务selectpid,now()-xact_startasduration,queryfrompg_stat_activitywherexact_startisnotnullorderbydurationdesc;9. 常见问题
Q1:PG 和 MySQL 能同时存在吗?
可以。推荐做法:
- PG:checkpoint + 会话历史;
- MySQL:登录鉴权 + 调用日志(暂时);
- 等业务稳定后再统一。
Q2:会话历史迁 PG 后,checkpoint 还需要迁吗?
需要,但两件事互相独立。checkpoint 的适配代码、配置切换与回滚见第 18 篇;会话历史只影响前端列表与详情。
Q3:为什么不用 Redis 做会话历史?
Redis 适合短期、可丢失的数据;会话历史要长期保留、按user_id列表查询、按thread_id读详情,关系库更合适。
Q4:登录鉴权迁 PG 要注意什么?
先转 DDL 与数据(pgloader),再换 SQLAlchemy 驱动与 SQL 方言,最后灰度切写;旧 MySQL 至少保留一个发布周期用于回滚。
Q5:用了 PG 之后 SQLite 依赖能删吗?
会话历史目前仍可能使用 SQLite;LangGraph 也保留了CHECKPOINT_BACKEND=sqlite的本地开发回退。建议至少保留一个版本周期。
Q6:conversations.messages和 checkpoint 的messages会重复吗?
会。两者用途不同:前者给前端展示,后者给 LangGraph 做上下文。短期保留两份是可接受的;如果后续要消重,建议只把conversations退化为索引表,不要直接读 checkpoint 内部表。
10. 完整代码汇总
10.1deploy/postgres/docker-compose.yml
services:postgres:image:postgres:16-bullseye# 兼容老内核/老 Docker;默认 postgres:16 在部分环境会报 popen failurecontainer_name:postgresrestart:unless-stoppedenvironment:POSTGRES_USER:pguserPOSTGRES_PASSWORD:"123456"# 开发记得改密码POSTGRES_DB:pgdbTZ:Asia/ShanghaiPGDATA:/var/lib/postgresql/dataports:-"5432:5432"volumes:-pgdata:/var/lib/postgresql/data# - ./init:/docker-entrypoint-initdb.d # 有初始化sql再打开deploy:resources:limits:cpus:'1.0'memory:1536Mreservations:cpus:'0.2'memory:512Mcommand:>postgres -c shared_buffers=384MB -c effective_cache_size=1152MB -c work_mem=16MB -c maintenance_work_mem=64MB -c max_connections=100 -c checkpoint_completion_target=0.9 -c wal_buffers=16MB -c default_statistics_target=100 -c idle_in_transaction_session_timeout=30000 -c autovacuum=on -c autovacuum_max_workers=2 -c autovacuum_naptime=60shealthcheck:test:["CMD-SHELL","pg_isready -U pguser -d pgdb"]interval:10stimeout:5sretries:5volumes:pgdata:10.2 会话历史表结构
CREATETABLEIFNOTEXISTSconversations(thread_idVARCHAR(64)PRIMARYKEY,user_idVARCHAR(64)NOTNULL,titleVARCHAR(200)NOTNULLDEFAULT'',messages JSONBNOTNULLDEFAULT'[]'::jsonb,created_at TIMESTAMPTZNOTNULLDEFAULTnow(),updated_at TIMESTAMPTZNOTNULLDEFAULTnow());CREATEINDEXIFNOTEXISTSidx_conversations_user_idONconversations(user_id,updated_atDESC);11. 小结
- 安装:4G 机器用
postgres:16+ 1.5G 内存限制 + 384MBshared_buffers足够支撑 checkpoint 与会话历史; - 会话历史:表结构从 SQLite 的 TEXT 升级为
JSONB + TIMESTAMPTZ,conversation_service接口不变; - MySQL 对比:不是性能问题,而是官方适配问题;登录鉴权迁 PG 可行,但要单独排期、用 pgloader + 灰度;
- 向量库:pgvector 能力上可以替代 Milvus,但本项目内存与规模不匹配,不建议换,继续用 Milvus 的 DISKANN;
- 最终形态:PG 负责 checkpoint + 业务 + 会话历史,Milvus 负责向量检索,各司其职;
- LangGraph 适配:依赖、
AsyncPostgresSaver初始化、配置切换、回滚与排障,全部放在第 18 篇。