移动端自动化测试这块,我前前后后折腾了快三年,从最早的 Appium 脚本堆到后来的图像识别方案,踩过的坑能写满一个笔记本。去年开始把大模型接进测试链路之后,有个问题一直卡着我:单模型跑全流程,准确率上不去,成本还下不来。直到我把模型按角色拆开,整个链路才真正跑通。这篇就聊聊 DroidRun/Mobilerun 这套方案里,角色级模型拆分到底怎么做的,为什么这么拆,以及拆完之后哪些地方会踩坑。如果你正在做移动端 UI 自动化,或者已经把 LLM 接进了测试流程但效果不理想,这篇内容应该能帮你省掉不少试错时间。我会从整体设计思路讲到具体实现,再到问题排查,尽量把每个决策背后的逻辑说清楚,让你看完能直接照着搭一套。1. 为什么单模型跑移动端自动化会翻车1.1 移动端场景的特殊性在哪移动端自动化和 Web 端最大的区别,在于状态空间是爆炸的。Web 页面再复杂,DOM 结构相对稳定,元素定位有 id、class、xpath 这些锚点。移动端不一样,同一个按钮在不同机型上可能叫不同的 accessibility label,弹窗出现时机受网络、权限、系统版本影响,页面层级还经常被原生控件和 WebView 混着用。我拿一个真实的登录流程举例。打开 App,等启动页消失,检测是否已有登录态,没有就点登录按钮,输入账号密码,处理可能弹出的权限请求,点击提交,等待首页加载。这一串操作里,光是"等待启动页消失"这一步,不同机型上耗时能从 1.5 秒到 8 秒不等。如果用单模型来做决策,它得同时理解当前屏幕状态、判断下一步动作、生成操作指令,还要处理异常分支。一个模型扛这么多职责,注意力被稀释,准确率自然上不去。更麻烦的是上下文长度。移动端每一步操作都要把当前屏幕的 UI 树或者截图喂给模型,一个完整流程下来,几十步操作,上下文轻松超过模型的窗口限制。你可能会说用滑动窗口截断,但截断之后模型就丢了历史状态,容易重复操作或者漏掉关键分支。1.2 单模型方案的三个硬伤我把单模型方案的问题总结成三条,都是实测出来的。第一,职责耦合导致 prompt 臃肿。你要让一个模型既做屏幕理解,又做动作决策,还得生成具体的点击坐标或者元素选择器,prompt 里得塞进 UI 树格式说明、动作空间定义、历史操作记录、异常处理规则。我最早那版 prompt 有 2000 多 token,模型经常顾此失彼,理解了屏幕却选错动作。第二,错误会累积放大。单模型方案里,如果某一步屏幕理解错了,比如把"取消"按钮识别成了"确认",后续所有操作都建立在错误状态上。而且模型自己很难发现这个错误,因为它没有独立的校验环节。第三,成本和延迟不可控。用一个参数量大的模型跑全流程,每步都要调用,token 消耗飞快。我算过一笔账,一个 20 步的测试用例,单模型方案下平均消耗 15000 token 左右,按当时的 API 价格,跑一千次就是一笔不小的开销。延迟也高,每步响应 3 到 5 秒,一个用例跑完要一分多钟。1.3 角色拆分的核心思路角色级模型拆分的本质,是把一个大而全的任务,拆成几个职责单一的子任务,每个子任务用最适合它的模型来做。这跟软件工程里的单一职责原则是一个道理,只不过对象从函数变成了模型。在 DroidRun/Mobilerun 这套方案里,我把角色拆成了四类:感知角色负责理解当前屏幕,决策角色负责判断下一步做什么,执行角色负责把决策转成具体操作,校验角色负责确认操作结果是否符合预期。每个角色用不同的模型、不同的 prompt、不同的上下文策略。这么拆的好处很直接。感知角色只需要关注屏幕内容,prompt 可以精简到 500 token 以内;决策角色拿到的是结构化的屏幕描述,不用处理原始 UI 树;执行角色做的是确定性的坐标计算或者元素匹配,甚至可以用小模型或者规则引擎;校验角色独立运行,能及时发现前面环节的错误。提示:角色拆分不是拆得越细越好。我试过拆成六个角色,结果角色间通信开销反而成了瓶颈。四个角色是我实测下来比较平衡的点,既能隔离职责,又不会让链路太复杂。2. 四个角色的职责边界与模型选型2.1 感知角色:把屏幕变成结构化描述感知角色的输入是原始屏幕数据,可能是 UI 树 XML,也可能是截图,输出是一段结构化的自然语言描述,比如"当前页面是登录页,包含账号输入框、密码输入框、登录按钮,登录按钮处于可点击状态"。这个角色的核心挑战是信息压缩。UI 树动辄几千个节点,直接喂给决策模型不现实。感知角色要做的是提取关键元素,过滤掉装饰性节点,把层级结构扁平化成列表。模型选型上,我用的是中等参数量的多模态模型。为什么不用最大的?因为感知任务相对标准化,不需要太强的推理能力,中等模型足够,而且速度快、成本低。实测下来,感知角色用 7B 到 13B 级别的模型,配合精心设计的 prompt,准确率能到 92% 以上,再往上加参数量,提升不到 3 个百分点,性价比不划算。prompt 设计上有个技巧:给模型一个固定的输出模板。我定义的模板是这样的:页面类型:[登录页/首页/列表页/详情页/弹窗] 关键元素: - [元素类型] [元素文本] [元素状态] [位置描述] 可操作项:[可点击/可输入的元素列表] 异常提示:[是否有弹窗、错误提示、加载状态]固定模板的好处是决策角色拿到的输入格式统一,不用做额外的解析。而且模板本身约束了模型的输出空间,减少了幻觉。2.2 决策角色:基于状态选动作决策角色拿到感知角色的输出,结合历史操作记录,判断下一步该做什么。它的输出是一个动作指令,比如"点击登录按钮"或者"在账号输入框输入 test@example.com"。这个角色是整条链路里最需要推理能力的,所以我用的是参数量最大的模型。决策质量直接决定测试能不能跑通,这里不能省。决策角色的 prompt 里要包含几样东西:当前页面描述、历史操作序列、当前测试目标、可用动作空间。历史操作序列不能太长,我一般保留最近 5 步,再早的用一句话摘要代替,比如"已完成登录信息填写"。动作空间的定义很关键。我定义的动作类型有:点击、输入、滑动、等待、返回、断言。每种动作有固定的参数格式。决策角色只能从这个集合里选,不能自由发挥。这样做的原因是,自由生成的动作指令执行角色没法可靠地解析,而且容易产生无效操作。有个细节值得说:决策角色要能输出"等待"动作。移动端很多场景需要等加载,如果模型不会等待,就会在页面还没渲染完的时候瞎点。我在 prompt 里明确告诉模型,如果检测到加载状态,优先输出等待动作,并指定等待时长。2.3 执行角色:把指令变成操作执行角色负责把决策角色的抽象指令,转成具体的设备操作。比如"点击登录按钮"要变成"在坐标 (540, 1200) 执行 tap"或者"通过 accessibility id 定位元素并点击"。这个角色其实不一定需要大模型。如果感知角色已经输出了元素的位置信息,执行角色用规则引擎就能完成。我现在的方案里,执行角色优先用元素定位,定位失败才回退到坐标点击,坐标从感知角色输出的位置描述里解析。为什么执行角色要独立出来?因为执行层有很多设备相关的细节,比如不同平台的坐标系统不一样,iOS 的逻辑坐标和 Android 的物理坐标需要转换,输入法弹出会遮挡元素需要先收起键盘。这些细节如果混在决策角色的 prompt 里,会严重干扰决策质量。独立出来之后,执行角色可以用代码实现这些逻辑,稳定可靠。2.4 校验角色:独立确认结果校验角色是四个角色里最容易被忽略,但最重要的一个。它的职责是:执行完一个动作后,重新感知屏幕,判断操作结果是否符合预期。举个例子,决策角色说"点击登录按钮",执行角色点了,校验角色要判断:页面是否跳转了?是否出现了错误提示?是否还在登录页?如果还在登录页且没有错误提示,可能是点击没生效,需要重试。校验角色我用的是和感知角色同级别的模型,因为它的核心任务也是屏幕理解,只是多了一个对比逻辑。prompt 里我会给它预期结果和实际屏幕描述,让它输出"符合预期"、"不符合预期但可恢复"、"不符合预期且需终止"三种结论。独立校验的价值在于打断错误累积。单模型方案里,错误会一路传下去,校验角色能在每一步之后做一次检查,及时发现问题。我实测下来,加了校验角色之后,测试用例的通过率从 68% 提升到了 89%。3. 角色间通信与上下文管理3.1 角色间的数据格式约定四个角色之间要通信,就得有统一的数据格式。我用的是 JSON,因为结构清晰,解析方便,而且模型生成 JSON 的准确率比生成其他格式高。感知角色的输出格式:{ "page_type": "login", "elements": [ {"type": "input", "text": "账号", "state": "editable", "position": "center-top"}, {"type": "input", "text": "密码", "state": "editable", "position": "center-middle"}, {"type": "button", "text": "登录", "state": "clickable", "position": "center-bottom"} ], "abnormal": null }决策角色的输出格式:{ "action": "click", "target": "登录按钮", "reason": "当前在登录页,账号密码已填写,下一步应提交", "expected_result": "跳转到首页" }执行角色的输出格式:{ "status": "success", "method": "accessibility_id", "detail": "clicked element with id btn_login" }校验角色的输出格式:{ "result": "match", "confidence": 0.95, "note": "页面已跳转至首页,符合预期" }这套格式我迭代了七八版才稳定下来。早期版本里,元素位置用的是绝对坐标,结果换机型就失效了。后来改成相对位置描述,由执行角色根据当前屏幕尺寸换算,兼容性好很多。3.2 上下文窗口的分配策略四个角色各自维护自己的上下文,不共享。这是角色拆分的一个关键设计。感知角色每次只处理当前屏幕,不需要历史上下文,窗口占用很小。决策角色需要历史操作记录,但只保留最近 5 步加一个摘要。执行角色基本不需要上下文,它只处理当前指令。校验角色需要预期结果和当前屏幕,也不需要长历史。这样分配下来,每个角色的上下文窗口占用都很低,不会出现截断问题。而且因为不共享上下文,一个角色的上下文污染不会影响其他角色。注意:决策角色的历史记录摘要很重要。我一开始不做摘要,直接把所有历史操作塞进去,结果 10 步之后上下文就爆了。后来改成保留最近 5 步详细记录,更早的用一句话概括,比如"已完成登录流程",上下文占用降了 60%,决策质量没下降。3.3 角色调用的编排逻辑四个角色不是简单的串行调用,中间有分支和循环。我用一个编排器来管理整个流程,伪代码大概是这样:def run_test_case(goal, max_steps=30): history = [] for step in range(max_steps): screen = capture_screen() perception = perception_role(screen) if check_goal_reached(perception, goal): return "success" decision = decision_role(perception, history, goal) if decision.action == "wait": sleep(decision.duration) continue execution = execution_role(decision) if execution.status != "success": handle_execution_failure(execution) continue new_screen = capture_screen() verification = verification_role(decision.expected_result, new_screen) if verification.result == "match": history.append(summarize(decision, execution)) elif verification.result == "recoverable": history.append(f"操作未达预期,已重试") else: return "failed" return "timeout"这个编排逻辑里有几个设计点值得说。目标检测放在决策之前,每步都检查是否已经达成目标,避免多余操作。等待动作单独处理,不进入执行和校验环节。执行失败和校验失败分开处理,执行失败通常是技术问题,重试即可;校验失败可能是决策错误,需要记录并调整策略。4. 实操搭建:从零跑通一条测试用例4.1 环境准备与依赖安装先说环境。我用的是一台 Linux 开发机,装了 Android SDK 和 adb,iOS 那边用了一台 Mac mini 做真机测试。Python 环境是 3.10,主要依赖有:pip install openai uiautomator2 pillow numpyuiautomator2用来抓 Android 的 UI 树和执行操作,iOS 那边用的是facebook-wda。图像处理用pillow,主要是截图裁剪和压缩。模型调用这块,我用的是兼容 OpenAI 接口的 SDK,方便切换不同厂商的模型。感知和校验角色用中等模型,决策角色用大模型,执行角色不用模型。配置上有个坑要注意:adb 的默认超时时间太短,抓 UI 树经常超时。我在~/.android/avd的配置里把adb timeout调到了 30 秒,稳定很多。4.2 感知角色的实现细节感知角色的核心是把 UI 树转成结构化描述。Android 的 UI 树是 XML,节点属性有text、resource-id、class、clickable、bounds等。我写了个解析器,提取关键节点,过滤掉bounds面积小于屏幕 1% 的装饰性节点。def parse_ui_tree(xml_str, screen_size): tree = ET.fromstring(xml_str) elements = [] for node in tree.iter(): bounds = parse_bounds(node.get("bounds")) area_ratio = (bounds[2]-bounds[0]) * (bounds[3]-bounds[1]) / (screen_size[0]*screen_size[1]) if area_ratio < 0.01: continue if not node.get("text") and not node.get("resource-id"): continue elements.append({ "type": classify_element(node), "text": node.get("text", ""), "id": node.get("resource-id", ""), "clickable": node.get("clickable") == "true", "bounds": bounds }) return elements解析完之后,把元素列表和截图一起喂给感知模型。为什么要给截图?因为有些元素 UI 树里没有,比如 WebView 里的内容,或者游戏画面。多模态模型能同时利用两种信息,准确率更高。感知角色的 prompt 我调了很多版,最后稳定下来的版本大概 400 token。关键是指令要明确:告诉模型输出 JSON,字段有哪些,每个字段的取值范围。模型对格式约束的遵循度,比自由发挥高得多。4.3 决策角色的 prompt 工程决策角色的 prompt 是整条链路里最复杂的,我拆成了四个部分:角色定义、当前状态、历史记录、动作空间。角色定义部分告诉模型它是移动端测试的决策者,目标是什么。当前状态就是感知角色的输出。历史记录是最近 5 步的操作摘要。动作空间定义了可选动作和参数格式。有个技巧很管用:在 prompt 里给几个 few-shot 示例。我放了三个示例,分别对应"正常操作"、"需要等待"、"遇到弹窗"三种场景。加了示例之后,决策准确率提升了大概 15 个百分点。另一个技巧是让模型输出推理过程。我在 prompt 里要求模型先输出reason字段,再输出action。这样模型会先想清楚为什么这么做,再给动作,比直接输出动作准确率高。虽然多消耗一些 token,但值得。4.4 执行角色的坐标换算执行角色看起来简单,其实坐标换算有不少门道。Android 的 UI 树里bounds是物理像素,但adb tap用的也是物理像素,所以直接用就行。iOS 不一样,wda的 UI 树里bounds是逻辑坐标,但点击用的是逻辑坐标,也不用换算。真正需要换算的是截图坐标和 UI 树坐标不一致的情况。我遇到过一个问题:截图是 1080x1920,但 UI 树里的 bounds 是 720x1280,因为设备设置了显示缩放。这时候如果直接用截图里的坐标去点击,就会点偏。解决办法是记录截图和 UI 树的缩放比例,执行点击时统一换算到 UI 树坐标系。def convert_coordinate(x, y, screenshot_size, ui_tree_size): scale_x = ui_tree_size[0] / screenshot_size[0] scale_y = ui_tree_size[1] / screenshot_size[1] return int(x * scale_x), int(y * scale_y)这个坑我踩了整整两天,一开始以为是模型识别错了元素,后来才发现是坐标系不一致。4.5 校验角色的对比逻辑校验角色的输入是预期结果和实际屏幕描述。预期结果是决策角色输出的,比如"跳转到首页"。实际屏幕描述是重新感知得到的。对比逻辑我分了三层。第一层是页面类型对比,如果预期是首页,实际感知出来也是首页,直接判定符合。第二层是关键元素对比,如果页面类型对不上,但预期结果里提到的关键元素出现了,也算符合。第三层是异常检测,如果屏幕上有错误提示、弹窗、加载失败等异常,判定为不符合。三层对比的优先级是从严到宽。第一层最严格,第二层放宽,第三层是兜底。这样设计的原因是,移动端页面变化多,严格对比容易误判,但完全宽松又会漏掉错误。提示:校验角色的 confidence 字段很有用。当 confidence 低于 0.7 时,我会让编排器多抓一次屏幕重新校验,避免因为截图时机不对导致的误判。5. 实测数据与效果对比5.1 单模型 vs 角色拆分的准确率对比我拿同一批测试用例,分别用单模型方案和角色拆分方案跑了一遍。测试用例覆盖登录、搜索、下单、支付四个流程,每个流程 25 条用例,总共 100 条。方案通过率平均步数平均耗时平均 token 消耗单模型68%18.372s15200角色拆分89%14.748s9800通过率提升 21 个百分点,耗时降低 33%,token 消耗降低 35%。这个结果比我预期的好。通过率提升主要来自校验角色的纠错,耗时降低是因为感知和执行角色用了更快的模型,token 降低是因为每个角色的 prompt 都精简了。5.2 不同模型组合的效果角色拆分的一个好处是,每个角色可以独立选模型。我试了几种组合,结果如下:感知模型决策模型校验模型通过率平均耗时中等大中等89%48s中等大大91%62s大大大92%85s小大中等81%38s可以看到,决策模型用大模型是必须的,换成小模型通过率掉 8 个百分点。感知和校验用中等模型足够,换成大模型提升有限但耗时增加明显。最下面那行是小模型做感知,通过率掉到 81%,说明感知角色也不能太弱。我最终选的组合是中等感知 + 大决策 + 中等校验,性价比最高。5.3 角色拆分的边界在哪角色拆分不是万能的。我试过拆成六个角色,增加了"规划角色"和"异常处理角色",结果通过率反而降到了 84%。原因是角色间通信开销增加,而且规划角色和决策角色的职责有重叠,经常互相矛盾。四个角色是我实测下来的平衡点。再少,职责耦合;再多,通信开销大。当然这个数字跟具体场景有关,如果你的测试流程特别复杂,可能需要五个角色,但我不建议超过六个。另一个边界是简单场景不需要拆分。如果测试用例只有三四步,比如打开页面检查一个元素存在,单模型方案更快更简单。角色拆分的价值在复杂流程里才体现得出来。6. 常见问题与排查技巧6.1 感知角色识别错误怎么排查感知角色出错的表现是:输出的页面描述和实际屏幕不符。比如实际是登录页,它说是首页。排查步骤我总结成三步。第一步,检查输入数据。把 UI 树 XML 和截图单独拿出来看,确认数据本身没问题。我遇到过 adb 抓取时机不对,抓到的是上一个页面的 UI 树,这种就是输入数据的问题。第二步,检查 prompt。把感知角色的完整 prompt 打印出来,看看有没有格式错误或者指令歧义。有一次我改 prompt 的时候漏了个引号,模型理解错了输出格式,导致解析失败。第三步,单独测试模型。把输入数据单独喂给模型,看输出是什么。如果单独测试正确,但集成到链路里出错,那就是数据传递的问题。6.2 决策角色选错动作怎么优化决策角色选错动作是最常见的问题。表现是:当前应该点登录,它却去点注册。优化方向有三个。一是补充 few-shot 示例。把出错的场景做成示例加进 prompt,让模型学会这种情况该怎么处理。我每次遇到新的错误模式,都会加一个示例,prompt 慢慢就完善了。二是调整历史记录的粒度。有时候模型选错动作是因为历史记录里缺少关键信息。比如它不知道账号已经填过了,就重复填。我在历史记录里明确标注了"账号已填写",问题就解决了。三是检查动作空间定义。如果动作空间里有歧义,模型会选错。比如"点击"和"轻触"如果都定义了,模型可能分不清。我后来把动作空间精简到六个,歧义少了很多。6.3 执行失败的重试策略执行失败的原因很多:元素没找到、坐标点偏了、页面还没加载完。不同的原因要用不同的重试策略。失败原因重试策略重试次数元素未找到等待 1 秒后重新感知3坐标点击无效改用元素定位2页面未加载等待 2 秒后重试5输入法遮挡收起键盘后重试2重试策略要跟校验角色配合。执行失败后,先重试,重试还失败就交给校验角色判断是否需要终止。我一开始不做区分,所有失败都重试三次,结果有些明显不可恢复的失败也重试,浪费时间。6.4 上下文超长的处理虽然角色拆分之后上下文占用降低了,但决策角色的历史记录还是会随着步数增加而变长。我的处理策略是滑动窗口加摘要。保留最近 5 步的详细记录,更早的合并成一句话摘要。摘要的生成不用模型,用规则就行。比如连续三步都是输入操作,摘要成"已完成表单填写"。这样上下文长度基本恒定,不会随步数增长。如果测试用例特别长,超过 50 步,我会在中间插入一个"检查点",把当前状态存下来,后续如果出错可以从检查点恢复,不用从头跑。6.5 模型 API 调用的稳定性模型 API 调用偶尔会超时或者返回错误。我的处理是加重试和降级。重试三次,每次间隔 1 秒。如果还失败,降级到备用模型。备用模型可以是同级别的另一个厂商,也可以是稍弱一点的模型。降级之后要记录,因为降级可能影响准确率。我在日志里标记了降级的步骤,事后分析的时候能看出来哪些失败是降级导致的。注意:API 调用的超时时间要设置合理。太短了容易误判超时,太长了卡住整个流程。我设置的是 30 秒,实测下来大部分调用在 5 秒内返回,30 秒足够覆盖网络波动。7. 角色拆分的扩展思路7.1 多设备并行时的角色分配单设备跑通了之后,我试了多设备并行。四台设备同时跑,每台设备一套角色实例。这时候有个新问题:模型 API 的并发限制。我的解决办法是角色分级调度。决策角色的调用优先级最高,保证及时响应;感知和校验角色的调用可以排队,稍微延迟不影响整体。执行角色不用模型,不受影响。另外,多设备并行时,感知角色的截图要加设备标识,避免混淆。我在每台设备上跑一个独立的 agent 进程,通过消息队列和编排器通信。7.2 跨平台适配的注意事项Android 和 iOS 的 UI 树结构不一样,感知角色的解析器要分开写。Android 用uiautomator2,iOS 用wda。两者的元素属性名不同,Android 是resource-id,iOS 是name。决策角色和校验角色可以复用,因为它们处理的是结构化描述,不关心底层平台。执行角色要分开,因为操作 API 不同。跨平台适配的工作量主要在感知和执行两个角色,决策和校验基本不用改。这也是角色拆分的一个好处:平台相关的逻辑被隔离在特定角色里,不会污染整个链路。7.3 后续可以优化的方向现在这套方案还有几个可以优化的点。一是感知角色的缓存,如果连续两步屏幕没变化,可以复用上一步的感知结果,省一次模型调用。二是决策角色的动作预测,根据历史操作预测下一步可能做什么,提前准备。三是校验角色的主动学习,把误判的案例收集起来,定期更新 prompt。这些优化我还在试验阶段,等跑稳定了再单独写一篇分享。这套角色级模型拆分的方案,我从去年年底开始搭,中间推倒重来了两次,现在算是比较稳定了。核心体会就一句话:不要让一个模型干所有事,把职责拆开,每个角色用最合适的模型和 prompt。拆分的粒度要试,四个角色是我的平衡点,你可以根据自己的场景调整。执行角色的坐标换算和校验角色的独立对比,是我踩坑最多的地方,希望你看完能少走点弯路。
移动端自动化测试这块,我前前后后折腾了快三年,从最早的 Appium 脚本堆到后来的图像识别方案,踩过的坑能写满一个笔记本。去年开始把大模型接进测试链路之后,有个问题一直卡着我:单模型跑全流程,准确率上不去,成本还下不来。直到我把模型按角色拆开,整个链路才真正跑通。这篇就聊聊 DroidRun/Mobilerun 这套方案里,角色级模型拆分到底怎么做的,为什么这么拆,以及拆完之后哪些地方会踩坑。如果你正在做移动端 UI 自动化,或者已经把 LLM 接进了测试流程但效果不理想,这篇内容应该能帮你省掉不少试错时间。我会从整体设计思路讲到具体实现,再到问题排查,尽量把每个决策背后的逻辑说清楚,让你看完能直接照着搭一套。1. 为什么单模型跑移动端自动化会翻车1.1 移动端场景的特殊性在哪移动端自动化和 Web 端最大的区别,在于状态空间是爆炸的。Web 页面再复杂,DOM 结构相对稳定,元素定位有 id、class、xpath 这些锚点。移动端不一样,同一个按钮在不同机型上可能叫不同的 accessibility label,弹窗出现时机受网络、权限、系统版本影响,页面层级还经常被原生控件和 WebView 混着用。我拿一个真实的登录流程举例。打开 App,等启动页消失,检测是否已有登录态,没有就点登录按钮,输入账号密码,处理可能弹出的权限请求,点击提交,等待首页加载。这一串操作里,光是"等待启动页消失"这一步,不同机型上耗时能从 1.5 秒到 8 秒不等。如果用单模型来做决策,它得同时理解当前屏幕状态、判断下一步动作、生成操作指令,还要处理异常分支。一个模型扛这么多职责,注意力被稀释,准确率自然上不去。更麻烦的是上下文长度。移动端每一步操作都要把当前屏幕的 UI 树或者截图喂给模型,一个完整流程下来,几十步操作,上下文轻松超过模型的窗口限制。你可能会说用滑动窗口截断,但截断之后模型就丢了历史状态,容易重复操作或者漏掉关键分支。1.2 单模型方案的三个硬伤我把单模型方案的问题总结成三条,都是实测出来的。第一,职责耦合导致 prompt 臃肿。你要让一个模型既做屏幕理解,又做动作决策,还得生成具体的点击坐标或者元素选择器,prompt 里得塞进 UI 树格式说明、动作空间定义、历史操作记录、异常处理规则。我最早那版 prompt 有 2000 多 token,模型经常顾此失彼,理解了屏幕却选错动作。第二,错误会累积放大。单模型方案里,如果某一步屏幕理解错了,比如把"取消"按钮识别成了"确认",后续所有操作都建立在错误状态上。而且模型自己很难发现这个错误,因为它没有独立的校验环节。第三,成本和延迟不可控。用一个参数量大的模型跑全流程,每步都要调用,token 消耗飞快。我算过一笔账,一个 20 步的测试用例,单模型方案下平均消耗 15000 token 左右,按当时的 API 价格,跑一千次就是一笔不小的开销。延迟也高,每步响应 3 到 5 秒,一个用例跑完要一分多钟。1.3 角色拆分的核心思路角色级模型拆分的本质,是把一个大而全的任务,拆成几个职责单一的子任务,每个子任务用最适合它的模型来做。这跟软件工程里的单一职责原则是一个道理,只不过对象从函数变成了模型。在 DroidRun/Mobilerun 这套方案里,我把角色拆成了四类:感知角色负责理解当前屏幕,决策角色负责判断下一步做什么,执行角色负责把决策转成具体操作,校验角色负责确认操作结果是否符合预期。每个角色用不同的模型、不同的 prompt、不同的上下文策略。这么拆的好处很直接。感知角色只需要关注屏幕内容,prompt 可以精简到 500 token 以内;决策角色拿到的是结构化的屏幕描述,不用处理原始 UI 树;执行角色做的是确定性的坐标计算或者元素匹配,甚至可以用小模型或者规则引擎;校验角色独立运行,能及时发现前面环节的错误。提示:角色拆分不是拆得越细越好。我试过拆成六个角色,结果角色间通信开销反而成了瓶颈。四个角色是我实测下来比较平衡的点,既能隔离职责,又不会让链路太复杂。2. 四个角色的职责边界与模型选型2.1 感知角色:把屏幕变成结构化描述感知角色的输入是原始屏幕数据,可能是 UI 树 XML,也可能是截图,输出是一段结构化的自然语言描述,比如"当前页面是登录页,包含账号输入框、密码输入框、登录按钮,登录按钮处于可点击状态"。这个角色的核心挑战是信息压缩。UI 树动辄几千个节点,直接喂给决策模型不现实。感知角色要做的是提取关键元素,过滤掉装饰性节点,把层级结构扁平化成列表。模型选型上,我用的是中等参数量的多模态模型。为什么不用最大的?因为感知任务相对标准化,不需要太强的推理能力,中等模型足够,而且速度快、成本低。实测下来,感知角色用 7B 到 13B 级别的模型,配合精心设计的 prompt,准确率能到 92% 以上,再往上加参数量,提升不到 3 个百分点,性价比不划算。prompt 设计上有个技巧:给模型一个固定的输出模板。我定义的模板是这样的:页面类型:[登录页/首页/列表页/详情页/弹窗] 关键元素: - [元素类型] [元素文本] [元素状态] [位置描述] 可操作项:[可点击/可输入的元素列表] 异常提示:[是否有弹窗、错误提示、加载状态]固定模板的好处是决策角色拿到的输入格式统一,不用做额外的解析。而且模板本身约束了模型的输出空间,减少了幻觉。2.2 决策角色:基于状态选动作决策角色拿到感知角色的输出,结合历史操作记录,判断下一步该做什么。它的输出是一个动作指令,比如"点击登录按钮"或者"在账号输入框输入 test@example.com"。这个角色是整条链路里最需要推理能力的,所以我用的是参数量最大的模型。决策质量直接决定测试能不能跑通,这里不能省。决策角色的 prompt 里要包含几样东西:当前页面描述、历史操作序列、当前测试目标、可用动作空间。历史操作序列不能太长,我一般保留最近 5 步,再早的用一句话摘要代替,比如"已完成登录信息填写"。动作空间的定义很关键。我定义的动作类型有:点击、输入、滑动、等待、返回、断言。每种动作有固定的参数格式。决策角色只能从这个集合里选,不能自由发挥。这样做的原因是,自由生成的动作指令执行角色没法可靠地解析,而且容易产生无效操作。有个细节值得说:决策角色要能输出"等待"动作。移动端很多场景需要等加载,如果模型不会等待,就会在页面还没渲染完的时候瞎点。我在 prompt 里明确告诉模型,如果检测到加载状态,优先输出等待动作,并指定等待时长。2.3 执行角色:把指令变成操作执行角色负责把决策角色的抽象指令,转成具体的设备操作。比如"点击登录按钮"要变成"在坐标 (540, 1200) 执行 tap"或者"通过 accessibility id 定位元素并点击"。这个角色其实不一定需要大模型。如果感知角色已经输出了元素的位置信息,执行角色用规则引擎就能完成。我现在的方案里,执行角色优先用元素定位,定位失败才回退到坐标点击,坐标从感知角色输出的位置描述里解析。为什么执行角色要独立出来?因为执行层有很多设备相关的细节,比如不同平台的坐标系统不一样,iOS 的逻辑坐标和 Android 的物理坐标需要转换,输入法弹出会遮挡元素需要先收起键盘。这些细节如果混在决策角色的 prompt 里,会严重干扰决策质量。独立出来之后,执行角色可以用代码实现这些逻辑,稳定可靠。2.4 校验角色:独立确认结果校验角色是四个角色里最容易被忽略,但最重要的一个。它的职责是:执行完一个动作后,重新感知屏幕,判断操作结果是否符合预期。举个例子,决策角色说"点击登录按钮",执行角色点了,校验角色要判断:页面是否跳转了?是否出现了错误提示?是否还在登录页?如果还在登录页且没有错误提示,可能是点击没生效,需要重试。校验角色我用的是和感知角色同级别的模型,因为它的核心任务也是屏幕理解,只是多了一个对比逻辑。prompt 里我会给它预期结果和实际屏幕描述,让它输出"符合预期"、"不符合预期但可恢复"、"不符合预期且需终止"三种结论。独立校验的价值在于打断错误累积。单模型方案里,错误会一路传下去,校验角色能在每一步之后做一次检查,及时发现问题。我实测下来,加了校验角色之后,测试用例的通过率从 68% 提升到了 89%。3. 角色间通信与上下文管理3.1 角色间的数据格式约定四个角色之间要通信,就得有统一的数据格式。我用的是 JSON,因为结构清晰,解析方便,而且模型生成 JSON 的准确率比生成其他格式高。感知角色的输出格式:{ "page_type": "login", "elements": [ {"type": "input", "text": "账号", "state": "editable", "position": "center-top"}, {"type": "input", "text": "密码", "state": "editable", "position": "center-middle"}, {"type": "button", "text": "登录", "state": "clickable", "position": "center-bottom"} ], "abnormal": null }决策角色的输出格式:{ "action": "click", "target": "登录按钮", "reason": "当前在登录页,账号密码已填写,下一步应提交", "expected_result": "跳转到首页" }执行角色的输出格式:{ "status": "success", "method": "accessibility_id", "detail": "clicked element with id btn_login" }校验角色的输出格式:{ "result": "match", "confidence": 0.95, "note": "页面已跳转至首页,符合预期" }这套格式我迭代了七八版才稳定下来。早期版本里,元素位置用的是绝对坐标,结果换机型就失效了。后来改成相对位置描述,由执行角色根据当前屏幕尺寸换算,兼容性好很多。3.2 上下文窗口的分配策略四个角色各自维护自己的上下文,不共享。这是角色拆分的一个关键设计。感知角色每次只处理当前屏幕,不需要历史上下文,窗口占用很小。决策角色需要历史操作记录,但只保留最近 5 步加一个摘要。执行角色基本不需要上下文,它只处理当前指令。校验角色需要预期结果和当前屏幕,也不需要长历史。这样分配下来,每个角色的上下文窗口占用都很低,不会出现截断问题。而且因为不共享上下文,一个角色的上下文污染不会影响其他角色。注意:决策角色的历史记录摘要很重要。我一开始不做摘要,直接把所有历史操作塞进去,结果 10 步之后上下文就爆了。后来改成保留最近 5 步详细记录,更早的用一句话概括,比如"已完成登录流程",上下文占用降了 60%,决策质量没下降。3.3 角色调用的编排逻辑四个角色不是简单的串行调用,中间有分支和循环。我用一个编排器来管理整个流程,伪代码大概是这样:def run_test_case(goal, max_steps=30): history = [] for step in range(max_steps): screen = capture_screen() perception = perception_role(screen) if check_goal_reached(perception, goal): return "success" decision = decision_role(perception, history, goal) if decision.action == "wait": sleep(decision.duration) continue execution = execution_role(decision) if execution.status != "success": handle_execution_failure(execution) continue new_screen = capture_screen() verification = verification_role(decision.expected_result, new_screen) if verification.result == "match": history.append(summarize(decision, execution)) elif verification.result == "recoverable": history.append(f"操作未达预期,已重试") else: return "failed" return "timeout"这个编排逻辑里有几个设计点值得说。目标检测放在决策之前,每步都检查是否已经达成目标,避免多余操作。等待动作单独处理,不进入执行和校验环节。执行失败和校验失败分开处理,执行失败通常是技术问题,重试即可;校验失败可能是决策错误,需要记录并调整策略。4. 实操搭建:从零跑通一条测试用例4.1 环境准备与依赖安装先说环境。我用的是一台 Linux 开发机,装了 Android SDK 和 adb,iOS 那边用了一台 Mac mini 做真机测试。Python 环境是 3.10,主要依赖有:pip install openai uiautomator2 pillow numpyuiautomator2用来抓 Android 的 UI 树和执行操作,iOS 那边用的是facebook-wda。图像处理用pillow,主要是截图裁剪和压缩。模型调用这块,我用的是兼容 OpenAI 接口的 SDK,方便切换不同厂商的模型。感知和校验角色用中等模型,决策角色用大模型,执行角色不用模型。配置上有个坑要注意:adb 的默认超时时间太短,抓 UI 树经常超时。我在~/.android/avd的配置里把adb timeout调到了 30 秒,稳定很多。4.2 感知角色的实现细节感知角色的核心是把 UI 树转成结构化描述。Android 的 UI 树是 XML,节点属性有text、resource-id、class、clickable、bounds等。我写了个解析器,提取关键节点,过滤掉bounds面积小于屏幕 1% 的装饰性节点。def parse_ui_tree(xml_str, screen_size): tree = ET.fromstring(xml_str) elements = [] for node in tree.iter(): bounds = parse_bounds(node.get("bounds")) area_ratio = (bounds[2]-bounds[0]) * (bounds[3]-bounds[1]) / (screen_size[0]*screen_size[1]) if area_ratio < 0.01: continue if not node.get("text") and not node.get("resource-id"): continue elements.append({ "type": classify_element(node), "text": node.get("text", ""), "id": node.get("resource-id", ""), "clickable": node.get("clickable") == "true", "bounds": bounds }) return elements解析完之后,把元素列表和截图一起喂给感知模型。为什么要给截图?因为有些元素 UI 树里没有,比如 WebView 里的内容,或者游戏画面。多模态模型能同时利用两种信息,准确率更高。感知角色的 prompt 我调了很多版,最后稳定下来的版本大概 400 token。关键是指令要明确:告诉模型输出 JSON,字段有哪些,每个字段的取值范围。模型对格式约束的遵循度,比自由发挥高得多。4.3 决策角色的 prompt 工程决策角色的 prompt 是整条链路里最复杂的,我拆成了四个部分:角色定义、当前状态、历史记录、动作空间。角色定义部分告诉模型它是移动端测试的决策者,目标是什么。当前状态就是感知角色的输出。历史记录是最近 5 步的操作摘要。动作空间定义了可选动作和参数格式。有个技巧很管用:在 prompt 里给几个 few-shot 示例。我放了三个示例,分别对应"正常操作"、"需要等待"、"遇到弹窗"三种场景。加了示例之后,决策准确率提升了大概 15 个百分点。另一个技巧是让模型输出推理过程。我在 prompt 里要求模型先输出reason字段,再输出action。这样模型会先想清楚为什么这么做,再给动作,比直接输出动作准确率高。虽然多消耗一些 token,但值得。4.4 执行角色的坐标换算执行角色看起来简单,其实坐标换算有不少门道。Android 的 UI 树里bounds是物理像素,但adb tap用的也是物理像素,所以直接用就行。iOS 不一样,wda的 UI 树里bounds是逻辑坐标,但点击用的是逻辑坐标,也不用换算。真正需要换算的是截图坐标和 UI 树坐标不一致的情况。我遇到过一个问题:截图是 1080x1920,但 UI 树里的 bounds 是 720x1280,因为设备设置了显示缩放。这时候如果直接用截图里的坐标去点击,就会点偏。解决办法是记录截图和 UI 树的缩放比例,执行点击时统一换算到 UI 树坐标系。def convert_coordinate(x, y, screenshot_size, ui_tree_size): scale_x = ui_tree_size[0] / screenshot_size[0] scale_y = ui_tree_size[1] / screenshot_size[1] return int(x * scale_x), int(y * scale_y)这个坑我踩了整整两天,一开始以为是模型识别错了元素,后来才发现是坐标系不一致。4.5 校验角色的对比逻辑校验角色的输入是预期结果和实际屏幕描述。预期结果是决策角色输出的,比如"跳转到首页"。实际屏幕描述是重新感知得到的。对比逻辑我分了三层。第一层是页面类型对比,如果预期是首页,实际感知出来也是首页,直接判定符合。第二层是关键元素对比,如果页面类型对不上,但预期结果里提到的关键元素出现了,也算符合。第三层是异常检测,如果屏幕上有错误提示、弹窗、加载失败等异常,判定为不符合。三层对比的优先级是从严到宽。第一层最严格,第二层放宽,第三层是兜底。这样设计的原因是,移动端页面变化多,严格对比容易误判,但完全宽松又会漏掉错误。提示:校验角色的 confidence 字段很有用。当 confidence 低于 0.7 时,我会让编排器多抓一次屏幕重新校验,避免因为截图时机不对导致的误判。5. 实测数据与效果对比5.1 单模型 vs 角色拆分的准确率对比我拿同一批测试用例,分别用单模型方案和角色拆分方案跑了一遍。测试用例覆盖登录、搜索、下单、支付四个流程,每个流程 25 条用例,总共 100 条。方案通过率平均步数平均耗时平均 token 消耗单模型68%18.372s15200角色拆分89%14.748s9800通过率提升 21 个百分点,耗时降低 33%,token 消耗降低 35%。这个结果比我预期的好。通过率提升主要来自校验角色的纠错,耗时降低是因为感知和执行角色用了更快的模型,token 降低是因为每个角色的 prompt 都精简了。5.2 不同模型组合的效果角色拆分的一个好处是,每个角色可以独立选模型。我试了几种组合,结果如下:感知模型决策模型校验模型通过率平均耗时中等大中等89%48s中等大大91%62s大大大92%85s小大中等81%38s可以看到,决策模型用大模型是必须的,换成小模型通过率掉 8 个百分点。感知和校验用中等模型足够,换成大模型提升有限但耗时增加明显。最下面那行是小模型做感知,通过率掉到 81%,说明感知角色也不能太弱。我最终选的组合是中等感知 + 大决策 + 中等校验,性价比最高。5.3 角色拆分的边界在哪角色拆分不是万能的。我试过拆成六个角色,增加了"规划角色"和"异常处理角色",结果通过率反而降到了 84%。原因是角色间通信开销增加,而且规划角色和决策角色的职责有重叠,经常互相矛盾。四个角色是我实测下来的平衡点。再少,职责耦合;再多,通信开销大。当然这个数字跟具体场景有关,如果你的测试流程特别复杂,可能需要五个角色,但我不建议超过六个。另一个边界是简单场景不需要拆分。如果测试用例只有三四步,比如打开页面检查一个元素存在,单模型方案更快更简单。角色拆分的价值在复杂流程里才体现得出来。6. 常见问题与排查技巧6.1 感知角色识别错误怎么排查感知角色出错的表现是:输出的页面描述和实际屏幕不符。比如实际是登录页,它说是首页。排查步骤我总结成三步。第一步,检查输入数据。把 UI 树 XML 和截图单独拿出来看,确认数据本身没问题。我遇到过 adb 抓取时机不对,抓到的是上一个页面的 UI 树,这种就是输入数据的问题。第二步,检查 prompt。把感知角色的完整 prompt 打印出来,看看有没有格式错误或者指令歧义。有一次我改 prompt 的时候漏了个引号,模型理解错了输出格式,导致解析失败。第三步,单独测试模型。把输入数据单独喂给模型,看输出是什么。如果单独测试正确,但集成到链路里出错,那就是数据传递的问题。6.2 决策角色选错动作怎么优化决策角色选错动作是最常见的问题。表现是:当前应该点登录,它却去点注册。优化方向有三个。一是补充 few-shot 示例。把出错的场景做成示例加进 prompt,让模型学会这种情况该怎么处理。我每次遇到新的错误模式,都会加一个示例,prompt 慢慢就完善了。二是调整历史记录的粒度。有时候模型选错动作是因为历史记录里缺少关键信息。比如它不知道账号已经填过了,就重复填。我在历史记录里明确标注了"账号已填写",问题就解决了。三是检查动作空间定义。如果动作空间里有歧义,模型会选错。比如"点击"和"轻触"如果都定义了,模型可能分不清。我后来把动作空间精简到六个,歧义少了很多。6.3 执行失败的重试策略执行失败的原因很多:元素没找到、坐标点偏了、页面还没加载完。不同的原因要用不同的重试策略。失败原因重试策略重试次数元素未找到等待 1 秒后重新感知3坐标点击无效改用元素定位2页面未加载等待 2 秒后重试5输入法遮挡收起键盘后重试2重试策略要跟校验角色配合。执行失败后,先重试,重试还失败就交给校验角色判断是否需要终止。我一开始不做区分,所有失败都重试三次,结果有些明显不可恢复的失败也重试,浪费时间。6.4 上下文超长的处理虽然角色拆分之后上下文占用降低了,但决策角色的历史记录还是会随着步数增加而变长。我的处理策略是滑动窗口加摘要。保留最近 5 步的详细记录,更早的合并成一句话摘要。摘要的生成不用模型,用规则就行。比如连续三步都是输入操作,摘要成"已完成表单填写"。这样上下文长度基本恒定,不会随步数增长。如果测试用例特别长,超过 50 步,我会在中间插入一个"检查点",把当前状态存下来,后续如果出错可以从检查点恢复,不用从头跑。6.5 模型 API 调用的稳定性模型 API 调用偶尔会超时或者返回错误。我的处理是加重试和降级。重试三次,每次间隔 1 秒。如果还失败,降级到备用模型。备用模型可以是同级别的另一个厂商,也可以是稍弱一点的模型。降级之后要记录,因为降级可能影响准确率。我在日志里标记了降级的步骤,事后分析的时候能看出来哪些失败是降级导致的。注意:API 调用的超时时间要设置合理。太短了容易误判超时,太长了卡住整个流程。我设置的是 30 秒,实测下来大部分调用在 5 秒内返回,30 秒足够覆盖网络波动。7. 角色拆分的扩展思路7.1 多设备并行时的角色分配单设备跑通了之后,我试了多设备并行。四台设备同时跑,每台设备一套角色实例。这时候有个新问题:模型 API 的并发限制。我的解决办法是角色分级调度。决策角色的调用优先级最高,保证及时响应;感知和校验角色的调用可以排队,稍微延迟不影响整体。执行角色不用模型,不受影响。另外,多设备并行时,感知角色的截图要加设备标识,避免混淆。我在每台设备上跑一个独立的 agent 进程,通过消息队列和编排器通信。7.2 跨平台适配的注意事项Android 和 iOS 的 UI 树结构不一样,感知角色的解析器要分开写。Android 用uiautomator2,iOS 用wda。两者的元素属性名不同,Android 是resource-id,iOS 是name。决策角色和校验角色可以复用,因为它们处理的是结构化描述,不关心底层平台。执行角色要分开,因为操作 API 不同。跨平台适配的工作量主要在感知和执行两个角色,决策和校验基本不用改。这也是角色拆分的一个好处:平台相关的逻辑被隔离在特定角色里,不会污染整个链路。7.3 后续可以优化的方向现在这套方案还有几个可以优化的点。一是感知角色的缓存,如果连续两步屏幕没变化,可以复用上一步的感知结果,省一次模型调用。二是决策角色的动作预测,根据历史操作预测下一步可能做什么,提前准备。三是校验角色的主动学习,把误判的案例收集起来,定期更新 prompt。这些优化我还在试验阶段,等跑稳定了再单独写一篇分享。这套角色级模型拆分的方案,我从去年年底开始搭,中间推倒重来了两次,现在算是比较稳定了。核心体会就一句话:不要让一个模型干所有事,把职责拆开,每个角色用最合适的模型和 prompt。拆分的粒度要试,四个角色是我的平衡点,你可以根据自己的场景调整。执行角色的坐标换算和校验角色的独立对比,是我踩坑最多的地方,希望你看完能少走点弯路。