跳到主要内容

运行时底座:VLM 多后端引擎、消息组装与主循环

30 秒导读: 前四章讲的是「智能」——统一动作空间、两种推理模式、坐标定位、跨平台执行。 但这些要真跑起来,底下得有一层枯燥但关键的管道:怎么调用一个视觉语言模型、怎么把 截图和文字拼成模型认得的消息格式、失败了怎么重试、整个 agent 怎么一步步转起来。这一章 就补这层管道。读完你能:接自己的模型后端、看懂消息为什么长这样、照着源码把主循环跑通。

本章面向要读源码、要接自己模型的人。它不重复第 2 章的动作解析、 也不重复第 3 章的坐标反归一化——只讲「让这一切能运行」的骨架。

代码全部在 playground/ 下:

关切文件
多后端引擎(统一 generate)playground/core/engine.py
消息组装(拼图文、选后端)playground/core/mllm.py
模块基类(造引擎的口子)playground/core/module.py
安全调用(3 次重试)playground/utils/common_utils.py
交互式主循环(predict→step)playground/agent_run_interactive.py

1. 这是什么(零基础也能懂)

一句话定义: 这是 ScaleCUA 的「电源和线路」——把「一个 GUI agent」拆成三段可替换的管道: 造引擎 → 拼消息 → 转循环

先建立一个直觉。一个 computer-use agent 每一步都在做同一件事:

┌──────────┐ ┌─────────────┐ ┌──────────┐ ┌──────────┐
│ 截当前屏 │ → │ 拼成 消息 │ → │ 问 模型 │ → │ 执行动作 │ ── 循环 ──┐
│ (截图) │ │ (图+文+历史) │ │ (VLM) │ │ (点/输入) │ │
└──────────┘ └─────────────┘ └──────────┘ └──────────┘ │
▲ │
└──────────────────────────────────────────────────────────────────┘

中间那三步(拼消息 / 问模型 / 循环)就是本章。它们刻意做得跟具体模型无关:

  • 你可以换成 OpenAI、Claude、Azure、本地 vLLM、HuggingFace——上层代码一个字不改。
  • 换后端只改一份 YAML 配置里的 engine_type 字段(见 §5)。

给谁用: 想把 ScaleCUA 接到自己私有模型上的人;想照着复现主循环的人;想搞清「为什么 同一段代码能同时喂 GPT 和 Claude」的人。

一句话类比: 把它想成一个多口充电器。不管你插的是 OpenAI 头还是 Claude 头,面板上 按钮(generate)长得一样;内部再各自转换电压(每家 API 的图片格式不同)。


2. 顶层全景(它大概怎么转)

三个类分工明确,自底向上叠成一根管子:

playground/agent_run_interactive.py
main() / run_agent() ← 主循环:20 步 predict→step

│ 调用 agent.predict()(NativeAgent / AgenticWorkflow)

┌──────────────────── core/module.py ────────────────────┐
│ BaseModule._create_vlm_api() ← agent 造引擎的唯一口子 │
└──────────────────────────┬──────────────────────────────┘
│ new

┌──────────────────── core/mllm.py ──────────────────────┐
│ VLMEngine ← 会话状态 + 消息组装 │
│ · 按 engine_type 挑一个底层引擎 │
│ · add_message() 按后端差异拼「图 + 文」 │
│ · get_response() → 转交底层引擎 │
└──────────────────────────┬──────────────────────────────┘
│ self.engine.generate(messages)

┌──────────────────── core/engine.py ────────────────────┐
│ LMMEngine 家族(统一 generate 接口 + backoff 重试): │
│ OpenAI · Anthropic · AzureOpenAI · vLLM · HuggingFace │
└──────────────────────────────────────────────────────────┘

怎么读这张图: 从上到下是「谁调用谁」。上层 agent 只认识 VLMEngine;VLMEngine 只认识 一个抽象的 self.engine;真正跟某家 API 说话的是 core/engine.py 里那五个具体引擎。换模型 = 换最底下那一层,上面两层不动。 这就是「模型可插拔」的物理含义。

各部件一句话职责:

部件干什么符号 / 文件
多后端引擎每家 API 一个类,都暴露同名 generatecore/engine.py LMMEngineOpenAI
消息组装器存会话、按后端拼图文负载core/mllm.py VLMEngine
造引擎口子agent 拿配置生一个 VLMEnginecore/module.py BaseModule._create_vlm_api
安全调用包一层 3 次重试common_utils.py call_llm_safe
主循环20 步 predict→step→录屏agent_run_interactive.py run_agent

3. 多后端引擎:一个 generate,五种后端

这节讲什么: core/engine.py 如何用「同名方法、各自实现」把五家不同的模型 API 抹平成 一个接口。

3.1 统一接口:面板一样,内部各异

思路很直接:定义一个空基类 LMMEngine(core/engine.py:17),然后每家后端一个子类, 都实现一个签名相近的 generate(messages, temperature, max_new_tokens, ...)。上层永远只 调 engine.generate(...),不关心底下是谁。

五个后端和它们的差异:

引擎类底层 SDK鉴权来源(环境变量)备注
LMMEngineOpenAIopenai.OpenAIOPENAI_API_KEY / OPENAI_BASE_URL标准 chat completions
LMMEngineAnthropicanthropic.AnthropicANTHROPIC_API_KEYsystem 单独传;另有思考模式
LMMEngineAzureOpenAIopenai.AzureOpenAIAZURE_OPENAI_API_KEY + endpoint额外累计 self.cost
LMMEnginevLLMopenai.OpenAI(指向本地)vLLM_ENDPOINT_URL本地自托管,默认采样参数不同
LMMEngineHuggingFaceopenai.OpenAI(TGI 端点)HF_TOKEN模型名硬编码 "tgi"

有意思的细节:vLLM 和 HuggingFace 都复用 OpenAI 的 SDK——因为它们都提供了 OpenAI 兼容的 HTTP 接口,只是 base_url 指到本地或 TGI。见 core/engine.py:239(vLLM)和 :278(HF)都是 OpenAI(base_url=..., api_key=...)

3.2 最简后端长什么样(OpenAI)

真实源码,core/engine.py:44-53 LMMEngineOpenAI.generate:

def generate(self, messages, temperature=0.0, max_new_tokens=None, **kwargs):
"""Generate the next message based on previous messages"""
result = self.llm_client.chat.completions.create(
model=self.model,
messages=messages,
max_tokens=max_new_tokens if max_new_tokens else 4096,
temperature=temperature,
**kwargs,
)
return result.choices[0].message.content

一句话:收一串 messages,调 SDK,把首个候选的文本返回。temperature 默认 0.0——GUI agent 要的是确定性、可复现,不是创造力。这是全家族的通用默认。

3.3 失败了自动退避重试(backoff)

网络会抖、会限流。每个 generate 上面挂了一个装饰器,core/engine.py:41-43:

@backoff.on_exception(
backoff.expo, (APIConnectionError, APIError, RateLimitError), max_time=60
)

白话:碰到连接错 / API 错 / 限流这三类异常,就按指数退避(第一次等一点、越等越久) 自动重试,最多重试 60 秒,超时才真正抛出。注意这是「引擎层」的重试;后面 §6 还有 「调用层」的第二层重试,两者叠加。

坑: Azure 和 vLLM 的 generate 上,这个装饰器被注释掉了(core/engine.py:208:241 都是 # @backoff...)。也就是说这两个后端没有引擎层重试,只能靠 §6 的调用层兜底。 接自己模型时留意这点不一致。

3.4 vLLM 的默认采样参数(为本地 Qwen 调过)

本地 vLLM 后端的 generate 签名跟别家不一样——它带了一组非零默认采样参数, core/engine.py:243-261 LMMEnginevLLM.generate:

def generate(
self,
messages,
temperature=0.0,
top_p=0.8,
repetition_penalty=1.05,
max_new_tokens=512,
**kwargs
):
"""Generate the next message based on previous messages"""
completion = self.llm_client.chat.completions.create(
model=self.model,
messages=messages,
max_tokens=max_new_tokens if max_new_tokens else 4096,
temperature=temperature,
top_p=top_p,
extra_body={"repetition_penalty": repetition_penalty},
)
return completion.choices[0].message.content

几个和别家不同、值得记住的点:

参数为什么
top_p0.8源码注释 :242 明说「Default params chosen for the Qwen model」——为 ScaleCUA 自家 Qwen 系模型调的
repetition_penalty1.05压重复;走 extra_body 传,因为这不是 OpenAI 标准字段
max_new_tokens512(默认)比别家的 4096 小得多——GUI 一步的输出(一个动作)本就短

一句话:别家的默认是「像 GPT 那样答长文」,vLLM 的默认是「像 Qwen 那样答一个动作」。 repetition_penalty 不能直接塞进 OpenAI 的标准参数,所以走 extra_body 这个「透传口袋」。

3.5 Anthropic 的两处特殊:system 单独传 + 思考模式

Anthropic 的 API 和 OpenAI 有两点结构性差异,引擎里都处理了。

(a) system prompt 要单独传。 OpenAI 把 system 当消息数组的第 0 条;Anthropic 要求 system 作为独立参数。所以 core/engine.py:73-86generate 把消息拆开:

return (
self.llm_client.messages.create(
system=messages[0]["content"][0]["text"], # 第 0 条单独抽出来当 system
model=self.model,
messages=messages[1:], # 其余才是对话
max_tokens=max_new_tokens if max_new_tokens else 4096,
temperature=temperature,
**kwargs,
)
.content[0]
.text
)

(b) Claude 3.7 思考模式。 多一个方法 generate_with_thinking,core/engine.py:92-109:

# Compatible with Claude-3.7 Sonnet thinking mode
def generate_with_thinking(self, messages, temperature=0.0, max_new_tokens=None, **kwargs):
full_response = self.llm_client.messages.create(
system=messages[0]["content"][0]["text"],
model=self.model,
messages=messages[1:],
max_tokens=8192,
thinking={"type": "enabled", "budget_tokens": 4096}, # 开思考,给 4096 token 预算
**kwargs,
)
thoughts = full_response.content[0].thinking # content[0] 是思考块
print("CLAUDE 3.7 THOUGHTS:", thoughts)
final_response = full_response.content[1].text # content[1] 才是最终答案
return final_response

重点看两处:一是 thinking={"type": "enabled", "budget_tokens": 4096} 打开思考并限预算;二是 返回结构变了——content[0] 是思考、content[1] 才是给下游用的动作文本,普通 generatecontent[0] 直接就是答案。接 Claude 3.7 且想看推理链时用这个方法。


4. 消息组装:把截图和文字拼成模型认的样子

这节讲什么: core/mllm.pyVLMEngine 如何维护一段会话,并按不同后端拼出格式不同的 图文负载。这是整章最容易踩坑的地方——同一张截图,喂给三家模型要拼成三种 JSON。

4.1 VLMEngine:挑引擎 + 存会话

构造时,VLMEngine 按配置里的 engine_type 字符串挑一个底层引擎,core/mllm.py:20-39:

engine_type = engine_params.get("engine_type")
if engine_type == "openai":
self.engine = LMMEngineOpenAI(**engine_params)
elif engine_type == "anthropic":
self.engine = LMMEngineAnthropic(**engine_params)
elif engine_type == "azure":
self.engine = LMMEngineAzureOpenAI(**engine_params)
elif engine_type == "vllm":
self.engine = LMMEnginevLLM(**engine_params)
elif engine_type == "huggingface":
self.engine = LMMEngineHuggingFace(**engine_params)
else:
raise ValueError("engine_type is not supported")

这一句 engine_type == "..." 就是「模型可插拔」的开关。 改配置里这个字段,就换了整条后端。

会话状态是一个 self.messages 列表。两个管理方法:

  • add_system_prompt(core/mllm.py:67):把 system 放在消息列表第 0 位(存在就替换,不存在就 追加)。构造时若没给 system,默认塞一句 "You are a helpful assistant."(:46)。
  • reset(core/mllm.py:56):清空对话、只留 system 那条——每个新任务开始前调,把上一任务的 历史抹掉。

4.2 add_message:同一张图,三种拼法

核心方法是 add_message(core/mllm.py:108-252)。它先做角色自动推断,再按后端拼图片 负载

角色自动推断——调用方常常不传 role,于是它看上一条消息是谁说的,自动交替:

上一条是 system → 这条是 user
上一条是 user → 这条是 assistant
上一条是 assistant → 这条是 user

三个后端分支里都有这段一模一样的推断(如 core/mllm.py:122-129)。直觉:对话天然「你一句 我一句」,所以能自动推。想强制指定时传 role="user" 即可绕过。

图片负载按后端拼——这是关键差异。截图先被 encode_image(core/mllm.py:48)转成 base64, 然后三家拼成三种结构:

后端content 里图片项长什么样源码行
OpenAI / Azure / HF{"type": "image_url", "image_url": {"url": "data:image/png;base64,...", "detail": "high"}}core/mllm.py:143-150
Anthropic{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": ...}}core/mllm.py:193-202
vLLM{"type": "image", "image": "data:image;base64,..."}core/mllm.py:241-243

三者的差异一眼看清:

OpenAI : type=image_url → 嵌套 image_url.url → 值是 "data:image/png;base64,XXXX"
Anthropic : type=image → 嵌套 source.{media_type,data} → data 是纯 base64(无 data: 前缀)
vLLM : type=image → 平铺 image 字段 → 值是 "data:image;base64,XXXX"

为什么必须分三套: 每家 API 对「一张图」的 JSON schema 定义不同。OpenAI 要 image_url 包一层还带 detail 清晰度;Anthropic 要 source 里分开写 media_type 和纯 base64 data; vLLM 用最扁平的 image 字段。拼错一个键,模型就收不到图。 文件顶部的 data_type_map (core/mllm.py:13-16)也记着 openai 用 image_url、anthropic 用 image 这个映射作参考。

每个分支还都支持单图或图列表:传进来是 list 就逐张 append(如 core/mllm.py:138-150), 否则当单图处理。多图场景(比如喂多步历史截图)靠这个。

4.3 put_text_last:文字放最后一句

OpenAI 系分支有个小开关 put_text_last,core/mllm.py:164-167:

# Rotate text to be the last message if desired
if put_text_last:
text_content = message["content"].pop(0) # 把开头的文字块弹出
message["content"].append(text_content) # 挪到末尾

默认组装是「先文字后图」。开了这个开关就变成「先图后文字」。为什么要这个: 某些模型对 「指令紧挨在图后面」更敏感——先给图、最后一句才是问题,能提升 grounding 效果。这是个可调的 prompt 排布旋钮。注意它只在 OpenAI 系分支实现,Anthropic / vLLM 分支没有。

4.4 get_response:把会话交给引擎

get_response(core/mllm.py:254)是收尾:如果传了临时 user_message 就追加一条,然后 return self.engine.generate(messages, temperature=..., max_new_tokens=..., **kwargs)——把攒好的 self.messages 整个交给 §3 的底层引擎。至此,「拼消息」和「问模型」接上了。


5. 模块基类:agent 造引擎的唯一口子

这节讲什么: agent(NativeAgent / AgenticWorkflow)不直接 new VLMEngine,而是继承 BaseModule、走一个统一方法造引擎——这样所有 agent 造引擎的方式一致。

core/module.py 很短,核心是 _create_vlm_api(core/module.py:10-17):

def _create_vlm_api(self, system_prompt=None, engine_params=None):
"""Create a new LMMAgent instance"""
model = VLMEngine(engine_params or self.engine_params)
if system_prompt:
model.add_system_prompt(system_prompt)
return model

一句话:拿一份 engine_params(默认用 self.engine_params,构造 BaseModule 时存的), 生一个 VLMEngine,可选地盖上 system prompt,返回。

它的意义在于「口子唯一」: 规划器要一个引擎、定位器要另一个引擎(见 §7里 AgenticWorkflow 的双模型),都调这同一个方法,只是传不同的 engine_params。上层 agent 因此 只跟「引擎参数字典」打交道,永远不碰具体后端类。


6. 健壮性:两层重试

这节讲什么: 除了 §3.3 的引擎层 backoff,调用侧还包了第二层重试,两层叠加兜住抖动。

utils/common_utils.py:27-42 call_llm_safe:

def call_llm_safe(agent) -> Union[str, Dag]:
# Retry if fails
max_retries = 3 # Set the maximum number of retries
attempt = 0
response = ""
while attempt < max_retries:
try:
response = agent.get_response()
break # If successful, break out of the loop
except Exception as e:
attempt += 1
print(f"Attempt {attempt} failed: {e}")
if attempt == max_retries:
print("Max retries reached. Handling failure.")
time.sleep(1.0)
return response

要点:

  • 最多 3 次,每次失败 sleep(1.0) 再来。
  • 捕获的是宽泛的 Exception——不像引擎层只认三类网络异常,这里连解析崩了也接得住。
  • 失败不抛,而是返回空串 "" 这是刻意的「优雅降级」:主循环拿到空响应会走各自的 兜底(通常等价于「这步没产出有效动作」),而不是让整个 agent 崩掉。

两层怎么配合: 引擎层 backoff 专治网络抖动/限流(指数退避,最多 60 秒);调用层 call_llm_safe 再兜一层任意异常(固定 3 次)。前者细、后者粗,合起来让「问模型」这一步很难 把整个 20 步循环带崩。


7. 配置驱动的主循环:20 步的骨架

这节讲什么: agent_run_interactive.py 如何只看一个配置字段就决定实例化哪种 agent, 然后跑一个固定上限 20 步的 predict→step 循环。这是把前六节所有零件串起来跑的地方。

7.1 一个字段决定两种 agent

main()(agent_run_interactive.py:106-190)读完 YAML 配置后,只看配置里有没有 ui_grounding_model 这个键,来决定造哪种 agent,:140-169:

读 agent_config(YAML)

├── 有 "ui_grounding_model" 键?
│ │
│ 是 → AgenticWorkflow(planner_model, ui_grounding_model, platform)
│ (规划器 + 定位器两个模型,见第 2 章「规划器+定位器」模式)
│ │
│ 否 → NativeAgent(planner_model, grounding_width/height,
│ prompt_template, platform, observation_type="vision")
│ (原生单模型,见第 2 章「原生单模型」模式)

得到 agent

对应源码 agent_run_interactive.py:140:

# planner + grounder
if "ui_grounding_model" in agent_config:
...
agent = AgenticWorkflow(engine_params_for_planner, engine_params_for_ui_grounding, args.platform)
else:
engine_params_for_planner = agent_config["planner_model"]
agent = NativeAgent(engine_params_for_planner, ..., observation_type="vision")

这就是「配置驱动」的顶点: 第 2 章讲的两种推理模式——原生单模型 vs 规划器+定位器——在运行时不是靠改代码切换,而是靠配置里有没有那个键。走 AgenticWorkflow 分支时,还顺手算了一下定位器的 grounding_height(按屏幕宽高比补全,:144-152),这块坐标细节 属于第 3 章,这里不展开。

7.2 run_agent:20 步 predict→step→录屏

真正的循环在 run_agent(agent_run_interactive.py:49-103)。骨架:

env.start_recording() ← 开始录屏
for idx in range(20): ← 硬上限 20 步
obs = env.get_obs() ← 截图(第 4 章的跨平台环境层给的)
存 step-{idx}.png
info, code = agent.predict(instruction, obs, env) ← 问 agent 要下一个动作
draw_tag_on_image(...) ← 把动作可视化标注到截图上(存档)
追加写 action.jsonl ← 每步动作落盘

if code[0]["name"] == "terminate": ← 终止 → 跳出循环
break
if code[0]["name"] == "wait": ← 等待 → sleep(5) 后继续,不 step
continue
else: ← 普通动作
env.step(code) ← 交给环境层执行(第 4 章)
sleep(2.0)

env.end_recording(recording.mp4) ← 收尾,存视频

三个分支值得记:

动作 name循环怎么处理源码
terminate打印 "Task End!" 并 break 出循环agent_run_interactive.py:86-88
waitsleep(5)continue,不调 env.step:90-92
其它sleep(1.0)env.step(code)sleep(2.0):94-99

sleep(2.0) 是给 GUI「动作生效、界面刷新」留时间,免得下一张截图截到还没变化的旧界面。 wait 分支等得更久(5 秒),用于「页面加载中、先别动」的场景。每一步都存了 step-{idx}.png、 标注图、action.jsonl,还全程录屏——这套落盘让事后能完整复盘 agent 每一步看到什么、做了什么。

predict 内部怎么调模型、怎么解析出 code 里的动作,是第 2 章的事; env.step 怎么把动作落到 pyautogui/adb/playwright,是第 4 章的事。 本章只负责把它们按 20 步的节奏串起来

7.3 交互式外层:一个 query 一次任务

main 最外面是个 while True(agent_run_interactive.py:173-188):读一行 Query: 输入 → env.reset() + agent.reset()(清掉上次任务的状态)→ 跑一次 run_agent → 问「还要不要再来 一条」。这就是「交互式」的含义:同一个进程里连续跑多条任务,每条之间把环境和 agent 都复位。

坑(源码里的死代码): run_agent 里有一段 if idx == 21: 想在超步时强制 terminate (agent_run_interactive.py:63-69),但外层是 for idx in range(20)——idx 最大只到 19, 这个分支永远进不去。实际的步数上限就是「跑满 20 步后循环自然结束」,那段兜底代码是无效的。 接手改造时不必依赖它。


8. 巧妙之处(可借鉴的技术)

  • 「同名方法 + 各自实现」抹平多后端。 一个空基类 LMMEngine + 五个都叫 generate 的子类 (core/engine.py),让上层彻底不感知后端差异。换模型只动配置。这是最朴素也最有效的可插拔法。
  • 差异集中在一处、而非散落各层。 三家 API 的图文格式差异,全部收敛在 add_message (core/mllm.py:108-252)一个方法的三个分支里。上层和主循环完全看不到这些脏活。
  • 两层重试、分工明确。 引擎层指数退避治网络(core/engine.py:41),调用层固定 3 次治一切 (common_utils.py:27),且失败返回空串而非抛异常——20 步循环因此很抗抖。
  • 配置里一个键切换整套推理架构。 "ui_grounding_model" in agent_config (agent_run_interactive.py:140)一句话,就在「单模型」和「规划器+定位器」之间切换,代码零改动。
  • 全程可复盘。 每步截图 + 标注图 + action.jsonl + 录屏(run_agent),让 agent 的每一步 决策都能事后审计。

9. 边界与局限(诚实)

  • 步数硬上限 20,且不可配。 for idx in range(20)(agent_run_interactive.py:58)写死; 长任务会被截断。idx == 21 那段超步兜底是死代码(见 §7.2 坑),别指望它。
  • 两个后端没有引擎层重试。 Azure(core/engine.py:208)和 vLLM(:241)的 backoff 装饰器 被注释掉了,只剩调用层兜底。后端间的健壮性不一致。
  • vLLM 默认参数是为 Qwen 调的。 top_p=0.8repetition_penalty=1.05max_new_tokens=512 (core/engine.py:243-261)是自家模型的经验值,注释 :242 明说「chosen for the Qwen model」; 接别的本地模型可能要重调。
  • 图片格式只覆盖三套。 add_message 只实现了 OpenAI 系 / Anthropic / vLLM 三种图片负载; HuggingFace 走的是 OpenAI 系分支。新增一种图片 schema 不同的后端,得手写第四个分支。
  • generate_with_thinking 是 Anthropic 专属,且未接进主循环。 它假设返回结构是 「content[0] 思考 + content[1] 答案」(core/engine.py:106-108),只对 Claude 3.7 成立; 仓库里没看到主循环默认调用它,更像预留能力。

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

主题文件路径符号名
引擎基类(可插拔的锚)playground/core/engine.pyLMMEngine
OpenAI 后端 + 统一 generateplayground/core/engine.pyLMMEngineOpenAI.generate
Anthropic:system 单独传playground/core/engine.pyLMMEngineAnthropic.generate
Claude 3.7 思考模式playground/core/engine.pyLMMEngineAnthropic.generate_with_thinking
Azure(累计成本、无 backoff)playground/core/engine.pyLMMEngineAzureOpenAI
vLLM 默认采样参数(Qwen)playground/core/engine.pyLMMEnginevLLM.generate
HuggingFace/TGI 后端playground/core/engine.pyLMMEngineHuggingFace
引擎层退避重试playground/core/engine.pybackoff.on_exception(装饰各 generate)
消息组装器 / 挑引擎playground/core/mllm.pyVLMEngine.__init__
system prompt / resetplayground/core/mllm.pyVLMEngine.add_system_prompt / VLMEngine.reset
按后端拼图文 + 角色推断playground/core/mllm.pyVLMEngine.add_message
后端图片格式映射表playground/core/mllm.pydata_type_map
文字后置开关playground/core/mllm.pyadd_messageput_text_last
交给引擎生成playground/core/mllm.pyVLMEngine.get_response
agent 造引擎的口子playground/core/module.pyBaseModule._create_vlm_api
调用层 3 次重试playground/utils/common_utils.pycall_llm_safe
一个键切两种 agentplayground/agent_run_interactive.pymain("ui_grounding_model" in agent_config)
20 步 predict→step 循环playground/agent_run_interactive.pyrun_agent

延伸阅读(同组其它章)