近两年做移动端自动化,我身边不少测试开发朋友都有类似困惑:手工脚本还能写,但一旦功能版本迭代起来,维护成本直接压垮人;想让大模型来接管,又觉得“给一个提示词就全自动跑UI回归”更像Demo而不是能落地的工程方案。我手里这套DroidRun/Mobilerun的迭代项目也一样,早期把所有任务都塞给同一个LLM角色,结果开会演示时很惊艳,真放到持续集成里跑一周,各种失控、漏断言、上下文错乱全冒出来了。这篇系列第六篇,我打算把当时逼出来的“角色级模型拆分”方案讲透:为什么需要拆成规划、执行、观察、汇报等独立角色模型,拆分后DroidRun怎么编排它们完成移动端自动化测试,以及我在真机、模拟器上踩过的那些坑。想把这个方向做成工程的,或者已经在试LLM辅助移动端回归的,这篇应该都能给你一些能直接抄作业的思路。
1. 为什么移动端自动化会走到“角色级模型拆分”
1.1 传统脚本模式的死穴
传统移动端自动化测试,最成熟的一套思路是录制回放加Page Object维护。Appium、Maestro、XCUITest,本质上都是帮你在代码里把“找元素—做操作—断言状态”这三件事固定下来。这套玩法的问题不在于不好用,而在于它把测试意图和UI细节绑得太死。
举个例子,回归测试里有一条“用户从首页进入商品详情页,添加到购物车”。脚本写起来很简单:找到商品卡片、点击、找到加购按钮、点击、断言购物车角标。可一旦界面改版,卡片改成宫格布局,加购按钮从详情页底部挪到侧滑菜单,脚本里所有定位表达式都得跟着改。更麻烦的是,很多移动端功能存在多种等价路径,比如可以点按钮,也可以左滑呼出操作栏,脚本往往只能覆盖那一条固定的交互路径,测试覆盖存在明显盲区。
在DroidRun的早期版本里,我们也是按这个模式堆脚本。脚本数量到两千多条的时候,每次发版前跑一次全量回归,脚本修复花掉的时间比新增功能的测试设计时间还长。那时候我就意识到,真正值钱的不是怎么找控件,而是把“测试要验证什么”和“具体操作怎么执行”分开,让剩余的部分可以由新的自动化引擎自动补全。
1.2 把整个流程都交给一个LLM,问题更大
看到LLM能力变强之后,第一反应很简单:把截图、页面结构、测试目标一起丢给模型,让它自己生成操作,自己判断结果,这不就把维护成本干掉了吗?DroidRun演示阶段确实这么干过,一个Agent模型同时承担思考、决策、执行、断言的所有职责。
Demo版跑通很快,因为场景就那么三五个。但一旦进入完整业务回归,问题立刻出现:
- 责任混在一起,上下文非常臃肿。系统要把页面XML、历史操作、业务规则、断言条件全部塞进同一份提示词,经常逼近上下文窗口上限。
- 同一个模型既想探索页面又要精确执行点击,输出动作格式不稳定,有时输出JSON多一点注释、少一个字段,工具解析直接失败。
- 模型在生成动作时会出现自我证明偏差——它自己执行、自己观察、自己判定成功,缺少外部校验,于是模型会认为“我点了,所以生效了”,根本没去看页面是否真的切换了。
- 问题定位困难。一批用例挂掉,说不清是决策判错、控件定位错,还是执行时序错。
后来我重新看整个测试过程,发现它天然就不是一个单一智能体场景。做测试的团队里有测试设计、测试执行、结果确认、测试报告这些不同角色,人对复杂任务的拆解方式天然适应这种分工。那我为什么不让模型也按角色拆开呢?这就是角色级模型拆分在DroidRun里产生的原因。
1.3 角色级模型拆分解决的核心问题
拆分的核心思想只有一句话:不要训练或配置一个“什么都会”的大模型单元来完成整条测试链路,而是把测试链路拆成多个语义清晰、职责单一的角色模型,每个角色只接特定的输入、输出和约束,通过一套稳定的协作协议组合起来。
在DroidRun/Mobilerun里,我们把这些角色模型定义成了四条流水线:Planner(规划者)、Executor(执行者)、Observer(观察者)、Reporter(汇报者)。这套拆法既保住了LLM的语义理解能力,又让每一个环节都可独立调试、可单独替换、可精确控制。移动端自动化最头疼的“动作粒度控制”和“结果可信校验”,也正是通过拆出来一个专门角色才真正做到可控。
2. DroidRun/Mobilerun的设计:角色有哪些,各管什么事
2.1 四个角色模型怎么切分
角色级模型拆分的第一步不是急着写提示词,而是把测试场景里的职能彻底划清楚。我们当时按下面这个表格梳理了分工:
| 角色 | 职责 | 输入 | 输出 |
|---|---|---|---|
| Planner | 理解测试目标,生成高层任务序列 | 测试需求、业务约束、可用页面列表 | 有序任务图(不涉及具体控件坐标) |
| Executor | 把高层任务转换为低层UI操作 | 当前页面结构、单个任务目标 | 稳定格式的动作指令(点击/滑动/输入) |
| Observer | 观察页面变化,判断操作结果 | 前后页面状态、操作日志 | 结果断言、页面状态摘要 |
| Reporter | 汇总各步骤结果,产出报告并反馈控制循环 | 各角色的运行日志与断言 | 结构化的测试报告、失败原因摘要 |
注意,我特意把Observer和Reporter分开。很多LLM测试框架把“观察结果”和“生成报告”合并,省事但不好排查。真正跑起来你会发现,页面状态摘要的格式需求和报告里写给人看的结论是完全不同两类输出,硬放在一起,提示词里的指令互相干扰,最后模型两边都做不精。
Planner是唯一能看到全局目标的角色。它不需要关心“这个按钮在当前屏幕的哪个位置”,只需要判断“这个测试目标应该经过哪些页面、哪些业务动作”。这样设计的好处是,页面改版最频繁的部分被隔离开,只要业务交互逻辑没变,Planner输出的任务图就不用改,回归测试的稳定性直接上了一个台阶。
Executor是动作执行层。它接收Planner给出的一个高度聚焦的子任务,比如“进入商品详情页”,再结合当前屏幕的真实UI结构生成具体操作。这个角色一次只处理一个子任务,上下文窗口压力也小。我们需要它输出可解析的动作序列,所以对输出格式的约束非常严格,Alignment相关的内容我会在第4章细说。
Observer理论上不需要知道测试目标,它就是一个“无感情的监控探头”。每次Executor执行完动作,Observer会把执行前后的页面状态拉出来做对比,输出结论:页面有没有变化、关键元素是否出现、状态是否符合预期。Observer的输出不直接决定用例通过与否,它只提供事实,判定交给上游编排逻辑。
Reporter负责最终呈现和记录。它在Observer事实的基础上,结合Planner的任务目标,组织一个给人看的结果。它也会主动标记哪些步骤有异常,把失败路径摘要回传给Planner,让Planner决定是换一条路径继续尝试还是终止这条用例。
2.2 角色之间的协议:状态数据流怎么走
角色拆分之后,最怕的一件事是各角色之间“对话”过于自由。让LLM角色直接互相发自然语言消息,看起来灵活,跑起来极易失控,因为模型会在对话里把职责边界模糊掉。
DroidRun的做法是给角色之间定义一个稳定的中间数据结构,角色之间不直接传导自然语言,统一通过结构化传递。这里我用一个简化的伪JSON来描述这条链路:
{ "task_id": "TC_1024", "goal": "验证用户可以从首页完成加购并查看购物车数量变化", "plan": [ {"step_id": 1, "action": "navigate_to_product_detail", "target": "首个商品卡片"}, {"step_id": 2, "action": "add_to_cart", "target": "详情页加购按钮"}, {"step_id": 3, "action": "open_cart_and_verify", "target": "购物车页面"} ], "executions": [ { "step_id": 1, "output_actions": [ {"type": "tap", "coordinates": [340, 620], "description": "点击首屏商品卡片"} ], "result": {"page_changed": true, "new_screen": "product_detail"} } ], "assertions": [ {"step_id": 3, "type": "element_exists", "target": "cart_badge", "status": "pass"} ], "report": "PASS: 用户加购后购物车角标从0变为1" }这个结构的价值在于:Planner产出的是一个计划数组,Executor每完成一步就往executions里追加记录,Observer按step_id把结果填进去,Reporter最后读取所有内容生成汇总。每个角色读到的只是它任务范围之内的那一段,而不是整段对话历史。这一步是角色拆分能真正落到工程基础上的关键。
一开始我们也想过用自然语言“任务卡”在各角色间传递,后来发现Executor偶尔会把Observer的观察结果当成自己该完成的目标,跑出完全不该有的操作。改成结构化数据后,这类“角色串扰”基本消失了。
2.3 这种拆法到底解决了什么问题
用角色级模型拆分之后,原先单一大模型方案里的几个核心痛点都有了对应解法:
第一,上下文可控。每个角色不再需要看到整条测试链路,执行角色只需要当前页面结构加一个子任务,几百上千tokens就足够,成本也低了很多。第二,可观测性大幅提高。因为在角色之间引入结构化中间结果,任何一步失败都可以通过数据定位是计划错了、执行错了还是断言错了。第三,提示词工程从“一个大而全的提示词”变成“四个小而精的提示词”,每个提示词都可以单独迭代。第四,角色模型可替换,将来如果某个环节用更小的专有模型或规则引擎更合适,可以整块换掉而不影响其他部分。
移动端自动化里我最看重的其实是可观测性。因为设备碎片化、系统版本不同、动画时序不一致,很多测试失败都是环境和时机问题。角色拆分之后,Observer只看页面变化,不参与操作,它反馈的“页面未变化”可以让我们很快区分是执行器点错了还是页面真的无响应,排查效率提升明显。
3. 实操落地:从提示词到可执行的最小框架
3.1 移动端自动化的环境准备与工具选型
讲完设计,我直接说怎么落地到代码。DroidRun的整体哲学是用最少依赖搞定链路,移动端部分我们假设已经有现成的UI自动化基础能力,比如能启动App、能获取页面结构、能执行点击和手势。这里我们有两种基础工具路线,根据自己的规模选:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 原生UIAutomator/XCUITest | 单平台、内部工具链成熟 | 稳定、延迟低 | 跨平台需要各自适配 |
| 跨端设备农场方案 | 需要统一管理Android/iOS真机 | 一套接口管多端 | 需要维护设备代理层 |
我们自己更多是Android真机加设备代理层,因为观察能力和截图获取在Android生态上更容易统一。但角色拆分这套组织和iOS自动化一样可以跑,核心不依赖具体工具,只依赖三个基础接口:
- 获取当前页面结构:返回简化后的控件树或关键元素列表。
- 查找元素:通过文本、resource-id等属性定位。
- 执行动作:模拟点击、滑动、输入。
这一步不复杂,但要注意别把原始XML直接交给大模型。后面第4章会讲为什么要预处理页面结构。
3.2 Planner的实现:把测试目标变成任务图
Planner的提示词不需要特别复杂,但需要输出约束非常明确。我给Planner定义的输出格式是JSON数组,每个节点包含step_id、action目标、期望页面三部分。
实际提示词核心结构大致如下:
你是移动端自动化测试的规划器。你只需要根据测试目标生成有序的操作计划,不要生成具体屏幕坐标。 约束: 1. 计划节点控制在10个以内。 2. 每个节点必须包含预期的页面或业务状态。 3. 如果目标信息不足,输出{"need_more_info": "具体缺失内容"}。 请基于以下测试目标输出JSON计划。 测试目标:{user_goal} 已知页面清单:{available_screens}跑下来效果很稳,关键是第三点约束,我以前没加这条,Planner会硬编出一个假页面流程,直接导致后面的执行全废。加上这一条之后,至少它能诚实地反馈信息不足。
3.3 Executor的实现:动作生成的“有限能力”约束
Executor的核心提示词要短。它只需要当前页面结构、Planner给的一个子任务,以及动作历史的前3步。我要求它输出的格式是:
{ "actions": [ {"type": "tap", "target": "text=加入购物车", "description": "点击加入购物车按钮"} ] }可选动作类型只有tap、swipe、input_text、back、wait。我把动作类型限制得很窄,因为动作类型越多,模型输出解析出错概率越高。实际跑下来,这五个动作足够覆盖绝大多数回归场景。
这一层最关键的是“一次只做一步”。不要给Executor一串任务让它连续执行。连续执行时模型经常自作主张跳过一些页面状态检查,一步错后面全错。我现在强制它在完成一个动作后立即把控制权交还给编排层,由编排层拉取新的页面结构再交给Observer判断,然后再决定下一步。
3.4 Observer的实现:只做事实核查
Observer的提示词也不难设计。给它执行前页面结构、执行后页面结构,以及执行的动作类型,让它输出:
{ "page_changed": true, "new_screen": "product_detail", "key_elements": [ {"name": "title", "value": "某商品标题", "exists": true} ] }有一个关键细节:Observer不需要知道原始测试目标。如果让Observer知道目标,它容易在页面没变化时脑补出“这个页面已经符合预期”的结论,这在大模型上特别明显。让它只看前后差异,它会老实很多。
Observer比较页面结构时,我还会把截图一并给到模型。图片对界面改版的感知比XML强得多,尤其在页面有大量图片元素时,靠控件树很容易漏判。多模态模型做Observer这个角色非常合适。
3.5 Reporter的实现与回放循环
Reporter是最轻量的角色,读取所有结构化中间结果之后,输出一段简洁的中文总结。它的输出我不做严格JSON约束,反而是格式越自然越好,因为主要给人看。
不过Reporter还有一项职责容易被忽略:它要把失败用例的上下文摘要反馈给Planner。比如“第2步进入详情页失败,失败原因:页面未发生变化”,Planner收到这个摘要后,可以调整计划,例如尝试从搜索入口进入而不是直接点卡片。这个反馈机制实现了简单的自动重试和多路径切换,也是DroidRun能够提升通过率的关键所在。
整个回放循环用伪代码表达是这样:
def run_test_case(goal, device_proxy, llm_clients): plan = planner.plan(goal) for step in plan.steps: page_before = device_proxy.get_page_structure() actions = executor.execute(step, page_before) device_proxy.perform(actions) page_after = device_proxy.get_page_structure() observation = observer.observe(page_before, page_after, actions) if observation.page_changed is false and step.expects_change: feedback = reporter.request_feedback(step, observation) alternative_plan = planner.replan(goal, feedback) # 进入备选路径,继续执行 ... result = reporter.summarize(device_proxy.test_session) return result这套循环跑得通之后,后面维护基本就是维护四个提示词和结构化协议,比维护两千条脚本轻松太多。
4. 常见问题与排查实录
4.1 页面控件树太大,大模型上下文爆炸
移动端页面结构比Web端臃肿得多,一个复杂的商品详情页控件树经常有几百个节点,原始XML长度能到几万tokens。直接把原始XML丢给Executor,轻则响应变慢,重则超出窗口长度。
我的处理是把页面结构先整理成精简列表:
- 找所有可点击、可输入的叶子节点。
- 过滤掉坐标类布局容器、装饰性View。
- 保留控件文本、resource-id、bounds,但bounds只保留中心点坐标。
- 按面积排序,筛掉屏幕外区域。
经过这一步,一个复杂页面通常能压到几十个节点,上下文压力骤降。实测下来,Executor的决策准确率并没有下降,因为移动端UI真正可交互的节点本来就少,剩下的都是布局噪声。
4.2 输出格式不稳定,Not JSON问题让人崩溃
大模型输出JSON时偶尔会在前面加解释文字,偶尔会把双引号转义成反斜杠。我们在角色协议上做了两层防护:
第一层是提示词约束,输出开头必须是某个固定标记。第二层是解析容错:去掉开头非JSON内容,再对常见错误做修复,比如单引号转双引号、去掉尾逗号。
下面这串代码是我们实际在用的解析壳:
import json import re def extract_json(raw_output: str) -> dict: text = raw_output.strip() # 这里容忍常见的自然语言前缀 start = text.find("{") end = text.rfind("}") if start == -1 or end == -1: raise ValueError(f"no json object found: {raw_output[:200]}") json_str = text[start:end + 1] # 修复尾逗号,常见问题 json_str = re.sub(r",\s*}", "}", json_str) return json.loads(json_str)执行器除了JSON输出之外,最好让模型同时产出一次带格式的动作摘要文本,给定位问题用。很多时候解析失败不一定是格式问题,而是模型把动作和断言混在同一个步骤里了,摘要文本能很快看出来。
4.3 设备和App渲染时序导致的“假失败”
移动端自动化最经典的坑就是等待。页面跳转有动画,列表刷新有延迟,Executor执行完点击后立刻获取页面结构,很可能拿到的还是旧页面,Observer就会输出“页面未变化”,整个流程被误判为失败。
我针对这个场景加了三个策略,测试稳定性提升非常明显:
- 等待策略:执行动作后强制等待页面静止,也就是连续多次获取页面结构都一样才继续。
- 目标校验:在点击之前,Executor输出目标元素之后,编排层先用控件树核验这个元素存在且可见,再真正执行点击。
- 二次观察:Observer拿到首次结果后,如果发现“页面临界状态”,会再发起一次主动刷新,隔两秒重新观察。
这三个策略本质上是在LLM能力外面套了一层工程防护,效果立竿见影。
4.4 角色越权,互相抢活
角色级模型拆分之后,我还是遇到过一次模型“抢活”现象。Executor在输出动作时,偶尔会顺手输出“我已经验证了这个元素的文本”,Observer也在输出里写“建议下一步点击返回”,Reporter在总结里写“该用例失败,建议开发排查”。
这些越权输出本身不会破坏流程,但会给链路数据造成污染,尤其当Reporter开始带个人观点,报告就不可信了。
解决方法是在每个角色的系统提示词里加一条铁律——禁止输出当前角色职责以外的内容。我原以为大模型能天然理解角色边界,实际操作下来不行,必须把禁止项写进提示词。比如Executor的提示词末尾一定会写:“你只输出动作指令JSON,禁止输出观察结论或断言结果。”这条指令加上后,越权问题基本绝迹。
4.5 失败自动重试和无休止循环
有了Planner的重规划能力后,另一个问题是模型可能在一个错误路径上反复重试。比如某个页面因为网络加载慢一直打不开,Planner会换一个入口,但如果所有入口都依赖网络,它可能连续换七八条路径,浪费时间。
我的处理是设置重试次数上限和“失败指纹”。当同一页面连续出现超过两次相同的失败事实时,直接终止用例并标记为阻塞,不再浪费token。这个规则放在编排层代码里,不靠LLM自觉,否则不可控。
5. 用了一个季度后的一些实测体会
5.1 什么时候值得拆,什么时候不值得拆
必须要说,角色级模型拆分不是所有移动端项目都需要。我按我们的经验给了一个参照:
- 只需要做冒烟测试、核心路径就三四条、用例量低于五十条,用一个大模型Agent就够了,拆了反而增加协议设计成本。
- 用例量大、页面频繁改版、需要长期维护的回归套件,比如超过一百条核心用例,角色拆分收益十分明显。
- 团队里有专门做测试平台开发的人,能把四个角色的编排工程化,而不是停留在“调提示词”,才建议拆。如果只是临时验证可行性,一个Agent先跑通更重要。
我们的DroidRun项目大概在用例数过三百条后做的拆分,拆分前后最直观的变化是失败用例的定位时间从按小时算降到按分钟算。
5.2 一些实测后的效率对比
我把同一个业务模块的三十条核心回归用例跑了三个版本,结果大致是:
| 方案 | 通过率 | 平均单用例耗时 | 排查失败所需时间 |
|---|---|---|---|
| 纯录制脚本 | 100% | 约40秒 | 查找定位失败20分钟以上 |
| 单一LLM Agent | 78% | 约60秒 | 需要看整段日志,时间不定 |
| DroidRun角色拆分 | 91% | 约90秒 | 直接看Observer/Raporter结构化结果,5分钟内 |
单用例耗时有增加,因为多角色串行调用会产生额外消耗,但换来的是通过率和可维护性的巨大提升。在持续集成流水线里,我更在意全量回归能不能自动跑完、失败能不能快速归类,而不是单条用例快几秒。这个取舍大家看自己场景。
5.3 后续可以怎么扩展
这个角色的协议框架稳定下来以后,下一步我能看到的方向有:
- 用向量库积累历史失败案例,给Observer和Reporter做参考,提高断言准确性。
- 把Planner的“任务图”作为数据资产沉淀下来,做跨版本的变更影响分析。
- 对Observer的结果做小样本统计,自动发现页面变化的波动规律,提前规避动画时序相关的假失败。
另外我个人特别建议在报告层接入业务历史数据。Reporter如果有历史通过率、历史失败类型的参考,它能给出的结论会更有价值,而不只是单纯说“这次跑了多少条”。
最后再分享一个小技巧,也是在DroidRun里一直在用的:四个角色的模型版本尽量解耦,不要绑定同一个模型供应商。Planner用推理能力强的,Executor用响应速度快的便宜模型,Observer用视觉能力好的多模态模型,Reporter用文本总结能力稳定的。这样拆分以后,成本方差更小,某个模型升级或抽风的时候,其他角色不会跟着遭殃。角色级模型拆分这件事的价值,说到底不是把任务分给多个LLM那么简单,而是把每一个角色的稳定性做成可单独优化、单独验收的工程模块。它让LLM驱动的移动端自动化终于从“看运气”变成了“看工程”。