跳到主内容
快讯直播
AI智模界
教程

用CUA-S1跑通桌面操作闭环:从截图到点击

很多桌面软件没有 API,也不给你调选择器:一个老旧的客户端、一个只能在浏览器里点出来的后台、一个每天都要手动导出一次的报表页面。CUA-S1 这类 Computer-Use Agent 的思路很直接——让模型看图,然后直接对着屏幕点。

这篇教程把整套闭环从零搭起来:截图、动作空间、坐标换算、执行器、日志,最后用一个可控的本地靶场页面验证它真的点对了地方。

适用场景

适合想给「没有接口的桌面软件」做自动化的开发者:软件的按钮位置固定、操作路径短、每天重复,但没有任何脚本入口。CUA-S1 属于 System 1 风格的单步智能体——看一眼截图,输出一个动作,执行,再看一眼,不做长链条规划。它的优势是延迟低、实现简单;代价是它不会替你拆解复杂任务,所以更适合同一路径反复执行的活,比如每天固定时间导出报表、批量改一组设置项、把一批文件按固定规则改名归档。

如果任务是「先在 A 系统查单号,再去 B 系统核对状态,最后发邮件」这种需要跨系统推理的流程,建议在外层再包一层任务分解,或者换用带规划能力的方案。

环境与前置条件

  • 操作系统:本文以 macOS 为例。屏幕录制与辅助功能的授权方式在 Windows / Linux 上完全不同,其他系统需要按官方文档替换这两块。
  • Python:3.10 及以上,具体最低版本以官方文档当前版本为准。
  • 硬件:如果模型跑在本机,内存/统一内存建议 16GB 起步,显存需求按模型参数规模计算,以官方文档给出的显存表为准;如果走远端推理接口,本机 8GB 内存也能跑通整条闭环。
  • 磁盘:预留 10GB 以上,日志和每步截图会持续增长。
  • 推理入口:一个兼容 Chat Completions 的接口(本地服务或远端都行),以及对应的 API Key。
  • 权限:需要有管理员权限,在「系统设置 → 隐私与安全性」里给终端授权。

分步骤部署

第 1 步:确认系统与 Python 环境

```bash

sw_vers

python3 --version

sysctl -n hw.memsize

```

第一条输出 macOS 版本,第二条输出 Python 版本号,第三条输出内存字节数(除以 1073741824 就是 GB)。能看到明确的版本号,说明基础环境没问题。

第 2 步:安装系统级依赖

```bash

xcode-select --install

brew install cliclick

```

第一条装 Xcode Command Line Tools,后面安装 pyobjc 之类的包会用到;已经装过会提示 already installed,属于正常。第二条是可选的备用点击工具,当 Python 侧点击不稳定时可以用命令行工具交叉验证。

第 3 步:拉取代码,建独立虚拟环境

```bash

git clone <官方仓库地址> cua-s1

cd cua-s1

python3 -m venv .venv

source .venv/bin/activate

python -m pip install -U pip

pip install -r requirements.txt

```

仓库地址从官方页面获取,不要照抄第三方转载的地址。requirements.txt 里的依赖清单以官方文档当前版本为准。示例脚本额外用到这几个包:

```bash

pip install mss pillow pyautogui requests

```

成功标志:命令行提示符前面出现 (.venv)

第 4 步:授予屏幕录制与辅助功能权限

这一步最容易漏,也最容易导致后面「截图全黑」或「点了没反应」。

1. 打开「系统设置 → 隐私与安全性 → 屏幕录制」,勾选你实际运行脚本的终端程序(Terminal、iTerm、VS Code 里的集成终端要勾 VS Code)。

2. 打开「系统设置 → 隐私与安全性 → 辅助功能」,做同样的勾选。

3. 完全退出终端再重新打开。macOS 的这类权限在进程启动时读取,只关窗口不生效。

第 5 步:准备一个可控靶场页面

不要一上来就拿生产系统练手。先做一个坐标固定、结果可读的本地页面:

```bash

mkdir -p ~/cua-lab && cd ~/cua-lab

cat > target.html <<'HTML'

<!doctype html>

<html>

<head>

<meta charset="utf-8">

<title>CUA-S1 靶场</title>

<style>

body { font: 20px/1.6 system-ui; padding: 40px; background: #fff; }

button { display: block; width: 280px; height: 72px; margin: 20px 0; font-size: 22px; }

#log { font-size: 26px; color: #0a0; margin-top: 20px; }

</style>

</head>

<body>

<h1>点击靶场</h1>

<button id="b1">按钮 A</button>

<button id="b2">按钮 B</button>

<button id="b3">按钮 C</button>

<div id="log">已点击:0</div>

<script>

let n = 0; const names = [];

document.querySelectorAll('button').forEach(b => b.addEventListener('click', () => {

n++; names.push(b.textContent.trim());

document.getElementById('log').textContent = '已点击:' + n;

document.title = 'CUA-TARGET:' + names.join(',');

}));

</script>

</body>

</html>

HTML

open -a "Google Chrome" target.html

```

页面打开后,每点一个按钮,标签页标题就会追加按钮名。这样验证就不需要靠肉眼看截图——直接读标题就行。

第 6 步:配置截图模块

新建 cua_loop.py,先写截图和坐标换算这两块:

```python

import mss

from PIL import Image

SEND_W = 1280 # 发给模型前把截图缩到这个宽度,省 token 也降延迟

SCT = mss.mss()

MON = SCT.monitors[1] # monitors[1] 是主显示器;monitors[0] 是所有屏幕拼成的虚拟桌面

def capture():

shot = SCT.grab(MON)

full = Image.frombytes("RGB", shot.size, shot.rgb)

send = full

if full.width > SEND_W:

h = round(full.height * SEND_W / full.width)

send = full.resize((SEND_W, h), Image.LANCZOS)

return send, full

def to_screen(x, y, send_w, send_h):

"""把模型在发送图上的像素坐标,换算成 pyautogui 的全局逻辑坐标"""

sx = MON["width"] / send_w

sy = MON["height"] / send_h

return round(MON["left"] + x * sx), round(MON["top"] + y * sy)

```

这里有个必须理解的点:Retina 屏上,截图拿到的像素尺寸通常是逻辑分辨率的两倍。MON["width"] 是逻辑宽度,send_w 是缩放后图像的宽度,两者一除就得到了这一层缩放系数。所有坐标都走 to_screen 换算,就不会出现「模型说点 (100,100),实际点到了别处」。

第 7 步:定义动作空间

动作空间是智能体和真实鼠标键盘之间的契约。这一版用九个动作,覆盖绝大多数桌面操作:

动作参数说明
clickx, y, button, clicks单击、双击、右键
movex, y只移动,用于触发悬停菜单
dragfrom, to拖拽,比如拖文件、拉滚动条
typetext逐字符输入,适合 ASCII
keykeys组合键,如 ["command","s"]
scrollx, y, dy在指定位置滚轮
waitseconds等界面渲染
donesummary任务结束

动作越少,模型选错的概率越低。真正需要长列表的时候再加,不要一上来堆二十个动作。

第 8 步:接入模型并固定输出格式

System 1 的关键在于提示词要死板。把动作空间写进 system 消息,并要求只输出一个 JSON:

```python

import base64, io, json, os, requests

BASE_URL = os.environ["CUA_BASE_URL"]

API_KEY = os.environ.get("CUA_API_KEY", "")

MODEL = os.environ["CUA_MODEL"] # 具体模型名以官方文档为准

TASK = "依次点击页面上的 按钮 A、按钮 B、按钮 C,全部点完后结束。"

SYSTEM_PROMPT = """你是一个桌面操作智能体,每一步只输出一个 JSON 对象,不要输出任何解释文字。

可用动作:

{"action":"click","x":int,"y":int,"button":"left|right","clicks":1}

{"action":"move","x":int,"y":int}

{"action":"drag","from":[x,y],"to":[x,y]}

{"action":"type","text":"..."}

{"action":"key","keys":["command","s"]}

{"action":"scroll","x":int,"y":int,"dy":int}

{"action":"wait","seconds":1}

{"action":"done","summary":"..."}

坐标基于随消息给出的截图,原点在左上角,单位是图中像素。"""

def ask_model(send_img, history):

buf = io.BytesIO()

send_img.save(buf, format="PNG")

b64 = base64.b64encode(buf.getvalue()).decode()

messages = [

{"role": "system", "content": SYSTEM_PROMPT},

{"role": "user", "content": [

{"type": "text", "text": f"任务:{TASK}\n已执行动作:{json.dumps(history[-6:], ensure_ascii=False)}"},

{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},

]},

]

resp = requests.post(

f"{BASE_URL.rstrip('/')}/chat/completions",

headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},

json={"model": MODEL, "messages": messages, "temperature": 0, "max_tokens": 256},

timeout=60,

)

resp.raise_for_status()

return parse_action(resp.json()["choices"][0]["message"]["content"])

def parse_action(text):

start, end = text.find("{"), text.rfind("}")

if start == -1 or end == -1:

raise ValueError(f"没有找到 JSON:{text[:200]}")

return json.loads(text[start:end + 1])

```

history 只回传最近 6 步,避免 prompt 无限膨胀;temperature 设成 0,让同一个画面尽量产出同一个动作。

第 9 步:写执行器与闭环主循环

```python

import datetime, hashlib, pathlib, time, pyautogui

pyautogui.FAILSAFE = True # 鼠标甩到屏幕左上角立即抛异常急停

pyautogui.PAUSE = 0.05

MAX_STEPS = int(os.environ.get("CUA_MAX_STEPS", "15"))

DRY_RUN = os.environ.get("CUA_DRY_RUN", "0") == "1"

RUN_DIR = pathlib.Path("runs") / datetime.datetime.now().strftime("%Y%m%d-%H%M%S")

RUN_DIR.mkdir(parents=True, exist_ok=True)

LOG = (RUN_DIR / "actions.jsonl").open("a", encoding="utf-8")

def execute(act, send_w, send_h):

name = act.get("action")

if name == "done":

print("模型判定任务完成:", act.get("summary", ""))

return True

if DRY_RUN:

print("[dry-run] 只打印不执行:", act)

return False

if name == "click":

x, y = to_screen(act["x"], act["y"], send_w, send_h)

pyautogui.click(x, y, clicks=act.get("clicks", 1), button=act.get("button", "left"))

elif name == "move":

x, y = to_screen(act["x"], act["y"], send_w, send_h)

pyautogui.moveTo(x, y)

elif name == "drag":

x1, y1 = to_screen(*act["from"], send_w, send_h)

x2, y2 = to_screen(*act["to"], send_w, send_h)

pyautogui.moveTo(x1, y1)

pyautogui.dragTo(x2, y2, duration=0.4)

elif name == "type":

pyautogui.typewrite(act["text"], interval=0.02)

elif name == "key":

pyautogui.hotkey(*act["keys"])

elif name == "scroll":

x, y = to_screen(act["x"], act["y"], send_w, send_h)

pyautogui.scroll(act.get("dy", -300), x=x, y=y)

elif name == "wait":

time.sleep(float(act.get("seconds", 1)))

else:

print("未知动作,忽略:", name)

return False

def main():

history, prev_hash, same = [], None, 0

for step in range(1, MAX_STEPS + 1):

send, full = capture()

full.save(RUN_DIR / f"{step:02d}_before.png")

cur_hash = hashlib.md5(full.tobytes()).hexdigest()

same = same + 1 if cur_hash == prev_hash else 0

prev_hash = cur_hash

if same >= 3:

print("连续 3 步画面无变化,主动退出,避免空转烧 token。")

break

try:

act = ask_model(send, history)

except Exception as e:

print(f"第 {step} 步输出无法解析({e}),本步降级为等待。")

act = {"action": "wait", "seconds": 1}

print(f"[{step}] {act}")

LOG.write(json.dumps({"step": step, "action": act}, ensure_ascii=False) + "\n")

LOG.flush()

history.append(act)

if execute(act, send.width, send.height):

break

time.sleep(0.6) # 给界面留渲染时间,这一步在 System 1 闭环里很关键

else:

print("达到最大步数上限,退出。")

if __name__ == "__main__":

main()

```

「画面哈希连续不变就退出」这个判断很值钱。没有它,模型点到空地方会一直点下去,既浪费额度也浪费时间。

第 10 步:先 dry-run,再真跑

```bash

export CUA_BASE_URL="你的推理入口地址"

export CUA_API_KEY="你的 Key"

export CUA_MODEL="以官方文档为准的模型名"

export CUA_MAX_STEPS=15

CUA_DRY_RUN=1 python cua_loop.py

```

dry-run 只打印动作不真的点。用打印出来的坐标和截图对照,确认点击位置对得上,再去掉 CUA_DRY_RUN=1 正式运行。

中文输入有个坑:typewrite 逐字符发送按键,碰到中文输入法容易乱码或丢字。稳妥做法是走剪贴板:

```python

import pyperclip

pyperclip.copy("需要输入的中文内容")

pyautogui.hotkey("command", "v")

```

验证部署是否成功

按顺序做三层验证,每一层都能单独定位问题。

第一层:截图权限。 单独跑一次截图并保存:

```bash

python -c "import mss; from PIL import Image; s=mss.mss().grab(mss.mss().monitors[1]); Image.frombytes('RGB', s.size, s.rgb).save('shot.png')"

open shot.png

```

预期结果:能看到当前屏幕的正常画面。如果是一片纯黑或者纯白,说明屏幕录制权限没生效,回到第 4 步。

第二层:坐标映射。 在脚本里临时插一段,把模型给的坐标点在图上画个十字再存下来:

```python

from PIL import ImageDraw

send, full = capture()

d = ImageDraw.Draw(send)

d.line([(send.width // 2 - 20, send.height // 2), (send.width // 2 + 20, send.height // 2)], fill="red", width=3)

d.line([(send.width // 2, send.height // 2 - 20), (send.width // 2, send.height // 2 + 20)], fill="red", width=3)

send.save("cross.png")

```

打开 cross.png,十字应当出现在截图正中央。这一步确认的是截图本身没有偏移。

第三层:闭环。 让靶场页面处于前台,正式运行:

```bash

python cua_loop.py

```

运行过程中应该看到终端逐步打印 {"action":"click","x":...,"y":...},页面上「已点击:N」的数字跟着涨。跑完后读标签页标题验证:

```bash

osascript -e 'tell application "Google Chrome" to get title of active tab of front window'

```

预期输出:CUA-TARGET:按钮 A,按钮 B,按钮 C。三个按钮名都出现,说明整条「截图 → 决策 → 点击 → 再截图」的闭环真的跑通了。首次执行这条命令时,macOS 会弹出「允许 Terminal 控制 Google Chrome」,需要手动允许。

同时检查 runs/<时间戳>/ 目录,应当有 01_before.png02_before.png 等每步截图,以及一份 actions.jsonl 动作记录。

常见报错与解决

报错 1:截图全黑或尺寸异常

```

捕捉到的图像是纯黑,或 mss 返回的 size 为 (0, 0)

```

→ 原因:运行脚本的终端没有屏幕录制权限,或授权后没有重启终端进程。

→ 解决:到「系统设置 → 隐私与安全性 → 屏幕录制」勾选对应的终端程序,完全退出并重开终端。如果反复授权都不生效,可以重置该项权限后重新授权(会清掉所有已授权程序,需逐个补回):

```bash

tccutil reset ScreenCapture

```

报错 2:pyautogui.FailSafeException

```

pyautogui.FailSafeException: The mouse was moved to a corner of the screen

```

→ 原因:鼠标被移动到了屏幕左上角,触发了 pyautogui 的急停保护。常见于脚本里有个 click(0,0) 之类的动作,或者模型把坐标算到了画面边缘。

→ 解决:把鼠标移开,检查动作日志里那一步的坐标是否合理。不建议长期关闭保护,但如果确实需要临时关掉:

```python

pyautogui.FAILSAFE = False # 只在确认坐标安全时临时使用

```

报错 3:点击位置整体偏移,总是点到隔壁按钮

```

模型返回 (420, 300),实际点到了离目标几十像素的地方

```

→ 原因:Retina 缩放或发送前的图像缩放没有参与坐标换算,模型用的是发送图上的像素坐标,执行器却按物理像素去点。

→ 解决:确认所有点击都经过 to_screen() 换算,并核对逻辑分辨率:

```bash

python -c "import pyautogui; print(pyautogui.size())"

```

打印出的应当是逻辑分辨率(例如 1512×982),而不是 3024×1964 这种物理像素值。两者不一致就说明换算里多乘或少除了一次缩放系数。

报错 4:ModuleNotFoundError: No module named 'objc' 或 pyobjc 编译失败

```

error: command 'clang' failed with exit code 1

```

→ 原因:缺少 Xcode Command Line Tools,或者当前 Python 版本不在依赖支持范围内。

→ 解决:

```bash

xcode-select --install

python -m pip install -U pip setuptools wheel

pip install pyobjc

```

仍然失败的话,换用官方文档标注支持的 Python 版本重建虚拟环境:

```bash

rm -rf .venv && python3 -m venv .venv && source .venv/bin/activate

pip install -r requirements.txt

```

报错 5:模型返回自然语言而不是 JSON

```

ValueError: 没有找到 JSON:好的,我先点击按钮 A,坐标大约在……

```

→ 原因:提示词约束不够强,或所选模型不擅长结构化输出。

→ 解决:把动作空间和「只输出 JSON」的要求写进 system 消息而不是 user 消息;如果接口支持结构化输出参数,开启它;同时在执行器里保留降级逻辑——解析失败就执行 wait 并重试一次,不要让整个循环崩掉。

报错 6:中文输入乱码或丢字

```

typewrite 输入 "你好" 结果出现 "nihao" 或空字符串

```

→ 原因:typewrite 是逐字符按键模拟,中文需要经过输入法,路径不可控。

→ 解决:改用剪贴板粘贴:

```python

import pyperclip

pyperclip.copy("要输入的中文")

pyautogui.hotkey("command", "v")

```

后续维护

备份。 需要备份的是三样东西:runs/ 目录(失败时的截图和动作序列是排查问题的依据)、环境变量文件、以及模型权重或缓存目录。日志可以定期归档后删掉原始文件:

```bash

tar -czf cua-backup-$(date +%F).tgz runs/ .env 2>/dev/null

find runs -maxdepth 1 -type d -mtime +14 -exec rm -rf {} +

```

升级。 拉新代码前先看变更说明,动作空间和配置文件格式偶尔会有不兼容改动:

```bash

git fetch && git log --oneline HEAD..origin/main

git pull

pip install -r requirements.txt

```

升级前后各跑一次靶场回归,确认三个按钮仍能依次点中,再放到真实任务上去。虚拟环境建议在跨大版本升级时重建,避免依赖漂移。

日志与监控。 actions.jsonl 里每行是一条动作记录,可以用它统计几个指标:单次任务的平均步数、每步平均耗时、失败集中在哪个动作上。如果 wait 动作的占比明显偏高,通常意味着界面渲染慢或者 time.sleep 设得太短;如果 click 反复点同一个位置,说明模型没意识到界面没变,这时候画面哈希去重的阈值可以调低一些。

安全边界。 自动化脚本跑起来之后,权限和鼠标键盘是真人级别的。几条建议:不要用管理员权限运行;把任务限定在固定的前台应用和时间窗口;永远保留急停手段(FAILSAFE、最大步数上限、画面不变即退出);先在 dry-run 模式下确认坐标,再去碰真实业务系统。系统分辨率或缩放比例一旦变化,所有历史坐标都会失效,所以换显示器、改缩放之后要重新跑一遍靶场验证。

任务本身的演进。 System 1 的单步反射能力有天花板。当任务开始需要「记住上一步查到的数字,再拿去填另一个窗口」时,就该在闭环外面加一层状态管理:把关键信息从截图里抽出来存成变量,再作为上下文喂给下一步的决策。这个改造比换更大的模型往往更划算。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。