BERT 分类模型导出为 ONNX 后,直接用 ONNX Runtime 在 CPU 上推理,不需要转 RKNN,不需要 GPU/NPU。
一句话总结
训练好的 BERT 模型导出为 ONNX 后,直接用 ONNX Runtime CPU 推理即可,不需要转 RKNN。 RKNN NPU 无法正确处理 BERT embedding 层(整数 token ID 查表),经过 6 轮调试后放弃 NPU 方案。最终在香橙派 ARM CPU 上实现:置信度 92.26%,推理耗时 111ms,已集成到 HXXedge v1.0.1。
Colab 训练 (bert-base-chinese)
↓ HF 格式(PyTorch)
Colab 导出 ONNX
↓ 检查 IsNaN(仅报告,不修改)→ ONNX Runtime 验证 → 记录基准 logits
↓ ✅ 验证通过后才下载
下载 ONNX + tokenizer + categories
↓
龘思 Docker 转 RKNN(rknn-toolkit2 == 2.3.2)
↓ 无 mean/std 预处理、optimization_level=0、disable_rules
↓ .rknn 文件
上传香橙派
↓
rknn-toolkit-lite2 推理验证 → 对比 logits 是否接近基准
bert-base-chinese(110M 参数)output/ 目录教训:ONNX 导出后如果不验证就下载,到了 Docker / 香橙派上才发现模型有问题,每次排查都要走完整链路(Colab → 下载 → Docker 转换 → 上传香橙派 → 测试),极其耗时。
正确做法:在 Colab 中完成以下全部验证,并记录原始 logits 作为基准,确认无误后再下载:
现象:PyTorch 导出 BERT 时,ONNX 图中包含 12 个 IsNaN 算子。RKNN 加载后输出警告 Unsupport CPU op: IsNaN in this librknnrt.so。
根因:PyTorch 2.x 的 ONNX 导出器对 BERT 的 LayerNormalization 分解时,额外生成了 NaN 安全检查:
计算结果 x → IsNaN(x) → Where(is_nan, default_val, x) → 最终输出
RKNN 运行时(librknnrt 2.3.2)不支持 IsNaN 算子。
改 opset_version 有用吗?
| opset_version | 结果 |
|---|---|
| 14 | ❌ 版本转换失败,产生 IsNaN |
| 17 | ❌ PyTorch 自动升到 18 再降回 17,仍产生 IsNaN |
| 18 | ❌ 同样有 IsNaN |
结论:改 opset_version 无效,IsNaN 是 PyTorch 导出器固定生成的。
曾尝试的方案:将 Where(IsNaN(x), default, x) 替换为 Identity(x),跳过 NaN 检查。
为什么失败:替换严重破坏了模型精度!
| 指标 | ONNX(Colab 基准) | RKNN(IsNaN→Identity 替换后) |
|---|---|---|
| logits[0]("你们老板是谁") | 5.2765 | -0.4722 |
| logits 范围 | [-1.13, 5.28] | [-0.85, 1.01] |
| argmax | 0(领导团队)✅ | 6(产品体系)❌ |
| softmax 置信度 | 92.26% | 13.62%(≈随机) |
诊断过程: 1. 全零输入 vs 正常输入 → 输出不同 ✅ 说明模型在读数据 2. 不同问题 → 输出不同 ✅ 但 logits 太平坦 3. 对比 Colab 基准 logits → 差距巨大,说明 IsNaN→Identity 替换破坏了计算图
根因分析:IsNaN+Where 虽然理论上是"防御性检查"(正常输入不会产生 NaN),但替换后 onnxsim 简化会进一步重组图结构,导致计算精度严重退化。
策略:保留 IsNaN 算子,不做任何替换,不做 onnxsim 简化
RKNN 转换时:IsNaN 在 CPU 上执行(有警告但不影响结果)
关键认识:RKNN 的 "Unsupport CPU op: IsNaN" 是警告,不是错误。RKNN 运行时可以在 CPU 上执行这些不支持的算子,只是速度稍慢。模型精度不受影响。
运行导出脚本后,确认以下全部通过:
⚠️ 检测到 12 个 IsNaN 算子(不做替换)
✅ ONNX 模型结构正确
✅ ONNX 推理验证通过(预测结果正确)
📊 softmax 置信度: 92.26%(足够尖锐)
重要:必须记录原始 logits 值,后续与 RKNN 推理结果对比。如果 RKNN logits 与 ONNX 基准接近,说明转换成功;如果差距大,说明转换有问题。
完整脚本:export_onnx.py
conda activate rkllm
cd /Daas/data/models/rkllm/BERT
BERT/
├── export_rknn.py # 转换脚本
├── bert_classifier_inference.py # 推理脚本(副本)
├── output_onnx/ # ONNX 模型(从 Colab 下载)
│ ├── model.onnx # ~93 KB(图结构)
│ ├── model.onnx.data # ~390 MB(权重数据)
│ ├── categories.json
│ ├── tokenizer.json
│ └── tokenizer_config.json
└── output_rknn/ # RKNN 输出
├── bert_classifier_w8a8_rk3588.rknn
└── categories.json
rknn.config(
# ⚠️ BERT 文本模型不做均值/标准差预处理!
# 整数 token ID 不能被归一化(否则破坏 embedding 查表)
target_platform='rk3588',
optimization_level=0, # 0=基础(对 BERT 更安全)
quantized_dtype='w8a8',
disable_rules=['fuse_mul_into_sdpa'], # 该规则与 BERT attention 不兼容
)
| 错误 | 原因 | 修复 |
|---|---|---|
dynamic_input [[[1, 64]]] len is 1, expect 2 |
dynamic_input 格式不对 | 设为 None(固定形状不需要) |
input shape ['batch_size', 'sequence_length'] not support |
ONNX 用了符号维度名 | 导出时去掉 dynamic_axes |
mean_values ([0]) for input 0 is wrong, expect 64 |
mean_values 长度不匹配 | 删除 mean/std(文本模型不需要) |
do_quantization=True but dataset is None |
量化需要校准数据 | 先设 False 跑通流程 |
fuse_mul_into_sdpa TypeError |
优化规则与 BERT 不兼容 | disable_rules=['fuse_mul_into_sdpa'] |
Unsupport CPU op: IsNaN + Segfault |
RKNN 不支持 IsNaN 算子 | Colab 端替换 IsNaN→Where 为 Identity(不做 onnxsim) |
| IsNaN 替换 + onnxsim 后精度崩塌 92%→14% | onnxsim 简化重组图结构 | 只做 IsNaN 替换,跳过 onnxsim |
完整脚本:export_rknn.py(Docker 端)
rknn-toolkit-lite2(轻量推理版,不是完整的 rknn-toolkit2)| 工具 | 用途 | 导入方式 |
|---|---|---|
| rknn-toolkit2 | 模型转换(Docker) | from rknn.api import RKNN |
| rknn-toolkit-lite2 | 模型推理(香橙派) | from rknnlite.api import RKNNLite |
/home/orangepi/rkllm/rknn-toolkit2/2.3.2/bert_classifier/
├── bert_classifier_w8a8_rk3588.rknn # RKNN 模型
├── categories.json # 类别定义
├── tokenizer.json # 分词器
├── tokenizer_config.json # 分词器配置
└── bert_classifier_inference.py # 推理脚本
from rknnlite.api import RKNNLite
from tokenizers import Tokenizer
# 加载
rknn = RKNNLite()
rknn.load_rknn('bert_classifier_w8a8_rk3588.rknn')
rknn.init_runtime()
# 推理
encoding = tokenizer.encode("你们老板是谁")
input_ids = np.array([encoding.ids], dtype=np.int32)
attention_mask = np.array([encoding.attention_mask], dtype=np.int32)
outputs = rknn.inference(inputs=[input_ids, attention_mask])
# 解析
pred_label = outputs[0][0].argmax()
answer = categories[str(pred_label)]['answer']
从 ONNX 导出到香橙派推理成功,经历了 5 轮完整的「Colab 导出 → 下载 → Docker 转换 → 上传香橙派 → 测试」循环。每轮至少 10-15 分钟,教训深刻。
| 轮次 | 问题现象 | 根因 | 修复 | 耗时 |
|---|---|---|---|---|
| 第 1 轮 | dynamic_input len is 1, expect 2 |
dynamic_input 格式 [[[1,64]]] 不对 |
设为 None(固定形状不需要) | ~15min |
| 第 2 轮 | input shape ['batch_size','sequence_length'] not support |
ONNX 用了符号维度名(dynamic_axes) | 导出时去掉 dynamic_axes,用固定整数维度 | ~15min |
| 第 3 轮 | mean_values ([0]) for input 0 is wrong, expect 64 |
mean_values 长度不匹配输入维度 | 改为 [[0]*64](后改为删除) |
~15min |
| 第 4 轮 | fuse_mul_into_sdpa TypeError |
RKNN 优化规则与 BERT attention 不兼容 | disable_rules=['fuse_mul_into_sdpa'] |
~15min |
| 第 5 轮 | IsNaN→Identity 替换 + onnxsim 后精度崩塌(92%→14%) | onnxsim 简化重组图结构破坏精度 | IsNaN 替换保留,跳过 onnxsim | ~30min |
关键诊断手段:
[5.28, -0.15, 0.89, ...],置信度 92.26%RKNN 退化:[-0.47, 0.90, -0.59, ...],置信度 13.62%(≈随机)
输入敏感性测试:传入全零输入 vs 正常输入 vs 不同输入,确认模型是否真正读取数据
结果:三种输入产生不同输出 ✅ 说明模型在计算,但精度退化
控制变量法:逐步排除 mean/std、optimization_level、IsNaN 替换、onnxsim 等因素
不要:导出 ONNX → 下载 → Docker 转 RKNN → 上传香橙派 → 发现不行 → 重来
要:导出 ONNX → ONNX Runtime 验证 → 记录原始 logits → 确认结果正确 → 再下载
每次"重来"的成本是:下载(几分钟)+ Docker 转换(几分钟)+ 上传 + 测试,至少 10-15 分钟。5 轮下来超过 2 小时。
基准 logits 对比法:如果 RKNN 推理结果异常,先对比 RKNN logits 与 Colab 基准 logits。差距大说明转换有问题,差距小说明是后处理问题。
PyTorch 2.x 的 ONNX 导出器对 BERT 固定会生成 12 个 IsNaN 算子,无论 opset 14/17/18 都一样。
RKNN 运行时不支持 IsNaN:不是警告后忽略,而是直接 Segfault。必须替换。
| 做法 | 结果 | 原因 |
|---|---|---|
| IsNaN→Identity + onnxsim | ❌ 精度崩塌(92%→14%) | onnxsim 重组图结构破坏计算 |
| 不替换 IsNaN | ❌ Segfault | librknnrt.so 不支持 IsNaN |
| IsNaN→Identity,跳过 onnxsim | ✅ 正确方案 | 替换避免崩溃,不做简化保护精度 |
RKNN 的 mean_values / std_values 是为图像模型设计的(像素值归一化)。BERT 的输入是整数 token ID,直接传入 embedding 层查表。设置 mean/std 可能导致 RKNN 对输入做浮点归一化,破坏 embedding 查表。
✅ 正确做法:不设置 mean_values 和 std_values。
RKNN 编译器有图优化规则,某些规则与 BERT 的 attention 结构不兼容:
disable_rules=['fuse_mul_into_sdpa'] # 必须禁用,否则 build 时 TypeError
| rknn-toolkit2 | rknn-toolkit-lite2 | |
|---|---|---|
| 用途 | 模型转换 + 推理 | 仅推理 |
| 运行环境 | Docker / PC | 香橙派等嵌入式设备 |
| 导入 | from rknn.api import RKNN |
from rknnlite.api import RKNNLite |
| 安装 | pip 安装(有依赖约束) | 设备预装或 pip 安装 |
| 文件 | 位置 | 说明 |
|---|---|---|
| export_onnx.py | extern/attachments/topic/LLMs/BERT/ |
Colab ONNX 导出(不修改图) |
| export_rknn.py | Daas-v2/data/models/rkllm/BERT/ |
Docker RKNN 转换 |
| bert_classifier_inference.py | Daas-v2/data/models/rkllm/BERT/ |
香橙派推理脚本(含诊断) |
| colab_train.py | extern/attachments/topic/LLMs/BERT/ |
Colab 训练脚本 |
| generate_training_data.py | extern/attachments/topic/LLMs/BERT/ |
训练数据生成 |
| preset_qa.json | extern/attachments/topic/LLMs/BERT/ |
预设知识(17 条) |
| BERT模型详解 | notes/人工智能/LLMs/ |
BERT 原理笔记 |
/home/orangepi/hqxbotEdge/hd001e_HXXedge_1.0.1//home/orangepi/rkllm/rknn-toolkit2/2.3.2/bert_classifier/output_onnx/| 问题 | 说明 |
|---|---|
| RKNN 精度崩塌 | 6 轮调试后 logits 始终为 13.62%(≈随机),与基准 92.26% 差距巨大 |
| 根因 | RKNN 编译器无法正确处理 BERT embedding 层(整数 token ID 查表 + 浮点计算混合) |
| 结论 | BERT 类文本模型不适合走 NPU,CPU 推理完全够用 |
| 指标 | 数值 |
|---|---|
| 置信度 | 92.26%(与 Colab 基准一致) |
| 推理耗时 | ~111ms(香橙派 ARM CPU) |
| 分类准确率 | 7/7 测试全部正确 |
BERT-ONNX-CPU 方案在两个平台均验证通过:
| 平台 | CPU 架构 | BERT 推理 | TTFT | 结果 |
|---|---|---|---|---|
| 香橙派 Orange Pi 5 Plus | ARM RK3588 | ~110ms | ~200ms | ✅ 5/5 正确 |
| 本地 Windows PC | x86 Intel/AMD | ~90ms | ~100ms | ✅ 5/5 正确 |
本地 Windows 测试用例:
| 问题 | 分类 | TTFT | 结果 |
|---|---|---|---|
| 你们有哪些核心产品? | 产品体系 | 126ms | ✅ 详细回答 |
| 你好啊 | 问候 | 115ms | ✅ 友好回复 |
| 你是谁 | 身份信息 | 97ms | ✅ 正确身份 |
| 哈哈 | 超范围 | 84ms | ✅ 礼貌拒答 |
| 请介绍广州啊 | 超范围 | 93ms | ✅ 礼貌拒答 |
结论: ONNX Runtime CPU 方案跨平台通用,ARM/x86 都能跑,不需要 GPU/NPU。x86 PC 性能更强,响应更快。
categories.json 中的 keywords 字段仅用于训练数据生成,推理时完全不用:
| 阶段 | keywords 用途 |
|---|---|
| 训练时 | 作为"种子问法"生成训练数据变体 |
| 推理时 | ❌ 完全不使用,只是参考文档 |
用户问:"你们电话是多少啊"(比 keywords 里的"电话"、"联系方式"都长)
| 方法 | 结果 | 原因 |
|---|---|---|
| 关键词匹配 | ❌ 无法匹配(没有完全匹配的关键词) | 只能匹配"电话"等短词,无法理解完整问句 |
| BERT 语义 | ✅ 正确分类为「联系方式」(96% 置信度) | 理解整句语义,知道这是在问电话 |
| 问法 | 关键词匹配 | BERT 语义 |
|---|---|---|
| "你们电话是多少啊" | ❌ 没有"多少啊"关键词 | ✅ 理解是问电话 |
| "能打哪个号码" | ❌ 没有"号码"关键词 | ✅ 理解是问电话 |
| "联系方式给我" | ✅ 有"联系"关键词 | ✅ 理解是问电话 |
| "怎么找到你们" | ❌ 没有匹配关键词 | ✅ 理解是问联系方式 |
| 特性 | 关键词匹配 | BERT 语义分类 |
|---|---|---|
| 泛化能力 | ❌ 只能匹配预设关键词 | ✅ 任意问法都能理解 |
| 维护成本 | 需要穷举所有问法 | 只需少量种子问法训练 |
| 抗干扰 | ❌ 多一个字就匹配失败 | ✅ 忽略无关词汇 |
| 推理速度 | 快(字符串匹配) | 较快(~100ms) |
在大模型火爆的当下,BERT 仍有独特优势:
| 维度 | BERT | 大模型 |
|---|---|---|
| 速度 | ~100ms | 1-3秒 |
| 资源 | CPU 即可 | 需要 GPU |
| 成本 | 免费 | API 按 token 收费 |
| 确定性 | ✅ 同问同答 | ❌ 可能幻觉/不一致 |
| 隐私 | ✅ 完全离线 | ❌ 数据要上传 |
| 灵活性 | ❌ 只能分类 | ✅ 任意问答 |
| 场景 | 为什么选 BERT |
|---|---|
| 客服 FAQ | 问题有限,BERT 更快更稳 |
| 边缘设备 | 香橙派/树莓派跑不动大模型 |
| 低延迟要求 | 100ms vs 秒级 |
| 成本敏感 | 零 API 费用 |
| 隐私合规 | 数据不出本地 |
| 场景 | 为什么需要大模型 |
|---|---|
| 开放对话 | 问题无边界 |
| 内容生成 | 写文章、代码 |
| 复杂推理 | 多步逻辑分析 |
| 零样本任务 | 不想训练新模型 |
用户提问
↓
BERT 快速分类(100ms)
├─ 命中预设 → 直接返回(快、稳、免费)
└─ 未命中 → 调用大模型(灵活、但慢/贵)
总结: BERT 是"专精快",大模型是"广慢贵"。固定场景 BERT 更优,开放场景大模型更强,组合使用才是最佳方案。
用户提问
↓
① BERT 分类(ONNX Runtime CPU,~110ms)
↓ 置信度 ≥ 70%?
├─ 是 → 返回对应答案
└─ 否 → ② 分级拒答
├─ 含公司关键词 → 礼貌拒答(领域内)
└─ 不含关键词 → 超范围拒答
/home/orangepi/hqxbotEdge/hd001e_HXXedge_1.0.1/
├── app.py # Flask 入口(只加载 BERT)
├── config.py # 配置(BERT 模型路径等)
├── bert_classifier.py # BERT 分类器模块(ONNX Runtime CPU)
├── routes/main.py # 二级流水线
└── restart.sh # 重启脚本
/home/orangepi/rkllm/rknn-toolkit2/2.3.2/bert_classifier/output_onnx/
├── model.onnx # ~390MB(ONNX 模型)
├── model.onnx.data # 权重数据
├── categories.json # 17 类定义
├── tokenizer.json # 分词器
└── tokenizer_config.json # 分词器配置