为什么LLM做移动端测试总是翻车?这是我这两年从DroidRun和Mobilerun两个框架踩坑踩出来的最核心问题。你给LLM一条很明确的任务,再塞给它一整棵UI控件树,它可能前三步还正常,第四步就开始自顾自地点击别的元素,甚至把"退出登录"当成"确认",最后测试失败了你连失败原因是规划错了还是定位错了都分不清。这一篇聊的就是我们怎么把"一个LLM全权包办"改成"规划者、定位者、执行者、验证者、诊断者五个角色各管一段"的完整思路,包含角色分工、Prompt设计、代码骨架、以及我实测下来最值得警惕的五个坑。如果你正在做LLM驱动的移动端自动化测试框架,或正被全链路让LLM跑手机测试的不可控性折磨,这篇应该对你有用。
1. 为什么一个LLM全权跑移动端测试,总在第三个步骤开始乱
1.1 上下文失控:UI层级信息是如何偷走模型注意力的
先看一组我自己统计过的数据。拿一台普通的测试机跑一个Android应用的回归用例,一屏UI Hierarch通常包含2000到8000个token。注意这还只是一屏的静态数据,真实测试里你每做一步操作,页面都会变化,下一次点击之前你又得重新抓一次层级信息。一个登录流程就算只有10步,光UI层级累计就要吃掉两三万token,加上任务描述、历史操作记录、中间截图信息,轻松突破一个中等上下文窗口的舒适区。
我最早在DroidRun里做单Agent方案时,连续跑过一批回归测试,前5个用例的准确率大概能到90%以上,跑到第8个用例开始下跌,11个用例往后直接掉到七成以下,而且完全找不到规律。后来把参与者的token日志拉出来看,才发现模型在最后几步经常"忘了"最初的任务要求——比如清理数据的步骤被跳过了,或者连续点了两次同一个按钮。这不是模型变笨了,而是上下文太长之后,模型对中间部分内容的注意力开始明显衰减,越靠前的指令越容易被冲淡。
角色级拆分的第一个收益就在这里:把上下文隔离。Planner只需要读一次任务描述和页面数据库,输出动作序列之后就可以"下班";Locator每次只关心"当前这一屏里哪个节点匹配这个语义目标";Executor只根据当前候选列表干活。每个角色各管各的上下文,token总量不仅大幅下降,模型能聚焦的信息范围也一下窄了很多。这个改变带来的准确率回升非常直接,不需要换更强的模型。
1.2 从"一个人干所有活"到"五个角色各管一段"
有人可能会问:把同一个LLM拆成五个角色,和我写五个不同的system prompt有什么本质区别?区别确实不只是Prompt层面,而是整个工程的边界划分。传统自动化测试框架里有Page Object Model、动作库、断言库,本身就是模块化设计;角色级拆分相当于把POM的思路移植到LLM原生测试框架里,让模型不是"生成一堆自动执行脚本",而是"持续扮演一个高内聚、低耦合的角色"。
这样拆有几个非常实际的好处。
第一是模型分级。规划角色对推理能力要求高,可以用能力更强的大模型;定位和执行角色调用频次极高,如果也用大模型,成本会很快失控。拆开以后,低频高难度的决策用大模型,高频低难度的操作用轻量模型,总成本能降不少,而准确率不降甚至更高。
第二是失败可归因。单体方案里一旦用例失败,你面对的是几百行混合了思考过程和执行的日志,得凭经验猜是规划错了还是定位错了。拆开之后,失败信息能落到具体角色头上:是Planner生成的步骤不合理,还是Locator给的候选全是错的,或者Executor执行超时?每一个都能走不同的处理策略。
第三是可扩展性。新接一个平台(比如iOS或者车机屏幕)时,只需要适配新的设备驱动和新的元素属性体系,其他角色的逻辑不用动。这一点我们是在Mobilerun做跨端统一时才真正体会到——如果角色不分层,换平台等于重写框架。
2. 五个核心角色:职责划分、Prompt设计要点与模型选择
2.1 Planner——把产品需求翻译成低风险动作序列
Planner是整个测试链路的第一环。它的输入很杂:可能是一句"验证登录失败时的提示",也可能是一段需求描述"用户在高风险操作后应该看到二次确认弹窗"。Planner要做的是把这种带业务语义的需求,转换成一份结构化的动作序列。
我的要求是输出严格JSON,每个步骤包含四个字段:action、target、value、expect。其中expect是留给后续Validator用的预期信息,可以是页面标题、控件文本,甚至是一句语义描述。下面是一个登录流程的Planner输出示例:
[ {"action": "launch_app", "target": null, "value": null, "expect": null}, {"action": "input_text", "target": "用户名输入框", "value": "alice", "expect": null}, {"action": "input_text", "target": "密码输入框", "value": "pass123", "expect": null}, {"action": "tap", "target": "登录按钮", "value": null, "expect": null}, {"action": "wait", "target": "首页", "value": null, "expect": "首页标题显示"} ]Planner的设计里有一个很关键的细节:它不能只靠模型脑补业务流程,还需要一个页面语义库作为辅助。我们在DroidRun里维护了一个简单的页面描述文件,记录每个页面的关键控件、页面跳转关系、常见业务要素(比如"登录页通常有用户名、密码、登录按钮")。Planner在读任务描述时把页面语义库一起带上,生成的步骤才会贴合真实应用的结构,而不是张嘴就来"点击菜单"但实际上应用里根本没有叫"菜单"的东西。
Prompt里我会固定约束两点:第一,所有步骤必须可回滚、无副作用,涉及删除数据或者修改关键配置的步骤要标记为"high_risk",等调度器决定是否需要人工确认;第二,输出不要出现推理过程,只要JSON,减少无效token。
2.2 Locator——从几百个控件节点里找到"用户说的那个元素"
Locator是整个角色链里最容易出问题、也最值得投入精力的一环。它要做的事情是:给定一个语义目标(比如"用户名输入框")和一份当前UI层级的压缩文本,输出Top-K个候选元素,带置信度和理由。
为什么输出Top-K而不是一个?因为一次定位命中率做到100%不现实——同一个输入框在代码里可能同时有text、content-desc、resource-id多种属性,模型偶尔也会被相似元素干扰。Top-K给了执行器一个择优重试的空间。
Locator的Prompt模板我会这样写:
你是移动端UI元素定位器。 目标语义:{target} 当前页面控件树(已压缩): {hierarchy} 请根据语义匹配给出你认为最匹配的1-5个元素候选,输出JSON数组,字段为: [{"id": "控件的唯一标识", "confidence": 0-1, "reason": "为什么匹配"}] 注意:只输出JSON数组,不输出任何解释。这里还有一个前置步骤极其重要:层级压缩。原始UI hierarchy里有大量无用节点——纯布局的ViewGroup、无文本无ID的ImageView、以及嵌套十层的FrameLayout。我们在适配器层做了过滤,只保留"可交互节点"(可点击、可输入、有文本或关键资源ID)并截断过长的content-desc。一个2000节点的树往往能压到200到300个节点,token开销直接降一个量级。少了这些干扰之后,Locator的命中率反而更高。
实际运行中,我建议每次调用Locator前都传一份精简后的层级,因为页面状态在变化,复用一个"看起来差不多"的旧层级去定位,经常出现目标元素坐标已经变了的情况。定位结果出来之后,执行器会优先选择置信度最高的候选,但要把"候选列表"完整保留在事件里,供Debugger在失败时做二次分析。
2.3 Executor——只负责执行设备动作,不临时改方案
Executor是唯一直接接触设备驱动的角色。它拿到的是Locator给出的候选元素和动作指令,然后调用底层驱动执行点击、输入、滑动等操作,最后返回一个执行结果。
执行器最大的陷阱是"越权"。最开始我们让执行器带一点模型自由发挥权,想让它在遇到弹窗时自动关掉弹窗再继续。结果它经常自作主张地跳步骤,或者把某个按钮的点击转换成滑动操作,导致执行路径完全不可控。后来Mobilerun里做了一个硬约束:Executor只接受明确动作,要么执行成功,要么返回Retry或Exception,绝对不允许它修改动作序列本身。
执行器里的一个核心机制是"动作确认反馈环"。比如点击一个输入框后,需要检查焦点是不是真的落在这个输入框上,再执行文本输入;输入完成后还要检查文本内容,确认没有输入错误。这些反馈是纯规则判断,不需要LLM再介入,但能显著降低后续步骤的连锁失败。毕竟移动端的输入框聚焦、软键盘弹出、焦点丢失这些场景,没做过的人很难想象有多频繁。
Executor的结果格式我会统一成状态机:OK / RETRY / FAIL / EXCEPTION。其中RETRY是给调度器的信号——比如某个控件正在加载中暂时不可点,可以由调度器决定等待多久后重试。不要把"再试一次"的逻辑写死在Executor里,否则它会陷入和动态页面的死磕,拖慢整个流程。
2.4 Validator——把模糊预期变成可判定断言
验证角色解决的是"这个步骤到底算不算成功"。传统测试的断言很明确——元素存在、文本相等、状态一致。但LLM场景里的预期往往是自然语言,比如"用户应该看到登录成功的提示",这句话里的"提示"可能是Toast、可能是跳转后的页面、也可能是按钮文案变化,无法用一条xpath表达清楚。
我们的方案是两阶段验证。第一阶段是显式断言:如果Planner给出的expect里有明确的属性(比如某个资源ID存在、某个文案等于"登录成功"),直接通过规则比对当前页面层级,走最快路径;第二阶段是语义验证:如果没有明确属性,就把当前页面快照和期望描述交给一个短prompt的LLM去判断,让它输出pass或fail并附一句理由。
二阶段的Prompt要特别短,而且只给判断依据,不给开放指令。举个例子:
这是当前页面控件信息: {snapshot} 请判断页面是否满足预期:{expectation} 只输出 pass 或 fail,并给出不超过10个字的依据。注意这里的snapshot不需要原始层级,只需要文本化的关键信息列表,比如"页面标题=设置、存在列表项=wifi、存在开关=飞行模式"。这样能省掉大量token,同时也不会稀释模型注意力。
为什么两阶段比纯规则或纯LLM好?纯规则太脆,遇到控件文案改了就失败;纯LLM又太慢太贵,每步都做语义判断会让执行时间成倍增加。显式断言挡住大多数确定场景,语义验证只兜底模糊场景,这个组合是我跑下来最平衡的。
2.5 Debugger——失败后的归因与重试决策
调试角色在初版框架里很容易被砍掉,但如果没有它,前面所有角色的稳定性都会打折扣。Debugger的职责只有一个:当用例失败时,根据失败上下文判断"到底是哪个环节出了问题",并给出处理建议。
我们内部把失败原因分成了五类:
| 失败类别 | 典型现象 | 处理路径 |
|---|---|---|
| 规划错误 | 动作序列逻辑错乱、步骤缺失 | 回传Planner重新生成 |
| 定位错误 | 候选元素全部不匹配或置信度过低 | 换一组候选或扩大搜索范围 |
| 执行错误 | 控件不可点、超时、动画未结束 | 等待后重试,最多三次 |
| 验证错误 | 页面语义满足但断言过严 | 人工审查验证规则 |
| 业务缺陷 | 应用本身行为异常 | 记录为失败,报告真正的Bug |
Debugger的判断输入是:失败角色返回的结果、当前页面快照、以及最近几条操作事件。它不一定需要完整的历史,只需要"最近的窗口",否则又会出现上下文过长的问题。
这个角色还有一个渐进增强的价值:每次排查完成后,把失败模式抽取成模板存进本地库。下次遇到相似失败时,Debugger可以先匹配模板,直接走对应的重试策略,不需要再调用一次大模型。我在Mobilerun里加的失败模板库,把重复失败场景的处理成本省了将近一半,而且跑得越久库越全,系统会越来越"懂"被测应用。
3. DroidRun和Mobilerun怎么落地这套角色模型
3.1 目录结构与模块边界
DroidRun最初是我这边为Android端做的实验框架,命名上带着当时随手起的代号。后来准备把iOS端也纳进来,发现DroidRun里大量接口天然耦合了Android元素体系,干脆把它做了一次通用化重构,出了Mobilerun。Mobilerun保留了角色拆分的内核,只把设备相关的东西全部下沉到适配器层。
一个简化后的目录结构长这样:
mobilerun/ ├─ core/ │ ├─ orchestrator.py │ ├─ state_store.py │ └─ event_bus.py ├─ roles/ │ ├─ planner.py │ ├─ locator.py │ ├─ executor.py │ ├─ validator.py │ └─ debugger.py ├─ adapters/ │ ├─ android_adapter.py │ ├─ ios_adapter.py │ └─ base_device.py ├─ llm/ │ ├─ model_config.py │ └─ prompt_templates/ ├─ data/ │ └─ page_db.yaml └─ runner.py模块边界上有两个必须守住的约定。第一:roles里的代码不允许import adapters里的具体实现,角色只能用"执行动作""获取层级"这类抽象接口。这样换平台时角色逻辑完全不动。第二:orchestrator不关心每个角色的内部实现,它只通过事件总线接收角色输出的结构化结果,再决定下一步调度。这两个约定是架构能长期演进的关键,一旦破戒,角色边界会迅速模糊。
3.2 角色间通信:JSON Lines事件总线的设计要点
角色间通信我们用了一套基于JSON Lines的事件系统。每个角色做完一件事,往总线上丢一个事件;orchestrator根据事件类型决定怎么路由。比如Locator完成一个定位动作,发这样一条事件:
{"role": "locator", "event": "element_candidates", "target": "用户名输入框", "candidates": [ {"id": "login_input", "confidence": 0.93}, {"id": "username_field", "confidence": 0.87} ], "cost_ms": 420}事件系统带来一个平时不起眼、关键时刻救命的好处:完整可回溯。用例跑挂了,你可以按事件时间轴回放。Locator当时给了哪些候选,Executor最终选了哪个,Validator是用哪一帧页面做判断的——全部有据可查。这种可观测性在单体prompt方案里根本做不到,也是我越来越依赖角色级拆分的原因。
还有一个工程级注意事项:幂等。移动设备测试里,"动作已经生效但事件没传回来"非常常见,比如点击后网络延迟,按钮响应了但事件还在路上。如果orchestrator因此重发同一动作,就可能重复提交表单,污染数据。我们的做法是给每个动作分配全局唯一的action_id,适配器层缓存动作结果;orchestrator发现同一个action_id完成事件重复到达时直接丢弃。
4. 从0到1搭建角色级移动端测试Agent:完整代码与实测记录
4.1 最小依赖与环境准备
我实际的开发环境是Python 3.11加pytest,设备端用的是开源的安卓自动化驱动,你们换成Appium或别的驱动都行,核心逻辑不受影响。除了驱动SDK,还需要三个东西:pydantic或dataclass做结构化数据定义、一个能输出JSON的LLM SDK、以及一个YAML解析库读页面语义库。
最小安装指令:
pip install appium-python-client pydantic pyyaml openai设备准备方面,提前打开开发者模式、开启USB调试、关掉动画,这几项对自动化执行的稳定性影响很大。建议所有用例跑之前执行一次adb shell settings put global window_animation_scale 0这类动画关闭命令,否则执行器在等待页面动画上会耗掉大量时间。
4.2 角色基类与调度器的核心实现
先定义角色基类。基类只做一件事:把Prompt模板和模型调用封装起来,并提供统一的结果出口。
import json from dataclasses import dataclass, field from typing import Any, Dict, List, Optional @dataclass class RoleContext: role_name: str input_data: Dict[str, Any] = field(default_factory=dict) output_data: Dict[str, Any] = field(default_factory=dict) history: List[Dict[str, Any]] = field(default_factory=list) class BaseRole: def __init__(self, name: str, llm_client, prompt_template: str): self.name = name self.llm_client = llm_client self.prompt_template = prompt_template self.context = RoleContext(role_name=name) def _call_llm(self, **params) -> str: prompt = self.prompt_template.format(**params) self.context.history.append({"prompt": prompt}) resp = self.llm_client.generate(prompt) self.context.history[-1]["response"] = resp return resp def run(self, input_data: Dict[str, Any]) -> Dict[str, Any]: raise NotImplementedError然后实现Planner角色:
class Planner(BaseRole): def run(self, input_data: Dict[str, Any]): task = input_data["task"] page_db = json.dumps(input_data.get("page_db", []), ensure_ascii=False) raw = self._call_llm(task=task, page_db=page_db) try: steps = json.loads(raw.strip().strip("```json").strip()) except json.JSONDecodeError: steps = self._fallback_parse(raw) return {"steps": steps, "status": "ok"}Locator角色,核心是输出候选数组:
class Locator(BaseRole): def run(self, input_data: Dict[str, Any]): target = input_data["target"] hierarchy = input_data["hierarchy"] raw = self._call_llm(target=target, hierarchy=hierarchy) candidates = json.loads(raw.strip().strip("```json").strip()) return {"candidates": candidates[:5], "status": "ok"}Executor就稍微特殊一点,它不一定走大模型,很多动作可以直接通过规则完成选择,只在规则无法判断时才请求模型:
class Executor(BaseRole): def __init__(self, *args, device=None, **kwargs): super().__init__(*args, **kwargs) self.device = device def run(self, input_data: Dict[str, Any]): action = input_data["action"] candidates = input_data.get("candidates", []) if not candidates: return {"status": "fail", "reason": "no_candidate"} best = candidates[0] result = self.device.perform(action, best["id"]) return {"status": result.status, "detail": result.detail}Orchestrator负责把它们串起来:
class Orchestrator: def __init__(self, roles: Dict[str, BaseRole], device): self.roles = roles self.device = device self.state = {"last_hierarchy": "", "history": []} def execute_task(self, task_desc: str): plan = self.roles["planner"].run({"task": task_desc, "page_db": self._load_page_db()}) for step in plan["steps"]: self.state["last_hierarchy"] = self.device.dump_compressed_hierarchy() candidates = [] if step.get("target"): loc = self.roles["locator"].run({ "target": step["target"], "hierarchy": self.state["last_hierarchy"], }) candidates = loc["candidates"] act_res = self.roles["executor"].run({ "action": step["action"], "candidates": candidates, "value": step.get("value"), }) if act_res["status"] in ("fail", "exception"): diag = self.roles["debugger"].run({ "step": step, "action_result": act_res, "history": self.state["history"][-5:], }) if diag["decision"] == "retry": continue break if step.get("expect"): v_res = self.roles["validator"].run({ "expectation": step.get("expect"), "snapshot": self.device.get_text_snapshot(), }) if v_res["status"] != "pass": return {"task": task_desc, "result": "failed", "failed_step": step, "reason": v_res["detail"]} self.state["history"].append({"step": step, "action_result": act_res}) return {"task": task_desc, "result": "passed"}上面这段代码我刻意省略了重试和超时管理,但它的清晰度足够展示角色间的协作方式。真正生产化的时候,还需要加动作队列、失败事件缓存和并发控制,不过骨架就是这样的。
4.3 用一个登录流程跑通全链路
实测中我用一个内部Demo应用跑了完整的登录流程。设备是某品牌的安卓测试机,任务描述是"打开应用,使用alice和passwd123登录,验证进入首页"。
实际执行时,Planner生成的步骤和我预期基本一致,只是它在"启动应用"之后多加了一步"等待首页首帧",这其实是好习惯。到Locator环节,"用户名输入框"的Top1候选命中了页面里的resource-id="login_username",置信度0.95,Executor直接选中执行。文本输入后,输入框的值被先回读了一次,确认是alice才去定位密码框。
真正出问题的是最后一步验证。Validators第一轮把"首页标题"理解成了页面顶部的文案,而应用首页顶部是一个自定义组件,文本为null,导致语义验证返回fail。Debugger介入后,分析出问题出在验证逻辑把"标题"强绑定到文本属性,而实际应用用的是content-desc。修正规则后重跑,验证通过。整个流程从任务下发到结束约35秒,其中Locaator调用三次,token总消耗约6400,比旧方案小了一个数量级。
5. 最容易踩的五个坑:我在DroidRun和Mobilerun里踩过的
5.1 角色间传参过载导致的上下文污染
角色拆分的初衷就是隔离上下文,但拆了之后人容易手滑,把多余的信息传给下游角色。我最早在Locator的Prompt里附带过完整任务描述,本意是让定位更智能,结果定位准确率倒退了不少——模型开始被任务描述里的业务词汇带偏,去匹配"语义相关"但实际根本不是目标控件的元素。后来严格限定Locator只看目标语义和当前层级,准确率才恢复。
5.2 温度参数没分开设置,执行器原地打转
移动端UI的动画和加载意味着同一个动作偶尔会失败,这很常见。起初所有角色都用temperature=0,结果是执行器在"点击登录按钮"遇到动画导致一次失败后,会非常坚决地重复同样动作,不做任何变通,直到重试上限耗尽。后来把Locator和Validator的温度设为0.2和0.4,让执行器在失败时能微调策略(比如先等待100ms再重试),情况才稳定下来。角色拆分的另一个好处就是温度可以按角色设置,不要偷懒用同一个。
5.3 事件系统缺少幂等,动作重复执行
没有幂等机制时,我遇到过最尴尬的事:登录流程跑完后数据被插入了两条,因为Executor实际完成了点击,但回调事件因网络波动丢失,调度器重发了同一动作。这个问题在真机测试里尤其常见,因为真机网络和环境比模拟器复杂太多。解决思路前面说过,统一action_id加执行结果缓存,同一action_id重复到达时直接返回缓存结果。这个改造做得很早,后来救了我很多次。
5.4 UI hierarchy获取太慢拖垮整体
获取层级是高频操作,在测试机上一次dump可能要1到2秒。如果不做任何优化,10步用例光这一项就要耗掉20秒。我后来在适配器层加了压缩缓存,只有页面标志位变化时才重新dump;并且把level 1的纯布局节点直接过滤掉,只保留可交互控件。优化后整条用例的等待时间有明显改善,更重要的是token成本也跟着降下来了。
5.5 Locator的"多平台通用"幻觉
DroidRun时期我以为Locator可以在Android和iOS之间复用同一个prompt,把iOS的层级文本化后直接喂给模型就行。结果iOS常见的Accessibility Label体系和Android的resource-id体系差异很大,用同一套prompt经常把"Back"按钮和"返回"语义搞混。后来Mobilerun里我把Locator的prompt按平台参数化,不同平台的控件属性说明分开写,但输出格式保持统一,这样角色层逻辑不用变,只是模板不一样。
6. 一个收尾但不总结的结尾:我对角色级拆分的真实感受
如果你正在搭建类似的框架,我的建议是:尽早把五个角色的边界定下来,哪怕第一版实现粗糙一点。因为合并不难,难的是后面想拆却动不了架构。我自己在DroidRun里就吃过这个亏,头两版把所有逻辑揉在一起,后面想接入更好的模型、想换设备平台都非常痛苦,等于重构了一遍才有了Mobilerun。
实际操作中还有一个小经验:不要一上来就把五个角色全写完再跑,先从Planner到Executor到Validator这条最小链路开始,Debugger和事件总线的完善可以放到第二迭代。先把最简链路跑通,你才能看到角色之间最真实的接口长什么样,后面扩展才不会走偏。
还有一个让我意外的收获是团队的协作方式。以前单体Agent的方案,出了问题只能一个人对着日志猜;现在角色是一等公民,团队成员可以各自负责一个角色的优化,比如有人专门调Locator的prompt,有人专门维护Validator的断言规则。这种分工不仅让系统的可维护性上来了,连团队的分工都变得更清楚了。移动端自动化测试涉及的设备和环境杂事很多,角色级拆分不能解决所有问题,但它至少让LLM部分变得可控、可调、可演进。