创见博客
LangChain 长期记忆实战:概念、分类、存储架构与 put/get/search
七崽爱吃小饼干2026/10/18阅读 0

上一篇(demo-18)用 checkpointer 实现了短期记忆:同一个 thread_id 内,多轮对话能接上。但换一个 thread_id,对话历史就看不到了。如果希望「这次会话里知道的用户信息,下次换一个会话还能用」,就需要长期记忆。

这篇进入长期记忆的实操:先讲清概念、分类和存储架构,再用 LangChain 的 store 跑通最基础的 put、get、search,最后把 store 接进 Agent,验证跨 thread 的记忆。

代码基于 JavaScript 版 langchain v1,模型用 DeepSeek。store 部分使用 @langchain/langgraph 提供的 InMemoryStore。

一、长期记忆要解决什么

先看短期记忆的边界。下面两段对话用了不同的 thread_id:

js
// 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 读取,或按内容搜索
对应参数checkpointerstore

一句话区分:

checkpointer 记住「这次聊了什么」,store 记住「这个人是谁」。

二、长期记忆的基本概念

长期记忆不是模型参数,也不是一份永远带在上下文里的超大提示词。它的本质是:

把跨会话有价值的信息存到外部存储,在需要时检索出来,再拼进模型的上下文。

所以它包含三个动作:

  1. 写入(put):判断什么值得记,写成一条结构化文档;
  2. 读取(get):按 namespace + key 精确取某条记忆;
  3. 检索(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,字段固定。

js
await store.put(["users", "u-001", "profile"], "basic", {
  name: "小明",
  city: "杭州",
  diet: "不吃香菜",
});

适合信息少、字段稳定、需要整体读取的场景。

Collection(集合):一条记忆一个 key,数量可以持续增长。

js
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 有两种能力:

  1. 结构化过滤:按 value 字段过滤,例如 kind = "preference";
  2. 语义检索:传 query,按向量相似度排序。

第二种需要给 store 配置索引:

js
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

不配置索引,只做键值存取:

js
import { InMemoryStore } from "@langchain/langgraph";

const store = new InMemoryStore();

配置向量索引:

js
const store = new InMemoryStore({
  index: {
    dims: 1536,
    embeddings: embeddingModel,
    fields: ["content"],
  },
});

5.2 put:写入与更新

js
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,
});

覆盖同一份文档:

js
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:精确读取

js
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:

js
// 搜索 users/u-001 下所有层次的记忆
await store.search(["users", "u-001"], { limit: 20 });

(1)只给前缀:返回全部

js
const all = await store.search(["users", "u-001", "memories"], { limit: 20 });
// 返回该 namespace 下的所有 Item

(2)filter:结构化过滤

js
const preferences = await store.search(["users", "u-001", "memories"], {
  filter: { kind: "preference" },
  limit: 20,
});

filter 的 key 是 value 里的顶层字段。也支持比较运算符:

js
const confident = await store.search(["users", "u-001", "memories"], {
  filter: { confidence: { $gte: 0.9 } },
  limit: 20,
});

常用运算符:$eq、$ne、$gt、$gte、$lt、$lte。

(3)query:语义检索

js
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)组合与分页

js
const results = await store.search(["users", "u-001", "memories"], {
  filter: { kind: "profile" },
  query: "用户住在哪里",
  limit: 2,
  offset: 0,
});

执行顺序是:先按 namespace 前缀和 filter 圈定候选集,再按 query 的相似度排序,最后用 limit / offset 分页。

(5)命名空间管理

js
await store.listNamespaces();
// [["users","u-001","memories"], ["users","u-001","profile"]]

await store.listNamespaces({
  prefix: ["users"],
  maxDepth: 2,
});

5.5 delete:删除记忆

js
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

js
import { createAgent, tool } from "langchain";
import { z } from "zod";

const agent = createAgent({
  model,
  tools: [saveMemory, searchMemory],
  store, // 长期记忆
});

工具可以在运行时通过 runtime.store 访问同一个 store:

js
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:

js
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 短期 + 长期一起用

完整的记忆体系通常是两个参数一起上:

js
const agent = createAgent({
  model,
  tools: [saveMemory, searchMemory],
  checkpointer: new MemorySaver(), // 短期:同一 thread 的对话历史
  store,                          // 长期:跨 thread 的用户信息
});
  • checkpointer 负责「当前这段对话」;
  • store 负责「这个用户是谁、有什么偏好、经历过什么」;
  • 工具或中间件负责在合适的时候读写 store。

七、生产实践要点

  1. 不要用 InMemoryStore 做生产持久化。换数据库型 store,并考虑多实例并发、备份和迁移。
  2. namespace 必须带租户或用户维度。["memories"] 这种全局命名空间迟早出隔离事故。
  3. 想清楚写入策略。语义记忆要更新而不是堆积;情景记忆可以追加;重要事实最好带 confidence、source、updatedAt。
  4. 检索要组合使用 filter 和 query。先用 namespace / filter 缩小范围,再语义排序,能显著降低噪声。
  5. 控制 top-k 和上下文长度。检索回来的记忆不是越多越好,过长会稀释模型注意力。
  6. 给记忆设计生命周期。去重、合并、过期、删除、用户撤回权,都要提前考虑。
  7. 区分 store 和 checkpointer。不要把对话消息塞进 store,也不要把用户画像塞进 thread state。
  8. 敏感信息谨慎存储。长期记忆会跨会话存在,必须考虑加密、访问控制和合规要求。

八、Python 对应写法

概念和 API 一致,Import 和参数名略有差异:

python
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:

python
from langchain_openai import OpenAIEmbeddings

store = InMemoryStore(
    index={
        "embed": OpenAIEmbeddings(model="text-embedding-3-small"),
        "dims": 1536,
        "fields": ["content"],
    }
)

把 store 接进 Agent:

python
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,就是「短期 + 长期」的完整记忆体系。

一句话:短期记忆让一段对话接得上,长期记忆让下一次见面还认得你。

评论
0/100