从一块 ESP32-S3 开发板到 AI 陪伴设备:我们如何搭建一套可持续演进的端云架构

本文记录一个真实工程项目的架构演进。它不是"调用一次大模型 API"的演示,而是一套同时包含嵌入式固件、双屏动画、实时音频、AI 推理、设备云、移动 App、运营后台和长期记忆的完整系统。

文中的延迟和吞吐数字来自开发期间的单轮日志样本,用于解释问题与优化方向,并非实验室环境下的正式性能基准。

1. 问题不是让 ESP32 调用大模型,而是让产品成为一个整体

项目最初的目标很直观:做一个带有一对眼睛屏幕、能够语音交流、能够根据情绪切换表情的 AI 玩偶。

硬件采用 ESP32-S3 开发板,外接两块 240×240 圆形 LCD,音频部分由 ES7210 负责采集、ES8311 负责播放,并带有 SD 卡、BOOT 按钮和 BLE 能力。基础体验包括:

  • 开机动画和联网提示音;
  • 双屏 GIF 表情;
  • 16 kHz 单声道语音采集;
  • 流式 ASR、LLM 和 TTS;
  • 播放期间语音打断;
  • 静音、配网、恢复配置;
  • 动态切换智能体和眼睛素材;
  • OTA、诊断和崩溃上报;
  • App 文字/语音聊天与设备管理;
  • 长期记忆和声纹识别。

真正开始实现后,我们很快发现:这不是一个"ESP32 + 大模型"的项目,而是一个小型实时分布式系统。

一次自然的语音回复至少经过:

text 复制代码
麦克风 → ESP32 音频采集 → WebSocket → NestJS → FastAPI
       → VAD/AEC/ASR → LLM → 流式文本切分 → 流式 TTS
       → NestJS → WebSocket → ESP32 播放器 → 扬声器

任意一个环节出现阻塞、排队、重复消费或状态不同步,用户感受到的就不是"某个服务慢了 300 ms",而是卡顿、抢话、无响应、表情错乱甚至设备重启。

因此,我们最终没有把所有逻辑塞进一个后端服务,而是将系统拆成五个边界清晰的层次。

2. 总体架构

flowchart LR subgraph Device[ESP32-S3 设备] MIC[ES7210 麦克风] DAC[ES8311 扬声器] LCD[双 GC9A01 LCD] FW[ESP-IDF 固件\n状态机 / PCM / GIF / OTA / BLE] MIC --> FW FW --> DAC FW --> LCD end subgraph Client[用户与运营端] APP[Flutter App] ADMIN[Vue Admin] end subgraph Business[NestJS 业务与设备云] AUTH[账号与设备鉴权] WS[WebSocket 网关] MQTT[MQTT 控制] DOMAIN[设备 / 智能体 / 会话 / OTA / 素材] end subgraph AI[FastAPI AI 核心] AUDIO[VAD / AEC / 声纹 / ASR] ORCH[对话编排] MODEL[多 LLM Provider] TTS[双向流式 TTS] MEMORY[长期记忆 / pgvector] end subgraph Data[数据与对象存储] PG[(PostgreSQL + pgvector)] REDIS[(Redis)] COS[(COS)] end FW <-->|二进制实时音频 + 控制消息| WS FW <-->|设备状态与命令| MQTT APP --> AUTH ADMIN --> AUTH APP <-->|文字 / App 语音| WS WS <-->|内部 WebSocket| AI DOMAIN --> PG AUTH --> REDIS AI --> PG AI --> COS ORCH --> MODEL ORCH --> TTS AUDIO --> ORCH MEMORY --> ORCH

这套拆分背后的原则是:

  1. 设备端负责确定性和实时性:采集、播放、显示、缓存、状态机和故障恢复。
  2. NestJS 负责身份、连接和业务规则:它知道"谁的设备、绑定了哪个智能体、是否允许升级"。
  3. FastAPI 负责 AI 流水线:它知道"音频如何处理、上下文如何构建、模型如何流式生成"。
  4. App 和 Admin 共用领域接口,但面向不同角色:一个服务用户,一个服务运营。
  5. 原始音频、大文件与结构化业务数据分开存储:数据库不承担对象存储职责。

3. 设备端:把资源留给实时链路

ESP32-S3 同时承担双屏 GIF 解码、I2S 收发、TLS/WebSocket、MQTT、SDMMC 和 OTA。很多桌面端"不值一提"的内存分配,在这里都可能抢走最后一块 DMA 连续内存。

我们曾遇到过:

  • GIF 卡在某一帧,LVGL 心跳延迟超过 30 秒;
  • SDMMC 读取时报 not enough mem
  • TLS 握手阶段 AES 内存分配失败;
  • 音频 DMA descriptor 创建失败;
  • OTA 擦写 Flash 时因任务栈不满足 cache-disabled 要求触发断言;
  • WebSocket 能连接,但创建任务时因内存不足直接失败。

最终,设备启动顺序也成为架构的一部分:先为音频 DMA 和关键任务保留内部内存,再初始化较大的 GIF 和网络组件。大块非 DMA 数据尽量进入 PSRAM,显示刷新则使用固定 DMA staging buffer,避免反复申请同尺寸内部内存。

设备端没有承担 JSON 业务解析、LLM 编排或记忆检索。它只维护一套明确的对话状态:

text 复制代码
BOOTING → IDLE → LISTENING → THINKING → SPEAKING
                    ↑             │
                    └── 打断 ─────┘

状态机统一管理麦克风、播放器和表情,避免三个模块各自认为"当前应该聆听"或"当前应该播放"。静音、OTA、素材切换属于更高优先级的设备状态,可以中止或禁止对话状态迁移。

4. 为什么控制走 MQTT,实时音频走 WebSocket

早期最容易犯的错误,是因为"已经有 MQTT"就想让所有通信都走 MQTT,或者因为"已经有 WebSocket"就把设备控制也塞入同一个连接。

我们最终保留了两条链路:

链路 适合的数据 原因
MQTT 心跳、音量、静音、智能体绑定、眼睛素材、OTA 命令 消息小、主题清晰、适合设备控制与离线语义
WebSocket 麦克风 PCM、播放参考 PCM、ASR/VAD 事件、TTS PCM 长连接、低延迟、需要连续双向传输与严格时序

两条链路共享设备身份,但不共享业务职责。即使音频 WebSocket 短暂重连,MQTT 仍可上报在线状态;素材下载或 OTA 进入维护模式时,又可以主动暂停音频连接,减少内存和带宽争抢。

5. 从 JSON + Base64 到二进制音频协议

第一版上行音频采用 JSON 包裹 Base64 PCM。它实现简单、日志直观,但不适合资源紧张的实时设备。

Base64 本身会把每 3 字节编码成4字节,理论体积膨胀约 33%。除此之外,还存在:

  • PCM → Base64 的 CPU 消耗;
  • Base64 和 JSON 字符串的额外内存;
  • 多次内存复制;
  • JSON 序列化期间持有 WebSocket 锁;
  • 麦克风和播放参考同时上传时的锁竞争。

全双工打开后,我们观察到 Could not lock ws-client within 100 timeout、麦克风帧发送失败和播放卡顿。这不是单纯增加播放器缓冲就能解决的问题,因为堵塞发生在发送路径本身。

因此我们设计了二进制上行协议 EYUP/1。当前设备参数为:

  • 麦克风:PCM S16LE、16 kHz、单声道;
  • 每帧 1600 个采样,即 100 ms;
  • 帧头记录类型、序号、采样时钟、流 ID 和负载长度;
  • 普通采集帧只携带 MIC PCM;
  • 播放期间的 type 3 帧仍只携带一份 MIC PCM,额外在帧头报告播放 reference 的序号、采样游标和 TTS request ID;
  • 云端根据这些游标,从自己已经缓存的 TTS PCM 中重建 AEC reference,不要求设备重复上传 reference PCM。

这里经历过一次重要演化。第一版云端 AEC 上行确实把 100 ms 的 MIC 和 100 ms 的 reference PCM 拼进同一帧,整个帧约 6.4 KB。最初 4096 字节的 WebSocket 缓冲无法容纳它,每帧会被拆成至少两次 TLS 写入。代码和现场日志显示,上行一度被压到约 5 FPS,MIC 与 reference 都可能丢失。将 I/O buffer 调整为 8192 字节后,完整双路帧能够一次写出,首先证明了卡顿确实来自发送路径。

但 8192 字节缓冲只是让第一版方案"可以运行",并没有回答另一个问题:云端本来就是 TTS PCM 的生产者,为什么要把它发给设备后,再让设备原样上传一次?

最终方案删除了重复 reference payload。当前 type 3 帧由 64 字节头和 3200 字节 MIC PCM 构成,总计 3264 字节。相较普通 MIC 帧,AEC 只增加 28 字节游标信息;按 100 ms 一帧计算,额外上行约 280 B/s,不到 16 kHz S16LE 单声道 MIC 原始带宽的 1%。

这类参数看似只是常量,实质上决定了实时链路是否成立。

text 复制代码
第一版:MIC PCM + Reference PCM
    ≈ 6.4 KB / 100 ms → ≈ 64 KB/s 上行
    4096-byte WS buffer → 一帧被拆成多次 TLS write

过渡版:保留完整双路 PCM,WS buffer 调到 8192
    一帧一次 write → 验证并缓解发送卡顿

最终版:MIC PCM + Reference Cursor
    3264 B / 100 ms → 约 32.64 KB/s 上行
    云端使用 TTS 缓存重建 Reference PCM

控制消息仍然使用 JSON,因为它们低频、可读且便于扩展。音频与控制不必为了"协议统一"而使用同一种编码。

6. NestJS 为什么不直接承担 AI 推理

NestJS 是系统的业务入口,负责:

  • 手机号账号、Token 和权限;
  • 设备出厂档案与设备密钥;
  • 设备和智能体绑定;
  • 会话和消息持久化;
  • MQTT 在线状态与命令;
  • 眼睛素材、音色、模型目录和 OTA 发布;
  • App/Admin API;
  • 设备 WebSocket 和 App WebSocket 鉴权;
  • 将设备连接映射为 Python AI 会话。

FastAPI 则负责变化更快、计算特征更明显的部分:

  • VAD、AEC、声纹和 ASR;
  • LLM Provider 路由;
  • 系统提示词、时间上下文和身份上下文;
  • 流式文本断句;
  • 双向流式 TTS;
  • 情绪解析;
  • 长期记忆提取和向量检索。

这样做不是因为 TypeScript 不能处理 AI,而是因为 Python 的音频、推理和模型生态更合适;同时,模型服务异常也不应该破坏账号注册、设备管理和运营后台。

6.1 这不是一次"按技术偏好拆服务"

如果项目只有一个网页聊天框,统一使用 TypeScript 或 Python 通常更简单。我们的系统之所以保留 NestJS + FastAPI,并不是团队分别喜欢两种语言,而是业务平面和实时 AI 平面已经表现出不同的变化速度、依赖形态和故障边界。

对比维度 NestJS 业务服务 FastAPI AI 核心
核心对象 用户、设备、智能体、绑定、商品、OTA、权限 音频帧、推理任务、模型上下文、PCM 流、记忆召回
主要 I/O REST、MQTT、数据库事务、设备连接管理 内部 WebSocket、模型 HTTP/WSS、音频与向量计算
状态生命周期 账号和设备数据需要长期保持一致 一次语音请求通常只有数秒到数十秒
依赖生态 Prisma、JWT Guard、Redis、MQTT、后台管理接口 NumPy、Sherpa-ONNX、AEC、Embedding、各类 AI SDK/协议
变化来源 产品功能、运营规则和权限模型 模型供应商、音频算法和推理参数
扩容指标 在线设备数、API QPS、MQTT/WS 连接数 并发推理数、音频实时率、CPU/GPU 和外部模型限流

这两类负载虽然共同服务一次对话,但不适合用同一套扩容和故障处理策略。

6.2 为什么没有全部写成 NestJS

TypeScript 完全可以调用 ASR、LLM 和 TTS HTTP API,也能处理 WebSocket 二进制帧。如果系统只做"转发云端接口",单独保留 Python 服务的价值确实不大。

但我们的 AI 核心后来不再只是 API 转发,它逐渐加入了:

  • 基于 NumPy 的 PCM 能量、相关性和时序处理;
  • Sherpa-ONNX 相关的本地推理能力;
  • 云端 AEC 所需的 MIC/reference 对齐和诊断;
  • 声纹特征提取与阈值判断;
  • 多语言 ASR 路由;
  • LLM 流式增量解析和 TTS 文本切片;
  • 多语言 Embedding、pgvector 检索和记忆整理器;
  • 对 MIC、reference、clean PCM 的离线回放与算法验证。

这些工作在 TypeScript 中并非不能实现,但 Python 的音频、科学计算、ONNX 和模型工具链更直接。以 AEC 和声纹调试为例,我们需要快速读取 PCM、计算 RMS/相关性、批量回放日志并替换算法实现。如果强行统一到 Node.js,往往还要维护原生 addon、单独的命令行工具或另一个离线 Python 脚本,最终只是把"双服务"变成更隐蔽的"双语言工具链"。

另一个现实问题是依赖风险。某次音频算法升级可能引入新的 ONNX Runtime 或本地动态库,它不应该迫使负责账号、OTA 和设备管理的主服务一起更换运行环境。让 Python 独立后,我们可以单独重启 AI 核心、切换模型依赖或回滚算法,而 NestJS 的登录、MQTT 心跳和管理 API 仍然可用。

6.3 为什么没有全部写成 FastAPI

反过来,把 NestJS 业务层全部迁入 Python 也不会让系统自然变简单。

当前业务后端已经围绕 NestJS 建立了清晰的模块体系:JWT Guard、角色权限、Prisma 数据模型、Redis、MQTT、设备 WebSocket、App WebSocket、文件上传、短信注册、设备导入、素材管理和 OTA 发布。Admin 和 App 共享这些领域接口。这里的复杂度来自权限和业务一致性,而不是模型计算。

例如"切换设备绑定的智能体"看似只是更新一个字段,实际需要:

  1. 验证设备和目标智能体都属于当前用户;
  2. 更新当前有效绑定,但不错误关闭历史会话;
  3. 通过 MQTT 通知在线设备;
  4. 关闭旧的实时 AI session;
  5. 允许设备使用新上下文重新建立 WebSocket;
  6. 把操作结果同步给 App。

这类流程更适合留在拥有设备连接、权限上下文和业务事务的 NestJS 中,而不是让音频处理服务反向掌握整套用户权限。

如果全部统一到 FastAPI,我们仍然需要重新实现连接注册表、Guard、MQTT 生命周期、运营模块和大量 DTO/权限规则,却不会因此减少 VAD、TTS 或 AEC 的复杂度。统一语言并不等于统一问题域。

6.4 两个服务之间传递什么,刻意不传递什么

NestJS 与 FastAPI 通过内部 WebSocket 交换实时会话数据。设备连接建立后,NestJS 完成设备鉴权和智能体绑定解析,再为本次设备 session 建立 Python WS。设备无需知道 Python 服务的位置,也不需要携带用户级凭据。

在这条内部连接中传递的是:

  • 已完成鉴权的 user ID、device ID、agent ID 和 session ID;
  • audio_start 等会话控制事件;
  • EYUP/1 二进制 MIC 音频帧,以及播放期间附带的 reference 游标;
  • speech_startasr_finalreply_emotion 等实时事件;
  • 24 kHz PCM 下行帧和对应 request ID。

刻意不让 FastAPI 处理的内容包括用户密码、设备 Secret、短信验证码、运营权限和设备所有权判断。Python 得到的是一次已授权 AI 会话所需的最小上下文,而不是完整用户凭据。

音频在 NestJS 和 FastAPI 之间保持二进制转发,没有再次编码为 Base64 JSON。这样,双服务增加的是一次进程间转发,而不是新一轮音频编码、解码和大字符串分配。当前两个服务可以部署在同一主机,通过本地连接通信;相比外部 ASR、LLM、TTS 的网络耗时,这次转发不是现阶段的主要延迟来源。不过这仍需要通过正式压测验证,而不能只凭架构推断。

数据库边界采用了务实而非绝对纯粹的方案:NestJS 是用户、设备、智能体、绑定和运营数据的业务所有者;FastAPI 使用受限数据库角色读取 AI 会话所需资料,并写入消息、摘要、向量记忆等 AI 结果。它不能绕过 NestJS 修改设备归属或用户权限。这样减少了一次对话内部频繁 REST 往返,同时仍通过数据库权限约束写入范围。

6.5 双服务带来的成本必须被正视

双服务并非免费,它至少引入了以下复杂度:

  • 设备 WS、NestJS↔FastAPI WS 两层连接需要共同定位问题;
  • NestJS session 与 Python session 必须使用同一个 session ID 关联;
  • 任一侧重连时,都要清理旧连接,防止出现"新连接替换旧连接"竞态;
  • request ID 必须贯穿 ASR、LLM、TTS、消息入库和设备播放;
  • 两套运行时、依赖、日志和健康检查需要分别部署;
  • 数据结构升级需要考虑 Node 和 Python 的兼容窗口;
  • Python 重启时,进行中的语音请求会中断,设备端必须能够恢复连接。

我们确实遇到过这类问题:设备 WebSocket 仍然在线,但 Python session 初始化失败;切换智能体后 NestJS 主动关闭旧 session,设备却没有正确重连;新连接建立后,旧连接的关闭回调又误伤新连接。解决这些问题依赖的不是换一种语言,而是统一 deviceId + sessionId + requestId 的关联、记录连接关闭来源,并让替换连接成为幂等操作。

为降低排障成本,两侧日志都必须携带同一组关联字段。一次掉线不能只输出"WebSocket closed",而要回答:

  • 哪个 device/session/request;
  • 是 peer close、server cleanup 还是 replaced by new connection;
  • 连接持续了多久;
  • 最后一次收发距现在多久;
  • 收发消息数、字节数和 buffered amount;
  • Python 子连接为何关闭。

换句话说,双服务要求我们把可观测性也当作协议的一部分。

6.6 什么情况下我们会选择统一语言栈

如果重新评估后出现以下情况,合并服务会是合理选择:

  • AI 层只剩少量无状态 HTTP API 转发;
  • 不再运行本地音频、ONNX、Embedding 或 AEC 处理;
  • 团队规模很小,维护两套运行时的成本高于隔离收益;
  • 内部 WebSocket 成为经过测量确认的性能瓶颈;
  • 业务与 AI 的发布节奏已经完全一致。

但以当前项目的实际形态,NestJS 和 FastAPI 的边界已经对应两个真实的问题域。我们选择双服务,不是为了追求"微服务架构",而是为了让设备业务保持稳定,让 AI 算法能够快速迭代,并允许两类负载独立扩容和故障隔离。

7. 流式链路:首包延迟比总耗时更重要

最早的回复路径是:

text 复制代码
ASR 完成
  → 等待 LLM 完整文本
  → HTTP TTS 生成完整音频
  → 上传 COS
  → 返回 MP3 URL
  → ESP HTTPS 下载
  → 播放

一轮实测日志中:

阶段 样本耗时
ASR 在 speech end 后得到 FINAL 506 ms
流式生成阶段 9225 ms
兼容 TTS 2663 ms
COS 上传 207 ms
speech end 到最终结果 12613 ms

总耗时超过 12 秒时,设备看起来就像没有反应。

后来链路改为 LLM 流式输出,并按照语义边界把文本持续送入双向流式 TTS。FastAPI 收到 PCM 后立即转发,不再等待完整 MP3。

另一轮优化后的样本中:

指标 样本值
ASR FINAL 距 speech end 125 ms
首个 LLM delta 2128 ms
首段送入 TTS 2333 ms
首个 PCM 2807 ms

这里不能直接宣称"系统延迟降低了 80%",因为两轮对话、网络和回复内容不同。但用户真正关心的等待从"完整链路结束"变成"首个可播放 PCM 到达",架构上的变化是确定的。

下行音频使用 24 kHz、S16LE、单声道,按 100 ms(4800 字节)切帧。服务端当前会为设备准备约 1.2 秒启动缓冲,并根据上游 TTS 块之间的间隔动态调整 lead time。

为什么需要自适应缓冲?一次早期现场日志显示:播放器准备了约 18 KB、等待 1.2 秒后开始播放,但随后缓冲归零,PCM underrun 计数一路增长到 150。盲目扩大固定缓冲虽然能掩盖网络抖动,却会持续增加首包等待;自适应策略则只在观察到上游 gap 时增加安全余量。

8. 不要让"迟到的数据"破坏下一轮对话

流式系统里最危险的并不总是丢包,而是旧数据晚到。

用户在上一段回复播放中再次说话时,系统需要:

  1. VAD 确认新语音;
  2. 立即停止当前播放器;
  3. 取消或废弃旧 TTS;
  4. 允许新的 ASR 开始;
  5. 旧回复已经生成的文字仍可按策略存库;
  6. 旧请求之后到达的 PCM 不能进入新播放器。

因此,音频帧和控制消息不仅要有 sequence,还需要 request ID。设备播放器只接受当前 expected request 的 EYAU 下行帧,发现旧 request 的 PCM 会直接丢弃。

这是一次重要的架构认识:停止播放是本地动作,取消回复是分布式动作。 如果只有一个布尔值 is_playing,旧的 TTS 块仍可能在几秒后到达并重新触发播放。

9. AEC 不只是一个算法模块,而是一条时间同步链路

开发板没有可靠的硬件回采通道。我们最初尝试过从 ES7210 的 TDM slot 中寻找硬件 reference,但现场数据无法证明某个 slot 稳定对应扬声器回采。之后 AEC 架构经历了三个阶段。

9.1 阶段一:在设备端寻找硬件 reference

这个方向理论上最接近传统免提设备:一路 MIC、一路由 Codec 或 ADC 回采的扬声器参考,然后在端侧或云端做 AEC。但这块板只有单麦,现有硬件资料和实际采样都没有提供一条可信、稳定的回采链路。把某个 TDM slot 的串音误认为 reference,反而导致播放一开始就被判断为用户打断。

因此我们停止依赖未经确认的硬件通道。没有硬件回采不等于无法获得数字 reference,因为云端和设备都接触过即将播放的 TTS PCM。

9.2 阶段二:设备重复上传完整 reference PCM

为了先验证云端 AEC 是否可行,我们在设备播放 PCM 时复制一份数字音频,并与 MIC 一起封装为完整双路帧上传:

text 复制代码
云端 TTS PCM → ESP 播放队列 ─┬→ ES8311 → 扬声器
                              └→ Reference PCM ─┐
ES7210 MIC PCM ────────────────────────────────┼→ 云端 AEC
                                               │
                 完整 MIC + Reference 上行 ────┘

这一步的价值是快速验证:使用"实际进入播放路径的数字 PCM"确实比猜测硬件 slot 可靠,也让云端能够看到 MIC/reference 两条时间线。

但它带来两个直接问题:

  1. 16 kHz S16LE 单声道 MIC 是 32 KB/s,再上传一份同规格 reference,上行接近翻倍;
  2. 100 ms 双路帧约 6.4 KB,超过当时 4096 字节 WS buffer,引发 TLS 拆包、锁竞争和音频卡顿。

这正是第五节中 4096→8192 字节缓冲优化发生的背景。它解决了完整双路帧的发送效率,却没有消除重复数据。

9.3 最终方案:设备只上传"播放事实",云端重建 reference

进一步梳理数据流后,我们意识到 reference PCM 原本就来自 Python:FastAPI 从 TTS 获得 24 kHz PCM,在发送给设备的同时按 request ID 缓存在当前 AEC session 中。设备真正独有的信息不是 PCM 内容,而是"这一刻实际播放到了哪里"。

因此最终链路改为:

text 复制代码
FastAPI 获得 24 kHz TTS PCM
    ├─→ 按 request ID 缓存,并转换出 16 kHz reference 时间线
    └─→ NestJS → ESP 播放队列 → ES8311 → 扬声器
                                      │
                                      └─→ 更新实际播放 cursor

ESP 上行:16 kHz MIC PCM
        + MIC sequence / sample clock
        + reference sequence / sample clock
        + TTS request ID
                        │
                        ▼
FastAPI:用 request ID 找到缓存 → 按 cursor 取 reference → AEC3

这里的 type 3 仍被称为 duplex 帧,是因为它同时描述"麦克风采集时间线"和"播放参考时间线",而不是因为 payload 中包含两份 PCM。当前每个 100 ms type 3 帧只有一份 3200 字节 MIC PCM,64 字节头中包含 reference sequence、reference sample clock 和 16 字节 reply UUID。

按当前协议计算:

模式 每个 100 ms 帧 约合上行带宽
普通 MIC 帧 36 B 头 + 3200 B PCM 32.36 KB/s
当前 AEC type 3 64 B 头 + 3200 B MIC PCM 32.64 KB/s
早期完整双路 PCM 头 + 6400 B PCM 约 64 KB/s

所以当前云端 AEC 相比普通麦克风上行只增加约 280 B/s 的游标元数据,而不是再增加一路 32 KB/s reference 音频。相较早期完整双路方案,上行负载接近减半。

9.4 游标解决了重复上传,但没有消除声学延迟

reference sample clock 表示设备播放任务已经推进到的数字位置,却不等于同一声音已经在此刻到达麦克风。真实路径仍然包括播放队列、I2S DMA、DAC、功放、扬声器、空气与外壳、麦克风、ADC 和采集 DMA。

因此云端不能简单用 cursor 对应的 reference 与当前 MIC 相减。它会围绕 cursor 搜索声学延迟,并在缓存的 16 kHz reference 时间线上截取与当前 100 ms MIC 对应的片段,再送入 AEC3。request ID 防止上一轮 TTS 的 reference 被误用于下一轮回复;每次新回复开始时,AEC 自适应状态和延迟估计也会重置,避免旧时间线在多轮对话后造成自我打断。

调试日志中曾观察到估计延迟约 59.1 ms 和 76.8 ms;同一轮样本里还出现过:

text 复制代码
corr=0.297, nearRms=571.6, cleanRms=527.6
corr=0.141, nearRms=539.1, refRms=2232.5, cleanRms=207.8

这些数字没有脱离环境的绝对意义,但可以用于判断:TTS reference 缓存和播放 cursor 是否正确关联、估计延迟是否漂移、消除后能量是否下降,以及为什么设备会把自己的 TTS 当成用户讲话。

AEC 最终被放在云端,原因不是端侧算法一定更差,而是:

  • 当前硬件没有可靠硬件 reference;
  • ESP32 同时运行显示、网络和音频,内部内存紧张;
  • 云端本来就拥有 TTS PCM,更容易重建并记录 MIC/reference/clean 三路数据;
  • 调参、回放和算法替换不需要重新刷固件。

最终方案的代价不再是上传完整 reference 的带宽,而是云端必须为每个活跃回复维护 TTS PCM 缓存,并正确处理 request ID、24→16 kHz 转换、播放 cursor、声学延迟估计和缓存生命周期。设备与云端还必须严格保持采样时钟语义一致,否则取出的 reference 片段再准确也属于错误时间线。架构选择从来不是"云端更先进",而是结合硬件约束后,把重复数据换成了更严格的时序协议和服务端状态管理。

10. 情绪和眼睛素材为什么属于两条链路

模型回复包含内容和情绪,但设备端的表情不应该等到整段音频结束才出现。因此 FastAPI 会尽早产生 reply_emotion,NestJS 同时将情绪同步到 MQTT,设备在首段语音播放前切换 GIF。

设备内置默认素材,同时支持从 COS 下载用户选择的眼睛素材到 SD 卡。素材不是单张图片,而是一组按情绪命名的 GIF,并提供不同分辨率变体。数据库记录素材组、情绪槽位、分辨率和版本;App 只展示与设备屏幕匹配的素材。

这里的关键不是"云端下发一组 URL 能不能做"------当然可以。真正的问题是:

  • 下载失败时是否保留上一套可用素材;
  • 是否每次切换都重复下载;
  • 如何保证一组文件全部完成后再原子切换;
  • GIF 调色板、透明色和局部帧是否与固件解码器兼容;
  • 下载和 SD 读取期间如何避免与 LVGL、音频争抢内存。

因此,素材切换被设计成设备维护状态,而不是普通 UI 事件。

11. 长期记忆:数据库里的聊天记录并不等于"记得"

系统会保存会话和消息,但把几百条历史消息全部放进上下文既昂贵,也会稀释当前问题。

长期记忆模块将对话提取成结构化事实,例如:

  • profile:用户资料;
  • preference:稳定偏好;
  • relationship:关系和称呼;
  • event:重要事件;
  • plan:未来计划;
  • temporary:有有效期的信息。

每条记忆生成 384 维多语言向量并存入 PostgreSQL pgvector。召回不是只按向量距离排序,而是组合:

text 复制代码
score = semantic_similarity × 0.72
      + importance          × 0.12
      + confidence          × 0.05
      + recency             × 0.03
      + pinned_bonus

随着对话增长,另一个问题出现了:同一事实会被模型用不同 key 反复提取。为此我们增加了记忆整理器:以 0.90 的向量相似度生成紧密候选簇,再判断 MERGESUPERSEDEKEEP_SEPARATE

首轮历史整理的实际结果是:

项目 数量
整理前有效记忆 345
整理后有效主记忆 322
标记为 SUPERSEDED 的历史记忆 23
损坏的替代关系 0

注意,我们没有物理删除那 23 条。长期记忆必须可解释、可撤销,所以旧事实保留来源,并通过 superseded_by 指向主记忆。

声纹也会影响记忆边界。严格模式下,声纹不通过的音频不会进入私人 ASR/记忆流程;非严格访客模式即使允许智能体回应,也不能把访客的话当作账号主人的稳定偏好。一次身份判断错误如果写入长期记忆,会比一次 ASR 错字影响更久。

12. OTA 和诊断是产品架构,不是发布后的补丁

设备能够 OTA,并不代表 OTA 已经闭环。

后台的 DISPATCHED 只表示命令已发送,不能表示升级成功。完整状态至少包括:

text 复制代码
AVAILABLE → DISPATCHED → DOWNLOADING → VERIFYING
          → REBOOTING → CONFIRMED
                         └→ ROLLED_BACK / FAILED

新固件启动后需要主动上报版本并确认 OTA 分区;如果启动失败回滚,也必须让后台重新允许发布,而不是永远停留在"已经推送"。

类似地,设备日志也不能只有"WebSocket disconnected"。我们逐渐加入:

  • 连接持续时长;
  • 最后收发时间;
  • 收发消息数与字节数;
  • close code、close reason 和关闭来源;
  • FreeRTOS 任务栈水位;
  • internal heap、largest block、PSRAM;
  • 音频队列、丢帧和 underrun;
  • LVGL heartbeat;
  • 上一次崩溃原因,并在下次启动后上报。

一次长连接日志曾记录约 33 分钟内接收 19640 条消息、约 62.9 MB 数据。这个数字本身不能证明网络好坏,但结合 lastRxAgoMsbufferedAmount 和关闭原因,就能区分服务端替换连接、NAT/网络断开和设备主动重连。

13. App、Admin 和设备为什么使用不同 Token

设备、用户 App 和运营后台都需要登录,但它们不是同一种主体:

  • 设备 Token 证明"这是一台已登记的设备";
  • 用户 Token 证明"这是某个账号";
  • 运营权限还需要角色和资源范围。

如果三者共用一种 Token,未来设备固件泄露、用户换手机或运营权限调整都会互相影响。当前架构在 NestJS 完成身份转换:设备只使用设备凭据,App 只使用用户凭据,Python 只信任来自 NestJS 的内部会话上下文。

Flutter App 负责配网、设备状态、智能体、素材、音色、文字聊天和语音通话;Vue Admin 在此基础上增加用户权限、设备导入、公共素材、智能体模板和 OTA 发布。二者调用同一领域服务,但页面能力由角色决定,而不是复制两套业务规则。

14. 这套架构最重要的三个经验

14.1 实时系统首先要减少复制、排队和隐式状态

Base64、JSON、大块临时内存、多个模块各自维护播放状态,这些单独看都不严重,叠加后就会变成持续卡顿和偶发崩溃。

14.2 首包、稳态和最终结果是三个不同指标

总耗时适合计费和容量分析,首包决定用户是否觉得设备"听懂了",稳态则决定声音是否卡顿。只优化其中一个指标,体验仍然可能失败。

14.3 每个跨进程动作都必须可关联、可取消、可追溯

一次回复需要 request ID;一次设备连接需要 session ID;一组音频需要 sequence 和 sample clock;一次 OTA 需要发布记录;一次记忆合并需要来源和替代关系。

当系统规模变大时,"当前正在播放什么"不能依赖某个进程内的布尔变量。

15. 目前仍在继续的工作

这套系统已经打通主要链路,但距离量产仍有很多工程工作:

  • 在多种网络环境下建立正式的端到端延迟基准;
  • 对 AEC 建立标准回放集,而不是只依赖现场主观听感;
  • 完善外壳声学设计和不同音量下的参数;
  • 为模型、ASR、TTS 和 COS 建立分用户成本统计;
  • OTA 灰度、失败率和自动回滚监控;
  • 设备协议版本协商与第三方厂商接入规范;
  • 长期记忆的用户可解释性、纠错和隐私策略;
  • 弱网、断电、SD 卡损坏等故障注入测试。

但架构的价值正在于:这些能力可以沿着既有边界继续增加,而不需要再次把整个系统推倒重来。

结语

AI 硬件最吸引人的部分往往是模型回答,但决定它能否成为产品的,通常是模型之外的部分:

  • 设备能否稳定运行数天;
  • 用户开口后多久得到第一个反馈;
  • 网络抖动时声音是否连续;
  • 被打断的旧回复会不会突然复活;
  • 切换智能体后上下文是否正确;
  • 升级失败能否回滚;
  • 几个月前的重要对话是否仍能被正确想起。

我们从一块双眼屏 ESP32-S3 开发板出发,最后构建的不是一条"语音问答链路",而是一套设备、云端和 AI 共同维护状态的系统。这也是整个工程最值得记录的地方。


数据口径说明

本文引用的数据分为三类:

  1. 协议理论值:如 Base64 约 33% 体积膨胀、16 kHz S16LE 单声道每秒 32000 字节;
  2. 当前代码参数:如 100 ms 上下行分帧、8192 字节 WS buffer、约 1.2 秒启动缓冲;
  3. 现场单轮日志样本:如 12.613 秒旧链路结果、2.807 秒首个 PCM、59.1/76.8 ms AEC 延迟估计。

第三类数据只用于说明工程现象。若用于产品宣传或方案对比,应在统一设备、网络、语料、音量与服务版本下重复采样,并报告 P50、P95、P99,而不是引用单次最优结果。

相关推荐
ValueHD2 小时前
视频会议终端是什么,有哪几种类型?
后端·音视频·视频编解码·腾讯会议
.Hypocritical.2 小时前
SpringBoot 各版本 Profile 多环境配置完整对比(2.x/ 2.4+ / 3.x/4.x
java·spring boot·后端
乐橙开放平台3 小时前
把工地遮挡和掉线接进项目部:setMessageCallback 订 alarm 与 deviceStatus
笔记·后端·物联网·自动化·音视频·智能家居
卷无止境3 小时前
FastAPI 的可观测性进化,从打日志到看清整个系统的呼吸
后端·python·fastapi
MetaLite4 小时前
SpringBoot防重复提交-加一把Redis锁就够吗
spring boot·redis·后端
卷无止境4 小时前
FastAPI 的 CI/CD 之路,从代码提交到线上运行
后端·python·fastapi
计算机毕设定制辅导-无忧学长4 小时前
《基于SpringBoot的图书管理系统设计与实现》
java·spring boot·后端
小蒜学长4 小时前
借助于大模型工具Cursor的中药材交易系统的设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端
狂炫冰美式4 小时前
电脑合盖之后 Cursor 还在偷我电?看看为啥
前端·人工智能·后端