1. 从几条社区 bug 说起:为什么“记忆”总是出问题
最近在几个 AI Agent 开发者社区里,陆续看到有人反馈同一类问题:Agent 昨天还记得的事情,今天再问就“失忆”了;或者在一个会话里刚纠正过的偏好,换个会话又原样犯;更离谱的是,有些 Agent 会把 A 用户的记忆串到 B 用户身上。表面上看,这些是三个不同的 bug——记忆丢失、记忆不生效、记忆串号,但把日志和调用链拉出来仔细看,会发现它们指向的是同一个设计漏洞:把“记忆”当成了一个可以随手读写的全局变量,而不是一个有生命周期、有作用域、有读写策略的独立子系统。
这个判断不是拍脑袋来的。我前后跟过几个 Agent 项目的记忆模块,从最早的“把历史对话直接拼进 prompt”,到后来引入向量库做长期记忆,再到现在的分层记忆架构,几乎每一代都会踩一遍类似的坑。社区里这些 bug 之所以反复出现,根本原因在于大多数团队在搭 Agent 的时候,注意力都放在“怎么让模型回答得更准”上,而记忆模块往往是最后才补的,补的时候又习惯性地用最省事的方式——一个全局字典、一个共享的向量集合、一段无脑拼接的上下文。省事是省事了,但作用域、生命周期、读写一致性这些工程问题一个都没解决,bug 自然就冒出来了。
这篇文章不打算只讲“怎么修某一个 bug”,而是想把这几个社区案例拆开,看看它们背后共用的那套错误设计假设是什么,然后给出一套可以落地的记忆分层方案。如果你正在做 Agent 开发,或者正在被“Agent 记不住东西”这件事折磨,下面的内容应该能帮你少走不少弯路。关键词里的 agent 记忆、agent 架构、长短期记忆网络、多 ai 协作这些方向,我都会结合具体案例展开讲。
2. 三个 bug 的完整排查链路:从现象到根因
2.1 案例一:跨会话记忆丢失,问题出在“会话结束即销毁”
第一个案例来自一个做客服 Agent 的团队。他们的设计是:每次用户发起新会话,就新建一个 session 对象,session 里挂一个memory字段,用来存这轮对话里提取的用户偏好。会话结束后,session 对象被回收,memory 自然也就没了。用户下次再来,Agent 完全不记得上次说过“我不要短信通知,只要邮件”。
这个 bug 的排查过程其实很典型。第一步先确认模型侧没问题——把上次的对话历史手动塞进 prompt,Agent 是能正确使用这个偏好的,说明模型理解能力没问题。第二步查存储层,发现偏好数据确实写进去了,但写在了 session 级别的内存对象里,进程一重启或者会话一关闭就没了。第三步才是根因:他们把“会话记忆”和“用户记忆”混在了一个存储层级里,没有做生命周期区分。
这里涉及一个基础但容易被忽略的概念:Agent 的记忆至少应该分两层——短期记忆(会话级)和长期记忆(用户级/全局级)。短期记忆跟着会话走,会话结束就可以丢;长期记忆必须独立于会话存在,要有持久化存储和明确的读写接口。社区热词里提到的“长短期记忆网络”虽然原本是 RNN 领域的概念,但在 Agent 语境下,这个分层思路是完全适用的。很多团队一上来就想着上向量数据库做“长期记忆”,但连最基本的会话级和用户级都没分开,向量库再强也救不了。
提示:判断一个 Agent 的记忆设计是否合理,先问一句——会话结束后,哪些数据应该留下?如果答不上来,说明生命周期没定义清楚。
2.2 案例二:纠正过的偏好不生效,根因是“读写优先级混乱”
第二个案例更隐蔽。用户在一个会话里明确纠正了 Agent:“我不叫张先生,叫我老张就行。”Agent 当场答应了,下一轮也确实叫了老张。但过了几轮之后,又开始叫张先生。用户很生气,觉得 Agent“阳奉阴违”。
排查这个问题的关键在于看记忆的读取顺序。他们的实现是这样的:每次生成回复前,会把“用户画像”和“最近对话历史”一起拼进 prompt。用户画像里存的是注册时填的“张先生”,而纠正信息只写进了对话历史。当对话历史被截断(因为上下文长度限制)之后,纠正信息就丢了,Agent 又读回了画像里的旧称呼。
这就是典型的读写优先级混乱:新写入的纠正信息,优先级应该高于旧的画像信息,但他们的读取逻辑是“画像优先、历史补充”,顺序反了。正确的做法应该是:记忆读取要有一个明确的优先级链,最近写入的、显式纠正的信息,优先级最高;其次是用户画像等静态信息;最后才是泛化的历史对话。而且,当高优先级信息写入时,应该考虑是否要“回写”到低优先级的存储里,避免下次读取时又被旧数据覆盖。
这个问题在多 AI 协作场景下会更严重。如果多个 Agent 共享一份用户画像,A Agent 收到的纠正没有同步给 B Agent,那 B Agent 就会继续用旧称呼。所以记忆的写入不能是“各写各的”,要有统一的写入入口和同步机制。
2.3 案例三:记忆串号,暴露的是“作用域隔离缺失”
第三个案例是最严重的,也是安全相关的。有用户发现,自己跟 Agent 聊的内容,出现在了另一个用户的对话里。虽然只是个别案例,但性质很严重。排查下来,问题出在他们的向量库检索上:所有用户的记忆都写进了同一个 collection,检索时只按语义相似度排序,没有加用户 ID 过滤。当两个用户的对话语义相近时,就可能检索到对方的记忆。
这个 bug 的根因是作用域隔离缺失。记忆的存储和检索,必须带上明确的作用域标识——用户 ID、租户 ID、会话 ID,至少要有用户级隔离。向量库的 metadata 里必须存这些字段,检索时作为硬过滤条件,而不是只靠语义相似度。很多团队为了图省事,把所有数据塞一个 collection,觉得“语义检索会自己找到对的”,这是非常危险的想法。语义相似不等于归属正确,隔离必须靠显式的过滤条件来保证。
把这三个案例放在一起看,会发现它们分别对应了记忆设计的三个维度:生命周期、读写优先级、作用域隔离。任何一个维度没定义清楚,都会以 bug 的形式暴露出来。而这三个维度,恰恰是大多数 Agent 记忆模块在设计阶段就缺失的。
3. 记忆模块的四个设计维度:作用域、生命周期、读写策略、一致性
3.1 作用域:每条记忆都必须回答“这是谁的记忆”
作用域是记忆设计的第一性问题。在写任何一条记忆之前,必须先确定它的归属层级。常见的层级有四种:全局级(所有用户共享,比如产品知识库)、租户级(一个组织内共享)、用户级(单个用户私有)、会话级(单次会话内有效)。这四层不是互斥的,一条记忆可以同时属于多个层级,但必须有明确的主归属。
落到实现上,每条记忆记录里至少要包含这几个字段:scope_type(作用域类型)、scope_id(作用域标识,如用户 ID)、session_id(会话标识,会话级记忆才需要)。检索时,这些字段作为硬过滤条件,先过滤再排序。向量库的 metadata 过滤能力就是干这个用的,别浪费。
我见过一些团队用“命名空间”来做隔离,比如每个用户一个 collection。这种方式隔离性最强,但扩展性差,用户量大了之后 collection 数量爆炸,运维成本很高。更推荐的做法是单 collection + metadata 过滤,配合合理的索引,性能和隔离性都能兼顾。
3.2 生命周期:短期记忆和长期记忆的边界在哪里
生命周期决定了记忆什么时候该被创建、什么时候该被淘汰。短期记忆(会话级)的生命周期就是会话本身,会话结束可以归档或丢弃;长期记忆(用户级及以上)需要持久化,并且要有明确的过期和清理策略。
这里有个实操中的经验:不要试图给所有记忆都设一个统一的过期时间。不同类型的记忆,生命周期差异很大。用户的称呼偏好,可能几个月都不变;而“用户当前正在处理的任务”这种记忆,可能几小时后就失效了。所以记忆记录里应该有一个ttl或者expire_at字段,按类型设置不同的过期策略。用户画像类的可以设长一点甚至不过期,任务上下文类的设短一点。
另外,短期记忆向长期记忆的“晋升”也需要设计。不是所有会话内容都值得长期保存,通常只有显式的用户偏好、纠正信息、重要事实才需要晋升。晋升的触发条件可以是用户显式表达(“记住我喜欢……”),也可以是模型判断(这条信息值得长期保留)。晋升时要走统一的写入接口,带上正确的作用域标识。
3.3 读写策略:写入优先级和读取顺序必须成对设计
读写策略是前面案例二暴露的核心问题。写入的时候,要明确这条记忆的优先级;读取的时候,要按优先级从高到低组装。这两件事必须成对设计,不能各写各的。
一个可落地的优先级设计是这样的:显式纠正信息 > 用户显式偏好 > 用户画像静态字段 > 近期对话历史 > 泛化历史记忆。写入时给每条记忆打上priority标记,读取时按优先级排序,高优先级的先拼进 prompt。同时,当高优先级信息写入时,要考虑是否要覆盖或更新低优先级的同主题记忆,避免出现“新旧信息同时存在、模型不知道该听谁的”的情况。
注意:优先级不是越高越好。如果所有记忆都标成最高优先级,等于没有优先级。优先级的设计要克制,通常三到五档就够了。
3.4 一致性:多 Agent 协作下的记忆同步问题
在多 AI 协作场景下,记忆一致性是个绕不开的问题。多个 Agent 可能同时读写同一份用户记忆,如果没有同步机制,就会出现 A 写 B 读不到、或者 A 和 B 同时写导致覆盖的情况。
解决思路有两种:一种是集中式,所有 Agent 的记忆读写都走同一个记忆服务,由服务层保证一致性;另一种是分布式,每个 Agent 本地有缓存,通过消息队列或事件机制同步变更。集中式实现简单、一致性强,但记忆服务会成为单点;分布式扩展性好,但一致性保证复杂。对于大多数中小规模项目,我建议先用集中式,把记忆服务做成一个独立的内部服务,所有 Agent 通过 API 读写。等规模上来了再考虑分布式缓存。
一致性还涉及一个细节:写入的可见性延迟。如果 A Agent 刚写入一条记忆,B Agent 立刻读取,能不能读到?在集中式方案下,只要写入成功返回,读取就应该能读到,这是强一致性。如果用了缓存,就要考虑缓存失效策略,避免读到旧数据。
4. 一套可落地的分层记忆架构:从存储到检索的完整实现
4.1 存储层选型:关系库、向量库、KV 库各管什么
分层记忆架构的存储层,不建议只用一种存储。不同类型的记忆,适合的存储不一样。我的经验是三种存储配合使用:
| 存储类型 | 适合的记忆 | 典型实现 | 关键字段 |
|---|---|---|---|
| 关系库 | 用户画像、结构化偏好 | PostgreSQL / MySQL | user_id, key, value, priority, updated_at |
| 向量库 | 语义化历史记忆、事实片段 | 向量检索服务 | user_id, session_id, embedding, text, ttl |
| KV 库 | 会话级临时状态、短期缓存 | Redis | session_id, key, value, expire_at |
关系库存结构化、需要精确查询的记忆,比如“用户的称呼偏好”这种 key-value 形式的;向量库存需要语义检索的非结构化记忆,比如“用户上次提到的项目背景”;KV 库存会话级的临时状态,读写快、自动过期。三者通过统一的记忆服务封装,上层 Agent 不直接接触底层存储。
这个选型的逻辑是:让每种存储做它最擅长的事。关系库擅长精确匹配和事务,向量库擅长语义相似度,KV 库擅长高频读写和自动过期。硬要用一种存储扛所有场景,要么查询效率低,要么实现复杂度高。
4.2 记忆服务的接口设计:写入、检索、更新、删除
记忆服务对外暴露的接口,建议至少包含四个:write、search、update、delete。每个接口都要带上作用域参数,这是强制的,不能省。
# 写入记忆 def write_memory(scope_type, scope_id, content, memory_type, priority, ttl=None): # 1. 校验作用域参数 # 2. 根据 memory_type 路由到对应存储 # 3. 写入时带上 priority 和 ttl # 4. 返回记忆 ID pass # 检索记忆 def search_memory(scope_type, scope_id, query, top_k=5, min_priority=0): # 1. 按作用域硬过滤 # 2. 按优先级和语义相似度排序 # 3. 过滤掉已过期的记忆 # 4. 返回排序后的记忆列表 pass接口设计的关键点是:作用域参数必须是必填项,不能有默认值。很多串号 bug 就是因为作用域参数有默认值,调用方忘了传,结果写到了默认作用域里。强制必填,能在编译期或调用时就暴露问题。
检索接口的排序逻辑也要明确:先按作用域过滤,再按优先级排序,同优先级内按语义相似度或时间排序。这个顺序不能反,否则高优先级的记忆可能被低优先级的相似记忆挤掉。
4.3 检索时的优先级组装:怎么把记忆拼进 prompt
检索出来的记忆,最终要拼进 prompt。拼接顺序直接影响模型的使用效果。我的建议是:高优先级的、结构化的记忆放前面,低优先级的、语义化的记忆放后面。因为模型对 prompt 前部的注意力通常更强,重要的信息放前面更保险。
一个典型的拼接模板是这样的:
[用户偏好] 称呼:老张 通知方式:仅邮件 [近期相关记忆] 用户上周提到正在做一个 Agent 记忆模块的项目。 用户对向量检索的性能比较关注。 [历史对话摘要] (略)每段记忆前面加一个明确的标签,告诉模型这段是什么。标签本身也是给模型的信号,帮助它判断该不该用、怎么用。不要把所有记忆混成一坨塞进去,那样模型很难区分哪些是当前有效的、哪些是过期的。
另外,拼接时要控制总长度。记忆不是越多越好,超过一定长度后,模型的注意力会被稀释,反而影响效果。通常建议记忆部分控制在 prompt 总长度的 30% 以内,具体数值要根据模型和任务调。
4.4 过期与清理:别让记忆库变成垃圾场
记忆库如果不清理,会越来越大,检索效率下降,而且过期信息会干扰模型判断。清理策略要分类型设计:
- 会话级记忆:会话结束后延迟一段时间(比如 24 小时)自动删除,给可能的回溯留点窗口。
- 用户级记忆:设置合理的 TTL,比如任务上下文类 7 天,偏好类 90 天或不过期。
- 全局记忆:定期人工审核,清理过时内容。
清理任务建议做成定时任务,每天跑一次,扫描过期记忆并删除。同时要有监控,记忆库的增长速度、检索延迟这些指标要盯着,异常增长往往意味着写入逻辑有问题。
提示:清理之前建议先做软删除,标记为 deleted 而不是物理删除,保留一段时间用于排查问题。确认没问题后再物理删除。
5. 实操中的坑与经验:那些文档里不会写的东西
5.1 向量检索的“语义相似陷阱”
向量检索有个很容易踩的坑:语义相似度高的记忆,不一定是对当前对话有用的记忆。比如用户问“我的邮件设置是什么”,向量检索可能返回一堆关于“邮件”的历史对话,但真正有用的只有那条“仅邮件通知”的偏好。如果只按相似度排序,偏好类记忆可能被淹没在大量对话片段里。
解决办法是给不同类型的记忆加权重。偏好类、纠正类记忆的权重调高,普通对话片段的权重调低。检索时用加权分数排序,而不是纯相似度。这个权重可以配置化,不同场景调不同值。
5.2 记忆写入的“重复写入”问题
同一个偏好,用户可能在不同会话里说了好几次。如果每次都写一条新记忆,记忆库里就会有大量重复。检索时这些重复记忆会占满 top_k,挤掉其他有用信息。
处理方式有两种:一种是写入时做去重,用语义相似度判断是否已有同类记忆,有则更新而不是新增;另一种是检索时做去重,相同主题的记忆只保留最新的一条。我倾向于写入时去重,因为检索时去重会增加延迟,而且判断逻辑复杂。写入时去重可以用一个简单的规则:同作用域、同 memory_type、语义相似度超过阈值的,视为同一条记忆,更新其内容和时间戳。
5.3 多 Agent 共享记忆时的“写冲突”
多个 Agent 同时写同一份记忆时,可能出现写冲突。比如 A Agent 写“用户喜欢邮件”,B Agent 写“用户喜欢短信”,两条记忆同时写入,模型读取时不知道该信哪个。
解决思路是引入版本号或时间戳,后写入的覆盖先写入的,同时保留变更历史。读取时只读最新版本。如果两个写入几乎同时发生,可以用乐观锁,写入时检查版本号,冲突则重试或合并。更复杂的场景可以用 CRDT(无冲突复制数据类型),但对大多数项目来说,版本号加时间戳就够了。
5.4 记忆模块的可观测性:怎么知道记忆有没有生效
记忆模块出问题,往往很难排查,因为它是“隐式”的——模型用了记忆,你从输出上看不出来;模型没用,你也不知道是没检索到还是检索到了没用。所以可观测性很重要。
建议在记忆服务的检索接口里加日志,记录每次检索的 query、过滤条件、返回的记忆列表和分数。同时在 Agent 生成回复后,记录这次回复用到了哪些记忆(可以通过让模型输出引用来源,或者事后分析)。这样出问题时,能快速定位是检索没召回、还是召回了但模型没用。
我自己的做法是在调试模式下,把检索到的记忆和最终 prompt 都打出来,人工核对。虽然土,但有效。等稳定了再逐步减少日志量。
6. 从这几个 bug 往外看:Agent 记忆设计的通用原则
把前面这些案例和经验收拢一下,Agent 记忆设计其实可以归纳成几条通用原则。第一条是作用域先行:任何记忆操作之前,先确定作用域,作用域不明确的记忆不允许写入。第二条是生命周期显式化:每条记忆都要有明确的创建、过期、清理策略,不能有“永久存在但没人管”的记忆。第三条是读写策略成对:写入时的优先级和读取时的排序逻辑必须匹配,不能各写各的。第四条是隔离靠过滤不靠语义:作用域隔离必须用显式的过滤条件保证,不能指望语义检索自己找对。第五条是可观测:记忆的写入、检索、使用都要有日志,出问题能追溯。
这几条原则听起来简单,但真正落地的时候,每一条都需要在架构层面做取舍。比如作用域先行,意味着所有记忆接口都要改签名,加作用域参数,存量代码要改;生命周期显式化,意味着要设计 TTL 策略和清理任务,运维成本增加。这些取舍没有标准答案,要根据项目阶段和团队规模来定。但有一点是确定的:记忆模块的设计缺陷,越晚暴露,修复成本越高。社区里这些 bug 之所以让人头疼,就是因为它们往往在项目上线后才被发现,那时候改架构的成本已经很高了。
如果你现在正在设计 Agent 的记忆模块,我的建议是先把作用域和生命周期这两件事想清楚,哪怕实现得简单一点,也比事后打补丁强。向量库、优先级、一致性这些可以逐步迭代,但作用域隔离和生命周期定义这两个地基,一开始就要打牢。踩过坑之后回头看,那些看起来“省事”的设计,最后都变成了更大的麻烦。