DeepAgents - 使用Postgres作为Checkpoint

前言

在用 deepagents 做 Chatbot 的时候,有个最基本的需求:Agent 得记住上一轮聊了什么。你不能每轮对话都让用户重新自我介绍一遍。

LangGraph / DeepAgents 内置了一个叫 checkpoint 的机制来处理这个事。开发阶段用 MemorySaver 跑跑 demo 问题不大,但一到生产环境------服务重启状态全丢、多实例部署无法共享------就该上 Postgres 了。

本文记录我在一个基于 FastAPI 的项目中,用 langgraph-checkpoint-postgres 做持久化 checkpoint 的过程,顺带聊聊连接池的配置和 PG 服务端 idle 超时带来的坑。

项目依赖精简如下:

复制代码
langgraph-checkpoint-postgres>=3.1.0
psycopg[binary,pool]>=3.3.4
deepagents>=0.4.12
fastapi>=0.135.2

Checkpoint 是什么

在 LangGraph 里,Agent 的执行过程本质上是一个有向图(graph):LLM 调用、tool 执行、条件分支......每一步都可能改变状态。Checkpoint 就是在图的每个节点执行后自动保存的状态快照,包含当前的消息历史、中间变量、以及图走到哪一步了。

你可以把它类比成玩游戏时的存档:打到一半存个档,下次可以从这个存档接着打,而不是从头开始。

在代码层面的体现就是 RunnableConfig 里的 thread_id

python 复制代码
config = RunnableConfig(
    callbacks=[cb],
    configurable={"thread_id": session_id}
)

async for chunk in ai_agent.astream(msg.content, config=config):
    await final_answer.stream_token(chunk)

同一个 thread_id 的消息会写入同一条 checkpoint 链,下次再用这个 thread_id 调 agent 时,LangGraph 会自动从最近的 checkpoint 恢复状态,继续往下跑,用户完全感知不到"重来"。

为什么用 Postgres 而不是 MemorySaver

MemorySaver 把 checkpoint 存在进程内存里,开发调试很方便,一行代码搞定。但问题也很明显:

  1. 服务重启就没了 --- 进程退出,内存释放,所有对话历史清零。
  2. 多实例无法共享 --- 如果你起多个 worker(uvicorn --workers 4),每个进程各有一份 MemorySaver,用户这次请求打到 worker A,下次打到 worker B,上下文就丢了。

Postgres 作为外部持久化存储天然解决了这两个问题。langgraph-checkpoint-postgres 提供了 AsyncPostgresSaver,内部自动管理三张表:checkpoints(状态快照)、checkpoint_writes(写操作记录)、checkpoint_blobs(序列化数据)。

建表只需在启动时调用一次 setup()

python 复制代码
conn_string = (
    f"postgresql://{cfg.PG_USER}:{cfg.PG_PASSWORD}"
    f"@{cfg.PG_HOST}:{cfg.PG_PORT}/{cfg.PG_DB}"
)

async with AsyncPostgresSaver.from_conn_string(conn_string) as temp_saver:
    await temp_saver.setup()

连接池:用 psycopg_pool 的 AsyncConnectionPool

为什么需要连接池

Agent 每次调用 ainvokeastream 都会跟 Postgres 交互多次(读之前的 checkpoint、写新的 checkpoint)。如果每次交互都新建一条数据库连接,三条消息就能把 PG 的 max_connections 打满。

更合理的方式是复用连接------初始化一个连接池,AsyncPostgresSaver 直接从池里拿连接,用完归还。

配置代码

psycopg_poolAsyncConnectionPool 用得最广,配置不复杂:

python 复制代码
from urllib.parse import quote_plus
from psycopg_pool import AsyncConnectionPool

_PG_POOL: AsyncConnectionPool = None

def _init_pg_pool():
    global _PG_POOL
    if not _PG_POOL:
        _PG_POOL = AsyncConnectionPool(
            f"postgresql://{quote_plus(cfg.PG_USER)}:"
            f"{quote_plus(cfg.PG_PASSWORD)}@"
            f"{cfg.PG_HOST}:{cfg.PG_PORT}/{cfg.PG_DB}",
            min_size=cfg.PG_POOL_MIN_SIZE,   # 保持的最小连接数
            max_size=cfg.PG_POOL_MAX_SIZE,   # 峰值最大连接数
            open=False,                       # 延迟打开,避免阻塞启动
        )

async def get_pg_pool() -> AsyncConnectionPool:
    global _PG_POOL
    if not _PG_POOL:
        _init_pg_pool()
    await _PG_POOL.open()  # 第一次调用时才真正建立连接
    return _PG_POOL

几个配置的点:

  • min_size / max_size :池里始终保持 min_size 条空闲连接待命;并发上来时最多扩到 max_size。实际项目里设 min=4, max=10 就够了,视并发量调整。
  • open=False :创建连接池对象时不立刻连数据库。因为 _init_pg_pool() 是在模块 import 时就调用的(懒加载单例),如果 open=True,import 阶段就会建连------万一数据库还没起来,整个应用就起不来了。
  • URL 编码 :用户名密码里如果有特殊字符,quote_plus 防注入。

AsyncPostgresSaver 复用连接池

拿到连接池后,直接传给 AsyncPostgresSaver

python 复制代码
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver

_PG_CHECKPOINTER: AsyncPostgresSaver = None

async def init_checkpointer(
    pg_pool: AsyncConnectionPool, is_setup: bool = False
) -> None:
    global _PG_CHECKPOINTER
    if not _PG_CHECKPOINTER:
        _PG_CHECKPOINTER = AsyncPostgresSaver(conn=pg_pool)

    if is_setup:
        # setup 时用临时连接建表,避免占用池里的连接
        conn_string = (...)
        async with AsyncPostgresSaver.from_conn_string(conn_string) as temp_saver:
            await temp_saver.setup()

注意 setup() 单独用了一个 from_conn_string 的临时连接,没有直接用池里的连接。

PG Server 端 idle 超时的坑

问题场景

Postgres 有两个容易跟连接池打架的超时参数:

  • idle_session_timeout (PG 14+):连接空闲超过 N 秒,直接 kill。不区分是否在事务中 ,只要没跑查询就算 idle。这个杀伤力最大------连接池里 min_size 维持的那几条空闲连接,过一会儿就全被 PG 杀了。
  • idle_in_transaction_session_timeout:连接处在事务中但啥也没干超过 N 秒,kill。比上面那个温和一些,只干"开着事务摸鱼"的连接。

这两个参数在大多数云厂商的 PG 实例上都有默认值(比如阿里云 RDS 默认 idle_session_timeout = 600sidle_in_transaction_session_timeout = 60s),你甚至不一定知道它们开着。

回到我们的场景。AsyncPostgresSaver 在写 checkpoint 时会开启事务;如果 agent 在两次 checkpoint 写入之间干了重活(比如等 LLM 响应十几秒),连接就可能被 idle_in_transaction_session_timeout 盯上。而池里那些 min_size 维持的空闲连接,什么都不干也会被 idle_session_timeout 一波带走。

被 kill 之后会怎样?连接池不知道这事------连接对象看起来还在池里,但底层 TCP 已经断了。下次 getconn() 拿出来用时直接报 OperationalError,用户体验就是 Agent 聊到一半突然崩了。

应对方案

方案一:调大服务端参数(如果有权限)

sql 复制代码
ALTER SYSTEM SET idle_session_timeout = 0;              -- 关掉空闲会话超时
ALTER SYSTEM SET idle_in_transaction_session_timeout = 0; -- 关掉事务中空闲超时
SELECT pg_reload_conf();

或者根据业务节奏设大一点,比如 idle_session_timeout = '10min'。不过云数据库通常不让你改这些,方案二更实际。

方案二:连接池主动回收,走在 PG kill 前面

AsyncConnectionPool 支持几个参数来控制连接生命周期:

python 复制代码
_PG_POOL = AsyncConnectionPool(
    conninfo,
    min_size=4,
    max_size=10,
    max_idle=300,        # 连接空闲超过 300s 自动回收
    max_lifetime=1800,   # 连接存活超过 30min 强制回收
    open=False,
)

如果 PG 的 idle_session_timeout 是 600s,那把 max_idle 设成 500s;如果 idle_in_transaction_session_timeout 是 60s,那把 max_idle 设成 50s。总之比 PG 的阈值小一点,连接池就会在 PG 动手之前自己把连接关掉重建。另外 max_lifetime 也能兜底------不管连接状态如何,到时间就回收。

AsyncPostgresSaver 构造时如果不传这两个参数,底层 AsyncConnectionPool 的默认值是 max_idle=600(10 分钟)、max_lifetime=3600(1小时)。如果你的 PG 实例的 idle 超时比这些值小,就一定要显式覆盖。

方案三:TCP keepalive

在连接字符串里加 keepalive 参数:

python 复制代码
conninfo = (
    f"postgresql://{user}:{pwd}@{host}:{port}/{db}"
    f"?keepalives=1"
    f"&keepalives_idle=30"
    f"&keepalives_interval=10"
    f"&keepalives_count=3"
)

TCP 层面的心跳保活,能防止中间网络设备(负载均衡器、防火墙)因为长时间无数据包把连接丢掉。不过这个解决的是网络层超时,跟 PG 的 idle_in_transaction_session_timeout 不是一回事。

方案四:check 回调做健康检查

python 复制代码
async def check_connection(conn):
    """归还连接前 ping 一下看看还活着没"""
    try:
        await conn.execute("SELECT 1")
    except Exception:
        raise  # 连接挂了,池会丢弃它

_PG_POOL = AsyncConnectionPool(
    conninfo,
    check=check_connection,
    ...
)

组合使用方案一/二 + 四,基本能覆盖。

应用生命周期管理

整个流程放在 FastAPI 的 lifespan 里:

python 复制代码
@asynccontextmanager
async def lifespan(app: FastAPI):
    try:
        pg_pool = await get_pg_pool()
        await init_checkpointer(pg_pool, is_setup=True)
        yield
    finally:
        if pg_pool:
            await pg_pool.close()

逻辑很简单:启动时建池 → 初始化 checkpointer 并建表 → 正常运行 → 关闭时回收连接。is_setup=True 只在启动时传一次,后续 get_checkpointer() 走懒加载路径,不会再建表。

get_checkpointer() 是一个懒加载的单例:

python 复制代码
async def get_checkpointer() -> AsyncPostgresSaver:
    global _PG_CHECKPOINTER
    if not _PG_CHECKPOINTER:
        pg_pool = await get_pg_pool()
        await init_checkpointer(pg_pool)
    return _PG_CHECKPOINTER

这样 Agent 层可以直接 await get_checkpointer() 拿到现成的实例,不用关心初始化细节。

Agent 侧一行接入

在 deepagents 的 create_deep_agent() 里,checkpointer 只是一个参数:

python 复制代码
class AIAgent:
    async def _init_deep_agent(self):
        if self._agent:
            return

        checkpointer = await get_checkpointer()

        self._agent = create_deep_agent(
            model=self._llm,
            tools=self._tools,
            checkpointer=checkpointer,  # 就这一行
            middleware=[...],
        )

之后每次调用 astream,只要 RunnableConfig 里传了 thread_id,LangGraph 就会自动从 Postgres 加载上下文、执行完毕后自动保存 checkpoint。streaming 场景下同样生效,不需要任何额外处理。

改进点

  • checkpoint 表膨胀 :随着时间推移,checkpoints 表会越来越大。LangGraph 目前没有内置的 checkpoint 清理策略,需要自己写定时任务按 thread_id + 时间条件清理旧记录。
  • pool size 调优min_size=4 不一定适合所有场景,实际部署后看看 PG 的 pg_stat_activity,根据活跃连接数动态调整。
  • 多实例部署 :如果多个 worker 共享同一个 Postgres,thread_id 天然跨实例可用。只要保证同一个用户的会话始终用同一个 thread_id,请求打到哪个 worker 都能正确恢复上下文。如果是 WebSocket 场景,需要注意 sticky session 或把 thread_id 持久化到客户端。
  • 连接池的 open=False + 手动 open() :这个模式保证 import 阶段不建连,但如果忘了在 lifespan 里调用 await _PG_POOL.open(),后续所有数据库操作都会报 PoolIsClosed 之类的错。可以考虑在 get_pg_pool() 里兜个底自动 open(),当前代码已经是这样做的。
相关推荐
瀚高PG实验室18 小时前
pgsql-ogr-fdw
数据库·postgresql·瀚高数据库·highgo
IvorySQL18 小时前
PostgreSQL 技术日报 (6月5日)|PG19 Beta1 上线,PGConf.PL 2026开启征稿
数据库·postgresql·区块链
IvorySQL1 天前
【HOW 2026 分论坛演讲】PG/IvorySQL私有云中实践
数据库·人工智能·sql·postgresql
倒流时光三十年1 天前
PostgreSQL ON CONFLICT DO UPDATE 增加 WHERE 条件优化性能
数据库·postgresql
IvorySQL1 天前
PostgreSQL 技术日报 (6月1日)|逻辑复制问题修复,AI 行业动态速览
数据库·人工智能·postgresql
*neverGiveUp*1 天前
PostgreSql常用SQL大全
数据库·sql·postgresql
倒流时光三十年1 天前
PostgreSQL HOT 优化 - 大白话解释
数据库·postgresql·hot
有想法的py工程师1 天前
PostgreSQL分区表父索引INVALID排查实战:缺少某个分区索引导致父索引INVALID
数据库·postgresql
睡不醒男孩0308232 天前
数据库高可用运维实操指南:基于CLup的PostgreSQL生产环境自动化管理
运维·数据库·postgresql