OpenHarmony 小鸿 AI 开发实战 17:从长文本到发热量化的端到端验收

📅 2026/7/30 21:10:20
OpenHarmony 小鸿 AI 开发实战 17:从长文本到发热量化的端到端验收
一套 OpenHarmony 语音终端能完成一次问答不等于它已经通过端到端验收。真正的验收要把问题拆成可追溯的观测点回答文本是否完整进入设备240×240 屏幕是否按 UTF-8 边界翻页最后一页是否停留TTS 音频是否排空队列满时怎样处理服务器从 VAD 结束到首个音频帧花了多久连续工作时温度和电流又怎样变化。少一层证据都可能把“偶然跑通”误写成“稳定通过”。本文依据小鸿 AI 当前真实的 WS63 设备端修改、Python 后端和本地确定性 smoke test 整理。当前设备端仍是 OpenHarmonymini、LiteOS-M 和 WS63 的原生 C/GN/HB 工程不是 ArkTS 应用。先把结论说清楚源码与本地协议规则可以复核两个本地 smoke 已重新通过本轮没有重新构建、烧录和运行实机也没有真实温升、电流、CPU 或无线占空比数据。因此这是一套“当前实现审计 验证与验收方法 未闭环清单”不是发热已经解决的性能报告。端到端验收先分开五层证据第一层是源码函数、常量、状态机和逐文件 SHA-256 能证明“当前代码写了什么”。第二层是构建完整命令、退出码、产物时间、大小和哈希能证明“这些源码生成了哪个包”。第三层是烧录BurnTool 选中的确切包、七镜像写入日志和时间能证明“哪个包进入了设备”。第四层是运行启动串口、屏幕、按键、Wi-Fi、语音上行、回答显示和扬声器播放能证明“设备行为是什么”。第五层才是量化时延分段、丢帧、温升、电流、CPU 和无线占空比能回答“是否稳定、瓶颈在哪里”。这五层不能互相代替。代码中有分页函数不代表指定固件已经包含它磁盘上有.fwpkg不代表 BurnTool 本次选中了它出现All images burn successfully不代表长回答翻到了最后一页实机摸起来温热也不能给出温升或功耗结论。本文后续每个“通过”都会标明证据所在层级。当前长文本边界不是旧版的 144 字节当前lvgl_ui_layout.h中待显示回答数组容量为 512 字节保留结尾 NUL 后最多承载 511 字节单页临时缓冲上限为 256 字节翻页周期为 3200 ms。入口当前使用strncpy(..., 511)后强制补 NUL超长外部文本会被截断而且截断点本身没有做 UTF-8 回退理论上可能切在多字节字符中间。这里的 256 则是后续分页测量和复制时的 UTF-8 字节上限不是“每页固定显示 256 个汉字”。一个常见中文字符通常占三个 UTF-8 字节实际页长还要受字体、字宽、行距、文本框宽高和混合标点影响。服务端又有另一层边界DEVICE_ANSWER_CHARS默认 72环境变量可在 40120 之间设置。Python 的这一项按字符裁剪设备端两个常量按字节管理不能把它们直接当成同一单位。当前正常服务端路径即使按 120 个四字节 Unicode 码点估算也不超过 480 字节通常不会撞到 511 字节入口上限但其他调用方、未来配置变化和异常输入仍需单独测试。/* 对话框缓存的对话文字大小。AI 回答是动态文本保留更长的 UTF-8 缓冲供滚动显示。 */ #define DISP_LVGL_TEXT_PENDING_MAX (512) /* 单页临时缓冲上限实际页长由当前字体、宽度和高度动态测量。 */ #define DISP_SHOW_TEXT_MAX_LEN (256) /* 每页显示 3.2 秒最后一页同样完整停留一周期。 */ #define DISP_SHOW_TEXT_DELAY_MS (3200)分页由当前字体和文本框实测决定进入disp_page_fit_bytes()的有效字符串不再按固定字节数直接截页。utf8_char_bytes()先识别 14 字节 UTF-8 序列分页函数保存合法字符边界再用lv_text_get_size()测量候选字符串。函数用二分查找寻找不超过文本框高度的最大字符边界最后返回字节数因此分页阶段不会从中间切开三字节中文字符也能随字体和回答区尺寸变化重新计算。这个保证不覆盖前面 511 字节入口截断两层边界必须分别测试。while (low high) { uint16_t mid (uint16_t)(low (high - low) / 2U); size_t bytes boundaries[mid]; memcpy(disp_chat_text, text, bytes); disp_chat_text[bytes] \0; lv_point_t measured {0}; lv_text_get_size(measured, disp_chat_text, font, letter_space, line_space, max_width, LV_TEXT_FLAG_NONE); if (measured.y max_height) { best mid; low (uint16_t)(mid 1U); } else { if (mid 0U) { break; } high (uint16_t)(mid - 1U); } } return (size_t)boundaries[best];源码层能确认算法和边界但还不能确认所有字形在实机上都没有裁切。真正的长文本用例应至少包含纯中文、中英混排、数字和标点、换行、罕见字缺字回退、四字节 emoji、接近 511 字节有效载荷的回答以及刻意超过入口容量的异常输入每组都要记录入口是否截断、页数、每页首尾字符、是否出现半个字符和最后一页是否可读。旧快照漂移本身就是一次验收案例早期文章和路线记录曾引用 144/108 字节分页41 号包也以分页文本为主题。但当前头文件已经明确改为DISP_SHOW_TEXT_MAX_LEN256实现也升级为按真实字形测量。继续拿旧文章中的 144 当当前常量会让测试数据、配图和代码块同时失真。这说明文章验收不能只在第一次写完时检查一次。源文件发生变化后应重新比对 SHA-256定位哪些摘录漂移再决定是修正文稿、重建固件还是保留历史结论并标注版本。41 号包大小为 1,947,048 字节SHA-256 为508259C32BF9DBA5081F5F9A0D20676059632153C107033FB8B9E5177C598820它只能证明一份历史包存在不能证明当前 256 字节和动态测量代码已烧入设备。最后一页结束还要与音频排空协同多页回答的第一页立即显示后续页由 LVGL timer 每 3200 ms 切换。显示最后一页时代码先设置s_show_text_final_dwelltrue等下一个周期再结束滚动。在正常且未触发 watchdog 的路径上Agent 先等音频队列为空再检查disp_text_is_playing()分页仍在进行时不会进入正常返回首页的 hold。与此同时源码设置了 5 秒音频排空超时和 12 秒总完成超时命中任一超时会中止本地播放并回到 IDLE而不是无限等待文字或音频。bool audio_empty ci1302_tts_is_empty(); uint32_t stop_elapsed_ms (s_tts_stop_tick 0U) ? 0U : elapsed_ms_since_tick(s_tts_stop_tick); bool drain_timeout !audio_empty stop_elapsed_ms AGENT_TTS_DRAIN_TIMEOUT_MS; bool finish_timeout stop_elapsed_ms AGENT_TTS_FINISH_TIMEOUT_MS; if (drain_timeout || finish_timeout) { log_error(%s force TTS finish: audio_empty%d elapsed%lu ms\r\n, TAG, (int)audio_empty, (unsigned long)stop_elapsed_ms); abort_local_tts_playback(drain_timeout ? drain timeout : finish timeout); if (g_protocol ! NULL protocol_has_close_audio(g_protocol)) { g_protocol-ops-close_audio_channel(g_protocol-impl); } set_state(AGENT_STATE_IDLE); return; } if (!audio_empty) { s_tts_empty_start_tick 0U; return; } /* 音频结束不代表长文本已经翻完等分页和最后一页停留结束再回首页。 */ if (disp_text_is_playing()) { s_tts_empty_start_tick 0U; return; } if (s_tts_empty_start_tick 0U) { s_tts_empty_start_tick osKernelGetTickCount(); return; } uint32_t hold_ms s_tts_had_audio ? AGENT_TTS_AUDIO_HOLD_MS : AGENT_TTS_TEXT_HOLD_MS; if (elapsed_ms_since_tick(s_tts_empty_start_tick) hold_ms) { return; }需要注意一个真实分支如果回答经测量只有一页disp_show_pending_text_utf8()会立即把s_disp_text_playing设为 false不进入多页的最终停留 timer此后页面保持时间由 TTS 排空后的 hold 逻辑承担。测试报告应把单页、多页和纯文本无音频三种路径分开而不是只观察一次多页回答。背压要从四槽 PCM 队列开始观察服务端发来 Opus 后WS63 解码为 16 kHz 单声道 PCM再合并为最多 4096 字节的块。在 OpenHarmony 编译条件下队列有四个槽。CI1302 用0x020A拉取设备再以0x020B返回当前 PCM 段。这个设计避免每个小 Opus 包都立即形成一次 UART 大事务但四槽并不是无限缓存。队列满时当前策略是丢弃最旧块、增加s_overflow_count然后写入新块前三次以及每 32 次打印警告。ci1302_tts_push()因为执行了“丢旧保新”未必向上层返回失败所以仅统计 push 失败无法发现所有音频缺口。实机验收必须同时抓取[DownLink] tts queue full、下行包数、CI1302 拉取节奏和听感。if (g_count TTS_SLOT_NUM) { s_overflow_count; if (s_overflow_count 3U || (s_overflow_count % 32U) 0U) { log_warn([DownLink] tts queue full, drop oldest block (count%lu)\r\n, (unsigned long)s_overflow_count); } g_rp (uint8_t)((g_rp 1U) % TTS_SLOT_NUM); g_count--; }服务端节流只能降低风险不能代替测量当前 Python 服务端把 TTS 生成超时默认设为 24 秒并限制在 845 秒。下行的前 10 个 40 ms frame 立即发送形成名义 400 ms 初始缓冲之后按照每帧 40 ms 的时间线节流。这能减少“一次把整段音频灌满设备队列”的概率但网络调度、Opus 包长度、设备解码耗时和 CI1302 拉取速度仍会造成抖动。stream_started loop.time() for index, packet in enumerate(speech.packets): if index TTS_INITIAL_BURST_FRAMES: target stream_started ( (index - TTS_INITIAL_BURST_FRAMES 1) * speech.frame_duration_ms / 1000.0 ) delay target - loop.time() if delay 0: await asyncio.sleep(delay)服务端已有 TTSpackets、音频时长和总elapsed日志语音上行也记录 frame、byte、推算时长和 ASR 结果但还没有一条统一记录贯通“VAD stop、ASR 完成、LLM 完成、TTS 首帧、设备首声、播放结束”。所以本文不能给出端到端 P50、P95 或最坏时延只能指出需要补齐的时间戳。本地 smoke 证明的是协议规则没有断本轮用禁止写入 bytecode 的方式重新执行protocol_smoke.py和response_variety_smoke.py。前者通过 FakeWriter 收集 WebSocket frame并用替换后的 ASR、LLM、TTS 函数检查 hello、listen、协议 v1/v2/v3 包头、音色切换、重连、三条 TTS JSON 和四个下行 Opus 包。后者检查重复回答、相似度重试、设备历史、persona、模式分类、独立事实复核与完整句截断。audio_stop_json3 downlink_opus4 asr_to_llmok protocol_headersok voice_switchok voice_reconnectok repeat_detectedok similarity_retryok device_history2 personaok response_modesok independent_fact_reviewok sentence_cutok syntax_compileok files5这些输出是真实的本地测试结果但测试中的音频包、识别文本和 TTS 结果是确定性替身。它没有连接公网 WebSocket没有调用真实 DeepSeek、sherpa-onnx 或 Fish Audio没有经过 WS63 无线栈、Opus 解码器和 CI1302 UART。因此状态应写成“本地协议回归通过”不能写成“端到端实机通过”。端到端时延需要同一会话的观测点建议每条实机会话使用同一个 session id至少记录七个时刻设备发送最后一个 Opus 包、发送listen/stop、服务端 ASR 完成、LLM 完成、TTS 生成完成、服务端发送首个 binary frame、设备开始发0x020B或扬声器出现首声。播放结束还要记录服务端tts/stop、设备队列排空和 UI 回到首页。报告不能只写一个“响应 2 秒”。至少要拆出上行收尾、ASR、LLM、TTS 生成、首帧网络、设备启动缓冲和整段排空同一组固定问题重复多次分别计算中位数、P95 和失败数。弱网、TTS fallback、短回答、多页回答与连续打断要单独成组。本轮没有执行这些实测所以没有填写任何毫秒结果。候选固件必须绑定当前源码才能进入实机结论当前磁盘上较新的候选包是000_BURN_THIS_48_THINKING_STATE_RECOVERY_WS63_20260728_2156.fwpkg大小 1,980,072 字节SHA-256 为C7427E87D927EE157400FFEED46E5209B3C7850BDA078E8851619858BA71FA6D快捷副本的大小和哈希一致。该文件时间晚于本文抽取的设备源码修改时间但文件时间的先后关系仍不能替代构建日志和源文件清单。candidate: 000_BURN_THIS_48_THINKING_STATE_RECOVERY_WS63_20260728_2156.fwpkg bytes: 1980072 sha256: C7427E87D927EE157400FFEED46E5209B3C7850BDA078E8851619858BA71FA6D设备端基础提交是cf18843f621269dba6fbdec2fe87f1b3b13460dc而本文涉及的 display、agent 和 TTS downlink 文件当前均是未提交修改。要把 48 号包写成“当前源码构建结果”仍需保存本次完整 HB/GN/SDK 命令、退出码、构建时间、业务源文件哈希与输出包哈希。本轮没有重新执行构建因此构建层只确认“候选文件存在且哈希已读回”。发热量化要先固定工况和测点目前没有可引用的真实温升或电流数据。下一轮测量应使用同一块设备、同一固件哈希、正常且固定的供电方式、固定屏幕亮度和音量并记录环境温度。至少区分启动后空闲、Wi-Fi 已连接但无会话、持续录音上行、等待 ASR/LLM、TTS 播放、屏幕持续翻页六种工况。板温测点要固定在可复现位置红外测温还要记录表面材质和发射率限制不能把芯片外壳、PCB 背面和外壳表面混为一个数字。建议的记录表包含固件 SHA-256、供电电压、采样时间、环境温度、板上测点温度、电流平均值、电流峰值、屏幕亮度、音量、Wi-Fi RSSI、会话次数、TTS 播放占比、队列 overflow 次数和异常日志。预热时长与采样周期应在测量前固定例如每种工况先稳定一段固定时间、再按固定周期采样“例如”是测试方案不是本文已经执行的数据。没有这些数据时只能提出假设背光、功放、Wi-Fi 活跃、CPU 空转或队列忙等都可能影响热量。触摸感觉、单次瞬时温度或不同环境下两张红外图不足以定位原因。正确顺序是先建立可重复基线再一次只改变一个因素最后用温升、电流和日志同时解释差异。当前验收结论按证据层级落表截至本文快照源码层已确认待显数组容量是 512 字节、有效载荷最多 511 字节当前单页临时上限是 256 字节而不是旧 144页长由 LVGL 字形测量决定多页最后一页有 3.2 秒停留正常路径会等待分页和音频排空5 秒/12 秒 watchdog 则提供异常出口。OpenHarmony 下行使用四个 4096 字节 PCM 槽且满时丢最旧块服务端默认把设备回答裁到 72 字符并以 10 帧初始 burst 加 40 ms 节流发送。本地测试层已确认两个确定性 smoke 和五个 Python 文件的内存语法编译通过。构建层仅确认候选包文件、大小和 SHA-256 存在没有在本轮把它与当前源文件哈希重新绑定。烧录层、启动层、真实长文本翻页、真实播放、端到端时延和温度电流量化均未在本轮执行。因此发布这篇文章时最重要的结论不是“所有问题已经解决”而是得到一条不会混淆证据的闭环路线先冻结或提交当前设备源码全量构建并保存日志与包哈希BurnTool 只选择该哈希用固定长回答逐页核对字符、页码、最后一页和返回首页同步抓取协议与 UART 日志最后在固定工况下测量温度和电流。完成这些步骤后才能把ready_local的文章证据升级为真正的“端到端实机验收通过”。