每聊 20 条自动总结,Agent 终于能"记住"你了。
前言
你有没有发现,和 ChatGPT 聊到第 50 轮的时候,它就开始"失忆"了?你之前说过的职业、爱好、项目进度,它全忘了。
这不是模型的问题,而是上下文窗口有限。把所有历史对话塞进 prompt,token 爆炸;不塞,Agent 就是金鱼记忆。
解决方案:Memory 模块 ------ 用向量数据库存储对话历史,每次提问时语义检索最相关的几条,让 Agent "想起"你之前说过的话。
本文基于 Milvus 向量数据库,带你实现一个完整的聊天记忆系统。
一、整体架构
用户提问
↓
向量化(Embedding)
↓
Milvus 语义检索 → 返回最相关的历史对话
↓
拼接上下文(历史 + 当前问题)
↓
LLM 生成回答
↓
本轮对话向量化 → 存入 Milvus
↓
每 20 条触发一次摘要总结 → 摘要也存入 Milvus
核心思想:不存全部原文,而是存向量;不检索全部历史,而是只取最相关的几条。
二、为什么是 Milvus?
| 数据库 | 类型 | 适合场景 |
|---|---|---|
| MySQL | 关系型 | 结构化数据,精确查询 |
| MongoDB | 文档型 | JSON 数据,灵活 schema |
| Redis | 缓存 | KV 存储,高速读写 |
| Milvus | 向量型 | 语义搜索,相似度匹配 |
Milvus 是专门为向量检索设计的数据库,支持亿级向量的毫秒级搜索。对比把向量存 JSON 文件或者 MySQL 里用暴力计算,性能差距是数量级的。
三、Milvus 本地安装
Docker 一键启动(推荐)
bash
# 拉取镜像
docker pull milvusdb/milvus:v2.4-latest
# 启动容器
docker run -d \
--name milvus \
-p 19530:19530 \
-p 9091:9091 \
milvusdb/milvus:v2.4-latest
启动后,Milvus 监听 localhost:19530。
验证连接
bash
# 安装 SDK
npm install @zilliz/milvus2-sdk-node
四、代码实现
4.1 初始化:创建集合与索引
insert-conversations.mjs ------ 建表 + 插入测试数据
js
import 'dotenv/config';
import {
MilvusClient,
DataType,
MetricType,
IndexType
} from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings } from '@langchain/openai';
const COLLECTION_NAME = 'conversations';
const VECTOR_DIM = 1024;
// 初始化 Embedding 模型
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: 'text-embedding-v3',
configuration: {
baseURL: process.env.OPENAI_BASE_URL
},
dimension: VECTOR_DIM
});
async function getEmbedding(text) {
return await embeddings.embedQuery(text);
}
const client = new MilvusClient({
address: 'localhost:19530'
});
字段定义相当于关系型数据库的建表:
js
await client.createCollection({
collection_name: COLLECTION_NAME,
fields: [
// UUID 作为主键,比自增 ID 更安全
// 自增 ID 可预测、可遍历,UUID 无法猜测
{ name: "id", data_type: DataType.VarChar, max_length: 50,
is_primary_key: true },
// 向量字段,1024 维浮点数组
{ 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 类型,用字符串存
{ name: 'timestamp', data_type: DataType.VarChar, max_length: 100 }
]
});
创建索引,加速向量搜索:
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 });
字段定义 vs 索引创建:字段定义决定"能存什么",索引创建决定"搜得多快"。
4.2 插入测试对话
js
const conversations = [
{
id: 'conv_001',
content: '用户:我叫赵六,是一名数据科学家\n助手:很高兴认识你,赵六!',
round: 1,
timestamp: new Date().toISOString()
},
{
id: 'conv_002',
content: '用户:我最近在研究机器学习算法\n助手:机器学习确实很有意思!',
round: 2,
timestamp: new Date().toISOString()
},
{
id: 'conv_003',
content: '用户:我喜欢打篮球和看电影\n助手:运动和娱乐都是好爱好!',
round: 3,
timestamp: new Date().toISOString()
},
// ... 更多对话
];
// 批量向量化并插入
const conversationData = await Promise.all(
conversations.map(async (conv) => ({
...conv,
vector: await getEmbedding(conv.content)
}))
);
await client.insert({
collection_name: COLLECTION_NAME,
data: conversationData
});
每条对话都会被转成 1024 维向量,连同原文一起存入 Milvus。
4.3 语义检索 + 对话生成
retrieval-memory.mjs ------ 检索相关历史,拼接上下文,生成回答
js
import { ChatOpenAI } from '@langchain/openai';
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
import { MilvusClient, MetricType } from '@zilliz/milvus2-sdk-node';
import { HumanMessage } from "@langchain/core/messages";
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
temperature: 0 // 确定性输出
});
核心检索函数:
js
async function retrieveRelevantConversations(query, k = 2) {
// 1. 用户问题 → 向量
const queryVector = await getEmbedding(query);
// 2. 在 Milvus 中做相似度搜索
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;
}
主循环:每轮对话做 4 件事
js
for (let i = 0; i < conversations.length; i++) {
const { input } = conversations[i];
// ① 检索:找到最相关的历史对话
const retrieved = await retrieveRelevantConversations(input, 2);
// ② 拼接:把历史塞进 prompt
let relevantHistory = '';
if (retrieved.length > 0) {
relevantHistory = retrieved
.map((conv, idx) => `[历史对话 ${idx + 1}]\n轮次:${conv.round}\n${conv.content}`)
.join('\n\n------\n\n');
}
const contextMessages = relevantHistory
? [new HumanMessage(`相关历史对话:\n${relevantHistory}\n\n用户问题:${input}`)]
: [new HumanMessage(input)];
// ③ 生成:LLM 基于上下文回答
const response = await model.invoke(contextMessages);
console.log(response.content);
// ④ 存储:本轮对话向量化后存入 Milvus
const conversationText = `用户:${input}\n助手:${response.content}`;
const convVector = await getEmbedding(conversationText);
await client.insert({
collection_name: COLLECTION_NAME,
data: [{
id: `conv_${Date.now()}_${i + 1}`,
content: conversationText,
vector: convVector,
round: i + 1,
timestamp: new Date().toISOString()
}]
});
}
效果:
- 第 1 轮:"我之前提到的机器学习项目进展如何?" → 无历史,直接回答
- 第 2 轮:"我周末经常做什么?" → 检索到第 1 轮内容,结合回答
- 第 3 轮:"我的职业是什么?" → 检索到最相关的对话,精准回答
五、每 20 条自动摘要(替换原文)
当对话越来越多,逐条存储会有问题:
- 向量库数据膨胀
- 检索到的都是碎片化信息
解决方案:每 20 条对话触发一次摘要,用摘要替换原文。
diff
第 1-20 条对话 → 生成 summary_001 → 删掉 conv_001~020
第 21-40 条对话 → 生成 summary_002 → 删掉 conv_021~040
Milvus 里永远只有:
- 最近 20 条原文(还没到总结周期)
- 之前的摘要(压缩过的历史)
js
// 存储 ID 追踪,用于后续删除
const recentIds = [];
async function maybeSummarize(history, round) {
if (round % SUMMARIZE_THRESHOLD !== 0) return;
// 取出最近 20 条对话
const messages = await history.getMessages();
const recent = messages.slice(-SUMMARIZE_THRESHOLD);
const conversationText = recent
.map(m => `${m._getType()}: ${m.content}`)
.join('\n');
// LLM 生成摘要
const summaryPrompt = `请将以下对话总结为一段简洁的摘要,保留关键信息(用户身份、偏好、重要决定):\n\n${conversationText}`;
const summary = await model.invoke([new HumanMessage(summaryPrompt)]);
// 摘要向量化后存入 Milvus
const summaryVector = await getEmbedding(summary.content);
await client.insert({
collection_name: COLLECTION_NAME,
data: [{
id: `summary_${Date.now()}`,
content: `[对话摘要] ${summary.content}`,
vector: summaryVector,
round: round,
timestamp: new Date().toISOString()
}]
});
// 关键:删掉被摘要的原文,防止数据膨胀
const idsToDelete = recentIds.splice(0, SUMMARIZE_THRESHOLD);
if (idsToDelete.length > 0) {
await client.delete({
collection_name: COLLECTION_NAME,
ids: idsToDelete
});
}
console.log(`✅ 已生成摘要,删除 ${idsToDelete.length} 条原文`);
}
存储量对比:
❌ 不删原文:
第1轮:20条原文 + 1条摘要 = 21条
第2轮:40条原文 + 2条摘要 = 42条
第N轮:20N条原文 + N条摘要 → 越来越大
✅ 删掉原文:
任意时刻:最多20条原文 + N条摘要 → 增长很慢
摘要的好处:
- 一条摘要涵盖 20 条对话的精华
- 检索时更容易命中关键信息
- 删掉原文后向量库数据量可控
六、业界参考:小龙虾、WorkBuddy 的 Memory 模块
这种"向量存储 + 语义检索"的记忆方案并不是我发明的,业界已经有很多成熟实践:
| 产品 | Memory 实现 |
|---|---|
| 小龙虾(Lobster) | 对话历史存向量库,检索后注入 prompt |
| WorkBuddy | 用户偏好 + 工作上下文双通道记忆 |
| Harness Memory | 持久化记忆文件 + 向量索引,跨会话保持 |
核心思路都一样:不存全部原文,只存向量;不检索全部历史,只取最相关。
七、总结
本文实现了一个完整的 AI 聊天记忆系统:
| 模块 | 技术 | 作用 |
|---|---|---|
| 向量化 | OpenAI Embedding (text-embedding-v3) | 文本 → 1024 维向量 |
| 存储 | Milvus 向量数据库 | 高效存储和检索向量 |
| 检索 | 余弦相似度搜索 | 找到语义最相关的历史对话 |
| 生成 | ChatOpenAI | 基于上下文生成回答 |
| 摘要 | 每 20 条自动总结 | 压缩历史,提升检索质量 |
Agent 不是金鱼,它可以记住你。只要给它一个 Memory 模块。
完整代码
- insert-conversations.mjs --- 建表 + 插入数据
- retrieval-memory.mjs --- 检索 + 对话 + 摘要
觉得有用的话,点个赞👍再走吧!