本文记录一个真实工程项目的架构演进。它不是"调用一次大模型 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. 总体架构
这套拆分背后的原则是:
- 设备端负责确定性和实时性:采集、播放、显示、缓存、状态机和故障恢复。
- NestJS 负责身份、连接和业务规则:它知道"谁的设备、绑定了哪个智能体、是否允许升级"。
- FastAPI 负责 AI 流水线:它知道"音频如何处理、上下文如何构建、模型如何流式生成"。
- App 和 Admin 共用领域接口,但面向不同角色:一个服务用户,一个服务运营。
- 原始音频、大文件与结构化业务数据分开存储:数据库不承担对象存储职责。
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 共享这些领域接口。这里的复杂度来自权限和业务一致性,而不是模型计算。
例如"切换设备绑定的智能体"看似只是更新一个字段,实际需要:
- 验证设备和目标智能体都属于当前用户;
- 更新当前有效绑定,但不错误关闭历史会话;
- 通过 MQTT 通知在线设备;
- 关闭旧的实时 AI session;
- 允许设备使用新上下文重新建立 WebSocket;
- 把操作结果同步给 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_start、asr_final、reply_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. 不要让"迟到的数据"破坏下一轮对话
流式系统里最危险的并不总是丢包,而是旧数据晚到。
用户在上一段回复播放中再次说话时,系统需要:
- VAD 确认新语音;
- 立即停止当前播放器;
- 取消或废弃旧 TTS;
- 允许新的 ASR 开始;
- 旧回复已经生成的文字仍可按策略存库;
- 旧请求之后到达的 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 两条时间线。
但它带来两个直接问题:
- 16 kHz S16LE 单声道 MIC 是 32 KB/s,再上传一份同规格 reference,上行接近翻倍;
- 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 的向量相似度生成紧密候选簇,再判断 MERGE、SUPERSEDE 或 KEEP_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 数据。这个数字本身不能证明网络好坏,但结合 lastRxAgoMs、bufferedAmount 和关闭原因,就能区分服务端替换连接、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 共同维护状态的系统。这也是整个工程最值得记录的地方。
数据口径说明
本文引用的数据分为三类:
- 协议理论值:如 Base64 约 33% 体积膨胀、16 kHz S16LE 单声道每秒 32000 字节;
- 当前代码参数:如 100 ms 上下行分帧、8192 字节 WS buffer、约 1.2 秒启动缓冲;
- 现场单轮日志样本:如 12.613 秒旧链路结果、2.807 秒首个 PCM、59.1/76.8 ms AEC 延迟估计。
第三类数据只用于说明工程现象。若用于产品宣传或方案对比,应在统一设备、网络、语料、音量与服务版本下重复采样,并报告 P50、P95、P99,而不是引用单次最优结果。