
在Agent开发里记忆系统属于那种“不做看不出问题一上线全是问题”的模块。初期跑Demo时上下文窗口还够用等对话超过几十轮、任务跨天甚至跨周模型就开始“失忆”用户前面明确说过的偏好被冲掉历史结论反复被推翻甚至工具调用的中间状态都对不上。最原始的做法是截断直接把最旧的对话丢掉但丢掉的往往是关键约束。这篇文章想分享的是从“截断”走到“总结压缩向量检索”的完整路径最终用Milvus把记忆做成可持续读写的长期库。它解决的问题很具体在有限上下文里保留最关键信息在跨会话场景下按需找回细节。适合正在做Agent开发、被上下文窗口困扰、或者想把记忆外置成独立模块的工程师参考。1. 为什么Agent需要“记忆进阶”1.1 截断方案的三个致命问题很多Agent项目的第一个版本上下文管理都是靠截断完成的设定一个最大Token数超过就把最早的消息丢出去。这个方案实现成本最低但在真实场景里会带来三个很致命的问题。第一个问题是关键信息被无差别删除。对话前几轮通常包含用户的核心目标、偏好的表达方式、已经确认的业务约束截断函数不会分辨这些它只按时间顺序丢。举个例子用户在一开始说过“所有金额展示保留两位小数不要用科学计数法”这属于全局约束删掉之后模型在后面的生成里随时可能放飞自我。第二个问题是模型会“编造”上下文。当早期信息缺失时模型为了保持回答连贯会自行脑补一个合理的背景。这种幻觉不是模型故意犯错而是它在信息不完整的情况下做了一个概率最高的猜测。排查起来非常痛苦因为你看到的回答推理过程是对的但前提已经被偷换掉了。第三个问题是工具调用链路断裂。现在的Agent多数会调用工具、查数据库、操作外部系统如果中途截断了某次工具调用的结果模型后续代码里可能会引用一个不存在的变量或一个已经被修改过的状态。这种情况下日志里会看到一串莫名其妙的系统报错但根因其实在上下文管理。1.2 记忆系统的正确目标工作记忆与长期记忆分离要跳出截断的泥潭首先得把记忆分个类。我习惯把Agent的记忆分成两层一层是“工作记忆”对应当前会话里需要立刻使用的信息比如用户刚刚发来的需求、最近几轮对话、尚未完成的工具调用结果这些必须在上下文窗口里另一层是“长期记忆”对应跨会话沉淀下来的用户偏好、项目背景、历史结论这些信息不需要每轮都完整出现但在特定时刻必须能找回来。这两层记忆的目标不一样。工作记忆要求低延迟、高保真最好是原文长期记忆要求高压缩、可检索它更像档案库而不是聊天记录。明白了这个分层也就理解了为什么要用两套机制配合总结压缩负责把工作记忆里的旧内容“折叠”成长期记忆向量检索负责在需要时从长期记忆里把相关内容“展开”回工作记忆。截断之所以失败是因为它只有删除没有折叠和展开。一个比较好懂的生活化类比是工位抽屉上下文窗口是桌面只能摆有限几样东西。截断是不断把旧文件扔进碎纸机总结压缩是给文件做一页纸的摘要放桌上向量检索则是给你一个带索引的文件柜需要用哪个细节按关键词去翻出原件。2. 总结压缩把对话历史“提炼”成摘要2.1 核心思路不是删除而是提炼总结压缩做的核心事情是把一段原始对话交给LLM让模型提取出“值得长期保留的信息”然后用这段摘要替代原始对话继续参与后续的上下文拼接。它的核心思路不是删除而是提炼所有可能影响未来决策的信息都要尽力保留。我在实际项目中把“值得保留的信息”拆成了几类用户的明确偏好和约束包括风格、格式、口径等已确认的方案结论和决策理由正在进行但未完成的任务及其当前状态关键数字、名称、时间节点、产品模块名用户情绪或态度上的变化这类信息在客服、销售Agent里尤其重要。要特别注意的是摘要压缩并不是“把长文本变短”这个简单任务它是“把信息密度提高”的过程。实现上最稳妥的做法是设计一个明确的摘要Prompt告诉模型应当抽取哪些维度而不是只丢一句“帮我总结这段对话”。否则模型会倾向于写一段通顺的叙事故事好读了关键字段却丢了。2.2 实操流程滚动摘要如何做推荐采用“滚动摘要”的方式而不是一次性总结所有历史。具体流程是设置一个触发阈值比如当前累积Token数超过整个窗口的70%时触发压缩从最早的消息开始取一段连续对话通常控制在3000~5000 Token作为待压缩块把这段对话和历史摘要一起发给LLM生成新的摘要用新摘要替换这段原始对话保留最近一小段最后10~20轮不压缩当新对话再次累计到阈值时重复上述过程但这次是对“旧摘要新对话”再次提炼。下面是我实际用过的摘要Prompt模板你可以在项目里直接改你是一个对话记忆压缩器。请阅读下面的对话记录提炼出一份用于长期记忆的结构化摘要。 你需要保留的信息包括 1. 用户的明确偏好、限制条件、不允许做的事 2. 已经确认的结论、决策及其背后原因 3. 正在进行中但尚未完成的任务以及当前状态 4. 关键数字、名称、时间节点、版本号、ID等 5. 用户情绪或态度的变化。 要求 - 不要保留寒暄、临时性疑问、重复表达 - 不要把自己的推测当成事实写入摘要 - 使用原对话中出现的语言保持原语言 - 摘要控制在400字以内 - 如果新对话与旧摘要冲突用新对话覆盖旧结论并标注“更新于某时间点”。 历史摘要 {old_summary} 新增对话 {new_dialog} 请输出新的摘要触发压缩时可以选择调用一个较便宜的模型比如主模型用大参数模型压缩模型用小参数模型。因为压缩不要求创造力要求的是信息抽取准确所以温度要调低一般0.2以下比较稳妥。2.3 压缩策略的踩坑总结这里分享几个踩过的坑都是常规文档里不会写的。第一个坑是“一次性压缩全部历史”。早期版本我为了省事每轮对话结束后把全部历史丢给LLM做一次完整总结。结果发现模型会漏掉中间过程的细节尤其当旧摘要里信息互相覆盖时模型倾向于保留最后出现的结论但用户可能在中途改过需求早期版本里的约束就悄悄丢了。后来改成“分块压缩”每次只压缩最早一段并且保留最近30~50条消息不压缩情况才好转。第二个坑是“摘要粒度过粗或过细”。太粗了摘要变得像新闻通稿缺少可用细节太细了摘要本身就把上下文窗口占满压缩失去意义。我的经验是把摘要控制在400~800字之间数据安全的做法是“摘要是索引原句进向量库”摘要只负责让当前上下文连续具体细节靠向量检索找回。第三个坑是“压缩频率过高”。每次对话都触发压缩不仅浪费Token还会让摘要碎片化新摘要会不断覆盖旧摘要里的未完成任务导致状态丢失。建议压缩动作做成惰性触发Token超过阈值才执行低于阈值不碰。3. 向量检索让Agent按需想起关键细节3.1 为什么有了摘要还需要向量检索摘要压缩解决的是“上下文连续”的问题但它有一个天然短板摘要容量有限不可能覆盖所有细节。比如用户上周提过“我之前上传的报表文件里31号那列的数据口径要改”这句话在摘要里大概率会被压成“用户对报表口径有偏好”但具体改哪一列、从什么口径改成什么口径摘要不会细写。这种时候就要靠向量检索从原始记忆里精确找回细节。向量检索的底层原理其实不复杂先用Embedding模型把文本转换成向量语义相近的文本在向量空间里距离也近查询时把用户的问题也转成向量然后找距离最近的TopK条记忆。它跟关键字搜索的区别在于不需要关键词完全匹配比如用户当前问“图表配色怎么处理”而历史记录里的原文是“把柱状图的主题色换成深蓝背景保持浅灰”Embedding模型能判断这两句话语义相关关键字召回则做不到。实现上可以选用开源模型BGE-M3或text2vec它们的中文语义理解能力都不错也可以用商业Embedding接口。唯一要注意的是Embedding模型的维度决定了向量库的维度换模型时向量库里的老数据需要重新编码所以在项目初期就要定好模型尽量少切换。3.2 向量检索的最小实现向量检索的最小链路是三步文本切片、向量化、相似度查询。文本切片尤其重要因为Embedding模型对输入长度有上限而且太长的一段话会把多个语义混在一起导致检索不精准。我一般按照“512个字符左右”作为切片单位同时做50个字符的overlap防止信息在切缝处断开。切片之后再用Embedding模型逐条向量化。这里有个细节不要对每句话都单独向量化句子太短语义信息太少检索时容易召回到无关片段也不要一个完整对话不切就做向量化长度超过模型上限会被截断信息损失更大。查询阶段先把用户当前的问题向量化然后到向量库做相似度搜索返回TopK条原文。TopK一般取5~10太少不够用太多会把无关内容塞进上下文。脚本里的代码可以简化为from sentence_transformers import SentenceTransformer model SentenceTransformer(BAAI/bge-m3) memory_chunks [...] # 已经切好片的文本列表 def add_memory(text): vec model.encode(text) # 写入向量库 def recall(query, top_k5): query_vec model.encode(query) # 用向量库检索返回 top_k 条真正要做成生产级方案向量存储这一环不能自己手写也别指望把几千条向量塞到内存里用numpy暴力算。对话数据一多内存和延迟都扛不住这时候就需要一个专门的向量数据库。3.3 向量存储选型ES、Chroma、Milvus怎么选目前主流的开源方案里Chroma、Qdrant、Elasticsearch、Milvus各有各的定位。Chroma轻量适合单机原型pip安装就能跑Qdrant是Rust重写性能不错功能也比较完整Elasticsearch本身是搜索引擎虽然支持dense_vector但在向量检索性能上并不占优数据量大了以后召回速度会明显变慢不少团队反映“ES向量检索时间太长”本质上是拿倒排索引的思路去做ANN检索效率上吃亏Milvus是专门的向量数据库分布式、云原生适合做生产环境的长期记忆库。我当时选Milvus还有一个原因Milvus支持标量字段与向量字段混合过滤。这意味着我可以给每条记忆打上session_id、时间戳、类型标签查询时通过过滤条件只搜某个会话或某段时间内的记忆而不是全库盲搜。这个能力在Agent记忆场景里非常实用因为记忆会跨会话累积全库召回会让旧信息干扰新判断。方案适合场景缺点Chroma本地Demo、原型验证不适合大规模数据管理能力弱Qdrant单机高性能检索分布式能力要自己搭Elasticsearch已有ES体系需要文本向量混合向量检索性能上限低Milvus长期记忆、生产环境、分布式部署运维有一定门槛4. 基于Milvus的长期记忆系统落地4.1 部署从Milvus Lite到Docker Standalone说到Milvus部署很多新手第一反应是头痛因为传统部署方式要拉etcd、MinIO、standalone三个组件在Windows上尤其麻烦。现在有个更轻的起步方案Milvus Lite它是Milvus官方提供的嵌入式版本直接通过pip安装pymilvus然后本地用文件模式操作适合开发测试。pip install pymilvusfrom pymilvus import MilvusClient client MilvusClient(milvus_demo.db) # 本地文件模式Milvus Lite在数据量不大的场景下非常舒服不用起服务就能跑通整个写入、查询流程。但如果要上生产或者数据量到了百万级向量还是得用Docker部署Milvus Standalone。这里给出Windows Docker Desktop下的一个简化部署思路安装Docker Desktop后官方推荐用docker-compose启动Milvus standalone但资源门槛不低建议给Docker至少8G内存。我个人的建议是开发阶段直接用Milvus Lite代码里把uri从本地文件路径换成Milvus服务地址业务逻辑完全不用改。现在pymilvus的接口做了统一从Lite切换到分布式版的成本很低。4.2 核心代码MemoryStore封装下面给出一个可用的MemoryStore封装建议直接复制到项目里改一改。核心能力是三个动态建Collection、写入一条记忆、按语义召回TopK条记忆。import time from pymilvus import MilvusClient from sentence_transformers import SentenceTransformer embedder SentenceTransformer(BAAI/bge-m3) class MemoryStore: def __init__(self, urimilvus_demo.db, dim1024): self.client MilvusClient(uri) self.collection_name agent_memory self.dim dim self._ensure_collection() def _ensure_collection(self): if not self.client.has_collection(self.collection_name): self.client.create_collection( collection_nameself.collection_name, dimensionself.dim, primary_field_nameid, vector_field_nameembedding, auto_idTrue, ) def add(self, text: str, session_id: str, memory_type: str chat): vec embedder.encode(text).tolist() self.client.insert( self.collection_name, { text: text[:2000], session_id: session_id, memory_type: memory_type, created_at: int(time.time()), embedding: vec, }, ) def search(self, query: str, top_k: int 5, session_id: str None, memory_type: str None): vec embedder.encode(query).tolist() filter_expr if session_id: filter_expr fsession_id {session_id} if memory_type: cond fmemory_type {memory_type} if filter_expr: filter_expr f and {cond} else: filter_expr cond res self.client.search( collection_nameself.collection_name, data[vec], limittop_k, filterfilter_expr or None, output_fields[text, session_id, memory_type, created_at], ) return res这段代码里有几个细节要强调。第一bge-m3的向量维度是1024如果你换用OpenAI的text-embedding-3-small维度是1536换模型后dim参数必须同步改否则查询会报维度不匹配。第二插入字段时我把原始文本限制在2000字符以内这是为了防止超长文本挤占存储空间。如果记忆原文超出了应该在写入前先做一次轻量截断或摘要而不是把全文塞进去。第三查询时支持按session_id和memory_type过滤这在实际项目中很有价值。比如只搜“当前用户的偏好类记忆”而不去搜历史会话里的工具报错信息会更准确。4.3 与Agent记忆流程的集成写入、召回、合并有了MemoryStore下面要解决的是它怎么和Agent主流程衔接。这里我讲一个比较成熟的集成方式分三块写入策略、召回策略、后台合并。写入策略上不建议把每一轮原始对话都写入向量库那是日志不是记忆。推荐的做法是会话进行中每隔几轮或当关键信息出现时把这一小段的“记忆点”提取出来再写入。所谓的“记忆点”就是一句话例如“用户要求所有金额显示两位小数”“已完成对账模块的接口联调”“暂未确定部署环境”。提取这一步可以复用第2节里的LLM摘要逻辑但Prompt更短目的是抽离事实而不是叙事。召回策略上在每一轮用户提问时先根据问题去MemoryStore里检索相关记忆把检索到的原文注入到Prompt上文的“相关记忆”段落里。要注意控制注入条数和每条长度通常TopK给5每条原文限制在200字以内。因为相关记忆只是辅助信息数量太多反而会稀释主上下文。后台合并是很多人忽略的一环。长期运行后向量库里会出现大量重复或重叠的记忆比如“用户喜欢极简风格”和“用户偏好性冷淡风配色”本质是同一件事。建议每天或每周跑一个离线任务用LLM对同一session_id下的记忆做聚类合并把语义重复的归并成一条新的记忆然后删除旧记录。这样可以保持长期记忆库的精简同时降低召回时的噪声。4.4 参数调优Milvus核心参数里最影响查询效果的是索引类型和nprobe/ef这些搜索参数。开发阶段用默认的FLAT索引就行它做的是全量暴力扫描小数据量下准确率最高。数据量到了几十万条以上建议改成HNSW索引查询速度快很多代价是构建索引时内存占用高一些。我用HNSW时的一个经验值是M取16efConstruction取200查询时ef取64。M越大召回越准但内存越多efConstruction越大索引质量越高但建索引越慢ef是查询时的探索范围调大会提高召回率但增加延迟。这几个参数没有绝对最优要根据自己的数据量做一次小规模抽样测试观察召回率与延迟的平衡点。另外要设置一个合理的timeout。Milvus查询在网络抖动或索引构建期间可能变慢设一个5秒或10秒的超时能避免Agent主流程被卡死。如果查询超时可以让Agent继续走无记忆的普通模式这叫作“降级设计”在记忆系统里非常重要。5. 高频问题排查与性能优化实录5.1 召回结果不相关问题出在哪这是项目里最容易踩的坑明明向量库里有正确信息检索出来的却总是不相关。排查方向有这么几个按优先级检查。先看Embedding模型和查询语言是否匹配。如果对话是中文但Embedding模型以英文语料为主那向量距离就不可靠。直接用BGE系列模型可以解决大部分中文场景问题。再看切片长度。切片太长一段话里混了多个主题查询时容易被其中某个不相关的主题带偏。切片太短句子碎片化语义不完整。推荐512字符左右并保留少量重叠。然后看是否漏了时间衰减。用户三个月前说过的偏好和三天前刚改过的偏好同时被召回了模型不知道该信哪个。解决方法是按时间戳加一个衰减权重或者直接在过滤条件里排除太久远的记忆。最后向量召回只是粗排它返回的是“语义相似”但不是“答案”。如果你要的是精准答案应该在召回结果后面加一个重排环节用一个更重的模型或者LLM对TopK条记忆做一次相关性打分再选择最终注入上下文的内容。这一步能显著提升最终效果。5.2 写入慢和查询慢怎么优化写入慢最常见的原因是逐条insert。向量数据库写入时每条记录都有索引更新开销逐条插入在高频场景下会累积成大延迟。优化方式是把记忆攒成一批每10~50条一次insert性能能提升好几倍。异步写入也很重要。Agent主流程不应该阻塞在记忆写入上建议把新增记忆的任务丢到消息队列或后台线程里用户在界面上不会感知到写入延迟。如果写入失败最多是这条记忆没存上不能影响主线对话。查询慢则优先检查数据量和索引类型。数据量还不大就用FLAT数据量大切HNSW。还有一个常见错误是每次查询都做全库扫描但没有过滤条件。给每条记忆打上session_id、memory_type等标量字段查询时加上过滤让Milvus先缩小检索范围再在子集里做向量计算速度会快非常多。5.3 常见问题速查表现象可能原因解决办法召回内容明显不相关Embedding模型不适合当前语言切换为BGE系列中文模型召回内容模糊细节对不上切片太长一个chunk包含多个主题按512字左右切片保留少量overlap旧记忆干扰新决策没有时间过滤或时间衰减查询时排除过旧数据或按时间加权写入阻塞Agent主流程同步写入向量库改异步写入查询延迟飙升数据量大且使用FLAT索引切换HNSW索引调大efConstruction向量维度报错Embedding模型切换后维度不匹配delete旧Collection并重建或全量重编码ES向量检索时间太长ES强项是倒排索引不是向量ANN换成Milvus专做向量检索摘要更新后状态丢失压缩粒度过粗或频率太高分层压缩最近对话不压缩摘要只做索引如果是刚接触这套方案我的建议是别一上来就铺全量Milvus。先用摘要压缩把上下文连续性问题解决掉再引入向量检索存关键细节最后才上Milvus做长期记忆库。我实际跑过项目之后最深的体会是“记忆不是存得越多越好而是该想起来时能想起来”。总结压缩和向量检索不是替代关系一个负责让当前对话保持连续一个负责让跨会话的细节随时可寻两者配合Agent才真正从“一次性工具”变成了“有积累的协作对象”。最后再分享一个细节向量库里存的一定要是原句不要只存摘要摘要是给模型看的索引原句才是召回时的可靠证据。