今天热榜上一天冒出四条 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 秒,代价是每算一段要截一张全屏------因为它同样不知道自己对没对。
五件事,今晚能做
- 导出你最想自动化的那个页面的无障碍树,跑一遍上面的体检,先拿到无名率和重名率。
- 给无名控件补 accessible name。Web 上是 aria-label,桌面端是控件的 Name 属性。改一个属性,比换一个更强的模型有效。
- ref 不跨快照缓存。每次动作后重新取树。
- 关键步骤写后置条件断言,验「值变了没有」「状态变了没有」,不验「接口返回 200 没有」。
- 压测按界面复杂度分层,每层至少 100 次,失败样本连同当时的树一起存。失败率的分母比分子重要。
GUI Agent 现在的瓶颈不是不够聪明,是它在读一份很多 App 从来没认真写过的说明书。
你给按钮起个名字,比等下一代模型有用。