跳到主要内容

两种推理模式:原生单模型 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 比例

3.3 format_history:把历史拼成 Previous operations

历史不靠旧截图,靠文字。format_historyself.history 里累积的每一条 low-level 指令编号拼成列表,填进用户模板:

# native_agent.py:130-140 NativeAgent.format_history
if len(history) > 0:
actions_history = [
f"Step {i+1}: {low_level}" for i, low_level in enumerate(history)
]
else:
actions_history = None
return self.user_instruction.format(
instruction=instruction,
actions="\n".join(actions_history) if actions_history is not None else None,
)

模板(user_prompt_planning)长这样,{actions} 就是上面拼出的 Step 1: ... / Step 2: ...:

Please generate the next move according to the UI screenshot, the task and previous operations.

Task:
{instruction}

Previous operations:
{actions}

注意 self.history 里 append 的是 low_level_instruction,也就是 <operation> 那段——模型上一步"打算做什么"的自然语言描述,而不是动作命令本身。历史因此是人话链条,不是 click(...) 链条。

3.4 parse_response:正则抽三段标签

CoT 输出被三对 XML 式标签包着,parse_response 用三条正则分别抠出来:

# native_agent.py:109-128 NativeAgent.parse_response
action_matches = re.findall(r"<action>\s*(.*?)\s*</action>", response, re.DOTALL)
# ... 把每个 <action> 块按行 split,去空行,汇进 actions 列表
operation_match = re.search(r"<operation>\s*(.*?)\s*</operation>", response, re.DOTALL)
think_match = re.search(r"<think>\s*(.*?)\s*</think>", response, re.DOTALL)
return (think, operation, actions)

三个返回值:think(思考,只用来记日志/回传 agent_info)、operation(下一步意图,进历史)、actions(一个字符串列表,每个元素是一行 func(args))。<action>findall 而非 search,且按换行拆——所以一步可以带多个动作

3.5 parse_action:把 func(args) 解析成动作字典

parse_action(第 142-196 行)是本类里最"手工"的部分:纯正则把 click(x=100, y=200) 这类字符串拆成 {"name": "click", "parameters": {...}}。它要应付三种参数写法。

先抓函数名和括号内内容:

# native_agent.py:145-150 NativeAgent.parse_action
match = re.match(r"(\w+)\((.*)\)", action)
func_name = match.group(1) # 如 "click"
args_str = match.group(2) # 如 "x=100, y=200"

然后分三种情形解析 args_str:

情形触发条件正则/逻辑例子
① 列表关键字参数= 且值是 [...](\w+)=\[([^\]]+)\] 抠出方括号,再逐项拆引号keys=['ctrl','c']
② 标量关键字参数= 的其余项(\w+)=([^,)]+),再按 isdigit / float / true-false 转型x=100hold=true
③ 纯位置参数不含 =`'…'"…"

情形 ① 和 ② 是两趟扫描同一个 args_str:先把所有 key=[list] 收走(第 153-165 行),再扫 key=scalar,并用 if param in args: continue 跳过已经被当列表处理过的键(第 167-183 行)。标量还会尝试转成 int / float / bool:

# native_agent.py:174-183 NativeAgent.parse_action(标量转型)
if value_str.isdigit():
value = int(value_str)
elif value_str.replace(".", "", 1).isdigit():
value = float(value_str)
elif value_str.lower() in ("true", "false"):
value = value_str.lower() == "true"
else:
value = value_str.strip("\"'") # 其余按字符串,剥掉引号

情形 ③(第 185-193 行)针对没有 = 号的调用,把所有位置实参收进一个 args["args"] 列表。最终每个动作变成 {"name": func_name, "parameters": args}

一个坑: 第 146-147 行,若某一行不匹配 \w+(...),parse_action 直接 return None——整批动作作废,而不是跳过这一行。格式不合规的容错很硬。


4. AgenticWorkflow:规划器 + 定位器

源码:playground/agents/agentic_workflow.py。这条路把"想"和"瞄"拆开:一个规划器模型(配置里可以是 GPT-4o)只输出一行自然语言 step 指令 + 一个坐标待填的动作;再交给一个独立的定位器(UIGroundingAgent,内部是 ScaleCUA 视觉模型)把坐标补上。

4.1 构造:两套 engine params、两个模型

# agentic_workflow.py:42-45 AgenticWorkflow.__init__
self.planner = self._create_vlm_api(
self.planner_prompt_template["sys_prompt_planning_cot"]
)
self.grounder = UIGroundingAgent(self.grounder_engine_params)

规划器和定位器各有各的 prompt_templatebase_url(见第 6 节配置对照)。规划器只需要一个 sys_prompt_planning_cot;定位器封装在 UIGroundingAgent 里(第 5 节)。

4.2 predict:规划一行 → 定位坐标 → 回填

predict(第 57-90 行)是本模式的主线。和 NativeAgent 不同,它保留整段消息历史(逐轮 append,不清空),只在第一步把任务塞进 system prompt:

# agentic_workflow.py:60-83 AgenticWorkflow.predict
if self.step_count == 0:
for msg in self.planner.messages:
if msg["role"] == "system":
msg["content"][0]["text"] = msg["content"][0]["text"].format(
task_instruction=instruction
) # 任务只在第一步注入 system
break

self.planner.add_message(
text_content=self.format_history(), # 一句静态的 "下一步做什么?"
image_content=observation["screenshot"],
role="user",
put_text_last=True,
)
response = call_llm_safe(self.planner)
thought, low_level_instruction, action = self.parse_response(response)
corrds = self.grounder.generate_coords(low_level_instruction, observation) # ← 定位外包
action = self.parse_action(action, corrds) # ← 用返回坐标回填

关键三步:

  1. 规划器出指令。 parse_response 从 response 里拆出一行 # 指令 和一行动作(见 4.3)。
  2. 定位器出坐标。low_level_instruction(那句人话,如"点击蓝牙开关")+ 当前截图喂给 grounder.generate_coords,拿回一个 (x, y)
  3. 回填。 parse_action 把规划器那行动作里占位的坐标换成定位器给的真坐标。

规划器的动作里坐标其实是"占位"的——真正的坐标永远由定位器说了算。注意 format_history 在本类里(第 161-163 行)只是原样返回一句静态用户提示,并不拼历史步骤(历史靠保留的消息链本身承载)。

4.3 parse_response:强制两行

规划器被要求输出恰好两行——第一行 # 开头的指令,第二行动作。parse_response(第 92-118 行)强校验这个格式:

# agentic_workflow.py:98-115 AgenticWorkflow.parse_response
code_block_match = re.search(r"```python(.*?)```", output, re.DOTALL) # 可能裹在代码块里
code_content = code_block_match.group(1).strip() if code_block_match else output.strip()

lines = [line.strip() for line in code_content.split("\n") if line.strip()]
if len(lines) != 2:
raise ValueError("Output must contain exactly 2 lines (instruction + action).")

instruction_line, action_line = lines
if not instruction_line.startswith("#"):
raise ValueError("First line must be an instruction (starting with #).")
instruction = instruction_line[1:].strip()
return None, instruction, action_line.strip()

对比 NativeAgent.parse_response 的宽松(抠到几段算几段),这里非常严:不是正好两行、第一行不是 # 开头,直接抛异常。第一个返回值恒为 None(本模式不回传 think),第二个是那句去掉 # 的指令,第三个是动作行。

4.4 parse_action:用定位器坐标回填 x/y

parse_action(第 120-159 行)先把动作行 func(...) 解析成 {name, parameters}(逻辑比 NativeAgent 简单,只处理 key=value 逗号分隔),最后一步才是本模式的灵魂——若动作里出现了 xy,就用定位器返回的 coord 覆盖:

# agentic_workflow.py:127-157 AgenticWorkflow.parse_action
match = re.match(r"^(\w+)\((.*)\)$", action_line.strip())
action_type = match.group(1)
args_str = match.group(2)
params = {}
for pair in [p.strip() for p in args_str.split(",") if p.strip()]:
key, value = pair.split("=", 1)
# ... 数值/字符串/None/True/False 转型 ...
params[key.strip()] = value

if "x" in params or "y" in params:
params["x"], params["y"] = coord[0], coord[1] # ← 规划器写的坐标被无条件替换

return {"name": action_type, "parameters": params}

只要动作声明了坐标参数,规划器自己写的那对 x/y 就被丢弃、换成定位器的结果。这正是"规划归规划、定位归定位"的落点:规划器根本不用把坐标算准,它只要说清"点哪个东西"

值得一提的坑:第 149-154 行对 None/True/False 用了 eval(value) 转型,注释里自己都标了 careful with security!——这是把模型输出当代码求值,信任边界很宽。


5. UIGroundingAgent:把一句指令变成一个点

源码:playground/agents/ui_grounding.py。它是 AgenticWorkflow 的定位器实现——输入一句 step 指令 + 截图,输出一个坐标点。

5.1 generate_coords:喂指令+截图,抽点

# ui_grounding.py:55-72 UIGroundingAgent.generate_coords
self.reset()
prompt = self.user_prompt.format(ref_expr=ref_expr) # ref_expr = 规划器那句指令
self.grounding_model.add_message(
text_content=prompt, image_content=obs["screenshot"], put_text_last=True
)
response = call_llm_safe(self.grounding_model)
numericals = parse_point_from_string(response) # 从文本里抠出坐标
assert len(numericals) >= 2
numericals = self.resize_coordinates(numericals, obs) # 反归一化
return numericals

每次调用先 reset()——定位器是无状态的,不像规划器要记历史。用户模板里 {ref_expr} 被替换成规划器给的那句人话,配上当前截图,模型只干一件事:回一个点。

5.2 parse_point_from_string:多种坐标写法都认

模型可能用不同格式吐坐标,parse_point_from_string(playground/agents/utils.py:72)按优先级试三种正则:

格式正则处理
<box>[[x1,y1,x2,y2]]</box><box>\[\[([^]]+)\]\]</box>取 bbox 四角中心点
x=.., y=..x\s*=\s*([\d.\-]+)\s*,\s*y\s*=\s*([\d.\-]+)直接取 x, y
(x, y)\(([\d.\-]+)\s*,\s*([\d.\-]+)\)直接取 x, y

框格式会算中心点,点格式直接用。抽出的原始数值再经 resize_coordinates(第 75-95 行)按定位模型的输出尺寸反归一化——这块 smart_resize 与 1920×1080 还原的数值细节,统一在第 3 章展开。


6. 配置对照

两种模式靠 YAML 配置切换。字段布局本身就说明了分工:

原生单模型 config/agent/scalecua_native_agent.yaml——只有一个 planner_model,enable_thinking 打开:

planner_model:
engine_type: openai
model: scalecua
base_url: http://10.140.60.1:8003/v1
grounding_width: 1920
grounding_height: 1080
resize_corrds: true
prompt_template: config/prompt_template/ours_ubuntu.json
enable_thinking: true # ← 决定用 cot 还是 withoutcot system prompt

规划器+定位器 config/agent/scalecua_agentic_workflow.yaml——两个模型块,planner_model 用 GPT-4o、指向 OpenAI;ui_grounding_model 用 ScaleCUA、指向自建定位服务:

planner_model:
engine_type: openai
model: gpt-4o-2024-11-20 # ← 规划器可以是纯语言强模型
base_url: https://api.openai.com/v1
prompt_template: config/prompt_template/gpt_planner.json
ui_grounding_model:
engine_type: openai
model: scalecua # ← 定位器是会看图的 ScaleCUA
base_url: http://10.140.60.52:10025/v1
resize_corrds: true
grounding_width: 1920
grounding_height: 1080

对照差异:

维度native_agent.yamlagentic_workflow.yaml
模型块数1(planner_model)2(planner_model + ui_grounding_model)
规划模型scalecuagpt-4o-2024-11-20
定位模型同一个 scalecua独立 scalecua
prompt 模板ours_ubuntu.json规划 gpt_planner.json / 定位 ours_ubuntu.json
enable_thinking有(true)无(GPT 模板固定 cot)

gpt_planner.json 里的 system prompt 结尾是 ... task: {task_instruction},这正对应 AgenticWorkflow.predict 第一步的 .format(task_instruction=instruction);而 ours_ubuntu.json 的用户模板带 {instruction}/{actions} 两个占位符,对应 NativeAgent.format_history


7. 取舍总结:何时选哪条

关切NativeAgent(原生)AgenticWorkflow(规划+定位)
每步调用次数1 次(省一趟往返)2 次(规划 + 定位)
谁产坐标模型自己,predict 里反归一化定位器产,parse_action 回填
历史怎么带只留 system + 当前截图,历史转纯文字保留整段消息链,任务只注入一次 system
规划器可换性必须是会看图+会规划的一体模型规划器可用任意强语言模型(GPT-4o)
response 格式三段 <think>/<operation>/<action>,宽松强制两行 # 指令 + 动作,不合格抛错
多动作支持(<action> 按行拆多个)单动作(actions=[action])

心智模型: 原生模式追求简洁与端到端一致性,一体模型自己对齐"意图↔坐标";规划+定位模式追求能力解耦——让最会推理的模型专心规划、最会看图的模型专心定位,代价是多一次调用和坐标一定要走定位器。两者产出的动作字典进入同一套跨平台环境层执行,下游对它们无感。


8. 代码地图

主题文件路径符号名
原生模式主类playground/agents/native_agent.pyNativeAgent
按 enable_thinking 选 system promptplayground/agents/native_agent.py:48NativeAgent.__init__
只留 system+当前截图、单次调用playground/agents/native_agent.py:70NativeAgent.predict
历史拼成 Previous operationsplayground/agents/native_agent.py:130NativeAgent.format_history
抽 think/operation/actionplayground/agents/native_agent.py:109NativeAgent.parse_response
func(args) 三情形解析playground/agents/native_agent.py:142NativeAgent.parse_action
规划+定位主类playground/agents/agentic_workflow.pyAgenticWorkflow
规划一行→定位→回填playground/agents/agentic_workflow.py:57AgenticWorkflow.predict
强制两行(#指令+动作)playground/agents/agentic_workflow.py:92AgenticWorkflow.parse_response
用定位坐标回填 x/yplayground/agents/agentic_workflow.py:120AgenticWorkflow.parse_action
定位器playground/agents/ui_grounding.pyUIGroundingAgent
指令+截图→坐标点playground/agents/ui_grounding.py:55UIGroundingAgent.generate_coords
多格式坐标抽取playground/agents/utils.py:72parse_point_from_string
原生模式配置playground/config/agent/scalecua_native_agent.yamlplanner_model
规划+定位配置playground/config/agent/scalecua_agentic_workflow.yamlplanner_model / ui_grounding_model

相邻章节: 动作字典与坐标契约的地基见第 1 章;坐标 smart_resize 与反归一化数值见第 3 章;动作如何在 pyautogui/adb/playwright 上执行见第 4 章;消息组装与 call_llm_safe 重试见第 5 章。总览见index