作者发现大模型对话中的记忆机制可被转化为代码静态分析能力,实现了超越原生对话用途的技术突破。
过去几个月,我一直在玩 LLM 智能体,主要用于漏洞研究。
它们在导航大型代码库、解释陌生的子系统以及帮助探索潜在攻击面方面表现得越来越出色。然而,一旦调查持续几个小时,我就一直遇到同样的问题:模型会逐渐丢失我们实际已经确定的事实。
它可能会建议一个我们已经排除过的方案,忘记某个假设后来被证明是错误的,或者自信地从不再有效的观察结果继续推理。显然,告诉 LLM 某件事是错误的不一定意味着它会停止相信所有依赖于它的事情 :)
我最初研究记忆系统是因为我想让 LLM 在复杂漏洞研究中更有用,并减少这种类型的幻觉。
当然,已经有很多为 LLM 提供记忆的解决方案。通常的做法是将旧的对话或观察结果存储在某个地方,对其进行嵌入,然后在模型再次需要时检索最相关的部分。
这种方法效果还不错,但有件事一直困扰着我。
在漏洞研究过程中,我不只是想让模型记住我们说过的话。
我想要它维护我们目前知道的内容。
想象一下,在调查过程中我们确定了以下事实:
attacker controls object_a
object_a points to object_b
object_b is a kernel object
由此,我们可能会得出结论:攻击者可以控制一个内核对象。
一个普通的记忆系统可以存储所有这些观察结果,并在我们询问该漏洞可利用性时再次检索它们。然后 LLM 会得出相同的结论。
然而,假设两个小时后,我们在 LLDB 中发现 object_a 实际上并不指向 object_b,而我们之前的观察是基于一个错误的假设。
此时我们的记忆可能包含类似这样的内容:
object_a points to object_b
attacker can control object_b
object_a does not actually point to object_b
现在我们检索这些记忆的一个子集,并希望 LLM 正确地找出哪些结论仍然有效。
这开始让我觉得有些熟悉。
这看起来像程序分析
我的日常工作中有很多涉及程序分析的内容。
在分析程序时,我们通常有一堆关于程序的事实和一些可以从这些事实推导出额外事实的规则。
例如,假设我们知道:
calls(foo, bar)
calls(bar, baz)
我们可以定义一条规则:如果一个函数调用另一个函数,而该函数本身可以到达第三个函数,那么第一个函数也可以到达第三个函数。
最终我们计算一个包含所有可以从程序推导出的内容的定点。更重要的是,如果我们的某个输入事实发生变化,有大量技术可以只更新受影响的结果,而不是从头开始重新运行所有内容。
这也正是我在漏洞研究期间对 LLM 的期望。
如果一个观察结果发生变化,我不想让模型从一份记录中重建整个调查,并希望它能注意到所有的后果。我希望受影响的结论自动失效。
从这个角度看待这个问题,我开始疑惑为什么我们要让 LLM 一次又一次地重建它的整个状态。
如果我们直接维护它呢?
就这样,我莫名其妙地最终为 LLM 写了一个 Datalog 引擎 :)
在继续之前,解释一下 Datalog 实际上是什么可能会有用。
Datalog 是一种声明式逻辑编程语言。我们不是编写描述如何计算某事物的指令,而是描述可以从中推导出新事实的事实和规则。
例如,我们可以存储以下事实:
controls(attacker, object_a).
points_to(object_a, object_b).
kernel_object(object_b).
然后定义以下规则:
controls_kernel_object(Attacker) :-
controls(Attacker, ObjectA),
points_to(ObjectA, ObjectB),
kernel_object(ObjectB).
从我们现有的事实中,引擎可以推导出:
controls_kernel_object(attacker).
目前还没有什么特别令人兴奋的。
然而,假设我们后来发现:
points_to(object_a, object_b).
如果 controls_kernel_object(attacker) 是从该事实推导出来的,我们就知道哪个结论依赖于刚刚改变的事实,并且可以自动使其失效。
这比把所有旧信息放入提示中并希望 LLM 能注意到同样的事情要好得多。
这最终演变成了 Lemmalog。
基本想法是:LLM 不一定非要负责维护自己的知识。相反,我将问题分成两部分。
LLM 处理模糊的部分:
"LLDB shows that the freed object is later reused
as the destination of the write."
|
v
freed(object_a)
reused_as(object_a, write_target)
而 Lemmalog 处理确定性的部分:
facts
|
v
rules
|
v
derived facts
这意味着 LLM 仍然负责理解自然语言、源代码、调试器输出以及调查过程中出现的所有杂乱信息。
LLM 恰好很擅长这些。
但是一旦这些信息被转换成结构化事实,我们就不再需要模型重复地确定其所有后果。数据库可以代替做这件事。
我遇到的第一个有趣问题之一是删除事实。
向 Datalog 数据库添加事实相对直接:添加新事实并评估任何可能产生额外结果的规则。
删除东西则有点麻烦。
以以下示例为例:
a.
b.
c :- a.
c :- b.
在这里,c 有两个独立的理由为真。
如果我们删除 a,我们不能简单地删除 c,因为 b 仍然提供了另一个推导。然而,如果我们同时删除 a 和 b,c 也应该消失。
这在漏洞研究过程中相当重要,因为一个结论可能由多个观察结果支持。
candidate_3_is_exploitable
即使某个特定的利用原语被证明不可用,只要存在另一条独立路径达到相同结果,它可能仍然为真。
所以 Lemmalog 必须跟踪事实是如何被推导出来的,并在某些内容发生变化时更新它们的支持。这也很方便地给了我们另一个有用的属性:
我们可以询问为什么某件事是真的。
想象一下,我们运行了一个智能体几个小时来调查某件事,它最终得出结论:
candidate_3_is_exploitable
这很好,但我也很想知道为什么。
因为 Lemmalog 已经跟踪了推导事实的依赖关系,我们可以询问一个结论的来源。例如,我们可能会得到一个概念上类似这样的东西:
candidate_3_is_exploitable
|
+-- attacker_controls_pointer
| |
| +-- observation_41
|
+-- pointer_reaches_target
|
+-- observation_57
+-- rule_12
如果后来发现 observation_41 是不正确的,我们知道这个结论可能不再有效,而且因为数据库也知道这一点,它可以自动删除受影响的结论。
这最初主要是为了使增量评估正确工作,但事实证明,能够询问 AI 智能体为什么它相信某件事也是很有用的 :)
它还解决了我在使用 LLM 辅助研究时遇到的一个更烦人的失败模式。有时候模型会自信地说出这样的话:
we already established that this pointer is attacker-controlled
但实际上那并不是真的。
如果一个结论存在于 Lemmalog 中,我可以询问它来自哪里。如果没有支持它的来源,那么它就不是维护状态的一部分。
这显然不能阻止 LLM 在提取过程中产生幻觉,但它确实使没有支持的结论更难悄悄地成为调查的一部分。
事实也会随时间变化
另一个问题是,替换旧事实并不总是等同于删除它们。
假设我们最初认为:
primitive_a is viable
后来变成:
primitive_a is not viable
对于大多数当前查询,我们可能只关心第二个陈述。然而,如果我们想理解为什么我们之前探索了某个特定的利用策略,旧状态仍然有用。
因此 Lemmalog 可以将事实与有效区间关联起来。
概念上,我们可以将状态表示为类似这样的形式:
viable(primitive_a) [10:14, 12:37)
not_viable(primitive_a) [12:37, ...)
这允许我们同时回答:
Is primitive_a viable now?
为什么我们之前认为 primitive_a 是可行的?
——而不是保留两个明显矛盾的事实,让 LLM 决定我们指的是哪一个。
再次强调,这其实不是一个语言模型的问题。
它主要是一个数据库问题。
为什么不直接用向量数据库?
向量数据库非常有用。
我们之前在这个分配路径上发现了什么?
语义搜索可能就是我们真正想要的。
但余弦相似度和真值并不是同一回事。
向量数据库可以检索到:
object_a 指向 object_b
因为它与我的问题相关。它本身并不知道这个陈述在两小时后已被推翻,也不知道其他五个结论依赖于它,因此不应再被视为有效。
这让我意识到,"记忆"这个词下实际上隐藏着两个不同的问题。
哪些过去的信息与这个问题相关?
考虑到我们目前学到的一切,目前什么是真的?
检索非常擅长解决第一个问题。
Lemmalog 主要是一个尝试解决第二个问题的实验。
两者也可以结合使用,这是我目前的做法。
漏洞调查本质上是分析状态
我越深入研究这一点,与程序分析的相似之处就越多。
在漏洞调查过程中,我们有观察结果:
这个字段可被攻击者控制
这个对象存活到第二个回调
primitive_b 依赖于 primitive_a
这可能变成任意写入
candidate_3 可被利用
这与我们已经在程序分析中做的事情有着惊人的对应关系。
观察结果
观察结果之间的关系
结论
目前已知的一切
当输入发生变化时,我们执行增量评估:
更新受影响的结论
因为我们追踪了依赖关系,所以也能够解释结果从何而来:
来源追踪
在某个时刻,我意识到我其实是以静态分析引擎的方式在处理这个问题,虽然并非有意为之。
这也改变了我对 LLM 本身角色的看法。
你几乎可以把整个系统看作一个略显奇怪的编译器。
LLM 充当前端:
源代码,
调试器输出,
自然语言笔记
|
v
结构化事实
Lemmalog 是中间表示和分析引擎:
结构化事实
|
v
演绎规则
|
v
维护的状态
另一个 LLM 调用最终可以将这个状态转回自然语言,建议下一个实验,或使用它执行某些操作。
有趣的是,我们的解析器是概率性的,而它之后的所有步骤并不一定是。
这真的让 LLM 变得更好吗?
这当然是重要的问题。
引擎本身现在支持增量评估、撤回、来源追踪、时态事实、聚合、实体协调、混合检索、按需查询,以及其他一大堆我可能只是因为实现 Datalog 功能比想象中更有趣而添加的功能。
还有一个 MCP 服务器,允许智能体直接使用 Lemmalog。
但如果给 LLM 这种记忆实际上并不能改善任何事情,那这一切都不重要。
所以我把它接入 MemEval,使用标准的读取模型和评估设置在 LongMemEval 和 LoCoMo 上进行了测试。摄取阶段的提取使用 Claude Sonnet 4.6(分块并文件缓存,所以每个对话只支付一次费用);提取之后的所有步骤都使用基准测试自己的标准化读取器和评判器。
结果比我预期的好一点。
LongMemEval 测试 LLM 能否回答跨越长对话历史的信息问题。我使用的分割包含 102 个问题,均等分配在用户事实、助手事实、偏好、多会话问题、时间推理和知识更新之间。
因为每个类别 17 个问题算不上大样本量,所以我运行了 Lemmalog 三次,而不是对某次恰好得分最高的结果感到兴奋。
Lemmalog
F1: 0.463 +/- 0.010
准确率: 0.575 +/- 0.004
作为对比,发布的记忆系统结果是:
PropMem 0.550
SimpleMem 0.480
Lemmalog 0.463 +/- 0.010
OpenClaw 0.244
Full Context 0.222
我自己运行的全上下文 GPT-4.1 得分是 0.197 F1。
所以 Lemmalog 还没有超过 PropMem,仍然略微落后于 SimpleMem,但它获得的 F1 是给 GPT-4.1 整个对话的两倍多。
更有趣的是,传递给回答模型的内容大约小了 38 倍。
全上下文: ~104,000 tokens/问题
Lemmalog: ~2,700 tokens/问题
显然,维护状态而不是反复重读整个历史是有用的 :)
来自一次代表性运行的分项结果如下:
我最感兴趣的结果是知识更新。
Lemmalog 得分 0.579,而 PropMem 为 0.528,全上下文为 0.202。
知识更新基本上就是我一开始关心的情况:
我们曾相信 A
|
后来我们得知 A 不再是真的
|
我们现在应该相信什么?
所以看到 Lemmalog 在最接近维护程序状态的那个类别上领先于已发布的对比方案,还是相当令人满足的。
单会话事实记忆也出乎意料地好。Lemmalog 在用户事实上达到 0.790,在助手事实上达到 0.672,而时间推理达到 0.416,与该运行中 PropMem 的 0.424 几乎相同。
明显剩下的问题是多会话推理:
PropMem 0.582
SimpleMem 0.382
Lemmalog 0.211
诊断这些失败很有趣:信息通常并非连接错误,而只是从未被提取出来。如果提取器从未发出关于 Airbnb 预订的事实,那么再多的推导也无法回答关于它的问题。
这就引出了运行基准测试中比较有趣的部分之一。
我不小心教它不要回答问题
有一段时间 LongMemEval 突然降到了 0.371 F1。
在仔细检查失败案例后,我发现 102 个问题中有 32 个被拒绝了。
这 32 个都是可以回答的。
我坐哪家航空公司的航班最多?
我有多少本杂志订阅?
未提及。
问题出在我添加的一条减少幻觉的指令上。我告诉读取器,在回答之前要确保答案确实被检索到的事实所支持。
不幸的是,模型将其理解为:
如果没有单个事实字面包含最终答案,就拒绝回答。
显然不可能有这样的事实:
most_flown_airline(user, swiss)
如果记忆包含的是:
flew(user, swiss, trip_1)
flew(user, swiss, trip_2)
flew(user, lufthansa, trip_3)
答案存在。只是需要计数。
修复方法是区分两种情况:
如果前提不存在或归因错误,拒绝。
如果证据存在但需要计数、比较、组合或排序事实,实际进行推理。
如果证据存在但需要计数、比较、组合或排序事实,实际进行推理。
修复之后,F1 恢复到 0.429。
剩下的差距则更隐蔽:计数路径一直是静默死的。计数行在显示给读取器之前会经过相关性过滤器,而该过滤器使用的复数词干提取器只对超过四个字符的词进行折叠。所以 owns 从未匹配 own,每条计数行都被丢弃,计数问题悄无声息地完全没有得到任何计数。
修复词干提取器、将计数与其计数的事实一起渲染、以及预先计算日期算术而不是指望模型正确减去两个日期,使 F1 达到了 0.463。
这种区分在另一个基准测试中也相当重要。
我也在完整的 LoCoMo 基准测试上运行了 Lemmalog。
LoCoMo 相当大:10 个长对话包含 1,986 个问题,涵盖事实回忆、时间推理、多跳问题、推理和对抗性假前提问题。
这个特别有用,因为 1,986 个问题使得偶然对某个幸运种子感到兴奋要困难得多。
同样,我运行了完整基准测试三次。
Lemmalog LoCoMo:
0.533 +/- 0.001 F1
发布的对比结果如下:
所以 Lemmalog 目前在这个对比中在专用记忆系统中排名第三,落后于 PropMem 和 OpenClaw。
如果我们把把整个对话扔进提示词当作记忆系统,它就是第四。
我认为这很公平 :)
更重要的是,三次运行的结果几乎完全一致,所以 ~0.53 似乎是一个真实结果,而非基准测试噪声。
最终配置下各分类的结果如下:
这里有两个结果是我特别喜欢的。
第一个是时间推理。
Lemmalog 的初始版本得分:
0.257
修复时间规范化和检索后:
0.454
这个 bug 实际上相当好笑。
曾经有一段时间,我将日期类值作为 interned Datalog 符号进行比较。
引擎的 < 运算符在符号上比较的是其内部 id。
内部 id 显然不是日期 :)
将提取的日期规范化为可比较的整数,并从实际时间戳中推导出 happened_before 之后,时间性能提升了近 20 个 F1 点。
我喜欢的第二个结果是对抗性问题。
0.707
而全上下文得分:
0.509
这些问题故意包含错误或错误归因的前提。
例如,对话可能包含一个关于某人收到礼物的故事,随后的问题将同一礼物归因于另一个人。
带有大量对话记录的语言模型相当容易受到诱惑,找到语义上相似的故事然后仍然回答。结构化记忆则可以注意到,问题中该人物根本没有支持性事实。
no
原来是一个非常有用的答案。
前端很重要
第一个 LoCoMo 实现得分为 0.483。
现在的得分约为 0.533。
Datalog 评估器并非突然变得聪明了 10%。
大部分改进来自于修复信息如何进入和退出分析状态。
例如,实体解析被证明非常重要。
想象以下会话:
Session 1:
"I bought a Honda Civic."
Session 3:
"My car broke down."
Session 7:
"The Civic is finally fixed."
如果提取产生:
bought(user, honda_civic).
broke_down(car).
fixed(civic).
那么 Datalog 引擎正是按照我们的要求在做。
不幸的是,我们让它推理三个不同的对象。
所以 Lemmalog 现在有一个协调过程,将情节本地的提及连接到规范实体。
纯词法检索也导致了一些有趣的失败。指代:
"kitchen gadget"
的问题不一定会检索到关于:
"Instant Pot"
的事实。
尽管这种关系对我们来说显而易见。
现在的检索结合了 BM25、图/实体提升和嵌入向量,而最终上下文既包含结构化事实,也包含它们来自的原始源片段。
这是另一个有用的提醒:这个架构的困难部分不一定是计算不动点。
而是从自然语言构建良好的 IR。
这,again,感觉像是程序分析。
有些东西可能应该保持模糊
还有一个领域是 Lemmalog 仍然相当薄弱:推理。
PropMem 0.289
Lemmalog 0.164
假设有人说:
I usually prefer quiet restaurants, except when I'm travelling
with friends, when I quite like somewhere lively.
将其扁平化为:
prefers(user, quiet_restaurants).
在 Datalog 还没看到它之前就已经丢失了一半有用信息。
明显的方向不是放弃结构化记忆,而是停止假装每条记忆都是无条件元组。
条件知识可以保持条件性:
prefers(User, lively_restaurants) :-
prefers_when(User, lively_restaurants, with_friends),
with_friends(User).
而原始情节文本可以保持可用,以应对结构化表示丢失有用细微差别的情况。
因此有用的架构看起来不太像:
vector memory
OR
symbolic memory
而更像:
agent memory
|
+--------------+--------------+
| |
deductive state episodic memory
| |
facts / rules / time fuzzy context
provenance semantic retrieval
retractions source text
幸运的是,这相当接近 Lemmalog 最终变成的样子。
还有一个部分的结果是我最初没有预料到会有如此大的差异。
对于 LongMemEval,回答模型看到的上下文大约是:
Full context: ~104,000 tokens/question
Lemmalog: ~2,700 tokens/question
少约 38 倍。
Full context: ~18,900 tokens/question
Lemmalog: ~3,400 tokens/question
当然有提取成本。
对话必须被读取一次并转换为事实,所以说整个系统简单便宜 38 倍是不诚实的。
重要的区别是提取只发生一次。
全上下文提示在每次查询时都要为整个历史付费。
对于一个持久化的智能体,这种差异会随时间增长。
在某种程度上,全上下文版本不仅仅变得昂贵。
它停止适应上下文窗口了。
Lemmalog 的查询上下文不会随整个对话记录增长,因为它检索的是相关维护状态而非完整历史。
这也算是原来的初衷。
这证明了什么吗?
LongMemEval 有 102 个问题,而 LoCoMo 仍然是一个对话记忆基准测试而非漏洞调查。
PropMem 在两个标准化比较中仍然总体上击败了 Lemmalog。
所以我不打算声称 Datalog 解决了 LLM 记忆问题 :)
但我确实认为结果足以表明这个想法并非完全愚蠢。
在三次 LongMemEval 运行中,Lemmalog 得分:
0.463 +/- 0.010 F1
0.575 +/- 0.004 accuracy
0.533 +/- 0.001 F1
当任务奖励架构设计目标所针对的能力时——知识更新——它特别有竞争力。