我的 Agent 把老约定全忘在上一轮:给它外挂了个零依赖的检索式记忆库
起因:那些规矩我每天在 prompt 里重贴一遍
我有个每天自动发一篇技术博客的 Agent。它要守的规矩不少:隐私红线、口吻必须第一人称、每天最多一篇、推广链接不许删、自检脚本怎么跑、站点慢的时候别死等……前前后后攒了 24 条。
这些规矩现在全靠我在触发 prompt 里原封不动重贴一遍,940 个字,每次都进去。而真正常用到的往往只有两三条,剩下的是纯死重量。
更早的时候我给上下文装过一层预算器,快满就滚动摘要、把旧轮次丢掉。丢了之后问题来了:丢掉的东西第二天照样要用。
所以我想的是把它外置成记忆库——写进去,用的时候按当前问题检索回三五条。目标很具体:不引第三方依赖,能在我这台机器上离线跑,检索错了我要能一眼看出为什么错。
第一次尝试:把最近 N 条塞回去,等于没塞
我先试了最懒的做法:库按时间排序,取最近 N 条塞进上下文。我造了 14 条查询,每条标注了它该命中的那条记忆,跑出来是这样:
[1] 朴素裁剪(普通查询组 14 条)
recency top3 P@1= 0.0% R@3= 0.0% MRR=0.000
recency top5 P@1= 0.0% R@3= 0.0% MRR=0.000
recency top8 P@1= 7.1% R@3= 14.3% MRR=0.107
recency top24 P@1= 7.1% R@3= 14.3% MRR=0.189全塞进去(top24)也只有 7.1%,因为就算 24 条都在上下文里,模型也未必挑得对那条。而按时间取recent 的逻辑本身就不成立——我要查的「无线网卡插哪个槽」,跟它是哪天记下来的没有任何关系。
那就得检索。
BM25 装上了,第一版只有 64%
BM25 是老算法,三十年了还在用,我选它的理由是零依赖、可解释、出错能查。参数 k1 和 b 的常规取值我查过(核对日期 2026-10-08):Manning 等人建议 k1 ∈ [1.2, 2.0]、b = 0.75,Lucene / OpenSearch 的默认就是 1.2 和 0.75,我直接用默认值,没调。
中文分词用单字 + bigram,英文按词,再过一遍停用词表。核心就这么点:
function tokenize(s) {
const out = [];
for (const w of (s.toLowerCase().match(/[a-z0-9][a-z0-9._-]*/g) || [])) out.push(w);
const zh = (s.match(/[\u4e00-\u9fa5]+/g) || []).join('');
for (let i = 0; i < zh.length; i++) {
const c = zh[i];
if (!STOP.has(c)) out.push(c);
if (i + 1 < zh.length && !STOP.has(zh[i]) && !STOP.has(zh[i + 1])) out.push(zh.slice(i, i + 2));
}
return out;
}
function bm25(idx, q, di, k1 = 1.2, b = 0.75, tk = tokenizeQuery) {
const m = idx.tf[di];
let s = 0;
for (const w of new Set(tk(q))) {
const f = m.get(w);
if (!f) continue;
s += (idx.idf.get(w) || 0) * (f * (k1 + 1)) / (f + k1 * (1 - b + b * idx.dl[di] / idx.avgdl));
}
return s;
}第一版跑完,P@1 = 64.3%,R@3 = 100%。R@3 已经满了,说明方向对,只是 top1 老挑错。我把错的那几条打出来看:
[诊断] BM25 top1 错在哪
「PowerShell 命令没有输出」 gold#9 排第3,top1 是 #11
gold: PowerShell 回显不稳,结果要 Set-Content 写文件再用读工具读回来
top1: 无头 Chrome 截图会被环境杀进程,必须加 user-data-dir 和绝对输出路径一条八竿子打不着的记忆,凭什么赢了?我把打分摊开:
query tokens: powershell | 命 | 命令 | 令 | 令没 | 没 | 输 | 输出 | 出
7.00 #11 无头 Chrome 截图会被环境杀进程,必须加 user-data-dir 和绝对输出路径
2.92 #9 PowerShell 回显不稳,结果要 Set-Content 写文件再用读工具读回来「输出」被我的分词拆成了 输、输出、出 三个词,#11 里正好有个「输出路径」,三个词一起命中,加起来 7 分。而 #9 只命中一个 powershell,虽然这个词很稀有、IDF 很高,一个词顶不过三个词叠加。
这是我自己的分词埋的坑:bigram 是在给同一个语义重复计分。修法是查询侧做最长匹配去重——bigram 命中之后,就把组成它的两个单字丢掉:
function tokenizeQuery(s) {
const t = tokenize(s);
const covered = new Set();
for (const x of t) if (x.length === 2 && /[\u4e00-\u9fa5]/.test(x)) { covered.add(x[0]); covered.add(x[1]); }
return t.filter(x => !(x.length === 1 && /[\u4e00-\u9fa5]/.test(x) && covered.has(x)));
}只改这一处,同一个测试集:
分词组合扫描(普通查询组 / 冲突查询组)
doc uni+bi / q uni+bi 普通 P@1= 64.3% MRR=0.798 | 冲突 P@1= 60.0% MRR=0.717
doc uni+bi / q 只单字 普通 P@1= 50.0% MRR=0.726 | 冲突 P@1= 40.0% MRR=0.617
doc uni+bi / q 最长去重 普通 P@1=100.0% MRR=1.000 | 冲突 P@1= 60.0% MRR=0.76764.3% → 100%。我顺手也试了「查询只留单字」,反而掉到 50%,说明 bigram 该留,只是不能和单字重复算。
我以为是标配的时间衰减,被我判了死刑
记忆库嘛,我本能地觉得该有时间衰减——越老越不值钱,半衰期一乘就完事。扫了一遍半衰期:
[3] 加统一时间衰减(语料 24 条,普通查询组)
hl=3天 P@1= 57.1% R@3=100.0% MRR=0.774
hl=7天 P@1= 64.3% R@3=100.0% MRR=0.821
hl=14天 P@1= 71.4% R@3=100.0% MRR=0.857
hl=30天 P@1= 78.6% R@3=100.0% MRR=0.893
hl=不衰减 P@1=100.0% R@3=100.0% MRR=1.000单调下降,衰减越强越差,最好的是完全不衰减。
一开始我以为是我没区分类型——环境事实(IP 段、命令被禁用)不会变,偏好和流程(口吻、发布节奏)才会变。我改成只衰减 pref 类、fact 类不衰减,结果更差,只剩一条对比证据:
[7] 类型区分衰减 vs 统一衰减,hl=14
「Agent 反复做同一件事停不下来」 gold#18(pref) 统一衰减第2 -> 类型衰减第4后来我想明白问题不在参数,在我这个测试集本身:库里每件事只说过一次。时间衰减解决的是「同一件事被说过两次、该听哪次」,不是「这条老不老」。在没有冲突的库里加衰减,等于凭空给正确记忆扣分。
想通之后我补了 5 条「陈旧记忆」——同一主题的旧说法,比如 8 月记的「429 就固定等 1 秒重试三次」,9 月改成了「要带抖动退避」。旧的那条我没删,留在库里,于是同一件事有了两个版本。再跑冲突查询组:
[4] 塞回 5 条陈旧记忆(29 条),冲突查询组
无衰减 P@1= 60.0% R@3=100.0% MRR=0.767
hl=3天 P@1= 80.0% R@3=100.0% MRR=0.900
hl=7天 P@1= 60.0% R@3=100.0% MRR=0.800
hl=14天 P@1= 60.0% R@3=100.0% MRR=0.767
hl=30天 P@1= 60.0% R@3=100.0% MRR=0.733
[5] top1 命中旧版本的条数(越小越好)
不衰减 2/5 hl=3天 0/5 hl=7天 1/5 hl=14天 1/5 hl=30天 2/5衰减终于有用了:不衰减时有 2/5 会把旧版本捞到第一,hl=3 天能压到 0。但代价是普通查询组从 100% 掉到 57.1%。同一个半衰期没法同时伺候两组——压得住旧版本的强度,也把单纯的老记忆一起压死了。
换个思路:写入时合并,而不是检索时衰减
既然冲突的根源是「同一件事存了两份」,那就在写入的时候解决,别留给检索去猜。新条目进来先跟库里比一遍,超过阈值就认定是同一件事的更新版,旧条目标成 superseded,默认不参与检索。
同一批冲突查询,不用再调任何参数:
[6] 写入时按主题合并(语料回到 24 条)
merge-only 冲突组 P@1=100.0% R@3=100.0% MRR=1.000
merge + hl30 冲突组 P@1= 80.0% R@3=100.0% MRR=0.900
同一批普通查询组:
merge-only 普通组 P@1=100.0% R@3=100.0% MRR=1.000两组都是 100%,而且是在完全没有时间衰减的前提下。加了 hl=30 反而掉到 80%——又一次证明衰减在这个场景里是负资产。
写入侧的账:
[9] 写入 10 条,其中 4 条是已有记忆的复述(阈值:BM25>=4 且词重叠率>=0.5)
新增 6 条 / 合并 4 条;库 24 -> 30
「发布文章前必须先跑一遍隐私扫描,…」 命中 #1 score=35.0
「文章口吻要第一人称,不要用讲师体…」 命中 #4 score=15.7
「每天最多发一篇…」 命中 #5 score=14.2
「撞 429 要带抖动退避…」 命中 #16 score=17.54 条复述全被正确合并,6 条新事实正常入库。库从 24 涨到 30 而不是 34。
上下文占用这块,全量 940 字对比检索 top3 的 99 字,省了 89.4%。省下的不是 token 数本身,是注意力——24 条里只留 3 条相关的,模型挑错的概率也跟着降了。
我的判断
先说这套东西的边界:24 条记忆和 19 条查询都是我自己造的测试集,不是真实对话日志。100% 这个数字只在这 24 条上成立,我不认为它能在几千条的库上复现。它证明的是「这几个设计选择之间没有互相拖后腿」,不是「记忆问题解决了」。
几个我自己踩出来的结论:
别急着上向量检索。 我这个库全是短句、术语密集、查询和记忆用词高度接近,BM25 完全够。向量要引入模型和依赖,出错还查不出原因。什么时候该换?等我发现「查询和记忆用词完全不重叠」的比例高到不可接受再说——目前 14 条里只有 3 条属于这类,而且它们都进了 top3。
时间衰减是给冲突兜底的,不是给时间兜底的。 库里每件事只存一份的时候加衰减,纯粹是自残。我现在的做法是写入时合并优先,衰减干脆不装。
合并阈值是这套东西最大的风险点。 BM25 ≥ 4 且词重叠率 ≥ 0.5 是我拍的,只在 10 条样本上验过。误合并会直接吞掉一条事实,而且吞得很安静。所以旧条目我只标 superseded,不删,真出事了能翻回来。
最值钱的那次改动跟算法无关。 从 64.3% 到 100% 靠的是把错误案例一条条打出来看、发现「输出」被拆成三个词重复计分。调 k1、调 b、换分词粒度我全试过,都是零收益。以后再遇到检索挑错,我的第一步一定是打三条错误案例出来看,不是调参。
代码就一个文件,零依赖,node daily/agent-memory-eval.js 直接跑,输出就是上面那些数字。