做LLM自动化测试也有一段时间了,这个系列写下来,发现大多数团队卡在同一个地方:LLM到底怎么“看懂”被测界面?传统的Selector、XPath模型用不惯,直接丢一张截图过去又经常定位不准,试了几次就放弃了。系列第01篇聊过整体架构选型,这篇把最核心的“统一技术底座”掰开讲清楚——视觉定位、DOM语义化、Computer Use,这三板斧才是LLM自动化测试能不能在团队里落地的关键。先把结论说透:模型聪明不聪明不是瓶颈,这三块底座稳不稳才是。下文全部是我实际跑过的路径和踩过的坑,按可以直接复现的标准写。
1. 传统定位方式的痛点:LLM为什么需要一个新底座
1.1 老一套玩法为什么让人心力交瘁
做过UI自动化的人都知道,传统定位方式的核心是“找特征”。找一个稳定ID,找不到就拼XPath,XPath太脆就上CSS Selector,再不行就加一堆># 以视觉定位服务为例,传入截图返回候选区域 def vision_locate(screenshot_path: str, instruction: str): with open(screenshot_path, "rb") as f: response = vision_service.detect( image=f.read(), text=instruction, top_k=3, # 返回最可能的3个候选区域 need_labels=True, # 让模型顺带输出每个候选区域的标签 ) # 返回示例: # [ # {"box": [210, 340, 90, 40], "label": "登录按钮", "confidence": 0.97}, # {"box": [210, 340, 90, 40], "label": "登录按钮(悬停态)", "confidence": 0.86}, # ] return response # 拿到坐标后,交给执行层做真实点击 def click_at_point(page, box): x, y, w, h = box center_x = x + w / 2 center_y = y + h / 2 page.mouse.click(center_x, center_y)
关键参数就三个:top_k候选数量、confidence置信度阈值、box_format坐标格式。建议top_k设成3而不是1,因为模型偶尔会有多个相似目标(比如两个“登录”入口),多候选能配合后续决策逻辑做兜底。置信度阈值我一般设0.85,低于这个值宁可放弃本次识别,也不要硬点,点错一次的代价比等一轮重试大得多。
2.3 视觉定位的三个实战雷区
第一个雷区是分辨率适配。截图如果直接用视口尺寸(比如1920×1080),而视觉模型内部会把图缩放到固定尺寸,返回的坐标可能和原始截图对不上。解决办法很简单:请求视觉服务时带上你的视口宽高,让服务端返回的坐标统一缩放回原尺寸,或者你在客户端自己按比例做一次坐标映射。我踩过这个坑,坐标偏移一整块都点不中,排查了半天发现是缩放没还原。
第二个雷区是动态遮挡。页面有悬浮层、弹窗、动态动画时,截图里的目标元素可能被遮挡一半。视觉模型有时候会幻觉出完整目标,给一个错误的框。应对办法:截图前先做一次“页面静止”处理,关闭所有动画和过渡效果,必要时强制等requestAnimationFrame轮询结束再截图。
第三个雷区是成本控制。多模态视觉识别单次调用比纯文本贵一个量级,而且延迟通常在2到5秒。别傻傻地每个元素都用视觉定位,那样一次用例跑下来光视觉调用就几十次,时间和钱都受不了。我的做法是先用下一章的DOM语义化去定位,只有语义化结果置信度低时才走视觉兜底,这样视觉调用占比能控制在10%以内。
3. DOM语义化实操:把页面翻译成LLM的语言
3.1 为什么原始HTML不能直接喂给模型
很多人一开始图省事,直接把page.content()拿到的HTML丢给LLM分析。我强烈不建议这么干。一个中型页面的原始HTML轻松几万甚至几十万个字符,直接撞爆上下文窗口先不说,即使塞得下,模型也会被里面海量的布局噪声带偏。
原始HTML里包含大量与用户意图无关的东西:布局用的div嵌套、样式容器、脚本标签、注释、隐藏节点。这些噪声对测试来说毫无价值,却会稀释模型对真正交互元素的注意力。打个比方,你要吩咐一个人去按电梯按钮,结果给了一整栋楼的建筑图纸,上面标了每个房间的下水管道位置。这不是帮忙,是添乱。
DOM语义化要做的事就四个字:降维提炼。从“布局树”里提取“意图树”,只保留和测试动作相关的信息:可点击的按钮、可输入的框、可选的选项、可跳转的链接、弹窗、表单控件。这个步骤做完,一个几万字符的页面能被压缩成几百到一两千字符的轻量描述,并且全是有效信息。
3.2 用Accessibility Tree构建“交互元素清单”
具体怎么做语义化?优先用浏览器自带的可访问性树(Accessibility Tree)。这个方案的出发点很简单:浏览器已经帮我们把DOM解析成了语义角色,按钮就是按钮,输入框就是输入框,弹窗就是弹窗。我们要做的只是把这些角色按LLM友好的格式重新组织一下。
以Playwright为例,获取语义快照非常直接:
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() page.goto("https://your-target-page.com") # 方案A:页面自带的accessibility tree快照 snapshot = page.accessibility.snapshot() print(json.dumps(snapshot, ensure_ascii=False, indent=2))拿到Accessibility快照之后,还要做一轮“交互元素过滤”:只保留满足以下任一条件的节点——role为按钮、链接、输入框、下拉框、单选框、复选框、选项卡、对话框、菜单项;或者节点自带onclick、tabindex、href等可交互属性。过滤完之后再按层级关系压缩成缩进结构。
我习惯的最终输出格式类似这样:
[页面] 登录页 [输入框] 用户名 (placeholder: 请输入手机号) [输入框] 密码 (placeholder: 请输入密码) [复选框] 记住我 [链接] 忘记密码? [按钮] 登录 (primary) [文字] 注册即同意《用户协议》这种格式就是给LLM的“菜单”。模型拿到菜单以后,只需要在给定选项里选,而不是凭空创造。后者正是幻觉的主要来源。
实测下来,同一页面原始HTML大约45KB,语义化压缩后只有800多字符,Token消耗直接降了一个量级,模型准确率反而高了。因为它不用再顶着噪声找重点。
3.3 DOM语义化与传统选择器定位的直观对比
为了让你看得更清楚,我把四种定位方式的差异放在一张表里:
| 定位方式 | 输入 | 对前端改版的抗性 | Token成本 | 维护成本 | 典型场景 |
|---|---|---|---|---|---|
| ID定位 | 唯一标识 | 极差 | 无 | 高 | 老系统、结构稳定 |
| XPath/CSS | 路径表达式 | 极差 | 无 | 高 | 传统自动化脚本 |
| DOM语义化 | 可访问性树/角色描述 | 强 | 低 | 低 | LLM测试的主力定位 |
| 视觉定位 | 截图+指令 | 极强 | 高 | 极低 | 复杂渲染、兜底方案 |
用同一个登录页面做实验,前端改版把按钮从btn-login改成submit-btn-primary,ID和XPath全部失效,DOM语义化输出的还是“按钮:登录”,完全不受影响。这就是LLM测试和传统测试的本质区别——模型理解的是语义,不是实现细节。
顺带说一句,很多团队问要不要给DOM加>def run_test_agent(task: str, page, max_steps=10): history = [] for step in range(max_steps): # 1. 观察:获取语义快照,必要时补充截图 snapshot = get_semantic_snapshot(page) screenshot = page.screenshot() # 2. 理解 + 决策:让LLM输出下一步动作 action = llm.plan( task=task, snapshot=snapshot, # DOM语义化底座 screenshot=screenshot, # 视觉定位底座(兜底用) history=history, ) # action示例:{"type": "click", "target": "登录按钮", "reason": "点击登录提交表单"} # 3. 执行:动作翻译成浏览器操作 result = execute_action(page, action, snapshot) # 4. 反馈:观察新状态并记录 history.append({"action": action, "result": result}) # 5. 判断:任务是否完成 if is_task_done(snapshot, task): return "PASS", history return "TIMEOUT", history
这里最关键的一步是execute_action。它不能直接拿LLM的文本去执行,得先做一层“动作映射”:把LLM输出的目标描述(比如“登录按钮”)在语义快照里匹配到具体元素引用,再调用page.click(element)或者page.locator(...).click()。这样即使同页面有两个“登录”字样(一个在导航栏,一个在表单底部),也能靠上下文语义正确区分。
我在实验项目里跑通过一个最常见的登录流程:输入用户名、输入密码、点击登录、断言URL跳转。用这套Agent循环,中间不需要写任何固定选择器。把页面语义化做扎实之后,换两套不同的前端UI都能跑过,这就是底座的威力。
4.3 安全与稳定:Computer Use落地必须加的“保险丝”
Computer Use最让人担心的是失控。模型下一个错误指令,脚本可能点了删除按钮、退出登录,甚至批量操作。所以落地时一定要加四道保险:
一是动作白名单。按项目风险等级配置允许执行的动作类型,高风险操作(如删除、清空、提交订单)默认禁止,除非测试任务里明确包含该动作且经过人工确认。
二是确认回滚机制。执行任何破坏性动作之前,Agent必须先把“准备做某事”写入日志,并生成操作前状态快照。一旦后续步骤发现状态异常,立即回滚到上一个稳定状态。
三是步骤上限。所有Agent任务必须设置最大步数,我一般给10到15步。超过上限强制失败并输出完整历史,方便排查是任务描述不清还是Agent绕圈。
四是人工审核模式。系统上线初期,每跑的N条用例随机抽1条完整回放操作序列,让人工确认Agent的决策链路是否合理。等你积累了足够的回放数据,再逐步降低抽检比例。
这些“保险丝”看着繁琐,但它们是Computer Use能走进稳定测试环境的前提。没有约束的Agent,拿来做Demo可以,进CI门禁迟早出事。
5. 三套方案怎么选:能力对比与落地路径
5.1 一张表看懂三种能力的适用边界
很多朋友问我三者到底怎么选。先给一张我常用的对比表,你对着场景判断就行:
| 能力 | 输入形态 | 抗UI变化 | 定位精度 | 成本 | 延迟 | 适用场景 |
|---|---|---|---|---|---|---|
| DOM语义化 | 可访问性树文本 | 强 | 高(明确元素) | 低 | 低 | 常规表单、列表、CRUD页面 |
| 视觉定位 | 截图 | 极强 | 中(坐标框) | 高 | 高 | Canvas、WebGL、自定义渲染、无DOM场景 |
| Computer Use | 决策指令 | 强 | 由前两者决定 | 中 | 中 | 端到端流程、多步骤任务、探索性测试 |
注意看“定位精度”这一行。DOM语义化虽然抗变化强,但遇到一个元素动态生成的不同实例(比如一排相同结构的卡片),它只能给出“第3个卡片”这样的位置描述;视觉定位能给精确坐标,但坐标本身不等于稳定,UI一改就得重新识别。所以精度维度上二者各有取舍。
成本维度上,DOM语义化基本可以忽略不计,一个快照加上过滤就是毫秒级;视觉定位则要想清楚调用频率。我见过一些团队把所有元素识别都走视觉,一个月账单跑下来够买三年内部测试机,这种做法不建议盲目复制。
5.2 场景化选型:什么时候该优先用哪套
选型不用搞复杂,记住三条经验法则:
第一,能语义化,别上视觉。90%的Web页面,Accessibility Tree都能提供足够的交互元素信息。先走语义化,命中不了再降级。
第二,无DOM场景,直接上视觉。Electron里的Canvas画布、WebGL报表、视频播放器内部控件、图像编辑器的画布区域,这些组件在DOM树里就是一块空白,不截图连“目标长什么样”都不知道。这种场景视觉定位几乎是唯一选择。
第三,复杂流程任务,用Computer Use统一调度。单个元素的定位选语义化或视觉,但一旦用例有多个步骤、有分支判断、有异常兜底,就需要Computer Use这一层来做“动作编排”。它不关心底层是哪个定位器给的坐标。
我还给团队画过一条极简决策链:页面是否有可交互DOM节点?有,用DOM语义化;没有,用视觉定位;需要在多个步骤间动态决策?挂Computer Use。三个问题走完,方案就定了。
5.3 团队落地三步走
第一步,搭底座做验证。先只实现DOM语义化快照,跑通一个最简单的登录用例,让团队熟悉LLM测试的基本工作流。这一步不用上视觉和Computer Use,把地基打牢。
第二步,补视觉兜底。挑一条语义化搞不定的用例,比如canvas绘制的流程图上点某个节点,接入视觉定位服务,让用例跑通。这一步的产出是建立降级策略:语义化失败时自动切换视觉。
第三步,上Computer Use做编排。把单条用例改写成任务描述式,验证Agent能自主完成多步骤操作,再逐步把用例池往里迁。同时把日志、回放、审计这些配套功能完善起来。
三步走完,LLM测试底座就算真正落地了。整个过程不建议跳步,尤其不要一上来就全量上Computer Use——没有语义化快照托底,Agent的容错空间很小。
6. 高频踩坑记录:定位不准背后的真实原因
6.1 坐标点到了,却总是点不中
这是视觉定位最常见的坑,九成原因是坐标系不一致。截图用的视口尺寸是1920×1080,视觉模型服务内部把图缩放到1024,返回的坐标基于缩放后的坐标系,直接拿去做mouse.click自然点偏。
解决方式是在调用视觉服务前约定好坐标基准。我个人习惯是让视觉服务返回“归一化坐标”(0到1区间),客户端再乘回实际视口尺寸。归一化坐标的好处是和分辨率完全解耦,无论浏览器窗口怎么缩放,坐标都不会偏。
# 归一化坐标转换示例 norm_x, norm_y = 0.32, 0.55 viewport_width = page.viewport_size["width"] viewport_height = page.viewport_size["height"] real_x = int(norm_x * viewport_width) real_y = int(norm_y * viewport_height)另外注意浏览器缩放比。如果页面整体缩放不是100%,截图和实际点击的物理坐标也有偏差。跑用例前先统一page.set_viewport_size和DPI设置,把变量控制住。
6.2 模型选错元素:幻觉怎么破
这是LLM测试特有的问题。模型明明知道要点击“登录”,结果在候选列表里选中了“注册”。传统框架不会出现这种事,因为选择器写死了。
我的排查经验,核心是缩小模型的“决策面”。做法包括:给语义快照里的每个元素加编号,让模型输出编号而不是自由文本;每个候选元素附带简短描述,减少歧义;给任务描述加上“必须从候选列表中选择”的硬性约束。
还有一个容易被忽略的因素:历史上下文污染。Agent跑过多步之后,历史记录里堆积了大量界面对话,模型容易被早先的旧状态误导,误以为当前元素还存在于页面上。解决办法是只保留最近2到3轮的状态快照,以及在上一步动作完成后的新快照上重新决策。
6.3 Token成本和响应延迟怎么控
LLM测试跑一次用例,Token消耗比普通API调用大很多,响应延迟也高。控制的关键是“少喂、快断”。
少喂:语义快照只保留交互元素,背景文字、装饰性段落、非交互区域一律过滤。截图只在视觉兜底时使用,不为每个动作都截图。历史记录做滚动窗口,超过3轮的旧记录及时截断。
快断:设置动作决策超时(我通常给8到10秒),超过直接降级。降级链是:语义化快照 → 候选元素列表 → 视觉定位 → 人工确认失败。每降一级,响应时间换个数量级。
另外强烈建议做Token预算巡检。一条用例跑完,统计每个环节的Token消耗,你会惊讶地发现50%的Token浪费在重复获取未变化的页面上。优化思路是加“页面状态指纹”:如果语义快照的哈希值没变化,就直接复用上一次的决策结果,不再调模型。
6.4 动态内容下的等待与重试策略
LLM测试和普通自动化一样,逃不开动态加载问题。页面异步渲染、接口返回慢、加载动画遮挡,都会导致Agent在错误的状态下做决策。
我的等待策略用了“三静”原则:网络静、动画静、元素静。网络静是指监听networkidle事件,确保没有在途请求;动画静是指关闭过渡样式或等待动画完成;元素静是指目标元素在连续两次轮询中位置不变。三个条件都满足,才把当前状态喂给模型。
重试策略上,我不建议对单动作无限重试。标准做法是:每个动作最多重试2次,每次重试前先刷新语义快照,确认页面状态是否真的变了。如果快照没变,说明问题不在等待,而在模型决策本身,这时候继续重试就是白烧Token,应该立即把用例状态置为“待人工排查”。
这套等待和重试策略在稳定性上帮了大忙。加了“三静”约束之后,Agent误判率大概下降了六成,主要原因是模型拿到的不再是“半成品”页面,而是一个稳定可理解的界面状态。
我个人在实际操作里的体会是,LLM自动化测试的难点从来不在模型选型,而在怎么把界面信息稳定地抽象出来喂给模型。视觉定位、DOM语义化、Computer Use这三个底座,本质上就是在做这件抽象工作。先把它们打磨稳,再谈聪明的Agent,否则一切上层能力都是空中楼阁。最后分享一个我常用的组合拳:DOM语义化先快速框定候选集,视觉定位对候选集做精确认证,Computer Use统一执行动作,三套配合起来,准确性、成本、速度才能同时兼顾。