NestJS + LangChain:checkpointer 详解,从原理到代码落地

一、先说结论:checkpointer 到底是什么

一句话:

checkpointer 就是 Agent 的“会话状态保存器”。

它负责把当前对话过程中的状态保存下来,并在下一次相同线程调用时再读回来。

在 LangChain / LangGraph 体系里,短期记忆的本质并不是“模型自己记住了什么”,而是:

  • 当前 Agent 运行过程中的状态会被保存
  • 下一次调用时,再根据线程标识把状态读回来
  • 从而形成“多轮会话”的效果

你可以把它理解成下面这组关系:

  • Agent:会思考、会调用工具的执行体
  • State:当前会话里的消息、工具调用结果、摘要等状态
  • Checkpointer:负责把 State 保存和恢复
  • thread_id:告诉 checkpointer,这轮调用属于哪条会话

所以:

  • 没有 checkpointer,Agent 每次都是“单轮”的
  • 有了 checkpointer,但没有 thread_id,也不知道该接哪条会话

二、checkpointer 和普通“聊天上下文”有什么区别

很多人第一次接触时,容易把 checkpointer 和“自己手动传历史消息”混在一起。

1. 普通聊天上下文

最直接的做法是,自己维护完整消息数组:

messages: [
  new HumanMessage("我叫张三"),
  new AIMessage("你好张三"),
  new HumanMessage("我刚才说我叫什么"),
]

这种方式虽然也能让模型看到历史,但问题很多:

  • 需要自己维护历史消息
  • controller / service 会越来越乱
  • 多用户会话隔离麻烦
  • 后续做裁剪、摘要、删除记忆会很难扩展

2. 使用 checkpointer

使用 checkpointer 时,你通常只需要传当前这一轮输入:

messages: [new HumanMessage("我刚才说我叫什么")]

然后框架会根据 thread_id 自动找到这一条线程之前保存过的状态,再继续执行。

也就是说,普通上下文是 你手动维护历史,而 checkpointer 是 框架帮你管理线程级状态

这也是为什么在 Agent 场景里,checkpointer 比“手动堆历史消息”更适合做真正的多轮会话。


三、checkpointer 的核心工作流

理解 checkpointer,最简单的方式就是看它的完整执行流程。

整个过程可以拆成 4 步。

第 1 步:创建 Agent 时挂上 checkpointer

createAgent({
  model,
  tools,
  checkpointer: new MemorySaver(),
})

这一步的含义是:

以后这个 Agent 每次运行时,它的状态都交给这个 checkpointer 管理。

这里的 MemorySaver() 是最常见的开发阶段方案,表示把状态先存在内存里。


第 2 步:调用时传入 thread_id

agent.invoke(
  {
    messages: [new HumanMessage(question)],
  },
  {
    configurable: {
      thread_id: "user-001",
    },
  },
);

这一步的含义是:

当前这次调用,属于 user-001 这条会话线程。

也就是说,checkpointer 不是“全局存一份状态”,而是按 thread_id 来区分不同会话。


第 3 步:运行前读取历史状态

如果 user-001 这条线程以前已经保存过状态,那么本轮执行开始前,checkpointer 会先把这条线程对应的状态读出来。

这一层通常是框架内部自动完成的,业务代码里看不到,但它确实在发生。


第 4 步:执行结束后保存新状态

本轮执行结束后,新状态会再次保存回 user-001 这条线程下。

比如这一轮发生了:

  • 用户问了一句天气
  • Agent 调用了天气工具
  • Agent 返回了最终答案

那么这些新的状态就会继续被保存下来,供下一轮继续使用。

这就是为什么同一个 thread_id 下,多轮会话能串起来。


四、最常见的两种 checkpointer

在实际开发里,最常见的是下面两种。

1. MemorySaver

这是开发阶段最常用的方案。

特点:

  • 数据保存在内存中
  • 接入非常简单
  • 适合本地调试
  • 服务一重启,状态就丢失

适用场景:

  • 本地开发
  • Demo 演示
  • 先验证多轮记忆链路是否正确

2. PostgresSaver

这是更适合生产环境的方案。

特点:

  • 数据保存在数据库中
  • 服务重启不会丢失
  • 多实例部署时可共享状态
  • 更适合正式业务场景

适用场景:

  • 正式项目
  • 真实用户会话
  • 需要持久化历史
  • 需要排查某些线程状态

所以一般建议是:

  • 开发阶段先用 MemorySaver
  • 确认逻辑稳定后,再升级到 PostgresSaver

五、在 Nest 项目里,checkpointer 应该放在哪?

这部分是最容易让人困惑的地方。

在我的这个 Nest 项目里,Agent 是在 tools.service.ts 里创建的,所以 checkpointer 也应该放在这里

原因其实很简单:

checkpointer 不是一个独立的外挂配置,而是 Agent 状态管理能力的一部分

而 Agent 是通过 createAgent() 创建出来的,所以谁负责创建 Agent,谁就应该决定:

  • model 是谁
  • tools 是哪些
  • systemPrompt 是什么
  • middleware 用哪些
  • checkpointer 用哪个

比如我当前项目里的核心结构大致是这样:

this.agent = createAgent({
  model: this.getModel(),
  tools: [weatherNowTool, weather3DTool, weather7dTool],
  checkpointer: new MemorySaver(),
  systemPrompt: '...',
});

从这段代码就可以看出,createAgent() 这一层其实已经在做 Agent 的完整装配。

所以虽然文件名叫 tools.service.ts,但在职责上,它实际上已经不只是“工具服务”,而更像一个 AgentService


为什么不能随便放到别的地方?

很多人会下意识觉得 checkpointer 可以放在:

  • controller
  • chat.service.ts
  • 某个全局配置文件

但这些位置其实都不太合理。

1. 不能放在 Controller

Controller 的职责是接收请求参数,比如:

  • 用户输入的问题
  • threadId
  • userName

它不应该关心 Agent 内部是怎么构造的。
如果把 checkpointer 放在 Controller,就会让接口层和 AI 运行机制耦合在一起。


2. 不适合优先放在 ChatService

在当前这个项目里,ChatService 更适合做业务转发,比如:

  • 接收参数
  • 调用 ToolsService.ask()
  • 返回结果

但真正决定 Agent 长什么样的,不是 ChatService,而是 createAgent() 那一层。


3. 最合理的位置就是创建 Agent 的地方

因为从本质上讲,checkpointer 就是 Agent 的“记忆系统”。

你可以把它理解成:

  • model 是大脑
  • tools 是能力
  • systemPrompt 是规则
  • checkpointer 是记忆系统

这些都应该在 Agent 创建时统一定义,而不是分散在其它层里。


六、在当前项目里,checkpointer 的实际作用是什么?

在没有加 checkpointer 之前,我的 Agent 每次调用其实都是“单轮”的。

也就是说:

  • 这一轮回答完就结束
  • 下一轮进来时,它并不知道上一轮聊过什么

但当我在 createAgent() 里加上:

checkpointer: new MemorySaver()

含义就变成了:

这个 Agent 具备了“保存和恢复会话状态”的能力。

不过要注意:

只有 checkpointer 还不够,还必须配合 thread_id 一起使用。

因为 checkpointer 只负责“记住状态”,但它不知道这份状态属于谁。
真正用来区分不同会话的,是调用 invoke() 时传入的:

configurable: {
  thread_id: threadId,
}

所以这两者的关系可以概括成:

  • checkpointer:负责保存和恢复记忆
  • thread_id:负责区分记忆属于哪条会话

少了任意一个,都无法形成真正的多轮会话能力。


七、为什么开发阶段先用 MemorySaver

在当前这个 Nest 项目里,我优先使用的是:

checkpointer: new MemorySaver()

原因是它最适合开发阶段快速验证功能。

它的优点很明显:

  • 接入简单
  • 不需要额外数据库
  • 本地调试方便
  • 能快速验证多轮记忆是否生效

但它也有很明确的限制:

  • 数据只存在内存里
  • 服务一重启就会丢失
  • 不适合正式线上环境

所以更合理的做法是:

  1. 先用 MemorySaver 跑通整条链路
  2. 确认多轮对话逻辑没问题
  3. 后续再替换成 PostgresSaver

这样开发成本最低,也最稳。


八、基于当前项目结构,checkpointer 的位置意味着什么?

如果从代码结构来看,我当前项目大致形成了这样的分层:

  • chat.controller.ts:接收 HTTP 请求
  • chat.service.ts:做业务转发
  • tools.service.ts:真正创建和调用 Agent
  • qweather.service.ts:负责对接外部天气 API

所以 tools.service.ts 这一层,本质上已经不是单纯的“工具管理器”了,而是整个 AI Agent 的装配中心。

也正因为如此,下面这些东西都应该放在这一层统一处理:

  • 模型实例
  • tools 注册
  • systemPrompt
  • middleware
  • checkpointer

这也是为什么我会说:

在当前这个 Nest 项目里,ToolsService 实际上已经承担了 AgentService 的角色。


九、thread_id 到底是什么?

这是 checkpointer 相关内容里最容易被问到的问题。

你可以把 thread_id 理解成:

“会话编号”

比如:

  • 用户 A:thread_id = user-a
  • 用户 B:thread_id = user-b

或者更细一点:

  • 张三第一次会话:thread_id = zhangsan-chat-1
  • 张三第二次会话:thread_id = zhangsan-chat-2

它的作用不是身份认证,而是:

告诉 checkpointer,这次调用应该去读写哪条会话状态。

同一个 thread_id

会继续接之前的对话。

不同的 thread_id

会话完全隔离。

所以从本质上讲,thread_id 就是 checkpointer 的“索引键”。


十、一个最小测试例子

第一次请求

GET /chat/ask?question=我叫张三&threadId=user-1

第二次请求

GET /chat/ask?question=我刚才说我叫什么&threadId=user-1

因为两次请求使用的是同一个 threadId=user-1,所以第二次调用时,checkpointer 会先把第一次的状态读回来,然后再继续回答。

如果第二次你改成:

GET /chat/ask?question=我刚才说我叫什么&threadId=user-2

那对框架来说,这就是一条新的线程,它不会继承 user-1 的历史状态。


十一、checkpointer 的几个常见误区

误区 1:加了 MemorySaver 就自动有记忆

不对。

你还必须在调用时传:

configurable: {
  thread_id: 'xxx'
}

没有 thread_id,checkpointer 根本不知道该把状态存到哪一条线程下。


误区 2:thread_id 可以一直写死

技术上可以,业务上通常不行。

如果你写死:

thread_id: 'default-thread'

那所有用户都会共用同一条会话,最终一定会串话。


误区 3:checkpointer 只保存聊天记录

不完全对。

checkpointer 保存的是 Agent State,而不只是纯文本聊天记录。

常见内容包括:

  • 消息历史
  • 工具调用结果
  • 摘要内容
  • 其它中间状态

所以它更准确的理解应该是:

它保存的是整条线程的运行状态,而不是简单的聊天文本。


误区 4:MemorySaver 适合直接上生产

通常不建议。

因为它只存在于进程内存中:

  • 服务重启就丢
  • 多实例之间不同步
  • 不适合正式线上环境

正式生产里,更常见的是数据库型 saver。


十二、什么时候该从 MemorySaver 升级到 PostgresSaver

当你出现下面这些需求时,就该考虑升级了:

  1. 服务重启后还要保留会话
  2. 多台服务器要共享记忆
  3. 用户下次回来还能继续之前的聊天
  4. 需要排查某些线程历史状态

这个时候,通常只需要把:

checkpointer: new MemorySaver()

替换成数据库版 saver,其余 thread_id 的调用方式一般不需要大改。


十三、这篇文章最重要的 3 个结论

结论 1

checkpointer = Agent 的状态保存器

它保存的不是单纯聊天记录,而是整条线程的运行状态。


结论 2

thread_id 是 checkpointer 正常工作的关键

没有 thread_id,就没有真正的线程级记忆。


十四、总结

在 NestJS + LangChain 项目里,想让 Agent 具备多轮记忆,核心并不是手动维护消息数组,而是:

  1. createAgent() 时配置 checkpointer
  2. invoke() 时传入 configurable.thread_id

其中:

  • MemorySaver 适合本地开发
  • PostgresSaver 更适合生产持久化
  • thread_id 决定会话隔离
  • checkpointer 决定状态如何保存和恢复

只有这两者真正配合起来,Agent 才能从“单轮问答”升级为“有状态的会话系统”。


Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐