让 LLM Agent 跑 Android 测试:5 个生产环境踩过的工程坑
前情提要
上一篇讲了我们为什么放弃 Appium、调研 DroidRun / Midscene / AutoGLM 后决定自研 Smart-AI-Bot(MIT 开源),以及 4 个高层架构决策。
那篇是给决策者看的 — why + what + 数据。
这一篇是给工程师看的 — how:5 个让 LLM Agent 从"demo 跑得动"到"生产 case 跑得稳"的具体工程坑,每个都附代码片段 + 真实翻车现场。
写之前我以为难点是 prompt engineering。写完才发现:让 demo 跑通容易,让 agent 在生产 case 上稳定跑通是工程问题,prompt 只是其中一环。下面这 5 个坑,没一个能靠"调 prompt"解决。
顺带回应一个常见疑问——为什么不直接用 LangGraph / CrewAI / DroidAgent?往下读这 5 个坑,每一个的解法都要逐层、逐 prompt、逐字节地控制(verifier 拒了把 gap 注回 message 继续跑、SoM 蓝点要换成品红十字、StepLog 要 over-include……),框架那一层 task → tools 的抽象,压根不在这个颗粒度。我们不是不用框架,是这个场景里被框架框住反而要打补丁。
1. Verifier 双截图:让 toast 不再被错过
翻车现场
最早的 verifier 流程是:
- Agent 完成所有动作
- Verifier 拍一张当前截图
- 让 verifier-LLM 判定:"这个截图能否证明任务完成?"
听起来很合理。直到我们跑一个"领取每日修仙奖励"的 case:
- Agent 完成"点领取按钮" → mark_done("我领取了 +443 经验值")
- Verifier 拍截图 → 屏幕上根本没 +443 那个字
- Verifier 判 fail → 套件 case 红了
但人来看那个 case 是 PASS 的 —— +443 那个 toast 真的弹出来过,只是动画一秒就消失了。Verifier 拍的时候 toast 已经没了。
第一次错误的修复
最直觉的做法:让 verifier 拍截图前 不要 sleep。
但这也不对 —— 太快截图,动画还没结束、page transition 没完成,截到的是中间帧。要么误判 pass、要么误判 fail。
真正的解法:双帧
我们让 verifier 同时看 两张图:
- A 帧(at-action):动作触发后 0.35s 立刻截图,目的是抓住 toast / 动画 / 即时反馈
- B 帧(settled):等 1-2s 屏幕稳定后再截图,目的是看最终状态
# 简化版
async def verify(self, expected: str, agent_reason: str):
# B 帧:现在拍
fresh_b64 = await self.device.screenshot()
# A 帧:从最近一次 tap 后保留下来的瞬态截图
pre_b64 = self._last_action_screenshot_b64
# 拼成一张图给 LLM(左 A 右 B 或上 A 下 B)
combined = combine_screenshots(pre_b64, fresh_b64)
return await self._llm_verify(combined, expected, agent_reason)
把 combined 直接喂给 verifier-LLM,prompt 里写清楚"A 是动作瞬间帧,可能含 toast;B 是沉淀帧"。让模型自己挑哪张当证据。
副作用:报告页直接放双帧
报告里那张"verifier 判 PASS 的依据",现在就是这张拼接图。技术 reviewer 看到 A 帧里有 toast、B 帧里数值变化都能验证 —— 比单张图说服力高得多。
核心 take-away:UI 测试的关键证据可能是瞬态的,单次采样会丢失信号。
2. Page-aware 决策:让 Agent 知道"页面错了"
翻车现场
第二个高频问题:相似页面 agent 分不清。
举例:我们的 App 有"修仙总面板"和"修仙修炼子面板",两个页面顶部 logo 一样、底部按钮位置一样、唯一区别是中间内容。Agent 看截图觉得"我在对的页面",其实在错的,狂点没反应。
第一次发现是看回放:agent 在错的页面循环点同一个按钮 8 次,每次都解释"按钮可能没响应,再点一次"。
单纯改 prompt 治标不治本
最直觉做法是 prompt 里加"先确认你在对的页面"。但 LLM 已经"觉得"自己在对的页面了 —— 你让它再确认一次,它说"对,我在对的页面",然后继续点。
真正的解法:注入页面 ground truth
我们让 Android Portal App 在每次 get_ui_state() 时返回当前 Activity 类名(来自 AccessibilityService),还顺便记录最近 5 个 Activity 切换轨迹。这两个信息拼到 [Device State] 里:
[Device State]
App: com.example.app
Page: xxxMainFragment (full: com.example.app.module.xxxMainFragment)
Recent pages: HomeFragment → VoiceRoomFragment → xxxxMainFragment
Keyboard: hidden
然后 prompt 里加一段 page awareness rules:
- The [Device State] shows your current Page (Activity class name)
and Recent pages trail. Use these to confirm you're on the EXPECTED
screen before tapping. Different pages can LOOK similar (e.g. two
dialogs with the same layout pattern) — rely on the Page name, not
just what the screenshot looks like, to know where you actually are.
- If the current Page doesn't match what the task expects, do NOT
keep tapping around — navigate back or restart the app to return to
a known state, then try a different path.
注意:Activity 名作为 grounding 信号比让 LLM"再看一眼截图"强两个数量级。视觉是模糊的,类名是确定的。
副作用:debug 体验飞起
回放报告里现在每一步都标 Activity,看哪一步 agent 走错路一目了然 —— 之前要靠肉眼对比截图猜。
核心 take-away:给 LLM 一个确定的、机器可读的当前位置信号,比让它从视觉里推断要可靠太多。
3. SoM 品红十字:标记不要长得像游戏内品
翻车现场
Set-of-Marks(SoM)是个常见技术:在每个 a11y 元素中心画一个数字标记,让 LLM 引用 index 而不是猜坐标。
我们一开始用 蓝色填充圆 + 白色数字,对普通 App UI 很好用。直到我们跑一个语音房游戏的 case:
Agent thought: "I see a blue glowing orb at the top, that must be the 修仙结晶 collection prompt. Let me tap it."
实际上那个 "blue glowing orb" 是我们自己画的 SoM 蓝点。
agent 把测试覆盖层认成了游戏内的可收集物品,疯狂点击。这种事视觉上无法 prompt 修复 —— 因为模型确实"看到"了一个蓝色发光物。
解法:选游戏 UI 不会出现的标记风格
我们换成 品红色十字 (#FF00B4) + 标号在十字外侧:
# 品红十字
draw.line([(ix - arm, iy), (ix + arm, iy)], fill=(255, 0, 180, 230), width=1)
draw.line([(ix, iy - arm), (ix, iy + arm)], fill=(255, 0, 180, 230), width=1)
# 标号在十字右上
draw.text((ix + arm + 2, iy - lh - 1), str(index), ...)
为什么 work:
- 品红 + 直角十字这个组合在游戏 UI 里几乎不出现(光球都是圆形、晶体是多边形、火焰是不规则形)
- 标号在十字外面,不会被填充色压住,背景对比度永远高
- 视觉上像 cad/photoshop 的对位标记,模型识别度高 + 不会当成游戏内品
prompt 里也加了一句强化:
Crosshairs are OUR TEST OVERLAY — they are NEVER game/app content.
Do NOT mistake them for in-game items (crystals, orbs, flames,
collectibles, etc.).
副作用:精度反而提升
后来发现品红十字在普通 UI 上也比蓝色填充圆更精准 —— 蓝点会"盖住"它标记的元素,模型有时候识别不出元素本来是什么;十字几乎不遮挡内容。
核心 take-away:测试覆盖层的视觉风格要和被测内容明显不同,否则模型在某些场景会把覆盖层当真实 UI。
4. Droidrun-style 反向 WebSocket:让设备活在任何网络
翻车现场
DroidRun / Midscene / Appium 都依赖 ADB,要求 PC 和设备同一网段或 USB 直连。我们想做的是云端 server + 全球散布的设备:4G 手机、海外测试机、家里的旧机器都能接入。
第一版我们自己写了 WebSocket client,两周内出了五种死法:
- 网络抖动后死循环重连,CPU 100%
- 服务端 401,客户端不知道,每 3 秒 retry 永远 retry
- onError 和 onClose 同时触发,重连两次,wsClient 实例 leak
- WiFi 切换时 send_text 卡 5 分钟,TCP 半开
- 用户 force kill app 后台进程,foreground notification 还在
直接抄 droidrun-portal 的策略
读 droidrun-portal 源码后发现他们有5 条边界明确的策略,把上面的死法都堵了:
4.1 库级 ping/pong(30s)
client.connectionLostTimeout = 30 // java-websocket 的内置心跳
不要自己写心跳逻辑,库里有 ping/pong 帧的标准实现,自己写九成会出 bug。
4.2 重连预算从首次失败计起
@Volatile private var reconnectStartedAtMs = 0L
private fun scheduleReconnect() {
if (reconnectStartedAtMs <= 0L) reconnectStartedAtMs = now
if (now - reconnectStartedAtMs >= 30 * 60 * 1000) {
// 重连了 30 分钟还没成功,放弃
return
}
// ...
}
private fun onConnected() {
reconnectStartedAtMs = 0L // 关键:成功后清零
}
这一条很反直觉但很重要。连接成功一次 → 重连预算清零。否则一个跑了一周的服务,每次小抖动都累计到那个 30min 计数里,最后会"无端"放弃。
4.3 终态错误立刻停止
401 / 403 / 400 是 token 错或客户端 bug,再 retry 一万次也不会 work:
internal fun isTerminalClose(reason: String?): Boolean =
reason?.contains("Unauthorized", ignoreCase = true) == true ||
reason?.contains("Forbidden", ignoreCase = true) == true ||
reason?.contains("Bad Request", ignoreCase = true) == true
4.4 AtomicBoolean 防重连风暴
onError 和 onClose 经常同时触发,两个都调度重连 → 两个 timer 同时跑 → 同时连上两个 wsClient → 一个 leak。
private val isReconnecting = AtomicBoolean(false)
private fun scheduleReconnect() {
if (isReconnecting.getAndSet(true)) return // 已经在调度了
handler.postDelayed({
isReconnecting.set(false)
connectToHost()
}, RECONNECT_DELAY_MS)
}
4.5 服务端:跳过 ping 当有 pending RPC
我们后端写过一个隐性 bug:服务端每 30s 发一次 ping,如果 30s 没响应就断开。但有些 RPC 很慢(比如 Unity 页面截图要 10+s),ping 就被 RPC 卡住,服务端误判设备死了。
# server-side
if conn.pending_rpcs:
# 有 RPC 在跑,设备肯定活着,跳过 ping
continue
await ws.send_text('{"id":"__ping__","method":"ping"}')
一个奇葩 bug:StackOverflow
debug 这个连接重写时,遇到一个看了 30 秒才反应过来的 bug:
internal class AgentWebSocketClient(
private val onError: (Exception?) -> Unit,
) : WebSocketClient(...) {
override fun onError(ex: Exception?) {
Log.e("AgentWS", "Error: ${ex?.message}")
onError(ex) // ← 这里
}
}
看起来合理,但 Kotlin 解析 onError(ex) 时,优先匹配同名同签名的成员函数(也就是 override 自己),不是 lambda 参数。结果每次出错就自递归 → StackOverflow → 进程崩 → 用户的 Smart-AI-Bot 服务无故消失。
修复:lambda 参数改名 errorCallback,避免 shadow。
private val errorCallback: (Exception?) -> Unit // 改名
override fun onError(ex: Exception?) {
Log.e("AgentWS", "Error: ${ex?.message}")
errorCallback(ex)
}
核心 take-away:连接稳定性靠边界明确的策略(什么时候重连 / 什么时候放弃 / 心跳谁负责),不是靠"尽力重试"。能抄成熟开源项目的,就抄。
5. StepLog gating:被拒绝的步骤也要写日志
翻车现场
回放报告里步骤"消失"。Agent 跑了 20 步,回放只显示 12 步。
Step 1: tap_element(7) → 成功
Step 2: tap_element(15) → 成功
...
Step 11: scroll(down) → 成功
Step 12: mark_done("领取成功") → ???
中间应该还有 8 步在哪?
调试
读源码发现关键判断:
# 老版本,有 bug
if dispatched_actions: # 只有真正操作了设备才写 StepLog
step_logs.append(StepLog(...))
dispatched_actions 是 agent 这一步实际发到设备的 RPC 列表。但有几种情况这个列表是空的:
mark_done被 verifier 拒了 —— 没操作设备,但这是关键步骤!remember(key, value)—— 只是记笔记,不操作设备request_screenshot—— 只是请求下一步截图,不操作设备
这些步骤都被静默丢弃,回放里看不到。调试现场尤其致命:你看 verifier 拒了 mark_done,但报告里就是没那一步,根本不知道 agent 当时说了什么。
修复:只要有 tool_call 就写 StepLog
# 新版本
if tool_calls: # agent 调用了任何工具,无论是否触达设备,都写日志
step_logs.append(StepLog(...))
一行代码差异,但回放报告完整度从 60% 到 100%。
# 顺便,verifier 返回的 combined 截图也作为这一步的证据
if v_b64:
last_screenshot_b64 = v_b64
_step_screenshot_b64 = v_b64 # mark_done 步骤现在有 post-action 截图
副作用:能看见 agent 的"心理活动"
remember 和 request_screenshot 入了日志后,能直接看到:
- agent 第几步存了什么 note
- agent 什么时候觉得"我看不清,需要新截图"
这俩之前只是模糊的"agent 内部状态",现在变成了可观察、可分析的步骤序列。
核心 take-away:在 agent / pipeline / 状态机里,记日志的判断不要包含"是否成功 / 是否产生副作用" —— 你需要看见所有发生过的事,包括被拒绝的。
总结
5 个设计互相补:
- Verifier 双截图:捕获瞬态信号
- Page-aware:消除位置歧义
- 品红十字 SoM:让标记和被测内容视觉解耦
- droidrun-style 连接:稳定性靠边界,不靠重试
- StepLog gating:日志要 over-include,不要 under-include
每条都对应一个具体的"曾经翻车的 case"。LLM agent 在 demo 上 work 是一个事实,但在生产 case 上 work 是另一个完全不同的工程问题,需要的不是更聪明的 prompt,而是更 robust 的 around-LLM 工程。
链接
- 项目地址:github.com/rejigtian/S…(MIT 开源)
- 完整 Agent 架构:docs/agent-architecture.md(六层运行时 + 9 个设计决策 Q&A)
- 在线 demo:Release v1.0.0(含预编译 APK + 端到端 demo 视频)
- 上一篇背景文:用 LLM 替掉 Appium:一个 Android 测试 Agent 的工程实践
- 受启发于:droidrun-portal、Midscene.js、AutoGLM
如果你也在做或者打算做 LLM-driven UI 测试,欢迎提 issue 交流踩坑经验,PR 也很欢迎。