
糖球这个项目做到第三篇很多人围观时的第一个问题其实是你这个小球屏幕上是不是跑了个大模型第一次被这么问的时候我愣了一下因为我自己早期也这么想过。但事实是屏幕正中央那个会跟着说话节奏呼吸的光圈只是一段本地动画真正在理解“今天天气怎么样”这句话的是后台那一台常开的服务器。ESP 圆屏在整个系统里的角色是一个不折不扣的语音客户端——它负责把声音收上来、把回复放出去再把交互状态画到屏幕上仅此而已。这不是偷懒而是在做了三次架构调整之后我确认这是桌面语音助手最合理的分工方式。尤其当你把圆屏、麦克风、喇叭这些硬件成本都算进去的时候“端侧不跑模型”反而是更省心、更稳定、更好迭代的路线。这篇就把这套“圆屏当客户端、模型放后台”的架构完整聊一遍包括为什么这么分、端侧到底做了哪些事、语音链路的具体设计以及我实测中踩过的坑和性能边界。如果你也想自己做一个带屏幕的语音盒子或者正在纠结“边缘端到底要不要跑模型”这篇应该对你有用。1. 糖球系列的架构选择为什么让圆屏只做“客户端”1.1 三次迭代三次不同的“聪明程度”糖球的第一版其实是奔着“端侧智能”去的。那时候我手头有一批 ESP32-S3觉得片上那点算力怎么也能干点事。于是用 ESP-SR 跑唤醒词、跑几个固定命令词识别“开灯”“关灯”“查天气”这种短指令结果确实能用延迟也不高。但问题很快暴露一旦用户问的问题没有预先设定整个链路就断了。你需要不断往固件里加规则每加一条规则就要重新烧录一次而且每次换话题用户都要记住你说的几个固定命令词——这不是语音交互这是遥控器。第二版我把后台方案的雏形搭了出来但当时还在纠结“让 ESP 尽量多算一点”于是把 ASR 和 TTS 都往端侧塞。结果就是ASR 模型量化后占了不少内存TTS 的声音一股机械味圆屏的动画还会因为 CPU 不够而掉帧。那段时间我花在调优上的时间远超做功能的时间。现在回头看这个阶段真正教会我的不是“怎么优化”而是“什么时候该放弃优化”。到第三版我做了个干脆的决定端侧彻底不碰模型。麦克风收上来的 16kHz 音频直接通过 WebSocket 推到后台后台做完 ASR、LLM、TTS 之后再把“该说什么”“该显示什么”推回来。圆屏自己只保留三样东西I2S 的音频收发、一个状态机、还有屏幕绘制。这个架构上线跑了两周我再也没有因为“用户问了个新问题”而改过一次固件。1.2 算力账为什么这条路在硬件上走不通很多人会把“ESP 跑模型”想象成一个优化问题觉得只要把模型压得够小总能塞进去。我们先看看硬件账。以 ESP32-S3 为例双核 240MHz片上 SRAM 512KB外挂 PSRAM 8MB。一个 7B 参数的 LLM即便量化到 4-bit 也要 3.5GB 左右就算你用极端的 1-bit 量化把它压到几百 MB推理速度也完全不可用——桌面语音交互的延迟预算通常在 1 秒以内否则用户会觉得它在卡顿。对比维度端侧方案ESP32-S3后台方案PC/服务器量化 LLM 内存需求数百 MB 起要么放不下要么勉强4GB无压力单次推理延迟分钟级或直接超时秒级可流式输出模型更新方式每次都要重新烧录固件后台替换文件即可交互灵活性固定命令词或极简单意图任意自然语言功耗与发热满负载发热明显直接忽略这还没算功耗和发热。ESP32-S3 跑满双核再加上 PSRAM 读写电流轻松到几百毫安小外壳里温度会一直往上走后台服务器就不存在这个问题。要说端侧方案仅有的优势大概是“断网可用”和“数据不出本地”。但糖球的定位是放在书桌上的在线助手Wi-Fi 是常态数据走自己的后台也谈不上泄露所以为了这两个优点牺牲交互体验不划算。1.3 瘦客户端化带来的工程收益我把这套“瘦客户端”架构跑通之后才意识到底层逻辑的变化。第一个收益是迭代速度。之前想让设备多懂一个功能要改固件、烧录、复位然后手动验证现在只要在后台加一个接口或者换一个 prompt客户端完全不用动。上个月我把后台的 ASR 从命令词识别换成了大模型通用的语音识别圆屏一个字节都没有重新烧录过。第二个收益是调试便利。因为前端只是一个语音客户端我在 PC 上写个小脚本用 WebSocket 向后台发同样的音频数据就能完整复现圆屏的问题。很多音频链路的 bug 根本不是 ESP 侧的问题在后台没有日志的情况下靠这个方式很容易定位。第三个收益也是我很看重的是同一个后台可以挂多个糖球。后续如果想给书房、客厅各放一个圆屏固件已经天然支持不用改任何通信逻辑。从工程角度看这其实就是一个典型的“终端—服务器”分层屏幕负责输入输出后台负责计算和决策两边通过一套稳定的协议对话。2. 端侧硬件的实际分工圆屏、麦克风、喇叭各管什么2.1 圆屏这块屏到底在显示什么糖球使用的是一块 GC9A01 驱动的 240×240 圆屏。SPI 接口IPS 面板刷新率够用。圆屏和普通方屏最大的差异在于视觉区域矩形驱动 IC 默认按矩形扫描圆形切割区域的四个角要处理成透明或底色好在 GC9A01 这类圆屏驱动通常会做圆形显示区的点阵映射实际使用上反而省了方屏做圆角时的很多麻烦。UI 布局上核心信息集中在中间圆形区域。因为是桌面摆件人眼距离屏幕大概 30~50cm所以字体需要偏大一屏能放下的文字量其实很少。这就决定了圆屏不能像手机一样展示长文本只能显示“一句话摘要”“状态图标”“波形动画”这类信息。在我设计的 UI 里屏幕的功能被严格限定在三件事等待时显示时钟或光波动画对话时显示当前状态听、想、说后台有结果时显示一小段文字摘要。帧缓冲我放在 PSRAM 里用 SPI DMA 刷新实测刷新率能稳定在 30fps 以上。这里提醒一句如果你用的是 ESP32-C3 这类没有 PSRAM 的芯片240×240 RGB565 一个全屏缓冲就是 115KB内部 SRAM 会非常紧张一定要做好缓冲复用和局部刷新的设计否则动画和音频会互相抢资源。2.2 麦克风与喇叭I2S 是整机的命脉语音客户端的所有信息都从 I2S 总线进来再经过处理后出去。糖球的拾音端是 INMP441一颗 I2S 接口的 MEMS 麦克风支持 16kHz/16bit 采样放音端是 MAX98357A一颗 I2S 输入的 Class-D 功放推一个 3W 的小喇叭。选这两个器件不是因为音质多惊艳而是它们只需要 3 根线BCLK、LRCLK、DIN/DOUT就能和 ESP 的 I2S 外设对接数据路径清晰问题也好排查。实测下来糖球的音频链路我锁定在 16kHz 16bit 单声道。16kHz 对 ASR 来说已经够用大部分语音识别模型的输入采样率就是 16k而 TTS 播放时 16k 听起来虽然比 48k 闷一点但在 3W 小喇叭的频响范围内差异很小。我试过 48k 的配置音质改善有限音频数据量却翻了三倍对上行带宽和后台内存都是负担所以从工程角度直接锁死在 16k。音频 DMA 缓冲我采用双缓冲每块 2KB。这个配置在 ESP-IDF 的 I2S 驱动下表现最稳定。缓冲太小容易在极端调度下降采样溢出缓冲太大则会让音频延迟变得不可接受。2KB 双缓冲带来的延迟大约 60ms加上网络传输用户能感知的延迟大部分来自后台模型推理端侧音频本身的延迟几乎可以忽略。2.3 端侧状态机与事件循环为了让固件不变成一团乱麻整个圆屏程序我用一个非常朴素的状态机来组织。四个状态分别是 IDLE空闲、LISTENING聆听、THINKING后台思考中、SPEAKING播放回复。状态之间的跳转由事件驱动主要包括手动按键或唤醒词触发进入聆听VAD 检测到用户说完话自动结束录音进入 THINKING后台返回合成音频后进入 SPEAKING播放完毕回到 IDLE。所有网络事件也在状态机里统一处理。比如 THINKING 状态收到后台的“超时”事件就跳到 IDLE 并显示错误提示。这样做的好处是固件逻辑非常容易测试——我不需要真的连后台只要在本地发出对应的事件就能看到屏幕和音频是否正确响应。事实上我在开发阶段有一半时间都在用 PC 串口发事件模拟后台圆屏根本不知道对方是真是假。// 状态机核心循环伪代码简化 event_t ev; while (true) { ev event_queue_receive(); switch (state) { case IDLE: if (ev EV_WAKEUP) start_recording(), state LISTENING; break; case LISTENING: if (ev EV_VAD_END) send_audio_finish(), state THINKING; break; case THINKING: if (ev EV_AUDIO_CHUNK) prepare_playback(), state SPEAKING; if (ev EV_TIMEOUT) show_error_ui(), state IDLE; break; case SPEAKING: if (ev EV_PLAY_DONE) state IDLE; break; } }这套状态机是糖球“不跑模型”但仍显得聪明的基础。它不需要理解用户说了什么只需要知道用户是否说完、后台是否给结果、音频是否播完剩下的交给后台。3. 语音客户端与服务端的通信链路设计3.1 为什么选 WebSocket 而不是 HTTP第一次搭语音链路的时候我用的是 HTTP录音完成后整体 POST 到后台后台返回文本客户端再播放。这套流程能跑但有两个很大的问题。第一是延迟浪费整套交互必须在录音完全结束之后才开始用户说完话到听见回复之间少了“边传边等”的余地第二是后台想主动通知事件很难需要用轮询来模拟而轮询本身又会增加无效请求。换成 WebSocket 之后两个问题都解决了。连接建立后客户端可以直接把音频二进制帧推给后台后台也可以把“ASR 中间结果”“LLM 正在生成”“TTS 音频分片”等消息主动推到客户端。实际使用中我把一段交互定义成这样的时序用户按一下糖球顶部或说唤醒词客户端开始录音并持续发送音频帧后台边收边跑 ASR识别到文本后立刻回一个事件后台再调用 LLM 拿到结构化回复再把回复文本和后续的 TTS 音频流分发给客户端。全程只有一个 TCP 连接没有 HTTP 的握手开销用 wss 加密后也能直接跑在常规网络环境里。当然 WebSocket 也不是银弹。我遇到过 NAT 超时导致连接被静默断开的问题所以协议里必须有心跳这个放到后面“断线重连”部分细讲。3.2 VAD 与音频分包从“一句话说完”到“一句完整请求”语音客户端面临的第一个技术问题是“怎么知道用户说完了”。方案有很多最朴素也最可靠的是能量 VAD对每一帧音频计算能量如果连续 300ms 低于阈值就认为这句话结束了。这个方法没有把语义考虑进去但用在“按下说话”的场景里非常稳。具体流程录音开始时每一帧音频先带一个自定义头部再封成 WebSocket 二进制帧发出去。头部包含采样率、声道数、帧序号后台按帧序号做排序避免乱序导致 ASR 结果错乱。我在实际传输中使用每包 400ms 音频的数据量16kHz 16bit约 12.8KB这个尺寸对 Wi-Fi 来说会分成几个包但后台重组容易也不会带来明显的缓冲延迟。还有个容易被忽略的点不要等 VAD 判定结束才开始传第一帧。录音一旦开始就持续上传后台同一时间开始做 VAD 或者直接喂给流式 ASR。这样当用户停顿 300ms 时后台已经拿到前面所有音频整体延迟大概只多了一个 VAD 尾巴的时间而不是多一轮完整录音的时长。WebSocket 二进制帧自定义头部: | 1B 类型 | 1B 标志 | 2B 序号 | 2B 负载长度 | 负载... | 常规消息类型: 0x01 音频帧 0x02 VAD结束 0x03 状态事件(JSON) 0x04 TTS音频帧 0x05 心跳3.3 后台模型的处理管线与返回格式后台我搭了一个非常轻量的 Python 服务用的 FastAPI 加 WebSocket。收到音频流之后管线依次是ASR 识别文本 → LLM 生成回复 → TTS 合成语音。这三个环节我分别用 whisper.cpp 提供 ASR用 ollama 起的一个本地模型提供对话能力再用本地的 TTS 模型完成合成。整个管线跑在后台的一台常开小主机上没有依赖任何外部 API体验上足够稳定。这里有个很关键的设计三个环节不是串行等到全部完成才响应而是边处理边把中间状态推给客户端。比如 ASR 识别出“广州明天天气怎么样”这句话后台立刻推一个{type:asr_text, text:广州明天天气怎么样}事件圆屏收到后就在屏幕上显示出这句话接着 LLM 开始生成后台推一个{type:thinking_started}等到 TTS 合成出第一片音频后台立刻推{type:audio_chunk, data:音频二进制}。也就是说圆屏显示“想”和播放“说”时后台模型可能还在继续生成这才是流式交互的正确姿势。消息类型我用一个简单的 JSON 文本区分音频数据则用二进制帧单独传送。后台返回的 JSON 结构大致是这样{ type: llm_reply, session_id: a71f8c..., text: 广州明天有阵雨出门记得带伞。, display_text: 广州明天有阵雨, action: speak }客户端拿到后把display_text渲染到屏幕把后续收到的 TTS 音频帧按顺序播放。前后端通过session_id关联同一次对话多次对话的上下文也由后台维护客户端是无状态的。这样糖球固件里几乎不需要保存任何对话历史重启也不会打断后台的会话管理。3.4 延迟预算与分包播放语音交互的体验很大程度上取决于延迟。我给糖球的延迟预算是从用户说完最后一个字到听见第一个回复声音控制在 2 秒以内如果后台 LLM 首 token 快通常能压到 1.2 秒左右。预算拆解下来大概是录音加网络传输 0.3~0.5sASR 0.3~0.6sLLM 首 token 0.2~1sTTS 首包 0.2~0.4s。每一段都有优化空间但客户端能优化的其实只有前面“边录音边上送”和后面“边合成边播放”这两段。TTS 的分包播放在这里比较有意思。后台 TTS 模型按句子切分生成一句就推一句ESP 端收到一个完整的音频分包后立刻开始播放而不是等整段回复都合成完。这样用户听到的虽然是“句子之间略有停顿”的合成音但整体感知是流畅的平均延迟比“等全部合成再下发播放”低了将近一半。4. 圆屏 UI 的调度逻辑聆听、思考、说话三态的视觉表达4.1 动画别和音频打架圆屏 UI 这块我踩过一个很典型的坑最开始动画代码里有一个全屏填充的过渡效果配合 60fps 的刷新目标结果一放 TTS 音频声音就断断续续。问题根源是 SPI DMA 刷新和 I2S DMA 读内存同时抢占总线带宽加上动画逻辑在 CPU 里占用太高音频 DMA 的读取就被耽误了。后来我把所有动画从“每一帧都重新画整屏”改成“脏矩形加局部刷新”帧率也限制到 30fps。人眼对 30fps 的圆形光晕动画已经很满意但对音频中断是零容忍所以这两者冲突时优先保音频。另外帧缓冲我明确分配到 PSRAM而音频 DMA 缓冲必须放在内部 SRAM这样两条 DMA 链路的物理位置更清晰实测音频几乎不再出现卡顿。4.2 把后台的“中间思考”变成视觉反馈一个好的语音交互不能让人对着屏幕干等。糖球在 THINKING 状态时屏幕中央会显示一个小光圈它会根据后台推送的事件变化节奏收到asr_text后浮现一句“你说的是×××”收到thinking_started后光圈进入“呼吸”模式收到第一段 TTS 音频时光圈变成一个小的声波条。这个过程本质上是在把后台模型的内部状态可视化让用户知道系统在干什么。屏幕文字显示也要克制。回复摘要一般只显示一两行超过长度就滚动。我实测过 240×240 圆屏上一屏能舒适放下大约 10~14 个汉字信息量非常有限所以展示策略是优先显示“动作指令类”信息比如“已帮你设置提醒”“明天下雨记得带伞”长的解释在语音里说屏幕只放关键词。4.3 网络异常时的 UI 兜底完全依赖后台的客户端最怕的就是后台连不上。我的方案是在本地状态机里加了“网络异常”这个分支WebSocket 连接断开时屏幕中央显示一个断网图标同时语音播报固定的本地提示音重连成功后自动消失。这个逻辑必须放在端侧不能依赖后台通知否则断网时屏幕就会卡死在 Thinking 状态用户完全不知道发生了什么。此外还有超时兜底。后台返回超时比如 LLM 卡死客户端最多等 15 秒超时后进入 IDLE 并显示“后台开小差了”。这个时间是实际体验调出来的太短容易误伤正常的慢回复太长又会让用户觉得设备死了。15 秒是我测试下来比较舒服的平衡点。5. 实战中的故障排查与性能边界5.1 内存不足与重启循环糖球开发中最头疼的问题之一是设备在“录音刚开始”的时候反复重启。一开始怀疑是电源后来加了电容、换了电源模块都没用最后打开串口日志才发现是 malloc 失败返回 NULL代码里没做空指针检查直接写指针就触发了 panic。排查过程也算典型用heap_caps_print_heap_info(MALLOC_CAP_INTERNAL)和MALLOC_CAP_SPIRAM分别打印内存发现录音用的 DMA 缓冲虽然只有 2KB但它要求内部 SRAM 才能被 DMA 访问而当时内部 SRAM 已经被蓝牙协议栈和 Wi-Fi 缓冲吃掉大半再要一块连续的 2KB 就失败。解决方法是把大块缓冲帧缓冲、音频处理缓冲明确用 PSRAM只有 DMA 需要的那一小块保留在内部 SRAM并且增加分配失败时的日志和重启计数方便通过串口观察。顺带一提ESP-IDF 编译时如果工程里任务栈给得偏小运行时会遇到栈溢出表现形式和内存不足很像甚至会在编译阶段出现 ninja 进程异常退出。我遇到过一次ninja.exe直接终止、退出代码非零的情况后来发现是系统物理内存不足导致编译器内存分配失败关掉几个占内存的程序后就正常了。这类问题虽然不算固件 bug但排查起来很费时间先把工具链环境理清楚能省不少事。5.2 音频断流与回声问题音频断流和回声经常一起出现。断流的现象是 TTS 播放时偶尔会“咔嗒”一声或者直接空一拍。后来定位到两个原因一是 I2S 的双缓冲太小极端调度下两个 DMA 缓冲都被消耗完播放就断了二是我把 TTS 分包播放的接收缓冲开得太小网络抖动导致某一帧音频到达晚了客户端因为“顺序播放”策略停在那等缺失的帧。修法是分别提升DMA 双缓冲从 2KB 提到 4KB播放逻辑改成“等待阈值加乱序重排”超过 300ms 的乱序直接丢弃而不是堵住整个播放队列。回声的问题比较有意思。INMP441 的指向性虽然不错但糖球外壳很小喇叭和麦克风距离可能不到 5cm音量一大录音里就混进了喇叭的声音。我在硬件上做了两个改动给麦克风加了一个海绵套并在外壳内部加了隔音棉软件上在后台 ASR 前做了一个简单的回声抑制用参考信号也就是 TTS 音频帧来衰减麦克风里的同源成分。效果立竿见影误唤醒和识别错误率都明显降下来了。5.3 断线重连与后台会话恢复WebSocket 连接因为网络波动断开后不能一头雾水地重连而是要有心跳和重连策略。我用的心跳是每 30 秒发一个 ping后台回 pong如果连续 3 次没有收到 pong客户端判定连接已死主动关闭并进入指数退避重连1 秒、2 秒、4 秒最多到 30 秒。重连成功后客户端会带一个上次的 session_id 请求恢复上下文后台如果发现 session 已过期就置为空闲状态屏幕同步回 IDLE。后台也要做会话治理。每个 session 如果超过 10 分钟没有任何消息就要主动清理防止后台进程积压变成内存黑洞。因为后台常开我把每个 session 对应的 ASR/LLM/TTS 进程都做了超时控制超过 60 秒未完成的请求强制取消。这个上限看起来宽松实际是为了防止模型偶发卡死时整个后台被拖垮。5.4 实测一轮完整交互的资源占用最后放一组我实测的数据方便评估这套架构的边界。糖球空闲时显示时钟无对话内部 SRAM 占用约 80KBPSRAM 占用约 1.1MBCPU 占用约 8%双核平均整机电流约 70mA。开始录音时内部 SRAM 会额外增加 2 个 2KB 的 DMA 缓冲CPU 占用升到 20% 左右。播放 TTS 时CPU 占用约 14%主要花在解包和 I2S 搬运上屏幕局部刷新约 30fps。整个系统跑下来最吃资源的其实不是网络是屏幕刷新和音频 DMA 同时运行的时序竞争这也是前面反复强调缓冲布局的原因。资源上还有一个值得注意的边界同时只能跑一条完整的对话链路。也就是说糖球不能一边播放回复一边收新的语音指令。这是我在设计时主动划定的边界——不加复杂的中断逻辑保证单一对话链路的稳定性比支持无缝打断更重要。如果后续要做多轮打断我会在状态机里新增一个“SPEAKING 中收到唤醒词”的跳转并在播放端做淡出但目前这个版本先把主干链路做到“稳”才是重点。回头再看这一整套设计其实核心就一句话边缘设备跑不跑模型不是技术能力问题而是产品定位问题。糖球选择做后台的语音客户端不是因为它跑不了模型而是因为在当前算力、功耗和交互体验的约束下不跑模型是最优解。如果你也想做类似的桌面语音硬件我的建议是先把端侧的音频、网络、屏幕这三块基础打扎实再去想模型的事。端侧稳了后台模型换一个、升级一次对你的设备来说都只是日常迭代而不是推翻重来。最后再分享一个小技巧开发圆屏固件时我经常用一段几十行的 Python 脚本模拟后台往 WebSocket 推预置的音频流观察屏幕状态切换和音频播放是否正常。这个小工具让我在没有模型的情况下也能快速验证端侧逻辑确实省了不少烧录次数。