1. 当 AX 接口开始"装死",AI 操作 macOS 的 Plan B 该怎么走
做过 macOS 自动化的人大概都经历过这种时刻:脚本昨天还跑得好好的,今天突然就卡在某个按钮上死活点不动。你打开日志一看,AX(Accessibility,辅助功能)接口返回的元素树要么是空的,要么层级乱得离谱,要么干脆超时。这不是你的代码写错了,而是 macOS 上 AX 这套东西本身就不太靠谱——不同应用对 AX 的支持程度参差不齐,系统版本一升级接口行为就可能变,某些 Electron 应用、游戏引擎渲染的界面、自绘控件更是几乎不给 AX 面子。
这篇内容聊的就是这个问题:当 AX 不可靠时,怎么让 AI(尤其是基于 LLM 的 Agent)继续操作 macOS。核心思路是从"纯 AX 驱动"退回到"视觉 + 坐标"这条更底层、更通用的路径,用截图理解界面、用坐标执行点击,把 AX 从"唯一依赖"降级为"锦上添花的加速器"。适合正在做 macOS 自动化、AI Agent、RPA 工具,或者单纯想让 LLM 帮忙操作电脑的开发者参考。不管你是刚接触这块,还是已经被 AX 折磨过几轮,下面这些踩坑经验和实操细节应该都能用得上。
需要先说明一点:这里讨论的"操作 macOS"指的是在你自己拥有或被授权使用的设备上做自动化,比如自动化测试、个人效率工具、无障碍辅助等场景。所有方案都建立在合法合规、用户知情的前提下。
2. 先搞清楚 AX 到底为什么会"不可靠"
2.1 AX 的本质:应用主动"汇报"的界面描述
AX 不是系统去"读"界面,而是应用主动把界面元素的结构、角色、位置、可执行动作汇报给系统。你通过 AX API 拿到的元素树,本质上是应用开发者愿意暴露给你的那部分信息。这就决定了一个根本问题:应用不汇报,你就什么都拿不到。
原生 AppKit 应用通常汇报得比较完整,因为苹果的控件默认就带 AX 支持。但一旦开发者用了自绘控件、自定义渲染,或者干脆没实现 AX 相关接口,元素树里就会出现"黑洞"——你能看到有个窗口,但窗口里啥都没有。
2.2 几种典型的 AX 失效场景
我把实际遇到过的 AX 失效情况归了几类,方便你对号入座:
| 场景类型 | 典型表现 | 常见应用 |
|---|---|---|
| 元素树为空 | AXUIElement 查询返回空数组 | 部分 Electron 应用、游戏 |
| 层级错乱 | 元素嵌套关系与实际视觉不符 | 自绘列表、虚拟滚动 |
| 位置滞后 | 元素 position 与实际渲染位置对不上 | 动画、异步加载界面 |
| 动作无效 | 拿到元素但 AXPress 没反应 | 自定义按钮、非标准控件 |
| 查询超时 | AX 调用卡住甚至拖垮整个进程 | 复杂界面、远程桌面 |
Electron 应用是重灾区。它本质上是套了个浏览器内核,界面是网页渲染的,AX 支持要靠 Electron 自己桥接,桥接质量参差不齐。你经常会遇到"能看到按钮文字,但拿不到可点击的元素"这种尴尬情况。
2.3 为什么不能"修好 AX"就完事
有人会想,那我针对每个应用写适配不就行了?理论上可以,但成本极高:应用一升级、系统一更新,适配就可能失效。而且对于 AI Agent 这种要"通用操作任意界面"的场景,你不可能给每个应用都写一套 AX 适配。所以更务实的做法是:AX 能用就用,不能用就退到视觉 + 坐标,把两条路都留着。
3. 视觉 + 坐标方案的整体设计思路
3.1 核心闭环:截图 → 理解 → 定位 → 执行
抛开 AX,AI 操作 macOS 的最小闭环其实很朴素:
- 截图:用系统 API 抓取当前屏幕或指定窗口的图像
- 理解:把截图交给 LLM(多模态模型),让它识别界面元素和可操作区域
- 定位:把 LLM 输出的语义描述转换成屏幕上的具体坐标
- 执行:用坐标驱动鼠标点击、键盘输入
这个闭环不依赖任何应用的 AX 支持,只要界面能显示在屏幕上,就能操作。代价是每一步都比 AX 慢、比 AX 模糊,所以工程上的重点就是怎么把模糊的坐标定位做得足够准。
3.2 坐标系的坑:Retina、缩放、多显示器
坐标这块第一个大坑就是坐标系不统一。macOS 上至少涉及三套坐标:
- 逻辑坐标(Point):应用和大多数 API 用的坐标,单位是 point
- 物理像素(Pixel):实际屏幕像素,Retina 屏上通常是逻辑坐标的 2 倍
- 截图坐标:截图工具输出的图像坐标,取决于你按什么分辨率截
如果你用screencapture截了一张 2x 的图,LLM 在图里识别到按钮在 (800, 600),但实际点击要用逻辑坐标,你就得除以缩放系数。多显示器场景更麻烦,每个屏幕的缩放可能不一样,还有主屏偏移。
提示:统一坐标的最佳实践是全程用逻辑坐标。截图后先记录这张图对应的逻辑区域(origin + size),LLM 返回的图像坐标再按比例映射回逻辑坐标,避免中间反复换算。
3.3 为什么选"坐标点击"而不是"模拟事件"
macOS 上模拟点击有几种方式:CGEvent 直接投递事件、AppleScript 的 click、AX 的 AXPress。当 AX 不可用时,CGEvent 是最通用的选择,因为它是在系统事件层注入的,不关心目标应用是否支持 AX。
CGEvent 的关键是坐标要准。它接受的是全局逻辑坐标(原点在主屏左上角),所以你要把目标元素的坐标换算成全局坐标。这里有个容易忽略的点:CGEvent 的坐标原点和你截图时的原点可能不一致,尤其是多屏时主屏不在最左边的情况,一定要先确认主屏位置。
4. 把截图喂给 LLM:提示词和输出格式的设计
4.1 让 LLM 输出"可执行的坐标"而不是"描述"
很多人第一次做视觉 Agent,提示词写的是"请描述这个界面"。结果 LLM 给你一段优美的文字描述,你还得再解析一遍。正确的做法是直接要求结构化输出,让 LLM 返回 JSON,包含元素名称、类型、坐标、置信度。
一个实测好用的提示词骨架大概是这样:
你是一个界面操作助手。我会给你一张 macOS 屏幕截图。 请找出图中所有可交互元素(按钮、输入框、菜单、链接等), 对每个元素返回: - name: 元素上的文字或功能描述 - type: button/input/menu/link/other - bbox: [x1, y1, x2, y2] 归一化到 0-1 的边界框 - confidence: 0-1 的置信度 只返回 JSON 数组,不要额外解释。用**归一化坐标(0-1)**而不是绝对像素,好处是截图分辨率变了也不用改提示词,映射回逻辑坐标时乘一下就行。
4.2 归一化坐标到逻辑坐标的换算
假设截图对应的逻辑区域是(originX, originY, width, height),LLM 返回的 bbox 是归一化的[x1, y1, x2, y2],那么中心点的逻辑坐标是:
cx = originX + (x1 + x2) / 2 * width cy = originY + (y1 + y2) / 2 * height这段换算看着简单,但origin 一定要算对。如果你截的是某个窗口而不是全屏,origin 就是窗口左上角的全局坐标,不是 (0, 0)。我见过太多人在这里栽跟头,点击总是偏,最后发现是 origin 搞错了。
4.3 处理 LLM 的"幻觉坐标"
多模态模型给坐标时经常有偏差,尤其是小元素、密集排列的工具栏。几个缓解手段:
- 要求返回 bbox 而不是单点,取中心点比让模型直接猜点更稳
- 让模型标注置信度,低于阈值的元素走二次确认或换策略
- 对关键操作做"点击后验证",点完再截一张图确认状态变了
注意:不要盲目相信 LLM 的坐标。它给的是"大概在这附近",不是像素级精确。对精度要求高的场景,可以结合模板匹配或 OCR 做二次校正。
5. 坐标定位的精度补救:OCR 与模板匹配
5.1 用 OCR 给 LLM 的坐标"校准"
LLM 说按钮在 (800, 600),但你知道它可能偏个十几像素。这时候可以用 OCR(比如系统自带的 Vision 框架,或者 Tesseract)在截图里找到按钮文字的实际位置,用 OCR 的结果覆盖 LLM 的坐标。文字类元素用这招特别有效,因为 OCR 对文字的定位通常比多模态模型准。
流程是:LLM 识别出"这里有个叫'提交'的按钮" → OCR 在截图里搜"提交" → 拿到 OCR 的精确 bbox → 用这个 bbox 算坐标。两者结合,既有 LLM 的语义理解,又有 OCR 的定位精度。
5.2 模板匹配处理图标类元素
图标按钮没有文字,OCR 帮不上忙。这时候可以用模板匹配:提前存好常用图标的截图模板,运行时在当前截图里做匹配。OpenCV 的matchTemplate就够用。
模板匹配的坑在于缩放和主题。同一个图标在 Retina 和非 Retina 下尺寸不同,浅色和深色主题下颜色不同。解决办法是准备多套模板,或者匹配前先做归一化处理。实测下来,模板匹配适合"固定不变的图标",对动态界面还是得靠 LLM。
5.3 三种定位方式的取舍
| 方式 | 精度 | 速度 | 适用场景 |
|---|---|---|---|
| LLM 视觉 | 中 | 慢 | 通用、未知界面 |
| OCR | 高 | 中 | 文字类元素 |
| 模板匹配 | 高 | 快 | 固定图标 |
实际工程里通常是组合使用:LLM 负责"理解界面、决定点哪",OCR 和模板匹配负责"把坐标定准"。单靠任何一种都不够。
6. 执行层:CGEvent 点击与键盘输入的实操细节
6.1 用 CGEvent 模拟鼠标点击
Python 里可以用Quartz框架调 CGEvent,核心代码大概长这样:
import Quartz def click_at(x, y): point = Quartz.CGPointMake(x, y) down = Quartz.CGEventCreateMouseEvent( None, Quartz.kCGEventLeftMouseDown, point, Quartz.kCGMouseButtonLeft) up = Quartz.CGEventCreateMouseEvent( None, Quartz.kCGEventLeftMouseUp, point, Quartz.kCGMouseButtonLeft) Quartz.CGEventPost(Quartz.kCGHIDEventTap, down) Quartz.CGEventPost(Quartz.kCGHIDEventTap, up)kCGHIDEventTap是事件注入点,用这个位置注入的事件最接近真实硬件输入,兼容性最好。点击之间加个几十毫秒的延迟,模拟真实人手速度,能减少被应用"忽略"的概率。
6.2 键盘输入与快捷键
键盘输入同样用 CGEvent,但要注意输入法状态。如果当前是中文输入法,你注入的字符可能被输入法拦截。稳妥的做法是操作前先切到英文输入法,或者用CGEventKeyboardSetUnicodeString直接注入 Unicode 字符串,绕过输入法。
快捷键(比如 Cmd+C)要分别注入按下和抬起事件,注意 modifier 标志位:
def key_combo(key_code, flags): down = Quartz.CGEventCreateKeyboardEvent(None, key_code, True) Quartz.CGEventSetFlags(down, flags) up = Quartz.CGEventCreateKeyboardEvent(None, key_code, False) Quartz.CGEventSetFlags(up, flags) Quartz.CGEventPost(Quartz.kCGHIDEventTap, down) Quartz.CGEventPost(Quartz.kCGHIDEventTap, up)6.3 权限:绕不过去的辅助功能授权
不管用 AX 还是 CGEvent,都需要辅助功能(Accessibility)权限。第一次运行会弹窗要求授权,用户得手动去"系统设置 → 隐私与安全性 → 辅助功能"里勾选你的程序。这一步没法自动化,是 macOS 的安全设计。
提示:开发时如果改了程序签名或路径,权限可能会失效,需要重新授权。用稳定的签名和固定路径能减少这种反复。
7. 让 AI Agent 自己决定"用 AX 还是用坐标"
7.1 双通道设计:AX 优先,坐标兜底
最实用的架构是双通道:Agent 先尝试 AX,拿到元素就直接操作,快且准;AX 拿不到或操作失败,自动降级到视觉 + 坐标。这样既保留了 AX 的效率,又有了坐标的通用性。
判断降级的条件可以设几个:AX 查询返回空、AX 操作后界面无变化、AX 调用超时。任何一个触发,就切到视觉通道。
7.2 用"操作后验证"驱动决策
Agent 每执行一步,都应该截图验证结果。点了个按钮,界面变了吗?输入了文字,输入框里有内容吗?验证通过就继续,失败就换策略重试。这个"执行-验证-重试"的循环是让 Agent 稳定的关键,比任何单点技术都重要。
验证本身也可以交给 LLM:把操作前后的两张截图给它,问"操作是否生效"。虽然慢,但对复杂界面很有效。
7.3 状态记忆:别让 Agent 每次都从零开始
一个成熟的 Agent 应该记住"这个应用 AX 好用""那个按钮坐标大概在哪"。把这些经验缓存下来,下次遇到同样的界面直接复用,能大幅提速。缓存要带失效机制,比如应用版本变了、窗口大小变了就清掉。
8. 实测中那些让人抓狂的边界情况
8.1 动画和异步加载
界面还在动画时截图,LLM 看到的可能是"半透明""错位"的状态,坐标自然不准。稳妥做法是操作前等界面稳定:连续截两张图,如果两张几乎一样,说明动画停了,再开始识别。
8.2 弹窗和焦点抢占
系统弹窗、通知、权限请求会突然冒出来抢焦点,把你的点击引到错误的地方。Agent 要有"异常检测":操作前先看看有没有意外弹窗,有就先处理掉。这个逻辑写起来烦,但不写的话线上会各种翻车。
8.3 多显示器和全屏应用
多屏时坐标原点在主屏,副屏的坐标可能是负数。全屏应用会隐藏菜单栏,截图区域和窗口坐标都要重新算。这些情况建议单独测试,别指望一套逻辑通吃。
8.4 性能:别让 Agent 慢到没法用
视觉方案每一步都要截图 + 调 LLM,一次操作可能好几秒。优化方向:截图只截变化区域、LLM 调用做缓存、简单操作走本地 OCR 不走大模型。实测下来,把常用操作缓存后,整体速度能提升好几倍。
9. 一些踩过坑之后才明白的经验
第一,别追求 100% 自动化。视觉 + 坐标方案本质是"概率性"的,总会有失败的时候。设计时就要考虑"失败了怎么办"——是重试、是报警、还是交回给人。把失败处理做好,比追求成功率更重要。
第二,日志要记全。每次操作都存下截图、LLM 返回、实际坐标、执行结果。出问题时这些日志就是你的救命稻草,能快速定位是识别错了还是执行错了。
第三,坐标点击要留"安全边距"。别精确点元素边缘,往中心偏一点,容错率更高。尤其是小按钮,边缘差几个像素就点空了。
第四,先在小范围验证再放大。新写一个操作流程,先手动跑通、再半自动、最后全自动。直接上全自动,出了问题你都不知道是哪一步崩的。
这套方案我自己在几个项目里跑下来,稳定性比纯 AX 高不少,代价是速度慢一些。对于"操作频率不高、但要求通用"的场景,视觉 + 坐标是更务实的选择。AX 能用的时候当然还是优先用,把它当成加速器而不是唯一依赖,心态就对了。