不报错的失败,才是最难治的
上一篇给了任务级的恢复协议:崩溃可见、损失可算、状态可对账。但记忆系统还有一类失败从头到尾不给信号:库不崩、写入不报错、进程活得好好的,回答却一周比一周差——用户口中的"越学越笨"。它没有抛出任何异常,唯一的观测手段是主动装探针。本篇先在一个确定性模拟里把退化复现出来(照例:本机用固定种子模拟,生产要接真实模型与真实记忆库),看清它为什么"单看每一步都对、整体却在烂";再回答观测工程的两问:探针要多大才抓得住退化,警报响几次才算真事。
退化的解剖:没有一条写入是坏人
先把病灶拆开。长期记忆的效果退化大致三条路径。过期堆积:世界在变,话题的真值从 v1 改到 v2,但 v1 时代的条目还躺在库里,而且条数比 v2 多。污染:抽取器或模型本身偶发幻觉,写进当时就错的事实,写入侧无法自知。检索失配:库变大后干扰项变密,第 491 篇算过这道账。三条路径有个共同根源:写入是逐条决策,质量是聚合性质。每条过期条目在写下那一刻都为真,每条幻觉也从未在写入时报警——所以任何基于异常日志的监控都抓不到它,能抓它的只有统计面板。还有一个反直觉的推论:越勤奋的 Agent 退化越快,因为它写得越多,过期证据的票数涨得也越多。"越学越笨"不是玄学,是多数票制在时间轴上的必然。
实验一:库在变大,准确率在掉
模拟 12 个话题,真值在 100 步里共变更 28 次;Agent 每步写一条观察(12% 是幻觉值),检索按相似度取 top-5 后多数票回答。另设一条治理线:每 20 步做一次合并退役——每个话题只留最近 3 条的多数值。两条线同种子同写入流,每 20 步用全部话题做一轮探针:
importrandom TOPICS=12N=100rng=random.Random(496)CHANGES={t:sorted(rng.sample(range(6,N+1),rng.randint(1,3)))fortinrange(TOPICS)}deftruth(t,s):return1+sum(1forcinCHANGES[t]ifc<=s)defwrite_batch(entries,s0,s1):forsinrange(s0,s1+1):t=rng.randrange(TOPICS)v=truth(t,s)ifrng.random()<0.12:v+=10# 幻觉值: 写入时无法自知entries.append([t,s,v,rng.uniform(0.55,0.95)])defanswer(entries,t):cand=sorted([eforeinentriesife[0]==t],key=lambdae:-e[3])[:5]ifnotcand:returnNonetally={}foreincand:tally[e[2]]=tally.get(e[2],0)+1returnmax(tally.items(),key=lambdakv:(kv[1],max(e[1]foreincandife[2]==kv[0])))[0]defevaluate(entries,s):good=0fortinrange(TOPICS):a=answer(entries,t)ifaisNoneora==truth(t,s):good+=1returngood/TOPICSdefconsolidate(entries):out=[]fortinrange(TOPICS):cand=sorted([eforeinentriesife[0]==t],key=lambdae:-e[1])[:3]ifnotcand:continuetally={}foreincand:tally[e[2]]=tally.get(e[2],0)+1v=max(tally.items(),key=lambdakv:(kv[1],max(e[1]foreincandife[2]==kv[0])))[0]out.append([t,cand[0][1],v,max(e[3]foreincand)])returnout raw,gov=[],[]print("话题真值变更(固定种子): %d 个话题, 共 %d 次变更, 100 步写入"%(TOPICS,sum(len(v)forvinCHANGES.values())))print()print("步 原库条数 无治理准确率 治理后条数 治理后准确率")fors0inrange(1,N+1,20):s1=min(s0+19,N)write_batch(raw,s0,s1)write_batch(gov,s0,s1)gov=consolidate(gov)print("%4d %9d %11.3f %12d %11.3f"%(s1,len(raw),evaluate(raw,s1),len(gov),evaluate(gov,s1)))print()print("无治理线: 每条写入在写下那一刻都是真的, 退化是聚合性质——")print(" 过期条目单条看都曾经为真, 它们在 top-k 里抱团, 把新事实淹没了。")print("治理线: 合并退役把库压回常数量级; 残余误差来自幻觉写进多数票——")print(" 合并治不了污染, 所以写入侧还要一块独立的审计面板。")运行输出:
话题真值变更(固定种子): 12 个话题, 共 28 次变更, 100 步写入 步 原库条数 无治理准确率 治理后条数 治理后准确率 20 20 0.917 9 0.917 40 40 0.500 12 0.667 60 60 0.250 12 0.833 80 80 0.417 12 0.667 100 100 0.333 12 0.667 无治理线: 每条写入在写下那一刻都是真的, 退化是聚合性质—— 过期条目单条看都曾经为真, 它们在 top-k 里抱团, 把新事实淹没了。 治理线: 合并退役把库压回常数量级; 残余误差来自幻觉写进多数票—— 合并治不了污染, 所以写入侧还要一块独立的审计面板。看无治理那一列:0.917 起步,100 步后只剩 0.333,中间不是平滑下坡而是台阶——每次话题真值变更后先跌一段,旧票仓继续增厚就再跌一段。曲线不单调刺眼的地方(第 80 步反弹到 0.417)恰恰提醒我们:小样本探针天然带噪声,单日涨跌说明不了任何事,这是下一节要算的账。治理线用"最近 3 条多数+退役其余"把 100 条压回 12 条,准确率抬到约两倍——这部分涨回来的是过期堆积的债。但注意它没有回到 0.917:剩下丢的分是 12% 幻觉写混进了"最近 3 条"的多数票,合并和退役对它完全无效。这条分裂的账决定了面板的拓扑:治过期的药(合并、valid_to 关闭旧区间、第 495 篇的墓碑)治不了污染;治污染的药在写入侧——来源标注、交叉核验、低置信进隔离区而不是直接入库。
三块面板:写入侧、检索侧、结果侧
观测面板按记忆流水的三段摆。写入侧看流量与成分:各写入者的条数分布、重复率、来源缺失率、隔离区占比——污染在这里现形,且唯一能在这里拦住。检索侧摆金标准探针:预先标注的"问题→应命中的记忆/应给出的答案",按场景切成切片(日程类、偏好类、事实更新类……),每天全库跑一遍——过期堆积和检索失配在这里现形。结果侧看端到端任务成功率与用户信号:它是终审,但太迟钝,只能裁决不能定位。三块面板的读数组合就是病灶坐标:写入侧恶化加检索侧掉分,是源头污染穿了全链路;写入侧平稳、只有某个切片掉分,是检索或索引退化;只掉在"事实更新类"切片,八成是旧条目没关区间。缺任何一块,诊断都退化成猜。
实验二:探针要多大,才配叫探针
面板摆在哪里、摆多大,是可以算的。设一次典型退化:60 题探针里只有 20 题的困难切片变坏(单题通过率 0.58→0.33),全局均值因此只掉 5 个百分点;用户日查询命中病灶的概率 8%。对比五种监测手段,报警阈值统一校准到误报率 ≤5%:
importrandomfrommathimportcomb P0,P1=0.58,0.33# 困难题切片: 退化事件前后的单题通过率G0,G1=0.743,0.693# 全局 60 题均值(困难片退化被 40 道无关题摊薄)USER_HIT=0.08# 用户日查询命中病灶片的概率defcdf(n,p,k):returnsum(comb(n,i)*p**i*(1-p)**(n-i)foriinrange(k))defcalib(n,p0,alpha=0.05):k=1whilecdf(n,p0,k+1)<=alpha:k+=1returnk# 报警条件: 通过题数 < kprint("退化事件: 困难题切片(20/60)通过率 %s -> %s"%(P0,P1))print("全局均值只从 %s 掉到 %s; 用户日命中率 %s。"%(G0,G1,USER_HIT))print("各手段的报警阈值都校准到误报率<=5% 再比, 谁也不占便宜。")print()print("手段 误报率 单次检出率 期望检出天数 检出前期望抱怨")defrow(name,n,p0,p1,confirm=1):k=calib(n,p0)fa=cdf(n,p0,k)det=cdf(n,p1,k)days=1/detifconfirm==1else(1+det)/det**2print("%-18s %7.3f %10.3f %12.1f %14.2f"%(name,fa,det,days,USER_HIT*days))row("全局 60 题",60,G0,G1)row("困难片 8 题",8,P0,P1)row("困难片 12 题",12,P0,P1)row("困难片 20 题",20,P0,P1)row("困难片 20 题连跑",20,P0,P1,2)rng=random.Random(4963)lag=[]for_inrange(4000):d=1whilerng.random()>=USER_HIT:d+=1lag.append(d)lag.sort()print("%-18s %7s %10s %11d天 %13s"%("无探针, 等抱怨","-","-",lag[2000],"1 次(定义)"))print()print("同一次退化: 全局面板单次检出只有 0.19, 且检出了也不知道跌在哪个切片。")print("满配 20 题切片单次 0.67 已是全场最快; 金标准是连跑两天, 误报压到 0.032^2≈0.001,")print(" 代价只慢约 2 天。8 题小套件被离散阈值卡死: 守住误报, 检出就塌到 0.20。")print("没有探针, 用户就是探针: 中位 9 天, 且抱怨非结构化、不可复现、定位不了切片。")运行输出:
退化事件: 困难题切片(20/60)通过率 0.58 -> 0.33 全局均值只从 0.743 掉到 0.693; 用户日命中率 0.08。 各手段的报警阈值都校准到误报率<=5% 再比, 谁也不占便宜。 手段 误报率 单次检出率 期望检出天数 检出前期望抱怨 全局 60 题 0.040 0.193 5.2 0.41 困难片 8 题 0.012 0.201 5.0 0.40 困难片 12 题 0.022 0.403 2.5 0.20 困难片 20 题 0.032 0.673 1.5 0.12 困难片 20 题连跑 0.032 0.673 3.7 0.30 无探针, 等抱怨 - - 9天 1 次(定义) 同一次退化: 全局面板单次检出只有 0.19, 且检出了也不知道跌在哪个切片。 满配 20 题切片单次 0.67 已是全场最快; 金标准是连跑两天, 误报压到 0.032^2≈0.001, 代价只慢约 2 天。8 题小套件被离散阈值卡死: 守住误报, 检出就塌到 0.20。 没有探针, 用户就是探针: 中位 9 天, 且抱怨非结构化、不可复现、定位不了切片。三个工程结论。一,切片即检出力:同一次退化,全局面板单次只能抓 19%,病灶切片满配能抓 67%——不看切片,一次影响两成用户的退化在均值曲线上只是一次普通抖动。二,套件大小买的是速度:8 题守不住 5% 误报纪律时检出塌到 0.20(二项分布的离散阈值在小样本上根本没有精细档位可选),12 题 0.40,20 题 0.67;每个在意的场景切片的金标准题量至少 15 到 20 题。三,报警是两拍不是一拍:单次越阈值约 3% 概率是噪声路过,“连续两天越阈值才立案"把误报压到千分之一以下,代价不过两天;连跑规则下 20 题片期望 3.7 天、期望抱怨 0.3 次仍然立案,而等用户抱怨要中位 9 天且只有一声不可复现的"它变笨了”。
金标准探针的维护纪律
探针自己也是记忆,得按第 495 篇的规矩治理:每题记录"问题、期望命中条目、判定规则、绑定的库版本",跑在快照上而不是活库上,否则库一边改探针一边掉分,永远说不清是谁的锅。真值会漂——偏好类探针的预期答案要随确认过的更新走版本,过期未复核的探针降权而不是硬算。每次套件增删题都要重新校准阈值(实验二的数字全部依赖题量)。最后是节奏:探针日跑、结果与当日记忆库快照版本号一起归档,退化立案时先冻结快照再动手排查——删除可疑条目之前不留现场,等于毁掉案发现场还常常错杀墓碑。
常见陷阱
一是只看全局均值做看板:摊薄之后什么都是"正常波动"。二是把单日下跌当罪证、又把连续三天平稳当无罪:阈值不校准、不看连跑,误报和漏报一起来。三是只有检索侧探针没有写入侧审计:掉分看得到、污染看不见,治理全靠事后合并,而实验一证明了事后合并治不了污染。四是探针套件半年不换、跑在活库上:系统慢慢过拟合到题面,面板从警报器变成应试分数。五是退化发生后直接热修库:不冻结快照、不分项消融,修完既证明不了修好了、也说不清坏在哪一环。
落地清单
- 三块面板:写入侧(污染率/重复率/写入者分布)、检索侧(分切片金标准探针)、结果侧(端到端成功率)
- 每个关键场景切片 15~20 题起步;报警阈值按二项分布校准到误报 ≤5%,连续两天越阈值才立案
- 探针跑在带版本号的库快照上,结果与快照一同归档;套件增删即重校准阈值
- 治理线(合并退役)与原库并行评估,两线差值就是治理的可见 ROI
- 立案后流程:冻结快照 → 按切片归位病灶 → 写入/检索/注入逐项消融 → 修复回归验证
观测讲完了,十篇的机制就全齐了:预算、压缩、双路径存储、检索与注入、隔离、工具调度、计划状态机、恢复治理、效果观测。下一篇是收官实战:把这些件装进同一个个人助理 Agent,跑通端到端,再用逐项消融表量出每一层的真实贡献。
参考来源
- Wikipedia, Memory consolidation:https://en.wikipedia.org/wiki/Memory_consolidation
- Wikipedia, False positives and false negatives:https://en.wikipedia.org/wiki/False_positives_and_false_negatives
- Wikipedia, Sample size determination:https://en.wikipedia.org/wiki/Sample_size_determination
- Packer et al., MemGPT: Towards LLMs as Operating Systems:https://arxiv.org/abs/2310.08560
- Letta (formerly MemGPT) 项目文档:https://github.com/letta-ai/letta