给 ESP32-S3 接上 WiFi、同一局域网下开启安卓平板「无线调试」,不用电脑、不用装伴侣 APP,只靠语音就能让小智帮你打开 WPS、写作文、保存、关机 —— 这听起来像科幻,但我们在 10 轮实测迭代后把它跑通了。
背景与架构选型:为什么是 ESP32-S3 做 ADB Host
市面上绝大多数 ESP32 语音助手走的是「云端 MQTT/WebSocket 控制智能家居」的老路:开关灯、查天气、放音乐。要控制安卓设备,常见方案要么需 PC 常驻跑 ADB 桥接,要么手机端装无障碍服务/Root 伴侣 APP,要么上云端大模型 Function Calling 再回调设备 —— 都不够「边缘侧、离线可控、零依赖」。
ESP32-S3 通过 WiFi 连接同一局域网下的安卓设备,用 mDNS 发现无线调试端口,建立 ADB TCP 连接。配合小智已有的 MCP 协议栈,我们在固件层注册了 10 个标准工具:connect/launch/look/tap/swipe/key/type/batch/status/done,覆盖安卓设备的完整控制链路 —— 启动应用、查看屏幕树、点击坐标、滑动、按键、文本输入、批量原子步骤、状态查询、任务完成标记。零 PC、零伴侣 APP、零 Root,联网即用。
核心难点一:中文输入死结 —— ADBKeyboard 广播直输
Android 13+ 的 adb shell input text 直输中文会抛 NPE:KeyCharacterMap 里根本没有 CJK 字符映射。剪贴板 Binder 编组方案我们试过,跨进程序列化失败放弃。最终落地的是开源虚拟键盘 ADBKeyboard:
ime enable com.android.adbkeyboard/.AdbIME启用ime set com.android.adbkeyboard/.AdbIME切换am broadcast -a ADB_INPUT_TEXT --es msg '<文本>'广播直输 Unicode- 约 500 ms 延迟等待 IME 绑定生效
- 用完
ime set切回原输入法
PC 端 adb 实测全链路通,固件侧封装成 type 步骤供 batch 调用,中文作文一句话进、逐字出,零乱码。
核心难点二:长任务多会话自动续接 —— 心跳看门狗 + 自愈机制
官方服务器有个黑盒约束:60-70 秒无语音输入强制收尾会话(发 goodbye),且工具调用不刷新该计时器。MCP 工具超时虽已从 10 秒提至 300 秒(issue #1102),但真瓶颈是静默超时。一篇 WPS 作文少说要 3-5 分钟,必须多会话接力。
设备侧三板斧:
- TLS 心跳看门狗 + UDP keepalive:双通道保活,对抗 adbd 空闲断连
- Goodbye 自愈判据:收到 goodbye 时,若距上次工具调用 ≤90 秒且非自然收尾,自动重开会话并发 7 字
listen detect文本「继续刚才的任务」 - Detect 文本长度受限:实测 16 字被拒、7 字通过,续接细节全靠工具描述引导 LLM「先读 recent 再看屏幕接着写」
实测 8 次自愈全链路通,批预算从 22 秒提至 35 秒(留 25 秒余量给 TTS 轮次),自愈去重(10 秒内重复 goodbye 只续一次)避免服务器重发消耗预算。
核心难点三:陈旧上下文污染 LLM —— 10min TTL + 分隔标记隔离
续接后的新会话里,LLM 不调工具转而去搜服务端长期记忆 —— 旧作文《我的梦想》在记忆里,当前任务《我的武侠梦》不在。test8 实锤:LLM 宣布「已写 3 篇作文」并陷入 4 轮「抱歉我记错了」道歉循环,~50 秒零工具调用。
借鉴业界观察掩码/滑动窗口策略(SWE-agent、JetBrains 2025 验证),我们在受限设备上实现最小版:
- 写端打标记:
launch成功写入==== 新任务开始==== - 读端截取:
status recent仅返回最后一个标记之后的文本 - TTL 兜底:10 分钟无写入自动重置缓冲,清理陈旧会话残留
test10 验证:续接读 recent 只看到当前作文,彻底消除「已写 N 篇」幻觉。
核心难点四:自然收尾误判为中断 —— 用户语音时间戳 45s 窗口
用户说「拜拜/休息」后服务器发 goodbye,若此时触发自愈就是「假续接」。关键洞察:服务器仅对静默 60-70 秒的会话强制收尾;用户在场时 goodbye 必伴随近期语音。
在 MAIN_EVENT_SEND_AUDIO 发包处记 last_user_voice_ms_,goodbye 自愈前守卫:用户 45 秒内说过话 = 自然收尾,跳过续接。test9 验证:564s 用户发话 → 568s goodbye → 零自愈;test5 真中断场景用户 80 秒无语音 → 正常续接。done 工具描述扩大到「任意手机任务完成后调用」,兜底 LLM 忘调 done 的情况。
10 轮迭代测试演进:从报错到闭环的完整复盘
| 轮次 | 暴露问题 | 最小补丁 | 关键指标 |
|---|---|---|---|
| test3 | detect 文本过长被拒(ERROR: Detect is only for wake words) | 缩为 7 字「继续刚才的任务」 | 续接不再报错 |
| test4 | 无 recent 缓冲,续接看不到旧文本 | NVS 缓冲 + RecordTypedText | 可读回已写内容 |
| test5 | 重复道歉循环 + 假续接 | done 工具 + 语音时间戳守卫 | 收尾不再假续接 |
| test7 | batch 预算小、自愈重发消耗、recent 重复读空转 | 35s 预算 + 10s 去重 + 5s 读缓存 | 单会话动作数翻倍 |
| test8 | recent 污染「已写 3 篇」 | launch 写分隔标记 | 污染减少但未根治 |
| test9 | 自然收尾守卫生效验证 | 45s 语音窗口守卫 | 拜拜后零自愈 |
| test10 | 最终验收 | 读端截取最后一个标记后文本 | 全指标达标 |
最终验收指标对比:test8 vs test10
| 指标 | test8(污染版) | test10(最终版) |
|---|---|---|
| 自愈次数 | 8 次(含假续接) | 2 次(仅真中断) |
done 调用 | 0 次 | 3 次 |
| ERROR | 有 | 零 |
| recent 污染 | 「已写 3 篇」+ 道歉循环 | 仅当前作文,零幻觉 |
| 假续接 | 每次拜拜都续 | 零 |
| 会话切换次数 | ~6 次 | ~3 次 |
工程经验总结
- 黑盒服务器约束下,设备侧只能做「最小必要」的补偿:心跳保活、自愈续接、上下文隔离、自然收尾检测 —— 每个机制解决一个确定性痛点,无过度设计
- 观察掩码/滑动窗口在受限设备上的落地:写端打标记、读端截取、TTL 兜底,三行代码解决跨会话上下文污染,避免了重写 NVS schema 的工程量
- 「用户在场」比「任务完成」更可靠的自然收尾信号:语音时间戳 45 秒窗口零误报,配合
done兜底,双重保险 - ADBKeyboard 广播是 Android 13+ 中文输入的唯一可行解:绕过 KeyCharacterMap,无需 Root、无需无障碍、无需剪贴板,PC 端验证即可上车
- 10 轮最小补丁迭代胜过大重构:每轮暴露一个问题、打一个补丁、跑一遍验收,形成可复现的工程演进链路
完整代码已开源:github.com/cljproton/xiaozhi-esp32-android-agent,基于 ESP-IDF release/v6.1,支持 ESP32-S3 系列板卡,烧录即用。
评论