☰
AST解析+拓扑剪枝+AI归纳:绘制十万行代码库架构全景图
2026/10/16 4:34:56 网站建设 项目流程

接手一个十万行规模的遗留代码库是什么体验?文档早就跟代码脱节,注释停留在三年前,模块边界模糊到搜索引擎都救不了。这种情况下想画一张能指导重构的架构全景图,靠人肉读代码基本是体力活,靠 IDE 的全局索引又嫌太重。后来我用 AST 解析加拓扑剪枝的思路,把代码库的依赖关系抽取出来,再用 AI 做结构化归纳,最终得到了比预期精细得多的架构视图。这篇文章就把这套流程从解析、剪枝到喂给模型生成全景图的完整思路拆开来讲。

阅读对象是准备做遗留系统重构、架构治理,或者想给大型代码库做可视化分析的同学。默认你了解最基本的 AST 概念,但不要求写过解析器。我会把涉及选型、参数、规避误判的关键判断都讲清楚,方便直接照着落地。

1. 先谈痛点:十万行代码库,为什么常规手段画不出可用架构图

1.1 文档、正则和 IDE 插件各自的局限

先说文档。大多数遗留系统的问题是文档更新永远赶不上代码演进。今天这个模块还叫订单网关,明天重构后改名成交易入口,文档却没跟上。靠文档画架构图,等于对着旧地图找新路。

再说正则。很多人图省事,用正则表达式去抓 import、require 或 include 语句,然后统计文件之间的引用关系。这招在小项目里能用,代码量一上来就出问题。你抓到了from order_service import create_order,但抓不到动态字符串拼接的导入路径,也分辨不出某个符号到底是从哪个模块导出的,更没办法区分“类型引用”和“函数调用”对架构边的影响差异。正则本质是文本匹配,代码里无处不在的注释、字符串、同名不同义的符号都会污染结果。

IDE 插件是另一个常见选择。某些重量级 IDE 的全项目索引确实很强大,能跳转、能找引用、能看依赖图。问题是十万行规模的代码库,全量索引构建一次可能要几十秒甚至几分钟,内存占用更是个大问题。而且 IDE 的依赖图多半面向“跳转辅助”,它的粒度是类和方法级别的调用关系,并不直接回答“我的系统分几层、每层有哪些模块、模块间依赖方向对不对”这种架构问题。想拿它做架构治理,还得二次加工。

1.2 AST 解析为什么更接近代码的真实意图

AST(抽象语法树)跟正则、文档的本质区别在于:它保留了代码的语法结构。一个 import 语句在 AST 里就是一个 ImportDeclaration 节点,它有清晰的 source、specifiers 字段;一个函数调用在 AST 里就是 CallExpression 节点,它的 callee 对象明确指向被调用的标识符或成员表达式。所以从 AST 做依赖抽取,不需要自己去猜“这段字符串是不是路径”,而是直接在语法层面按结构取数。

AST 还有一个隐藏价值:它能区分不同“依赖语义”。继承和函数调用对架构的重要性完全不同。一个模块继承另一个模块的基类,意味着强耦合;一个模块只是在内部调用了对方的工具函数,那可能是弱依赖,甚至可以通过依赖倒置轻松解耦。AST 能给出这些语义标签,后面做拓扑剪枝时就多了一个维度:按依赖强度决定边的去留,而不是一刀切。

在大型代码库里,AST 解析的另一个优势是分布式的可裁剪性。你可以按文件、按目录分片解析,再把结果合并。十万行代码听上去很多,但拆成几百个文件后,每个文件的解析都是独立任务,天然适合并行处理。而正则往往要全量拉取文本做匹配,反而不好并行。

当然,AST 也有它的边界。它解决的是“代码里实际写了什么”的问题,不解决“开发者在脑子里的设计意图”这个问题。但架构全景图本来就该以代码事实为准,等图出来了,再配合一小部分人工经验去修正,比从头靠人肉梳理可靠得多。

2. 从源码到架构图的数据管线:AST 解析与依赖抽取细节

2.1 解析选型:通用性与精度的权衡

选定 AST 路线之后,第一件事是选解析工具。我用的方案是分语言走两条线:

  • 对 JS/TS 代码,用现成的 parser 直接产出 ESTree 兼容的 AST,优点是生态成熟,很多分析库可以直接用。
  • 对 C++/Java/C# 这类静态语言,用具备增量解析能力、对语法错误容忍度更高的解析器(比如基于 Tree-sitter 的方案),它能在单文件有语法问题时仍然输出部分 AST,不会因为某个文件 parse 失败就中断整条流水线。

提示:Tree-sitter 这类容错解析器在代码库混乱时几乎是必需品。十万行遗留代码里总有历史遗留的语法问题,如果解析器直接抛异常,你就得要么修代码,要么跳过文件,两条路都难受。容错解析可以保证“坏文件少抽几条边,但不影响整个图”。个人实测中至少能多挽回 5% 到 10% 的依赖边。

解析方式上,建议按文件粒度做。把每个文件解析完,输出该文件内的实体列表和对外引用列表,然后汇总成全局依赖。增量解析在这里很有价值:代码库每天都在变,只重解析被修改的文件,能省下大量时间。

2.2 实体与依赖边的抽取规则

AST 解析出来之后,要抽两类东西:实体和依赖边。

实体按架构图的粒度需求来定。画架构全景图不需要精确到每个方法,通常抽以下几类:

实体类型来源 AST 节点粒度说明
模块/文件文件级 AST 根节点最底层的粒度,方便后面聚合
类ClassDeclaration / ClassDefinition保留继承关系
函数(顶级导出的)FunctionDeclaration + export 修饰批量工具函数聚集区
接口/抽象类InterfaceDeclaration / AbstractClass架构的稳定边界
目录(合成节点)非 AST 来源,由文件路径聚合成用于横向聚合

依赖边抽取则要看语言特性。以 Java/C# 为代表的对象导向语言,核心边包括:

  • extends边:继承关系,强耦合,必须保留
  • implements边:接口实现,反映契约关系
  • method_call边:A 类方法里调用了 B 类方法,中等强度依赖
  • field_access边:A 持有 B 类型的成员变量,中强依赖
  • constructor_call边:A 实例化 B,强依赖
  • import边:最弱但最通用的信号,早期剪枝可以考虑丢弃

对于 JS/TS 这类动态语言,主要抓import/require边和成员调用边,同时要小心一个坑:类型引用和运行时调用在 TS 里是两类不同的 AST 节点,建议分开抽。TypeScript 的 type-only import 本质是编译期依赖,运行时根本不存在,如果你不区分,画出来的架构图会把纯类型依赖也当成模块耦合,导致错误的架构判断。

依赖方向的统一约定也很重要。我习惯统一为“被依赖方指向依赖方”,也就是 A import 了 B,图里画 B -> A。这么约定是为了让出边多的节点成为“被广泛依赖的基础层”,在后续按拓扑层级做折叠时,天然就能把底层库、工具类识别出来。

2.3 中间数据结构的组织方式

抽取结果不建议直接丢给后续 AI 或可视化工具,而是先落成一个中间表示。最实用的结构是两层:

第一层叫file_entities。记录每个文件里定义了哪些类/函数/接口,它们的公开符号列表,以及这些符号是否被其他文件引用。

第二层叫dependency_graph。它只关注文件与文件、目录与目录之间的有向边。一条边的典型记录是:

{ "source": "src/order/service/OrderService.java", "target": "src/payment/client/PaymentClient.java", "type": "method_call", "count": 12 }

source和target记录两端文件路径,type记录依赖类型,count记录该类型的调用次数。后面做剪枝时,count会作为权重的重要输入。不要把类级别的关系直接当成文件级别的关系,否则一个文件里有 10 个类,每两个类之间都有边,最后画出来的图会有几百条冗余边。先聚合到文件层,后面按目录再聚合到模块层,层级清晰更好处理。

3. 拓扑剪枝的核心思路:三层剪枝把十万量级压到千量级

抽完依赖图之后,你面对的可能是一个包含上万个节点和几万条边的巨型有向图。直接可视化,结果就是一团乱麻。这时候就需要拓扑剪枝。剪字怎么理解?拿一棵真实的大树作比喻:你要画的是这棵树的“形”,不是每片叶子。剪枝就是把细枝末叶去掉,把主干和主要分支留下来。四层楼高的树画在纸上,永远只需要十几条线条。

具体到代码依赖图,我按噪声过滤、横向聚合、纵向折叠的顺序来。

3.1 第一层:噪声节点过滤与第三方依赖隔离

这一层最朴实,也最容易立竿见影。目标是把不参与业务架构的节点先剔掉。

规则一,过滤测试代码。test、tests、tests、spec.ts 这类路径下的文件全部不进架构图。测试代码的依赖特征是“多而散”,它们几乎依赖所有业务模块,如果不过滤,测试节点会变成巨大的集线器,严重干扰拓扑分析。十万行代码里测试往往占两三万行,过滤后图规模直接小一块。

规则二,隔离第三方库和自动生成代码。vendor、node_modules、third_party 这类路径,以及常见的 generated、pb.go、grpc.pb.cc 等生成文件,全部折叠成一个灰色节点,或者干脆从主图里拿掉。生成代码与业务无关,而且它们的依赖关系经常是整体式的,保留下来只会扭曲业务模块间的真实耦合。

规则三,折叠基础库和工具包。那些被全体模块引用、出度很低、几乎没有入边的节点,例如日志封装、公共 utils、公共常量定义,它们不是架构分析的重点。把它们合并成一个 Infra 节点即可,否则架构全景图上所有模块都指向同一个 utils 节点,既占空间又没信息量。

3.2 第二层:横向聚合、环合并与纵向折叠

这一层是让图变小变清晰的关键。

横向聚合是什么意思?把同一目录下关系紧密的一组文件合成一个上层节点。例如src/order/下有 OrderService、OrderRepository、OrderController 三个文件,它们彼此依赖密切,对外又承担相近的职责,那就聚合为order模块节点。聚合的粒度可以按目录深度来控制:先按一级目录聚合,如果图还是太密,就继续按顶层模块聚合成更大的域。这里我推荐有界贪心策略——设定目标节点数(比如 500 个),从叶子层开始逐级向上聚合,直到低于目标数。

环合并处理循环依赖。遗留代码里模块 A 调用 B,B 又调用 A,这种情况太常见了。从架构视角看,环内部的互相调用细节不重要,重要的是“这个环作为一个整体,对外依赖谁、被谁依赖”。所以把环内部压缩成一个强连通分量节点,是消除依赖图混乱的法宝。一次性压缩后,边数通常能再减少 20% 到 30%。

纵向折叠是沿着依赖方向的层级压缩。对于一条过深的调用链,例如 A -> B -> C -> D -> E,如果中间三层都是纯内部实现细节,就把整条链压成 A -> E,中间用一个内部节点表示。折叠的判断标准是:中间节点对外是否只跟这条链上的节点连接。如果是,则说明它是私有实现路径,可以折叠。纵向折叠对降低 AI 生成上下文长度非常有效。

3.3 第三层:按权重与出入度做边级剪枝

这一层已经不是“过滤”而是“取舍”。图的边是一堆有 type 和 count 的有向依赖。如果两个模块之间同时存在 method_call、new、import 三种边,合并成一条加权边,权重是各类别边数的加权和。我用的经验权重如下:

依赖类型权重
extends/implements10
field_access8
constructor_call6
method_call4
import/require1

权重高的边代表更强的耦合,架构分析中优先级最高。

然后设置边权重阈值,低于阈值且两端节点都不属于关键模块的边直接丢弃。关键模块怎么判定?按聚合后的出入度排名,排名前 20% 的节点视为关键模块。这个思路的本质是:给核心模块一点剪枝特权,不让细枝末节的弱依赖污染它的边界。

最后,如果边数仍太多,可以按“边介数”做二次筛选。边介数衡量一条边在多少对节点之间的最短路径上出现。介数高意味着它是联通的关键桥梁,介数低则说明它是局部连接,就算剪掉也不影响整体图的可读性。把低介数边按权重重排,逐个尝试剪除,直到边数降到目标范围。这个步骤稍重,但要画一张真正清晰的全景图,这一步带来的收益最明显。

注意:剪枝是服务于“读图成本”和“AI 上下文长度”的取舍动作。剪得太多,架构真相会被掩盖;剪得太少,图还是没法看。目标不是剪到最简,而是剪到“架构师一眼能看出分层与依赖方向”的程度。以个人经验,节点数在 500 到 800 之间、边数在 1500 到 2500 之间,画出的全景图在可读性和信息量之间平衡得最好。

4. 剪枝后的数据如何喂给 AI 并生成高精度架构全景图

4.1 把图数据编码成大模型友好的结构

剪枝完成后的图,本质上是一张有向带权图。要交给大模型,得先转成结构良好的文本输入。

我最推荐的编码格式是层级 JSON + 局部摘要。官方文档式的 JSON 太冗长,所以我压缩成 compact 形式:每个模块节点只输出 ID、名称、类型、层级路径、关键依赖的子集和中英文混合的一句话职责描述。这里的职责描述不是自动生成的,而是从代码库的 README、目录名、核心类名中提取的“特征词汇”,目的是给模型一个锚点。

一个示例片段:

[ { "id": "mod_order", "name": "order", "type": "service", "path": "src/order", "depends_on": ["mod_payment", "mod_user", "infra_db"], "incoming_from": ["mod_api", "mod_checkout"], "note": "order create/cancel/query flows" }, { "id": "mod_payment", "name": "payment", "type": "service", "path": "src/payment", "depends_on": ["infra_db", "infra_msgqueue"], "incoming_from": ["mod_order", "mod_api"], "note": "pay/pay-callback/refund flows" } ]

JSON 里要有depends_on和incoming_from两个视角,模型才能同时理解“本模块依赖谁”和“谁依赖本模块”。这一点经常被忽略。如果只给单向依赖,模型很难推断出分层的合理性。

职责描述不要靠模型猜。你可以把每个模块文件列表中的目录名、文件名、导出的核心符号拼起来,再让模型根据这些素材生成一句话总结。这能显著提升描述的可信度。纯粹让模型根据模块 ID 脑补职责,十有八九会产出看起来像模像样、实际与代码不符的废话。

4.2 分层生成与多轮修正:让 AI 输出可校正的架构视图

架构全景图的生成,不要一次性让模型输出最终版。我强烈建议采用先骨架、再细节、后查漏的三段式流程。

第一阶段,只给模型提供模块节点列表,以及模块间的主要加权依赖边,让它先输出一个分层判断:哪些模块属于上层、哪些属于基础设施层、哪些属于领域服务层。这个阶段不要求模型承担太多细节,只判断关键依赖方向。

第二阶段,把第一阶段产生的分层结论和剪枝后的完整边集放入提示词,让模型写一个架构说明文本:每层有哪些模块、模块间的关键交互路径有哪些、是否存在异常依赖(比如上层直接依赖了基础层的东西、业务模块互相耦合形成一个巨大环)。这一步实际是让模型替你做架构评审。

第三阶段,让模型把结论转成结构化输出——比如一份自定义的文本格式或者一份 Mermaid/Markdown 的图表结构。这里要小心:大模型直接生成的 Mermaid 或嵌套列表经常有语法错误或层级混乱,要允许人工修正格式,不要让模型输出一次成形并以为万事大吉。我在实际过程中会让模型先输出layer -> module -> key edges的缩进文本,再由自己脚本转成目标格式,这样绕开了模型对图表语法不稳定的问题。

多轮修正在这里很关键。第一轮生成结果后,我会把明显可疑的依赖——比如 A 依赖 B,但代码里根本找不到证据——喂给模型,让它结合 JSON 原始边集提出修正建议。模型不一定能直接修复,但它的判断可以作为人工排查的线索。

4.3 精度评估:如何判断“高精度”是真实可达的目标

标题里提到“高精度”,这里得落地一个可量化的评估方法。我采用三个指标:

  • 节点命中率:人工从代码库里随机抽取 30 个模块,看 AI 生成的架构图里是否能找到对应节点,且职责描述方向一致。全中才算通过。
  • 边命中率:同样是随机抽 30 条关键依赖路径,检查架构图中的对应方向边是否存在。这个指标最直接反映依赖抽取的完整度。
  • 分层一致率:让两位对项目有了解的工程师分别标记“哪些模块应该属于基础层、领域层、接口层”,再跟 AI 输出的分层做对比,计算一致比例。这个指标有点主观,但能暴露模型“看着名字乱分层”的老毛病。

实测下来,完整跑通剪枝流水线之后,前两个指标可以稳定达到 85% 到 95%。剩下未命中的部分,误差要么来自自动生成代码的残留,要么来自被过度折叠的私有路径。后面专门讲怎么处理。

5. 实测效果:从混乱代码到可读架构图的典型结果

我拿一个模拟的“遗留订单管理系统”跑过一次全流程,这里把结果列出来,给大家一个直观的体感。

项目规模:约 10 万行代码,5500 多个源文件,核心业务分布在订单、支付、用户、库存、营销五个域,另有一堆历史遗留的公共模块。这些模块之间的依赖非常混乱,存在多个明显的双向环。

  • 初始依赖图:约 5500 个文件节点、28000 多条边。这个规模直接可视化,任何屏幕都看不清楚。
  • 第一层剪枝(测过代码、第三方库、生成代码后):节点降到 4100 个,边降到 19000 条。
  • 第二层剪枝(按目录聚合到模块级、压缩强连通分量后):节点降到 620 个,边降到 4200 条。
  • 第三层剪枝(按权重和介数筛选后):节点约 580 个,边约 2100 条。

这个压缩比几乎是数量级的。之后把数据喂给大模型,生成的架构分层描述初步结论是:订单域和支付域之间存在一条隐蔽的反向依赖,根因是订单模块直接调用了支付回调里的内部类;用户域和营销域通过一个老旧的用户标签工具类互相耦合,这个工具类本该下沉到基础层。

对比剪枝前的全量图,这些结论光靠人眼根本发现不了。全量图里支付回调内部类那个节点被淹没在几百个同类节点里,但聚合后它的位置就暴露出来了——订单域的边指向了支付域内部,而不是支付域的公开门面。这就是把细节聚合成模块后产生的“结构洞察”。

5.1 剪枝参数参考表与调优策略

每个人的代码库情况不同,但剪枝参数可以给一个起步值,再根据结果微调。

参数名含义参考起步值调优方向
test_paths测试路径清单test, tests,tests, spec 目录项目命名不同需要补充
third_party_paths第三方代码路径vendor, third_party, node_modules按项目实际依赖管理方式调整
edge_weight_threshold边权重下限3调大则边更少,但可能丢掉真实弱耦合
target_node_count聚合目标节点数600调大保留更多细节
scc_merge是否压缩强连通分量true关闭则保留环内部细节,图更乱
fold_depth纵向折叠最大层级3调大则折叠更激进

调优不需要靠感觉。跑一次全流程后,看“关键路径上的节点是否被误剪”和“同类节点是否残留重复”这两个信号。如果某条核心业务链路上的中间节点被折叠到看不出来,就该降低 fold_depth;如果图上还有一堆只被调用一次的单一工具类,就该调高 edge_weight_threshold 或 target_node_count。

5.2 边界案例:剪枝把核心依赖误伤了怎么办

有一种最容易翻车的场景:支付模块的refund()方法只是在日志系统里打了条记录,而日志系统刚好是个被大量弱引用的公共模块,权重分不足,在第三层剪枝时被剪没了。结果全景图缺失了“订单 -> 日志系统”这条边。虽然在架构分析层面这不算大事,但对依赖完整性有洁癖的同学就会很难受。

解决办法是给“关键路径”加白名单。先识别一组关键业务路径(比如从 API 入口到订单服务的完整链路),这条链路上出现的节点和边标记为protected,第三层剪枝时跳过 protected 边。关键路径怎么识别?按入度和出度最高的节点做正向和反向 BFS,收集在最大距离 N 内的节点。这样既保住了主干,又删掉了干扰项。

注意:剪枝不是一次过,建议跑两遍。第一遍粗剪后快速生成一版粗略图,人工扫一眼看哪些边不该被删、哪些节点不该出现,把白名单和黑名单都记录好,第二遍再精细剪。这比一遍参数调到完美要高效得多。

6. 工程化落地中踩过的坑与后续优化空间

前面讲了一路顺畅的方法,但实际落地过程中我踩过不少坑,有几个很有代表性,写出来供大家参考。

6.1 复杂语法构造导致的误抽、漏抽

第一个坑是泛型和模板展开。C++ 的模板、Java 的泛型很容易让 AST 里出现一层 synthetic 节点。比如OrderRepository<Order, Long>在 AST 里的类型引用往往挂在泛型参数节点上,如果不做专门处理,抽取出的依赖会指向错误的目标。Java 侧相对好处理,多走一步从 type binding 里解析实际类型;C++ 侧就比较麻烦,模板实例化产生的依赖经常是隐式的,AST 上根本看不到。遇到 C++ 模板大量泛滥的库,建议放宽“精度期望”,接受一部分漏抽,重点抓非模板边。

第二个坑是动态语言带来的 AST 不确定性。之前用项目里的 Python 代码做实验,发现装饰器包裹的函数、动态__getattr__生成的属性,AST 上有结构但语义上是不确定的。我从 AST 里看到obj.user.create_order(),以为 user 是外部模块,实际上obj.user是运行时从配置里加载的鸭子类型。这类依赖对静态分析来说是无解的,只能在生成架构图时打上“低置信度”标记,让架构师知道这条边需要人工确认。

第三个坑是switch/case 和策略模式造成的“伪边”。一个策略分发函数里 case 了十几个分支,AST 会认为这个调度类依赖了所有策略类。从架构角度这其实是合理的——它确实耦合了所有策略。但如果策略实现类本身又反向依赖调度类,就会形成环。这个环是真实存在的,不是误抽。问题是:压缩环之后,调度类就不可见了,架构图上只留下一个“策略环”的抽象,这会让不熟悉代码的人困惑。我的处理办法是:压缩环时保留环内“入度最高”的节点作为环的对外代表节点,让它继续出现在图上。

6.2 性能压力与解析缓存策略

10 万行代码说多不多,但 5500 个文件逐个建 AST 再抽依赖,也要消耗不少时间。我第一次跑全量解析花了 40 多分钟,明显不能接受。后来做了两个优化。

一是按文件哈希做增量解析缓存。文件的 AST 解析结果是完全确定的,一个文件没变更,就复用上次结果。这样第二次运行全流程通常几秒到十几秒就完事。增量解析对日常迭代意义巨大,改了几行代码以后重画架构图,不必全量重跑。

二是并行解析。文件之间互不依赖,天然可以开多进程。用 8 核机器时,解析耗时从 40 多分钟降到 6 分钟左右。性能瓶颈主要在 AST 节点的对象创建和后续图数据的序列化,可以顺手把不用的节点字段丢弃,只保留抽取所需的实体和边信息,内存能省一大截。

依赖图的构建阶段也遇到过内存问题。全量依赖边如果全缓存到内存里,5500 个文件能产生 28000 条边,每条带着类型和计数,内存峰值会高达好几个 GB。解决办法是只在内存里保留边信息而丢弃原始 AST 对象,或者用磁盘上的边列表文件保存,需要时按节点批量加载。

6.3 后续值得做的扩展方向

这套管线跑顺之后,可以低成本扩展出很多实用能力方向供参考。

一是架构演进追踪。每次代码变更后重新跑一遍解析和剪枝,对比两个版本的架构图,可以自动发现“新增了哪些跨层依赖”“哪些环变大了”“哪些模块的扇出明显上升”。这个对评审变更合入很有价值,不是玄学,而是纯粹的图差异分析。

二是架构规则检查。把人工定义的架构边界(比如“领域层不得依赖接口层”“基础设施层不得反向依赖业务层”)转成图的路径约束,然后对剪枝后的依赖图做路径可达性检查。一旦出现违反约束的路径,就把路径上的节点和边列出来。这比纯代码扫描更能反映架构级违规。

三是按需深挖。全景图只负责给出全貌,当架构师想深挖某个模块的内部结构时,用该模块在全局图中的位置,只重新解析该模块的文件级 AST 并展开成类粒度的小图。这种粗粒度全景 + 细粒度局部的组合,比一味追求所有细节都在全景图里要实用得多。

在做过多个项目的代码库梳理之后,我的体会是:画架构全景图的本质不是“把每行代码都塞进图里”,而是通过结构化剪枝把噪音去掉,把真正影响架构走向的主干暴露出来。AST 负责忠实记录代码事实,拓扑剪枝负责给出可视化的体面,AI 负责把图翻译成架构语言。三者放在一条流水线里,十万行混乱代码库才真正变成一个可以被讨论、被演进、被治理的东西。

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

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

立即咨询