☰
AST拓扑剪枝:用AI还原十万行老代码库架构全景图
2026/10/15 1:32:43 网站建设 项目流程

接手一个十万行级别的老代码库时,我最害怕的不是功能跑不动,而是我不确定它到底“长什么样”。这个“什么样”指的就是架构:模块边界在哪、核心调用链路长什么样、哪些地方是真正的枢纽、哪些只是历史包袱。AST 拓扑剪枝就是在我看来目前最值得尝试的一条路:先靠 AST 把混乱代码转成结构化的依赖图,通过拓扑剪枝把图浓缩到 AI 能吞下的规模,最后让 AI 基于这张图生成高精度的架构全景图。

这套思路不是让你放弃读代码,而是把“从零开始读十万行”这种几乎不可能完成的任务,改造成“先由图建立骨架,再按图索骥回到代码验证”的可执行流程。下面我把整条链路拆开讲,包括每个环节为什么这么做、参数怎么调、我实际踩过的坑,以及最后怎么量化验证图的精度。

1. 先说清楚:这题到底难在哪

1.1 “十万行”不是一个量词,是一种状态

十万行代码不是一个复杂度数字,而是一种状态。我处理过一套约 12 万行的遗留订单系统,文件 480 多个,顶层模块 70 多个,函数粗算超过 8000 个。这个量级单看数字不算浮夸,真正可怕的是模块间的引用密度:一个下单接口从入口处理函数到数据库访问层,中间要穿十二层调用链,而且很多调用是跨包绕路完成。

这种代码库的特点是局部看起来都有道理,全局完全没有统一设计。你有 80% 的时间不是花在“理解某一段代码在干什么”,而是花在“搞清楚这一段到底被谁调了、它又调了谁”上。我每次试图人工整理架构图,最后都会陷入一个循环:画到一半发现某个中间层其实还有隐藏入口,只能撕掉重画。一个维护者花两周画出来的架构图,可能只覆盖代码库的三分之一。

更麻烦的是,这类代码库通常没有新员工 onboarding 文档,唯一的“文档”就是代码本身。而你没法靠阅读代码本身来速成全局认知,因为阅读代码天然是线性扫描,可架构是非线性结构。这就是为什么要借助工具先把结构抽取出来。

1.2 人工画架构图会栽在哪

人面对大型代码库时,有三道过不去的坎。

第一是采样偏差。画图的人一定会从自己熟悉的入口开始读代码,他会把这片局部当作全局的骨架,而另外几条隐藏的主链根本不在视野内。我之前见过有人把某个 util 工具库画成全系统的核心枢纽,理由是全项目一半文件都 import 了它,从“被引用数量”看它确实是明星,但从“控制流核心”看它只是个普通人。

第二是认知过载。当图里的节点超过一两百个,普通人的工作记忆就开始爆了。为了让自己看得懂,人会自动简化,最常见的简化就是“把性质不同的依赖合并成一堆箭头”,最后图是简化了,信息也丢光了。

第三是验证成本太高。手画架构图没有中间产物,每一步都是结论,一旦发现某个分支判断错误,整层都要推翻。我有段时间专门帮别人校核架构图,发现人工图的错误率在核心链路部分通常低,但在边缘模块部分高得离谱,因为有谁会专门去翻三个月前画图时没看过的那个包呢。

1.3 AI 裸读代码为什么不行

既然人工不行,那让 AI 直接读代码库呢?我自己试过三次,效果都不理想。

最直观的问题是上下文窗口。十万行代码按平均每行 15 个 token 算,就是 150 万 token,当前通用大语言模型的上下文窗口根本放不下。就算硬塞进去,推理时间、费用和切片的取舍也都不现实。

第二个问题更隐蔽,叫窗口偏见。AI 接收的是一段截断后的代码,它看到的顺序是你喂给它的顺序,不是真实的依赖顺序。它很容易把两个恰好出现在相邻文件里的公共类当成同一层,把一前一后出现的两个毫无关系的服务判断成调用关系。代码库的原貌在 AI 眼里是不存在的,它只能基于更早的经验“猜”。

第三个问题是脑补。语言模型是概率推理器,在信息不足时有天生的补全倾向。你问它“这个模块为什么依赖配置中心”,它就算看不到配置中心的引用,也会给出一个逻辑通顺的解释。这种一本正经的胡说八道,在架构梳理任务里是致命的。

所以结论很清楚:AI 需要的不是十万行文本,而是一张已经去掉噪音、只剩高价值结构的图。谁来做这一步数据加工?AST 加拓扑剪枝。

2. 方案选型:AST + 拓扑剪枝,解决的是什么

2.1 AST 能提供什么“可靠的原料”

AST 是抽象语法树的简称,是源代码经过语法解析后得到的结构化树形表示。编译器、IDE 的语法高亮和自动补全都靠它。它最大的优势是:不关心注释写没写、排版乱不乱、命名规不规范,只严格记录“这段代码声明了什么、引用了什么、调用了什么、继承了谁”。

这直接压过了正则表达式和全文检索。用正则匹配 import 语句,很容易把注释里写的 example import 当真;AST 只会认真正的 import 节点。正则不知道继承关系,AST 可以;正则不知道一个标识符是出现在调用位置、类型位置还是赋值位置,AST 可以通过父节点类型轻松判断。

从 AST 里我们能稳定提取三类架构级事实:模块或文件之间的依赖关系,函数与类之间的调用关系,类之间的继承实现关系。这三类事实就是架构图的原料。拿到它们之后,AI 才有真实可引用的数据,而不是在看到代码前就用“常识”编造架构。

这里我想补充一个体感差异。用正则做依赖扫描,得到的是一堆“可能相关”的符号;用 AST 做解析,得到的是“确定存在”的语法关系。前者适合做搜索,后者才适合做架构分析。

2.2 拓扑剪枝:从十万行到几百个可分析节点

原料有了,第二个难题是图太大。依赖图的规模通常会超出你的预想:文件级 import 图往往有几千个节点,一旦把函数调用关系也加入进来,上万个节点是家常便饭。这个规模别说让 AI 消化,人眼看到渲染图的第一反应就是关掉。

拓扑剪枝听着高深,本质上就是在依赖图上做结构化裁剪。裁剪的核心理念很简单:信息不是越多越好,架构图只需要保留“高架构信息量”的节点和边。一个节点如果只被一处调用且自身没有任何子调用,它在架构意义上基本没有存在必要;一个模块内部的小函数团如果只被一个父模块引用,它大概率只是那个父模块的内部实现。

我拿那套遗留订单库实际跑出来的数字是这样的:初始调用图有 1.2 万个节点、8 万条边。经过三层剪枝之后,剩下 420 个节点、2100 条边。这个规模,AI 一次就能读完,人也能在一张纸上看出层次。

剪枝不是扔掉信息,而是信息压缩。被折叠的叶子函数和内部工具类,它们仍然存在于代码库里,只是不再占用架构图上的宝贵位置。所有被合并的信息,都会以节点的元数据形式留下来,随图一起交给 AI,只是不参与主要可视化布局。

2.3 这套方案的适用边界

适用场景非常明确:老系统架构梳理、重构前的依赖评估、新员工入职时快速建立代码地图、识别模块边界和核心依赖路径。

不适用场景同样要讲清楚。第一,运行时动态行为它分析不了,反射、动态代理、插件机制这一类动态调用,AST 里根本没有;第二,分布式系统跨服务调用链它看不到,那是链路追踪的活;第三,多语言混合项目单靠一种语言解析器搞不定,需要多解析器拼接,复杂度会急剧上升。

我特别想强调一点:这套方案产出的架构图是认知脚手架,不是施工图纸。它帮你快速建立全局认知,但不会告诉你每个接口的语义是否正确、每个分支是否真的会被执行到。把架构图和运行时 profiling、日志链路追踪结合使用,才能得到完整拼图。

3. 核心细节与实操要点:从 AST 到高精度架构图的完整拆解

3.1 解析层:抽取架构级节点

第一步是把源码解析成 AST,然后从 AST 中抽取架构级节点。节点不能直接用语法树里的每一种节点,否则你会得到几十万个 node,毫无可读性。我通常在抽取时收缩为这样几类:文件或模块节点、顶层类节点、公开方法或函数节点。

每个节点需要保留足够的元数据:源文件路径、节点类型(module / class / function)、这个节点引用了哪些其他节点。解析器选型要跟语言生态走。主流语言都有成熟的 AST 解析方案,比如 JVM 系有对应的语法树分析库,Python 生态有多款解析器,前端处理 JS/TS 可以用通用的语法解析框架。不建议自己从头写解析器,除非你只打算支持一个极小的语法子集。

在抽取 import 关系时,一定要区分仓库内部依赖和外部依赖。我的习惯是先把第三方包和标准库的路径前缀整理成一个白名单,解析 import 时直接标记 external。如果不做这一步,第三方库会被误判成核心枢纽节点,后续剪枝全都会被带偏。

3.2 关系层:构建多类型依赖边

第二步是把节点之间的关系建成边。为了后面的剪枝足够准,我会把边分成几种类型:import 边、调用边、继承边。

为什么要区分类型?因为它们表达的架构含义完全不同。import 边说明模块间存在耦合关系,调用边说明运行时控制流依赖,继承边说明设计上的扩展点。让 AI 做归纳时,如果它只知道“A 和 B 连在一起”,不知道这条边的性质,就很难给出有洞察的结论。边类型会直接影响它的推理质量。

建边时还有个重要细节:边的权重。一个函数即使被很多地方调用,如果所有调用都集中在同一条业务链路上,它并不一定是架构级的枢纽;反过来说,一个被几十个不同模块共同调用的公共方法,哪怕每个调用点的权重都不高,聚合起来也很强。所以在跑剪枝之前,要给边加一个重复引用系数,后续基于权重做 Top-K 裁剪。

动态语言里那些 AST 看不见的动态调用,比如 Python 里的 getattr 拼接调用、Java 里的反射调用,要单独标记为 uncertain,不要混进静态边。架构全景图通常不会依赖这些边做主干,但你得知道它们的存在。

3.3 剪枝层:三类裁剪策略与参数设计

剪枝是整个方案里技术含量最高、也最容易被低估的一步。我把它拆成三类策略,按顺序执行。

第一类叫外部依赖裁剪。把前面标记为 external 的节点全部删除,但保留反向信息。比如某个模块直接调用了第三方 SDK,我们把这个 SDK 节点删掉,但给那个模块补一个标签:external_deps: [sdk_x]。第三方库通常不是你画架构图的主体,但它们划出了模块的外部边界,所以信息不能完全丢,只是不参与主图布局。

第二类叫信息量裁剪。基于拓扑位置计算每个节点的信息量,按阈值保留。常用的指标是出入度、介数中心性和 PageRank 值。我实际用到的主要是出入度加上必经路径保护。只靠出入度会误杀入口模块,因为入口模块的出入口度不一定最高,它的价值在“位置”而不是“度数”。

我贴一段简易伪代码,展示这个思路的实现骨架:

def prune_graph(G, entry_nodes, keep_ratio=0.2): # 第一步:保护从入口出发的必经路径,避免误删入口链 protected = set() for entry in entry_nodes: for path in nx.all_simple_paths(G, entry, cutoff=5): protected.update(path) # 第二步:按出入度挑出高信息量节点 degrees = {n: G.in_degree(n) + G.out_degree(n) for n in G.nodes} threshold = sorted(degrees.values(), reverse=True)[ int(len(degrees) * keep_ratio) ] core = {n for n, d in degrees.items() if d >= threshold} # 第三步:保留核心节点及其直连邻居,保证图连通 keep = set(core) | protected for n in core: keep.update(G.neighbors(n)) to_remove = set(G.nodes()) - keep G.remove_nodes_from(to_remove) return G

方式上 keep_ratio 取 0.1 到 0.3 比较稳。0.05 我会砍掉太多入口链,0.5 则会把大量噪音保留下来。建议先跑一版 0.2,检查核心入口模块是否还在图里,再微调。

第三类叫结构裁剪。把所有小连通团压成一个服务或模块节点。一个区域如果只有十几个函数,而且只被一个上层模块引用,它基本就是那个模块的内部实现,合并后能显著降噪。

还有一步很容易被忽略:强连通分量合并。两个模块互相调用,静态图上就形成了一个环,直接摊开完全是在误导 AI,AI 会把环内的节点当成稳定分层的一部分。我的做法是把环内成员合并成一个虚拟节点,命名格式如“xxx_cycle”,并在喂给 AI 的说明里明确标注:该区域存在循环依赖,需要人工复核。

3.4 生成层:如何把图“喂”给 AI

剪枝完成后的图还是数据对象,不能直接把 networkx 对象丢给 AI。需要先序列化成文本。我通常给 AI 准备三份材料:节点清单、边清单、剪枝统计。

节点清单每行一个,包含 id、类型、名称、源路径;边清单每行一条,包含 src、dst、边的类型和聚合权重;剪枝统计则告诉 AI 原始节点数、剪枝后节点数、哪些区域被合并、哪些区域被标记为循环依赖。

Prompt 设计上有四个约束,缺一不可:

第一,只允许基于给定图结构做推断,不允许补充图里不存在的依赖。这一点要写进 system prompt,否则 AI 会用常识补全。

第二,必须区分明确依赖和推测职责。如果它只是根据模块命名猜测职责,要明确标注“推测”。

第三,输出格式要包含分层建议、核心调用链路、模块职责清单和潜在风险点。

第四,图中出现 merged 或 cycle 节点时,必须单独列出一节,说明该区域是裁剪或环合并的结果,不能当成真实模块直接解释。

实测下来,AI 读这份高信噪比文本后给出的架构描述,远好于直接读代码。给它的是结构化的骨架,不是一堆带噪声的文本流。

4. 实操实录:一次十万行遗留库的架构还原

4.1 准备阶段:工具选型与脚本骨架

回头看那套 12 万行的老订单库,我当时的方案是把整个流程拆成四个脚本:解析脚本负责把源码转成 JSON 中间文件,建图脚本负责组装节点和边,剪枝脚本负责执行三类裁剪策略,导出脚本负责把图序列化成 AI 能读的文本。

一开始我觉得流程很简单,偷懒写成了一个大管道,结果每次调参都要重跑完整链路。后来改成每步落盘中间 JSON,这一步省了我后面至少十个小时。这个教训值得强调:处理大型代码库时,中间产物比代码本身还重要。

4.2 流水线落地:内存策略与边数量

第一次全量解析 480 个文件时,内存直接冲到 4GB,在本地开发机上差点把环境搞崩。后面改成逐文件解析、边解析边更新全局符号表,AST 树用完就释放,峰值内存降到 1GB 以下。

建图结束后的统计是:12 万行代码,函数级调用边约 8.2 万条。这个数字本身不吓人,但要知道边是按调用点累计的,很多工具函数被几十处调用,所以边数会膨胀。剪枝前我做了两件事:去重和合并。把同一对函数之间的重复调用边合并为一条,权重累加;把直接的字段访问排除掉,只保留真正的方法级调用。做完之后边数降到 5 万条左右。

如果你的图跑出来直接上百万条边,说明粒度太细了,把语句级的读写访问也算进来了。架构图不需要那么小的粒度,节点先收缩到函数或方法级再说。

4.3 调参实战:剪枝参数怎么选

第一版剪枝我设置 keep_ratio = 0.05,图看着非常清爽,但核心入口模块反而被删掉了。排查原因就是入口模块的出入度不够高,它被删除是因为“表现不突出”而不是“没有价值”。这个偏差很典型,单纯按度数排序会偏爱公共工具类,低估靠近用户侧的入口链。

修正方案是入口保护加度数剪枝并行:先把入口模块沿必经路径上的节点全部保护起来,再按度数剪枝。keep_ratio 从 0.05 拉到 0.2,整个核心流程就保住了。

后续的叶子工具类合并环节也有坑。一开始按目录合并效果很差,因为工具类分散在各个包里。改成按“被引用模式”聚类之后,效果立刻变好:如果两个函数经常被同一批上游引用,就将它们折叠成一个虚拟节点。这个操作让节点数从 4800 降到 1100。

然后再经过强连通分量合并和连通组件压缩,最终剩 420 个节点、2100 条边。这个规模下,喂给 AI 的文本不到 2 万 token,十几秒就能收获整个架构的归纳结果。

调参过程中,我保留了一份剪枝前后对比表,作为项目交付的一部分:

阶段节点数边数说明
原始函数级图1200082000包含全部调用点
去重合并后1200052000相同调用合并为权重边
外部依赖裁剪980034000第三方节点下沉为标签
信息量裁剪48009800保护入口 + keep_ratio=0.2
结构裁剪与环合并4202100小连通团和强连通环被折叠

4.4 校验阶段:高精度不是嘴上说的

画出来的图要拿去用,就必须经过校验。我做了两层验证。

第一层是抽样核验。从最后的 420 个节点里随机抽 30 个,回到源码里去人工核对它的上游和下游依赖是否真的存在。这 30 个节点里,只有 2 个出现依赖漏检,原因是它们牵扯到了动态反射调用,静态边本身没错,只是不完整。

第二层是业务盲评。我请团队里最熟悉这套订单库的同事,让她只看图不翻代码,判断 AI 对某个“支付服务”区域职责总结是否准确。图上那个支付服务区域,实际上横跨了三个代码包,并且同时依赖配置中心和外部网关,所以被 AI 标成了高风险区域。她对着图沉默了几秒说:我们确实一直说要拆这个模块,图没骗人。

真正有用的架构图,就是这样:它不一定让你马上知道改代码的具体路径,但会让你猛然意识到“原来这个区域是核心”,或者“原来这个模块根本不是我想的那样”。这种业务侧的认可,比什么指标都更有说服力。

5. 常见问题与排查技巧实录

5.1 边爆炸与内存不足

处理大型代码库时,最常遇到的就是边数量和内存一起失控。

第一步排查粒度。如果边数超过百万,大概率是把低层级语句访问也算进去了。强制节点收缩到函数或方法级,语句级访问全部忽略。

第二步做边合并。在剪枝前先做一次去重聚合,把重复调用合并成权重边。这一步通常能把边数打五折到七折。

第三步流式建图。不要等所有节点都解析完再建边,边解析边把边写入临时存储,最后统一聚合。能显著控制峰值内存。

5.2 AI“一本正经地胡说八道”

直接丢代码时,AI 最擅长编造模块,喂剪枝后的图以后幻觉率会大幅下降,但不会归零。最典型的幻觉是:AI 看到某个模块名包含 payment,就会自动脑补出“它依赖风控系统”,即使图上根本没有这条边。

我的排查技巧是强制 AI 在输出每个依赖关系时引用图中的节点 id,并允许我根据 id 反查源码。如果某个 id 在源码里找不到对应关系的边,就是幻觉。另一个方法是多次生成投票,同一幅图生成三轮,如果第二轮和第一轮对某个职责描述不一致,通常那一轮就藏着编造成分。

5.3 循环依赖怎么处理才合理

最错误的做法是一上来就解环。遗留代码里的循环依赖大多不是 bug,而是长期演进的“历史结石”,强行解环可能引发连锁故障。

正确做法是先把强连通分量识别出来,折叠成一个环形区域节点,在图上标记为 cycle。在给 AI 的说明里明确告诉它:这个区域内部是循环依赖,不要试图划分这两个模块的先后层。环合并不等于解决问题,但至少让问题在图上可见,后续再排期处理。

5.4 图太“平”没有层次感怎么办

静态 import 图天然没有层次,所有节点挤在一起,看起来就是一个大毛线团。解决办法是引入层种子。

我给每个节点打上一个 layer 标签:入口类、控制器类标记为 top,数据库访问对象、外部网关、配置中心标记为 base,中间层根据它们到 top 和 base 的距离自动归为 mid。打完标签再喂给 AI,它输出的架构图立刻有了纵深感,不会把所有模块排成一排。

这个操作应该在剪枝完成之后做,不要提前,因为剪枝前的图还是噪音。

5.5 高精度如何量化验证

精度不能靠感觉,要量化。

我常用的指标是“架构边命中率”:从剪枝后的图里随机抽 N 条边,回到源码确认这些边是否真实存在,计算命中率。我们那次抽了 50 条边,命中 46 条,命中率 92%;另外 4 条是动态调用漏检,静态依赖图里确实没有对应边,但不代表调用不存在。

还有一个辅助指标是“AI 责任命中率”:看 AI 对节点职责的描述,与业务侧人工判断的一致性。虽然这个指标主观成分大,但对项目汇报很有效。

这两个指标写上报告,比“我觉得挺准的”管用。

6. 一些经验沉淀与后续扩展方向

这套流程现在已经被我固化成了“架构快照”工具链。新项目从第一天开始就跑一遍,架构图进入仓库后跟着代码版本走。每次重构前重新生成一版,对比两版图之间的漂移,基本能看出模块边界是否被破坏、核心链路是否被绕开。

如果继续往下探索,有两个方向值得做。一是把剪枝后的依赖图和运行时覆盖率数据叠加,识别出那些“图上很重但线上根本没人调用”的死角模块,这类角落往往是重构时最容易暴雷的地方。二是把模块职责描述做成自动化的架构评审规则,当新增代码导致某个核心模块的入度和出度结构明显改变时,开会之前 AI 就能提前提醒。

个人体会是,剪枝本身并不难,难的是承认代码库里有大量信息根本不配占用架构图的版面。敢于丢弃,AI 才能看到真正的骨架。面对十万行混乱代码库,全景图不是把每个细节摊开给你看,而是把最重要的关系用最少的边讲清楚。这个方向我会继续迭代,希望这套方法也能帮你在下一个老代码库里少踩几个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询