训练与批判器:UI-S1 半在线强化学习 与 GUI-Critic-R1 操作前纠错
30 秒导读: 前面几章讲的都是「运行时」怎么把一台手机开起来点点点(单智能体、多智能体、原生基座)。这一章换个方向:讲研究/训练侧怎么把「多轮交互能力」和「会自我纠错」这两件事做进模型本身,而不是靠运行时的 prompt 拼装。两项工作——UI-S1(用离线轨迹模拟在线 RL 来训 GUI agent)和 GUI-Critic-R1(在动作落到界面之前先判它对不对)——一个管「怎么把能力练出来」,一个管「怎么在出手前拦住错误」。
本章属于 Mobile-Agent 家族里偏研究的一支。想先看家族全景,回 index.md;想看运行时框架怎么把感知/决策/反思拆成智能体,看 02-v2-multi-agent.md;想看能力如何被内化进原生基座,看 04-gui-owl-foundation-models.md。这一章跟那一章是互补的:04 讲「把能力装进权重」的运行时形态,本章讲支撑它的数据/训练/评测这一面。
1. 这是什么(零基础也能懂)
1.1 先说清楚两件事各自解决什么
一句话定位:
- UI-S1 —— 一套训练方法。它要解决的问题是:GUI agent 得一步一步跟界面交互(多轮),但真拉起成百上千台模拟器做在线强化学习又慢又贵;纯拿离线数据做监督又学不到「多轮里前后步互相影响」。UI-S1 的答案是半在线强化学习(Semi-online RL)——用离线轨迹去模拟一次在线 rollout,取两者的折中。
- GUI-Critic-R1 —— 一个批判器(critic)模型。它要解决的问题是:GUI 操作是在真实环境里实时执行的,容错率极低——点错一下可能就删了文件、付了款,而且不可逆。它的答案是操作前(pre-operative)错误诊断:在动作真正落到界面之前,先让一个 critic 判断「这一步会不会错」,对了才放行。
1.2 一句话直觉
把两者放到一个开发者熟悉的类比里:
| 项目 | 类比 | 干的活 |
|---|---|---|
| UI-S1 | 用录像回放当陪练来练一个选手 | 不用每次都拉真人对练(在线 RL 太贵),拿录好的对局(离线轨迹)模拟出「一步接一步」的交互来训练 |
| GUI-Critic-R1 | 出手前的代码 review / 预检 | 动作提交前先让一个"审查员"看一眼:这步棋会不会把局面走死?会才拦 |
1.3 为什么把它们放同一章
因为它们是同一个主张的两半:
早期的 Mobile-Agent(v1/v2)靠运行时的反思智能体——出错了、执行完了,再回头看截图判断对不对(见 02-v2-multi-agent.md 的反思智能体)。这一章的两项工作,是把这份「反思/纠错」能力从运行时 prompt 迁到模型侧:
- UI-S1 把「多轮里哪一步该得多少分」的信用分配,做成训练目标,让模型在参数里学会走多步。
- GUI-Critic-R1 把「这步对不对」的判断,做成一个前置的、独立训练的模型,在执行前拦截。
一句话:运行时靠临场提示,不如把纠错能力直接练进权重、并在出手前设一道闸。
2. 顶层全景(两 条流水线怎么转)
这两项工作在代码仓里是两个独立子目录:UI-S1/ 和 GUI-Critic-R1/。下面各画一张「怎么读」的流程图。
2.1 UI-S1:离线轨迹 → 半在线 rollout → 双层优势 → 更新模型
怎么读这张图: 从左到右是一次训练迭代;关键在中间——用离线轨迹驱动一次多轮 rollout,遇到「走错」就断开,再把 reward 分成 episode 级和 step 级两层算优势。
离线轨迹(AndroidControl) 半在线 rollout(逐步) 信用分配(两层优势)
┌───────────────────────┐ ┌──────────────────────────┐ ┌─────────────────────────── ─┐
│ 一条任务 = 多步 │ │ 第 t 步:模型看截图+历史 │ │ ① episode 级:整条轨迹一个 │
│ (goal, steps[]) │──▶│ 产出动作 → 和标注比对 │──▶│ 标量 reward,组内归一化 │
│ 每步有标准答案 bbox │ │ extract_match? │ │ ② step 级:同一步的多个 │
└───────────────────────┘ │ 是 → 继续下一步 │ │ 采样,组内归一化 │
│ 否 → 断开,后续不再累加 │ │ 加权相加 = 最终 advantage │
└──────────────────────────┘ └────────────────────────────┘
│
▼
GRPO/DAPO 更新(verl 训练栈)
各部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
| 半在线 rollout | 逐步喂离线轨迹、比对动作、错就断开 | UI-S1/evaluation/eval_qwenvl.py(评测里同构逻辑)process_line |
| 折扣回报 | 从后往前累加 reward,遇到「不匹配」清零重来 | UI-S1/uis1/core_uis1.py:31 compute_step_discounted_returns |
| 双层优势 | episode 级 + step 级两种归一化优势加权 | core_uis1.py:67 compute_uis1_outcome_advantage |
| verl 训练栈 | 分布式 GRPO/DAPO 训练框架(改自 volcengine/verl) | UI-S1/verl/、UI-S1/scripts/train_example.sh |
| 多基座评测 | 用统一指标评 Qwen-VL/OS-Atlas/UI-TARS/AgentCPM 等 | UI-S1/evaluation/eval_*.py |
2.2 GUI-Critic-R1:一步的上下文 → critic 判断 → 对/错 + 建议
怎么读这张图: 输入是「某一步之前」的全部信息(指令+历史+这一步的决策+带红圈标注的截图),critic 输出结构化的 <score> 判断和一条建议;评测再拿标准答案比对。
输入(某步操作之前) critic 模型 输出 + 评测
┌────────────────────────┐ ┌────────────────────┐ ┌───────────────────────────┐
│ User instruction │ │ Qwen2.5-VL 微调 │ │ <score>Correct/Incorrect</> │
│ History(前几步) │──▶│ (GUI-Critic-R1) │──▶│ + Reflection / Suggestion │
│ Decision(这一步动作) │ │ 只判断,不执行 │ │ │
│ 截图(动作位置画红圈) │ └────────────────────┘ │ 对错:二分类指标(acc/F1) │
└────────────────────────┘ │ 建议:另一个 72B 判相似度 │
└───────────────────────────┘
各部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
| critic 推理 | 拼 system+user prompt、喂截图、生成判断 | GUI-Critic-R1/test.py:48 critic_inference / call_model |
| 对错评测 | 抽 <score> 标签、和 ground-truth 比、算二分类指标 | GUI-Critic-R1/statistic.py:55 calculate_metrics_binary |
| 建议评测 | 用 Qwen2.5-72B 判「模型建议」和「标准建议」是否同义 | GUI-Critic-R1/statistic.py:19 similarity_measure |
| 三类测试集 | 覆盖三种场景(见 §4.3) | GUI-Critic-R1/test_files/gui_{s,i,web}.jsonl |
3. 核心原理之一:UI-S1 半在线强化学习
本节讲清 UI-S1 到底「半」在哪、以及它怎么在多轮里分配信用。
3.1 它要解决的小问题:在线太贵,离线太笨
先把三种范式摆开(这是理解「半在线」的前提):
| 范式 | 数据从哪来 | 多轮信息 | 代价 |
|---|---|---|---|
| 离线(offline) | 固定的标注轨迹 | 弱:每步独立监督,学不到前后步互相影响 | 便宜 |
| 在线(online) | 实时和真环境交互采样 | 强:真多轮,能试错 | 贵、慢、要拉大量模拟器 |
| 半在线(semi-online) | 离线轨迹,但当作在线来 rollout | 中:用离线数据模拟多轮交互 | 折中 |
README 原话把这层意思说得很直白:半在线 RL 是「利用离线轨迹模拟在线强化学习的新颖范式」,目的是高效训出多轮交互能力(UI-S1/README.md:14)。
3.2 「半」的关键动作:走错就断开
半在线的精髓在一次 rollout 怎么跑:拿一条离线轨迹,让模型从头逐步走,每步把模型产出的动作和标注答案比对;一旦这一步没对上(extract_match 为假),就停,后续步骤不再算入这次「模拟的在线交互」。
这段逻辑在评测代码里看得最清楚(训练侧 rollout 同构):
# UI-S1/evaluation/eval_qwenvl.py:process_line —— 逐步走离线轨迹,错即断
while step_id < num_steps:
model_response = call_mobile_agent_vllm(messages=..., model_name=args.model_name)
pred_action = fm.parse_response(model_response)
type_match, extract_match = evaluate_android_control_action(...)
if not extract_match: # 这一步没对上
break # 断开:不再往下走
step_id += 1
task_success = (step_id == num_steps) # 全部走完才算成功
eval_qwenvl.py:74 这行 task_success = (step_id == num_steps) 就是半在线指标 SOP 的定义:必须一步不错地走完整条轨迹才算成功——这正模拟了在线环境里「错一步,后面的截图状态就全歪了」的连锁效应。evaluate_android_control_action 的定义在 UI-S1/evaluation/qwenvl_utils.py:104。
3.3 折扣回报:从后往前,遇错清零
有了「走到哪断」的轨迹,怎么给每一步发 reward?UI-S1 用带折扣的回报(discounted return),而且遇到不匹配就把累加清零——这样断点后面的步骤不会「蹭」到前面的未来回报。
# UI-S1/uis1/core_uis1.py:31 compute_step_discounted_returns —— 从后往前累加
for t in reversed(range(len(traj_rewards))):
if traj_extract_matches[t]: # 这步对上了
running_return = traj_rewards[t] + gamma * running_return # 累加未来回报
traj_returns[t] = running_return
else: # 这步没对上
running_return = 0.0 # 清零:断开区间
traj_returns[t] = traj_rewards[t]
gamma(折扣因子)在训练脚本里设为 0.5(UI-S1/scripts/train_example.sh:28),即越靠后的未来奖励衰减越快。else 分支的「清零」是半在线的信用分配核心:一条轨迹里,只有连续走对的前缀才共享未来回报,一旦走错,后面的步骤在信用上就被隔离开。
3.4 双层优势:episode 级 + step 级
UI-S1 算优势(advantage)时同时用两个粒度,再加权相加:
# UI-S1/uis1/core_uis1.py:67 compute_uis1_outcome_advantage —— 两层优势相加
episode_advantages = episode_norm_reward(...) # 整条轨迹一个标量,组内归一化
step_advantages = step_norm_reward(...) # 同一步的多次采样,组内归一化
scores = episode_advantage_w * episode_advantages + step_advantage_w * step_advantages
两层各管一件事:
- episode 级(
episode_norm_reward,core_uis1.py:95):把整条轨迹看成一个样本,拿到一个标量分,在同一个 prompt 的多条轨迹这一组里做均值/(可选)标准差归一化。管的是「这整条走得好不好」。 - step 级(
step_norm_reward,core_uis1.py:166):把同一个 (prompt, step) 的多次采样凑成一组(键是f"{index}-{step_id}",见core_uis1.py:197),在组内归一化。管的是「在这一个具体步上,哪个采样更好」。
这是典型的 GRPO(Group Relative Policy Optimization,组内相对策略优化) 思路——不用单独的 critic 网络估基线,而是拿同一组样本的均值当基线。mode 决定是否除以标准差:mean_norm 只减均值(remove_std=True),mean_std_norm 减均值再除标准差(core_uis1.py:79)。训练脚本用的是 mean_std_norm(train_example.sh:20)。
注意一处细节:
episode_norm_reward默认compute_mean_std_cross_all_data=True(core_uis1.py:101),即均值/标准差是跨 batch 全体算的,注释说这样「更稳」;若设为 False 才退回「按 N 条轨迹」的标准 episode 级优势(core_uis1.py:119-121)。
3.5 训练栈:verl + GRPO/DAPO
真正把上面这些拼成分布式训练的,是 UI-S1/verl/(改自 volcengine/verl 与 verl-agent,见 README.md:100 致谢)。入口脚本 scripts/train_example.sh 把关键旋钮都摆在命令行上:
| 旋钮 | 值 | 含义 | 位置 |
|---|---|---|---|
algorithm.adv_estimator | uis1 | 用本章的双层优势估计器 | train_example.sh:56 |
algorithm.gamma | 0.5 | 折扣因子 | train_example.sh:93 |
algorithm.uis1.step_advantage_w | 1.0 | step 级优势权重 | train_example.sh:94 |
actor_rollout_ref.rollout.n | 8 | 每个 prompt 采样 8 条,构成 GRPO 的「组」 | train_example.sh:89 |
algorithm.filter_groups.enable | True(DAPO) | 开启 DAPO 的组过滤 | train_example.sh:97 |
algorithm.filter_groups.std_threshold | 0.3 | 组内 reward 标准差低于此则丢弃 | train_example.sh:99 |
| base 模型 | Qwen2.5-VL-7B-Instruct | 被训练的多模态基座 | train_example.sh:64 |
filter_groups(DAPO 里的动态采样)是个实用细节:如果一组里 8 条采样的 seq_future_reward 标准差太小(大家一样好或一样坏),这组提供不了对比信号,就过滤掉(train_example.sh:98),把算力省给有区分度的组。
3.6 多基座评测:同一把尺子量五个模型
UI-S1/evaluation/ 下有一组评测脚本,用同一套半在线 rollout 逻辑去量不同基座,好处是横向可比:
| 脚本 | 评的模型 |
|---|---|
eval_qwenvl.py | Qwen2.5-VL-7B / UI-S1-7B(本项目产物) |
eval_os-atlas-7b.py | OS-Atlas-7B |
eval_os-genesis-7b.py | OS-Genesis-7B |
eval_ui-tars-7b.py | UI-TARS-7B |
eval_agentcpm.py | AgentCPM-GUI-8B |
不同基座的动作空间/prompt 格式不一样,所以每个脚本配一个 *_utils.py(如 qwenvl_utils.py、ui_tars_utils.py、os_atlas_utils.py、agentcpm_utils.py)负责各自的动作解析与匹配,但外层的「逐步走、错即断、算 SOP」是一致的(eval_qwenvl.py:98 main 里统计 Success Rate 与 Average Progress)。README 汇报 UI-S1-7B 在半在线指标 SOP 和在线指标 AndroidWorld 上都拿到开源 7B 的 SOTA(README.md:20)。
4. 核心原理之二:GUI-Critic-R1 操作前纠错
本节讲清「操作前(pre-operative)」到底早在哪、critic 长什么样、怎么评。
4.1 它要解决的小问题:GUI 错误不可逆
论文 Introduction 把动机讲得很清楚:GUI 自动化是在线交互环境里执行的,每一步容错率都低,一个错误可能层层放大,酿成删除、付款这类不可逆后果(GUI-Critic-R1/README.md:31-32)。
所以它引入一个 pre-operative critic(操作前批判器):在动作真正执行之前,通过推理「这个动作的潜在结果和正确性」来给出反馈(README.md:33)。这跟 v2 那种执行后回看截图的反思智能体正相反——把闸门前移到出手之前。
4.2 critic 到底看什么、答什么
看 test.py 里拼给模型的输入,就知道 critic 的「视野」有多具体。system prompt 只有一句:
# GUI-Critic-R1/test.py:50
system_prompt = "You are a helpful critic agent for GUI operation."
而 user prompt(存在数据集的 problem 字段里)给了 critic 四样东西:
| 输入 | 内容 |
|---|---|
| User instruction | 用户的总指令(常含多个细节要求) |
| History | 前几步的动作历史 |
| Decision | 这一步要评的决策 |
| Image | 执行前的截图;若动作带坐标(click/swipe),交互区域用半透明红圈或红箭头标出 |
这段规格逐字来自测试集的 problem 字段(如 GUI-Critic-R1/test_files/gui_s.jsonl 首条记录)。critic 被要求先做 Observation → Possible Result → Critique 三步推理,再给结论。
输出是结构化的。评测端就靠标签抽答案:
# GUI-Critic-R1/test.py:142 —— 从输出里抠出 <score> 判断
answer = response.split("<score>")[-1].split("</score>")[0].strip()
...
if answer == ground_truth: # 和标注(Correct/Incorrect)比
accuracy_value = 1
数据集的 solution 字段是「判断 + 建议」两行,例如 gui_web 首条是 'Incorrect \n"Click on one of the checkbox options..."'——第一行是对错标签,第二行是对应的建议动作(供 §4.4 的建议评测用)。
推理用 Qwen2.5-VL 生成,贪心解码(top_k=1)、最多 2048 新 token(test.py:85)。
4.3 三类测试集:覆盖三种场景
批判器要在不同来源、不同难度的场景上都靠谱,所以 GUI-Critic-Test 分三份(GUI-Critic-R1/test_files/):
| 文件 | 场景 | 条数 |
|---|---|---|
gui_i.jsonl | GUI-I(in-domain,同分布) | 656 |
gui_s.jsonl | GUI-S(源自 Odyssey 等,跨应用/更难) | 114 |
gui_web.jsonl | GUI-W(web 网页场景,源自 GUIAct) | 428 |
(条数为本 commit 下实测行数;场景归属可从记录里的 images 路径前缀推断,如 gui_s 指向 odyssey/、gui_web 指向 guiact/——(inferred),代码未显式命名。)三份合起来 1198 条,覆盖「同分布 / 跨应用移动端 / 网页」三类,让 critic 的准确率不是只在一种界面上刷出来的。
4.4 两把尺子:判「对错」也判「建议好不好」
GUI-Critic-R1 的评测有个不常见的设计:除了判对错,还判 critic 给的建议质量。
第一把尺子——对错,标准二分类指标。statistic.py:154 的 get_result 把 solution 里含 Incorrect 的记为 0、否则 1,再和 critic 抽出的 critic_result 比,交给 calculate_metrics_binary(statistic.py:55)算 Accuracy / Precision / Recall / F1 / 各类准确率 / 格式错误率。
第二把尺子——建议有效性。光判对错不够:critic 说「Incorrect」时,它给的改法对不对?这没法字符串精确比,于是用另一个大模型当裁判:
# GUI-Critic-R1/statistic.py:19 similarity_measure —— 让 72B 判两句建议是否同义
response = Generation.call(
model="qwen2.5-72b-instruct",
messages=messages, # prompt:两句话描述的动作是否 similar?Yes/No
)
calculate_suggestion_acc(statistic.py:115)把标注建议和模型建议丢给这个 72B 判同义,get_result 里再多线程跑一遍、汇总成 Suggestion score(statistic.py:189-193)。README 也提示要为此配 Qwen-72B 的 API(README.md:62)。
训练方法(README 层面): critic 本身是用 S-GRPO(Suggestion-aware Gradient Relative Policy Optimization) 训的,核心是引入一个建议奖励(suggestion reward),让反馈更可靠;配套还有一条 reasoning-bootstrapping 的数据收集流水线来造 GUI-Critic-Train/Test(
README.md:34-35)。仓库内只提供了评测代码,训练代码未随此 commit 释出——代码里看不出 S-GRPO 的具体实现。
5. 巧妙之处(可借鉴的技术)
- 「错即断」把在线的连锁效应搬进离线训练。 半在线的灵魂不是数据多在线,而是 rollout 时一步不匹配就 break / 把折扣回报清零——用离线数据复刻了「前一步歪了、后面全歪」的真实代价(
core_uis1.py:45-51、eval_qwenvl.py:69)。 - 双层优势 = 又看整局又看单步。 episode 级管「这盘走得好不好」,step 级管「这一手比同组其它采样好不好」,加权相加兼顾长程目标与逐 步信用(
core_uis1.py:67-92)。 - DAPO 动态过滤省算力。 一组采样若 reward 标准差过低(没区分度)就整组丢弃,把梯度花在有对比信号的样本上(
train_example.sh:97-99)。 - 纠错前移 + 结构化输出。 GUI-Critic-R1 把「对不对」的判断挪到执行前,并用
<score>...</score>标签让判断可被机械抽取、可评测(test.py:142)。 - 用 LLM 当裁判评「建议」而非只评标签。 建议无法精确匹配,就请一个 72B 判同义,把「反馈是否有用」也量化了(
statistic.py:19、115)。
6. 边界与局限(诚实说明)
- UI-S1 仍是「半」在线,不是真在线。 rollout 用的是离线轨迹的标注答案做匹配(
evaluate_android_control_action),模型探索不到标注之外的状态;真正的在线能力还得靠 AndroidWorld 这类在线基准另行验证(README.md:20)。 - GUI-Critic-R1 只放出评测、没放训练。 本 commit 里
GUI-Critic-R1/只有test.py/statistic.py/ 三份测试集;README 的 TODO 明确「模型 checkpoint、GUI-Critic-Train、AndroidWorld 上的应用代码」都还未发布(README.md:41-45)。S-GRPO 与建议奖励的实现细节代码里看不出。 - 评测脚本里有若干瑕疵。
statistic.py:103check_format_output里.sript()是.strip()的笔误(该 函数实际未被主流程调用);calculate_suggestion_acc里len(suggestionion) == None的判断也写得可疑(statistic.py:136)——引用时以行为为准,别照抄。 - 硬编码的 API key。
statistic.py:7直接把 dashscope 的API = 'sk-...'写进源码——这是上游仓库里的明文密钥,属于被研究的数据、不要使用或外传;自己复现时应换成自己的 key。 - 依赖真实数据集图片。 两个项目都需要另行下载图片/数据(UI-S1 要 AndroidControl,GUI-Critic 的测试图 README 标注为「待发布」),仓库内不含大图。
7. 横向对比(和家族其它章、和 shelf 兄弟)
在 Mobile-Agent 家族内部,这一章是训练/评测侧,和运行时侧形成对照:
| 维度 | 运行时反思(v2,02) | 内化进基座(v3/GUI-Owl,04) | 本章训练侧 |
|---|---|---|---|
| 纠错发生在 | 运行时、执行后回看 | 模型内部,推理时 | UI-S1:训练时的信用分配 / GUI-Critic:执行前 |
| 载体 | prompt + 独立反思智能体 | 权重 | UI-S1:RL 目标 / GUI-Critic:独立 critic 模型 |
| 代表符号 | 反思智能体 | 原生基座 | compute_uis1_outcome_advantage / critic_inference |
一句话:v2 靠临场提示做反思,04 把能力焊进权重,本章则从训练目标和前置校验两个角度把「少犯错、犯了能拦」做扎实。
放到 ai-agent-reference 整个货架看,「操作前 critic」和「用 RL 训 GUI agent」都是 computer-use 方向的通用范式——同货架其它 computer-use / GUI agent 子库(如 agent-s 等)可对照其各自的「规划-执行-校验」拆法,看它们把这道「出手前的闸」放在哪、由谁来把。
8. 代码地图(导航索引)
用符号名 grep 比行号抗漂移(上游更新后行号会变,符号名通常还在)。路径相对克隆根
aiRef/repos/mobile-agent/。
UI-S1
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 双层优势估计器(入口) | UI-S1/uis1/core_uis1.py | compute_uis1_outcome_advantage |
| episode 级归一化优势 | UI-S1/uis1/core_uis1.py | episode_norm_reward |
| step 级归一化优势 | UI-S1/uis1/core_uis1.py | step_norm_reward |
| 折扣回报 / 错即清零 | UI-S1/uis1/core_uis1.py | compute_step_discounted_returns |
| 半在线 rollout(评测) | UI-S1/evaluation/eval_qwenvl.py | process_line / main |
| 动作匹配(SOP 判定) | UI-S1/evaluation/qwenvl_utils.py | evaluate_android_control_action |
| 训练超参与 verl 入口 | UI-S1/scripts/train_example.sh | adv_estimator=uis1 / filter_groups |
| 多基座评测 | UI-S1/evaluation/ | eval_os-atlas-7b.py / eval_ui-tars-7b.py / eval_agentcpm.py / eval_os-genesis-7b.py |
GUI-Critic-R1
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| critic 推理(拼 prompt/喂图/生成) | GUI-Critic-R1/test.py | critic_inference / call_model |
| 对错标签抽取与比对 | GUI-Critic-R1/test.py | __main__(response.split("<score>")) |
| 二分类指标 | GUI-Critic-R1/statistic.py | calculate_metrics_binary / get_result |
| 建议相似度(72B 当裁判) | GUI-Critic-R1/statistic.py | similarity_measure / calculate_suggestion_acc |
| 三类测试集 | GUI-Critic-R1/test_files/ | gui_s.jsonl / gui_i.jsonl / gui_web.jsonl |