让 AI 操作桌面程序:从「截图点击」到三层降级架构
视觉+模拟点击方案为什么在沙箱环境必然失败?本文记录一次完整的根因诊断,以及最终落地的三层降级架构(CDP 协议层 / UIA 控件层 / 视觉注入层),含实测性能数据与 17 条工程坑位。
设计一套让 AI 操作本机桌面程序的方案时,最常见的起点是「截图 → 多模态看懂界面 → 模拟鼠标点击」。这条路看起来直观,但在真实的沙箱/受限执行环境里会以最难排查的方式失败:脚本报告点击成功,屏幕上什么都没有发生。
这篇文档记录了这个问题的完整诊断过程,以及最终替换成的三层降级架构,附带实测数据与工程坑位清单。
一、缘起:一个”成功”的失败
1.1 第一版方案:视觉驱动闭环
最直觉的方案,也是社区最常见的做法:
截屏(1920×1080 PNG)
→ 多模态模型看图,判断按钮位置
→ pyautogui 移动鼠标 + 点击
→ 再截屏验证
→ 循环
技术栈:pyautogui(输入模拟)+ Pillow(图像处理)+ 窗口枚举工具。跑在一个隔离的 Python venv 里,不污染系统环境。
初测时它真的能用——点击启动器按钮、进入下一层界面都成功了。于是放心地把它固化成了技能。
1.2 转折:点击开始”失效”
两周后的测试里,同样的脚本出现了诡异现象:
| 观察项 | 表现 |
|---|---|
| 脚本输出 | clicked 33,995 —— 正常,无报错 |
| 光标实际位置 | 没有移动 |
| 屏幕上的反应 | 什么都没有 —— 用户就在屏幕前亲眼确认 |
| 单轮耗时 | 5~15 秒(截图 2MB + 多模态推理) |
最迷惑人的地方在于:调用链没有任何异常,退出码 0,日志正常。如果只看脚本输出,会以为操作成功了;只有人盯着屏幕才会发现根本没动。
二、根因诊断:三个实验定位问题
2.1 实验一:光标回读
moveTo 之后再读一次真实光标位置:
import ctypes, pyautogui
pt = ctypes.wintypes.POINT()
user32.GetCursorPos(ctypes.byref(pt))
print(f"before: ({pt.x},{pt.y})") # (1873, 0)
pyautogui.moveTo(500, 500, duration=0.2)
user32.GetCursorPos(ctypes.byref(pt))
print(f"after: ({pt.x},{pt.y})") # (1873, 0) ← 没变
结论:输入没有送达。
2.2 实验二:API 返回值与错误码
把上层封装剥掉,直接调用系统 API:
ok = user32.SetCursorPos(500, 500)
err = kernel32.GetLastError()
print(ok, err) # False 0
SetCursorPos 返回 False,但 GetLastError() == 0。
这个组合非常关键:
- 如果是权限不足(UIPI 拦截),
GetLastError通常返回5 (Access Denied); 返回失败 + 错误码为 0是 API 被 hook 后静默拒绝的典型特征——拦截者在用户态直接改写了返回值,没有走系统错误链。
2.3 实验三:排除权限因素
# 自身进程完整性级别
# → S-1-16-12288 (High Mandatory Level)
# 目标进程是否提权
kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, target_pid)
advapi32.GetTokenInformation(token, TokenElevation, ...)
# → elevated = False
自身是高完整性级别,目标是未提权进程——按 Windows 的 UIPI 规则,高完整性向中完整性发送输入是完全允许的。权限因素被彻底排除。
2.4 诊断结论
综合三个实验:
执行环境的沙箱 hook 了输入注入类 API(
SetCursorPos/SendInput),调用被静默拒绝;但读类 API(GetCursorPos)仍正常工作——可读、不可写。
这也解释了为什么第一版方案曾经能用:沙箱策略是在两次测试之间收紧的。这正是依赖”输入注入”路线的根本脆弱性——你的能力边界不由你的代码决定,而由宿主环境的安全策略决定。
一个重要的侧面观察:既然 GetCursorPos 能读到真实坐标,说明不是桌面会话隔离,而是单向的 API 拦截。这个区分决定了后续选型——不是”换个桌面跑”能解决的,必须绕开输入注入这条通道。
三、核心洞察:能不截图就不截图
诊断过程中浮现出一个更根本的效率问题:视觉路线的成本结构本身就不合理。
每让模型看一次 2MB 截图 ≈ 5~15 秒推理
而界面上的信息(按钮文本、元素位置、层级结构)本来就有结构化表示——DOM、无障碍控件树。用结构化数据替代图像推理,收益不是百分比级的:
| 操作 | 视觉 + 注入 | 协议 / 控件层 |
|---|---|---|
| 读取界面文本 | 截图 + 多模态推理 5~15s | 结构化直读 毫秒~秒级 |
| 点击元素 | 估算坐标 + 注入(被拦则不可达) | 元素级调用 确定性生效 |
| 定位精度 | 依赖视觉估算,可能偏几像素 | 精确到元素,无歧义 |
提速的本质不是”协议更快”,而是”不截图”。对话模型处理一屏文字的延迟远低于处理一张高分辨率图像。
四、三层降级架构
基于上述结论,最终方案由”一条路”改为”三层优先降级”,并在入口强制自检。
Step 0 — 强制注入自检(先于一切操作)
python diag_inject.py <目标pid>
- 退出码
0→INJECTION_OK,可用视觉闭环 - 退出码
2→INJECTION_BLOCKED,立即转入 CDP/UIA,禁止重试注入
判定原理就是 2.1 节的实验:moveTo 后回读光标坐标,未变化即注入未送达。
这个自检是整个架构的前置条件。没有它,就会在”脚本说成功、屏幕没反应”的循环里空转——这是最消耗时间的失败模式。
路线 ① — CDP 协议层(浏览器 / Electron / Chromium 内核)
适用面最广、性能最好的一层。原理是 Chrome DevTools Protocol:
外部客户端
↕ WebSocket ws://127.0.0.1:9222/devtools/page/<id>
调试服务器(运行在浏览器主进程内部)
↕ 进程内消息分发
各个窗口 / 渲染进程
关键点:点击走 element.click(),在 DOM 层完成,不经过操作系统输入通道——因此天然绕开 2.4 节的沙箱拦截。
为什么必须带参数启动? 这是最常被问到的问题:
- 调试服务器是 Chromium 在进程启动早期读取
--remote-debugging-port后创建的组件;不带参数启动的进程里它根本不存在,端口无人监听,客户端第一步握手就没有对端。 - 「事后给已运行进程开启调试端口」等价于代码注入(
CreateRemoteThread/ DLL 注入那一类)——不但会遇到同一套沙箱拦截,还属于杀软高危行为。 - Electron 本身有运行时开关(
webContents.debugger可动态 attach 自身),但需要改应用源码,外部调用者无法使用。
# 浏览器
chrome.exe --remote-debugging-port=9222 --user-data-dir=<临时目录> --remote-allow-origins=*
# Electron 应用(需先完全退出,再带参启动 —— 注意单实例锁)
YourApp.exe --remote-debugging-port=9222
能力清单:
list # 列出页面 target
attach <标题子串> # 附上已有窗口
text [selector] [limit] # 提取文本,替代截图 ★
attrs <selector> # 元素清单:tag/text/href/id/rect
click <selector> [index] # DOM 级点击,无需注入 ★
eval <js> # 任意 JS,返回 JSON
open <url> / launch <path> # 新建标签页 / 启动浏览器
路线 ② — UIA 控件层(原生 Win32 应用)
Windows 的 UI Automation 是系统级无障碍 API,特点是:外部进程随时可以对任何已开窗口做查询与操作,无需任何启动参数。点击优先用 GetInvokePattern().Invoke()(不经过鼠标),失败才回退物理坐标点击。
但对 Electron/Chromium 应用无效——Chromium 出于性能考虑默认不维护完整无障碍树(只检测到屏幕阅读器等辅助客户端时才按需渲染),实测对 Electron 应用只能读到窗口标题。这是路线 ① 存在的直接原因。
路线 ③ — 视觉 + 注入(游戏 / 自绘画面兜底)
仅当 Step 0 通过时可用。适用于没有控件树的场景(游戏画面、OpenGL/DirectX 自绘界面)。
枚举窗口 → 置前 + 截屏 → 视觉分析 → 模拟点击/键盘 → 再截屏验证
坐标规范很关键:预览图是缩略图,截图文件是全分辨率(例如预览 1080×608、实际 1920×1080,换算比例 1.778)。精确定位时先用图像库裁剪局部区域再看,避免肉眼估算偏差。
决策树
目标是什么?
├─ 浏览器 / Electron / Chromium 内核 → ① CDP 协议层 最快最准
├─ 原生 Win32 桌面应用 → ② UIA 控件层 文本替代截图
├─ 游戏 / 自绘画面(无控件树) → ③ 视觉 + 注入 仅当 Step 0 通过
└─ 以上都不行 → 如实告知用户需要手动介入
五、环境准备:幂等自举
为了在任意设备上开箱可用,环境准备写成幂等脚本,一条命令完成:
python setup_env.py # 输出 PYEXE=<venv python 绝对路径>
执行逻辑:
- 探测基础 Python:优先受管运行时(取最高版本),回退系统
PATH上的python - 创建隔离 venv(不存在时)——不污染系统环境
- 安装三层依赖:
websocket-client(CDP)/uiautomation(UIA)/pyautogui, Pillow, pygetwindow(视觉层) - 自检并输出:打印
PYEXE与屏幕分辨率,后续所有脚本统一用该解释器执行
依赖安装必须带 --no-cache-dir——受限环境下 pip 缓存写入会触发安全删除失败:
[safe-delete][SAFE_DELETE_FAIL_CLOSED] ... windows-sandbox-recycle-bin-unavailable
安装验证清单(四步,可照做)
装完之后按顺序验证,确认三层路线各自可用:
验证 1 · 环境自举
python setup_env.py
期望输出:[setup] OK + SCREEN=<宽>x<高> + PYEXE=<venv python 路径>。
拿到 PYEXE 后,后续所有脚本都用它执行(保证依赖可见)。
验证 2 · 当前环境能否走注入路线(关键)
"<PYEXE>" diag_inject.py <任意目标进程pid>
- 输出
INJECTION_OK→ 视觉层可用 - 输出
INJECTION_BLOCKED(退出码 2)→ 本环境禁用视觉注入路线,直接采用协议/控件层
这一步决定了本次任务该走哪条路线,务必先跑。
验证 3 · CDP 协议层
"<PYEXE>" cdp.py launch "<chrome路径>" --url https://example.com
"<PYEXE>" cdp.py list # 应列出一个 page target
"<PYEXE>" cdp.py text h1 # 应返回 "Example Domain"
"<PYEXE>" cdp.py click "a" # DOM 点击链接,返回 CLICKED:...
"<PYEXE>" cdp.py eval "location.href" # 若 URL 已跳转 → 点击真实生效
判定标准:Click 之后 location.href 发生变化 = 端到端打通(这一点很重要,能证明点击是真实生效而非仅 JS 状态变化)。
验证 4 · UIA 控件层
"<PYEXE>" uia.py wins # 列出顶层窗口
"<PYEXE>" uia.py text "<某个窗口名>" # dump 界面文本
对原生应用应能读到完整界面文本;若只能读到窗口标题,说明目标是 Chromium 内核应用(不暴露无障碍树)→ 回到路线 ① 用 CDP。
六、实测数据
6.1 协议层 vs 视觉层
| 操作 | 视觉 + 注入 | CDP 协议 |
|---|---|---|
| 读取界面内容 | 截图 + 推理 5~15s | 1.19s(纯文本) |
| 点击元素 | 估算坐标 + 注入,沙箱下不可达 | 1.36s,真实生效 |
| 打开页面 | — | 3.96s(含 2.5s 等待) |
数据含约 1s 的 Python 启动+建连固定开销;纯 CDP 往返在毫秒级。
6.2 Electron 应用端到端案例
对一个 Electron 桌面应用做了完整验证(应用需带调试端口重启,这是唯一需要用户配合的步骤):
list # 发现主窗口 target
eval [class*=avatar] # 模糊匹配定位用户菜单触发器
click .user-menu-trigger # DOM 点击 → 弹出面板
text .user-menu-popover # 一次读取面板全部文本
click .user-menu-trigger # 再点收起,恢复原状
结果:3 个命令、全程约 5 秒、零截图、零注入,菜单项与账户信息一次性完整读出。
一个有趣的副产品:DOM 返回的元素矩形与早期视觉方案估算的点击坐标几乎完全吻合——证明当年那套视觉定位其实是对的,败在注入通道,而非坐标精度。
可复用的 DOM 探索模式:
class 子串匹配([class*=avatar])
→ getBoundingClientRect() 定位
→ 点击触发器
→ popover 容器 innerText 整段读取
6.3 一个提取小坑
从面板中”标签 + 数值”分离的结构里取数据时,别做叶子节点遍历:标签和数值常常跨子树分布,按顺序取”下一个叶子”会拿到相邻的其它菜单项。直接读整个容器的 innerText 再按行解析更稳。
七、工程坑位清单
环境 / 安装
- pip 安装必须
--no-cache-dir,否则在受限环境下因缓存删除失败而中断 - 某些受限环境禁止运行时编译代码(如 PowerShell 的
Add-Type)→ 改用 Pythonctypes直接调用系统 API - 跨 shell 调用要当心转义:Git Bash 里
taskkill //F会报无效参数,需MSYS2_ARG_CONV_EXCL="*"加单斜杠形式 - 在受限环境里做”写测试文件再删除”的探测会失败 → 只做
os.makedirs(exist_ok=True),不做删除
注入类(最重要)
- 注入静默失败:脚本返回 clicked 但屏幕无反应 → 先跑自检,不要靠重试解决
- 判定不是权限问题(目标未提权仍失败)→ 属宿主环境拦截,只能换路线
- 64 位 Python 上手写
ctypes剪贴板调用会崩(句柄被截断为 32 位)→ 直接用pyperclip pyautogui.FAILSAFE = False必须显式设置,否则鼠标移到屏幕角落会抛异常中断自动化
CDP 类
- Chrome ≥111 WebSocket 握手 403:需要
--remote-allow-origins=*,或连接时设置suppress_origin=True(两者都做最稳) - GUI 子进程易被回收:后台方式启动的浏览器进程可能被杀 → 在同一条命令链里完成”启动 → 等待 → 操作”,或用系统级分离启动
- 本地 HTTP 请求被系统代理劫持 → 访问
127.0.0.1需ProxyHandler({})绕过代理 - 单实例锁陷阱:Chrome 与部分 Electron 应用有单实例锁,带参启动的新进程会把参数转交给旧实例后退出,调试端口永远不会打开 → 必须先完全退出旧实例
操作类
- 双实例启动会卡死部分客户端(窗口”未响应” + 内存不再增长)→ 启动前确认无残留,只启动一次
- 窗口枚举工具可能漏窗口(尤其标题为空或处于”未响应”状态)→ 直接用
EnumWindows兜底 SetForegroundWindow受前台锁限制 → 用 AttachThreadInput 技巧,并用GetForegroundWindow()验证是否真的置前成功- 流媒体/云游戏画面:窗口化模式截屏正常,DirectX 全屏独占可能截到黑屏
- 中文输入:
pyautogui.write不支持 → 走剪贴板写入 + 粘贴键
八、能力边界与安全建议
不要在这些场景指望自动化:
- UAC 弹窗:系统级安全桌面隔离,任何输入注入都无法触及 → 只能人工确认
- 管理员权限窗口:普通权限的模拟输入会被忽略
- 内核级反作弊客户端:不要做输入注入,如实告知风险并交由用户决策
必须遵守的原则:
- Electron 应用走 CDP 需重启带调试端口 → 有中断风险,必须先征得用户明确同意(若该应用正在承载当前会话,重启等于自杀)
- 支付、删除、不可逆操作 → 先读取界面文本确认状态,再征求用户同意
- 注入被阻断时不要空转重试 → 直接降级并如实说明原因
- 调试端口是完整控制通道 → 用完及时关闭;不要长期让生产应用带端口运行
九、小结
| 维度 | 结论 |
|---|---|
| 视觉 + 注入 | 直观但脆弱:慢、依赖宿主策略放行输入注入 |
| 协议 / 控件层 | 快且稳:结构化数据替代图像推理,绕开输入通道 |
| 架构选择 | 三层降级 + 入口强制自检,而非单一路线 |
| 设计心法 | 能不截图就不截图;不能读就换通道,而不是重试 |
一句话总结这次实践:当你发现”操作明明成功却毫无反应”时,问题通常不在你的代码,而在你被允许使用的通道。 承认通道的存在与边界,比优化通道内的动作更重要。
本文所述工具为一个可复用的桌面自动化技能包,包含 8 个脚本(协议驱动、控件树、自检、视觉闭环)。如果你要操作的是原生 Windows 桌面程序(没有 CDP 入口的传统软件、系统设置界面),另有一条 MCP 通道可以把它交给 AI——见《给 AI 装上操作 Windows 的手:Windows-MCP 部署、加固与实测》。