系列最终篇。前两篇的记忆,本质都是"把历史从头线性读一遍";这一篇我们把记忆升级成会"按语义回忆"的长期记忆------用 Embedding 把对话向量化存进 Milvus,提问时先检索出相关的那一小段历史再回答。文末附完整可运行代码。
前言
欢迎来到记忆模块系列的第 3 篇,也是收官篇。前两篇我们分别解决了"聊完就忘"(内存/文件记忆)和"上下文撑爆"(截断 + 自动总结)。但细心的你可能会发现:前两招其实都还是"线性记忆"------无论用户问什么,模型都得把历史从头到尾读一遍。
试想聊了三百轮、存了几万字之后,每次回答都全量重读历史------token 贵、窗口塞不下、速度还慢。而人的记忆不是这样:你问朋友"上次说的那个项目进展如何",他不会把认识你的整个过程重放一遍,而是直接调出"项目"相关的那一小段记忆。
第三篇就来打造这种能力:把对话向量化存进向量数据库(Milvus),回答前先做一次语义检索(Semantic Search),只召回"相关的那几段历史"。
- 适合谁看:跟完前两篇,想给 Agent 装"长期记忆"的同学;也适合对 Embedding / 向量数据库感兴趣的读者。
- 读完能收获:一条完整的"记忆读写闭环"------对话如何入库、提问如何召回、新对话如何回存;以及 Milvus 集合、索引、相似度检索的实战用法。
- 需要什么 :本地能连上 Milvus(
localhost:19530),配好大模型接口;你还可以用刚下载的 Attu 可视化查看数据 👀。
认知铺垫:两座新概念山
动手前,先花两分钟爬过两座概念山:
① Embedding(向量嵌入)
文本模型没法直接计算"这句话和那句话像不像"。Embedding 就是把一段文本映射成一串固定长度的数字(向量),让语义相近的句子在向量空间里"靠得近"。
代码里这样用:
js
import { OpenAIEmbeddings } from '@langchain/openai';
const VECTOR_DIM = 1024;
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: 'text-embedding-v3',
configuration: { baseURL: process.env.OPENAI_API_BASE_URL },
dimension: VECTOR_DIM, // 每个向量固定 1024 维
});
async function getEmbedding(text) {
const result = await embeddings.embedQuery(text);
return result; // 返回 [0.023, -0.018, ...] 共 1024 个数字
}
💡 生活化比喻:把每句对话变成"地图上的一个坐标",意思越接近,坐标越靠近。
② 向量数据库 Milvus
普通数据库按"字段值"精确查;向量数据库擅长按"相似度"查------给定一个向量,返回库里最相似的 Top-K 条。Milvus 里的几个概念对应关系:
| 概念 | 对应关系 | 本例 |
|---|---|---|
| Collection(集合) | ≈ 一张"表" | conversations |
| Field(字段) | ≈ 列 | id / vector / content / round / timestamp |
| Index(索引) | ≈ 加速检索的数据结构 | IVF_FLAT |
| Metric(度量) | ≈ 相似度怎么算 | COSINE 余弦相似度 |
整体架构:记忆的"读写闭环"
两条通路循环咬合,就是完整闭环:
text
┌───────────── 长期记忆(Milvus)──────────────┐
│ 对话记录 → Embedding → insert 入库 │
│ ▲ │
│ │ search(语义检索 topK)
│ │ ▼
新对话回合:user提问 ──────────────────────→ 召回"相关的历史片段"
│ │
└── LLM 结合召回的历史作答 ←─────────────────┘
(新 Q&A 再入库存进"长期记忆")
写路径 :一段对话 → Embedding 成向量 → 带着原文一起插进 Milvus。 读路径:新问题 → Embedding 成向量 → 在 Milvus 里搜 Top-K 相似片段 → 只把这几段拼进上下文交给 LLM。
下面两个 demo 文件正好就是这两条路:insert-conversations.mjs(建库 + 写)、retrieval-memory.mjs(写读闭环)。
写路径:把对话"存"进长期记忆 ------ insert-conversations.mjs
Step 1:设计集合的"字段"(表结构)
js
await client.createCollection({
collection_name: COLLECTION_NAME,
fields: [
{ 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 },
{ name: 'timestamp', data_type: DataType.VarChar, max_length: 100 },
]
});
逐行拆解:
| 字段 | 类型 | 作用 |
|---|---|---|
id |
VarChar(主键) | 每条记忆的唯一身份证(注释里提醒:生产用 UUID 更安全) |
vector |
FloatVector(1024) | 检索的核心------对话的向量,维度必须和 Embedding 一致 |
content |
VarChar(5000) | 原文快照,命中后能直接拼给 LLM |
round |
Int64 | 记录这是第几轮对话 |
timestamp |
VarChar | 时间戳(Milvus 没有 Date 类型,用字符串存) |
⚠️ 最容易踩的坑:
vector的维度(1024)必须和 Embedding 模型输出的维度完全一致,不一致会插入失败或检索错乱。
Step 2:建索引并载入内存
js
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 });
createIndex 决定"怎么搜得快",metric_type 决定"怎么算相近"(本例用 COSINE)。loadCollection 把数据载入内存,检索才会快。
Step 3:写入种子对话(一条真实验证数据)
js
const conversations = [
{ id: 'conv_001', round: 1, content: '用户:我叫赵六,是一名数据科学家\n助手:很高兴认识你,赵六!数据科学是一个很有趣的领域。' },
{ id: 'conv_002', round: 2, content: '用户:我最近在研究机器学习算法\n助手:机器学习确实很有意思,你在研究哪些算法呢?' },
// ... 还有 职业/篮球/电影 等 3 条
];
const conversationData = await Promise.all(
conversations.map(async (conv) => ({
...conv,
vector: await getEmbedding(conv.content), // 原文 → 向量
}))
);
await client.insert({ collection_name: COLLECTION_NAME, data: conversationData });
底层逻辑 :每条对话既要存原文(content),又要存向量(vector) 。向量负责"被检索到",原文负责"被检索到后能读懂"。用 Promise.all 并发算出 5 条向量,再一次性 insert。
读路径:提问前先"回忆" ------ retrieval-memory.mjs
Step 1:语义检索核心函数
js
async function retrieveRelevantConversations(query, k = 2) {
const queryVector = await getEmbedding(query); // 问题也转成向量
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: k, // 召回最像的 2 条
metric_type: MetricType.COSINE,
output_fields: ['id', 'content', 'round', 'timestamp'], // 命中后要原文
});
return searchResult.results;
}
逐行拆解:
| 代码 | 作用 |
|---|---|
getEmbedding(query) |
把"新问题"转成和库中同一空间的向量 |
client.search + limit: k |
在集合里做相似度检索,返回最相近的 k 条 |
output_fields |
命中的记录除了向量,还要把原文等字段一起取回 |
💡 这里的问题不需要包含关键词------语义相近就能命中。这就是"检索记忆"和"按 id 读历史"的本质区别。
Step 2:拼上下文,让 LLM 带着"回忆"回答
js
for (let i = 0; i < conversations.length; i++) {
const { input } = conversations[i]; // 用户新提问
console.log(`第${i + 1} 轮:${input}`);
// ① 先到"长期记忆"里翻出相关的 2 段
const retrieved = await retrieveRelevantConversations(input, 2);
let relevantHistory = '';
if (retrieved.length > 0) {
relevantHistory = retrieved.map((conv, idx) =>
`[历史对话 ${idx + 1}] 轮次:${conv.round}\n${conv.content}`
).join('\n\n------\n\n');
}
// ② 有回忆就把"回忆 + 问题"交给 LLM,否则只问问题
const contextMessages = relevantHistory
? [new HumanMessage(`相关历史对话:\n${relevantHistory}\n\n用户问题:${input}`)]
: [new HumanMessage(input)];
const response = await model.invoke(contextMessages);
console.log(response.content);
// ③ 本轮对话存进"短期记忆"
await history.addMessage(userMessage);
await history.addMessage(response);
}
底层逻辑 :回答质量的关键在 contextMessages------只把检索出的相关片段塞进上下文,而不是全量历史。这让模型既能"记得"用户身份、偏好这类老信息,又不必每次支付全量重读的成本。
Step 3:新对话"回存",记忆越聊越丰满
js
const conversationText = `用户:${input} \n 助手: ${response.content}`;
const convId = `conv_${Date.now()}_${i + 1}`; // 注释:时间戳+序号做唯一 id,生产建议 uuid
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() }]
});
这样每一轮新的 Q&A 都会被向量化写回 Milvus,记忆库随对话增长,下一次提问能召回更丰富的内容------"长期记忆"是越用越懂你的。
闭环的意义:Agent 记忆的分层全景
到这里,记忆模块的三篇就齐了。回头看整套分层设计:
| 记忆层 | 载体 | 读取方式 | 上一篇 |
|---|---|---|---|
| 短期记忆 | 内存 / 文件 | 最近对话全量线性读 | 第 1 篇 |
| 上下文压缩 | 截断 + 总结 | 控制长度、保留要义 | 第 2 篇 |
| 长期记忆 | Milvus 向量库 | 按语义召回相关片段 | 本篇 ✅ |
三者组合的典型姿势:长期记忆负责"调取相关历史",短期记忆负责"承接当前上下文"------就像人脑的海马体(长期)与工作记忆(短期)协作一样。
几个值得记住的要点:
- 写库与检索用的是同一套 Embedding 模型,维度必须一致(1024),否则数据"异空间"检索不到。
- 搜的是语义不是关键词------"我上次的项目进展"能命中存的是"机器学习算法"的那段历史。
- 命中之后要带原文 :向量只负责定位,真正喂给 LLM 的是
output_fields取回的content。 - 记忆要回写:每次问答后把新对话入库,长期记忆才会持续成长。
- 本地单机注意资源:Milvus 是内存/磁盘吃大户的,demo 规模无所谓,生产要规划索引策略与分布式部署。
结语 & 收尾
三篇下来,我们从"无状态 → 会话记忆 → 文件持久化 → 截断 → 自动总结 → 向量长期记忆",把一个 Agent 的记忆模块完整搭起来了。项目还在持续迭代------未来可能的方向:把总结也并入检索(对历史先总结再入库)、或用图数据库建模实体关系记忆。仓库会持续更新,值得 Watch 一下~
你的 Agent 现在最需要哪一层记忆?短期、总结还是向量检索?评论区聊聊 👏 觉得这个系列有用的话,一键三连是最好的催更动力!
完整项目代码
- 仓库地址 :gitee.com/dcx2758/ai_...
- 本文项目所在目录 :
ai/agent/memory/demo
环境准备(本篇特有)
本篇 demo 依赖本地 Milvus (监听 19530):
bash
# 方式一(推荐,Docker 起 Milvus Standalone)
# 下载官方 standalone compose 文件并启动,访问 localhost:19530
docker compose up -d
# 可选:用 Attu 图形界面查看数据(连 localhost:19530,端口默认)
# Windows 装 attu-x64.exe,浏览器打开即可看到 conversations 集合
若连不上
localhost:19530,retrieval-memory.mjs会友好提示"无法连接到 Milvus"并退出,不会崩。
启动步骤
bash
# 1. 克隆(项目在子目录,clone 后 cd 进去)
git clone git@gitee.com:dcx2758/ai_doubao_dcx.git
# (没配 SSH:git clone https://gitee.com/dcx2758/ai_doubao_dcx.git)
cd ai/agent/memory/demo
npm install
# 2. 新建 .env(embedding 用的 text-embedding-v3 需你的网关支持)
OPENAI_API_KEY=你的_API_Key
OPENAI_API_BASE_URL=https://你的代理地址/v1
OPENAI_MODEL_NAME=你的对话模型名
# 3. 先建库并写入种子数据(会创建 conversations 集合并插入 5 条对话)
node src/memory/insert-conversations.mjs
# 4. 再跑"提问 → 检索 → 回答 → 回存"的完整闭环
node src/memory/retrieval-memory.mjs
⚠️ 注意运行顺序:
retrieval-memory假设集合已由insert-conversations建好并加载。反过来跑会检索不到数据。
memory 文件夹全景(记忆模块三篇全部对应):
text
src/memory
├── truncation-memory.mjs # 第 1 篇:截断
├── summarization-memory.mjs # 第 2 篇:自动总结(按条数)
├── summarization-memory2.mjs # 第 2 篇:自动总结(按 token)
├── insert-conversations.mjs # 第 3 篇:向量化入库(本文)
└── retrieval-memory.mjs # 第 3 篇:语义检索记忆(本文)
依赖版本 :@langchain/core ^1.2.3、@langchain/openai ^1.5.5、langchain ^1.5.10、@zilliz/milvus2-sdk-node(Vector 维度 1024,检索度量 COSINE)、dotenv ^17.4.2。