📖 本章学习目标
- ✅ 理解 LangGraph Checkpointer 的核心概念与工作原理
- ✅ 掌握 MemorySaver(内存)和 SqliteSaver(本地DB) 的使用
- ✅ 学会使用
thread_id实现多用户/多会话状态隔离- ✅ 理解检查点的存储结构,能够读取和恢复历史状态
- ✅ 掌握检查点在故障恢复和调试中的应用
- ✅ 避免常见的持久化陷阱和性能问题
一、为什么需要持久化
1、无持久化的 Agent 是"失忆症患者"
没有持久化的 Agent 每次对话都从零开始------上一次你告诉它你叫什么名字,下次重启后它完全不记得。这在生产中是不可接受的。
没有持久化的痛点:
- ❌ 服务重启后所有对话历史丢失
- ❌ 多轮任务中途中断后无法恢复
- ❌ 无法支持多用户并发,状态互相干扰
- ❌ 无法审计和回放历史执行过程
- ❌ 长时任务(数小时)一旦失败全部重来
❌ 无持久化
用户:我叫Alice
AI:记住了
进程重启
用户:我叫什么?
AI:不知道😢
✅ 有持久化
用户:我叫Alice
AI:记住了
💾保存检查点
进程重启
用户:我叫什么?
AI:你叫Alice!😊
🔄恢复检查点
2、Checkpointer 解决了什么
有持久化
对话1
状态A
💾 检查点存储
对话2
恢复状态A
继续执行
无持久化
对话1
状态A
对话2
状态重置
Checkpointer 提供:
- ✅ 每个节点执行后自动保存状态快照
- ✅ 基于
thread_id隔离不同会话 - ✅ 支持从任意历史检查点恢复执行
- ✅ 天然支持"时间旅行"调试(第15章详述)
- ✅ 故障恢复:从中断点继续,无需重头开始
核心概念对比:
| 特性 | 无持久化 | 有持久化 |
|---|---|---|
| 进程重启 | 状态丢失 | 状态保留 |
| 多用户 | 状态混乱 | 完全隔离 |
| 故障恢复 | 从头开始 | 检查点恢复 |
| 调试能力 | 黑盒 | 可查看历史 |
| 长时任务 | 不可靠 | 可靠 |
二、MemorySaver:内存检查点
1、最简单的持久化方案
MemorySaver 将检查点存储在内存中,进程退出后消失。适合开发测试和简单场景:
步骤1:导入依赖
typescript
import * as dotenv from 'dotenv';
dotenv.config();
import { MemorySaver, MessagesAnnotation, StateGraph } from '@langchain/langgraph';
import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage, AIMessage } from '@langchain/core/messages';
步骤2:定义节点
typescript
const model = new ChatOpenAI({ model: 'gpt-4o-mini' });
async function chatNode(state: typeof MessagesAnnotation.State) {
const response = await model.invoke(state.messages);
return { messages: [response] };
}
步骤3:创建MemorySaver并编译图
typescript
// 创建内存检查点保存器
const memorySaver = new MemorySaver();
const graph = new StateGraph(MessagesAnnotation)
.addNode('chat', chatNode)
.addEdge('__start__', 'chat')
.addEdge('chat', '__end__')
// 编译时传入 checkpointer
.compile({ checkpointer: memorySaver });
代码解读:
MemorySaver:进程内存存储,适合开发和测试compile({ checkpointer }):启用持久化,图的每个节点执行后自动保存- 一旦启用 checkpointer,
invoke时必须传入configurable.thread_id
工作原理:
- 每个节点执行完成后,自动序列化
State - 将
State保存到MemorySaver的内部Map - 下次调用相同
thread_id时,自动加载历史State
2、thread_id:会话隔离的关键
场景1:单用户多轮对话
typescript
// 会话1:用户 Alice
const aliceConfig = { configurable: { thread_id: 'alice-session-001' } };
// 首轮对话
await graph.invoke(
{ messages: [new HumanMessage('我叫Alice,请记住我的名字')] },
aliceConfig
);
// 第二轮对话(自动携带上下文)
const result = await graph.invoke(
{ messages: [new HumanMessage('你记得我叫什么名字吗?')] },
aliceConfig
);
const reply = result.messages[result.messages.length - 1] as AIMessage;
console.log(reply.content); // "你叫 Alice!"
configurable.thread_id是会话唯一标识,相同 ID 的调用共享状态。LangGraph 会自动加载该 thread_id 的历史检查点,拼接消息历史。不同用户使用不同 thread_id,实现状态完全隔离。
thread_id必须是字符串- 同一
thread_id的所有调用共享状态 - 不同
thread_id的状态互不干扰
场景2:多用户并发
typescript
// 会话2:用户 Bob(完全独立)
const bobConfig = { configurable: { thread_id: 'bob-session-001' } };
const bobResult = await graph.invoke(
{ messages: [new HumanMessage('你知道我叫什么名字吗?')] },
bobConfig
);
const bobReply = bobResult.messages[bobResult.messages.length - 1] as AIMessage;
console.log(bobReply.content); // "抱歉,你还没有告诉我你的名字。"
常见实际应用:
- Web应用:thread_id = userId 或 sessionId
- 客服系统:thread_id = conversationId
- 任务系统:thread_id = taskId
多用户隔离示意图:
MemorySaver 内部存储
thread: alice-001
messages: [...]
thread: bob-001
messages: [...]
thread: charlie-001
messages: [...]
用户Alice
用户Bob
用户Charlie
三、SqliteSaver:本地持久化存储
1、安装依赖
bash
npm install @langchain/langgraph-checkpoint-sqlite better-sqlite3
npm install -D @types/better-sqlite3
说明:
@langchain/langgraph-checkpoint-sqlite:SQLite检查点实现better-sqlite3:高性能SQLite数据库驱动@types/better-sqlite3:TypeScript类型定义
2、SqliteSaver 的使用
步骤1:创建SqliteSaver
typescript
import { SqliteSaver } from '@langchain/langgraph-checkpoint-sqlite';
// 创建 SQLite 检查点保存器(自动创建数据库文件)
const sqliteSaver = SqliteSaver.fromConnString('./checkpoints.db');
fromConnString('./checkpoints.db')是指定 SQLite 数据库文件路径
- 如果文件不存在,会自动创建
- 如果文件已存在,会加载已有的检查点数据
- 相对路径相对于当前工作目录
步骤2:编译带持久化的图
typescript
const persistentGraph = new StateGraph(MessagesAnnotation)
.addNode('chat', chatNode)
.addEdge('__start__', 'chat')
.addEdge('chat', '__end__')
.compile({ checkpointer: sqliteSaver });
与MemorySaver用法完全相同,只是换了实现。SQLite数据持久化到磁盘,进程重启后仍然可用,适合本地开发、单机部署场景,生产环境推荐使用 PostgreSQL(参考第14章)。
两种Checkpointer对比:
MemorySaver: 速度快,但重启丢失SqliteSaver: 速度稍慢,但重启保留
3、进程重启后恢复对话
第一次运行(保存状态)
typescript
// 模拟:第一次运行(保存状态)
async function firstRun() {
await persistentGraph.invoke(
{ messages: [new HumanMessage('记住:今天的任务是写报告')] },
{ configurable: { thread_id: 'task-001' } }
);
console.log('✅ 状态已保存到 SQLite');
// 进程结束...
}
await firstRun();
第二次运行(恢复状态)
typescript
// 模拟:第二次运行(恢复状态)
async function secondRun() {
const result = await persistentGraph.invoke(
{ messages: [new HumanMessage('我刚才说的任务是什么?')] },
{ configurable: { thread_id: 'task-001' } } // 相同的 thread_id
);
const reply = result.messages[result.messages.length - 1] as AIMessage;
console.log(reply.content); // "你刚才说今天的任务是写报告。"
}
await secondRun();
两次运行使用相同的thread_id:'task-001',第二次运行时,LangGraph自动从SQLite加载第一次的检查点,消息历史被完整恢复,AI记得之前的对话。即使中间隔了几天,只要数据库文件还在,就能恢复。
实际应用场景:
- 客服系统:用户关闭浏览器后再打开,对话继续
- 任务助手:今天没完成的任务,明天继续
- 学习助手:上次学习进度自动保存
持久化工作流程:
SQLite数据库 LangGraph 应用程序 SQLite数据库 LangGraph 应用程序 第一次运行 进程重启 第二次运行 invoke(thread_id='task-001') 执行节点 保存检查点 确认保存 返回结果 invoke(thread_id='task-001') 加载检查点 返回历史状态 继续执行 返回结果
四、读取和管理检查点
1、获取会话状态快照
typescript
// 获取某个 thread 的最新状态
async function getLatestState(threadId: string) {
const state = await persistentGraph.getState({
configurable: { thread_id: threadId },
});
console.log('消息数量:', state.values.messages.length);
console.log('最新检查点:', state.config.configurable?.checkpoint_id);
console.log('下一步节点:', state.next); // 图是否还有未执行的步骤
return state;
}
代码解读:
getState(config):获取指定thread_id的最新状态快照state.values:当前 State 的实际值state.config:包含checkpoint_id,可用于时间旅行state.next:数组,包含下一步要执行的节点名(若图已完成则为空)
返回值结构:
json
{
values: { messages: [...] }, // 当前状态
config: { checkpoint_id: '...' }, // 检查点ID
metadata: { source: 'chat', ... }, // 元数据
next: ['chat'] // 下一步节点
}
2、遍历历史检查点
使用getStateHistory可以获取历史检查点,这在调试、审计和分析历史很有用,配合第15章的时间旅行功能,可以回滚到任意历史状态。
typescript
async function listHistory(threadId: string) {
const history = persistentGraph.getStateHistory({
configurable: { thread_id: threadId },
});
let count = 0;
for await (const checkpoint of history) {
count++;
console.log(`检查点 ${count}:`, {
id: checkpoint.config.configurable?.checkpoint_id,
node: checkpoint.metadata?.source,
messageCount: checkpoint.values.messages?.length ?? 0,
});
}
}
// 使用示例
await listHistory('task-001');
// 输出:
// 检查点 1: { id: 'xxx', node: 'chat', messageCount: 4 }
// 检查点 2: { id: 'yyy', node: 'chat', messageCount: 6 }
// 检查点 3: { id: 'zzz', node: 'chat', messageCount: 8 }
3、手动更新状态
使用 updateState可以对检查点状态做修改。
typescript
// 直接修改某个 thread 的状态(不经过图的执行)
async function injectMessage(threadId: string, message: string) {
await persistentGraph.updateState(
{ configurable: { thread_id: threadId } },
{ messages: [new HumanMessage(message)] },
'chat' // 指定更新后从哪个节点继续执行
);
console.log('✅ 状态已手动更新');
}
// 使用示例
await injectMessage('task-001', '补充信息:报告需要在周五前完成');
updateState(config, update, asNode)手动修改检查点状态。其中asNode用于模拟更新是由哪个节点产生的(影响路由决策)。主要用于注入测试数据、人工修正 AI 的中间结果等场景,这是第8章"人机交互"的底层机制之一。
应用场景:
- 人工审核:修改AI生成的内容后再继续
- 数据纠错:修复错误的中间结果
- 测试注入:快速设置特定状态进行测试
五、检查点的存储结构
1、理解检查点的数据模型
每个检查点包含以下核心信息:
| 字段 | 说明 | 用途 | 示例 |
|---|---|---|---|
checkpoint_id |
唯一标识符 | 时间旅行定位 | '1ef4a2b3-c5d6' |
thread_id |
会话 ID | 隔离不同用户 | 'user-123-chat' |
values |
State 快照 | 恢复执行状态 | {messages:[...]} |
metadata |
元数据 | 调试信息 | {source:'chat'} |
next |
待执行节点 | 中断后续执行 | ['agent'] |
parent_config |
父检查点引用 | 构建历史链 | {checkpoint_id:'...'} |
2、检查点时间线
检查点0
初始状态
检查点1
节点A执行后
检查点2
节点B执行后
检查点3
节点C执行后
检查点4
最终状态
时间线详解:
- 检查点0:图启动前的初始状态
- 检查点1:节点A执行完成后的状态快照
- 检查点2:节点B执行完成后的状态快照
- 检查点3:节点C执行完成后的状态快照
- 检查点4:图执行完毕的最终状态
每个检查点都可以通过checkpoint_id精确定位,实现"时间旅行"。
3、检查点在数据库中的存储
sql
-- SQLite 数据库中的表结构(简化版)
CREATE TABLE checkpoints (
thread_id TEXT, -- 会话ID
checkpoint_id TEXT, -- 检查点ID
parent_checkpoint_id TEXT, -- 父检查点ID
type TEXT, -- 类型:full/incremental
checkpoint BLOB, -- 序列化的检查点数据
metadata BLOB, -- 元数据
PRIMARY KEY (thread_id, checkpoint_id)
);
六、最佳实践和踩坑指南
💡 实践 1:thread_id 的设计策略
生产级 thread_id 设计建议:
typescript
// ✅ 包含用户 ID 和场景,便于管理
const threadId = `user-${userId}-chat-${chatRoomId}`;
const threadId2 = `task-${taskId}-run-${runNumber}`;
const threadId3 = `customer-${customerId}-support-${ticketId}`;
// ❌ 使用随机字符串(无法追踪归属)
const badThreadId = Math.random().toString(36);
推荐的命名规范:
| 场景 | 格式 | 示例 |
|---|---|---|
| 聊天应用 | user-{userId}-chat-{chatId} |
user-123-chat-456 |
| 任务系统 | task-{taskId}-run-{runId} |
task-789-run-001 |
| 客服系统 | customer-{id}-ticket-{id} |
customer-001-ticket-999 |
| 测试环境 | test-{testCase}-{timestamp} |
test-login-1234567890 |
💡 实践 2:检查点大小控制
typescript
import { RemoveMessage } from '@langchain/core/messages';
// 定期清理过长的消息历史,控制检查点大小
async function trimHistory(state: typeof MessagesAnnotation.State) {
const MAX_MESSAGES = 20;
if (state.messages.length > MAX_MESSAGES) {
const toRemove = state.messages
.slice(0, state.messages.length - MAX_MESSAGES)
.map(m => new RemoveMessage({ id: m.id! }));
return { messages: toRemove };
}
return {};
}
💡 实践 3:选择合适的Checkpointer
选择指南:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 开发测试 | MemorySaver | 速度快,无需配置 |
| 本地应用 | SqliteSaver | 零配置,单文件 |
| 小型生产 | PostgreSQL | 可靠性高,支持并发 |
| 大型生产 | Redis + PostgreSQL | 高速缓存+持久化 |
| 云原生 | LangGraph Platform | 托管服务,免运维 |
⚠️ 常见问题
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 启用 checkpointer 后忘记传 thread_id | 运行时报 "Missing thread_id" | 每次 invoke/stream 都必须传 configurable.thread_id |
| MemorySaver 用于生产 | 重启后状态丢失,用户投诉 | 生产环境使用 SqliteSaver 或 PostgreSQL |
| thread_id 重用导致状态污染 | 新会话意外继承旧会话的历史 | 每个新会话生成唯一的 thread_id |
| 检查点无限增长 | 数据库文件越来越大 | 定期清理过期的 thread 检查点 |
| 并发访问冲突 | 多用户同时写入出错 | 使用PostgreSQL等支持并发的数据库 |
| 大对象存入State | 序列化慢,占用空间大 | 大对象存外部存储,State只存引用 |
📝 本章小结
核心知识点回顾
| 知识点 | 关键要点 | 应用场景 |
|---|---|---|
| MemorySaver | 内存存储,进程内有效 | 开发测试 |
| SqliteSaver | 本地数据库,重启可恢复 | 单机部署 |
| thread_id | 会话唯一标识,隔离状态 | 多用户系统 |
| getState | 读取最新状态快照 | 查询/监控 |
| getStateHistory | 遍历历史检查点 | 调试/审计 |
| updateState | 手动注入或修改状态 | 人机交互 |
| 检查点清理 | 控制State大小 | 性能优化 |
🎯 动手练习
练习 1:跨进程持久化对话
- 目标:用 SqliteSaver 实现重启后继续上次对话
- 要求 :
- 第一次运行保存状态:"记住我的名字是张三"
- 关闭程序,重新运行
- 第二次运行询问:"我叫什么名字?"
- 验收标准 :
- AI能正确回答"你叫张三"
- 证明状态成功跨进程恢复
练习 2:多用户会话管理
- 目标:模拟3个不同用户同时与 Agent 对话,状态互不干扰
- 要求 :
- 创建3个不同的thread_id
- 每个用户告知AI自己的名字
- 交叉询问其他用户的名字
- 验收标准 :
- 询问不同用户"你知道我叫什么",各自得到正确回答
- 没有任何状态泄漏或混淆
练习 3:检查点历史审计
- 目标:打印某个 thread 的所有检查点历史
- 要求 :
- 进行5轮对话
- 遍历并打印所有检查点
- 显示每个检查点的时间戳、节点名和消息数量
- 验收标准 :
- 能清晰看到完整的执行历史链
- 至少5个检查点记录
练习 4:状态清理机制
- 目标:实现自动清理过旧消息的功能
- 要求 :
- 创建trimHistory节点
- 当消息超过15条时,删除最旧的5条
- 验证清理后仍能保持对话连贯性
- 验收标准 :
- 消息数量始终不超过15条
- 最近的对话上下文完整保留
- 检查点体积明显减小
📚 延伸阅读
下一章:第8章 ------ 人机交互:中断与审批流程