第 4 章 · 持久化与记忆
🎯 本章目标
- 理解"记忆"为什么是智能体的分水岭:没有记忆的图,每一轮都是陌生人
- 掌握短期记忆:
checkpointer+configurable.thread_id,让一段对话"记得住" - 学会把记忆落盘:用
SqliteSaver跨进程重启保留历史 - 会用
getState/getStateHistory查看存档,用updateState做时间旅行 - 分清短期记忆(按 thread 隔离)与长期记忆(store,跨 thread 共享)
- 处理上下文膨胀:用
RemoveMessage裁剪历史,别把窗口撑爆
本章代码
📌 代码约定:正文的代码块都保持可直接运行(含 import 与模型初始化)。若某段为聚焦概念的节选,会明确标注「节选」并指向同名的
code/完整文件。所有示例的输出都是真实运行结果,不是编造的示例输出。
| 文件 | 内容 |
|---|---|
code/01-memory-saver-thread.ts | MemorySaver + thread_id:记得住 / 失忆 |
code/02-sqlite-persistence.ts | SqliteSaver 落盘:进程重启后记忆还在 |
code/03-state-history-timetravel.ts | getState / getStateHistory / updateState 与时间旅行 |
code/04-long-term-memory-store.ts | InMemoryStore:跨 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 的历史。看三个关键点:
- 线程 A 第二轮记得名字 —— 因为第一轮的状态被存进了 checkpointer,第二轮自动读回,
messages里还留着「我叫蜗牛」。 - 线程 B 完全失忆 —— 它用的是另一个
thread_id,checkpointer 里是空档,messages只有它自己那一句,所以模型答"你没有说名字"。 - 图本身没有任何变化 —— 差别只在
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); // ★ 必须用返回的 configupdateState 不是"原地改过去",而是在旧存档上分叉出一个新存档;必须用它返回的 config 去 invoke,才会从分叉点续跑。用旧的 pastConfig 只会得到原分支,看不到改动。
⚠️ 时间旅行的经典用途是调试:发现模型在第 3 步走偏,就回到第 2 步改一改状态、重跑后面的步骤,而不用把整段对话重来。
五、长期记忆:Store 跨 thread 共享
到这里我们要区分两种"记忆":
| 短期记忆 | 长期记忆 | |
|---|---|---|
| 载体 | checkpointer | store |
| 作用域 | 单个 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 写下的偏好。关键点:
- 线程 B 是全新的(另一条
thread_id,对话历史里没有"蜗牛爱吃什么"),但它照样在回答里提到了"兰州拉面"—— 因为它从 store 里读到了。 - 对照清楚地展示了两种记忆的边界:两条 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 }) |
| 节点里取 store | getStore(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 |
三句话总结
- checkpointer + thread_id = 短期记忆:图本身无状态,记忆全靠 saver 按 thread 归档;换
thread_id就换一段互不相通的历史。 - MemorySaver 在内存、SqliteSaver 在磁盘、store 跨 thread —— 三者接口统一,按"要不要跨进程 / 要不要跨 thread"来选。
- 每一步都留下 checkpoint:
getState/getStateHistory能看,updateState能改,于是"时间旅行"和"消息裁剪"都成了顺手的事。
动手练习
- 把示例 2 的
DB_PATH改成项目内的./04-persistence-and-memory/chat.sqlite,先跑一次;注释掉开头的rmSync再跑一次,观察第二次运行时「从磁盘读回的历史」是不是接着上次继续变长。 - 给示例 1 加一条线程 C:先在线程 A 告诉它"我叫蜗牛",再在线程 C 问"我叫什么",确认它答不出来;然后给示例 4 的 store 里写入
{ name: "蜗牛" },用getStore在线程 C 的节点里读出来,让线程 C 也能叫出名字 —— 亲手体会"短期 vs 长期"的区别。 - 改造示例 3:不要注入
HumanMessage,而是用updateState把messages里「步骤B完成」那条替换/删除掉,再重跑,观察历史分支的变化。(提示:updateState的values.messages里放RemoveMessage就能删。)
下一章预告
有了"能暂停、能存档"的地基,下一章我们请出 LangGraph 的招牌能力之一:人工介入(Human-in-the-Loop)。用 interrupt() 让图在关键处停下来等人类批准,配合本章的 checkpointer 做到"暂停后过几天再恢复"—— 这才是真正能上线跑业务的智能体。