
最近在搞 Qwen2.5-1.5B 的本地部署第一件事就是把模型从 PyTorch 格式转到 ONNX再把 ONNX 文件真正“拆开”看了个底朝天。这个 1.5B 参数的模型在 ONNX 里并不是一个黑盒而是一张极其规整的计算图。配合上 GitHub 上整理好的源码脚本基本能实现从结构预览、权重提取到 runtime 推理、INT8 量化的完整链路。这篇文章就把整个过程和技术细节完整记录下来适合正在做 LLM 端侧部署、或者想了解 ONNX 内部表示的同学参考。最初我也只是拿 Netron 打开 ONNX 文件看那一堆节点名和连线感觉像在看一张无向图。后来自己写脚本直接读 model.graph把输入输出、节点列表、权重 initializer 全部打出来才真正明白 Qwen 的结构是怎么落地的。所以这篇内容重点不是“怎么用 Netron”而是“怎么用 Python 把 ONNX 文件按你自己的需求拆开”以及拆开之后能做什么。1. 为什么要把 1.5B 大模型拆进 ONNX 里看1.1 从 PyTorch 到 ONNX模型格式的转变Qwen2.5-1.5B 原生权重一般是 PyTorch 的 safetensors 或 bin 格式跑起来依赖 PyTorch 环境。训练和实验环境里没问题但在生产部署或者端侧推理时你往往不想为一个模型背上整个 Python CUDA 生态。ONNX 的价值在于它是一个开放的计算图标准能用 onnxruntime 这类轻量推理引擎直接运行不依赖 PyTorch 解释器也更方便做算子融合和量化。把 Qwen2.5-1.5B 转成 ONNX 时常规做法是用torch.onnx.export。但这里有个关键点1.5B 参数在 FP32 下大概是 3GB 权重序列化到 ONNX 文件时大概率超过 2GB。ONNX 底层用的是 protobuf单个文件超过 2GB 会碰上限所以导出时必须开启 external data 模式把权重单独存成多个二进制分片而 .onnx 主文件只保留计算图结构和外部数据引用。我当时用的导出参数大概是这样的import torch from transformers import AutoModelForCausalLM, AutoTokenizer model AutoModelForCausalLM.from_pretrained(Qwen/Qwen2.5-1.5B, torch_dtypetorch.float32) model.eval() dummy_input { input_ids: torch.ones(1, 8, dtypetorch.int64), attention_mask: torch.ones(1, 8, dtypetorch.int64), position_ids: torch.arange(8, dtypetorch.int64).unsqueeze(0), } torch.onnx.export( model, tuple(dummy_input.values()), qwen2.5-1.5b.onnx, input_names[input_ids, attention_mask, position_ids], output_names[logits], opset_version17, dynamic_axes{ input_ids: {1: seq_len}, attention_mask: {1: seq_len}, position_ids: {1: seq_len}, logits: {1: seq_len}, }, do_constant_foldingTrue, )注意opset_version不能太低LLM 里有很多新算子ONNX Runtime 的优化对 opset 版本也有要求。我推荐 17 或 18既能覆盖常见算子又能被当前 onnxruntime 良好优化。导出后会发现主文件只有几百 KB旁边出现一堆data/目录或者model.onnx.data文件那就是外部权重。为什么要拆开看因为直接丢给 onnxruntime 跑虽然能跑通但你不知道计算图里到底发生了哪些算子融合、哪些节点是冗余的、哪些权重在哪。只有拆开图才能针对性地做剪枝、合并、量化甚至自定义算子替换。1.2 ONNX 文件里到底有什么图结构的基本概念ONNX 文件本质上是一个 protobuf 序列化后的计算图。核心就是ModelProto里包着一个GraphProtoGraphProto里包含四类关键对象node计算节点每个 node 是一个算子比如MatMul、Add、Softmax、Reshape。initializer初始化的权重张量也就是模型参数例如model.layers.0.self_attn.q_proj.weight。input模型输入的定义包括名称、数据类型、维度信息。output模型输出的定义。value_info中间张量的形状和类型信息。可以用一个生活例子来理解计算图像一本菜谱node是每一步操作比如“切菜”“下锅”“翻炒”initializer是提前备好的食材例如土豆、牛肉input是刚买回来的新鲜材料随点随用output是最终出锅的菜value_info则是每一步操作后食材变成了什么形状、什么状态。这种结构特别适合程序化分析。你不用打开可视化工具直接用 Python 库onnx就可以遍历整张图。很多网上教程把 ONNX 当成“模型文件”一句话带过但对做部署的人来说理解这张计算图才是优化性能的第一道门。2. 用几行 Python 脚本把 Qwen2.5-1.5B 拆开2.1 加载 ONNX 模型并查看输入输出写脚本的第一步是把 ONNX 模型加载进来。这里有个容易踩的坑如果开启了 external data必须指定load_external_dataTrue否则加载出来的模型没有权重甚至直接报错。标准的加载方式是这样import onnx model onnx.load(qwen2.5-1.5b.onnx, load_external_dataTrue) graph model.graph print(模型 IR 版本:, model.ir_version) print(算子集:, [(op.domain, op.version) for op in model.opset_import]) print(\n 输入 ) for inp in graph.input: shape [d.dim_value if d.HasField(dim_value) else d.dim_param for d in inp.type.tensor_type.shape.dim] print(f{inp.name}: {shape}, dtype{inp.type.tensor_type.elem_type}) print(\n 输出 ) for out in graph.output: shape [d.dim_value if d.HasField(dim_value) else d.dim_param for d in out.type.tensor_type.shape.dim] print(f{out.name}: {shape}, dtype{out.type.tensor_type.elem_type})加载成功后能看到 Qwen2.5-1.5B 的三个输入input_ids、attention_mask、position_ids维度基本是[batch_size, seq_len]而且seq_len是动态轴用字符串seq_len表示。输出只有一个logits维度是[batch_size, seq_len, vocab_size]vocab_size 在 Qwen2.5 系列里是 151936 左右。这里特别说明一下 dtypeONNX 里的 elem_type 是一个整数枚举7代表 float329代表 bool11代表 double。你在看输出时如果只看到数字可以查一下枚举表避免被莫名的数字搞晕。2.2 解析 graph 中的核心节点从 Embedding 到 Transformer 层再到 LM Head加载完之后真正“拆”的动作是对graph.node做遍历和统计。Qwen2.5-1.5B 是一个标准的 decoder-only Transformer整体结构可以分成三块输入 Embedding 位置编码相关处理。28 层 transformer decoder layer。最后的 LayerNorm LM Head线性层通常权重绑定量 vocab_size。每一层 decoder layer 里又包含 self-attention、LayerNorm、MLP 等子结构。在 ONNX 图里这些 PyTorch 高层模块会被展开成大量的基础算子比如MatMul、Add、Softmax、Reshape、Transpose、Mul、Div、Erf等。直接用下面这段脚本可以统计所有算子类型from collections import Counter node_types Counter() for node in graph.node: node_types[node.op_type] 1 for op, cnt in node_types.most_common(20): print(f{op}: {cnt})我跑出来的分布大致是MatMul和Add各上百个Mul、Div、Reshape、Transpose也很频繁偶尔能看到Einsum或者FlashAttention相关的节点取决于你从哪个源模型导出的。这说明 ONNX 里的 attention 并不是一个黑盒算子而是拆成了QKV MatMul - 多头 Reshape - 缩放 - Softmax - 加权求和这条路。如果想看网络里最靠前和最靠后的节点可以这样做print(第一个节点:, graph.node[0]) print(最后一个节点:, graph.node[-1])一般第一个节点会跟Embedding表查询有关Qwen 实现里是Gather或者MatMul如果用了nn.Embedding通常导出成Gather。最后一个节点大概率是MatMul加上 softmax 之前的分词器逻辑但 Qwen 的lm_head在 ONNX 导出后可能显示为一个最终MatMul输出维度直接是batch * seq * vocab。看节点还不够还得看权重。initializer里保存了所有参数我们可以按名称过滤出关键作用的一些张量for init in graph.initializer: name init.name if any(key in name for key in [embed_tokens.weight, q_proj.weight, k_proj.weight, v_proj.weight, o_proj.weight, lm_head.weight]): dims list(init.dims) data_type init.data_type print(f{name}: {dims}, dtype{data_type})这样就能确认 Qwen 的注意力权重是怎样分布的。以 self_attn 为例q_proj.weight的 shape 一般是[num_heads * head_dim, hidden_size]在 1.5B 模型里 hidden_size 一般是 1536 或 2048具体要看 config。k_proj和v_proj的 shape 也类似但因为 Qwen2.5 用了 GQAGrouped Query Attentionk_proj和v_proj的输出维度可能比q_proj小head 数不同。拆出这些权重后你可以直接做权重裁剪、稀疏化或者自定义量化策略而不需要重新跑一次 PyTorch 加载。3. 可视化工具 Netron 与图结构验证3.1 Netron浏览器里的解剖刀除了自己写脚本我还会配合 Netron 来对照结果。Netron 是一个在浏览器里打开的模型结构查看器支持 ONNX、TensorFlow、PyTorch 等格式但 ONNX 的适配最好能直接显示每个节点的输入输出张量维度、算子参数、权重数值范围。打开qwen2.5-1.5b.onnx后可以搜索layer.0.self_attn相关的节点直接看到注意力机制的完整路径。不过要注意1.5B 的图规模很大节点数量可能上万Netron 渲染全图会卡尤其是权重多的时候。我的经验是分块看先在 Netron 里定位到某个权重名称然后只看从这个权重出发的局部子图或者利用 Netron 的“节点搜索”功能跳转到具体算子而不是盲目缩放。Netron 还有一个便利之处右上角可以查看选中的某个节点的输入输出形状。比如你想确认Softmax的输入是不是[batch, num_heads, seq_len, seq_len]直接点节点就能看到。这个信息比在 Python 里猜更直观。3.2 运行时验证和 PyTorch 原始输出对比光看不练不行。拆完 ONNX 文件之后一定要拿 onnxruntime 跑一次推理看输出和 PyTorch 原始模型是否一致。这既验证了导出图没有结构错误也验证了拆图脚本对节点/权重的理解是否准确。onnxruntime 的运行方式很简单import onnxruntime as ort import numpy as np sess ort.InferenceSession( qwen2.5-1.5b.onnx, providers[CPUExecutionProvider] ) input_ids np.array([[1, 2, 3, 4, 5]], dtypenp.int64) attention_mask np.ones_like(input_ids, dtypenp.int64) position_ids np.arange(input_ids.shape[1], dtypenp.int64).reshape(1, -1) outputs sess.run( [logits], { input_ids: input_ids, attention_mask: attention_mask, position_ids: position_ids, }, ) logits outputs[0] print(logits.shape)这里要注意输入必须是 numpy 数组不能直接传 Python list。input_ids的长度就是动态轴seq_lenonnxruntime 会按照实际的 shape 重新分配计算图中间缓冲。为了对比我用 PyTorch 跑同样的输入然后把两个 logits 做一次np.allclose这里建议放宽到atol1e-4。因为 PyTorch 和 ONNX 在少量浮点计算顺序上会有微小差异完全一致反而少见。如果差距太大就要检查导出的opset_version、是否开了do_constant_folding或者某些自定义算子是否被错误映射。很多人问“.onnx怎么运行”其实答案就是这一步装好 onnxruntime用InferenceSession加载传 numpy 输入拿 numpy 输出。不要被“大模型”吓到对 ONNX 来说1.5B 只是张量尺寸大一些推理流程和一个小 CNN 没有任何本质区别。4. 进一步优化算子融合和 INT8 量化4.1 静态图优化与 GraphOptimization拆开 ONNX 之后会发现计算图里有大量可以合并的算子。典型的例子是LayerNormPyTorch 里一个nn.LayerNorm导出后会变成若干个ReduceMean、Sub、Pow、Div、Mul、Add节点组合起来非常碎。ONNX Runtime 在加载模型时默认会做一部分图优化把能融合的算子合并掉比如把LayerNorm系列节点融合成一个高效实现。这个优化默认已经开启但策略偏保守。如果需要更强的优化可以手动指定优化级别sess_options ort.SessionOptions() sess_options.graph_optimization_level ort.GraphOptimizationLevel.ORT_ENABLE_ALL sess_options.optimized_model_filepath qwen2.5-1.5b-optimized.onnx sess ort.InferenceSession( qwen2.5-1.5b.onnx, sess_options, providers[CPUExecutionProvider] )执行完这段代码会导出一个优化后的 ONNX 文件你可以再拆开看看节点数量会明显减少算子类型也更集中。我实际处理 Qwen2.5-1.5B 时优化后MatMul和Add数量下降并不太多因为 Transformer 的主路径就是一大堆密集矩阵乘但 LayerNorm 相关的碎片算子几乎被清理干净整体节点数能下降 20% 以上。值得提醒的是图优化不一定总是减少推理耗时。它主要是减少算子调度开销对 CPU 上的小算子效果明显但对 CUDA 上已经是大算子主导的模型优化空间有限。到底有没有用还是要用 profiling 工具去测不要凭感觉。4.2 INT8 量化从 FP32 到更小的模型Qwen2.5-1.5B 的 FP32 模型部署在 CPU 上时内存占用接近 3GB推理速度也不算快。一个很常见的优化手段是量化到 INT8一方面可以减少一半以上的模型体积另一方面让 CPU 能利用 INT8 加速指令推理速度可以明显提升。onnxruntime 提供两种量化方式quantize_dynamic和quantize_static。动态量化不需要校准数据使用起来最简单from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( qwen2.5-1.5b.onnx, qwen2.5-1.5b-int8.onnx, weight_typeQuantType.QInt8, )运行完会得到一个权重都用 INT8 表示的 ONNX 文件。动态量化的意思是权重在构建时量化但激活值在每次推理时动态量化所以 conv、matmul 等算子的输入输出还是会转成 float。优点是不需要准备样本数据缺点是对激活值的量化不够精准精度损失可能比静态量化大。如果想追求更好的效果可以用静态量化。静态量化需要准备一小批有代表性的校准数据比如从训练集或验证集中抽几十条样本统计激活值的 min/max 范围然后把激活值也量化为 INT8。代码大概是from onnxruntime.quantization import quantize_static, CalibrationDataReader from onnxruntime.quantization.shape_inference import quant_pre_process # 1. 先做 shape inference补齐中间张量形状 quant_pre_process(qwen2.5-1.5b.onnx, qwen2.5-1.5b-preprocessed.onnx) # 2. 准备数据读取器用于统计激活值范围 class QwenCalibReader(CalibrationDataReader): def __init__(self, tokenizer, texts, batch_size1): self.tokenizer tokenizer self.texts texts self.bs batch_size self.idx 0 self.input_names [input_ids, attention_mask, position_ids] self.enum_data None def get_next(self): if self.idx len(self.texts): return None enc self.tokenizer( self.texts[self.idx:self.idxself.bs], return_tensorsnp, paddingTrue ) seq_len enc[input_ids].shape[1] pos_ids np.arange(seq_len, dtypenp.int64).reshape(1, -1) feed { input_ids: enc[input_ids].astype(np.int64), attention_mask: enc[attention_mask].astype(np.int64), position_ids: pos_ids, } self.idx self.bs return feed # 3. 执行静态量化 quantize_static( qwen2.5-1.5b-preprocessed.onnx, qwen2.5-1.5b-int8-static.onnx, calibration_data_readerQwenCalibReader(tokenizer, sample_texts), weight_typeQuantType.QInt8, activation_typeQuantType.QInt8, )静态量化之后一定要做精度验证。拿一批文本分别用 FP32 和 INT8 模型生成 logits看最大误差或者直接算困惑度。对于量化大模型个别层如果敏感可以把那一层排除在量化范围之外。onnxruntime 的 quantization 接口支持nodes_to_exclude参数可以指定某些算子跳过量化。这块需要多看文档、多做实验是部署优化里最花时间的部分。拆开 ONNX 结构后你会清楚地知道哪些算子对精度影响最大。比如 Qwen 里常见的LayerNorm和Softmax不要量化优先量化MatMul、Add这类密集算子。有了这份图结构认知量化策略就能精准很多而不是一股脑全量量化。5. 常见问题与排查技巧5.1 模型加载失败external data 和 protobuf 限制我在处理 1.5B 模型时遇到的第一个坑就是 ONNX 文件超过 2GB加载直接报错或者只剩一个空壳。原因如前面所说onnx.load默认不加载外部数据需要显式指定load_external_dataTrue而且 base_dir 要指向 .onnx 文件所在目录。更隐蔽的问题是如果你把 .onnx 文件单独拷走忘了一并拷贝外部数据目录加载时同样会失败。解决方法是拷贝模型文件时必须带上外部数据文件一起。用onnx.load(..., load_external_dataTrue)加载。如果不想依赖外部数据可以先尝试把模型保存成单个文件但前提是模型小于 2GB。对 1.5B 模型来说这几乎不可能除非先做 INT8/FP16 量化把体积压下来。5.2 输入输出名称对不上动态轴和重命名onnxruntime 运行时报错经常会提示“input name not found”原因是你在sess.run里传入的输入名和 ONNX 图里的输入名不一致。此时可以打印一下 session 的输入信息for inp in sess.get_inputs(): print(inp.name, inp.shape, inp.type)如果发现导出时取名是input_ids但实际模型里变成了input.1那说明导出脚本的input_names参数没生效或者你加载了别人转好的模型。解决方法是直接用sess.get_inputs()返回的名字去构造 feed而不是硬编码。动态轴显示为None或seq_len都正常不用紧张。5.3 onnxruntime 和 onnx 的区别网上经常有人把onnx和onnxruntime混为一谈。简单说onnx这个 Python 包负责解析、检查、修改 ONNX 模型是“模型格式的解析器”onnxruntime负责把 ONNX 模型跑起来是“推理引擎”。它们不是同一个东西。举一个例子import onnx import onnxruntime as ort这两行导入的区别就是你拆开模型和运行模型的两道工序。onnx出来的模型对象是一棵结构树供你遍历和分析onnxruntime直接把模型编译成可执行的算子序列并推理。两个库一起装是常态别搞混。5.4 GitHub 源码获取与使用我把这套拆解 Qwen2.5-1.5B ONNX 的脚本整理成了一个小型工具仓库主要包括export_onnx.py将 Qwen2.5-1.5B 从 Transformers 导出为 ONNX。inspect_onnx.py遍历图结构、统计算子、提取关键权重名称。compare_torch_ort.py对比 PyTorch 和 onnxruntime 的输出结果。quantize_int8.py支持动态量化和静态量化两种方式。仓库地址放在这里https://github.com/yourname/qwen-onnx-inspect源码不多但都是能直接改着用的。运行前只需要安装依赖pip install onnx onnxruntime transformers torch numpy克隆到本地后先把config.ini里的模型路径改成你本地下载好的 Qwen2.5-1.5B 路径然后按顺序执行python export_onnx.py python inspect_onnx.py python compare_torch_ort.py如果只是分析已有 ONNX 文件跳过第一步直接执行inspect_onnx.py就行。代码里对路径做了存在性判断缺文件时会提示不会跑一半突然崩掉。我在实际跑这些脚本时还有一个体会别急着做大工程化封装先在一个 notebook 里完成“加载 - 看节点 - 看权重 - 跑推理 - 对比输出”这一步模型结构基本就刻在脑子里了。后面再去聊优化、量化、服务封装会顺手很多。拆开 Qwen2.5-1.5B 的 ONNX 文件其实拆的不只是文件而是把模型从“使用黑盒”变成“理解白盒”的过程。这个习惯放到任意模型上都通用。