拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

YOLOv11-CLS图像分类C++部署实战:ONNX Runtime推理链路完整指南

YOLOv11-CLS图像分类C++部署实战:ONNX Runtime推理链路完整指南

简介:一份以C++和ONNX Runtime为核心的YOLOv11-CLS图像分类模型部署文档,面向具备C++与深度学习基础、从事计算机视觉或图像识别项目开发的工程师。文档为单个docx文件(约37KB),结构紧凑,依次介绍项目背景、数据准备、完整C++代码示例、代码逐行解释、运行步骤与项目总结,同时列出相关参考资料和注意事项,便于查阅。推理流程覆盖模型加载、输入图像预处理、置信度阈值动态调整、类别统计与最终分类结果输出,代码中预设输入尺寸640、置信度阈值0.5等常用配置,并采用随机裁剪、旋转、亮度调整等数据增强手段提升模型泛化能力。通过该文档可重点掌握ONNX Runtime的模型加载、图片预处理、API调用等关键操作,以及如何用OpenCV完成图像缩放和归一化。文档还讨论了量化剪枝、可视化工具、RESTful API等后续优化方向,为扩展部署架构提供思路。目前已有1508人学习下载,适合需要快速搭建本地化图像分类环境,用于自动化图像检测、实时视频流监控或安防系统分类任务的技术人员。

1. YOLOv11-CLS 为什么要用 C++ 接 ONNX Runtime:一次部署失败换来的一课

做过一次图像分类模型部署,你就知道真正卡人的往往不是模型本身,而是“训练时好好的,一进 C++ 就不知道哪里出了问题”。YOLOv11-CLS 是 Ultralytics 在 YOLO11 系列里提供的图像分类模型,社区习惯叫 YOLOv11-CLS,官网仓库里文件名通常是 yolo11n-cls.pt 这种格式。很多人以为分类模型比检测简单,不用配 anchor、不用解码候选框,直接拿输出取 argmax 就行,结果在 C++ 侧被预处理不一致、输出解析错误、内存访问违例折腾到怀疑人生。

用 C++ 和 ONNX Runtime 部署 YOLOv11-CLS,核心不是“把 onnx 文件加载起来跑一次”,而是把图像预处理、模型推理、输出后处理这三段完整地封装成可复用的推理链路,并且保证 C++ 的结果和 Python 验证结果逐位对齐。这个方案最适合两类人:一是要把分类器嵌进现有 C++ 服务端或桌面程序的开发者,二是在边缘设备上做图像分类、需要脱离 Python 环境独立运行的嵌入式工程师。下面按我自己的落地路径来拆,先导出并验证 ONNX,再搭 C++ 工程,最后把坑和验证手段都摊开讲。

2. 从 .pt 到 .onnx:导出、形状确认和 Python 端先行验证

2.1 导出 ONNX 的两种方式:命令行和 Python API

Ultralytics 的模型导出本质上是把 PyTorch 权重转换成 ONNX 计算图,这一步不需要自己写 torch.onnx.export,官方封装已经处理好了动态轴、算符映射这些细节。我一般直接用 Python API,因为可以顺手打印导出的文件路径和输入形状,方便后面 C++ 端对照。命令行的方式适合批量操作:yolo export model=yolo11n-cls.pt format=onnx imgsz=224,跑完会在同目录生成 yolo11n-cls.onnx。

from ultralytics import YOLO # 加载分类模型权重,n 是 nano 版本,资源紧张的设备也能跑 model = YOLO("yolo11n-cls.pt") # imgsz 必须和训练时一致,分类模型默认 224 # opset 用 12 以上,ONNX Runtime 全系列版本都支持 model.export( format="onnx", imgsz=224, opset=12, simplify=True, # 用 onnxslim 做计算图精简 dynamic=False # 分类模型固定输入尺寸,不要开动态轴 )

这里的几个参数值得解释一下。simplify会合并一些冗余算符,比如把连续的 reshape 和 transpose 压缩掉,文件更小,C++ 端加载也更快;如果遇到导出报错,可以先把 simplify 关掉确认是不是精简环节出的问题。dynamic=False对分类模型来说是合理的,因为输入永远是[1, 3, H, W],不需要像检测那样支持任意尺寸。输出文件是单输出的,形状为[1, class_num],这一点和检测模型的多输出完全不同,C++ 端后处理要按这个形状来写。

2.2 ONNX 文件里到底有什么:用 onnx 库确认输入输出

拿到 onnx 文件后别急着写 C++,先用 onnx 库把输入输出的名称、形状、数据类型打出来。这一步非常关键,因为 ONNX Runtime C++ API 要求你按名称取输入输出,名称写错一个字符就报错。而且不同版本的 Ultralytics 导出的输入节点名可能是images,也可能被简化成input,必须以实际文件为准,不能靠猜。

import onnx model = onnx.load("yolo11n-cls.onnx") graph = model.graph for inp in graph.input: # 打印输入名称,比如 images,以及形状 [1,3,224,224] print("input:", inp.name, [d.dim_value for d in inp.type.tensor_type.shape.dim]) for out in graph.output: # 打印输出名称和形状,分类模型通常是 [1,1000] # 如果不是 1000,说明你训练时的类别数不是 ImageNet 的 1000 print("output:", out.name, [d.dim_value for d in out.type.tensor_type.shape.dim])

输入名称和输出名称要记下来,后面 C++ 里会用session.GetInputName动态获取,但代码里最好还是留一个常量字符串作为 fallback 对照。另外确认一下输出形状里的第二个维度,比如1000,这就是你最终的类别总数。如果这里是[1, 5],说明你用了一个 5 类的自定义数据集,后处理的 Top-5 就要按实际类别数去截断,不能写死。

2.3 Python onnxruntime 先跑通:预处理、推理、softmax、argmax

C++ 部署最怕没有参照物。所以我一直坚持:任何模型进 C++ 之前,先用 Python onnxruntime 跑通一条完整链路,把输入预处理、推理、后处理写成一个可重复执行的脚本,这个脚本的输出就是后面 C++ 工程必须对齐的“黄金参照”。不要拿 Ultralytics 的 predict 结果当参照,因为 predict 内部还包含它自己的预处理细节,直接和 onnxruntime 输出比反而容易对不上。

import cv2 import numpy as np import onnxruntime as ort # 读取图片,注意 cv2 读出来是 BGR,后面必须转 RGB img = cv2.imread("test_cat.jpg") img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (224, 224), interpolation=cv2.INTER_LINEAR) # Ultralytics 分类训练的预处理只做 /255 归一化,不做 mean/std img = img.astype(np.float32) / 255.0 # HWC 转 CHW,再补 batch 维度 img = np.transpose(img, (2, 0, 1)) input_tensor = np.expand_dims(img, axis=0).astype(np.float32) # ONNX Runtime 推理 sess = ort.InferenceSession("yolo11n-cls.onnx", providers=["CPUExecutionProvider"]) input_name = sess.get_inputs()[0].name output_name = sess.get_outputs()[0].name logits = sess.run([output_name], {input_name: input_tensor})[0] logits = logits[0] # 去掉 batch 维 # 输出是 logits,不是概率,必须自己做 softmax exp_logits = np.exp(logits - np.max(logits)) probs = exp_logits / np.sum(exp_logits) top5_idx = np.argsort(probs)[::-1][:5] for idx in top5_idx: # idx 就是类别 ID,映射到类别名后可以直接对比 C++ 结果 print(idx, probs[idx])

这段代码里有几个必须讲清楚的细节。cv2.resize默认插值算法是 INTER_LINEAR,而 Ultralytics 训练时用的是它自己封装的 Resize,两者对同一张图的计算结果在像素级上可能有细微差异,但一般不会导致 Top-1 变化。真正会导致结果翻车的是忘了 BGR 转 RGB,那会让模型把猫识别成狗。输出端为什么一定要自己 softmax?因为 ONNX 导出的分类模型输出的是未归一化的 logits,直接取 argmax 虽然类别 ID 一般不会错,但当你需要拿概率做阈值过滤时,logits 是不可比的。

3. 用 C++ 搭出一个可复现的 ONNX Runtime 部署工程

3.1 工程结构和依赖:Visual Studio + CMake,ONNX Runtime 怎么放

C++ 侧我常用的组合是 Visual Studio 2022 生成器加 CMake,开发时用 VSCode 配 CMake Tools 插件调试,两者共用同一套 CMakeLists。ONNX Runtime 官方不提供 vcpkg 里的一等支持,最常见做法是直接从 GitHub Releases 下载对应平台的压缩包,解压后得到一个包含 include、lib、bin 三个目录的结构。Windows 下如果不想自己管 DLL,也可以改用 NuGet 包 Microsoft.ML.OnnxRuntime,它会自动把 dll 拷到输出目录,但版本更新频率高,团队协作时版本锁定要额外注意。

onnxruntime-win-x64/ ├── include/ │ └── onnxruntime_cxx_api.h # C++ API 头文件 ├── lib/ │ └── onnxruntime.lib # 链接时用到的导入库 └── bin/ └── onnxruntime.dll # 运行时必须拷到 exe 旁边

我一般把解压后的目录放在第三方库目录下,通过环境变量或 CMake 变量传给工程,不提交到代码仓库。下面是 CMakeLists 的关键部分,重点是把 include 目录和 lib 目录指对,同时把 onnxruntime.dll 在构建后自动复制到输出目录,否则一运行就报“找不到 onnxruntime.dll”。

cmake_minimum_required(VERSION 3.20) project(yolo11_cls_deploy CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 这里通过 -DORT_DIR=xxx 传入 ONNX Runtime 解压路径 set(ORT_DIR "$ENV{ORT_DIR}" CACHE PATH "ONNX Runtime root") # OpenCV 用于图像读取和预处理,Windows 下用预编译包即可 find_package(OpenCV REQUIRED) add_executable(yolo11_cls_deploy main.cpp classifier.cpp ) target_include_directories(yolo11_cls_deploy PRIVATE ${ORT_DIR}/include ${OpenCV_INCLUDE_DIRS} ) target_link_libraries(yolo11_cls_deploy PRIVATE ${ORT_DIR}/lib/onnxruntime.lib ${OpenCV_LIBS} ) # 把 dll 复制到 exe 目录,避免运行时找不到 add_custom_command(TARGET yolo11_cls_deploy POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different ${ORT_DIR}/bin/onnxruntime.dll $<TARGET_FILE_DIR:yolo11_cls_deploy> )

这里有个容易踩的细节:ONNX Runtime 的 C++ API 要求 C++17,低于这个标准编译会报一堆模板错误。另外如果你用 MinGW 而不是 MSVC,链接时常常遇到符号不一致的问题,因为官方发布的 lib 是针对 MSVC 生成的导入库,MinGW 下最好改用动态加载方式或者换用 MSVC 工具链,别在编译期死磕。

3.2 推理类:加载 session、预处理、推理、softmax 一起封装

写好一个分类任务,绝不能在主程序里散落各种 ONNX Runtime API 调用。我习惯把加载、预处理、推理、后处理全部封装进一个类,主程序只负责读图、调用接口、打印结果。这样既方便单元测试,也方便以后换模型时只改一处。下面是这个类的核心实现,注释里标明了每个环节对应的 Python 验证逻辑。

// classifier.h #include <opencv2/opencv.hpp> #include <onnxruntime_cxx_api.h> #include <string> #include <vector> class Classifier { public: explicit Classifier(const std::string& model_path); // 输入 BGR 图片,输出按概率降序排列的 <类别ID, 概率> std::vector<std::pair<int, float>> Predict(const cv::Mat& bgr_img, int topk = 5); private: Ort::Env env_{ORT_LOGGING_LEVEL_WARNING}; Ort::SessionOptions session_options_; Ort::Session session_; std::vector<std::string> input_names_; std::vector<std::string> output_names_; int input_h_ = 224; int input_w_ = 224; };
// classifier.cpp #include "classifier.h" #include <algorithm> Classifier::Classifier(const std::string& model_path) : env_(ORT_LOGGING_LEVEL_WARNING), session_(env_, model_path.c_str(), session_options_) { // 日志级别设 WARNING,避免每次推理都刷 INFO 日志 session_options_.SetIntraOpNumThreads(4); session_options_.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); // 一次性读取输入输出名称,不要在每次推理时重复查询 Ort::AllocatorWithDefaultOptions allocator; size_t num_inputs = session_.GetInputCount(); for (size_t i = 0; i < num_inputs; i++) { auto name = session_.GetInputNameAllocated(i, allocator); input_names_.push_back(name.get()); } size_t num_outputs = session_.GetOutputCount(); for (size_t i = 0; i < num_outputs; i++) { auto name = session_.GetOutputNameAllocated(i, allocator); output_names_.push_back(name.get()); } } std::vector<std::pair<int, float>> Classifier::Predict(const cv::Mat& bgr_img, int topk) { // 预处理:BGR 转 RGB、缩放、转 float、除以 255、HWC 转 CHW cv::Mat rgb_img; cv::cvtColor(bgr_img, rgb_img, cv::COLOR_BGR2RGB); cv::Mat resized; cv::resize(rgb_img, resized, cv::Size(input_w_, input_h_), 0, 0, cv::INTER_LINEAR); cv::Mat float_img; resized.convertTo(float_img, CV_32FC3, 1.0 / 255.0); // 构建 batch=1 的连续内存张量,注意 ONNX Runtime 要求输入是连续内存 std::vector<float> input_tensor(input_w_ * input_h_ * 3); int channel_size = input_w_ * input_h_; for (int c = 0; c < 3; c++) { for (int i = 0; i < input_h_; i++) { for (int j = 0; j < input_w_; j++) { // OpenCV Mat 的内存布局是 HWC,这里转成 CHW input_tensor[c * channel_size + i * input_w_ + j] = float_img.at<cv::Vec3f>(i, j)[c]; } } } // 构造输入输出张量 std::vector<int64_t> input_shape = {1, 3, input_h_, input_w_}; Ort::MemoryInfo memory_info = Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor_value = Ort::Value::CreateTensor<float>( memory_info, input_tensor.data(), input_tensor.size(), input_shape.data(), input_shape.size()); // 推理,输出是 logits auto output_tensors = session_.Run( Ort::RunOptions{nullptr}, input_names_.data(), &input_tensor_value, 1, output_names_.data(), output_names_.size()); float* logits = output_tensors[0].GetTensorMutableData<float>(); size_t num_classes = output_tensors[0].GetTensorTypeAndShapeInfo() .GetShape()[1]; // softmax,用最大值平移保证数值稳定性 float max_val = *std::max_element(logits, logits + num_classes); std::vector<float> probs(num_classes); float sum = 0.0f; for (size_t i = 0; i < num_classes; i++) { probs[i] = std::exp(logits[i] - max_val); sum += probs[i]; } for (size_t i = 0; i < num_classes; i++) { probs[i] /= sum; } // 取 topk,注意只排前 k 个不需要完整排序 std::vector<std::pair<int, float>> results; for (size_t i = 0; i < num_classes; i++) { results.emplace_back(static_cast<int>(i), probs[i]); } std::partial_sort(results.begin(), results.begin() + topk, results.end(), [](const auto& a, const auto& b) { return a.second > b.second; }); results.resize(topk); return results; }

几个参数值得单独说。SetIntraOpNumThreads(4)控制的是算子内部并行线程数,不是总线程数;如果你的程序同时有多个推理请求,这个值不要设置成 CPU 核心数,否则每个请求都把所有核占满,线程切换开销会吃掉吞吐。ORT_ENABLE_ALL是图形优化等级,ONNX Runtime 会尝试融合算符,比如把 Conv 和 ReLU 合并,对延迟有明显收益。预处理部分,convertTo的缩放系数 1.0/255.0 是直接对应 Python 里的astype(np.float32) / 255.0,两边必须一致,这是最容易出精度偏差的位置。

3.3 主程序:读图、跑推理、打印 Top-5

主程序要解决两件事:一是把图片喂进去,二是把结果按可读的方式打出来。如果你有类别名文件,就按 ID 查表;没有就先用数字 ID,但务必在日志里输出完整概率分布,方便调试时和 Python 输出对照。下面是一个最小可运行的主程序。

// main.cpp #include "classifier.h" #include <iostream> int main(int argc, char* argv[]) { if (argc < 3) { std::cerr << "Usage: yolo11_cls_deploy <model.onnx> <image.jpg>" << std::endl; return 1; } try { Classifier classifier(argv[1]); cv::Mat img = cv::imread(argv[2]); if (img.empty()) { std::cerr << "Failed to load image: " << argv[2] << std::endl; return 1; } auto results = classifier.Predict(img, 5); for (size_t i = 0; i < results.size(); i++) { std::cout << "top " << (i + 1) << ": class=" << results[i].first << " prob=" << results[i].second << std::endl; } } catch (const Ort::Exception& e) { // ONNX Runtime 的异常都带错误码,这里打印出来便于定位 std::cerr << "ONNX Runtime error: " << e.what() << std::endl; return 1; } return 0; }

跑通标准不是“能运行”,而是“输出和第 2 章的 Python 结果完全一致”:同一个模型文件、同一张图,Top-5 的类别 ID 和概率排序必须一模一样。如果类别 ID 一致但概率在小数点后三位有差异,属于浮点累加误差,可以接受;如果概率差异超过 0.01,几乎可以断定预处理或代码逻辑里有 bug,别急着往下走,先回头排查。

4. 部署中常见的五个坑:现象、原因、解决

4.1 输出值加起来不是 1

第一个碰到的坑往往是输出的数值范围看起来不对,比如最大值是 3.2,最小值是 -2.1,加起来也不等于 1。原因在于 ONNX 导出的分类模型输出的是 logits,不是概率,你需要自己套 softmax。千万不要在训练时或者 Python 验证时习惯了直接拿输出做 argmax,就认为 C++ 里也可以这么做。argmax 结果一般不会错,但一旦涉及阈值判断、多标签过滤,logits 和概率完全是两套尺度。解决办法是在 C++ 的推理类里显式实现 softmax,并且用 Python onnxruntime 的输出做一次数值对照。

4.2 C++ 精度和 Python 对不齐,而且差得离谱

这是部署分类模型时最典型的问题。现象是同一张图,Python 端 Top-1 是正确的,C++ 端 Top-1 变成了另一个完全不相关的类别。原因几乎都出在预处理上:最常见的是忘了 BGR 转 RGB,其次是缩放尺寸和插值算法不一致。OpenCV 的cv::imread读出来是 BGR,而模型训练时用的是 RGB,不转换的话整个颜色通道全乱了,模型表现必然雪崩。另外检查一下是否把缩放系数写成了 1.0 而不是 1.0/255.0,或者误加了 ImageNet 的 mean/std 归一化——Ultralytics 分类模型训练时只做除以 255,不做均值减法。解决方法是把 C++ 预处理后的 float 张量 dump 成二进制文件,和 Python 端预处理后的 numpy 数组对比,逐元素检查差异。

4.3 崩溃 C0000005 / Access Violation,而且只在发布版出现

如果你从 C# 或其他语言通过 P/Invoke 调 C++ 推理库,更容易撞上 Access Violation C0000005,但纯 C++ 程序里也会出现。现象是 Debug 版正常,Release 版偶尔崩溃,或者连续跑几百张图后必崩。原因往往是Ort::Value的生命周期管理出了问题:你把Session定义在局部作用域里,或者输入 tensor 底层的std::vector在session.Run还没结束时就被释放了。ONNX Runtime 的Run是异步语义,输出张量内部可能仍引用输入内存块。解决办法是把Session作为类成员常驻,输入 tensor 用成员变量持有,Run返回后立刻把输出数据拷贝到自己的容器里,不要长时间保留Ort::Value。

4.4 onnxruntime.dll 找不到,或者报“无法定位程序输入点”

换了一台电脑运行,程序直接起不来,报找不到 onnxruntime.dll,或者能起来但加载 session 时弹窗说无法定位程序输入点。前者好解决,把 release 包里的 onnxruntime.dll 复制到 exe 同级目录,或者加入系统 PATH;后者比较隐蔽,是你用的 onnxruntime.dll 和你链接的 onnxruntime.lib 不是同一个版本的产物。如果你是从 NuGet 更新了包但没重新生成导入库,或者自己手动混用了两个版本的二进制,就会出现这种情况。解决办法是确保 lib 和 dll 来自同一个压缩包或同一个 NuGet 版本,另外留意平台:x86 的 exe 加载 x64 的 dll 也会报类似错误,把整个工程切到 x64 再编译。

4.5 换了个 CPU 后,推理速度反而变慢,CPU 占用率却不高

我把模型从 i7 台式机搬到一台至强服务器上一跑,延迟从 8ms 变成了 25ms,而且 CPU 占用率只有 30% 左右,看起来像“没用满”。原因是 ONNX Runtime 的算子内核会针对不同 CPU 指令集做分发,比如 AVX-512 和 AVX2 的性能差异很大;你在自己机器上编译时开了-march=native,或者 ONNX Runtime 里做了 CPU 特性检测,换机器后指令集能力不同,走的算子实现路径也不同。另外一个容易被忽略的点是 BIOS 或虚拟机配置把 CPU 频率锁死了,或者程序被限制在单 NUMA 节点上。解决办法是先查 CPU 支持的指令集和当前频率,再用SetIntraOpNumThreads配合Ort::SessionOptions的SetEnableCpuMemArena做一轮参数扫描,找到这台机器上延迟最小的线程数。

5. 上生产前的验证手段与进阶技巧

5.1 延迟和吞吐怎么测才可信

分类模型的性能评估,最忌讳跑一次就报数字。CPU 推理受频率调度影响极大,同一台机器不同时刻测得的结果可能相差 30%。我习惯在程序里写三段计时,分别统计预处理、推理、后处理耗时,并且在正式测试前先跑 20 张图“热身”,让 CPU 频率和缓存状态稳定下来,然后再连续测 200 张,取 P50 和 P95 两个指标。P95 比平均值更能反映真实体验,因为用户能感知到的是尾延迟。

// 计时代码片段,配合 Classifier 使用 auto t0 = std::chrono::high_resolution_clock::now(); auto results = classifier.Predict(img, 5); auto t1 = std::chrono::high_resolution_clock::now(); double elapsed_ms = std::chrono::duration<double, std::milli>(t1 - t0).count();

如果必须把预处理时间和推理时间分开,就在Predict内部埋时间戳,外部只取总耗时。这样测出来的数据可以支持你做决策:瓶颈在预处理说明图像解码和缩放环节要优化,比如换成cv::dnn的缩放或者 GPU 上的 resize;瓶颈在推理说明要考虑模型压缩或换硬件。

5.2 多线程并发:一个 session 还是多个 session

ONNX Runtime 的Ort::Session不是完全线程安全的,并发调用同一个 session 的Run在多数版本里是允许的,但内部存在锁竞争,并发一上来延迟会明显恶化。我的建议是:如果每个请求都很重、QPS 要求高,就用线程池,每个线程持有一个独立的Session,加载同一个 onnx 文件;如果只是偶尔来一个请求,单 session 就够了。注意多个 session 会各自持有模型参数的内存拷贝,nano 模型还好,x 模型可能多占用几百 MB 内存,要根据硬件量力而行。线程数可以从 1 开始往上加,观察延迟不再下降反而上升时,那个点就是这台机器的饱和并发。

5.3 模型压缩方向怎么选

如果 CPU 推理延迟仍然压不下来,优先考虑模型量化而不是换更小的模型。ONNX Runtime 支持动态量化,但直接用Ort::SessionOptions的量化 API 往往精度损失不小;更稳的做法是在导出环节用 onnx 的 QDQ 格式,配合校准数据集做静态量化,让 ONNX Runtime 在加载时使用整数内核。FP16 在 CPU 上没有收益,在支持 FP16 的 GPU 上才有意义。如果你是在 RK3588 这类板卡上部署,ONNX Runtime 通常不是最终选择,更常见的是把 ONNX 转成 RKNN 再跑,但前面在 C++ 里验证过的预处理逻辑和后处理逻辑可以原样复用,这也是先在本机把 C++ 部署跑通的价值所在。

这些年做模型部署,我最大的教训就是“永远不要相信两边的输出应该差不多”。只要 C++ 和 Python 的结果有差异,我就去逐元素对比输入张量,而不是先去怀疑模型文件或 ONNX Runtime 本身。预处理对了,推理框架一般不会辜负你。希望你也能养成这个习惯,希望帮到你。

本文还有配套的精品资源,点击获取

返回列表