DeepAgents(十七):记忆也需要管理
记忆也需要管理
记忆写进去只算开头。用得久了,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 个动作
淘汰解决「谁退场」。剩下的那些,还得收拾一遍。
我在库里灌了 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,真答案挤不进去 |
记忆系统的水平,不体现在存了多少条,体现在它敢不敢让旧的退场,以及退场之后有没有把剩下的收拾干净。