很多人看到“AI陪伴设备”这几个字,第一反应是“一个音箱接个大模型API”。真上手之后你会发现,光是让设备稳定听清你说话、在断网时还能给出回应、在不刷固件的情况下增加新技能,就够你折腾好一阵子。我们用了两个多月,从一块合宙的 ESP32-S3-N16R8 开发板起步,搭起了一套可持续演进的端云架构。这台设备原型现在能陪聊、讲故事、设闹钟、做视力保护提醒,而且新增技能完全不用动固件。
这篇文章不是“点灯”教程,而是完整记录我们做这套东西时的核心决策和踩坑过程,覆盖硬件选型、音频链路、BLE配网、端云分工、Agent化演进,以及几个差点把项目拖垮的工程坑。适合正在做AI硬件原型的嵌入式开发者、想给产品加AI能力的创客,以及做IoT云端和应用层的工程师参考。
1. 为什么是 ESP32-S3:一块开发板背后的选型逻辑
1.1 从候选方案对比说起
先亮结论:我们最终选了 ESP32-S3-N16R8 这个型号(16MB Flash + 8MB PSRAM),主控是 ESP32-S3 双核 Xtensa LX7,主频最高 240MHz,支持 Wi-Fi 4 和 BLE 5.0。这个芯片在 AIoT 圈子里不算新,但放到“AI 陪伴设备”这个场景里,它有几个很难替代的长处。
先看我们当时对比过的几类方案:
- 经典 ESP32 / ESP32-D0WD:价格低、生态成熟,但RAM只有520KB,没有PSRAM那么大,跑唤醒词模型时性能余量小。做纯传感器网关绰绰有余,做语音交互就比较紧。
- ESP32-S3:多了向量指令扩展,ESP-DSP里的矩阵乘法、FFT这类操作有明显加速;官方SDK里对语音、屏幕、摄像头的外设支持也全,适合做交互型设备。
- RP2040 / nRF5340:前者双核Cortex-M0+,算力有限,WiFi还得外挂;后者BLE很强,但WiFi要另接模块,增大了硬件面积和功耗管理的复杂度。
- 树莓派 Zero 2 W / 全志这类 Linux 板:系统能力强,可以直接跑 Python、跑 Whisper,但功耗、启动速度和结构尺寸都不占优,做玩具原型行,做成一个随时在线的陪伴设备,发热和供电都是问题。
这里有个容易被忽略的点:AI陪伴设备的“陪伴”,首先意味着长时间在线,其次才是聪明。所以功耗、启动速度、外设实时性,和算力同等重要。ESP32-S3 的 240MHz 双核在绝对算力上不够看,但用在“唤醒、音频采集、网络通信、轻量本地逻辑”这些环节刚刚好,重活交给云端。
1.2 陪伴设备对硬件的需求清单
如果你也准备做类似的东西,建议先列一份需求清单,再回头看硬件。我们的清单大概是这样的:
- 语音输入:至少一路数字麦克风,能在16kHz采样率下稳定工作
- 语音输出:I2S接口的D类功放,比如MAX98357,直接推3W小喇叭
- 网络:Wi-Fi + BLE,一个用于数据通信,一个用于配网和近场交互
- 存储:Flash至少8MB,原因很简单——固件、可选的语音模型、字体资源、OTA双分区加起来很容易就超过4MB
- 内存:最好是带PSRAM的型号。AI相关的音频处理通常会一次性申请大buffer,没有PSRAM会频繁触发内存分配失败
- 调试与扩展:留出UART、I2C、SPI,后面加屏幕、加传感器、加电机都不至于推翻重来
我们用的板子是合宙的 ESP32-S3-N16R8,因为它是开发板里少见的 N16R8 大存储版本,而且引脚基本都引出了,焊线、飞线都方便。如果直接从模组做起,M21、S3-12K 这类也行,但原型阶段没必要给自己添堵。
1.3 双核240MHz的性能余量到底够不够
拿到板子之后,我最关心的是性能余量,因为音频处理最怕“任务调度卡顿导致的声音断裂”。我们的FreeRTOS任务划分大致是这样:CPU0 跑I2S音频采集、WiFi/MQTT协议栈,CPU1跑唤醒词检测、VAD和业务逻辑。ESP-IDF默认就会把协议栈放在一个核上,另一个核留给应用,这个天然的分工很适合我们。
实测下来,四个任务常驻的情况下(I2S读音频、唤醒词检测、MQTT保活、业务逻辑),CPU占用率在唤醒词检测时才明显上涨,平时待机只有个位数。内存方面,内部RAM我们只放了关键任务栈和DMA描述符,音频数据统一走PSRAM,这样即便同时缓存10秒的录音也不会把内存挤爆。
所以回答标题那个问题:对语音陪伴设备来说,性能余量不是用来跑大模型的,而是用来吸收工程实现中的各种浪费。只要把内存规划好,ESP32-S3完全够用。
2. 音频链路是生命线:从麦克风选型到端到端延迟预算
2.1 数字麦克风与I2S数据流
设备要“陪聊”,第一件事是听清人说话。我们用了 INMP441 数字麦克风,I2S 接口,16kHz 16bit 单声道。为什么不用模拟麦克风加ADC?因为模拟链路还要解决偏置、放大、抗干扰的问题,数字麦直接把PDM转成I2S数据,代码少一个量级。
关键代码其实很简单:ESP-IDF里配置一个 I2S 驱动,开启 DMA 接收,然后从 DMA buffer 里拿数据。这里最常见的错误是把每次读取的一帧数据想成“一句话”,实际上音频是流式的,必须用一个持续追加的环形缓冲区把帧串起来。
size_t bytes_read = 0; int16_t *buf = (int16_t *)heap_caps_malloc(frame_samples * sizeof(int16_t), MALLOC_CAP_SPIRAM); while (1) { i2s_channel_read(i2s_rx_handle, buf, frame_bytes, &bytes_read, portMAX_DELAY); status = ring_buffer_write(&audio_rb, buf, frame_bytes); if (status != RB_OK) { // 这里要处理缓冲区满了的情况,直接丢帧或标记溢出 overflow_cnt++; } }配套的还有一件事:DMA的buffer大小要靠实测调。我们最初用默认配置,设备一开扬声器就出现爆音,后来把DMA描述符数量和buffer size都调大了一档,问题立刻缓解。原因是WiFi和音频共用总线时,DMA在总线竞争下无法保证稳定出数据,buffer太小就会欠载。
2.2 唤醒词与VAD:让设备知道该“听”了
语音唤醒用的是乐鑫官方的 ESP-SR(esp-skainet)框架,支持中文唤醒词和命令词识别。模型可以烧录到Flash里直接跑,支持多命令词唤醒。我们把唤醒词设成“你好小智”,又加了三个本地命令词“停止”“大声点”“安静”,这三个词不走云端,端侧识别后直接执行,能减少很多误操作。
VAD(Voice Activity Detection,语音活动检测)的作用更基础:判断当前是人声还是静音。VAD一旦判断是静音,就可以停止上云、降低CPU频率甚至进入light sleep,这对电池供电的设备非常重要。我们实测,纯待机(VAD在跑、WiFi连接已保持)时平均电流大约在80-120mA之间,如果使用电池供电,这个值还有优化空间,但对原型机来说已经能接受。
这里有一个容易被忽略的“声音打架”问题:TTS播放的时候,麦克风会把扬声器的声音也收进去。工业音箱有专门的AEC(回声消除)模块,但我们原型阶段没有上。我们的做法是:播放TTS时临时屏蔽唤醒和VAD,播完再恢复。代价是一段TTS期间用户无法打断,用起来有点像老式录音电话。如果做二代产品,我建议把AEC或打断检测提前到需求里,否则体验感差很多。
2.3 端侧处理与整体交互延迟
真实产品里,用户对延迟是很敏感的。我们把整条链路的延迟预算拆开看,大概是这样的:
- 唤醒词检测:150-250ms
- 用户说完话,音频结束判定(VAD检测到静音):300-800ms
- 录音上云、云端ASR:300-500ms
- LLM处理:500-1500ms
- TTS首包输出:400-800ms
- 端侧播放:即时
加起来,端到端响应大概在2-3秒之间。这个数字在一问一答的场景下勉强能接受,但一旦涉及多轮对话,体验就会感觉“像在和一个反应慢半拍的人聊天”。我们的处理方式是:ASR采用流式识别,用户还没说完,云端就开始出中间结果;TTS选择流式返回,边下边播。首包的延迟优化优先级远高于生成完整内容的延迟,这一点在做端云架构时一定要想清楚。
3. BLE配网与设备身份:从裸板到云上节点的第一步
3.1 为什么选BLE配网,而不是SmartConfig或AP配网
一台设备要上云,绕不开配网。市面上常见的配网方式有三种:
| 配网方式 | 操作体验 | 稳定性 | 适用场景 |
|---|---|---|---|
| AP配网(SoftAP) | 手机需要切换WiFi连设备热点,再切回来 | 最稳定,不依赖路由器 | 信号环境复杂、设备量大 |
| SmartConfig(ESP Touch) | 手机App发UDP组播包,无需切换WiFi | 依赖路由器是否允许组播,兼容性问题多 | 家庭路由器,但兼容性看运气 |
| BLE配网 | App通过BLE把WiFi信息发给设备,设备自动连接 | BLE信道独立,基本不受WiFi环境影响 | 交互体验好,还能反向读设备状态 |
我们选BLE配网,核心原因是体验和可控性的平衡。BLE配网过程中,App能实时知道设备连WiFi的结果:是密码错误、路由器信号弱,还是连上外网了?这些信息都能通过BLE通知直接弹给用户。SmartConfig做不到这种双向交互,AP配网又要求用户在手机设置里来回切换,容易把小白用户搞懵。
ESP32-S3 上实现BLE配网有两种协议栈选择:Bluedroid 和 NimBLE。Bluedroid 功能全,但体积大、内存占用高;NimBLE 是开源实现,内存占用低得多,配网场景只需要 GATT server,NimBLE完全够用。
3.2 GATT服务设计与状态机实现
我们设计的GATT服务非常简单,一个Service,三个Characteristic:
- Write Characteristic:接收SSID
- Write Characteristic:接收WiFi密码
- Notify Characteristic:上报配网状态(0x01: 连接中,0x02: 成功,0x03: 密码错误,0x04: 超时)
流程状态机如下:
BLE_START_ADV -> BLE_WAIT_CREDENTIAL -> WIFI_CONNECTING -> WIFI_CONFIG_SUCCEED | | +----------> WIFI_CONFIG_FAIL <---- 重新等待设备上电后在BLE广播中携带自己的设备ID,App扫描到后点击连接,依次写入SSID、密码。设备拿到凭据后启动WiFi连接,同时通过Notify把状态异步回报给App。无论成功失败,App都能在界面立刻看到结果。
这里有一个细节:WiFi的SSID和密码我们统一用UTF-8编码在JSON里传输,而不是分别写两个字段。原因很简单,有些SSID本身包含特殊字符,分开传容易在字符串边界出问题,JSON整体传过去解析一次就够了。JSON体积很小,BLE的20字节ATT MTU虽然理论上是限制,但实际能协商到更长的MTU,传几十字节没压力。
3.3 安全细节与设备身份
配网过程最容易被人吐槽的是“WiFi密码明文传输”。严格来说,BLE链路本身有加密机制,只要App和设备完成配对绑定,传输内容在BLE链路层就是加密的。我们建议在App侧一定要开启BLE配对,不要用“just works”这种免配对模式。
配网成功之后,设备把WiFi凭据写入NVS。这里建议开启ESP-IDF的NVS加密特性,否则二进制flash被读出去,凭据就泄露了。同时,我们利用芯片eFuse生成一个全局唯一的设备UUID,后面MQTT的clientId、云端的设备注册表都靠它来关联数据。
设备上云之后,身份认证可以分两个等级:
- 原型阶段:MQTT使用 username/password + 唯一clientId,服务器校验。
- 量产阶段:建议升级为设备证书双向TLS,每个设备烧入独立证书,服务端离线验证。
我们目前停在第一阶段,但架构上已经预留了证书校验的扩展点。因为从一开始就意识到:设备身份和通信安全,很难在架构定稿后再往里塞。
4. 端云架构的分工边界:哪些逻辑留在端侧,哪些必须上云
4.1 通信选型:为什么是MQTT
陪伴设备的端云通信,实质是双向低频通信:设备主动上报状态,云端偶尔下发指令。HTTP轮询不是不行,但问题很明显——云端给设备下发消息时必须先建立连接,延迟不可控。WebSocket能做到长连接,但协议层太重,设备端的断线重连逻辑要自己写。
我们最终选了MQTT over TLS,原因有三个:
- 协议是发布/订阅模型,天然适合“云端下发指令”这种反向控制
- 断线重连、遗嘱消息、会话保持这些机制都是现成的,不用自己造
- 生态成熟,云端的EMQX或Mosquitto一跑就起来,开发效率高
配置上,我们把QoS设为1,意味着消息至少送达一次,但允许重复。为什么不用QoS0(可能丢)或QoS2(恰好一次)?设备端命令重复一次通常能幂等处理,而消息丢一次可能就真的丢了,QoS1是性价比最高的折中。
4.2 端侧、云端的分层模型
我们把整个系统的职责分成四层:
- 设备能力层:麦克风、扬声器、LED、传感器、按键。这一层只做物理交互,不关心业务逻辑。
- 设备业务层:唤醒、VAD、本地命令词、播放控制、音频流上传。这一层和设备强相关,但尽量不做“理解”的工作。
- 云端网关层:设备连接管理、消息路由、状态管理、技能调度。这一层把设备接入与具体AI能力解耦。
- AI能力层:ASR、LLM、TTS、知识库、各种工具调用。这一层为设备提供“智能”,也是后期扩展空间最大的地方。
这个分层的核心思想是:设备端代码追求“稳定”,云端能力追求“变化”。设备端如果经常要改,意味着每次都要OTA、都要用户配合,体验极差。所以我们把所有容易变化的部分(对话逻辑、技能定义、文案、模型参数)都放到云端,通过配置下发而不是固件升级来变更。设备端只维护一套稳定的原子动作协议。
4.3 断网降级:陪伴设备不能因为没网就变砖
一个很容易被忽略的产品需求是:设备没网的时候怎么办?我们实测过,家里断网、云服务故障、用户在电梯里,任何一种情况都可能发生。如果设备在没网时只会说“网络开小差了”,这个产品基本不会有人在日常使用它。
我们的降级策略分三档:
- 轻度降级:网络通但云端响应慢。此时设备继续尝试,同时播放“信号不太稳定,再等等我”这类话术,让用户感觉设备在思考而不是死机。
- 中度降级:网络断开但本地功能可用。闹钟、计时器、本地预置的语音包(比如问候语、几个固定笑话)全部走本地,至少保证“陪伴感”不断。
- 重度降级:完全没有网络且电量低。设备进入低功耗模式,关键日志保存在本地,恢复网络后自动续传。
这些降级逻辑不需要云端的参与,全部跑在设备端。我们在设备端维护了一个“本地能力表”,断网时按优先级从高到低提供服务。比如本地故事机模式:设备存了20条不同主题的短故事,断网时随机播一条,比直接“无网络”体验好太多。
5. 从固定对话到AI Agent:可持续演进的交互架构
5.1 第一版为什么不可持续
说实话,我们的第一版就是网上最常见的“ESP32-S3 + HTTP POST大模型API”玩法:设备录音,上传到后端,后端拿文本调LLM,返回一段文本,设备TTS播出来。如果用户说“明天7点叫我起床”,我们就在后端写正则匹配,看到“闹钟”和“7点”就拼一个闹钟指令。
这套第一版跑通只花了两天,但很快发现三个问题:
- 新增一个功能要同时改后端和大模型的提示词,还要改设备端动作逻辑,改动全链路都跑一遍
- LLM经常返回些稀奇古怪的文本,设备端没法统一处理,只能原样TTS出来
- 用户意图一旦跨功能组合,比如“明天天气好就叫我去跑步”,硬编码逻辑基本无解
问题的本质在于:把“理解”和“执行”耦合在一起了。LLM擅长理解,设备擅长执行,中间应该有一个标准化的“动作协议”来衔接。
5.2 引入Agent后的动作协议设计
我们借鉴了AI Agent的tool calling思想,做了一套非常轻量的设备动作协议。云端大模型的作用不再直接输出用户要的文本,而是输出一个结构化的“动作意图”,例如:
{ "intent": "alarm_set", "params": { "time": "07:00", "repeat": ["monday", "tuesday"] }, "tts": "好的,周一和周二早上7点提醒你,我会准时叫你起床。" }设备端不需要理解“alarm_set”背后的业务逻辑,它只负责执行动作列表。新增支持一种设备动作,需要改动设备固件;但如果只是新增一个“技能”——比如健康打卡、成语接龙、心情陪伴——不需要改固件,只需在云端注册技能描述、触发规则和参数schema,再由LLM根据用户请求选择并填充参数。
这套设计思路和当前主流的AI Agent框架是一致的。我们后端用 Spring AI 作为模型的统一接入层,好处是它对Qwen、DeepSeek、GLM这类主流模型都有统一接口,模型切换只改配置不改代码。Spring AI 里同样提供了函数调用能力,我们把这些函数注册成设备能力,让LLM自己去路由。
5.3 设备端事件总线与原子动作
设备端这边,我们做了两件事:一是统一了“事件”的概念,二是统一了“动作”的概念。
事件包括:唤醒成功、用户说完话、按键按下、定时器到点、传感器触发。动作包括:播放TTS、播放URL音频、设置闹钟、点亮LED、马达震动、静音、进入OTA模式。事件产生后进入事件队列,设备端动作执行器循环消费事件队列。
这套事件总线的价值在后期才体现出来。比如“讲完一个故事后LED自动切换成呼吸灯”这种组合行为,以前要写专门的任务,现在只需要在云端配置一个“故事结束”事件和“LED呼吸灯”动作的绑定规则。设备端代码一行没动,新行为就上线了。
5.4 新增一个技能只需要几步
实际演示一下,假如我们要给设备加一个“成语接龙”技能:
- 云端新增一个技能服务,接收成语接龙的请求并返回“下一个成语”和解释
- 在技能注册表里添加配置:技能名、描述、可调用的设备动作(TTS播放、超时等待)
- Spring AI 里注册一个函数定义,把“cy_jielong”关联到这个技能服务
- 用户对设备说“我们来玩成语接龙”,LLM 判断意图后调用技能,返回动作序列
- 设备端正常执行TTS和等待,结束
整个过程不需要重新烧录固件,也不需要改设备端代码。这是我认为“可持续演进”最实在的体现:设备端稳定,云端活跃,能力以配置和函数为单位扩展,而不是以固件版本为单位扩展。
6. 演进过程中踩过的坑:三个差点拖垮项目的工程问题
6.1 音频buffer溢出与内存碎片
第一个坑出现在第一轮长时间运行测试。设备连续跑了两小时后,声音开始频繁出现“滋滋”底噪,重启后恢复,再过几小时又复发。
排查过程:先在日志里看音频任务的溢出计数,发现从某个时间点开始,ring buffer溢出次数飙升到几万。进一步定位,发现控制循环里有任务在不停动态申请内存释放内存,时间一长导致PSRAM和内部RAM都出现碎片,大的音频buffer申请不到,只能退而求其次用小块buffer,于是音频链条的节奏被打乱。
修复方法是两点:
- 所有语音链路的内存改成启动时一次性静态分配,运行时绝不动态申请
- 所有循环中用到的临时对象尽量复用,避免频繁malloc/free
内存碎片是嵌入式开发的经典问题,在音频这种实时性要求高的场景会被无限放大。写代码时的建议很简单:设备端程序的内存分配,在main函数里统一规划好。
6.2 集体OTA之后,MQTT重连风暴
有一次我们灰度发布新固件,给几十台测试设备同时推送OTA。结果OTA完成后云端的MQTT broker连接数瞬间涨了三倍,部分设备被broker主动踢下线,然后这些设备立刻重连,形成恶性循环。
原因很清楚:所有设备都是同一时间收到新固件、同一时间重启、同一时间发起MQTT连接,造成的“重连风暴”把broker打爆了。
我们的解决措施分成三层:
- 设备端:MQTT重连采用指数退避加随机抖动,首次失败0.5秒,第二次1秒,第三次2秒,上限30秒,每次加一个0-500ms的随机偏移
- 云端:broker的会话过期时间调短,避免大量死会话长期占用资源
- 发布运维:OTA分批推送,每批间隔30-60秒,避免设备集体上线
这套组合拳之后,再没出现过重连风暴。顺便说一句,指数退避加抖动这段逻辑,建议任何做IoT设备的人都提前写上,平时用不上,一用就救命。
6.3 云端响应文案可配置化
最后一个坑比较隐蔽:设备在异常场景下播报的文案——比如“我没听清”“网络出问题了”“这个技能我还在学”——第一版是硬编码在设备里的。后来产品要调文案,结果每次都要发一版OTA,来回折腾非常痛苦。
我们把这类文案全部搬到云端配置里,设备在启动时拉取一次,之后每次用到文案时先从本地缓存查询,找不到再用默认值。这样产品改文案、调整语气,都不需要动设备。
这个改动虽然小,但对团队协作方式的改变是巨大的:设备端开发者不用再陪着产品改文案走发布流程,产品自己也能完成迭代。做硬件团队,一定要尽早划分“硬件稳定边界”和“业务快速迭代区”,不然会被每一次微小的产品改动拖死。
如果让我重新做一次这个项目,我会在第一天就把动作协议、事件总线和云端配置下发这三件事定下来,而不是等版本迭代时再补。设备端的代码可以写得保守、稳定、无趣,但云端一定要灵活、快速、可配置,这才是AI陪伴这类产品能持续演进的根本逻辑。最后再分享一个小技巧:开发阶段一定要把设备日志和事件自动上报打通,我们后期定位所有问题几乎都靠日志回放,这比任何调试器都管用。