持久化
将图的每一步状态保存为检查点,让智能体真正"记得"自己做了什么
线程、检查点、跨线程存储 —— 从无状态到有记忆的基石状态丢了
你有没有遇到过这样的情况?
没有持久化
- 图执行到一半崩了,所有中间结果全丢了,只能从头再来
- 想查看上一步的状态?只能自己加
console.log - 想在某个节点暂停、等人确认后继续?做不到——没有"恢复"机制
- 多个用户同时用,状态混在一起,分不清谁是谁
- 用户说"回到刚才那一步",你一脸茫然——没有历史记录
有持久化
- 每一步自动存快照,崩了从上一个检查点恢复
- 随时用
getState()查看当前完整状态 - 在任意节点暂停,确认后调用
invoke继续 - 每个线程独立
thread_id,状态互不干扰 getStateHistory()完整回溯,甚至可以"时间旅行"- 节点部分成功时,其他已完成节点的结果不会丢——恢复时跳过它们
开始之前
需要具备
- 已完成第 04 章 Quickstart,能构建和运行基本的 StateGraph
- 理解节点、边、状态三大核心概念
- 熟悉 TypeScript 的 async/await 和异步迭代
- Node.js 18+ 环境
读完后你应该能
- 说清线程、检查点、超级步骤三者的关系
- 用
MemorySaver给图加上持久化 - 使用
getState/getStateHistory/updateState操作检查点 - 理解
StateSnapshot的字段含义 - 用
InMemoryStore实现跨线程的长期记忆 - 根据场景选择合适的 Checkpointer 后端
暂不涉及
- 持久化执行(Durable Execution)的深入实现
- 时间旅行的完整用法和分叉机制
- 生产环境数据库后端的部署细节
- 分布式场景下的检查点一致性
npm install @langchain/langgraph @langchain/core。本章所有代码基于 LangGraph.js。
核心概念
理解持久化之前,先搞清三个关键词

图:线程、检查点与超级步骤的关系
【AI 生图提示词】请生成一张中文技术概念图,风格简洁、配色柔和、适合教学: - 画面分三层,从上到下分别是"线程"、"超级步骤"、"检查点" - 顶层"线程":一个横向的圆角矩形,内部标注 thread_id: "thread-1",表示一个独立的执行序列 - 中层"超级步骤":在线程内部,用 3-4 个小方块表示,标注 Step 0、Step 1、Step 2、Step 3,方块之间用箭头连接 - 底层"检查点":每个超级步骤下方对应一个紫色的虚线圆角矩形,标注 CP-0、CP-1、CP-2、CP-3 - 侧边用文字说明:线程 = 检查点的序列,检查点 = 超级步骤结束时的状态快照,超级步骤 = 所有计划节点的执行 - 各元素关系:线程包含多个超级步骤(一对多),每个超级步骤结束时产生一个检查点(一对一),检查点按时间顺序排列在线程内 - 关键标注:thread_id 标在线程矩形内,CP 编号标在虚线矩形内,侧边说明三者的包含关系 - 配色建议:浅米色背景,线程用橙色边框,超级步骤用蓝色方块,检查点用紫色虚线,箭头用深灰色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
线程 Threads
每个检查点序列有唯一的 thread_id。不同线程的状态完全隔离,互不影响。
configurable.thread_id
检查点 Checkpoints
每个超级步骤结束时保存的状态快照(StateSnapshot)。包含值、下一步、元数据等。
StateSnapshot
超级步骤 Super-steps
一个"滴答"——所有当前计划执行的节点跑完,就产生一个新的检查点。
一个 tick
thread_id,否则 LangGraph 不知道把状态记到哪个账本里。
步骤与快照
对于 START → A → B → END 的简单图,执行一次会产生 4 个检查点

图:超级步骤与检查点的产生过程
CP-0
CP-1
CP-2
CP-3
【AI 生图提示词】请生成一张中文技术流程图,风格简洁、配色柔和、适合教学: - 画面主体:一条从左到右的时间线,展示 START → A → B → END 四个节点 - 每两个节点之间有一个紫色的虚线矩形,标注检查点编号和产生时机 - 时间线上方:节点执行过程(橙色方块) - 时间线下方:检查点快照(紫色虚线矩形),标注每个检查点保存了什么 - 特别标注:初始输入产生 CP-0,A 执行完产生 CP-1,B 执行完产生 CP-2,到达 END 产生 CP-3 - 各元素关系:节点之间是线性串联(START→A→B→END),每两个节点之间产生一个检查点快照,检查点与节点是一一对应关系 - 关键标注:每个检查点矩形内标注 CP 编号和保存内容(当前 state 快照),节点方块标注执行内容 - 配色建议:浅米色背景,节点用橙色,检查点用紫色虚线,箭头用深灰色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
// 定义带 reducer 的状态
import { StateSchema, MemorySaver, StateGraph, START, END } from "@langchain/langgraph";
import { z } from "zod";
const State = new StateSchema({
foo: z.string(),
bar: new ReducedValue(z.array(z.string()).default(() => []), {
inputSchema: z.array(z.string()),
reducer: (x, y) => x.concat(y),
}),
});
// 用 checkpointer 编译——这一行就启用了持久化
const checkpointer = new MemorySaver();
const graph = workflow.compile({ checkpointer });
thread_id 会报错。每次 invoke / stream 都必须在 configurable 中指定 thread_id。
使用方式
从创建到运行——MemorySaver 的完整用法
// 完整示例:一个带持久化的简单图
import { StateSchema, StateGraph, MemorySaver, START, END } from "@langchain/langgraph";
import { z } from "zod";
const State = new StateSchema({
messages: z.array(z.string()).default(() => []),
});
const nodeA = (state) => {
console.log("Node A executing, messages:", state.messages);
return { messages: ["from A"] }; // reducer 自动 concat
};
const nodeB = (state) => {
console.log("Node B executing, messages:", state.messages);
return { messages: ["from B"] };
};
// 关键一步:编译时传入 checkpointer
const graph = new StateGraph(State)
.addNode("a", nodeA)
.addNode("b", nodeB)
.addEdge(START, "a")
.addEdge("a", "b")
.addEdge("b", END)
.compile({ checkpointer: new MemorySaver() });
// 运行时必须指定 thread_id
const config = { configurable: { thread_id: "thread-1" } };
const result1 = await graph.invoke({ messages: ["initial"] }, config);
console.log(result1.messages); // ["initial", "from A", "from B"]
// 第二次调用——状态会延续!
const result2 = await graph.invoke({ messages: ["second call"] }, config);
console.log(result2.messages); // ["initial", "from A", "from B", "second call", "from A", "from B"]
{ checkpointer: new MemorySaver() };②调用时传 { configurable: { thread_id: "..." } }。缺一不可。
获取状态
用 getState() 查看当前线程的完整状态快照
// 获取当前线程的最新状态
const config = { configurable: { thread_id: "thread-1" } };
const snapshot = await graph.getState(config);
console.log(snapshot.values); // 当前状态值
console.log(snapshot.next); // 下一个要执行的节点列表
console.log(snapshot.config); // 配置信息(含 thread_id)
console.log(snapshot.metadata); // 元数据(步骤号、来源等)
console.log(snapshot.createdAt); // 创建时间
console.log(snapshot.parentConfig); // 父检查点的 config(用于回溯)
console.log(snapshot.tasks); // 当前待执行的任务列表
指定 checkpoint_id 获取历史检查点
// 获取特定检查点的状态(而非最新)
const historyConfig = {
configurable: {
thread_id: "thread-1",
checkpoint_id: "具体的检查点 ID", // 从 metadata 中获取
},
};
const oldSnapshot = await graph.getState(historyConfig);
console.log("历史状态:", oldSnapshot.values);
从历史中精准定位某个检查点
// 收集全部历史
const history = [];
for await (const state of graph.getStateHistory(config)) {
history.push(state);
}
// 按待执行节点查找——"找到 nodeB 即将执行前的那个检查点"
const beforeNodeB = history.find((s) => s.next.includes("nodeB"));
// 按步骤号查找——"找到第 2 步的状态"
const step2 = history.find((s) => s.metadata.step === 2);
// 按来源查找——"找到所有由 updateState 创建的分支"
const forks = history.filter((s) => s.metadata.source === "update");
快照字段
StateSnapshot 的每个字段代表什么?
| 字段 | 类型 | 说明 |
|---|---|---|
values |
Record<string, any> | 当前状态的完整值——所有状态字段及其当前值 |
next |
string[] | 下一个超级步骤要执行的节点名称列表。图为空数组时表示执行完毕 |
config |
RunnableConfig | 当前检查点的配置,包含 thread_id 和 checkpoint_id |
metadata |
Record<string, any> | 元数据:步骤号 step、来源 source、写入记录 writes(节点名 → 该节点本次写入的值)等 |
createdAt |
string | 检查点创建的时间戳 |
parentConfig |
RunnableConfig | undefined | 父检查点的配置。通过它可以回溯到上一个检查点 |
tasks |
TaskState[] | 当前待执行的任务列表。每个任务包含名称、状态、写入等信息 |
values(看当前状态)、next(看下一步做什么)、parentConfig(看上一步在哪)。掌握这三个就能应付 80% 的调试场景。
更新状态
用 updateState() 直接修改检查点中的状态值
// 直接修改当前线程的状态
const config = { configurable: { thread_id: "thread-1" } };
await graph.updateState(config, {
foo: "updated value",
});
// 验证修改
const snapshot = await graph.getState(config);
console.log(snapshot.values.foo); // "updated value"
指定 as_node 参数——让更新看起来像某个节点产生的
// 模拟 nodeA 的输出
await graph.updateState(config, {
messages: ["injected by human"],
}, "nodeA"); // as_node: "nodeA"
// 现在状态就像 nodeA 返回了 "injected by human" 一样
// 下一步 next 会变成 nodeA 之后的节点
典型用途
- 人工审核后修改中间结果
- 注入额外的上下文信息
- 测试时模拟节点输出
- 回滚到特定检查点后调整状态
注意事项
- 更新会经过 reducer,不是直接覆盖
as_node会影响next的计算- 更新后会产生新的检查点
- 必须指定
thread_id
跨线程存储
检查点是线程内的,InMemoryStore 用于跨线程共享数据
检查点绑定在单个线程上,不同线程之间无法共享状态。如果你需要跨线程的记忆——比如用户偏好、长期知识库——就需要 InMemoryStore。
import { InMemoryStore } from "@langchain/langgraph";
// 创建跨线程存储
const store = new InMemoryStore();
// 编译时传入 store
const graph = workflow.compile({
checkpointer: new MemorySaver(),
store, // 同时使用 checkpointer 和 store
});
// 在图外部操作存储——命名空间:[userId, "memories"]
const userId = "user-123";
const namespace = [userId, "memories"];
// 写入记忆
await store.put(namespace, "memory-1", {
content: "用户偏好深色主题",
type: "preference",
});
// 搜索记忆
const results = await store.search(namespace, {
query: "用户喜欢什么",
});
console.log(results); // [{ key: "memory-1", value: { content: "..." }, ... }]
📄 完整代码:05_cross_thread_store.ts
checkpointer(线程内短期记忆)+ InMemoryStore(跨线程长期记忆)。前者自动管理,后者手动读写。
语义搜索
给 InMemoryStore 加上向量嵌入,实现语义级别的记忆检索
默认的 store.search() 是关键词匹配。如果你需要"意思相近"的搜索——比如搜"用户喜欢什么"能匹配到"偏好深色主题"——就需要接入嵌入模型。
💡 什么是嵌入模型(Embedding)?点击展开
▼一句话解释:嵌入模型把一段文字变成一个数字数组(向量)。意思越相近的文字,得到的数字数组越接近。
举个例子,假设有一个简化版的嵌入模型,把每段话变成 4 个数字:
对比上面三组数字,前两组很接近(差别小),第三组和它们差别很大。这就反映了语义上的关系:前两句话意思相近,第三句完全不相关。
在 LangGraph 的语义搜索中,当你调用 store.search({ query: "用户的视觉偏好" }) 时,嵌入模型会先把 query 变成向量,然后和存储中所有记忆的向量做相似度计算,返回最接近的结果。这就是"语义搜索"——不需要精确的关键词匹配,只要意思相近就能找到。
真实的嵌入模型(如 text-embedding-3-small)会把文字变成 1536 个数字,而不是上面的 4 个。维度越高,表达越精细,但计算成本也越高。
import { InMemoryStore } from "@langchain/langgraph";
import { OpenAIEmbeddings } from "@langchain/openai";
// 创建带嵌入的存储
const embeddings = new OpenAIEmbeddings({
model: "text-embedding-3-small",
});
const store = new InMemoryStore({
index: {
dims: 1536, // 嵌入维度,取决于模型
embeddings: embeddings, // 嵌入模型实例(字段名是复数 embeddings)
},
});
// 现在 search 会做语义匹配
const results = await store.search(["user-123", "memories"], {
query: "用户的视觉偏好",
});
// 能匹配到 "用户偏好深色主题",即使没有关键词重叠
无嵌入(默认)
- 按 key 查找或关键词匹配
- 适合少量、精确的记忆
- 不需要额外的 API 调用
有嵌入
- 按语义相似度排序
- 适合大量、模糊的记忆检索
- 每次 put 自动计算嵌入向量
put 时会自动计算嵌入,大量写入时注意 API 限流。InMemoryStore 适合开发测试,生产环境建议使用持久化后端。
节点内使用
在节点函数中通过 runtime 参数访问跨线程存储
编译时传入了 store,节点内部就能通过第二个参数的 runtime 字段访问它。不需要闭包或全局变量。
// 节点签名:async (state, runtime) => { ... }
const recallMemories = async (state, runtime) => {
const userId = state.userId;
// 通过 runtime.store 访问跨线程存储
const memories = await runtime.store?.search(
[userId, "memories"],
{ query: state.userInput }
);
const memoryText = memories
?.map((m) => m.value.content)
.join("\n") || "无历史记忆";
return { context: memoryText };
};
// 保存新记忆的节点
const saveMemory = async (state, runtime) => {
const userId = state.userId;
await runtime.store?.put(
[userId, "memories"],
`memory-${Date.now()}`,
{ content: state.newFact, type: "fact" }
);
return { newFact: "" }; // 清空,避免重复保存
};
runtime.store?.search() / runtime.store?.put() / runtime.store?.get() / runtime.store?.delete()。注意可选链 ?.——如果编译时没传 store,runtime.store 为 undefined。
存储后端
根据场景选择合适的 Checkpointer 实现
| 后端 | 包名 | 适用场景 | 持久性 |
|---|---|---|---|
| MemorySaver | @langchain/langgraph |
开发调试、单元测试 | 内存 进程退出即丢失 |
| SQLite | @langchain/langgraph-checkpoint-sqlite |
单机部署、桌面应用 | 文件 单文件数据库 |
| Postgres | @langchain/langgraph-checkpoint-postgres |
生产环境、多实例部署 | 数据库 可扩展、可靠 |
| MongoDB | @langchain/langgraph-checkpoint-mongodb |
已有 MongoDB 基础设施 | 数据库 文档模型 |
| Redis | @langchain/langgraph-checkpoint-redis |
高吞吐、低延迟场景 | 缓存 可配置 TTL |
选择建议
- 开发阶段:用
MemorySaver,零配置 - 单机部署:用
SqliteSaver,一个文件搞定 - 生产环境:用
PostgresSaver,最成熟稳定 - 已有基建:选你最熟悉的数据库
切换成本
所有 Checkpointer 实现相同的接口,切换只需要改一行编译代码:
// 开发
import { MemorySaver } from "@langchain/langgraph";
.compile({ checkpointer: new MemorySaver() })
// 生产
import { PostgresSaver } from "...";
.compile({ checkpointer: new PostgresSaver(pool) })
排错指南
持久化使用中的常见问题与修复方案
Missing thread_id 报错
原因:编译时传了 checkpointer,但调用 invoke/stream 时没有传 configurable.thread_id。
修复:每次调用都必须传:{ configurable: { thread_id: "xxx" } }
状态没有持久化 / 第二次调不通
原因:用了不同的 thread_id,或者忘记编译时传 checkpointer。
修复:确认同一个对话使用相同的 thread_id;检查 compile({ checkpointer }) 是否有传。
getStateHistory 返回空
原因:该线程还没有执行过,或者 thread_id 拼写错误。
修复:先 invoke 一次产生检查点,再调用 getStateHistory。
updateState 后 next 不对
原因:as_node 参数决定了"下一个节点"的计算起点。
修复:理解 as_node 的含义——它告诉 LangGraph "这次更新相当于哪个节点的输出",从而确定图的执行位置。
动手练习
构建一个带持久化和跨线程记忆的对话图
练习 1:基础持久化
- 构建一个两节点图:
greet和farewell - 用
MemorySaver编译 - 用相同的
thread_id调用两次,观察状态累积 - 用
getState()打印状态快照
练习 2:历史与修改
- 用
getStateHistory()遍历所有检查点 - 打印每个检查点的
values和next - 用
updateState()修改某个历史状态 - 再次
getState()验证修改
练习 3:跨线程记忆
- 创建带
InMemoryStore的图 - 写一个节点保存用户信息到 store
- 写一个节点从 store 搜索记忆
- 用不同 thread_id 验证跨线程共享
// 参考答案:练习 1 & 2
import { StateSchema, StateGraph, MemorySaver, START, END } from "@langchain/langgraph";
import { z } from "zod";
const State = new StateSchema({
messages: z.array(z.string()).default(() => []),
});
const graph = new StateGraph(State)
.addNode("greet", (s) => ({ messages: [`Hello, ${s.name}!`] }))
.addNode("farewell", (s) => ({ messages: ["Goodbye!"] }))
.addEdge(START, "greet")
.addEdge("greet", "farewell")
.addEdge("farewell", END)
.compile({ checkpointer: new MemorySaver() });
const config = { configurable: { thread_id: "demo" } };
// 第一次调用
await graph.invoke({ messages: [], name: "Alice" }, config);
const snap = await graph.getState(config);
console.log(snap.values.messages); // ["Hello, Alice!", "Goodbye!"]
// 遍历历史
for await (const state of graph.getStateHistory(config)) {
console.log(`Step ${state.metadata?.step}:`, state.next, state.values);
}
本章回顾
持久化是 LangGraph 高级功能的基石
核心要点
- 线程:通过
thread_id隔离不同执行序列 - 检查点:每个超级步骤结束时的状态快照(
StateSnapshot) - 超级步骤:所有计划节点执行完毕 = 一个 tick
- MemorySaver:两步启用——编译时传 checkpointer,调用时传 thread_id
- getState / getStateHistory / updateState:读写检查点的三板斧
- InMemoryStore:跨线程的长期记忆,支持语义搜索
关键 API 速查
| 操作 | 方法 |
|---|---|
| 启用持久化 | compile({ checkpointer }) |
| 获取当前状态 | getState(config) |
| 遍历历史 | getStateHistory(config) |
| 修改状态 | updateState(config, values) |
| 跨线程存储 | compile({ store }) |
| 语义搜索 | store.search(ns, { query }) |
MemorySaver 开始,生产环境迁移到 PostgresSaver,切换成本只有一行代码。
官方文档
- LangGraph.js 官方文档 — 持久化完整 API
- Persistence 概念 — 检查点与线程的详细说明
- Memory 概念 — 跨线程记忆管理