去年年底我手头多了一块微雪出品的 ESP32-S3 N16R8 开发板,当时正好在琢磨一个 AI 陪伴类的项目。市面上现成的陪伴设备要么太封闭,要么只做云端对话、本地就是个蓝牙音箱,完全没有“设备感”。我想做的是:一块小板子,既能独立完成语音唤醒、交互反馈,又能借助云端大模型的能力实现真正开放的自由对话——于是就有了这套持续演进了大半年的端云架构。
这个项目最适合两类人看:一类是手里有 ESP32-S3 开发板、想把它做成“能聊天”的实物设备但不知道从哪下手的硬件爱好者;另一类是已经在做 AI 硬件原型、但对端云边界、OTA 升级、会话状态这些“上了量才会暴露的问题”还没有完整认知的同学。下面我就从硬件选型、固件架构、云端接入、以及我踩过的那些坑,把这套方案从头到尾拆开讲。
1. 项目定位与整体设计思路拆解
1.1 为什么是 ESP32-S3,而不是树莓派或者手机方案
做 AI 陪伴设备,摆在面前的第一道选择题就是算力平台。树莓派性能强、能本地跑小模型,但成本高、功耗大、体积也压不下来,做出来的东西更像“开发板拼装”而不是“陪伴设备”。手机方案的开发门槛低,但本质上只是套壳,用户已经有一个手机了,为什么还要做一个依托手机的设备?
ESP32-S3 在 2023 到 2024 年这个时间节点几乎是端侧 AI 硬件原型的标准答案。理由有三:
- 双核 Xtensa LX7 处理器,主频 240MHz,配合 SIMD 指令集,跑音频处理、I2S 采集、屏幕刷新这些任务绰绰有余;
- 内置 2.4GHz Wi-Fi 和 BLE 5.0,一颗芯片同时搞定网络连接和近场配网,不需要外挂模块;
- N16R8 版本带 16MB Flash 和 8MB PSRAM,8MB PSRAM 很关键——这决定了你能不能开足够的音频缓冲区,以及能不能在端侧跑一个轻量级的唤醒词模型。
我的判断是:AI 陪伴设备短期内不会在端侧跑大模型,真正的主力是“端侧响应 + 云端智能”。ESP32-S3 恰好是这个分工里性价比最高的端侧载体,它不需要有多聪明,但必须足够稳、足够省电、足够便宜。
1.2 端云架构的边界划分:哪些留在端侧,哪些交给云端
这是整个项目里最重要的架构决策。我见过太多人一上来就把音频流直接推到云端,结果网络稍微抖动一下设备就变成“哑巴”。我的划分原则只有一条:凡是与用户体验直接相关的实时操作,全部留在端侧;凡是需要语义理解和知识生成的操作,全部交给云端。
具体来说,端侧负责四件事:
- 语音唤醒词检测(本地完成,响应时间在几百毫秒以内);
- 音频采集和 VAD(语音活动检测),判断用户是否说完了一句话;
- 屏幕显示、指示灯、按键反馈等本地交互;
- 网络状态监测和断线降级(比如提示“网络不佳”,而不是直接卡死)。
云端负责三件事:
- 把端侧上传的音频转成文字(ASR);
- 把文字送入大模型生成回复(LLM);
- 把回复文本合成语音(TTS),回传给端侧播放。
这个边界的核心价值在于:即使云端完全不可用,设备本身依然是一个有反应的硬件,用户可以立刻感知到问题所在,而不是面对一块死屏。从产品体验上看,这比“断了网就彻底废掉”的设备高出一个维度。
1.3 可持续演进的架构原则
“可持续演进”不是一句口号,在这个项目里我把它落实成三条具体原则:
- 协议先于实现:端云通信从一开始就使用 JSON over WebSocket 的通用协议,而不是为具体某个大模型定制接口。这样以后换模型、加功能,端侧固件不需要大改。
- 配置驱动行为:设备的角色设定、音色、语速、唤醒词等都用云端下发的配置来控制,而不是写死在固件里。这样不改固件就能调节产品行为。
- 保留端侧扩展位:PSRAM 里预留至少 2MB 的余量,后续如果要加更复杂的端侧音频处理(比如自定义唤醒词、本地指令识别),不用换硬件。
这套原则在后来的迭代中帮了大忙。最典型的一次是我把对话模型从通用对话换成角色扮演模型,端侧固件一行没改,只改了云端服务和配置下发,整个产品就完成了“升级”。
2. 硬件选型与端侧资源盘点
2.1 开发板选型:为什么最终锁定微雪 ESP32-S3 N16R8
市面上 ESP32-S3 的开发板非常多,合宙、微雪、官方 DevKitC、各种第三方小板子,选择困难症很容易发作。我给自己的筛选标准是三条:Flash/PSRAM 容量、扩展接口的丰富度、以及调试排障的便利性。
最终选定的微雪 ESP32-S3 N16R8(我用的具体型号带了 1.9 英寸 GC9A01 圆形屏幕和 6 轴 IMU),核心参数如下:
| 参数 | 配置 | 我的用途 |
|---|---|---|
| SoC | ESP32-S3(双核 240MHz) | 主控 |
| Flash | 16MB | 固件 + 资源文件 + OTA 双分区 |
| PSRAM | 8MB | 音频缓冲、屏幕缓冲、JSON 解析堆 |
| 屏幕 | GC9A01 圆形 240x240 SPI | 表情与状态显示 |
| 麦克风板 | INMP441 或板载模拟麦 | 语音采集 |
| 扩展接口 | 排针引出全部 GPIO | 外接功放和扬声器 |
我特别看重 N16R8 这个版本,是因为 16MB Flash 在后续做 OTA 升级时太重要了。如果你用的是 4MB Flash 的版本,OTA 双分区一划分,每个分区只够放一个很小的固件,很多功能就得为了空间砍掉。8MB PSRAM 则让内存分配变得奢侈,尤其是后期接 GC9A01 屏幕时,需要分配的显存和绘制缓冲加起来就能吃掉不少内存。
2.2 麦克风方案:INMP441 数字麦与板载模拟麦的取舍
语音交互是陪伴设备的入口,麦克风的选型直接决定识别率。我手头有两块麦克风方案可以对比:
- INMP441:I2S 接口的 MEMS 数字麦克风,输出直接是 PCM 数据,不受模拟走线干扰,信噪比高,但是需要额外的 L/R 引脚配置,焊接也比较麻烦;
- 板载模拟麦克风(微雪板载方案):走 ADC 采集,接线简单,但是底噪明显偏大,在安静房间里做唤醒测试时误唤醒率很高。
我的结论是:如果你对语音唤醒距离的要求超过 30 厘米,直接上 INMP441,不要犹豫。模拟麦做原型演示没问题,做到成品阶段还是得数字麦。
INMP441 接 ESP32-S3 的标准接法我后面会详细说,这里先提醒一个关键点:INMP441 是单声道 I2S 输出,数据格式化后是 32-bit 帧,有效数据在高 18 位。这个如果你不知道,读出来的音频会全是噪声或者音量小得离谱。
2.3 屏幕、扬声器与供电的配套设计
GC9A01 这块圆形屏是这个项目里“陪伴感”的重要来源。240x240 的圆形 LCD,SPI 接口,刷新率能满足 30fps 的动画需求。我把它用来显示两种内容:
- 表情动画:眼睛、嘴巴的简单几何图形,根据设备状态切换(待机、聆听、思考、说话);
- 调试信息:Wi-Fi 信号强度、对话状态码、当前模式,方便开发阶段快速排障。
扬声器部分我建议用 I2S 功放(比如 MAX98357A)而不是直接 DAC 输出。MAX98357A 是 I2S 输入的 D 类功放,引脚少、音质够用,和 ESP32-S3 的 I2S 外设无缝对接。直接用一个三极管驱动的蜂鸣器放语音是灾难性的,千万别省这个钱。
供电是整个硬件里最容易“带崩”的地方。ESP32-S3 开启 Wi-Fi 的瞬间电流可以冲到 500mA,加上屏幕背光和功放,峰值电流可能超过 1A。我的建议是:
- 如果电池供电,选 3.7V 锂电池 + 至少 500mA 输出的 LDO(如 RT9013 或 ME6211);
- 如果 USB 供电,务必用优质线材,劣质 Micro-USB 线的压降会让你反复遇到“重启循环”的灵异问题;
- 功放的电源最好从电池端单独引出,避免和主控共用一组走线时产生地环路噪声。
3. 端侧软件架构与核心模块实现
3.1 固件框架选型:ESP-IDF 是底线
ESP32-S3 的固件开发有 Arduino、MicroPython、ESP-IDF 三条主要路线。我的选择是ESP-IDF,并且强烈建议你至少在做一个功能模块时尝试一下 ESP-IDF。
原因不复杂:Arduino 写起来快,但一旦要精细控制 I2S 时钟、调整 PSRAM 的 malloc 策略、做 OTA 的时候,Arduino 封装反而变成一层障碍。MicroPython 做原型演示可以,跑生产级的音频采集和通信任务,GC 停顿和解释器开销会让你头疼。
ESP-IDF 的学习曲线是陡的,但它的组件体系(esp-adf、esp-sr、esp-web-socket-client)几乎覆盖了音频设备开发的所有需求,而且每个组件都有乐鑫官方维护,排障时找资料容易得多。
这个项目的固件结构是这样的:
app_main ├── network_manager // Wi-Fi 连接、断线重连、网络状态上报 ├── ble_config // BLE 配网服务(GATT Server) ├── audio_pipeline // I2S 采集 -> VAD -> 环形缓冲 ├── wake_word_detector // ESP-SR 唤醒词检测 ├── display_manager // GC9A01 驱动、表情渲染 ├── cloud_client // WebSocket 客户端、心跳、重连 └── event_dispatcher // 模块间事件总线各模块之间通过事件总线解耦。比如唤醒词模块检测到唤醒,发一个EVENT_WAKEUP,音频模块收到后开始录音,云端模块收到后连接服务器准备上传。这样每个模块都能独立测试,后期加新功能也不用在 main 里堆逻辑。
3.2 BLE 配网流程:给一个没有键盘屏幕的设备配 Wi-Fi
ESP32-S3 没有键盘没有浏览器,第一次上电怎么告诉它 Wi-Fi 密码?最顺手的方案是 BLE 配网。这里有两个实现路线:
- 乐鑫的 SmartConfig / ESP-Touch:用手机 App 广播 Wi-Fi 信息,简单但不好定制 UI 流程,而且对路由器频段有一定要求;
- 自建 BLE GATT 服务:设备作为 BLE Peripheral,手机 App 作为 Central,通过自定义的 Service 下发 Wi-Fi SSID 和密码。可控性强、能自定义状态反馈,我选了这条。
BLE 配网的流程设计上有个小细节值得分享:千万不要把 String 类型的 SSID 和密码直接塞进 GATT Characteristic,然后让手机一次性写进去。GATT 单次写入的长度有限,中文 SSID 或者特殊字符的密码很容易出问题。我的做法是设计一套简单的分包协议:
服务端 UUID: xxx 特征值 1: wifi_ssid_rx (手机写入,最大 32 字节) 特征值 2: wifi_pass_rx (手机写入,最大 64 字节) 特征值 3: ctrl_cmd_rx (手机写入 0x01 表示开始配网) 特征值 4: status_tx (设备通知手机:IDLE/CONNECTING/CONNECTED/FAILED)手机 App 先把 SSID 和密码写入对应特征值,再写0x01触发配网。设备收到后开始连接 Wi-Fi,每 500ms 上报一次状态,直到CONNECTED或FAILED。这个流程用户反馈非常直观,比傻等几秒钟不知道发生了什么好太多。
还有个关键点:配网完成后 BLE 服务要主动关闭,否则设备一直处于可被发现状态,挺费电的。重新进入配网模式可以通过按键长按触发,也可以做成“配置丢失且连续 30 秒没连上 Wi-Fi”时自动进入。
3.3 麦克风 I2S 采集:从原始 PCM 到有意义的音频帧
INMP441 接入 ESP32-S3 的 I2S 外设,标准接法是:
INMP441 VDD -> 3.3V INMP441 GND -> GND INMP441 SCK -> GPIO 4 (I2S BCK) INMP441 WS -> GPIO 5 (I2S WS) INMP441 SD -> GPIO 6 (I2S DIN) INMP441 L/R -> GND (选择左声道还是右声道输出)ESP-IDF 里 I2S 驱动用i2s_std_config这个结构体配置,关键参数如下:
i2s_std_config_t std_cfg = { .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(16000), // 采样率 16kHz .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG( I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_MONO ), .gpio_cfg = { .mclk = I2S_GPIO_UNUSED, .bclk = GPIO_NUM_4, .ws = GPIO_NUM_5, .dout = I2S_GPIO_UNUSED, .din = GPIO_NUM_6, }, };为什么采样率用 16kHz 而不是 44.1kHz?因为语音识别场景的带宽上限就是 8kHz,16kHz 采样率已经完全够用,还能大幅降低上传流量和云端 ASR 的计算成本。数据位宽选 32-bit 是因为 INMP441 的硬件特性,但真正有效的数据只在高位,读取后要右移 14 位再转成 16-bit PCM。
注意:把 I2S 读出来的裸数据直接传给云端是不行的。必须先做音量归一化,甚至一个简单的静音检测(VAD)。VAD 我用的方案很简单——计算短时能量,超过阈值且持续 250ms 就认为用户开始说话,低于阈值持续 800ms 就认为一句话结束。这个逻辑用 16kHz/16bit 的音频帧来算,性能开销微乎其微,但能把上传的音频裁剪到最短,省流量也省延迟。
3.4 GC9A01 圆形屏显示:表情驱动的关键实现
GC9A01 是 SPI 接口的 240x240 圆形屏,驱动 IC 是 ST7789 的变种,ESP-IDF 里可以直接用 esp_lcd 的esp_lcd_panel_io接口。初始化的时序和寄存器配置网上资料很多,我这里重点说显示内容怎么和对话状态联动。
我的做法是抽象了一个display_state:
typedef enum { DISPLAY_STATE_IDLE, DISPLAY_STATE_LISTENING, DISPLAY_STATE_THINKING, DISPLAY_STATE_SPEAKING, DISPLAY_STATE_ERROR, } display_state_t;不同状态下渲染的内容完全不同:
IDLE:显示一只半闭眼的淡入淡出动画,表示设备在待机;LISTENING:显示一个不断扩大的声波圆环,表示正在拾音;THINKING:显示三个旋转的圆点,表示“在思考”;SPEAKING:显示嘴部张合动画,下巴开合幅度和 TTS 的音量包络同步;ERROR:显示一个 WiFi 图标打叉的图案,并闪烁两次。
关键实现细节是屏幕刷新不能阻塞主逻辑。ESP-IDF 的 LCD 驱动可以用 DMA 自动搬运帧数据到 SPI 外设,所以显示逻辑跑在独立的任务里,读取状态机变量然后渲染,完全不占用主控的处理时间。帧率我只开到 20fps 左右,因为表情动画不需要太高帧率,但能省下不少电。
记得在 PSRAM 里分配一个lv_color16_t *draw_buf = heap_caps_malloc(240*240*2, MALLOC_CAP_SPIRAM)作为屏幕的像素缓冲区。GC9A01 一帧全屏就是约 115KB,这内存放在内部 SRAM 会瞬间耗尽,放在 PSRAM 则毫无压力。
4. 云端服务与 AI 能力接入
4.1 云端职责划分:网关、会话与模型解耦
云端是整个系统的大脑,但大脑也不是只有一块。我把云端拆成三个逻辑层:
- 接入网关:负责和设备保持 WebSocket 长连接,做鉴权、心跳、消息路由;
- 会话服务:维护每个设备的对话历史、角色设定、上下文窗口,和具体的模型服务商解耦;
- 模型编排:对接 ASR、LLM、TTS 三类模型,哪一家模型效果好就调用哪家,内部做好统一接口封装。
这个分层最大的好处是:换模型不影响设备端协议,改会话逻辑不影响模型调用。这半年来我至少换了三个 LLM 供应商、两个 TTS 引擎,设备端固件一次更新都没做过。
网关选型我用了 Node.js +ws库做原型。原因很朴实:WebSocket 生态成熟,JSON 处理顺手,做原型速度快。生产环境如果并发量大,网关这一层可以横向扩展,无状态设计后加个 Nginx 负载均衡就能扛。
4.2 大模型接入与 Prompt 设计:陪伴场景的“人格”从哪来
LLM 接入本身不复杂,真正难的是如何让模型“稳定地”扮演一个陪伴角色。这个陪伴设备的角色设定是一个叫“小橘”的温暖系小伙伴,我跟它说过,无论用户聊什么,回复都要短、自然、有温度,像朋友而不是客服。
为此我设计了一套三段式的 Prompt 模板:
系统提示(System): 你是“小橘”,一个 25 岁的友善伙伴。你的说话风格: 1. 句子简短,通常不超过 2 句话; 2. 不用网络梗,不用 emoji 描述表情; 3. 语气温暖,可以共情,但不说教; 4. 如果话题超出你的知识范围,坦诚说不知道,并引导用户聊别的话题。 用户输入(User): [用户语音转写文本] 对话历史(History): 保留最近 10 轮对话,按时间顺序组织。为什么把历史单独放?因为很多模型对上下文的组织格式很敏感,System + 乱序的 User/Assistant 交替消息很容易让模型进入状态错乱。我干脆把历史拼接到用户消息之前,用\n\n分隔,这样大多数模型都能稳定理解“这些是之前聊过的,现在用户又说了这句”。
Prompt 里还有一个容易被忽略的点:限制回复长度。很多人以为模型回得越多越好,但陪伴设备是语音输出,超过 100 个字的回复用户根本听不完,体验很差。我在 Prompt 里明确写了“通常不超过 2 句话”,同时在代码里设置max_tokens为 150,双保险。
4.3 情感陪伴场景的会话管理:状态与记忆
会话管理是这半年里改动最多的模块,踩的坑也最多。第一版我把对话历史直接存成数组,每轮对话 append 进去,满了就丢弃最老的。后来发现一个问题:模型对“第 8 轮的某句话”没有记忆,用户明天重新开机对话,它完全不记得昨天聊了什么。
所以“记忆”至少要分两层:
- 短期会话上下文:当前这一轮连续对话的完整记录,存在内存里,超过 10 轮就把最早的消息压缩成摘要(用 LLM 做 summary);
- 长期用户画像:用户的关键信息(名字、喜欢的音乐、家里有猫、最近在准备考试等),存在数据库里,每次新会话开始时注入到 System Prompt 里。
长期记忆的提取时机很重要。我建议在每轮会话结束后异步调用一次 LLM,传入“对话记录 + 已有画像”,让它输出增量更新的结构化 JSON。这个过程的 Prompt 大概是:
基于这段对话,提取关于用户的持久事实(喜好、经历、关系等)。 只输出新增事实,以 JSON 列表返回,例如 [{"attr": "name", "value": "小明"}]。 如果没有任何新增,输出 []。这样长期记忆会随着对话慢慢丰富,设备也变得越来越“懂你”。当然,这个功能比较费 token,我目前的策略是每 3 轮对话执行一次,并且限制在 5 个事实以内。
4.4 云端的可扩展性设计:为多设备并发做准备
刚开始做云端时只有一台测试机器,我就没考虑并发,所有设备的信息都放在进程内存里。后来把设备拿给朋友试用,三台设备同时在线上,问题就来了:一台设备的音频上传卡顿会影响另一台设备的响应,因为模型调用是串行的。
这次教训让我把云端改造成无状态服务:
- 会话状态从进程内存挪到 Redis,以
device_id为 key,过期时间 30 分钟; - 访达的模型调用全部走异步任务队列(BullMQ + Redis),设备发来的请求先入队,任务执行完再通过 WebSocket 把结果推回设备;
- 网关只做连接管理和消息转发,不做业务计算。
这样改完之后,加设备只需要给网关加并发,模型服务的执行节点可以独立扩容。整套体系虽然还是跑在一台机子上,但架构上是真正“可持续演进”了。
5. 端云协同与数据链路打通
5.1 基于 WebSocket 的双向通信:心跳、重连与消息格式
端云之间的通信我最终选了 WebSocket 而不是 MQTT。原因很简单:MQTT 的发布订阅模型更适合“传感器上报”,而陪伴设备需要的是一个 Request/Response 式的双工通道——端侧上传音频、云端返回回复,WebSocket 天然就支持这种模式,而且调试起来也更直接。
消息格式统一为 JSON,最外层包含type字段,核心类型如下:
| 方向 | type | 说明 |
|---|---|---|
| 端->云 | audio_start | 开始上传音频流 |
| 端->云 | audio_chunk | 音频二进制块(base64 编码) |
| 端->云 | audio_end | 音频发送完毕 |
| 云->端 | asr_result | 识别出的文本 |
| 云->端 | llm_chunk | 大模型逐字生成的回复 |
| 云->端 | tts_audio | TTS 合成的音频数据(base64) |
| 云->端 | state_change | 云端主动推送状态变化(如等待唤醒等) |
关键设计点:大模型的回复不做“整段处理后一次性返回”,而是通过llm_chunk流式推送。这样端侧可以在用户说完话之后几百毫秒就开始听到第一句话,而不是等模型挖完整个回复再合成语音,延迟体感完全不一样。
心跳机制也很重要。ESP32-S3 的 Wi-Fi 在深度休眠后恢复连接需要时间,如果云端迟迟没收到设备心跳,就会误判设备离线、清除会话状态。我的心跳间隔是 30 秒,云端连续 90 秒收不到心跳就断开会话,但保留 2 分钟内重连的“会话恢复”能力,用户不会因为一瞬间断网就丢失整个对话上下文。
5.2 音频上传与 TTS 回传:带宽与延迟的平衡
音频采集已经在端侧裁剪过了,一秒钟 16kHz 采样的 16-bit PCM 是 32KB,一次 5 秒的查询音频大约 160KB。直接用 JSON 的 base64 字段传输会让体积膨胀 33%,所以我改用 WebSocket 的二进制帧上传音频块,元信息继续用 JSON 消息。
整个数据链路是这样跑的:
- 用户说完话,端侧把音频切成 20ms 一帧的 PCM;
- 每帧单独通过 WebSocket binary frame 发送,帧头带一个递增的 seq 号;
- 云端收齐音频后,调用 ASR 得到文本;
- 云端把文本送入 LLM,流式拿到回复;
- 回复文本送给 TTS 引擎,合成音频后以
tts_audio消息返回; - 端侧收到 TTS 音频后,边写入 I2S 功放边播放,同时驱动屏幕显示“说话中”的表情。
第 2 步里帧头带 seq 号很有用,万一有丢帧可以及时重传。实测在普通家庭 Wi-Fi 环境下,这个链路从用户说完话到设备开口回答,延迟大约在 1.5 到 2 秒之间,属于可接受的交互范围。
TTS 返回的音频格式上,我选择 OPUS 编码而非 MP3。OPUS 在 16kbps 下语音可懂度已经很高,压缩率比 MP3 好,解码库在 ESP32-S3 上跑也没有压力。一开始我为了省事直接传 MP3,结果流量大了一倍,传输延迟也明显上升。
5.3 离线降级与网络异常处理:设备不能“装死”
无线环境永远是不可靠的。我在开发过程中遇到过无数次 Wi-Fi 信号差、云端超时、DNS 劫持、乃至路由器重启的情况。如果设备在这些异常下直接卡死或静默,用户会觉得产品坏了。
所以我在端侧实现了一套三级降级策略:
- 第一级:网络不稳定。WebSocket 连续 3 次心跳超时后,设备进入“网络不佳”状态,屏幕显示弱信号图标,语音提示“我这边信号不太好,请靠近路由器试试”,但继续尝试重连;
- 第二级:云端不可用。重连 5 次仍失败,设备进入“离线模式”,此时唤醒词检测仍然工作,但会回复“我暂时没法上网,等网络恢复了我们再聊天”;
- 第三级:完全离线。拔掉电源几小时后再回来,设备冷启动后如果 Wi-Fi 和云端都不可用,屏幕只显示时间,不主动打扰用户。
这个三级策略在用户侧的效果非常明显——设备从“莫名其妙没反应”变成“每次都给出合理的反馈”,即使用户不懂技术细节,也能感觉到这设备是“懂事”的。
6. 可持续演进的工程化实践
6.1 固件 OTA 升级通道:不改硬件就改功能的唯一途径
如果你做的是 demo,烧录固件用串口没问题;如果你做的是陪伴设备,每台都要手动插线升级,那就不可能持续演进了。ESP32-S3 支持原生 OTA,把固件分为ota_0和ota_1两个分区,新固件下载到备用分区,校验后切换启动。
我实现的 OTA 流程是这样的:
- 设备端每隔 12 小时向云端请求一次固件版本信息;
- 如果云端版本号高于本地版本,返回固件下载 URL(放到对象存储);
- 设备用 HTTPS 下载固件到 OTA 备用分区,边下边校验 SHA256;
- 下载完成后写入确认标记,调用
esp_ota_end和esp_ota_set_boot_partition; - 重启进入新固件,新固件启动后上报当前版本,云端记录。
OTA 最容易出问题的点是下载中断。我加了断点续传逻辑,每次下载记录偏移量,重新连接后从断点继续,实测 2MB 的固件在普通宽带下 30 秒内能完成下载,成功率超过 99%。
不要忘了回滚机制。如果新固件启动后连续 3 次崩溃,就要检测到异常并回退到旧分区。ESP-IDF 的esp_ota_mark_app_valid_cancel_rollback和esp_ota_mark_app_invalid这两个 API 就是干这个的,一定要用起来。
6.2 云端服务的无状态化改造:从“演示”到“产品”
前面提到过,云端一开始是“单进程内存存状态”的写法,三台设备同时在线就暴露出串行问题。我把改造分成两个阶段:
第一阶段,把会话状态迁移到 Redis。每条会话的 key 是session:{device_id},value 是对话历史的 JSON 数组,过期时间 30 分钟。这样即使处理请求的那台服务实例崩溃,另一台实例也能接管,用户无感知。
第二阶段,把模型调用异步化。设备端把音频上传后,网关把“ASR 任务”和“LLM 任务”推入 Redis 队列,多个 worker 并行消费。TTS 也是异步生成,生成完成后直接推回设备。
无状态化带来的一个额外好处是可以灰度发布。新版本的服务先部署一台,让 10% 的流量走新逻辑,观察指标没问题再全量切。设备端的 OTA 也有类似的灰度策略——先升级一台做内测,没问题再批量推送。
6.3 数据埋点与迭代优化:用真实数据改进体验
做 AI 陪伴设备,最大的困惑不是“功能不够”而是“不知道用户怎么用”。我一开始连基础日志都没有,出了问题只能瞎猜。后来我建立了一套极简埋点体系,端侧和云端分别记录:
端侧埋点:
- 唤醒成功率、唤醒到开始录音的耗时;
- 音频上传的时长和数据量;
- WebSocket 断线次数和重连耗时;
- TTS 首包到达时间(从说完话到听到第一个字)。
云端埋点:
- ASR 每轮识别的置信度和耗时;
- LLM 每次调用的 token 消耗和首 token 延迟;
- TTS 合成的音频长度和返回耗时;
- 每轮对话的完整时间轴(音频上传完成时间、ASR 完成时间、LLM 首包时间、TTS 完成时间)。
这些数据是优化体验的“眼睛”。举个例子,我通过埋点发现“LLM 首 token 延迟”占了端到端延迟的 40% 以上,后来通过换用支持流式输出的模型服务商,把这一项从 800ms 降到了 250ms,整体体感立刻上了一个台阶。
7. 常见问题与排查技巧实录
7.1 麦克风 I2S 读出来全是噪声或音量极小
这是 INMP441 接入 ESP32-S3 时最常见的坑,我自己也耗了两天。现象有两种:
- 读出来全是白噪声:99% 是 I2S 位宽配置不对。INMP441 输出 24-bit 有效数据但封装在 32-bit slot 里,如果你把 slot 配置成 24-bit 或者 16-bit,时钟采样错位,读出来的自然全是噪声。修正方法就是 32-bit slot + mono 模式;
- 音量极小:确认 L/R 引脚的电平。L/R 拉高是右声道输出,拉低是左声道输出。如果你的 slot 配置和 L/R 不匹配,采到的不是噪声而是极低的串扰信号。
排查 I2S 问题的最好工具是逻辑分析仪,抓 SCK、WS、SD 三根线,一看波形就明白问题出在哪。
7.2 BLE 配网经常失败,或者配网成功后设备没有连上 Wi-Fi
BLE 配网失败的原因里,环境干扰、广播参数、以及手机兼容性三者占比最高。我踩过的一个隐蔽坑是:设备在“开始配网”信号之后立刻去连 Wi-Fi,但这个信号在 BLE 通知发出后如果还有 Pending 的写请求没有处理完,系统就会卡在 BLE 协议栈里,Wi-Fi 连接延后执行,而手机已经认为配网失败。
解决办法是把“开始配网”做成延迟 3 秒生效的定时器,先把 BLE 协议栈的收尾工作处理干净,再启动 Wi-Fi 连接。当然还有常见的问题:使用 5GHz Wi-Fi 的朋友连不上,因为 ESP32-S3 只支持 2.4GHz,这个在配网 UI 里一定要提前提示用户。
7.3 屏幕花屏、刷新闪动
GC9A01 花屏大多是 SPI 速率过高或者电源纹波问题。SPI 时钟我最初拉到 40MHz,花屏严重,降到 20MHz 后稳定。如果你的板子走线比较长,30MHz 以上基本都有风险。另一个因素是屏幕的背光电源和主控共用一路 LDO,屏亮瞬间的大电流拉低了 GPIO 电平,也会导致花屏。把背光供电独立出来就能解决。
7.4 内存不足,跑一段时间后 OOM 重启
ESP32-S3 虽然有 8MB PSRAM,但如果代码里分配不谨慎,还是会被吃干净。最常见的两个原因:
- 用 C 标准库的
malloc而不是heap_caps_malloc(..., MALLOC_CAP_SPIRAM),大内存块会塞进内部 SRAM(只有 512KB),瞬间耗尽; - WebSocket 接收大 JSON 消息时没有限制缓冲区大小,云端发来一条超大消息就能让设备 OOM。
我的经验是:所有超过 4KB 的动态分配全部显式指定MALLOC_CAP_SPIRAM;WebSocket 的 buffer 限制为 8KB,超出的消息直接丢弃并触发错误重传。
7.5 WebSocket 连接几十秒后必断
这个问题困扰了我很久。后来发现是 ESP-IDF 的esp-tls组件和服务器之间对 keepalive 的理解不一致。服务器端没设置 keepalive,网关默认 60 秒没数据就断开 TCP;而我的应用层心跳是 30 秒,理论上不会触发。但如果云端做了一层负载均衡(比如 Nginx),它会独立管理 TCP 连接,应用层心跳过了负载均衡但 TCP 层面的空闲超时还是会发生。
解决办法就是在云端入口同时配置 TCP keepalive 探活参数,保证负载均衡不会因为“空闲”而关闭连接。这个问题在开发机上很难复现,一上生产环境必现,非常隐蔽。
写在最后
做这个项目最大的体会是:AI 陪伴设备不是“硬件 + 一个 API”,而是一套从端侧实时性到云端智能性的系统工程。ESP32-S3 是一个性价比极高的起点,但真正让设备“活起来”的,是端云边界怎么划、会话记忆怎么管、异常情况怎么降级、以及整个系统能不能持续不换硬件就升级。
我个人在后半段的调试中还有一个强烈感受:一定要尽早把埋点和日志体系做起来,不要等“差不多能用”了再补。很多看起来很诡异的线上问题,比如延迟突变、连接闪断、内存缓慢增长,没有数据你根本没法定位。
如果你手里也有一块吃灰的 ESP32-S3,我建议你从最简版本开始:先让设备能唤醒、能录音、能上报音频到云端、能播放云端返回的语音。这条最小通路跑通了,再逐步加上屏幕表情、长期记忆、OTA 这些“可持续演进”的部分。一步步来,很快你也会有一台属于自己、而且还能越用越懂你的 AI 陪伴设备。