QCU skill
让电脑 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 的常驻 Chromium(qcu/layers/browser_daemon.py):端口从 9222 向上探空位、subprocess.Popen 带 start_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> 给的是 int,aria-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 权限齐备。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)