将LLM记忆转化为程序分析
过去几个月,我一直在研究大语言模型(LLM)智能体,尤其聚焦于漏洞研究场景。
这类智能体在处理大型代码库、解释陌生子系统、探索潜在攻击面等方面的能力,已经达到了令人惊讶的水平。但一旦研究持续数小时,我就会反复遇到同一个问题:模型会逐渐遗忘我们已经明确的结论。
它可能提出一个我们早已排除的方案,或是忘记某个假设已被证伪,甚至基于失效的观察结果继续自信地推导。显然,告诉LLM某个结论有误,并不代表它会自动摒弃所有基于该结论的后续推论 :)
最初我着手研究记忆系统,就是为了提升LLM在复杂漏洞研究中的实用性,减少这类幻觉问题。
目前已有不少给LLM添加记忆功能的方案,通常是将历史对话或观察结果存储起来,转化为嵌入向量,在模型需要时检索最相关的片段。
这种方法效果尚可,但我总觉得有哪里不对。
在漏洞研究过程中,我不希望模型仅仅记住我们说过的话,更希望它能持续维护我们当前的已知结论。
假设在某次研究中,我们得出以下结论:
攻击者控制object_a
object_a指向object_b
object_b是内核对象
据此我们可以推断:攻击者能够控制一个内核对象。
普通记忆系统会存储所有这些观察结果,当我们询问漏洞可利用性时,再将它们检索出来,由LLM重新推导结论。
这当然不错!
但如果两小时后,我们通过LLDB调试发现,object_a实际上并不指向object_b,之前的观察基于错误假设呢?
此时记忆系统中可能同时存在以下内容:
object_a指向object_b
攻击者可控制object_b
object_a实际上不指向object_b
我们只能从中检索部分片段,寄希望于LLM能正确判断哪些结论仍然有效。
这种场景让我感觉似曾相识。
这像极了程序分析
我日常的工作大量涉及程序分析。分析程序时,我们通常会先收集一系列关于程序的事实,再通过规则从这些事实中推导出新结论。
比如已知:
calls(foo, bar)
calls(bar, baz)
我们可以定义一条规则:如果函数A调用函数B,函数B又能调用函数C,那么函数A也能调用函数C。
最终我们会计算出一个"不动点",包含从程序中能推导出的所有结论。更重要的是,如果某个输入事实发生变化,我们有成熟的技术只更新受影响的结果,无需从头重新计算。
这正是我希望LLM在漏洞研究中具备的能力:当某个观察结果被推翻时,我不想让模型从头梳理整个研究过程,再寄希望于它能注意到所有连锁反应;我希望受影响的结论能自动失效。
从这个角度看问题,我开始思考:为什么我们要让LLM反复重构整个认知状态? 如果我们直接维护这个状态呢?
于是,我莫名其妙地为LLM写了一个Datalog引擎 :)
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的诞生
这个想法最终落地成了Lemmalog。
核心思路是:不必让LLM负责维护自身知识体系,而是将问题拆分为两部分。
LLM处理模糊性任务:
"LLDB显示已释放的对象后来被重新用作写入目标。"
|
v
freed(object_a)
reused_as(object_a, write_target)
而Lemmalog处理确定性任务:
事实
|
v
规则
|
v
推导得出的事实
也就是说,LLM仍负责理解自然语言、源代码、调试器输出等研究过程中的非结构化信息——这正是LLM擅长的领域。
但一旦这些信息被转化为结构化事实,就无需再让模型反复推导所有连锁反应,这项工作可以交给数据库完成。
事实撤回机制
我遇到的第一个有意思的问题是如何删除事实。
向Datalog数据库添加事实相对简单:加入新事实后,重新评估所有可能产生新结果的规则即可。
但删除事实就麻烦多了。
举个例子:
a.
b.
c :- a.
c :- b.
这里c为真有两个独立依据。如果我们删除a,不能直接删除c,因为b仍然能推导出c;但如果同时删除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辅助研究中遇到的一个常见问题:有时模型会自信地说"我们已经确认这个指针是攻击者可控的",但事实并非如此。
如果某个结论存在于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, ...)
这样我们既能回答"primitive_a现在是否可行?",也能回答"我们之前为什么认为primitive_a可行?",无需同时保留两个看似矛盾的事实,再让LLM去判断我们指的是哪一个。
这本质上不是语言模型的问题,而是数据库的问题。
为什么不用向量数据库?
向量数据库非常有用。比如当我问"我们之前关于这条分配路径有什么发现?"时,语义检索正是我需要的。
但余弦相似度和事实真相并不是一回事。
向量数据库可能会检索出object_a points to object_b,因为它和我的问题相关,但它本身并不知道这个结论在两小时后已被证伪,也不知道有五个其他结论依赖于它,因此也应被标记为无效。
这让我意识到,"记忆"这个词下其实隐藏着两个不同的问题:
第一个问题是:过去的哪些信息和当前问题相关? 第二个问题是:综合目前所有已知信息,哪些结论是当前成立的?
检索技术擅长解决第一个问题,而Lemmalog主要是为解决第二个问题做的尝试。
当然,两者也可以结合使用,这正是我目前的做法。
漏洞研究本质是维护分析状态
随着研究深入,我发现漏洞研究和程序分析的相似之处越来越多:
漏洞研究中的观察结果:
这个字段是攻击者可控的
对应程序分析中的输入事实;
漏洞研究中的假设:
这个对象会存活到第二个回调函数执行
对应程序分析中的前提条件;
漏洞研究中的关系:
primitive_b依赖于primitive_a
对应程序分析中的规则;
漏洞研究中的假设:
这可能转化为任意写漏洞
对应程序分析中的中间结论;
漏洞研究中的结论:
candidate_3可被利用
对应程序分析中的最终推导事实。
两者的工作流程也高度契合:
- 输入事实:观察结果
- 规则:观察结果之间的关系
- 推导事实:结论
- 不动点:当前所有已知结论
- 当输入变化时:执行增量评估,仅更新受影响的结论
由于跟踪了依赖关系,我们还能解释结果的来源,也就是溯源信息。
我发现自己不知不觉中把漏洞研究当成了静态分析引擎来处理,这也改变了我对LLM角色的认知:整个系统就像一个有点奇怪的编译器。
LLM充当前端:
源代码、
调试器输出、
自然语言笔记
|
v
结构化事实
Lemmalog则是中间表示和分析引擎:
结构化事实
|
v
演绎规则
|
v
维护的状态
之后可以再调用LLM,将维护的状态转化为自然语言,提出下一步实验建议,或是执行具体操作。
有趣的是,这个"编译器"的解析器是概率性的,但后续处理完全可以是确定性的。
它真的能提升LLM的性能吗?
这当然是最关键的问题。
目前Lemmalog引擎已经支持增量评估、事实撤回、溯源查询、时间维度事实、聚合操作、实体融合、混合检索、按需查询等功能——其中不少功能是我出于兴趣添加的,因为实现Datalog特性比我预想的有趣得多。此外还有一个MCP服务器,允许智能体直接调用Lemmalog。
但如果这些功能无法真正提升LLM的性能,那就毫无意义。
因此我将它接入MemEval,用LongMemEval和LoCoMo两个基准测试,采用它们标准化的读取模型和评估流程进行测试。信息提取阶段使用Claude Sonnet 4.6(采用分块和文件缓存,每个对话只需付费一次),提取之后的所有步骤都使用基准测试自带的标准化读取模型和评判标准。
测试结果比我预期的要好一些。
LongMemEval测试
LongMemEval测试LLM能否回答分散在长对话历史中的问题。我选用的测试集包含102个问题,平均分为6类:用户事实、助手事实、偏好、多会话问题、时间推理、知识更新。
由于每类只有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
全上下文 0.222
我自己用GPT-4.1全上下文运行的F1值为0.197。
可见Lemmalog目前还未超过PropMem,略落后于SimpleMem,但F1值是GPT-4.1全上下文模式的两倍多。
更有趣的是,传给回答模型的上下文大小缩小了约38倍:
全上下文:约104,000 tokens/问题
Lemmalog:约2,700 tokens/问题
显然,维护状态而非反复重读整个历史是有效的 :)
某次代表性运行的分类结果如下:
| 系统 | 用户事实 | 助手事实 | 偏好 | 多会话 | 时间推理 | 知识更新 |
|---|---|---|---|---|---|---|
| PropMem | 0.851 | 0.767 | 0.147 | 0.582 | 0.424 | 0.528 |
| SimpleMem | 0.752 | 0.566 | 0.126 | 0.382 | 0.578 | 0.475 |
| Lemmalog | 0.790 | 0.672 | 0.128 | 0.211 | 0.416 | 0.579 |
| OpenClaw | 0.401 | 0.432 | 0.127 | 0.082 | 0.185 | 0.234 |
| 全上下文 | 0.265 | 0.415 | 0.177 | 0.062 | 0.212 | 0.202 |
我最关注的是"知识更新"类的结果: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的F1值突然降到了0.371。
排查失败案例后我发现,102个问题中有32个被拒绝回答,但这些问题其实都是可以回答的。
比如像"我乘坐最多的航空公司是哪家?"或"我订阅了多少种杂志?"这样的问题,得到的回答都是"未提及。"
问题出在我为减少幻觉添加的一条指令上:我要求读取模型确保答案有检索到的事实支撑才能回答。
但模型将其理解为:如果没有单个事实直接包含最终答案,就拒绝回答。
比如记忆中存储的是:
flew(user, swiss, trip_1)
flew(user, swiss, trip_2)
flew(user, lufthansa, trip_3)
并没有一个明确的事实most_flown_airline(user, swiss),但答案确实存在,只是需要统计数量。
修复方案是区分两种情况:
- 如果前提不存在或归因错误,拒绝回答;
- 如果证据存在但需要统计、比较、组合或排序事实,则基于这些事实进行推理。
修复后,F1值回升到0.429。
剩下的差距源于更隐蔽的问题:统计功能实际上一直处于失效状态。统计相关的内容会先经过相关性过滤器,再展示给读取模型,但过滤器使用的词干提取器只处理长度超过4个字符的单词,导致owns无法匹配own,所有统计相关的内容都被过滤掉,统计类问题根本得不到数据支持。
修复词干提取器、将统计结果与对应事实一起展示、预计算日期运算而非依赖模型自行计算日期差后,F1值最终达到0.463。
这种区分在另一个基准测试中也至关重要。
LoCoMo测试
我还在完整的LoCoMo基准测试中运行了Lemmalog。
LoCoMo的规模大得多:包含10段长对话,共1986个问题,涵盖事实回忆、时间推理、多跳问题、推理和对抗性虚假前提问题。
这个测试更可靠,因为1986个问题的样本量足够大,不容易因随机性产生偏差。
同样,我重复运行了三次整个测试:
Lemmalog LoCoMo测试结果:
0.533 +/- 0.001 F1值
已发表的对比结果如下:
| 系统 | F1值 |
|---|---|
| PropMem | 0.605 |
| OpenClaw | 0.557 |
| 全上下文 | 0.542 |
| Lemmalog | 0.533 ± 0.001 |
| Hindsight | 0.489 |
| Graphiti | 0.416 |
| Memory-R1 | 0.389 |
| SimpleMem | 0.358 |
可见Lemmalog在专用记忆系统中排名第三,仅次于PropMem和OpenClaw;如果把全上下文模式也算作一种记忆系统,则排名第四。
这个结果我认为是合理的 :)
更重要的是,三次运行的结果几乎完全一致,说明~0.53是稳定结果,而非测试噪声。
最终配置下的分类结果如下:
| 分类 | Lemmalog | PropMem | 全上下文 |
|---|---|---|---|
| 事实回忆 | 0.399 | 0.431 | 0.517 |
| 时间推理 | 0.454 | 0.615 | 0.369 |
| 多跳问题 | 0.545 | 0.599 | 0.674 |
| 推理 | 0.164 | 0.289 | 0.197 |
| 对抗性问题 | 0.707 | 0.794 | 0.509 |
有两个结果我特别满意。
第一个是时间推理:Lemmalog最初版本的得分是0.257,修复时间归一化和检索问题后达到0.454。
问题出在一个有趣的bug上:我曾将类日期值作为Datalog符号进行比较,而引擎对符号的<操作是比较它们的内部ID,显然内部ID和日期没有任何关系 :)
将提取的日期归一化为可比较的整数,并根据实际时间戳推导happened_before关系后,时间推理的F1值提升了近20个百分点。
第二个满意的结果是对抗性问题:Lemmalog得分0.707,而全上下文模式仅为0.509。
这类问题故意包含虚假或错误归因的前提。比如对话中提到某人收到一份礼物,问题却将这份礼物归给另一个人。
使用完整对话文本的LLM很容易被语义相似的内容误导,进而给出错误答案;而结构化记忆系统能直接发现问题中提到的人没有对应的支撑事实,从而给出正确的否定回答。
换句话说,有时候"不知道"恰恰是最有用的答案。
前端处理至关重要
Lemmalog在LoCoMo测试中的最初得分是0.483,现在达到了0.533。
并不是Datalog引擎突然变聪明了10%,大部分提升来自于优化信息进出分析状态的方式。
比如实体融合就非常关键。假设存在以下会话:
会话1:"我买了一辆本田思域。"
会话3:"我的车坏了。"
会话7:"思域终于修好了。"
如果提取结果是:
bought(user, honda_civic).
broke_down(car).
fixed(civic).
那么Datalog引擎确实严格执行了指令,但实际上我们让它推理三个完全不同的对象。
因此Lemmalog现在增加了融合步骤,将不同会话中提到的同一实体关联起来。
纯词汇检索也导致过一些有趣的失败:比如问题中提到"厨房用具",检索系统可能无法匹配到关于"Instant Pot"的事实,尽管我们知道这两者是相关的。
现在的检索系统结合了BM25算法、图/实体权重和向量嵌入,最终提供的上下文既包含结构化事实,也包含事实对应的原始文本片段。
这再次提醒我:这个架构中最难的部分不一定是计算不动点,而是从自然语言中构建良好的信息检索系统——这感觉又和程序分析有点像了。
有些内容仍需保留模糊性
Lemmalog目前还有一个明显的短板:推理能力。在LoCoMo测试中:
PropMem 0.289
Lemmalog 0.164
这其实很合理。比如有人说:"我通常喜欢安静的餐厅,但和朋友旅行时,我更喜欢热闹的地方。"如果将其简化为prefers(user, quiet_restaurants),那么在Datalog处理之前,我们就已经丢失了一半有用信息。
显然,解决方案不是放弃结构化记忆,而是不要试图将所有记忆都转化为无条件的元组。条件性知识可以保留其条件:
prefers(User, lively_restaurants) :-
prefers_when(User, lively_restaurants, with_friends),
with_friends(User).
同时保留原始对话文本,以便在结构化表示丢失重要细节时使用。
理想的架构不应是"向量记忆或符号记忆",而应是:
智能体记忆
|
+--------------+--------------+
| |
演绎状态 情景记忆
| |
事实/规则/时间 模糊上下文
溯源信息 语义检索
事实撤回 原始文本
幸运的是,Lemmalog目前的架构已经非常接近这个理想状态。
关于Token消耗的意外收获
测试结果中还有一个我最初没想到的巨大优势:
在LongMemEval中,回答模型处理的上下文大小:
全上下文:约104,000 tokens/问题
Lemmalog:约2,700 tokens/问题
减少了约38倍。
在LoCoMo中:
全上下文:约18,900 tokens/问题
Lemmalog:约3,400 tokens/问题
减少了约6倍。
当然,信息提取阶段需要消耗Token,因此说整个系统的成本直接降低38倍并不准确。但关键区别在于:信息提取只需进行一次,而全上下文模式每次查询都要重新处理整个对话历史。
对于持续运行的智能体,这种差异会随时间不断扩大:
| 对话轮次 | 全上下文模式 | Lemmalog模式 |
|---|---|---|
| 50 | 100K/查询 | ~2.5K/查询 |
| 100 | 200K/查询 | ~2.5K/查询 |
| 500 | 1M/查询 | ~2.5K/查询 |
最终,全上下文模式不仅会变得极其昂贵,甚至会超出模型的上下文窗口限制。而Lemmalog的查询上下文不会随对话历史增长,因为它只检索相关的维护状态——这正是我最初设计它的初衷。
这能证明什么?
目前还不能完全下定论。LongMemEval只有102个问题,LoCoMo也只是对话记忆基准测试,而非真实的漏洞研究场景。而且PropMem在两个标准化测试中仍然全面领先Lemmalog。
所以我不会声称Datalog已经解决了LLM的记忆问题 :)
但测试结果足以证明这个思路并非异想天开:
在三次LongMemEval运行中,Lemmalog的平均得分为:
0.463 +/- 0.010 F1值
0.575 +/- 0.004 准确率
在LoCoMo测试中:
0.533 +/- 0.001 F1值
尤其在其设计目标场景中表现出色:知识更新、时间状态维护、多跳关系推理和拒绝无依据前提。
不过对我来说最有趣的不是最终的数字,而是整个优化过程:最初的标准化LongMemEval配置得分只有0.226,现在达到了0.463,提升了一倍多。
大部分提升来自于针对具体失败案例,解决一系列明确的计算机科学问题:
- 实体身份关联断裂
- 日期表示错误
- 检索遗漏语义别名
- 聚合功能存在但未被有效利用
- 词干提取器无法匹配
owns和own - 读取模型被错误地训练为拒绝综合推理
这些问题都不需要更大的语言模型,只需要更好地维护模型周围的状态——考虑到我启动这个项目的初衷,这个结果实在是有点讽刺。
我的下一个实验才是真正关心的内容:让智能体处理复杂的漏洞研究任务,运行较长时间,观察维护分析状态是否能防止它重复提出已被推翻的假设,以及在观察结果之间幻觉出不存在的关系。
这肯定比记住爱丽丝在哪里工作要有趣得多 :)
结论
我真正想要的不是给LLM更好的记忆,而是让它不再忘记我们为什么相信某些结论。
如果智能体已经推导得出A蕴含B,B蕴含C,后来又发现A不再成立,我们不应需要给它50条旧消息,再让它自己判断C是否仍然可信。同样,如果某个利用策略依赖的假设已被调试器证伪,我不希望模型在两小时后,因为旧对话在语义上相关,再次提出同样的策略。
对于涉及事实、依赖关系、失效处理和不动点的问题,我们早已掌握了解决方案——数据库和程序分析领域已经研究了几十年。
基准测试结果至少表明,这个思路不仅在理论上可行:Lemmalog已经能与专用LLM记忆系统竞争,在其设计目标的部分任务上大幅超越全上下文模式,同时只需向读取模型提供原始对话历史的极小一部分。
当然,它还有很多不足之处。
但或许我们不必每次遇到智能体遗忘问题时,都想着扩大上下文窗口。有时候,我们只需要维护好状态就行。
Lemmalog的源代码可在这里获取。
干杯!