从LLM记忆到程序分析:代码场景下的结构化记忆设计

从LLM记忆到程序分析:代码场景下的结构化记忆设计 最近看到一个很有意思的标题I accidentally turned LLM memory into program analysis。这句话特别像那种“做着做着发现方向偏了结果偏到了一个更有意思的方向”的现场记录。我顺着这个标题想了一段时间发现它值得展开讲。很多人在做 LLM 应用的时候第一反应是给模型加一个“记忆”用来记住用户偏好、历史对话、知识库条目。但一旦我们把记忆的对象从“人说的话”换成“代码结构和调用关系”这个系统本质上就不再是记忆工具了它变成了一个程序分析器。这篇文章想拆清楚三件事为什么 LLM memory 会滑向 program analysis这个思路能不能在本地用最小方案复现复现之后它能解决哪些实际工程问题又会在哪里翻车。如果你正在做 LLM 应用、智能代码助手、代码库问答或者只是对“记忆系统到底在记忆什么”好奇这篇值得往下看。1. 为什么 LLM memory 会变成 program analysis1.1 记忆系统原本关心的是“记住什么”先回到 LLM memory 的本源。普通聊天型记忆系统记的是用户说过什么、做过什么、偏好什么。它的核心数据结构是“用户画像 历史会话摘要”更新策略是“新对话覆盖旧对话”检索方式是“根据当前问题找最相关的历史片段”。这套东西跑在客服、助手、推荐场景里没有问题因为它关注的是语义偏好不需要精确到“哪个函数调用了哪个函数”。但工程场景不一样。当你想让 LLM 辅助写代码、改代码、审代码的时候它不能只记住“用户上次说这个模块有点慢”它必须记住“这个函数被谁调用、这个变量从哪里来、这个类的状态什么时候被修改”。这些内容一旦进入 memory数据结构就会从“自然语言摘要”变成“实体关系图”。此时 memory 不再负责记流水账它开始回答三类问题这段代码的输入来自哪里这个改动会影响哪些下游调用这个模块里哪些定义是重复的、过期的、互相矛盾的如果你问一个 LLM agent 这些问题它必须有一个能支撑精确推理的知识底座。这个底座通常就叫 memory但它的内容组织方式和程序分析里的符号表、调用图、数据流关系几乎没有区别。1.2 换成代码场景后记忆系统开始回答“哪里影响了哪里”传统程序分析要做的事情简单说就是在不运行程序的情况下分析出程序的静态结构、依赖关系和潜在风险。典型产物包括调用图、控制流图、数据流分析、指针分析、污点传播路径。而 LLM memory 一旦面向代码它的目标就变成了记住整个代码库里与当前任务相关的上下文并在需要时把它们取出来。这两件事看似不同实际是同一件事的两面。传统程序分析是“先建图再在图上查路径”LLM memory 是“先建索引再根据问题召回上下文”。记忆系统里的 key 是函数名、变量名、文件路径、模块标识value 是源码片段、语义摘要、约束条件更新逻辑是依赖变化时同步刷新相关条目检索逻辑是找到与当前改动最相关的定义和调用链。这么一拆你就能理解为什么作者会说“I accidentally turned LLM memory into program analysis”。他不是故意做了一个静态分析器而是做记忆系统的过程中为了让 agent 能正确回答代码问题不得不把 memory 设计成带关系、带依赖、带失效机制的结构化存储。这个结构天然就是程序分析的骨架。2. 从“意外”中拆出三层核心机制2.1 上下文构建就是程序切片程序切片是程序分析里的经典技术。它做的事情是给定某一行代码和某个变量找出所有可能影响这行代码、这个变量取值的语句集合。换句话说它把整个程序缩小成一个“只与当前问题相关”的子集。LLM memory 在代码场景里做上下文构建时也在做同样的事。假设你要让 agent 解释“这个函数返回结果为什么可能为空”。如果 memory 只给你函数本身的源码模型很难回答如果把参数来源、调用点、可能的空值赋值路径都塞进上下文模型就能给出合理推断。这里的关键是“选择性取用”。很多人一开始会把整个仓库塞进 prompt然后发现上下文窗口不够、费用爆炸、回答被不相关内容干扰。正确做法是像程序切片一样只把与当前问题相关的函数、变量定义、调用关系取出来。这个取用策略本质上决定了你的 memory 是“程序分析器”还是“垃圾桶”。我一般会先用一个小项目做实验只取三层信息当前函数或类的源码当前函数直接调用的其他函数和全局变量当前函数的直接调用方这三层已经能解决很多“为什么”“会不会影响”“能不能改”的问题。再多一层token 消耗会急剧上升但回答质量未必提升。2.2 记忆条目就像符号表与调用图传统编译器里符号表存储变量、函数、类、模块的定义和使用信息调用图存储函数之间的调用关系。LLM memory 要把代码库记住最终也会落到类似结构上。比较直接的做法是把代码库按函数、类、文件切块每一块生成一个记忆条目条目里除了源码还要记录它依赖了哪些其他条目以及它被哪些条目依赖。这个双向依赖关系就是调用图的雏形。下面这个表格可以比较直观地看到两个领域的对应关系LLM 记忆系统传统程序分析共同职责上下文片段程序切片缩小分析范围记忆条目符号表 / 调用图节点存储实体定义与引用关系记忆检索 Top-K别名分析 / 指针分析找出相关定义和引用路径记忆更新策略增量编译 / 失效分析保持分析结果与源码一致记忆阈值与压缩抽象解释精度平衡效率与分析精度这样一看就清楚了记忆系统并不是“给 LLM 加了个数据库”它其实是在做一套面向自然语言查询的前端底层是一个简化的程序分析引擎。2.3 检索命中和别名分析是同一件事程序分析里有一个很麻烦的问题同一个变量可能通过不同的名字引用到同一个对象同一个函数可能有多个调用路径。要做精确分析就必须处理别名。LLM memory 在检索时也会遇到类似问题。举个例子。用户问“改动 create_client 会影响线上吗”但代码库里可能有一个别名函数get_client内部调用了create_client还有一个装饰器包装了它。如果 memory 只按文本相似度检索可能只找到create_client本身而漏掉get_client和装饰器调用链。要解决这个问题memory 不能只在“字符串字面量”层面检索它需要维护“谁定义了这个符号、谁引用了这个符号、有没有间接引用”。这就是别名的记忆化。所以如果你发现自己的 memory 检索经常漏上下文不要只调大 Top-K先想想你的记忆条目里有没有维护符号的“定义点”和“引用点”。没有这个映射再多上下文也是碰运气。3. 在本地复现这个思路最小可运行方案3.1 环境与依赖下面这个方案不需要很重的环境本地一台普通开发机就能跑。我建议按这个最低配置准备Python 3.10 或更高版本一个 LLM 接口可以是本地模型也可以是云端 API关键是能传入自定义 system prompt 和上下文片段一个简单的向量检索库比如chromadb或faiss也可以用简单的文本匹配先验证一个 3 到 10 个文件的小型 Python 项目作为测试对象如果你只是先跑通流程甚至可以先不接向量库。用一个 Python 字典把“函数名 - 源码块”存起来按函数名精确匹配就行。向量检索是在命名不那么一致的时候才需要的优化第一步不用急着上。3.2 第一步按函数切块并写入 memory先把代码库按函数或类切块。这里的关键是切块粒度。按行切太碎按整个文件切太粗。我建议按“函数 / 类 / 模块级变量”为单位。可以先用 Python 自带的ast模块做切块不需要额外依赖import ast from pathlib import Path def extract_functions(file_path: str): source Path(file_path).read_text(encodingutf-8) tree ast.parse(source) blocks [] for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): block ast.get_source_segment(source, node) if block: blocks.append({ name: node.name, file: file_path, source: block, lineno: node.lineno, }) return blocks这段代码不对源码做任何修改只做安全解析。解析失败时会抛出语法错误这时候说明目标文件不是合法 Python 文件。实际项目中可能混入生成代码、拼接代码、加密代码这些都是正常的遇到就跳过并记录文件名。3.3 第二步让 memory 维护依赖关系切块之后要识别每个函数引用了哪些其他函数。这一步可以继续交给astfor node in ast.walk(block_ast): if isinstance(node, ast.Call): func node.func if isinstance(func, ast.Name): dependencies.add(func.id) elif isinstance(func, ast.Attribute): dependencies.add(func.attr)一个函数体里调用的函数就是这个函数依赖的符号。把这些依赖存进 memory 条目就得到了最简单的调用图。注意这里只做了一级依赖。真实代码里A 调用 BB 调用 C当你问“改 A 会影响谁”时只查一级依赖会漏掉 C。进阶做法是做多级 BFS但第一步先用一级验证流程能跑通再扩展。3.4 第三步用检索结果回答“改动影响”类问题整理好 memory 之后可以让 LLM 基于检索结果回答问题。流程如下用户提问“改动create_client函数会影响哪些调用方”从 memory 中查出create_client的源码块和它的直接调用方。如果有需要再查调用方的调用方做多级展开。把所有相关源码块组装成上下文。把上下文和问题一起发给 LLM。组装上下文时注意控制总长度。我一般会先用一个token_limit变量限制片段数量比如最多 8 个片段每个片段最多 200 行。超出部分不塞进 prompt只标记“存在但未展开”。3.5 验证结果是否有效跑通之后验证标准不是“模型回答得很流畅”而是“回答里提到的调用关系是否和代码实际匹配”。我建议用一个小项目做三组测试测试问题期望结果直接调用方是谁回答里的函数名必须和代码中的调用点一致多级影响范围至少覆盖第一层调用第二层可选删除某函数的影响回答要提到报错路径而不是泛泛说“可能有影响”如果前两组都不过问题大概率不在 LLM而在 memory 切块或依赖提取。先把检索结果打印出来看确认“和这个问题相关的函数有没有被查出来”再决定是调模型还是调检索。注意这里不要一上来就换大模型。先确认检索出来的上下文是不是你想要的再考虑模型能力问题。4. 这个思路能解决什么真实问题4.1 跨文件关联分析很多代码库的问题不是单文件内能看出来的。一个函数在 A 文件定义在 B 文件里被调用在 C 文件的配置里被注册在 D 文件的测试里被断言。传统人肉查找要切来切去用全局搜索又容易漏掉动态导入。如果把“文件名 符号名 调用点”作为 memory 条目存下来agent 在回答跨文件问题时就不需要把所有文件都塞进上下文。它只需要先定位符号定义点再沿引用关系往外查。这正是程序分析中“定义-使用链”的思路。我实际体验下来这个思路在两类任务里非常有效一是“这个函数到底在哪里被使用”二是“这个报错是从哪条调用链传过来的”。前者靠定义-使用索引后者靠调用链检索。4.2 修改影响评估改一处代码影响哪些模块这个判断对开发者和代码评审都很有价值。传统做法依赖测试覆盖率和人工经验LLM memory 可以做得更细。做法是修改前把变更的函数名找出来修改后从 memory 里提取它的调用方和间接调用方最后让 LLM 根据调用链判断影响范围。即使 LLM 只做了一层关系推断也能给开发者一个比“全局搜了一遍”更完整的候选清单。这个场景里重要的不是 LLM 是否理解业务逻辑而是它能不能拿到“被谁调用”的结构信息。结构信息来自 memory不来自模型温度。4.3 比单轮问答更稳的原因直接给 LLM 一个代码文件问“影响谁”它也能回答但稳定性取决于你能不能把相关代码都放进 prompt。一旦文件多了上下文装不下它就只能靠猜。有了 memory就可以把“检索”和“推理”拆开。检索负责精确召回相关代码推理负责基于代码做判断。这样即使模型是通用模型只要召回足够准输出就不会太离谱。这也是为什么说“LLM memory 变成 program analysis”不是坏事反而是一种更稳的架构选择。5. 边界、坑点和排查顺序5.1 记忆膨胀与上下文窗口最常见的坑是 memory 越积越多最后每次检索都要在大量条目里找。这就像程序分析的复杂度失控节点少了可以暴力遍历节点多了必须做索引、剪枝和分层。解决办法是给记忆条目加“新鲜度”和“优先级”。最近被检索到的条目录优先级高长期没被访问的条目可以压缩成摘要。不要试图把所有历史都留在全量上下文里那是把上下文窗口当堆内存用迟早溢出。5.2 过期记忆代码改动了memory 里可能还留着旧版本。如果 LLM 基于旧版本回答就会误导人。这是“LLM 代码记忆”最危险的坑。处理策略是每次代码变更后找到对应的记忆条目标记为“待失效”。有两种做法一种是直接删除旧条目、写入新条目另一种是保留旧条目并打上过期标签让 LLM 在检索结果里看到变更历史。第二种适合“用户想知道为什么之前会有这个实现”的场景。5.3 误判和幻觉LLM 即使拿到了正确上下文也可能在推理时夸大影响范围、编造不存在的调用关系。这时候要设计验证手段。我的建议是凡是程序结构类的结论都要求 LLM 给出“证据”也就是具体文件路径和行号。如果模型拿不出证据就降低它结论的置信度。更进一步可以在后端用ast对模型的结论做一次校验比如把模型回答的函数名和实际依赖集合做交集交集之外的可以标黄显示。5.4 基础运行环境问题除了业务逻辑问题还要留意两个运行环境问题。第一memory 系统长期运行会占用大量内存日志里出现 OOMout of memory时先查记忆条目数量和向量索引大小不要急着调 LLM 参数。第二某些本地推理框架在加载大模型或长上下文时可能会直接中断进程退出码类似 0xc0000005 或 segmentation fault。这种情况大多是基础组件或系统环境问题和程序分析逻辑无关先确认模型服务、驱动版本和虚拟内存配置。5.5 排查链路清单如果结果不对我一般按这个顺序排查先看现象是检索不到还是检索到了但答案不准。再看记忆内容输出 memory 条目确认切块是否正确、依赖是否缺失。再看检索参数Top-K 是不是太小、相似度阈值是不是太高。再看上下文组装有没有超出模型窗口有没有把无关片段混进去。最后才看模型本身换更强模型之前先确认前四步没问题。注意很多“记忆不生效”的问题根源是更新策略没做好而不是检索代码写得不好。6. 给想做这个方向的人几条实用建议6.1 先当程序分析器用别当记忆库用如果你做的不是带严格关系结构的记忆而只是把对话历史存下来那它很难支撑代码分析任务。我的建议是一开始就按“程序分析器”来设计确定实体、确定关系、确定失效规则。这样即使后面不专门做程序分析这套结构也不会白做。6.2 记录结构而不是记录原文很多 LLM memory 实现喜欢存“原文”。原文在检索时是精确的但代价是占用大、更新难。更好的做法是存“结构化事实”文件 A 的第 22 行定义了函数create_client函数create_client被get_client调用get_client从文件 C 的配置中读取超时时间。这些事实组合起来就是一个轻量级调用图。LLM 只需要在这个图上做推理不需要每次都读全量源码。6.3 建立失效机制程序分析里最重要的一件事是“分析结果必须反映当前代码”。LLM memory 也一样。没有失效机制的记忆就是一座会过期的地图。建议每次代码提交前后都触发一次记忆更新重新生成变化文件的条目并重新计算依赖关系。如果做不到实时至少要做到“发现文件变化时提示用户记忆过期”。6.4 不要一上来就追求高并发和大模型做 LLM memory 的第一个版本完全可以用单进程、小模型、简单字符串匹配来跑通。重点是验证“检索 结构化条目 LLM 推理”这个链路。链路验证成功后再考虑向量检索、并行更新、多级调用图这些优化。否则很容易在还没验证有用之前先被工程复杂度拖垮。踩过几次之后我发现很多问题不是工具能力不够而是前置环境和输入材料没有处理干净。LLM memory 变成 program analysis 这件事看起来是一次方向偏移实际上是一个提醒当我们把记忆的对象从“自然语言”换成“代码结构”就必须接受一个新的约束——记忆不能只是模糊的摘要它必须能支撑精确查询、关系推导和失效判断。如果你也在做类似方向建议先把这个“意外”当作正式需求来对待。把你想分析的关系列成一张表把代码块切成能查询的单元把依赖和引用记下来然后再把 LLM 接到这个结构上。这个顺序比先接 LLM、再补记忆要稳得多。