给 Agent 加长期记忆:判据只挡噪音,挡不住「一条都不写」
本文的每个数字都是跑出来的。 环境是 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 才认。
⭐ 两条可以带走的:
- OpenAI 兼容层会静默吞掉后端专有的选项。 它不报「不支持这个参数」, 它就是不管。凡是通过兼容层传运行时参数的地方,都值得用一个 荒谬值去验证它有没有生效。
- 脚本现在把「实际 prompt 长度」打在表里 —— 那一列就是「选项到底生效了没有」的现场证据,不用再去翻探针。
这和上一篇里 「判定用错字段导致 32% 变 17%」是同一类错:表格好看,而它什么都没测。
本文没有回答的三个问题:
一是 顺序与时间戳的分离。第四节的顺序固定为「旧前新后」, 所以那个「时间戳让它更含糊」的结论里混着位置效应。 要分离得把顺序也做成一个维度 —— 这是本文最该被补上的一个实验。
二是 记忆的检索。全篇假设所有记忆都被无条件注入。 真实系统会先检索再注入,那时噪音的代价会小很多 —— 而检索本身的召回率是上一篇的题目。
三是 三类记忆的生命周期。stub 里列了「事实 / 偏好 / 任务状态」 各自的过期方式,本文只测了「冲突时听谁的」,没测「什么时候该删」。 那需要跨会话的时间线,本轮的 20 个会话都是单会话。