上一篇(demo-18)用 checkpointer 实现了短期记忆:同一个 thread_id 内,多轮对话能接上。但换一个 thread_id,对话历史就看不到了。如果希望「这次会话里知道的用户信息,下次换一个会话还能用」,就需要长期记忆。
这篇进入长期记忆的实操:先讲清概念、分类和存储架构,再用 LangChain 的 store 跑通最基础的 put、get、search,最后把 store 接进 Agent,验证跨 thread 的记忆。
代码基于 JavaScript 版
langchainv1,模型用 DeepSeek。store 部分使用@langchain/langgraph提供的InMemoryStore。
一、长期记忆要解决什么
先看短期记忆的边界。下面两段对话用了不同的 thread_id:
// thread-A:告诉它用户信息
await agent.invoke(
{ messages: [{ role: "user", content: "我叫小明,住在杭州。" }] },
{ configurable: { thread_id: "thread-A" } }
);
// thread-B:换一个会话再问
await agent.invoke(
{ messages: [{ role: "user", content: "我叫什么?住在哪?" }] },
{ configurable: { thread_id: "thread-B" } }
);
// 助手:我不知道你的名字和住址。
这不是 checkpointer 失效,而是它本来只按 thread 保存状态。跨 thread 的信息,需要另一套存储:store。
| 短期记忆 | 长期记忆 | |
|---|---|---|
| 作用范围 | 单个 thread / 会话 | 跨 thread / 跨会话 |
| 典型载体 | checkpointer 里的 state(messages) | store 里的 JSON 文档 |
| 存什么 | 当前对话消息、临时状态 | 用户画像、稳定事实、偏好、经验、规则 |
| 生命周期 | 会话级 | 长期,直到被更新或删除 |
| 检索方式 | 按 thread_id 恢复 state | 按 namespace + key 读取,或按内容搜索 |
| 对应参数 | checkpointer | store |
一句话区分:
checkpointer 记住「这次聊了什么」,store 记住「这个人是谁」。
二、长期记忆的基本概念
长期记忆不是模型参数,也不是一份永远带在上下文里的超大提示词。它的本质是:
把跨会话有价值的信息存到外部存储,在需要时检索出来,再拼进模型的上下文。
所以它包含三个动作:
- 写入(put):判断什么值得记,写成一条结构化文档;
- 读取(get):按 namespace + key 精确取某条记忆;
- 检索(search):按命名空间、过滤条件或语义相似度找相关记忆。
完整生命周期:
用户交互
↓
需要记忆? ── 否 ──> 直接调用模型
│ 是
↓
search / get 检索相关长期记忆
↓
拼装上下文(系统提示 + 长期记忆 + 当前对话)
↓
调用模型 / 执行工具
↓
按策略 put 写回新记忆(热路径或后台)
这里有个非常重要的认知:store 不会自动把内容塞进模型上下文。
store 只是一个存储层。真正让长期记忆生效的,是你在调用模型前做的检索与拼装;或者把 put / search 封装成工具,让 Agent 自己决定何时读写。前者可控,后者灵活,实际项目常常两者结合。
三、长期记忆的类型划分
3.1 按内容类型:语义 / 情景 / 程序性
借鉴认知科学和 CoALA 论文,长期记忆通常分三类:
| 类型 | 存什么 | 例子 | 常见形式 |
|---|---|---|---|
| 语义记忆(Semantic) | 事实、知识、偏好 | 用户叫小明、住在杭州、对花生过敏、喜欢冰美式 | Profile 或 Collection |
| 情景记忆(Episodic) | 经历、做过的事 | 上次退款先联系了人工客服,最后用订单号解决 | Collection |
| 程序性记忆(Procedural) | 规则、方法、流程 | 退款 SOP、系统提示词、操作规范 | 提示词 / Collection |
三类记忆的读写策略不同:
- 语义记忆要去重和更新,比如用户搬家后,住址应该被覆盖而不是新增两条;
- 情景记忆适合追加,保留时间和上下文,必要时按时间衰减;
- 程序性记忆更稳定,通常由开发者维护,或通过「反思」离线更新。
3.2 按组织形式:Profile vs Collection
同样是语义记忆,有两种组织方式:
Profile(档案):一个命名空间一份 JSON,字段固定。
await store.put(["users", "u-001", "profile"], "basic", {
name: "小明",
city: "杭州",
diet: "不吃香菜",
});
适合信息少、字段稳定、需要整体读取的场景。
Collection(集合):一条记忆一个 key,数量可以持续增长。
await store.put(["users", "u-001", "memories"], "mem-001", {
content: "用户喜欢喝冰美式咖啡",
kind: "preference",
confidence: 0.9,
});
适合偏好、事件、经验等不断增长的信息。代价是需要自己处理去重、更新、删除和检索排序。
3.3 按写入时机:热路径 vs 后台
- 热路径(in the hot path):对话过程中实时写入。记忆新,但会增加延迟,而且 Agent 要「分心」判断存什么;
- 后台(in the background):会话结束后异步整理。主流程更快,但新记忆有延迟,需要设计触发时机(定时 / 事件 / 手动)。
基础 demo 通常用热路径,生产系统更多是「热路径写入关键事实 + 后台整理去重」。
3.4 按归属划分 namespace
长期记忆通常按「谁的信息」隔离。常见的 namespace 设计:
["users", userId, "profile"] 用户画像,字段少而稳定
["users", userId, "memories"] 用户级记忆集合
["orgs", orgId, "policies"] 组织级共享规则
["assistants", assistantId, "instructions"] 某个助手的程序性记忆
namespace 既是组织方式,也是隔离边界。多租户系统里,千万不能把不同用户的数据写进同一个共享 namespace。
四、存储架构
4.1 三个核心概念:namespace、key、value
LangChain 的长期记忆建立在 LangGraph 的 BaseStore 抽象上。它是一个按命名空间组织的 JSON 文档存储:
store
├── users/u-001/profile
│ └── basic -> { name, city, diet }
└── users/u-001/memories
├── mem-001 -> { content, kind, confidence }
├── mem-002 -> { content, kind, confidence }
└── mem-003 -> { content, kind, confidence }
- namespace:字符串数组,类似文件夹路径,用于分层与隔离;
- key:namespace 内唯一的文档 ID,类似文件名;
- value:任意可 JSON 序列化的对象,是记忆本体;
- Item:读取时返回的包装对象,除
value外还带key、namespace、createdAt、updatedAt。
4.2 后端实现
| 实现 | 存储位置 | 适用场景 |
|---|---|---|
InMemoryStore | 当前进程内存 | 开发、测试、单进程 demo |
| PostgresStore / RedisStore 等 | 外部数据库 | 生产环境、多实例共享 |
无论后端是什么,put / get / search 的语义一致。所以可以先用 InMemoryStore 开发,上线时替换后端,业务代码基本不动。
InMemoryStore的数据只到进程为止,重启即丢失。它适合演示 API,不适合生产持久化。
4.3 向量索引:让 search 支持语义检索
search 有两种能力:
- 结构化过滤:按 value 字段过滤,例如
kind = "preference"; - 语义检索:传
query,按向量相似度排序。
第二种需要给 store 配置索引:
import { InMemoryStore } from "@langchain/langgraph";
import { OpenAIEmbeddings } from "@langchain/openai";
const store = new InMemoryStore({
index: {
dims: 1536,
embeddings: new OpenAIEmbeddings({ modelName: "text-embedding-3-small" }),
fields: ["content"], // 只对 content 字段做向量化;默认是整份文档
},
});
配置后:
put时会自动把文档转成向量并存入索引;search({ query })时先算查询向量,再按余弦相似度排序;- 没有配置索引时,
query不会产生语义排序效果,只会返回前缀下的文档。
本 demo 为了离线可跑,用了一个本地关键词向量实现(字符 + 二元组哈希),不是真正的语义 embedding;生产环境请换成实际 embedding 模型。
4.4 写入语义与并发
put 是「有则覆盖,无则新建」:
- 相同的
namespace + key再次put,旧 value 被整体覆盖; createdAt保持不变,updatedAt更新;- 没有内置的字段级合并,也没有乐观锁;并发写时后写覆盖先写。
如果业务需要「合并而不是覆盖」,常见做法是:先 get,在应用层合并后再 put;或者一条记忆一个 key,避免多个写者改同一份文档。
五、基础 API:put、get、search
5.1 创建 store
不配置索引,只做键值存取:
import { InMemoryStore } from "@langchain/langgraph";
const store = new InMemoryStore();
配置向量索引:
const store = new InMemoryStore({
index: {
dims: 1536,
embeddings: embeddingModel,
fields: ["content"],
},
});
5.2 put:写入与更新
await store.put(["users", "u-001", "profile"], "basic", {
name: "小明",
city: "杭州",
diet: "不吃香菜",
});
await store.put(["users", "u-001", "memories"], "mem-001", {
content: "用户叫小明",
kind: "profile",
confidence: 1.0,
});
覆盖同一份文档:
const before = structuredClone(
await store.get(["users", "u-001", "memories"], "mem-001")
);
await store.put(["users", "u-001", "memories"], "mem-001", {
content: "用户叫小明,是一名前端工程师",
kind: "profile",
confidence: 1.0,
});
const after = await store.get(["users", "u-001", "memories"], "mem-001");
// before.value.content -> 用户叫小明
// after.value.content -> 用户叫小明,是一名前端工程师
// createdAt 不变,updatedAt 更新
demo 里用了
structuredClone保存覆盖前的快照。get返回的对象可能与存储内部共享引用,覆盖后直接看旧对象,可能读到的已经是新值。
5.3 get:精确读取
const item = await store.get(["users", "u-001", "profile"], "basic");
console.log(item);
// {
// key: "basic",
// namespace: ["users", "u-001", "profile"],
// value: { name: "小明", city: "杭州", diet: "不吃香菜" },
// createdAt: 2026-...,
// updatedAt: 2026-...
// }
const missing = await store.get(["users", "u-001", "profile"], "not-exist");
// null
get 只按 namespace + key 精确读取,不做搜索。它适合 Profile 这种「整份档案读取」的场景。
5.4 search:过滤与语义检索
search 的第一个参数是 namespace 前缀,不是完整 namespace:
// 搜索 users/u-001 下所有层次的记忆
await store.search(["users", "u-001"], { limit: 20 });
(1)只给前缀:返回全部
const all = await store.search(["users", "u-001", "memories"], { limit: 20 });
// 返回该 namespace 下的所有 Item
(2)filter:结构化过滤
const preferences = await store.search(["users", "u-001", "memories"], {
filter: { kind: "preference" },
limit: 20,
});
filter 的 key 是 value 里的顶层字段。也支持比较运算符:
const confident = await store.search(["users", "u-001", "memories"], {
filter: { confidence: { $gte: 0.9 } },
limit: 20,
});
常用运算符:$eq、$ne、$gt、$gte、$lt、$lte。
(3)query:语义检索
const results = await store.search(["users", "u-001", "memories"], {
query: "用户喜欢喝什么",
limit: 3,
});
for (const item of results) {
console.log(item.score, item.value.content);
}
// 0.82 用户喜欢喝冰美式咖啡
// 0.31 用户对花生过敏
// ...
score 是相似度分数,越高越相关。再次强调:query 需要 store 配置了向量索引。
(4)组合与分页
const results = await store.search(["users", "u-001", "memories"], {
filter: { kind: "profile" },
query: "用户住在哪里",
limit: 2,
offset: 0,
});
执行顺序是:先按 namespace 前缀和 filter 圈定候选集,再按 query 的相似度排序,最后用 limit / offset 分页。
(5)命名空间管理
await store.listNamespaces();
// [["users","u-001","memories"], ["users","u-001","profile"]]
await store.listNamespaces({
prefix: ["users"],
maxDepth: 2,
});
5.5 delete:删除记忆
await store.delete(["users", "u-001", "memories"], "mem-001");
删除一条文档。若需要批量操作,可以用 store.batch([...]) 把多个 get / put / search / delete 合并成一次调用;基础用法先掌握单个 API 即可。
5.6 API 小结
| API | 作用 | 关键点 |
|---|---|---|
put(namespace, key, value) | 写入或覆盖 | 同 key 覆盖;createdAt 不变,updatedAt 更新 |
get(namespace, key) | 精确读取 | 返回 Item 或 null |
search(prefix, options) | 前缀检索 | 支持 filter、query、limit、offset |
delete(namespace, key) | 删除 | 按 namespace + key |
listNamespaces(options) | 列出命名空间 | 支持前缀、后缀、深度、分页 |
六、把 store 接进 Agent
存储层会用了,接下来看它怎么和 Agent 结合。
6.1 注入 store
import { createAgent, tool } from "langchain";
import { z } from "zod";
const agent = createAgent({
model,
tools: [saveMemory, searchMemory],
store, // 长期记忆
});
工具可以在运行时通过 runtime.store 访问同一个 store:
const saveMemory = tool(
async ({ content, kind }, runtime) => {
await runtime.store.put(
["users", "u-001", "memories"],
`mem-${Date.now()}`,
{ content, kind, confidence: 1.0 }
);
return `已保存:${content}`;
},
{
name: "save_memory",
description: "把关于用户的一条长期记忆写入 store。",
schema: z.object({
content: z.string().describe("完整、独立可理解的事实"),
kind: z.enum(["profile", "preference", "episodic", "procedural"]),
}),
}
);
const searchMemory = tool(
async ({ kind }, runtime) => {
const items = await runtime.store.search(["users", "u-001", "memories"], {
filter: kind ? { kind } : undefined,
limit: 20,
});
return JSON.stringify(items.map((item) => item.value));
},
{
name: "search_memory",
description: "读取关于用户的长期记忆。",
schema: z.object({
kind: z
.enum(["profile", "preference", "episodic", "procedural"])
.optional(),
}),
}
);
6.2 跨 thread 验证
两个不同的 thread,共享同一个 store:
const threadA = { configurable: { thread_id: "thread-A" } };
const threadB = { configurable: { thread_id: "thread-B" } };
await agent.invoke(
{ messages: [{ role: "user", content: "请记住:我叫小明,住在杭州,喜欢喝冰美式咖啡。" }] },
threadA
);
// 工具 save_memory 被调用,信息写入 store
const result = await agent.invoke(
{ messages: [{ role: "user", content: "我叫什么?住在哪?喜欢喝什么?" }] },
threadB
);
console.log(result.messages.at(-1).content);
// 你叫小明,住在杭州,喜欢喝冰美式咖啡。
thread-B 没有 thread-A 的对话历史,也没有共享 checkpointer,但它能答出来,因为信息存在 store 里。
最终 store 中大致是:
users/u-001/memories
mem-001 [profile] 用户叫小明
mem-002 [profile] 用户住在杭州
mem-003 [preference] 用户喜欢喝冰美式咖啡
6.3 短期 + 长期一起用
完整的记忆体系通常是两个参数一起上:
const agent = createAgent({
model,
tools: [saveMemory, searchMemory],
checkpointer: new MemorySaver(), // 短期:同一 thread 的对话历史
store, // 长期:跨 thread 的用户信息
});
- checkpointer 负责「当前这段对话」;
- store 负责「这个用户是谁、有什么偏好、经历过什么」;
- 工具或中间件负责在合适的时候读写 store。
七、生产实践要点
- 不要用
InMemoryStore做生产持久化。换数据库型 store,并考虑多实例并发、备份和迁移。 - namespace 必须带租户或用户维度。
["memories"]这种全局命名空间迟早出隔离事故。 - 想清楚写入策略。语义记忆要更新而不是堆积;情景记忆可以追加;重要事实最好带
confidence、source、updatedAt。 - 检索要组合使用 filter 和 query。先用 namespace / filter 缩小范围,再语义排序,能显著降低噪声。
- 控制 top-k 和上下文长度。检索回来的记忆不是越多越好,过长会稀释模型注意力。
- 给记忆设计生命周期。去重、合并、过期、删除、用户撤回权,都要提前考虑。
- 区分 store 和 checkpointer。不要把对话消息塞进 store,也不要把用户画像塞进 thread state。
- 敏感信息谨慎存储。长期记忆会跨会话存在,必须考虑加密、访问控制和合规要求。
八、Python 对应写法
概念和 API 一致,Import 和参数名略有差异:
from langgraph.store.memory import InMemoryStore
store = InMemoryStore()
store.put(("users", "u-001", "profile"), "basic", {
"name": "小明",
"city": "杭州",
})
item = store.get(("users", "u-001", "profile"), "basic")
print(item.value)
store.put(("users", "u-001", "memories"), "mem-001", {
"content": "用户喜欢喝冰美式咖啡",
"kind": "preference",
"confidence": 0.9,
})
results = store.search(
("users", "u-001", "memories"),
filter={"kind": "preference"},
limit=10,
)
配置向量索引时,Python 侧字段名是 embed:
from langchain_openai import OpenAIEmbeddings
store = InMemoryStore(
index={
"embed": OpenAIEmbeddings(model="text-embedding-3-small"),
"dims": 1536,
"fields": ["content"],
}
)
把 store 接进 Agent:
from langchain.agents import create_agent
agent = create_agent(
model="deepseek:deepseek-chat",
tools=[...],
store=store,
)
九、小结
- 概念:长期记忆跨 thread 共享,载体是 store 中的 JSON 文档。store 不会自动进入上下文,需要
get/search后再拼装给模型。 - 分类:
- 内容类型 → 语义(事实)/ 情景(经历)/ 程序性(规则);
- 组织形式 → Profile(档案)/ Collection(集合);
- 写入时机 → 热路径 / 后台。
- 架构:
namespace(分层隔离)+key(文档 ID)+value(JSON);可选向量索引让search({ query })支持语义检索;开发用InMemoryStore,生产换数据库型 store。 - API:
put写入或覆盖;get精确读取,不存在返回null;search支持前缀、filter、query、limit / offset;delete/listNamespaces管理文档与命名空间。
- Agent 集成:
createAgent({ store }),工具里通过runtime.store读写;再配合 checkpointer,就是「短期 + 长期」的完整记忆体系。
一句话:短期记忆让一段对话接得上,长期记忆让下一次见面还认得你。