给 Agent 加长期记忆:判据只挡噪音,挡不住「一条都不写」

RAG 与记忆系统第 2 / 2 篇

本文的每个数字都是跑出来的。 环境是 Ollama 0.33.2 + qwen2.5:7b-instruct-q4_K_M(本地,离线,不需要 key), 验证代码与那 20 个会话在仓库 experiments/memory/。 ⚠️ 用本地 7B 是为了测机制。凡是形如「N/22」的数只对这个模型这套提示词成立, 但「判据挡不住什么」「记忆挤掉了什么」是结构性的。

给 agent 加长期记忆,难的不是存和取,是决定什么值得记。 写多了污染上下文,写少了等于没有。

stub 给这一篇的判据是:

记忆的价值要减去它占掉的上下文。噪音率高于三成的自动写入不如不写。

跑完之后我得说:这条判据是对的,但它挡不住最容易犯的那个错。

一、先把「噪音」变成能算的东西

要算噪音率,得先能判定一条记忆是不是噪音。两个显然的办法都不行:

  • 让模型判自己写的条目 → 循环论证
  • 我人工判 → 不可复现,而判据要求可复现

所以做法是预先埋点:20 个会话,每个里面埋好「值得长期记住的事实」, 每条配一个特征词。

{ "id": "s01",
  "turns": ["我这个项目用的是 pnpm,不是 npm…",
            "刚才那个报错是端口被占了,我 kill 掉就好了。",
            "对了部署在 doudou.bj.cn 这台机器上。"],
  "worth": [{ "fact": "包管理器用 pnpm", "key": "pnpm" },
            { "fact": "部署在 doudou.bj.cn", "key": "doudou.bj.cn" }] }

判定就变成机械的:写出来的条目含某条 key → 覆盖;不含任何 key → 噪音; worth 里没被覆盖的 → 漏写。20 个会话一共埋了 22 条。

⚠️ 这是一个定义,不是发现。 噪音率取决于我埋了什么。 所以每个会话里的干扰内容刻意写成真的不值得长期记的东西 —— 一次性的调试细节、当场解决的报错、闲聊。 把这些也算成“值得记”的话,噪音率会趋近 0,而那什么都没测。

二、写入时机的比较,被提示词盖过去了

三种写入时机:

  • explicit —— 纯正则(| 分支),只收命中触发词的那几轮(记一下|注意:|约定|以后|别再|不要|必须|重要)。不调模型。
  • endOfSession —— 会话结束时,把整段对话喂给模型抽一次
  • perTurn —— 每一轮各抽一次,最后合并

第一版我只用了一版抽取提示词,跑出来 endOfSession 覆盖 2/22。 正准备写「会话结束时整体抽取效果最差」。

然后换了一版提示词,写入时机一个字没动:

提示词 差别
A 「…没有值得记的就输出「无」。」
B 去掉那个逃生出口,改成「逐条列出…(用户的偏好、项目约定、环境配置)」
写入时机 提示词 条目数 覆盖 噪音率 模型调用
explicit(正则) — 9.0 9/22 0% 0
endOfSession A 2.0 2/22 0% 20
endOfSession B 57.0 21/22 61% 20
perTurn A 16.0 12/22 25% 60
perTurn B 70.0 21/22 70% 60

⇒ 覆盖 2/22 → 21/22,噪音率 0% → 61%,只因为改了一句话。

那句「没有值得记的就输出「无」」看着是个无害的兜底,实际是给模型开了一扇门 —— 它大量选择走那扇门。这张表被提示词主导,写入时机的差别在它面前是次要的。

固定提示词再比时机,才是干净的:

提示词 endOfSession perTurn
A 2/22 · 20 次调用 12/22 · 60 次调用
B 21/22 · 20 次调用 · 噪音 61% 21/22 · 60 次调用 · 噪音 70%

在能把事情办成的那版提示词(B)下,「每轮写」纯亏: 三倍模型调用、更高噪音、覆盖完全相同。

三、判据挡不住「一条都不写」

判据只规定了噪音率的上限。按它筛一遍:

格子 噪音率 覆盖 判据 实际有用吗
endOfSession + A 0% 2/22 ✅ 通过 几乎什么都没记
explicit(正则) 0% 9/22 ✅ 通过 有用,且零模型成本
perTurn + A 25% 12/22 ✅ 通过 有用,但 60 次调用
endOfSession + B 61% 21/22 ❌ 不通过 记得最全,但六成是废条目

🚨 「一条都不写」的噪音率是 0%,它是这个判据下的最优解。

所以判据要补一条覆盖率下限。只有上限没有下限的指标, 总会把「什么都不做」评成满分 —— 这和「跑一遍全绿证明不了任何事」 (沙箱负例那篇)是同一个形状: 一个只能被“不作为”满足的指标,测不出作为的质量。

⭐ 顺带一条值得单独说的:一条正则拿到 9/22,零模型调用、零噪音。 每轮调模型(A)拿到 12/22,代价是 60 次调用和 25% 噪音。 多那 3 条值不值 60 次调用,是个要算的账 —— 而“上模型”这个决定 经常是不算这个账就做了的。

四、矛盾的记忆:时间戳让它更含糊,不是更果断

记忆里放两条直接冲突的(顺序固定:旧的在前、新的在后),问一个答案取决于 听谁的问题。比如:

- 用户偏好用 npm 安装依赖
- 用户改用 pnpm,不要再给 npm 命令
问:我要装一个依赖,给我命令。
条件 听新的 听旧的 两个都提 都没用
不带时间戳 36/60(60%) 0/60 24/60(40%) 0/60
带时间戳 24/60(40%) 0/60 36/60(60%) 0/60

先说好消息:「只听旧的」两档都是 0/60。 它从不单独follow那条过期的说法。

然后是我预期错了的地方。我以为加时间戳([2026-01-05] vs [2026-08-30]) 会帮它选新的。结果相反:「只听新的」从 60% 掉到 40%, 「两条都摆出来」从 40% 涨到 60%。

⭐ 时间戳没让它更果断,让它更含糊了 —— 多给一维信息,它倾向于把两边都陈述一遍。 而「两个都提」在这里不算成功:用户问的是一条命令,给两条互相矛盾的等于没回答。

📌 这个结论跑了两轮不同样本量(5 次/组和 12 次/组),比例完全一致 (60%/40% → 40%/60%),不是抖动。

⚠️ 但这一段有个没分离的混淆项:顺序是固定的(旧前新后)。 所以我没法区分「时间戳效应」和「位置效应」—— 也许它本来就偏向后面那条,而时间戳只是打断了这个偏向。 要分离得把顺序也做成一个维度,本轮没做。

⇒ 实用结论只能给到这一步:冲突消解不要指望模型自己做。 写入的时候就该把旧条目标记为失效或直接删掉,而不是两条都留着靠时间戳提示。

五、记忆挤掉了什么

判据的前半句是「记忆的价值要减去它占掉的上下文」。占多少,是可以直接量的。

每条记忆约 30 字中文:

记忆条数 prompt_tokens 净增量 每条平均
0 34 0 —
5 164 130 26.0
10 306 272 27.2
20 606 572 28.6
40 1206 1172 29.3

但「占掉上下文」只报 token 数的话,「挤掉」还只是个形容词。 把它变成可证伪的:固定 num_ctx=1024,把一个工单号放在对话最开头, 中间垫 12 轮无关问答,最后问那个工单号。

记忆条数 实际 prompt 长度 还答得出开头那个工单号
0 940 5/5
10 1022 0/5
20 985 0/5
30 993 0/5

⭐ 注意第二列:实际 prompt 长度并没有超过 1024,它被截到了 1024 以内。 被截掉的正是最早的那条(工单号),而记忆块(放在 system 位置)活下来了。

⇒ 10 条记忆就足以把开头的事实完全挤掉 —— 而且是全有全无,不是渐进衰减。 10 条 ≈ 272 token ≈ 一个 1024 窗口的 27%。

⚠️ num_ctx=1024 是刻意压小的。这里量的是机制存在, 不是「多少条记忆会出问题」那个阈值。

六、那张表的第一版是 5/5,因为量具没接上

上一节那个实验,第一版三档全是 5/5 —— 看起来「记忆不会挤掉历史」。

我不太信,于是给了一个荒谬的小值去证伪它:如果 num_ctx 真的生效, 传 64 的时候开头那个事实必然答不出来。

num_ctx=  64   /v1  prompt_tokens=940      答对 ✅   ← 选项被忽略
               原生 prompt_eval_count=46    答错(输出一串乱码数字)
num_ctx= 256   /v1  prompt_tokens=940      答对 ✅
               原生 prompt_eval_count=252   答错
num_ctx=4096   /v1  prompt_tokens=940      答对
               原生 prompt_eval_count=940   答对

⇒ /v1/chat/completions 三档的 prompt_tokens 恒为 940 —— num_ctx 根本没生效。请求成功、不报错、选项被静默丢弃。 Ollama 原生 /api/chat 的 options.num_ctx 才认。

⭐ 两条可以带走的:

  1. OpenAI 兼容层会静默吞掉后端专有的选项。 它不报「不支持这个参数」, 它就是不管。凡是通过兼容层传运行时参数的地方,都值得用一个 荒谬值去验证它有没有生效。
  2. 脚本现在把「实际 prompt 长度」打在表里 —— 那一列就是「选项到底生效了没有」的现场证据,不用再去翻探针。

这和上一篇里 「判定用错字段导致 32% 变 17%」是同一类错:表格好看,而它什么都没测。


本文没有回答的三个问题:

一是 顺序与时间戳的分离。第四节的顺序固定为「旧前新后」, 所以那个「时间戳让它更含糊」的结论里混着位置效应。 要分离得把顺序也做成一个维度 —— 这是本文最该被补上的一个实验。

二是 记忆的检索。全篇假设所有记忆都被无条件注入。 真实系统会先检索再注入,那时噪音的代价会小很多 —— 而检索本身的召回率是上一篇的题目。

三是 三类记忆的生命周期。stub 里列了「事实 / 偏好 / 任务状态」 各自的过期方式,本文只测了「冲突时听谁的」,没测「什么时候该删」。 那需要跨会话的时间线,本轮的 20 个会话都是单会话。