最近在折腾一套用多模态 Agent 跑 UI 测试的验证项目。我给它一个纯文字目标——“打开控制台,给默认项目创建一个新的 API Key”——它就得自己看懂页面、找到入口、填表单、提交,最后确认结果。最开始我以为难点在“怎么让模型能控制浏览器”,但真做起来才发现,最难的是路径规划:面对一个满屏都是按钮和链接的页面,它凭什么选对下一步,选错了怎么回来。
这篇文章想把路径规划这部分完整拆开讲。没打算写那些“大模型一进来测试就全自动了”的漂亮话,而是把我踩过的坑、验证过的策略、最后能跑通的工程架构都摆出来。适合两类人看:一类是正在评估 Agent 化 UI 测试可行性的测试架构师,另一类是想动手写一个最小 Planner 的开发者。
1. 从“写死脚本”到“喂给 Agent 一个目标”:UI 测试的范式转变
1.1 传统脚本自动化为什么撑不住高频变更
做 UI 测试的人应该都有这个体感:脚本跑得越久,维护成本越接近报废临界点。原因其实很朴素——脚本把“路径”写死了,而页面不是静态的。
我遇到过最典型的例子是一个按钮的 class 从 primary 改成 action-btn,选择器直接挂掉,整个登录用例瘫痪。改回来倒是容易,但后面还有几十个用例引用了同一个选择器,牵一发动全身。更麻烦的是那种“选择器没挂,但语义已经错了”的情况:开发者调整了 DOM 结构,按钮从直接子节点变成了嵌套在某个容器里,选择器依然能找到元素,可点击区域已经被其他元素遮挡,脚本还会傻傻地执行 click,最后断言失败,你得花半天去看录像才能明白发生了什么。
断言也一直是个老大难。传统自动化能把“点击后出现某个文本”这类结果检查得很干净,但对视觉层面的问题几乎无能为力。按钮错位、样式加载失败、图片懒加载导致布局抖动,这些在功能脚本里根本不会被发现,因为它们没有改变 DOM 的语义结构。换句话说,传统脚本擅长检查“页面说了什么”,很难检查“页面长得对不对”。
更致命的是探索性场景。产品经理临时给某个老页面塞了一个新的引导弹窗,走老路径的用例全部被弹窗挡住,测试同学只能手工跑一遍。这时候大家会意识到:我们缺的不是更健壮的选择器,而是一个能在运行时重新理解页面、自己绕开障碍的东西。
1.2 Agent 化测试真正改变的是什么
多模态 Agent 的思路是把“目标”给到系统,而不是把“步骤”给到系统。我只需要说“完成注册并进入首页”,Agent 自己去看截图、读可访问性树、决定先点哪里后点哪里。这在工程上的意义不是省掉了几行选择器,而是把路径规划从“编译期”挪到了“运行期”。
运行期规划意味着每一次执行都是一次重新规划。页面变了?没关系,Agent 看到的是当前这秒的页面状态,它不需要知道历史上有过哪个 class。弹窗挡住了?它会在候选动作里把弹窗上的“关闭”按钮的优先级调高,先处理障碍再继续主任务。这种能力是传统脚本完全不具备的。
但代价也很明显:路径规划从一个确定性问题变成了开放问题。传统脚本每一步是确定的,而 Agent 每一步都要做决策,决策就有概率出错。所以整个系统设计的核心不再是“怎么写选择器”,而是“怎么让 Agent 的决策尽量正确,并且错了能快速恢复”。这也是我这篇文章最想讲清楚的部分。
2. 多模态 Agent 眼中的页面:截图、无障碍树与行动候选集
2.1 视觉通道:让它像人一样“看”见界面
要让 Agent 规划路径,它首先得知道自己“能做什么”。多模态模型可以把截图直接作为输入,从中识别出按钮、输入框、链接、卡片这些可交互元素的像素位置。这一步通常依赖视觉模型具备的 grounding 能力,也就是“你指给我看”的能力——给它一张图和一个描述“登录按钮在哪里”,它能返回一个边界框坐标。
这个能力很性感,但直接拿原始截图去推理有两个问题。第一个是上下文窗口吃紧。一张 1920x1080 的截图,经过压缩后也会消耗大量视觉 token,而 Agent 完成一个任务可能要执行几十步,每次都塞一张整屏截图,成本很快就会失控。第二个是像素位置的精度问题。视觉模型偶尔会给出偏了几个像素的坐标,点击时正好落在两个元素的交界处,动作就执行到了错误的控件上。
所以我的做法是:先让视觉模型做一遍初步的场景理解,只保留它认为当前步骤最关键的两三个视觉区域,再用这些局部截图做动作选择。比如目标是要填一个表单,那 Agent 只需要聚焦在表单区域,而不是整页扫描。这既降低了 token 消耗,也减少了无关视觉信息对决策的干扰。
2.2 文本通道:无障碍树是自动化测试的“藏宝图”
只靠视觉是远远不够的。截图能告诉我“这里有个按钮”,但告诉我按钮叫什么、是普通按钮还是提交按钮、当前是否 disabled、所属表单是哪个的是无障碍树(Accessibility Tree)。它本质上是对 DOM 的一个语义化提炼,把页面渲染结果映射成一个个带角色、名称、属性和坐标的可交互节点。
从无障碍树提取候选动作比从截图识别要稳定得多。因为它不是“猜”,而是页面主动告诉你“哪些元素是可以交互的”。我的流程是:从无障碍树里筛出 role 为 button、link、combobox、textbox 的节点,再为每个节点生成一个候选动作。动作大概分几类——click、type、hover、select、scroll、press,每类都附带目标的语义信息、路径信息、坐标信息。
这个步骤把“页面理解”变成了“候选集生成”,意义很关键。Agent 不再需要在无限大的动作空间里瞎猜,而是从一个有序的候选列表里选。列表里的每个候选都有 label、坐标、DOM 路径,选哪个都不会完全无的放矢。路径规划的第一步,其实是缩小搜索空间。
2.3 信息融合:视觉和文本打架时听谁的
真正在落地的时候,截图和无障碍树经常对不上。最典型的情况是:无障碍树里某个按钮位置正常,但截图上一看,它被一个 loading 遮罩盖住了。这时候如果只看文本信息去执行点击,就会点击到一个看不见的元素上。反过来,页面上有一条基于 JavaScript 动态绘制的横幅,视觉上一清二楚,但由于实现方式的问题,它没有出现在无障碍树里,只看结构信息就会漏掉它。
我的处理原则很简单:结构文本负责“候选有哪些”,截图负责“候选现在能不能点”。执行一个动作之前,先拿截图对应的局部区域做一次视觉校验,确认这个元素确实可见、没有被遮挡、没有处于加载状态。如果校验不过,这个候选的动作优先级就降低,或者直接在下一步跳过。
融合之后,我对每个候选动作会给出一组信心分数:语义匹配分数、视觉可见性分数、坐标距离分数、历史出现频率分数。规划器的核心任务,其实就是基于这些分数去做最终的组合决策。后面章节我再展开说这个决策怎么做,这里先记住一个大前提:没有候选集,就没有规划。
3. 路径规划的核心机制:拆目标、选动作、能反悔
3.1 目标分解:从“完成下单”到可执行的子步骤
路径规划的第一步不是选动作,而是拆目标。直接让 Agent 面对“完成下单”这种大目标去选动作,它大概率会在几个看起来相关的按钮之间反复横跳。原因在于 UI 界面的操作是分阶段的:先进入商品详情、然后加入购物车、再进结算页、最后提交订单。没有一个中间层的子目标,Agent 在每一时刻的决策就会失去优先级。
实际操作中,我习惯把目标分解成两层。顶层是业务意图,比如“用余额支付完成购买”;底层才是具体的原子动作,比如“点击余额支付单选按钮”。中间层是一组子目标:浏览商品、加入购物车、进入结算、选择支付方式、确认提交。Agent 每完成一个子目标,就把状态推进一格,然后重新规划下一个子目标的动作路径。
这个分解动作不一定要一次性完成。我发现让模型在每一步都重新审视“当前子目标是否已完成”更可靠,而不是在任务开始时一口气把子目标序列写完。原因还是异步页面:你规划的第二个子目标可能因为第一个子目标的某个弹窗而无法到达。动态地推进子目标状态,比静态的全量规划更贴合真实 UI 的脾气。
3.2 每次动作选择的推理与打分
有了候选集和当前子目标,接下来的问题就是“选哪个”。我试过两种思路,一种是纯打分排序,一种是让模型走一段推理再给动作。前者快但容易选错,后者慢但决策质量高。最终我采用的是混合模式:先用逻辑规则做一个粗筛,再让模型在粗筛后的候选上做选择。
粗筛的规则至少包含三条:匹配度,候选元素的文本是否和子目标中的关键词有语义重合;可达性,元素是否在当前视口内,不在就优先考虑滚动或跳转动作;状态色,元素如果处于 disabled 或正在 loading 的状态就直接排除。这三条规则成本极低,能把候选集从几十个压到三五个。
然后我给模型的指令大概长这个样:
你正在执行 UI 测试任务。 当前子目标:{{subgoal}} 可用的候选动作: {{candidate_actions}} 上一轮动作的结果:{{observation}} 请选择下一步动作,并给出简短理由。 如果候选集中没有任何动作有助于推进子目标,请返回 special_action:target_done 或 special_action:backtrack。实测下来,“必须给理由”这个约束特别重要。它强迫模型做了逻辑推理而不是凭视觉印象点选,出错率会明显下降。另一个值得强调的点是:模型往往会倾向于选择上一次已经点过的按钮,导致原地打转。我在 prompt 里会显式传入“最近 N 步执行过的动作列表”,要求模型避免重复选择,除非有明确理由。
3.3 失败回溯与状态快照:规划器必须能“反悔”
路径规划里最容易被低估的是回溯能力。UI 测试的问题不在于 Agent 会不会犯错,而在于犯错之后能不能低成本地恢复。我做过的第一个原型就没有回溯机制,结果 Agent 在一个错误页面上连续尝试了五次点击,最后才意识到“好像走错了”,但整个用例已经超时。后来我加了三样东西,才把这个问题压住。
第一样是页面快照。每次动作执行前,我做两件事:截一张图,同时算一下当前无障碍树的状态哈希。动作执行完后再算一次新哈希。如果哈希没变,说明这个动作没有对页面产生实质影响,可以直接判定为无效动作,不再给它二次尝试的机会。如果哈希变了但和预期的方向不一致,就进入回滚分支。
第二样是回滚缓冲。Agent 每完成一个成功的子目标,我就存一份当时的快照和操作队列。一旦后续某一步连续失败超过阈值,就直接恢复到上一份成功快照对应的页面状态,而不是让 Agent 在当前坏状态下继续折腾。这个机制最接近人类测试员的直觉:跑岔了,退回去重来,而不是原地乱点。
第三样是终止条件。我设置了两个硬性上限:最大步数和最大连续失败数。达到任何一个限制,Agent 都必须停止并返回 FAIL。这听起来很基础,但缺了它,Agent 就会在一个极端场景里无限循环。有人可能会担心这样会漏测,但我的经验是:与其让任务超时或幻觉式地自称成功,不如快速失败,把问题交给人来分析。
4. 主流规划策略实测对比:ReAct、树搜索与候选排序
4.1 ReAct 的稳定牌:短路径和明确目标下非常好用
ReAct 这个名字听起来复杂,实际上就是把“推理-行动-观察”三个动作循环起来:模型先思考当前该干什么,然后执行一个动作,基于观察到的结果再思考下一步。我第一个可用的 Agent 原型就是用它做的,结构非常简单,只需要维护一个循环,把模型的推理文本和动作调用都追加到上下文里。
这套机制在短路径上表现得相当扎实。比如“点击设置,关闭消息通知”这类只有两三步的目标,ReAct 几乎不会出错,因为每一步的语义关联都很强,模型的推理空间很小。它的实现成本低得让人惊喜,不需要额外的搜索基础设施,连动作的选择都是模型直接从候选列表里决定的。
但一旦路径拉长,ReAct 就会出问题。最主要的是“漂移”:执行到第 20 步时,模型已经忘了最初的业务目标,开始在页面里做无关探索。另一个问题是错误会累积。第 3 步做错了一个选择,模型不会自动回头纠正,而是带着错误状态继续往下推,最后整个路径崩掉。所以我把 ReAct 定位成“短链路上的最稳妥选项”,而不是包打天下的方案。
4.2 树搜索的探索价值与成本代价
树搜索的思路和 ReAct 完全不同:它会同时保存多个候选路径分支,遇到当前分支走不下去时,可以退回到分支点尝试另一个方向。这意味着 Agent 不再是一次走到黑,而是有了一棵树状的探索空间。
在一条真实的长链路里,树搜索的价值在于“后悔药”是结构化的。假设 Agent 在填表单时不知道该选哪个选项卡,它可以分出两个分支:一个试选项卡 A,一个试选项卡 B。如果 A 分支导致页面报错,系统直接回到分支点走 B。这种探索方式在遇到“二选一”式的 UI 分叉时非常可靠。
但代价是成本成倍上涨。每个分支都需要独立的页面快照、模型推理、动作执行,资源共享很麻烦。一个 10 步的任务如果平均每一步有两个分支,节点数就会爆炸。我在一个真实的跨模块流程里试过全量树搜索,结果一次任务跑了将近二十分钟,token 花费高到难以接受。所以我后来的建议是:树搜索不要全链路用,只在风险最高的分叉点做局部探索,其余步骤继续用线性规划。
4.3 混合策略:先粗后细,用两层规划器缩小决策空间
实战下来最平衡的方案是两层规划器。第一层叫 subgoal planner,它只负责拆解子目标序列,不关心具体动作。第二层是动作规划器,在给定子目标的前提下,使用 ReAct 式的推理从候选动作里做选择。这种结构的核心思想是:把“去哪”和“怎么去”分离,每一层解决的问题都被大幅简化。
subgoal planner 不需要每次都看完整截图,只需要理解业务目标和当前页面状态的大致语义,所以可以给它压缩过的上下文。动作规划器则相反,需要尽量完整的候选列表和局部视觉信息,以便做出高精度的点击决策。两层之间的通信其实就一个接口:当前子目标。
我做过一个对比测试,用同一套任务分别跑纯 ReAct 和混合两层规划器。在超过 15 步的复杂任务里,纯 ReAct 的成功率大概只有 60%,而且经常漂移;混合规划器把成功率拉到了 80% 以上,同时单步决策的延迟几乎没有增加。下面这个表格是我当时整理的策略对比,可以给正在选型的人做个参考:
| 维度 | 纯 ReAct | 局部树搜索 | 两层混合规划 |
|---|---|---|---|
| 实现复杂度 | 低 | 中高 | 中 |
| 短路径表现 | 很稳定 | 稳定但浪费 | 稳定 |
| 长路径表现 | 容易漂移 | 能探索更多路径 | 漂移概率低 |
| 失败恢复能力 | 弱 | 强 | 中强 |
| 计算成本 | 最低 | 最高 | 中等 |
| 适用场景 | 单页表单、简单设置 | 流程分叉明显的场景 | 大多数端到端流程 |
5. 把一个规划器装进真实测试框架:接口、延迟与观测
5.1 最小可用的 Agent 测试闭环长什么样
如果你现在想动手做一个最简版本,我建议按四个模块来拆。Observer 负责采集页面状态,它把截图和无障碍树打包成一份统一的快照对象。Planner 负责决策,它拿到快照、子目标、历史动作之后,返回一个动作。Executor 负责执行动作,并且返回执行结果和新的页面状态。最后用一个 Loop 把前面三个模块串起来,处理重试和终止条件。
用伪代码表达的话,大概是这个形状:
class Observer: def snapshot(self) -> PageSnapshot: # 返回包含截图、无障碍树、状态哈希的对象 ... class Planner: def plan(self, goal, snapshot, history) -> Action: # 返回一个 Action,例如 Action(type="click", target="btn-save") ... class Executor: def execute(self, action) -> ExecutionResult: # 执行动作,返回是否成功、页面是否变化、新的快照 ... class Loop: def run(self, goal, max_steps=30, max_failures=3): while True: snap = observer.snapshot() action = planner.plan(goal, snap, history) result = executor.execute(action) if result.success: history.append(action) if planner.judge_goal_done(goal, snap): return "PASS" else: failures += 1 if failures >= max_failures: break return "FAIL"这套结构最大的好处是每个模块都可以独立替换。比如 Observer 可以换成不同的浏览器驱动实现,Planner 可以从简单的打分逻辑升级成两层规划器,Loop 里可以灵活地插入回滚逻辑。我建议先跑通这个闭环再考虑优化,因为很多做 Agent 测试的人一开始就想着上多复杂的规划算法,结果连最基础的执行循环都没跑顺。
5.2 延迟、token 预算与并发执行的取舍
把规划器接进真实测试框架之后,最先感受到的不是智能化,而是慢。一次动作决策通常要经历截图编码、无障碍树序列化、Prompt 构造、模型推理这一整套流程,单步延迟经常在 2 到 5 秒之间。一个 20 步的任务跑下来就是一两分钟,如果还要做树搜索,那就奔着十分钟去了。
我能提供的优化手段里,最有效的是上下文压缩和缓存。上下文压缩指的是不要每次都把完整无障碍树塞给模型,而是根据当前子目标,只保留与目标可能相关的子树。缓存则是指,同一个页面状态下,如果模型对某个动作已经给过高置信度决策,就不重复推理,直接复用上一次的结论。这两项加起来,能把单步 token 消耗压缩到原来的三分之一。
并发控制也是一个容易忽略的点。如果你需要在多个浏览器实例里同时跑不同的测试目标,共享的模型服务会变成瓶颈。我的做法是给每个测试任务维护独立的动作历史,但共享同一个规划模型的连接池,做到按任务分配并发额度,而不是让所有任务无限制推送请求。
5.3 观测性建设:每一次决策都要能回放
Agent 测试最容易让人抓狂的一点是“不可解释”。传统脚本失败,你可以通过日志定位到具体哪一行选择器挂了;Agent 失败,你面对的是一大段自由文本的推理过程,很难判断它当时是怎么想的。所以我从一开始就要求所有决策都落成结构化日志,格式大致是下面这样:
{ "step": 7, "goal": "完成余额支付下单", "subgoal": "选择支付方式", "snapshot_hash": "7a3f5c...", "decision": "click", "target_label": "余额支付", "target_coordinates": [420, 630], "llm_reason": "当前子目标是选择支付方式,余额支付单选项符合条件,且视觉校验通过", "outcome": "success" }你千万别小看这份日志。有一次 Agent 在某个表单页反复失败,我查了很久都没头绪,最后回放截图才发现页面上有一个非标准的悬浮层,它的关闭按钮在无障碍树里的顺序是乱的,模型每次点击都被遮挡。如果没有结构化日志和截图回放,这个问题几乎不可能快速定位。
观测性的另一个好处是可以做对比实验。同一个目标,如果我想测试加了一层候选粗筛之后效果是否更好,只需要在同样的测试基准集上跑两遍,对比每一步的决策日志和成功率就行。没有这些底座的日志,所谓“优化”就是你自己的玄学。
6. 实测踩过的坑与路径质量的及格线
6.1 高频失败模式与应对清单
跑了几百次 Agent 测试之后,我整理了一个高频问题清单,基本覆盖了路径规划在实际场景里的主要翻车点。
| 现象 | 根因 | 应对方式 |
|---|---|---|
| 同类按钮被反复点错 | 页面有多个“继续”按钮,模型只看语义没看位置 | 候选动作里引入 DOM 路径前缀,强制模型参考位置信息 |
| 点了按钮无反应 | 异步加载导致按钮还在 loading 状态 | 执行前加稳定性等待,连续三次无状态变化再判定失败 |
| 弹窗遮住目标元素 | 引导层遮挡了无障碍树里可交互的节点 | 动作执行前用截图做视觉遮挡校验,优先处理弹窗关闭动作 |
| iframe 内部元素找不到 | 无障碍树没有暴露 iframe 子树的入口 | 单独解析 iframe 文档,为其生成独立候选集 |
| 长路径执行到一半偏离目标 | 模型遗忘初始业务意图 | 每五个子目标做一次目标一致性检查,把当前状态与目标重新对齐 |
| 无限重复点击同一元素 | 模型陷入局部最优 | 限制候选中最近 N 步已执行动作,要求必须选择新的方向 |
我给每个问题都配了一个明确的修补措施,实测下来修复率最高的两个是:执行前做视觉遮挡校验,以及候选动作里显式带入 DOM 路径信息。前者解决的是“看得见但点不到”,后者解决的是“两个长得一样的控件”这种经典混淆。
6.2 评估路径规划质量的量化指标
单纯的“任务成功率”没法反映路径规划的真实质量。两条路径都能完成任务,但一条用了 25 步、烧掉了大量 token、中间还依赖了三次失败回滚;另一条只用了 9 步、全程平滑。对测试团队来说,两者价值完全不同。
我会建议用一组四维指标来做准出判断。任务成功率是第一位,没有它其他都没意义。然后是平均步数和平均 token 消耗,这两个指标直接关联执行成本和模型费用。最后是失败恢复率,也就是在路径中发生了一次错误动作之后,Agent 能自己纠正并完成任务的概率。恢复率偏低说明你的 Agent 只会“一根筋”走到底,遇到意外就崩,这是路径规划不成熟的信号。
为了持续监控这组指标,我搭建了一个小规模的基准集,里面混合了普通路径、带干扰弹窗的路径、有 iframe 的路径、以及一个容易分心点错按钮的长流程。每次改动规划逻辑,就在这组基准上跑一遍,对照四维指标决定这次改动是否值得合入。没有这个基线,你做的所有“优化”都只是感觉。
6.3 从这套系统里得到的一点做法沉淀
说几句个人体会。把多模态 Agent 引入 UI 测试后,我最大的感受是:它不会让测试工作变“少”,只会让测试工作的焦点从“维护选择器”转移到“设计规划器”。过去你在写死亡脚本,现在你在调教一个会决策的代理;过去你担心脚本脆弱,现在你担心 Agent 误判。问题不同了,但都需要专门的人去盯。
有一个小技巧我一直保留着:每次跑任务之前,我会把用户历史操作日志里的高频路径喂给 Agent 作为先验。它不需要严格遵循,但可以作为候选排序时的加分项。比如日志显示大多数用户进设置页时都会先点右上角头像,那 Agent 在候选集里就会把头像路径的优先级稍微调高一点。这样做的好处是,路径规划不完全是“白手起家”,它有了真实用户行为的经验背书。
如果你也想把这套思路落地到你自己的项目里,我的建议很直白:先做最小闭环,再上混合规划策略,然后把观测性底座的日志从第一步就建起来。别急着追求“全智能”,把回滚机制做扎实,比让 Agent 一键点到底重要得多。