AI 工程基础体系 · 第 70/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

ONNX 与模型互操作:导出、算子集、动态形状、验证和部署

ONNX(Open Neural Network Exchange)是用于描述机器学习模型计算图、张量类型、形状和参数的开放格式。它的目标不是把任意框架的全部运行时行为完整复制出来,而是定义一套相对稳定的中间表示,使模型可以从训练框架导出,再交给不同的推理运行时执行。

因此,“导出成功”不等于“模型已经可以部署”。一个可用的 ONNX 交付物至少需要同时满足:

  1. 图结构能够被目标运行时解析;
  2. 图中算子的语义与原模型一致;
  3. 输入、输出、数据类型和动态维度符合服务契约;
  4. 在代表性数据上,数值误差处于可接受范围;
  5. 目标硬件、执行提供者、权限、模型文件和依赖能够稳定运行;
  6. 评测、版本、成本和回滚信息可以被追踪。

本文以 PyTorch 导出 ONNX 为主线,解释 ONNX 的模型结构、算子集、动态形状、验证方法和部署边界,同时覆盖传统机器学习、深度学习和生成式 AI 中常见的互操作问题。

一、先区分三个对象:框架模型、ONNX 模型和推理运行时

训练框架中的模型通常包含比推理计算图更多的内容:

  • 参数和缓冲区,例如权重、BatchNorm 的运行统计量;
  • Python 模块和控制逻辑;
  • 自动微分关系;
  • 训练态行为,例如 Dropout;
  • 优化器状态、梯度和学习率调度器;
  • 数据预处理、分词器、后处理代码;
  • 可能无法直接转换的自定义算子。

ONNX 模型主要描述推理阶段的静态或半静态计算图。一个 ONNX 文件通常包含:

  • graph:节点、输入、输出、中间值和初始化参数;
  • opset_import:每个算子域使用的算子集版本;
  • ir_version:ONNX 中间表示本身的版本;
  • 输入和输出的元素类型、形状信息;
  • 模型权重,或者通过 external data 存储在独立文件中;
  • 可选的元数据和模型文档。

推理运行时则负责真正执行图,例如 ONNX Runtime、TensorRT、OpenVINO 或某些厂商的 NPU 运行时。运行时通常还要选择执行提供者(Execution Provider,简称 EP),例如 CPU、CUDA 或 TensorRT。

三者的关系可以表示为:

flowchart LR
    A[训练框架模型<br/>PyTorch / 其他框架] --> B[导出器<br/>Tracing 或 Export]
    B --> C[ONNX Graph<br/>IR + Opset + Weights]
    C --> D[模型检查与数值验证]
    D --> E[目标推理运行时]
    E --> F[CPU / GPU / NPU 执行提供者]
    F --> G[线上服务]
    H[预处理与分词] --> G
    G --> I[后处理与业务决策]

ONNX 只覆盖图中的中间部分。比如,文本模型的 tokenizer、图像归一化、标签映射、采样策略和权限校验并不会因为模型导出为 ONNX 就自动获得统一语义。

二、ONNX 图到底表达了什么

1. 节点、张量和初始化参数

一个节点可以抽象为:

y=Op(x1,x2,;a1,a2,)y = \operatorname{Op}(x_1, x_2, \ldots; a_1, a_2, \ldots)

其中:

  • xix_i 是输入张量;
  • yy 是输出张量;
  • Op 是 ONNX 算子;
  • aia_i 是属性,例如卷积步长、填充方式或激活函数参数。

模型参数通常以 initializer 的形式存在图中。例如线性层:

Y=XWT+bY = XW^T + b

其中 XX 是输入,WWbb 是模型参数。导出后,MatMulGemm 节点以及对应 initializer 共同表达这一计算。

ONNX 张量不仅有数值,还具有类型和形状。例如:

input: FLOAT, [batch, sequence, 768]
output: FLOAT, [batch, sequence, 3072]

这里的 batchsequence 可以是符号维度,但符号维度并不意味着运行时接受任何形状。图中的矩阵乘法、reshape、拼接和注意力操作仍然会对形状施加约束。

2. ONNX 不保存任意 Python 语义

下面这些行为不能简单地假设会被准确导出:

def forward(self, x):
    if x.sum() > 0:
        return x * 2
    return x - 2

这里的分支条件依赖输入数据。导出器可能:

  • 将某次示例输入走过的分支固化;
  • 将分支转换为 ONNX 控制流;
  • 直接报告不支持;
  • 在不同导出模式下产生不同结果。

因此,导出不是把 Python 程序逐行翻译成 ONNX。导出器需要把模型降低(lowering)为目标算子集支持的图。如果模型包含输入依赖控制流、自定义 Python 容器、动态属性或框架私有算子,就必须验证导出后的语义,而不能只看文件是否生成。

3. 训练态和推理态必须明确分离

导出前通常应执行:

model.eval()

这会影响至少两类常见算子:

  • Dropout:训练态随机丢弃,推理态通常为恒等或等价缩放;
  • BatchNorm:训练态依赖当前 batch 统计量,推理态使用保存的运行统计量。

如果模型仍处于训练态,导出的图可能包含随机性,或者与线上推理预期不一致。torch.no_grad() 可以关闭梯度记录,但不能替代 model.eval();二者解决的是不同问题。

三、算子集(opset)是什么,以及为什么它决定互操作边界

1. 算子集不是“算子数量”

ONNX 算子集(operator set,简称 opset)是某个算子域在特定版本下的语义集合。以默认域为例,节点可能声明使用 AddMatMulReshape 等算子,而 opset_import 指定这些算子的版本。

同名算子在不同 opset 中可能发生变化:

  • 输入或属性定义变化;
  • 广播规则变化;
  • 默认值变化;
  • 类型支持范围变化;
  • 旧版本被弃用或替换。

所以“模型使用 opset 18”表示模型中的默认域算子遵循 opset 18 的定义,并不表示所有运行时都必然支持该模型。

此外,ONNX 还存在不同算子域:

  • ai.onnx:标准算子,通常是默认域;
  • ai.onnx.ml:传统机器学习算子,例如部分树模型、SVM 等;
  • com.microsoft:运行时扩展算子;
  • 厂商或项目自定义域。

使用扩展域通常会降低跨运行时能力。一个运行时即使支持标准 ONNX,也可能不支持 com.microsoft 或某个硬件厂商自定义算子。

2. opset 选择是三方约束

实际可用的 opset 需要同时满足:

可用 opset导出器能够正确生成的版本目标运行时支持的版本目标硬件后端能够执行的版本\text{可用 opset} \subseteq \text{导出器能够正确生成的版本} \cap \text{目标运行时支持的版本} \cap \text{目标硬件后端能够执行的版本}

例如,较新的 PyTorch 导出器可能能够生成某个较新的 opset,但目标 TensorRT、NPU 编译器或嵌入式运行时只支持较老版本。反过来,强行使用较老 opset 也可能导致某些算子无法表达,或者触发不理想的分解。

因此,不能把“越新越好”作为选择原则。应先固定部署目标,再选择目标支持范围内、能够表达模型语义的 opset。

3. opset 升级不会自动修复所有问题

ONNX 的版本转换工具可以转换一部分算子版本,但它不是通用的语义修复器。以下情况可能无法安全转换:

  • 新版本算子语义无法由旧版本等价表达;
  • 图中存在自定义域算子;
  • 动态形状导致转换器无法推导形状;
  • 转换后目标后端仍不支持生成的节点;
  • 量化、控制流或特殊数据类型依赖运行时扩展。

转换后必须重新执行结构检查和数值对比。不能只检查 opset_import 是否改变。

四、从 PyTorch 导出一个带动态形状的 ONNX 模型

下面给出一个完整的最小示例。模型接收形状为 [batch, sequence, hidden] 的输入,并对最后一个维度执行线性变换和激活。它代表 Transformer 中许多逐 token 投影层的简化形式。

1. 安装依赖

示例使用 PyTorch 的现代 ONNX 导出路径和 ONNX Runtime:

python -m pip install torch onnx onnxruntime onnxscript

实际项目应锁定版本,例如通过 requirements.txt、uv lockfile 或容器镜像记录版本。导出器、ONNX、ONNX Runtime 和 Python 版本之间可能存在兼容性差异。

2. 导出代码

from pathlib import Path

import torch
import torch.nn as nn


class TokenProjection(nn.Module):
    def __init__(self, hidden_size: int = 4, output_size: int = 3):
        super().__init__()
        self.proj = nn.Linear(hidden_size, output_size)
        self.act = nn.ReLU()

    def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
        # 这里为了演示图结构,input_ids 实际上是 FLOAT 特征,
        # 名称沿用输入接口示例,真实项目应使用更准确的命名。
        return self.act(self.proj(input_ids))


torch.manual_seed(0)

model = TokenProjection(hidden_size=4, output_size=3).eval()
example = torch.randn(2, 5, 4)

output_path = Path("token_projection.onnx")

# torch.export.Dim 表示符号维度。
# min/max 是导出器用于记录的约束范围,不能把它理解为
# 所有运行时都自动执行的业务校验。
batch = torch.export.Dim("batch", min=1, max=32)
sequence = torch.export.Dim("sequence", min=1, max=128)

torch.onnx.export(
    model,
    (example,),
    output_path,
    input_names=["input"],
    output_names=["output"],
    opset_version=18,
    dynamo=True,
    dynamic_shapes={
        "input": {
            0: batch,
            1: sequence,
        }
    },
)

print(f"exported: {output_path}")

这里每一步都有明确作用:

  • eval() 固定推理态行为;
  • example 提供输入个数、类型和初始形状;
  • opset_version=18 指定导出目标算子集;
  • dynamo=True 使用 PyTorch 当前推荐的基于 torch.export 的导出路径;
  • dynamic_shapes 将第 0 维和第 1 维标记为动态;
  • 第 2 维 hidden_size=4 保持静态,因为线性层的输入特征数必须匹配权重。

PyTorch 不同版本的导出器行为会变化。较老版本常见的是基于 tracing 的路径,以及:

dynamic_axes={
    "input": {0: "batch", 1: "sequence"},
    "output": {0: "batch", 1: "sequence"},
}

dynamic_axes 是旧导出路径中常见的接口;使用 dynamo=True 时,应优先按当前 PyTorch 文档使用 dynamic_shapes。如果项目必须兼容旧版本,应该在固定版本环境中测试,而不是把两套参数混用后假设语义完全相同。

3. 检查导出文件的结构

import onnx

model_onnx = onnx.load("token_projection.onnx")
onnx.checker.check_model(model_onnx)

print("IR version:", model_onnx.ir_version)
print("Opsets:", [
    (item.domain or "ai.onnx", item.version)
    for item in model_onnx.opset_import
])

for value in model_onnx.graph.input:
    print("input:", value.name, value.type.tensor_type.shape)

for value in model_onnx.graph.output:
    print("output:", value.name, value.type.tensor_type.shape)

onnx.checker.check_model 主要验证模型格式和图结构的一致性,例如节点输入输出、类型和属性是否满足 ONNX 约束。它不能证明:

  • 模型与 PyTorch 数值等价;
  • 目标硬件后端支持所有算子;
  • 预处理和后处理一致;
  • 动态形状的所有边界都能运行;
  • 性能满足服务目标。

五、动态形状不是“任意形状”

1. 动态维度的含义

设输入张量为:

XRB×S×HX \in \mathbb{R}^{B \times S \times H}

其中:

  • BB 是 batch size;
  • SS 是序列长度;
  • HH 是隐藏维度。

BBSS 声明为动态,表示同一个图可以在一定范围内接收不同的 BBSS。但模型仍然要求:

H=H权重H = H_{\text{权重}}

对于注意力模型,还可能要求:

Sq=SkS_q = S_k

或者要求 attention mask 的形状与 query/key 的长度相容。动态维度是符号变量,不是取消形状约束。

2. 形状约束来自算子组合

例如:

X: [B, S, 4]
W: [3, 4]
Y = X @ W^T

输出为:

Y: [B, S, 3]

这里 BS 可以变化,但最后一维不能从 4 改成 8。若导出器只能看到一个示例输入 [2, 5, 4],而没有显式声明动态维度,它可能把 25 固化为静态形状。随后输入 [4, 5, 4] 可能在运行时被拒绝。

更复杂的例子是 reshape:

XRB×S×Hreshape(X,[B,SH])X \in \mathbb{R}^{B \times S \times H} \rightarrow \operatorname{reshape}(X, [B, S \cdot H])

如果图中还要求固定 token 数,或 reshape 的目标形状来自常量,那么仅标记输入维度动态并不足以保证该路径成立。

3. 动态形状与后端性能的取舍

动态形状提高了模型复用能力,但可能增加:

  • 编译器形状分析成本;
  • 内核选择和缓存复杂度;
  • TensorRT 等后端的 profile 配置成本;
  • 内存规划难度;
  • 延迟抖动。

许多后端会要求为动态输入提供优化档位,例如:

min shape: [1, 1, 768]
opt shape: [8, 128, 768]
max shape: [32, 512, 768]

这不是 ONNX 标准本身的统一要求,而是具体后端的部署约束。一个 ONNX 图可以声明动态形状,但 TensorRT、NPU 编译器或服务框架仍可能要求额外的 min/opt/max profile。

生产环境常见的选择是:

  • 输入尺寸完全固定:获得更简单的编译和更稳定的性能;
  • 只让 batch 动态:适合固定分辨率图像或固定序列长度;
  • batch 和 sequence 都动态:适合通用服务,但要限制范围并配置 profile;
  • 按长度分桶:将请求分配到若干静态或半静态模型,减少 padding 和性能抖动。

4. 用多个形状验证动态声明

导出后不应只使用示例输入 [2, 5, 4] 验证。至少应测试:

test_shapes = [
    (1, 1, 4),
    (2, 5, 4),
    (8, 16, 4),
    (32, 128, 4),
]

还要测试边界外输入,例如 [33, 128, 4]。如果导出时声明了 batch <= 32,目标运行时可能拒绝该输入,也可能直到某个后端编译阶段才失败。服务层应明确执行输入校验,而不是依赖后端产生可读错误。

六、验证不能只做“文件能打开”

模型验证至少分为四层。

1. 格式验证

import onnx

onnx_model = onnx.load("token_projection.onnx")
onnx.checker.check_model(onnx_model)
print("ONNX graph is structurally valid")

这一层回答“文件是否符合 ONNX 图格式”,不回答“结果是否正确”。

2. 运行时可执行性验证

import numpy as np
import onnxruntime as ort

session = ort.InferenceSession(
    "token_projection.onnx",
    providers=["CPUExecutionProvider"],
)

print("inputs:", [
    (item.name, item.shape, item.type)
    for item in session.get_inputs()
])
print("outputs:", [
    (item.name, item.shape, item.type)
    for item in session.get_outputs()
])

x = np.random.default_rng(1).standard_normal(
    size=(8, 16, 4)
).astype(np.float32)

y_onnx = session.run(["output"], {"input": x})[0]
print("output shape:", y_onnx.shape)

预期输出形状为:

(8, 16, 3)

如果这里失败,常见原因包括:

  • 输入名称不匹配;
  • 输入 dtype 错误,例如模型需要 float32,实际传入 float64
  • 动态形状声明与图内 reshape 冲突;
  • ONNX Runtime 不支持某个算子或算子版本;
  • 使用了未注册的自定义域;
  • 模型外部权重文件缺失或路径错误。

3. 数值等价验证

import numpy as np
import torch

model.eval()

rng = np.random.default_rng(2)
x_np = rng.standard_normal((8, 16, 4)).astype(np.float32)
x_torch = torch.from_numpy(x_np)

with torch.no_grad():
    y_torch = model(x_torch).cpu().numpy()

y_onnx = session.run(["output"], {"input": x_np})[0]

max_abs_error = np.max(np.abs(y_torch - y_onnx))
max_rel_error = np.max(
    np.abs(y_torch - y_onnx)
    / np.maximum(np.abs(y_torch), 1e-12)
)

print("max absolute error:", max_abs_error)
print("max relative error:", max_rel_error)

np.testing.assert_allclose(
    y_torch,
    y_onnx,
    rtol=1e-5,
    atol=1e-6,
)

常用判定形式为:

yonnxyrefatol+rtolyref|y_{\text{onnx}} - y_{\text{ref}}| \le \text{atol} + \text{rtol}\cdot |y_{\text{ref}}|

其中:

  • atol 是绝对误差阈值;
  • rtol 是相对误差阈值;
  • yrefy_{\text{ref}} 通常是 PyTorch 或高精度参考实现的结果。

误差阈值不是 ONNX 规范规定的固定值。它取决于:

  • FP32、FP16、BF16 或 INT8;
  • CPU、GPU 和不同算子内核;
  • 是否使用融合算子;
  • 模型对微小数值变化是否敏感;
  • 最终业务指标是否对误差敏感。

对于分类模型,除了逐元素误差,还应比较 top-1/top-k、一致率和置信度分布。对于生成式模型,逐 token 的完全一致更难保证,应区分:

  • logits 的数值误差;
  • greedy decoding 的 token 一致率;
  • sampling 下的分布一致性;
  • 长上下文和 KV cache 场景的质量指标。

4. 业务指标验证

假设模型用于二分类,逐元素误差很小并不自动证明线上质量一致。应在固定评测集上比较:

  • accuracy、F1、AUC;
  • 不同人群或不同数据分布上的指标;
  • 拒答、阈值和校准结果;
  • 延迟、吞吐和显存;
  • 输入非法、超长和空值场景。

模型、数据、评测和部署配置应作为同一个版本单元记录。否则出现指标变化时,无法判断是导出、数据预处理、运行时、量化还是阈值变更造成的。

七、定位“结果不一致”的方法

数值不一致时,不应直接提高误差阈值。应按照计算图逐层定位。

1. 先固定所有非确定因素

验证前应固定:

  • 模型为 eval()
  • 随机种子;
  • 输入数据和 dtype;
  • tokenizer 版本;
  • 预处理参数;
  • 运行时和执行提供者;
  • 是否启用量化、图优化和混合精度。

如果模型包含随机采样、Dropout、动态路由或非确定性 GPU 算子,逐元素比较可能没有意义,应改用统计和业务指标。

2. 比较输入和输出契约

最常见的错误不是算子本身,而是输入契约不一致:

  • RGB 与 BGR 顺序不同;
  • NCHWNHWC 混用;
  • 图像归一化从 [0, 255] 变成 [0, 1],或均值方差不同;
  • tokenizer 的词表、特殊 token 或 padding side 不同;
  • int64int32float32 发生隐式转换;
  • attention mask 使用了 0/1 与加性 mask 两种不同语义。

应先保存送入 PyTorch 和 ONNX 的完全相同的输入数组,再比较输出。

3. 比较中间值

可以将模型改造成输出关键中间张量,或者使用 ONNX 图调试工具检查节点结果。定位顺序通常是:

  1. 第一个产生明显偏差的节点;
  2. 检查该节点输入是否已经偏差;
  3. 检查算子属性、广播、轴和数据类型;
  4. 检查后端是否使用了融合或近似实现。

如果第一个偏差节点是 ResizePadLayerNormalizationSoftmaxGatherWhere,应重点检查坐标变换、轴编号、边界规则和类型转换。这些算子在不同框架和后端中最容易出现看似细小但会累积的差异。

4. 区分导出错误和后端错误

同一个 ONNX 文件可以分别在:

  • ONNX Runtime CPU;
  • ONNX Runtime CUDA;
  • 目标硬件后端;

上执行。如果 CPU 结果与 PyTorch 一致,但目标后端不一致,问题更可能位于后端转换、算子融合、精度模式或自定义内核,而不是导出阶段。

八、传统机器学习、深度学习和生成式 AI 的不同边界

1. 传统机器学习模型

树模型、线性模型和部分预处理可以使用 ai.onnx.ml 域中的算子表达,但实际导出能力取决于训练库和转换器。即使模型主体成功导出,以下部分仍可能在图外:

  • 缺失值填充;
  • 类别编码;
  • 特征列顺序;
  • 标准化参数;
  • 标签映射;
  • 业务阈值。

特征顺序错误通常不会引发运行时异常,却会造成严重的静默错误。因此特征 schema 应与 ONNX 文件一起版本化,并在服务入口校验。

2. 深度学习模型

卷积网络常见问题包括:

  • NCHW/NHWC 布局;
  • padding 和 resize 规则;
  • 动态输入尺寸;
  • 后处理是否导出,例如 NMS、top-k、decode;
  • 自定义 CUDA 算子。

Transformer 常见问题包括:

  • attention_maskposition_ids
  • mask 的 dtype 与广播形状;
  • past_key_values 的输入输出;
  • causal mask;
  • cache 长度动态增长;
  • 算子融合对精度和支持范围的影响。

3. 生成式 AI 模型

生成式模型通常不是单个“一次前向”问题,而是一个循环:

tokent+1=Sample(Decoder(xt,cachet))\text{token}_{t+1} = \operatorname{Sample}\left( \operatorname{Decoder}(x_{\le t}, \text{cache}_t) \right)

ONNX 图可以表达 decoder 的一次或若干次前向,但以下内容常常仍由外部生成循环控制:

  • tokenizer;
  • prompt 拼接;
  • stopping criteria;
  • temperature、top-k、top-p;
  • random generator;
  • KV cache 管理;
  • 流式输出;
  • 安全过滤和内容策略。

若将循环、采样和 cache 全部塞入图中,可能提高封装程度,但也会降低后端兼容性和调试性。常见部署方案是将模型拆成:

  1. prefill 图:处理完整 prompt;
  2. decode 图:每次生成一个或一小批 token;
  3. 外部 runtime loop:维护 cache、采样和停止条件。

大模型还可能超过单个 protobuf 文件或普通文件处理方式的便利范围,需要 external data。部署时必须把 .onnx 和外部权重文件作为同一不可分割的制品管理,并验证相对路径、挂载权限和校验和。

九、算子不支持时的处理路径

当目标运行时不支持某个算子时,通常有四种路径。

1. 改写模型结构

例如,将后端不支持的复合操作改写为标准算子组合。这样通常具有最好的跨运行时能力,但可能改变数值或性能,需要重新验证。

2. 使用兼容的 opset 或导出选项

如果问题来自某个算子版本,可以尝试目标后端支持的较低 opset。但这只有在导出器能够正确表达语义时才成立。

3. 注册自定义算子

自定义算子可以保留特殊功能,但必须同时提供:

  • ONNX 节点定义和域;
  • 目标运行时的 kernel;
  • CPU/GPU 等执行实现;
  • 输入输出类型和形状规则;
  • 版本兼容策略;
  • 测试与回滚方案。

这已经不是“通用 ONNX 部署”,而是“ONNX 图加运行时扩展”。

4. 保留图外逻辑

对于分词、复杂后处理、业务规则或不值得图内化的控制逻辑,将其保留在服务代码中通常更容易维护。代价是需要严格定义图外输入输出契约。

错误做法是把不支持的节点删除、替换为恒等函数,或者只让文件通过 checker。这样可能导致模型“能运行但结果错误”,比直接失败更危险。

十、部署时的组件、状态和故障路径

一个生产推理服务至少包括以下状态:

stateDiagram-v2
    [*] --> Loading
    Loading --> Ready: 模型、权重、EP 初始化成功
    Loading --> Failed: 文件缺失/权限/算子不支持
    Ready --> Validating: 收到请求
    Validating --> Rejected: schema 或权限不合法
    Validating --> Executing: 形状、类型、配额通过
    Executing --> Responding: 推理完成
    Executing --> Timeout: 超过截止时间
    Executing --> Failed: OOM/设备错误/运行时异常
    Responding --> Ready
    Rejected --> Ready
    Timeout --> Ready
    Failed --> Recovering
    Recovering --> Ready: 重启或切换版本成功
    Recovering --> Failed: 恢复失败

1. 加载阶段

服务启动时应验证:

  • 模型文件和 external data 是否存在;
  • 文件校验和是否匹配制品清单;
  • ONNX checker 是否通过;
  • 目标 EP 是否成功初始化;
  • 输入输出名称、类型和形状是否符合服务配置;
  • 模型版本、训练数据版本和评测结果是否匹配。

模型文件目录应采用只读权限。运行时进程不应拥有修改模型、凭据或宿主机敏感目录的权限。模型加载失败应使实例进入不可接流量状态,而不是启动后才在第一条请求上暴露错误。

2. 请求阶段

服务层应先校验:

  • 输入字段是否完整;
  • dtype 和 rank 是否正确;
  • 动态维度是否在允许范围内;
  • token 数、图像尺寸或 batch 是否超过配额;
  • 调用方是否有权限访问该模型;
  • 单请求和租户累计成本是否超限。

这类校验不仅是健壮性措施,也直接关系到成本控制。一个没有长度上限的生成式 AI 接口可能因超长 prompt、过大的 KV cache 或异常 batch 造成显存耗尽。

3. 执行阶段

推理服务需要处理:

  • 并发请求是否共享 session;
  • 是否使用请求级 buffer,避免数据竞争;
  • batcher 是否改变输入顺序;
  • 动态 shape 是否触发重复编译;
  • GPU stream 和线程模型是否满足运行时要求;
  • 超时后是否能够取消或隔离执行;
  • OOM 后是否需要重启 worker。

ONNX Runtime 的 InferenceSession 通常可以被多个请求复用,但具体线程安全和执行提供者行为仍应以目标版本文档和压力测试为准。不要在每个请求中重新加载模型,这会造成高额初始化开销并放大内存占用。

4. 观测与回滚

应记录但避免泄露敏感内容:

  • 模型版本和 opset;
  • 执行提供者;
  • 输入形状桶;
  • 请求数量、错误率、p50/p95/p99 延迟;
  • CPU、GPU、显存和内存;
  • token 数和生成长度;
  • 超时、OOM、算子错误;
  • 业务质量指标。

日志不应默认记录原始 prompt、个人信息或完整特征。模型权限和数据权限应分别控制:能调用模型不代表能读取所有输入数据,也不代表能查看模型权重。

回滚应回到一个经过结构、数值、性能和业务评测的完整制品,而不是只替换 .onnx 文件。模型、tokenizer、预处理、后处理和服务配置之间必须保持版本一致。

十一、性能验证不能只看单次延迟

部署评测至少需要区分:

  • 首次加载时间;
  • warm-up 后单请求延迟;
  • 并发下的 p50、p95、p99;
  • 吞吐量;
  • 峰值内存和显存;
  • 动态 shape 不同档位的性能;
  • CPU fallback 比例;
  • 量化或混合精度造成的质量变化。

如果目标是降低成本,不能只比较每次请求的毫秒数,还应计算:

单位成本=实例成本×运行时间有效请求数\text{单位成本} = \frac{\text{实例成本} \times \text{运行时间}} {\text{有效请求数}}

对生成式 AI,还要考虑输入 token、输出 token、cache 长度和失败重试。一个单请求延迟较低但频繁 OOM 或需要大量 padding 的部署方案,实际单位成本可能更高。

当后端只支持部分算子时,图可能发生 CPU/GPU 分段执行。此时即使每个算子都“支持”,设备间拷贝也可能成为主要瓶颈。应检查执行日志、节点分配和 profile,而不是只看模型总耗时。

十二、常见误解及其失败表现

误解一:文件生成成功就代表导出成功

失败表现是模型可以加载,但输出全为零、形状错误或业务指标下降。原因可能是 tracing 固化了某个分支、预处理未纳入验证,或训练态状态错误。

误解二:opset 越高越兼容

更高 opset 可能包含更完整的语义,但旧版运行时和硬件后端可能无法解析或编译。兼容性取决于导出器、运行时和硬件三者,而不是一个版本数字。

误解三:声明动态形状后可以输入任意长度

动态维度仍受算子约束、导出时记录的范围、后端 profile 和服务资源限制。超长输入可能导致 shape error、编译失败或 OOM。

误解四:onnx.checker 通过就代表结果正确

checker 只验证结构合法性。必须使用相同输入比较参考框架和目标运行时结果,再进行业务评测。

误解五:量化后的误差仍应使用 FP32 阈值

INT8、FP16 和 BF16 会引入不同程度的数值变化。应分别建立精度阈值和业务验收标准,尤其关注 softmax、归一化、长序列累积和生成模型 logits。

误解六:把 tokenizer 和后处理留在服务外就不需要版本管理

恰恰相反。ONNX 模型只接收张量,如果 tokenizer 或图像预处理改变,模型可能仍能正常运行,但语义已经改变。输入契约必须包含预处理版本、词表版本、标签映射和特殊 token 规则。

十三、一个可执行的交付验收顺序

一个新模型进入部署前,可以按以下顺序执行:

  1. 固定 PyTorch、ONNX、导出器和运行时版本;
  2. model.eval(),明确推理态状态;
  3. 明确输入输出名称、dtype、rank 和 shape 约束;
  4. 根据目标后端选择可用 opset;
  5. 导出 ONNX,并记录模型 hash、opset、IR 版本;
  6. 使用 onnx.checker 验证结构;
  7. 在 CPU 参考运行时验证可执行性;
  8. 对多个静态和动态形状执行数值对比;
  9. 在目标 EP 或硬件上重新执行数值和业务评测;
  10. 检查算子分配、fallback、内存、延迟和吞吐;
  11. 验证非法输入、超限输入、超时、OOM 和模型加载失败;
  12. 将模型、权重、tokenizer、预处理、评测报告和服务配置作为一个版本交付;
  13. 以小流量灰度发布,并保留上一版本的快速回滚路径。

其中第 7 步和第 9 步不能互相替代。CPU 运行时适合判断导出图是否基本正确,目标硬件验证则负责发现后端算子支持、精度和性能问题。

结语

ONNX 的核心价值是提供模型计算图的互操作边界,而不是承诺所有框架、所有算子和所有硬件之间完全等价。真正可靠的流程需要同时理解:

  • 导出器如何将框架模型降低为 ONNX 图;
  • opset 如何定义算子语义和兼容范围;
  • 动态形状如何表达符号维度以及仍然存在的约束;
  • 结构检查、运行时检查、数值对比和业务评测各自能证明什么;
  • 目标执行提供者如何影响算子支持、精度、性能和成本;
  • 模型之外的预处理、生成循环、权限和数据契约如何共同决定线上行为。

只有当这些部分作为同一个生产系统被验证和版本化时,ONNX 文件才不只是一个导出产物,而能成为可部署、可观测、可回滚的模型交付接口。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。