数据截至 (上游 commit 5d92feea9f1e)
跨平台环境层:一份动作 → pyautogui / adb / playwright
30 秒导读: 模型不会直接碰鼠标。它吐出的是一串归一化动作字典(见 01-action-space),像
{"name":"click","parameters":{"x":0.5,"y":0.5}}。 本章讲这串字典最后一公里怎么走完:同一个click,在 Ubuntu 桌面上变成一条pyautogui.click(...)命令字符串远程执行,在 Android 上变成一次 adbTap,在网页里变成一次 Playwrightpage.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.py 的 BaseEnv 是三端共同的父类,它定死了两件事:
观测怎么取、动作怎么执行。
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_obs | base_env.py:55-64 | 取观测:按需返回 screenshot(必有其一)和 a11tree,组成 dict 交给模型 |
step | base_env.py:66-77 | 先把整批动作压进 action_history,再 execute;整段 try/except,失败打栈返 False |
execute | base_env.py:84-86 | 把 action_list 拆成单个动作,依次调 execute_single_action |
execute_single_action | base_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 坐标 × 屏幕尺寸 = 像素"的道理,这里只点出三端各自在哪一行做这件事,不重复推导:
| 平台 | 反归一化发生处 | 做法 |
|---|---|---|
| Ubuntu | ubuntu_env.py:281-284(在 execute_single_action 入口) | parameters["x"] * self.screen_size[0],即 0–1 × 屏宽 → 绝对像素 |
| Web | web_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-284 把 x/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) |
|---|---|---|---|
| 后端 | 命令字符串远程 exec | HTTP /step 语义动作转 adb | 进程内 Playwright API |
| 坐标处理 | 入口 ×screen_size(:281-284) | 透传,不换算 | 入口 ÷dpr 物理→CSS(:696-713) |
| click | pyautogui.click(x,y,...)(:356) | action:"Tap", element:[x,y](:78) | page.mouse.click(x,y,...)(:762) |
| write | pyautogui.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/back、open_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-86 | BaseEnv.get_obs / step / execute |
| 唯一抽象缝隙 | playground/envs/base_env.py:79-82 | BaseEnv.execute_single_action(abstract) |
| 动作字典编译器 | playground/envs/base_env.py:91-166 | @agent_action click / type / scroll |
| Ubuntu 分发表 | playground/envs/ubuntu/ubuntu_env.py:287-303 | UbuntuEnv.execute_single_action action_handlers |
| Ubuntu 拼 pyautogui | playground/envs/ubuntu/ubuntu_env.py:356-577 | _execute_click / _execute_write / _execute_scroll / _execute_hotkey |
| Ubuntu 送远端 exec | playground/envs/ubuntu/ubuntu_env.py:319-329 | UbuntuEnv._execute_single_action |
| Ubuntu 远端执行端点 | playground/envs/ubuntu/env_api/env_api_launch.py:179-188 | execute → execute_python_command |
| env-manager 端口/孵化 | playground/envs/ubuntu/env_api/env_api_manager.py:44-49,102-141 | find_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-309 | AndroidEnv.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-732 | WebEnv.execute_single_action action_handlers |
| Web 调 Playwright | playground/envs/web/web_env.py:762-797 | _execute_click / _execute_write |
| Web swipe = scrollBy | playground/envs/web/web_env.py:996-1045 | _execute_swipe |
上一章 03 定位落点:smart_resize 与坐标反归一化 · 下一章 05 运行时底座:VLM 多后端引擎、消息组装与主循环