1. 为什么短期记忆撑不起一个真正的 Agent做过 LangChain.js Agent 的人大概都有过这种体验聊了七八轮之后Agent 开始失忆前面告诉它的用户偏好、业务约束、已经确认过的结论它统统不记得了。你翻文档发现有个BufferMemory接上去好像能记住几轮但对话一长Token 直接爆掉成本飙升不说模型还会因为上下文里塞了太多无关内容而开始胡言乱语。这就是短期记忆的天花板。它本质上就是把最近 N 轮对话原封不动塞进 Prompt属于暴力记忆。对话轮次少的时候没问题一旦进入真实业务场景——比如一个客服 Agent 需要记住用户三个月前反馈过的设备型号或者一个代码助手需要记住上周定下的架构约定——短期记忆就彻底不够用了。长期记忆要解决的核心问题是在需要的时候把相关的那部分记忆捞出来而不是把所有记忆都塞进去。这个捞的动作就是检索。而检索的前提是把记忆变成可以被语义搜索的东西也就是向量。这就是为什么我们需要一个向量数据库Milvus 就是这里面比较能打的一个选择。这篇是实战的下篇重点讲怎么用 Milvus 给 LangChain.js 的 Agent 搭一套可检索的长期记忆。上篇如果讲的是记忆的抽象和接口设计这篇就全是落地细节Milvus 怎么装、Embedding 怎么选、记忆怎么存怎么取、检索质量怎么调。我会把踩过的坑和调参的经验都摊开讲你照着做基本能跑通。适合的读者是已经用过 LangChain.js、写过基础 Agent、现在想给它加上记得住事能力的开发者。如果你还没碰过 LangChain.js建议先把 Agent 和 Tool 的基本用法过一遍再来看这篇不然有些概念会有点跳。2. Milvus 的部署选择从本地 Docker 到生产集群2.1 为什么在几个向量库里选了 Milvus向量数据库这个赛道现在很卷Chroma、Qdrant、Milvus、pgvector 各有各的拥趸。我在做技术选型的时候主要看三个维度数据规模上限、检索性能、以及运维成本。Chroma 最大的优势是轻pip install chromadb就能跑本地开发体验极好适合原型验证。但它的定位偏向嵌入式数据量上到百万级向量之后检索延迟和稳定性就开始吃紧。Qdrant 用 Rust 写的性能不错单机部署也简单API 设计很干净。Milvus 则是这几个里面架构最重的但也是最能扛的——它天生就是分布式设计支持存算分离索引类型丰富十亿级向量是它的舒适区。我最终选 Milvus 的原因很实际Agent 的长期记忆是会持续增长的今天可能只有几千条半年后可能就是几百万条。我不想在数据量涨上来之后再迁移一次数据库。Milvus 的standalone模式在本地开发时和单机数据库没区别等要上生产了切换到集群模式不用改业务代码这个平滑过渡的能力很值钱。当然如果你的场景就是几千条记忆、单机跑跑Chroma 完全够用没必要为了未来可能上 Milvus。选型这事匹配当前需求最重要别过度设计。2.2 本地开发环境Docker Compose 一把梭Milvus 官方推荐用 Docker Compose 部署 standalone 版本这是本地开发最省心的方式。它依赖三个组件etcd 存元数据、MinIO 存对象数据、Milvus 本体负责计算。官方提供了一个milvus-standalone-docker-compose.yml直接下载下来就能用。# 下载官方 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 # 检查状态三个容器都应该是 healthy docker compose ps启动之后Milvus 默认监听19530端口gRPC和9091端口HTTP 健康检查。你可以用curl http://localhost:9091/healthz验证一下返回OK就说明起来了。注意Milvus 对内存有要求standalone 模式建议至少给 Docker 分配 8GB 内存低于这个数启动过程中容易 OOM。我第一次在 4GB 的虚拟机上试etcd 反复重启排查了半天才发现是内存不够。Windows 用户如果不想折腾 Docker也可以用 Milvus Lite——这是 2.4 版本之后引入的轻量模式直接pip install milvus就能在本地跑一个文件版的 Milvus适合快速验证。但要注意 Milvus Lite 不支持所有索引类型功能是阉割过的正式开发还是建议上 Docker。2.3 连接 MilvusNode.js SDK 的初始化细节LangChain.js 的 Milvus 集成底层用的是zilliz/milvus2-sdk-node你需要先装这个包npm install zilliz/milvus2-sdk-node langchain/community langchain/openai连接配置本身不复杂但有几个参数值得说道说道import { MilvusClient } from zilliz/milvus2-sdk-node; const client new MilvusClient({ address: localhost:19530, username: , // standalone 默认无鉴权 password: , ssl: false, // 本地不开 TLS });address这里有个坑如果你在 Docker 容器里跑 Node 应用而 Milvus 在宿主机上localhost是连不通的得用宿主机的内网 IP 或者 Docker 网络里的服务名。我见过不少人卡在这一步报错是连接超时其实网络根本没通。另外生产环境一定要开鉴权。Milvus 支持用户名密码配合 TLS 使用。别裸奔向量库里存的可都是业务数据。3. Embedding 模型记忆能不能被捞出来的关键3.1 检索质量的上限由 Embedding 决定很多人把注意力全放在向量数据库上觉得换个更快的库检索就准了。这是个误区。向量检索的本质是用向量距离衡量语义相似度而向量是 Embedding 模型生成的。如果 Embedding 模型本身对语义的刻画能力差再好的数据库也捞不出正确的结果。Embedding 模型决定了检索质量的上限向量数据库只决定你能不能高效地逼近这个上限。所以选 Embedding 模型比选向量库更值得花时间。3.2 主流 Embedding 模型的取舍我把常见的几个模型拉出来对比一下方便你做决策模型维度中文能力成本适用场景OpenAI text-embedding-3-small1536中等低通用场景英文为主OpenAI text-embedding-3-large3072较好中对精度要求高的场景BGE-large-zh1024优秀自托管免费中文为主的业务BGE-M31024优秀自托管免费多语言、长文本Cohere embed-v31024较好中多语言场景如果你的 Agent 主要处理中文对话BGE 系列是性价比很高的选择可以本地部署没有 API 调用成本数据也不出内网。缺点是得自己搭推理服务对运维有一点要求。如果图省事、预算也够OpenAI 的text-embedding-3-small是稳妥的默认选项1536 维在精度和存储成本之间平衡得不错。维度这个事要单独说一下。维度越高语义刻画越细但存储和检索成本也越高。3072 维的向量比 1536 维的存储占用翻倍检索时的计算量也更大。除非你的场景对精度极其敏感否则 1024 到 1536 维是甜点区。3.3 在 LangChain.js 里接 EmbeddingLangChain.js 把 Embedding 抽象成了Embeddings接口你换模型只需要换实现类业务代码不用动。用 OpenAI 的话import { OpenAIEmbeddings } from langchain/openai; const embeddings new OpenAIEmbeddings({ modelName: text-embedding-3-small, // 批量大小控制单次请求的文本数量 batchSize: 512, });这里batchSize是个容易被忽略的参数。默认值偏小批量写入记忆的时候会发很多次请求慢且费钱。调到 512 左右能明显提速但别调太大超过模型的单次请求上限会报错。如果你用自托管的 BGE可以用langchain/community里的HuggingFaceEmbeddings指向你本地的推理服务地址。要注意的是自托管服务的吞吐和稳定性得自己保证别让 Embedding 服务成了整个 Agent 的瓶颈。提示Embedding 模型一旦选定不要中途更换。不同模型生成的向量空间是不兼容的换了模型之后旧记忆的向量和新查询的向量不在一个空间里检索结果会完全错乱。如果非要换必须把所有历史记忆重新 Embedding 一遍。这个迁移成本要在选型时就考虑进去。4. 把 Agent 的记忆写进 Milvus数据结构设计4.1 记忆不是一段文本而是一条带元数据的记录新手最容易犯的错是把记忆当成一坨纯文本存进去。这样存进去容易但检索出来之后你没法做过滤、没法做时间排序、没法区分记忆类型。真实的 Agent 记忆至少需要这几个字段文本内容记忆的原始文本检索出来之后要喂给 LLM 的向量文本的 Embedding用于相似度检索记忆类型是用户偏好、事实知识、还是对话摘要不同类型检索时的权重不一样时间戳记忆产生的时间用于时效性排序会话 ID / 用户 ID用于隔离不同用户或不同会话的记忆重要度有些记忆比另一些更重要检索时可以加权Milvus 的 Collection 支持自定义 Schema这些字段都能作为标量字段存进去检索时配合向量相似度做混合过滤。4.2 用 LangChain.js 的 Milvus VectorStoreLangChain.js 提供了Milvus这个 VectorStore 实现它帮你封装了 Collection 的创建和基本的增删查。初始化大概长这样import { Milvus } from langchain/community/vectorstores/milvus; const vectorStore await Milvus.fromDocuments( [], // 初始为空后续动态添加 embeddings, { clientConfig: { address: localhost:19530, }, collectionName: agent_memory, // 向量字段配置 vectorField: vector, // 主键字段 primaryField: id, // 文本字段 textField: text, // 其他标量字段 indexConfig: { index_type: HNSW, metric_type: COSINE, params: { M: 16, efConstruction: 200 }, }, } );这里indexConfig是重点。index_type选HNSW是因为它在召回率和检索速度之间平衡得最好适合记忆检索这种对延迟敏感的场景。metric_type用COSINE是因为 Embedding 向量的语义相似度通常用余弦距离衡量用欧氏距离反而不准。HNSW的两个参数M和efConstruction值得解释一下。M是每个节点在图中连接的邻居数越大召回率越高但内存占用越大16 是常用值。efConstruction是建索引时的搜索深度越大索引质量越好但建索引越慢200 是个不错的起点。这两个参数建完索引就改不了了要调得重建。4.3 自定义 Schema把记忆的元数据塞进去Milvus.fromDocuments用的是默认 Schema只有 id、vector、text 三个字段。要存记忆类型、时间戳这些元数据得自己定义 Schema。LangChain.js 的封装对自定义 Schema 支持有限这时候我建议直接用底层的MilvusClient来建 Collectionawait client.createCollection({ collection_name: agent_memory, fields: [ { name: id, data_type: DataType.VarChar, max_length: 64, is_primary_key: true }, { name: vector, data_type: DataType.FloatVector, dim: 1536 }, { name: text, data_type: DataType.VarChar, max_length: 8192 }, { name: memory_type, data_type: DataType.VarChar, max_length: 32 }, { name: session_id, data_type: DataType.VarChar, max_length: 64 }, { name: timestamp, data_type: DataType.Int64 }, { name: importance, data_type: DataType.Float }, ], });建完 Collection 之后要建索引向量字段建 HNSW 索引标量字段比如 session_id、memory_type建倒排索引这样过滤的时候才快。很多人只给向量字段建了索引结果带过滤条件的检索慢得离谱问题就出在这。注意Milvus 的VarChar字段必须指定max_length而且这个长度是硬限制超了会直接报错。文本字段我一般给 8192够存一段对话摘要了。如果你的记忆是整篇文档得先切分再存别指望一个字段塞下几万字。5. 检索策略怎么把对的那条记忆捞出来5.1 纯向量检索的局限存进去只是第一步能不能在需要的时候捞出来才是关键。最朴素的做法是拿当前对话的文本去 Embedding然后做向量相似度检索取 Top-K。这个方案在简单场景能用但真实场景下问题不少。第一个问题是相似不等于相关。用户说帮我查下订单向量检索可能捞出一堆包含订单这个词但完全不相关的记忆。第二个问题是没有时效性考量三个月前的记忆和昨天的记忆在向量空间里可能一样近但显然应该优先用新的。第三个问题是无法按类型筛选用户偏好和事实知识混在一起捞噪音很大。所以纯向量检索不够得配合过滤和重排。5.2 混合检索向量相似度加标量过滤Milvus 支持在向量检索的同时加标量过滤条件这叫混合检索。比如我只想检索某个用户最近的记忆const results await client.search({ collection_name: agent_memory, data: [queryVector], limit: 10, filter: session_id user_123 and timestamp ${Date.now() - 7 * 24 * 3600 * 1000}, output_fields: [text, memory_type, timestamp], });这个filter表达式是 Milvus 的标量过滤语法支持比较、逻辑运算、in等操作。加上时间过滤之后检索结果的相关性会明显提升因为排除了过期的记忆。过滤条件的选择要看业务。客服 Agent 可能按用户 ID 过滤知识库 Agent 可能按文档来源过滤个人助手可能按记忆类型过滤。原则是能用标量条件排除的就别让向量检索去猜。5.3 重排让最该被记住的记忆浮上来检索出 Top-K 之后直接按向量距离排序喂给 LLM 其实不够好。我一般会加一层重排综合考虑几个因素向量相似度语义相关性的基础分时间衰减越新的记忆加权越高可以用指数衰减重要度记忆写入时标记的重要程度记忆类型匹配当前查询意图和记忆类型的匹配度一个简单的加权公式function rerank(candidates, now) { return candidates .map((c) { const ageHours (now - c.timestamp) / 3600000; const timeDecay Math.exp(-ageHours / 168); // 一周半衰期 const score 0.6 * c.similarity 0.2 * timeDecay 0.15 * c.importance 0.05 * typeMatch(c.memory_type); return { ...c, score }; }) .sort((a, b) b.score - a.score); }权重不是拍脑袋定的得根据你的场景调。我做过一个客服场景时间衰减的权重给到 0.3 效果最好因为用户的问题往往和最近的交互强相关。而知识库场景里时间几乎不重要权重可以压到 0.05。重排这层逻辑LangChain.js 没有现成的封装得自己写。但它的价值很大是区分能用和好用的分水岭。5.4 检索数量 K 值怎么定Top-K 的 K 值也是个需要调的参数。K 太小可能漏掉关键记忆K 太大噪音多还浪费 Token。我的经验是如果记忆条目本身很短一句话K 可以给到 10 到 15如果记忆是段落级的K 给 3 到 5 就够了最终喂给 LLM 之前还要根据 Token 预算做一次截断别小看这个 K 值它直接影响 Agent 的响应质量和成本。我见过有人 K 给到 50结果每次请求都塞进去几千 Token 的无关记忆模型被带偏账单还翻倍。6. 记忆的写入时机与生命周期管理6.1 什么时候该写记忆不是每轮对话都值得存。如果什么都存向量库很快就会被垃圾记忆淹没检索质量断崖式下跌。我一般在这几个时机写记忆用户明确表达偏好时比如我习惯用中文回复我的项目用 TypeScript确认了重要事实时比如这个订单的收货地址是 XXX对话告一段落时把这一段对话总结成一条摘要记忆Agent 做出关键决策时记录决策依据方便后续追溯写入的触发可以交给 LLM 判断。在 Agent 的 Prompt 里加一个指令让它判断当前对话是否产生了值得长期记住的信息如果是就调用一个save_memory工具。这样比无脑全存要精准得多。6.2 记忆去重别让同一条记忆存十遍用户反复说同一件事或者 Agent 反复总结同一段对话都会导致重复记忆。重复记忆不仅浪费存储还会在检索时挤占 Top-K 名额让真正有用的记忆排不进来。去重的做法是写入前先拿新记忆的向量去检索一下如果存在相似度超过阈值比如 0.95的记忆就更新那条而不是新增。Milvus 支持 upsert 操作可以基于主键更新。但相似度去重需要先查再写有额外的开销得权衡。我的做法是给去重设一个宽松的阈值只在高度相似时才去重避免误合并了本该分开的记忆。宁可留一点冗余也别把不同的记忆合并成一条。6.3 记忆的淘汰向量库不是只进不出的长期运行的 Agent记忆会无限增长。虽然 Milvus 扛得住但检索质量会随着噪音增加而下降。所以需要淘汰机制。淘汰策略有几种按时间淘汰最老的、按重要度淘汰最低的、按访问频率淘汰最少被检索到的。我倾向于组合使用给每条记忆算一个存活分综合时间、重要度、访问次数定期清理存活分最低的那批。Milvus 支持按条件删除await client.delete({ collection_name: agent_memory, filter: importance 0.2 and timestamp ${Date.now() - 90 * 24 * 3600 * 1000}, });这个清理任务可以做成定时任务比如每天凌晨跑一次。别在业务高峰期删删除操作会占用计算资源可能影响检索延迟。7. 实测中踩过的坑和调优经验7.1 向量维度和 Collection 不匹配这是最常见的报错。你建 Collection 的时候 dim 设的是 1536结果换了个 1024 维的 Embedding 模型写入时直接报维度不匹配。而且 Milvus 的 Collection 一旦建好dim 是改不了的只能删了重建。我的建议是在项目初期就把 Embedding 模型定死写进配置文件别在代码里硬编码。这样至少能保证写入和查询用的是同一个模型。如果确实要换模型老老实实走一遍全量重新 Embedding 的迁移流程。7.2 索引没建检索慢到怀疑人生Milvus 的 Collection 建好之后如果不建索引默认走的是暴力检索FLAT数据量小的时候没感觉上万条之后延迟就上来了。我一开始图省事没建索引测到五万条记忆的时候单次检索要两三秒完全没法用。建了 HNSW 索引之后降到几十毫秒。建索引的时机也有讲究。数据量小的时候建索引反而可能比暴力检索慢因为索引有额外的开销。一般数据量过万之后再建索引比较划算。但生产环境建议一开始就建好别等数据涨上来再补。7.3 批量写入的坑往 Milvus 写数据一条一条写会非常慢因为每次写入都有网络往返和事务开销。一定要批量写。LangChain.js 的addDocuments内部会做批处理但批量大小可以调。我实测下来每批 500 到 1000 条比较合适太小了慢太大了单次请求超时。还有一个坑是并发写入。多个请求同时往同一个 Collection 写Milvus 是支持的但并发太高会导致写入排队。如果你的 Agent 是高并发的写入最好走一个队列串行化处理避免把 Milvus 打爆。7.4 检索结果为空但数据明明存在这个坑我排查了很久。现象是数据确实写进去了query也能查到但search就是返回空。后来发现是写入之后没有 flush。Milvus 的写入是异步的数据先进内存达到一定量或者手动 flush 之后才持久化并可以被检索到。await client.flush({ collection_names: [agent_memory] });在写入之后调一下 flush或者等 Milvus 自动 flush默认间隔是 1 秒左右。测试的时候如果写完立刻查很容易遇到这个数据还没准备好的情况别以为是代码写错了。7.5 关于记忆检索的一个反直觉经验最后分享一个我调了很久才想明白的点检索出来的记忆不是越多越好也不是越相关越好而是越当前有用越好。我一开始追求高召回率把 Top-K 调得很大结果 Agent 反而变笨了。因为检索出来的记忆里混了很多相关但当前用不上的内容LLM 被这些噪音干扰抓不住重点。后来我把 K 调小加了重排只喂最相关的三五条Agent 的回答质量反而上去了。这背后的道理其实简单LLM 的注意力是有限的你塞给它的每一条记忆都在争夺它的注意力。与其给它一堆可能相关的不如给它几条确定有用的。记忆检索的目标不是找全而是找对。Milvus 这套方案跑下来我的体感是部署和接入都不难难的是检索策略的调优。向量库本身只是个工具真正决定 Agent 记忆能力好坏的是 Embedding 模型的选择、Schema 的设计、以及检索和重排的逻辑。这些没有标准答案得结合你的业务场景反复试。我上面给的参数都是起点不是终点你照着跑通之后一定要根据自己的数据去调。
