ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent记忆系统:难在“记进去的那一刻”的写入端设计

Agent记忆系统:难在“记进去的那一刻”的写入端设计

1. 存储和检索早已不是瓶颈,真正的坎在写入端

做 Agent 记忆系统的这大半年,我最大的感受是:大部分团队把精力花在了向量数据库选型、召回策略调优、混合检索权重配比这些事上,结果项目上线后最头疼的问题根本不是"存不进去"或者"搜不出来",而是 Agent 面对用户时,该记住的没记住,不该记的记了一大堆,甚至记进去的东西过两天就变成了干扰源。

换句话说,存储和检索是"书架"和"图书管理员"的问题,而真正难的是"采编"环节——决定一本书值不值得进馆、怎么写摘要、怎么归类、怎么处理内容冲突。

先说说为什么存储和检索其实没那么难。以当前主流架构为例,记忆的存储层无非是向量库加关系型数据库的混合方案:向量库负责语义召回,关系库负责精确过滤和时间排序。选型上,开源的、托管的、云厂商内置的,成熟方案一把抓,吞吐量和延迟都有现成压测数据可以参考。检索层更不用说,TopK 召回加 Rerank,再加上关键词与向量混合的 Hybrid Search,效果上限是可预期的。

我踩过的坑集中在一个被严重低估的环节:记忆的写入编码(Encoding)。同样一段对话,A 方案记下来的是"用户说他下周要去上海出差",B 方案记下来的是"用户出行计划:2025-06-15 至 06-18 在上海,目的为参加供应商会议,关注点包括交通与住宿安排"。这两个记忆项在存储层面没有任何区别,但后续检索出来的可用性天差地别。

一个更直观的类比:图书馆买书(存储)和读者找书(检索)都有标准流程,真正考验馆员水平的是"这本书值不值得买、买回来怎么写编目摘要、放在哪个分类下"。编目摘要写得潦草,书再新也没有人找得到;分类归错了,检索系统再先进也是白搭。Agent 记忆完全同理——写入那一刻的编码质量,决定了记忆在未来的每一次调用上限。

所以在聊具体方案之前,先把一个认知摆正:记忆系统的问题清单里,写入端的占比远高于存储端和检索端。后面我会展开讲写入端到底难在哪几个具体问题上,以及我们项目里是怎么一步步解决这些问题的。

2. 记进去的那一刻发生了什么:写入端的完整动作拆解

一个完整的记忆写入流程,远不止"把文本塞进向量库"这么简单。我把一次写入拆开来看,它至少包含五个连续动作:触发(Trigger)→ 筛选(Filter)→ 编码(Encode)→ 融合(Merge)→ 存储(Store)。

触发是"什么时机该写";筛选是"这段内容值不值得写";编码是"写成什么形式、结构化到什么程度";融合是"和已有的记忆冲突怎么写、重复怎么写";存储才是最后一步。前四步都在"记进去的那一刻"完成,也是记忆系统里智力密度最高的部分。

很多人以为存储是写入的终点,其实存储只是收尾动作。真正的技术难点集中在触发、筛选、编码和融合。下面逐个拆。

2.1 触发与筛选:为什么 Agent 总是该记的不记

先说触发。你可能会想,触发不就是"对话结束后把上下文存下来"吗?实际操作中远没有这么简单。

我在一个客服知识库 Agent 项目里做过实验:每轮对话结束都触发一次记忆写入,结果三十分钟的会话产生了 47 条记忆项,其中有效信息大概只有 6 条,其余全是寒暄、重复表达和无关细节。更麻烦的是这些噪声记忆会被后续检索命中,把上下文窗口撑爆,还拉低了回答的准确率。

触发策略至少要区分三种场景:

  • 每轮对话结束:适合极轻量级的记录,比如用户明确表达的需求、偏好、时间承诺。
  • 任务里程碑节点:比如用户完成了一次订单支付、提交了一份表单、确认了一个方案,这类事件值得完整沉淀。
  • 异常或显著事件:比如用户表达了强烈不满、突然改变计划、提供了一份关键文档,这类信息需要立即写,甚至要触发一次"记忆回顾",把它和以前的记忆做关联。

筛选则是给触发加上一道闸门。我目前用的是一个两阶段的筛选器:先用轻量规则把明显无价值的内容过滤掉(比如纯语气词、单轮寒暄、重复追问),再对通过规则的内容用一个打分模型判断"重要性"和"时效性"。重要性高的进入长期记忆,时效性强的进入短期工作记忆,两者都低的直接丢弃。

一个简单但有效的规则示例:

保留条件(满足其一即可): - 包含明确日期、时间、地点、人名、机构名 - 包含用户对产品或服务的偏好、限制、禁忌 - 包含任务状态变化(已完成、已失败、已推迟) - 用户情绪标记为负面或强需求 - 包含数字、金额、订单号等实体化信息

这条规则看起来平平无奇,但它能把无用记忆的写入量砍掉一大半。我们项目里加上这一层之后,单次长对话生成的记忆条数从 47 条降到了 11 条,检索命中后被采纳的比例明显提升。

2.2 编码方式的选择:决定记忆好不好用的分水岭

筛选之后是编码。这是"记进去的那一刻"里最考验设计功力的地方,也是我觉得可以称得上"真正难的一步"的核心原因。

什么叫编码?简单说就是把原始对话文本转换成一条可复用、可检索、可推理的记忆记录。同样的原始素材,编码成什么样,直接决定了未来它能不能被用上。

我用一个具体例子说明。原始对话是:

用户:"我下周二下午三点要参加一个线上评审会,需要提前准备一份项目进度汇报的 PPT。"

低质量编码(很多项目的默认做法)是直接把这句原文塞进向量库。表面上看没毛病,语义检索也能命中,但实际用起来问题很多:

  • 过了两周,用户问"我最近有什么重要会议",这条记忆命中了,但 Agent 不知道会议结束没有,也不知道这件事和当前任务是否相关。
  • 用户改口说"评审会改到周四了",新的记忆写进来,旧的还在。检索时两条都可能命中,Agent 直接懵了。
  • 内容里没有区分"事实陈述"和"用户意图",后续想按"用户待办事项"维度调用时根本筛不出来。

高质量编码则是结构化的三层:

记忆类型: 日程事件 主体: 用户 动作: 参加线上评审会 时间: 2025-06-17 14:00 前置条件: 需要准备项目进度汇报PPT 状态: 待完成 关联实体: 项目X、线上会议平台 优先级: 高

这个格式下,召回就变得非常精准:可以按时间范围查、按状态查、按实体关联查。而且关键信息全是"原子化"的,后续如果时间变更,做合并逻辑也容易得多。

我在实际中把编码分为三个抽象层次,不同层次适合不同类型的记忆:

层次形式适用场景优缺点
原始层对话原文或轻清洗文本短期内需要引用细节的场景信息无损,但冗余度高,长期占用空间
摘要层LLM 生成的段落摘要中长期的情景记忆压缩比高,但过度摘要会丢失关键实体
结构化层JSON/表格形式的实体与关系需要精确查询和推理的记忆查询能力强,但构建成本最高

项目里最终采用的是"混合编码":原始文本存档,摘要层负责语义召回,结构化层负责精确过滤和推理。三层之间通过记忆 ID 互相绑定,查询时先走结构化过滤,再走摘要召回,最后回原始文本确认细节。这个方案在召回率和精确率之间做到了比较舒服的平衡。

2.3 融合与冲突处理:记进去之后不互相打架

编码完了还有一个很容易被忽略的步骤——和已有记忆做融合。如果不做这一步,记忆库很快就会变成一团相互矛盾、互相重复的杂讯集合。

举个真实案例。用户在周一说了"我周末一般不喜欢被工作消息打扰",周五又发消息说"周六上午可以安排一个短会,但十点后不行"。这时候周五这条新记忆不是简单追加,而是要对周一那条偏好记忆做"例外补充"。如果系统只是机械地把两条都存下来,没有任何关联关系,未来调用时可能上一次回复"周六不安排工作",下一次又回复"周六上午可以开会",前后矛盾。

融合要做的事包括三件:

  1. 语义去重:识别表达不同但意思相同的新记忆,合并成一条,避免向量库里出现大量高相似度的重复项。
  2. 时间修正与版本更新:同一事件的记忆发生变化时,旧记忆要标记为"已过期"或"被替换",不能和新记忆并存。
  3. 关联链接:把新记忆和已有的相关记忆建立关系边的连接,比如"偏好禁止打扰"和"周六例外"之间建立补充关系。

融合操作建议在写入后通过异步任务执行,不要阻塞主链路。可以给每条记忆加一个merge_status字段,经过融合处理后再进入"已确认"状态。这样即使合并逻辑偶尔出错,也不会影响到当次对话的响应速度。

3. 为什么存储与检索解决不了写入端的问题:一次失败的"调优"经历

可能你还是会想:既然存储和检索有那么多成熟方案,写入端做得粗糙一点,是不是也能靠检索层的优化兜住?我一开始也是这么想的,结果项目里一次典型的"伪优化"经历,让我彻底改变了看法。

3.1 我试图用检索优化来掩盖写入缺陷

某个内部项目里,Agent 需要在一段长时间的多轮对话中记住用户提到的多个需求,并在后续轮次里准确调用。当时的实现很简单:每轮对话原文直接入库,不做筛选,不做编码,不做融合。问题很快就出现了——用户在前面对比了三款产品,后面问"你刚才说哪个更适合我们",Agent 总是答不对。

第一反应当然是调检索。我把 TopK 从 5 调到 10,把相似度阈值从 0.78 降到 0.7,加了关键词加权,甚至试了换不同向量模型。效果有一点改善,但很不稳定:有时候能召回正确信息,有时候召回的是用户早期的原始讨论记录——因为原始记录里信息混杂,根本分不清哪段才是用户最终的结论。

这个阶段我算是彻底理解了一件事:检索层的天花板是由写入层决定的。如果写入进去的记忆本身就是一团乱麻,召回算法再精确,也只是"从垃圾堆里找相对不那么垃圾的东西"。

3.2 写入端的成本实际上更隐蔽

另一个让我印象深刻的地方是隐形成本——错误的写入会让系统付出远比"记下来"更高的代价。

比如用户明确说"不要推荐含花生成分的零食",一个缺乏筛选和结构化编码的记忆系统可能把这句话原封不动存了进去。如果后续用户的提问是"有什么坚果推荐吗",按语义相关性来看,这两句话容易被同时召回。可这个时候 Agent 需要额外判断:这条记忆是"用户偏好"还是"这条对话的历史内容"?这本来应该是写入时就已经区分好的。

我在项目里统计过,写入端做一次结构化编码,大概要花 80-120ms 的 LLM 调用时间。听起来不便宜,但它能避免的后续问题,包括但不限于:检索命中噪声拉低回答准确率、多轮对话内自相矛盾、上下文窗口被无效记忆占满、需要频繁重构向量库中的数据。这些代价叠加起来,一次编码的算力开销简直微不足道。

所以现在的态度很明确:宁可写入端多花一点算力把编码做扎实,也不要在检索端反复修补。检索端应该解决的是"怎么从高质量记忆里快速找到最相关的那一条",而不是"怎么从烂数据里捞取可用信息"这种别无选择的事情。

这段经历后来也成了我们团队的一个共识,甚至写进了新人入门文档里:先检查写入链路,再怀疑检索召回,顺序反了就是在浪费时间和算力。

4. 一套可落地的「写入优先」记忆管线设计

讲完理念和踩坑,下面给一套我们项目里实际在用的记忆写入管线设计。它的思路总结成一句话就是:把写入端当成一个独立的、有状态的、可观测的服务来设计,而不是主流程里顺带做的一步。

整体管线长这样:

对话/事件流 │ ▼ 触发判断模块(规则 + 轻量分类模型) │ ▼ 价值筛选模块(重要性打分 + 时效性标记) │ ▼ 编码模块(原始文本存储 + LLM 摘要 + 结构化抽取) │ ▼ 冲突检查与合并模块(语义去重 + 版本更新 + 关联链接) │ ▼ 存储模块(向量库 + 关系库 + 缓存) │ ▼ 写入后校验(检索自测 + 衰减调度)

每一层都有它存在的理由,也有对应的具体实现参数和指标,下面展开讲。

4.1 触发与筛选模块的工程配置

触发模块不用太复杂,我用的是一条规则加两个信号源。规则层面,监听对话事件流中的三个事件类型:消息结束、任务状态变更、关键实体出现(如时间/地点/金额/邮件地址)。信号源层面,一个是情感分析结果,分数低于 -0.3 触发强提醒;另一个是用户显式指令,比如"记住""别忘了"这类。

筛选模块我用了一个很小的打分模型,输入是触发模块传过来的文本和消息上下文,输出是三个分数:重要性 0-1、时效性 0-1、隐私风险 0-1。决策逻辑如下:

  • 隐私风险 > 0.8:直接丢弃,不写入任何存储层。
  • 重要性 > 0.7:进入长期记忆存储。
  • 时效性 > 0.7 且重要性 < 0.4:进入短期工作记忆,24 小时后自动清理。
  • 两者都低:丢弃,只保留统计日志。

隐私风险这个维度容易被人忽略,但在面向个人数据的场景里相当重要。所谓隐私风险,主要判断内容包括证件号码、银行卡信息、医疗问诊记录、家庭住址等敏感信息。我们的处理原则是:如果信息对任务确实必要(比如用户主动提交收件地址用于下单),那就把地址归档到独立的加密字段中,不让它在自由文本的记忆里出现;如果与当前任务无关,直接丢弃,不进记忆。

一套下来,单条记忆的整体处理耗时压制在 150ms 以内。如果你们对延时敏感,可以把 LLM 编码部分移到异步队列,对话主链路只做规则筛选和结构化预解析。

4.2 编码模板与元数据设计

编码模块是整个管线里最有技术含量的部分。我目前的实现是:先调用一次 LLM 做摘要压缩,再调用一次 LLM 做结构化信息抽取。两次调用采用不同的 Prompt 模板,避免一次调用里角色混杂导致抽取质量不稳定。

摘要层的模板我按记忆类型分了几种,比较常用的是"事件类摘要"和"偏好类摘要"。偏好类摘要的 Prompt 更强调记录"用户的稳定倾向",比如:

请将以下对话整理成一条用户偏好记忆,要求: 1. 只保留用户明确表达的倾向、偏好、限制条件。 2. 忽略单次任务中的临时性信息,除非它暗示了一种长期习惯。 3. 输出格式为:偏好主体 + 偏好内容 + 限制条件(如有)。

结构化抽取层的模板则更像填表,要求模型输出固定字段。比如一个"任务事件"类型的记忆,字段包括:

{ "event_type": "meeting", "subject": "线上评审会", "start_time": "2025-06-17T14:00:00+08:00", "timezone": "Asia/Shanghai", "location": "线上", "participants": [], "prerequisites": ["准备项目进度汇报PPT"], "status": "pending", "source_conversation_id": "xxx", "importance_score": 0.85, "expire_at": null }

两个抽取结果加一条原始文本,共同构成一条完整记忆。原始文本提供"证据链",摘要层负责语义召回,结构化字段负责精确检索和推理。这个三层结构几乎把记忆的可用性从上到下都覆盖了。

关于元数据,我强烈建议至少包含这几类字段:created_at(创建时间)、updated_at(更新时间)、source_id(来源会话ID)、access_count(被调用次数)、last_access_at(最后调用时间)。access_count和last_access_at是后续做记忆衰减和遗忘策略的依据,一开始不存,后面想补就难了。

4.3 去重与合并的实操方法

去重和合并在项目里踩过的坑最多,这里给几条实打实的经验。

第一,语义去重不能只靠向量相似度。做过实验,两句话"我平时喜欢喝美式咖啡"和"我一般喝美式,不喝拿铁",向量相似度只有 0.61,明显不到常规去重阈值,但这两条长期并存就会产生矛盾:用户之前说"不喝拿铁",后面万一改主意了,系统该怎么判断?更稳妥的做法是:向量相似度作为召回候选池,具体是否算重复,用一个轻量 LLM 判断"这两条记忆是否在陈述同一件事、且新记忆是否包含修改意图"。

第二,更新旧记忆时,不要物理删除旧版本,而是标记为 superseded_by。这样既能保留历史轨迹用于审计,也能避免向量库里出现残缺引用。

第三,合并动作必须可回滚。我在设计里给每条记忆加了一个edit_history字段,记录每一次合并前后的 diff。一旦后续对话发现合并后的记忆还是有问题,可以直接回滚到上一个版本。

第四,同类冲突要设定明确优先级。比如用户做过最新显式声明,那么它的优先级就高于历史偏好推断;如果用户自己前后矛盾(今天说喜欢 A,明天说喜欢 B),我们以最近一次为准,同时把之前的偏好标记为"已改变"。这比让 Agent 自己推断哪个更合理要稳定得多。

4.4 写入后校验:让"记进去"变成可验证的过程

写入后校验是我特别想强调的一个环节,因为这相当于给"记进去的那一刻"装了一个质检器,也正好回应了标题里"记进去"这个关键词——不是写到库里就算记进去了,必须验证"未来能不能调得出来、调出来有没有用"。

具体做法很朴素:每条记忆写入完成后,异步触发一次"自测检索"。方法是用这条记忆自己的摘要作为查询词,去向量库检索 Top5,看看正确记忆是否出现在召回列表里,以及排序是不是第一。如果没召回或者排名靠后,说明这条记忆的编码方式和索引字段有问题,需要重新提取关键词、重新生成摘要。

这个自测机制能暴露一个很隐蔽的问题:写入内容本身可能是好的,但 Embedding 后落到了语义空间的错误区域。比如某个只在特定语境下才有意义的记忆,它的向量表示可能离其他常见查询非常远,导致后续怎么搜都搜不到。

自测不通过的记忆会自动打上needs_reindex标记,进入二次编码队列,由增强模式再做一次摘要和结构化抽取。这个二次编码通过率通常能到 70% 以上,剩下的 30% 基本可以确定是原始信息就模糊或不可用,直接丢弃比勉强保留更好。

这套自测机制上线后,记忆召回准确率从 71% 提升到了 86%,效果非常显著,而且每次新记忆写入后两秒内就能发现问题,不用等到用户真的触发了错误回答才发现。

5. 长期演进中的三个方向:衰减、分层与主动遗忘

写入管线的设计稳定之后,下一步要考虑的是"长期运行"带来的问题。记忆不是存得越多越好,一个跑了半年的 Agent,记忆库里可能有几十万条记录,如果不做衰减和分层,即使写入编码做得再好,检索质量也会逐渐下降。

5.1 记忆衰减:让不常用的记忆自动退场

衰减策略的目的不是删除记忆,而是调整记忆的"可访问性"。我在系统里实现了一个简单但有效的衰减模型,每条记忆有一个retrieval_value,计算公式大致为:

retrieval_value = importance_score * decay_factor ^ (days_since_last_access / half_life_days)

参数解释:importance_score是写入时打的分数,half_life_days是半衰期天数,不同类型记忆取值不同。常被调用的记忆半衰期设得很长,比如 180 天;偶尔用到的设 30 天;短期记忆设 1 天。公式的计算逻辑很简单——时间越久没用过,记忆的检索优先级就越低。衰减到阈值以下的记忆虽然还在库里,但不再参与默认召回,除非用户显式触发深度检索。

这样做的好处是:既保留了历史记忆的完整性,又保证了日常对话中召回的记忆都是高价值、高时效的。我试过不设衰减直接全量召回,上下文的噪声比例大概在 20% 左右,加了衰减后降到 7%。

5.2 记忆分层:工作记忆、情景记忆、语义记忆各司其职

分层的思想借用了认知科学里的记忆模型,但工程实现上没那么玄乎,本质上就是按时效性和抽象程度把记忆分成三个存储区。

  • 工作记忆区:只保存当前会话内部产生的临时上下文,会话结束就清空或归档。它对应的是"短期不需要持久化"的信息。
  • 情景记忆区:保存的是具体事件和对话经历,保留原始细节和摘要层,适合按时间轴回溯调用。
  • 语义记忆区:保存的是从大量事件中抽象出来的稳定结论,比如用户偏好、业务规则、长期目标。

三层之间不是孤立的。一条进入语义记忆区的结论,通常是从多条情景记忆里提取出来的,所以需要在语义记忆上记录关联的情景记忆 ID,形成"结论可溯源"的链路。

举个例子,用户连续三次在项目汇报前提到"我需要一份进度表",系统可以在第三次时触发语义抽取,生成一条"用户每逢项目汇报前需要进度表"的语义记忆,同时关联三次具体事件。之后用户说"我下周要汇报",Agent 可以基于语义记忆直接给出进度表,不需要再翻历史细节。

5.3 主动遗忘:比"记住"更需要勇气的设计决策

最后一个想说的话题是主动遗忘。这可能是记忆中讨论最少、但也最实用的一块。遗忘不是删除信息,而是基于成本和收益的判断,把低价值记忆从活跃集里彻底移除。

实现上我用了一个基于access_count和last_access_at的简单策略:一条记忆在 90 天内没有被访问、access_count低于 3 次,就会被标记为"可归档"状态,从向量库的热索引中迁移到冷存储。冷存储里保留原始结构化数据和文本,不参与默认语义召回,只在显式审计或深度搜索时可查。

这个策略上线后的效果出人意料地好。不只是节省了向量库存储费用,更明显的是检索质量的提升——因为活跃集里的记忆不再包含大量"可能有用但一直没用上"的干扰项。我还发现,主动遗忘的机制设计反而让记忆系统更像一个会学习的人,而不是一台无限堆积数据的录音机,这一点对我的启发很大。

如果后续有时间,我还会在"按需回溯被遗忘记忆"的能力上继续打磨——留个入口,让用户可以显式地找回某些被归档的历史记录。毕竟遗忘是为了更好的调用,不是把过去彻底抹掉。

6. 写在最后:对记忆系统设计的一点个人体会

回看这段做 Agent 记忆系统的经历,感触最深的一点就是标题里说的那个判断——记忆真正难的一步,确实在"记进去的那一刻"。存储和取用都有大量成熟工具和方案,难的是在写入时做好价值判断、抽象编码、冲突处理和可验证性设计。这一层没有做好,后续所有关于检索和增强的努力,都像是在坏地基上盖精装房。

如果你正准备设计一个 Agent 记忆模块,我的建议很直接:先花时间把写入管线的每个动作列出来,从触发到编码到冲突处理,一步一步想清楚,然后再去纠结向量库选型、召回策略和排序模型。把记忆编码当成一个和生成回复同等重要的设计对象,效果会远超你的预期。

最后再分享一个实际测试出来的小数据:写完这篇文章时,我们线上那个 Agent 的记忆系统单次写入耗时约 180ms,检索 Top1 准确率稳定在 88% 左右,而线上运行时长已经超过了六个月。比起之前"全量记录、检索兜底"的版本,用户的明显感知是它"更懂事了"。这大概就是对"记进去的那一刻"这件事价值的最好验证。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进