数据截至 (上游 commit 5d92feea9f1e)
两种推理模式:原生单模型 vs 规划器+定位器
30 秒导读: ScaleCUA 每走一步都要回答两个问题——"下一步该做什么"(规划)和"这个动作落在屏幕哪个坐标"(定位)。本章讲它最核心的设计取舍:让同一个模型一口气把两件事都干了(
NativeAgent),还是把这两件事拆给两个模型(AgenticWorkflow:一个规划器出自然语言指令,一个定位器出坐标)。两条路径产出的动作字典格式完全一致——地基是同一套统一动作空间。
1. 这节讲什么
上一章(统一动作空间)定义了"动作长什么样"——click(x=..., y=...)、write(message=...) 这类函数调用,以及坐标的归一化契约。
这一章讲动作是怎么被"想"出来的。给定一句任务("帮我打开设置里的蓝牙")和一张当前屏幕截图,agent 要吐出下一个动作。ScaleCUA 提供两种实现,它们最终都产出同一种动作字典,但内部分工截然不同:
| 模式 | 类 | 谁规划 | 谁定位坐标 | 典型配置 |
|---|---|---|---|---|
| 原生单模型 | NativeAgent | 一个 ScaleCUA 模型 | 同一个模型 | 端到端一次调用 |
| 规划器+定位器 | AgenticWorkflow | 规划器(可为 GPT-4o) | 另一个 ScaleCUA 定位模型 | 两次调用,分工 |
一句话直觉: 原生模型像一个人同时"想动作 + 用眼睛瞄准";规划器+定位器像一个"军师"只说"点那个蓝牙开关",再由一个"神枪手"专门把准星对到像素上。前者省一次网络往返,后者让你能拿最强的语言模型当军师、拿最会看图的模型当枪手。
本章聚焦消息怎么组、response 怎么拆、动作字典怎么拼。坐标的 smart_resize 与反归一化数值细节留给第 3 章;底层消息如何逐条 append、call_llm_safe 怎么重试留给第 5 章。
2. 两条路径一眼对比
先看全景。两条路径都实现了同一个方法签名 predict(instruction, observation, env) -> (info, actions),但内部数据流不同。
┌─────────────────────────────────────────────┐
任务 + 截图 ───► │ NativeAgent.predict │
│ │
│ [system prompt] + [截图 + 文字历史] │
│ │ │
│ ▼ 一次 call_llm_safe │
│ <think> <operation> <action> │
│ │ │
│ parse_response → parse_action │
│ │ │
│ 坐标就在 <action> 里,自己反归一化 │
└──────────────────┼──────────────────────────┘
▼
动作字典 list
┌─────────────────────────────────────────────┐
任务 + 截图 ───► │ AgenticWorkflow.predict │
│ │
│ planner(GPT-4o) ──► "# 点蓝牙开关" │
│ + click(x,y) │
│ │ │
│ ▼ 把这行指令交给定位器 │
│ grounder(ScaleCUA) 看截 图 ──► (x, y) │
│ │ │
│ parse_action 用 grounder 的坐标回填 │
└──────────────────┼──────────────────────────┘
▼
动作字典(单个)
怎么读这张图: 上半是"一个模型两件事全干";下半是"规划器只出一行文字指令 + 一个坐标是占位的动作,真坐标由定位器补"。核心区别就一句——坐标从哪来:原生模型自己产坐标,规划器+定位器把坐标外包给第二个模型。
3. NativeAgent:一个模型同时规划与定位
源码:playground/agents/native_agent.py。这是最直接的一条路:模型既懂任务、又懂屏幕,一次调用把 think / operation / action 全给你。
3.1 __init__:按 enable_thinking 选 system prompt
构造时最关键的一步——根据配置里的 enable_thinking 开关,决定给模型灌带思考链还是不带思考链的系统提示:
# native_agent.py:48-56 NativeAgent.__init__
if engine_params["enable_thinking"]:
self.planner = self._create_vlm_api(
self.prompt_template["sys_prompt_planning_cot"], self.engine_params
)
else:
self.planner = self._create_vlm_api(
self.prompt_template["sys_prompt_planning_withoutcot"],
self.engine_params,
)
self.user_instruction = self.prompt_template["user_prompt_planning"]
两个 system prompt 都从 prompt_template JSON 里取(如 config/prompt_template/ours_ubuntu.json,含 sys_prompt_planning_cot / sys_prompt_planning_withoutcot 两把钥匙)。CoT 版本要求模型按固定格式输出三段——<think> 推理、<operation> 下一步意图、<action> 可执行命令。
self.user_instruction 是每一步都会复用的用户消息模板,里面有 {instruction} 和 {actions} 两个占位符(见 3.3)。
3.2 predict:只保留 system + 当前截图
predict(第 70-107 行)是每一步的入口。它有一个容易被忽略但很关键的动作——每次只保留 system 消息,把上一轮的用户消息(含旧截图)全丢掉:
# native_agent.py:73-83 NativeAgent.predict
if len(self.planner.messages) > 1:
self.planner.messages = [self.planner.messages[0]] # 只留 system,丢掉旧的截图/消息
self.planner.add_message(
text_content=self.format_history(instruction, self.history), # 历史用"文字"带,不带旧图
image_content=observation["screenshot"], # 只喂"当前"这一张截图
role="user",
)
response = call_llm_safe(self.planner)
thought, low_level_instruction, actions = self.parse_response(response)
self.history.append(low_level_instruction)
为什么这样设计? 多模态历史里塞一堆旧截图,又贵又容易让模型分心。ScaleCUA 的取舍是:视觉只保留"此刻"这一帧,历史用纯文字描述(见 3.3 的 Previous operations)。这让每步的上下文短而聚焦。
拿到原始 response 后,parse_response 抽出三段,parse_action 把 <action> 里的函数调用变成动作字典,最后对坐标做反归一化(x /= grounding_width)。归一化的数值细节见第 3 章;这里只要记住:原生模式下坐标是模型直接产的,predict 自己负责除以宽高还原成 0~1 比例。