GUI Agent 的上限,卡在按钮有没有名字

今天热榜上一天冒出四条 GUI Agent:agent-desktop 连点 55 次 0 失误、Mika 在安卓后台虚拟屏上替你操作 App、WorkBuddy 接管浏览器,还有一篇讲 App 未来会被 Agent 以三种方式调用。

全在回答同一个问题:这件事能不能做。

第二个问题没人问:在什么界面上做不了。

其实 agent-desktop 那篇文末自己给了答案,只是被写进了「适用边界」:「我之前测滴答清单时碰到一排长得一模一样、没有名字的输入框,它在后台模式下直接拒绝修改,得允许它加 --headed 切到前台、用真鼠标去点。」

这句比「55 次 0 失误」重要得多。它说明不截图这条路线有一个硬边界,而且这个边界不在模型侧,在界面侧。

ref 定位,本质是拿序号代替坐标

不截图的 Agent 怎么定位:读操作系统的无障碍树,给每个控件编一个号,让模型输出「点 e17」。

这比像素坐标靠谱,因为 e17 是语义节点,不是猜出来的 x=842, y=613。

但它靠谱的前提是:节点有名字。

一个控件没有 accessible name,模型手里就只剩角色和位置------「第 4 个 textbox」。而位置是相对的。界面上多一行维护公告、多一个错误气泡、多一条 toast,第 4 个就变成了第 5 个。

像素坐标点歪了你知道,因为屏幕没反应。ref 指错了你不知道,因为 e17 始终在,始终可点。

先体检,再接模型

想知道一个界面能不能交给 Agent,不用先调模型,先看树。三个指标:无名率、重名率、ref 劫持率。

python 复制代码
from collections import Counter

ACT = {"button", "link", "textbox", "checkbox", "combobox", "menuitem", "tab"}


def flatten(node, path=()):
    out = []
    for i, ch in enumerate(node.get("children", [])):
        p = path + (i,)
        if ch["role"] in ACT:
            out.append((p, ch["role"], (ch.get("name") or "").strip()))
        out += flatten(ch, p)
    return out


def audit(tree):
    nodes = flatten(tree)
    unnamed = sum(1 for _, _, n in nodes if not n)
    dup = sum(v for v in Counter(
        (r, n) for _, r, n in nodes if n).values() if v > 1)
    return len(nodes), unnamed / len(nodes), dup / len(nodes)


def hijack(before, after):
    """ref 按遍历顺序编号,第 i 个的 ref 永远是 e(i+1)。
    编号没变,看的是同一个编号是否还指向同一个控件。"""
    A = [(r, n) for _, r, n in flatten(before)]
    B = [(r, n) for _, r, n in flatten(after)]
    m = min(len(A), len(B))
    return sum(1 for i in range(m) if A[i] != B[i]) / m

输入就是无障碍树导出的 JSON。Web 上抓 CDP 的 Accessibility.getFullAXTree,桌面端拿 UIA 的控件树,结构一样。

拿两个真实形态的界面对比。新建账单六个控件,全部有名字;旧版报销单十个控件,五个没名字,三个都叫「确定」:

text 复制代码
新建账单      total=6   unnamed=0%   dup=0%
报销单(旧版) total=10  unnamed=50%  dup=30%

无名率 50%,意味着模型一半的动作只能靠角色做排除法。这种界面后台模式不用试,要么开 --headed 到真实窗口里点,要么先把名字补上。

重名率 30%,意味着「点确定」这句话在这个界面上没有唯一解。三个确定,一个是保存、一个是提交、一个是删除。

最坏的情况不是 ref 变了,是 ref 没变

第三个指标我第一次算错了。

我在旧版报销单顶部插了一条「系统将于今晚 22:00 维护」的提示条,然后重新给整棵树编号。第一版脚本的结论是「ref 位移 0%」------看起来完全安全。

这个 0% 是假的。ref 按遍历顺序编,前面插一个节点,后面全部顺移一位,同时末尾少一个。整个序列看过去仍然是 e1、e2、e3...... 一模一样。变的不是编号,是编号指向的东西。

改成比对「同一位置还是不是同一个控件」之后:

text 复制代码
hijack GOOD   0%
hijack LEGACY 70%

十个位置里七个的 ref 指向了别的控件。剩下三个没被算进去,因为它们恰好都叫「确定」:角色没变、名字没变、ref 没变,所有校验都通过,但你点的是另一个「确定」。

这是最难查的一类失败:不报错、不告警、结果错误。缓存 ref 的 Agent 会在这里把「保存」点成「删除」。我的脚本用两行代码复现了这个 bug;同样的 bug 落在真实系统里,表现是账对不上。

所以第一条纪律:ref 的生命周期只有一次快照。任何动作之后重新取树,宁可多花一次快照的时间。

补名字,三种写法

无名率是可以改的,而且不用动业务逻辑。

kotlin 复制代码
// Compose:一个输入框别拆成五个无名节点,合并语义再补描述
TextField(
    value = amount, onValueChange = { amount = it },
    modifier = Modifier.semantics(mergeDescendants = true) {
        contentDescription = "金额输入框"
    }
)
html 复制代码
<!-- Web:aria-label 优先于 placeholder,placeholder 会在输入后被清空 -->
<input id="amount" aria-label="金额">
swift 复制代码
// SwiftUI:这个 label 同时给 VoiceOver 和 Agent 用
TextField("金额", text: $amount)
    .accessibilityLabel("金额输入框")

注意 Compose 那个 mergeDescendants。默认一个 TextField 在树里可能变成输入框加若干文本节点,Agent 数到的「第 4 个 textbox」和你想的第 4 个往往不是同一个。

这三段代码的目标用户不是 Agent,是读屏软件。但可访问性和 Agent 可操作性本来就是同一件事的两种说法:一个把界面讲给人听,一个把界面讲给模型听。给视障用户写名字,顺手就把语义给了 Agent。反过来,一个读屏都用不顺的界面,Agent 一定也点不对。

55 次这个数字,能撑住多少结论

0 失败样本的 95% 置信上限是 1 - 0.025^(1/n)。n=55 时是 6.5%。

意思是真实失败率完全可能是 5%,而 55 次里大概率一次都碰不到。想让「失败率低于 1%」这句话站得住,需要连续 300 次全对。

全对次数 95% 置信上限
55 6.5%
100 3.6%
300 1.2%
1000 0.4%

低于 100 次的,叫 smoke test,不叫可靠性验证。

还有一层更麻烦的:计算器是界面里的优等生。36 个按钮全有名字、没有网络请求、没有异步渲染、没有权限弹窗、没有浮层遮挡。真实业务页面是「旧系统表单 + 登录态过期 + 一个飘在按钮上的客服浮窗」。

所以压测不能只堆次数,得分层。至少四层:纯本地无异步、有网络请求的表单、需要登录态的系统、带弹窗和浮层的页面。每层单独算成功率,它们的失败原因完全不同。混在一起算个 98%,等于什么都没说。

动作和断言,必须分开写

不管用哪个框架,有一件事得自己做:不要相信 Agent 返回的「已完成」。

python 复制代码
def step(agent, action, expect):
    before = agent.snapshot()
    agent.do(action)
    try:
        return assert_state(agent.snapshot(), expect, before)
    except AssertionError:
        agent.dump_trace()   # 连同当时的树一起存档
        raise

动作只负责发出,成败由界面状态判定。Agent 说「已保存」不算数,标题栏从「未保存」变成「已保存」才算数。

回头看 agent-desktop 那篇里一个容易被忽略的细节:补一行字加保存花了 3 分 49 秒,因为每一步都在重新确认界面状态。慢是代价,也是 0 失误的真实原因。快的那条路每次动作 0.15 秒,代价是每算一段要截一张全屏------因为它同样不知道自己对没对。

五件事,今晚能做

  1. 导出你最想自动化的那个页面的无障碍树,跑一遍上面的体检,先拿到无名率和重名率。
  2. 给无名控件补 accessible name。Web 上是 aria-label,桌面端是控件的 Name 属性。改一个属性,比换一个更强的模型有效。
  3. ref 不跨快照缓存。每次动作后重新取树。
  4. 关键步骤写后置条件断言,验「值变了没有」「状态变了没有」,不验「接口返回 200 没有」。
  5. 压测按界面复杂度分层,每层至少 100 次,失败样本连同当时的树一起存。失败率的分母比分子重要。

GUI Agent 现在的瓶颈不是不够聪明,是它在读一份很多 App 从来没认真写过的说明书。

你给按钮起个名字,比等下一代模型有用。

相关推荐
Trae_陈冀20 分钟前
写测试用例总漏场景还格式乱?我做了个自动生成工具,需求一键转标准用例
agent·测试
初学AI的小高1 小时前
Agent 评测驱动开发:用 pytest 测试工具调用与执行轨迹
agent·测试
典学长编程3 小时前
Agent 基础学习:让大模型从“能说”到“会做”
agent·react·text2sql·工具调用
MysTic_Zhong3 小时前
【2】整体架构蓝图:六层依赖与技术选型
人工智能·agent
Bazingga3 小时前
Claude Code核心拆解:计划模式——怎么让模型"先对齐再动手"
agent
用户3126874877203 小时前
4 周写完 650 万行数学证明:DeepMind 用 AI 智能体蜂群验证 Signal 协议,形式化验证被彻底改写
agent
威哥爱编程3 小时前
模拟面试智能体开发实战 05:给面试官接上题库和检索,题目结构化、大纲可溯源,AI 不再凭印象出题
python·agent·harmonyos
威哥爱编程4 小时前
模拟面试智能体开发实战 06:答得好怎么追问、答得错怎么点破,追问引擎让面试官会深挖会纠错
python·agent·harmonyos
GoGeekBaird5 小时前
换个 AI 就要重新介绍一遍自己?我用 OneSelf 给自己搭了个个人站
github·agent