让电脑 Agent 操作更快

一次被验证过的按钮点击要多久?
视觉流派(GPT/Claude / Operator 类):3–6 秒。
QCU:64 毫秒——而且每一次点击都带着「生效确认」。

这篇文章把 QCU(Quick Computer Use) 拆开讲:贴着真实源码讲清楚它的实现、它和视觉流派在底层逻辑上的本质差异、每一毫秒提速是从哪行代码里抠出来的——最后附一次把 4.5 秒的点击修成 64 毫秒的完整破案过程。所有代码片段都摘自仓库 v1.6,标注了文件与函数名,可以直接对着源码读。


一、结论

全部为本机实测(macOS Apple Silicon,2026-08),测试方法附在文末。

Web 路径(QCU 自带 Chromium,daemon 常驻):

操作 p50 p95 说明
observe 本地表单页(26 元素) 77 ms 81 ms 内部仅 19.5 ms
observe example.com 66 ms 66 ms 内部 6.1 ms
observe Hacker News(230 元素) 213 ms 215 ms 含整页正文文本
act:fill 输入框 64 ms 75 ms
act:click 复选框(含验证) 77 ms 79 ms

桌面路径(macOS Calculator,276 个 AX 元素):

操作 p50 对照
observe 全树 337 ms 朴素 AX 遍历同树 1227 ms(快 3.7×)
单击数字键(含生效验证) 64 ms 视觉流派同一步 3–6 s
后台窗口单击(自动激活重试) 84 ms 修复前此场景 8.8 s 且误报失败
裸 AXPress(OS 底线) 5.8 ms 说明天花板还远没摸到

近 30 天真实使用遥测(~/.qcu/telemetry.jsonl,1280 条记录)交叉验证:web 层 p50 82 ms、成功率 100%;desktop 层 p50 327 ms、成功率 92%。

二、两种范式

把两条技术路线的核心循环并排放着,差异一目了然。

视觉流派(截图 → 云端 VLM → 坐标猜测):

while not done:
    png = capture_screen()                        # 284 ms,9.2 MB Retina PNG
    coords = vlm_locate(png, "Sign in button")    # 1–3 s 推理 + 网络往返
    cg_event_click(*coords)                       # 坐标是模型"猜"的 → 概率性
    # 想确认点对了?再来一整轮:截图 + 上传 + 推理
    png2 = capture_screen()
    ok = vlm_compare(png, png2)                   # 又是 1–3 s

每一轮循环固定支付四笔钱:截图(284 ms)、上传(数百 ms 到数秒)、VLM 推理(1–3 s)、概率性定位的纠错重试。同机实测:全屏截图 284 ms + 本地 OCR 1149 ms,还没开始「理解」就 1.4 秒了;换成云端 VLM,单步 3–6 秒是常态。

QCU(读结构 → 确定引用 → 直达元素):

while not done:
    obs = qcu.observe()               # 77–337 ms,返回结构化元素清单
    ref = llm_pick(obs.elements)      # "ref_27" 是确定性引用,不是像素猜测
    res = qcu.act(click(ref))         # 64–84 ms,内置 AX 状态 diff 验证

关键洞察只有一句话:视觉流派在重新推导操作系统本来就知道的信息。屏幕上每个按钮的角色、名称、坐标、启用状态,macOS 和 Chromium 早就维护着一棵完整的「可达性树」(Accessibility Tree)——那是给 VoiceOver 屏幕阅读器用的 API。QCU 直接去读这棵树。

一次真实的 qcu observe --app Calculator 返回(节选):

[
  {"ref": "ref_7",  "role": "button", "name": "All Clear", "enabled": true},
  {"ref": "ref_9",  "role": "button", "name": "Divide",    "enabled": true},
  {"ref": "ref_16", "role": "button", "name": "7",         "enabled": true},
  {"ref": "ref_27", "role": "button", "name": "5",         "enabled": true},
  {"ref": "ref_49", "role": "button", "name": "Equals",    "enabled": true}
]

LLM 拿到的不是一张要「看图找按钮」的截图,而是一份可以直接引用的清单。点「5」就是 {"type":"click","params":{"ref":"ref_27"}}——引用是确定的,不是猜的。

三、架构总览

            qcu observe / qcu act(CLI)
                     │  loopback TCP JSON-RPC(每命令 ~47 ms 开销)
            ┌────────▼────────┐
            │ 常驻 Python daemon │ ← 省掉每命令 ~300 ms 进程启动
            └────────┬────────┘
            ┌────────▼────────┐
            │  Router 规则路由  │ ← priority 10–100 的规则链
            └────────┬────────┘
   ┌───────────┬─────┴──────┬──────────────┐
   ▼           ▼            ▼              ▼
 web_a11y   desktop_ax  desktop_      screenshot_
 (CDP)      (AX API)    appleevents   fallback(最后兜底)

下面逐层拆开,每层都贴核心代码。

四、Router

不是所有任务都该走同一条路。qcu/router/rules.py 用一张优先级表表达全部路由逻辑:

# qcu/router/rules.py — 每条规则 = (优先级, 名称, 条件函数, 目标层, 人类可读理由)
RULES: list[Rule] = [
    (100, "canvas_target",
     lambda f: bool(_f(f, "has_canvas_target")),
     "screenshot_fallback",
     "Target sits on a <canvas>/WebGL surface; a11y tree is blind to it."),

    (95, "visual_confirm",
     lambda f: bool(_f(f, "needs_visual_confirm")),
     "screenshot_fallback",
     "Action is in the critical list; force a visual confirmation pass."),

    (70, "web_a11y",
     lambda f: _f(f, "context") == "web" and (...),
     "web_a11y",
     "Web context; a11y tree available — fastest path."),

    (65, "desktop_ax",
     lambda f: _f(f, "context") == "desktop"
               and (_f(f, "ax_trusted", False) or _ax_actually_trusted()),
     "desktop_ax",
     "Desktop context with macOS AX trusted."),

    (60, "desktop_appleevents",
     lambda f: _f(f, "context") == "desktop"
               and not _ax_actually_trusted() and _appleevents_available(),
     "desktop_appleevents",
     "AX not granted, but Apple Events (Automation) path is usable."),

    (55, "desktop_no_ax", ..., "screenshot_fallback",
     "Neither AX nor Apple Events available — last resort only."),
]

三个值得注意的设计:

1. 权限降级是渐进的。 macOS 桌面自动化有两套独立授权:Accessibility(AX API 用)和 Automation(AppleScript 用)。用户只授了其中一项时,router 自动降到可用的那条路(规则 60),而不是直接失败。

2. desktop_ax 的条件里有个 _ax_actually_trusted() 现场探测——因为 feature dict 里的 ax_trusted 默认值只是猜测,路由规则在决策前会真的调一次 AXIsProcessTrusted()

def _ax_actually_trusted() -> bool:
    """Probe whether macOS AX permission is really granted right now."""
    try:
        from ApplicationServices import AXIsProcessTrusted
        return bool(AXIsProcessTrusted())
    except Exception:
        return False

3. 每次决策(含所有被否决的备选)都追加进 ~/.qcu/telemetry.jsonl——这是给未来把规则链换成 ML 分类器攒的免费训练数据。本文的遥测数字就来自这里。

五、desktop_ax:从 8 秒到 337 毫秒的遍历

桌面层的第一件事是把目标 app 的 AX 树走一遍。朴素写法是对每个节点读全部 7 个属性(role/name/value/position/size/enabled/focused),每读一次都是一次跨进程 AX IPC——Notes 这种大树上实测要 8 秒。

QCU 的 walk()qcu/layers/desktop_ax.py)用 role-first 策略砍掉了大部分 IPC:

def walk(elem, depth, path):
    if max_depth > 0 and depth > max_depth:
        return
    # 1) 先读 ROLE —— 最便宜的判别器。绝大多数节点是布局容器
    #    (AXGroup/AXLayoutArea),对它们我们一个属性都不想多读。
    try:
        role_err, role = _ax_get(AXUIElementCopyAttributeValue, elem, "AXRole")
    except Exception:
        return
    if role_err != 0 or not role:
        return
    role_norm = from_ax(str(role)) if role else "group"

    # 2) 只有交互元素才付全属性查询的钱(它们是少数派)
    if role_norm in INTERACTIVE_ROLES:
        _, value   = _ax_get(..., "AXValue")
        _, pos     = _ax_get(..., "AXPosition")
        _, size    = _ax_get(..., "AXSize")
        _, enabled = _ax_get(..., "AXEnabled")
        _, focused = _ax_get(..., "AXFocused")
        # ...序列化成 Element,建立 ref 缓存...

    # 3) 布局容器:跳过全部昂贵属性,直接下钻
    _, kids = _ax_get(..., "AXChildren")
    for idx, k in enumerate(kids or []):
        walk(k, depth + 1, path + [idx])

源码注释里记着这笔账:“This is the single biggest win on big trees like Notes (~8s -> ~2s): previously every node paid 7 AX round-trips regardless of role.” 在 Calculator(1166 节点)上,朴素遍历 1227 ms vs QCU 337 ms,3.7 倍

INTERACTIVE_ROLES 是个很小的集合(qcu/common/normalize.py):

INTERACTIVE_ROLES = frozenset({
    "button", "link", "edit", "password", "searchbox",
    "checkbox", "radio", "switch", "combobox", "listbox",
    "option", "menu_item", "tab", "slider", "spinbutton", "tree_item",
})

另外还有一个 WriteOnce 的 per-pid 优化:对 Catalyst/WebKit 应用(Notes、App Store、Music)设置 AXEnhancedUserInterface=True——和 VoiceOver 打开的是同一个开关,不设置的话这些应用只暴露菜单栏:

app_pid = front.processIdentifier()
if app_pid not in self._enhanced_apps:          # WriteOnce:同一 pid 只设一次
    AXUIElementSetAttributeValue(app, "AXEnhancedUserInterface", True)
    self._enhanced_apps.add(app_pid)

六、执行:AXPress 直达元素本体

点击的主路径不碰坐标、不碰鼠标,直接对元素执行它的无障碍动作(desktop_ax.py::_ax_native_click):

def _ax_native_click(self, elem, role, atype="click"):
    # 查询这个元素支持哪些 AX 动作(PyObjC 12.2.1 / macOS 26 有一次改名,
    # 两个名字都接受)
    _, actions = _ax_get(_GetActionNames, elem, None)
    acts = [str(a) for a in (actions or [])]

    if atype == "right_click":
        preferred = ("AXShowMenu", "AXPress")       # 右键 = 原生上下文菜单
    else:  # click / double_click
        preferred = ("AXPress", "AXConfirm", "AXOpen", "AXShowMenu", "AXToggle")

    for act_name in preferred:
        if act_name in acts:
            err = AXUIElementPerformAction(elem, act_name)   # ← 就是这一行,5.8 ms
            if atype == "double_click" and err == 0 and act_name == "AXPress":
                time.sleep(0.05)
                AXUIElementPerformAction(elem, act_name)     # 双击 = 两次轻按
            return (err == 0), act_name
    return False, None

为什么这比合成鼠标事件强:

  • 不依赖坐标——元素没有 bounds(图标类控件)也能点;
  • 不依赖前台焦点——后台 WebKit 视图的按钮也接受 AXPress;
  • 语义正确——AXShowMenu 就是「弹出上下文菜单」,比「右键按下+释放」更接近意图;
  • 实测 5.8 ms,而 CGEvent 合成还要等目标应用的主线程响应。

AXPress 之上还有完整兜底链:AX 失败 → Apple Events(osascript perform action "AXPress" of ...,只需 Automation 权限)→ CGEvent 坐标点击。坐标点击是最后手段,而且 firing 之前要过一道安全门qcu/layers/_click_safety.py):遍历 CGWindowListCopyWindowInfo(返回顺序即前后栈序),确认该坐标会落在目标 app 的窗口上、且没有被更高层级的悬浮窗遮挡——杜绝「点击泄漏到飞书/通知」的经典事故:

# _click_safety.py 的判定要点
# 1. window_at_point(x, y):取包含该点的 layer-0 窗口 = 真正会收到事件的窗口
# 2. _owner_match:kCGWindowOwnerName 与目标 app 比对 → 不符 = wrong_owner
# 3. overlay_occluder:栈序更高 + 不同 app + alpha > 0.02 → occluded_by_overlay
#    (同 app 浮层不算,全透明 click-through 不算)

七、验证系统:让「静默失败」不存在

AXPress 返回 err=0 不代表点击生效——Electron 应用的 stub no-op 恰好就返回 0。所以 QCU 给每个 click/fill 配了一个基于 AX 状态 diff 的验证器(qcu/layers/_ax_verify.py)。

第一步:动作前拍快照。 snapshot() 采集目标元素的 value 哈希、子节点数、焦点态、存活态,以及窗口级信号(标题/数量/全文哈希):

@dataclass
class Snapshot:
    value_hash: tuple[int, str]      # (len, sha1[:16]) — O(1) 比较且抗前缀碰撞
    children_count: int              # 浅层子节点数(cap 200)
    focused: Optional[bool]
    element_alive: bool              # AXRole 还读得到吗
    window_title_hash: tuple[int, str]
    window_count: int
    window_text_hash: tuple[int, str] # 窗口内全部静态文本的哈希(1.6 新增)

第二步:无前置 sleep 的轮询,命中即早退。 每类信号一个时间窗,全部由实测数据标定(源码注释里记着标定过程):

WINDOW_BY_CLASS: dict[str, int] = {
    "value_flip": 400,      # 勾选框翻转:实测中位 52 ms,400 ms 留 7 倍余量
    "value_set": 400,       # fill:实测 10/10 命中,中位 52 ms max 55 ms
    "value_delta": 400,     # 滑杆数值变化
    "button_press": 1200,   # 通用按钮:数据表明 ≤500 ms
    "children_change": 1500,# 弹窗/面板开合
    "menu_open": 2500,      # SwiftUI 模态冷启动实测 max 466 ms,保守放宽
    "element_death": 1500,  # 关闭型菜单项:元素应消失
}

轮询循环本身很朴素,性能全部来自「不睡死等」:

while True:
    if now < t_next:
        time.sleep(min(t_next - now, poll_ms / 1000.0))   # 首采样仅 50 ms 后
    if now > deadline:
        break
    after = snapshot(target, ax_getter, app=app)
    if _match_signal(before, after, signal, expected_text):
        return VerdictResult(verified=YES, matched_at_ms=(now - t_start) * 1000, ...)
    t_next = now + poll_ms / 1000.0                        # 之后每 100 ms 一采

信号匹配按动作类型分发——每种角色看它该看的信号:

def _match_signal(before, after, signal, expected_text):
    if signal == SignalClass.VALUE_FLIP:          # 勾选框:AXValue 0↔1
        return before.value_hash != after.value_hash
    if signal == SignalClass.VALUE_SET:           # fill:AXValue == 期望文本
        v = str(after.value_raw)
        return v == expected_text or \
               _normalize_for_compare(v) == _normalize_for_compare(expected_text)
    if signal == SignalClass.ELEMENT_DEATH:       # 关闭菜单项:元素应消失
        return after.element_alive is False
    if signal == SignalClass.BUTTON_PRESS:        # 通用:任一信号翻转即算
        return (before.value_hash != after.value_hash
                or before.children_count != after.children_count
                or before.window_count != after.window_count
                or before.focused != after.focused
                or before.window_text_hash != after.window_text_hash)  # 1.6 新增

_normalize_for_compare 甚至处理了全角/半角数字和空白差异——应用会「自作主张」重排用户输入(日期框自动插 /、数字框去前导零),不归一化就会把成功的 fill 误判成失败。

这套验证值多少钱? 命中时实测中位 25–55 ms。对比视觉流派验证一步动作:再截一张图 + 再过一次 VLM,又是 2–4 秒。

八、web_a11y:CDP 常驻 + 并发几何

Web 路径的底座是一个 detached 的常驻 Chromiumqcu/layers/browser_daemon.py):端口从 9222 向上探空位、subprocess.Popenstart_new_session=True(命令退出杀不死它)、就绪判定是轮询 /json/version 返回 HTTP 200(TCP 连上≠就绪)。之后每条 qcu 命令都用 Playwright 附着回同一个浏览器:

# web_a11y.py — 附着而不重启,页面状态跨命令存活
self._browser = await pw.chromium.connect_over_cdp(bd.cdp_url(port))
# does NOT spawn a new browser and does NOT reset the existing
# pages' DOM/focus/form state

观察则是一次 CDP 调用拿全树,然后按角色分级裁剪:

# web_a11y.py
tree_res = await cdp.send("Accessibility.getFullAXTree", {"depth": cdp_depth})

ref 生成带代数前缀obs_13:ref_1),代数随每次观察递增——页面导航后旧 ref 自动失效,防止 agent 拿过期引用点错元素。动作执行用 Playwright locator 主路径:观察时顺手给每个元素打上 data-llm-ref="ref_N" 属性,点击时 page.locator('[data-llm-ref="ref_N"]').click()——DOM 变了也没关系,属性还在就能找到

一个典型的性能细节:元素几何信息(bounds)需要逐个调 Runtime.callFunctionOn 拿,旧版串行执行在 HN 上要 360 ms。改成并发后:

# web_a11y.py — 串行 N 次 RPC → asyncio.gather 一次重叠
results = await asyncio.gather(*(
    cdp.send("Runtime.callFunctionOn", args) for args in geometry_calls
))

这就是 web observe 内部延迟只有 6–151 ms 的原因之一。

九、daemon:每命令省 300 毫秒的架构

每条 qcu 命令原本要起一个全新 Python 进程——解释器启动 + import 整个包,约 300 ms;15 步任务光进程税就 4.5 秒(这个数字写在 daemon/server.py 的模块注释里,是它存在的理由)。

方案是个极简 JSON-RPC daemon:loopback-only TCP、一行 newline 分隔的 JSON 一个请求、处理函数原样复用 CLI 的 handler(cli_handlers.py),只是把它们的 stdout 捕获回来:

# qcu/daemon/server.py
def _dispatch(method: str, params: dict) -> dict:
    table = {
        "session_start": lambda: h.session_start(params.get("context", "auto"), ...),
        "observe":       lambda: h.observe(layer=params.get("layer"), ...),
        "act":           lambda: h.act(params.get("action", ""), ...),
    }
    fn = table.get(method)
    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):      # handler 们 print(json) + 返回码
        rc = fn()
    payload = json.loads(buf.getvalue())
    return {"ok": True, "result": payload, "exit_code": rc}

daemon 还解决桌面路径的第二个问题:焦点漂移。短命进程每次都重新解析目标 app,而调用方(终端/IDE)会抢焦点;常驻 daemon 持有 _target_app 和活的 ax_element 句柄,跨命令不丢。

代价是长驻进程会踩到缓存类 bug——这正是第九节破案故事的主题。

十、破案:一次点击为什么要 4.5 秒,修完为什么只要 64 毫秒

基准测试不只是出好数字的。初版基准顺手挖出 6 个真实 bug,其中 verify 的时序 bug 藏得极深,值得完整讲一遍排查过程。

10.1 症状

Calculator 数字键点击:显示屏确实123,456,789 变成了 1,234,567,895,QCU 却报 ok=false,错误信息是 AXPress err=0 but verify=no,还级联触发 3 秒的 Apple Events 兜底超时。单次点击墙钟 4.5–8.8 秒。

10.2 排查链

第一步:单进程复刻 verify 流程。 在一个独立 Python 进程里手动执行「拍 before → AXPress → 轮询」,一切正常,match=True。同样的代码走 daemon RPC 就失败。诡异。

第二步:前台 daemon + 打点。 给 verify 循环临时加 stderr 输出,发现 before/after 的窗口文本哈希完全相同——点击明明生效了,快照却看不到变化。

第三步:排除焦点假说。 发现后台状态的 AXPress 确实是静默 no-op(err=0 无效果),据此先加了一层「激活重试」(见 10.3 的修复 2)。但前台场景依然不匹配。

第四步:真凶浮出。 对比单进程测试和 daemon 路径的每一行差异,最后锁定在快照拍摄的时序上:

# 修复前的 _click 流程(desktop_ax.py)—— 注意两行的顺序
ok_ax, act_name = self._ax_native_click(elem, role, atype)  # ① AXPress 在这里生效
if ok_ax:
    verify_meta = self._verify_action(elem, role, atype)     # ② "before" 快照在这里才拍!

_verify_action 内部自己拍 before 快照——但此时 AXPress 已经执行完了。Calculator 按键的效果在按下瞬间就写入 AX 树,于是「事前快照」拍到的已经是事后状态:before == after永远不可能匹配

这也完美解释了此前的疑点:为什么 checkbox 类验证偶尔能过(AX 值的传播比 press 的返回慢半拍,快照来得及拍到旧值),而窗口文本从不匹配(Calculator 的 SwiftUI 文本更新在 press RTT 内完成)。

10.3 修复

修复 1:快照提前到动作前,作为参数传入。

# 修复后
pre_snap = self._pre_action_snapshot(elem)                   # ① 先拍快照
ok_ax, act_name = self._ax_native_click(elem, role, atype)   # ② 再按键
if ok_ax:
    verify_meta = self._verify_action(elem, role, atype, before=pre_snap)  # ③ 传入

_verify_action 的签名相应扩展,before 缺省时保持旧行为(fill 路径本来就是先快照后 setattr,不受影响)。

修复 2:后台 no-op 加激活重试。 verify 判定 no 后,不直接掉进 3 秒的慢兜底,而是先做一次更便宜的精准重试——从元素本体拿到 pid、反查 app 名、激活、重按、用放宽到 2.5 s 的窗口重验:

if verified != "yes":
    target_name = self._target_app
    if not target_name:
        got = self._elem_pid(elem)                    # AXUIElementGetPid:活引用,不会陈旧
        target_name = name_for_pid(got)
    if target_name:
        self._activate_app(target_name)
        time.sleep(0.3)
        ok_ax2, act_name2 = self._ax_native_click(elem, role, atype)
        if ok_ax2:
            retry_meta = self._verify_action(elem, role, atype, window_ms=2500)
            if retry_meta.get("verified") == "yes":
                return LayerResult(ok=True, ...)

10.4 同一次排查顺带修掉的另外几个 bug

CDP 数值 value 崩溃。 CDP 会按 DOM 属性类型序列化 a11y 树的 value:<input type=number> 给的是 intaria-checked 给的是 bool。旧 clean_text 直接把值喂给 re.sub

# 修复前 —— 遇到 int 直接 TypeError,整个 observe 崩掉
def clean_text(s, max_len=200):
    if not s: return ""
    s = _WHITESPACE_RE.sub(" ", s).strip()     # int 进来这里炸

# 修复后 —— 类型宽容
if s is None or s == "": return ""
if isinstance(s, bool):
    s = "true" if s else "false"               # aria-checked → LLM 友好的字符串
elif not isinstance(s, str):
    s = str(s)                                 # 0 是合法的滑杆值,不该被吞成 ""

SwiftUI 按钮匿名。 SwiftUI 控件的可达性标签放在 AXDescription 而非 AXTitle,QCU 只读后者,于是 Calculator 的 57 个按钮里 54 个没有名字。修复是在 walk 里加惰性回退——只在「交互元素 + title 为空」时多付一次 IPC,不拖慢遍历主路径:

# desktop_ax.py walk() 内
if not name_s:
    try:
        desc_err, desc = _ax_get(
            AXUIElementCopyAttributeValue, elem, "AXDescription")
    except Exception:
        desc_err, desc = -1, None
    if desc_err == 0 and desc:
        name_s = clean_text(str(desc))         # 'Five' / 'Divide' / 'Equals' ...

daemon 里永不刷新的 NSWorkspace 进程列表。 排查 verify 时发现的陈年暗坑:长驻进程里 NSWorkspace.runningApplications() 的列表永远不刷新——Calculator 退出重开后,列表仍返回死 pid(实测:列表 pid 12268 已死,frontmostApplication() 报的 12403 才是真的)。名字匹配于是解析到死 pid,AXWindows 恒空。修复分两层:名字候选逐个用 AXWindows 探活;全部探死则从 Quartz 窗口表反查活 pid(窗口存在 = 进程活着):

# 全部候选都不健康时:从窗口列表反查活 pid
dead = {c.processIdentifier() for c in candidates}
for w in CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly,
                                    kCGNullWindowID):
    owner = str(w.get("kCGWindowOwnerName") or "")
    p = w.get("kCGWindowOwnerPID")
    if owner.lower().removesuffix(".app") == needle and p and int(p) not in dead:
        front = _Shim(p)        # 只需要 .processIdentifier() 的鸭子类型垫片
        break

同样的陈旧列表还会污染 verify——旧版用应用名查 pid 建 app 元素,拿到死 pid 后窗口级信号静默失效。修复是反转为从元素本体取 pid(AXUIElementGetPid 查询的是活的 AX 运行时,不可能陈旧)。

10.5 修复成绩单

指标 修复前(1.5) 修复后(1.6)
单次点击(含验证)· 前台 4500+ ms,报 ok=false 64 ms,ok=true
单次点击(含验证)· 后台 ~8800 ms,报 ok=false 84 ms,ok=true
连续 8 次数字键验证命中 0/8(全假阴性) 8/8
observe(275 元素) 463 ms 337 ms
SwiftUI 命名按钮 1/57 54/57
同 daemon 跨 app 重启 observe 0 元素(死 pid) 276 元素

每个修复点都配了回归测试(tests/test_fix_16.py 等 12 个新用例),全套 434/434 通过

十一、提速从哪来:一笔一笔算

以 HN 页面「观察 + 点一个链接」为例,QCU 单步 ~213 + 64 ms vs 视觉流派 ~4000 ms:

砍掉/换掉的环节 省下 对应实现
不截图 −284 ms 根本不进视觉管线
不上传 −数百 ms 全本地
不做 VLM 推理 −1000~3000 ms a11y 树直接可读
23.6 KB 结构化文本 vs 9 MB 图像 token 决策更快更便宜 角色分级裁剪 + compact 模式
daemon 复用进程 −300 ms/命令 第九节的 JSON-RPC daemon
role-first 遍历 1227 → 337 ms 第五节的 walk()
并发几何查询 −~300 ms 第八节的 asyncio.gather
AXPress 直达元素 5.8 ms 第六节
验证不重截图 25–55 ms 第七节状态 diff

一句话总结:不是把截图流程优化快了,而是换了信息源——把「感知」从 inference 变成 read,把「执行」从坐标猜测变成元素直达。

十二、诚实的边界

  • QCU 不替代智能。 驱动它的仍是 LLM;QCU 替换的是感知与执行通道,不是决策。
  • canvas / WebGL / 游戏没有 a11y 树(T4 层),QCU 明确报错而不是硬猜——这是它主动放弃的场景。
  • Electron 应用的 AX 树是系统级 stub(Slack/Notion 等,源码里记着 2026-08-03 对 8 款应用的实测:设置 AXManualAccessibility 返回 err=0 但树不生长)。QCU 的对策是检测到 AXWebArea 空壳后提示切换到 web 接管模式,而不是假装能操作。
  • 首次配置需要 macOS 三项 TCC 权限(Accessibility / Input Monitoring / Screen Recording),qcu doctor 会逐项探测并给出设置面板直达命令。

十三、结语

视觉流派在用一万倍于必要成本的通道,重新推导操作系统已经知道的信息。QCU 的答案朴素得几乎不像 AI 技巧:去读那棵树。在这个范式下,单步百毫秒、确定性引用、每步免费验证、本地零网络——是结构带来的,不是调优调出来的。

而这次 1.6 的破案也说明另一件事:性能工程里最贵的 bug 往往不在算法里,而在时序假设里——一个拍晚了的快照,就能让一套正确的验证逻辑永远输出错误答案。64 毫秒不是调参调出来的,是把 snapshot() 挪到 press() 前面换来的。

仓库在这里,1.3 万行代码全部可拆解:
https://github.com/ergou-yu/quick-computer-use (v1.6,MIT,含 BENCHMARK.md 完整评测报告)


测试方法备忘:web 基准用 file:// 本地表单页 + example.com + Hacker News 各 5–10 轮取 p50/p95;桌面基准用 macOS Calculator(276 元素)+ 裸 pyobjc 对照组;视觉底线用 QCU 同款 Quartz 截图管线 + Apple Vision OCR 各 5 轮;历史数据来自本机 30 天 1280 条真实遥测(qcu stats)。环境:Python 3.12 / Playwright Chromium / macOS 三项 TCC 权限齐备。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐