先讲一个我亲历的翻车场景。那会儿我在做一个给线下门店用的客服 Agent,上线第一天就在群里被吐槽:“你们这个机器人是不是失忆了,我昨天刚说过家里狗粮吃完了,今天又问我要不要囤货,关键我还回复过他了。”于是我们连夜把聊天记录塞进记忆系统,第二天变成了:“你们这机器人怎么回事,连我住哪个小区都往外说,它是不是把不该记的也记住了?”同一个 Agent,同一周内收到两种截然相反的投诉——一边嫌它忘得快,一边嫌它记太多。
这个事让我彻底想清楚了一件事:长期记忆这个功能,难点从来不是“怎么存得下”,而是怎么在记住与不越界之间找平衡。这两个投诉背后,其实是三个独立的技术问题:记忆该怎么分层存储、每一条记忆的存活周期怎么定、以及整套数据在写入和读取时怎么治理。也就是标题里说的 Memory 分层、TTL 和数据治理。这篇文章我会把这三件事拆开讲,最后附一套可以直接落地的轻量实现方案,适合那些正在给 Agent 加记忆功能,又不想把项目做成数据的团队。
1. 先搞清楚一件事:Agent 缺的不是记忆,是“有边界的记忆”
1.1 长期记忆到底解决了什么痛点
在动手写任何记忆代码之前,我建议先回答一个问题:你这个 Agent,到底需要记住什么?这个问题如果答不好,后面做多少分层设计都是白搭。
以我做过的几个项目来看,Agent 对记忆的真实需求通常只有三类。第一类是连续性需求,用户希望 Agent 能接着上次的对话继续,不至于每次都得把前因后果重新说一遍。第二类是个性化需求,Agent 要能记住用户的偏好,比如喜欢简洁回答还是详细回答、习惯看表格还是看文字。第三类是知识积累需求,Agent 需要在多次交互中慢慢沉淀某个领域的事实性信息,比如客户公司的内部术语、常见问题的标准回复口径。
听起来都不难对吧?但一旦落到工程实现上,问题就来了:这些信息类型完全不同,有的只在当次对话里有用,有的要跨天、跨月甚至跨年,还有的需要能被检索、被更新、被删除。把它们一股脑塞进同一个存储里,就像把厨房、卧室和书房的书全堆进一个抽屉,找的时候乱,丢的时候更乱。
1.2 越界不是“记住不该记的”这么简单
我在那个客服项目里,把“越界”归纳成了四种典型姿势,你们可以对号入座。
第一种是权限越界。Agent 在某个对话里通过联网搜索或插件拿到了用户资料,比如收货地址、会员等级,然后在下一次对话中主动使用了这些信息,但用户并没有授予这个用途。权限越界的本质是:就算数据合法,使用场景也不合法。
第二种是隐私越界。这是最容易被忽略的。很多记忆系统默认把整个对话原文存进向量库,但对话里可能包含身份证号、银行卡、家庭住址这类明文敏感信息。哪怕你不主动调用,只要它存在你的存储层里,一旦泄露或被人通过接口枚举出来,就是事故。
第三种是事实越界。用户上个月说要换工作,这个月已经入职新公司了,但记忆库里还写着“正在寻找新工作机会”,Agent 就会一本正经地推荐简历优化服务。事实越界不是存储的问题,而是记忆没有过期机制,旧的事实得不到更新。
第四种是上下文越界。有些实现会把几十条记忆一股脑拼进 prompt,结果主任务被大量无关历史淹没,模型要么开始编造、要么生成一些近乎胡言乱语的内容。这种情况我在项目里见过很多次,本质上是记忆系统没有做召回和取舍,把“能取到的”当成了“该用的”。
这四个越界姿势,分别对应了设计记忆系统时必须回答的四个问题:谁能读?能不能存?存多久?什么时候用?下面我就按这个框架一层层往里讲。
2. 记忆分层:别让所有信息都挤在同一张网里
2.1 记忆的四个层级,以及它们各自的服务定位
我见过不少团队把“长期记忆”当作一个黑盒,认为只要能存、能查就行。实际上,一个可用的记忆系统,至少要区分出四个层级,每一级的存储介质、读写策略和服务目标都不一样。
- L0 工作记忆(Working Memory):相当于当前对话的上下文窗口,只存在于本次会话内。它的特点是高速度、短寿命,通常由 LLM 的上下文窗口承载,我们需要做的是合理的截断与摘要,而不是存储。
- L1 情景记忆(Episodic Memory):以“事件”为单位记录用户与 Agent 的交互历史,比如“用户在某天咨询了狗粮品牌并完成购买”。它偏结构化,适合放在数据库表里,用来支撑“上次聊到哪了”这类查询。
- L2 语义记忆(Semantic Memory):从历史中提炼出来的、相对稳定的用户画像和偏好,比如“用户有两只猫,偏好无谷猫粮”。这类信息适合向量化存储,支持语义召回。
- L3 程序记忆(Procedural Memory):记录 Agent 自身学会的工作流、工具调用偏好和领域规则,比如“处理退货时需要先验证订单号”。它通常以规则、技能配置或微调参数的形式存在,不适合让普通对话写入。
我最初犯的错,就是把 L0 的工作记忆直接丢进 L2 的向量库。结果每条原始对话都变成一个向量,召回时噪声极大,还经常召回十几条内容高度重复的记录。分层的意义就在这里:每一条数据进来时,都要明确自己属于哪一层,然后按这一层的规则去存、去用、去养。
2.2 写入路径和读取路径分开设计
分层不能只停留在概念上,落到代码里要把两条路径分别设计清楚。
写入路径上,Agent 每接收一条新信息,要先过一个“记忆提取器”,把信息拆解成结构化事件、语义画像和操作规则三类,再分别写入对应的层级。我在实际项目里会用一个小型模型或规则引擎做这件事,比如检测到用户说了“我家猫最近特别挑食”,就提取出语义画像:宠物偏好=挑食;检测到用户完成了下单操作,就提取出一个情景事件:order_id=xxx,商品=xxx。
读取路径上,Agent 不是每次对话都要去查所有层级,而是按任务类型做路由。比如用户问“我之前买的猫粮到货没”,路由到 L1 情景记忆精确查询;用户问“你能记得我喜欢什么吗”,路由到 L2 语义记忆做召回;用户问“你处理退货的流程是什么”,路由到 L3 程序记忆读取规则。
这样设计的最大好处是互不干扰。L1 的精确查询不会被 L2 的模糊召回污染,L3 的规则不会被用户对话的噪声覆盖。很多 Agent 表现得不稳定,往往是这几层之间的隔离没做好。
2.3 每一层要用什么存储,其实有明确的取舍
聊到存储选型,我发现很多人被“向量数据库”这四个字带着走。但冷静下来看,L1 情景记忆最合适的是结构化数据库,比如 PostgreSQL 或 SQLite,因为它是确定性查询,字段明确、适合索引;L2 语义记忆才需要向量检索能力,可以用支持向量索引的数据库,也可以单独引入向量服务;L3 程序记忆通常就是配置文件和代码逻辑,连数据库都未必需要。
一句话总结:不要让最重的向量存储承担所有层级的读写,那样既浪费资源,又会让召回质量变差。你完全可以先跑一个只有 SQLite 的版本,用关键词和元数据过滤实现 L1,等确实需要语义召回后再引入向量检索。
3. TTL 是记忆的“保质期”,不设置等于每天往数据库里扔垃圾
3.1 为什么记忆不能永远有效
“长期记忆”这个名字很误导人,它让人以为记忆就应该永久保留。但真实世界里,长期有效的东西很少,大部分记忆在几小时、几天或者几周后就该过期。
先说一个典型事故。我之前在某保险问答 Agent 里加了一个功能,用户会问“我去年那个理赔流程走到哪一步了”。Agent 把理赔进度写进了长期记忆,但理赔流程本身是有状态变化的:提交 -> 初审 -> 补充材料 -> 结案。如果没有 TTL,当理赔状态已经跳到“结案”时,如果调用旧记录或作为背景信息提供给模型,就会干扰判断,甚至给用户回复错误进度。我们当时还排查了很久,最后发现就是一条两个月的旧记忆在 prompt 里“作祟”。
TTL 就是来解决这个问题的。它给每一条记忆设定一个存活时间,到期后这条记忆就不再参与召回,甚至直接从存储中移除。它本质上是一种时间维度的数据边界:既保证记忆足够新鲜,又防止过期信息污染模型判断。我见过不下十个团队,上线长时间运行的 Agent 后出现“胡说八道”的现象,排查到最后都发现是过期记忆在捣乱。
3.2 TTL 不是一刀切,按记忆层级设不同有效期
更关键的是,TTL 不能用一个统一值。不同层级的记忆,保质期应该有数量级的差别。
我目前用的配置大致是这样:L0 工作记忆不持久化,会话结束就销毁,最多保留一份摘要供临时使用,有效期 24 小时;L1 情景记忆里的状态类事件(比如订单进度、预约状态)有效期设 7 到 30 天,因为这类信息会被更新的状态覆盖;L2 语义记忆里的稳定偏好(比如饮食偏好、沟通风格)可以设 90 天甚至更长,但必须配合定期复核;L3 程序记忆里的规则基本没有 TTL,因为它本身是经过验证的配置,更新走的是版本管理。
这里要特别强调一点:TTL 字段必须是每条记忆的元数据,而不是整张表的统一属性。因为即使是同一个用户,他的收货地址和临时备注的保质期也完全不同。如果把 TTL 设成表级参数,就可能出现两种尴尬:要么为了照顾某条短命数据而把整表设成短 TTL,要么为了长期偏好而把所有数据都设成超长 TTL。
3.3 过期之后怎么办:删除、归档、还是让模型“看不见”
设置 TTL 只是第一步,过期后的处理策略同样重要。简单粗暴地 physical delete 会把调试线索也一起删了;但一直软删除又会积累大量脏数据。
我的经验是分三种情况处理。对高敏感数据(比如包含身份信息的内容),到期直接硬删除,连备份都不留,这在数据治理上最安全。对可审计数据(比如用户明确授权的消费记录),采用软删除或归档到冷存储,不上线参与检索。对纯噪声数据(比如无意义的闲聊记录),到期直接删除,不保留任何痕迹。
还有一种比较温和的做法叫“逐步降级”:一条记忆到期后不是立即消失,而是从 L2 语义记忆中降级到低优先级的低频存储里,再经过一个更长的观察期后最终删除。这种做法适合那种“短期内没用、但可能有反复”的记忆类型。不过要小心,降级逻辑越复杂,后面排查问题越麻烦,建议在真正遇到性能瓶颈再上,不要一开始就做得过于花哨。
4. 数据治理:边界比记忆能力本身更重要
4.1 一把“权限锁”:每条记忆都要带作用域
聊完 TTL,再来看另一个经常被忽略的问题:谁能读取这条记忆。我在实际项目里踩到过一个很隐蔽的坑:同一个 Agent 实例服务多个用户,但因为记忆表里没有作用域字段,结果 A 用户的偏好被当成了 B 用户的背景信息喂给模型。如果是电商推荐场景,最多是推荐得不对;如果是医疗或金融场景,这个问题就直接升级成事故了。
解决其实很简单,我给每条记忆加三个字段:owner_id(归属人/归属会话)、agent_id(允许哪个 Agent 使用)、scope(读取权限范围,比如仅本人、仅客服、仅内部)。检索时,任何一条记忆进入候选集之前,都必须先通过这三个字段的过滤。这里不要依赖模型“自觉”,所有过滤必须在存储层或者检索前完成,因为 prompt 里的权限约束在复杂场景下很容易被绕过。
另外还要注意:作用域不是设置完就不变了。用户的授权范围可能变化,比如用户曾经同意 Agent 记住手机号用于订单通知,后来取消了这个授权。那么不仅仅要移除这条记忆,还要在授权变更日志里记录操作,保证可追溯。我在项目里会把这种变更做成一个独立的事件流,方便后续审计。
4.2 脱敏与最小化:能记住抽象的,就不要记住原文
数据最小化是一条值得反复强调的原则。我之前看到一个团队把用户和客服的完整对话原文直接存进记忆库,理由是“不知道后面模型要用到哪段”。结果上线后不久,安全审计就提出了整改要求,因为他们把用户的身份证号、银行卡后四位、家庭地址这些明明可以直接脱敏的信息,完整地存下来了。
正确的做法是在写入记忆之前,先过一个 PII(个人可识别信息)检测与脱敏流程。比如用户说“我的手机号是 138xxxx1234,帮我改一下收货地址”,写入记忆的应该是“用户已更新收货地址,手机号已脱敏为 138****1234”,而不是把原文照搬进去。如果某些场景确实需要敏感信息的精确值,那就不能放在常规记忆库里,要放到专门的安全存储,并且通过显式授权才能访问。
脱敏之外,还要控制记忆的“量”。我见过有些 Agent 的 prompt 正文里被塞了二十多条记忆,其中大半和当前问题无关。正确的做法是:宁可召回 3 条最相关的记忆,也不要为了“全面”把 20 条都塞进去。多数情况下,3 到 5 条高质量记忆就足以让模型表现稳定,大量无关记忆只会稀释模型的注意力。
4.3 遗忘权不只是合规概念,更是数据质量的解药
“用户有权利要求删除自己的数据”这句话,在合规上是对的,在系统设计上同样重要。因为用户删除数据的动机,往往不仅是因为隐私,还因为“我不希望你再用这些信息揣测我”。
我做过一个极端的案例:某用户在某情感陪伴类 Agent 里倾诉了很长时间,后来关系结束,要求清空所有记忆。如果我们只是把他的不闻不问记个 deleted 标志,但向量库里还留着他的情感经历数据,虽然不会被正常检索到,但备份、日志、缓存里依然有存在。这不仅是合规风险,也是数据质量的隐患——历史残片会在未来某个不恰当的时机重新冒出来。
所以我在系统里设计了一个“删除传播”机制:当用户发起删除请求时,不只是删掉记忆主表里对应记录,还会同步清理向量索引里的向量、通知日志系统清理或脱敏相关条目、并刷新可能缓存了这条记忆的读取层。这个链路做起来很繁琐,但它是“边界记忆”的底线能力,不能省。
5. 一套能直接落地的轻量实现:从 SQLite 到向量检索
5.1 选型:不是所有项目都需要一开始就上向量库
很多朋友问我说,记忆系统是不是一定要用向量数据库。我的回答通常是:看你的召回需求。如果你的 Agent 需要在“模糊语义”上做记忆召回,比如用户说“我想起之前讨论过某个关于过敏的事”,那你确实需要向量检索;但如果你的 Agent 主要靠结构化条件查询,比如按用户 ID 和时间范围查询,那传统关系数据库完全够用。
我给几个项目做过的落地路径是这样的:第一个版本用 SQLite + FTS5(全文搜索),把情景记忆和语义画像都存成带元数据的记录;等到用户量上来,发现确实需要“按意思找记忆”之后,才给 L2 层引入向量索引。别一开始就把系统架构复杂化,复杂往往意味着更难排查越界问题。
5.2 核心存储结构设计
下面给出一套我在实际项目里用过的、比较稳的存储结构,供参考。它整体上是一个memory_items表,配合一个memory_vectors表。
-- 记忆主表 CREATE TABLE memory_items ( id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, -- 数据归属人 agent_id TEXT NOT NULL, -- 允许使用的 Agent 范围 scope TEXT NOT NULL DEFAULT 'private', -- private/shared/internal level TEXT NOT NULL, -- episodic/semantic/procedural content TEXT NOT NULL, -- 记忆正文 meta JSONB, -- 扩展元数据:来源、置信度等 ttl_seconds INTEGER NOT NULL, -- 从 created_at 起的存活秒数 created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ); -- 向量索引表 CREATE TABLE memory_vectors ( id TEXT PRIMARY KEY, memory_id TEXT NOT NULL, embedding BLOB NOT NULL, model_name TEXT NOT NULL, created_at INTEGER NOT NULL );关键点在于,expires_at不是靠查询时临时计算的,而是在写入时直接算好,这样检索时只需要一个简单的范围过滤,性能好且逻辑清晰。level和scope字段必须建索引,因为这两个字段决定了绝大部分的过滤条件。
5.3 检索链路:过滤、召回、重排,一步都不能少
检索时,我的标准流程是四步:权限过滤 -> 过期过滤 -> 语义/关键词召回 -> 重排组装。
第一步,所有候选记忆先按当前用户的owner_id、agent_id、scope过滤,这一步在数据库层完成,确保越权记忆根本进不了候选池。第二步,增加expires_at > now的条件,把过期记忆直接挡在门外。第三步,对剩下的候选做召回,L1 用 SQL 查询,L2 用向量或 FTS 召回 top K。第四步,对召回结果做重排,把更贴近当前问题的、置信度更高的记忆排到前面。
我可以用一个伪代码片段来展示这个流程:
def retrieve_memory(user_id, agent_id, query, now_ts, top_k=5): candidates = db.query( "SELECT * FROM memory_items " "WHERE owner_id = ? AND agent_id = ? " "AND expires_at > ? " "AND scope IN (?, ?) " "ORDER BY updated_at DESC LIMIT 20", (user_id, agent_id, now_ts, "private", "shared") ) if not candidates: return [] # 语义召回:只在 L2 层级进行 query_embedding = embed(query) vec_candidates = vector_search( memory_ids=[c.id for c in candidates], embedding=query_embedding, top_k=top_k ) # 重排:结合时间衰减与相关性 reranked = rerank( vec_candidates, time_decay_factor=0.9, now_ts=now_ts ) return reranked[:top_k]这个流程看起来简单,但每一步都对应着一个“越界防线”。如果一开始就把召回范围放大到所有记忆,后面哪怕重排做得再好,也无法弥补越权或过期数据已经进入候选池的问题。
5.4 周期清理任务:别让 TTL 只停留在配置里
TTL 设置得再好,如果没有后台任务去真正执行清理,最终还是会积累大量脏数据。我在项目里会跑一个周期任务,通常是每隔一小时执行一次批量清理。
def cleanup_expired_memories(now_ts, batch_size=500): expired_batch = db.query( "SELECT id, scope, level FROM memory_items " "WHERE expires_at <= ? LIMIT ?", (now_ts, batch_size) ) for item in expired_batch: if item.scope == "high_sensitive": hard_delete(item.id) # 高敏数据直接删 elif item.level == "episodic": archive(item.id) # 情景记忆归档 else: soft_delete(item.id) # 语义画像软删除 log_cleanup_audit(now_ts, len(expired_batch))清理的时候有一点要提醒:批量不要太大,尽量用游标循环处理,否则数据库锁和性能问题会让你半夜被线上告警吵醒。这个我踩过,批量删十万条记忆时直接把同库的在线服务拖垮了。
5.5 实测环境的检验方式与常见坑
最后聊一套我自己在实测阶段常用的检验方式。模拟一个只发生本地交互的环境,跑一个长时间运行的 Agent,让用户每天和它聊几句,期间故意更新个人信息,比如改地址、换偏好、取消授权,然后定期检查 Agent 的回复是否用了最新信息、有没有提到已删除的历史、是否出现了另一个用户的记忆。这类测试能同时暴露分层、TTL 和治理三个层面的问题,比只看单轮对话的准确率有用得多。
常见的坑还包括:把 TTL 设成 0 表示永久,导致清理任务把永久记忆误删;清理任务和用户写入任务并发时出现竞态;向量表和主表的数据不一致。每个坑的具体表现不一样,但调试入口都一样:先把检索日志打全,看每条候选记忆是从哪个层级、哪条过滤规则进来的。在记忆系统里,“不可见”的问题比“报错”的问题难十倍,日志是全栈排查的底气。
我在几个项目里把这套方案跑通之后,最大的感受是:长期记忆真正考验的不是存了多少,而是敢扔多少。分层解决的是组织问题,TTL 解决的是时间问题,数据治理解决的是边界问题。这三件事都做扎实了,Agent 的记忆才既好用又可控。