最近老有人问我:ESP32 接上大模型,是不是就算做 AI 硬件了?我的回答一般都很直接:不是。把 ESP32 连上 WiFi、申请一个大模型 API Key、POST 一段 prompt 过去,然后拿到返回文字显示在屏幕或者做成语音播报,这只能算是一个“联网的玩具”,距离 AI 硬件还差着十万八千里。
我带着团队做过几个类似的端侧 AI 项目,也帮朋友点评过不少开源方案,大家最容易栽跟头的从来不是“怎么调 API”,而是那些看起来不起眼、但一上线就暴露的工程问题。网络断了怎么办?返回特别长怎么办?用户说话的时候音乐停了没有?电池顶得住吗?密钥被扒出来怎么办?设备卖给用户之后固件怎么升级?这些问题不解决,ESP32 和大模型的组合永远停留在 demo 阶段。
这篇文章我想把这些天踩过的坑、想到的解决方案、以及我自己总结的 8 个关键工程问题,一次性说透。希望能帮那些想做“ESP32 + 大模型”智能音箱、语音助手、交互玩具的朋友少走几个月的弯路。
1. 先把概念拆清楚:ESP32 接大模型到底在接什么
1.1 一个典型的“AI 语音助手”硬件原型
我见过太多人最初的想法几乎一模一样:用 ESP32 接一个麦克风模块,再外接一个小喇叭,按下按钮或者喊一句唤醒词,录音上传到云端 ASR 识别成文字,然后把这个文字发给大模型 API,等模型回答后,再把回答文本交给云端 TTS 合成语音,最后用扬声器播放出来。
从功能链路来看,这套方案确实用上了大模型,体验上也像是“能对话的 AI 智能音箱”。但如果你把这一整套拆开看,ESP32 在整个系统里扮演的角色是什么?无非是“采集声音 + 上传音频 + 下载文本/音频 + 播放输出”。真正的智能全在云端,ESP32 只是个带网络功能的遥控器加播放器。
这不是贬义,而是事实。AI 硬件的核心价值在哪?我认为在于“硬件与 AI 能力深度耦合后创造出的独特体验”。比如离线唤醒、本地打断、环境自适应、传感器联动、关键信息本地处理,这些都是单纯调用 API 做不到的。而恰恰是这些“耦合体验”背后的工程实现,才最磨人。
1.2 你以为的核心 vs 真正的难点
很多人误以为核心竞争力是“选哪个大模型”。大模型 API 的调用方式都差不多,国内外的模型服务商也都在把接口做得越来越标准。你换了模型,代码改动其实不大。真正的难点是:如何在 ESP32 这种资源极其有限的设备上,把一条不稳定的网络链路、一个延迟不可控的模型服务、一个对时间敏感的音频交互,组织成一个稳定可用的产品。
举个例子:你用一个 4MB Flash 的 ESP32 开发板,内存只有 320KB 级别的 SRAM。云端大模型一次返回的 JSON 可能就有 5KB,甚至 20KB。20KB 听起来不大,但要在单片机上拼出一个完整的 JSON 字符串,还要用解析库去提取其中某个字段,稍不注意内存就爆了。你再叠加上 WiFi buffer、音频 buffer、TLS 握手时的堆内存占用,几乎每一步都是“空间换时间”的取舍。
所以我下面列出的 8 个工程问题,全部都是在真实项目中反复出现、并且有明确解决方向的问题。不是销售话术,也不是教科书概念,全是我自己一遍遍跑出来的经验。
2. 真正卡脖子的 8 个工程问题盘点
2.1 网络层:Wi-Fi 连接与断线重连
ESP32 的 Wi-Fi 能力其实不弱,但一旦进入低功耗模式,或者路由器信号出现波动,断线几乎是必然的。问题在于很多人的代码是顺序执行的:开机连网,连不上就一直卡在WiFi.begin()那里,页面转圈,设备像个傻子。这绝对不行。
我的建议是建立独立的连接状态机,把“未连接、正在连接、已连接、断线重连、连接失败”这几种状态分开管理。在 Arduino 里可以用WiFi.onEvent注册 Wi-Fi 事件回调,然后搭配WiFi.reconnect()或者定时重连。重连时要注意次数上限,不能无限原地循环;如果重连 N 次失败,要进入低功耗或手动配置模式,让用户重新配网。另外,家里如果用的 5G Wi-Fi,很多 ESP32 模块根本搜不到,需要设备端提示用 2.4G 频段,这也是新手最常见的“为什么连不上”问题。
2.2 协议层:HTTP 轮询还是 WebSocket 长连接
当你调用大模型 API 时,最容易想到的是 HTTP POST 一把梭,发完请求等返回。但大模型生成文本往往需要几秒甚至十几秒。如果用 HTTP 轮询,要么请求超时,要么用户等得很痛苦。如果模型服务支持流式输出(SSE 或 WebSocket),我们可以做到“边说边接收”,用户感觉第一个字很快就出来了,体验会好非常多。
ESP32 这边我实测下来,WebSocket 比起“短连接 + 轮询”更适合长对话场景。一方面省去重复建立 TLS 连接的开销,另一方面服务端可以主动往设备推消息,适合处理打断、状态更新、多轮对话等复杂交互。不过 WebSocket 的库也有性能差异,建议选用支持 ESP32 的 WebSocketsClient 库,并设置合理的PingInterval,避免空闲时被服务端断开。连接中断时,要实现自动重连,并且保留会话上下文,而不是让用户重头再来。
2.3 数据层:JSON 解析与内存峰值控制
大模型 API 返回的基本都是 JSON。ESP32 上最常用的解析库是 ArduinoJson,默认用StaticJsonDocument在栈上分配内存,容量固定,小响应没问题,大响应直接NoMemory崩溃。换成DynamicJsonDocument也只是把内存放到堆上,如果堆不够,照样失败。
我的常用做法是分级处理。如果模型开启了流式返回,我会以行为单位读取数据流,每一行单独解析,提取增量内容,而不是等到全部接收完再解析。同时根据响应长度限制,在代码里做保护,比如超过 8KB 就截断或丢弃。还要留意不同 API 的返回格式差异,比如某些接口把内容放在choices[0].message.content,另一些放在response字段,用json["data"]["answer"]这种硬编码路径前,一定先用containsKey做校验。
2.4 交互层:唤醒、打断与状态管理
凡是做语音交互的硬件,都必须解决“状态怎么切换”的问题。代码里最忌讳的是阻塞式等待:发了一个 HTTP 请求,然后while (client.available() == 0) {}干等,期间麦克风不采样、按键不响应、屏幕不刷新。这会导致用户按下按钮没反应,说一半想停止也停不了,体验极其糟糕。
我习惯把设备状态定义成几个明确的枚举:空闲、录音、请求中、播放中、休眠。主循环只负责轮询事件,网络响应和音频播放都放到非阻塞的任务或回调里。请求中的状态下,如果用户再次按下按键,应该触发“打断”逻辑:中止当前 HTTP 连接,关闭 TTS 播放,清空缓冲区,回到空闲状态。哪怕是简单的三状态切换,也比没有状态机的“一通乱写”稳定得多。
2.5 音频层:拾音、降噪与播放策略
如果你的方案是语音对话,音频链路比文本链路复杂一整个量级。ESP32 用 I2S 接口接模拟麦克风或数字麦克风,采样率、位深、DMA buffer 都要调。单麦克风采集时,很难避免环境噪声,远端模型识别率会大幅下降。所以至少要做简单的 VAD(语音活动检测),没检测到人声时不要上传音频,既能省流量,也能减少误触发。
播放环节的坑更多。用 ESP32-AudioI2S 库播放 MP3 流时,它内部会边下载边解码边播放,但网络一卡就会断断续续。如果先下载整个音频文件再播放,又需要大量存储空间。我后来采用的办法是“短音频先缓存,长音频转流式播放”:把 TTS 结果限制在几十字的短句,预下载内容到内存分区,然后立即播放;超过限制就分段返回。交互体验的核心是“快”,宁肯 TTS 句子短一点,也不要让用户等太久。
2.6 电源层:峰值电流与功耗预算
很多人做出来之后满怀信心地装电池,结果半天就没电。原因很简单:ESP32 开启 Wi-Fi 时瞬态电流可以冲到 300mA 甚至更高,如果还要驱动扬声器、给麦克风供电,瞬时电流轻松超过 500mA。用普通锂电池 18650 直接供电,电压跌落一下,ESP32 就会重启。
正确的做法是先统计各模式下的电流消耗:待机休眠、连接 WiFi 待命、录音、播放、请求处理。然后根据目标电池容量和使用时长,算出平均功耗。一般建议 ESP32 在空闲时进入 Modem 休眠,保留 Wi-Fi 连接但降低功耗;播放时外接功放由 GPIO 控制开关;录音前再拉高麦克风供电。硬件上还要在电源输入处加大电容,至少 470uF 到 1000uF,吸收瞬态尖峰。
2.7 安全层:密钥管理与设备鉴权
这是最容易被忽视,也最容易出事的地方。很多教程直接教你const char* apiKey = "sk-xxxx"写死在代码里。如果只是自己玩玩问题不大,但一旦量产或者开源,这把密钥就相当于公开了。别人反编译你的固件,用 binwalk 等工具就能把明文密钥抠出来,然后拿你的账号疯狂调用模型 API,账单哭都来不及。
我建议采用一个简单的云端代理模式:设备不放 API Key,只保存一个设备唯一 ID 和设备端证书,或者干脆只存储一个短期 token。设备通过 HTTPS 访问你自己搭建的代理服务,由代理服务持有大模型密钥并转发请求。这样即使设备固件被扒,也只会泄露一个无效的设备凭据。同时在固件里开启CONFIG_ESP32_MEASURE_TIME没必要,但至少要把调试串口的日志输出关掉,别把请求参数和 token 打出来。
2.8 产品层:OTA、日志与远程调试
Demo 不需要,产品必须要有。ESP32 支持 OTA 升级,最简单的做法是通过 HTTP 下载固件包,然后写入 OTA 分区。但这里有个关键细节:要保证万一新固件有问题,设备还能回滚到旧版本。乐鑫 ESP-IDF 里有一个esp_ota_mark_app_valid_correctly_booted()机制,你必须在应用正常运行一段时间后打上“这个版本没问题”的标记,否则下次重启自动回滚。很多人忽略这一点,导致远程升级变砖,最后只能返厂用串口刷。
远程日志也建议一开始就设计好。在设备端把所有关键事件和错误码通过网络日志发送到你自己的服务器,出现问题不用让用户接串口线。没有日志,排查问题就是大海捞针。另外要考虑同一个局域网内多台设备同时升级时,固件分发服务器的带宽是否扛得住。
3. 从零搭建一个可用的“ESP32+大模型”最小系统
3.1 硬件选型与接线:不是随便一块板子都行
如果只是验证大模型接口,用便宜的 ESP32 DevKit 就够了。但如果要做语音交互,我更推荐 ESP32-S3 开发板,自带更多 GPIO 和更好的浮点运算能力,关键是支持 PSRAM。PSRAM 不是可有可无,它能让你的内存容量从几百 KB 提升到几 MB,这在解析大 JSON、缓存音频数据时几乎是救命的。麦克风可以用 INMP441 数字麦克风模块,六根线接 I2S;喇叭或者音频输出,我常用 MAX98357A 功放模块,直接 I2S 数字输入,不用自己搭模拟放大电路。
接线时有几个小细节:数字麦克风和功放不能共用一个 I2S 时钟?其实可以,但要保证数据引脚分开。电源部分务必用 5V 供电,I2S 功放需要 5V 或 3.3V 取决于模块。如果电池供电,建议中间加一次低压差稳压,并保证地线回路足够粗,否则音频会有滋滋的底噪。
3.2 软件架构:把状态机放在最前面
软件上我不会给你放一整个项目代码,而是分享一个已经被我验证过的结构。主程序就四层:
- 第一层:硬件初始化,包括 WiFi、I2S、GPIO、NVS 存储。
- 第二层:事件循环,不停扫描串口、按键、网络消息。
- 第三层:状态机,根据当前事件转换状态,并执行对应动作。
- 第四层:云端客户端,封装大模型请求、流式接收、超时处理。
伪代码大概是:
enum DeviceState { IDLE, LISTENING, PROCESSING, SPEAKING, ERROR }; DeviceState state = IDLE; void loop() { handleWiFi(); handleButton(); handleAudioQueue(); handleWebSocketMessages(); }handleWebSocketMessages里收到大模型的增量文本后,不是直接去阻塞解析,而是把文本追加到当前对话 buffer,同时更新 UI 或触发 TTS。整个过程不允许任何超过 100ms 的阻塞操作。
3.3 核心代码思路:请求、解析与播放三段式
我用 Arduino 环境给你们一个精简到极致的请求示例,重点不是代码本身,而是流程:
// 需要安装:HTTPClient, ArduinoJson String buildPrompt(String userText) { StaticJsonDocument<1024> doc; doc["model"] = "qwen-turbo"; JsonArray messages = doc.createNestedArray("messages"); JsonObject userMsg = messages.createNestedObject(); userMsg["role"] = "user"; userMsg["content"] = userText; String output; serializeJson(doc, output); return output; } void sendToLLM(String prompt) { if (WiFi.status() != WL_CONNECTED) return; WiFiClientSecure client; client.setInsecure(); // 生产环境不要这样用! HTTPClient http; http.begin(client, "https://api.example.com/v1/chat/completions"); http.addHeader("Content-Type", "application/json"); // 真实项目里这里应该是代理服务颁发的设备 token,而不是你的大模型 key http.addHeader("Authorization", "Bearer 这里放中转token"); int code = http.POST(buildPrompt(prompt)); if (code == 200) { String resp = http.getString(); StaticJsonDocument<4096> rd; deserializeJson(rd, resp); const char* answer = rd["choices"][0]["message"]["content"]; playAndSpeak(answer); } else { state = ERROR; } http.end(); }这段代码只是为了示意。真正实战时,4096字节的静态文档只适合短回复,长回复必须考虑流式读取解析。另外setInsecure()只建议临时开发使用,产品上必须校验证书或者用更安全的证书固定方案。而且要注意,中文WiFiClientSecure的 TLS 握手也是一个内存大户,出现重启时先怀疑它。
3.4 流式响应的处理方案:一次只吃一小口
如果你接的模型服务支持 WebSocket,写一个接收循环:
void handleWebSocketData(uint8_t* payload, size_t len) { // 每收到一帧数据,就追加到环形缓冲区 ringBuffer.push(payload, len); // 尝试按换行符拆分出完整的一行 JSON while (ringBuffer.hasLine()) { String line = ringBuffer.readLine(); StaticJsonDocument<2048> doc; if (deserializeJson(doc, line) == Ok) { const char* delta = doc["choices"][0]["delta"]["content"]; if (delta) { ttsBuffer.addDelta(delta); // 累积后统一处理 } } } }流式的好处是内存峰值恒定,不受最终响应总长度影响。但它有一个副作用:如果网络卡顿,增量数据零碎,TTS 拼出来可能不连贯。我的经验是把增量文本先攒 120 毫秒再合成,避免每个字都触发一次 TTS 请求。这个“攒批窗口”需要根据实际网络调整,网络好就短一点,网络差就长一点。
4. 我在实战中踩过的坑与解决方案
4.1 坑:ESP32 总是连不上家里的 Wi-Fi
现象是别人一行WiFi.begin(ssid, pass)就能连上,你的设备反复打印 “Connecting ...” 就是不行。排查到最后发现是路由器的 5G 和 2.4G 双频合一,ESP32 只支持 2.4G,手机自动连上 5G 后,你以为密码没错,其实设备在 2.4G 频段上找不到网络。解决方法是把路由器双频分开,固定用 2.4G 连接,并且信道不要自动选择,避免半夜信道一换设备就掉线。
另外一个坑是 DHCP 租期太短,ESP32 长时间不用后醒来获取不到 IP。建议在代码里记录一个“上次成功连接的信息”,超时后重启 WiFi 网卡而不是单纯重连。
4.2 坑:JSON 解析直接触发重启
有一次我调试一个对话返回,模型回答稍微长一点,设备就在deserializeJson那里自动重启了。原因是StaticJsonDocument<4096>不够大,返回内容里有中文转义字符,实际内存占用超过预算,导致堆栈溢出。解决方案不是一味调大 capacity,因为内存不够时调大也一样崩。
我后来做了三层防护:第一,发送请求前限制max_tokens,建议控制在 300 以内;第二,接收 body 时先判断长度,超过我设定的最大长度就直接截断,不让它进解析器;第三,改用流式解析,分块处理增量 JSON 行。如果你被迫要解析完整大 JSON,那就用DynamicJsonDocument,但记得写入后立刻shrinkToFit(),或者干脆把不必要字段忽略掉。
4.3 坑:TTS 播放时出现鬼畜停顿
第一次用 ESP32-AudioI2S 播放云端 TTS 返回的 MP3 时,声音断断续续,甚至一个词重复播放。查下来是播放库需要从 URL 边下边播,而它内部缓冲区只有几十 KB,网络一抖动就衔接不上。另外我还犯了一个错:TTS 服务返回的是 MP3 链接,我却把链接里的临时签名带上了&字符,导致 URL 解析出错。
解决方法是:先用 HTTP 下载一部分音频数据到内存或 Flash 存储,等到缓冲足够再启动播放;同时把 TTS 文本切成短句,一句一句请求,一句一句播放。虽然请求次数变多,但整体等待时间反而更短,因为用户可以在第一句播放时后台继续请求第二句,形成“边听边等”的自然节奏。
4.4 坑:OTA 升级之后设备变砖
我在测试远程升级功能时,新固件里有个 bug 导致设备反复崩溃重启,但 OTA 分区已经被标记为“引导成功”,于是系统永远停留在坏版本上,只能串口重刷。以后的产品做法是:应用启动后先不急着标记有效,而是等 60 秒或跑完一轮基本自检后,再调用esp_ota_mark_app_valid_correctly_booted()。如果新固件崩溃回滚,旧固件会自动接管,避免变砖。
另外升级时要保持电源稳定,我见过有人在升级过程中拔掉 USB 线,设备直接变砖。量产设备的 OTA 代码还要加上“升级文件校验”,用哈希比对固件完整后再写入,不要边下载边盲写。
5. 把原型做成产品:成本、量产与体验平衡
5.1 成本账本:从开发板到模组
如果你只是自己玩,开发板 30 块钱左右,加麦克风功放和喇叭材料成本可能不到 60 块钱。但一旦想量产,开发板的板载 USB 转串口、天线、LED、按键、排针全是冗余,成本和体积都受不了。换成 ESP32-S3-WROOM 模组,单个采购价大概 10 元上下,自己画最小系统板,集成晶振、天线、Flash,再做好电源管理和音频电路,物料成本可以做到 20 元以内,加上外壳、认证、组装,整机 BOM 成本还是有很大压缩空间。
不过这里要提醒:量产不是只换一个模组那么简单。开发板上那一堆外围元件其实是经过了厂商的 EMI 设计和天线阻抗匹配,你自己画板时要重新做射频布局,天线区域不能铺铜不能走线,电源纹波也要重新验证。这是模拟和射频工程师的地盘,软件出身的人最好和硬件同事一起评审,否则信号覆盖和音频底噪会折磨死你。
5.2 端侧还是云端?任务拆分的黄金法则
关于 AI 任务放端侧还是放云端的判断,我总结了一条经验:凡是“唤醒、打断、本地快捷键、数据缓冲”这类对延迟要求极高、数据量小的任务,尽量放端侧;凡是“语义理解、长文本生成、高质量 TTS”这类需要大模型的任务,放云端;处于中间地带的简单命令词识别,可以用端侧的小模型尝试,ESP32-S3 带 PSRAM 后可以跑的轻量 KWS 模型不在少数。
不要天真地指望把大模型压缩进 ESP32。现在确实有一些极小参数模型可以在 MCU 上跑,但能做出来的交互能力很有限,还占用大量 Flash 和 RAM。我认为现阶段最务实的方案是:用端侧小模型搞定交互骨架,用云端大模型提供内容深度,二者结合才是 AI 硬件该有的样子。
5.3 后续还能怎么玩:离线词库、多设备联动和本地小模型
当你把上面 8 个工程问题都啃下来以后,这个产品的基础架构就已经很扎实了。这时候可以开始加一些锦上添花的功能:比如离线唤醒词库,在设备无网络时也能通过本地识别打开功能;比如多设备联动,一个 ESP32 网关带多个传感器节点,大模型负责生成控制指令,节点执行动作;再比如在设备上跑一个简单的异常声音检测,用 TinyML 判断玻璃破碎或者婴儿啼哭,再触发云端大模型分析。
我在实际项目里最享受的是“联动的爽感”:智能音箱收到用户说“我有点冷”,大模型返回把空调开到 26 度,ESP32 通过红外发射或串口把命令发给空调模块。这套逻辑完全不复杂,但给用户的感受却非常“AI”。这种体验背后,靠的是稳定的网络层、健壮的状态机、合理的内存规划,以及你对那 8 个工程问题的应对能力。
所以我从来不觉得 ESP32 接大模型是一件“low”的事情。恰恰相反,能把单片机、无线网络、云端 AI、本地音频这些碎片拼成一个不崩、不卡、不烧钱的产品,才是真正的 AI 硬件基本功。下次再有人问“接上大模型就算 AI 硬件吗”,你可以把这篇文章丢给他,然后淡淡地说:先把我上面列的 8 个问题解决一半,再考虑产品的事。