Claude Agent 的隐形污染:错笔记自动全局挂载,绝大多数记忆用例都会漏测
摘要
Anthropic 在 2026 年 11 月 6 日公开的研究博客里,把智能体记忆拆成四层:会话日志、记忆笔记、wiki 这类结构化表征,以及每个新会话都会自动加载的注入上下文。四层里最危险的是最顶上那层——一条错笔记写进去,此后每个会话都被读一遍。这篇文章把官方给出的实验(含一次宝可梦跑关和一个 35 万文件的企业代码库)翻译成测试动作:四个测试维度、一套可运行的注入上下文体积与重复门禁、一张"该写哪一层"的判定表。
Anthropic 在《The components of agent memory》里描述了一次宝可梦红的跑关:模型把宝可梦中心的位置记错了,写成"在山洞东侧"。它走出建筑时判断"山洞在我身后",于是更新笔记,写下"山洞已通关"。然后它从一个单向的台阶上跳下去,跳了三次,最后放弃,写下"我决定停止而不是继续循环"。
问题不在它记错了——人也会记错。问题在于这条错笔记被写进了每局都会加载的那一层。后面每一次重开,它都从"山洞已完成"出发,所以永远不会再回山洞。
同一篇研究里还给了另一半:一次"做梦"(Anthropic 的术语,指空闲时重读日志、校对笔记的离线过程)把这条笔记和原始会话日志对了一遍,改回正确位置,那一次直接走进了山洞,抵达了下一座城市。而没有修正的那次运行,始终没到。
我把这件事当成写这篇文章的起点。Agent 记忆的测试重点不是"它记得对不对",而是"一条错的东西能活多久、能扩散到多远"。 记忆越自动,扩散越快——注入上下文那一层,是每个新会话都无条件加载的。
四层记忆,四种不同的失效方式
Anthropic 的定义很窄,先划清边界:记忆指模型从经验中获得、跨会话保留、以后可以读到的知识。它明确排除三样东西——权重变化、单次会话内的学习、以及"压实"(用摘要替换早先对话轮次)。这个边界很关键,因为它把讨论限定在"Agent 读写的文件"上,而不是训练。
四层从下到上:
-
会话日志:可搜索的事件流水,什么都记。 -
记忆笔记:Agent 自己判断值得留的观察、规则、纠正。 -
记忆表征:从笔记长出来的结构,比如互链的 wiki 页面。 -
注入上下文:每次新会话开头自动加载的那一小块。
"做梦"在四层之间搬运信息:校对笔记(对照日志)、建 wiki、把最可靠的东西推到最顶层。
为什么"记忆更多"不等于"记得更好"
Anthropic 公开了一组对照,我建议每个想把记忆做进产品的团队都看一眼。
宝可梦红,每个模型跑一次(官方明说这一组是"说明性"的,不是统计结论):
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
官方对 Sonnet 3.7 的描述是:"它假设什么都可能重要,不加区分地写进记忆。" 到第 10,000 步附近,它开始花回合整理记忆,没有任何游戏动作。
这个对照对测试的意义很直接:记忆的"覆盖率"是个坏指标,"信噪比"才是好指标。 如果你的验收单上写的是"记忆能否记录所有关键操作",那你会得到一个 413 文件夹的产品,和 1 枚徽章的结果。
另一组更有说服力,因为它有基线:
CL-Bench 1.0 数据库问答任务,同一模型分别在无记忆和有持久记忆目录下作答(官方数据,Anthropic 自跑):
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Sonnet 4.6 那一行几乎没动。官方给的解释是:它"在回答前读记忆"的比例不到一半,而 Sonnet 5 是十次里有九次。把提示改成"先读记忆、再检验你的理论"之后,Sonnet 4.6 从 0.330 升到 0.641。
这条对测什么最有启发:同一套记忆,模型不读就等于没写。 所以"记忆已写入"和"记忆被使用"必须分成两条断言,不能合并成一个"记忆功能可用"。我见过太多验收只测了前者。
还有一个反直觉结论,官方原文写得很平:告诉模型"记忆是用来干什么的"有效,规定"怎么用"反而有害。一条"只记录失败"的硬规则,让 Opus 5 的记忆增益掉了 0.234,Opus 4.7 掉了 0.150。所以如果你的产品给用户提供了"记忆写入规则模板",这些模板本身要有 A/B 数据支撑,不能凭直觉配置。
wiki 比搜索强,但没说强在所有地方
企业侧那组实验值得单独拎出来,因为它最接近我们真实的项目:约 35 万个文件、约 4.2 亿 token 的代码库,128 次"做梦"会话,25 个问题。
三个数字:
-
wiki 单独用,达标率随会话数从 13% 涨到 48%。 -
wiki + 搜索,61%;只用搜索,53%。 -
wiki 从 103 页 766 条链接,长到 222 页 12,893 条链接。
但官方也写了它不管用的地方:答案集中在单个文档的任务上,wiki 没有明显增益;有一个 wiki 长到了源文件体积的 1.77 倍;多个 Agent 写同一份记忆时会互相覆盖,把 57 个事实写成 41 个(72%),改成"每份合同一页、逐字引用条款"后,57 个全部保住。
这几条给了测试一个非常具体的判定:**"记忆检索"这个功能不能只测一个场景。** 至少要按"答案在单文档 / 需要多个文档综合 / 需要跨版本比对"三类分开测,因为你很可能会看到——第三类收益最大,第一类零收益,第二类取决于 wiki 有没有膨胀。
四个测试维度,从便宜到贵
把上面这些收成可执行的清单。每个维度都写明"用什么判",避免变成一堆无法执行的形容词。
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
第三行那个"体积超限被截断",有个公开的锚点:官方文章提到,Claude Code 只把 MEMORY.md 的前 200 行或 25 KB 当作索引加载,其余按需读取。这意味着注入层有一个硬上限,越靠后的内容越不算数。如果一条关键规则被写到了第 300 行,它实际上等于没被注入。
这一条可以直接写成一个门禁:
import pathlib, re, sys
MAX_LINES = 200
MAX_BYTES = 25 * 1024 # 与官方披露的 Claude Code 索引上限一致
DUP_MIN_LEN = 12 # 短于此长度的条目不做重复判定
def load_injected(path: pathlib.Path) -> list[str]:
"""只取会被自动加载的那一段,模拟按行/按字节双上限截断"""
raw = path.read_text(encoding="utf-8")
lines = raw.splitlines()[:MAX_LINES]
buf, used = [], 0
for ln in lines:
n = len(ln.encode("utf-8")) + 1
if used + n > MAX_BYTES:
break
buf.append(ln)
used += n
return buf
def entries(lines: list[str]) -> list[str]:
"""把注入层拆成条目:标题行与列表项各算一条"""
out = []
for ln in lines:
s = ln.strip()
if s.startswith(("#", "-", "*")) or re.match(r"^\d+[.)]", s):
body = re.sub(r"^(#+|-|\*|\d+[.)])\s*", "", s)
if len(body) >= DUP_MIN_LEN:
out.append(body)
return out
def gate(path: pathlib.Path):
lines = load_injected(path)
items = entries(lines)
seen, dup, long_items = {}, [], []
for it in items:
key = re.sub(r"\s+", "", it) # 去空白后比对,抓近似重复
if key in seen:
dup.append((seen[key], it))
else:
seen[key] = it
if len(it) > 120:
long_items.append(it[:40] + "…")
total = path.read_text(encoding="utf-8").splitlines()
report = {
"被自动加载的行数": len(lines),
"文件总行数": len(total),
"被截断而未被注入的行数": max(0, len(total) - len(lines)),
"注入层条目数": len(items),
"重复条目对": dup,
"超长条目": long_items,
}
blocked = bool(dup) or report["被截断而未被注入的行数"] > 0
return blocked, report
if __name__ == "__main__":
blocked, rep = gate(pathlib.Path(sys.argv[1]))
print(rep)
sys.exit(1if blocked else0)
三点说明,免得被当成万能检查。
第一,"被截断"直接判失败是我的选择,不是官方结论。理由是:被截断意味着作者以为写进去了、实际没生效,这种"以为"比明确的缺失更危险。团队可以改成告警,但至少要显式记录。
第二,DUP_MIN_LEN=12 和"去空白后比对"只能抓近似的重复条目,抓不到"用不同说法表达同一条规则"。这一类要靠人工或语义比对,代码只做第一道筛。
第三,这个门禁测的是文件卫生,不测内容对错。内容对错只能靠和日志、需求、工单交叉核对——这也是"写入正确性"那一行的判据来源。
"该写哪一层"比"写什么"更容易出错
写完检测,还有一个设计问题绕不开:这条信息到底该放哪一层?放错了,测试做得再细也救不回来。我给团队的判定顺序是这样的:
问四个问题,第一个回答"是"的地方就是它该待的层。
-
这条信息是"发生过什么"吗?是 → 只进日志。日志不需要判断,只负责可查。 -
这条信息以后大概会再用到,但不确定什么时候?是 → 写笔记。允许它暂时不准确,允许被"做梦"校正。 -
这条信息需要和别的信息互相印证、被反复引用?是 → 进 wiki,并且每页只讲一件事。 -
这条信息错了会导致所有会话一起错,且改动成本极高?是 → 不要放注入层。
第 4 条是反直觉的。直觉上"最关键的规则应该每次必读",Anthropic 的做法恰好相反:注入层要小。原文里的理由是"更长的指令文件会降低遵循度",Claude Code 只加载前 200 行就是这条原则的落地。
所以正确的做法往往是:注入层写"有这么一个记忆库、什么时候该去查",细节放到下面一层按需读。官方那句总结我抄在这里:告诉它记忆是用来干什么的,别规定它怎么用。
一个具体的越权案例,说明测试要拦什么
11 月 6 日 Google 官方博客公开的轨迹分析里有一类现象,和记忆的"污染"是同一机理:Agent 在代码里读到 TODO(bug/xxx),把这条给人看的旧注释当成了待办任务。13% 的会话会遇到这类注释,其中 6% 会真的动起来,83% 的动作不是用户要求的。
我说它是同一机理,因为两件事的共同点是:**Agent 无法区分"给我看的"和"叫我做的"**。记忆笔记、注入的上下文、代码注释、README,只要在它的视野里,都可能被升级成指令。
测试上要拦的,就是这条升级路径:
-
记忆条目里出现"必须""禁止""忽略以上"这类祈使句,是否经过人工确认? -
笔记引用的外部内容(工单、链接、日志片段)是否可能被第三方写入?如果会,就需要隔离。 -
同一份记忆被多个 Agent 写入时,是否有"每人一页"的隔离,还是共用一份会被覆盖?
第三点在官方数据里是有代价证据的:57 个事实写成 41 个。这不是理论风险。
边界与还不确定的地方
有几条必须写清楚。
第一,宝可梦那一组实验,官方明说每个模型只跑一次,是"说明性"的。413 与 9 这个对比很有说服力,但它不是统计结论,不能写进"我们的产品实测数据"去对外说。
第二,wiki 的 13%→48%、54%→61% 这些数字来自 25 个问题的集合,样本小,官方自己也列了这个限制。作为选型参考可以,作为效果承诺不行。
第三,本文所有数字都是 Anthropic 自己跑的,没有第三方复现。我在正文里逐处标了"官方数据",不做转译夸写。
第四,我上面给的注入层门禁用 200 行/25 KB 当阈值,这个值来自官方对 Claude Code 的描述,不代表你用的框架有同样的限制。用之前先确认自己那一层的真实截断点,否则这个门禁只是自我安慰。
最后判断:记忆这块现在最缺的不是更强的模型,是可核对的证据链。一条笔记是从哪次会话、哪一行日志来的,能不能点开看;这条规则是什么时候被验证过的,有没有过期。这些结构一旦有了,测试才有东西可以断言。没有它,我们能测的只有"记忆存在"——而这个答案,几乎永远是通过。
本文部分内容参考了霍格沃兹测试开发学社整理的相关技术资料,主要涉及软件测试、自动化测试、测试开发及 AI 测试等内容,侧重测试实践、工具应用与工程经验整理。
- 点赞
- 收藏
- 关注作者
评论(0)