干等 12.9 秒才等到 Agent 点下一个按钮,这种事我经历过太多次。当时我负责一个基于大模型的 GUI Agent 项目,跑最简单的“打开设置页面并切换开关”任务,日志里一大半时间都耗在“定位目标元素”这一步。截图后把整张页面丢给多模态模型去读,然后等模型输出坐标,这一来一回轻松吃掉十秒以上。后来我把定位链路重构了一遍,单步定位延迟从 12.9 秒压到了 0.3 秒,整体任务快了一个数量级。这篇文章就把这 40 倍提速的完整过程拆开讲清楚,包括思路、选型、代码结构和我一路上踩过的坑。
做 GUI Agent 的人都知道,Agent 的“眼睛”和“手”通常分开:眼睛负责理解屏幕内容,手负责执行点击、输入、拖拽。定位速度就是眼睛理解屏幕并决定“点哪里”的速度。而这一步恰恰是很多 Agent 卡顿的根源,不是大模型不够聪明,而是你把最贵的模型用在了最不该用的环节。后面所有优化,其实都围绕一个原则:能用结构化数据解决的问题,绝不让大模型去“看”。
1. 先搞清楚 12.9 秒到底花在哪:定位链路拆解
1.1 一个典型定位动作的完整路径
在重构前,我的 GUI Agent 执行一次点击定位,大概要经历四步:
- 截取当前屏幕或页面截图;
- 缩放图片到模型支持的尺寸;
- 把图片交给多模态大模型,附带提示词“请告诉我目标按钮的中心坐标”;
- 等待模型输出 JSON,解析出坐标,再移动鼠标点击。
每一步都是毫秒级积累,但第三步是大头。以当时用的云端多模态接口为例,图片上传和首 token 返回的延迟经常在 8 到 11 秒之间。如果模型输出还要走一次思维链,时间更长。12.9 秒就是这么堆出来的。
对比很残酷:一个纯人工操作只需要 0.5 秒的点击动作,Agent 却要花 12 秒。用户体验上的观感就是“这玩意儿是不是卡死了”。更麻烦的是,这个延迟会随着任务步骤数线性累积。一个 20 步的任务,光定位就吃掉 258 秒,这种任务根本没法交付给真实用户。
1.2 12.9 秒的账单:逐项时间分解
我在优化前给定位链路加了埋点,用日志把每一步耗时打印出来。这是一份非常有参考价值的账单:
| 环节 | 延迟(秒) | 占比 | 说明 |
|---|---|---|---|
| 屏幕截图 | 0.2 | 1.5% | 截图本身很快,瓶颈不在这 |
| 图片压缩与预处理 | 0.3 | 2.3% | 缩放、格式转换、base64 编码 |
| 多模态模型调用 | 11.2 | 86.8% | 图片上传、排队、首 token 时间 |
| 坐标解析与动作执行 | 1.2 | 9.3% | 模型输出不稳定时解析失败要重试 |
| 合计 | 12.9 | 100% | 单次定位动作完整延迟 |
看到这个账单,结论非常清楚:要提速,就必须干掉“大模型看图”这个环节。注意,我不是说要放弃视觉理解能力,而是要把视觉理解从主路径挪到兜底路径。这也是整个优化方案的起点。
1.3 第一个优化思路:别让大模型看图说话
当时团队里有过一轮讨论,有人提出来“换一个更快的小模型”,毕竟多模态小模型的延迟能做到两秒以内。但我算了一笔账:哪怕换小模型,单步延迟也就从 12.9 秒降到 2 秒左右,离 0.3 秒还是有数量级差距,而且小模型在复杂界面上的误判率会明显上升,定位不准后面还要重试,实际收益更小。
真正该做的,是让 Agent 不要“看界面”,而是“读界面”。浏览器里的每一个可交互元素,本来就带着角色、名称、坐标、状态这些结构化信息,浏览器内核早就帮我们算好了。问题只在于——你的 Agent 有没有去读它。这个思路延伸到非浏览器场景也一样:Windows 桌面有 UIA(UI Automation),iOS 有 accessibility 框架,macOS 有 AX API,它们都是“界面元素的数据库”。用好这些结构化接口,才是从根上解决延迟问题的正路。
2. 方案选型:为什么用“可访问性树 + 轻量匹配”而不是继续堆模型
2.1 可访问性树(A11y Tree)是怎么回事
可访问性树,英文 Accessiblity Tree,是浏览器为辅助技术(屏幕阅读器、盲人导航工具)维护的一棵界面结构树。它里面每一个节点都代表一个界面元素,记录了三类关键信息:
- 角色(role):这个元素是按钮、链接、输入框还是复选框;
- 名称(name):元素的文本标签,比如“保存”“取消”“用户名输入框”;
- 边界框(bounds):元素在视口里的坐标和尺寸。
在 Chrome 里,通过 DevTools Protocol 可以拿到完整树。很多朋友可能不知道,这个树其实也是“无头浏览器操作界面”的天然结构化数据源。它相当于一份“界面地图”:每个控件的语义、位置、可用状态都写得明明白白。
拿到这棵树之后,定位目标元素就变成了一个数据库查询问题:给定一个目标描述(比如“名为保存的按钮”),在树里找到 role=button 且 name=保存 的节点,取出它的中心坐标,让鼠标点过去。整个过程不涉及一张图片,不需要大模型推理,耗时自然就从秒级降到了毫秒级。
2.2 从“截图认知”转向“结构化检索”的取舍
有朋友可能会质疑:你这样做,不等于把 GUI Agent 变成了老式 UI 自动化脚本吗?多模态模型的通用性去哪了?
我的回答是:结构化检索为主,视觉模型兜底,这个组合的通用性并不差,而且工程上反而更稳。GUI Agent 的目标不是看图说话,而是完成用户任务。用户在自然语言里给出的指令,绝大多数最终可以映射到“某个名称的控件”或“某种类型的控件”。而 A11y 树恰好提供了名称和类型这两个字段。
举个具体例子。用户说“删除第一行的那条记录”,Agent 需要理解什么是“第一行记录”。此时思维链模型先确定目标按钮的语义描述:它应该是当前表格行里,role=button 且 text 包含“删除”的那个节点。然后轻量匹配器在树里检索,直接拿到坐标。这个过程中,大模型还是参与了决策,只是它的输入从整张截图变成了精简后的“候选列表”——上下文变小了,推理也快得多。
| 维度 | 纯视觉方案(原方案) | A11y + 轻量匹配(新方案) |
|---|---|---|
| 延迟 | 8~12 秒 | 0.1~0.5 秒 |
| 坐标精度 | 凭模型“感觉”,不稳定 | 浏览器给出精确 bounds |
| 动态列表处理 | 每次都要重新识别 | 每次刷新树,实时获取 |
| 依赖端 | 显卡/云 API | 本地浏览器进程,几乎零成本 |
| 弱视觉能力 | 需要大模型能力很强 | 只需要匹配算法正确即可 |
2.3 适用边界:哪些场景不能全靠 A11y
A11y 树不是万能药。我总结了三类场景,必须保留视觉兜底:
- Canvas 渲染的界面:很多在线绘图工具、游戏大厅、视频剪辑面板,所有内容都在一张 canvas 上,A11y 树里可能只有一个空节点;
- 桌面端非标准框架:比如某些基于 Electron 但关闭了辅助功能支持的旧应用,A11y 树信息缺失严重;
- 视频/直播类软件:画面区域本来就是动态像素,没有控件结构可言。
在这些场景下,我仍然会走“截图 + 轻量目标检测模型/OCR + 区域匹配”的兜底方案。但注意,兜底频率要控制住。我设定的规则是:A11y 树里能找到的,绝不走视觉;只有找不到时才降级。优化后 0.3 秒的平均延迟,就是在大多数页面都能命中前一条路径的前提下实现的。
3. 实操:把 12.9 秒压到 0.3 秒的四个关键动作
3.1 动作一:拿到“带坐标的地图”——通过 CDP 获取可访问性树
我用的是 Playwright 库。启动一个带持久化上下文的 Chromium 实例,然后针对当前页面获取可访问性树。关键点在于,Playwright 直接对页面操作的 API 是不够细的,我通过 CDP(Chrome DevTools Protocol)的 Accessibility.getFullAXTree 命令拿数据。
下面是一段示意代码,重点不是完整实现,而是把“取树”的核心逻辑写出来:
from playwright.sync_api import sync_playwright def get_ax_tree(page): cdp = page.context.new_cdp_session(page) # 返回完整的可访问性树 result = cdp.send("Accessibility.getFullAXTree") return result["nodes"]拿到树之后,我不会直接全量丢给匹配器。先过滤:只保留包含名称、有坐标信息的节点,并去掉不可见节点。注意 A11y 树里有些节点的 bounds 是空值,那些一般是辅助性容器,不参与匹配。过滤后再把每个节点的 name、role、bounds、id 整理成一条条记录,形成一个列表。这个列表就是我的“页面元素索引”。
3.2 动作二:用轻量匹配器替代 LLM 点击判断
拿到元素列表后,定位目标就变成一个匹配算法问题。我的匹配器分三层:
第一层,精确匹配。如果任务解析器已经给出了确切的 role 和 name,直接精确匹配,命中率最高,延迟几乎为零。这里有个技巧:匹配 name 的时候,不要光做完全相等,还要做首尾空格归一化、全半角归一化、大小写归一化。网页上经常有“保存 ”和“保存”这种肉眼一致但字符串不一致的情况。
第二层,模糊匹配。用户指令是“点击保存按钮并关闭页面”,解析器会拆出目标控件为“保存按钮”,但仍可能在不同页面出现多个“保存”(比如保存草稿、保存并关闭)。我的做法是算一个基于编辑距离和关键字覆盖组合的相似度,把相似度高于 0.85 的候选都列出来。如果出现多个候选,再结合上下文或者让大模型只对这几个候选做一次轻量判断。
第三层,语义兜底。候选超过 N 个且名字都像,就调用一次小规模多模态模型,输入裁剪后的元素截图而非整页截图。这一步延迟控制在 0.8 秒以内,因为输入图片只有按钮区域,模型只需要回答是或否。
def match_elments(nodes, target_role, target_name, top_k=3): candidates = [] for node in nodes: if node["role"] != target_role: continue score = calc_text_similarity(node["name"], target_name) if score > 0.85: candidates.append((score, node)) candidates.sort(reverse=True, key=lambda x: x[0]) return candidates[:top_k]实际测试下来,前两层命中率已经覆盖了 90% 以上的定位请求。也就是说,大部分操作根本不需要大模型出场。
3.3 动作三:缓存与预取,去掉重复劳动
提到 0.3 秒,光是 A11y 树获取本身大约要 80 到 150 毫秒,匹配算法在几毫秒以内,真正的大头反而在 CDP 调用和 JSON 解析上。如果每次定位都重新拉一次全量树,不仅慢,还会不必要地打断页面状态。所以我在中间加了一层页面状态缓存。
缓存策略是这样的:以“页面 URL + 路由参数 + 文档变化标记”为 key,把最新一次的可访问性树快照存在进程内存里。当 Agent 在一个页面连续执行多个步骤时(比如填表单,每填一个字段都要定位下一个输入框),大部分情况下页面结构没有变化,直接命中缓存,定位延迟能压缩到 0.05 秒以内。只有当 Agent 执行了点击、跳转、展开下拉等操作后,才主动无效化缓存并重新拉取。
实现上我维护一个全局字典:
page_cache = { "key": None, "ax_nodes": [], "timestamp": 0, } def get_cached_ax_tree(browser_page): key = build_page_key(browser_page) if page_cache["key"] == key and time.time() - page_cache["timestamp"] < TTL: return page_cache["ax_nodes"] nodes = get_ax_tree(browser_page) page_cache.update({"key": key, "ax_nodes": nodes, "timestamp": time.time()}) return nodes这个缓存的 TTL 我设得很保守:一般不超过 5 秒。原因很简单,GUI 不是静态页面,下拉菜单展开后 A11y 树会发生巨大变化,缓存太久容易拿到过期坐标,导致 Agent 点错。
3.4 动作四:防御性重试与坐标补偿
拿到坐标并不是结束,真正点击时还有一堆“坑”。关键一个:CDP 返回的 bounds 是相对视口(viewport)的 CSS 像素坐标,而鼠标点击需要的是屏幕全局坐标。尤其在多显示器、浏览器窗口非最大化、系统缩放比例不是 100% 的环境下,这个差值可能达到几百像素。
我的处理方式是统一走“浏览器上下文坐标 → 视口坐标 → 窗口坐标 → 屏幕坐标”的换算链路。Playwright 的 locator.click 方法内部如果使用 CDP 的 Input.dispatchMouseEvent,是直接用视口坐标的,所以这个问题还不突出。但一旦你混用 PyAutoGUI 做桌面级点击(比如拖拽到浏览器外部),就必须拿到窗口的 screenX/screenY 和系统的 scaleFactor 做补偿。
防御性重试也很重要。我加入了点击后的“验证”逻辑:点击完成后,重新获取局部元素的属性,判断它是否进入了预期状态(比如按钮变成“已保存”)。如果状态没变,就重新定位一次,最多重试两次,再失败就走视觉兜底。这个机制把很多动态页面、动画延迟导致的误点问题挡在了外面。
3.5 实测数据:前后效果对照
优化完成后,我在三个典型任务上做了回归测试:一个是表单填写并提交,一个是列表筛选与导出,还有一个是拖拽文件上传。单步定位延迟的 P50 和 P95 数据如下:
| 指标 | 优化前 | 优化后 | 提升倍数 |
|---|---|---|---|
| 单步定位 P50 | 12.9 秒 | 0.3 秒 | 43x |
| 单步定位 P95 | 15.2 秒 | 0.8 秒 | 19x |
| 20 步完整任务 | 约 258 秒 | 约 28 秒 | 9.2x |
20 步完整任务的提升没有到 40 倍,因为除了定位,每一步还包含页面跳转、输入操等真实交互成本。但这些成本本来就是任务自身需要的,只有定位环节才是纯浪费。这个数据也佐证了我的判断:定位链路优化是 GUI Agent 工程化交付性价比最高的一步棋。
4. 我的工程化封装:框架无关的三层接口设计
4.1 模块划分:Capture、Position、Act 三层
很多做 Agent 的朋友一开始就把定位逻辑写死在任务脚本里,换一个浏览器框架就重写一遍。我在优化过程中把代码重构成了三个独立模块,让“从什么环境拿信息”和“怎么匹配目标”彻底解耦:
- Capture 层:负责从底层环境获取结构化元素列表。浏览器场景下,就是 CDP A11y 树;桌面应用场景下,可以是 UIA 树;移动端场景下,可以走 Android UIAutomator dump。
- 层:接收用户任务指令解析出的“目标语义”,在元素列表里做匹配,输出目标元素坐标。
- Act 层:负责执行真实动作,包含坐标换算、消息分发和执行后验证。
这样做最大的好处是,Capture 层可以按环境切换,Position 层不需要动。后来我把同一个 Position 模块用在了浏览器和桌面客户端上,只换了 Capture 实现,定位逻辑一行没改。
4.2 核心接口与关键代码
我对外暴露的接口非常简单,核心就两个函数:
class GUIEnvironment: def get_elements(self) -> list[Element]: raise NotImplementedError class GUIPositioner: def locate(self, elements: list[Element], target_semantics: dict) -> Position: # 匹配逻辑,返回元素的中心坐标 ... # 使用示例: env = BrowserEnvironment(page) elements = env.get_elements() pos = GUIPositioner().locate(elements, {"role": "button", "name": "保存"}) env.act("click", pos)Element 是一个 dataclass,包含 id、role、name、bounds、visible、enabled 等字段。这个设计不算复杂,但它保证了扩展性:新增一个环境,只需要写一个新的 Capture 实现;新增一种交互方式(比如双击、右键),只需要在 Act 层加方法。
4.3 落地到非浏览器场景的调整点
如果要把这套方案搬到 Windows 桌面应用,Capture 层从 CDP 改成 UIA,有几个点需要注意:
- UIA 树获取可能较慢,尤其是大型应用首次遍历可能有几秒延迟,建议缓存或提前预取;
- UIA 的坐标是屏幕坐标系,不用做视口换算,直接传给 win32 鼠标事件即可;
- 有些自绘控件(例如 WPF 或 Qt 的复杂 item)在 UIA 树中表现为空节点,需要配合图像识别兜底。
移动端略有不同。Android 的 UIAutomator dump 在 WebView 混合应用里对 H5 内容无能为力,那个场景还是得切回 CDP 或 Appium 的 page source。总而言之,结构化数据优先,视觉兜底,这套策略是跨端通用的,只是每类环境的“结构化数据”长在不同位置。
5. 避坑实录:我在这个过程中踩过的五个坑
5.1 坐标漂移:高分屏和系统缩放是头号杀手
第一次重构完,我在自己的 2K 显示器上测试一切正常,换到同事的 4K 本上直接全部点偏。查了半天发现,他 Windows 系统缩放是 150%,Chromium 报告的是 CSS 像素,而鼠标用的是物理像素。这个坑的教训是:凡是走桌面级鼠标事件的场景,一定要拿到 deviceScaleFactor 做换算,并且写一个“点击后位置校验”的断言,别等用户反馈。
5.2 懒加载元素“看得见却点不到”
A11y 树里有这个元素,坐标也有,但点击时就是没有反应。后来发现是页面用了懒加载,元素在视口边缘但还没真正渲染完成。解决办法是在点击前多做一个滚动到元素并等待稳定状态的操作,不要盲目相信树里“可见”字段。我当时在 Act 层加了一个小功能:目标元素不在视口内时,先通过 CDP 的 DOM.scrollIntoViewIfNeeded 把它滚进来,等一个 animation frame 再点击。
5.3 CDP 全量树并不总是“全量”
Accessibility.getFullAXTree 这个名字有误导性。在部分页面,尤其是带 shadow DOM 或 iframe 的复杂应用里,默认参数拿到的树是不完整的。我的解法是同时启用 CDP 的 Accessibility.enable,并在获取树之前等待一段时间让辅助功能缓冲填满。如果你发现某些元素一直找不到,先查一下它是不是被 shadow root 包住了。如果需要穿透 shadow DOM,就得用 DOM 自带的 piercing 选择器或者递归 flatten 树,这一步血泪经验。
5.4 不要迷信“首次定位慢很正常”
有人会说第一次建立索引慢,后面就快了。我踩过的坑是,一些 Agent 框架在每次会话里都会新建一个浏览器上下文,于是每次会话的“第一次定位”都得重新拉树、重新编译缓存,等于把慢吞吞的整页分析重复了一遍。后来我把必要的浏览器上下文做成长驻复用,配合持久化 userDataDir,让页面资源在第二次会话时还能命中部分缓存。这个改动对 P95 延迟的改善非常明显。
5.5 缓存策略太激进,过期的坐标害我误触
当时我把页面状态的缓存 TTL 调到了 30 秒,想着能省 CDP 调用。结果在一次自动化测试里,Agent 明明已经点开了一个弹窗,还在用旧缓存里的老坐标去点弹窗背后的按钮,差点把测试环境的数据搞乱。后来我调整成两个策略共存:点击、输入、跳转等会产生状态变更的动作执行后强制清缓存;只有键盘输入(且焦点没变)这种不影响界面结构的动作才保留缓存。这个“动作类型感知”的缓存策略比单纯用时间 TTL 安全得多。
6. 优化做完之后,我对 GUI Agent 定位的重新认识
把定位速度从 12.9 秒压到 0.3 秒之后,有一个额外的好处是我没预料到的:Agent 在更短的延迟里,会显得更像真人操作。节奏紧凑,没有那种“发呆”的间隙,用户对自动化任务的信任度明显提升。这也是我会建议每个做 GUI Agent 的团队都先审视定位链路的原因。
到现在,我仍然保留着一套纯视觉的兜底链路,只是它不再承担绝大部分定位请求了,更像是一个消防队。A11y 结构找不到的东西、Canvas 里的复杂操作,才需要它出马。这个“结构优先、视觉兜底”的分层架构,后来也成了团队处理所有 Agent 感知类问题的默认范式。
最后分享一个小经验:不要把“AI Agent”理解为一个黑盒子大模型工程问题。它本质上是一个系统问题,感知层、决策层、执行层各司其职,每一层都可能成为瓶颈。尤其在感知层,多模态模型能力强,但延迟和成本都高,更适合做决策参考和理解兜底,而不是做像素级定位的主力。先用结构化数据把地基打好,你会发现很多性能问题根本不需要“堆钱堆卡”,只需要换一个数据获取姿势。