WS63 端完成 WebSocket 握手后真正的语音工作才到服务端开始接收 OTA 查询返回字体与协议配置为每条 WebSocket 连接创建 Session收集 Opus在 VAD stop 后执行 ASR把文本交给 LLM再将 TTS 音频分帧送回设备。任何一环的格式、超时或资源边界不一致设备端都可能表现为“连接成功但没有回答”。本文对应小鸿 AI 当前真实的 Python 服务端它负责接住 OpenHarmony WS63 设备发出的协议消息。服务没有使用 FastAPI、Flask 或第三方 WebSocket 框架而是直接基于asyncio.start_server()解析 HTTP 和 WebSocket。这样依赖较少也意味着握手、frame、路由、限流和异常关闭都由项目代码自己承担。本轮完成的是本地语法检查和确定性冒烟不是生产服务器在线状态证明。服务端入口是一套 asyncio TCP 服务器main()使用asyncio.start_server(handle_client, host, port)监听连接。每个 client 先读取 HTTP header再由handle_http()判断普通 HTTP、WebSocket Upgrade、OTA、字体、健康检查或 chat。处理结束后统一关闭 writerWebSocket 路径则在同一连接上继续读帧。async def main() - None: parser argparse.ArgumentParser() parser.add_argument( --host, defaultenv(HOST, 127.0.0.1)) parser.add_argument( --port, typeint, defaultint(env(PORT, 18088))) args parser.parse_args() server await asyncio.start_server( handle_client, args.host, args.port) async with server: await server.serve_forever()源码默认端口是 18088但 systemd unit 的ExecStart明确传入--host 0.0.0.0 --port 8002。因此讨论部署端口时要看实际启动参数不能只看 Python 默认值。公开文章也不应把某个当前公网 IP 当成架构常量host、HTTP base URL 和 WS URL都应从环境配置注入。主前缀是 /hajimi/xiaohong 只是兼容别名当前代码把API_PREFIX设为/hajimi把/xiaohong保留为旧设备兼容前缀。OTA、WebSocket、字体、健康检查和 chat 路由都同时接受两者但新的配置响应应返回主前缀。API_PREFIX /hajimi LEGACY_API_PREFIX /xiaohong if path_only in { f{API_PREFIX}/font.bin, f{LEGACY_API_PREFIX}/font.bin }: writer.write( file_response(FONT_FILE_PATH, headers))兼容别名适合迁移窗口不等于两套产品命名可以永久混用。日志、监控和文档最好统一记录 canonical prefix并单独统计 legacy 请求量当旧固件占比降到可控范围后再决定是否移除别名。OTA 响应同时承担固件、字体和 WebSocket 配置设备请求 OTA 时服务端先解析 JSON。普通设备请求返回固件版本信息与 WebSocket 配置字体请求通过 board type 区分返回font.bin的版本、URL、size 和 SHA-256。字体 URL使用/hajimi/font.bin正好对应第 08 篇 LittleFS 下载器。Range 下载能否续传还取决于文件响应实现。当前file_response()解析 Range header计算 start/end返回完整文件或分段内容。设备端只有在响应码、Content-Range 和本地 offset 一致时才继续写.part因此后端不能简单把 Range 忽略后仍返回一份 200 全量数据而不让设备察觉。OTA 返回的 WebSocket token 属于认证信息。下面只保留环境变量名PUBLIC_HTTP_BASE_URL... PUBLIC_WS_URL... HAJIMI_WS_TOKEN... FONT_FILE_PATHfont.bin FONT_VERSION...文章、Git 仓库和测试输出都不应包含真实 token、API key 或云厂商 voice ID。示例默认值只能用于本地开发部署时应由权限受控的环境文件覆盖。WebSocket Upgrade 后先建立 Session服务端校验 Upgrade 请求、计算Sec-WebSocket-Accept并返回 101然后为连接创建Session。Session 记录 session id、设备对应的 dialogue key、voice key、协议版本、音频格式、采样率、帧时长、累计 binary frame/byte 以及本轮 Opus packet。dataclass class Session: session_id: str dialogue_key: str voice_key: str DEFAULT_VOICE_KEY binary_frames: int 0 binary_bytes: int 0 audio_packets: list[bytes] field( default_factorylist) protocol_version: int 1 audio_format: str opus sample_rate: int 16000 channels: int 1 frame_duration_ms: int 40 audio_overflow: bool False设备 hello 到达后服务端更新这些协议参数并回 server hello。设备端只有收到这个 hello 才把 audio channel 标成 opened。两端分别维护 Session 与 Agent 状态但用相同的 hello、listen、tts 消息和 binary frame 约定对齐生命周期。Session 对音频帧数和总字节设硬上限无限收集 binary frame 会让一个异常客户端耗尽服务器内存。当前代码设置MAX_AUDIO_FRAMES 750和MAX_AUDIO_BYTES 512 * 1024。每个 binary packet 进入 Session 时累计 frame 和 byte超过任一上限后清空 packet 列表并设置audio_overflow不再继续堆积。MAX_AUDIO_FRAMES 750 MAX_AUDIO_BYTES 512 * 1024 if ( len(session.audio_packets) MAX_AUDIO_FRAMES or session.binary_bytes MAX_AUDIO_BYTES ): session.audio_packets.clear() session.audio_overflow True这是一种保护服务器的硬失败而不是语音质量策略。设备正常发送多少帧取决于 40 ms frame 时长、说话长度和协议开销如果经常触发上限应先检查 VAD stop 是否丢失、断线后 Session 是否释放、客户端是否重复发送而不是直接把上限扩大十倍。listen/stop 才触发 ASR→LLM→TTS服务端收到 binary frame 时只收集音频不会对每个 40 ms packet 单独跑一次识别。设备端 VAD 结束并确认最后一帧已经上行后发送listen/stop服务端才把当前 packet 序列交给 ASR。ASR 返回空文本时不能继续制造一个似是而非的回答返回有效 question 后LLM 调用通过asyncio.to_thread()从事件循环移到工作线程因为同步 HTTP 或模型 SDK 可能阻塞。答案经过长度、角色和重复检查再进入 TTS。question await asyncio.to_thread( recognize_session_audio, session) if question: answer await asyncio.to_thread( answer_question, question, session) else: answer 没有听清问题请靠近麦克风重新说一遍。 session.audio_packets.clear() await send_tts_response(writer, session, answer)真实代码中的函数参数和异常分支比示意更完整。关键顺序是 packet 收集、stop、ASR、LLM、TTS若设备提前 stop、服务器在每帧识别或 TTS 未准备好就发 stop都会破坏端到端时序。对话历史按设备隔离并设置数量与时间边界服务端根据 Device-Id、Client-Id 或 HTTP 请求中的 identity 生成 dialogue key。历史表和 voice selection 表都由线程锁保护因为 LLM 调用在工作线程中执行。当前历史最多保留配置的轮数同时有 TTL 和最大设备数避免一个长期运行的进程无限积累。回答生成不只是“把问题发给模型”。当前代码有 persona、答案长度、重复检测、相似度重试、设备历史和句子截断逻辑。response_variety_smoke.py专门覆盖了重复回答检测、相似回答重试、两条设备历史、persona、response mode 与独立事实审查等分支。这类测试可以证明确定性规则没有在一次修改中被破坏但不能证明云模型永远返回高质量内容。线上仍需观察超时、空响应、错误码、敏感内容处理与成本并为外部模型不可用准备可理解的设备提示。TTS 用 JSON 控制帧包住二进制 Opus 流send_tts_response()先整理文本并选择 voice profile再生成语音。发送顺序是 TTS start JSON、若干 binary Opus frame、TTS stop JSON。设备收到 start 后从LISTENING转SPEAKINGbinary frame 进入播放缓冲收到 stop 后仍要等待播放队列排空。server - {type:tts,state:start,...} server - binary Opus frame 1 server - binary Opus frame 2 server - ... server - {type:tts,state:stop}服务端配置了 TTS 生成超时并对初始 burst frame 做控制。首包太慢会让用户误以为设备没响应一次塞太多又会加大网络 send buffer 和设备播放队列压力。真实体验需要同时测生成时延、首帧时延、播放连续性和 stop 后的收尾。健康检查展示能力状态但不是完整业务验收/hajimi/health返回 service、canonical/legacy prefix、LLM 是否启用、模型与 persona 参数、ASR/TTS status 以及可用 voice 列表。它适合判断进程是否响应、配置是否被读取也能帮助部署脚本做基础探针。但 health 为 200 不代表设备语音已打通WebSocket Upgrade 可能被反向代理挡住token 可能不匹配字体 Range 可能配置错误ASR 模型可能只在首次调用时加载失败TTS 云服务也可能超时。完整验收仍应使用一条真实设备会话覆盖 OTA、hello、Opus 上行、ASR 文本、LLM 答案、TTS 下行和设备播放。当前部署文件存在两处必须正视的不一致第一处是端口Python 默认 18088systemd unit 显式启动在 8002。只要运维以 unit 为准并让反向代理指向 8002这不是 bug但文档和监控必须写清实际入口。第二处更危险仓库中的旧 Nginx 片段仍只代理/xiaohong/并指向 18088当前 canonical prefix 是/hajimisystemd 又使用 8002。这个片段不能直接当作当前可部署配置更不能因为文件名存在就声称线上已生效。# 迁移后的方向示意不代表已部署事实 location /hajimi/ { proxy_pass http://127.0.0.1:8002/hajimi/; }WebSocket location 还需要 Upgrade、Connection、Host 和超时等 header。真正修改服务器前应先备份现有 Nginx 配置执行语法检查reload 后再用 health 与 WebSocket 客户端验证。本篇只审计本地文件没有连接生产主机也没有替用户修改线上配置。本轮测试结果与证据边界本轮对server.py、asr_service.py、tts_service.py、protocol_smoke.py和response_variety_smoke.py执行了 Python 语法编译检查protocol_smoke.py输出包含audio_stop_json3、downlink_opus4、asr_to_llmok、protocol_headersok、voice_switchok和voice_reconnectok回答多样性冒烟的重复检测、相似度重试、设备历史、persona、response mode、事实审查和句子截断均为 ok。这些结果属于本地测试。它们没有证明生产 systemd 正在运行、Nginx 已使用新前缀、真实 DeepSeek/ASR/TTS 凭据有效也没有证明 WS63 实机在公网弱网下稳定。文章把source_snapshot.json的逐文件 SHA-256 作为代码证据锚点把本地 smoke 作为测试证据把尚未执行的线上和实机检查列为待办避免把三个阶段混成一个“验证通过”。至此设备侧的 CI1302 Opus、Agent/WebSocket 状态机和 Python asyncio 服务已经能够按同一协议图阅读。下一步继续扩展功能前最值得先补的是端到端可观测性为每个 session 贯通 device id、session id、audio frame count、ASR 文本、LLM 耗时、TTS 首帧和 close reason。只有日志能够跨设备与服务器对齐偶发的“没听见、没回答、播一半”才会从猜测变成可定位的问题。