LangChain.js Agent Memory 实战(下):用 Milvus 构建可检索的长期记忆
摘要:围绕检索式 Memory,完整实现对话向量化、Milvus 存储、相似度搜索、上下文注入与新记忆回写,并说明短期历史、摘要和长期语义记忆如何协同。
上篇解决了三个问题:
- 用消息历史让模型拥有连续对话能力;
- 用文件让消息跨进程持久化;
- 用截断和摘要控制上下文长度。
但截断与摘要都围绕"最近发生了什么"管理上下文。假设用户进行了大量对话,现在突然问到很久以前讨论过的向量数据库,最近几条消息里未必包含相关内容;如果只保留近期上下文,那段旧信息已经不可见;如果每次传入完整历史,上下文又会持续膨胀。
检索式 Memory 提供了第三条路线:
text
历史对话持久化到 Milvus
↓
当前问题转换成向量
↓
按向量相似度检索相关历史
↓
相关历史与当前问题一起交给模型
↓
把本轮问答继续写回 Milvus
这样,长期记忆不必全部进入当前上下文。每一轮只取与问题语义最接近的少量记录,既控制输入规模,又能找回较早的信息。
本文将完成这条闭环:创建 Milvus 集合、生成 Embedding、插入历史对话、执行相似度检索、拼接模型上下文,并把新问答保存为下一轮可检索的记忆。
一、检索式 Memory 的组成
整个过程包含三个核心对象:
- Embedding 模型:把自然语言转换成固定维度的向量;
- Milvus:保存向量及其对应的对话正文、轮次和时间;
- 聊天模型:读取检索到的相关历史并回答当前问题。
依赖如下:
json
{
"dependencies": {
"@langchain/core": "^1.2.11",
"@langchain/openai": "^1.5.13",
"@zilliz/milvus2-sdk-node": "^3.0.5",
"dotenv": "^17.4.2"
}
}
环境变量需要同时描述聊天模型、Embedding 模型和 Milvus 地址:
dotenv
MODEL_NAME=你的聊天模型名称
OPENAI_API_KEY=你的API-Key
OPENAI_BASE_URL=你的模型服务地址
EMBEDDINGS_MODEL_NAME=你的Embedding模型名称
MILVUS_ADDRESS=你的Milvus地址
这里让聊天模型与 Embedding 模型使用相同的 API Key 和基础地址,但模型名称分别配置。
二、初始化 Embedding 模型和 Milvus 客户端
先定义集合名称与向量维度:
js
const COLLECTION_NAME = "conversations";
const VECTOR_DIM = 1024;
再创建 Embedding 实例:
js
import "dotenv/config";
import { OpenAIEmbeddings } from "@langchain/openai";
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
dimensions: VECTOR_DIM,
});
async function getEmbedding(text) {
return embeddings.embedQuery(text);
}
embedQuery(text) 接收一段文本,返回长度为 1024 的数字数组。这个数组本身不是给读者阅读的内容,而是后续相似度搜索的依据。
VECTOR_DIM 会同时用于 Embedding 配置和 Milvus 字段定义,两处必须一致:
text
Embedding 输出维度:1024
Milvus vector 字段维度:1024
然后创建 Milvus 客户端:
js
import { MilvusClient } from "@zilliz/milvus2-sdk-node";
const client = new MilvusClient({
address: process.env.MILVUS_ADDRESS,
});
正式执行建表、写入或检索前,等待客户端连接:
js
console.log("连接到 Milvus...");
await client.connectPromise;
console.log("连接成功");
三、为长期对话设计集合结构
关系型数据库通常把结构化字段组织成表,而这里把长期对话保存在名为 conversations 的集合中。每条记录包含五个字段:
| 字段 | Milvus 类型 | 用途 |
|---|---|---|
id |
VarChar |
唯一标识一条对话记录,同时作为主键 |
vector |
FloatVector |
对话正文对应的 1024 维向量 |
content |
VarChar |
可读的用户或 AI 对话正文 |
round |
Int64 |
对话轮次 |
timestamp |
VarChar |
ISO 格式时间字符串 |
创建集合:
js
import {
DataType,
IndexType,
MetricType,
} from "@zilliz/milvus2-sdk-node";
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,
},
],
});
时间字段使用 new Date().toISOString() 生成字符串:
js
timestamp: new Date().toISOString()
因此 schema 中把它定义为 VarChar,而不是日期类型。
这个 schema 还体现了向量数据库记录的两部分:
vector用于机器计算相似度;content、round、timestamp用于把检索结果重新组织成人可以理解、模型也可以读取的上下文。
只保存向量而不保存正文,检索后就无法把真实对话交还给聊天模型。
四、为向量字段创建索引
集合创建后,为 vector 字段创建索引:
js
await client.createIndex({
collection_name: COLLECTION_NAME,
field_name: "vector",
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE,
});
console.log("索引已经创建");
这段配置表达了三件事:
- 检索目标是
vector字段; - 索引类型使用
IVF_FLAT; - 相似度度量使用
COSINE。
后续搜索也会明确写出 MetricType.COSINE,保证写入后的索引配置和查询时的度量方式保持一致。
集合和索引属于初始化工作。下面的初始化程序应在首次准备数据时执行;如果同名集合已经存在,再次创建会遇到名称冲突,因此不要把初始化逻辑混在每一轮聊天请求里反复执行。
五、把种子对话转换成向量并写入 Milvus
先准备三轮、六条对话:
js
const conversations = [
{
id: "conversation_1",
content: "用户:你好,我最近正在学习 Milvus 向量数据库。",
round: 1,
timestamp: new Date().toISOString(),
},
{
id: "conversation_2",
content: "AI:Milvus 是一个向量数据库,常用于 RAG、语义搜索和推荐系统。",
round: 1,
timestamp: new Date().toISOString(),
},
{
id: "conversation_3",
content: "用户:向量数据库和 MySQL 有什么区别?",
round: 2,
timestamp: new Date().toISOString(),
},
{
id: "conversation_4",
content: "AI:MySQL 更适合结构化数据和精确查询,而 Milvus 更擅长根据向量相似度进行语义检索。",
round: 2,
timestamp: new Date().toISOString(),
},
{
id: "conversation_5",
content: "用户:那 RAG 为什么要使用向量数据库?",
round: 3,
timestamp: new Date().toISOString(),
},
{
id: "conversation_6",
content: "AI:因为 RAG 需要从大量文本中找到和用户问题语义最相近的内容,再交给大模型生成回答。",
round: 3,
timestamp: new Date().toISOString(),
},
];
每条记录此时还缺少 vector。可以用 Promise.all() 并发生成所有向量,再保留原字段:
js
const conversationData = await Promise.all(
conversations.map(async (item) => ({
...item,
vector: await getEmbedding(item.content),
}))
);
转换前:
js
{
id,
content,
round,
timestamp
}
转换后:
js
{
id,
content,
round,
timestamp,
vector
}
最后一次性写入:
js
await client.insert({
collection_name: COLLECTION_NAME,
data: conversationData,
});
这里按单条用户消息或 AI 消息存储种子数据,所以一次检索可能返回某个问题,也可能返回与它对应的回答。后面保存新对话时会采用稍有不同的粒度:把一整轮"用户问题 + AI 回答"合并成一条记录。
六、完整的初始化程序
把连接、建集合、建索引和插入种子数据组合起来:
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;
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
dimensions: VECTOR_DIM,
});
async function getEmbedding(text) {
return embeddings.embedQuery(text);
}
const client = new MilvusClient({
address: process.env.MILVUS_ADDRESS,
});
async function main() {
try {
console.log("连接到 Milvus...");
await client.connectPromise;
console.log("连接成功");
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,
},
],
});
await client.createIndex({
collection_name: COLLECTION_NAME,
field_name: "vector",
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE,
});
const conversations = [
{
id: "conversation_1",
content: "用户:你好,我最近正在学习 Milvus 向量数据库。",
round: 1,
timestamp: new Date().toISOString(),
},
{
id: "conversation_2",
content: "AI:Milvus 是一个向量数据库,常用于 RAG、语义搜索和推荐系统。",
round: 1,
timestamp: new Date().toISOString(),
},
{
id: "conversation_3",
content: "用户:向量数据库和 MySQL 有什么区别?",
round: 2,
timestamp: new Date().toISOString(),
},
{
id: "conversation_4",
content: "AI:MySQL 更适合结构化数据和精确查询,而 Milvus 更擅长根据向量相似度进行语义检索。",
round: 2,
timestamp: new Date().toISOString(),
},
{
id: "conversation_5",
content: "用户:那 RAG 为什么要使用向量数据库?",
round: 3,
timestamp: new Date().toISOString(),
},
{
id: "conversation_6",
content: "AI:因为 RAG 需要从大量文本中找到和用户问题语义最相近的内容,再交给大模型生成回答。",
round: 3,
timestamp: new Date().toISOString(),
},
];
const conversationData = await Promise.all(
conversations.map(async (item) => ({
...item,
vector: await getEmbedding(item.content),
}))
);
await client.insert({
collection_name: COLLECTION_NAME,
data: conversationData,
});
console.log("初始对话已经写入 Milvus");
} catch (error) {
console.error("错误:", error);
}
}
main();
初始化完成后,conversations 集合便拥有可供检索的长期对话。
七、根据当前问题检索相关历史
检索函数接收当前问题和返回数量 k:
js
import { MetricType } from "@zilliz/milvus2-sdk-node";
async function retrieveRelevantConversations(query, k = 2) {
try {
const queryVector = await getEmbedding(query);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: k,
metric_type: MetricType.COSINE,
output_fields: ["id", "content", "round", "timestamp"],
});
return searchResult.results;
} catch (error) {
console.error("检索对话时出错:", error.message);
return [];
}
}
它的执行过程可以拆成三步。
1. 把当前问题转换成向量
js
const queryVector = await getEmbedding(query);
历史正文和当前问题使用同一个 Embedding 配置,产生相同维度的向量。
2. 在同一个向量字段上执行搜索
js
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: 2,
metric_type: MetricType.COSINE,
output_fields: ["id", "content", "round", "timestamp"],
});
limit: 2 表示只返回最相关的两条记录。控制 k,也就控制了注入模型的长期历史数量。
3. 返回可构造上下文的字段
vector 已经完成了检索任务,交给聊天模型时真正需要的是可读字段,因此通过 output_fields 取回 ID、正文、轮次和时间。
发生异常时返回空数组:
js
async function retrieveRelevantConversations(query, k = 2) {
try {
// 执行向量检索
} catch (error) {
console.error("检索对话时出错:", error.message);
return [];
}
}
这样上层聊天逻辑仍然可以退化为只使用当前用户问题,而不是因为没有检索结果就无法继续调用模型。
八、把检索结果注入模型上下文
假设要连续演示三个问题:
js
const conversations = [
{ input: "为什么要学习向量数据库" },
{ input: "MySQL 和 Milvus 的区别" },
{ input: "RAG 和哪个数据库强相关" },
];
每一轮先执行检索:
js
const retrievedConversations = await retrieveRelevantConversations(input, 2);
再把结果转换成一段结构化文本:
js
let relevantHistory = "";
if (retrievedConversations.length > 0) {
relevantHistory = retrievedConversations
.map((item, index) => `
[历史对话 ${index + 1}]
轮次:${item.round}
${item.content}`)
.join("\n\n----------\n\n");
} else {
console.log("未找到相关历史对话");
}
如果成功找到历史,最终消息会同时包含检索内容和当前问题:
js
const contextMessages = relevantHistory
? [
new HumanMessage(`相关历史对话:
${relevantHistory}
用户问题:${input}`),
]
: [userMessage];
const response = await model.invoke(contextMessages);
模型实际接收到的内容大致如下:
text
相关历史对话:
[历史对话 1]
轮次:2
用户:向量数据库和 MySQL 有什么区别?
----------
[历史对话 2]
轮次:2
AI:MySQL 更适合结构化数据和精确查询,而 Milvus 更擅长根据向量相似度进行语义检索。
用户问题:MySQL 和 Milvus 的区别
这里并不是要求聊天模型自己访问 Milvus。检索发生在调用模型之前,应用把结果整理成 HumanMessage,模型只负责阅读这段上下文并生成回答。
如果没有结果,contextMessages 就退化为原始的 userMessage:
js
[new HumanMessage(input)]
九、短期 history 与模型上下文不是同一个概念
演示中还维护了一个内存历史:
js
const history = new InMemoryChatMessageHistory();
await history.addMessage(userMessage);
await history.addMessage(response);
但调用模型使用的是:
js
await model.invoke(contextMessages);
而不是:
js
await model.invoke(await history.getMessages());
这意味着当前实现里,history 负责记录本次程序运行期间发生的问答,但不会自动参与下一轮模型输入。真正进入模型上下文的是"Milvus 检索结果 + 当前问题"。
这一点很重要:
text
addMessage() 只是保存消息
getMessages() 并放入 model.invoke() 才会影响模型本轮回答
因此,这个示例突出的是检索式长期记忆 ,而不是把本次运行的全部短期消息也带入每一轮。如果需要同时使用两者,就要在构造 contextMessages 时明确合并,而不是只调用 history.addMessage()。
十、把新问答写回长期记忆
模型回答后,先把本轮用户问题和 AI 回答组合成一段文本:
js
const conversationText = `用户:${input}
AI:${response.content}`;
再生成唯一 ID、向量与时间:
js
const conversationId = `conv_${Date.now()}_${index + 1}`;
const conversationVector = await getEmbedding(conversationText);
最后写回同一个集合:
js
await client.insert({
collection_name: COLLECTION_NAME,
data: [
{
id: conversationId,
content: conversationText,
vector: conversationVector,
round: index + 1,
timestamp: new Date().toISOString(),
},
],
});
至此,一轮对话形成了闭环:
text
当前问题
↓ Embedding
查询 Milvus
↓
相关历史 + 当前问题
↓
聊天模型回答
↓
用户问题 + AI 回答
↓ Embedding
写回 Milvus
下一轮检索不仅能找到最初插入的种子对话,也可能找到刚刚生成的新对话。
种子数据按单条消息存储,新数据则把一问一答合并成一条记录。这两种粒度都能被当前 schema 接收,不过理解检索结果时要注意区别:前者的一条结果只代表用户或 AI 的单条消息,后者的一条结果代表完整一轮问答。
十一、完整的检索式 Memory 示例
下面把检索、生成和写回组合成一个可以直接理解的完整流程:
js
import "dotenv/config";
import { OpenAIEmbeddings, ChatOpenAI } from "@langchain/openai";
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import { HumanMessage } from "@langchain/core/messages";
import {
MilvusClient,
MetricType,
} from "@zilliz/milvus2-sdk-node";
const COLLECTION_NAME = "conversations";
const VECTOR_DIM = 1024;
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDINGS_MODEL_NAME,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
dimensions: VECTOR_DIM,
});
async function getEmbedding(text) {
return embeddings.embedQuery(text);
}
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
const client = new MilvusClient({
address: process.env.MILVUS_ADDRESS,
});
async function retrieveRelevantConversations(query, k = 2) {
try {
const queryVector = await getEmbedding(query);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: k,
metric_type: MetricType.COSINE,
output_fields: ["id", "content", "round", "timestamp"],
});
return searchResult.results;
} catch (error) {
console.error("检索对话时出错:", error.message);
return [];
}
}
async function retrievalMemoryDemo() {
try {
console.log("连接到 Milvus...");
await client.connectPromise;
console.log("连接成功\n");
} catch (error) {
console.error("无法连接到 Milvus:", error.message);
return;
}
const history = new InMemoryChatMessageHistory();
const conversations = [
{ input: "为什么要学习向量数据库" },
{ input: "MySQL 和 Milvus 的区别" },
{ input: "RAG 和哪个数据库强相关" },
];
for (let index = 0; index < conversations.length; index++) {
const { input } = conversations[index];
const userMessage = new HumanMessage(input);
console.log(`第 ${index + 1} 轮对话,用户:${input}`);
console.log("[检索相关历史对话]");
const retrievedConversations =
await retrieveRelevantConversations(input, 2);
let relevantHistory = "";
if (retrievedConversations.length > 0) {
relevantHistory = retrievedConversations
.map((item, resultIndex) => `
[历史对话 ${resultIndex + 1}]
轮次:${item.round}
${item.content}`)
.join("\n\n----------\n\n");
} else {
console.log("未找到相关历史对话");
}
const contextMessages = relevantHistory
? [
new HumanMessage(`相关历史对话:
${relevantHistory}
用户问题:${input}`),
]
: [userMessage];
const response = await model.invoke(contextMessages);
console.log(response.content);
await history.addMessage(userMessage);
await history.addMessage(response);
const conversationText = `用户:${input}
AI:${response.content}`;
const conversationId = `conv_${Date.now()}_${index + 1}`;
const conversationVector = await getEmbedding(conversationText);
try {
await client.insert({
collection_name: COLLECTION_NAME,
data: [
{
id: conversationId,
content: conversationText,
vector: conversationVector,
round: index + 1,
timestamp: new Date().toISOString(),
},
],
});
} catch (error) {
console.error("插入失败:", error);
}
}
}
retrievalMemoryDemo().catch(console.error);
运行顺序是:先单独执行初始化,确保集合、索引和种子对话已经存在;然后再执行检索式聊天流程。
十二、三种上下文管理方式放在一起比较
到这里,已经得到三种不同的 Memory 管理策略:
| 策略 | 选择历史的依据 | 进入上下文的内容 | 主要特点 |
|---|---|---|---|
| 截断 | 时间顺序 | 最近若干消息 | 简单直接,旧信息被移除 |
| 总结 | 时间顺序 + 模型压缩 | 旧历史摘要 + 最近原文 | 能保留旧信息要点,但细节被压缩 |
| 检索 | 当前问题与历史的向量相似度 | 最相关的 K 条历史 | 能找回较早且相关的对话 |
它们回答的是三个不同问题:
text
截断:最近说了什么?
总结:过去总体说了什么?
检索:过去哪些内容与当前问题最相关?
因此,完整的 Memory 模块可以同时拥有这些能力,而不是只能三选一。例如:
text
当前会话保留最近消息
+
较早会话定期生成摘要
+
长期内容写入 Milvus
+
每轮按问题检索相关历史
一个自然的演进方向是:例如每积累 20 条对话就触发一次总结,把摘要或对话写入 Milvus;后续再从 Milvus 中取回相关历史,与近期消息共同构成上下文。这样 Memory 不只是一个不断增长的 messages 数组,而会成为 Agent Harness 中独立的存储与管理模块。
十三、实现时最容易混淆的几个点
1. 保存过,不等于模型看见了
无论消息保存在内存、文件还是 Milvus 中,都必须在调用前读取并放进 model.invoke() 的消息数组,才会影响当前回答。
2. Embedding 维度必须前后一致
示例明确使用 1024:
js
dimensions: 1024
集合字段也必须是:
js
dim: 1024
否则生成的向量不能按当前 schema 正常写入或搜索。
3. 建索引和搜索使用同一种度量
创建索引和搜索都使用 MetricType.COSINE。不要只改其中一处而让两边的配置表达不同的相似度标准。
4. content 才是最终注入聊天模型的内容
向量用于定位记录,聊天模型读取的是 content。所以记录中既要有机器检索用的 vector,也要有模型可读的正文。
5. 初始化和聊天循环职责不同
集合、字段和索引负责准备存储结构;聊天循环负责检索与追加数据。将两者分开,可以避免每次聊天都重复创建同名集合。
总结
从内存消息数组一路走到 Milvus,可以看到 Agent Memory 的重点从来不只是"把聊天记录存下来",而是同时解决两个问题:
- 存储问题:历史放在内存、文件还是数据库;
- 管理问题:当前这一轮究竟选择哪些历史交给模型。
短期对话可以使用 InMemoryChatMessageHistory,跨进程恢复可以使用文件历史;上下文变长后,可以按消息数或 Token 数截断,也可以把旧对话总结成摘要;当长期历史更多时,则可以用 Embedding 与 Milvus 按语义取回相关记录。
最终形成的检索式 Memory 闭环是:
text
对话产生 → 向量化 → 持久化
↑ ↓
模型回答 ← 注入上下文 ← 相似度检索
到这一步,模型依然是无状态的,但 Agent 已经能够通过 Harness 中的 Memory 模块保存历史、控制上下文,并在需要时找回相关信息。