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 连接 Milvus:Node.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-small | 1536 | 中等 | 低 | 通用场景,英文为主 |
| OpenAI text-embedding-3-large | 3072 | 较好 | 中 | 对精度要求高的场景 |
| BGE-large-zh | 1024 | 优秀 | 自托管免费 | 中文为主的业务 |
| BGE-M3 | 1024 | 优秀 | 自托管免费 | 多语言、长文本 |
| Cohere embed-v3 | 1024 | 较好 | 中 | 多语言场景 |
如果你的 Agent 主要处理中文对话,BGE 系列是性价比很高的选择,可以本地部署,没有 API 调用成本,数据也不出内网。缺点是得自己搭推理服务,对运维有一点要求。如果图省事、预算也够,OpenAI 的text-embedding-3-small是稳妥的默认选项,1536 维在精度和存储成本之间平衡得不错。
维度这个事要单独说一下。维度越高,语义刻画越细,但存储和检索成本也越高。3072 维的向量比 1536 维的存储占用翻倍,检索时的计算量也更大。除非你的场景对精度极其敏感,否则 1024 到 1536 维是甜点区。
3.3 在 LangChain.js 里接 Embedding
LangChain.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 VectorStore
LangChain.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来建 Collection:
await 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 的设计、以及检索和重排的逻辑。这些没有标准答案,得结合你的业务场景反复试。我上面给的参数都是起点,不是终点,你照着跑通之后,一定要根据自己的数据去调。