LangChain.js Agent Memory 实战(下):用 Milvus 构建可检索的长期记忆

文章来源声明: 原文作者:半个落月; 来源站点:掘金; 原文链接:https://juejin.cn/post/7687493428758183987; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

从存下来到找回来,文章把检索式 Memory 的工程闭环讲透了,并厘清短期 history 与长期语义记忆的边界,适合正为 Agent 上下文膨胀发愁、准备引入向量数据库的开发者参考。

LangChain.js Agent Memory 实战(下):用 Milvus 构建可检索的长期记忆 ---------------------------------------------------

摘要:围绕检索式 Memory,完整实现对话向量化、Milvus 存储、相似度搜索、上下文注入与新记忆回写,并说明短期历史、摘要和长期语义记忆如何协同。

上篇解决了三个问题:

  • 用消息历史让模型拥有连续对话能力;
  • 用文件让消息跨进程持久化;
  • 用截断和摘要控制上下文长度。

但截断与摘要都围绕“最近发生了什么”管理上下文。假设用户进行了大量对话,现在突然问到很久以前讨论过的向量数据库,最近几条消息里未必包含相关内容;如果只保留近期上下文,那段旧信息已经不可见;如果每次传入完整历史,上下文又会持续膨胀。

检索式 Memory 提供了第三条路线:

历史对话持久化到 Milvus
          ↓
当前问题转换成向量
          ↓
按向量相似度检索相关历史
          ↓
相关历史与当前问题一起交给模型
          ↓
把本轮问答继续写回 Milvus

这样,长期记忆不必全部进入当前上下文。每一轮只取与问题语义最接近的少量记录,既控制输入规模,又能找回较早的信息。

本文将完成这条闭环:创建 Milvus 集合、生成 Embedding、插入历史对话、执行相似度检索、拼接模型上下文,并把新问答保存为下一轮可检索的记忆。

一、检索式 Memory 的组成

整个过程包含三个核心对象:

  1. Embedding 模型:把自然语言转换成固定维度的向量;
  2. Milvus:保存向量及其对应的对话正文、轮次和时间;
  3. 聊天模型:读取检索到的相关历史并回答当前问题。

依赖如下:

<span>{</span>
  <span>"dependencies"</span><span>:</span> <span>{</span>
    <span>"@langchain/core"</span><span>:</span> <span>"^1.2.11"</span><span>,</span>
    <span>"@langchain/openai"</span><span>:</span> <span>"^1.5.13"</span><span>,</span>
    <span>"@zilliz/milvus2-sdk-node"</span><span>:</span> <span>"^3.0.5"</span><span>,</span>
    <span>"dotenv"</span><span>:</span> <span>"^17.4.2"</span>
  <span>}</span>
<span>}</span>

环境变量需要同时描述聊天模型、Embedding 模型和 Milvus 地址:

MODEL_NAME=你的聊天模型名称
OPENAI_API_KEY=你的API-Key
OPENAI_BASE_URL=你的模型服务地址
EMBEDDINGS_MODEL_NAME=你的Embedding模型名称
MILVUS_ADDRESS=你的Milvus地址

这里让聊天模型与 Embedding 模型使用相同的 API Key 和基础地址,但模型名称分别配置。

二、初始化 Embedding 模型和 Milvus 客户端

先定义集合名称与向量维度:

<span>const</span> <span>COLLECTION_NAME</span> = <span>"conversations"</span>;
<span>const</span> <span>VECTOR_DIM</span> = <span>1024</span>;

再创建 Embedding 实例:

<span>import</span> <span>"dotenv/config"</span>;
<span>import</span> { <span>OpenAIEmbeddings</span> } <span>from</span> <span>"@langchain/openai"</span>;

<span>const</span> embeddings = <span>new</span> <span>OpenAIEmbeddings</span>({
  <span>apiKey</span>: process.<span>env</span>.<span>OPENAI_API_KEY</span>,
  <span>model</span>: process.<span>env</span>.<span>EMBEDDINGS_MODEL_NAME</span>,
  <span>configuration</span>: {
    <span>baseURL</span>: process.<span>env</span>.<span>OPENAI_BASE_URL</span>,
  },
  <span>dimensions</span>: <span>VECTOR_DIM</span>,
});

<span>async</span> <span>function</span> <span>getEmbedding</span>(<span>text</span>) {
  <span>return</span> embeddings.<span>embedQuery</span>(text);
}

embedQuery(text) 接收一段文本,返回长度为 1024 的数字数组。这个数组本身不是给读者阅读的内容,而是后续相似度搜索的依据。

VECTOR_DIM 会同时用于 Embedding 配置和 Milvus 字段定义,两处必须一致:

Embedding 输出维度:1024
Milvus vector 字段维度:1024

然后创建 Milvus 客户端:

<span>import</span> { <span>MilvusClient</span> } <span>from</span> <span>"@zilliz/milvus2-sdk-node"</span>;

<span>const</span> client = <span>new</span> <span>MilvusClient</span>({
  <span>address</span>: process.<span>env</span>.<span>MILVUS_ADDRESS</span>,
});

正式执行建表、写入或检索前,等待客户端连接:

<span>console</span>.<span>log</span>(<span>"连接到 Milvus..."</span>);
<span>await</span> client.<span>connectPromise</span>;
<span>console</span>.<span>log</span>(<span>"连接成功"</span>);

三、为长期对话设计集合结构

关系型数据库通常把结构化字段组织成表,而这里把长期对话保存在名为 conversations 的集合中。每条记录包含五个字段:

字段Milvus 类型用途
`id``VarChar`唯一标识一条对话记录,同时作为主键
`vector``FloatVector`对话正文对应的 1024 维向量
`content``VarChar`可读的用户或 AI 对话正文
`round``Int64`对话轮次
`timestamp``VarChar`ISO 格式时间字符串

创建集合:

<span>import</span> {
  <span>DataType</span>,
  <span>IndexType</span>,
  <span>MetricType</span>,
} <span>from</span> <span>"@zilliz/milvus2-sdk-node"</span>;

<span>await</span> client.<span>createCollection</span>({
  <span>collection_name</span>: <span>COLLECTION_NAME</span>,
  <span>fields</span>: [
    {
      <span>name</span>: <span>"id"</span>,
      <span>data_type</span>: <span>DataType</span>.<span>VarChar</span>,
      <span>max_length</span>: <span>50</span>,
      <span>is_primary_key</span>: <span>true</span>,
    },
    {
      <span>name</span>: <span>"vector"</span>,
      <span>data_type</span>: <span>DataType</span>.<span>FloatVector</span>,
      <span>dim</span>: <span>VECTOR_DIM</span>,
    },
    {
      <span>name</span>: <span>"content"</span>,
      <span>data_type</span>: <span>DataType</span>.<span>VarChar</span>,
      <span>max_length</span>: <span>5000</span>,
    },
    {
      <span>name</span>: <span>"round"</span>,
      <span>data_type</span>: <span>DataType</span>.<span>Int64</span>,
    },
    {
      <span>name</span>: <span>"timestamp"</span>,
      <span>data_type</span>: <span>DataType</span>.<span>VarChar</span>,
      <span>max_length</span>: <span>100</span>,
    },
  ],
});

时间字段使用 new Date().toISOString() 生成字符串:

<span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>()

因此 schema 中把它定义为 VarChar,而不是日期类型。

这个 schema 还体现了向量数据库记录的两部分:

  • vector 用于机器计算相似度;
  • contentroundtimestamp 用于把检索结果重新组织成人可以理解、模型也可以读取的上下文。

只保存向量而不保存正文,检索后就无法把真实对话交还给聊天模型。

四、为向量字段创建索引

集合创建后,为 vector 字段创建索引:

<span>await</span> client.<span>createIndex</span>({
  <span>collection_name</span>: <span>COLLECTION_NAME</span>,
  <span>field_name</span>: <span>"vector"</span>,
  <span>index_type</span>: <span>IndexType</span>.<span>IVF_FLAT</span>,
  <span>metric_type</span>: <span>MetricType</span>.<span>COSINE</span>,
});

<span>console</span>.<span>log</span>(<span>"索引已经创建"</span>);

这段配置表达了三件事:

  • 检索目标是 vector 字段;
  • 索引类型使用 IVF_FLAT
  • 相似度度量使用 COSINE

后续搜索也会明确写出 MetricType.COSINE,保证写入后的索引配置和查询时的度量方式保持一致。

集合和索引属于初始化工作。下面的初始化程序应在首次准备数据时执行;如果同名集合已经存在,再次创建会遇到名称冲突,因此不要把初始化逻辑混在每一轮聊天请求里反复执行。

五、把种子对话转换成向量并写入 Milvus

先准备三轮、六条对话:

<span>const</span> conversations = [
  {
    <span>id</span>: <span>"conversation_1"</span>,
    <span>content</span>: <span>"用户:你好,我最近正在学习 Milvus 向量数据库。"</span>,
    <span>round</span>: <span>1</span>,
    <span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
  },
  {
    <span>id</span>: <span>"conversation_2"</span>,
    <span>content</span>: <span>"AI:Milvus 是一个向量数据库,常用于 RAG、语义搜索和推荐系统。"</span>,
    <span>round</span>: <span>1</span>,
    <span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
  },
  {
    <span>id</span>: <span>"conversation_3"</span>,
    <span>content</span>: <span>"用户:向量数据库和 MySQL 有什么区别?"</span>,
    <span>round</span>: <span>2</span>,
    <span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
  },
  {
    <span>id</span>: <span>"conversation_4"</span>,
    <span>content</span>: <span>"AI:MySQL 更适合结构化数据和精确查询,而 Milvus 更擅长根据向量相似度进行语义检索。"</span>,
    <span>round</span>: <span>2</span>,
    <span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
  },
  {
    <span>id</span>: <span>"conversation_5"</span>,
    <span>content</span>: <span>"用户:那 RAG 为什么要使用向量数据库?"</span>,
    <span>round</span>: <span>3</span>,
    <span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
  },
  {
    <span>id</span>: <span>"conversation_6"</span>,
    <span>content</span>: <span>"AI:因为 RAG 需要从大量文本中找到和用户问题语义最相近的内容,再交给大模型生成回答。"</span>,
    <span>round</span>: <span>3</span>,
    <span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
  },
];

每条记录此时还缺少 vector。可以用 Promise.all() 并发生成所有向量,再保留原字段:

<span>const</span> conversationData = <span>await</span> <span>Promise</span>.<span>all</span>(
  conversations.<span>map</span>(<span>async</span> (item) => ({
    ...item,
    <span>vector</span>: <span>await</span> <span>getEmbedding</span>(item.<span>content</span>),
  }))
);

转换前:

{
  id,
  content,
  round,
  timestamp
}

转换后:

{
  id,
  content,
  round,
  timestamp,
  vector
}

最后一次性写入:

<span>await</span> client.<span>insert</span>({
  <span>collection_name</span>: <span>COLLECTION_NAME</span>,
  <span>data</span>: conversationData,
});

这里按单条用户消息或 AI 消息存储种子数据,所以一次检索可能返回某个问题,也可能返回与它对应的回答。后面保存新对话时会采用稍有不同的粒度:把一整轮“用户问题 + AI 回答”合并成一条记录。

六、完整的初始化程序

把连接、建集合、建索引和插入种子数据组合起来:

<span>import</span> <span>"dotenv/config"</span>;
<span>import</span> {
  <span>MilvusClient</span>,
  <span>DataType</span>,
  <span>MetricType</span>,
  <span>IndexType</span>,
} <span>from</span> <span>"@zilliz/milvus2-sdk-node"</span>;
<span>import</span> { <span>OpenAIEmbeddings</span> } <span>from</span> <span>"@langchain/openai"</span>;

<span>const</span> <span>COLLECTION_NAME</span> = <span>"conversations"</span>;
<span>const</span> <span>VECTOR_DIM</span> = <span>1024</span>;

<span>const</span> embeddings = <span>new</span> <span>OpenAIEmbeddings</span>({
  <span>apiKey</span>: process.<span>env</span>.<span>OPENAI_API_KEY</span>,
  <span>model</span>: process.<span>env</span>.<span>EMBEDDINGS_MODEL_NAME</span>,
  <span>configuration</span>: {
    <span>baseURL</span>: process.<span>env</span>.<span>OPENAI_BASE_URL</span>,
  },
  <span>dimensions</span>: <span>VECTOR_DIM</span>,
});

<span>async</span> <span>function</span> <span>getEmbedding</span>(<span>text</span>) {
  <span>return</span> embeddings.<span>embedQuery</span>(text);
}

<span>const</span> client = <span>new</span> <span>MilvusClient</span>({
  <span>address</span>: process.<span>env</span>.<span>MILVUS_ADDRESS</span>,
});

<span>async</span> <span>function</span> <span>main</span>(<span></span>) {
  <span>try</span> {
    <span>console</span>.<span>log</span>(<span>"连接到 Milvus..."</span>);
    <span>await</span> client.<span>connectPromise</span>;
    <span>console</span>.<span>log</span>(<span>"连接成功"</span>);

    <span>await</span> client.<span>createCollection</span>({
      <span>collection_name</span>: <span>COLLECTION_NAME</span>,
      <span>fields</span>: [
        {
          <span>name</span>: <span>"id"</span>,
          <span>data_type</span>: <span>DataType</span>.<span>VarChar</span>,
          <span>max_length</span>: <span>50</span>,
          <span>is_primary_key</span>: <span>true</span>,
        },
        {
          <span>name</span>: <span>"vector"</span>,
          <span>data_type</span>: <span>DataType</span>.<span>FloatVector</span>,
          <span>dim</span>: <span>VECTOR_DIM</span>,
        },
        {
          <span>name</span>: <span>"content"</span>,
          <span>data_type</span>: <span>DataType</span>.<span>VarChar</span>,
          <span>max_length</span>: <span>5000</span>,
        },
        {
          <span>name</span>: <span>"round"</span>,
          <span>data_type</span>: <span>DataType</span>.<span>Int64</span>,
        },
        {
          <span>name</span>: <span>"timestamp"</span>,
          <span>data_type</span>: <span>DataType</span>.<span>VarChar</span>,
          <span>max_length</span>: <span>100</span>,
        },
      ],
    });

    <span>await</span> client.<span>createIndex</span>({
      <span>collection_name</span>: <span>COLLECTION_NAME</span>,
      <span>field_name</span>: <span>"vector"</span>,
      <span>index_type</span>: <span>IndexType</span>.<span>IVF_FLAT</span>,
      <span>metric_type</span>: <span>MetricType</span>.<span>COSINE</span>,
    });

    <span>const</span> conversations = [
      {
        <span>id</span>: <span>"conversation_1"</span>,
        <span>content</span>: <span>"用户:你好,我最近正在学习 Milvus 向量数据库。"</span>,
        <span>round</span>: <span>1</span>,
        <span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
      },
      {
        <span>id</span>: <span>"conversation_2"</span>,
        <span>content</span>: <span>"AI:Milvus 是一个向量数据库,常用于 RAG、语义搜索和推荐系统。"</span>,
        <span>round</span>: <span>1</span>,
        <span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
      },
      {
        <span>id</span>: <span>"conversation_3"</span>,
        <span>content</span>: <span>"用户:向量数据库和 MySQL 有什么区别?"</span>,
        <span>round</span>: <span>2</span>,
        <span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
      },
      {
        <span>id</span>: <span>"conversation_4"</span>,
        <span>content</span>: <span>"AI:MySQL 更适合结构化数据和精确查询,而 Milvus 更擅长根据向量相似度进行语义检索。"</span>,
        <span>round</span>: <span>2</span>,
        <span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
      },
      {
        <span>id</span>: <span>"conversation_5"</span>,
        <span>content</span>: <span>"用户:那 RAG 为什么要使用向量数据库?"</span>,
        <span>round</span>: <span>3</span>,
        <span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
      },
      {
        <span>id</span>: <span>"conversation_6"</span>,
        <span>content</span>: <span>"AI:因为 RAG 需要从大量文本中找到和用户问题语义最相近的内容,再交给大模型生成回答。"</span>,
        <span>round</span>: <span>3</span>,
        <span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
      },
    ];

    <span>const</span> conversationData = <span>await</span> <span>Promise</span>.<span>all</span>(
      conversations.<span>map</span>(<span>async</span> (item) => ({
        ...item,
        <span>vector</span>: <span>await</span> <span>getEmbedding</span>(item.<span>content</span>),
      }))
    );

    <span>await</span> client.<span>insert</span>({
      <span>collection_name</span>: <span>COLLECTION_NAME</span>,
      <span>data</span>: conversationData,
    });

    <span>console</span>.<span>log</span>(<span>"初始对话已经写入 Milvus"</span>);
  } <span>catch</span> (error) {
    <span>console</span>.<span>error</span>(<span>"错误:"</span>, error);
  }
}

<span>main</span>();

初始化完成后,conversations 集合便拥有可供检索的长期对话。

七、根据当前问题检索相关历史

检索函数接收当前问题和返回数量 k

<span>import</span> { <span>MetricType</span> } <span>from</span> <span>"@zilliz/milvus2-sdk-node"</span>;

<span>async</span> <span>function</span> <span>retrieveRelevantConversations</span>(<span>query, k = <span>2</span></span>) {
  <span>try</span> {
    <span>const</span> queryVector = <span>await</span> <span>getEmbedding</span>(query);

    <span>const</span> searchResult = <span>await</span> client.<span>search</span>({
      <span>collection_name</span>: <span>COLLECTION_NAME</span>,
      <span>vector</span>: queryVector,
      <span>limit</span>: k,
      <span>metric_type</span>: <span>MetricType</span>.<span>COSINE</span>,
      <span>output_fields</span>: [<span>"id"</span>, <span>"content"</span>, <span>"round"</span>, <span>"timestamp"</span>],
    });

    <span>return</span> searchResult.<span>results</span>;
  } <span>catch</span> (error) {
    <span>console</span>.<span>error</span>(<span>"检索对话时出错:"</span>, error.<span>message</span>);
    <span>return</span> [];
  }
}

它的执行过程可以拆成三步。

1. 把当前问题转换成向量

<span>const</span> queryVector = <span>await</span> <span>getEmbedding</span>(query);

历史正文和当前问题使用同一个 Embedding 配置,产生相同维度的向量。

2. 在同一个向量字段上执行搜索

<span>const</span> searchResult = <span>await</span> client.<span>search</span>({
  <span>collection_name</span>: <span>COLLECTION_NAME</span>,
  <span>vector</span>: queryVector,
  <span>limit</span>: <span>2</span>,
  <span>metric_type</span>: <span>MetricType</span>.<span>COSINE</span>,
  <span>output_fields</span>: [<span>"id"</span>, <span>"content"</span>, <span>"round"</span>, <span>"timestamp"</span>],
});

limit: 2 表示只返回最相关的两条记录。控制 k,也就控制了注入模型的长期历史数量。

3. 返回可构造上下文的字段

vector 已经完成了检索任务,交给聊天模型时真正需要的是可读字段,因此通过 output_fields 取回 ID、正文、轮次和时间。

发生异常时返回空数组:

<span>async</span> <span>function</span> <span>retrieveRelevantConversations</span>(<span>query, k = <span>2</span></span>) {
  <span>try</span> {
    <span>// 执行向量检索</span>
  } <span>catch</span> (error) {
    <span>console</span>.<span>error</span>(<span>"检索对话时出错:"</span>, error.<span>message</span>);
    <span>return</span> [];
  }
}

这样上层聊天逻辑仍然可以退化为只使用当前用户问题,而不是因为没有检索结果就无法继续调用模型。

八、把检索结果注入模型上下文

假设要连续演示三个问题:

<span>const</span> conversations = [
  { <span>input</span>: <span>"为什么要学习向量数据库"</span> },
  { <span>input</span>: <span>"MySQL 和 Milvus 的区别"</span> },
  { <span>input</span>: <span>"RAG 和哪个数据库强相关"</span> },
];

每一轮先执行检索:

<span>const</span> retrievedConversations = <span>await</span> <span>retrieveRelevantConversations</span>(input, <span>2</span>);

再把结果转换成一段结构化文本:

<span>let</span> relevantHistory = <span>""</span>;

<span>if</span> (retrievedConversations.<span>length</span> > <span>0</span>) {
  relevantHistory = retrievedConversations
    .<span>map</span>(<span>(<span>item, index</span>) =></span> <span>`
[历史对话 <span>${index + <span>1</span>}</span>]
轮次:<span>${item.round}</span>
<span>${item.content}</span>`</span>)
    .<span>join</span>(<span>"\n\n----------\n\n"</span>);
} <span>else</span> {
  <span>console</span>.<span>log</span>(<span>"未找到相关历史对话"</span>);
}

如果成功找到历史,最终消息会同时包含检索内容和当前问题:

<span>const</span> contextMessages = relevantHistory
  ? [
      <span>new</span> <span>HumanMessage</span>(<span>`相关历史对话:
<span>${relevantHistory}</span>

用户问题:<span>${input}</span>`</span>),
    ]
  : [userMessage];

<span>const</span> response = <span>await</span> model.<span>invoke</span>(contextMessages);

模型实际接收到的内容大致如下:

相关历史对话:

[历史对话 1]
轮次:2
用户:向量数据库和 MySQL 有什么区别?

----------

[历史对话 2]
轮次:2
AI:MySQL 更适合结构化数据和精确查询,而 Milvus 更擅长根据向量相似度进行语义检索。

用户问题:MySQL 和 Milvus 的区别

这里并不是要求聊天模型自己访问 Milvus。检索发生在调用模型之前,应用把结果整理成 HumanMessage,模型只负责阅读这段上下文并生成回答。

如果没有结果,contextMessages 就退化为原始的 userMessage

[<span>new</span> <span>HumanMessage</span>(input)]

九、短期 history 与模型上下文不是同一个概念

演示中还维护了一个内存历史:

<span>const</span> history = <span>new</span> <span>InMemoryChatMessageHistory</span>();

<span>await</span> history.<span>addMessage</span>(userMessage);
<span>await</span> history.<span>addMessage</span>(response);

但调用模型使用的是:

<span>await</span> model.<span>invoke</span>(contextMessages);

而不是:

<span>await</span> model.<span>invoke</span>(<span>await</span> history.<span>getMessages</span>());

这意味着当前实现里,history 负责记录本次程序运行期间发生的问答,但不会自动参与下一轮模型输入。真正进入模型上下文的是“Milvus 检索结果 + 当前问题”。

这一点很重要:

addMessage() 只是保存消息
getMessages() 并放入 model.invoke() 才会影响模型本轮回答

因此,这个示例突出的是检索式长期记忆,而不是把本次运行的全部短期消息也带入每一轮。如果需要同时使用两者,就要在构造 contextMessages 时明确合并,而不是只调用 history.addMessage()

十、把新问答写回长期记忆

模型回答后,先把本轮用户问题和 AI 回答组合成一段文本:

<span>const</span> conversationText = <span>`用户:<span>${input}</span>
AI:<span>${response.content}</span>`</span>;

再生成唯一 ID、向量与时间:

<span>const</span> conversationId = <span>`conv_<span>${<span>Date</span>.now()}</span>_<span>${index + <span>1</span>}</span>`</span>;
<span>const</span> conversationVector = <span>await</span> <span>getEmbedding</span>(conversationText);

最后写回同一个集合:

<span>await</span> client.<span>insert</span>({
  <span>collection_name</span>: <span>COLLECTION_NAME</span>,
  <span>data</span>: [
    {
      <span>id</span>: conversationId,
      <span>content</span>: conversationText,
      <span>vector</span>: conversationVector,
      <span>round</span>: index + <span>1</span>,
      <span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
    },
  ],
});

至此,一轮对话形成了闭环:

当前问题
   ↓ Embedding
查询 Milvus
   ↓
相关历史 + 当前问题
   ↓
聊天模型回答
   ↓
用户问题 + AI 回答
   ↓ Embedding
写回 Milvus

下一轮检索不仅能找到最初插入的种子对话,也可能找到刚刚生成的新对话。

种子数据按单条消息存储,新数据则把一问一答合并成一条记录。这两种粒度都能被当前 schema 接收,不过理解检索结果时要注意区别:前者的一条结果只代表用户或 AI 的单条消息,后者的一条结果代表完整一轮问答。

十一、完整的检索式 Memory 示例

下面把检索、生成和写回组合成一个可以直接理解的完整流程:

<span>import</span> <span>"dotenv/config"</span>;
<span>import</span> { <span>OpenAIEmbeddings</span>, <span>ChatOpenAI</span> } <span>from</span> <span>"@langchain/openai"</span>;
<span>import</span> { <span>InMemoryChatMessageHistory</span> } <span>from</span> <span>"@langchain/core/chat_history"</span>;
<span>import</span> { <span>HumanMessage</span> } <span>from</span> <span>"@langchain/core/messages"</span>;
<span>import</span> {
  <span>MilvusClient</span>,
  <span>MetricType</span>,
} <span>from</span> <span>"@zilliz/milvus2-sdk-node"</span>;

<span>const</span> <span>COLLECTION_NAME</span> = <span>"conversations"</span>;
<span>const</span> <span>VECTOR_DIM</span> = <span>1024</span>;

<span>const</span> embeddings = <span>new</span> <span>OpenAIEmbeddings</span>({
  <span>apiKey</span>: process.<span>env</span>.<span>OPENAI_API_KEY</span>,
  <span>model</span>: process.<span>env</span>.<span>EMBEDDINGS_MODEL_NAME</span>,
  <span>configuration</span>: {
    <span>baseURL</span>: process.<span>env</span>.<span>OPENAI_BASE_URL</span>,
  },
  <span>dimensions</span>: <span>VECTOR_DIM</span>,
});

<span>async</span> <span>function</span> <span>getEmbedding</span>(<span>text</span>) {
  <span>return</span> embeddings.<span>embedQuery</span>(text);
}

<span>const</span> model = <span>new</span> <span>ChatOpenAI</span>({
  <span>modelName</span>: process.<span>env</span>.<span>MODEL_NAME</span>,
  <span>apiKey</span>: process.<span>env</span>.<span>OPENAI_API_KEY</span>,
  <span>temperature</span>: <span>0</span>,
  <span>configuration</span>: {
    <span>baseURL</span>: process.<span>env</span>.<span>OPENAI_BASE_URL</span>,
  },
});

<span>const</span> client = <span>new</span> <span>MilvusClient</span>({
  <span>address</span>: process.<span>env</span>.<span>MILVUS_ADDRESS</span>,
});

<span>async</span> <span>function</span> <span>retrieveRelevantConversations</span>(<span>query, k = <span>2</span></span>) {
  <span>try</span> {
    <span>const</span> queryVector = <span>await</span> <span>getEmbedding</span>(query);
    <span>const</span> searchResult = <span>await</span> client.<span>search</span>({
      <span>collection_name</span>: <span>COLLECTION_NAME</span>,
      <span>vector</span>: queryVector,
      <span>limit</span>: k,
      <span>metric_type</span>: <span>MetricType</span>.<span>COSINE</span>,
      <span>output_fields</span>: [<span>"id"</span>, <span>"content"</span>, <span>"round"</span>, <span>"timestamp"</span>],
    });

    <span>return</span> searchResult.<span>results</span>;
  } <span>catch</span> (error) {
    <span>console</span>.<span>error</span>(<span>"检索对话时出错:"</span>, error.<span>message</span>);
    <span>return</span> [];
  }
}

<span>async</span> <span>function</span> <span>retrievalMemoryDemo</span>(<span></span>) {
  <span>try</span> {
    <span>console</span>.<span>log</span>(<span>"连接到 Milvus..."</span>);
    <span>await</span> client.<span>connectPromise</span>;
    <span>console</span>.<span>log</span>(<span>"连接成功\n"</span>);
  } <span>catch</span> (error) {
    <span>console</span>.<span>error</span>(<span>"无法连接到 Milvus:"</span>, error.<span>message</span>);
    <span>return</span>;
  }

  <span>const</span> history = <span>new</span> <span>InMemoryChatMessageHistory</span>();
  <span>const</span> conversations = [
    { <span>input</span>: <span>"为什么要学习向量数据库"</span> },
    { <span>input</span>: <span>"MySQL 和 Milvus 的区别"</span> },
    { <span>input</span>: <span>"RAG 和哪个数据库强相关"</span> },
  ];

  <span>for</span> (<span>let</span> index = <span>0</span>; index < conversations.<span>length</span>; index++) {
    <span>const</span> { input } = conversations[index];
    <span>const</span> userMessage = <span>new</span> <span>HumanMessage</span>(input);

    <span>console</span>.<span>log</span>(<span>`第 <span>${index + <span>1</span>}</span> 轮对话,用户:<span>${input}</span>`</span>);
    <span>console</span>.<span>log</span>(<span>"[检索相关历史对话]"</span>);

    <span>const</span> retrievedConversations =
      <span>await</span> <span>retrieveRelevantConversations</span>(input, <span>2</span>);

    <span>let</span> relevantHistory = <span>""</span>;

    <span>if</span> (retrievedConversations.<span>length</span> > <span>0</span>) {
      relevantHistory = retrievedConversations
        .<span>map</span>(<span>(<span>item, resultIndex</span>) =></span> <span>`
[历史对话 <span>${resultIndex + <span>1</span>}</span>]
轮次:<span>${item.round}</span>
<span>${item.content}</span>`</span>)
        .<span>join</span>(<span>"\n\n----------\n\n"</span>);
    } <span>else</span> {
      <span>console</span>.<span>log</span>(<span>"未找到相关历史对话"</span>);
    }

    <span>const</span> contextMessages = relevantHistory
      ? [
          <span>new</span> <span>HumanMessage</span>(<span>`相关历史对话:
<span>${relevantHistory}</span>

用户问题:<span>${input}</span>`</span>),
        ]
      : [userMessage];

    <span>const</span> response = <span>await</span> model.<span>invoke</span>(contextMessages);
    <span>console</span>.<span>log</span>(response.<span>content</span>);

    <span>await</span> history.<span>addMessage</span>(userMessage);
    <span>await</span> history.<span>addMessage</span>(response);

    <span>const</span> conversationText = <span>`用户:<span>${input}</span>
AI:<span>${response.content}</span>`</span>;
    <span>const</span> conversationId = <span>`conv_<span>${<span>Date</span>.now()}</span>_<span>${index + <span>1</span>}</span>`</span>;
    <span>const</span> conversationVector = <span>await</span> <span>getEmbedding</span>(conversationText);

    <span>try</span> {
      <span>await</span> client.<span>insert</span>({
        <span>collection_name</span>: <span>COLLECTION_NAME</span>,
        <span>data</span>: [
          {
            <span>id</span>: conversationId,
            <span>content</span>: conversationText,
            <span>vector</span>: conversationVector,
            <span>round</span>: index + <span>1</span>,
            <span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
          },
        ],
      });
    } <span>catch</span> (error) {
      <span>console</span>.<span>error</span>(<span>"插入失败:"</span>, error);
    }
  }
}

<span>retrievalMemoryDemo</span>().<span>catch</span>(<span>console</span>.<span>error</span>);

运行顺序是:先单独执行初始化,确保集合、索引和种子对话已经存在;然后再执行检索式聊天流程。

十二、三种上下文管理方式放在一起比较

到这里,已经得到三种不同的 Memory 管理策略:

策略选择历史的依据进入上下文的内容主要特点
截断时间顺序最近若干消息简单直接,旧信息被移除
总结时间顺序 + 模型压缩旧历史摘要 + 最近原文能保留旧信息要点,但细节被压缩
检索当前问题与历史的向量相似度最相关的 K 条历史能找回较早且相关的对话

它们回答的是三个不同问题:

截断:最近说了什么?
总结:过去总体说了什么?
检索:过去哪些内容与当前问题最相关?

因此,完整的 Memory 模块可以同时拥有这些能力,而不是只能三选一。例如:

当前会话保留最近消息
        +
较早会话定期生成摘要
        +
长期内容写入 Milvus
        +
每轮按问题检索相关历史

一个自然的演进方向是:例如每积累 20 条对话就触发一次总结,把摘要或对话写入 Milvus;后续再从 Milvus 中取回相关历史,与近期消息共同构成上下文。这样 Memory 不只是一个不断增长的 messages 数组,而会成为 Agent Harness 中独立的存储与管理模块。

十三、实现时最容易混淆的几个点

1. 保存过,不等于模型看见了

无论消息保存在内存、文件还是 Milvus 中,都必须在调用前读取并放进 model.invoke() 的消息数组,才会影响当前回答。

2. Embedding 维度必须前后一致

示例明确使用 1024:

<span>dimensions</span>: <span>1024</span>

集合字段也必须是:

<span>dim</span>: <span>1024</span>

否则生成的向量不能按当前 schema 正常写入或搜索。

3. 建索引和搜索使用同一种度量

创建索引和搜索都使用 MetricType.COSINE。不要只改其中一处而让两边的配置表达不同的相似度标准。

4. content 才是最终注入聊天模型的内容

向量用于定位记录,聊天模型读取的是 content。所以记录中既要有机器检索用的 vector,也要有模型可读的正文。

5. 初始化和聊天循环职责不同

集合、字段和索引负责准备存储结构;聊天循环负责检索与追加数据。将两者分开,可以避免每次聊天都重复创建同名集合。

总结

从内存消息数组一路走到 Milvus,可以看到 Agent Memory 的重点从来不只是“把聊天记录存下来”,而是同时解决两个问题:

  1. 存储问题:历史放在内存、文件还是数据库;
  2. 管理问题:当前这一轮究竟选择哪些历史交给模型。

短期对话可以使用 InMemoryChatMessageHistory,跨进程恢复可以使用文件历史;上下文变长后,可以按消息数或 Token 数截断,也可以把旧对话总结成摘要;当长期历史更多时,则可以用 Embedding 与 Milvus 按语义取回相关记录。

最终形成的检索式 Memory 闭环是:

对话产生 → 向量化 → 持久化
   ↑                     ↓
模型回答 ← 注入上下文 ← 相似度检索

到这一步,模型依然是无状态的,但 Agent 已经能够通过 Harness 中的 Memory 模块保存历史、控制上下文,并在需要时找回相关信息。