你有没有遇到过这种情况:一个 Agent 头几轮对话还挺聪明,知道你的偏好,能记住前面聊过的关键信息,但聊到第 20 轮、第 30 轮的时候,突然像换了个人,把你几分钟前明确说过的事情忘得一干二净,甚至开始重复问同样的问题。这不是模型变笨了,而是你的 Agent 失忆了。
解决失忆问题,最直觉的办法是加长上下文窗口,或者做摘要压缩,也就是所谓的截断方案。但我实际做下来发现,截断只能解一时之渴,真正要支撑一个可以长期使用、跨会话记忆的 Agent,必须给它设计一套独立的 Memory 模块,把记忆从对话上下文里剥离出来,持久化存储,按需召回。这篇文章就记录我从"上下文截断"一路做到"基于 Milvus 的生产级 Memory 模块"的完整过程和踩坑实录,里面全是可直接抄的代码、参数和设计思路,适合正在做 Agent 开发、遇到过"记忆丢失"问题、或者想系统化设计 Agent 记忆体系的同学。
1. 先搞清楚 Agent 是怎么"失忆"的
1.1 上下文窗口的钱不够花
大多数 Agent 的记忆,本质上是依赖大模型的上下文窗口。你今天用 Claude、GPT 或者国产开源模型,它们的上下文窗口从几万 token 到几十万 token 不等。听起来很大,但你仔细算一笔账就明白了:假设一个 Agent 接了 5 个工具,每个工具的描述按 300 token 算,系统提示词加上安全约束按 1500 token 算,用户发给 Agent 的一个普通问题带附件按 500 token 算,调用一次工具时传入的参数加上工具返回的结果,轻则 500 token,重则几千 token。这还没算模型为了保证输出稳定而在内部消耗的 token。
我一个实际运行的 RAG 类 Agent,单轮完整调用链(用户提问 -> 检索 -> 工具调用 -> 生成回答)平均要消耗 4000 到 6000 token。一个 128k 上下文的模型,表面看着能装下几十轮对话,实际上真正留给"历史对话记忆"的空间,可能只够 20 到 30 轮。一旦超出,无论模型有多大,都必须面对同一个问题:该丢什么,该留什么。
更隐蔽的是,很多 Agent 框架在处理超出上下文的时候,默认策略是"从最早的消息开始截断"。这种方法实现简单,但它等于把一个人的"早期记忆"全部抹掉了。用户在第一轮说的"我住在杭州,平时晚上 10 点后才有空"这种重要偏好,很可能在第 15 轮就被默默丢弃了。等 Agent 在后续对话里约错时间、推错地点,用户只会觉得这 Agent 是人工智障,根本不会想到是上下文截断惹的祸。
1.2 截断方案的性价比陷阱
市面上常见的截断方案,我大致归成三类:硬截断、摘要压缩、关键信息抽取。硬截断就是上面说的,超长就从最老的开始删,实现成本最低,但效果最差。摘要压缩稍微聪明一点,用一次额外的模型调用,把早期对话浓缩成一段摘要,再塞回上下文里。这种做法能保留粗粒度的信息,但摘要本身就是有损压缩,细节一定会丢。
我试过让模型对 30 轮对话做摘要压缩,结果用户在第 3 轮提到的一个"用 Python 写一个 API 服务,部署在 8080 端口"这个细节,摘要里完全没有体现。因为做摘要的模型认为这个信息太具体,不值得占用摘要篇幅。可对于后续对话来说,端口号恰恰是最关键的执行参数。关键信息抽取则依赖模型判断什么重要,但不同场景下"重要"的定义完全不同,规则和模型都很难稳定拿捏。
这里有个残酷的现实:无论哪种截断方案,Agent 的记忆上限都等于上下文窗口的大小,只是表现形式不同。这就好像你让一个人靠脑子记住所有事情,不管你怎么帮他归纳整理,他大脑的容量是有上限的。真正生产级的解决办法,是给 Agent 造一个"外挂记忆库"——把记忆存到外部存储里,需要的时候再精准捞回来。这也是我最终走向向量数据库方案的根本原因。
2. 长期记忆方案选型:为什么是 Milvus
2.1 短期记忆和长期记忆要分开设计
在动手之前,我先把 Agent 的记忆拆成了三层。第一层叫会话缓存,也就是当前这轮对话的完整消息列表,直接放在内存里,上下文窗口内解决。第二层叫工作记忆,指 Agent 在当前任务中需要的临时状态,比如"正在调用的 API 地址""刚才检索到的文件路径",这些信息时效性强,通常用一个短期的 KV 存储就能搞定,我用的是内存字典加 Redis 兜底。第三层叫长期记忆,跨会话、跨任务的核心事实和用户偏好,这才是 Memory 模块要解决的核心问题。
这三层记忆不能混在一起管理。如果长期记忆也塞进上下文,那本质上还是截断方案的变种;如果工作记忆写进持久化存储,又会因为过期脏数据干扰检索。我最终的架构是:会话缓存走模型上下文,工作记忆放在一个简单的存储层,长期记忆走向量数据库。这样每一层各司其职,互不干扰。
2.2 主流存储方案横向对比
我在选型的时候,重点对比了四种方案:直接用 Redis 存文本、SQLite 存结构化记录、本地 Faiss 做向量检索、Milvus 作为独立的向量数据库服务。对比的维度包括检索能力、部署成本、扩展性、生态成熟度和运维难度。
先从检索能力说起。Redis 和 SQLite 适合做精确匹配和简单的 SQL 查询,但"我记得之前好像聊过这件事"这种模糊的语义记忆,它们完全无能为力。你不可能用 SQL 写一个查询,让数据库帮你找出"和当前问题语义最相近的历史记忆"。Faiss 是 Meta 开源的向量检索库,性能很强,但它是一个库而不是一个服务,你需要自己管索引的持久化、并发读写和部署形态。我用 Faiss 做了个原型,单机单进程跑没问题,一旦你想要多个 Agent 实例共享同一份记忆,或者数据量到了千万级,Faiss 的方案就会非常痛苦。
Milvus 则是一个真正的分布式向量数据库服务,支持数据持久化、多副本、混合查询,还提供了 Python SDK。它虽然部署上比前几个方案重,但换来的是生产级的能力。社区里有大量的生产案例,生态成熟度明显更高。考虑到我做的是希望长期运行的 Memory 模块,而不是一次性 Demo,我最终选了 Milvus。
2.3 Milvus 的取舍:值不值得上
Milvus 不是没有缺点。第一,它的架构比 SQLite 复杂得多,即使单机版部署也需要依赖 etcd 和 MinIO 两个组件,对没有 Docker 环境的服务器来说,安装成本肉眼可见。第二,vector 检索本身有个特点:它适合做"找相似"而不是"找精确"。如果你要查的是"用户上个月买了一件白色 T 恤的订单号",向量检索大概率不如下一条 SQL 来得准确。第三,Milvus 的内存占用和索引构建需要调优,默认参数在数据量小的时候反而可能拖慢速度,后面我会专门讲参数怎么调。
但它的收益也是实打实的。真正跑起来之后,Agent 的记忆不再受上下文窗口限制,知识可以跨天累积;多个 Agent 进程可以共享同一份记忆;支持按时间、按用户、按话题做过滤,精度可控。对一个要长期运营的 Agent 来说,这套能力远比那点部署成本值钱。
说到底,选型没有绝对的对错,只有合不合适。如果只是做个三五天的玩具项目,Redis 存文本完全够用。但我这个模块的目标是"生产级",意味着要能承载长期多用户的数据量,要能在 Agent 实例崩溃后依然保留记忆,要能在后续做用户画像分析时直接查历史记忆库。这些需求指向的都是 Milvus 而不是本地文件方案。
3. Memory 模块的数据模型与读写链路
3.1 表结构设计:记忆不是聊天记录的堆叠
很多人在设计 Agent 记忆时,第一个想法是"把聊天记录存下来"。这个思路有个致命问题:原始聊天记录噪音太大,直接存进去,检索的时候什么都搜得到,也什么都搜不准。我把记忆模块的数据模型抽象成四个核心字段组:ID 与归属信息、内容主体、向量表示、元数据标签。
归属信息包括用户 ID、会话 ID、记忆块 ID,用于隔离不同用户的记忆。内容主体是我们想让 Agent 记住的那条信息,通常是一段自然语言描述,而不是原始对话的逐字记录。向量表示是内容通过向量化模型生成的 embedding,Milvus 靠它做相似度检索。元数据标签则包含记忆产生的时间戳、记忆的重要度分数、关联的对话轮次、记忆的类型(用户偏好、任务进度、临时事实)等。
我用 pymilvus 定义了这样的 schema:
from pymilvus import ( connections, CollectionSchema, FieldSchema, DataType, Collection, utility ) connections.connect(host="localhost", port="19530") fields = [ FieldSchema(name="memory_id", dtype=DataType.VARCHAR, max_length=64, is_primary=True), FieldSchema(name="user_id", dtype=DataType.VARCHAR, max_length=64), FieldSchema(name="session_id", dtype=DataType.VARCHAR, max_length=128), FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=4096), FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1024), FieldSchema(name="memory_type", dtype=DataType.VARCHAR, max_length=32), FieldSchema(name="importance", dtype=DataType.FLOAT), FieldSchema(name="created_at", dtype=DataType.INT64), ] schema = CollectionSchema(fields, description="agent long-term memory store") collection = Collection("agent_memory", schema)这里的 dim=1024 对应我使用的 embedding 模型的输出维度。content 字段存的是"记忆块",而不是原始对话。记忆块是怎么生成的?我的做法是,当一条对话中包含值得长期记忆的信息时,用一个专门的"记忆抽取模型"把原始对话改写成简洁的陈述句。比如用户说"我下周要去上海出差三天,每天晚上才有时间处理邮件",抽取出来的记忆块就是"用户下周在上海出差,晚上才有时间处理邮件"。这个改写过程会丢掉情绪化表达和无关细节,大大提升后续检索的精确度。
3.2 写入链路:过滤噪音,只记值得记的
Memory 模块最容易犯的一个错误就是什么都记。你想想,用户问一句"今天天气怎么样",难道也要写成记忆块永久保存吗?显然不需要。如果 Agent 每轮对话都写入向量库,过不了几天,库里的有效记忆就会被海量噪音淹没,检索时召回的全是无关内容。
我的写入链路分四步:触发判断、信息抽取、重要度打分、异步写入。触发判断的核心是一个分类模型或者一套规则,判断当前这轮对话是否有值得长期保存的信息。我最初用的是一套关键词加语义双重规则,但效果不稳定,后来换成让一个轻量模型做二分类判断:这段对话中是否包含用户偏好、任务目标、关键事实、允诺信息等。这一步能把写入量过滤掉 70% 以上。
信息抽取就是上面说的记忆块改写。这一步我会额外调用一次大模型,让它输出结构化的抽取结果,包括记忆内容、记忆类型、重要度分数。重要度打分我直接从 0 到 1 让模型给出,同时用规则做兜底修正,比如包含明确时间节点、金额、地址信息时,自动提高重要度。最后写入采用异步队列,避免阻塞主对话流程。队列我一开始用 Python 的 asyncio.Queue,后来多实例部署时换成了 Redis Stream,这样即使某个 Agent 实例挂了,记忆写入也不会丢。
关键的是,写入的向量必须和检索时用同一个 embedding 模型生成。我见过有人写入用文本模型 A 的向量,检索时换了模型 B,结果检索结果一塌糊涂还不明所以。这个坑我踩过一次之后,就把 embedding 模型的名称和版本直接写进了 collection 的描述里,换模型必须重建索引。
3.3 召回链路:让 Agent 想起"最相关的事"
写入只是把记忆存进去,真正的重点是召回。召回链路设计得好不好,直接决定 Agent 能不能在关键时刻想起来该想的事。我在召回时用了一个混合策略:向量相似度检索 + 标量字段过滤 + 时间衰减加权。
向量相似度检索是核心。当前用户输入经过 embedding 模型转换成向量,再去 Milvus 里搜最相近的记忆块。但这里有个问题:向量相似度反映的是语义距离,不代表当前对话的时效性。用户一周前问过的事,和五分钟前刚发生过的事,如果语义相似,向量分数可能差不多。所以我的召回函数会额外加上时间衰减因子——越久远的记忆,权重越低。
混合检索的表达式大概是这样的:
res = collection.search( data=[query_embedding], anns_field="embedding", param={"metric_type": "IP", "params": {"ef": 128}}, limit=10, expr='user_id == "u_1001" and importance > 0.3', output_fields=["content", "memory_type", "created_at", "importance"] )这里的 expr 是标量过滤条件,只检索指定用户的记忆,并且过滤掉重要度过低的记忆。检索结果的精排我会在应用层再做一次,综合考虑向量分数、时间衰减、记忆类型优先级,最后把 top 5 记忆块注入到 Agent 的 system prompt 中。
停一下,这里有个很容易忽略的细节:注入的时机要在构造上下文之前,放在系统提示词里,而不是简单地追加在对话结尾。因为放在系统提示词里,模型会把它当成"应该遵守的背景知识"来对待;放在对话结尾,模型可能只把它当成普通的用户或助手消息,优先级完全不同。我对比实测过,同样一条记忆,放在系统提示词里对回答准确性的提升,明显优于追加在对话尾部。这个细节大家在做的时候一定不要漏掉。
4. Milvus 部署与 Collection 初始化实操
4.1 Docker Compose 部署单机版
Milvus 的部署,我推荐直接用 Docker Compose 起单机版,一条命令搞定,资源消耗可控。Milvus 官方提供了 docker-compose.yml,核心组件是 etcd 做元数据存储、MinIO 做对象存储、standalone 节点提供查询和写入服务。生产自用的话,这三个组件已经够用了。
我把当时的 docker-compose.yml 精简版留在这里:
version: "3.5" services: etcd: container_name: milvus-etcd image: quay.io/coreos/etcd:v3.5.5 environment: - ETCD_AUTO_COMPACTION_MODE=revision - ETCD_AUTO_COMPACTION_RETENTION=1000 - ETCD_QUOTA_BACKEND_BYTES=4294967296 volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd minio: container_name: milvus-minio image: minio/minio:RELEASE.2023-03-20T20-16-18Z environment: MINIO_ACCESS_KEY: minioadmin MINIO_SECRET_KEY: minioadmin volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data command: minio server /minio_data healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] interval: 30s timeout: 20s retries: 3 standalone: container_name: milvus-standalone image: milvusdb/milvus:v2.4.1 command: ["milvus", "run", "standalone"] ports: - "19530:19530" - "9091:9091" environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus depends_on: - "etcd" - "minio"启动之后,用docker compose up -d拉起来,再检查一下三个容器的状态。等 standalone 容器日志里出现 Milvus Proxy ... started 这样的字样,说明服务已经就绪。然后跑一条 Python 脚本验证连接:
from pymilvus import connections connections.connect(host="localhost", port="19530") print("connect ok")连接这块,很多人会用 port 9091 去连,那是 Milvus 的监控端口,不是服务端口。真正对外提供 SDK 连接的是 19530。我第一回部署的时候就是连错了端口,折腾了半天才发现。这个细节值得记一下。
4.2 Collection 和索引的初始化
Collection 相当于传统数据库里的表,建表之前要想清楚字段设计。我上面的 schema 定义里,embedding 字段指定了 dim=1024,这个值必须和实际 embedding 模型的输出维度完全一致。如果用的是 OpenAI 的 text-embedding-3-small,维度是 1536;用开源的中文向量模型,比如 BGE-large-zh,维度是 1024。维度不一致,插入数据的时候会直接报错。
建完 collection 之后,还要创建索引。Milvus 支持多种索引类型,我对比下来,HNSW 在中等数据规模下性能最好,召回率也高。它在底层构建的是一张多层图,检索时从顶层往底层搜索,有一个核心参数 M,控制每个节点的最大连接数,M 越大检索越精确但内存占用也越高。另一个参数 efConstruction 控制建图时的搜索范围,值越大建图越慢但索引质量越高。
我用的索引参数是:
index_params = { "metric_type": "IP", "index_type": "HNSW", "params": {"M": 16, "efConstruction": 256} } collection.create_index("embedding", index_params)metric_type 这里我特意用了 IP(内积)而不是 L2 欧式距离。因为我用的 embedding 模型输出的向量已经做了归一化,在归一化向量的前提下,内积等价于余弦相似度,而且检索性能比 L2 更好。如果你用的模型没有做归一化,请改用 COSINE 或者在插入前自己先做归一化,否则检索分数会失真。
4.3 关键参数调优建议
Milvus 默认参数对新手友好,但生产环境一定要手动调,否则数据量一上来就会遇到响应慢甚至查询超时的问题。我重点调了三个参数:seal_proportion 相关的一致性级别、索引构建的 efConstruction、查询时的 ef。
先说一致性级别。Milvus 提供四种一致性级别,从强一致到最终一致。Agent 记忆场景对一致性要求没那么极端,毕竟记忆晚几秒生效不影响大局。我用的是 Bounded,也就是有界一致性,保证在固定时间窗口内数据可见,同时换取更低的延迟。如果强一致,在数据量大时会有明显的性能损耗。
再说 ef 参数。HNSW 检索时传入的 ef 控制搜索的候选集大小,ef 越大召回越准但越慢。我实测 100 万级数据时,ef 从 64 调到 128,召回准确率提升了大概 5%,延迟增加了 200 毫秒左右。生产上我直接用 128,作为准确率和延迟的平衡点。
还有一点容易忽略:collection 的 shards_num 和 partitions。数据量小的时候,一个 shard 就够了;数据量到千万级再考虑加。分区分层是另一种玩法,比如按 user_id 做 partition key,这样检索的时候可以限定分区,大幅缩小扫描范围。Milvus 2.4 支持在 schema 里直接声明 partition_key_field,我就给 user_id 加了这个能力,检索表达式里带上 user_id 条件时,Milvus 会自动路由到对应分区。
5. 常见问题排查与避坑实录
5.1 典型问题速查表
基于我这几个月的实际运维经验,我把最常见的问题整理成了一张速查表,大家直接对照排查就行。
| 现象 | 疑似原因 | 解决思路 |
|---|---|---|
| 检索结果完全不相关 | embedding 模型不一致或未归一化 | 确认写入和检索用同一个模型,检查向量是否归一化 |
| 插入数据时报 dimension mismatch | 向量维度与 schema 中 dim 不一致 | 核对 embedding 模型输出维度,重建 collection |
| 查询延迟高 | ef 参数太大或数据未压缩 | 调低 ef,检查 Segment 数量,执行 compaction |
| 内存占用持续上涨 | 索引未释放或加载了过多 partition | 用 release 释放不用的集合,控制 load 范围 |
| 重启后数据丢失 | 依赖 MinIO 的数据卷未挂载 | 检查 docker-compose 中的 volumes 配置是否持久化 |
| 首轮查询很慢,后面变快 | Milvus 查询需要先加载 collection | 在服务启动时主动 load_collection 并预热 |
5.2 检索不准确的排查思路
检索不准确,很多人第一反应是换向量数据库,其实大多数时候问题出在前面。我按概率从高到低梳理一下:先看 embedding 模型,是不是写入和检索用了两个不同的模型;再看查询语句,是不是原始输入没做清洗,带着一堆无关感叹词直接生成了向量;然后看过滤条件,expr 里的字段名拼写对不对,索引和 schema 字段是否匹配;最后才是索引参数和相似度度量方式的问题。
这里特别强调一下查询语句的预处理。有些 Agent 框架会把用户的原始消息原封不动地送去生成向量,但用户在实际对话里往往夹杂口语、错别字和上下文指代。比如用户说"那个项目就按之前说的来",如果直接向量化这段文本,检索出来的记忆可能完全无关,因为关键信息"项目名"根本没出现在这句话里。我的做法是先用一个轻量级的"查询改写"步骤,把这种包含指代的查询还原成具体的描述,再送去向量化。这一步对召回率的提升非常明显,值得大家试一试。
5.3 记忆污染与误召回
最后一个大坑是记忆污染。什么叫记忆污染?就是 Agent 从向量库里召回了一段记忆,但这段记忆本身是错误的、过时的,或者来源不可靠,导致 Agent 基于错误记忆给出了错误回答。这种情况比没有记忆更危险,因为 Agent 会理直气壮地用一个错误的"历史事实"来回答用户。
我实际遇到过两个典型的污染场景。第一个是过时信息覆盖失败——用户说"我换工作了,现在不在字节了",但旧记忆里"用户在字节工作"这条还留在库里,如果新记忆没有显式标记为覆盖旧记忆,两条内容可能同时被召回,Agent 就容易混淆。第二个是信息源污染——Agent 在做网络搜索后把搜到的内容当成用户提过的偏好写进了记忆库,下次直接在回答里引用一个用户从没说过的事实。
应对这两个问题的方案是:写新记忆时,如果内容与旧记忆冲突,给旧记忆打上 soft delete 标记并降低检索权重;区分记忆来源字段,在 schema 里加一道 user_said 和 agent_inferred 的类型区分,召回时默认只注入用户明确表达过的记忆,Agent 推断类的记忆只在用户主动询问时才展示。这个设计让我在后续排障时省了无数精力。
6. 一点个人体会
整套 Memory 模块从原型到上线,前后迭代了三个版本。第一个版本用 Faiss 在本地硬干,跑 demo 没问题,一上多实例就崩。第二个版本换到 Milvus,但写入链路没有做过滤,库里的记忆 70% 是垃圾,检索效果甚至不如不存。第三个版本才把触发判断、记忆抽取、重要度打分、异步写入、混合召回完整跑通,Agent 的长期记忆能力才算真正立住。
我个人最大的体会是:记忆模块的核心瓶颈从来不是存储,而是"该记什么、该忘什么"的判断。向量数据库只是让记忆有了容器,但记忆的质量取决于写入和召回这两条链路的设计。如果一开始就在这两条链路上花足够的功夫,后面能省掉大量调检索、洗数据的痛苦。
最后再分享一个小技巧:给记忆模块设计一个简单的自检机制,每天自动抽取若干条记忆做链路验证——写入一条测试记忆,立刻检索,检查能否召回。这个机制让我在 Milvus 出问题的时候,最快能在五分钟内定位是服务问题还是数据问题。生产环境的可靠性,往往就体现在这些小细节里。