给 Agent 一个检索式记忆:把对话历史存进 Milvus,该记的都记得

给 Agent 一个检索式记忆:把对话历史存进 Milvus,该记的都记得

想先跑起来? 文章配套的完整代码在 ai/agent/memory/demo/src/memory/insert-conversations.mjs(建集合+灌种子数据)、retrieval-memory.mjs(检索 + 对话)。跑之前需要本地已经拉起 Milvus(docker compose up -d,本机 localhost:19530)。


开篇:一个很"贵"的 Bug

先看一段真实的教训。我上一版"带记忆"的 Agent 是这样写的:

javascript 复制代码
// ❌ 第一版:把「全部历史」一股脑塞给模型
async function chat(question) {
  const allHistory = await getFullConversationFromDB(); // 从第 1 条拿到最后一条
  const response = await model.invoke([
    new SystemMessage(`这是你们之前所有的对话:\n${allHistory}`),
    new HumanMessage(question)
  ]);
  return response.content;
}

你猜怎么了?

对话到第 30 轮时,allHistory 已经有几万 tokens。先是不停爆上下文超限,我 trim 到最近 20 轮;然后模型开始"忘记"用户三天前说过的事 ------ 因为那些"旧事"早被 trim 掉了。

我花了一晚上,得出一个反直觉的结论:

记忆的敌人不是"存得不够多",而是"取的时候什么都给"。

截断是"省预算",摘要(summarization)是"压体积"------这两种都是被动缩水 ,把历史删掉/揉成一团。而真正让记忆"越用越全"的,是第三种思路:检索式记忆。这篇文章就是解决这个问题的。


核心概念:先讲清楚"检索式记忆"是什么

检索式记忆(Retrieval-based Memory):把每条对话历史转成向量存进向量数据库,新问题进来时不带全部历史,只用问题去向量检索"最相关的几条历史",带上它们回答。

一句话理解:记忆不是查字典,是"扫一眼目录找最相关的那页"。

对照三种手段,你立刻能看到差异:

手段 对历史做什么 本质 适配
截断 truncation 只留最近的 N 条/token 省预算 短期高频
摘要 summarization 把旧的揉成一段总结 压体积 中期
检索 retrieval 要用了才取出最相关的 扩范围 长期跨会话

关键认知:前两种都假设"最近的就是最重要的",但真正重要的记忆往往是"最相关的",而不一定是"最近的"。 用户三天前提过一个需求,今天突然问"那个需求你记得吗"------截断救不了你,只有检索能在海量历史里把那一条捞出来。


第一步:建一个能装"带向量"的集合(Milvus)

先解决"历史存哪"。我用的是 Milvus(开源向量数据库),核心就是建一个集合,字段里除了原始内容,还有一个 1024 维的向量

javascript 复制代码
// insert-conversations.mjs
const COLLECTION_NAME = 'conversations'; // 集合
const VECTOR_DIM = 1024;                 // 模型 text-embedding-v3 的维度

await client.createCollection({
  collection_name: COLLECTION_NAME,
  fields: [
    // 🔑 id 用 VarChar 做主键:业务上方便(conv_001),不依赖自增数字
    { name: "id", data_type: DataType.VarChar, max_length: 50, is_primary_key: true },
    // 🔑 这颗才是关键:把整段对话转成向量,检索靠它
    { name: 'vector', data_type: DataType.FloatVector, dim: VECTOR_DIM },
    // 存原始文字,检索命中后要读回来看
    { name: 'content', data_type: DataType.VarChar, max_length: 5000 },
    { name: 'round', data_type: DataType.Int64 },
    // ⚠️ Milvus 没有专门 Date 类型,时间戳用 VarChar 或 Int 存
    { name: 'timestamp', data_type: DataType.VarChar, max_length: 100 }
  ]
});

⚠️ 一个很容易卡住的坑:Milvus 没有 Date/DateTime 数据形态 ,我第一次想直接建 DateTime 字段直接报类型错。时间戳改用 VarChar(ISO 字符串)或 Int64(毫秒数)就行。

写完字段,要给 vector 建索引,否则检索是全表扫,慢:

javascript 复制代码
await client.createIndex({
  collection_name: COLLECTION_NAME,
  field_name: 'vector',          // 🔑 索引建在最频繁查询的字段上
  index_type: IndexType.IVF_FLAT,
  metric_type: MetricType.COSINE // 🔑 相似度用余弦,衡量"方向接近"
});
await client.loadCollection({ collection_name: COLLECTION_NAME });

第二步:往集合里写数据 ------ 每条对话都要"向量化"

索引建好了,怎么把历史灌进去?核心是:每一条对话,都要用 embedding 模型转成一个 1024 维的向量存进去。

javascript 复制代码
// 建集合后,批量灌入 5 条种子对话
const conversations = [
  { id:'conv_001', content:'用户:我叫赵六,是一名数据科学家\n助手:很高兴认识你!' , round:1 },
  { id:'conv_002', content:'用户:我最近在研究机器学习算法\n助手:你在研究哪些算法呢?', round:2 },
  // ... conv_003/004/005 分别是"爱好篮球电影""周末电影院""职业软件工程师"
];

// 🔑 每条都转向量:Promise.all 并行,别一条条 await 卡死
const conversationData = await Promise.all(
  conversations.map(async (conv) => ({
    ...conv,
    vector: await getEmbedding(conv.content)   // 核心:内容 -> 向量
  }))
);

await client.insert({
  collection_name: COLLECTION_NAME,
  data: conversationData
});

getEmbedding 的实现,就是调一次 embedding 模型:

javascript 复制代码
const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: 'text-embedding-v3',
  dimension: VECTOR_DIM,                    // 🔑 维度要和建集合时一致
  configuration: { baseURL: process.env.OPENAI_BASE_URL }
});

async function getEmbedding(text) {
  return await embeddings.embedQuery(text);
}

⚠️ dimension 建集合时写多少,写数据时必须一样。1024 建集合、写数据用了 1536 ------ 插入会失败或产生诡异结果。


第三步:检索 ------ 这才是它"聪明"的地方

数据进去了,回到开篇那个 bug。新问题来了,我们带全部历史,而是先检索:

javascript 复制代码
// retrieval-memory.mjs
async function retrieveRelevantConversations(query, k = 2) {
  const queryVector = await getEmbedding(query);   // 问题也转成向量
  const searchResult = await client.search({
    collection_name: COLLECTION_NAME,
    vector: queryVector,                          // 用问题的向量去比
    limit: k,                                     // 只取最相关的 k 条
    metric_type: MetricType.COSINE,
    output_fields: ['id', 'content', 'round', 'timestamp'] // 🔑 命中后要读原文回来看
  });
  return searchResult.results;
}

然后,把检索到的那 2 条历史拼成一个"相关历史"上下文,连问题一起发给模型:

javascript 复制代码
const retrievedConversations = await retrieveRelevantConversations(input, 2);

let relevantHistory = '';
if (retrievedConversations.length > 0) {
  relevantHistory = retrievedConversations
    .map((conv, idx) => `[历史对话 ${idx + 1}]\n轮次:${conv.round}\n${conv.content}`)
    .join('\n------\n');
}

// 🔑 关键:只喂"检索到的相关历史",而不是全部历史
const contextMessages = relevantHistory
  ? [new HumanMessage(`相关历史对话:\n${relevantHistory}\n\n用户问题:${input}`)]
  : [new HumanMessage(input)];

const response = await model.invoke(contextMessages);

看到了吗?发给模型的,永远只有最相关的 2 条 + 当前问题。 上下文不会无限膨胀,而且是"按需取"。

一个我自己写的时候纠结过的点:问题本身要不要也转向量? 答案是要。query 和存储的 content 是两种对象,但检索比对的是"向量在语义空间的距离"------只有两头都转成同一个空间的向量,余弦相似度才有意义。这也是"检索式记忆"区别于"关键词搜索"的本质:它比的是语义,不是字符串匹配。


第四步:每次对话后,把结果写回记忆

光取不存,记忆池不会增长。所以每轮对话结束,要把"用户问题 + 模型回答"合成一条,转向量再插回 Milvus:

javascript 复制代码
// 每轮对话完成后持久化
const conversationText = `用户:${input} \n 助手: ${response.content}`;
const convId = `conv_${Date.now()}_${i + 1}`;       // 时间戳+轮次 保证唯一
const convVector = await getEmbedding(conversationText);

await client.insert({
  collection_name: COLLECTION_NAME,
  data: [{
    id: convId,
    content: conversationText,
    vector: convVector,
    round: i + 1,
    timestamp: new Date().toISOString()
  }]
});

至此闭环成型,用一个 ASCII 图串起来:

css 复制代码
   新的问题
      │
      ▼
  [问题 → embedding 转向量]
      │
      ▼
  [Milvus 余弦检索 k 条最相关历史] ──┐
      │                            │ 命中则带上
      ▼                            ▼
  [模型回答] ←── 上下文 = 相关历史 + 当前问题
      │
      ▼
  [回答 + 问题 合成一条 → embedding → 插回 Milvus]
      │
      └── 记忆池不断变大,越聊越懂你

核心动作一句话:记住过去------提问时检索最相关的过去,回答后把新的过去存回去。


运行一遍,看真实输出

配好本地 Milvus 和 .envOPENAI_API_KEY/OPENAI_BASE_URL/MODEL_NAME),先灌数据再跑检索:

bash 复制代码
node src/memory/insert-conversations.mjs   # 建集合 + 写 5 条种子
node src/memory/retrieval-memory.mjs       # 带检索的对话

retrieval-memory.mjs 时,"用户:我的职业是什么?" 这条会被 k=2 检索,从 5 条历史里命中 conv_005(软件工程师)那条,然后带上下文回答。那一刻你会看到:模型"想起来"了三天前的旧设定------因为那条被检索出来了,而不是被截断了。

附:想要更直观,用 Attu 链接 127.0.0.1:19530,能直接看到集合里每条对话的向量和原文。


结尾:回扣那个"贵"的 Bug

回到开篇 ------ 让 Agent "忘记旧事"的,不是存得少,而是把全部历史都塞给模型、旧的自然被挤掉。

记住:截断省预算,摘要压体积,检索扩范围。想要长期记忆,别 trim 掉旧事,把它向量化存起来,等被问到再捞回来。

下次你写带记忆的 Agent 会做的不同的事:先问自己"这个记忆是给现在用的,还是给未来复用的?"------给未来的,进向量库,别塞进上下文。

一个开放问题(评论区聊聊):三种记忆手段各有代价,生产环境你会怎么组合它们?我用的是"短期截断 + 中期摘要 + 长期向量检索"三段式,你有更好的方案吗?


相关推荐
星云API技术支持1 小时前
企业微信二次开发外部群:群聊数据与联系人信息如何建立关联
数据库·php·企业微信
磐链科技1 小时前
区块链开发入门实战:用 Hardhat 与 Ethers.js 搭建你的第一个 DApp
开发语言·javascript·区块链
我不是程序员三三1 小时前
桌面管理软件哪个最好?从一次上线翻车事故,拆解选型评估与配置落地全流程
数据库
落木萧萧8251 小时前
MyBatis 关联查询的四种写法,为什么最后都变回了手写 XML
java·数据库·后端
枫叶丹41 小时前
小模型与本地 Agent:不是更小的替代品,而是新的系统分工
人工智能·chatgpt·agent·codex
春风解人意1 小时前
从零开始学习嵌入式P35----数据库
数据库·嵌入式硬件·学习
sunoo-2291 小时前
【网络编程 + 数据库】select 与 epoll 核心区别详解 + SQLite 入门到 C 接口全攻略
linux·网络·数据库·vscode·学习·sqlite
TDengine (老段)1 小时前
TDengine 错误处理 — 错误码、异常传播、故障恢复
大数据·数据库·物联网·时序数据库·tdengine·涛思数据
码艺-Alimjan1 小时前
维吾尔语数字转文本
java·javascript·python·算法·go