跳到主要内容

跨平台环境层:一份动作 → pyautogui / adb / playwright

30 秒导读: 模型不会直接碰鼠标。它吐出的是一串归一化动作字典(见 01-action-space),像 {"name":"click","parameters":{"x":0.5,"y":0.5}}。 本章讲这串字典最后一公里怎么走完:同一个 click,在 Ubuntu 桌面上变成一条 pyautogui.click(...) 命令字符串远程执行,在 Android 上变成一次 adb Tap,在网页里变成一次 Playwright page.mouse.click(...)。三端后端天差地别,却共用同一套契约——这就是 ScaleCUA "一次训练、跨平台落地"能力的执行端


1. 本章讲什么(先定位)

前面几章解决的是"模型该说什么": 01 定义动作字典的形状,02 讲谁来生成它, 03 讲怎么把模型看到的图坐标还原成 0–1 的归一化落点。

到这里,手上已经有一个平台无关的动作:{"name": "click", "parameters": {"x": 0.5, "y": 0.5}}

本章只回答一个问题:

这个平台无关的动作,如何被真实的操作系统 / 设备 / 浏览器执行?

答案是一层叫 Env(环境) 的适配器。每个平台一个 Env 类,它们都继承同一个抽象基类 BaseEnv,对外暴露一模一样的方法(reset / get_obs / step),对内却把动作翻译成完全 不同的后端命令。这是典型的"统一接口 + 平台特化实现"。


2. 统一契约:BaseEnv 说清"动作长什么样、怎么被执行"

一切从抽象基类开始。playground/envs/base_env.pyBaseEnv 是三端共同的父类,它定死了两件事: 观测怎么取动作怎么执行

2.1 三个方法拼出主循环

先看这条链子(高层,不进平台细节):

Agent 决策
│ 产出 action_list = [ {name, parameters}, ... ]

env.step(action_list) # base_env.py:66 记录 history → 调 execute

├─ self.action_history.append(action_list) # 先存档,后执行

env.execute(action_list) # base_env.py:84 逐个动作循环

└─ for action in action_list:
env.execute_single_action(action) # base_env.py:79 抽象方法 —— 平台在这里各写各的

三段各自的职责,一句话讲清:

方法位置干什么
get_obsbase_env.py:55-64取观测:按需返回 screenshot(必有其一)和 a11tree,组成 dict 交给模型
stepbase_env.py:66-77先把整批动作压进 action_history,再 execute;整段 try/except,失败打栈返 False
executebase_env.py:84-86action_list 拆成单个动作,依次调 execute_single_action
execute_single_actionbase_env.py:79-82抽象方法(@abstractmethod + @timeout_decorator.timeout(10)),各平台必须实现

关键设计:execute_single_action 是唯一的抽象缝隙。 基类把"记历史、拆批次、超时保护"都做好了, 只把"一个动作到底怎么落地"留给子类。于是三端的差异全部收敛在这一个方法里——这正是本章要精读的。

小细节:step 先记 history 再执行(base_env.py:67)。这样即使执行抛异常,这一步也留在了历史里 ——评测时 evaluate 要靠 action_history[-1][-1] 判断任务是不是以 terminate 收尾 (ubuntu/ubuntu_env.py:249-261)。

2.2 动作字典从哪来:@agent_action 高层方法

BaseEnv 里还有一组带 @agent_action 装饰器(base_env.py:7-9,只是给函数打个 is_agent_action=True 标记)的方法,如 click / type / scroll(base_env.py:91-166)。它们不执行任何东西, 只负责把一次语义操作编译成上面那种动作字典列表。例如 type(text=..., enter=True) 会展开成 "click → write → press enter" 三条动作(base_env.py:134-153)。

所以数据流是:高层意图 → @agent_action 编成动作字典 → execute_single_action 落地。本章聚焦后半段。


3. 分水岭:归一化坐标在"哪一步、怎么"落地(接第 3 章)

三端最刺眼的差异之一,是那句"乘回屏幕尺寸"发生在哪第 3 章已经推导过 "归一化 0–1 坐标 × 屏幕尺寸 = 像素"的道理,这里只点出三端各自在哪一行做这件事,不重复推导:

平台反归一化发生处做法
Ubuntuubuntu_env.py:281-284(在 execute_single_action 入口)parameters["x"] * self.screen_size[0],即 0–1 × 屏宽 → 绝对像素
Webweb_env.py:696-713(在 execute_single_action 入口)不是 ×屏宽,而是把"绝对物理像素 ÷ dpr" → CSS 视口坐标(见 §6)
Android无内联反归一化坐标原样进 payload,作为 element:[x,y] 交给 env_api(inferred:期望上游已给设备像素)

一句话记住: Ubuntu 在执行入口把 0–1 乘成像素;Web 假设进来的已是物理像素,再除 dpr 换成 CSS; Android 直接透传。第 3 章讲"为什么要乘",本章讲"三端各在何处乘/除"。


4. Ubuntu 桌面:动作 → pyautogui 命令字符串 → 远端 exec

Ubuntu 是三端里工程链路最长的:动作先被拼成一条 pyautogui 的 Python 命令字符串,再通过 HTTP POST 发给运行在虚拟机里的 env-api,由它 exec 执行。我们分三层看:分发 → 拼命令 → 送执行。

4.1 分发表:一个动作名对一个 handler

UbuntuEnv.execute_single_action(ubuntu_env.py:271-317)的核心就是一张 action_handlers 字典(ubuntu_env.py:287-303),按 action["name"] 查表:

# 真实源码节选 ubuntu_env.py:287-303
action_handlers = {
"moveTo": self._execute_move_to,
"click": self._execute_click,
"write": self._execute_write,
"dragTo": self._execute_drag_to,
"press": self._execute_press,
"scroll": self._execute_scroll,
"hotkey": self._execute_hotkey,
"doubleClick": self._execute_double_click,
"rightClick": self._execute_right_click,
# ... callUser / wait / response / terminate / keyUp / keyDown
}
handler = action_handlers.get(action_type, None) # 查不到就 warning 返 False

查表前,先在 ubuntu_env.py:281-284x/y 从 0–1 乘成像素(§3 那一步)。查不到的动作名只打 Unknown action type 警告并返回 False,不崩(ubuntu_env.py:307-309)。

4.2 每个 handler = 拼一条 pyautogui 命令字符串

这是 Ubuntu 端最有意思的地方:handler 不直接调 pyautogui,而是把它拼成一段文本。看四个代表:

动作handler(位置)拼出的命令字符串
click_execute_click (:356-375)pyautogui.click(button='left', x=960, y=540, clicks=1)
write_execute_write (:390-402)pyautogui.typewrite('hello')(用 repr(message) 保证转义)
scroll_execute_scroll (:561-577)pyautogui.scroll(3, x=960, y=540)
hotkey_execute_hotkey (:542-559)pyautogui.hotkey('ctrl', 'c')(逐键做 KEYBOARD_KEYS 白名单校验)

write 为例,核心就一行(ubuntu_env.py:397):

# ubuntu_env.py:397 —— 把要输入的文本包成一句 pyautogui 调用
command = "pyautogui.typewrite({:})".format(repr(message))

repr() 在这里是安全兜底:它给字符串加引号并转义,避免文本里的引号/换行把命令字符串拼坏。 hotkey/press 更进一步,先拿 KEYBOARD_KEYS(ubuntu/vm/actions.py)白名单过滤非法键 (ubuntu_env.py:548-551),不认识的键直接拒绝。

4.3 送执行:POST 命令字符串,远端 exec

拼好的字符串统一走 _execute_single_action(ubuntu_env.py:319-329):

# ubuntu_env.py:319-329 —— 命令字符串 POST 给虚拟机里的 env-api
def _execute_single_action(self, command: str):
exec_url = f"{self.server_path}:{self.env_port}/execute"
self._request_handler(exec_url, {"command": command}, ...)
time.sleep(self.action_pause) # 每个动作后固定停顿,等界面稳定

远端 /execute 端点(ubuntu/env_api/env_api_launch.py:179-188)收到后,交给 env.controller.execute_python_command(command) 真正运行。所以那条 pyautogui.click(...) 文本是在虚拟机内部被当 Python 执行的,鼠标动的是 VM 的屏幕,不是宿主机。

一张图收束 Ubuntu 的一次 click:

{name:"click", x:0.5,y:0.5}
│ execute_single_action 入口 × screen_size → x=960,y=540 (ubuntu_env.py:281-284)

_execute_click 拼字符串 "pyautogui.click(button='left', x=960, y=540, clicks=1)" (:356-375)

_execute_single_action POST → http://host:env_port/execute (:319-329)
▼ (虚拟机内)
/execute → controller.execute_python_command(cmd) → 真·pyautogui 执行 (env_api_launch.py:179-188)

4.4 底座:env-manager 怎么把这些 env-api 拉起来

上面 POST 的目标 env_port 是谁给的?答案是一个两级进程模型:一个常驻的 env-manager 按需 孵化多个 env-api 子进程,每个占一个端口、管一台 VM。

① 本地起 manager。 UbuntuEnv.__init__ 若没给现成 server_path,就用 MANAGER_LAUNCH_SCRIPT(ubuntu_env.py:65-69)本地 subprocess.Popen 拉起 manager,起完 sleep(5)poll() 确认没秒退(ubuntu_env.py:97-104)。

② 分配空闲端口。 manager 侧 find_free_port(env_api_manager.py:44-49)在 START_PORT=10150 .. END_PORT=10199(env_api_manager.py:14-16)区间里,跳过已占端口、用 _is_free_port 探测(:37-41),挑一个空闲的。

③ 孵化 env-api。 /create_env_api(env_api_manager.py:102-141)用 LAUNCH_SCRIPT (fastapi run ... env_api_launch.py --port {},:18-22)起子进程,sleep(3)poll() 兜底判活,把 {env_id, port} 回给客户端。上限 MAX_NUM_ENV=32

④ 容错。 客户端所有请求都过 _request_handler(ubuntu_env.py:147-171):底层 request_api_wrapper重试 try_max_times 次、每次 sleep(1)(ubuntu_env.py:41-62); 彻底失败时 _request_handler 兜底去 /close 关 env、必要时 terminate() 本地 manager 进程并回收端口,避免僵尸进程和端口泄漏。

UbuntuEnv.__init__
│ MANAGER_LAUNCH_SCRIPT → Popen 起 env-manager (常驻) ubuntu_env.py:65-69,97-104

POST /create_env_api → find_free_port() 选端口 → LAUNCH_SCRIPT 孵化 env-api 子进程
│ env_api_manager.py:44-49, 102-141

拿到 {env_id, port} → 之后所有 screenshot/step/execute 都打到这个 port
全程包在 _request_handler(重试+清理) ubuntu_env.py:147-171

5. Android:动作空间为什么不一样 · 动作 → adb(经 env_api)

移动端换了个后端,也换了一半的动作AndroidEnv.execute_single_action(android_env.py:285-309) 的分发表长这样:

# android_env.py:289-301
action_dict = {
"click": self._execute_click,
"write": self._execute_write,
"swipe": self._execute_swipe,
"long_press": self._execute_long_press,
"navigate_home": self._execute_home,
"navigate_back": self._execute_back,
"open_app": self._execute_open_app,
"wait": self._execute_wait,
"terminate": self._execute_terminate,
"callUser": self._execute_callUser,
"response": self._execute_response,
}
if action_name not in action_dict:
raise Exception(f"Action {action_name} not supported.") # 注意:直接抛,不是返 False

为什么动作空间和桌面不同? 因为交互模型不同:

  • 手机没有 hotkey(组合键)、rightClick(右键)、moveTo(悬停)、scroll 滚轮——这些桌面概念在触屏上不存在。
  • 手机多出触屏/系统语义:swipe(滑动)、long_press(长按)、navigate_home / navigate_back (Home / Back 系统键)、open_app(拉起应用)——这些桌面又没有。

所以 Android 的动作字典是一套为触屏与安卓系统裁剪过的子集,和 Ubuntu 只在 click/write/wait/terminate 等少数动作上重合。

落地方式也变了:不拼 pyautogui,改发 adb 语义动作。 每个 handler 组一个 JSON payload,POST 到 env_api 的 /step,由远端把它翻成 adb 命令。例如:

动作handler(位置)POST /step 的 payload 语义
click_execute_click (:78-90)action:"Tap", element:[x,y]
swipe_execute_swipe (:106-121)action:"Swipe Precise", kwargs:{start:from_coord, end:to_coord}
long_press_execute_long_press (:123-137)action:"Long Press", element:[x,y]
navigate_home_execute_home (:139-150)action:"Home"
navigate_back_execute_back (:152-163)action:"Back"
open_app_execute_open_app (:165-179)action:"Launch", kwargs:{app:app_name}
# android_env.py:78-90 —— 一次点击 = 一个 "Tap" 语义动作发给 env_api,由它转 adb
click_payload = {"env_id": self.env_id, "action": "Tap",
"element": [parameters["x"], parameters["y"]]}
response = request_api_wrapper(f"{self.Base_url}step", json=click_payload)

注意 Android 的 execute_single_action 没有 Ubuntu 那句 x * screen_size——坐标原样进 payload。屏幕尺寸是构造时从 env_api 的 /get_screen_size 拿到并缓存(android_env.py:68-74), 反归一化的责任落在调用方(参见 第 3 章)。


6. Web 网页:动作 → Playwright · dpr 换算 · swipe = scrollBy

Web 端后端是 Playwright(浏览器自动化),它有真正的 page.mouse / page.keyboard API, 所以 handler 直接调方法、不用拼字符串也不用远程 exec。WebEnv.execute_single_action (web_env.py:669-744)的分发表(:715-732)和 Ubuntu 相似,但把 scroll 换成了 swipe, 并加了 keyup/keydown

6.1 dpr:先把物理像素换成 CSS 坐标

Web 的坐标处理和桌面方向相反。浏览器的截图是物理像素(视口 × dpr,web_env.py:290), 但 Playwright 的 page.mouse 吃的是 CSS 视口坐标。于是入口先除以 dpr:

# web_env.py:696-713 —— 绝对(物理)像素 ÷ dpr → CSS 视口坐标
if "x" in parameters and parameters["x"] is not None:
parameters["x"] = parameters["x"] / self.dpr
if "y" in parameters and parameters["y"] is not None:
parameters["y"] = parameters["y"] / self.dpr
# from_coord / to_coord 同样各自 /dpr

其中 css_width, css_height = screen_size // dpr(web_env.py:77-78),context 建立时 device_scale_factor=self.dpr(web_env.py:186-188)。这一步和 第 3 章 的"乘 screen_size"是同一根链条的两端:模型看的是物理像素图,乘回物理像素后,这里再除 dpr 落到 CSS。

6.2 handler = 直接调 Playwright

动作handler(位置)Playwright 调用
click_execute_click (:762-782)self.page.mouse.click(x, y, button=..., click_count=clicks)
write_execute_write (:784-797)self.page.keyboard.type(message)
drag_execute_drag_to (:799-818)page.mouse.down/move/up 三连
swipe_execute_swipe (:996-1045)page.mouse.wheel(...)page.evaluate("window.scrollBy(...)")

6.3 swipe 的两条路:有落点用 wheel,无落点用 scrollBy

Web 的 swipe 兼了桌面"滚动"的活,逻辑值得单看(web_env.py:996-1045):

  • 给了 from_coord/to_coord → 用两点差 delta = from - to 当滚动量,移到起点后 page.mouse.wheel(delta_x, delta_y)(:1013-1015, 1034-1036)。
  • 只给方向 direction + 幅度 amount → 按 css_height/width * amount 算距离,没有落点时用 JavaScript 滚整页:
# web_env.py:1039-1043 —— 无鼠标落点时,直接让页面滚动
js_scroll = f"window.scrollBy(0, {delta_y});" # 上下
# 或 f"window.scrollBy({delta_x}, 0);" # 左右
self.page.evaluate(js_scroll)

这就是 Web 独有的"退路":桌面靠 pyautogui.scroll 滚轮,网页则能绕过鼠标、直接命令 DOM 滚动。


7. 一张对照表:同一个抽象动作,三端各自怎么落

把三端放一起,ScaleCUA"一份动作字典、三套后端"的设计一目了然:

抽象动作Ubuntu(pyautogui)Android(adb via env_api)Web(Playwright)
后端命令字符串远程 execHTTP /step 语义动作转 adb进程内 Playwright API
坐标处理入口 ×screen_size(:281-284)透传,不换算入口 ÷dpr 物理→CSS(:696-713)
clickpyautogui.click(x,y,...)(:356)action:"Tap", element:[x,y](:78)page.mouse.click(x,y,...)(:762)
writepyautogui.typewrite(repr(msg))(:390)action:"Type", text(:92)page.keyboard.type(msg)(:784)
滚动scroll:pyautogui.scroll(:561)swipe:"Swipe Precise"(:106)swipe:mouse.wheel / scrollBy(:996)
组合键hotkey:pyautogui.hotkey(:542)无(移动端不适用)hotkey / press(:820-852)
系统导航navigate_home/backopen_app(:139-179)
未知动作warning 返 False(:307-309)抛异常(:302-303)False(:740-741)
执行后停顿sleep(action_pause)(:329)sleep(5)(:305)page.wait_for_timeout(...)(各 handler)

读法: 横看同一个动作在三端的落法;竖看每端的"性格"——Ubuntu 靠远程字符串 exec,Android 用语义动作, Web 直连自动化 API。共同点是:上游给的永远是那一个 {name, parameters} 字典,平台差异全被 Env 层吸收。


8. 边界与局限(诚实)

  • 超时是 10 秒还是 60 秒不一致。 基类抽象声明 @timeout_decorator.timeout(10)(base_env.py:80), 但 Android 子类自己标了 @timeout_decorator.timeout(60)(android_env.py:285);Web 的 execute_single_action 未见该装饰器。三端超时行为并不统一。
  • Android 未知动作会直接抛异常(android_env.py:302-303),不像 Ubuntu/Web 那样温和返 False ——同一批动作里出现平台不支持的动作,Android 会中断这一批。
  • Web 的 0–1 反归一化在别处。 execute_single_action 里那段"×css_width"是注释掉的 (web_env.py:683-693),现役代码只做 /dpr;真正把 0–1 变成物理像素这一步不在本文件,依赖上游 (参见 第 3 章)。
  • Ubuntu 命令是字符串 exec。 /execute 端点直接 execute_python_command(env_api_launch.py:185), 安全性完全依赖它只在隔离 VM 内运行;不是给不可信输入用的通用执行器。
  • Android 有重复定义。 resart(拼写如此)在 android_env.py:211:220 被定义了两次,后者覆盖前者; 属于上游代码瑕疵,不影响主路径。

9. 代码地图(导航索引)

主题文件路径符号
统一契约:观测/步进/执行playground/envs/base_env.py:55-86BaseEnv.get_obs / step / execute
唯一抽象缝隙playground/envs/base_env.py:79-82BaseEnv.execute_single_action(abstract)
动作字典编译器playground/envs/base_env.py:91-166@agent_action click / type / scroll
Ubuntu 分发表playground/envs/ubuntu/ubuntu_env.py:287-303UbuntuEnv.execute_single_action action_handlers
Ubuntu 拼 pyautoguiplayground/envs/ubuntu/ubuntu_env.py:356-577_execute_click / _execute_write / _execute_scroll / _execute_hotkey
Ubuntu 送远端 execplayground/envs/ubuntu/ubuntu_env.py:319-329UbuntuEnv._execute_single_action
Ubuntu 远端执行端点playground/envs/ubuntu/env_api/env_api_launch.py:179-188executeexecute_python_command
env-manager 端口/孵化playground/envs/ubuntu/env_api/env_api_manager.py:44-49,102-141find_free_port / create_env_api / LAUNCH_SCRIPT
客户端容错playground/envs/ubuntu/ubuntu_env.py:147-171,36-62_request_handler / request_api_wrapper
Android 分发表(动作子集)playground/envs/android/android_env.py:285-309AndroidEnv.execute_single_action action_dict
Android 转 adb 语义动作playground/envs/android/android_env.py:78-179_execute_click/_execute_swipe/_execute_home/_execute_back/_execute_open_app
Web 分发表 + dpr 换算playground/envs/web/web_env.py:669-732WebEnv.execute_single_action action_handlers
Web 调 Playwrightplayground/envs/web/web_env.py:762-797_execute_click / _execute_write
Web swipe = scrollByplayground/envs/web/web_env.py:996-1045_execute_swipe

上一章 03 定位落点:smart_resize 与坐标反归一化 · 下一章 05 运行时底座:VLM 多后端引擎、消息组装与主循环