公司动态

LLM记忆结构化:让程序分析结论可查询、可复核

📅 2026/9/1 14:39:40
LLM记忆结构化:让程序分析结论可查询、可复核
那段时间我经常用一个对话式 LLM 分析内部代码库。某回我想搞清楚“订单模块和用户模块之间是否存在隐式依赖”它读了一会儿代码给了我一段很流利的回答订单在三个位置读取了用户状态并且没有走接口边界而是直接查了同一张表。听完我觉得挺有道理就循着引用去验证。结果发现它说的“三个位置”里两个确实存在第三个指向的却是一段已经被注释掉的旧代码。真正让我难受的不在回答准不准而在于整个过程不可查——它怎样定位到这些位置、依据是什么、哪些结论有把握全都被压缩在上下文里过一会儿就找不到了。这件事给我一个判断LLM 的记忆如果只停留在对话流里对程序分析的价值就是临时的。它像一支出色的调研队伍但从不写调研笔记。Lemmalog 这个实验项目就是想解决这个最基础的问题——把 LLM 在做程序分析时产生的记忆转成程序可以查询、可以复核、可以复用的结构化数据。这篇文章不是正式的教程也不是严谨的论文而是一次从真实困惑出发的设计复盘。1. 先想清楚把 LLM 记忆变成程序分析真正难在哪很多人一开始会把问题简化成“用什么模型”“要不要用 Agent”或者是“LLM 能不能替代静态分析工具”。从我自己的实践看这些都是次要问题。最难的地方在于程序分析和自然语言对话对“知识”的要求完全不同。1.1 程序分析容忍“不完美”的能力很低日常对话里一句回答哪怕有 20% 的模糊或偏差听的人也能靠常识补齐。程序分析不一样。无论是做依赖分析、数据流分析还是找循环引用它产出的结论最终会被人拿去改架构、写评审、做重构决策。这时候一个“大概正确”的结论往往比“不知道”更危险。我后来在 Lemmalog 里加的第一条设计原则就是所有从 LLM 记忆里提炼出来的结论都不能被当成“事实”只能被当成“候选证据”。原因很简单LLM 的输出过程本质是概率性的语言生成即使同样的 prompt、同样的代码、同一个模型两次运行也可能在某个细节上不一致。而程序分析工具例如基于 AST 的调用图、基于类型系统的引用检查、基于运行时 trace 的数据流分析它们的特点是确定性强同样的输入一定产出同样的输出。这两种东西的差异不是“谁更聪明”的问题而是“是否可以被验证”的问题。1.2 LLM 的记忆并不是数据而是上下文里的临时激活状态如果去拆解 LLM 在分析代码时的行为你会发现它并没有一个真正的“记忆库”。它能记住的内容全部来自当前上下文窗口里的 token 序列。你把代码片段、历史结论、中间思考结果都放在同一段上下文里它就能引用这些内容一旦上下文被重置或裁剪之前的“记忆”就消失了。这带来一个很直接的结果普通用户在对话里得到的好体验并不是因为模型记住了这个项目而是因为它在一段足够长的上下文里做了“临时推理”。一旦窗口关闭所有中间结论、引用位置、验证思路都不存在了。Lemmalog 想做的就是把这些临时状态趁它们还在上下文里时“捞”出来变成真正持久化的数据。这个思路有点像调试器里的“堆快照”程序运行时的瞬间状态无法复现但你可以专门设计一个机制把它 dump 出来供事后分析。1.3 问题的本质是如何把一次性的语言推理变成可复用的分析资产所以真正的问题不是“LLM 能不能做程序分析”而是“如何把一次性的语言推理变成可复用的分析资产”。要做到这一点需要满足三个条件可提取推理过程中的中间结论是不能只停留在模型脑子里的必须有一个机制引导模型把它们输出出来。可查询输出的内容不能是自由文本必须是结构化数据能按实体、关系、证据、置信度去检索。可复核每一条结论都要能回到具体的代码位置或逻辑片段交给确定性工具或人去二次验证。这三个条件环环相扣。先能提取才能谈查询先结构化才便于复核复核之后留下来的才是真正能进入程序分析工作流的内容。2. Lemmalog 的源头我先画了一张“记忆分层图”Lemmalog 最初的代码并不多真正花时间的其实是最开始的“分层设计”。我把它画成了一张图三层结构。2.1 三层记忆对话层、事实层、分析层第一层是原始对话层。这里存放的是 LLM 和用户之间的完整问答记录以及提示词里送入的代码片段、检索片段。这一层信息最全但噪音也最大看着像流水账很难直接给程序消费。第二层是事实层。这是 Lemmalog 的核心。它从对话层里抽取出来的关键断言、实体名称、关系方向、证据位置、置信度以及模型自身的版本和推理参数。这一层有一定噪音但已经可以被程序读取只是需要二次校验。第三层是分析层。这一层不是由 LLM 产出的而是把事实层的数据交给确定性分析工具校验、清洗之后生成的依赖图、调用路径、数据流报告等结论。只有到这一层才称得上真正的“程序分析结果”。这三层可以简单列成一张表层级内容是否可直接用于程序分析对话层问答记录、代码片段、检索结果否噪音高且缺少标签事实层断言、实体、关系、证据、置信度有限需要二次校验分析层依赖图、调用路径、数据流结论是可以查询和引用Lemmalog 的核心逻辑就是负责“从对话层到事实层”的抽取以及“从事实层到分析层”的校验桥接。2.2 值得程序化的三类记忆在开发和使用的过程中我逐渐发现并不是所有 LLM 记忆都值得程序化。真正值得沉淀的大概有三种。第一类是“断言型记忆”。例如“模块 A 存在对模块 B 的循环依赖”“函数find_by_id会被三个调用点引用”“这两个接口之间缺少幂等保护”。这类记忆的特点是可以被验证可以被证实或证伪。第二类是“实体关系型记忆”。例如“实体 A 调用了实体 B”“文件 C 中定义了结构体 D”“接口 E 依赖了配置项 F”。这类记忆本质上可以被映射成图结构是程序分析最常用的素材。第三类是“方法论记忆”。它指的是模型在分析过程中使用过的判断策略例如“我通过搜索 import 语句定位到了依赖入口”“我通过对比两个文件的 diff 发现了重复逻辑”。这类记忆不会直接形成分析结论但能告诉后续使用者这次分析是怎么做的结论覆盖了哪些范围遗漏了哪些方向。2.3 记录粒度的取舍太粗没有用太细撑不住一个很容易踩的坑是想把 LLM 每一步的思考都记录下来。这听起来很严谨但实际执行时成本极高。上下文很快会被占满输出会开始不稳定而且绝大多数中间思考对最终结论没有帮助。我的做法是只记录“足够支撑复核”的中间结论而不是记录“每一步推理”。所谓的足够支撑就是一条断言必须包含实体名称、关系方向、证据位置和置信度。如果缺了其中任何一项在事实层就标记为“待补充”不急着进入分析层。这个取舍直接决定了 Lemmalog 的提取 prompt 设计我宁愿让它输出 10 条带证据的精炼断言也不要 50 条没有来源的模糊总结。因为程序分析最终的可靠性不取决于 LLM 说了多少而取决于有多少能被验证。3. 从原型到可用的三条实现路径在设计完分层之后Lemmalog 进入具体实现。这一部分我想分享三条实际走通的路径。它们可以独立使用也可以组合使用。3.1 探针法在提示词里埋入固定留痕指令第一种实现路径是“探针法”。思路很简单在问题里固定要求模型在输出最终结论前先输出若干条“分析断言”每一条都带实体、方向和证据位置。我常用的一种提示词模板长这样请分析以下代码中的模块依赖关系。 分析完成后额外输出一个 JSON 块包含 - assertion: 你得到的关键结论 - entities: 关联到的代码实体 - direction: 关系方向 - evidence: 你依据的代码位置或行号 - confidence: 高/中/低 代码片段 {code_snippet} 请不要解释 JSON 以外的内容。这里的关键是“额外输出 JSON 块”而不是只让模型回答。因为默认的对话式回答虽然可读性高但不利于程序解析强制 JSON 输出虽然可能损失一点自然语言上的流畅度但能保证后续流程可以自动处理。探针法适合的场景是你想在一个既有 LLM 工作流里低成本地增加留痕能力。它不改变原流程只是在最后加一步收口。3.2 导出法让模型生成记忆卡片第二种路径是“导出法”。相比探针法它更完整。我不再只让模型在回答末尾加一个 JSON而是要求它把当前上下文中跟程序分析相关的所有记忆整理成一张“记忆卡片”。记忆卡片的结构大致是{ lemma_id: LEMMA-001, assertion: Module A has a circular dependency with Module B, entities: [ModuleA, ModuleB], relation: depends_on, direction: A - B, B - A, evidence: [ src/a.py:12 imports b.B, src/b.py:18 imports a.A ], confidence: high, model: qwen2.5-14b-instruct, timestamp: 2025-06-01T10:00:00Z, prompt_version: lemmalog-v0.2 }注意这里的model、timestamp、prompt_version三项很重要。它们不是凑数而是为了复现。因为 LLM 的输出强烈依赖模型版本和提示词版本如果将来发现事实层条目有问题至少能回头定位当时用的是什么配置。导出法的优势是信息密度高、结构清晰适合批量处理。缺点是模型并不是每次都能严格输出合法 JSON。我遇到最多的问题是模型把 JSON 塞进 markdown 的代码块里或者在 JSON 后继续补解释文本。因此解析端要考虑先提取代码块、再去掉前后噪声最后才做 JSON 解析。3.3 复核法用确定性工具给每一条断言盖章前面两条路径都在解决“提取”第三条路径解决“验证”。这也是 Lemmalog 和很多“用 LLM 做分析”项目最大的不同它不信任 LLM 自己给出的结论而是要求每一条断言都必须经过确定性工具的复核。我的做法是对正代码库生成一份“确定性地基”再用它来校验事实层。假设你有一个 Python 项目地基可以包含用 tree-sitter 或 AST 解析出的函数、类、变量定义。用 import 分析得到模块依赖边。用代码查询工具或 grep 得到的调用点分布。然后对每一条从 LLM 那里提取的断言做一次匹配。例如断言“A 依赖 B”就查询地基里是否真的有A import B或A 调用了 B 的成员这样的记录。如果匹配上了标记为“已验证”如果匹配不上标记为“待人工确认”而不是直接删除。这一步的价值在于它把 LLM 的“语言表达能力”和程序的“事实检索能力”做了结合。LLM 负责提出假设确定性工具负责验证假设。两者的分工完全不同但恰好互补。4. 落地时最容易翻车的四道坎任何这类项目在演示时都显得很顺但一落到真实项目上问题就浮出来了。我按踩坑顺序整理出四个最需要注意的地方。4.1 记忆卡也会产生幻觉证据位置是最后的保险很多人以为只要让 LLM 输出“evidence”字段它就能给出真实的代码位置。实际上不是。模型生成的证据位置经常存在以下问题文件路径是对的但行号是错的。两个文件被张冠李戴。引用的变量名在代码里根本不存在。所以证据字段必须当成“候选线索”不能当成“真实引用”。我在 Lemmalog 里的做法是凡是进入分析层的结论证据必须是“程序能够重新解析到的真实代码位置”。如果是 LLM 自己给的位置在未经复核前只能留在事实层。4.2 上下文膨胀会让后续记忆质量快速下降第二个坑和上下文长度有关。当你在一个长会话里不断追加新代码片段和中间结论时LLM 对前面内容的关注度会逐渐下降。最典型的表现是越到后文它越是只依据最近输入的代码进行判断而忘了早期已经验证过的结论。这本质上是注意力分配和上下文窗口挤压的问题。可靠的做法不是让上下文无限膨胀而是分阶段处理每个阶段只分析一个代码区域分析完立刻提取记忆卡片然后清空上下文进入下一个区域。最后再把所有记忆卡片合并。这样虽然没有让模型“一次看完全局”但每一段记忆都是相对完整的而且互相之间可以利用结构化数据重新关联。4.3 实体名不一致导致结构化后无法对齐第三个坑在实体对齐。LLM 非常擅长使用自然语言称呼实体例如“那个支付接口”“用户模块里返回用户状态的函数”。这种描述对人来说很友好但机器根本没法匹配。为了解决这个问题我在提示词里明确要求实体名称必须使用代码库中真实的符号名必要时带上模块路径。如果模型在回答中使用了非代码文字解析端会标记为“未对齐”并尝试通过语义相似度匹配到最近的符号名。匹配不上就放到待办列表不强行进入分析层。这一步非常耗时但它决定了后续查询的价值。没有对齐到真实符号的结论最后基本都会变成噪音。4.4 复现很困难版本和推理参数都要固化最后一道坎是复现性。如果两次分析的模型版本不同、量化方式不同、temperature 参数不同得到的记忆卡片可能会有明显差异。哪怕只是 prompt 里多了一个空行都可能改变模型对指令的服从程度。因此Lemmalog 在每次采集记忆时都会记录一份“分析上下文头”包括模型名称、量化方式、temperature、top_p、prompt 版本、代码库 commit 号。这看起来繁琐但一旦出现结论偏差它是唯一能让人追回去的手段。现象优先排查常见原因结构化输出解析失败输出格式、JSON 转义模型返回了 markdown 代码块断言和真实代码对不上符号名、文件版本模型读到了过期代码上下文越长越不稳定上下文裁剪策略早期内容被注意力机制遗忘两次分析结果不一致模型版本、temperature推理参数未固定5. 一个可以复用的“五步过滤法”如果看完前面内容你并不是一定要用 Lemmalog也可以把这套思路提炼成自己的方法。我最终沉淀出的流程是一个五步过滤法。5.1 五步过滤从原始对话到可查询结论第一步采集。把 LLM 分析过程中完整的提问、回答、检索片段都记录下来。这一阶段不用做任何过滤宁可多存不可早扔。第二步过滤。根据“是否可验证”和“是否具体”两个维度筛掉无效信息。诸如“这段代码看起来有点乱”这类感受性评价直接丢弃“A 模块在调用 B 模块的时候没有校验返回值”这类可验证断言保留。第三步结构化。把保留的断言转成统一格式的元组或 JSON至少包含实体、关系、方向、证据、置信度。这一步是 Lemmalog 的主要工作。第四步交叉验证。用 AST、调用图、类型检查器甚至单元测试验证每一条断言。验证通过的进入分析层未通过的进入待人工确认列表。第五步查询/可视化。将验证通过的结论导入图数据库、关系表或报告模板。按需查询“哪些模块存在循环依赖”“这个函数被哪些上层模块调用”输出的不再是 LLM 的原始文字而是可复核的分析事实。这个五步流程的价值在于把一次性的对话经验变成可持续迭代的分析资产。你第一次分析完一个模块后留下的记忆卡片可以在下一次重构、评审、新人入职时被重新查询和使用。5.2 这套方法适合谁、不适合谁说清楚适用边界非常重要。它不是万能的。适合的场景包括中小型代码库的前期摸查。需求评审前的技术分析草稿。新成员快速理解系统结构的辅助材料。用 LLM 对某个模块做探索性分析再把结论交给人工复核。不适合的场景包括需要强正确性保障的生产级静态分析例如安全漏洞判定。代码规模极大、上下文无法覆盖完整项目且没有分区策略时。团队没有配置固定模型版本和提示词版本管理导致结论无法复现时。分析结果需要作为发布阻断依据且没有人工复核机制时。如果你需要的是一种“理解代码的辅助工具”这套思路非常匹配如果你希望 LLM 直接产出“可信的分析结论”那还需要在复核层做大量确定性工作。5.3 长期维护把它当数据工程而不是一次性的 prompt 工程最后一点建议是心态上的。不要把 Lemmalog 或类似方案当成“调一个提示词就能完成的任务”。它更接近一个数据工程问题你要持续维护提取规则、对齐实体、更新复核策略并在每次代码变更后重新生成记忆卡片。我自己的习惯是每隔一段时间抽查几条已进入分析层的历史断言用最新代码去重新验证。因为代码库一直在变化之前成立的结论可能已经过时。这个过程很像维护测试用例越早把它当作系统的一部分后面就越省力。Lemmalog 本身多半不会成为一个多么正式的工具但它让我确认了一件事LLM 和程序分析之间缺的不是智能而是“可验证的记忆”。只要能把记忆结构化、可追溯、可复核LLM 就能从一个随时会忘事的临时助手变成分析工作流里真正能被信赖的前端。如果你也想做类似的事情我建议不要急着搭复杂架构。先拿一个小模块用一条带 JSON 输出要求的 prompt 跑一次把结果手工抽成表格再用 AST 或调用图去比对。跑通这一小段你大概就能理解 Lemmalog 为什么要这样设计。剩下的工程化等真正遇到规模问题再补也不迟。