一文搞懂 AI 聊天应用的 Memory 模块:Milvus 向量数据库实战

每聊 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 条自动摘要(替换原文)

当对话越来越多,逐条存储会有问题:

  1. 向量库数据膨胀
  2. 检索到的都是碎片化信息

解决方案:每 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 模块。


完整代码


觉得有用的话,点个赞👍再走吧!

相关推荐
岁月宁静27 分钟前
AI 应用开发工程师简历怎么写:HR 初筛、技术复筛、面试追问,三道关卡各看什么?
人工智能·python·面试
Dream it possible!3 小时前
LeetCode 面试经典 150_多维动态规划_交错字符串(97_C++_中等)
算法·leetcode·面试·动态规划
yeccheng6 小时前
MySQL 索引超详解:从底层 B+ 树到面试高频考点
java·开发语言·jvm·mysql·面试
j7~12 小时前
【C++ 八股文】(篇四)C++11 新特性
开发语言·c++·面试·c++11·八股文
nice_lcj5201 天前
Java Lambda 表达式入门:怎么写、怎么用、常用方法有哪些
java·开发语言·后端·面试
nice_lcj5201 天前
ThreadLocal 入门:它是怎么隔离数据的、为什么会导致内存泄漏
java·开发语言·后端·面试·并发编程·内存泄漏·threadlocal
Yuzhiyuxia1 天前
【AI Agent面试系列】上下文管理1
人工智能·面试·职场和发展
CoderYanger1 天前
Java EE 进阶:8.MyBatis 操作数据库(入门)(第一弹)
java·数据库·spring·面试·职场和发展·java-ee·mybatis
Rain的Java大神之路2 天前
公交App上“还有2站,约5分钟”怎么实现?90%的人答错这道大厂面试题:GPS漂移、ETA不准、10万QPS热点读全拆透(附完整代码)
java·后端·面试
利威尔xu2 天前
2026-11-05-Go内存管理面试
面试·golang