
家里那只 AI 玩偶第一版做出来的时候亲戚们都说“挺厉害能聊天”。但我自己心里清楚它顶多算个“按键对讲机”。直到有一天小朋友对着它连说三句“我要听恐龙的故事”玩偶还在慢吞吞处理第一句一边播放一边错过了后面所有指令。那一刻我意识到“能对话”和“连续对话”之间隔着一条完整的链路重构。这篇文章就把这次 WebSocket 二进制音频链路重构的完整过程拆开讲清楚。包括为什么放弃 HTTP 轮询、为什么选 WebSocket、音频帧协议怎么设计、ESP32 端怎么从“录完发”改成“边录边发边收边播”以及服务端 ASR、LLM、TTS 流水线如何支撑打断和连续问答。文末还有延迟实测数据和几个典型坑的完整排查记录。如果你手上正好有 ESP32 AI 语音设备项目或者在做嵌入式音频上云的链路设计这篇文章应该能帮你少走不少弯路。1. 从“按键对讲”起步第一版玩偶到底哪里不行1.1 最初的一问一答长什么样第一版架构特别简单ESP32 板上接一个 INMP441 数字麦克风和一个 MAX98357 I2S 功放用户按住玩偶身上的按键说话松手后本地把录音暂存在 PSRAM然后通过 HTTP 一次性 POST 给服务端。服务端按“ASR 识别 - 调用 LLM - TTS 合成”的顺序跑完最后返回一段 MP3 或 WAV 音频ESP32 下载完再播放。这个流程在很多教程里都能看到也确实能跑通。但用起来非常别扭每次对话都要按一次按键而且是强制半双工按住的瞬间心里就得组织好语言。从松手到听到回应最快也要 2 秒通常 35 秒等得心焦。最致命的是音频是一个完整文件服务端必须等用户说完才能开始识别。用户连续说两句第二句直接丢失因为录音状态早就结束了。1.2 三个让我决定重构的瞬间第一个瞬间就是开头说的“恐龙故事”事件。孩子连说三句玩偶只回答了第一句后面两句全部丢掉。这不是模型笨是整个链路设计上就不支持连续指令。第二个瞬间发生在语音识别率上。小朋友说话停顿多经常是“我想听……那个……恐龙的故事”按一次键录音ASR 拿到一大段停顿识别结果里全是语气词。后来我意识到按键录音天然鼓励人说长句而长句对 ASR 的断句能力要求极高。反过来如果做成流式识别VAD 能按句切开停顿只是自然语句边界识别率会明显提高。第三个瞬间是延迟。HTTP 方案里整个端到端链路是串行的录音结束 → 上传 → ASR → LLM → TTS → 下载 → 播放。每一环节的时间都是叠加的。我统计过公网环境下平均端到端延迟在 3.8 秒左右偶尔到 6 秒。这种延迟放在智能音箱上完全不可接受。1.3 重构前先想清楚我要的“连续对话”究竟指什么重构之前我列了一个需求清单用来约束自己不被“顺手的方案”带跑需求具体说明免按键去掉物理按键触发靠 VAD 检测说话开始和结束断句响应用户说完一句系统能在一句话结束后尽快回应而不是等整段录音结束动态插话打断播放 TTS 时如果用户开始说话播放立即停止并进入聆听状态低延迟局域网内端到端延迟控制在 1.5 秒以内公网控制在 2.5 秒以内稳定性长时间连续会话不掉线WiFi 抖动时能自愈这五条需求列完结论很明确必须把“上传一次文件”改成“全双工长连接 流式音频”而实现它的通信底座基本只有 WebSocket 合适。2. 链路选型为什么最终是 WebSocket 二进制流而不是 HTTP 或 MQTT2.1 HTTP 轮询能跑但注定是死路有人可能会问既然第一版已经是 HTTP 了能不能在 HTTP 基础上加轮询比如 ESP32 每隔 200ms 上传一小段音频服务端返回“识别中”或“有结果”。听起来可行实际一算就崩了HTTP 每次请求都有 Headers 开销就算只传 320 字节音频加上 TCP 握手和 TLS 握手的成本一个请求的网络开销可能是数据本身的 10 倍以上。更麻烦的是HTTP 是单向请求-响应模型服务端没法主动把 TTS 音频推给 ESP32只能靠 ESP32 持续轮询“有结果了吗”这会产生大量无效请求。实测数据用了 HTTP 短轮询之后同样一段 5 秒的交互网络请求量从 1 次涨到 30 多次WiFi 模块的电流显著上升而且延迟并没有降低。方向直接排除。2.2 MQTT 的诱惑与陷阱MQTT 是嵌入式场景的明星协议我也认真考虑过。它的优点是 QoS 机制完善、服务端主动下发很容易、社区生态成熟。但做音频实时流时MQTT 有几个绕不开的问题MQTT Broker 主要面向消息分发音频流如果需要 20ms 一帧、每帧 320 字节频率过高时 Broker 转发压力和 Topic 管理复杂度都会上来。MQTT 的 QoS 1/2 有确认重传机制在弱网下可能因为重传导致音频帧乱序而语音流对乱序很敏感。做打断功能时控制指令如“停止播放”需要极高优先级但在共享 Topic 的架构里音频帧和命令帧容易互相排队。MQTT 更适合“设备上报状态、平台下发控制”这种低频消息场景。音频流这种高频双向数据直接走点对点的 WebSocket 反而更简单。2.3 WebSocket 二进制帧唯一对的选择最终选定 WebSocket理由有三个第一天然全双工。一个 TCP 连接上ESP32 可以持续上传音频帧服务端可以同时下发 TTS 音频帧和控制命令。双方不需要约定“谁先发谁后发”这就是连续对话需要的通信模型。第二二进制帧开销极低。WebSocket 帧头最小 2 字节加上自定义业务帧头也就 810 字节。比 HTTP 的 Headers 小两个数量级。第三库生态成熟。服务端我用 Go 的 gorilla/websocket 做网关ESP32 端用 Arduino 生态里的 WebSockets 库两边对接很方便。这里补充一个选型细节如果 ESP32 端条件允许尽量用WiFiClientSecure WebSockets 的 WSS 连接。我第一版图省事用了明文 WS局域网内没问题一上公网就被运营商嗅探和干扰。走 WSS 之后至少传输层不用再担心。2.4 音频编码格式怎么定通信协议定了接下来是音频数据本身。我的原始采集格式是 16kHz / 16bit / 单声道 PCM一帧 20ms 就是 320 字节。如果直接裸传 PCM20ms 发一次单路双向每秒钟才 32KB带宽根本不是问题。那到底要不要压缩编码我一开始想用 Opus因为压缩率高、音质好。但踩了坑ESP32 上跑 Opus 编码需要额外算力编码一帧 20ms 的 Opus在 ESP32-S3 上大概要 35ms CPU 时间这会挤占 I2S 采集和 WiFi 任务的时间片。而且解码端也麻烦服务端和客户端都要维护一套编码器的上下文。后来我换了个思路PCM 直传 低采样率。16kHz 16bit 单声道本身带宽需求很小局域网完全无压力公网 WSS 链路上 32KB/s 上行、32KB/s 下行也远没到宽带上限。省掉编解码环节整个链路的 CPU 开销和内存占用都下来了。提示如果你所在环境公网带宽确实紧张可以考虑 ADPCMIMA-ADPCM这类轻量压缩ESP32 上编码一帧 320 字节 PCM 到 160 字节 ADPCMCPU 开销可以忽略省一半带宽。代价是音质稍有损失。当前方案先求稳压缩留作后续优化项。3. ESP32 端重构从“录完发”到“边录边发边收边播”3.1 硬件与开发环境我用的开发板是 ESP32-S3-DevKitC-1板载 PSRAM这个对音频缓冲很重要。麦克风是 INMP441I2S 数字输出功放是 MAX98357AI2S 数字输入接一个 3W 小喇叭。开发环境是 Arduino IDE配 esp32 核心库 2.0.x。接线表器件引脚ESP32-S3 引脚INMP441SCKGPIO 4INMP441WSGPIO 5INMP441SDGPIO 6MAX98357ABCLKGPIO 15MAX98357ALRCGPIO 16MAX98357ADINGPIO 17注意INMP441 和 MAX98357A 的 I2S 时钟必须分开配不能共用一个 I2S 外设否则采播同时进行时会互相干扰。3.2 音频采集I2S 双缓冲与 DMA重构后的第一件事是把采集从“一次性录一大段”改成“持续小段读取”。我用的是 I2S 自带的 DMA 环形缓冲机制。ESP32 的 I2S 驱动会持续把麦克风数据写入 DMA buffer应用层只需要定期把数据读走。核心代码大致是这样#include driver/i2s.h #define I2S_WS 5 #define I2S_SCK 4 #define I2S_SD 6 #define SAMPLE_RATE 16000 #define SAMPLE_BITS 16 void setup_i2s_mic() { i2s_config_t i2s_config { .mode (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX), .sample_rate SAMPLE_RATE, .bits_per_sample I2S_BITS_PER_SAMPLE_16BIT, .channel_format I2S_CHANNEL_FMT_ONLY_LEFT, .communication_format I2S_COMM_FORMAT_STAND_I2S, .intr_alloc_flags ESP_INTR_FLAG_LEVEL1, .dma_buf_count 8, .dma_buf_len 320, .use_apll false, .tx_desc_auto_clear false, .fixed_mclk 0 }; i2s_pin_config_t pin_config { .bck_io_num I2S_SCK, .ws_io_num I2S_WS, .data_out_num I2S_PIN_NO_CHANGE, .data_in_num I2S_SD }; i2s_driver_install(I2S_NUM_0, i2s_config, 0, NULL); i2s_set_pin(I2S_NUM_0, pin_config); }注意dma_buf_len我设置成 320正好是 20ms 16kHz 的采样点数。这样每次 DMA 中断时读出来的数据天然就是 20ms 一个音频帧不用再做拼接。然后是主循环里的读取逻辑。我建了一个生产者-消费者队列I2S 读线程或主循环每 20ms 读一次 DMA buffer把数据塞进队列WebSocket 发送任务从队列取数据封装成二进制帧发送。两个任务之间用 FreeRTOS 队列做解耦避免网络抖动导致采集线程阻塞。// 发送任务 void audio_send_task(void *param) { int16_t pcm[320]; size_t bytes_read 0; while (true) { esp_err_t err i2s_read(I2S_NUM_0, pcm, sizeof(pcm), bytes_read, portMAX_DELAY); if (err ESP_OK bytes_read 0) { if (xQueueSend(audioQueue, pcm, pdMS_TO_TICKS(10)) ! pdTRUE) { // 队列满丢弃当前帧优先保证实时性不做重传 } } } }这里有个重要的取舍音频帧不做重传机制。语音实时流对延迟的敏感度远高于对丢包的敏感度如果因为网络拥堵重传一帧后面所有帧都会被堵住体验反而更差。所以策略就一句话发不出就当这帧不存在。3.3 自定义二进制帧协议WebSocket 本身只保证字节流的传输不关心内容。为了让 ESP32 和服务端能顺畅沟通我定义了一个轻量二进制帧协议字段长度说明帧头1 字节固定 0xA5用于同步帧类型1 字节0x01 上行音频 / 0x02 下行音频 / 0x03 控制命令 / 0x04 事件 / 0x05 心跳序列号2 字节递增用于排序和丢帧检测时间戳4 字节毫秒用esp_timer_get_time()换算数据长度2 字节大端序数据区N 字节按帧类型定义CRC162 字节对帧头到数据区做 CRC 校验我一开始没加序列号和时间戳后来调试时发现WiFi 弱网状态下 WebSocket 底层偶尔会乱序或丢帧没有序列号根本定位不了问题。加上之后定位效率高了很多。CRC 校验则用来丢弃损坏的帧避免音频噪声突然爆音。帧封装逻辑void send_audio_frame(int16_t *pcm, size_t samples) { uint8_t frame[350]; int idx 0; frame[idx] 0xA5; frame[idx] 0x01; // 上行音频 frame[idx] (seq 8) 0xFF; frame[idx] seq 0xFF; seq; uint32_t ts (uint32_t)(esp_timer_get_time() / 1000); frame[idx] (ts 24) 0xFF; frame[idx] (ts 16) 0xFF; frame[idx] (ts 8) 0xFF; frame[idx] ts 0xFF; int payload_len samples * sizeof(int16_t); frame[idx] (payload_len 8) 0xFF; frame[idx] payload_len 0xFF; memcpy(frame idx, pcm, payload_len); idx payload_len; uint16_t crc calc_crc16(frame, idx); frame[idx] (crc 8) 0xFF; frame[idx] crc 0xFF; webSocket.sendBIN(frame, idx); }3.4 播放链路从“播完整文件”到“流式播放”下行链路是重构里改动最大的一块。第一版是下载完整音频文件再播重构后改成服务端边合成边推送、ESP32 边接收边播放。播放端我用了一个专门的播放任务不断从播放队列取音频帧写入 I2S 功放。播放队列的容量我调成了 500ms 左右的音频量。为什么是 500ms太小了容易在弱网下播放断流太大了会让打断响应变慢。实测下来 400600ms 是甜点区。播放任务的伪代码void audio_play_task(void *param) { int16_t pcm[320]; while (true) { if (xQueueReceive(playQueue, pcm, pdMS_TO_TICKS(100)) pdTRUE) { size_t written 0; i2s_write(I2S_NUM_1, pcm, sizeof(pcm), written, portMAX_DELAY); } else { // 超时无数据静音填充避免 I2S 输出噪声 int16_t silence[320] {0}; i2s_write(I2S_NUM_1, silence, sizeof(silence), written, portMAX_DELAY); } } }注意I2S 播放端在数据中断时不能直接停止否则功放会输出刺耳的啪嗒声。必须喂静音数据。接收 WebSocket 下行帧时根据帧类型区分音频帧和控制帧。控制帧里的“停止播放”命令优先级最高收到后立即清空播放队列并发出一个“播放已停止”的事件给服务端。3.5 内存和任务调度的坑ESP32-S3 虽然有 PSRAM但 WebSocket 的发送缓冲和播放队列如果都用 PSRAM 会有个问题PSRAM 访问速度比内部 SRAM 慢而且 I2S DMA 不能直接访问 PSRAM。所以我把 I2S 相关的 DMA buffer 放在内部 SRAMWebSocket 的发送缓冲和业务队列放 PSRAM。内存占用实测模块内存类型占用大小I2S 采集 DMA内部 SRAM10KBI2S 播放 DMA内部 SRAM10KB音频发送队列50 帧PSRAM32KB音频播放队列40 帧PSRAM25KBWebSocket 收发缓冲PSRAM8KBWiFi/TLS 相关内部 SRAM约 40KB还有一个容易忽略的坑Arduino 的loop()里如果做了太多耗时操作WiFi 底层任务会被饿死导致连接假死。重构后我把所有业务逻辑都拆成 FreeRTOS 任务loop()里只放一个vTaskDelay(10)保证低优先级任务有机会运行。4. 服务端流水线ASR、LLM、TTS 怎么做到“边听边答”4.1 WebSocket 网关与会话管理服务端我用 Go 写了一个 WebSocket 网关负责维护每个玩偶的长连接。每个连接进来网关创建一个会话对象会话里包含音频输入通道、音频输出通道和控制命令通道。网关的核心任务不是业务逻辑而是连接生命周期管理。包括认证握手设备用设备 ID Token 换取 WebSocket 连接。心跳保活服务端每 30 秒发一个心跳帧如果 3 次没收到响应就主动断开。设备侧断线重连ESP32 端会保存会话状态重连后从中断处继续。我见过很多项目把业务逻辑全塞进 WebSocket Handler 里最后代码一团乱。我的做法是Handler 只负责把二进制帧解析成内部消息对象然后投递给各个业务模块。模块之间通过 channel 通信不直接依赖 WebSocket 连接对象。4.2 一个有趣的问题半双工和全双工的边界真正的连续对话要求服务端在播放 TTS 的同时还能接收和处理用户的新语音。但这里有个工程约束ASR 模型、LLM 和 TTS 三者天然有先后依赖不能完全并行。所以我在服务端设计的不是“全双工”而是“带打断的快速半双工”。状态机状态说明触发条件IDLE空闲等待语音无活动LISTENING正在接收音频流并做 VAD 检测收到音频帧且 VAD 判定为语音开始PROCESSING语音已结束ASR/LLM/TTS 处理中VAD 判定为语音结束PLAYING正在下发 TTS 音频TTS 首包生成INTERRUPTING播放被用户打断播放状态下收到新的有效语音帧关键在 PLAYING 状态。播放状态下如果 VAD 检测到用户开始说话网关立即向播放通道发一个“停止播放”控制帧同时清空 TTS 发送队列通知 TTS 合成任务取消当前任务。ESP32 端收到控制帧后清空播放队列整个中断过程要做到 100ms 以内。4.3 打断Barge-in的关键VAD 优先级做打断最容易踩的坑是用户只是咳嗽一声、挪动一下玩偶系统就误判为打断。所以服务端的 VAD 不能只看“有没有声音”还要看声音持续时间和能量。我的 VAD 参数语音起始阈值连续 30ms 能量超过阈值判定为语音开始。语音结束阈值连续 400ms 能量低于阈值判定为语音结束。静音抑制低于阈值的时间超过 2 秒关闭音频上行节省流量。在 PLAYING 状态下VAD 的灵敏度会调高一档因为用户插话往往比较轻。这算是一个工程上的妥协打断漏判的代价大于误判的代价。4.4 延迟预算拆分为了让整体延迟可控我把每一环的耗时都做了预算环节目标耗时WiFi 采集 20ms 帧 WebSocket 发送2030ms上行传输局域网210ms服务端 VAD 检测0ms边收边检ASR 识别流式句尾返回150450msLLM 首 token8002000msTTS 流式首包150400ms下行传输210msESP32 播放缓冲50100ms合计1.23.0s这个预算表我在重构前就写好了整个开发过程都在按这个表调优。最后实测局域网端到端延迟稳定在 1.31.8 秒公网 23.5 秒跟预算基本吻合。5. 实测数据和踩坑排查记录5.1 延迟实测数据用局域网 公网两种环境分别测试 50 轮对话统计结果如下指标局域网公网平均端到端延迟1.45s2.8sP95 延迟1.9s4.2s最长延迟2.6s6.1s打断响应时间80ms120ms断线重连耗时1.2s2.5s打断响应时间是指“用户开始说话”到“喇叭完全停止出声”的时间。这个数据达标连续对话的体验就基本建立了。5.2 WebSocket 1006 断连的完整排查链路这是整个重构过程中最折磨人的一个坑。现象是玩偶对讲 515 分钟之后WebSocket 连接突然断开onclose回调里 code 是 1006异常关闭而且服务端没有主动断开的日志。我按下面这个顺序排查最后才定位到根因。这个链路值得完整写出来第一步查服务端日志。确认服务端没有调用Close()也没有报错。排除服务端主动断开。第二步查 ESP32 端日志。发现断开前一刻WiFi 信号正常RSSI 在 -55dBm 左右排除信号弱导致的问题。第三步查 ESP32 内存。通过ESP.getFreeHeap()打印发现 WebSocket 断开前堆内存并没有明显下降。排除内存泄漏。第四步怀疑 TLS 证书问题。因为用的是 WSS怀疑证书链在某些网络环境下验证失败。但测试时用的固定 IP不涉及域名排除。第五步抓网络包。用 Wireshark 在服务端抓包发现断开前 30 秒 TCP 层已经有重传断开时是 ESP32 发了一个 RST 包。这说明问题出在设备侧网络栈。第六步查 WiFi 省电模式。ESP32 默认开了 WiFi modem sleep在低流量时会进入省电模式导致 TCP 保活失效NAT 超时会话被服务端或中间设备悄悄清掉。关掉 modem sleep 后问题明显减少但没有根除。第七步查心跳机制。最终根因是服务端和 ESP32 之间的应用层心跳间隔太长当时是 60 秒而中间运营商 NAT 的空闲超时大约是 3045 秒。TCP 层长时间没有数据包NAT 表项被清掉服务端再发数据时ESP32 收到一个对端不可达的 ICMP直接 RST。解决方案是双重保活应用层心跳缩短到 15 秒。WiFi 层用esp_wifi_set_ps(WIFI_PS_NONE)关掉省电模式权重各一半都改掉之后 1006 断连彻底消失。5.3 音频卡顿与播放缓冲策略还有一个现象公网环境下TTS 音频播放偶尔会有“嗝哒”声像是中间掉了一段。排查后发现问题出在 TTS 合成速度跟不上播放速度。我用的 TTS 服务是流式返回但首包后每包间隔不稳定。如果播放队列里积累的音频不够播放端会饿死然后静音填充造成可感知的卡顿。解决办法有两个服务端做预取TTS 合成完第一句话后不要立刻把整个音频流推给设备而是先攒够 300ms 的音频再开始下发这样播放端一启动就有缓冲。播放端动态调速如果播放队列里的数据持续低于 300ms说明网络或 TTS 速度跟不上会把 I2S 的采样率从 16000 调整为 15900让播放速度稍慢给上游一点缓冲时间。这个微调人耳完全听不出来。5.4 WiFi 掉线和重连策略长连接场景下WiFi 掉线不可避免。关键是掉线后怎么重连。我设计了一套三级恢复机制第一级WebSocket 重连。如果 WiFi 还连着只是 WebSocket 断开直接重新建立 WebSocket 连接同时携带上一次的会话 ID。第二级WiFi 重连。如果 WiFi 本身断开了比如路由器重启ESP32 端启用 WiFi 自动重连重连成功后立即重连 WebSocket。第三级全量重启。如果连续重试 5 次都失败进入深度重置包括重新初始化 WiFi 栈和重启 WebSocket 任务。同时设备端启动时会从 NVS 读取上次会话的上下文重连后不需要用户重新讲一遍之前的内容。6. 重构后的一些体会与后续方向链路重构完成之后再回头看第一版其实核心差异不在“代码量”而在“通信模型”。第一版是“文件传输思维”录音是文件、回复是文件一切以文件为单位重构后是“流思维”音频是流、回复是流、控制命令是流数据像水一样在整个链路里持续流动。这个转变带来的体验提升是质的玩偶不再是一个“对讲机”而是一个可以随时说话、随时被打断、随时接话的对话对象。孩子现在可以对着它说“讲个恐龙的故事……不对我要霸王龙的故事”玩偶会立刻切到霸王龙版本——这在第一版是完全不可能实现的体验。最后分享一个个人经验如果做完音频链路重构还有余力优先做这三件事。第一本地 VAD 前置。把 VAD 检测放到 ESP32 端不仅能省流量还能显著降低服务端压力。我目前 VAD 放服务端但 ESP32 端的简易能量检测已经在做了准备下个版本前移。第二唤醒词本地化。做一个 12 个词的轻量唤醒模型在 ESP32 上跑做到真正的“随时唤醒、随时对话”而不是靠 VAD 一直开着。第三OTA 升级链路。玩偶设备分散在不同家庭固件迭代没法靠烧录器。配合这次重构的 WebSocket 长连接我已经预留了 OTA 命令通道后续可以直接通过服务端下发固件包这个可以单独写一篇展开了。