跳到主要内容

统一动作空间与坐标契约(一切的地基)

30 秒导读: ScaleCUA 是一个"看屏幕、动鼠标键盘"的 GUI agent。要让一个模型能操作 Ubuntu 桌面、安卓手机和浏览器,第一件事不是写代码,而是先定两条约定:模型被允许说哪些"动作词",以及动作里的坐标怎么写。这一章只讲这两条约定和它们对应的数据结构——它们是后面所有章节的地基。

本章讲清整个框架最底层的两个"宪法条款":

  1. 模型输出什么 —— 一套用 Python 函数签名声明的动作空间 DSL,外加 <think>/<operation>/<action> 三段标签格式。
  2. 坐标怎么表达 —— 动作里的 x/y 一律是相对屏幕的归一化浮点(0~1)。

外加一个承上启下的东西:代码侧的语义动作原语(带 @agent_action 的高层方法),它把"打一段字"这种人类意图,展开成一串低层动作字典。

本章只讲约定与数据结构。动作字符串如何被解析出来,见 第 3 章 之前的 第 2 章;归一化坐标如何反算回像素,见 第 3 章;一个动作字典如何落到 pyautogui / adb / playwright,见 第 4 章


1. 为什么需要"动作空间"这个东西(零基础也能懂)

先建立直觉。一个 GUI agent 的工作循环极其朴素:

看一张截图 → 想一下 → 说一句"我要点这里 / 打这段字" → 真的去点 / 去打 → 再看新截图……

问题出在中间那句"说一句"。模型是个只会吐文字的东西——它怎么"说"才能被程序可靠地执行?

  • 如果让它自由发挥(比如"点一下右上角那个蓝色按钮"),那这句话既不好解析,也没法直接驱动鼠标。
  • 如果给它一套固定的、像编程语言一样的动作词表,让它只能从里面选、并按规定填参数,那么"说"就变成了"写一小段可执行代码",程序照着执行即可。

这套固定的动作词表 + 参数规范,就是动作空间(action space)。ScaleCUA 把它写成一组 Python 函数签名,直接塞进 system prompt 告诉模型:"你只能调用这些函数,参数就这么填。"

一句话类比: 动作空间就像一份"点菜菜单"。模型不能想吃什么写什么,只能勾菜单上的菜(click / write / scroll……),还得按菜单规定填份数和口味(参数)。菜单之外的话一律无效。


2. 顶层全景:两条约定 + 一层原语

这一章覆盖三块内容,它们的关系是这样的:

┌─────────────────────────────────────────────┐
│ 约定一:动作空间 DSL(写在 system prompt 里) │
模型看到的"规则" ───► │ click/scroll/write/press/hotkey/terminate… │
│ + <think>/<operation>/<action> 输出格式 │
└───────────────────┬─────────────────────────┘
│ 模型据此吐出

<action> click(x=0.51, y=0.33) </action>

┌───────────────────┴─────────────────────────┐
│ 约定二:坐标契约 │
│ x, y ∈ [0,1] 的归一化浮点(相对屏幕宽/高) │
└───────────────────┬─────────────────────────┘

代码侧另有一层 ─────► ┌───────────────────┴─────────────────────────┐
(给规划器调用) │ 语义动作原语 @agent_action │
│ click()/type()/scroll()… 高层方法 │
│ 展开成一串低层 {name, parameters} 动作字典 │
└─────────────────────────────────────────────┘

三块各自的角色,用一句话点明:

内容是什么存在于哪服务谁
动作空间 DSL声明"模型能说哪些动作词"system prompt 文本模型(约束它的输出)
坐标契约规定动作里 x/y 的含义约定 + base_env.onScreen 校验跨平台复用
语义动作原语高层方法 → 低层动作字典base_env.py@agent_action 方法代码侧的规划/组装

下面逐块细讲。


3. 约定一:动作空间 DSL(模型输出什么)

3.1 三段 system prompt,对应三种"喂法"

ScaleCUA 把动作空间和输出格式直接写死在 system prompt 里。这些 prompt 存在 config/prompt_template/ours_ubuntu.json,一个文件里放了三段不同的 system prompt:

JSON 键引用给谁用动作空间大小输出要几段
sys_prompt_groundingconfig/prompt_template/ours_ubuntu.json:2定位器(只落点,不规划)只有 5 个鼠标动作只要 <action>
sys_prompt_planning_withoutcotconfig/prompt_template/ours_ubuntu.json:3规划但不要长思考完整(15 个动作)<operation> + <action>
sys_prompt_planning_cotconfig/prompt_template/ours_ubuntu.json:4规划 + 思维链完整(15 个动作)<think> + <operation> + <action>

怎么读这张表: 从上到下,给模型的"权限"和"话痨程度"递增。grounding 只让它做"把一句话落到坐标"的苦力活,所以动作最少、话最少;planning_cot 让它既想(think)、又说计划(operation)、又给动作(action)。这三种模式怎么被选用、规划器与定位器如何配合,见 第 2 章

3.2 动作空间长什么样:用函数签名声明

关键设计:动作空间不是散文描述,而是一组带 docstring 的 Python 函数签名。模型看到的就是"代码",于是它输出的也自然是"代码"。

以 planning 版(config/prompt_template/ours_ubuntu.json:4,sys_prompt_planning_cot)里的 click 为例,system prompt 里原样写着:

# 源自 sys_prompt_planning_cot(ours_ubuntu.json:4),原样喂给模型
def click(
x: float | None = None,
y: float | None = None,
clicks: int = 1,
button: str = "left",
) -> None:
"""Clicks on the screen at the specified coordinates. ...
the `button` parameter specifies which mouse button to use
('left', 'right', or 'middle')."""
pass

模型读完这段签名,就知道:要点击,写 click(x=..., y=..., clicks=..., button=...);x/y 是浮点、可省略;默认左键点一次。签名即契约——参数名、类型、默认值全是给模型的规则。

3.3 完整动作词表(planning 版)

sys_prompt_planning_cot / sys_prompt_planning_withoutcot 声明了完整的 15 个动作。按用途分组:

组别动作一句话职责
鼠标click doubleClick rightClick在坐标点击 / 双击 / 右键
鼠标moveTo dragTo scroll移动 / 拖拽 / 滚轮
键盘press按下并释放某键(可多次)
键盘hotkey组合键(如 Ctrl-Shift-C):顺序按下、逆序释放
键盘keyDown keyUp单独按住 / 单独释放(拆开的组合键)
文本write输入一段文本
控制wait等待若干秒让界面变化
控制response回答用户的提问
控制call_user呼叫真人接管
控制terminate结束任务,带 status(success/failure)

doubleClick / rightClick 的 docstring 明说自己是 click 的包装(如 "This is a wrapper function for click(x, y, 2, 'left')"),这暗示了后端只需实现 click,其余靠转发——具体落地见 第 4 章

sys_prompt_grounding(config/prompt_template/ours_ubuntu.json:2)的动作空间故意只留 5 个:click / doubleClick / rightClick / moveTo / dragTo。因为定位器的活儿只有"把参照表达式落到屏幕坐标",不需要打字、按键、终止这些规划级动作。

3.4 输出格式:三段标签

光有动作还不够,还得规定模型整段回复怎么排版,程序才好切割。三段 prompt 各自规定的 ## Output Format 不同:

grounding(ours_ubuntu.json:2):
<action>
[一组可执行动作]
</action>

planning_withoutcot(ours_ubuntu.json:3):
<operation>
[下一步打算做什么的自然语言描述]
</operation>
<action>
[一组可执行动作]
</action>

planning_cot(ours_ubuntu.json:4):
<think>
[推理过程]
</think>
<operation>
[下一步打算做什么]
</operation>
<action>
[一组可执行动作]
</action>

三段标签各司其职:

  • <think> —— 模型的思维链,给自己推理用,执行时丢弃。
  • <operation> —— 一句人话概括"我这步要干嘛",可读、可日志、可当历史回填。
  • <action> —— 真正要执行的动作命令,程序从这里切出可执行代码。

三段 prompt 都在 ## Note 里反复强调两条硬约束:动作必须在定义的动作空间内必须用对应标签包裹。这就是"菜单之外一律无效"的书面规定。

<action> 里那段字符串怎么被解析成结构化动作,不在本章;见 第 3 章前的解析环节与 第 2 章的主循环。

3.5 user prompt:模板里的两个占位

system prompt 定"规则",user prompt 定"这一轮的具体输入"。同一个 JSON 里给了两个 user 模板:

引用内容
user_prompt_planningconfig/prompt_template/ours_ubuntu.json:5{instruction}(任务)和 {actions}(历史操作)两个占位,让规划模型带着"任务 + 过往动作"上下文决策
user_prompt_groundingconfig/prompt_template/ours_ubuntu.json:6只有一个 {ref_expr}——定位器只需要"把这句参照表达式落到坐标",别的一概不给

对比很能说明问题:规划需要任务和历史(user_prompt_planning 有两个槽);定位只需要一句"点哪个东西"(user_prompt_grounding 就一个槽)。这再次呼应 3.1 的分工——两条链路连喂给模型的 user 上下文都是裁剪过的。


4. 约定二:坐标契约(x/y 是归一化浮点)

4.1 为什么不用像素?

回看 3.2 的签名:x: float | None。这里藏着整个框架跨平台复用的关键抽象——动作里的坐标不是像素整数,而是相对屏幕的归一化浮点,取值 0~1。

道理很直接:同一个模型要操作分辨率各异的设备——Ubuntu 桌面可能 1920×1080,安卓手机可能 1080×2400。如果模型直接吐像素,它就得知道当前屏幕多大,而且换个设备就全废。改用"占屏幕宽/高的比例"(比如 x=0.5 表示水平正中),那么同一个 0.5,在任何分辨率上都指向正中——模型的输出与设备分辨率彻底解耦。

坐标写法例子换设备后
绝对像素x=960失效,得重算
归一化浮点(本框架)x=0.5不变,永远是水平中点

4.2 契约的代码兜底:onScreen

这条约定不只是口头承诺,base_env.py 里有断言给它兜底。BaseEnv.onScreen(playground/envs/base_env.py:30-37)在判断一个坐标是否落在屏幕内时,先做这件事:

# playground/envs/base_env.py:30-37 BaseEnv.onScreen
def onScreen(self, x, y):
screen_width, screen_height = self.get_screen_size()
if isinstance(x, float) and isinstance(y, float):
assert 0 <= x <= 1 and 0 <= y <= 1 # 契约:浮点坐标必须在 [0,1]
x = round(x * screen_width) # 归一化 → 像素
y = round(y * screen_height)
return 0 <= x < screen_width and 0 <= y < screen_height

重点看两处:

  • assert 0 <= x <= 1 and 0 <= y <= 1(第 33 行)—— 只要坐标是 float,就强制它落在 0~1。越界直接崩,把"归一化"从约定变成了运行时不变量。
  • x = round(x * screen_width)(第 34 行)—— 用当前屏幕尺寸把比例反算回像素。这是"归一化坐标 → 真实像素"最朴素的一步。

注意 onScreen 里的反归一化是最简版(直接乘屏宽屏高)。真正把模型输出落到像素时,还牵涉 smart_resize(模型看到的是被缩放过的图,坐标要按缩放比换算)——那套完整逻辑是 第 3 章 的主题,本章只需知道"契约是 0~1、代码会 assert"。


5. 语义动作原语:高层意图 → 低层动作字典

5.1 两种"动作"别混淆

到这里要小心一个容易混的点。ScaleCUA 里"动作"其实有两种表示:

表示长什么样谁产生 / 谁消费
动作命令字符串click(x=0.5, y=0.3)模型吐出来的(第 3 节的 DSL)
动作字典{"name": "click", "parameters": {...}}代码内部流转、最终交给后端执行

第 3 节讲的是前者(模型侧的文本)。本节讲后者里一个特别的东西:base_env.py 里带 @agent_action 装饰器的一组高层方法。

5.2 @agent_action 是什么

它就是个标记(playground/envs/base_env.py:7-9),给函数打个 is_agent_action = True 的旗:

# playground/envs/base_env.py:7-9
def agent_action(func):
func.is_agent_action = True
return func

被它标记的方法(click / type / scroll / wait / done / fail / callUser,playground/envs/base_env.py:91-221)是面向代码调用者的高层语义动作——注意它们和第 3 节 system prompt 里的函数同名但不是一回事:这些方法接受的是自然语言的元素描述(element_description),返回的是一串低层动作字典

5.3 关键戏法:一个高层动作展开成一串低层动作

最能体现设计的是 type(playground/envs/base_env.py:119-153)。"往某个输入框打一段字并回车"这个人类意图,在真实操作里其实是三步:先点进输入框,再写字,可能还要按回车。type 就把这三步展开成一个动作字典列表:

# playground/envs/base_env.py:119-153 BaseEnv.type(节选:展开逻辑)
actions = []
if element_description is not None: # ① 若指定了目标,先点进去
actions.append(
{"name": "click", "parameters": {"x": None, "y": None, "clicks": 1, "button": "left"}}
)
actions.append({"name": "write", "parameters": {"message": text}}) # ② 再写字
if enter: # ③ 需要就补一次回车
actions.append(
{"name": "press", "parameters": {"keys": "enter", "presses": 1}}
)
return actions

一句话看懂:typeclick + write + press 三个低层动作字典。上层只管说"往这填字",拆解交给这一层。

其余 @agent_action 方法的展开关系,列成表:

高层方法引用展开成的低层动作字典
clickbase_env.py:91-117[{click}](x/y 留空 None,待定位填)
typebase_env.py:119-153[{click}?, {write}, {press:enter}?]
scrollbase_env.py:155-166[{scroll}]
waitbase_env.py:168-182[{wait: seconds}]
donebase_env.py:184-198[{terminate: status=success}]
failbase_env.py:200-212[{terminate: status=failure}]
callUserbase_env.py:214-221[{callUser}]

5.4 注意:坐标此刻是 None

再看 click(playground/envs/base_env.py:91-117)返回的字典,x/y 全是 None:

# playground/envs/base_env.py:106-116 BaseEnv.click 返回
actions = [
{"name": "click",
"parameters": {"x": None, "y": None, "clicks": num_clicks, "button": button_type}}
]
return actions

这不是 bug,是分工。这一层只负责结构(要点一下、点几下、哪个键),具体坐标由后续的定位环节填——高层方法拿到的是 element_description(一句"点哪个东西"的话),把它变成真实 x/y 是定位器的活。这条线索正好接到 第 3 章 的坐标落点。

5.5 done / fail 的一个副作用

done(playground/envs/base_env.py:184-198)除了产出 terminate 动作,还顺手把返回值存进实例:self.returned_info = return_value(第 194 行)。这说明高层原语除了"生成动作",偶尔也承担一点状态记录——done 记结果、fail 给出可用于重规划的 rationale。这类高层原语如何在主循环里被调度,见 第 5 章


6. 本章要带走的三个点

  1. 动作空间是写在 system prompt 里的一组 Python 函数签名(config/prompt_template/ours_ubuntu.json 的三段 sys_prompt_*),配 <think>/<operation>/<action> 三段标签。模型只能在这套词表内说话,菜单外一律无效。
  2. 坐标契约是 0~1 的归一化浮点,用 BaseEnv.onScreen(playground/envs/base_env.py:30-37)的 assert 兜底。它让同一段模型输出跨分辨率、跨平台复用——这是整个框架最关键的抽象。
  3. 代码侧的 @agent_action 原语(playground/envs/base_env.py:91-221)把"打一段字"这类高层意图,展开成一串 {name, parameters} 低层动作字典(如 typeclick+write+press),坐标此刻留空待填。

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

主题文件路径符号名
定位器动作空间 + 输出格式(5 动作)playground/config/prompt_template/ours_ubuntu.jsonsys_prompt_grounding
规划动作空间(无思考,15 动作)playground/config/prompt_template/ours_ubuntu.jsonsys_prompt_planning_withoutcot
规划动作空间(带思维链)playground/config/prompt_template/ours_ubuntu.jsonsys_prompt_planning_cot
规划用 user 模板(instruction/actions 占位)playground/config/prompt_template/ours_ubuntu.jsonuser_prompt_planning
定位用 user 模板(ref_expr 占位)playground/config/prompt_template/ours_ubuntu.jsonuser_prompt_grounding
坐标契约 + 归一化断言playground/envs/base_env.py:30-37BaseEnv.onScreen
语义动作标记playground/envs/base_env.py:7-9agent_action
高层点击原语(坐标留空)playground/envs/base_env.py:91-117BaseEnv.click
打字原语(展开 click+write+press)playground/envs/base_env.py:119-153BaseEnv.type
滚动原语playground/envs/base_env.py:155-166BaseEnv.scroll
等待原语playground/envs/base_env.py:168-182BaseEnv.wait
成功终止(存返回值)playground/envs/base_env.py:184-198BaseEnv.done
失败终止(带重规划理由)playground/envs/base_env.py:200-212BaseEnv.fail
呼叫真人playground/envs/base_env.py:214-221BaseEnv.callUser

下一步该读哪章: