Skip to content

第 4 章 · 持久化与记忆 ​

🎯 本章目标 ​

  • 理解"记忆"为什么是智能体的分水岭:没有记忆的图,每一轮都是陌生人
  • 掌握短期记忆:checkpointer + configurable.thread_id,让一段对话"记得住"
  • 学会把记忆落盘:用 SqliteSaver 跨进程重启保留历史
  • 会用 getState / getStateHistory 查看存档,用 updateState 做时间旅行
  • 分清短期记忆(按 thread 隔离)与长期记忆(store,跨 thread 共享)
  • 处理上下文膨胀:用 RemoveMessage 裁剪历史,别把窗口撑爆

本章代码

📌 代码约定:正文的代码块都保持可直接运行(含 import 与模型初始化)。若某段为聚焦概念的节选,会明确标注「节选」并指向同名的 code/ 完整文件。所有示例的输出都是真实运行结果,不是编造的示例输出。

文件内容
code/01-memory-saver-thread.tsMemorySaver + thread_id:记得住 / 失忆
code/02-sqlite-persistence.tsSqliteSaver 落盘:进程重启后记忆还在
code/03-state-history-timetravel.tsgetState / getStateHistory / updateState 与时间旅行
code/04-long-term-memory-store.tsInMemoryStore:跨 thread 的长期记忆
code/05-trim-messages.ts上下文膨胀与 RemoveMessage 裁剪

一、为什么"记忆"是智能体的分水岭 ​

前面几章里,我们的图都是"跑完就忘":调用 invoke(),走完节点,返回结果,状态烟消云散。

typescript
await graph.invoke({ messages: [new HumanMessage("你好")] }); // 第一轮
await graph.invoke({ messages: [new HumanMessage("我叫什么?")] }); // 第二轮:完全不知道上一轮

问题很明显:第二轮调用时,图根本不知道第一轮聊了什么 —— 因为没有任何地方保存过第一轮的状态。

解决它只有一个关键词:checkpointer(检查点存储器)。

没有 checkpointer:          有 checkpointer:
   invoke → 状态 → 丢弃         invoke → 状态 ──┐
                                               │ 存进 saver
   invoke → 状态 → 丢弃         invoke → 状态 ──┤(按 thread_id 归档)
                                               │ 读回来
                              → 于是"记得"上一轮

只要在 compile() 时挂上一个 checkpointer,图就会在每一步之后自动把状态存下来,并在下一次调用时自动读回来。这就是 LangGraph 的持久化机制,也是"有状态智能体"的地基。

💡 一个类比:checkpointer 像游戏存档,thread_id 是存档位。同一档位里读档,接着上次玩;换一档,从头开始。


二、短期记忆:MemorySaver + thread_id ​

示例 1:同一个 thread 记得住,换个 thread 就失忆 ​

typescript
/**
 * 示例 1:MemorySaver + thread_id —— 让对话"记得住"
 *
 * 核心概念:
 *   • checkpointer:把每一步的状态快照存下来(这里是内存,进程结束就没了)
 *   • thread_id:一次会话的"房间号"。同一个 thread_id → 同一段记忆;
 *                换一个 thread_id → 从零开始,完全失忆。
 *
 * 本示例用一个最简的"聊天机器人图"演示:同一个图、同一个 saver,
 * 仅靠 configurable.thread_id 的差别,就能看出"记得"与"失忆"。
 */
import { StateGraph, START, END, MessagesAnnotation, MemorySaver } from "@langchain/langgraph";
import { HumanMessage } from "@langchain/core/messages";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";

async function main() {
  banner("示例 1:MemorySaver + thread_id");
  assertChatKey();

  const model = createModel({ temperature: 0 });

  // 图:messages → 调模型 → 结束(把回答追加回 messages)
  const graph = new StateGraph(MessagesAnnotation)
    .addNode("chat", async (state) => {
      const res = await model.invoke(state.messages);
      return { messages: [res] };
    })
    .addEdge(START, "chat")
    .addEdge("chat", END)
    // ★ 关键:编译时挂上一个 checkpointer,图才有"记忆"能力
    .compile({ checkpointer: new MemorySaver() });

  /** 跑一轮对话:把用户输入交给图,返回模型这一轮的正文回答 */
  async function ask(threadId: string, text: string): Promise<string> {
    const result = await graph.invoke(
      { messages: [new HumanMessage(text)] },
      { configurable: { thread_id: threadId } } // ★ 房间号
    );
    return String(result.messages[result.messages.length - 1].content);
  }

  // ── 线程 A:连续两轮,看它记不记得名字 ──────────────────────
  console.log("🧵 线程 A(thread_id = \"thread-A\")");
  const a1 = "你好,我叫蜗牛,请记住我的名字。";
  console.log(`👤 ${a1}`);
  console.log(`🤖 ${await ask("thread-A", a1)}\n`);

  const a2 = "我刚才说我叫什么名字?只回答名字。";
  console.log(`👤 ${a2}`);
  console.log(`🤖 ${await ask("thread-A", a2)}\n`);

  // ── 线程 B:换一个 thread_id,问同样的问题 ──────────────────
  console.log("🧵 线程 B(thread_id = \"thread-B\",全新会话)");
  console.log(`👤 ${a2}`);
  console.log(`🤖 ${await ask("thread-B", a2)}\n`);

  // ── 对照 ───────────────────────────────────────────────────
  console.log("📌 对照结论:");
  console.log(`   线程 A 再问「我叫什么」 → ${(await ask("thread-A", "再确认一次:我叫什么?只回答名字。")).slice(0, 40)}`);
  console.log("   线程 A 记得名字;线程 B 因为 thread_id 不同,完全没有线程 A 的历史。");
}

main().catch((e) => {
  console.error("❌ 调用失败:", e);
  process.exit(1);
});

运行:

bash
npx tsx 04-persistence-and-memory/code/01-memory-saver-thread.ts

真实输出:

========================================================================
  示例 1:MemorySaver + thread_id
========================================================================

🧵 线程 A(thread_id = "thread-A")
👤 你好,我叫蜗牛,请记住我的名字。
🤖 你好,蜗牛!很高兴认识你,我记住啦——你叫蜗牛。有什么想聊的或需要帮忙的,尽管告诉我。

👤 我刚才说我叫什么名字?只回答名字。
🤖 蜗牛

🧵 线程 B(thread_id = "thread-B",全新会话)
👤 我刚才说我叫什么名字?只回答名字。
🤖 你没有说名字

📌 对照结论:
   线程 A 再问「我叫什么」 → 蜗牛
   线程 A 记得名字;线程 B 因为 thread_id 不同,完全没有线程 A 的历史。

看三个关键点:

  1. 线程 A 第二轮记得名字 —— 因为第一轮的状态被存进了 checkpointer,第二轮自动读回,messages 里还留着「我叫蜗牛」。
  2. 线程 B 完全失忆 —— 它用的是另一个 thread_id,checkpointer 里是空档,messages 只有它自己那一句,所以模型答"你没有说名字"。
  3. 图本身没有任何变化 —— 差别只在 configurable.thread_id。记忆不在图里,在 saver 里。

三个必须记住的细节 ​

① checkpointer 挂在 compile() 上,不是挂在 invoke() 上(节选):

typescript
import { MemorySaver } from "@langchain/langgraph";
const graph = builder.compile({ checkpointer: new MemorySaver() }); // ★ 记忆开关在这里

② thread_id 通过 configurable 传:invoke(input, { configurable: { thread_id } })。thread_id 相同 → 共享同一段 messages;不同 → 各聊各的、互不相通。

③ MemorySaver 只在内存里:它是"内存里的字典",进程一退出就全部清零,适合开发和测试。要跨进程/重启保留,就得换落盘的 saver —— 也就是示例 2。

⚠️ 记忆误区:以为 MemorySaver 会"永久记住"。它只活在这一个进程的生命周期内,重启即清零。


三、把记忆落盘:SqliteSaver ​

短期记忆要跨进程存活,就要把 checkpoint 写到一个文件里。LangGraph 官方提供了 SQLite 版:SqliteSaver。

示例 2:进程重启后,记忆还在 ​

typescript
/**
 * 示例 2:SqliteSaver 落盘 —— 进程重启后记忆还在
 *
 * MemorySaver 把快照存在内存里,进程一退出就全没了。
 * SqliteSaver 把快照写进一个 .sqlite 文件,于是"记忆"能跨进程存活。
 *
 * 本示例用「两次独立地打开 saver」来模拟进程重启:
 *   阶段 ①:新建 saver,写入一段对话,然后关闭数据库(= 进程退出)
 *   阶段 ②:重新新建 saver 打开同一个文件,把历史读回来,并接着往下聊
 */
import { rmSync } from "node:fs";
import { StateGraph, START, END, MessagesAnnotation } from "@langchain/langgraph";
import { SqliteSaver } from "@langchain/langgraph-checkpoint-sqlite";
import { HumanMessage, type BaseMessage } from "@langchain/core/messages";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";

// ★ 记忆文件路径(项目根目录外,用绝对路径写清楚,避免歧义)
const DB_PATH = "/tmp/langgraph-ch4-sqlite-demo.sqlite";
const THREAD_ID = "user-001";

const model = createModel({ temperature: 0 });

/** 每次都新建一张图,但共用同一个 saver —— 强调"记忆在 saver 里,不在图里" */
function buildGraph(saver: SqliteSaver) {
  return new StateGraph(MessagesAnnotation)
    .addNode("chat", async (state) => {
      const res = await model.invoke(state.messages);
      return { messages: [res] };
    })
    .addEdge(START, "chat")
    .addEdge("chat", END)
    .compile({ checkpointer: saver });
}

function printMessages(label: string, messages: BaseMessage[]) {
  console.log(`   ${label}(共 ${messages.length} 条):`);
  messages.forEach((m, i) => {
    console.log(`      ${i}. [${m.getType()}] ${String(m.content).replace(/\n/g, " ").slice(0, 70)}`);
  });
}

async function main() {
  banner("示例 2:SqliteSaver 落盘(跨进程重启)");
  assertChatKey();

  // 从头开始:删掉旧文件,保证每次运行的输出是确定的
  rmSync(DB_PATH, { force: true });

  // ── 阶段 ①:写入对话,然后"退出进程" ────────────────────────
  console.log("━━━ 阶段 ①(进程 #1):写入对话后关闭 ━━━\n");
  console.log(`📂 记忆文件:${DB_PATH}`);

  {
    const saver1 = SqliteSaver.fromConnString(DB_PATH);
    const graph1 = buildGraph(saver1);

    for (const text of [
      "你好,我叫蜗牛,是一名 Node.js 工程师。",
      "顺便记住:我最喜欢的颜色是蓝色。",
    ]) {
      console.log(`👤 ${text}`);
      const r = await graph1.invoke(
        { messages: [new HumanMessage(text)] },
        { configurable: { thread_id: THREAD_ID } }
      );
      console.log(`🤖 ${String(r.messages[r.messages.length - 1].content).replace(/\n/g, " ")}\n`);
    }

    // 关闭数据库连接 = 模拟进程退出
    saver1.db.close();
    console.log("💾 已关闭数据库连接(模拟进程 #1 退出)\n");
  }

  // ── 阶段 ②:重新打开同一个文件,"进程重启" ──────────────────
  console.log("━━━ 阶段 ②(进程 #2):重启后打开同一文件 ━━━\n");

  {
    const saver2 = SqliteSaver.fromConnString(DB_PATH);
    const graph2 = buildGraph(saver2);

    // 把持久化在磁盘上的历史读回来
    const snapshot = await graph2.getState({ configurable: { thread_id: THREAD_ID } });
    console.log("📜 从磁盘读回的历史(重启后依然存在):");
    printMessages("messages", snapshot.values.messages as BaseMessage[]);

    // 接着之前的对话继续问
    const q = "你还记得我叫什么、做哪一行、喜欢什么颜色吗?各用一两个词回答。";
    console.log(`\n👤 ${q}`);
    const r = await graph2.invoke(
      { messages: [new HumanMessage(q)] },
      { configurable: { thread_id: THREAD_ID } }
    );
    console.log(`🤖 ${String(r.messages[r.messages.length - 1].content).replace(/\n/g, " ")}`);

    saver2.db.close();
  }

  console.log("\n✅ 结论:记忆存在 .sqlite 文件里,图重启、进程重启都不会丢。");
}

main().catch((e) => {
  console.error("❌ 调用失败:", e);
  process.exit(1);
});

运行:

bash
npx tsx 04-persistence-and-memory/code/02-sqlite-persistence.ts

真实输出:

========================================================================
  示例 2:SqliteSaver 落盘(跨进程重启)
========================================================================

━━━ 阶段 ①(进程 #1):写入对话后关闭 ━━━

📂 记忆文件:/tmp/langgraph-ch4-sqlite-demo.sqlite
👤 你好,我叫蜗牛,是一名 Node.js 工程师。
🤖 你好,蜗牛!很高兴认识你,Node.js 工程师 👋   蜗牛这名字挺有反差感——慢工出细活,但 Node.js 的事件循环可是很能扛并发的。  有什么我可以帮你的吗?比如: - Node.js 性能调优、内存泄漏排查 - 异步/事件循环相关问题 - 架构设计、微服务、API 开发 - TypeScript、NestJS、Express/Koa/Fastify - 调试、部署、Docker、CI/CD  今天想聊点什么?

👤 顺便记住:我最喜欢的颜色是蓝色。
🤖 好的,蜗牛,记住了:**你最喜欢的颜色是蓝色**。   之后聊到配色、终端主题、UI 设计或者个人化示例时,我会优先想到蓝色。🔵

💾 已关闭数据库连接(模拟进程 #1 退出)

━━━ 阶段 ②(进程 #2):重启后打开同一文件 ━━━

📜 从磁盘读回的历史(重启后依然存在):
   messages(共 4 条):
      0. [human] 你好,我叫蜗牛,是一名 Node.js 工程师。
      1. [ai] 你好,蜗牛!很高兴认识你,Node.js 工程师 👋   蜗牛这名字挺有反差感——慢工出细活,但 Node.js 的事件循环可是很能扛并发
      2. [human] 顺便记住:我最喜欢的颜色是蓝色。
      3. [ai] 好的,蜗牛,记住了:**你最喜欢的颜色是蓝色**。   之后聊到配色、终端主题、UI 设计或者个人化示例时,我会优先想到蓝色。🔵

👤 你还记得我叫什么、做哪一行、喜欢什么颜色吗?各用一两个词回答。
🤖 名字:蜗牛   职业:Node.js 工程师   颜色:蓝色

✅ 结论:记忆存在 .sqlite 文件里,图重启、进程重启都不会丢。

阶段 ① 结束时,我们主动 saver1.db.close() 并丢弃了那个 saver 对象 —— 相当于进程退出。阶段 ② 用 SqliteSaver.fromConnString(DB_PATH) 重新打开同一个文件,getState() 就把 4 条历史原样读了回来,模型也基于这些历史回答了名字、职业和颜色。

三点说明:

  • fromConnString(path) 传的是文件路径(内部用 better-sqlite3 打开,首次自动建表):SqliteSaver.fromConnString("/tmp/langgraph-ch4-sqlite-demo.sqlite")。
  • saver.db.close() 是"进程退出"的模拟:显式关闭确保数据刷盘,再丢弃对象、重新构造,效果等价于重启。
  • 本示例用了 /tmp 下的绝对路径:示例开头 rmSync(DB_PATH, { force: true }) 删旧文件,保证每次运行输出确定。实际项目里换成项目内路径(如 ./chat.sqlite)即可;本仓库的 .gitignore 已经忽略了 *.sqlite,不用担心把记忆文件提交进 git。

💡 何时用哪种 saver:开发调试用 MemorySaver(零配置、跑得快);需要跨进程/重启保留就用 SqliteSaver(单机落盘);生产多实例、要分布式协作,再换 Postgres 等更重的 saver。接口都是同一套 BaseCheckpointSaver,换 saver 不用改图。


四、存档、历史与时间旅行 ​

挂了 checkpointer 之后,每一步都会留下一个 checkpoint(存档)。LangGraph 给了三个 API 来操作它们:

API作用
graph.getState(config)取"当前"存档(最新状态 + next 待执行节点)
graph.getStateHistory(config)列出这条 thread 上的所有存档(最新在前)
graph.updateState(config, values)改写某个存档的状态(会在其上分叉出新存档)

示例 3:列出所有存档,再回到过去重跑 ​

typescript
/**
 * 示例 3:状态历史与"时间旅行"
 *
 * 挂了 checkpointer 之后,每一步都会留下一个 checkpoint(存档)。
 *   • graph.getState(config)         → 看"当前"存档
 *   • graph.getStateHistory(config)  → 列出这条 thread 上的所有存档
 *   • graph.updateState(过去config…) → 回到某个旧存档,改写它的状态
 *   • graph.invoke(null, 旧config)   → 从这个旧存档"重新往下跑"
 *
 * 这就是"时间旅行":回到过去某一步,改一改,重新演一遍。
 */
import {
  StateGraph,
  START,
  END,
  MessagesAnnotation,
  MemorySaver,
  type StateSnapshot,
} from "@langchain/langgraph";
import { HumanMessage, AIMessage, type BaseMessage } from "@langchain/core/messages";
import { banner } from "../../lib/model.js";

const THREAD_ID = "time-travel-demo";

/** 建图:A → B → C,每步都往 messages 里追加一条 AI 消息 */
function buildGraph() {
  return new StateGraph(MessagesAnnotation)
    // 节点 A:追加"步骤A完成"
    .addNode("A", () => ({ messages: [new AIMessage("步骤A完成")] }))
    // 节点 B:追加一条消息,里面带上"执行时看到的 messages 条数"
    .addNode("B", (state: typeof MessagesAnnotation.State) => ({
      messages: [new AIMessage(`步骤B完成(执行时 messages 有 ${state.messages.length} 条)`)],
    }))
    // 节点 C:追加"步骤C完成"
    .addNode("C", () => ({ messages: [new AIMessage("步骤C完成")] }))
    .addEdge(START, "A")
    .addEdge("A", "B")
    .addEdge("B", "C")
    .addEdge("C", END)
    .compile({ checkpointer: new MemorySaver() });
}

/** 把一条消息压成一行短摘要 */
function brief(m: BaseMessage): string {
  return `[${m.getType()}] ${String(m.content).replace(/\n/g, " ").slice(0, 50)}`;
}

/** 打印一个存档的关键信息 */
function printSnapshot(label: string, s: StateSnapshot) {
  const cpId = String(s.config.configurable?.checkpoint_id ?? "(无)");
  // checkpoint_id 前段是时间戳(同一运行里都一样),所以我们只显示后 8 位来区分
  const next = s.next.length ? s.next.join(", ") : "(结束)";
  const msgs = (s.values.messages ?? []) as BaseMessage[];
  const last = msgs.length ? brief(msgs[msgs.length - 1]) : "(空)";
  console.log(`   ${label}  cp=…${cpId.slice(-8)}  next=[${next}]  messages=${msgs.length}`);
  console.log(`            最后一条:${last}`);
}

async function main() {
  banner("示例 3:状态历史与时间旅行");
  const graph = buildGraph();
  const baseConfig = { configurable: { thread_id: THREAD_ID } };

  // ── 1. 正常跑一次 ──────────────────────────────────────────
  console.log("▶ 第 1 次运行(HumanMessage:开始)");
  const first = await graph.invoke(
    { messages: [new HumanMessage("开始")] },
    baseConfig
  );
  console.log(`   最终 messages 条数:${first.messages.length}\n`);

  // ── 2. getState:看当前存档 ────────────────────────────────
  console.log("📌 graph.getState(config) —— 当前存档:");
  const current = await graph.getState(baseConfig);
  printSnapshot("当前", current);
  console.log();

  // ── 3. getStateHistory:列出所有存档(最新在前) ────────────
  console.log("📜 graph.getStateHistory(config) —— 全部存档:");
  const history: StateSnapshot[] = [];
  for await (const s of graph.getStateHistory(baseConfig)) history.push(s);

  // 反转成"从早到晚"更符合直觉
  const chrono = [...history].reverse();
  chrono.forEach((s, i) => printSnapshot(`#${i}`, s));
  console.log();

  // ── 4. 找到"步骤A之后、B之前"的那个存档 ─────────────────────
  //    特征:messages 以"步骤A完成"结尾,next=[B]
  const afterA = chrono.find(
    (s) =>
      s.next.includes("B") &&
      (s.values.messages as BaseMessage[]).some((m) => m.content === "步骤A完成")
  );
  if (!afterA) throw new Error("没有找到 步骤A 之后的存档");

  const cpAfterA = String(afterA.config.configurable?.checkpoint_id);
  console.log(`🕰 选中存档(步骤A之后):cp=…${cpAfterA.slice(-8)},next=[${afterA.next.join(", ")}]`);

  // ── 5. 时间旅行:改写这个旧存档,再从它重新往下跑 ───────────
  console.log("\n🔄 时间旅行:在旧存档里插一条消息,然后从那里重新执行 B、C");
  // 直接复用快照自带的 config —— 它包含 thread_id + checkpoint_ns + checkpoint_id
  const pastConfig = afterA.config;

  // 5.1 回到过去,改写状态(往 messages 里插一条 HumanMessage)
  //     注意:updateState 会返回一个「新存档」的 config(在旧存档上分叉出来的)
  const forkedConfig = await graph.updateState(pastConfig, {
    messages: [new HumanMessage("(时间旅行注入:这一步我插了一句话)")],
  });

  // 5.2 从这个「分叉出来的存档」重新 invoke(input 传 null:状态已在 checkpointer 里)
  const forked = await graph.invoke(null, forkedConfig);
  console.log(`   重新执行后的 messages 条数:${forked.messages.length}`);
  console.log("   重新执行后的完整消息:");
  (forked.messages as BaseMessage[]).forEach((m, i) => {
    console.log(`      ${i}. ${brief(m)}`);
  });

  console.log("\n💡 注意 B 节点这次的输出 —— 它看到的是「被改写后的状态」,");
  console.log("   所以它在时间线上走的是一条和第一次不同的分支。");
}

main().catch((e) => {
  console.error("❌ 调用失败:", e);
  process.exit(1);
});

运行:

bash
npx tsx 04-persistence-and-memory/code/03-state-history-timetravel.ts

真实输出:

========================================================================
  示例 3:状态历史与时间旅行
========================================================================

▶ 第 1 次运行(HumanMessage:开始)
   最终 messages 条数:4

📌 graph.getState(config) —— 当前存档:
   当前  cp=…6dbba9ae  next=[(结束)]  messages=4
            最后一条:[ai] 步骤C完成

📜 graph.getStateHistory(config) —— 全部存档:
   #0  cp=…60723b10  next=[__start__]  messages=0
            最后一条:(空)
   #1  cp=…43a13186  next=[A]  messages=1
            最后一条:[human] 开始
   #2  cp=…960091ed  next=[B]  messages=2
            最后一条:[ai] 步骤A完成
   #3  cp=…e8efdc70  next=[C]  messages=3
            最后一条:[ai] 步骤B完成(执行时 messages 有 2 条)
   #4  cp=…6dbba9ae  next=[(结束)]  messages=4
            最后一条:[ai] 步骤C完成

🕰 选中存档(步骤A之后):cp=…960091ed,next=[B]

🔄 时间旅行:在旧存档里插一条消息,然后从那里重新执行 B、C
   重新执行后的 messages 条数:5
   重新执行后的完整消息:
      0. [human] 开始
      1. [ai] 步骤A完成
      2. [human] (时间旅行注入:这一步我插了一句话)
      3. [ai] 步骤B完成(执行时 messages 有 3 条)
      4. [ai] 步骤C完成

💡 注意 B 节点这次的输出 —— 它看到的是「被改写后的状态」,
   所以它在时间线上走的是一条和第一次不同的分支。

逐段读这段输出:

  • 历史共 5 个存档(#0 到 #4):从 __start__ 之前(空状态)、到 A 前、B 前、C 前、结束。每个存档都记着 next(下一步该谁执行)和当时的 messages。
  • #2 号存档:next=[B]、messages=2,最后一条是「步骤A完成」—— 这就是"步骤 A 刚做完"的那一刻。
  • 时间旅行:我们回到 #2,插了一句「时间旅行注入」,再从那里重跑。结果 B 节点这次看到的是 3 条消息(因为多了一句),所以它写下「步骤B完成(执行时 messages 有 3 条)」,最终消息数变成 5。同一段历史,被改写后走出了另一条分支。

三个易踩的细节:

  • getStateHistory() 是"最新在前":它返回 async iterator,顺序从最新到最旧,所以迭代最近的存最省事;想按时间顺序看就 .reverse() 一下。
  • 恢复执行要复用快照自带的 config:别自己拼 { thread_id, checkpoint_id },很容易漏掉 checkpoint_ns,会报 Invalid configurable value for key "checkpoint_ns": expected a string identifier (got undefined)。快照的 config 已经含 thread_id + checkpoint_ns + checkpoint_id,直接用最稳(本项目就踩过这个坑)。
  • updateState() 返回的是"分叉存档"的 config:
typescript
const forkedConfig = await graph.updateState(pastConfig, {
  messages: [new HumanMessage("(时间旅行注入:这一步我插了一句话)")],
});
const forked = await graph.invoke(null, forkedConfig); // ★ 必须用返回的 config

updateState 不是"原地改过去",而是在旧存档上分叉出一个新存档;必须用它返回的 config 去 invoke,才会从分叉点续跑。用旧的 pastConfig 只会得到原分支,看不到改动。

⚠️ 时间旅行的经典用途是调试:发现模型在第 3 步走偏,就回到第 2 步改一改状态、重跑后面的步骤,而不用把整段对话重来。


五、长期记忆:Store 跨 thread 共享 ​

到这里我们要区分两种"记忆":

短期记忆长期记忆
载体checkpointerstore
作用域单个 thread_id跨所有 thread
存什么整条线程的完整状态(对话、字段…)你自定义的任意 { namespace, key } → value
类比聊天窗口的历史记录用户档案 / 知识库

一句话:短期记忆回答"这段对话聊到哪了",长期记忆回答"我关于这个用户/这个世界知道什么"。

长期记忆用一个独立对象 Store 承载,compile() 时和 checkpointer 一起传进去,节点里用 getStore(config) 取。

示例 5 之前先看示例 4:线程 A 写,线程 B 读 ​

typescript
/**
 * 示例 4:长期记忆 —— InMemoryStore(跨 thread)
 *
 * 短期记忆 = checkpointer + thread_id:一段对话里"记得住",但换 thread 就失忆。
 * 长期记忆 = store:按「命名空间 + key」存任意数据,**所有 thread 共享**。
 *
 * 本示例:
 *   线程 A 通过节点把"用户最爱吃的食物"写进 store;
 *   换一条全新的线程 B(完全不同的一段对话),节点依然能从 store 里读到它。
 *   —— 这就证明了"短期记忆按 thread 隔离,长期记忆跨 thread 共享"。
 */
import {
  StateGraph,
  START,
  END,
  Annotation,
  MemorySaver,
  InMemoryStore,
  getStore,
  messagesStateReducer,
  type LangGraphRunnableConfig,
} from "@langchain/langgraph";
import { HumanMessage, SystemMessage, type BaseMessage } from "@langchain/core/messages";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";

// ── 状态:messages(对话,按 thread 隔离)+ remember(本轮要写入长期记忆的口味)──
const ChatState = Annotation.Root({
  messages: Annotation<BaseMessage[]>({
    reducer: messagesStateReducer, // 和 MessagesAnnotation 一样:追加而非覆盖
    default: () => [],
  }),
  /** 本轮要写进长期记忆的食物;空字符串表示"本轮不写" */
  remember: Annotation<string | undefined>,
});

/** 长期记忆里的命名空间:按"用户"分目录,而不是按 thread */
const USER_ID = "user-42";
const NS = ["preferences", USER_ID];

async function main() {
  banner("示例 4:长期记忆 InMemoryStore(跨 thread)");
  assertChatKey();

  const model = createModel({ temperature: 0 });
  const store = new InMemoryStore(); // ★ 长期记忆仓库(内存版,进程结束即失)

  // ── 节点:既读写长期记忆,又调用模型 ────────────────────────
  async function assistant(
    state: typeof ChatState.State,
    config: LangGraphRunnableConfig
  ) {
    const s = getStore(config); // ★ 在节点里拿到 store
    if (!s) throw new Error("没有配置 store:请在 compile({ store }) 里传入");

    // ① 写:本轮带了 remember → 写进长期记忆
    if (state.remember) {
      await s.put(NS, "food", { food: state.remember });
      console.log(`   [长期记忆·写] namespace=[${NS.join("/")}] key=food → ${state.remember}`);
    }

    // ② 读:从长期记忆里取用户口味
    const item = await s.get(NS, "food");
    const food = (item?.value?.food as string | undefined) ?? undefined;
    console.log(`   [长期记忆·读] food = ${food ?? "(尚未记录)"}`);

    // ③ 用读到的长期记忆构造系统提示,再让模型回答
    const system = food
      ? `你记得这位用户(${USER_ID})最喜欢吃:${food}。回答时自然地体现这份记忆。`
      : `你还不知道这位用户(${USER_ID})的口味偏好。`;

    const res = await model.invoke([new SystemMessage(system), ...state.messages]);
    return { messages: [res], remember: "" }; // 写完后清空 remember
  }

  const graph = new StateGraph(ChatState)
    .addNode("assistant", assistant)
    .addEdge(START, "assistant")
    .addEdge("assistant", END)
    // ★ 同时挂 checkpointer(短期记忆)和 store(长期记忆)
    .compile({ checkpointer: new MemorySaver(), store });

  // ── 线程 A:把口味写进长期记忆 ──────────────────────────────
  console.log("🧵 线程 A(thread-A):告诉它我的口味\n");
  const a = await graph.invoke(
    { messages: [new HumanMessage("你好,我叫蜗牛。")], remember: "兰州拉面" },
    { configurable: { thread_id: "thread-A" } }
  );
  console.log(`🤖 ${String(a.messages[a.messages.length - 1].content).replace(/\n/g, " ")}\n`);

  // ── 线程 B:全新会话,但同一个用户 ──────────────────────────
  console.log("🧵 线程 B(thread-B,全新对话,但同一个用户):问晚上吃什么\n");
  const b = await graph.invoke(
    { messages: [new HumanMessage("晚上吃点什么好?")] }, // 注意:这里没有 remember
    { configurable: { thread_id: "thread-B" } }
  );
  console.log(`🤖 ${String(b.messages[b.messages.length - 1].content).replace(/\n/g, " ")}\n`);

  // ── 对照:短期记忆是"按 thread 隔离"的 ──────────────────────
  const sA = await graph.getState({ configurable: { thread_id: "thread-A" } });
  const sB = await graph.getState({ configurable: { thread_id: "thread-B" } });
  const nA = (sA.values.messages as BaseMessage[]).length;
  const nB = (sB.values.messages as BaseMessage[]).length;
  console.log("📌 短期记忆对照(按 thread 隔离):");
  console.log(`   thread-A 的对话有 ${nA} 条消息;thread-B 只有 ${nB} 条。`);
  console.log("   → 两条 thread 的对话历史互不相通(短期记忆跟着 thread 走)。");
  console.log("\n📌 长期记忆对照(跨 thread 共享):");
  const back = await store.get(NS, "food");
  console.log(`   store 里存的 food = ${back?.value?.food}`);
  console.log("   → thread-B 虽然在另一条时间线上,却读到了 thread-A 写下的偏好。");
}

main().catch((e) => {
  console.error("❌ 调用失败:", e);
  process.exit(1);
});

运行:

bash
npx tsx 04-persistence-and-memory/code/04-long-term-memory-store.ts

真实输出:

========================================================================
  示例 4:长期记忆 InMemoryStore(跨 thread)
========================================================================

🧵 线程 A(thread-A):告诉它我的口味

   [长期记忆·写] namespace=[preferences/user-42] key=food → 兰州拉面
   [长期记忆·读] food = 兰州拉面
🤖 你好呀,蜗牛!又见到你啦~我记得你最喜欢吃兰州拉面,最近有没有去吃一碗热腾腾的?🍜

🧵 线程 B(thread-B,全新对话,但同一个用户):问晚上吃什么

   [长期记忆·读] food = 兰州拉面
🤖 晚上不知道吃什么的话,我第一反应就是你最喜欢的兰州拉面呀。来一碗热乎乎的,多加牛肉、蒜苗和辣子,再配个卤蛋或小菜,简单又满足。要是今天想换换口味,也可以先告诉我大概想吃什么类型,我再帮你选。

📌 短期记忆对照(按 thread 隔离):
   thread-A 的对话有 2 条消息;thread-B 只有 2 条。
   → 两条 thread 的对话历史互不相通(短期记忆跟着 thread 走)。

📌 长期记忆对照(跨 thread 共享):
   store 里存的 food = 兰州拉面
   → thread-B 虽然在另一条时间线上,却读到了 thread-A 写下的偏好。

关键点:

  1. 线程 B 是全新的(另一条 thread_id,对话历史里没有"蜗牛爱吃什么"),但它照样在回答里提到了"兰州拉面"—— 因为它从 store 里读到了。
  2. 对照清楚地展示了两种记忆的边界:两条 thread 的 messages 互不相通(各 2 条),但 store 里的 food 是共享的。

四个必须掌握的 API 细节:

  • store 在 compile() 时传入,节点里用 getStore(config) 取;节点的第二个参数就是 config,别忘了传给 getStore,否则拿不到:
typescript
graph = new StateGraph(ChatState)
  .addNode("assistant", assistant) // assistant(state, config)
  .compile({ checkpointer: new MemorySaver(), store }); // ★ 两个一起挂

async function assistant(state: typeof ChatState.State, config: LangGraphRunnableConfig) {
  const s = getStore(config); // 就是 compile 时传进去的 store
}
  • 数据按「namespace + key」组织:namespace 是字符串数组(像目录),key 是记录名。按"用户/业务"分 namespace,而不是按 thread —— 这正是长期记忆"跨 thread"的本质。
typescript
await s.put(["preferences", "user-42"], "food", { food: "兰州拉面" });
const item = await s.get(["preferences", "user-42"], "food");
// item.value.food === "兰州拉面"
  • InMemoryStore 同样是内存版(import { InMemoryStore } from "@langchain/langgraph"):进程结束就没了。生产要持久化的长期记忆,同样换落盘的 store 实现(接口都是 BaseStore)。
  • store 还有 search() 和 listNamespaces():支持按命名空间前缀检索、按字段过滤,还支持向量检索(配合 embedding 做语义搜索):
typescript
await s.search(["documents"], { filter: { type: "report" }, limit: 5 });

💡 一句话记忆:thread_id 管"哪段对话",namespace 管"哪个用户"。 短期记忆跟着 thread 走,长期记忆跟着 namespace 走。


六、上下文膨胀与消息裁剪 ​

挂了 checkpointer 后,messages 会一轮一轮往上加:

轮次:  1     2     3     4     5
条数:  2  →  4  →  6  →  8  →  10   (每轮 +2)

一直聊下去,迟早会顶爆模型的上下文窗口,还烧 token。必须学会裁剪历史。

LangGraph 的做法是在状态里真正删除旧消息:messagesStateReducer 认得一个特殊对象 RemoveMessage —— 它不追加消息,而是按 id 把对应消息从数组里删掉。

示例 5:不裁剪 vs 每轮裁剪 ​

typescript
/**
 * 示例 5:上下文膨胀与消息裁剪(RemoveMessage)
 *
 * 挂了 checkpointer 后,messages 会一轮一轮地累积,越滚越长。
 *   轮次:1 → 2 → 3 → 4 → 5 …
 *   条数:2 → 4 → 6 → 8 → 10 …   (每轮 +2)
 * 最终会顶爆模型的上下文窗口,也烧钱。
 *
 * 解决思路:只保留最近的 N 条。LangGraph 提供了 RemoveMessage,
 * 配合 messagesStateReducer,可以在状态里**真正删掉**旧消息。
 *
 * 本示例跑两遍同样长的对话:
 *   A) 不裁剪:看 messages 一路膨胀;
 *   B) 带裁剪节点:看 messages 被稳定地压在 N 条以内。
 */
import {
  StateGraph,
  START,
  END,
  MessagesAnnotation,
  MemorySaver,
} from "@langchain/langgraph";
import { HumanMessage, AIMessage, RemoveMessage, type BaseMessage } from "@langchain/core/messages";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";

const MAX_MESSAGES = 6; // 最多保留最近 6 条(3 轮对话)

const model = createModel({ temperature: 0 });

/** 把一条消息压成一行短摘要 */
function brief(m: BaseMessage): string {
  return `[${m.getType()}] ${String(m.content).replace(/\n/g, " ").slice(0, 34)}`;
}

/** 只保留最近 N 条:为更早的每条消息生成一个 RemoveMessage */
function trimMessages(messages: BaseMessage[]): RemoveMessage[] {
  if (messages.length <= MAX_MESSAGES) return [];
  return messages
    .slice(0, messages.length - MAX_MESSAGES)
    .map((m) => new RemoveMessage({ id: m.id as string }));
}

/** 聊天节点:调模型并追加回答 */
async function chatNode(state: typeof MessagesAnnotation.State) {
  const res = await model.invoke(state.messages);
  return { messages: [res] };
}

/** 裁剪节点:把超出 N 条的旧消息从状态里删掉 */
function trimNode(state: typeof MessagesAnnotation.State) {
  const before = state.messages;
  const removals = trimMessages(before);
  if (removals.length === 0) {
    console.log(`   [裁剪节点] 当前 ${before.length} 条,未超过 ${MAX_MESSAGES} 条,不裁剪`);
    return {};
  }
  const kept = before.slice(before.length - MAX_MESSAGES);
  console.log(`   [裁剪节点] 裁剪前 ${before.length} 条 → 裁剪后 ${kept.length} 条`);
  console.log("      被删掉的:");
  before.slice(0, before.length - MAX_MESSAGES).forEach((m) => console.log(`         - ${brief(m)}`));
  console.log("      保留下来的:");
  kept.forEach((m) => console.log(`         + ${brief(m)}`));
  return { messages: removals };
}

const QUESTIONS = [
  "第1句:请回复“收到1”。",
  "第2句:请回复“收到2”。",
  "第3句:请回复“收到3”。",
  "第4句:请回复“收到4”。",
  "第5句:请回复“收到5”。",
];

async function main() {
  banner("示例 5:上下文膨胀与 RemoveMessage 裁剪");
  assertChatKey();

  // ── A) 不裁剪:messages 一路膨胀 ────────────────────────────
  console.log("━━━ A) 不裁剪 ━━━");
  const plain = new StateGraph(MessagesAnnotation)
    .addNode("chat", chatNode)
    .addEdge(START, "chat")
    .addEdge("chat", END)
    .compile({ checkpointer: new MemorySaver() });

  for (let i = 0; i < QUESTIONS.length; i++) {
    const r = await plain.invoke(
      { messages: [new HumanMessage(QUESTIONS[i])] },
      { configurable: { thread_id: "plain-thread" } }
    );
    console.log(`   第 ${i + 1} 轮后:messages = ${r.messages.length} 条`);
  }

  // ── B) 带裁剪节点:稳定压在 N 条以内 ────────────────────────
  console.log("\n━━━ B) 每轮之后裁剪(保留最近 6 条)━━━");
  const trimming = new StateGraph(MessagesAnnotation)
    .addNode("chat", chatNode)
    .addNode("trim", trimNode)
    .addEdge(START, "chat")
    .addEdge("chat", "trim")
    .addEdge("trim", END)
    .compile({ checkpointer: new MemorySaver() });

  let finalMessages: BaseMessage[] = [];
  for (let i = 0; i < QUESTIONS.length; i++) {
    console.log(`\n   ── 第 ${i + 1} 轮 ──`);
    const r = await trimming.invoke(
      { messages: [new HumanMessage(QUESTIONS[i])] },
      { configurable: { thread_id: "trim-thread" } }
    );
    finalMessages = r.messages;
    console.log(`   第 ${i + 1} 轮后:messages = ${r.messages.length} 条`);
  }

  // ── 对照 ───────────────────────────────────────────────────
  const sPlain = await plain.getState({ configurable: { thread_id: "plain-thread" } });
  console.log("\n📌 对照(同样 5 轮对话):");
  console.log(`   不裁剪的 thread:messages = ${(sPlain.values.messages as BaseMessage[]).length} 条(一路涨)`);
  console.log(`   带裁剪的 thread:messages = ${finalMessages.length} 条(稳定在 ${MAX_MESSAGES} 以内)`);
  console.log("\n   裁剪后状态里剩下的消息:");
  finalMessages.forEach((m, i) => console.log(`      ${i}. ${brief(m)}`));
}

main().catch((e) => {
  console.error("❌ 调用失败:", e);
  process.exit(1);
});

运行:

bash
npx tsx 04-persistence-and-memory/code/05-trim-messages.ts

真实输出:

========================================================================
  示例 5:上下文膨胀与 RemoveMessage 裁剪
========================================================================

━━━ A) 不裁剪 ━━━
   第 1 轮后:messages = 2 条
   第 2 轮后:messages = 4 条
   第 3 轮后:messages = 6 条
   第 4 轮后:messages = 8 条
   第 5 轮后:messages = 10 条

━━━ B) 每轮之后裁剪(保留最近 6 条)━━━

   ── 第 1 轮 ──
   [裁剪节点] 当前 2 条,未超过 6 条,不裁剪
   第 1 轮后:messages = 2 条

   ── 第 2 轮 ──
   [裁剪节点] 当前 4 条,未超过 6 条,不裁剪
   第 2 轮后:messages = 4 条

   ── 第 3 轮 ──
   [裁剪节点] 当前 6 条,未超过 6 条,不裁剪
   第 3 轮后:messages = 6 条

   ── 第 4 轮 ──
   [裁剪节点] 裁剪前 8 条 → 裁剪后 6 条
      被删掉的:
         - [human] 第1句:请回复“收到1”。
         - [ai] 收到1
      保留下来的:
         + [human] 第2句:请回复“收到2”。
         + [ai] 收到2
         + [human] 第3句:请回复“收到3”。
         + [ai] 收到3
         + [human] 第4句:请回复“收到4”。
         + [ai] 收到4
   第 4 轮后:messages = 6 条

   ── 第 5 轮 ──
   [裁剪节点] 裁剪前 8 条 → 裁剪后 6 条
      被删掉的:
         - [human] 第2句:请回复“收到2”。
         - [ai] 收到2
      保留下来的:
         + [human] 第3句:请回复“收到3”。
         + [ai] 收到3
         + [human] 第4句:请回复“收到4”。
         + [ai] 收到4
         + [human] 第5句:请回复“收到5”。
         + [ai] 收到5
   第 5 轮后:messages = 6 条

📌 对照(同样 5 轮对话):
   不裁剪的 thread:messages = 10 条(一路涨)
   带裁剪的 thread:messages = 6 条(稳定在 6 以内)

   裁剪后状态里剩下的消息:
      0. [human] 第3句:请回复“收到3”。
      1. [ai] 收到3
      2. [human] 第4句:请回复“收到4”。
      3. [ai] 收到4
      4. [human] 第5句:请回复“收到5”。
      5. [ai] 收到5

对照非常直白:不裁剪的 thread 滚到 10 条;带裁剪的 thread 稳定停在 6 条,而且每次删的都刚好是最老的一组。

三个细节:

  • RemoveMessage 要带 id:它靠 id 定位删哪条,所以每条消息必须有 id(new RemoveMessage({ id: m.id as string }))。LangGraph 自动生成的消息都带 id,手写 new HumanMessage(...) 时运行时也会补上。
  • 裁剪节点返回的是"一组删除指令":return { messages: removals }(removals: RemoveMessage[])。这些对象会被 messagesStateReducer 解释成"删掉这些 id"而非"追加" —— 这就是 MessagesAnnotation 天生支持裁剪的原因。
  • 别把"最近几轮"拆散:示例按"整条"裁剪(保留最近 N 条)。真项目更常见的是按轮次裁剪(保留最近 N 轮),避免把一条 tool_calls 和它的 ToolMessage 拆开导致 API 报错;思路一样,只是挑选 RemoveMessage 的边界不同。

💡 还有个更狠的开关:REMOVE_ALL_MESSAGES。new RemoveMessage({ id: REMOVE_ALL_MESSAGES }) 会清空整个 messages(常配合"摘要记忆":把老对话压成一段总结,再清空原始消息)。它从 @langchain/langgraph 导入。


七、本章小结 ​

本章速查表

你要做的事代码
开启记忆.compile({ checkpointer: new MemorySaver() })
指定会话invoke(input, { configurable: { thread_id: "..." } })
落盘记忆SqliteSaver.fromConnString("/path/x.sqlite")
加载长期记忆.compile({ checkpointer, store })
节点里取 storegetStore(config)(节点第二参是 config)
写长期记忆store.put(["ns", "user"], "key", { ... })
读长期记忆store.get(["ns", "user"], "key")
查当前存档graph.getState(config)
列全部存档for await (const s of graph.getStateHistory(config))
改写旧存档graph.updateState(pastConfig, values) → 用返回的 config 续跑
续跑(不传新输入)graph.invoke(null, config)
裁剪消息new RemoveMessage({ id }) + messagesStateReducer

三句话总结

  1. checkpointer + thread_id = 短期记忆:图本身无状态,记忆全靠 saver 按 thread 归档;换 thread_id 就换一段互不相通的历史。
  2. MemorySaver 在内存、SqliteSaver 在磁盘、store 跨 thread —— 三者接口统一,按"要不要跨进程 / 要不要跨 thread"来选。
  3. 每一步都留下 checkpoint:getState / getStateHistory 能看,updateState 能改,于是"时间旅行"和"消息裁剪"都成了顺手的事。

动手练习

  1. 把示例 2 的 DB_PATH 改成项目内的 ./04-persistence-and-memory/chat.sqlite,先跑一次;注释掉开头的 rmSync 再跑一次,观察第二次运行时「从磁盘读回的历史」是不是接着上次继续变长。
  2. 给示例 1 加一条线程 C:先在线程 A 告诉它"我叫蜗牛",再在线程 C 问"我叫什么",确认它答不出来;然后给示例 4 的 store 里写入 { name: "蜗牛" },用 getStore 在线程 C 的节点里读出来,让线程 C 也能叫出名字 —— 亲手体会"短期 vs 长期"的区别。
  3. 改造示例 3:不要注入 HumanMessage,而是用 updateState 把 messages 里「步骤B完成」那条替换/删除掉,再重跑,观察历史分支的变化。(提示:updateState 的 values.messages 里放 RemoveMessage 就能删。)

下一章预告

有了"能暂停、能存档"的地基,下一章我们请出 LangGraph 的招牌能力之一:人工介入(Human-in-the-Loop)。用 interrupt() 让图在关键处停下来等人类批准,配合本章的 checkpointer 做到"暂停后过几天再恢复"—— 这才是真正能上线跑业务的智能体。


← 上一章:节点、边与控制流 | 返回总目录 | 下一章:人工介入(HITL) →

内容基于 LangGraph.js 官方文档与示例(langchain-ai/langgraphjs,MIT License)整理并重排为中文学习路径