简介:物体检测是计算机视觉的核心任务之一,传统YOLO等模型受限于固定类别数,难以应对数万类别的工业质检场景。开放式词汇检测通过CLIP文本-视觉对齐,将类别名称编码为文本向量,使模型可识别训练中未见的类别。Detic借助这一原理,支持21k类物体识别,但将其落地到生产环境需要解决模型部署难题。ONNX Runtime提供了跨平台推理能力,使C#应用能够高效加载并运行Detic模型,实现大规模类别检测。从文本嵌入预处理到候选框后处理,C#技术栈可完整承接推理链路,适用于工业零部件识别、电商商品分类等长尾场景。围绕C#环境下基于ONNX Runtime的Detic部署实践,为高类别数物体检测需求提供了一套可行方案。
1. 先把原理讲透:Detic为何能识别21k类,以及这对部署意味着什么
之前有段时间项目组接到一个需求,要在工业质检场景里识别两万多种不同的零部件和产品外观异常。一开始团队按老路子走,用YOLO训练一个多分类检测模型,结果数据标注就卡了一个多月,2万个类别的边界框标注根本做不完,类间混淆也严重。后来调研到Meta的Detic——一个使用图像级标签训练的开放式词汇检测模型,官方宣称能检测21k类物体。这个量级确实不是YOLO方案能比的。
但问题是Detic是PyTorch生态的项目,我们这边的业务系统是C#技术栈,要做成Windows上位机里的一个功能模块。于是就有了这篇文章的主题:C#用onnxruntime部署Detic,实现2万1千种类别的物体检测。
先把结论放在前面:这条路能走通,但有很多坑——模型导出、文本嵌入、类别后处理、性能调优,每个环节都值得好好梳理。如果你也在做C#桌面端或服务端的物体检测,正愁类别数不够用,这篇文章应该能帮你省下一两周的摸索时间。
1.1 开放式词汇检测:CLIP的文本-视觉对齐如何工作
传统检测器在模型最后有一个全连接分类头,输出维度等于训练时的类别数。以YOLOv8为例,如果你训练了80个COCO类别,模型输出就是[batch, 84]——前4个是坐标,后80个是各类别概率。一旦业务上新增一个类别,就得重新收集数据、重新训练整个模型。这在类别数只有几十个的时候还能忍,一旦到了上千甚至上万,几乎走不通。
Detic的思路完全不同。它的分类头不再是一个固定维度的全连接层,而是把“类别”变成了文本向量。具体来说,Detic用CLIP的文本编码器把每个类别的名称(比如“coffee maker”“red wine glass”)编码成一个768维或1024维的向量,多个模板句子的向量取平均后作为这个类别的“代表向量”。在检测时,模型的分类头输出的是图像区域的特征向量,然后把这个视觉特征向量和预先算好的类别文本向量做点积,点积得分最高的类别就是检测结果。
用大白话说,传统检测器像一个只认识本班学生的班主任,名单是固定的;Detic像是拿着一本全校花名册的教导主任,不管谁来了都能把名字和人脸比对出来。它之所以能检测21k类,是因为类别向量是文本编码来的,不依赖于训练时的固定标签。
这对C#部署来说是一个关键信号:推理链路可以拆成两部分。第一部分是视觉编码,图像进入网络,输出候选框和对应的视觉特征向量;第二部分是类别匹配,把视觉特征和预计算的文本嵌入矩阵相乘得到分类分数。第二部分完全可以离线准备好,C#程序启动时加载一个类别向量矩阵就行。理论清晰了,接下来就是实操层面的细节。
1.2 模型推理链路拆解:ONNX输入输出到底长什么样
如果你直接把Detic官方仓库的PyTorch模型扔给torch.onnx.export导出,大概率会失败。因为Detectron2里有不少自定义算子,比如RoIAlign和Deformable Conv,ONNX导出兼容性比较差。更麻烦的是,Detic推理时还涉及一些后处理逻辑,这些不应该也没必要塞进ONNX图里。
我的建议是导出一个“视觉编码器”版本,而不是把完整的检测pipeline都导出来。这个简化版本的ONNX模型输入是一张图像,输出三样东西:
| 输出名称 | 形状 | 说明 |
|---|---|---|
| proposals | [N, 4] | 候选框坐标,归一化到0~1 |
| features | [N, 768] | 每个候选框经过RoIAlign和分类头之前的视觉特征向量 |
| objectness | [N] | 候选框的置信度分数,可用于筛选 |
也就是说,模型只负责“找到可能有个东西的地方并提取特征”,至于这个东西是什么,留给C#侧的文本嵌入矩阵来回答。这个方案有几个好处:一是ONNX文件体积小(几百MB,主要是backbone权重),二是类别体系可以随时换,不用重新导出模型,三是21k类的分类矩阵乘法可以放在C#侧灵活优化。
如果你的业务完全不需要动态增删类别,也可以把21k类的文本嵌入矩阵直接变成一个ONNX常量权重,做成一个额外的矩阵乘子模型,把分类计算也扔到GPU上跑。这个思路后面在性能优化部分再展开。
1.3 2万1千类的“真面目”:LVIS类别体系与文本embedding
需要澄清一点:Detic论文里说的21k类,并不是LVIS数据集本身有21k个类别。LVIS v1.0大约有1200多个常见类别,Detic利用WordNet和ImageNet-21k的层级关系,把LVIS的类别标签扩展到了21k个细粒度类别——比如“杯子”被扩展成了“咖啡杯”“红酒杯”“啤酒杯”等。这种细粒度扩展让检测器能区分近义但不同的物体,但也带来了一个实际问题:类别之间的文本向量可能非常接近(比如“cappuccino cup”和“coffee cup”),这会让分类分数非常接近,误检率升高。
文本嵌入矩阵的规模大约是[21000, 768]的float32数组,算下来64MB左右。如果类别向量维度是1024,那就变成86MB。这个矩阵在C#里加载没问题,但要注意加载时机和内存管理,建议在程序启动时一次性读入内存并转置成[768, 21000]的布局,方便后续矩阵乘。
还需要注意prompt模板的影响。Detic在生成类别文本向量时不是只把类别名丢给CLIP文本编码器,而是会用多个prompt模板(比如“a photo of a {label}”“a photo of the {label}”“itap of a {label}”等)分别编码,取平均。这样得到的类别向量对CLIP来说更“自然”,分类效果也更好。如果你要自己生成新的类别向量,建议保留这个多模板平均策略,不要只用裸类别名。
2. 模型转换与运行环境:拿到能用的ONNX文件只是第一步
2.1 从PyTorch到ONNX的导出思路
如果你有PyTorch环境,可以自己从Detic官方权重导出。大致的流程是:加载Detic的config和权重,把model的forward方法拦腰截断,让它在输出proposals和RoI features的地方停下来,然后通过torch.onnx.export导出。
实际操作中有几个容易卡住的点:
- Detic内部用的是Detectron2的
GeneralizedRCNN结构,导出的图里可能会带torchvision::nms这样的自定义节点,onnxruntime不一定支持。解决办法是导出时关掉NMS,把NMS留到C#侧做。 - RoIAlign在onnxruntime 1.14之后的CPU EP已经支持,但GPU EP的支持情况要看版本。如果你导出时遇到不支持的操作符,可以考虑把RoIAlign从导出的图里剥出来,用C#自己实现——但这很麻烦,建议优先尝试升级onnxruntime版本或者改用支持更好的模型变体。
- 导出时要设置
dynamic_axes,让proposal数量N这个维度是动态的,因为每张图生成的候选框数量不一样。
我个人的建议是:如果不是非要从官方PyTorch权重自己导,优先找社区已经转好的Detic ONNX文件。GitHub上有些项目专门做Detectron2模型到ONNX的转换,里面可能就有Detic R50或R101的成品。自己导一次的成本不算高,但排查算子兼容性的时间成本完全不可控——我就因为一个DeformableConv2d算子折腾了差不多一个星期。
2.2 C#侧onnxruntime配置:CPU/CUDA/DirectML选型
在C#项目里引入onnxruntime,通常是通过NuGet安装对应的包,先看你们项目的运行环境再选。
| 后端 | NuGet包名 | 适用场景 | 推理速度 |
|---|---|---|---|
| CPU | Microsoft.ML.OnnxRuntime | 无GPU服务器或轻量验证 | 较慢 |
| CUDA | Microsoft.ML.OnnxRuntime.Gpu | NVIDIA显卡,追求最高性能 | 快速 |
| DirectML | Microsoft.ML.OnnxRuntime.DirectML | Windows下AMD/Intel/NVIDIA通用 | 中等 |
| TensorRT | 自定义编译EP | NVIDIA高端卡,极致优化 | 最快但配置复杂 |
如果目标机器是Windows且显卡不是N卡,推荐用DirectML EP,它走的是DirectML API,Windows 10/11自带的驱动就能跑,不需要额外装CUDA环境。如果目标机器有N卡且你愿意装CUDA Toolkit和cuDNN,那就用CUDA EP。如果只是想先跑通流程、验证结果,CPU版本就够,只是速度会慢不少,尤其后处理部分。
项目文件里加上这些包:
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.17.1" /> <PackageReference Include="OpenCvSharp4" Version="4.8.0.20230708" /> <PackageReference Include="OpenCvSharp4.runtime.win" Version="4.8.0.20230708" />如果走CUDA路线,换成Microsoft.ML.OnnxRuntime.Gpu,版本一定要和CUDA版本匹配。比如onnxruntime 1.17.x对应CUDA 11.8和cuDNN 8.x,装错了运行时会报Failed to find cudart之类的错误。
2.3 最容易卡住的DLL加载问题
这个问题基本上每个C#接入onnxruntime的人都会遇到。你编译完程序,跑起来第一行代码就抛异常:
System.DllNotFoundException: Unable to load DLL 'onnxruntime': 找不到指定的模块。排查方向有几个,按概率从高到低:
项目的Platform target不是x64。onnxruntime的Native DLL只有64位版本,如果你的项目编译目标是
AnyCPU且勾选了Prefer 32-bit,就会加载失败。项目平台目标必须设为x64。NuGet包没把native DLL复制到输出目录。正常的
Microsoft.ML.OnnxRuntime包会在runtimes/win-x64/native/下带onnxruntime.dll,MSBuild会自动复制。但如果你手动更换过输出目录或做过特殊清理,可能会漏掉。检查一下输出目录里有没有onnxruntime.dll。用了Gpu包但CUDA相关DLL不在系统路径。
Microsoft.ML.OnnxRuntime.Gpu包本身只带onnxruntime.dll,运行时会动态加载cudart64_110.dll、cublas64_11.dll、cudnn64_8.dll等CUDA依赖。你需要把CUDA Toolkit的bin目录加入系统Path,或者直接把相关DLL复制到程序输出目录。多个onnxruntime版本冲突。如果解决方案里有多个项目,一个引了CPU包一个引了Gpu包,最终输出目录里的onnxruntime.dll可能被覆盖。解决方案是统一版本,只保留一个包引用。
注意:如果程序运行环境是Windows Server,可能需要安装VC++ 2019 Redistributable。有些服务器精简版系统缺这个运行库,onnxruntime.dll加载时也会报DllNotFoundException。
3. 核心代码实战:C#调用onnxruntime完成推理
3.1 图像预处理:resize、归一化、通道顺序
Detic训练时用的是Detectron2的标准预处理方式:图像保持BGR通道顺序(因为OpenCV读取就是BGR),逐通道减去均值[103.530, 116.280, 123.675],除以标准差[1.0, 1.0, 1.0]。等一下,这里有个容易踩的坑:CLIP本身是基于RGB图像预训练的,但Detic的检测backbone是Detectron2里的ResNet,它训练时用的是BGR输入。所以你在预处理时到底用BGR还是RGB,取决于导出的ONNX模型内部是否做了通道转换。
最稳妥的做法是先用一张纯红色图片做测试,比如一张全红的图,用代码打印预处理后的张量值,看第一个通道是不是接近红色通道值。这个方法虽然是笨办法,但能一次性确认通道顺序,后面处理彩色图像时不会出错。整体预处理流程如下面的代码:
using OpenCvSharp; public static Tensor<float> PreprocessImage(string imagePath, int targetWidth, int targetHeight) { Mat image = Cv2.ImRead(imagePath, ImreadModes.Color); Mat resized = new Mat(); Cv2.Resize(image, resized, new Size(targetWidth, targetHeight)); float[] mean = { 103.530f, 116.280f, 123.675f }; float[] std = { 1.0f, 1.0f, 1.0f }; var tensor = new DenseTensor<float>(new[] { 1, 3, targetHeight, targetWidth }); unsafe { byte* ptr = (byte*)resized.Data; for (int y = 0; y < targetHeight; y++) { for (int x = 0; x < targetWidth; x++) { int channelOffset = (y * targetWidth + x) * 3; tensor[0, 0, y, x] = (ptr[channelOffset + 0] - mean[0]) / std[0]; tensor[0, 1, y, x] = (ptr[channelOffset + 1] - mean[1]) / std[1]; tensor[0, 2, y, x] = (ptr[channelOffset + 2] - mean[2]) / std[2]; } } } return tensor; }这段代码用的是OpenCvSharp的Mat.Data指针直接读取像素,速度比At<Vec3b>快不少。如果你项目里不方便用unsafe代码,也可以用Mat.GetArray或者resized.Data的Marshal.Copy方式,但预处理耗时在整体推理耗时里占比不大(640x640的图像大约几十毫秒),性能敏感度不高。
3.2 推理会话与输入张量构造
创建onnxruntime会话的代码比较模板化,但有三个细节会影响最终性能。第一,一定要开图的优化级别;第二,如果机器有多个CPU核心,配置线程数;第三,显式做一次warm-up推理。
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class DeticDetector { private readonly InferenceSession _session; private readonly string _inputName; public DeticDetector(string modelPath, bool useGpu = false) { var options = new SessionOptions(); options.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL; if (useGpu) { options.AppendExecutionProvider_CUDA(0); } else { options.AppendExecutionProvider_CPU(); // 显式设置CPU线程数,默认用满所有核心,反而会因为调度开销变慢 options.SetSessionThreadOptions(new SessionOptionsThreadOptions { IntraOpNumThreads = Environment.ProcessorCount / 2, InterOpNumThreads = 1 }); } _session = new InferenceSession(modelPath, options); _inputName = _session.InputMetadata.Keys.First(); // warm-up推理,让内存池和kernel初始化完成 var dummyInput = new DenseTensor<float>(new[] { 1, 3, 640, 640 }); var dummyInputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor(_inputName, dummyInput) }; using (var dummyResult = _session.Run(dummyInputs)) { } } public (float[], float[], int) RunInference(Tensor<float> inputTensor) { var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor(_inputName, inputTensor) }; using (var results = _session.Run(inputs)) { var boxes = results.First(r => r.Name == "proposals").AsTensor<float>(); var features = results.First(r => r.Name == "features").AsTensor<float>(); var scores = results.First(r => r.Name == "objectness").AsTensor<float>(); int numBoxes = boxes.Dimensions[0]; float[] boxData = boxes.ToArray(); float[] featureData = features.ToArray(); float[] scoreData = scores.ToArray(); return (boxData, featureData, numBoxes); } } }关于CPU线程数,有个经验值:如果目标机器是4核8线程,IntraOpNumThreads设置成4通常比8更快。onnxruntime内部对算子的并行度控制并不总是完美,线程数设得过高反而会引入大量上下文切换,在Core i5级别的机器上尤其明显。如果你在服务器上部署,还需要考虑机器上其他服务的CPU占用,不要默认吃满所有核心。
3.3 输出解析:从裸浮点数据到干净的目标框
proposals输出的是归一化后的候选框坐标,需要乘回图像原始宽高才能得到实际像素坐标。features是每个候选框对应的视觉特征向量,维度一般是768。objectness是候选框的置信分数,通常模型可能输出上千个候选框,但大部分objectness分数很低,先用阈值筛掉一批,能大幅减少后续分类和NMS的计算量。
public class DetectionBox { public float X1, Y1, X2, Y2; public float Score; public int ClassId; } public List<DetectionBox> ParseProposals(float[] boxes, float[] features, float[] scores, int numBoxes, int featureDim, float objectnessThreshold) { var candidates = new List<(int Index, float Score)>(); for (int i = 0; i < numBoxes; i++) { if (scores[i] >= objectnessThreshold) { candidates.Add((i, scores[i])); } } // 只保留objectness最高的N个,比如300个 var topCandidates = candidates .OrderByDescending(c => c.Score) .Take(300) .Select(c => c.Index) .ToList(); var proposals = new List<float[]>(topCandidates.Count); foreach (int idx in topCandidates) { float x1 = boxes[idx * 4 + 0]; float y1 = boxes[idx * 4 + 1]; float x2 = boxes[idx * 4 + 2]; float y2 = boxes[idx * 4 + 3]; // 从归一化坐标转换到原始图像尺寸 x1 *= originalWidth; y1 *= originalHeight; x2 *= originalWidth; y2 *= originalHeight; // 提取对应视觉特征 float[] feature = new float[featureDim]; Array.Copy(features, idx * featureDim, feature, 0, featureDim); proposals.Add(new float[] { x1, y1, x2, y2 }); } return proposals; }注意objectnessThreshold的取值。Detic的objectness分数和最终分类分数不是一个尺度,前者通常在0~1之间,但分布偏集中。我建议先跑几张测试图,打印一下分数分布,然后取一个合适的阈值,通常0.2到0.4之间比较合理。阈值太低会导致候选框过多,矩阵乘和NMS都变慢;阈值太高可能把真正的目标框过滤掉。
4. 21k类的后处理与性能瓶颈:真正压榨推理性能
4.1 类别置信度计算与TopK选择
拿到视觉特征后,下一步就是和文本嵌入矩阵做矩阵乘法,得到每个候选框在21k个类别上的分数。这一步是整个C#部署方案里计算量最大的环节,处理不好性能直接崩。
先看最直观的写法:
// textEmbeddingMatrix: float[21000, 768] // features: float[300, 768](top 300候选框) // scores: float[300, 21000] float[,] scores = new float[numBoxes, numClasses]; for (int i = 0; i < numBoxes; i++) { for (int c = 0; c < numClasses; c++) { float sum = 0; for (int d = 0; d < featureDim; d++) { sum += features[i, d] * textEmbeddingMatrix[c, d]; } scores[i, c] = sum; } }这段代码在300个框、21000个类别、768维特征下,一共是300 * 21000 * 768 = 48.4亿次浮点乘加运算。C#纯循环跑这个量级,就算开了Release模式也要几十秒,完全不可用。
优化思路有三个层次。
第一层,用Parallel.For并行化。48亿次运算在8核机器上摊到每核6亿次,降到能接受的范围但依然需要好几秒。
第二层,把类别内层循环改成矩阵乘布局,利用缓存局部性。如果文本嵌入矩阵按[768, 21000]预转置存储,特征和矩阵的乘法可以写成:
Parallel.For(0, numBoxes, i => { for (int c = 0; c < numClasses; c++) { float sum = 0; for (int d = 0; d < featureDim; d++) { sum += features[i, d] * textEmbeddingTransposed[d, c]; } scores[i, c] = sum; } });这个写法能让特征向量连续访问,内存友好一些,但本质没有改变计算量。
第三层,也是最推荐的做法:把矩阵乘放到GPU上。前面提到,可以把文本嵌入矩阵做成一个简单的ONNX矩阵乘子模型,在C#里加载后作为一个额外的InferenceSession调用。这样视觉特征先通过Detic模型得到,再把特征张量喂给这个“分类器模型”,矩阵乘的耗时从几秒降到几十毫秒。如果不想多维护一个ONNX模型,也可以用ONNX Runtime的TensorAPI结合DirectML EP,在C#里直接调用GPU算子。但封装起来比较复杂,维护成本高。
如果你们的部署环境没有GPU,还有一招:业务上很多时候不需要真的区分全部21k类,而是只需要关注业务相关的几百个类别。比如电商场景只需要识别热门商品类目,那文本嵌入矩阵可以把类别筛选成500行,矩阵乘计算量直接降到原来的四十分之一。这个思路在工程落地上非常实用。
4.2 类别感知NMS的实现与优化
分类完成后,每个候选框都有一个最高分类分数和对应类别。接下来的NMS流程和YOLO里的NMS一样,但需要按类别分组进行——也就是说,同类别之间做去重,不同类别之间的重叠框保留。21k类别如果逐类做NMS确实很夸张,但实际操作中大部分类别没有候选框,可以先按类别分组再过滤掉空组。
public List<DetectionBox> ClassAwareNms(List<DetectionBox> detections, float iouThreshold) { var groups = detections .OrderByDescending(d => d.Score) .GroupBy(d => d.ClassId); var result = new List<DetectionBox>(); // 并行执行每个类别内部的NMS Parallel.ForEach(groups, group => { var kept = new List<DetectionBox>(); var sorted = group.OrderByDescending(d => d.Score).ToList(); while (sorted.Count > 0) { var best = sorted[0]; kept.Add(best); sorted.RemoveAt(0); sorted.RemoveAll(d => ComputeIou(best, d) > iouThreshold); } lock (result) { result.AddRange(kept); } }); return result.OrderByDescending(d => d.Score).ToList(); } private static float ComputeIou(DetectionBox a, DetectionBox b) { float x1 = Math.Max(a.X1, b.X1); float y1 = Math.Max(a.Y1, b.Y1); float x2 = Math.Min(a.X2, b.X2); float y2 = Math.Min(a.Y2, b.Y2); float interW = Math.Max(0, x2 - x1); float interH = Math.Max(0, y2 - y1); float inter = interW * interH; float areaA = (a.X2 - a.X1) * (a.Y2 - a.Y1); float areaB = (b.X2 - b.X1) * (b.Y2 - b.Y1); float union = areaA + areaB - inter; return union > 0 ? inter / union : 0; }这里有个容易被忽略的问题:Parallel.ForEach里用到了lock (result),如果某个类别框特别多(比如“人”这个类别在人群场景下可能产生大量候选框),这个类别的NMS计算时间会拖慢整体速度。一个更精细的做法是让每个类别产生NMS结果后写入独立列表,最后再合并,但处理起来稍复杂。个人建议先把Parallel方案跑通,只在实际性能不达标时再优化。
4.3 实测数据与调优记录
我在一台i5-12400 CPU + GTX 1660 Super + 16GB内存的Windows 11机器上做了几组实测,部署的是ResNet50 backbone的Detic模型,输入640x640:
| 配置 | 目标数量 | 平均耗时 |
|---|---|---|
| CPU推理 + CPU矩阵乘 | 300个候选框 | 约3.2秒 |
| GPU推理 + CPU矩阵乘 | 300个候选框 | 约1.8秒 |
| GPU推理 + GPU矩阵乘 | 300个候选框 | 约0.6秒 |
| GPU推理 + GPU矩阵乘 | 100个候选框 | 约0.35秒 |
这个数据的启示很明显:Detic模型的backbone推理在GPU上并不慢,真正拖后腿的是21k类矩阵乘和NMS。所以如果你的业务可以接受降低候选框数量(比如把objectness阈值从0.2提高到0.4,或者按业务场景把类别从21k缩小到几百个),整体延迟能大幅下降。
另一个值得注意的点是内存占用。Detic ONNX模型本身大约400MB(ResNet50),加上文本嵌入矩阵64MB,再加上运行时缓冲区,进程内存占用很容易超过1GB。如果部署在内存受限的生产环境,需要关注这一点。可以考虑用半精度(float16)存储文本嵌入矩阵,64MB直接减半到32MB,精度损失在可接受范围内。
踩坑记录与最后的经验分享
整个部署过程中最让我头疼的不是onnxruntime本身,而是Detic这种研究型模型的pipeline拆解。研究代码里很多操作是为了学术上的通用性而存在的,真正落地到C#工程时必须做减法。我个人的建议是:先别急着把21k类全跑通,先用100个类别的文本嵌入矩阵验证完整链路,把推理、后处理、画框都跑顺了,再逐步扩展类别数。这样在排查问题时,每个环节的复杂度都小一个量级。
还有一个小技巧:在开发阶段把每个中间步骤的结果导出到文件里检查。比如把输入的张量、proposals的坐标、特征的均值和标准差,都dump出来和Python环境里的结果对比。这一步虽然繁琐,但能快速定位到底是预处理问题、模型导出问题还是后处理问题。我当时排查颜色通道搞反的问题,就是这个方法救的命。
最后说一句关于onnxruntime版本的选择:不要一味追求最新版本。onnxruntime的版本更新经常伴随算子实现和EP行为的变化,你可能今天升级了小版本,某一天线上模型的输出就和之前不一样了。确定一个验证过所有测试用例的版本,然后锁死在项目里,比什么都重要。这不是说不要升级,而是升级要有充分的回归测试兜底。
本文还有配套的精品资源,点击获取