DeepAgents(十七):记忆也需要管理

2026-10-22 06:260 条评论1 次阅读

记忆也需要管理

记忆写进去只算开头。用得久了,3 种坏结果一定会出现:同一件事攒了十几条、2 条互相矛盾、半年前的偏好还在生效。

真正的功课在写入之后的收拾。

一、先把原文和事实分开

同一个「记住」,在库里其实是 2 种东西。

原文。 用户当时说了什么,一字不改,带时间带上下文。它的价值是保真,出了问题能追到原话;代价是长,一条对话可能几百字,检索不精准。

事实。 从原文里抽出来的一句短话,比如「编辑器偏好:neovim」。它短,能去重、能对账,方便按意思检索。

产线是 2 层都存:事实从原文抽出来,原文留着当证据。只存事实,将来出错追不回原话;只存原文,每轮都要重新理解一遍,又慢又贵。

二、写记忆的 2 个时机

写记忆有 2 个时机,对话中写,对话结束后整理。

前者叫热路径。好处是当场记下,下一句就能用上;坏处是模型正在答话,归纳往往很糙,换个说法就记成新的一条。

后者叫冷路径:对话结束后由后台任务读一遍原文,抽出事实、合并重复、把过期的标掉。归纳可以换更强的模型,可以写得慢一点,也不占对话的上下文。代价是下次才生效。

这 3 条里最后一条别省:组织策略和合规口径让 Agent 自己写,等于把规矩交给被管的人定。

三、写得进去的关键在 4 个动作

把一条新事实放进库,第一步是和旧的比一遍,然后落成 4 个动作里的 1 个:ADD、UPDATE、DELETE、NONE。我在本机 Postgres 上跑了 8 轮对话,输入是已经抽好的候选事实。

轮次 新来的事实 动作 库里的结果
1 编辑器是 vim ADD 新增 1 行
2 回答先给结论 ADD 新增 1 行
3 编辑器是 vim NONE 不新增,权重加 0.1
4 编辑器是 neovim UPDATE 新行进场,旧行标失效并指向新行
5 主题用深色 ADD 新增 1 行
6 不再用深色 DELETE 标失效,不删行
7 系统是 Ubuntu ADD 新增 1 行
8 编辑器是 neovim NONE 不新增

跑完库里 5 行,其中 3 行是活的。旧的那行 vim 还在,active 是 false,superseded_by 指向新行,version 从 1 记到 3。

这里最容易漏的是 NONE。重复出现也在加权。 同一件事被提到 3 次,说明这条对用户重要,权重该往上走,但它不该变成 3 行。

很多记忆库越用越臃肿,就是只做了 ADD,没做 NONE。

第二个坑是判断「同一件事」靠什么。精确匹配 subject + predicate 最稳,也最省;相似度阈值要调,调低了合并错,调高了记成 2 条。我的做法是先精确匹配,再拿 0.9 以上的相似度兜底。

四、遗忘这件事,缓存领域已经踩过一遍

说到淘汰旧数据,缓存的功课比记忆系统早做很多年。

Redis 的 maxmemory-policy 给了 7 种选择,按 3 个维度组合:动不动手(noeviction 直接报错)、按什么挑(LRU、LFU、随机)、在哪个范围里挑(全部键,还是只挑设了过期时间的)。

官方建议很实在:热点集中用 LRU;循环扫描或分布均匀时 LRU 会失效,该换随机;想用 TTL 长短暗示淘汰顺序,用 volatile-ttl。

Caffeine 用的是 W-TinyLFU:1% 的窗口区按 LRU 收留新来的,99% 的主区再分观察区(20%)和保护区(80%)。新条目要进主区,先跟「正要被淘汰的那个」比历史热度,输的走。

热度用 4 bit 的计数草图估,上限 15,攒够次数就把计数全减半,让老热点慢慢失温。

为什么要搞这么复杂?因为任何单一规则都会在某个场景下崩掉。LRU 扛不住一次全表扫描,扫过的冷数据会把真热点全冲出去;LFU 扛不住热点迁移,曾经很热、如今没人用的条目靠着历史计数赖着不走。

五、5 种淘汰策略喂同一份数据

我造了 1230 条记忆横跨 90 天:60 条硬事实(身份、时区、语言)、720 条阶段性偏好、450 条一次性信息。3 份提问流水各跑 5 个策略:纯对话、掺批量扫描(占 27%)、热点迁移(前 45 天只问前 30 条硬事实,后 45 天只问后 30 条)。

策略 纯对话命中 带扫描命中 热点迁移命中 硬事实留存(3 份取最差)
纯 TTL 30 天 61.70% 61.92% 72.67% 0/60
LRU 容量 400 16.33% 14.89% 20.33% 0/60
LFU 容量 400 72.74% 55.11% 38.93% 30/60
重要性 + 30 天未用 100% 100% 100% 60/60
混合分 + 硬事实保护 75.52% 57.62% 63.70% 60/60

表里有 4 个坑,全是实测。

第 1 个坑:命中率最高的那个策略,什么都没淘汰。 「重要性 + 30 天未用」三份流水都是 100%,但最后在册 968 到 1230 条,掺扫描那份一条没清。命中率得和在册条数一起看,只看命中率会选一个不敢删东西的方案。

第 2 个坑:批量扫描把时间信号污染了。 「30 天没被命中」依赖 last_hit_at,可一次批量导出就会把所有条目的这个字段刷成今天。同一份数据,纯对话清掉 262 条,掺了扫描清掉 0 条。

第 3 个坑:LFU 在热点迁移时掉得最狠。 纯对话 72.74%,热点一变只剩 38.93%,还误杀了 30 条硬事实。老的那批计数高、占着位置,新的一批从 0 开始,抢不过。

第 4 个坑:纯 TTL 和 LRU 会把硬事实杀干净。 时区、语言、身份这些放 3 年都不变的东西,被「30 天没用」和「最久没访问」当垃圾清了,留存 0/60。

混合分和「重要性 + 30 天未用」这 2 行,三份流水都把 60 条硬事实留住了。差别在于后者的做法是几乎不淘汰,压力全留给了容量。

六、记忆不能照抄缓存的删法

缓存淘汰错一条,代价是下次请求慢一点,回源再取一遍就行。记忆淘汰错一条,没有回源的地方:用户半年前说过的那句话、你当时为什么那么判断,库里没了就是没了。

第 1 步,先标失效,不删行。 同一批数据,日常查询只看 active 是 2 条,去掉这个条件看全量是 6 条,标掉的 4 条内容一个字没改。

第 2 步,留一个后悔窗口。 标失效后放 N 天,确认没人回滚,再做物理清理。

第 3 步,硬事实打保护位。 认人的那几条(身份、时区、语言、禁忌)设成不可淘汰,容量压力再大也不动。上面那 60 条就是这么保住的。

业界的成熟做法也是这个方向。Zep 的记忆图谱给每条事实记 2 条时间轴(在世界上何时成立、系统何时知道),冲突时只给旧边打一个失效时间,不删。mem0 干脆不删,改成检索时给分数乘一个 0.3 到 1.5 的系数,常用的往上浮,久没人碰的往下沉。

七、记忆还得整理:3 个动作

淘汰解决「谁退场」。剩下的那些,还得收拾一遍。

新记忆进来

淘汰:定谁退场

整理:去重、归纳、归档

top-k 装得下不同的事

我在库里灌了 27 条脏数据:14 条讲同一批 5 件事(「编辑器偏好:neovim」这类不同说法)、8 条零碎经历、5 条一次性信息。

去重合并。 同一组 predicate + object,留最新那条,权重按重复次数往上加,其余标失效。5 组重复标掉 9 条,在册剩 18 条。

归纳提炼。 8 条零碎经历压成 1 条结论(243 字压到 88 字),原文退到证据层。退到证据层的条目检索时不再返回,追问细节时还能调出来。

冷归档。 一次性信息权重降到 0.05,留着但不参与竞争。5 条全降,一条没删。

-- 同组只留最新 1 条
with r as (
    select id, row_number() over (
        partition by user_id,
                     predicate,
                     object
        order by created_at desc,
                 id desc) rn
    from memories where active
)
update memories m set active = false
from r
where m.id = r.id and r.rn > 1;

八、整理之后,top-5 才装得下 5 件事

整理值不值,看检索结果变了没有,省空间是其次。

问「我该用什么编辑器、用什么语言回答、怎么排版」,整理前取 top-5,3 个名额被 neovim 的不同说法占着,剩下 2 个才是别的偏好。5 个名额只覆盖 3 件事。

整理后再问同一个问题,5 个名额对应 5 件不同的事:编辑器、语言、排版、回答顺序、时区。

可检索层从 27 条降到 6 条,全量 28 条一条没物理删除。

整理这件事的收益不在存储成本,在检索质量。 重复条目挤占的是 top-k 的名额,跟硬盘没关系。

九、并发写会安静地丢更新

多轮对话、后台整理、多个入口同时写,同一行被 2 个地方改是常态。

我开 2 个连接读同一行,都读到 vim,然后分别改成 neovim 和 emacs 并提交。结果留下 emacs,版本号 3,2 个事务都成功,没有任何报错。第 1 次修改就这么没了。

解法是加版本号,把更新写成条件更新:

update memories
set object = %s, version = version + 1
where user_id = %s and version = %s;

同样 2 个连接带上版本号更新:第 1 个影响 1 行,第 2 个影响 0 行。0 行就是冲突信号,重读再决定怎么合并。

8 个线程同时抢同一行时,最后只有 1 个写成功,其余 7 个都拿到 0 行。这比 8 个都成功、7 个悄悄被覆盖要好得多。

能察觉的失败,比察觉不到的失败便宜。

十、一条记忆要定的 5 件事

要定的事 定错了会怎样
写什么 只存原文,检索不精准;只存事实,出问题追不回
何时写 全走热路径,记一堆重复;全走冷路径,当下用不上
怎么写 只做 ADD 不做 NONE,库越来越臃肿
何时退 只增不减,噪音盖过有效记忆
何时整理 不整理,重复条目占满 top-k,真答案挤不进去

记忆系统的水平,不体现在存了多少条,体现在它敢不敢让旧的退场,以及退场之后有没有把剩下的收拾干净。