Agent长期记忆实战:从上下文截断到基于Milvus的Memory模块
你有没有遇到过这种情况一个 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. 长期记忆方案选型为什么是 Milvus2.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 记住的那条信息通常是一段自然语言描述而不是原始对话的逐字记录。向量表示是内容通过向量化模型生成的 embeddingMilvus 靠它做相似度检索。元数据标签则包含记忆产生的时间戳、记忆的重要度分数、关联的对话轮次、记忆的类型用户偏好、任务进度、临时事实等。我用 pymilvus 定义了这样的 schemafrom pymilvus import ( connections, CollectionSchema, FieldSchema, DataType, Collection, utility ) connections.connect(hostlocalhost, port19530) fields [ FieldSchema(namememory_id, dtypeDataType.VARCHAR, max_length64, is_primaryTrue), FieldSchema(nameuser_id, dtypeDataType.VARCHAR, max_length64), FieldSchema(namesession_id, dtypeDataType.VARCHAR, max_length128), FieldSchema(namecontent, dtypeDataType.VARCHAR, max_length4096), FieldSchema(nameembedding, dtypeDataType.FLOAT_VECTOR, dim1024), FieldSchema(namememory_type, dtypeDataType.VARCHAR, max_length32), FieldSchema(nameimportance, dtypeDataType.FLOAT), FieldSchema(namecreated_at, dtypeDataType.INT64), ] schema CollectionSchema(fields, descriptionagent long-term memory store) collection Collection(agent_memory, schema)这里的 dim1024 对应我使用的 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_fieldembedding, param{metric_type: IP, params: {ef: 128}}, limit10, expruser_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_MODErevision - ETCD_AUTO_COMPACTION_RETENTION1000 - ETCD_QUOTA_BACKEND_BYTES4294967296 volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd command: etcd -advertise-client-urlshttp://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(hostlocalhost, port19530) print(connect ok)连接这块很多人会用 port 9091 去连那是 Milvus 的监控端口不是服务端口。真正对外提供 SDK 连接的是 19530。我第一回部署的时候就是连错了端口折腾了半天才发现。这个细节值得记一下。4.2 Collection 和索引的初始化Collection 相当于传统数据库里的表建表之前要想清楚字段设计。我上面的 schema 定义里embedding 字段指定了 dim1024这个值必须和实际 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 出问题的时候最快能在五分钟内定位是服务问题还是数据问题。生产环境的可靠性往往就体现在这些小细节里。