给 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 和 .env(OPENAI_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 会做的不同的事:先问自己"这个记忆是给现在用的,还是给未来复用的?"------给未来的,进向量库,别塞进上下文。
一个开放问题(评论区聊聊):三种记忆手段各有代价,生产环境你会怎么组合它们?我用的是"短期截断 + 中期摘要 + 长期向量检索"三段式,你有更好的方案吗?