第 05 章

持久化

将图的每一步状态保存为检查点,让智能体真正"记得"自己做了什么

线程、检查点、跨线程存储 —— 从无状态到有记忆的基石

状态丢了

你有没有遇到过这样的情况?

没有持久化

  • 图执行到一半崩了,所有中间结果全丢了,只能从头再来
  • 想查看上一步的状态?只能自己加 console.log
  • 想在某个节点暂停、等人确认后继续?做不到——没有"恢复"机制
  • 多个用户同时用,状态混在一起,分不清谁是谁
  • 用户说"回到刚才那一步",你一脸茫然——没有历史记录

有持久化

  • 每一步自动存快照,崩了从上一个检查点恢复
  • 随时用 getState() 查看当前完整状态
  • 在任意节点暂停,确认后调用 invoke 继续
  • 每个线程独立 thread_id,状态互不干扰
  • getStateHistory() 完整回溯,甚至可以"时间旅行"
  • 节点部分成功时,其他已完成节点的结果不会丢——恢复时跳过它们
持久化是 LangGraph 工程化的基础:没有人机交互(HITL)、没有记忆、没有时间旅行、没有容错恢复——这些高级功能全部建立在检查点(checkpoint)之上。

开始之前

需要具备

  • 已完成第 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。

核心概念

理解持久化之前,先搞清三个关键词

图:线程、检查点与超级步骤的关系

线程 Threads

每个检查点序列有唯一的 thread_id。不同线程的状态完全隔离,互不影响。

configurable.thread_id

检查点 Checkpoints

每个超级步骤结束时保存的状态快照(StateSnapshot)。包含值、下一步、元数据等。

StateSnapshot

超级步骤 Super-steps

一个"滴答"——所有当前计划执行的节点跑完,就产生一个新的检查点。

一个 tick

简单记忆:线程是"账本",检查点是"每一页",超级步骤是"翻页的动作"。你必须指定 thread_id,否则 LangGraph 不知道把状态记到哪个账本里。

步骤与快照

对于 START → A → B → END 的简单图,执行一次会产生 4 个检查点

图:超级步骤与检查点的产生过程
思考题:你做了一个聊天机器人,但发现用户每次发消息,AI 都像失忆了一样——完全不记得之前说过什么。你需要让对话有"记忆":同一用户的多次对话保持上下文,不同用户的对话要互相隔离。
// 定义带 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。

📄 完整代码:05_memory_saver.ts

使用方式

从创建到运行——MemorySaver 的完整用法

思考题:编译时传了 checkpointer,运行时忘了传 thread_id 会怎样?同一个 thread_id 第二次 invoke 会发生什么——是从头开始执行,还是跳过已完成的节点?
// 完整示例:一个带持久化的简单图
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"]

📄 完整代码:05_memory_saver.ts

两步启用持久化:①编译时传 { checkpointer: new MemorySaver() };②调用时传 { configurable: { thread_id: "..." } }。缺一不可。

获取状态

用 getState() 查看当前线程的完整状态快照

思考题:你的 Agent 运行了好几步,现在你想看看当前的状态是什么、翻看之前每一步的历史记录、直接手动改一下状态里的某个值,怎么做到?
// 获取当前线程的最新状态
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");

📄 完整代码:05_state_snapshot.ts

快照字段

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() 直接修改检查点中的状态值

思考题:人工审核后需要把修改注入到状态里继续执行。但如果指定了 as_node 参数,下一步 next 会怎么变化?更新后的值会经过 reducer 处理吗?
// 直接修改当前线程的状态
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 之后的节点

📄 完整代码:05_state_snapshot.ts

典型用途

  • 人工审核后修改中间结果
  • 注入额外的上下文信息
  • 测试时模拟节点输出
  • 回滚到特定检查点后调整状态

注意事项

  • 更新会经过 reducer,不是直接覆盖
  • as_node 会影响 next 的计算
  • 更新后会产生新的检查点
  • 必须指定 thread_id

跨线程存储

检查点是线程内的,InMemoryStore 用于跨线程共享数据

检查点绑定在单个线程上,不同线程之间无法共享状态。如果你需要跨线程的记忆——比如用户偏好、长期知识库——就需要 InMemoryStore。

思考题:你用 thread_id 隔离了每个对话,但发现用户在对话A里说"我喜欢深色主题",到了对话B里 AI 就忘了这个偏好了。你需要一种跨对话共享的"长期记忆"。
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 个数字:

"我喜欢深色主题"
[0.8, 0.3, 0.9, 0.1]
"深色模式更好看"
[0.7, 0.4, 0.8, 0.2]
"今天吃火锅"
[0.1, 0.9, 0.2, 0.8]

对比上面三组数字,前两组很接近(差别小),第三组和它们差别很大。这就反映了语义上的关系:前两句话意思相近,第三句完全不相关。

"我喜欢深色主题" vs "深色模式更好看"
92%
"我喜欢深色主题" vs "今天吃火锅"
15%

在 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: "用户的视觉偏好",
});
// 能匹配到 "用户偏好深色主题",即使没有关键词重叠

📄 完整代码:05_semantic_search.ts

无嵌入(默认)

  • 按 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(内存),一切正常。但部署到生产环境后,服务一重启,所有对话记忆就丢了。你需要换一个持久化的后端,但不想改业务代码。
后端包名适用场景持久性
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) })

📄 完整代码:05_checkpointer_backends.ts

排错指南

持久化使用中的常见问题与修复方案

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 "这次更新相当于哪个节点的输出",从而确定图的执行位置。

快速自检清单:编译时传了 checkpointer 吗?调用时传了 thread_id 吗?同一次对话的 thread_id 一致吗?节点函数签名包含 runtime 参数吗(如果要用 store)?

动手练习

构建一个带持久化和跨线程记忆的对话图

练习 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 })
核心结论:持久化让你从"跑一次丢一次"变成"每一步都记得住"。它是人机交互、时间旅行、容错恢复的基础。选择合适的 Checkpointer 后端,从 MemorySaver 开始,生产环境迁移到 PostgresSaver,切换成本只有一行代码。

继续本课程

官方文档