在实际项目里读一份开源 AI 引擎的神经网络代码,最难的地方往往不是代码有多少行,而是结构密度太高。KataGo 的训练侧模型定义就是这样:几十个字符的文件里塞进了特征编码、主干堆叠、全局池化、以及策略头、价值头、辅助头这些并行分支,层数和通道数还由外部配置决定。想搞清楚一条张量从输入特征走到策略输出的完整路径,如果从上往下滚代码,很容易看到第五个类就迷路。把模型结构可视化出来,是性价比更高的做法:先用脚本把代码里的类、子模块和重复层级抽成一份中间数据,再把这份数据渲染成一张可以和源码对照阅读的结构图。这篇记录的是用 Trae 配合 GLM 系列模型(标题里写的 5.3 指的是当时实际可选的版本,模型迭代很快,落地时以自己 IDE 里能选到的为准)完成 KataGo 主干可视化的完整过程,时间盒 55 分钟,最终产出三样东西:一份模块清单、一份 JSON 结构描述、一张 SVG 结构图。适合正在读 KataGo 源码、或者想给自己项目里的神经网络代码做文档化的读者。下面的方法不依赖具体模型,换一个仓库也能套用。
1. 先把目标定死:可视化 KataGo 的模型结构到底要画什么
很多人在这一步就直接让助手"帮我画一下模型结构图",然后得到一张似是而非的框图,看起来很像,实际上和仓库里的代码对不上。问题不在模型能力,而在需求本身没有被定义清楚。可视化不是画一张漂亮的图,而是把代码里的事实抽象成可核对的数据,再用图的形式表达出来。所以动手之前,得先回答三个问题:从哪一层代码抽、图上要包含哪些信息、哪些信息允许省略。
1.1 KataGo 的网络定义分散在配置、构建、推理三层
KataGo 的网络相关代码至少要分成三块来看。第一块是训练侧的模型定义,负责用 Python 把网络一层层搭出来,这是可读性最好的入口,也是可视化的主要对象。第二块是推理侧的 C++ 实现,位于 cpp/neuralnet/ 目录,它和训练侧在语义上是对齐的,但代码形态完全不同,不适合作为画图的直接输入。第三块是模型配置,决定层数、通道数、块的类型这些规模参数,它不定义拓扑,但决定了拓扑被重复多少次。
这三块的关系可以用一个类比理解:模型定义文件是"电路图",配置是"这块板子要焊几个相同的单元",C++ 推理代码是"同一张电路图的另一版实现"。如果只画电路图不看配置,你画出来的是"一个块长什么样";如果只读配置不看代码,你连块里面有什么都说不清。所以结构图必须同时反映这两层信息。
这里有一个容易被忽略的点:KataGo 的块类型并不只有一种。在它的实现里,除了普通的残差块,还存在带全局池化的残差块、瓶颈结构的残差块、嵌套瓶颈结构的残差块等变体。标题里提到的 Transformer 结构,指的是主干中引入了注意力式的全局信息交互,而全局池化某种程度上就是在做"跨位置的信息聚合",两者在语义上有承接关系。不同版本里类的命名和分布位置会变,所以后面所有脚本都采用宽松匹配,而不是把某个具体类名写死。
1.2 一张能用的结构图至少要有四类信息
判断一张模型结构图有没有用,最简单的标准是:能不能只看着图,回答下面四个问题。
| 信息类型 | 具体内容 | 为什么必须有 |
|---|---|---|
| 拓扑 | 哪个模块接在哪个模块后面,哪些是并行分支 | 决定数据流向,是图的核心 |
| 重复次数 | 主干块重复多少层,是否由配置变量控制 | 决定模型规模,也是理解参数量的前提 |
| 并行分支 | 策略头、价值头、辅助头各自从哪一层分出 | 分支位置画错,整张图的语义就错了 |
| 规模参数 | 通道数、注意力头数、输出通道数的来源 | 没有规模信息,图和"一个块"没区别 |
反过来说,有几类信息是可以而且应该省略的。比如每个张量的精确形状、每个卷积层的 kernel size 和 padding、激活函数的具体实现细节,这些信息量太大,塞进一张图里只会让人看不清拓扑。它们的正确位置是单独的参数表,而不是结构图。
结构图解决"在哪"和"接谁",参数表解决"多大"和"多少"。两类信息混在一张图里,图就废了。
1.3 为什么这件事适合交给 AI IDE,但分工必须清楚
用 Trae 这类 AI IDE 做这件事,优势不在于它"懂 KataGo",而在于它能快速完成三件机械工作:一是在几十个文件里定位相关定义,二是把一堆类名归纳成有层次的模块清单,三是根据你给的数据结构生成格式化的脚本和文档。这三件事如果手工做,光是翻文件和抄类名就要花掉大半时间。
但模型的短板同样明显:它在计数和精确引用上不可靠。你问它"主干有多少个残差块",它可能给出一个看起来很合理的数字,而这个数字在代码里根本不存在。所以整个流程的核心分工是:
- 模型负责"读、归纳、写脚本、写文档"。
- 脚本负责"数、抽取、校验"。
- 人负责"定义验收标准、判断合理性"。
这个分工决定了后面所有步骤的顺序:先让模型产出可核对的清单,再用脚本把清单变成结构化数据,最后才画图。跳过任何一步,图都会变成"看着像但没法验证"的产物。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:Trae、Python 和 Graphviz 的最小组合
这一段看起来琐碎,但它决定了后面 40 分钟顺不顺利。我在第一次做类似任务时,图已经生成出来了,结果 dot 命令不在 PATH 里,临时装包又碰上权限问题,白等了十分钟。所以环境这一步不要省。
2.1 工具清单与版本确认
| 组件 | 作用 | 建议版本 | 检查方式 |
|---|---|---|---|
| Trae | 仓库索引、对话、多文件编辑、内置终端 | 用最新稳定版即可 | 打开项目后能在对话里引用文件路径 |
| GLM 系列模型 | 定位代码、归纳清单、生成脚本 | 以 IDE 内实际可选的版本为准 | 提问后能给出带文件名的回答 |
| Python | 跑 AST 抽取脚本 | 3.10 及以上 | python3 -V |
| Graphviz | 把 DOT 渲染成 SVG 或 PNG | 2.40 以上 | dot -V |
| Git | 拉取仓库并固定提交 | 任意近版本 | git --version |
版本这块要强调一句:KataGo 的模型定义在不同提交之间会变,如果你在文章里看到某个类名,本地却搜不到,八成不是你的操作有问题,而是仓库版本不同。所以第一件事是把提交号记下来,写进最后的文档里。
2.2 拉取仓库并定位模型定义目录
bash复制git clone --depth 1 https://github.com/lightvector/KataGo.git
cd KataGo
git rev-parse HEAD # 记下这个提交号,后面写文档要用
ls python/ | head -30
python/ 目录是训练侧代码的主入口,模型定义、训练循环、配置集合基本都在这里。先用肉眼扫一遍文件名,判断哪个文件是"纯网络结构定义",哪个是"训练流程",哪个是"配置集合"。判断标准很简单:结构定义文件里应该几乎全是类的定义和 forward 方法,不应该出现优化器、数据加载、日志这些东西。
bash复制grep -rn "^class " python/*.py | wc -l
grep -rln "class .*Block" python/*.py
第一条命令数一下类定义的总数,第二条命令找出哪些文件里有块级别的类。这两个数字是后面校验的基准线:如果助手给你的清单里类数量远多于实际,说明它在编;远少于实际,说明它漏了。
2.3 安装 Graphviz 并做一次最小渲染验证
不要等图画出来了才去验证渲染链路。先用一个三节点的小图跑通,确认字体、中文和输出格式都没问题。
bash复制# Debian / Ubuntu 系
sudo apt-get update && sudo apt-get install -y graphviz fonts-noto-cjk
# macOS
brew install graphviz
dot -V
然后写一个最小的验证文件:
dot复制digraph t {
graph [rankdir=LR, fontname="Noto Sans CJK SC"];
node [shape=box, style="rounded,filled", fillcolor="#f5f7fa",
fontname="Noto Sans CJK SC", fontsize=12];
input -> trunk -> head;
}
bash复制dot -Tsvg t.dot -o t.svg && ls -l t.svg
检查点有三个:t.svg 能正常生成;用浏览器打开后中文不乱码;节点形状和颜色符合预期。中文乱码是 Graphviz 最常见的坑,根因是字体名写错或者系统没装中文字体,fonts-noto-cjk 装上之后把 fontname 指向 Noto Sans CJK SC 基本就能解决。
先把渲染链路跑通,再去做抽取。反过来的话,你会分不清"图画错了"和"图渲染不出来"到底是哪一层的问题。
3. 55 分钟怎么分配:把"看懂"拆成四个可交付的阶段
55 分钟这个数字如果不加限定,容易被误读。这里说明一下:它指的是第二次做同类任务、环境已经就绪、目标已经明确的情况下的耗时。第一次做的时候,光是搞清楚该看哪个文件可能就要半小时。所以下面这套时间盒的价值不在于"快",而在于它把一个大而模糊的任务切成了四段有明确产出的小任务,每段结束时你都能判断自己有没有卡住。
3.1 四段时间盒与阶段产出
| 时间段 | 阶段 | 主要动作 | 产出物 | 卡住的信号 |
|---|---|---|---|---|
| 0-8 分钟 | 定位 | 拉代码、找文件、数类定义 | 文件清单 + 基准数字 | 找不到结构定义文件 |
| 8-22 分钟 | 归纳 | 让助手出模块清单,grep 校验 | 带 file:line 的清单表 | 清单里的类名 grep 不到 |
| 22-38 分钟 | 抽取 | 写 AST 脚本,输出 JSON | 结构描述 JSON | 引用解析失败率高 |
| 38-50 分钟 | 出图 | 生成 DOT、调布局、渲染 | SVG 结构图 | 图过密、看不清 |
| 50-55 分钟 | 校验 | 对照源码抽查、写文档 | 图 + 说明 + 提交号 | 图与代码对不上 |
这张表最重要的作用是设定止损点。比如第 22 分钟你发现清单里一半的类名都搜不到,那就不该继续往下写脚本,而应该退回去重新给助手限定范围。很多时间浪费在"已经发现不对但还想着再试一下"上。
3.2 每个阶段的"完成标准"要可判定
"完成"这个词必须有客观标准,否则你会反复怀疑自己做完了没有。我给每个阶段定的标准是这样的。
定位阶段完成的标准:能说出结构定义文件的路径、类定义的总数、以及至少三个块级别类的名字。这三个信息都可以用命令复现,不依赖记忆。
归纳阶段完成的标准:清单里每一条都能用 grep -n "class XxxBlock" 文件路径 命中,且行号误差在合理范围内(代码改动会导致行号漂移,所以行号只作为参考,类名和文件名才是硬约束)。
抽取阶段完成的标准:脚本跑完不抛异常,JSON 里节点数大于 10,未解析的引用数占比低于 10%,并且能把未解析的条目逐条解释清楚(通常是一些 nn.Sequential 之类的框架内置组件,属于预期之内)。
出图阶段完成的标准:SVG 能在浏览器里打开,主干路径上的节点从左到右阅读顺畅,重复块被折叠成带次数的节点,三个头部分支在图上能一眼分辨。
校验阶段完成的标准:随机抽三个节点,回到源码里确认它的子模块和图上画的边一致。
4. 第一阶段:先让助手建立索引,不要直接要图
这是整个流程里最容易被跳过、也最容易出问题的一步。直接说"帮我画出这个模型的架构图",模型会给你一个结构通顺、命名合理、但和仓库代码没有对应关系的答案。正确的做法是先要一份可以被命令验证的索引。
4.1 提示词要把输出格式和禁止项写死
给助手的第一个提示词不需要很长,但约束必须明确。我实际用的版本大致是这样的:
text复制只读 python/ 目录下的 .py 文件,不要读 cpp/。
任务:找出定义神经网络结构的文件,输出一份模块清单。
输出要求:
1. 每条一行,格式为:文件名 | 类名 | 基类 | 一句话职责
2. 类名必须来自源码中的 class 定义,不允许推测或重命名
3. 如果某个类的作用你不确定,职责一栏写 UNKNOWN,不要编
4. 最后单独列出:你判断哪几处是主干、哪几处是输出头
禁止:
- 不要输出代码
- 不要给出层数、通道数等具体数值(这些来自配置文件,不在结构定义里)
- 不要总结整个仓库,只回答上面这个问题
几个约束的理由值得说一下。"只读 python/ 目录"是为了避免它去翻 C++ 推理代码然后混着回答,两边的类名体系不一样,混起来会出现大量无法验证的条目。"职责不确定就写 UNKNOWN"这一条看似降低了质量,实际上是提高了可靠性:你会清楚地知道哪些地方需要自己去看,而不是被一段编造得很像的描述骗过去。"不要给具体数值"是因为层数和通道数根本不在结构定义文件里,模型如果给了,必然是猜的。
4.2 用 grep 把清单里的每一条都过一遍
拿到清单之后不要急着用,先做机械校验。这里我给一个可以直接跑的脚本,把清单里声称的类名逐个去源码里验证:
python复制# verify_names.py
import pathlib
import re
import sys
ROOT = pathlib.Path("python")
def load_defined_classes():
defined = {}
for p in ROOT.rglob("*.py"):
text = p.read_text(encoding="utf-8", errors="ignore")
for m in re.finditer(r"^\s*class\s+([A-Za-z_]\w*)", text, re.M):
defined.setdefault(m.group(1), []).append(str(p))
return defined
def main(claimed_file):
defined = load_defined_classes()
names = [l.split("|")[1].strip() for l in
pathlib.Path(claimed_file).read_text(encoding="utf-8").splitlines()
if l.count("|") >= 2 and l.strip()]
missing = [n for n in names if n not in defined]
print(f"清单条目: {len(names)}")
print(f"源码中可命中: {len(names) - len(missing)}")
print(f"无法命中: {len(missing)}")
for n in missing:
print(" -", n)
return 0 if not missing else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1]))
这个脚本只做一件事:把清单里的类名和源码里所有 class 定义做集合比对。命中率低于九成,说明这一轮回答基本不可用,需要带上下文的追问;命中率高于九成但有几条对不上,通常是个别类名被简化了(比如把 NestedBottleneckResidualBlock 写成 BottleneckBlock),改回来即可。
这一步的意义不只是校验真假,它还顺带产出了一份权威的类名列表。后面写 AST 脚本时,用这份列表做交叉引用,可以快速发现解析漏洞。
4.3 归纳阶段真正要拿到的是一张"分层表"
校验通过之后,把清单整理成分层结构。这里不需要很精细,但层次必须清楚,因为后面 DOT 里的 cluster 分组直接来自这张表。
| 层级 | 典型类名(以实际仓库为准) | 职责 | 出现位置 |
|---|---|---|---|
| 入口 | 模型主类 | 组装主干与各输出头,定义前向顺序 | 结构定义文件 |
| 特征编码 | 输入编码 / 特征块 | 把棋盘特征映射到初始通道数 | 主干之前 |
| 主干块 | 残差块、带全局池化残差块、瓶颈块 | 堆叠 N 次,做特征变换 | 主干之内 |
| 归一化 | 主干收尾的池化/归一化层 | 把二维特征聚合成通道向量 | 主干之后 |
| 输出头 | 策略头、价值头、辅助头 | 分别输出落子分布、局面评估等 | 主干之后,并行 |
有了这张表,你就会发现一个之前没注意的事实:策略头和价值头的输入来源可能不同,有的从头部的池化结果分出,有的从主干更早的位置分出。这个差别在纯文字阅读时很容易忽略,但在图上会非常扎眼,这也是可视化的价值之一。
5. 第二阶段:用 AST 抽取结构,把模型的"事实"落到 JSON
到这一步,手里已经有了一份经过校验的类名清单和一张分层表。接下来的目标是把这些信息变成机器可读的结构描述。为什么中间要加一层 JSON,而不是直接从 Python 生成 DOT?因为调试时你需要一个稳定的中间产物:图不对的时候,你能快速判断是抽取错了还是渲染错了。
5.1 正则表达式在这里不够用
第一反应可能是用正则去找 self.xxx = SomeBlock(...)。实际测试下来,这种做法很快就崩了,原因有三个。
第一,类定义是嵌套的。一个块类的 __init__ 里可能又定义了内部类或者闭包,正则无法感知层级,会把内部和外部的赋值混在一起。第二,赋值可以跨行。写成多行的构造函数调用用单行正则匹配不到,或者只匹配到一半,得到残缺的边。第三,框架内置组件和自定义组件混在一起。nn.Sequential、nn.ModuleList 这些不是仓库定义的类,如果不过滤,图上会出现一堆无意义的节点。
Python 自带 ast 模块正好解决这三个问题:它给你一棵结构化的语法树,嵌套关系、跨行写法、调用链都能准确拿到,剩下的只是遍历策略。
5.2 抽取脚本:以实例为单位建边
这里有一个设计决策要提前说清楚:节点应该以"类"为单位,还是以"实例"为单位?答案是实例。原因是同一个类在模型里可能出现在多个位置,比如主干里用了 20 次同一种残差块,输出头里又用了一次。如果按类建节点,这些位置会被合并成一个,图就丢失了结构信息。按实例建节点,节点 id 用 宿主类.属性名 表示,既能区分位置,又能直接从 id 反查源码。
python复制# extract_structure.py
import ast
import json
import pathlib
import sys
ROOT = pathlib.Path("python")
SKIP_CALLEES = {"Sequential", "ModuleList", "ModuleDict", "Identity", "Dropout"}
def callee_name(call: ast.Call) -> str:
f = call.func
parts = []
while isinstance(f, ast.Attribute):
parts.append(f.attr)
f = f.value
if isinstance(f, ast.Name):
parts.append(f.id)
return ".".join(reversed(parts))
def loop_count(stmt: ast.For):
it = stmt.iter
if isinstance(it, ast.Call) and isinstance(it.func, ast.Name) \
and it.func.id == "range" and it.args:
a = it.args[0]
if isinstance(a, ast.Constant) and isinstance(a.value, int):
return a.value
return None # 由变量控制的循环,标记为动态
def scan_init(cls: ast.ClassDef):
edges = []
for fn in cls.body:
if not (isinstance(fn, ast.FunctionDef) and fn.name == "__init__"):
continue
for stmt in fn.body:
if isinstance(stmt, ast.Assign):
count = 1
elif isinstance(stmt, ast.For):
count = loop_count(stmt)
else:
continue
body = stmt.body if isinstance(stmt, ast.For) else [stmt]
for sub in body:
for node in ast.walk(sub):
if not isinstance(node, ast.Assign):
continue
for tgt in node.targets:
if not (isinstance(tgt, ast.Attribute)
and isinstance(tgt.value, ast.Name)
and tgt.value.id == "self"):
continue
if not isinstance(node.value, ast.Call):
continue
callee = callee_name(node.value).split(".")[-1]
edges.append({
"owner": cls.name,
"attr": tgt.attr,
"callee": callee,
"repeat": count,
"lineno": node.lineno,
})
return edges
def main(out_path):
classes, edges = {}, []
for p in sorted(ROOT.rglob("*.py")):
try:
tree = ast.parse(p.read_text(encoding="utf-8", errors="ignore"))
except SyntaxError:
continue
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
classes[node.name] = {"file": str(p), "lineno": node.lineno}
edges.extend(scan_init(node))
known = set(classes) | SKIP_CALLEES
unresolved = [e for e in edges if e["callee"] not in known]
graph = {
"classes": classes,
"edges": [e for e in edges if e["callee"] in known],
"unresolved": unresolved,
}
pathlib.Path(out_path).write_text(
json.dumps(graph, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"类: {len(classes)} 边: {len(graph['edges'])} 未解析: {len(unresolved)}")
for e in unresolved[:15]:
print(" 未解析:", e["owner"], "->", e["callee"], f"(line {e['lineno']})")
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "structure.json")
脚本里有几个关键点值得展开。
loop_count 只在 range 的第一个参数是整数字面量时才返回具体次数,其他情况返回 None,表示"次数由变量决定"。这个设计是刻意的:层数在 KataGo 里由配置文件控制,代码里通常写成 range(self.num_layers) 这种形式。如果硬猜一个数字,图上的信息就是错的;标成动态,反而能让人意识到"这里要去配置里看"。
SKIP_CALLEES 把框架内置组件过滤掉,但它们不是被丢弃,而是从"边"降级为"属性"。后面画图时,如果一个节点的子模块全是框架内置组件,它就是一个叶子节点,不需要继续展开。
unresolved 单独收集,是为了给校验提供抓手。正常情况下未解析条目应该很少,主要是跨文件引用漏掉的类,或者带条件判断的动态构造。如果未解析占比很高,说明遍历逻辑有问题,不能带着这个状态去画图。
5.3 中间表示的数据结构要能回答三个问题
跑完脚本之后,打开 JSON 检查一下,它至少应该能回答三个问题:某个类在哪个文件第几行;某个类的直接子模块有哪些;某个子模块被重复了几次。
json复制{
"classes": {
"Trunk": { "file": "python/model_pytorch.py", "lineno": 412 }
},
"edges": [
{ "owner": "Trunk", "attr": "block_0", "callee": "ResBlock", "repeat": 6, "lineno": 430 },
{ "owner": "Trunk", "attr": "gpool", "callee": "GlobalPoolingBlock", "repeat": 1, "lineno": 441 }
],
"unresolved": []
}
上面这段 JSON 只是结构示意,实际的类名、文件名和行号以你自己仓库的抽取结果为准。图的正确性最终要和源码对照,而不是和示例对照。
到这一步,你已经把"模型长什么样"这件事从自然语言转移到了结构化数据上。这一步完成之后,后面所有工作都变成了纯数据变换,不再依赖模型的理解能力,出错的概率会大幅下降。
6. 第三阶段:从 JSON 生成结构图并调好可读性
有了 JSON,画图就变成了一个确定性的转换过程。这里选择 Graphviz 而不是前端图表库,理由是它把"布局"这件事交给算法,你只需要描述拓扑和分组,不用手算坐标。对结构图这种层次明确、节点数量中等的图形,Graphviz 的 dot 布局引擎效果足够好。
6.1 DOT 生成脚本:按宿主分组,按重复折叠
python复制# to_dot.py
import json
import pathlib
import sys
def esc(s):
return str(s).replace('"', '\\"')
def build(graph, focus=None, max_nodes=120):
edges = graph["edges"]
if focus:
keep = {e["owner"] for e in edges if e["owner"] == focus}
edges = [e for e in edges if e["owner"] in keep]
by_owner = {}
for e in edges:
by_owner.setdefault(e["owner"], []).append(e)
lines = [
"digraph Model {",
' graph [rankdir=LR, splines=spline, nodesep=0.30, ranksep=0.55,',
' fontname="Noto Sans CJK SC", fontsize=12];',
' node [shape=box, style="rounded,filled", fillcolor="#eef2f7",',
' color="#8c98a8", fontname="Noto Sans CJK SC", fontsize=10];',
' edge [color="#7a8698", arrowsize=0.7];',
]
count = 0
for owner, items in by_owner.items():
lines.append(f' subgraph cluster_{esc(owner)} {{')
lines.append(f' label="{esc(owner)}";')
lines.append(' style="rounded,dashed"; color="#c3ccd8";')
for e in items:
if count >= max_nodes:
break
nid = f'{e["owner"]}.{e["attr"]}'
label = e["callee"]
if e["repeat"] and e["repeat"] > 1:
label = f'{label}\\n x{e["repeat"]}'
elif e["repeat"] is None:
label = f'{label}\\n x N'
lines.append(f' "{esc(nid)}" [label="{esc(label)}"];')
count += 1
lines.append(" }")
for e in edges:
src = f'{e["owner"]}.{e["attr"]}'
for child in edges:
if child["owner"] == e["callee"]:
dst = f'{child["owner"]}.{child["attr"]}'
lines.append(f' "{esc(src)}" -> "{esc(dst)}";')
lines.append("}")
return "\n".join(lines)
if __name__ == "__main__":
src = sys.argv[1] if len(sys.argv) > 1 else "structure.json"
dst = sys.argv[2] if len(sys.argv) > 2 else "model.dot"
graph = json.loads(pathlib.Path(src).read_text(encoding="utf-8"))
pathlib.Path(dst).write_text(build(graph), encoding="utf-8")
print("written:", dst)
这个脚本的核心逻辑是三轮:第一轮按宿主类分组,每个宿主变成一个 subgraph cluster,这样图上的模块边界和源码里的类边界是一致的;第二轮为每个实例生成节点,重复次数标在标签里;第三轮在实例之间连边,条件是"子模块的宿主类等于当前节点的类"。
重复折叠这一步很关键。如果不折叠,一个 20 层的主干会在图上生成 20 个几乎一样的节点,图会变得又宽又长,反而看不到结构。折叠之后,图表达的是"这里有 20 个同类型的块",信息量并没有减少,视觉负担却小了一个数量级。
还有一点:max_nodes 这个上限是必要的。第一次跑的时候我没设上限,结果生成了一张两千多个节点的图,SVG 打开直接卡住。后来改成按宿主分文件渲染,一次只画一个类的内部结构,全局图只画类与类之间的关系,问题就解决了。
6.2 布局参数怎么调才有用
Graphviz 的属性很多,但真正影响可读性的就那么几个。下面这张表是我反复调试后总结的经验值。
| 属性 | 常用值 | 作用 | 调过头会怎样 |
|---|---|---|---|
rankdir |
LR |
从左到右排列,符合数据流方向 | 改成 TB 后宽图会变成高图,长主干不适合 |
splines |
spline / ortho |
边的走线方式 | ortho 正交线整洁但会重叠,密集图慎用 |
nodesep |
0.25 / 0.35 | 同层节点间距 | 太大会让图变宽,太小标签会压在一起 |
ranksep |
0.5 / 0.6 | 层间距 | 太小边会挤成一团,连线看不清方向 |
fontname |
中文字体名 | 解决中文乱码 | 写错就直接显示方块 |
concentrate |
true |
合并重复边 | 密集图中会隐藏"多条边指向同一节点"的事实 |
我的建议是先按默认值出一版,看着哪里难受再调那一个属性,不要一次性把所有参数都改一遍。图的可读性是主观的,改一个调一个更容易找到合适的组合。
6.3 渲染与验收:三个必须检查的点
bash复制dot -Tsvg model.dot -o model.svg
dot -Tpng -Gdpi=150 model.dot -o model.png
ls -lh model.svg model.png
渲染完之后,按三点验收。第一,主干路径能不能从左到右一路读下来,中间有没有断开的边或者孤立节点。第二,重复块的标签是不是都带上了次数,有没有出现 xN 这种本该是具体数字却被标记成动态的情况,如果有,说明配置读取那一步漏了。第三,三个输出头是不是都接在了正确的位置,特别是有没有出现两个头都从同一个节点分出的情况,这通常是抽取时丢失了中间层。
图能生成不等于图是对的。SVG 只是渲染结果,正确性的唯一标准是和源码里的子模块定义逐条对齐。
7. 第四阶段:校验、排错和三个高频坑
前面所有步骤都可能出错,而且错误往往不会以异常的形式暴露出来,而是以"图看起来挺合理,但某一处悄悄错了"的形式存在。所以校验必须当成一个独立阶段来做,而不是"顺手看一眼"。
7.1 五类典型现象与定位方法
| 现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 图上出现源码里搜不到的类名 | 模型在归纳阶段编造,或类名被简写 | 跑 verify_names.py,逐条 grep |
用真实类名替换,重新生成清单 |
| 主干只有一两个节点 | 边连接逻辑依赖类名完全匹配,而实际用了别名或工厂函数 | 看 JSON 里的 unresolved 条目 |
补别名映射表,或用基类做匹配 |
| 图过密无法阅读 | 未折叠重复块,或一次画了整个模型 | 数节点数量 | 设 max_nodes,按宿主分文件渲染 |
| 中文显示为方块 | fontname 指向的字体不存在 |
fc-list | grep -i noto |
装中文字体,字体名写全 |
| SVG 打开卡顿 | 节点数上千,或边数爆炸 | grep -c "->" model.dot |
分层渲染,或改成 PNG 降低复杂度 |
这张表的价值在于它把"图不对"这个模糊感受拆成了可执行的检查动作。比如"主干只有一两个节点"这一行,检查动作是去看 JSON 里的未解析条目,而不是盯着图发呆。排查的效率差别就在这里。
7.2 一条固定的排查链路
遇到问题按下面的顺序走,不要跳步。这个顺序是从"最靠近数据源头"往后排的,先排除上游,再看下游。
- 确认仓库提交号和分支,和清单生成时是不是同一个。版本漂移是最隐蔽的原因。
- 确认抽取脚本扫的目录范围对不对,有没有漏掉
python/下的子目录。 - 看
unresolved列表,逐个解释。解释不了的先记下来,往下走。 - 在 JSON 里搜一个你确定的类,看它的
edges里子模块数量和源码里__init__的赋值数量是否一致。 - 检查 DOT 里的边数,
grep -c "\->"的结果应该和 JSON 里能被匹配上的边数大致相当。 - 最后才怀疑 Graphviz。绝大多数"图画错了"的问题,根因在前四步。
第 4 步是最关键的校验动作,也是我最后 5 分钟一定要做的:挑一个中等复杂度的类,打开源码数一数 __init__ 里对 self.xxx 的赋值有几个,再去看 JSON 里这个类的边有几条。数量对不上,就说明遍历逻辑漏了分支(比如赋值写在 if 里,而脚本只处理了 Assign 和 For,跳过了 If)。这个漏洞我在第一次写脚本时就踩到了,表现是某个带可选组件的块少画了一条边,图上完全看不出来,只有数数才能发现。
7.3 三个高频坑,每个都值得单独记一下
第一个坑:直接要图,跳过索引。表现是助手给出一张结构完整、命名漂亮的架构图,但你拿任何一个类名去 grep 都搜不到。根因是把"归纳"和"事实"混为一谈了,模型给出的是它见过的类似项目的平均结构,而不是你这个仓库的结构。正确做法是先要带文件名的清单,校验通过之后再进入抽取阶段。预防手段很简单:任何来自模型的类名,只要不能 grep 到,就一律不采信。
第二个坑:按类建节点,导致多实例被合并。表现是图上主干只有一种残差块,但源码里明明有三种不同配置的残差块实例。根因是节点 id 用了类名,Trunk 里出现两次 ResBlock 会被当成一个节点。正确做法是节点 id 用 宿主类.属性名,把位置信息编码进去。这个改动很小,但对图的准确性影响很大。
第三个坑:把代码结构和实际运行的模型混为一谈。表现是图画对了,但和真正跑起来的模型对不上,比如图上画了六个块,实际推理时只有四个,因为配置文件里的层数不是六。根因是结构图只反映了"有哪些类型的组件",没有反映"这次运行用了几个"。正确做法是在图之外单独维护一张配置表,把层数、通道数、块类型这些参数从配置里读出来,和结构图一起交付。图和参数表分开,各自准确,合起来才是完整的信息。
8. 把一次性任务变成可复用流程
这套流程跑完一次之后,真正的价值在于它可以复用。下次换一个仓库,比如别的开源 AI 引擎、或者自己项目里那堆没人看得懂的模型代码,同样的四步依然成立:定位、归纳、抽取、出图。区别只在于类名和文件路径。
8.1 一份可以直接抄走的检查清单
环境和准备阶段:
- 仓库已克隆,提交号已记录。
python3 -V和dot -V都能正常输出。- 中文字体已安装,最小 DOT 文件能渲染出中文。
- 结构定义文件已定位,类定义总数已统计。
归纳阶段:
- 清单每一条都有文件名和类名。
verify_names.py命中率在九成以上。- 不确定的条目明确标注为 UNKNOWN,没有被编造的描述填充。
- 已区分主干、输出头和辅助组件。
抽取阶段:
- 脚本只扫目标目录,范围明确。
- 节点以实例为单位,id 包含宿主和属性名。
- 循环次数能取到具体值就取,取不到标记为动态。
unresolved列表逐条解释过,没有遗留。
出图与校验阶段:
- 重复块已折叠,节点总数在可读范围内。
- 主干路径从左到右连续,没有断边。
- 输出头分支位置和源码一致。
- 随机抽查三个节点,子模块与源码一致。
- 图、参数表、提交号一起归档。
8.2 学习环境和团队环境的差别
自己学的时候,一张 SVG 加上临时脚本就够了。但如果这套东西要在团队里用,标准会高很多。
| 维度 | 个人学习 | 团队/长期维护 |
|---|---|---|
| 输入 | 本地随意克隆 | 固定提交号,锁定版本 |
| 脚本 | 一次性,能跑就行 | 进仓库,有参数说明和错误处理 |
| 输出 | 一张图 | 图 + 参数表 + 生成脚本 + 变更记录 |
| 更新 | 需要时重跑 | 模型结构变动时触发重新生成,最好有检查 |
| 校验 | 目测 | 自动比对节点数、边数,异常时报警 |
差别最大的是"更新"这一行。模型代码是会变的,今天画好的图过两个月可能就和代码对不上了。如果图是手工维护的,它一定会腐化;如果图是脚本生成的,只要脚本还在,重跑一次就能同步。所以真正需要长期保存的不是那张 SVG,而是抽取脚本和中间 JSON。
8.3 可以继续往下做的三件事
第一件是把参数表自动化。现在层数和通道数是人工从配置里读的,可以写一个小脚本把配置项解析成 JSON,和结构图一起生成。这样图和参数就永远同步。
第二件是把图做成可交互的。Graphviz 出的是静态图,如果节点多了还是不好读。可以把 JSON 喂给前端图库,做成点击节点展开子结构的形式,主视图只显示主干,点进去才看块内部。这一步不是必需,但对特别大的模型很有用。
第三件是把这套方法用到别的仓库上,作为练习。选一个结构清晰的中等规模项目,完全按四步走一遍,重点练两件事:写提示词时怎么把输出格式约束死,以及校验时怎么设计可执行的检查动作。这两项能力比记住任何具体框架的 API 都更耐用。
回到这次任务本身,55 分钟里真正花在"画图"上的时间不到十五分钟,剩下的时间几乎都花在两件事上:确认代码里到底有哪些东西,以及确认抽出来的东西和代码一致。这个时间分布本身就说明了一个判断——可视化的难点从来不是渲染,而是保证图和代码说的是同一件事。任何能缩短"确认"这一步的工具都值得用,但任何跳过"确认"的捷径都不值得走。
