AX接口不可靠时,AI如何用视觉+坐标操作macOS
1. 当 AX 接口开始装死AI 操作 macOS 的 Plan B 该怎么走做过 macOS 自动化的人大概都经历过这种时刻脚本昨天还跑得好好的今天突然就卡在某个按钮上死活点不动。你打开日志一看AXAccessibility辅助功能接口返回的元素树要么是空的要么层级乱得离谱要么干脆超时。这不是你的代码写错了而是 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 核心闭环截图 → 理解 → 定位 → 执行抛开 AXAI 操作 macOS 的最小闭环其实很朴素截图用系统 API 抓取当前屏幕或指定窗口的图像理解把截图交给 LLM多模态模型让它识别界面元素和可操作区域定位把 LLM 输出的语义描述转换成屏幕上的具体坐标执行用坐标驱动鼠标点击、键盘输入这个闭环不依赖任何应用的 AX 支持只要界面能显示在屏幕上就能操作。代价是每一步都比 AX 慢、比 AX 模糊所以工程上的重点就是怎么把模糊的坐标定位做得足够准。3.2 坐标系的坑Retina、缩放、多显示器坐标这块第一个大坑就是坐标系不统一。macOS 上至少涉及三套坐标逻辑坐标Point应用和大多数 API 用的坐标单位是 point物理像素Pixel实际屏幕像素Retina 屏上通常是逻辑坐标的 2 倍截图坐标截图工具输出的图像坐标取决于你按什么分辨率截如果你用screencapture截了一张 2x 的图LLM 在图里识别到按钮在 (800, 600)但实际点击要用逻辑坐标你就得除以缩放系数。多显示器场景更麻烦每个屏幕的缩放可能不一样还有主屏偏移。提示统一坐标的最佳实践是全程用逻辑坐标。截图后先记录这张图对应的逻辑区域origin sizeLLM 返回的图像坐标再按比例映射回逻辑坐标避免中间反复换算。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 字符串绕过输入法。快捷键比如 CmdC要分别注入按下和抬起事件注意 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 能用的时候当然还是优先用把它当成加速器而不是唯一依赖心态就对了。