【ONNX】HQServer集成ONNX Runtime通用推理封装

前言

在图像识别、文本分类、目标检测和推荐评分等场景中,模型训练通常不应该和 Web API 运行在同一个业务层里。训练完成后,把模型导出成 ONNX,再由服务端加载并执行推理,是一种比较常见的部署方式。

这次 HQServer 增加了 HQ.Common.Onnx 通用封装,目标不是替业务模型完成图片解码或后处理,而是把模型加载、Session 复用、输入节点校验、推理执行、元数据读取和资源释放这些重复工作统一起来。

本文单独介绍这套 ONNX Runtime 集成,不依赖前面的 RabbitMQ、Quartz 或 Demo 文章。

一、为什么要封装 ONNX Runtime

直接在每个业务服务里创建 InferenceSession,很容易出现几个问题:

  • 每次请求都重新加载模型,启动慢且占用大量内存。
  • 输入节点名称写错时,只能等到 Runtime 执行才发现。
  • 多输入模型容易漏传节点,或者传入重复、未知的节点。
  • 输出对象依赖 Runtime 生命周期,离开方法后继续使用可能失效。
  • 模型文件和 Session 没有明确的释放边界。

OnnxModelBase 将这些通用行为集中起来,派生模型只需要完成“业务输入如何转换成 Tensor”和“输出如何转换成业务对象”。

二、安装和项目引用

HQ.Common 使用 Microsoft.ML.OnnxRuntime:

<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.22.0" />

业务项目引用 HQ.Common 后即可使用:

using HQ.Common.Onnx;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;

CPU 推理可以使用默认包。CUDA、DirectML 等执行 Provider 需要根据部署机器和 ONNX Runtime 版本选择对应包,并在 CreateSessionOptions 中配置。

三、最简单的单输入模型

假设模型只有一个输入节点,输入是一维 float 数组,业务模型可以继承单泛型版本:

public sealed class ScoreModel(string modelPath)
    : OnnxModelBase<float[]>(new OnnxModelOptions
    {
        ModelPath = modelPath,
        ModelName = "评分模型"
    })
{
    protected override IReadOnlyCollection<NamedOnnxValue> CreateInputs(
        float[] input)
    {
        var tensor = new DenseTensor<float>(
            input,
            [1, input.Length]);

        return Input(tensor);
    }
}

Input(tensor) 会自动读取模型唯一的输入节点名称,并校验 Tensor 元素类型。如果模型实际包含多个输入,调用这个方法会立即抛出明确异常,提示改用命名输入。

调用方式:

using var model = new ScoreModel("models/score.onnx");

var prediction = model.Predict([0.1f, 0.2f, 0.3f]);
var outputName = model.Metadata.Outputs.Single().Name;
var output = prediction[outputName];

float[] values = output.GetValues<float>();
IReadOnlyList<int> shape = output.Dimensions;

模型文件在构造函数中只加载一次,之后的 Predict 会复用同一个 InferenceSession

四、多输入模型

BERT、文本匹配和部分多模态模型通常需要多个输入节点。此时使用 Input(name, tensor) 创建命名输入,再用 Inputs(...) 组合:

public sealed record BertInput(
    long[] InputIds,
    long[] AttentionMask,
    long[] TokenTypeIds);

public sealed class BertModel(string modelPath)
    : OnnxModelBase<BertInput>(new OnnxModelOptions
    {
        ModelPath = modelPath,
        ModelName = "BERT模型"
    })
{
    protected override IReadOnlyCollection<NamedOnnxValue> CreateInputs(
        BertInput input)
    {
        var length = input.InputIds.Length;

        var inputIds = new DenseTensor<long>(
            input.InputIds,
            [1, length]);
        var attentionMask = new DenseTensor<long>(
            input.AttentionMask,
            [1, length]);
        var tokenTypeIds = new DenseTensor<long>(
            input.TokenTypeIds,
            [1, length]);

        return Inputs(
            Input("input_ids", inputIds),
            Input("attention_mask", attentionMask),
            Input("token_type_ids", tokenTypeIds));
    }
}

Inputs(...) 会在执行推理前检查:

  • 输入节点名称是否真实存在;
  • 是否缺少模型要求的输入;
  • 是否传入未知输入;
  • 是否出现重复输入名称;
  • Tensor 元素类型是否和模型元数据一致。

模型要求的具体 Shape 仍然由业务模型负责构造,最终 Shape 校验由 ONNX Runtime 执行。

五、读取模型元数据

模型加载后可以通过 Metadata 查看输入和输出节点:

foreach (var input in model.Metadata.Inputs)
{
    Console.WriteLine($"输入:{input.Name}");
    Console.WriteLine($"形状:[{string.Join(", ", input.Dimensions)}]");
    Console.WriteLine($"类型:{input.ElementType.Name}");
}

foreach (var output in model.Metadata.Outputs)
{
    Console.WriteLine($"输出:{output.Name}");
    Console.WriteLine($"形状:[{string.Join(", ", output.Dimensions)}]");
    Console.WriteLine($"类型:{output.ElementType.Name}");
}

调试新模型时,建议先打印一次元数据,再根据实际节点名称、元素类型、输入形状和输出结构实现 CreateInputs

六、图片模型如何接入

图片模型的输入通常不是简单的数组,而是一个图片流。框架不强行绑定图片库,派生类负责图片解码、缩放、Letterbox、RGB/BGR 排列、归一化和 NCHW/NHWC 转换:

public sealed class ImageModel(string modelPath)
    : OnnxModelBase<Stream>(new OnnxModelOptions
    {
        ModelPath = modelPath,
        ModelName = "图片分类模型"
    })
{
    protected override IReadOnlyCollection<NamedOnnxValue> CreateInputs(
        Stream imageStream)
    {
        var tensor = CreateImageTensor(imageStream);
        return Input(tensor);
    }

    private static DenseTensor<float> CreateImageTensor(Stream imageStream)
    {
        // 根据具体模型完成图片解码、Resize、归一化和布局转换。
        throw new NotImplementedException();
    }
}

调用时由业务层负责管理图片流:

using var model = new ImageModel("models/classifier.onnx");
await using var image = File.OpenRead("test.jpg");

var prediction = model.Predict(image);

实际接口中还应限制上传文件大小、图片像素数量和推理并发数,避免恶意输入造成内存或 CPU 资源耗尽。

七、直接返回业务结果

如果不希望业务层拿着通用输出对象解析,可以使用双泛型基类,将输出转换成业务对象:

public sealed record DetectionResult(
    string ClassName,
    float Confidence,
    float X,
    float Y,
    float Width,
    float Height);

public sealed class DetectionModel(string modelPath)
    : OnnxModelBase<Stream, IReadOnlyList<DetectionResult>>(
        new OnnxModelOptions
        {
            ModelPath = modelPath,
            ModelName = "目标检测模型"
        })
{
    protected override IReadOnlyCollection<NamedOnnxValue> CreateInputs(
        Stream input)
    {
        var tensor = CreateImageTensor(input);
        return Input(tensor);
    }

    protected override IReadOnlyList<DetectionResult> ProcessOutputs(
        IDisposableReadOnlyCollection<DisposableNamedOnnxValue> outputs)
    {
        var tensor = outputs.Single().AsTensor<float>();
        return ParseDetections(
            tensor.ToArray(),
            tensor.Dimensions.ToArray());
    }

    private static DenseTensor<float> CreateImageTensor(Stream input)
        => throw new NotImplementedException();

    private static IReadOnlyList<DetectionResult> ParseDetections(
        float[] values,
        int[] dimensions)
        => throw new NotImplementedException();
}

ProcessOutputs 执行期间必须把需要的数据复制出来,例如调用 ToArray()。不要把 ONNX Runtime 的原始输出对象直接返回给调用方,因为 Predict 结束时输出集合会被释放。

八、依赖注入和 Session 生命周期

InferenceSession 加载模型成本较高,业务模型建议注册为 Singleton:

builder.Services.AddSingleton<ScoreModel>(_ =>
    new ScoreModel("models/score.onnx"));

如果模型路径来自配置,可以先绑定自己的 Options,再创建模型。模型文件应放在受信任、只读的目录中,不能让普通用户通过请求参数指定任意模型路径。

应用关闭时,模型对象会释放内部 Session。不要在每个请求中执行 new ScoreModel(...),也不要把一个未注册生命周期的模型对象随意保存到静态变量。

九、扩展 SessionOptions

需要配置图优化、线程数或硬件 Provider 时,可以重写 CreateSessionOptions

protected override SessionOptions CreateSessionOptions(
    OnnxModelOptions options)
{
    var sessionOptions = base.CreateSessionOptions(options);

    // 根据部署环境启用具体 Provider 或线程配置。
    // sessionOptions.AppendExecutionProvider_CPU();

    return sessionOptions;
}

Provider 配置必须和实际部署机器、Native 依赖及模型算子兼容。建议先在目标环境验证加载和推理,不要只在开发机验证。

十、模型输入输出的排查顺序

  1. 确认模型文件路径和文件权限。
  2. 打印 Metadata.InputsMetadata.Outputs
  3. 确认节点名称完全匹配,包括大小写和下划线。
  4. 确认 Tensor 元素类型,例如 floatlongint
  5. 确认输入 Shape 是模型需要的布局,例如 [1,N][1,3,224,224]
  6. 先用一个固定样本完成单元测试,再接入 HTTP 上传或业务数据。
  7. 确认输出已经复制,避免返回已释放的 Runtime 对象。

十一、生产使用注意事项

  • 模型文件必须来自可信来源,部署目录建议只读。
  • 限制输入文件大小、Tensor 长度、图片分辨率和单请求推理时间。
  • 根据 CPU/GPU 资源控制并发,不要无限制并行调用推理。
  • 记录模型版本和输入输出元数据,方便回溯结果差异。
  • 不要在日志中记录原始图片、敏感文本或完整业务输入。
  • 模型升级要和业务后处理逻辑一起做兼容性验证。

总结

HQServer 的 ONNX 封装把通用生命周期和输入输出校验沉淀到 OnnxModelBase,业务模型只需要关注自己的输入预处理和输出解析。单输入模型使用 Input(tensor),多输入模型使用 Input(name, tensor)Inputs(...),业务结果使用双泛型版本转换。

推荐的落地顺序是:先读取模型元数据,再实现 Tensor 构造,随后用固定样本验证推理,最后注册为 Singleton 接入 API。这样可以把模型问题和 Web、数据库、消息队列问题分开排查。

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享