Keybo Blog

Keybo 博客

每天一点新鲜事

LLM 记性差,这位漏洞研究员不给它加记忆了,改给它记账

跑了几个小时的 agent,开始忘事

今天刷 HN,一篇标题很怪的长文挂在首页上。作者 Jordy Zomer 是安全研究员,平时干漏洞挖掘,前几个月一直拿 LLM agent 干活,顺手把这段折腾写了下来。我把原文从头到尾读了一遍,这可能是最近关于 agent 记忆最实在的一篇第一手材料。它有数字,有翻车,还有作者自己泼的冷水。

他碰到的问题,用过 agent 干活的人大概都眼熟。模型在导航大型代码库、解释陌生子系统这些事上已经很好用了,可一旦调查持续几个小时,它就开始丢东西。它会重新建议一个早就被排除的方案,会忘掉某个假设已经被证伪,还会基于一句过时的观察继续自信地推理。你告诉它哪里错了也没用,它嘴上认,可所有建立在这个错误上的结论还原封不动地待在那儿。

他举了自己调查里的一个例子。一开始确认了三件事,攻击者可控 object_a,object_a 指向 object_b,object_b 是个内核对象。由此能推出,攻击者可以控制内核对象。两小时后在调试器里一看,object_a 其实不指向 object_b,前面那条观察基于一个错误假设。

这时候,常见的记忆方案里会躺着三条互相打架的记录。指向关系本身,由它推出的结论,还有一条「指向关系是错的」。检索方案把这一小撮碎片端给模型,指望它自己想明白哪条还作数。

「记得说过什么」和「知道现在什么为真」

Zomer 把这个别扭归结成两个词的区别。remember 是记得我们说过什么,maintain 是维护住我们当前知道什么。他要的是后面这个。

给 LLM 加记忆,大家默认的做法是把旧对话和旧观察存起来,做向量,需要时检索最相关的几条。他对这条路有句原话,挺狠的,「cosine vibe similarity and truth are not quite the same thing」,余弦氛围相似度和真理不是一回事。向量库能把「object_a 指向 object_b」检索出来,因为它跟眼前的问题相关。可它不知道这条记录两小时前已经被证伪,更不知道有五个结论挂在它上面,全该跟着作废。

他由此把「记忆」拆成了两个问题。第一个,过去的信息里哪些跟当前问题相关,这是检索问题,向量数据库干得很好。第二个,综合到目前为止学到的一切,现在什么为真,这是状态维护问题。过去一年大家挤在第一个问题里卷,他要解的是第二个。

解法来自他的老本行。他日常做程序分析,一堆输入事实,一组推导规则,算到不动点为止。某条输入变了,就增量更新受影响的结果,不重算全局。他意识到这就是他想要的东西,「为什么我们一直让 LLM 一遍遍重建自己的状态,直接维护它不就行了」。于是他给 LLM 写了一个 Datalog 引擎。

模糊的活归 LLM,确定的活归数据库

成品开源,叫 Lemmalog,分工一句话说得清。LLM 负责读自然语言、源码、调试器输出这些乱七八糟的信息,把它们抽成结构化事实,比如 freed(object_a) 和 reused_as(object_a, write_target)。这一步模型本来就擅长。事实一旦进了库,后面的推导、作废、溯源全由 Datalog 引擎确定性地算,不再经过模型。

他自己打了个比方,整个系统像一台古怪的编译器。LLM 是前端,吃进源码、调试输出和自然语言笔记,吐出结构化事实。Lemmalog 是中间表示加分析引擎。最后再来一次 LLM 调用,把状态翻译回人话,或者建议下一步实验。他还补了一句,我们的解析器是概率性的,但解析器后面的一切不必是。

删事实比加事实难多了

往库里加事实容易,删事实难。他先碰到的是撤回。假设结论 c 有两条推导路径,a 能推出它,b 也能。删掉 a,c 不能删,b 还撑着。a 和 b 都删了,c 才跟着消失。这在漏洞研究里是真需求,一个「candidate_3 可利用」的结论可能有好几条独立的利用路径撑着,其中一条走不通,结论不该倒。所以引擎得记住每个事实是怎么推出来的,输入一变,跟着更新这些支撑。

记依赖带来一个副产品,可以回答「你为什么信这个」。agent 跑了几个小时,宣布 candidate_3 可利用,你可以向引擎要一棵完整的推导树,一路追到具体的 observation_41 和 rule_12。这治好了他碰上的一种烦人失败,模型自信地说「我们早就确认过这个指针是攻击者可控的」,可根本没有这回事。现在查一下来源就知道,没有出处的结论进不了维护状态,幻觉很难再静默混进调查。

还有一处是时间。事实更新和事实删除是两码事。他先相信「primitive_a 可行」,过一阵发现不可行。旧的信念不该直接删掉,当初为什么会去试那条路,这本身就是有用的历史。Lemmalog 给事实挂上有效区间,写出来大概长这样,viable(primitive_a) [10:14, 12:37)。这样「现在可行吗」和「当时为什么觉得可行」都答得上来,不用留两条打架的记录让模型猜。

成绩单

架构说完了,得看分数。他接进 MemEval 评测框架,用官方标准化的读取模型和评分配置,在两个基准上各跑了三遍。抽取阶段用 Claude Sonnet 4.6,按对话只付一次费,抽取之后的答题和评判全用基准自带的标准模型。这个设置值得说一句,他没有自己挑模型刷分。

LongMemEval · 102 题 · 3 次运行:F1 0.463 ± 0.010,准确率 0.575 ± 0.004。 对比已发表成绩,PropMem 0.550,SimpleMem 0.480,OpenClaw 0.244,全量上下文 0.222。他自己用 GPT-4.1 跑全量上下文,0.197。没打赢前两名,但比把整段对话塞进上下文高出一倍多。
LoCoMo · 10 段长对话 · 1986 题 · 3 次运行:F1 0.533 ± 0.001。 专用记忆系统里排第三,PropMem 0.605,OpenClaw 0.557。把「全量塞进提示词」也算作一种记忆系统的话,排第四,0.542。三次运行几乎一模一样,不像运气。

分项里最扎眼的是知识更新这一类。题目考的正是「先信 A,后来发现 A 不成立,现在该信什么」,Lemmalog 拿了 0.579,是已发表成绩里的第一,PropMem 0.528,全量上下文只有 0.202。这恰好是他做这套东西最初想解的问题,赢在这里算名正言顺。

对抗性题目也有看头。LoCoMo 里有一类问题故意带错误前提,对话里写的是某人收了礼物,题目偏把这份礼物安到别人头上。塞满对话记录的模型容易被语义相似的故事带偏,找到一段像的就答了。结构化记忆查不到相关事实,干脆回答没有这回事。这一类他拿了 0.707,全量上下文 0.509。他开玩笑说,「no」原来是个很有用的答案。

最好看的是三个翻车现场

分数之外的部分才是这篇最好看的。LongMemEval 一度从正常水平掉到 0.371,他把失败案例一条条翻出来,发现 102 题里有 32 题被模型拒答了,而且这 32 题全都能答。

元凶是他自己加的防幻觉指令。他告诉读取模型,回答必须有检索到的事实支撑。模型理解成了,没有任何一条事实字面上包含最终答案,那就拒答。可「我坐哪家航空最多」这种题,答案得靠数出来,瑞航两次,汉莎一次,库里不会有哪条事实直接写着「最常用瑞航」。修法是把两种情况分开,前提缺失或者张冠李戴,拒答;证据都在、只是要数一数比一比拼一拼,那就真去推理。改完,F1 回到 0.429。

剩下的差距藏得更深。计数行在交给模型之前要过一道相关性过滤器,过滤器用的复数词干器只处理超过四个字母的词,于是 owns 永远匹配不上 own,所有计数行被静默丢掉。计数类的题从头到尾拿不到任何数字。他把词干器修了,让计数和它数的对象一起出现,再把日期运算预先算好,不指望模型自己减两个日期。这几刀下去,F1 到了 0.463。

LoCoMo 上的时间推理从 0.257 跳到 0.454,bug 更有喜剧效果。他一度把日期当成 Datalog 符号来比较,而引擎对符号比大小,比的是内部编号。内部编号当然不是日期。把抽取出来的日期规范化成可比较的整数,再从真实时间戳推先后,时间推理一口气涨了近二十个 F1 点。

还有个实体对齐的例子。会话一说「我买了辆本田思域」,会话三说「我的车坏了」,会话七说「思域终于修好了」。抽取要是产出三个不同的实体,后面的引擎再聪明,也是在对着三辆不同的车推理。他后来加了一道归并,把各处提到的东西对到同一个规范实体上。检索也从纯词法换成 BM25、实体图和向量混着用,问「厨房小家电」才找得到 Instant Pot。

他把这轮提升逐条归了因。第一版标准化配置 0.226,现在 0.463,翻了一倍多。实体没对上,日期存成了不能比较的样子,检索漏掉说法不同的同一件事,聚合明明做了却没露给模型,词干器不认 owns 是 own 的复数,读取模型还被他自己那条指令教会了拒答。没有一项需要更大的模型,全是模型外面的状态没维护好。

一笔 token 账

答题模型看到的内容量,差距比分数更直观。LongMemEval 上,全量上下文大约每题 104000 token,Lemmalog 大约 2700,差 38 倍。LoCoMo 上 18900 对 3400,差 6 倍。

他自己提醒,这不能直接读成便宜 38 倍。对话要先完整读一遍、抽成事实,这笔钱要花。区别在于抽取只付一次,全量方案每次提问都要为整段历史重新付一遍。对话越长,差距越大。50 轮的时候,全量方案每次查询背 10 万 token,500 轮就是 100 万,到某个点干脆装不进上下文窗口了。Lemmalog 每次查询始终两千五左右。

丑话在前面

作者在结尾把冷水自己泼了,我也照抄一遍。LongMemEval 总共 102 题,一类才 17 题,样本撑不起大结论。LoCoMo 是大,可它考的是对话记忆,跟漏洞调查是两回事。总分上,PropMem 在两个基准都还压着 Lemmalog。他也明说,不打算宣布 Datalog 解决了 LLM 记忆。

推理性题目是硬伤,LoCoMo 上 0.164,PropMem 是 0.289。原因也直白。有人说「我一般喜欢安静的餐厅,但和朋友出行时喜欢热闹的」,抽成一句 prefers(user, quiet_restaurants),一半信息在进库之前就扔了。他的解法是别假装每条记忆都是无条件的事实,条件留在规则里,原文留着备查。所以最后的架构是两条腿,演绎状态管事实、规则、时间和作废,情节记忆管模糊语境、语义检索和原文。

我读完的想法

我自己读完,最在意的是分数之外的东西。这套做法把「记忆」拆成了检索和状态维护两个问题,过去一年大家挤在第一个问题里卷,第二个很少有人正面碰。agent 干的活越来越长,一致的知识状态只会比流畅的回忆更值钱,这篇等于提前交了一份能跑的参照。

那串翻车故事值得做 agent 的人多看两眼。翻倍的成绩没有一项来自更大的模型,全是词干器、日期编码、实体对齐这种几十年的老课题。下次怀疑模型不够聪明之前,可以先查查自己的状态管理。

也要把话说小。这是研究员的自述,基准怎么挑、三遍重跑怎么算,都是他自己的口径。知识更新那一项全场第一,也建立在 17 道题上。它证明了这条路值得走,离解决问题还远。真正的检验在后面,他接下来打算让带着 Lemmalog 的 agent 跑一场完整的漏洞调查,看它还会不会复活死掉的假设。那才是这套东西的主场。

来源

原文 I accidentally turned LLM memory into program analysis,Lemmalog 源码在 github.com/JordyZomer/lemmalog,评测框架 MemEval。本文所有数字与表述来自原文。