← 知识库 / 技术

让 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 节的沙箱拦截。

为什么必须带参数启动? 这是最常被问到的问题:

  1. 调试服务器是 Chromium 在进程启动早期读取 --remote-debugging-port 后创建的组件;不带参数启动的进程里它根本不存在,端口无人监听,客户端第一步握手就没有对端。
  2. 「事后给已运行进程开启调试端口」等价于代码注入(CreateRemoteThread / DLL 注入那一类)——不但会遇到同一套沙箱拦截,还属于杀软高危行为。
  3. 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 绝对路径>

执行逻辑:

  1. 探测基础 Python:优先受管运行时(取最高版本),回退系统 PATH 上的 python
  2. 创建隔离 venv(不存在时)——不污染系统环境
  3. 安装三层依赖:websocket-client(CDP)/ uiautomation(UIA)/ pyautogui, Pillow, pygetwindow(视觉层)
  4. 自检并输出:打印 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~15s1.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 再按行解析更稳。


七、工程坑位清单

环境 / 安装

  1. pip 安装必须 --no-cache-dir,否则在受限环境下因缓存删除失败而中断
  2. 某些受限环境禁止运行时编译代码(如 PowerShell 的 Add-Type)→ 改用 Python ctypes 直接调用系统 API
  3. 跨 shell 调用要当心转义:Git Bash 里 taskkill //F 会报无效参数,需 MSYS2_ARG_CONV_EXCL="*" 加单斜杠形式
  4. 在受限环境里做”写测试文件再删除”的探测会失败 → 只做 os.makedirs(exist_ok=True),不做删除

注入类(最重要)

  1. 注入静默失败:脚本返回 clicked 但屏幕无反应 → 先跑自检,不要靠重试解决
  2. 判定不是权限问题(目标未提权仍失败)→ 属宿主环境拦截,只能换路线
  3. 64 位 Python 上手写 ctypes 剪贴板调用会崩(句柄被截断为 32 位)→ 直接用 pyperclip
  4. pyautogui.FAILSAFE = False 必须显式设置,否则鼠标移到屏幕角落会抛异常中断自动化

CDP 类

  1. Chrome ≥111 WebSocket 握手 403:需要 --remote-allow-origins=*,或连接时设置 suppress_origin=True(两者都做最稳)
  2. GUI 子进程易被回收:后台方式启动的浏览器进程可能被杀 → 在同一条命令链里完成”启动 → 等待 → 操作”,或用系统级分离启动
  3. 本地 HTTP 请求被系统代理劫持 → 访问 127.0.0.1 需 ProxyHandler({}) 绕过代理
  4. 单实例锁陷阱:Chrome 与部分 Electron 应用有单实例锁,带参启动的新进程会把参数转交给旧实例后退出,调试端口永远不会打开 → 必须先完全退出旧实例

操作类

  1. 双实例启动会卡死部分客户端(窗口”未响应” + 内存不再增长)→ 启动前确认无残留,只启动一次
  2. 窗口枚举工具可能漏窗口(尤其标题为空或处于”未响应”状态)→ 直接用 EnumWindows 兜底
  3. SetForegroundWindow 受前台锁限制 → 用 AttachThreadInput 技巧,并用 GetForegroundWindow() 验证是否真的置前成功
  4. 流媒体/云游戏画面:窗口化模式截屏正常,DirectX 全屏独占可能截到黑屏
  5. 中文输入:pyautogui.write 不支持 → 走剪贴板写入 + 粘贴键

八、能力边界与安全建议

不要在这些场景指望自动化:

  • UAC 弹窗:系统级安全桌面隔离,任何输入注入都无法触及 → 只能人工确认
  • 管理员权限窗口:普通权限的模拟输入会被忽略
  • 内核级反作弊客户端:不要做输入注入,如实告知风险并交由用户决策

必须遵守的原则:

  • Electron 应用走 CDP 需重启带调试端口 → 有中断风险,必须先征得用户明确同意(若该应用正在承载当前会话,重启等于自杀)
  • 支付、删除、不可逆操作 → 先读取界面文本确认状态,再征求用户同意
  • 注入被阻断时不要空转重试 → 直接降级并如实说明原因
  • 调试端口是完整控制通道 → 用完及时关闭;不要长期让生产应用带端口运行

九、小结

维度结论
视觉 + 注入直观但脆弱:慢、依赖宿主策略放行输入注入
协议 / 控件层快且稳:结构化数据替代图像推理,绕开输入通道
架构选择三层降级 + 入口强制自检,而非单一路线
设计心法能不截图就不截图;不能读就换通道,而不是重试

一句话总结这次实践:当你发现”操作明明成功却毫无反应”时,问题通常不在你的代码,而在你被允许使用的通道。 承认通道的存在与边界,比优化通道内的动作更重要。


本文所述工具为一个可复用的桌面自动化技能包,包含 8 个脚本(协议驱动、控件树、自检、视觉闭环)。如果你要操作的是原生 Windows 桌面程序(没有 CDP 入口的传统软件、系统设置界面),另有一条 MCP 通道可以把它交给 AI——见《给 AI 装上操作 Windows 的手:Windows-MCP 部署、加固与实测》。