ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LangChain.js Agent 长期记忆实战:基于 Milvus 的向量检索与 Embedding 方案

LangChain.js Agent 长期记忆实战:基于 Milvus 的向量检索与 Embedding 方案 1. 为什么 Agent 需要长期记忆做过 LangChain.js Agent 的人大概率都遇到过这个场景你跟 Agent 聊了半小时交代了一堆偏好和背景信息结果关掉页面重新打开它像失忆一样问你“有什么可以帮您”。这不是模型不行而是 Agent 的记忆机制本身就没设计好。LangChain.js 的 Agent 默认只有短期记忆也就是靠 ConversationBufferMemory 或者 ConversationSummaryMemory 这类组件把最近几轮对话塞进上下文窗口。上下文一满旧信息就被挤掉了。更麻烦的是就算上下文没满你也没法精确检索“上周三我提到的那份 API 设计规范”这种跨会话信息。长期记忆要解决的核心问题就一个让 Agent 在任意时刻都能找回跟当前任务相关的历史信息而不是把所有历史都塞进 prompt。这就引出了两个关键技术点——Embedding 和向量检索。Embedding 负责把文本转成高维向量向量检索负责在毫秒级从海量记忆中找到最相关的几条。Milvus 在这个环节扮演的角色就是向量数据库。它专门为大规模向量相似度搜索设计支持十亿级向量、多种索引类型、标量过滤而且有 Node.js SDK 可以直接在 LangChain.js 项目里用。相比 Chroma 和 QdrantMilvus 在数据量上到千万级以后性能优势非常明显尤其是需要同时做向量检索和标量过滤的场景。这篇文章是“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 SDKzilliz/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 部署 MilvusMilvus 本地部署最省事的方式是用 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 控制建索引时的搜索深度越大索引质量越好但建索引越慢。对于百万级数据M16、efConstruction200 是经过验证的稳妥配置。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-small1536 维每百万 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参数控制搜索时的候选集大小越大召回率越高但查询越慢。对于记忆检索这种对延迟敏感的场景ef64 是实测下来比较平衡的值。标量过滤除了 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 中封装自定义 RetrieverLangChain.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 字段至少留 4096memory_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 支持了稀疏向量如果记忆里有大量关键词信息可以结合稠密向量和稀疏向量做混合检索召回率还能再提升一截。
返回列表