前言
在图像识别、文本分类、目标检测和推荐评分等场景中,模型训练通常不应该和 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 依赖及模型算子兼容。建议先在目标环境验证加载和推理,不要只在开发机验证。
十、模型输入输出的排查顺序
- 确认模型文件路径和文件权限。
- 打印
Metadata.Inputs和Metadata.Outputs。 - 确认节点名称完全匹配,包括大小写和下划线。
- 确认 Tensor 元素类型,例如
float、long或int。 - 确认输入 Shape 是模型需要的布局,例如
[1,N]、[1,3,224,224]。 - 先用一个固定样本完成单元测试,再接入 HTTP 上传或业务数据。
- 确认输出已经复制,避免返回已释放的 Runtime 对象。
十一、生产使用注意事项
- 模型文件必须来自可信来源,部署目录建议只读。
- 限制输入文件大小、Tensor 长度、图片分辨率和单请求推理时间。
- 根据 CPU/GPU 资源控制并发,不要无限制并行调用推理。
- 记录模型版本和输入输出元数据,方便回溯结果差异。
- 不要在日志中记录原始图片、敏感文本或完整业务输入。
- 模型升级要和业务后处理逻辑一起做兼容性验证。
总结
HQServer 的 ONNX 封装把通用生命周期和输入输出校验沉淀到 OnnxModelBase,业务模型只需要关注自己的输入预处理和输出解析。单输入模型使用 Input(tensor),多输入模型使用 Input(name, tensor) 与 Inputs(...),业务结果使用双泛型版本转换。
推荐的落地顺序是:先读取模型元数据,再实现 Tensor 构造,随后用固定样本验证推理,最后注册为 Singleton 接入 API。这样可以把模型问题和 Web、数据库、消息队列问题分开排查。









