news 2026/11/2 7:40:04

RK3588部署YOLOv5s:C++重写NMS实现359倍加速

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RK3588部署YOLOv5s:C++重写NMS实现359倍加速

在 RK3588 上部署 YOLOv5s 这个系列写到第五篇,这次要解决的是 C++ 侧 NMS 的性能问题。前四篇我依次完成了环境搭建、rknn-toolkit2 模型导出、RKNN 推理接口封装、以及输出张量的解码,跟着做下来的读者应该都会遇到一个很现实的现象:模型前向推理确实够快,但画面出来后框的刷新速度跟不上。尤其是把输出锚点数摊开看,一次 640x640 的输入会产生 25200 个候选位置,后处理里一个不讲究的 NMS 在 RK3588 的 CPU 上能跑出一百多毫秒,比 NPU 推理时间还长。

这篇记录的是我把 NMS 从纯 Python 重写为 C++,并把单帧后处理耗时从 112.3 毫秒压到 0.312 毫秒的完整链路。359 倍不是某一个操作带来的,而是数据结构、排序策略、短路计算和 SIMD 指令一层层抠出来的。如果你也需要在 RK3588 这类 arm64 设备上做实时检测,这篇可以直接当一份优化笔记抄。

1. NMS 为什么会在 RK3588 上成为“帧率刺客”

1.1 从 25200 个锚点说起

YOLOv5s 输入 640x640 时,三个检测头的输出特征图分别是 80x80、40x40、20x20。每个特征图上的每个格子都有 3 个 anchor,所以模型推理之后一共会产生:

(80 * 80 + 40 * 40 + 20 * 20) * 3 = 25200

也就是 25200 组预测值。RKNN 输出给我的张量形状是 [1, 25200, 85],其中前 5 个数是 cx、cy、w、h、objectness,后面 80 个数是 COCO 类别的条件概率。理论上,每一组都对应一个“潜在的目标框”。

但事实上绝大多数检测位置都是背景,objectness 乘上类别概率之后分数非常低。只有很少一部分超过置信度阈值,能进入 NMS 阶段。以我常用的一张测试图为例,conf_thres=0.25 过滤之后剩下大概 200 到 300 个框,分布在不同类别里。

那为什么不直接对这几百个框做循环判断?问题在于很多人第一次写 NMS 时,是带着“所有框先全局排序,然后从最高分往下依次删”的思路去写的。一旦候选框数量是几百个,两两之间要计算 IoU 的次数就是 O(n^2)。也就是说,最终参与后处理的只有 1% 的候选框,但算法在前期要为这 1% 付出大量无效判断。

1.2 一份最朴素的 Python NMS 为什么能跑出 112ms

我最初为了快速验证精度,直接在 RK3588 上跑过一个纯 Python 版本。代码思路和大家见到的教程差不多:

def iou(a, b): x1 = max(a[0], b[0]) y1 = max(a[1], b[1]) x2 = min(a[2], b[2]) y2 = min(a[3], b[3]) inter = max(0.0, x2 - x1) * max(0.0, y2 - y1) union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter return inter / union def nms_python(dets, iou_thres): dets = sorted(dets, key=lambda x: x[4], reverse=True) keep = [] while dets: best = dets.pop(0) keep.append(best) dets = [d for d in dets if iou(best, d) <= iou_thres] return keep

这个版本在 RK3588 的一个 A76 大核上跑,单帧要 112.3 毫秒。速度慢不是 IoU 公式写错了,而是 CPython 的每次列表 pop、每次 lambda 排序、每次函数调用都在做大量解释和对象分配。两个 float 相乘,在 C++ 里就是一条vmul.f32指令,在 Python 里却要经过 int/float 对象拆包、属性访问、函数调用返回,三层开销叠起来完全是两个世界。

这个 112ms 并不是极端情况。如果候选框数量冲到 500 个左右,纯 Python 版本甚至会超过 150ms,因为内层列表推导[d for d in dets if ...]每删一次框都会重新生成一个列表,内存分配和旧列表回收带来的开销比想象中的还夸张。

1.3 RK3588 的 CPU 结构和优化空间

RK3588 是 4 颗 Cortex-A76 + 4 颗 Cortex-A55 的 big.LITTLE 设计。NPU 负责模型卷积,后处理几乎都会落到 A76 大核上。A76 的核心频率最高约 2.2GHz,寄存器是 128 位宽的 NEON SIMD,支持常规的vmaxq_f32、vminq_f32、vld1q_f32这类批量数据操作。

这意味着两件事:第一,只要用 C++ 重写,不再受 Python 解释器束缚,性能会有数量级提升;第二,如果把内存布局和计算逻辑再整理一遍,让数据尽量连续存放、减少分支跳转,还可以利用 NEON 一次性处理多个候选框的坐标比较,把单核性能压榨到极限。

在动手写代码之前,我先明确了一个原则:先正确,再快。很多优化手段会直接影响最终保留框的选择,比如 float 运算顺序不同可能带来微小误差,如果一开始就上黑魔法,出错后很难定位。所以我第一版 C++ 完全按 Python 逻辑翻译,之后再逐步加优化。

2. NMS 的判定逻辑:只知道“算重叠”远远不够

2.1 IoU 公式与边界条件

Non-Maximum Suppression 的核心是计算两个框的交并比。公式不复杂:

IoU = 交集面积 / (框A面积 + 框B面积 - 交集面积)

但工程实现里有几个容易出错的细节。两个框如果没有交集,计算出来的inter_w或inter_h会是负数,如果不做 max(0) 处理,后面积会造成负数面积,IoU 就可能变成负值,导致该抑制的框被保留。所以我在 C++ 里写 IoU 时先做了两条快速判断:

inline float IoU(const Box &a, const Box &b) { float xx1 = std::max(a.x1, b.x1); float yy1 = std::max(a.y1, b.y1); float xx2 = std::min(a.x2, b.x2); float yy2 = std::min(a.y2, b.y2); float inter_w = xx2 - xx1; float inter_h = yy2 - yy1; if (inter_w <= 0.f || inter_h <= 0.f) return 0.f; float inter = inter_w * inter_h; float uni = (a.x2 - a.x1) * (a.y2 - a.y1) + (b.x2 - b.x1) * (b.y2 - b.y1) - inter; return inter / uni; }

另外,某些极端情况下uni可能非常接近 0,比如两个框都是退化框。我在解码阶段就会把w <= 0 || h <= 0的候选直接丢弃,不让它进入 NMS。

2.2 类别感知 NMS 和全局 NMS 的区别

我第一次写 NMS 时用的是全局抑制,也就是不管类别,只要两个框 IoU 超过阈值就删掉低分那个。后来发现同一个位置如果同时站着行人和自行车,类别不同但框高度重合,全局 NMS 会把其中一类误删。标准做法是“类别感知 NMS”:先按 class_id 分组,再在每一个类别内部执行同一套抑制逻辑。

对应到代码层面,排序时不能只按 score 排序,要按(class_id, score)双关键字排序。这样同类别的候选框会被排在相邻区间,后面优化时只需要在区间内部做两两比较,不同类别的框天然不会互相干扰。

2.3 阈值怎么选

置信度阈值和 IoU 阈值会影响速度和召回率。我常用的组合是conf_thres=0.25、iou_thres=0.45。IoU 阈值越高,重叠的框越难被抑制,保留的框越多;置信度阈值越低,进入 NMS 的候选框越多,计算量成平方增长。

如果你做的是小目标检测,比如车辆、行人这类场景,把置信度降到 0.1 会明显提升召回,但候选框可能涨到 1000 个以上。这时候 NMS 的优化就不仅是“够用”,而是决定整条流水线能不能实时跑起来的关键。

3. 从 0 写一个能跑的 C++ NMS:正确性第一版

3.1 结构体与解码出口对接

我定义了一个很朴素的Box结构体:

struct Box { float x1, y1, x2, y2; float score; int cls; float area; };

这里直接预存了area,因为 IoU 计算时框面积会被重复使用,每次现算虽然也便宜,但在几万次甚至几十万次循环里累计起来就不是小钱了。

解码函数从 RKNN 拿到的裸 buffer 里逐个锚点读取数据,流程是:

std::vector<Box> decode(float *data, int num_anchors, int num_classes, float conf_thres) { std::vector<Box> boxes; boxes.reserve(1024); for (int i = 0; i < num_anchors; ++i) { float *ptr = data + i * (5 + num_classes); float obj = ptr[4]; int cls = -1; float max_cls = 0.f; for (int c = 0; c < num_classes; ++c) { if (ptr[5 + c] > max_cls) { max_cls = ptr[5 + c]; cls = c; } } float score = obj * max_cls; if (score < conf_thres) continue; float cx = ptr[0]; float cy = ptr[1]; float w = ptr[2]; float h = ptr[3]; if (w <= 0.f || h <= 0.f) continue; boxes.push_back(Box{ std::max(0.f, cx - w * 0.5f), std::max(0.f, cy - h * 0.5f), std::min(640.f, cx + w * 0.5f), std::min(640.f, cy + h * 0.5f), score, cls, w * h }); } return boxes; }

这里出现了一个部署中非常常见的坑:RKNN 输出的坐标到底有没有恢复到 640x640 尺度。我在转换模型时选择让输出保留经过 stride 还原后的坐标,所以解码后直接对应输入图像像素位置。如果你的模型输出还是 feature map 上的相对坐标,需要在解码前乘上对应的 stride,否则后面 NMS 的结果会完全错乱。

3.2 排序:按类别和分数一起排

正确性第一版里,我用std::sort对索引排序:

std::vector<int> order(boxes.size()); std::iota(order.begin(), order.end(), 0); std::sort(order.begin(), order.end(), [&](int a, int b) { if (boxes[a].cls != boxes[b].cls) return boxes[a].cls < boxes[b].cls; return boxes[a].score > boxes[b].score; });

之所以排索引而不是直接给std::vector<Box>排序,是因为后面 NMS 需要一种“当前保留集合”的标记,直接让对象跟着索引但不是唯一选择;直接排序引用对象会带来大量内存拷贝。把 Box 对象留在原地,只移动 int 索引,能显著减少 cache miss。

3.3 朴素双循环 NMS:先保证结果和 Python 一致

这版我用std::vector<bool>作为抑制标记:

std::vector<int> nms(const std::vector<Box> &boxes, const std::vector<int> &order, float iou_thres) { std::vector<int> keep; keep.reserve(16); std::vector<bool> removed(boxes.size(), false); for (size_t i = 0; i < order.size(); ++i) { int idx = order[i]; if (removed[idx]) continue; keep.push_back(idx); const Box &a = boxes[idx]; for (size_t j = i + 1; j < order.size(); ++j) { int jdx = order[j]; if (removed[jdx]) continue; if (boxes[jdx].cls != a.cls) continue; if (IoU(a, boxes[jdx]) > iou_thres) { removed[jdx] = true; } } } return keep; }

这个版本在候选框只有两三百个时已经能做到 1.8 毫秒左右,相对 Python 是几十倍的提升。如果模型跑在低分辨率场景,比如 320x320 输入,候选框会更少,这个版本可能已经够用。但到了 640x640 并伴随低置信度阈值时,内层循环里还要做一次类别判断和一个 IoU 函数调用,依然有优化空间。

4. 让性能飞起来:标记、剪枝、SIMD

4.1 用连续内存的标记数组替代删减容器

Python 列表pop(0)和列表推导虽然方便,但每次删除元素都涉及内存搬移。C++ 的vector::erase也一样,频繁删除中间元素会让 O(n) 变成实际上的 O(n^2) 内存操作。

所以我在所有 C++ 版本里都没有真正删除过 Box,只用removed标记跳过去。这样内存始终是连续的一块,访问顺序也接近线性,配合 CPU 的 cache 预取机制,性能表现会好很多。

4.2 面积预计算和短路 IoU

IoU 计算里,交集区域如果小于等于零,可以直接返回 0,不需要继续计算 union。这个分支看起来是一件小事,但内层循环可能被执行几十万次,每次少两次减法和一次除法,收益非常可观。

另外,因为我们在结构体里预存了area,IoU 函数里的uni计算也不再需要重新读取两端坐标去求面积,直接:

float uni = a.area + b.area - inter; return inter / uni;

这些细碎优化叠加之后,朴素版从 1.8ms 降到了约 0.9ms,并没有改变任何算法逻辑。

4.3 按 class_id 分区间,删掉内层类别判断

虽然排序已经让同类框相邻,但朴素版的内层循环里依然有一条if (boxes[jdx].cls != a.cls) continue;。在候选框很少时无所谓,但候选框多时这条分支会伴随大量无意义循环。

更彻底的做法是找出每个类别在order里的起始和结束下标,然后在每个区间内部独立跑 NMS:

struct Segment { int begin; int end; }; std::vector<Segment> segs; int start = 0; for (size_t i = 1; i < order.size(); ++i) { if (boxes[order[i]].cls != boxes[order[i - 1]].cls) { segs.push_back({start, (int)i}); start = (int)i; } } segs.push_back({start, (int)order.size()}); // 每个 segment 内部单独做 np.py 逻辑 for (auto seg : segs) { for (int i = seg.begin; i < seg.end; ++i) { int idx = order[i]; if (removed[idx]) continue; keep.push_back(idx); const Box &a = boxes[idx]; for (int j = i + 1; j < seg.end; ++j) { ... } } }

我只展示这个分段思路,没有贴完整的nms重复代码。分段之后,内层循环不再需要判断类别,比较次数也只会落在同一类别的候选框之间。实际操作中,4 个类别的候选框总共 240 个时,分段版本和朴素版本的耗时差距大概在 20% 到 30%。

4.4 NEON 批量 IoU:最后一层 CDN

当候选框数量在几百个时,NMS 真正的热点是内层循环里反复读取每个候选项坐标、求 min/max。A76 的 NEON 寄存器能一次处理 4 个 float,所以可以把“四个候选框与当前框 a 的 IoU”打包成一条指令组。

下面的代码不是完整版本,只展示核心向量化思路。我先假设候选框坐标被平铺成连续数组,例如bx1[j]、by1[j]、bx2[j]、by2[j]各是一段连续内存,这样vld1q_f32能一次性加载 4 个 float:

#include <arm_neon.h> inline float32x4_t IoU4(const Box &a, const float *bx1, const float *by1, const float *bx2, const float *by2, const float *bareas) { float32x4_t v_xx1 = vmaxq_f32(vdupq_n_f32(a.x1), vld1q_f32(bx1)); float32x4_t v_yy1 = vmaxq_f32(vdupq_n_f32(a.y1), vld1q_f32(by1)); float32x4_t v_xx2 = vminq_f32(vdupq_n_f32(a.x2), vld1q_f32(bx2)); float32x4_t v_yy2 = vminq_f32(vdupq_n_f32(a.y2), vld1q_f32(by2)); float32x4_t v_w = vsubq_f32(v_xx2, v_xx1); float32x4_t v_h = vsubq_f32(v_yy2, v_yy1); float32x4_t v_inter = vmulq_f32(v_w, v_h); float32x4_t v_uni = vsubq_f32(vaddq_f32(vdupq_n_f32(a.area), vld1q_f32(bareas)), v_inter); return vmulq_f32(v_inter, vrecpeq_f32(v_uni)); }

vrecpeq_f32是 NEON 提供的倒数估计指令,精度有限,对不需要精确到小数点后多位的 NMS 场景是足够的。如果你不信任这个近似,也可以用vrecpsq_f32做一次 Newton-Raphson 迭代提高精度,但速度会略微下降。

我最终采用的版本,其实只在候选框数量较多时启用这段向量化代码,候选框少于 64 个时仍然走标量版本,避免 NEON 带来的对齐和调用开销超过收益。

4.5 编译参数

在 RK3588 上交叉编译时,我用的参数是:

aarch64-linux-gnu-g++ -O3 -std=c++17 -fno-rtti -fno-exceptions \ -march=armv8.2-a+fp16+dotprod -mtune=cortex-a76 \ -pipe nms.cpp -o nms_test

-O3让编译器自动向量化,-mtune=cortex-a76让指令调度更贴近 A76 的流水线。需要留意的是不要在本机编译时用-march=native,因为 RK3588 和 x86 开发机的指令集完全不同,编译出的二进制会遇到非法指令。

5. 实测报告:359 倍是怎么测出来的

5.1 测试环境和测试方法

为了确保对比公平,所有版本都跑在 RK3588 开发板上,CPU 固定使用同一颗 A76 大核,通过taskset -c 4绑定核心 4。测试输入是同一张 640x640 图片的 RKNN 推理输出,也就是说 NPU 部分完全相同,只有 NMS 实现不同。

Python 版本使用 Python 3.8.15,C++ 版本使用 GCC 9.4.0。每个版本跑 100 次取平均,避免 cold page 和调度噪声。

5.2 逐版本耗时对比

我记录的数据如下:

版本耗时相对 Python 提升
Python 朴素 NMS112.3 ms1x
Python 优化版(减少列表重建)68.5 ms1.6x
C++ 朴素版1.82 ms62x
C++ 分段 + 预计算面积0.83 ms135x
C++ 分段 + NEON 批量 IoU0.312 ms359x

112.3 除以 0.312 约等于 359.9,考虑测量波动,我之前在技术群里汇报时写的 359 倍,实际统计口径是平均帧耗时比例。你如果在自己环境里复现,数字可能会在 350 到 370 之间浮动,但只要结构一致,这个量级的提升是可以稳定复现的。

5.3 为什么没有直接用 cv2.dnn.NMSBoxes

很多人会问:OpenCV 不是已经内置了cv2.dnn.NMSBoxes吗,为什么还要自己写?在我的部署链路里,OpenCV 内置 NMS 当然可以作为替代方案,但它有几个我不喜欢的地方:

  • 我需要把 RKNN 输出转成 Python list 再传进去,这个转换本身在 Python 侧就要几十毫秒量级;
  • OpenCV 的 NMS 实现是固定的接口,不方便按类别做精细控制,也不方便对坐标范围做裁剪;
  • 我想在同一份 C++ 代码里做解码、阈值过滤、NMS 全流程,不希望在中间额外引入一个 Python 层的依赖。

如果你只是快速验证功能,先调用cv2.dnn.NMSBoxes完全没问题,但走到性能指标阶段,自己写一套类感知 NMS 几乎是必然选择。

5.4 结果一致性:优化之后框和原来一样吗

这是一定要验证的。我用 Python 朴素版、C++ 分段版、C++ NEON 版三份代码分别输出 keep 框的索引,对比了 100 张测试图的保留框集合。

结论是:分段 C++ 版本和 Python 基本一致,唯一可能出现差异的地方是浮点数运算顺序。Python 里算inter_w和 C++ 里算inter_w可能会在最后一位有效数字上有区别,尤其当 IoU 恰好非常接近 0.45 阈值时,保留结果可能差一个框。NEON 版的近似倒数指令也会带来类似影响。

应对方式是在阈值比较处不写>=,而是写> iou_thres。这样即使 IoU 有极微小的上下抖动,也能避免“等于阈值时被抑制”的边界问题。实测下来,100 张图的 keep 集合全部一样。

6. 部署到 RK3588 时容易踩的四个坑

6.1 不要试图把 NMS 塞进模型里

有的朋友想省事,希望模型直接输出最终框。这不现实,NPU 适合矩阵计算,不适合这种带分支和删除的迭代逻辑。RKNN 工具链里虽然也提供部分后处理支持,但灵活性非常差,一旦你的输入尺寸、类别数或者阈值变化,就要重新转换模型,调试效率很低。

我最后采用的架构非常清晰:NPU 只负责推理,CPU 端负责解码和 NMS,两者通过 RKNN 的零拷贝接口传递原始张量。这样后处理可以独立优化,也不会被 NPU 的固定输入输出束缚。

6.2 坐标尺度一定要打印出来确认

YOLOv5 的原始输出是网格坐标和归一化后的宽高,需要乘 stride 才能得到输入图像坐标。但 RKNN 导出时如果带上了部分后处理,输出坐标可能是已经在 640x640 尺度的数值。

我一开始没打印第一帧的原始坐标,直接把输出当作网络原封不动的结果去解码,导致画出来的框全跑到左上角。后来在解码前打印几个非零 box 的 cx、cy、w、h 才发现,数值范围已经是 640 尺度。这个坑基本属于“每个接手模型的人都要踩一遍”的类型,建议解码代码里临时加一行printf验证尺度,再继续往后写。

6.3 NMS 函数里的动态分配不要每帧都触发

我第一版把keep、removed、order都定义在nms()函数内部,跑起来也没觉得异常,但用perf看热点时会发现malloc和free占比不小。因为每帧都要为几百个 int 分配内存、释放内存,在实时链路里这个开销本来可以完全避免。

改进做法是给每个线程准备一个复用结构:

struct NmsWorkspace { std::vector<int> order; std::vector<bool> removed; std::vector<int> keep; std::vector<Segment> segs; }; void nmsReuse(WorkSpace &ws, const Boxes &boxes, float iou_thres) { ws.order.clear(); ws.removed.assign(boxes.size(), false); ws.keep.clear(); ... }

保留容量之后,reserve 分配只发生在前几帧,后续帧的clear()不会释放内存,性能会稳定在一个更低的水平。

6.4 多线程不是第一优先级

看到 0.312ms 之后,有人会问为什么不用多线程给每个类别分配一个核。我的建议是:先看你的候选框总量。如果过滤后只有一两百个框,多线程切分本身的同步开销可能达到 0.1ms 量级,收益不明显。真正值得多线程化的,不是 NMS 本身,而是图像缩放、预处理、等待 NPU 这些更粗粒度的流程。

我在完整 pipeline 里是把解码和 NMS 放在一个独立的线程里,让它们和下一帧的 NPU 推理重叠执行。也就是说,NPU 在算第 N 帧时,CPU 同时在做第 N-1 帧的后处理。这样即使后处理是 5ms,也不会直接叠加到单帧延迟上。

最终在我的 RK3588 板子上,YOLOv5s 的 NPU 推理耗时约 47.8ms,后处理从 112.3ms 压到 0.312ms 之后,单帧总延迟从 160ms 左右降到 48ms 上下,帧率从 6fps 以下提升到可以稳定跑 20fps。如果再把图像缩放和显示刷新也改成双缓冲线程模式,还能继续往上走。

我个人的实操体会是:NMS 这类算法优化的核心,往往不是那几条 SIMD 指令,而是先想清楚哪些比较是无效的,哪些数据是重复读取的。只要把无效计算剪掉,哪怕不写一行 NEON,也能拿到十倍级别的提升。359 倍的加速听起来很夸张,但它确实是在 RK3588 这样一颗移动处理器上,从 Python 解释器到 C++ 向量化一层层换回来的真实结果。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 7:39:34

WebService与HTTP接口的核心差异与应用场景

1. WebService与HTTP接口的本质差异在分布式系统开发中&#xff0c;WebService和HTTP接口是两种常见的服务交互方式。虽然它们都基于网络通信&#xff0c;但设计理念和技术实现存在根本性区别。我曾参与过多个企业级系统的服务集成项目&#xff0c;深刻体会到错误选择通信协议带…

作者头像 李华
网站建设 2026/11/2 7:39:31

Coros MCP协议解析:社区逆向工程的运动数据接口

1. 高驰MCP不是“高驰官方协议”&#xff0c;而是开发者社区自发构建的逆向工程接口层 “高驰MCP”这个称呼在运动健康技术圈里最近半年突然密集出现&#xff0c;但凡搜过Coros手表数据导出、TCX文件解析、FIT格式转换的人&#xff0c;几乎都撞见过 coros-additional-mcp 这…

作者头像 李华
网站建设 2026/11/2 7:38:19

30米分辨率地形坡度数据的计算与应用实践

1. 项目概述&#xff1a;30米分辨率地形坡度数据的价值与应用地形坡度数据是地理信息系统&#xff08;GIS&#xff09;和遥感领域的基础空间数据之一。这份全国30米分辨率的Tif格式坡度数据集&#xff0c;意味着每个像素代表地面上3030米范围内的平均坡度值。这种中高分辨率的数…

作者头像 李华
网站建设 2026/11/2 7:38:19

AList存储驱动开发实战:Google Drive接入与真机避坑

AList 的存储驱动开发这件事&#xff0c;说穿了就是给一个“文件列表器”接上新的存储后端。标题里点到的 Google Drive 算是比较难缠的一类&#xff1a;它的 API 模型跟传统网盘差得远&#xff0c;认证链路更长&#xff0c;配额也更敏感。我前后写过几个云盘驱动&#xff0c;也…

作者头像 李华
网站建设 2026/11/2 7:38:19

大模型选型与AI应用落地:从模型对比到工程实践

这两年大模型圈子变化太快&#xff0c;很多朋友隔三差五就来问我&#xff1a;现在到底该用哪个模型&#xff1f;为什么别人家应用那么聪明&#xff0c;自己接上去一个模型却像个笨蛋&#xff1f;其实每次遇到这种问题&#xff0c;我都想拉张表好好讲讲&#xff0c;但说实话&…

作者头像 李华
网站建设 2026/11/2 7:37:39

教师AI工具清单:聊天、画图、视频、数字人全场景指南

1. 教师为什么需要一份自己的AI工具清单这两年我接触了不少一线教师&#xff0c;从小学到高校都有&#xff0c;聊下来最大的感受是&#xff1a;大家不是不想用AI&#xff0c;而是被市面上五花八门的工具搞晕了。今天听说DeepSeek能解题&#xff0c;明天又有人说豆包做课件快&am…

作者头像 李华