ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ASP.NET MVC 集成 ONNX Runtime 本机部署图像与视频 LLM 实战

ASP.NET MVC 集成 ONNX Runtime 本机部署图像与视频 LLM 实战 1. 项目缘起与整体架构思路1.1 为什么要在 ASP.NET MVC 里塞进一个本机 LLM先说背景。我手头有一套跑了快三年的 ASP.NET MVC 老系统业务是工业质检方向的数据采集和报表前端是 Razor 视图加一点 jQuery后端 .NET Framework 4.7.2数据库 SQL Server。这套东西稳定是稳定但这两年客户开始提一些智能化的需求最典型的就是上传一段产线视频自动生成一段文字描述上传一张缺陷图片自动判断缺陷类型并给出处理建议。一开始我想的是直接调云端 API简单省事。但实际跑下来问题不少一是产线现场网络环境复杂外网访问不稳定二是客户对数据出本地网络这件事非常敏感质检图像和视频涉及工艺细节不愿意往外传三是按调用量计费长期算下来成本不低。所以最后定下来的方案是在本机跑 LLM 推理通过 C# 后端调用前端还是走原来的 MVC 那套。这里要澄清一个概念很多人一听本机跑 LLM就觉得不可能觉得大模型动辄几十上百 G普通工控机根本带不动。这个认知在 2023 年是对的但现在的局面变了。一方面有大量 7B 甚至 3B 级别的小模型量化之后体积能压到 4G 以内另一方面 ONNX Runtime 这类推理框架对 CPU 推理的优化做得相当到位配上 int8 量化在 i7 级别的机器上跑一个视觉模型做单张图片推理几百毫秒到一两秒是能接受的。视频的话稍微麻烦点要抽帧但也不是不能做。所以这个项目的核心思路可以概括成一句话用 ONNX 作为模型的统一交付格式用 ONNX Runtime 作为 C# 侧的推理引擎把图像 LLM 和视频 LLM 的能力封装成 MVC 里的 Service前端通过 Controller 调用。整个链路不依赖任何外部服务全部在本机完成。1.2 整体架构分层我把整个工程分成了四层从下往上说层级职责关键技术模型层存放 ONNX 模型文件、分词器配置、预处理参数ONNX、tokenizer.json推理层加载模型、执行推理、管理会话生命周期ONNX Runtime、Microsoft.ML.OnnxRuntime服务层封装图像/视频的具体业务逻辑对外暴露干净接口C# Service、依赖注入应用层MVC Controller、Razor 视图、文件上传下载ASP.NET MVC、IIS这么分的好处是模型换了、推理引擎换了上层业务代码基本不用动。我后面把图像模型从 BLIP 换成 Qwen-VL 的量化版服务层只改了一个类Controller 一行没动。1.3 为什么选 ONNX 而不是直接跑 PyTorch这是被问得最多的一个问题。PyTorch 在 Python 里跑得好好的为什么要费劲转成 ONNX原因有三个。第一C# 生态里没有原生的 PyTorch 运行时。你当然可以用 Python.NET 或者起一个 Python 子进程但前者版本兼容性坑多后者进程间通信的开销和稳定性都是问题。第二ONNX Runtime 有官方维护的 C# 包Microsoft.ML.OnnxRuntime这个 NuGet 包质量很高API 设计也符合 .NET 的习惯用起来顺手。第三ONNX 是格式标准不是框架同一个模型文件可以被不同语言、不同平台加载未来如果要迁移到别的环境模型资产不会浪费。当然代价也有不是所有模型都能顺利导出 ONNX尤其是带动态控制流的模型导出时经常报错。这个后面在实操部分会详细讲怎么处理。1.4 图像 LLM 和视频 LLM 的定位差异虽然标题里把这两个并列但它们在工程上的处理方式差别很大得分开说。图像 LLM处理的是单帧静态图像输入是一张图加一段文本 prompt输出是文本。典型任务包括图像描述、视觉问答、缺陷分类。它的推理是一次性的一张图进去一段文字出来延迟可控。视频 LLM本质上是在图像 LLM 的基础上加了时序维度。有两种做法一种是真正的视频模型输入是视频张量模型内部处理时序另一种是抽帧 图像模型 文本聚合把视频拆成若干关键帧每帧过图像模型再把结果拼起来。我选的是第二种原因很实际真正的视频 LLM 参数量大、显存要求高本机跑不动而抽帧方案用现有的图像模型就能做工程复杂度低得多。这个取舍很关键后面会专门展开讲抽帧策略怎么定。2. 环境搭建与模型准备的关键细节2.1 Visual Studio 侧的工程配置开发环境我用的是 Visual Studio 2022社区版就够。新建项目选ASP.NET Web Application (.NET Framework)模板选 MVC。这里有个坑要提醒如果你的目标框架是 .NET Framework 4.7.2 或以下ONNX Runtime 的某些新版本会不兼容建议至少升到 4.7.2能上 4.8 更好。NuGet 包需要装这几个Install-Package Microsoft.ML.OnnxRuntime Install-Package Microsoft.ML.OnnxRuntime.Managed Install-Package Newtonsoft.Json Install-Package System.MemoryMicrosoft.ML.OnnxRuntime是原生库加托管封装Managed包提供纯托管的部分。System.Memory是因为 ONNX Runtime 的 API 里大量用到SpanT和MemoryT老框架需要这个包来补。平台目标一定要设成x64不要用 AnyCPU。ONNX Runtime 的原生库是按平台编译的AnyCPU 在 64 位系统上跑起来会找不到 DLL。这个坑我踩过报错信息是Unable to load DLL onnxruntime查了半天才发现是平台目标的问题。提示如果项目里有其他依赖是 32 位的那就麻烦了得考虑把 ONNX 推理拆成一个独立的 64 位进程通过命名管道或者本地 HTTP 通信。不过这种情况现在很少见了。2.2 模型从 PyTorch 到 ONNX 的转换模型转换这一步通常在 Python 环境里做跟 C# 没关系但这一步的质量直接决定了后面 C# 侧好不好用。以图像模型为例假设你从 HuggingFace 上拉了一个视觉问答模型转换的基本流程是import torch from transformers import AutoModelForVision2Seq, AutoProcessor model AutoModelForVision2Seq.from_pretrained(your-model-path) processor AutoProcessor.from_pretrained(your-model-path) dummy_image torch.randn(1, 3, 224, 224) dummy_input_ids torch.randint(0, 1000, (1, 16)) torch.onnx.export( model, (dummy_image, dummy_input_ids), model.onnx, input_names[pixel_values, input_ids], output_names[logits], dynamic_axes{ pixel_values: {0: batch}, input_ids: {0: batch, 1: seq_len}, logits: {0: batch, 1: seq_len} }, opset_version14 )这里有几个关键点。opset_version 建议用 14 或更高低版本的 opset 对某些算子支持不好尤其是 attention 相关的。dynamic_axes 一定要设否则导出的模型 batch size 和序列长度都是固定的实际用起来很别扭。dummy input 的 shape 要跟实际推理时一致不然导出的图结构可能不对。导出之后别急着往 C# 里塞先用 Python 的 onnxruntime 验证一遍确认输出跟原模型对得上。这一步能省掉后面大量的排查时间。2.3 int8 量化的取舍模型体积和推理速度是绕不开的话题。一个 FP32 的 7B 模型大概 28GFP16 是 14Gint8 是 7G 左右。本机跑的话int8 基本是唯一现实的选择。ONNX Runtime 提供了量化工具from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( model_inputmodel.onnx, model_outputmodel_int8.onnx, weight_typeQuantType.QInt8 )动态量化只量化权重激活值在推理时动态量化实现简单对精度影响相对小。静态量化需要校准数据集精度可能更好但准备工作多。实测下来视觉模型做 int8 动态量化之后精度下降通常在 1-3 个百分点对于描述图像内容这种任务基本感知不到。但如果你的任务是精细分类比如区分十几种相似的缺陷类型那量化带来的精度损失可能就不能接受了得权衡。注意量化不是万能的。有些模型量化后会出现输出乱码、重复生成的问题这时候要么换量化策略要么老老实实用 FP16。别为了省那点内存把效果搞崩了。2.4 分词器和预处理资源的准备模型只是半边天另外半边是预处理。图像模型需要把图片 resize、归一化、转成张量文本模型需要分词、转 token id。这些逻辑在 Python 里是 processor 帮你做的到了 C# 侧得自己实现。分词器这块HuggingFace 的 tokenizer 可以导出成tokenizer.jsonC# 侧用Microsoft.ML.Tokenizers这个包来加载。不过这个包对某些特殊 tokenizer 的支持还不完善遇到问题的话一个务实的做法是把分词逻辑也做成一个 ONNX 模型或者干脆在 C# 里手写一个简化版的分词器只支持你实际用到的那些 token。图像预处理相对简单无非是 resize、归一化。用System.Drawing或者ImageSharp都能做。注意归一化的均值和方差要跟训练时一致这个参数一般在模型的preprocessor_config.json里。3. C# 侧推理引擎的封装与调用3.1 InferenceSession 的生命周期管理ONNX Runtime 的核心类是InferenceSession它负责加载模型、管理内存、执行推理。这个对象的创建开销不小一个几百兆的模型加载可能要几秒所以绝对不能每次请求都新建。正确的做法是在应用启动时创建全局复用。在 MVC 里可以用Application_Start或者依赖注入的单例模式public class OnnxSessionManager { private static readonly LazyInferenceSession _session new LazyInferenceSession(() { var options new SessionOptions(); options.IntraOpNumThreads Environment.ProcessorCount / 2; options.GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL; return new InferenceSession(models/model_int8.onnx, options); }); public static InferenceSession Session _session.Value; }IntraOpNumThreads控制算子内部的并行线程数设成 CPU 核心数的一半是个经验值设太高反而会因为线程切换开销导致性能下降。GraphOptimizationLevel设成ORT_ENABLE_ALL让运行时做尽可能多的图优化。提示InferenceSession是线程安全的多个请求可以并发调用Run方法。但要注意如果模型很大并发推理会导致内存暴涨实际部署时最好加一个信号量限制并发数。3.2 输入张量的构造ONNX Runtime 的输入是NamedOnnxValue需要把 C# 的数据转成DenseTensor。以图像为例public DenseTensorfloat PreprocessImage(Bitmap bitmap, int targetSize 224) { var resized new Bitmap(bitmap, new Size(targetSize, targetSize)); var tensor new DenseTensorfloat(new[] { 1, 3, targetSize, targetSize }); float[] mean { 0.485f, 0.456f, 0.406f }; float[] std { 0.229f, 0.224f, 0.225f }; for (int y 0; y targetSize; y) { for (int x 0; x targetSize; x) { var pixel resized.GetPixel(x, y); tensor[0, 0, y, x] (pixel.R / 255f - mean[0]) / std[0]; tensor[0, 1, y, x] (pixel.G / 255f - mean[1]) / std[1]; tensor[0, 2, y, x] (pixel.B / 255f - mean[2]) / std[2]; } } return tensor; }这段代码有个性能问题GetPixel是出了名的慢。生产环境建议用LockBits直接操作内存速度能快十倍以上。我这里为了代码清晰用了GetPixel实际项目里一定要换掉。3.3 执行推理与结果解析构造好输入之后调用Runvar inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(pixel_values, imageTensor), NamedOnnxValue.CreateFromTensor(input_ids, inputIdsTensor) }; using var results OnnxSessionManager.Session.Run(inputs); var logits results.First().AsTensorfloat();Run返回的是一个IDisposable的集合用完要释放否则内存会持续增长。这个细节很容易被忽略尤其是在 Web 环境里每个请求泄漏一点跑几天就 OOM 了。拿到 logits 之后如果是分类任务直接取 argmax如果是生成任务需要做自回归解码也就是把上一步的输出拼回输入循环生成下一个 token。这个循环在 C# 里写起来比较繁琐但逻辑是清晰的var generatedIds new Listlong(); for (int step 0; step maxNewTokens; step) { var stepInputs BuildInputs(imageTensor, generatedIds); using var stepResults session.Run(stepInputs); var nextTokenLogits stepResults.First().AsTensorfloat(); long nextToken ArgMax(nextTokenLogits, generatedIds.Count); if (nextToken eosTokenId) break; generatedIds.Add(nextToken); }每一步都要重新跑一次模型所以生成 50 个 token 就是 50 次推理。这是自回归模型的固有特性没什么好办法绕过只能靠 KV Cache 优化。ONNX Runtime 对 KV Cache 的支持需要模型导出时就处理好比较复杂这里先不展开。3.4 视频处理的抽帧策略视频这块核心问题是抽多少帧、怎么抽。抽帧太密推理次数多慢抽太稀可能漏掉关键信息。我的经验是对于 30 秒以内的短视频均匀抽 8 帧1 分钟左右的抽 16 帧再长的先做场景检测在场景切换处抽帧。场景检测可以用简单的帧差法计算相邻帧的直方图差异超过阈值就认为发生了场景切换。这个方法实现简单效果也够用。public ListBitmap ExtractKeyFrames(string videoPath, int maxFrames 16) { var frames new ListBitmap(); // 用 FFmpeg 或者 MediaFoundation 读取视频 // 计算帧间差异选出关键帧 // 均匀采样兜底 return frames; }C# 读视频可以用 FFmpeg 的命令行也可以用MediaFoundation。FFmpeg 更通用但需要额外部署 exeMediaFoundation 是系统自带但 API 比较难用。我选的是 FFmpeg通过Process调用把抽帧结果输出成图片序列再逐张读进来。抽出来的帧分别过图像模型得到每帧的描述最后用一个简单的文本聚合逻辑拼成整段视频的描述。聚合可以用规则比如取出现次数最多的描述也可以再过一个小语言模型做摘要。规则方案够用我就没上模型。4. 训练与微调的务实思考4.1 本机工程到底要不要碰训练这个问题得先泼盆冷水本机做 LLM 训练基本不现实。训练一个 7B 模型哪怕只是微调也需要几十 G 显存普通工控机或者开发机根本扛不住。所以本机工程里训练和推理要分开看。推理是本机的事训练是另一回事。但微调这个词有歧义得拆开说。全参数微调改所有参数需要大量显存本机做不了。LoRA 微调只训练低秩适配器显存需求大幅降低一张 24G 的卡能微调 7B 模型。但这也需要 GPU纯 CPU 微调慢到没法用。Prompt 工程不改模型只改输入。这是本机工程唯一能在线做的优化。所以我的建议是本机工程专注推理微调在别的机器上做完把微调后的模型导出成 ONNX 再拿过来用。这样职责清晰本机不需要 GPU。4.2 LoRA 微调的基本流程虽然本机不做但流程得知道因为微调后的模型要能顺利导出 ONNX。LoRA 的核心思想是在原模型的某些层旁边挂一个小矩阵训练时只更新这个小矩阵。用peft库做的话大概是这样from peft import LoraConfig, get_peft_model config LoraConfig( r8, lora_alpha16, target_modules[q_proj, v_proj], lora_dropout0.05, biasnone ) model get_peft_model(base_model, config)r是秩越大表达能力越强但参数越多8 或 16 是常见值。target_modules指定在哪些层挂 LoRA一般选 attention 的 q 和 v 投影。训练完之后LoRA 权重是独立的需要合并回原模型才能导出 ONNXmerged_model model.merge_and_unload() merged_model.save_pretrained(merged_model)这一步很关键忘了合并的话导出的 ONNX 里没有微调的效果。4.3 微调数据的准备微调效果好不好八成看数据。对于视觉问答任务数据格式一般是图像 问题 答案的三元组。数据量方面LoRA 微调通常几百到几千条就能看到效果。但数据质量比数量重要得多。我见过用 200 条精心标注的数据微调出来的效果比用 5000 条爬来的脏数据好得多。标注的时候要注意几点答案要简洁一致不要一会儿说这是一张猫的图片一会儿说图中有一只猫问题要覆盖实际使用场景别只标这是什么这种泛泛的问题要包含一些困难样本比如模糊的、遮挡的否则模型在真实场景里会翻车。4.4 微调后的模型导出注意事项微调后的模型导出 ONNX比原模型更容易出问题。常见的是 LoRA 合并后某些层的结构变了导致导出时算子不支持。遇到这种情况一个办法是导出时用torch.onnx.export的trainingtorch.onnx.TrainingMode.EVAL参数确保导出的是推理图。另一个办法是先用onnxsim做一遍图简化把冗余的算子合并掉。导出后一定要做数值对齐验证同样的输入PyTorch 和 ONNX 的输出差异应该在 1e-3 以内。超过这个量级说明导出有问题得回去查。5. 常见问题与排查实录5.1 模型加载失败的几种典型情况报错信息可能原因解决方法Unable to load DLL onnxruntime平台目标不是 x64或缺少 VC 运行库改平台目标装 VC RedistributableInvalid model file模型文件损坏或格式不对用 onnxruntime Python 验证文件Unsupported opset versionopset 版本过高重新导出降低 opsetInput name mismatch输入名跟模型定义不一致用 Netron 查看模型输入名Netron这个工具强烈推荐可视化查看 ONNX 模型结构输入输出名、算子类型、张量形状一目了然。排查问题时比看日志快多了。5.2 推理结果不对怎么查结果不对分两种一种是输出格式对但内容离谱一种是直接报错。内容离谱的话先查预处理。九成的推理结果问题都出在预处理上。归一化的均值方差对不对通道顺序是 RGB 还是 BGRresize 用的是双线性还是最近邻这些细节跟训练时不一致结果就会偏。排查方法很直接在 Python 里用同样的输入跑一遍原模型把中间张量 dump 出来跟 C# 侧的对比。哪一步开始出现差异问题就在哪。5.3 内存泄漏的定位Web 环境里内存泄漏是慢性病跑着跑着就 OOM。ONNX Runtime 相关的泄漏点主要有两个InferenceSession没复用Run返回的结果没释放。定位方法是用性能计数器监控进程的私有字节数看它是不是随时间单调增长。是的话用dotMemory或者ANTS Memory Profiler抓快照对比两个时间点的对象数量找出持续增长的类型。我遇到过一次泄漏最后发现是NamedOnnxValue创建后没释放。这个对象实现了IDisposable但很多人不知道以为它就是个值包装。加上using之后问题就解决了。5.4 性能优化的几个方向推理慢的话按这个顺序排查模型是不是 int8 量化了。没量化的话先量化速度能提升 2-4 倍。线程数设置是否合理。IntraOpNumThreads和InterOpNumThreads都要调默认值往往不是最优。有没有重复加载模型。每次请求都 new 一个 session 的话光加载就几秒。输入尺寸是不是太大。图像从 448 降到 224计算量降四倍。能不能批处理。多个请求攒一批一起推理吞吐量能上去。实测数据供参考i7-12700 上跑一个 int8 量化的视觉模型224x224 输入单张推理约 300ms同样的模型 FP32 要 1.2s。批处理 8 张的话平均每张能降到 150ms 左右。5.5 视频处理的特殊坑视频这块有几个坑跟图像不一样。内存占用。视频抽帧后如果一次性全读进内存一个 1080p 的视频抽 16 帧就是 16 张 6MB 的位图接近 100MB。并发几个请求就爆了。正确做法是抽一帧处理一帧处理完立即释放。时间戳对齐。如果业务需要知道描述对应视频的哪个时间点抽帧时要记录时间戳别只存图片。编码格式。有些视频是 H.265 编码FFmpeg 默认可能不支持要加-c:v libx265之类的参数。这个跟 FFmpeg 的编译选项有关部署时要注意。6. 工程化落地的一些经验6.1 把推理封装成独立服务虽然标题说的是 MVC 工程但实际部署时我建议把推理部分拆成一个独立的 Windows 服务或者控制台程序通过本地 HTTP 或者命名管道跟 MVC 通信。这么做的理由推理是 CPU 密集型任务跑在 IIS 进程里会跟 Web 请求抢资源导致页面卡顿独立进程可以单独控制并发、单独重启、单独监控而且 IIS 的应用程序池回收机制对长时间运行的推理任务不友好。拆开之后MVC 侧只负责接收请求、转发给推理服务、返回结果职责清晰稳定性也好很多。6.2 模型版本管理模型文件动辄几百兆不适合直接塞进 Git。我的做法是模型文件放在一个独立的文件服务器或者共享目录工程里只存一个版本清单文件记录每个模型的名字、版本、路径、MD5。应用启动时读清单按需加载。这样模型更新不用重新部署代码改清单就行。6.3 日志和监控推理服务的日志要记几个关键指标每次推理的耗时、输入尺寸、输出 token 数、是否命中缓存。这些数据积累下来能帮你发现性能瓶颈和异常请求。监控的话简单点用性能计数器复杂点接 Prometheus。本机部署的话性能计数器够用了。6.4 降级策略推理服务不可能永远可用模型加载失败、内存不足、请求超时都可能发生。要有降级策略推理失败时返回一个默认结果或者提示用户稍后重试而不是让整个页面崩掉。在 MVC 里可以用 try-catch 包住推理调用catch 到异常就返回一个友好的错误信息。同时记录日志方便事后排查。这套东西我从零搭起来花了大概两周中间踩的坑基本都写在上面了。最深的体会是本机跑 LLM 这件事难点不在模型本身而在工程细节。模型转换、预处理对齐、内存管理、并发控制每一项都有坑但每一项都有成熟的解法。把这些问题一个个解决掉整套系统跑起来其实挺稳的。
返回列表