用AST拓扑剪枝,让AI帮你绘制十万行代码的架构全景图
发布时间:2026/10/15 23:33:46 锦皓数字建站

我最近在梳理一个内部系统的架构代码量正好卡在十万行左右。这个体量的老项目有个通病文档停在四年前模块之间互相调用得像一团毛线想画一张高精度架构全景图几乎无从下手。我最初试过直接把代码丢给 AI 让它总结全貌结果出来的东西看着“像模像样”拿去评审会上被别人一问就露馅它把两个根本不相关的模块说成有依赖把正确的分层层级完全弄反了。说白了AI 在局部代码解析上很强但面对一个十万行的大仓库它既看不全也看不懂全局关系。后面我换了个思路不再把源码当原料喂给 AI而是先用 AST 语法树做拓扑剪枝把代码库压缩成一张“仍然保留结构”的骨架图再把骨架交给 AI 去绘制架构全景。这个做法在几个内部系统的梳理里都试出了不错的效果今天就把整个思路、具体操作和踩过的坑完整复盘一遍。1. 十万行代码为什么会让 AI “全瞎”先说结论不是 AI 能力不行而是任务的目标就不该是“读完整套代码”。当你把十万行原始代码摆在 AI 面前时它既没有能力也没有必要把所有细节都装进脑子。真正值钱的不是每一行代码而是代码之间的关系。这里面的问题可以分为三层。1.1 上下文窗口AI 书桌只有那么大大模型的能力再强也有物理上限就是上下文窗口。算一笔账十万行代码平均一行就算 10 到 20 个 token加起来就是一百到两百万 token。现在主流通用模型虽然在中长上下文上一直在加码但离“完整装下十万行项目”还是有量级差距更不用说装完之后你还要它做推理和输出。我见过有人不服气说“分块读取不就行了”。分块确实能绕过窗口但马上带来另一个问题AI 看第 100 个文件的时候前面 99 个文件里的依赖关系它已经记不清了。不是它不想记是模型本身没有外置记忆机制跨块的关联只能靠上下文里还留存的只言片语去衔接。结果就是它理解每个模块时是“局部清醒”的一旦需要全局视角它就开始打马虎眼。这里有一个反直觉的点信息量太大时有效信息反而被淹没了。十万行代码里可能有九万行是局部实现细节而架构判断只需要一万行的“骨架信息”。不先做削减AI 收到的就是噪音远大于信号。1.2 局部拼图每个模块都懂模块之间全靠猜把代码分块喂给 AI 以后最典型的结果是这样你单问“登录模块干什么”它回答得很准确再问“订单模块干什么”也没问题但你让它说出这两个模块之间的调用链路它就开始用“概率补全”的方式给你编。我曾经测试过一个内部系统的两个模块A 模块明明只是通过消息队列异步触发 B 模块而且中间还要跨一个持久化层。AI 第一次给出的全景图里直接画成了 A 调用 B 的同步方法链还一本正经地标注了“数据一致性风险”。其实就是两个服务之间没有直接依赖都是异步事件这个风险点根本不存在。更麻烦的是AI 在第二次提问时会给出完全相反的依赖方向因为它自己也是在猜不是真的从代码里“读”出来的。这种错不是模型笨而是它缺乏可靠的结构证据。它没有维护一张真实的依赖图它的判断依据是训练时见过的“这种代码长这样一般会是那样”本质是统计相关性不是事实确定性。要让 AI 从“猜”变成“推”需要先把代码库里真正的结构证据提取出来拿出来。1.3 我们要的全景图其实是“骨架”那到底什么是高精度架构全景图我的理解是至少包含这四样东西分层哪些模块是基础设施层哪些是业务逻辑层哪些是入口层依赖方向谁依赖谁谁不该依赖谁什么方向是“逆着架构设计”的核心链路从某个用户请求入口出发经过哪些关键模块最终落到数据存储稳定边界哪些模块是相对稳定的底层哪些模块天天变还牵一发动全身这四样东西有一个共同特点它们描述的是“节点和边”的结构信息而不是“节点内部实现”的信息。也就是说全景图最需要的是一张有层次的拓扑图至于图中每个节点内部有多少复杂分支、有多少魔法变量那是另一码事不应该混进当前任务里。明白了这一点接下来的方案就很自然把代码变成 AST再做拓扑剪枝把“树叶”剪掉、把“树干”和“边”留下再把干净的结构骨架交给 AI。2. AST 拓扑剪枝帮 AI 把代码“脱敏成骨架”2.1 AST 不是“读代码”是“读语法结构”AST 全称是抽象语法树是编译器在解析源代码时生成的一棵结构化树状表示。它把一段代码拆成这样的层次一个文件是一个 Program 节点Program 下面挂着 Function、Class、Import 这些顶层声明Function 下面又挂着参数、函数体、Return 语句Return 语句下面挂着表达式、变量、函数调用举个例子login(user)这个函数在 AST 里不是一行字符串而是一个结构函数名是login参数列表里有一个标识符user函数体里可能有一条调用getDB()的语句。AST 已经把“调用关系”这种语义信息显式挂在树上了你不需要用正则去匹配字符串直接遍历节点就能拿到“谁调用了谁”。但这里有个麻烦AST 的原始体积通常比源代码本身还要大 5 到 20 倍。因为语法树会为每个括号、每个运算符、每个局部变量都建一个节点。如果整棵 AST 直接丢给 AI那比纯文本代码还要糟糕相当于给了 AI 一堆已经拆到芝麻粒大小的零件根本看不出机器长什么样。所以必须做剪枝。剪枝的核心思想是根据下游任务决定是保留符号级信息还是模块级信息。2.2 剪哪些、留哪些完全取决于你想画什么图既然我们的目标是“架构全景图”那节点的保留标准就是“和结构、依赖、边界相关的信息保留和局部实现相关的信息折叠”。我实际操作时用的是下面这张取舍表。保留折叠或丢弃模块、包、命名空间的声明函数体内部的具体运算逻辑类、接口、函数签名局部变量的赋值链Import / Require / Include 语句字符串字面量、数字字面量、正则函数调用表达式作为依赖边复杂条件分支的具体判断过程可见性关键字、装饰器、注解注释和代码格式全局常量、宏定义嵌套过深的子表达式明细一个重要的原则是调用表达式是最值钱的节点类型。因为在所有 AST 节点里只有调用表达式能直接构成有向依赖边。调用表达式告诉你是 A 函数里真的掉过了 B 函数的这是架构全景图最需要的实锤证据。如果我剪枝时把调用表达式也删了那整张图就变成了一堆孤立的模块名没有任何关系可画。举一个具体剪法。假设有一个“交易引擎”模块里面有二十个函数每个函数都有很长的内部实现。画架构图时这二十个函数会被折叠成一句话“交易引擎模块暴露了createOrder、cancelOrder、settleOrder三个公开方法内部调用链涉及库存、账户、支付三个子模块。”折叠后的 node 信息量很大但 token 消耗很少AI 能更快抓住这个模块在系统里的位置和职责。2.3 拓扑排序从一团乱麻里抽出依赖主线剪完树之后我们得到一个“节点变少、但边依然密集”的图。光有图还不行还得给这个图一个顺序主线这个操作就是拓扑排序。拓扑排序做的事情很简单如果 A 依赖 B那在最终排序里 B 就要排在 A 前面。对整个依赖图做一遍拓扑排序你能得到一张“模块层级表”最底层是那些谁也不依赖的基础库最顶层是直接面向用户的入口模块中间层按依赖关系逐层往上堆。但真实代码库会有各种复杂情况最常见的就是循环依赖。比如模块 P 调用了 QQ 又调用了 P这时候标准拓扑排序会失败因为谁也排不到谁前面。遇到这种情形工程上不是直接放弃而是把相互依赖的节点合并成一个“强连通分量”也就是一个闭包。强连通分量这个概念特别适合画架构图。它告诉你在庞大的代码库里有这么几个“纠缠成团”的区域这些区域内部已经分不出谁高谁低了但整团和外部其他模块的边界是清楚的。架构评审里这些强连通分量往往就是最值得人肉重点排查的“异味”也是全景图上最显眼的边界区。剪枝时把强连通分量作为一个聚合节点保留保留率可以大幅度降低同时不会丢失关键信息。3. 一次完整的剪枝实操记录这一节我拿真实的处理流程来写大家可以把这套流水线直接搬到自己的代码库上跑一遍。整个链路一共四步解析建表、构图合并、加权剪枝、喂给 AI。3.1 解析与建表先有符号表才有依赖关系剪枝的第一步是选择一个支持多语言的 AST 解析器对所有源文件做一遍解析。解析器的选择有讲究要选那种能输出“节点类型”和“子节点范围”的工具因为后面需要根据节点类型区分 Function、Import、CallExpression。解析完成后不要急着画图先建一张全库符号表。符号表就是把代码里所有的命名实体统一登记的地方。我一般这样组织符号表# 伪代码解析并收集符号 # 不同语言的 AST 节点类型各不相同这里只画核心思路 def collect_symbols(file_node): for node in traverse(file_node): if node.type in [function_definition, class_definition, module_declaration, import_statement]: symbols.append({ id: id(node), name: node.child_by_field_name(name), qualified_name: f{module_path}.{node.name}, type: node.type, line: node.start_point[0], file: module_path, dependencies: [] }) elif node.type call_expression: callee_name node.child_by_field_name(function) symbols[current_context][dependencies].append(callee_name)这张表有两个关键作用。第一它把“模块路径 符号名 文件位置”绑定在一起后续合并模块级依赖时可以直接查表。第二它能做“引用解析”文件 A 里出现了一个getUser的调用只有从符号表里查到getUser定义在文件 B你才有底气记录一条“A → B”的依赖边。这里要特别提醒解析时要保留限定名。不要只存func_123这种短名一定要存成module_path.ClassName.method_name这种完整形式。不然后面两个模块里同名方法会被当成同一个符号架构图直接画错。3.2 构图与强连通分量合并把互相纠缠的模块先揉成团符号表建好之后第二步是根据引用关系构建模块级有向图。这个有向图通常是这样定义如果模块 A 中有一个调用表达式引用到了模块 B 中任意一个符号就增加一条边 A → B。这里还要区分边类型。我在实际处理时会把依赖分成三类import 依赖文件头部显式 import 出来的包依赖调用依赖在函数体内实际调用了另一个模块的函数实现依赖类 A 继承了类 B或者实现了接口 B分完类之后信息会清楚很多。比如 A import 了 B 但实际没调用 B 的函数这条依赖其实很弱剪枝时可以选择丢掉如果 A 调用 B 了那就是硬依赖必须保留。构建完整图之后接下来就该做强连通分量合并。这一步不仅能筛出循环依赖还能大幅降低节点数。我今年梳理的一个十万行后端系统第一步解析出来有 548 个模块直接硬画全景图完全是人眼灾难做了一遍 SCC 合并之后只剩 163 个强连通分量很多互相纠缠的模块被合并成了“一个业务包”架构图上终于能看出边界来了。SCC 合并还有个额外价值合并完的每个强连通分量生成的时候要保留一份内部成员清单。这样 AI 看到“业务包 X”时如果需要深入内部还能展开看明细。这些明细不进主提示词但可以留着做二次追问。3.3 加权剪枝核心节点靠“扇入扇出”说话第三步是加权剪枝。这步的目的是决定哪些节点要留在主骨架里哪些节点要折叠掉。我给每个节点算一个权重分公式很简单权重 扇入数量 × 2 扇出数量扇入就是有多少个其他节点依赖它扇出就是它自己依赖了多少个其他节点。这个公式背后的直觉是被依赖得多的节点通常是基础设施级别的稳定模块必须保留依赖很多别的模块的节点往往是入口层或门面类模块在架构图上“牵一发而动全身”也要保留。除了这个基础权重还需要叠加一个复杂度因子。如果一个函数内部的分支和循环特别多说明它承载了大量业务规则。这种节点即使在拓扑里不是绝对核心也值得保留一个签名摘要。我一般用“分支数量 循环数量”做粗糙估算达到一定阈值就直接标记为“高复杂度节点”。剪枝时按权重从高到低排序保留前 N 个节点作为主骨架。这里 N 可以根据下游任务调。如果要画给新人看的全景图保留比例低一点如果要给架构师评审用保留比例高一点。剪掉的节点不是直接丢垃圾桶而是折叠成一行“摘要描述”挂在它上一级聚合节点下面。我用过一个很管用的剪枝规则每条依赖边的两端至少有一端是保留节点。如果边两端的节点都被剪掉这条边就跟着没了但如果一边保留一边剪掉这条边必须保留因为它连接着骨架和外部的过渡区域。这个规则能有效避免把关键的连接关系误删掉。3.4 骨架喂给 AI提示词里必须说清“这是剪过的”图和骨架都做好以后才轮到大模型上场。这一节的核心经验是提示词里必须明确告诉 AI你喂给它的是剪枝后的依赖骨架不是完整源码。要提前说清楚边界AI 才会老老实实在骨架范围里推演而不是自作主张去补全。我通常用这样的提示词结构背景 你是一位资深系统架构师。下面是一个十万行代码库经过 AST 拓扑剪枝之后的依赖骨架。 请注意这是剪枝后的结构信息不是完整源码。你只能基于给出的模块和边做分析。 如果没有某条边禁止假设存在该依赖。 输入 - 模块列表包括模块的唯一标识、简短职责、内部大致结构 - 依赖边包括发起方、目标方、依赖类型import / call / impl - 关键函数仅列出高复杂度或高扇入函数的签名用于辅助判断 输出要求 1. 给出分层架构判断标注哪些是基础层、业务层、入口层 2. 给出核心调用链路从请求入口到数据存储的路径 3. 标出强连通分量和循环依赖风险 4. 列出你无法确定的依赖点用“存疑”标记替代猜测这个提示词里最重要的是最后一条“列出你无法确定的点”。它会逼着 AI 把“说不准”的地方摆到台面上而不是悄悄糊弄过去。我实测下来加了这条之后AI 返回的内容里多了很多“该模块依赖来源无法由骨架确认”的备注这些备注恰好帮我定位到了几个原本靠人肉排查漏掉的动态加载点。如果希望 AI 输出结构化结果用于后续绘图可以让它返回 JSON 格式里面分layers、relations、risks三个字段。有了 JSON 之后直接喂给画图工具或者再让 AI 用一段图结构文本描述来表达都很方便。4. 剪枝参数怎么定我用的三个量化指标剪枝最容易被问的问题是“到底剪多少合适”答案是没有普适参数但可以用三个量化指标来定分别是节点保留率、边保留率、语义失真度。4.1 节点保留率剩下多少才算“精”节点保留率 剪枝后保留的节点数 / 原始全量节点数。这个指标直接决定了骨架图的密程度。我自己的经验区间是场景节点保留率备注给新人做系统概览2% - 5%只画核心业务链路和分层常规架构评审5% - 10%保留核心分层关键依赖边齐全重构风险排查10% - 15%尽量保留高复杂度节点支持细化分析不要一上来就追求“越少越好”。节点保留率太低AI 能看到的模块聚合程度太高反而会把一些平级模块错当父子结构。我一般第一次跑先定 10%如果 AI 给出的分层判断出现大面积“无法判断”就往回调高一点。4.2 边保留率丢边等于丢关系边保留率 剪枝后保留的依赖边数量 / 原始依赖边总数量。我提醒大家注意边比节点更不能乱丢。节点剪掉 90%场景里还剩 10% 的模块名很多时候够用但依赖边如果也被剪掉 90%那剩下的模块之间就几乎没有任何连接AI 看到的就是一大堆失联的模块它只能靠语义去猜它们之间的关系这恰恰是我们最想避免的情况。我在实操里的硬性要求是边保留率不低于 70%。这个标准有两个理由。第一依赖边本身的 token 成本很低每条边只是一对 ID 加一个类型标签留着不心疼。第二架构全景图本质上就是靠边来呈现的边是图的灵魂。如果边确实多到爆炸可以考虑合并而不是删除。比如把 5 条 A 模块到 B 模块的调用边合并成一条“A 频繁调用 B”的聚合边保留次数和大致调用方向信息这样既压缩了体积又没有丢掉关系。4.3 语义失真度怎么自查剪完的东西还能不能用这是三个指标里最主观、也最重要的一环。语义失真度指的是剪枝后骨架还能不能准确还原“关键业务路径”上的逻辑。我用的自查方式比较简单粗暴——抽关键链路验证。第一步在剪枝前的完整图里手动挑 3 到 5 条“入口模块 → 中间业务模块 → 数据存储模块”的核心链路。 第二步在剪枝后的骨架图里沿同一方向找那些链路节点看它们是否还在、边是否仍然连通。 第三步如果某条链路上任何一个节点被剪掉了就把它强制加回保留集合。这一步操作成本不高但能直接防止“剪枝把核心业务流程剪断”的灾难。我有一次对某个订单系统剪枝节点保留率已经压到 4%但自查时发现“查询订单”这条路线上一个关键服务被我剪掉了。原因很简单那个服务扇入不高分数不高但它功能极其核心。把它强制拉回保留集合之后全景图质量明显改善。实际跑流水线时我会把这三个指标放到一次迭代里。先自动剪再用关键链路强制修正最后抽样看 AI 输出。如果 AI 在回答“某个模块依赖谁”时偏差明显就提高保留率重新跑。5. 踩坑实录剪枝管线里的典型事故与排查再完美的思路落地都会踩坑。这一节里我把自己在实战里遇到最多的四类问题和排错方法整理出来全是很实用的经验。5.1 同名符号被缝合重名函数硬凑成一家第一个大坑来自符号表设计不够严谨。一个十万行代码库里有太多同名函数比如getConfig、init、parse各种模块都有。如果符号表里只用函数名做唯一标识剪枝时会把不同模块的两个getConfig当成一个符号然后错误地给它们连上“同名边”。具体表现是架构图上出现一条本不存在的调用边而且这条边在 AI 生成的描述里会被当成事实。排查方法很简单——看符号表里限定名的唯一性。我后来规定符号表必须采用文件路径 类名 方法名的唯一限定名并且在生成依赖边时只使用限定名做匹配。还有一个补充做法对于解析失败或找不到唯一目标的引用不要硬连边标记为“未解析引用”。这类未解析引用可以在后续提示词里单独以“存疑依赖”的形式提供给 AI让它知道这里可能有依赖但没有证据而不是让它自由发挥。5.2 动态导入变成毛线球剪不出边还爱乱连第二个大坑是动态导入。现代代码里很多模块是运行时才加载的比如根据配置字符串动态 import、反射调用某个类。AST 解析器只能看到静态的 import 和固定的函数调用看不到import(someVariable)或反射加载(com.xxx.Class)这种动态路径。动态依赖一旦多起来剪枝后的骨架图就会在两个方向出问题。一个方向是“漏边”动态加载的模块完全不出现全景图缺了一大块另一个方向是“乱连边”AST 解析器把动态参数里的字符串常量误当成了普通字符没有识别出这也是一个依赖而后续 AI 看到两个模块没有任何关系就默认它们独立了。我的处理办法是在剪枝管线里加一道“启发式扫描”。用 AST 节点里的字符串常量收集所有看起来像路径的字符串再和代码库里的真实路径做匹配命中的就补一条“路由式”依赖边。这种边的可靠性比静态调用边弱所以要标记为“推断边”。在提示词里我会明确告诉 AI推断边不代表一定存在直接调用只代表运行时可能加载。5.3 框架魔法AST 看不见的依赖太大第三个大坑也是老代码库里最常见的坑就是框架帮助我们把依赖从“代码里”挪到了“配置里”。比如依赖注入容器、插件注册表、路由映射、事件总线。这部分依赖在 AST 里完全看不到但运行时它们才是真正的主干道。我曾遇到过一个模块架构图里看它和外部模块几乎没有依赖边但线上运行的时候它通过框架的依赖注入包接了一堆服务实际承担了整个系统的调度中枢职责。如果只看 ASTAI 会判断这个模块是独立的“叶子节点”这显然是错的。解决办法是这样的在剪枝图上增加一个虚拟“外部边界节点”把框架配置、路由文件、依赖注入定义都塞到边界节点里。扫描配置文件里的类名或组件名凡是代码库里能找到对应定义的都连一条“配置绑定”边。这些节点的信息量不大但能让 AI 知道“这里存在一个运行时装配层”避免它把所有关系都看成静态依赖。5.4 校准小技巧先在万行以下项目上“手画图”对照最后一个经验价值极高也比较容易被忽视剪枝管线刚搭好时千万别直接拿去跑十万行代码否则出了错都分不清是剪枝参数的问题还是问题本来就在代码库。我的做法是先拿一个几千行的模拟项目做基线校准。选一个你能完全理解的小项目手动画出它的架构图然后让剪枝管线自动跑一遍。对比自动图和手画图看差异集中在哪是模块粒度太粗还是边被误剪了还是符号表有重名冲突。这些问题在手画图一一对照时一抓一个准等小项目校准通过后再拿同一套参数跑大项目成功率会高很多。这个过程要保留基线脚本。每次调整剪枝逻辑后都先在小项目上跑回归看语义失真度有没有变化。如果小项目失真了大项目不可能不出问题。把这套校验收进自动流程里剪枝参数调整就不再是盲人摸象了。剪枝管线跑顺之后你会发现这件事最大的价值不是那张图本身而是它逼着你去把代码库里最核心的依赖关系重新梳理了一遍。AI 在骨架上画全景图时它不再去猜“谁调用了谁”而是把工具有限的证据翻译成清晰的结构语言。如果你也正面对一个十万行级别的老仓库不妨先别急着让 AI 读源码把它剪成一副骨架再说。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。