1. 多模态 Agent 做 UI 测试,到底难在哪
UI 测试这件事,做过的人都懂:写用例不难,难的是维护。一个按钮挪了位置、一个弹窗改了文案、一个加载动画多转了半秒,传统基于选择器的脚本就可能集体飘红。而多模态 Agent 的出现,让很多人看到了另一种可能——不再死盯 DOM 结构,而是像人一样"看"界面、"理解"意图、"决定"下一步点哪里。
但问题也随之而来:Agent 能看懂界面,不代表它能规划出一条靠谱的测试路径。所谓路径规划,说白了就是"从当前页面出发,经过哪些操作,才能覆盖到目标场景"。这件事对人是常识,对 Agent 却是一道综合题——它要同时处理视觉信息、文本语义、控件层级、状态变化,还要在有限步数内做出取舍。
我最近在几个模拟项目里反复折腾这套东西,踩了不少坑,也总结出一些还算能落地的思路。这篇就围绕"多模态 Agent 如何规划 UI 测试路径"这个核心,把整体设计、关键细节、实操过程和排查经验一次讲透。适合正在做自动化测试、想引入 Agent 能力、或者单纯对多模态落地感兴趣的朋友。不管你是刚接触还是已经写过几版 Demo,应该都能从里面找到能直接抄的部分。
先说清楚一个前提:这里的"多模态"指的是 Agent 同时接收截图(视觉)、控件树(结构)、**文本指令(语义)**这几类输入,而不是只靠一种模态硬扛。路径规划则是 Agent 在每一步决定"下一个动作是什么"的决策过程。两者结合,才是标题里说的那件事。
2. 整体设计思路:为什么不能只靠一种模态
2.1 纯视觉方案的天花板在哪
一开始我也想过,既然多模态模型看图能力这么强,那直接喂截图让它输出点击坐标不就行了?实测下来,短流程还行,一旦页面复杂就崩。原因有几个:
- 坐标漂移:模型给的坐标是像素级的,分辨率一变、缩放一改,全废。
- 元素歧义:界面上三个长得差不多的"确定"按钮,光看图分不清哪个是哪个。
- 状态盲区:截图是静态的,模型看不到"这个按钮当前是禁用态"这种属性,容易点了个寂寞。
所以纯视觉方案适合做兜底和校验,不适合做主力规划。
2.2 纯控件树方案又缺了什么
那退回传统控件树呢?结构清晰、定位精准,但它的毛病同样明显:
- 语义缺失:控件树告诉你这是个
Button,但不告诉你它叫"提交订单",模型没法判断该不该点。 - 动态渲染:很多现代前端框架的控件树是懒加载的,截图里有的元素,树里可能还没生成。
- 跨端不一致:同一套业务,移动端和桌面端的控件树结构可能完全不同,脚本没法复用。
控件树擅长"精确执行",不擅长"理解意图"。
2.3 多模态融合的规划架构
我最终采用的是一种三层融合的架构,用一句话概括就是:视觉负责"看到什么",控件树负责"能操作什么",语义负责"该做什么"。
具体分工是这样的:
| 模态 | 输入内容 | 主要职责 | 输出形式 |
|---|---|---|---|
| 视觉 | 页面截图 | 识别布局、发现异常、判断状态 | 区域描述、元素候选 |
| 结构 | 控件树/无障碍树 | 精确定位、获取属性 | 可操作元素列表 |
| 语义 | 测试目标文本 | 理解意图、拆解步骤 | 动作序列草案 |
规划流程走的是"先粗后细、边执行边修正"的路子。Agent 先根据测试目标生成一个粗粒度的动作序列(比如"打开首页 → 进入搜索 → 输入关键词 → 提交 → 校验结果"),然后每执行一步,就用当前截图和控件树重新评估下一步,动态调整。
这里有个关键设计:规划不是一次性的,而是滚动进行的。很多人一开始想做一个"完整规划器",把整条路径算出来再执行,结果页面一变全盘皆输。滚动规划虽然看起来"短视",但容错率高得多。
2.4 为什么选择滚动规划而不是全局规划
我拿两个模拟项目做过对比。全局规划在流程固定的场景下确实快,一次算完直接跑,但遇到下面这些情况就歇菜:
- 页面有随机弹窗(比如活动提示)
- 加载时间不确定导致元素出现顺序变化
- 某些步骤的结果依赖上一步的实际返回值
滚动规划虽然每步都要调一次模型,成本高一些,但它的鲁棒性是全局规划比不了的。而且实际跑下来,滚动规划的步数往往比全局规划还少——因为它能及时止损,不会在错误路径上一条道走到黑。
3. 核心细节拆解:路径规划的四个关键环节
3.1 状态表示:把页面变成 Agent 能"读懂"的东西
Agent 要规划路径,首先得知道"我现在在哪"。这一步的核心是把当前页面抽象成一个状态表示。我的做法是生成一个结构化的状态描述,包含三部分:
- 可交互元素列表:从控件树里提取所有可点击、可输入、可滑动的元素,每个元素带上文本、类型、位置、是否可用。
- 视觉摘要:用多模态模型对截图做一次描述,重点标注"有哪些区域""有没有异常提示""当前焦点在哪"。
- 历史动作栈:记录已经执行过的动作,避免重复点击和死循环。
这三部分拼成一个 JSON,作为 Agent 的输入。举个简化的例子:
{ "interactive_elements": [ {"id": "e1", "text": "搜索框", "type": "input", "enabled": true}, {"id": "e2", "text": "搜索", "type": "button", "enabled": true}, {"id": "e3", "text": "登录", "type": "link", "enabled": false} ], "visual_summary": "页面顶部有搜索栏,中部是商品列表,底部有分页控件", "action_history": ["open_home", "click_search_box"] }注意:状态表示不要做得太细,否则 token 消耗爆炸;也不要太粗,否则 Agent 判断不了。我的经验是可交互元素控制在 30 个以内,超了就做一次筛选,只保留和当前目标相关的。
3.2 动作空间定义:Agent 能做什么
动作空间就是 Agent 的"工具箱"。定义得太小,很多操作做不了;定义得太大,模型容易选错。我最终收敛到这么几类原子动作:
click(element_id):点击某个元素input(element_id, text):在输入框里填内容scroll(direction, amount):滚动页面wait(seconds):等待,用于处理加载assert(condition):断言,用于校验back():返回上一页done():结束当前流程
每个动作都要求 Agent 输出结构化的参数,而不是自然语言。这一点很重要——自然语言描述动作,执行层还得再解析一遍,容易出错。结构化输出直接就能映射到执行代码。
3.3 目标拆解:把"测试登录"变成可执行步骤
测试目标通常是自然语言,比如"验证用户登录失败时的提示"。Agent 需要把它拆成可执行的子目标。我的做法是让模型先输出一个子目标列表,每个子目标对应一个可验证的状态。
以"验证登录失败提示"为例,拆解结果可能是:
- 打开登录页
- 输入错误的用户名和密码
- 点击登录按钮
- 校验页面出现错误提示文案
每个子目标在执行时,Agent 会判断"当前是否已达成",达成了就进入下一个,没达成继续规划动作。这种子目标驱动的方式,比直接让模型输出一长串动作要稳得多。
3.4 决策与回溯:走错了怎么退回来
再好的规划也会走错。比如 Agent 以为点某个按钮会弹出登录框,结果弹的是注册框。这时候就需要回溯机制。
我的实现是给每个子目标维护一个"尝试栈"。如果连续 N 步(我设的是 3 步)都没有推进子目标,就触发回溯:撤销最近的动作,换一个候选元素重试。如果所有候选都试过了还不行,就标记这个子目标失败,跳到下一个,最后统一报告。
实操心得:回溯的粒度不要太细。我一开始做成"每步都可撤销",结果状态管理极其复杂。后来改成"以子目标为单位回溯",代码量少了一半,效果反而更好。
4. 实操过程:从零搭一个能跑的规划器
4.1 环境与依赖准备
先说环境。我用的是 Python 3.10,核心依赖就几个:
pip install playwright pillow openai pydanticplaywright:负责浏览器控制和截图、控件树获取pillow:截图预处理,比如裁剪、缩放openai:调用多模态模型(换成任何支持视觉输入的模型都行)pydantic:定义动作和状态的数据结构,保证类型安全
浏览器我用的是 Chromium,因为它的无障碍树(accessibility tree)比较完整,提取控件信息方便。
4.2 页面状态采集:截图 + 控件树
采集这一步是整个流程的地基。我封装了一个capture_state函数,每次调用返回当前页面的完整状态:
async def capture_state(page): screenshot = await page.screenshot(full_page=False) # 裁剪掉无关区域,减少 token 消耗 img = Image.open(io.BytesIO(screenshot)) img = img.resize((img.width // 2, img.height // 2)) # 提取可交互元素 elements = await page.evaluate("""() => { const nodes = document.querySelectorAll('button, input, a, [role=button]'); return Array.from(nodes).slice(0, 30).map((el, i) => ({ id: 'e' + i, text: el.innerText || el.placeholder || '', type: el.tagName.toLowerCase(), enabled: !el.disabled, rect: el.getBoundingClientRect().toJSON() })); }""") return {"screenshot": img, "elements": elements}这里有几个细节值得说:
- 截图缩放:原图直接喂模型,token 消耗大且没必要。缩到一半,模型照样能看清布局。
- 元素数量限制:
slice(0, 30)是硬性截断,防止页面元素过多导致 prompt 超长。 - rect 保留:虽然规划主要靠 element_id,但保留位置信息有助于后续做视觉校验。
4.3 规划 Prompt 的设计
Prompt 是 Agent 的"大脑说明书"。我反复改了七八版,最终的结构是这样的:
你是一个 UI 测试 Agent。当前测试目标:{goal} 已完成的子目标:{completed} 当前子目标:{current_subgoal} 页面可交互元素:{elements} 页面视觉描述:{visual_summary} 已执行动作:{history} 请输出下一个动作,格式为 JSON: {"action": "click", "element_id": "e3", "reason": "..."}关键点在于:
- 明确当前子目标:不要让模型自己记,直接告诉它现在要干什么。
- 给出历史动作:防止重复点击。
- 要求输出 reason:这个字段不参与执行,但排查问题时极其有用,能看到模型"怎么想的"。
- 强制 JSON 格式:配合 pydantic 做校验,格式不对就重试。
4.4 执行循环与异常处理
主循环的逻辑其实很简单:
async def run_test(goal, max_steps=30): subgoals = await plan_subgoals(goal) for sg in subgoals: steps = 0 while not await check_subgoal(sg) and steps < 10: state = await capture_state(page) action = await decide_action(sg, state) try: await execute(action) except Exception as e: log_error(action, e) await handle_failure(action) steps += 1这里面check_subgoal是校验函数,可以用断言、也可以用模型判断。handle_failure负责回溯和重试。
踩过的坑:
max_steps一定要设。我有一次忘了设上限,Agent 在一个死循环里点了 200 多次,直接把测试环境搞崩了。现在每个子目标最多 10 步,整个流程最多 30 步,超了就报错退出。
4.5 结果校验与报告生成
跑完之后要出报告。我的报告包含三部分:
- 子目标达成情况:哪些过了,哪些没过
- 动作轨迹:每一步的动作、元素、reason
- 失败截图:失败时的页面截图,方便人工复核
报告用 Markdown 生成,直接贴到 CI 的产物里。这样即使 Agent 判断错了,人工也能快速定位。
5. 常见问题与排查技巧实录
5.1 模型选错元素怎么办
这是最高频的问题。Agent 明明该点"登录",结果点了旁边的"注册"。排查下来通常是两个原因:
- 元素文本太相似:两个按钮文案接近,模型分不清
- 视觉摘要没提到关键信息:模型只看到控件树,不知道页面上有弹窗遮挡
解决办法:在状态表示里加入元素的空间关系,比如"e3 位于 e2 右侧 20px"。模型对位置信息很敏感,加上之后准确率明显提升。
5.2 页面加载慢导致规划失败
Agent 规划时页面还没加载完,元素列表是空的。这种情况我加了一个等待策略:截图后先检查可交互元素数量,如果少于 3 个,就等 1 秒重新采集,最多等 5 次。
5.3 死循环与重复动作
Agent 反复点同一个按钮,因为每次点击后页面状态没变,它以为没点成功。解决办法是在 prompt 里明确列出"已执行动作",并加一条规则:如果某个动作执行后状态无变化,不要重复执行。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 点错元素 | 元素文本相似 | 查看 reason 字段 | 加入空间关系描述 |
| 元素列表为空 | 页面未加载完 | 检查采集时机 | 加重试等待 |
| 死循环 | 状态无变化 | 看动作历史 | 加去重规则 |
| 子目标卡住 | 目标拆解太粗 | 检查子目标定义 | 拆得更细 |
| token 超限 | 元素太多 | 看 prompt 长度 | 截断+筛选 |
5.5 独家避坑技巧
最后分享几个我踩坑踩出来的经验:
- reason 字段一定要留:它不参与执行,但排查问题时能省你一半时间。
- 子目标别超过 5 个:拆得太细,Agent 容易在中间迷路;太粗又执行不了。5 个左右是个甜点。
- 截图别用全页:全页截图又大又糊,用视口截图就够了。
- 先跑通再优化:我一开始就想做完美的架构,结果两周没跑通一条用例。后来先用最糙的方式跑通,再逐步优化,效率高多了。
这套东西我前后迭代了大概两个月,从最初的"点啥啥不对"到现在能稳定跑通大部分常规流程。它肯定不是银弹,复杂交互和强状态依赖的场景还是得靠人工兜底。但对于那些"改个文案就挂一片"的回归测试,多模态 Agent 确实能省下大量维护成本。后续我打算把动作空间再扩展一下,加上拖拽和文件上传,覆盖更多场景。