基于ESP32-S3与Ollama的离线语音助手构建全解析 先说结论这个项目做出来的不是一个玩具而是一个真正能脱离手机和公网、在家里的局域网里完成“语音问答”的完整设备。核心思路是ESP32-S3 负责最前端的音频采集、自定义唤醒词检测和语音指令识别真正需要“理解”和“生成回答”的本地大模型推理则交给局域网内的一台电脑或 NAS 上的 Ollama 服务来完成。整条链路不经过公网所以断网也能用隐私也留在家里。这个方案适合谁如果你玩过 ESP32对音频和嵌入式 AI 感兴趣又不想把语音数据送到云端想自己掌握整个语音助手的每一层那这篇文章就是给你准备的。我尽量把唤醒词训练、TFLM 推理、Ollama 对接这些环节的坑都写清楚照着做基本能跑通。1. 整体方案为什么用 ESP32-S3 做离线语音助手1.1 端侧能力边界与架构分工有朋友一看到“ESP32-S3 部署大模型”就以为是把 LLM 塞进单片机里跑这个必须先说清楚ESP32-S3 的 8MB PSRAM 和几百 MHz 的双核 CPU跑不了任何像样的 Transformer 大模型。真正可行的架构是“端侧做感知服务器做思考”。我常用的分工是这样的端侧ESP32-S3麦克风采集、自定义唤醒词检测、指令词识别比如“开灯”“查天气”“讲个笑话”、把文本指令通过 HTTP 发给局域网内的 Ollama 服务、播放返回的音频或执行本地动作。服务侧局域网主机/NAS部署 Ollama加载 0.5B ~ 1.5B 量级的中文小模型接收端侧传来的指令文本生成回答再把文本回传。这样既保住了“离线语音助手”的离线属性不依赖公网又把本地大模型推理放在了合适的位置上。ESP32-S3 的优势在于它自带向量指令加速跑 TFLM 唤醒词模型绰绰有余而且外设丰富——I2S 音频接口、WiFi、PSRAM 一应俱全是语音前端最合适的低成本芯片。1.2 为什么不用现成语音模块市面上有现成的离线语音识别模块比如一些出厂就训练好固定命令词的方案集成了 MCU 和麦克风直接串口发指令就能用。我一开始也试过但很快就发现瓶颈自定义唤醒词自由度太低要么厂商工具链闭源要么训练流程不透明而且很难和本地大模型串联。自己做的好处是每一层都可控唤醒词可以是任何两个到四个字的词今天叫“小智同学”明天可以换成“你好管家”随时训练。指令识别可以做成固定命令词也可以把任意文本指令转给大模型理解灵活度完全不同。成本极低一块带 PSRAM 的 ESP32-S3 开发板加一颗 I2S 麦克风总价几十块。自己做要付出的代价相对可控得会一点 ESP-IDF得接受唤醒模型精度要自己调得耐着性子看音频数据流。1.3 完整数据链路先看一张时序图为了让后文不迷路我用文字描述一下整个设备的运行流程你可以对照着看上电后ESP32-S3 初始化 I2S 麦克风开始以 16kHz/16bit 采样音频。音频数据进入 DMA 缓冲区唤醒词检测线程实时跑 TFLM 推理持续检测“自定义唤醒词”。没唤醒时所有音频只在本地做滑窗检测不做任何网络操作。检测到唤醒词后系统提示音响起录音线程开始缓存后续 1~2 秒的指令音频。指令音频经过命令识别模型得到文本指令如果识别为“需要思考”的开放问题就拼装 Prompt通过 HTTP POST 发给局域网内的 Ollama。Ollama 把生成文本返回ESP32-S3 播放预置的提示音或者把回答文本送到本地 TTS 链路完成一轮交互。这个流程里真正需要调优的主要有四块音频采集品质、唤醒模型准确率、指令识别鲁棒性、HTTP 大响应处理。下面逐个讲。2. 硬件选型与环境准备2.1 核心硬件与引脚接线我建议的入门配置如下整套买下来成本很低主控ESP32-S3-DevKitC-1带 8MB PSRAM 的版本注意别买成 2MB PSRAM 的阉割版麦克风INMP441I2S 数字输出单颗就能用比模拟麦简化很多功放/喇叭MAX98357A I2S 功放模块 3W 小喇叭用来播放提示音和 TTS 结果电源建议 5V/2A 的 USB 供电音频功放瞬时电流不小INMP441 和 MAX98357A 都是 I2S 接口可以同时挂到 ESP32-S3 的同一组 I2S 总线上只是它们一个做输入一个做输出。常用的引脚分配如下不同板子引脚有差异以你自己编译时为准信号GPIO说明I2S SCK (BCLK)GPIO 4时钟麦克风和功放共用I2S WS (LRCLK)GPIO 5声道选择共用INMP441 SDGPIO 6麦克风数据输入MAX98357A DINGPIO 7功放数据输出功放 SD_MODEGPIO 8功放关断/模式控制拉高开启接线这里有个容易踩的坑INMP441 的 L/R 引脚决定它输出在左声道还是右声道如果你只接一颗麦克风建议把 L/R 接地WS 为低时对应左声道。如果 L/R 悬空或者接反读出来的数据可能全是无规律的噪声。2.2 ESP-IDF 开发环境安装虽然 Arduino 也能做到部分功能但跑 TFLM、调 DMA 缓冲、看内存占用还是 ESP-IDF 更顺手。我用的是 v5.2 以上的版本安装过程不复杂mkdir -p ~/esp cd ~/esp git clone --recursive https://github.com/espressif/esp-idf.git cd esp-idf ./install.sh esp32s3 source ./export.sh如果你在 Windows 上官方提供了 ESP-IDF PowerShell 安装器装完从开始菜单打开 “ESP-IDF CMD” 窗口即可。我习惯用 VSCode Espressif 插件可以直接在 IDE 里烧录和看串口日志调试体验会好很多。这里有个细节安装时务必加上esp32s3目标否则后面idf.py set-target esp32s3会提示缺工具链。2.3 工程初始化与分区配置语音项目对 Flash 要求比较高唤醒词模型、命令词模型、音频提示文件都要占空间。我建议新建工程后先把 Flash 分区表改成大容量 App 分区idf.py create-project esp32_voice_assistant cd esp32_voice_assistant idf.py set-target esp32s3 idf.py menuconfig在 menuconfig 里重点做两件事Partition Table - Partition Table选择 “Single factory app (large), no OTA”或者自定义分区表给 factory 至少 6MB。Component config - ESP System Settings - Panic handler behaviour建议选 “Print registers and reboot”方便调试崩溃。如果后续要播放长一点的提示音频还可以在分区表里划分一个spiffs分区专门放 WAV 文件。这一步提前做好后面不会因为空间不够而推倒重来。3. 自定义唤醒词训练microWakeWord 实践3.1 唤醒词检测的原理直觉唤醒词检测本质是一个“语音活动分类”问题给你一段几十毫秒到几百毫秒的音频判断它是不是唤醒词。microWakeWord 这类方案通常把音频切成固定长度的帧提取梅尔频谱特征然后送进一个小型 CNN 做二分类或者多分类。这里你先不用纠结神经网络的深度重点在于两个工程化概念特征窗口一般取 30~50ms 的音频帧帧移 10~20ms滑窗输入模型。后处理模型输出的概率不是直接用的需要做平滑和阈值判断比如连续 N 帧概率都超过 0.8 才触发才能有效降低误唤醒。理解了这两点后面调参数就有方向了唤醒率低就降阈值、加窗口误唤醒多就升阈值、补负样本。3.2 数据采集正负样本怎么录自定义唤醒词训练最关键的其实是数据。用 microWakeWord 的 PC 端工具或自己写 Python 脚本录音都可以我建议直接在安静的房间里用同一颗 INMP441 录制保持和实际部署时相近的采样率与增益。正样本要求每个唤醒词至少录 100~200 条覆盖不同人说、不同语速、不同音量。不要只在一个固定位置录换换距离、换换方向否则模型会过拟合到某个固定声学环境。每条音频建议 1 秒左右正好包含整个唤醒词读音。负样本要更丰富录各种非唤醒词的日常语音比如“你好”“打开”“好的”“开始”这些容易混淆的短句。录环境噪声空调声、键盘声、电视声、开关门声。如果你有现成的语音库也可以混合进来。我自己实验的结果是负样本数量至少要是正样本的 2~3 倍模型才不容易乱搜。3.3 训练与导出 tflite 模型microWakeWord 的训练流程大致是把音频文件整理成列表脚本负责切帧、提取特征、训练 CNN、导出 TFLite 模型。训练时核心参数大致如下采样率16kHz单声道16bit特征类型Mel spectrogram 或 MFCC模型结构两层卷积 全连接参数量控制在几万级别训练轮数30~50 轮优化器Adam初始学习率 0.001训练完导出的.tflite模型建议再做一下量化。浮点模型在 ESP32-S3 上也能跑但速度和内存占用都不如 int8 量化模型python scripts/quantize_tflite.py \ --model_path model/wakeword.tflite \ --output_path model/wakeword_int8.tflite \ --representative_data dataset/representative/量化后的模型通常只有几十 KB非常适合单片机部署。3.4 量化检查与资源评估导出量化模型后先别急着烧录在 PC 上用测试集验证一下精度损失。int8 量化对唤醒词这类简单任务影响很小但如果你的量化数据集和真实环境差异太大唤醒率会明显下降。我当时统计过一个参考值50 轮训练正样本 150 条、负样本 400 条的情况下测试集准确率能做到 97% 左右量化后仍在 95% 以上。这已经足够日常使用了。把模型转成 C 数组这一步也别忽略用工具生成头文件xxd -i wakeword_int8.tflite wakeword_model.h生成的数组会很大注意确认它最终被编译进了固件而不是被链接器丢掉了。在 CMake 里加上EMBED_FILES是最省心的方式后面我会讲到。4. 端侧推理与对话指令识别4.1 在 ESP32-S3 上跑 TensorFlow Lite MicroESP-IDF 工程里集成 TFLM 的做法有两种用esp-tflite-micro组件或者手动克隆tensorflow/tflite-micro并把必要源码加入工程。我推荐用官方组件省事idf.py add-dependency espressif/esp-tflite-micro推理主流程非常固定初始化解释器指定模型数据、Tensor Arena 大小。把输入 Tensor 填充为音频特征。调用interpreter.Invoke()。读取输出 Tensor得到概率。代码骨架像这样static tflite::MicroInterpreter* interpreter nullptr; void setup_tflite() { static tflite::MicroMutableOpResolver10 resolver; resolver.AddConv2D(); resolver.AddMaxPool2D(); resolver.AddFullyConnected(); resolver.AddSoftmax(); static uint8_t tensor_arena[60 * 1024]; interpreter new tflite::MicroInterpreter( model_data, resolver, tensor_arena, sizeof(tensor_arena)); interpreter-AllocateTensors(); }Tensor Arena 大小要按模型实际需求给定太大浪费 RAM太小会在AllocateTensors阶段直接报错。我建议从 40KB 开始逐步往上加报错信息里通常会提示需要多少。4.2 实时音频处理与触发逻辑音频数据从 I2S 读出来之后不能直接塞进模型。ESP32-S3 的 I2S 驱动可以配置成 DMA 持续搬运我在一个 FreeRTOS 任务里循环读取size_t bytes_read 0; int16_t samples[512]; i2s_channel_read(i2s_rx_handle, samples, sizeof(samples), bytes_read, portMAX_DELAY);拿到原始 PCM 后需要做这几步字节序转换INMP441 输出格式是 24bit 左对齐但通过 I2S 配置可以只取高 16bit。去直流分量否则特征计算会被低频偏置干扰。滑窗拼接把当前帧和之前的历史帧拼成特征窗口。实现上我维护了一个环形缓冲区存放最近一秒的音频。每来一帧新数据就从缓冲区里取一段固定长度做特征送入模型推理。这样即使唤醒词出现在两帧交接处也能被检测到。4.3 指令识别多分类命令 or ESP-SR MultiNet唤醒成功后紧接着要识别用户说了什么指令。这里有两个路线如果指令集合固定“开灯”“关灯”“播报天气”用 microWakeWord 同类的多分类模型就行训练时把每个指令当成一类。好处是模型小、响应快、完全离线缺点是没法处理没见过的问法。如果希望更自然可以用乐鑫官方的 ESP-SR 组件里的 WakeNet 和 MultiNet。MultiNet 支持中文命令词识别能返回意图和槽位对“把客厅的灯调到百分之五十”这类说法支持得更好。ESP-SR 通过idf.py add-dependency espressif/esp_sr集成选择对应的中文模型即可。我个人现在的方案是两者结合固定指令走自定义模型省电、快开放问答走大模型链路。检测到一个“非固定指令”的兜底后把整段音频后的文本内容转交给 Ollama。这个组合在体验上最接近市面上的智能音箱。5. 本地大模型推理链路5.1 先说实话ESP32-S3 跑不了大模型但本地推理可以这样落地标题里写了“本地大模型推理”这个表述需要澄清一下ESP32-S3 本身跑不了 Llama、Qwen 这些动辄几 GB 的大模型。但“本地”这个需求完全可以通过局域网内的 Ollama 服务实现——设备不出门数据不出网体验上它就是你的离线语音助手。如果你的要求是“极简纯端侧”那也可以把“大模型”降级成 TinyML 意图分类模型或者规则模板匹配但那就谈不上“推理”了。为了平衡标题的想象和工程现实我在这一章讲的是局域网内的大模型推理这也是目前最主流、最有实用价值的落地方式。5.2 Ollama 部署与模型选择Ollama 是我用过最省心的本地模型运行工具。安装一句话就能完成Linux 和 macOS 都支持。装好后先把默认监听地址改成局域网可访问因为 ESP32 要通过 WiFi 访问它sudo systemctl edit ollama在配置里加一行环境变量然后重启服务[Service] EnvironmentOLLAMA_HOST0.0.0.0:11434拉取模型时别贪大ESP32 端的延迟和内存都有限我用下来最合适的还是 0.5B~1.5B 参数的中文小模型。比如qwen2.5:0.5b这种级别单次推理在普通电脑上也就一两秒。你也可以试试 1.5B 的版本回答质量更好但 ESP32 端要处理更长的响应文本压力会大一些。模型拉取很简单ollama pull qwen2.5:0.5b ollama run qwen2.5:0.5b先在这台电脑上直接对话测试确认模型本身没问题再接 ESP32。5.3 ESP32-S3 端 HTTP 调用与解析ESP32-S3 作为 HTTP Client 发送 POST 请求调用 Ollama 的/api/generate接口。我用的是esp_http_client组件核心配置如下esp_http_client_config_t config { .url http://192.168.1.100:11434/api/generate, .method HTTP_METHOD_POST, .timeout_ms 30000, }; esp_http_client_handle_t client esp_http_client_init(config);请求体是一个 JSON至少包含模型名、提示词、是否流式输出{ model: qwen2.5:0.5b, prompt: 用一句话回答什么是ESP32, stream: false, options: { num_predict: 128, temperature: 0.7 } }发完请求后在event handler里接收响应数据。注意 Ollama 非流式返回时整个 JSON 响应可能长达几 KB甚至十几 KB所以我用了一个动态增长的缓冲区来存不能用固定小数组。响应结构大致如下{ model: qwen2.5:0.5b, response: ESP32是一款集成WiFi和蓝牙的微控制器..., done: true }用 cJSON 解析时只需要取顶层键response的字符串值。cJSON 是 ESP-IDF 自带的#include cJSON.h直接用。5.4 回复音频与完整交互闭环大模型返回的是文本要让音箱“说”出来还得解决文本转语音。如果你希望完全离线最稳的做法是在电脑上把常用的回复模板和提示音提前合成 WAV烧录到 ESP32 的 Flash 里播放时直接读出来。对于大模型生成的自由文本我目前的做法是局域网内跑一个本地 TTS 服务比如开源的 Piper TTS收到 ESP32 发来的文本后合成 WAV再把音频流返回给 ESP32 播放。流程拉通后整个体验是这样的我说“小智同学”它回一声提示音我说“今天适合跑步吗”它把文本发给本地模型模型返回一段建议文本再经本地 TTS 变成语音播放出来。整个过程不超过五秒断网也一样工作。6. 常见问题与排查实录6.1 编译与链接问题这个项目我第一次编译就遇到了奇怪的问题模型文件太大超过了默认分区。解决办法就是前面说的在 menuconfig 里把分区表改成 “Single factory app (large)”。如果你自定义分区表记得给 factory 分区多留空间。另一个高频问题是 Tensor Arena 报错。AllocateTensors失败时串口会打印你需要的最小 arena 大小。刚开始可以给一个大的值比如 80KB跑通后再逐步缩小。注意 ESP32-S3 的 SRAM 虽然不小但给 WiFi、I2S 缓冲、HTTP 缓冲都要留余量所以 arena 不要盲目给大。如果编译时发现模型数组被链接器优化掉检查 CMakeLists 里是否用EMBED_FILES引入了模型文件建议写成target_add_binary_data(app.elf ../model/wakeword_int8.tflite EMBED)用xxd生成头文件的办法容易因为链接顺序问题导致符号找不到用 CMake 内嵌最稳定。6.2 唤醒体验问题我踩过最大的坑是唤醒率不稳定。一开始正样本只录了 50 条还是在同一个位置录的结果换个人说话就经常不醒。后来把正样本扩到 150 条负样本扩到 400 条效果立刻好转。如果误唤醒频繁优先看负样本覆盖度。是不是环境里有电视声、水声、电磁噪声录一批这些噪音加进去比盲目提高概率阈值更有效。阈值也别一开始就调到 0.9那样会误杀真实唤醒建议从 0.7 开始试根据实际体验微调。还有一个细节I2S 麦克风增益。INMP441 没有可调增益但你可以对 PCM 数据做幅度归一化。唤醒词检测时如果声音小到特征接近噪声基本没法识别。我会在静音检测里做一个自动增益估计把输入信号缩放到合理范围。6.3 Ollama 通信问题ESP32-S3 访问不到 Ollama我排查过几类原因OLLAMA_HOST没设置成0.0.0.0默认只监听本机设备自然连不上。电脑防火墙挡住了 11434 端口Linux 下检查防火墙规则Windows 下要在入站规则里放行。ESP32 和电脑不在同一个网段检查路由器 AP 隔离是否开启。请求发出后长时间没响应通常是大模型推理太慢。建议在 Ollama 配置里启用模型常驻内存避免每次请求都重新加载。也可以把模型换成更小的 0.5B速度提升非常明显。JSON 解析失败在我这也出现过主要是响应太长被截断了。解决方法是动态分配缓冲区以及设置num_predict限制生成长度一般 128 就够日常问答了。6.4 性能与稳定性整个设备跑起来后CPU 占用和内存占用需要实测。我用free命令查看剩余内存发现 WiFi 和 HTTP 同时活跃时内存波动较大。优化方向有两个一是把音频处理和网络请求放到不同核心的不同任务避免抢占二是减少 TFLM 推理频率比如唤醒检测每 150ms 跑一次即可不用每帧都跑。整机长时间运行时偶发死机八成是电源问题。功放瞬时电流加上 WiFi 发射峰值电流USB 口供电不足很容易造成重启。我后来换成了独立的 5V/2A 电源适配器并给功放单独并联了一个 470uF 电解电容问题就消失了。个人经验与后续扩展这套系统我前后调了两周最大的体会是离线语音助手的难点不在模型而在数据和工程细节。唤醒词训练本身不难难的是录够好样本TFLM 部署也不难难的是把音频链路调稳定Ollama 对接更简单难的是让整个交互延迟控制在可接受范围。如果你也想做建议按这样的顺序推进先用官方例程跑通 I2S 录音回放再训练一个最简单的双词唤醒模型然后接 Ollama最后才做指令识别和 TTS 美化。每一步都能单独验证出了问题也好定位。后面我打算继续做两件事一是把灯光、窗帘、传感器这些本地设备接入指令系统做一个真正不用云的语音控制中枢二是尝试在局域网里跑一个更适合家庭场景的系统提示词让大模型不仅能回答问题还能记住一些家庭成员的习惯。这个方向玩起来很有意思也很有价值。