1. 为什么 Agent 需要长期记忆
做过 LangChain.js Agent 的人大概率都遇到过这个场景:你跟 Agent 聊了半小时,交代了一堆偏好和背景信息,结果关掉页面重新打开,它像失忆一样问你“有什么可以帮您”。这不是模型不行,而是 Agent 的记忆机制本身就没设计好。
LangChain.js 的 Agent 默认只有短期记忆,也就是靠 ConversationBufferMemory 或者 ConversationSummaryMemory 这类组件,把最近几轮对话塞进上下文窗口。上下文一满,旧信息就被挤掉了。更麻烦的是,就算上下文没满,你也没法精确检索“上周三我提到的那份 API 设计规范”这种跨会话信息。
长期记忆要解决的核心问题就一个:让 Agent 在任意时刻都能找回跟当前任务相关的历史信息,而不是把所有历史都塞进 prompt。这就引出了两个关键技术点——Embedding 和向量检索。Embedding 负责把文本转成高维向量,向量检索负责在毫秒级从海量记忆中找到最相关的几条。
Milvus 在这个环节扮演的角色就是向量数据库。它专门为大规模向量相似度搜索设计,支持十亿级向量、多种索引类型、标量过滤,而且有 Node.js SDK 可以直接在 LangChain.js 项目里用。相比 Chroma 和 Qdrant,Milvus 在数据量上到千万级以后性能优势非常明显,尤其是需要同时做向量检索和标量过滤的场景。
这篇文章是“LangChain.js Agent Memory 实战”的下篇,上篇讲了短期记忆和对话链的搭建,这篇专注讲怎么用 Milvus 把长期记忆落地。适合已经跑通过 LangChain.js 基础 Agent、想给 Agent 加上“记住事情”能力的开发者。如果你还没接触过 LangChain.js,建议先看上篇把基础链路跑通。
2. 整体架构设计与选型考量
2.1 长期记忆的数据流设计
长期记忆不是简单地把对话存进数据库就完事了。一个完整的记忆系统需要处理四个环节:写入、向量化、存储、检索。
写入环节要决定“什么信息值得记”。不是每句话都有长期价值,比如“好的”“嗯嗯”这种就没必要存。我的做法是用一个轻量的 LLM 调用做记忆提取,把对话中涉及事实、偏好、决策的内容抽出来,再写入向量库。
向量化环节要选 Embedding 模型。这个选择直接影响检索质量。OpenAI 的 text-embedding-3-small 性价比高,1536 维,适合大多数场景。如果对中文支持要求高,可以用 BGE-M3 或者 Cohere 的 embed-multilingual-v3.0。维度越高检索越准,但存储和计算成本也越高。
存储环节就是 Milvus 的 Collection 设计。需要定义向量字段、标量字段(比如用户 ID、时间戳、记忆类型)、以及索引类型。检索环节要处理相似度阈值、Top-K 召回、标量过滤条件的组合。
整个数据流是这样的:用户输入 → Agent 处理 → 判断是否需要写入记忆 → 调用 Embedding 模型 → 存入 Milvus → 下次对话时先检索相关记忆 → 注入 prompt → Agent 生成回复。
2.2 为什么选 Milvus 而不是 Chroma 或 Qdrant
向量数据库选型这件事,我在三个项目里分别用过 Chroma、Qdrant 和 Milvus,说下实际感受。
Chroma 最大的优势是轻量,pip install 就能跑,适合原型验证。但它的 Node.js 支持一直不太行,而且数据量上到百万级以后查询延迟明显上升。Qdrant 的 Rust 实现性能很好,API 设计也干净,但它的分布式部署需要额外配置,社区版功能有限。
Milvus 的优势在于:第一,它有官方维护的 Node.js SDK(@zilliz/milvus2-sdk-node),跟 LangChain.js 集成很顺;第二,它支持 IVF_FLAT、HNSW、DiskANN 等多种索引,可以根据数据量和精度要求灵活选择;第三,它的标量过滤能力很强,可以在向量检索的同时按时间范围、用户 ID 等条件过滤,这对记忆系统非常关键。
当然 Milvus 的部署比 Chroma 重。本地开发可以用 Docker Compose 一键起,生产环境建议用 Milvus 集群或者 Zilliz Cloud。如果你的数据量在十万条以内,Chroma 其实够用;但如果你预期记忆会持续增长到百万级,Milvus 是更稳妥的选择。
2.3 LangChain.js 与 Milvus 的集成方式
LangChain.js 提供了Milvus这个 VectorStore 类,可以直接把 Milvus 当作向量存储来用。它的接口跟其他 VectorStore 一致,支持addDocuments、similaritySearch、similaritySearchWithScore等方法。
但实际用的时候我不建议直接用这个封装类,原因有两个:一是它的过滤条件语法跟 Milvus 原生语法有差异,复杂过滤场景下容易踩坑;二是它默认的 Collection schema 比较固定,不方便加自定义标量字段。
我的做法是用@zilliz/milvus2-sdk-node直接操作 Milvus,然后在 LangChain.js 里封装一个自定义的 Retriever。这样灵活性最高,也能精确控制索引参数和检索逻辑。下面的实操部分会详细讲这个方案。
3. Milvus 环境搭建与核心配置
3.1 本地 Docker 部署 Milvus
Milvus 本地部署最省事的方式是用 Docker Compose。官方提供了一个 standalone 模式的 compose 文件,包含 Milvus、etcd、MinIO 三个服务。
# 下载官方 compose 文件 wget https://github.com/milvus-io/milvus/releases/download/v2.4.0/milvus-standalone-docker-compose.yml -O docker-compose.yml # 启动 docker compose up -d # 检查状态 docker compose ps启动后 Milvus 默认监听 19530 端口(gRPC)和 9091 端口(HTTP)。用docker compose ps确认三个容器都是 healthy 状态再继续。
注意:Milvus 对内存要求比较高,standalone 模式建议至少 8GB 可用内存。如果机器内存不够,etcd 会频繁重启,表现为连接超时。
Windows 用户如果不想装 Docker Desktop,也可以用 WSL2 里跑 Docker,体验跟 Linux 一致。我试过在 Windows 原生环境直接跑 Milvus 二进制,配置太折腾,不推荐。
3.2 Node.js SDK 安装与连接
npm install @zilliz/milvus2-sdk-node连接代码:
import { MilvusClient, DataType } from '@zilliz/milvus2-sdk-node'; const client = new MilvusClient({ address: 'localhost:19530', username: '', password: '', }); // 测试连接 const health = await client.checkHealth(); console.log('Milvus health:', health);如果连接报错Failed to connect to Milvus,先检查 Docker 容器状态,再确认防火墙没有拦截 19530 端口。
3.3 Collection Schema 设计
记忆系统的 Collection 需要几个关键字段:
const collectionName = 'agent_memory'; const schema = [ { name: 'id', data_type: DataType.VarChar, max_length: 64, is_primary_key: true, }, { name: 'vector', data_type: DataType.FloatVector, dim: 1536, // 跟 Embedding 模型维度一致 }, { name: 'user_id', data_type: DataType.VarChar, max_length: 64, }, { name: 'content', data_type: DataType.VarChar, max_length: 4096, }, { name: 'memory_type', data_type: DataType.VarChar, max_length: 32, }, { name: 'created_at', data_type: DataType.Int64, }, ];这里user_id用来隔离不同用户的记忆,memory_type区分事实、偏好、决策等类型,created_at支持按时间范围过滤。content存原始文本,检索到之后直接注入 prompt。
创建 Collection 和索引:
await client.createCollection({ collection_name: collectionName, fields: schema, }); await client.createIndex({ collection_name: collectionName, field_name: 'vector', index_type: 'HNSW', metric_type: 'COSINE', params: { M: 16, efConstruction: 200 }, });索引类型选 HNSW 是因为它在召回率和查询速度之间平衡得最好。M 和 efConstruction 是两个关键参数:M 控制每个节点的最大连接数,越大召回率越高但内存占用也越大;efConstruction 控制建索引时的搜索深度,越大索引质量越好但建索引越慢。对于百万级数据,M=16、efConstruction=200 是经过验证的稳妥配置。
4. 记忆的写入与向量化实操
4.1 记忆提取策略
不是所有对话都值得写入长期记忆。我的做法是在 Agent 处理完一轮对话后,用一个轻量 prompt 让 LLM 判断是否需要提取记忆:
const memoryExtractionPrompt = ` 你是一个记忆提取器。分析以下对话,提取值得长期记住的信息。 值得记住的包括:用户偏好、事实陈述、重要决策、约定事项。 不值得记住的包括:寒暄、确认性回复、临时性信息。 对话内容: ${conversation} 如果有值得记住的信息,以 JSON 数组返回,每项包含 content 和 memory_type。 memory_type 可选值:fact、preference、decision。 如果没有值得记住的信息,返回空数组 []。 `;这个步骤会增加一次 LLM 调用,但能显著减少无效记忆的写入。实测下来,过滤掉寒暄和确认性回复后,向量库的检索准确率能提升 30% 以上。
4.2 Embedding 生成与批量写入
Embedding 模型我选的是 OpenAI 的 text-embedding-3-small,1536 维,每百万 token 成本 0.02 美元,性价比很高。如果项目对中文支持要求高,可以换成 BGE-M3,但需要自己部署推理服务。
import OpenAI from 'openai'; const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); async function getEmbedding(text) { const response = await openai.embeddings.create({ model: 'text-embedding-3-small', input: text, }); return response.data[0].embedding; }批量写入时要注意 Milvus 的单次插入上限。默认情况下单次 insert 最多 16384 条,超过需要分批。另外向量字段的维度必须跟 schema 定义完全一致,否则会报dimension mismatch。
async function insertMemories(memories, userId) { const rows = await Promise.all( memories.map(async (mem) => ({ id: `${userId}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, vector: await getEmbedding(mem.content), user_id: userId, content: mem.content, memory_type: mem.memory_type, created_at: Date.now(), })) ); await client.insert({ collection_name: collectionName, data: rows, }); // 插入后需要 flush 才能被检索到 await client.flushSync({ collection_name: collectionName }); }注意:Milvus 插入数据后不会立即对搜索可见,需要等 flush 完成。
flushSync会阻塞直到 flush 完成,适合对实时性要求高的场景。如果写入量大,可以用异步 flush 加定时查询的方式。
4.3 记忆去重与更新
长期记忆系统跑久了会出现重复记忆的问题。比如用户在不同对话里反复提到同一个偏好,每次都被提取成新记忆。我的做法是在写入前先做一次相似度检索,如果找到相似度超过 0.95 的记忆,就更新而不是新增。
async function upsertMemory(memory, userId) { const vector = await getEmbedding(memory.content); const existing = await client.search({ collection_name: collectionName, vector: [vector], filter: `user_id == "${userId}"`, limit: 1, output_fields: ['id', 'content'], }); if (existing.results[0]?.score > 0.95) { // 更新已有记忆 await client.upsert({ collection_name: collectionName, data: [{ id: existing.results[0].id, vector, user_id: userId, content: memory.content, memory_type: memory.memory_type, created_at: Date.now(), }], }); } else { // 新增记忆 await insertMemories([memory], userId); } }这个去重逻辑能有效控制记忆总量,避免向量库无限膨胀。
5. 记忆检索与 Agent 集成
5.1 相似度检索与标量过滤
检索是长期记忆的核心环节。基本流程是:用户输入 → 生成 query embedding → Milvus 相似度搜索 → 返回 Top-K 相关记忆 → 注入 prompt。
async function retrieveMemories(query, userId, topK = 5) { const queryVector = await getEmbedding(query); const results = await client.search({ collection_name: collectionName, vector: [queryVector], filter: `user_id == "${userId}"`, limit: topK, output_fields: ['content', 'memory_type', 'created_at'], params: { ef: 64 }, }); return results.results .filter(r => r.score > 0.7) // 过滤低相关度结果 .map(r => ({ content: r.content, type: r.memory_type, score: r.score, })); }ef参数控制搜索时的候选集大小,越大召回率越高但查询越慢。对于记忆检索这种对延迟敏感的场景,ef=64 是实测下来比较平衡的值。
标量过滤除了 user_id,还可以加时间范围。比如只检索最近 30 天的记忆:
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; const filter = `user_id == "${userId}" && created_at > ${thirtyDaysAgo}`;5.2 把检索结果注入 Agent Prompt
检索到的记忆需要以合适的方式注入 prompt。我的做法是在 system prompt 里加一个“相关记忆”区块:
function buildSystemPrompt(memories) { const memoryBlock = memories.length > 0 ? `\n\n以下是关于用户的相关记忆,请在回答时参考:\n${memories.map(m => `- [${m.type}] ${m.content}`).join('\n')}` : ''; return `你是一个有帮助的助手。${memoryBlock}`; }这里有个细节:记忆不要全部注入,只注入跟当前 query 相关度最高的几条。注入太多会占用上下文窗口,反而影响模型对当前问题的注意力。实测 Top-5 是比较合适的值。
5.3 在 LangChain.js 中封装自定义 Retriever
LangChain.js 的 Retriever 接口需要实现_getRelevantDocuments方法。封装成 Retriever 的好处是可以直接接入 LangChain 的 chain 体系:
import { BaseRetriever } from '@langchain/core/retrievers'; import { Document } from '@langchain/core/documents'; class MilvusMemoryRetriever extends BaseRetriever { constructor({ client, collectionName, userId, embeddingFn }) { super(); this.client = client; this.collectionName = collectionName; this.userId = userId; this.embeddingFn = embeddingFn; } async _getRelevantDocuments(query) { const vector = await this.embeddingFn(query); const results = await this.client.search({ collection_name: this.collectionName, vector: [vector], filter: `user_id == "${this.userId}"`, limit: 5, output_fields: ['content', 'memory_type'], }); return results.results .filter(r => r.score > 0.7) .map(r => new Document({ pageContent: r.content, metadata: { type: r.memory_type, score: r.score }, })); } }这样就能在 LangChain.js 的 chain 里像用其他 Retriever 一样用它。
6. 常见问题与排查技巧
6.1 检索结果不相关怎么办
这是最常见的问题。排查顺序是:先看 Embedding 模型是否适合当前语言和领域,再看相似度阈值是否设得太低,最后看 Top-K 是否太大导致噪声混入。
如果用的是 OpenAI 的 embedding 模型但内容主要是中文,检索质量会明显下降。换成 BGE-M3 或者 Cohere 的多语言模型会有改善。另外相似度阈值建议从 0.7 起步,根据实际效果调整。低于 0.6 的结果基本可以认为是噪声。
6.2 Milvus 连接超时或查询变慢
Milvus 查询变慢通常有三个原因:索引没建好、数据量超过单机承载、或者 ef 参数设得太大。
先确认索引状态:
const indexInfo = await client.describeIndex({ collection_name: collectionName, field_name: 'vector', }); console.log(indexInfo);如果索引状态不是Finished,说明索引还在建。数据量超过 500 万条以后,standalone 模式会开始吃力,建议迁移到集群模式。ef 参数超过 128 以后查询延迟会明显上升,除非对召回率有极高要求,否则不建议超过 128。
6.3 记忆写入后检索不到
最常见的原因是忘了 flush。Milvus 的数据插入后先进入内存缓冲区,flush 之后才对搜索可见。另一个原因是 filter 条件写错了,比如 user_id 大小写不一致,或者时间戳单位搞混(Milvus 的 Int64 时间戳是毫秒)。
还有一个隐蔽的坑:如果 Collection 创建时没有指定consistency_level,默认是Bounded,意味着搜索可能读到稍旧的数据。对实时性要求高的场景可以设为Strong,但会牺牲一些性能。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 检索结果不相关 | Embedding 模型不适配 | 换多语言模型,检查相似度阈值 |
| 查询延迟高 | 索引未建好或 ef 过大 | 检查索引状态,降低 ef 值 |
| 写入后检索不到 | 未 flush 或 filter 错误 | 调用 flushSync,检查 filter 语法 |
| 连接超时 | Milvus 容器未就绪 | docker compose ps 检查容器状态 |
| 维度不匹配 | Embedding 维度与 schema 不一致 | 确认模型输出维度与 schema dim 相同 |
7. 几个实操中踩过的坑
第一个坑是 Collection 的max_length设太小。VarChar 字段的 max_length 是硬限制,超过会直接报错。content 字段我一开始设了 1024,结果遇到长文本记忆就写不进去。后来改成 4096 才够用。建议 content 字段至少留 4096,memory_type 留 32 就够。
第二个坑是 Embedding 的批量调用。OpenAI 的 embedding 接口单次最多 2048 条输入,超过会报错。而且批量调用时如果其中一条失败,整批都会失败。我的做法是分批调用,每批 100 条,失败时重试单条。
第三个坑是 Milvus 的upsert行为。Milvus 的 upsert 是先删后插,如果主键不存在会直接插入。但它的删除是标记删除,实际数据要等 compaction 之后才真正清理。所以频繁 upsert 会导致存储膨胀,需要定期触发 compaction。
第四个坑是时间戳精度。JavaScript 的Date.now()返回毫秒,但 Milvus 的 Int64 字段如果按秒来理解就会出错。统一用毫秒,并且在 filter 里也用毫秒,避免单位混乱。
这套方案我在两个项目里跑过,一个是对内的知识助手,一个是对外的客服 Agent。知识助手场景下记忆量增长比较慢,半年积累了几万条;客服场景下每天新增几千条,三个月到了几十万条。Milvus 在几十万条量级下查询延迟稳定在 10ms 以内,完全满足实时对话的需求。
后续如果要扩展,可以考虑给记忆加过期策略,比如超过 90 天的低相关度记忆自动归档。另外 Milvus 2.4 支持了稀疏向量,如果记忆里有大量关键词信息,可以结合稠密向量和稀疏向量做混合检索,召回率还能再提升一截。