引子:为什么拿视频 App 当试金石
晚上十点,我瘫在沙发上,对电脑说了一句:「打开视频 App,看看我在追什么,然后去电影频道挑一部评分 9 分以上、符合我口味的片子,直接播。」我的 AI 助手答应了——然后卡住了。
说句「符合我口味」听起来轻巧,背后是 LLM 在做画像推断和选片决策。其中「从候选片库里挑出最对味的几部」这层能力——拿口味描述和片库做混合检索(关键词 + 语义)——正是 RAG 的典型场景,我在另一个开源项目 langchain-llm-toolkit 里做了完整实现;这套混合检索的设计我单独写过一篇 混合检索架构:当关键词遇上语义理解,感兴趣可以配合这篇一起看。
视频 App 是 macOS 桌面 Agent 比较难处理的一类应用:界面大量自绘,AX 树可能几乎为空,所有按钮都藏在像素后面。对一个只会「读 AX 树、按节点点击」的助手来说,它是个黑盒。盯着失败的日志,我意识到:如果助手连视频 App 都操作不了,它就不是「能操作 Mac 的助手」,只是「能操作部分原生应用的玩具」。
我拿视频 App 当试金石。不是因为追剧有多重要,而是它代表了最坏情况:不给你任何辅助接口,只有像素。啃下它,意味着这套方案终于不再局限于那些 AX 做得规规矩矩的原生应用;啃不下,桌面智能助手就永远停在 demo 阶段。这篇文章讲的就是我为啃下它而逼出来的双通道架构——如果你也想让 AI 操作那些 AX 靠不住的 Mac 应用,思路是一样的。
出发点
我卡在两件事上:一个是要让 LLM「能点能填」,另一个是 Mac 上各种应用的 UI 实现方式天差地别——有的老老实实走 AX,有的自己画得干干净净,AX 树几乎是空的。
最开始我以为只要调通 AX 就够了。toolClick、toolTypeText、toolFocus 这三件套,语义层完全成立:找到节点、执行 action、刷新大纲。但真的跑起来就撞了墙:treeOf(pid, 10) 在视频 App 和音乐 App 上返回的 AX 节点要么为空,要么全是无意义的容器。LLM 在那上面什么都找不到,findNodes 返回空,toolClick 直接报「没有找到,先 read_screen」。
所以我加了第二通道:合成 HID 坐标操作。toolClickAt、toolDoubleClickAt、toolDrag、toolRightClickAt、toolScroll、toolScrollTo——这一批直接往屏幕坐标打事件。两条通道共享同一个 SessionState:pid 标记当前目标应用,outline 存 AX 树的扁平化结果,lastObservedAt 记录最近一次主动观察的时间戳,任何输入类工具执行前都会先过 staleObservation 的检查。
这套设计不是什么预先规划出来的架构,而是被真实场景一步步逼出来的。五个具体的坑,下面逐个说。
踩坑:从一次 35 步的视频挑片任务里踩出来的五层防御
引子里那句「直接播」,落地是一条 35 步的操作链。这五个坑不是通用架构课,而是我从视频 App 的任务日志里一个个踩出来的——每一个都对应一次真实的卡死或误判。
坑一:语义通道失灵——AX 树为空,或给了却拒绝
第一道坎是语义通道:LLM 想按节点操作,但视频 App 的 AX 树几乎为空,treeOf(pid, 10) 拿不到任何可用的节点——这是语义通道失灵的第一种形态。第二种形态更隐蔽:Finder 的侧边栏是个典型的坑。treeOf(pid, 10) 返回的节点里有 AXPress/AXOpen,findNodes 能匹配到「访达」「文档」这些关键词,一切看起来很顺。但 performAction(pid, path, "AXPress", ...) 执行的时候,Finder 直接返回 -25205。这个错误码在 AX 文档里没有明确说明,实测发现是 Finder 内部对某些自建元素的拒绝。我的 toolClick 最初直接把这个错误往上抛,LLM 拿到一个 AXPress failed: -25205 就懵了——它不知道这是「元素不支持点击」还是「权限问题」还是「系统 bug」。
所以我改了 toolClick 的错误处理:
try {
await performAction(state.pid, target.path, target.actions[0], ...);
} catch (e) {
return {
result:
`点击「${target.label}」(${target.actions[0]})失败:${e instanceof Error ? e.message : String(e)}。` +
`元素可能已变化或实际不支持该动作——先 read_screen 刷新大纲重新定位,或改用 click_at 按坐标点击。`,
state,
};
}
这个错误信息的生成是个反复迭代的过程。最初的版本只打印了 raw error,LLM 读到 -25205 还是不知道怎么办。后来我加上了「元素可能已变化或实际不支持该动作」这样的提示,明确告诉 LLM 两条出路:要么重新 read_screen 刷新大纲,要么降级到 click_at 按坐标点。这一步是「纯 AX 单通道」走向双通道的真正起点——自绘 UI 应用(视频 App、音乐 App)的 AX 树几乎为空,findNodes 找不到任何东西,不依赖 AX 树的坐标通道是唯一的出路。
这里有个值得单独拎出来的原则:底层错误不应该直接暴露给 LLM,而应该被转换成下一步可执行的恢复路径。 LLM 不需要理解 -25205 是「不支持点击」还是「权限问题」还是「系统 bug」,它只需要知道接下来该做什么。
坑二:坐标漂移——窗口一动,坐标全废
坐标操作本身带来新问题:窗口一挪、分辨率一变、LLM 猜的坐标就飞了。那次 35 步任务里,我中途把窗口最大化,之后所有旧坐标全部失效,每一步都要重新 ocr 定位——这是我第一次意识到坐标通道需要一层「守卫」。LLM 猜的坐标 (x, y) 是基于它看到的屏幕分辨率和窗口位置来的。如果用户中途拖动了窗口、或者系统分辨率变了(比如外接显示器插拔),模型给的坐标就可能落在目标窗口外面——甚至落在另一个应用上。最严重时 LLM 连续点了三次,每次坐标都偏移 50px,第三次点到了系统设置面板。
clampToWindow 就是解决这个问题的。它拿目标的 windowBounds,把模型给的 (x, y) 钳到窗口的实际 frame 内:
export async function clampToWindow(
pid: number | null,
x: number,
y: number,
): Promise<{ x: number; y: number; note: string }> {
if (pid === null) return { x, y, note: "" };
try {
const b = await windowBounds(pid);
if (!b) return { x, y, note: "" };
const cx = Math.min(Math.max(x, b.x), b.x + b.w - 1);
const cy = Math.min(Math.max(y, b.y), b.y + b.h - 1);
if (cx !== x || cy !== y) {
return {
x: cx,
y: cy,
note: `(坐标 (${Math.round(x)}, ${Math.round(y)}) 不在目标应用窗口内,已校正为 (${Math.round(cx)}, ${Math.round(cy)}))`,
};
}
} catch {
/* 拿不到窗口就不校正 */
}
return { x, y, note: "" };
}
注意这个函数的返回值带了 note 字段——这是给 LLM 看的,让它知道发生了什么。最初的版本直接把坐标改掉就不管了,LLM 发现自己猜的 (100, 200) 变成了 (95, 180) 但没有任何提示,它会误以为自己的推理是准确的,下一轮继续猜同样的错误坐标。加了 note 之后,LLM 能收到类似「坐标不在窗口内,已校正为…」的反馈,下一轮就会调整策略。函数本身也用 try/catch 吃掉了 windowBounds 可能失败的情况——应用刚启动还没有 frame,拿不到就不校正,保持无感。
坑三:context 爆炸——35 步任务把历史喂爆
chat.ts 里的 modelToolResult 就是从这里长出来的。最初我把每次 read_screen 的完整输出都喂回 LLM:引子里那个视频挑片任务跑完 35 步,历史消息从 2000 字符膨胀到 5 万多字符(每步 read_screen 约 1500 字符,35 步就是 52500)。免费端点的 token 用量暴涨,响应延迟从 2 秒拖到 12 秒,context 窗口早就爆了。
export const MAX_TOOL_RESULT_LEN = 2000;
const TOOL_RESULT_TAIL_LEN = 300;
export function modelToolResult(result: string, limit = MAX_TOOL_RESULT_LEN): string {
if (limit <= 0 || result.length <= limit) return result;
const headLen = Math.max(limit - TOOL_RESULT_TAIL_LEN, 0);
const head = result.slice(0, headLen);
const tail = result.slice(-Math.min(TOOL_RESULT_TAIL_LEN, limit));
const omitted = result.length - head.length - tail.length;
return `${head}\n…(结果过长,已截断 ${omitted} 字符;需要完整内容再单独读取)…\n${tail}`;
}
这个截断策略是我反复试出来的。最初试过「只保留前 N 字符」,LLM 丢失了尾部结论,经常误判任务完成;也试过「随机采样」,破坏了 AX 树的层级结构,关键词匹配失效。最终的方案是保留头部 + 尾部 + 截断标记:头部有树根和关键路径,尾部常有「计数/汇总/状态检查」之类的结论,中间用省略号标注截断了多少字符。LLM 收到这种消息后,如果需要完整内容,会主动调 read_screen 重新获取特定节点。
MAX_TOOL_RESULT_LEN 的默认值是 2000,但它不是写死的——可以在设置里调。不同任务的 AX 树深度差异很大:简单应用的 outline 可能只有 300 字符,复杂 IDE 的 outline 单步就能到 5000 字符。把默认值设在 2000,是实测里 80% 任务的单步输出落在这个区间。
坑四:outline 失效——一次失败的 AX 调用清空整个 session
坑一的失灵是「树是空的」,坑四是「树坏了」。视频 App 之外,我也一直在音乐 App 上复测同类的自绘 UI——refreshOutline 最初是每次动作执行完无条件调用的,有一次在音乐 App 上,fetchTree 返回了旧数据(应用内部状态变了但 AX 树没刷新),我拿这个旧 outline 去找节点,findNodes 找到了已不存在的元素,performAction 失败,toolClick 返回错误,但 state.outline 已经被覆盖成无效数据。后续的 toolTypeText 再用这个 outline 找输入框,全部报错。
排查了 30 分钟才发现是 outline 被清空了——fetchTree 在某个时间点挂了,整个 session 就废了。修复方式是给 refreshOutline 加 try/catch 保底:
export async function refreshOutline(state: SessionState, depth = 10): Promise<SessionState> {
if (state.pid === null) return state;
try {
return markObserved({ ...state, outline: await treeOf(state.pid, depth) });
} catch {
return state;
}
}
失败就保留旧状态,不让 LLM 拿到脏数据。每次动作执行完都会调 refreshOutline 尝试重刷大纲,失败则保留旧状态——这是从多次 crash 里学到的,不能因为一次 AX 调用失败就把整个 session 清空。
坑五:离线快捷指令的静默截断
前四层防御都在线场景。离线快捷指令是另一个极端:不接 LLM,只执行一条预置命令。当消息包含后续步骤(比如「打开日历,用 ocr 查看今天的日期」),离线模式只能执行前半部分,后面的步骤最初会被静默丢掉——用户不知道发生了什么。后来加了 tailNote,明确告诉用户哪些步骤没执行、怎么补救:
const tailNote = tail
? `\n\n📎 这条消息还包含后续步骤(「${tail.slice(0, 40)}${tail.length > 40 ? "…" : ""}」)。离线快捷指令只能执行单条命令;配置 LLM 后重新发送,我会完整执行。`
: "";
一个提示,把「系统没响应」变成「下一步该做什么」。
这五个坑不是同时想清楚的,是一个个踩完坑之后才改的。每一条都是在原来的方案走到死胡同之后才换的——纯 AX 不够用时加坐标通道,坐标不准时加 clampToWindow 的 note 反馈,context 膨胀时加 modelToolResult 截断,AX 偶尔挂时加 refreshOutline 的 try/catch,静默截断丢失上下文时加 tailNote。这五层防御,全都是在啃视频 App 这类自绘 UI 应用时被逼出来的。每一层调整都在把原来的「假设 AX 稳定存在」改成「AX 随时可能失败」,这也是双通道架构最核心的设计哲学:不信任单一通道的可靠性,用冗余和降级来兜底。
验证
跑完引子里那个 35 步视频挑片任务、花了整整一下午之后,我回过头看这五个补丁,发现每一层都对应着一次真实的崩溃或误判。验证方式各不相同:有的靠手动复现,有的靠日志统计,有的靠刻意触发边界条件——但没有验证过的补丁,我不会放心合进主线。
坐标漂移。 我写了一个简单的测试:窗口原本左边缘在 x=50,我执行 click_at 60 200(距左边缘只有 10px),然后手动把窗口向右拖 50px(左边缘到 x=100),再执行同样的命令。没有 clampToWindow 的时候,第二次点击落在窗口外面,打到了桌面;加了 clamp 之后,返回值从 (60, 200) 变成了 (100, 200),同时附带了 note:
(坐标 (60, 200) 不在目标应用窗口内,已校正为 (100, 200))
LLM 收到这条 note 后,下一轮会重新 read_screen 获取新的窗口位置,而不是继续盲猜。这个验证很简单——就是手动拖窗口、重复执行同一个命令、看点击落在哪里。但它是整个坐标通道的基石。
AX 操作的容错性。 我在 Finder 侧边栏上反复执行 toolClick,每次都会触发 -25205 错误。最初的错误处理只返回 raw error,LLM 拿到 -25205 就停了,不再尝试任何操作。改成现在的错误信息后:
点击「访达」(AXPress)失败:action failed: -25205。
元素可能已变化或实际不支持该动作——先 read_screen 刷新大纲重新定位,或改用 click_at 按坐标点击。
LLM 的下一步行为完全变了:它会先 read_screen 刷新大纲,如果还是找不到,就降级到 click_at。这个行为转变是实打实的——我记录了 10 次同样的操作,改之前 5 次直接卡死,改之后 8 次能自动降级到坐标点击。
context 膨胀。 加上 modelToolResult 截断后重跑那个 35 步任务:历史消息长度稳定在 8000 字符以内,响应延迟回到 3 秒左右(截断前的对比数据见坑三)。数据来自 sessionTranscript 导出的日志——logSession 会把每次对话追加到日志文件,直接 cat 看文件大小即可。两个常量的取值也不是拍脑袋定的:35 步任务里 80% 步骤的 read_screen 输出落在 300-2000 字符之间,只有少数复杂 IDE 的 outline 会超过 2000。
outline 失效。 这是最隐蔽的坑。我在音乐 App 上执行了一系列操作,发现 toolTypeText 突然全部报「没有找到文本输入区」。排查发现,fetchTree 在某次调用后返回了空数组,但 refreshOutline 无条件更新了 state.outline,导致后续所有基于 outline 的操作都失效。改成 try/catch 保底之后,我再跑同样的流程,fetchTree 挂掉时保留了旧的 outline,toolTypeText 还能继续工作。这个验证需要复现——我刻意让 fetchTree 在某些条件下失败(比如应用刚启动还没初始化完),看 session 会不会崩溃。
盲操作风险。 staleObservation 的核心逻辑是:
const STALE_OBSERVATION_MS = 60_000;
export function staleObservation(state: SessionState): string | null {
if (state.lastObservedAt === null) {
return "⚠️ 还没有任何界面观察快照(未 ocr / read_screen)。坐标与输入类操作是盲操作,请先 ocr 或 read_screen 再继续。";
}
const age = Date.now() - state.lastObservedAt;
if (age > STALE_OBSERVATION_MS) {
return `⚠️ 界面快照已超过 ${Math.round(age / 1000)}s 未刷新,坐标与输入类盲操作可能已落在过期画面上。请先重新 ocr 或 read_screen,再继续。`;
}
return null;
}
我写了个测试脚本:调用 toolClickAt 但不先调 ocr 或 read_screen,结果直接返回了拒绝消息。再把 lastObservedAt 设成 60 秒前,再次调用,同样被拒绝。这 60 秒阈值是我实测后定的——LLM 平均一轮操作链在 30-60 秒内完成,超过这个时间窗口,界面状态可能已经变了,继续盲点风险太大。
让 Agent 能操作,不等于让 Agent 随便操作
读到这里你可能已经想到那个问题了:一个能「点任何东西」的助手,交到日常工作的手里会不会太危险?安全设计是双通道之外的另一条底线,三条硬规则:
- 危险操作需要确认:删除、发送、登出这类不可逆动作,Agent 会停下等你点头,不会自己执行;
- 密码框拒绝自动输入:任何密码输入框都不会被自动填充,密码相关操作一律交还给你;
- 敏感目录需要授权:扫描个人目录这类行为,必须先经过你的授权。
这三条不是事后补丁,而是架构层面就定下的约束——「能操作」和「能随便操作」之间,隔着一道必须由人确认的门。
结果
这不是单个按钮的 Demo,而是一条连续 35 步的真实操作链。跑了 35 步任务之后,我终于看到这套架构真正跑起来的样子。
视频 App 里,LLM 能先用 read_screen 拿到 AX 树(几乎为空),然后直接用 click_at 点播放按钮;音乐 App 的歌词搜索框,它能通过 findNodes 匹配到 AXTextArea 角色,走 toolTypeText 把文本塞进去;Finder 侧边栏那些返回 -25205 的节点,LLM 会自动降级到坐标点击。这三类场景覆盖了我这次测试中最初以为「做不了」的主要问题。
不过这三个场景只是验证集——ax-agent 的定位从来不是「某个视频 App 的助手」,而是 README 里写的那句话:一个通用的 UI 观察 + 控制底座,不是快捷指令工具。除了这条 35 步的智能链路,日常用得最多的反而是最轻的离线 Chat:命令在本地解析执行,不接任何 LLM、不联网,数据完全不出进程,「打开备忘录」「点开文件传输助手」就是一句话的事。Smart Mode 不绑定具体厂商,任何 OpenAI 兼容 API(DeepSeek / Qwen / Ollama / LM Studio)都能接;Inspector 调试模式可以逐个元素查看属性、手动驱动。
整套东西最终打包成一个不到 9MB 的 Tauri 本地应用,双击装上就能用,源码见 ax-agent。
但回头看,这套架构不是预先规划出来的——它是被上面那五个坑一步步逼出来的。我现在对这件事的理解,可以浓缩成一句话:当你在操控一个不可靠的外部系统时,不要试图修复它的每个故障点,而是设计多通道的冗余和显式的降级路径——让失败可感知、可回退、可告知。
源码导航
项目仓库:github.com/erishen/ax-agent
src-tauri/src/ax_act.rssrc-tauri/src/ax_core.rssrc/chat.tssrc/llm.tssrc/tools/input.tssrc/tools/shared.ts
为什么需要「语义 AX 操作 + 合成 HID 事件」双通道架构?
Mac 上自绘 UI 的应用(如视频 App、音乐 App)AX 树几乎为空,LLM 调用 findNodes 返回空、toolClick 直接报错。双通道让 LLM 在 AX 找不到的场景下能降级到坐标操作,避免卡死。
LLM 给出的坐标因为窗口拖动或分辨率变化而偏移怎么办?
clampToWindow 会把模型给的 (x, y) 钳到目标窗口的实际 frame 内,并通过返回值里的 note 字段告知 LLM 发生了什么,避免模型误以为自己的推理准确、下一轮继续猜同样的错误坐标。
如何防止 LLM 在没有任何界面快照的情况下持续盲点盲输?
staleObservation 机制:距最后一次 ocr / read_screen 超过 60 秒(STALE_OBSERVATION_MS = 60_000),坐标类和输入类工具直接拒绝执行并返回提示。这个阈值来自实测中 LLM 平均一轮操作链的量级。
为什么 read_screen 的输出会被截断?
早期版本把完整输出喂回 LLM,35 步任务后历史消息从 2000 字符膨胀到 5 万多字符,token 用量暴涨、响应延迟从 2 秒拖到 12 秒。现在的策略是保留头部(树根和关键路径)+ 尾部(计数/汇总/状态检查)+ 省略号标注截断量,LLM 如需完整内容会主动重新调用 read_screen。MAX_TOOL_RESULT_LEN 默认 2000 可在设置中调整。
AX 调用失败时 session 会崩溃吗?
不会。refreshOutline 用 try/catch 保底——fetchTree 失败时保留旧的 outline,不让无效数据覆盖 session 状态,后续基于关键词的操作(如 toolTypeText 找输入框)还能继续工作。这是从音乐 App 上「outline 被清空、整个 session 报废」的真实事故里学到的。
发表回复