
我最早对离线语音助手的执念源于一次在无网环境下的尴尬——对着智能音箱喊了半天它只回我一句“网络连接失败”。那一刻我意识到如果语音助手的核心能力全部依赖云端它在断网场景下就是个摆设。于是有了这篇文章的主角基于ESP32-S3从零打造的离线语音助手支持自定义唤醒词并且把大模型推理拉到本地局域网内完成全程不依赖公网。这篇实战指南不是教科书是我自己从选型、训练、部署到调优一路踩过来的记录。我会把microwakeword训练自定义唤醒词的完整流程、ESP32-S3上的TFLite Micro部署细节、以及本地大模型推理的两条路线MCU直推和网关推理都拆开讲清楚。适合三类人看一是想用ESP32-S3做语音交互产品的嵌入式开发者二是想把大模型私有化、但又不想总开着电脑跑服务的AI应用爱好者三是对数据隐私敏感、追求断网可用的折腾派玩家。1. 为什么要自己动手做离线语音助手ESP32-S3的能力边界与选型逻辑1.1 离线语音助手的真实需求做离线语音助手这件事需求其实很具体。你自己想想语音助手最常见的三个痛点是什么隐私、延迟、断网不可用。云端的语音助手麦克风录到的声音要上传到服务器识别结果再返回。就算厂家说“数据加密”数据经过第三方服务器始终是个心理疙瘩。延迟方面一次云端识别走一圈快则几百毫秒慢则一两秒对话节奏很受影响。最尴尬的是断网——家里宽带一断智能音箱直接变砖。离线语音助手把唤醒、识别、理解这几段全部搬到本地设备好处是显而易见的数据不出局域网响应速度取决于本地算力而非网络状况断网也能用。而ESP32-S3就是目前做这件事性价比极高的一块芯片。1.2 ESP32-S3的硬件参数与语音场景适配我一开始也想过用树莓派或者旧手机当语音助手的主控但对比一圈下来ESP32-S3的优势在于成本和功耗的平衡点选得非常好。ES32-S3的关键参数我列一下项目参数对语音助手的意义核心双核Xtensa LX7240MHz可双核并行一个核跑音频采集一个核跑推理SRAM512KB唤醒词级别的模型完全够放PSRAM最高16MBOctal跑更大一点的命令识别模型暂存音频缓冲向量指令支持PIE扩展指令对矩阵乘法和卷积有硬件加速TFLite Micro推理有加成音频接口I2S ×2可直接接MEMS麦克风INMP441这类芯片即插即用无线WiFi BLE 5唤醒后通过WiFi把请求发给本地网关做LLM推理功耗平均80~200mA射频/推理峰值更高比树莓派动辄0.5A强一个量级可电池供电关键在于ESP32-S3不是靠堆算力取胜而是靠“够用就好”的精准定位。它跑不了大语言模型但跑一个几十KB到几百KB的神经网络唤醒词检测器绰绰有余它放不下Llama但它能把音频处理好把唤醒做利索然后把理解任务交付给局域网里性能更强的设备。1.3 为什么选择“MCU唤醒网关推理”而不是单设备全包如果追求极致的“本地”为什么不直接用一台带GPU的电脑做全部事情因为成本、功耗、形态完全不同。一台PC全年开机做语音助手电费和噪音先不说它需要固定放置。而ESP32-S3做成一个小板子塞在客厅角落、床头柜几乎无感。整套系统拆成两半前端ESP32-S3负责拾音、唤醒词检测、命令词理解做到毫瓦级待机功率。后端局域网内的PC/树莓派/NAS负责真正的大模型推理通过Ollama这类工具提供HTTP API。这两半通过WiFi通信数据全程不走公网。用户直观体验就是喊一嗓子设备就响应和云方案几乎一样快但隐私和可用性都稳了。这套架构是经过实际验证比较顺的组合。1.4 整体方案的链路设计我最终跑通的完整链路是麦克风采集(INMP441/ESP32-S3 I2S) → VAD检测到人声 → 唤醒词检测(microwakeword训练的自定义词) → 本地意图识别(可选跑在ESP32-S3上的超轻量分类模型) → 通过WiFi调用局域网内Ollama API → 大模型返回文本结果 → 用预置语音片段或TTS合成结果播放整个过程没有任何环节依赖外网。下面每个环节的细节我逐一展开先从最核心的自定义唤醒词说起。2. 自定义唤醒词从0到1microwakeword数据集制作与训练全流程2.1 为什么选microwakeword而不是直接用ESP-SR自带唤醒词乐鑫官方有ESP-SR框架内置了唤醒词和语音识别方案。但这里有个现实问题ESP-SR自带的唤醒词是固定的比如“Hi 乐鑫”、“小智小智”这类中文自定义唤醒词需要向乐鑫申请模型训练服务流程走下来挺麻烦。关键是你想让设备响应“你好小助手”“嘿管家”这种自己定义的词用现成方案基本没戏。microwakeword这个开源项目的思路就不一样。它本质上是一个面向MCU的唤醒词训练工具链你只需要准备目标唤醒词的音频样本就能在本地训练出自己的模型然后导出成TFLite格式部署到ESP32-S3上。模型结构以DNN/CNN为主经过int8量化后体积通常在几十到两百KB之间非常适合ESP32-S3的资源。我最初也担心自定义唤醒词的效果是不是不如人家工厂级训练的好。实际验证下来只要数据集做得合格自定义唤醒词在安静环境下唤醒率能做到95%以上误唤醒率也能控制在可接受水平。对于个人项目和中小产品来说完全够用。2.2 搭建训练环境的完整命令microwakeword依赖的是Python TensorFlow官方支持Linux环境。我用的是Ubuntu 22.04Python 3.10具体安装步骤# 克隆项目 git clone https://github.com/josesamuel/microwakeword.git cd microwakeword # 建议创建虚拟环境避免污染系统Python python3 -m venv venv source venv/bin/activate # 安装依赖 pip install numpy soundfile python_speech_features tensorflow # 注意训练用CPU版本即可这个项目模型不大GPU并非必须项目里的核心目录结构大概是这样的train.py负责训练主流程utils.py里有特征提取和数据加载的工具models/里放着模型定义。我用的分支是v0.2及以上版本对ESP32-S3的适配相对成熟。2.3 数据集制作唤醒词唤醒率的命门做唤醒词训练数据集质量决定最终效果。这一步不能偷懒我一开始只用自己录的100条样本训练结果在嘈杂环境中误唤醒率惨不忍睹后来重新采集扩充数据效果才上来。数据集怎么准备正面样本目标唤醒词需要覆盖以下维度发音人群至少3~5人男女都要有尽量有不同口音。我找了5个人每人录2~3遍共约500条。距离变化距离麦克风0.3米、1米、2米各录一部分。远场样本特别重要否则实际使用时设备听得不清醒。环境差异安静房间、开着电视的房间、有空调底噪的房间各录一部分。音量变化正常音量、轻声说、大声喊都要覆盖。负面样本容易触发误报的音频同样关键日常对话片段、电视人声、音乐片段。与唤醒词发音相近的词比如我的唤醒词是“你好小机”那“你好小鸡”“你好小技”这些发音相似的全要收录。咳嗽声、关门声、键盘敲击声等环境音。单条样本长度控制在1秒左右。microwakeword默认特征提取是按16kHz采样率做的所以录制时最好统一规格。我用Audacity录制的直接设成16kHz、16bit、单声道WAV格式。数据增强是低成本提效果的手段。我的数据集里清洗干净后大概1300条又做了三倍的扩充# 在microwakeword的utils.py里有augment辅助函数部分版本有 # 常见操作加噪声、变调变速变音量如果没有内置增强也可以自己写简单脚本用减速5%~15%、加速5%、音量随机增益0.8~1.2倍、混入低音量背景噪声。这些增强方式对唤醒词识别鲁棒性的提升非常明显。2.4 训练参数与模型导出microwakeword的训练入口是train.py关键参数如下python train.py \ --wakeword 你好小机 \ --positive_dir ./data/positive \ --negative_dir ./data/negative \ --output ./models/my_wakeword \ --epochs 40 \ --batch_size 32 \ --learning_rate 0.001模型结构默认是几层DNN加一个分类头输出两类目标唤醒词和非唤醒词。训练过程中重点观察两个指标训练集和验证集准确率接近说明没有过拟合。如果验证集准确率在95%以下优先回头补数据而不是调模型。训练完成后导出TFLite模型这一步要做int8量化否则模型体积和推理速度都不理想# 量化脚本思路根据项目内export脚本封装 converter tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations [tf.lite.Optimize.DEFAULT] converter.representative_dataset generate_representative_dataset() converter.target_spec.supported_ops [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] tflite_model converter.convert()量化后模型落在大约60~150KB具体看网络结构。我最终导出的是90KB的int8模型在ESP32-S3上单次推理约80ms符合实时检测需求。2.5 测试集上的真实表现我在训练完成后单独留了200条样本做测试最终结果指标实测值说明唤醒率安静97%0.5米距离正常音量唤醒率噪声环境88%电视人声背景下1米距离误唤醒率2次/小时播放新闻联播/播客时的表现单次推理延迟80msint8量化模型ESP32-S3实测误唤醒率还差强人意但如果你的场景对误唤醒特别敏感比如办公室建议把负面样本里相近发音的词多加一些同时在部署时把触发阈值调高一点。这部分后文会专门说。3. 把唤醒词模型跑进ESP32-S3TFLite Micro部署与音频链路打通3.1 创建ESP-IDF工程与基础配置部署阶段我用的是ESP-IDF v5.x。麦克风方案我选了INMP441这颗I2S MEMS麦克风很成熟淘宝几块钱一片焊到面包板上就能跑数据是24bit内部用I2S协议和主控通信。创建工程idf.py create-project offline_voice_assistant cd offline_voice_assistant idf.py menuconfig需要在menuconfig里开的配置项Component config → ESP32S3-Specific → Support for external, SPI-connected RAM打开因为后面模型TFLite Micro的Tensor Arena要放在PSRAM。Component config → Partition Table选Single factory app (large)或者自定义一个factory分区。因为模型和代码会占不少空间。Component config → ESP System Settings → Memory protection保持默认即可如果遇到PSRAM的cache问题在调。3.2 I2S麦克风驱动与音频采集INMP441接线很简单VDD接3.3VGND接地SCK接ESP32-S3的I2S_SCK引脚SD接I2S_SD引脚WS接I2S_WS引脚L/R接地表示使用左声道。I2S初始化关键代码#include driver/i2s_std.h #define I2S_NUM I2S_NUM_0 #define I2S_SCK GPIO_NUM_4 #define I2S_WS GPIO_NUM_5 #define I2S_SD GPIO_NUM_6 #define SAMPLE_RATE 16000 #define SAMPLE_BITS 16 #define DMA_BUF_LEN 512 #define DMA_BUF_COUNT 4 i2s_std_config_t i2s_config { .clk_cfg { .sample_rate_hz SAMPLE_RATE, .clk_src I2S_CLK_SRC_DEFAULT, }, .slot_cfg { .data_bit_width I2S_DATA_BIT_WIDTH_16BIT, .slot_bit_width I2S_SLOT_BIT_WIDTH_16BIT, .slot_mode I2S_SLOT_MODE_MONO, .slot_mask I2S_STD_SLOT_LEFT, }, .gpio_cfg { .mclk I2S_GPIO_UNUSED, .bclk I2S_SCK, .ws I2S_WS, .dout I2S_SD, .din I2S_GPIO_UNUSED, }, }; i2s_new_std_rx(i2s_config, rx_handle); i2s_channel_enable(rx_handle);注意INMP441输出的是24bit数据但采样率16kHz时用16bit截断足够唤醒词特征使用还能省一半DMA带宽。我这里是直接配置成16bit接收实测没问题。3.3 音频滑动窗口与VAD策略唤醒词检测不能等整句话说完才开始我采用的办法是滑动窗口窗口长度1秒每次前进160ms重叠率84%。每帧音频经过预处理后送入TFLite Micro模型推理。连续2帧检测到唤醒词才确认触发过滤偶发误判。VAD语音活动检测也很关键否则设备在静音时会不断跑推理白白耗电。我直接在ESP32-S3上做了一个简单的能量检测如果滑动窗口内的RMS能量低于阈值比如300/32768直接跳过推理。这个策略很朴实但在室内场景足够有效静音时几乎零推理开销。3.4 TFLite Micro集成与推理核心TFLite Micro的集成方式我选择了直接使用tflite-micro的ESP-IDF组件版本。在工程根目录idf.py add-dependency espressif/esp-tflite-micro模型文件放在工程main/目录下通过EMBED_FILES引入构建系统运行时直接从flash读取不占SRAM# main/CMakeLists.txt idf_component_register( SRCS main.c audio.c wakeword.c INCLUDE_DIRS . EMBED_FILES models/my_wakeword.tflite )推理代码核心流程#include tensorflow/lite/micro/micro_interpreter.h #include tensorflow/lite/micro/micro_mutable_op_resolver.h // 模型二进制 extern const unsigned char my_wakeword_tflite[] asm(_binary_my_wakeword_tflite_start); extern const unsigned char my_wakeword_tflite_end[] asm(_binary_my_wakeword_tflite_end); static tflite::MicroInterpreter* interpreter nullptr; static uint8_t* input_buffer nullptr; static uint8_t* output_buffer nullptr; // Tensor Arena放在PSRAM实测128KB足够这个模型 static uint8_t tensor_arena[128 * 1024] __attribute__((section(.ext_ram.bss))); void wakeword_init(void) { const tflite::Model* model tflite::GetModel(my_wakeword_tflite); static tflite::MicroMutableOpResolver10 resolver; resolver.AddFullyConnected(); resolver.AddSoftmax(); resolver.AddReshape(); resolver.AddQuantize(); resolver.AddDequantize(); static tflite::MicroInterpreter static_interpreter(model, resolver, tensor_arena, sizeof(tensor_arena)); interpreter static_interpreter; interpreter-AllocateTensors(); input_buffer interpreter-input(0)-data.uint8; output_buffer interpreter-output(0)-data.uint8; }推理前的特征提取MFCC步骤我是按照microwakeword训练时用的参数在C代码里复刻的默认帧长25ms帧移10msMFCC维度取13阶部分做了一阶差分。ESP32-S3上计算约耗时10~20ms在可接受范围内。3.5 唤醒触发后的响应逻辑唤醒成功后ESP32-S3进入命令收听状态。命令识别在这里有两种做法把“开灯”“关灯”“查天气”等固定命令做成另一个分类模型在ESP32-S3本地跑适合简单控制场景。直接把1~2秒音频传给局域网网关在网关上跑Whisper或更轻量的STT模型做识别再交给LLM理解。我做的版本是两者结合本地命令分类模型覆盖高频固定指令低频自由对话则走网关。这样唤醒词和常用指令零延迟响应只有复杂查询才需要大模型介入体验和云方案已经很接近。4. 本地大模型该跑在哪ESP32-S3直推与局域网网关推理两条路线4.1 ESP32-S3上能直接跑多大的“模型”很多新手会天真地问“ESP32-S3能不能跑DeepSeek”。答案是物理上不可能大语言模型参数动辄数亿即使极致量化也需要至少几百MB内存且推理速度无法接受。ESP32-S3的512KB SRAM连一个完整的1B模型参数都放不下16MB PSARM虽然容量够了但带宽和算力撑不住逐Token生成。那“本地大模型推理”在ESP32-S3上到底能做到什么程度可以跑一到几十MB的超轻量神经网络模型比如命令词分类模型几KB到几百KB开灯/关灯/播放音乐/暂停。意图识别槽位提取模型几百KB识别“把客厅灯调到最亮”的目标和动作。小型语音分类模型几十KB识别音乐声、婴儿哭声等特殊音频事件。这些本质上都是“小而专”的模型不是通用对话模型。我不建议硬塞通用LLM到ESP32-S3上工程风险大且体验糟糕。4.2 更务实的路线局域网网关Ollama把大模型放到局域网内的PC、树莓派或NAS上ESP32-S3通过WiFi调用这是目前体验和隐私兼顾的最优解。网关侧我用的是Ollama因为它部署简单API接口干净模型管理方便。步骤# 在网关设备上安装OllamaLinux示例 curl -fsSL https://ollama.com/install.sh | sh # 拉取适合CPU/轻量GPU推理的小模型 ollama pull qwen2.5:1.5b # 或者更轻量的 ollama pull tinyllama:1.1b # 如果想要思维链能力 ollama pull deepseek-r1:1.5bOllama默认监听网络的方式是绑定127.0.0.1要让局域网内的ESP32-S3访问需要配置环境变量再重启服务# Systemd服务的话编辑 /etc/systemd/system/ollama.service # 或者在启动时设置 OLLAMA_HOST0.0.0.0:11434 ollama serve注意局域网里做推理服务最好在路由器里把这个IP锁定或者设置防火墙只允许特定设备访问。我个人建议不要直接暴露到公网哪怕你觉得自己家网安全。局域网内加密需求不强但访问控制还是要的。4.3 模型选型与量化策略网关跑大模型不是模型越大越好。我在PC上做了对比模型量化等级模型大小CPU推理速度8线程体验评价tinyllama:1.1bQ4_K_M约0.8GB约35 token/s快但逻辑有限qwen2.5:1.5bQ4_K_M约1.1GB约22 token/s中文好逻辑尚可deepseek-r1:1.5bQ4_K_M约1.1GB约18 token/s有推理链但输出慢qwen2.5:7bQ4_K_M约4.7GB约6 token/s要占用大量内存CPU慢对语音助手场景我实测下来qwen2.5:1.5b是甜点选项中文能力强、响应速度合话、对CPU要求不高。7b模型虽然理解力更强但响应延迟拉长到3~5秒语音交互会显得“迟钝”。如果网关是树莓派5这类ARM设备建议选Qwen2.5-0.5B或TinyLlama量化用Q4_K_M即可。树莓派跑1.5b会偏慢实测约8~10 token/s勉强可用。4.4 ESP32-S3侧调用Ollama API的实现ESP32-S3侧通过HTTP请求调用Ollama的生成接口// 使用esp_http_client esp_http_client_config_t config { .host 192.168.1.100, // 网关局域网IP .port 11434, .path /api/generate, .method HTTP_METHOD_POST, }; esp_http_client_handle_t client esp_http_client_init(config); char post_data[512]; snprintf(post_data, sizeof(post_data), {\model\:\qwen2.5:1.5b\,\prompt\:\%s\,\stream\:false}, user_text); esp_http_client_set_post_field(client, post_data, strlen(post_data)); esp_http_client_perform(client);注意几个工程细节超时控制大模型生成可能要几秒HTTP客户端要设置足够的读取超时我设的是10秒否则读一半就EOF。内存响应JSON可能比较大不能全量缓存在ESP32-S3里。我用的是数据回调函数逐块解析只要response字段里的文本边收边拷到外部PSRAM的缓冲区。断线降级如果网关没在线HTTP请求会失败。这时候ESP32-S3应该播放一段“网络未连接”的本地提示音而不是傻等。这个方案跑通之后体验和云语音助手已经非常接近了更重要的是所有请求都停留在家庭局域网内隐私边界清晰可控。5. 全链路调通的实测数据与四大典型坑5.1 实测性能数据我最终跑通的版本核心性能数据如下ESP32-S3 240MHzINMP441麦克风网关为八线程PC跑qwen2.5:1.5b指标实测值静音待机功耗约65mA唤醒词检测占用RAM约170KB含Tensor Arena 128KB唤醒词检测单次推理耗时80ms语音活动检测到唤醒确认耗时约450ms本地命令分类响应延迟约350ms局域网Ollama端到端响应2.5~4.5秒取决于问题长度断网/网关离线降级提示约200ms触发本地提示音对离线语音助手来说这个表现已经算舒服。唤醒后350ms内能响应本地命令复杂问题等几秒也符合语音交互的心里预期。5.2 坑一PSRAM带宽导致推理变慢第一版我把Tensor Arena放进外部PSRAM结果唤醒词推理一次要跑到350ms比SRAM慢了四倍多。原因不难猜到PSRAM带宽远低于片内SRAM且PSRAM是挂在cache上的如果访问不连续性能损耗更明显。解决思路是分层放置内存模型参数权重放在Flash只读段推理时通过cache读取不需要占用SRAM。Tensor Arena优先放内部SRAM我的模型配128KB Arena可以在内部SRAM放下。只有音频环形缓冲这种大块但低实时要求的数据放PSRAM。调整后推理时间从350ms降回80ms。如果模型确实大到放不进SRAM也要尽量让热点数据输入输出张量留在SRAM把非热点放PSRAM。5.3 坑二TFLite Micro算子不兼容我第一次训练唤醒词模型时用了带LSTM的RNN结构结果在TFLite Micro上跑了半天跑不通。MicroMutableOpResolver支持的算子有限LSTM虽然在TFLite里有算子支持但在Micro版本的算子集里往往缺失或不稳定。排查方法很简单把模型结构限制在全连接层FullyConnected、卷积Conv2D、池化MaxPool2D、Softmax这些基础算子上。如果一定要用序列建模用简单的1D卷积叠加感受野或者用两帧拼接代替时序建模。我把模型结构换成两层卷积两层全连接后完美兼容效果也没有明显下降。结论是MCU上跑模型结构越朴素越踏实不要追求花哨。5.4 坑三麦克风底噪与供电干扰导致误唤醒率飙升有一段时间怎么调阈值都压不住误唤醒后来发现是电源问题。ESP32-S3在WiFi发射时射频频段会有瞬间大电流如果和模拟麦克风共用一条LDO线路电源噪声会直接串进I2S采样数据里。排查和修复过程供电路径USB 5V → 3.3V LDOAMS1117→ ESP32-S3开发板与INMP441。问题表现WiFi发包瞬间I2S采样值出现周期性尖峰唤醒模型把这些尖峰当成语音片段。修复方案给麦克风独立加一颗低噪声LDORT9013并在INMP441的VDD引脚就近加一个10uF0.1uF退耦电容。改动后误唤醒率从每小时十几次降到每小时两次以内。这个坑提醒我在音频项目里模拟链路的供电干净程度往往比算法本身更影响最终体验。5.5 坑四阈值不是越高越安全唤醒词检测模型的输出是一个概率值microwakeword会有一个默认触发阈值通常设在0.5。部署时如果觉得误唤醒多直觉是往高了调。我试过调到0.9误唤醒确实少了但唤醒率直线掉到70%屋里喊破嗓子都叫不醒。这里的关键在于概率输出是连续值阈值影响的是查准率和查全率的平衡点不存在一个万能最优值。我最后用的是“双阈值时间过滤”策略触发阈值0.6低于这个值直接忽略。连续2个滑动窗口都超过阈值才触发规避单帧偶发高分。如果超过0.8则单帧即可触发应对用户喊得很急的场景。效果是在不牺牲唤醒率的前提下把误唤醒降到了可以接受的水平。你也可以根据自己的使用环境调整这两个值不必照搬。最后分享一个小技巧自定义唤醒词的数据集不用侥一次就定型。跑一段时间后把你实际使用中误唤醒的音频片段收集起来ESP32-S3可以回传最近10秒音频到网关做记录定期补充进负面样本重新训练。我第二轮训练加完这些“真实场景毒样本”后误唤醒率又降了一半这个习惯建议保留。