
1. 为什么训练好的模型不能直接搬上开发板我最早做自定义唤醒词的时候以为最难的部分是把模型精度堆上去。等真把模型往 ESP32-S3 上搬才发现训练只是整个项目三分之一的工作量。真正耗时间的是 ONNX、INT8、TFLite 这一串格式转换背后的细节——任何一个环节的疏漏都会让板子上的模型变成薛定谔的唤醒词要么死活不触发要么半夜对着电视乱喊。先说清楚这三种格式各自解决什么问题你才能理解这条链路为什么非走不可。ONNX 是训练生态里的通用交换格式它只负责把计算图描述清楚跟 PyTorch、TensorFlow 都解耦但它没有针对嵌入式硬件做任何优化一张图里全是 FP32 浮点算子和冗余节点。TFLite 是面向移动端和嵌入式端的推理格式它会做算子融合比如把 Conv Bias ReLU 合成一个算子、权重重排、内存规划是真正可以塞进 MCU 的格式。INT8 则是把权重和激活从 FP32 压成 8 位定点数模型体积直接缩到四分之一内存占用和推理耗电都大幅下降。那为什么非要用 INT8ESP32-S3 的 LX7 双核虽然带单精度 FPU但跑 FP32 卷积依旧不划算——内存带宽是瓶颈。芯片片上只有 512KB SRAM模型一旦超过这个量就得放外部 PSRAM而 PSRAM 的访问延迟比片上 RAM 高不少。改成 INT8 之后权重能塞进片上 RAM再配合 ESP-DL 对 INT8 的 SIMD 优化ESP32-S3 的 PIE 指令扩展单次推理耗时和功耗都能降一个数量级。说白了INT8 不只是模型小一点而是决定了你的设备能不能做到电池供电、常年待机。所以完整链路是PyTorch 训练 FP32 模型 → 导出 ONNX → 结构转成 TensorFlow 生态 → 带校准数据做 INT8 静态量化 → 固件集成 → 在 ESP32-S3 上跑起来。这篇文章按这个顺序把每一步的取舍和坑都讲清楚适合正在做语音唤醒、离线命令词识别的人参考也适合任何想把自定义模型塞进 ESP32 系芯片的开发者——毕竟换汤不换药模型结构换一换链路是一样的。2. 训练阶段的三个决定特征、结构、量化友好性很多人以为格式转换是部署阶段的事训练阶段可以完全不考虑。我踩过一次之后就明白了训练阶段做的几个决定直接决定了你后面是顺风顺水还是反复返工。2.1 输入特征训评一致的 MFCC/log-mel 配置唤醒词检测本质上是小词汇量的关键词分类输入特征几乎都用的是 MFCC 或 log-mel 谱。我用的配置是采样率 16kHz帧长 30ms帧移 20ms每次堆叠 49 帧40 维 log-mel 特征最终模型输入是(1, 49, 40)。49 帧对应大约 1 秒的音频上下文既能覆盖两三个音节的唤醒词又不会因为窗口太长导致响应迟钝。这里有一个非常重要的原则训练时用的特征提取代码将来是要 1:1 搬到 C 语言里去的。所以建议从一开始就别用 librosa 这种全家桶依赖而是自己把预处理写成独立函数然后把 mel 滤波器组系数和 DCT 矩阵导出成数组。等到部署的时候C 端直接用同一组系数从源头杜绝训练一套、部署一套的精度崩塌。我见过太多项目训练时用 librosa 的默认参数板子上换了个库滤波器组对不上模型精度直接没法看。2.2 网络结构算好权重、峰值内存、推理耗时三笔账结构方面推荐 DS-CNN深度可分离卷积或者 TC-ResNet 这种为端侧设计的小网络别一上来就上 Transformer。重点是算三笔账权重体积目标 INT8 量化后权重控制在 300KB 以内这样才有机会全部塞进片上 SRAM。激活峰值内存中间特征图的最大值决定了推理时需要的临时 buffer设计网络时最好让特征图尺寸平滑递减别出现一个巨大的中间层把 RAM 撑爆。单次推理耗时在 240MHz 下一个权重约 150KB 的 DS-CNN 单次推理实测大约 40 到 60ms如果超过 100ms你就要认真考虑是不是结构选大了。我用的参考结构大约是十来层卷积参数量在 10 万到 30 万之间量化后权重 100 到 300KB。这个量级的模型在 ESP32-S3 上属于吃得下、跑得动的甜点区。2.3 量化感知训练小模型不要把希望全押在后量化上对唤醒词这种小模型我个人建议的路径是先用后训练量化PTQ快速摸底掉点严重再上量化感知训练QAT。PTQ 的原理是用一小批校准数据统计激活值的分布然后确定量化 scale 和 zero_point。听起来很简单但对参数本来就少的网络某一层激活分布一旦有少量离群值量化误差就会被放大模型可能从偶尔误触发退化成完全哑掉。QAT 则在训练过程中插入伪量化节点让网络自己适应 8bit 的精度损失量化后的精度通常比 PTQ 高不少。PyTorch 里可以用torch.ao.quantization的 QAT 流程来做导出时这些伪量化节点会以 QDQ 的形式跟着计算图走后续转 TFLite 时量化参数能直接对上。另外训练时的数据增强对量化也有肉眼可见的帮助。加点背景噪声、混响、不同说话人的样本让激活分布更接近真实场景量化的校准结果也会更稳。3. 导出 ONNX 并为 TFLite 提前清路PyTorch 导出 ONNX 看起来就是一行torch.onnx.export实际上坑不少。这一节我把自己的标准流程和判断逻辑写出来。3.1 固定输入形状与 opset 版本的选择上板子的模型输入形状必须是固定的。别用 dynamic_axes动态 shape 在 PC 上跑没问题但 TFLite 和 ESP-DL 对动态输入的兼容性很差转换时可能直接报错就算转换成功也会多出一堆动态 shape 处理逻辑性能受影响。opset 版本我习惯用 13。太低的版本有些算子不支持太高的版本导出的图有时包含一些新算子TFLite 那边还没跟上。13 是一个两边都兼容得比较好的版本。import torch model.eval().cpu() x torch.randn(1, 1, 49, 40) # batch, channel, frame, mel_bins torch.onnx.export( model, x, kws.onnx, input_names[input], output_names[logits], opset_version13, dynamic_axesNone, )注意导出前一定要model.eval()否则 BatchNorm 的行为会不一样导出的图都带着训练期的统计逻辑部署时必出问题。3.2 导出、化简、输出对比三连验证导出之后别急着往下一步走先做三件事。第一件是用 onnxruntime 验证输出和 PyTorch 是否一致pip install onnxruntime onnx onnxsimimport numpy as np import onnxruntime as ort sess ort.InferenceSession(kws.onnx) ort_out sess.run(None, {input: x.numpy()})[0] pt_out model(x).detach().numpy() print(np.max(np.abs(pt_out - ort_out)))输出差异应该在 1e-4 量级如果差到 1e-2 以上说明图里有什么东西没导对先排查再往下走。第二件是用 onnxsim 化简计算图。PyTorch 导出的图里经常有一堆恒等算子、冗余的 Reshape、Transposeonnxsim 能把这些清掉后续转 TFLite 的时候少很多麻烦python -m onnxsim kws.onnx kws_sim.onnx第三件是打开 Netron 看一眼结构重点确认有没有奇怪的循环节点、有没有把整个大数组当作常量写进图里。3.3 提前规避 TFLite 不擅长处理的算子在导出阶段你就要站在 TFLite 的角度审视网络结构。最典型的坑是 LSTM/GRUPyTorch 导出 LSTM 会产生一堆 Loop 和序列相关算子TFLite 虽然有一部分序列算子支持但 TFLite Micro 和 ESP-DL 上兼容性参差不齐很容易卡在转换环节。唤醒词模型真没必要用 RNN因果卷积或者带有限上下文的卷积结构完全可以替代还更省内存。另外torch.where、高级索引、动态 shape 的算子能不用就不用。这些算子 PC 上跑着没感觉一旦经过 ONNX → TFLite 链路要么转换失败要么在板子上生成一堆低效代码。你可以在导出后用 onnxruntime 的 quantization 工具扫描一遍模型里的算子把 TFLite 不支持的那几个提前解决掉。4. INT8 TFLite 转换实操校准数据集是隐藏主角这一步是整条链路的命门。很多人以为转换就是一个命令的事实际上一大半的精度问题都出在校准数据集和量化参数的细节上。4.1 onnx2tf把计算图翻译成 TensorFlow 生态ONNX 没法直接交给 TFLite Converter中间需要先把图翻译成 TensorFlow 的 SavedModel 格式。我用的是 PINTO 的 onnx2tf它对各种边缘算子的支持比其他工具全而且在很多嵌入式场景里是被反复验证过的。onnx2tf -i kws_sim.onnx -o tfmodel执行完之后输出目录里会有一个 SavedModel 目录同时也会顺带生成一个 float32 的 TFLite 文件。这个 float32 的 TFLite 可以用来做链路自检但我们要的 INT8 模型还得靠 TFLite Converter 自己控制量化细节。onnx2tf 也有直接出量化模型的参数但不同版本参数名变化比较大我更推荐走 TFLite Converter 这条路可控性强校准数据怎么喂、哪些算子走 INT8一目了然。4.2 TFLiteConverter 全整数量化校准数据怎么给TFLite 的全整数量化需要你提供一个 representative_dataset也就是一小批能代表真实输入分布的样本。TFLite Converter 会在这批样本上跑一遍浮点模型统计每一层激活值的 min/max然后算出量化参数。import numpy as np import tensorflow as tf # mfcc_data 的形状为 (N, 49, 40)float32来自你的特征提取管线 def representative_dataset(): for i in range(200): yield [mfcc_data[i:i 1].astype(np.float32)] converter tf.lite.TFLiteConverter.from_saved_model(tfmodel) converter.optimizations [tf.lite.Optimize.DEFAULT] converter.representative_dataset representative_dataset converter.target_spec.supported_ops [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.inference_input_type tf.int8 converter.inference_output_type tf.int8 tflite_model converter.convert() with open(kws_int8.tflite, wb) as f: f.write(tflite_model)注意supported_ops只留TFLITE_BUILTINS_INT8这是为了让所有算子都强制走 INT8。如果模型里有某些 TFLite 量化不支持的算子转换时你会得到警告或错误这时候宁可回头改模型也别让那些算子偷偷以 float 形式留在图里——否则上板后性能和精度都会出问题。校准数据直接决定量化质量我用的经验值是最少 200 条特征序列。校准数据要覆盖不同的说话人、不同的距离、不同的噪声环境甚至故意混入电视声、空调声这种真实家里会出现的背景音。前面章节说特征前端要 1:1 保持一致在这里同样适用喂给 representative_dataset 的必须是和模型训练时完全相同的特征提取结果而不是随便拿几段音频改改就能用的。4.3 量化前后精度与体积对比量化的好坏不能靠感觉我每次都会做一个量化前后对比测试在同一个测试集上跑 FP32 模型和 INT8 模型记录准确率、误唤醒率、模型体积。指标FP32 ONNXINT8 TFLite模型体积约 1.1MB约 160KB测试集准确率97.3%96.2%误唤醒率0.4 次/小时0.8 次/小时单次推理耗时(PC)2.3ms1.1ms如果 INT8 掉点在 1% 到 2% 以内基本可以接受如果准确率掉了 5 个点以上别继续往部署走了回头检查校准数据集、检查是否有算子没量化到位或者干脆上 QAT 重新训一版。4.4 转换后模型的检查清单拿到 kws_int8.tflite 之后用 Python 的 TFLite Interpreter 把输入输出信息打出来确认一遍import tensorflow as tf interpreter tf.lite.Interpreter(model_pathkws_int8.tflite) interpreter.allocate_tensors() inp interpreter.get_input_details()[0] out interpreter.get_output_details()[0] print(inp[dtype], inp[shape], inp[quantization]) print(out[dtype], out[shape], out[quantization])我要看到的是输入 dtype 是int8shape 和预期一致注意 TFLite 内部默认 NHWC输入可能变成(1, 49, 40, 1)量化参数里 scale 和 zero_point 不是 0。这一步确认好后面板子上喂数据才知道怎么对齐。5. ESP32-S3 部署与实测从 tflite 文件到听见就醒模型文件搞定了接下来是板子端。这部分涉及两条路线选择、音频前端对齐、任务调度和低功耗设计我逐个讲。5.1 部署路线选择ESP-DL 转换还是 TFLite Micro拿到 INT8 TFLite 之后上板有两条路。第一条是走乐鑫官方的 ESP-DL。ESP-DL 是针对 ESP32-S3 深度优化的推理库支持 PIE SIMD 指令对 INT8 模型的加速效果非常明显。它的仓库里有一个 model converter 工具可以直接吃 TFLite也支持 ONNX输出 esdl 格式的模型然后通过 ESP-DL 的 API 加载推理。这条路性能最好适合要做产品化的场景但你需要花时间学习 ESP-DL 的模型转换和 API 用法。第二条是直接用 TFLite Micro。tflite-micro 官方仓库对 ESP32-S3 有支持你只要把 C 源码编译进 ESP-IDF 或 Arduino 工程就能直接加载 kws_int8.tflite 来做推理。这条路胜在方便适合快速验证模型在板子上能不能跑、精度有没有问题缺点是对算子的支持比 ESP-DL 少性能也不如 ESP-DL。我的建议是先用 TFLite Micro 在板子上把模型跑通、把精度验证完确定没问题了再花时间迁移到 ESP-DL 追求性能。如果你用的是 ESP32-S3 N16R8 这种带 8MB PSRAM 的模块TFLite Micro 的 buffer 可以放到 PSRAM但要注意放 PSRAM 后延迟会上去性能敏感的操作尽量留在片上 RAM。5.2 音频采集与特征前端 1:1 对齐板子端音频采集用 I2S PDM 麦克风ESP-IDF 的新版驱动直接支持 PDM RX 模式。以下是一个典型的配置片段#include driver/i2s_pdm.h i2s_chan_config_t chan_cfg { .id I2S_NUM_0, .role I2S_ROLE_MASTER, }; i2s_channel_handle_t rx_chan NULL; i2s_new_channel(chan_cfg, rx_chan, NULL); i2s_pdm_rx_config_t pdm_cfg { .clk_cfg { .sample_rate_hz 16000, .clk_src I2S_CLK_SRC_DEFAULT, }, .slot_cfg { .slot_mode I2S_SLOT_MODE_MONO, .data_bit_width I2S_DATA_BIT_WIDTH_16BIT, }, }; i2s_channel_init_pdm_rx_mode(rx_chan, pdm_cfg); i2s_channel_enable(rx_chan);采集到的数据是 16kHz、16bit 的 PCM接着要做和训练时一模一样的预处理预加重、分帧、加窗、FFT、mel 滤波、log、DCT。C 端的 FFT 可以用 esp-dsp 库mel 滤波器组矩阵和 DCT 矩阵直接从 Python 端导出成 C 数组这样能保证两边用的系数完全一致。这一步我强烈建议做一个对照测试把同一段 PCM 音频分别喂给 Python 预处理代码和 C 端预处理代码对比输出的 MFCC 数值误差。如果误差超过 1e-3 级别一定要查清楚是哪里不一致否则后面所有精度问题都会变得无法定位。5.3 双核调度、VAD 与低功耗设计唤醒词检测是个持续运行的任务不能傻乎乎地在主循环里跑推理。我的做法是分成两个任务核心 0跑 I2S 采集和 MFCC 特征提取把特征帧写入一个环形缓冲区。核心 1负责推理每次从缓冲区取最新的 49 帧特征执行 INT8 推理对输出做平滑判断。唤醒词检测的特殊之处在于你不能只在喊完唤醒词之后才开始识别必须持续开着音频流所以批次式处理和流式处理要区分清楚。流式处理需要一个滑动窗口每来一帧新特征就把旧的一帧踢掉保持窗口内始终是最近的 49 帧。如果每一帧都推理20ms 一帧、推理 40ms算力肯定跟不上。我的方案是加一道简单的 VAD语音活动检测计算当前帧的能量和过零率只有超过门限才触发推理否则让核心 1 进入空闲等待。这样一来安静环境下 CPU 占用率极低也省了很多功耗。推理结果再做平滑处理比如连续 3 帧都判定为唤醒词才确认触发能显著减少偶发误唤醒。低功耗方面空闲时可以让系统进入 light sleep用定时器周期性唤醒做能量检测检测到疑似语音再进入全速推理模式。唤醒词设备一般要求长期待机这个设计是逃不掉的。5.4 实测性能数据我用一个约 160KB 权重的 DS-CNN 唤醒词模型实测数据如下项目数值模型权重约 160KB输入特征1 × 49 × 40INT8单次推理耗时约 45ms 240MHz峰值 RAM约 230KB含音频缓冲和推理 arena从发声到触发约 600ms含窗口积累与平滑确认误唤醒率约 0.6 次/小时普通家庭环境600ms 的响应延迟对于唤醒词来说是可以接受的毕竟用户喊完到设备响应有 1 秒左右的心理预期。如果你想进一步压缩延迟可以减小窗口帧数或者缩短平滑确认的帧数但要接受误唤醒率上升的代价。6. 部署路上踩过的坑按排查顺序整理最后分享几个我实际踩过、并且查了很久才定位的坑。这些东西在官方文档里基本不会写但对做完整链路的人来说都是真金白银。6.1 坑位一一个看似无害的 Transpose 让推理慢了 3 倍模型转成 TFLite 后在 PC 上跑得好好的上板之后每帧推理多了几十毫秒。查了很久发现网络第一层前面有个 TransposePyTorch 默认 NCHW而 TFLite 内部是 NHWC这个 Transpose 在 PC 上几乎不需要时间但在 MCU 上要把整张特征图在内存里搬一遍单次推理直接多了 20 多毫秒。解决方式是在设计网络时尽量让输入从一开始就是模型期望的排布推导出转换器会插入的转置位置能省的转置尽量省掉。遇到这种问题先用 Netron 看看 tflite 的图里有多少 Transpose 节点如果密集出现在关键路径上就该考虑调整网络结构或者输入的 memory format 了。6.2 坑位二校准集太干净真实场景误触发我第一版校准数据用的是自己录的安静环境语音量化后在测试集上精度挺好看结果拿到客厅实测电视一开就开始乱触发。原因是校准数据的激活分布太集中量化参数对真实环境里的宽动态范围根本不适应。解决办法是重新构造校准集加入不同信噪比的语音、不同距离的录音、电视声、空调声、厨房噪声让校准分布尽量覆盖真实场景。重新量化之后误唤醒率直接从几倍降到可接受范围。这个坑提醒我校准数据的重要性不亚于训练数据它本质上也是测试集的一部分。6.3 坑位三输入输出的 scale/zero_point 被忽略TFLite 全整数量化模型要求输入是 int8但 int8 不能直接塞原始特征必须先用模型的量化参数做一次变换q round(f / scale) zero_point我一开始图省事直接把计算好的 MFCC 特征强转成 int8 喂给模型结果输出完全不可用。后来把图里的输入 scale 和 zero_point 打印出来在 C 端做了一次真正的定点化才解决。这里的关键是一定要从 tflite 文件里读取量化参数而不是在 Python 训练端自己猜一组。6.4 坑位四偶发几十毫秒卡顿排查到内存分配设备跑起来之后偶发出现几十毫秒的卡顿现象是波形和推理时间都正常但整体响应时不时抖一下。最后定位到是推理过程中动态分配内存导致的碎片化——每次推理都申请一次 arena跑一段时间后内存碎片化分配耗时越来越不稳定。解决办法是启动时一次性静态分配好模型推理所需的 arena 和环形缓冲区推理过程不再做任何动态内存申请。改成静态分配之后卡顿彻底消失。对 MCU 上的实时音频任务来说动态内存分配能不用就不用这句话不是教条是血泪教训。如果让我重新做一次我会在训练阶段就把部署会发生什么前置到每一次结构选择里。特征前端统一、网络结构克制、量化感知设计、校准数据贴近真实这四件事做好ONNX 转 INT8 TFLite 这条链路就只是流程问题而不是玄学问题。