Fairy是我在移动端智能体方向上、从模型训练到端侧部署完整做过的一个项目代号。简单来说,它是一个跑在手机上的GUI Agent,也就是让模型像人一样看屏幕、理解界面、点按操作,最终完成跨App的任务。项目里最有代表性的三个模块是RGR、OCA、EMA,分别承担感知对齐、动作校准、训练与状态稳定这三类工作。这篇分享我不打算讲太多理论包装,重点是把这三套机制为什么存在、怎么组合、落地时会踩到哪些意料之外的坑,全部摊开来说。如果你正在做移动端自动化、GUI智能体,或者准备把手上的视觉模型往端侧操作方向迁移,这篇内容应该能帮你省掉不少试错时间。
1. 先把这个项目说清楚:Fairy到底在解决什么问题
1.1 移动GUI Agent的基本范式
任何一个GUI Agent,不管宣传得多玄,核心都逃不开三个阶段:感知,也就是看懂屏幕上有哪些元素、分别在什么位置;决策,也就是判断当前要完成用户目标、下一步应该做什么;执行,就是把决策结果转成一次真实的屏幕操作,比如点击、滑动、长按、输入文本。
Fairy作为移动端GUI Agent,感知部分不是靠读系统控件树,而是直接处理屏幕截图。这么做的好处是通用性极强,不用依赖系统是否开放无障碍接口,也不存在不同版本UI框架不兼容的问题。坏处也很明显:截图是像素,是二维坐标,没有层级信息,没有控件类型标注,一切都要从视觉里推出来。
这个项目选型时,我们内部也讨论过到底是走系统辅助功能接口拿控件树,还是纯视觉方案。最后决定纯视觉优先,原因很直接:很多第三方应用把自定义绘制的控件做得非常复杂,系统节点树要么拿不到关键信息,要么拿到了也是空壳。纯视觉方案虽然难,但这是唯一能覆盖绝大多数App的技术路线。RGR、OCA、EMA这三个模块,本质上都是在为"纯视觉+端侧执行"这条路线补细节。
1.2 为什么传统模型在移动GUI上频繁翻车
把一套在通用目标检测上效果不错的模型直接搬来做GUI闭环,第一轮测试就会暴露大量问题。
第一个问题是坐标精度远远不够。通用检测模型输出的框是宽松的,画个大概区域没问题,但点击操作需要一个精确的落点。GUI上的很多控件尺寸极小,比如右上角的关闭按钮、列表item里的折叠箭头,框的中心点如果偏离10像素,可能就会点到旁边的功能上。
第二个问题是截图内容不断变化。页面滚动、弹窗出现、键盘弹出、网络加载延迟,都会让同一时刻的界面状态完全不同。模型如果只做过单帧静态检测,进入动态页面后很容易出现"画面元素已经变了,但模型还在按上一帧的记忆决策"的情况。
第三个问题是训练与推理的分布漂移。训练数据里见到的控件风格是A类型,线上遇到的是B类型,哪怕都是同一类按钮,形状和颜色变了,模型的表现就会明显下降。
Fairy的落地思路,就是把这三个问题拆开,分别交给RGR、OCA和EMA去处理。RGR解决"看准"的问题,OCA解决"点准"的问题,EMA解决"训练和推理不稳定"的问题。三者不是叠加关系,而是串联在一条链路上的三个环节,任何一个环节掉链子,最终的任务完成率都会崩。
1.3 RGR、OCA、EMA三者的分工与整体策略
为了方便理解,可以把Fairy的完整链路简化成下面这个流程:
- 拿到一帧屏幕截图;
- RGR模块对截图做区域级理解,定位出当前可操作的UI元素,并提取每个元素的类型、文本、位置框;
- 决策模块根据用户指令,从候选元素中选择要操作的目标;
- OCA模块根据目标元素信息,结合屏幕上下文,计算最终的精确点击坐标和动作类型;
- 执行完成后,回捞新的截图,判断操作是否生效;
- EMA模块在训练阶段稳定模型权重,在推理阶段对连续帧的动作预测做平滑,减少误触和抖动。
这个策略的核心是不要让一个模型去干所有事。端到端的单模型方案看起来简洁,实际工程里极难收敛,而且出了问题不好定位。拆成三个模块,每个模块都能单独测试、单独优化、单独回滚。RGR做得好不好看检测准确率,OCA做得好不好看动作执行成功率,EMA做得好不好看训练曲线的稳定性和推理时的动作抖动频率。这种分模块策略,让整个项目在开发期就能持续验证,不需要等到最后联调才发现问题。
2. RGR:让模型真正看见界面元素
2.1 RGR要解决的问题:视觉标志与坐标错位
RGR的全称我们内部叫Region-aware Grounding and Refinement,区域感知的视觉定位与精修。最初立项时,它要解决的核心矛盾是:模型看到的"语义"和实际像素坐标之间,存在系统性错位。
举个例子。截图上有一个"立即登录"按钮,模型能够正确识别出它的文本含义,但如果你把它预测的框中心和按钮真实中心叠在一起看,会发现普遍往右下偏移了大概5-8个像素。这种偏移在单张图上无关痛痒,累积到后续决策和点击环节就会被放大,最终导致操作点落在按钮边缘或干脆点空。
偏移来源很多:标注框的标注误差、输入图像缩放比例、模型下采样倍率。普通检测任务里这种框的偏移可以容忍,毕竟IoU达到0.5就算检测成功,但GUI Agent对边缘极其敏感,Fairy的RGR模块就是为了把这种偏移压到最小。
RGR在结构上做了一个很关键的设计:先定位区域,再在区域内回归精修坐标。第一阶段用轻量级的区域提议网络找出候选元素的大致位置,第二阶段针对每个候选区域,使用一个更细粒度的回归头,在放大后的局部特征图上预测精确的中心点、宽高以及四个边界。这样做的好处是,第二阶段的输入是经过RoIAlign裁剪出来的局部区域,分辨率足够高,小控件的细节不会被全局特征图丢弃。
2.2 RGR在检测与识别环节的落地细节
先说检测部分。Fairy没有直接使用大模型做端到端界面理解,因为移动端推理资源有限,而且延迟要求很高。RGR使用的检测底座是一个轻量级的两阶段结构,骨干网采用经过INT8量化后大约只有2MB的MobileNetV3变体,Feature Pyramid Network层也只保留了P3-P5三层。整个检测模型在端侧的单帧推理时间控制在15毫秒以内,这个数字是在中端手机上实测的结果。
第二阶段的核心回归头,输入是第一阶段候选框对应的局部特征图,输出三个预测分支:
- 分类分支:判断当前元素的可操作类型,比如按钮、输入框、滑块、列表项、图标;
- 文本分支:如果元素携带文字,用轻量OCR模块提取文字内容,用于匹配用户指令;
- 精修回归分支:预测中心点偏移量、宽高缩放量、以及四个边界的修正值。
精修回归分支的损失函数我们用的是Smooth L1 Loss,没有用IoU Loss。原因很朴素:GUI元素大多是规则矩形,IoU Loss在这种场景下的收益不明显,而且Smooth L1在训练初期对异常框的梯度更温和,不容易把模型训飞。回归目标做成了相对当前候选框的归一化偏移量,这样不管候选框多大,loss的尺度都是一致的。
2.3 RGR的数据标注与回归目标设计
数据是RGR模块最大的成本消耗点。Fairy项目的训练数据来自两部分:一部分是公开的GUI数据集,另一部分是自建的App截图标注集。
自建标注时,我们一开始用矩形框标注工具,后来发现问题很大。标注员对"按钮边界"的理解不统一,同一个控件,有人把外发光也框进去了,有人只框文字区域。这种不一致直接导致回归头学习目标混乱。后来改为四点标注+自动拟合矩形:标注员点出控件四个角,系统计算最小外接矩形,并且要求所有标注框统一采用控件可点击区域,而不是视觉高亮区域。这个调整让框的标注方差从之前的大约12像素降到了4像素以内。
回归目标设计中还有一个容易被忽视的细节:不同屏幕尺寸的数据要归一化。Fairy用统一的分辨率基准,所有截图先等比缩放再填充到640x960,标注坐标同步换算。如果不做这一步,模型在不同分辨率上就会产生严重的尺度混淆。
2.4 实操注意点:小目标与重复图标的处理
- 小目标:很多GUI控件在图上的面积不足总像素的1%,全局下采样后只剩几个像素。我们的解法是提高输入分辨率至640x960,同时把FPN的P2层加回来,让小目标也能有足够的语义特征。
- 重复图标:首页上四五个长得几乎一样的金刚区图标,仅靠视觉特征无法区分。RGR会给每个检测元素分配一个屏幕块ID,比如"屏幕左1/4、上1/3区域",这种粗略的位置先验能在后续决策阶段帮上大忙。
- 文本覆盖:按钮上的文字和可点击区域重叠时,OCR结果不能直接作为分类依据。RGR的做法是把文本识别结果作为元素的一个属性,而不是替代元素本身。
3. OCA:把“看得到”变成“点得准”
3.1 点击坐标为什么总是不准
RGR提供了比较可靠的元素框,但这不意味着我们能直接点框中心。实际操作中,框中心点击的成功率大概只有不到90%,剩下的失败来自几类情况。
第一种是控件本身就不规则。比如一个卡片整体可点击,但卡片上有内部按钮。你检测到的框是整个卡片,点中心可能落在内部按钮上,触发的是另一个动作。第二种是视觉框不等于可点击区域。很多App里文字标签和真正响应点击的父容器并不重合,模型看到的"登录"文字,可点击区域其实是文字下方的整个通栏。第三种是系统上下文干扰。键盘弹出后页面整体上移,但某些App的触摸事件回调区域没有同步位移,模型计算出的坐标在视觉上对准了,真实系统响应却错位了。
OCA,全称Optimal Click Action,做的就是一件事:在屏幕坐标系中找到一个真正能触发目标动作的点。
3.2 OCA的闭环校准机制
OCA不是一次性计算器,而是一个带反馈的闭环校准器。它的流程是:
- 接收RGR输出的目标元素框和元素类型;
- 从屏幕中心向外生成多组候选点击点,每组包括中心点、中心偏左上点、偏右下点等;
- 对每个候选点,利用局部图像的上下文特征进行评分,评估该点落在目标元素可点击区域内的置信度;
- 选择置信度最高的候选点作为首次点击位置;
- 执行点击后,捕获新截图,判断当前屏幕状态是否发生预期变化。
如果点击后1秒内界面没有出现预期变化,OCA会启动惩罚式重采样:排除刚才点击过的位置,重新生成候选点,并向边界方向扩散搜索半径。重试最多3次,超过后判定本次动作失败,转交给上层决策模块重新规划。
这个闭环的收益在真机测试中非常明显。以某个目标点为例,开始时的盲点成功率是87%,加入闭环校准后提升到96.8%,而且重试成本极低,因为大多数情况第二次尝试就能成功。
3.3 OCA的工程实现要点
OCA在工程实现上,最复杂的是候选点评分模型的选择。我们对比试验了几种方案:直接用RGR特征做全连接打分、在局部区域上用一个小卷积网络做热力图预测、完全基于规则判断。最后采用的方案是局部特征图上的二维热力图预测,它对不规则区域的表达能力最好。
具体做法是:以目标元素框为中心,裁剪尺度为原始输入框的1.8倍的局部区域,缩放成一个固定的64x64特征图块,然后用热力图头预测每个像素位置可作为点击点的概率。推理时把热力图恢复回原始坐标空间,取概率峰值作为最优点击点。
注意:OCA的输入分辨率需要与RGR保持一致,否则热力图坐标回映射时会出现系统性偏移。我们在实际联调中遇到过一次,RGR用640x960、OCA独立用512x512,结果点击点整体偏移了12像素左右。
OCA还针对不同元素类型设置了不同的先验策略:输入框优先点击左侧约四分之一处,而不是正中心,这样可以避免点到光标没有落在可编辑区域;滑块优先点击滑动条中心;开关控件优先点击整个控件宽度的四分之三处。这些先验规则不参与训练,只作为热力图生成的先验偏置,效果比纯数据学出来的稳定很多。
3.4 不同场景下的点击策略对比
| 场景 | 简单策略中心点击 | OCA热力图+闭环 |
|---|---|---|
| 普通按钮 | 92.3% | 98.1% |
| 卡片区域 | 78.5% | 96.4% |
| 输入框 | 84.2% | 95.7% |
| 滑块 | 无法直接中心点击 | 尝试滑动后成功 |
| 动态加载列表 | 失败率较高 | 等待列表稳定后点击 |
数据来自同一个测试集、同样200个任务样本。可以看到,普通按钮上差距还不算大,越是不规则、动态的目标,OCA闭环越有价值。
4. EMA:训练稳定与推理平滑的双重角色
4.1 训练侧EMA:权重滑动平均的作用
EMA是行业里再常见不过的技术,但Fairy里面EMA的重要性被抬到了一个比较高的位置,因为GUI Agent的训练有一个明显特点:loss曲线波动极大。
原因在于GUI数据集的难易程度差别悬殊。有的样本就是一个居中的"下一步"按钮,模型一下就能学会,loss很低。有的样本整个页面全是列表项,目标元素藏在底部,还需要先滑动才能看到,模型在决策阶段很容易迷路,loss居高不下。两类样本在同一个batch里交替出现,参数更新方向互相拉扯,SGD加常规学习率调度很容易让模型训练崩溃。
解决办法就是EMA参数更新。我们在每个step迭代时,额外维护一份模型参数的滑动平均值,用于评估和推理。训练时每step更新一次,更新公式是:
ema_decay = 0.999 # 衰减系数,常用0.99到0.9999之间 with torch.no_grad(): for ema_param, param in zip(ema_model.parameters(), model.parameters()): ema_param.data.mul_(ema_decay).add_(param.data, alpha=1 - ema_decay)训练500个step之后,EMA模型的权重基本代表了训练历史中"平均方向"的参数,而不是最后一个step的激进更新。这在loss尖刺频繁出现时,能保住模型的最佳状态。Fairy最终提交的推理模型,全部是EMA权重,而不是原始训练权重。
4.2 推理侧EMA:状态级平滑
推理侧的EMA是Fairy里比较有特点的设计,常见的Agent项目很少在推理时做平滑,但移动GUI场景下非常必要。
移动端截图是连续的,但前后帧之间变化并不完全平滑。弹窗渐入渐出、滚动惯性、加载动画,都会导致同一目标在连续帧中的预测结果产生抖动。比如罗盘钟表类图标,在滚动过程中每一帧的RoI特征都不完全一致,RGR预测的框位置就有几像素的随机抖动。如果直接用单帧的检测结果去驱动OCA点击,用户会看到模型有时候点偏、有时候点准,表现很不稳定。
推理侧EMA做的就是对连续帧的目标元素预测结果做滑动平均。维护一个字典,以元素ID为键,存该元素的平滑中心坐标和置信度。每一帧新预测结果到达时,按下面公式更新:
alpha = 0.3 # 当前帧权重 for obj_id in current_objects: if obj_id in not in smoothed_objects: smoothed_objects[obj_id] = current_objects[obj_id] else: prev = smoothed_objects[obj_id] prev["center_x"] = alpha * obj["center_x"] + (1 - alpha) * prev["center_x"] prev["center_y"] = alpha * obj["center_y"] + (1 - alpha) * prev["center_y"] prev["confidence"] = alpha * obj["confidence"] + (1 - alpha) * prev["confidence"]只有当一个元素连续三帧存在、且平滑置信度超过阈值,才允许OCA真正对它发起点击。这增加了大约一帧的延迟,但大幅降低了误触概率。实测误触率从EMA启用前的每100个任务约11次,降到每100个任务约3次。
4.3 实现示例与参数选择
EMA参数的选择需要根据页面动态程度来定。Fairy的默认配置是:
- 训练侧EMA衰减系数0.999;
- 推理侧坐标平滑alpha为0.3;
- 推理侧置信度平滑alpha为0.2;
- 元素存在性确认帧数为3;
- 点击动作确认帧数为2,即需要在连续两帧中保持高置信度才执行点击。
如果你的场景是偏向静态页面,比如系统设置界面,可以适当提高存在性确认帧数到5,进一步降低误触。如果是短视频信息流这类高频滚动页面,确认帧数过高会显得反应迟钝,建议调低到2。
4.4 EMA不是万能的:什么时候该关掉
EMA也不是越强越好。训练侧EMA衰减太接近1,会导致模型参数更新极慢,在分布变化比较大的迭代阶段,EMA模型反而比原始模型更差。推理侧EMA如果alpha太小,模型切换目标时会持续用旧位置预测新位置,造成"目标已经滚走,点击点还停留在原来位置"的问题。
我们在测试中遇到过最长的一次事故:滑动列表后,新页面元素已经刷新,但推理侧状态字典里所有旧元素对象还存在,而新元素已经出现,旧元素还占着平滑缓存,模型花了大约8帧才完全切换到新状态,期间点击全部打到了错误位置。后来加入状态失配检测:如果连续三帧检测到的屏幕结构(简单计算为元素的分布模式)与缓存状态差异超过阈值,就清空所有推理侧EMA缓存,重新初始化。此后再没出现过同类问题。
5. 综合落地的完整流程与评测
5.1 端到端pipeline
把RGR、OCA、EMA串起来,Fairy在设备上的实际运行流程可以描述成:
- 系统触发任务,比如用户要求"打开天气应用并查看明天降水概率";
- Agent进入执行循环,每次循环开始捕获当前屏幕截图;
- RGR模型对截图进行检测,输出候选元素列表;
- 候选元素经过EMA状态缓存平滑,生成当前可操作的稳定元素列表;
- 决策模块将用户指令与候选元素列表做语义匹配,选出当前目标元素;
- OCA根据目标元素信息和局部图像特征,生成最优点击点;
- 执行点击或滑动手势;
- 跳回步骤2继续,直到任务完成或者达到最大步数限制。
这个pipeline在单设备上的单步耗时,不含真实UI响应时间,大约是330毫秒。其中截图压缩约50毫秒,RGR检测约15毫秒,OCR识别约80毫秒,OCA热力图计算约60毫秒,决策模块约100毫秒,剩下的都是调度开销。
5.2 数据准备与模拟器策略
纯真机采集数据太慢,Fairy采用了模拟器和真机混合的数据生产方案。模拟器用来大规模生成元素标注数据,真机用来采集复杂交互和网络变差的场景数据。
模拟器采集时有个重要的坑:模拟器的分辨率、渲染速度、字体渲染与真机差异明显。我们做了两层缓解。第一层是渲染随机化,给模拟器环境随机应用不同的分辨率、字体大小、深色模式、系统字体缩放,增加数据分布覆盖。第二层是做真机样本比例保证,要求每个App的真机采集数据占比不低于训练集总量的15%,否则检测模型在模拟器上过拟合严重,真机表现掉点明显。
5.3 模型训练与部署配置
训练阶段的配置如下:
- 训练数据集:约120万张截图,其中80万来自模拟器,40万来自真机;
- 骨干模型:MobileNetV3-Small,在ImageNet预训练权重基础上做全量微调;
- 训练策略:batch size 32,AdamW优化器,初始学习率1e-3,余弦退火调度;
- EMA衰减:0.999,每step更新;
- 训练轮数:8个epoch,第4个epoch开始做随机分辨率抖动训练;
- 量化:训练完成后做INT8量化,精度损失控制在检测AP下降不超过0.8个百分点。
部署上,模型整体量化后体积为4.3MB,运行在端侧AI推理框架中。CPU单核推理延迟大约18毫秒,加上前后处理后能稳定控制在25毫秒内。
5.4 评测指标与结果分析
Fairy的评测体系分成三个层次:
- 元素级评测:对比RGR检测框与标注框的IoU、中心点距离误差。目标是IoU大于0.7且中心距离误差小于6像素。
- 动作级评测:对页面执行操作后,判断预期界面是否出现。用于评测OCA。
- 任务级评测:完整执行用户任务串,计算任务完成率、平均步数、每步成功率。
最终结果里,RGR在2000张测试截图上的检测mAP达到0.82,中心距离误差中位数4.5像素。OCA动作执行成功率96.8%。任务级完成率在30个跨App任务评测中达到87%。听起来不算惊艳,但考虑全部跑在端侧、无云端算力,这个数字已经进入可用区间。
6. 常见问题与排查速查
6.1 典型问题与处理对策
| 现象 | 可能原因 | 排查方法 | 解法 |
|---|---|---|---|
| 点击点总是偏右下 | RGR回归头训练不充分或标注坐标偏差 | 抽样标注框与模型预测框对比 | 调整损失权重,重新校准标注规则 |
| 连续点两次才生效 | 推理侧EMA存在性确认帧数过少 | 查看点击前后截图 | 把确认帧数从2调到3 |
| 页面滚动后模型用了旧坐标 | 推理侧EMA缓存未清空 | 打印缓存状态字典 | 加入状态失配检测 |
| 模型训练loss尖刺频繁 | 难易样本交替剧烈 | 查看batch内loss方差 | 调高EMA衰减系数,进行梯度裁剪 |
| 检测框正确但点击仍失效 | OCA热力图未考虑系统UI遮挡 | 抓取系统窗口层级 | 在OCA输入中叠加系统遮罩层提示 |
| 模拟器效果好、真机差 | 模拟器过拟合 | 分组评测 | 保证真机数据比例不低于15% |
6.2 我踩过的坑和避坑心得
最先想说的是数据集分辨率问题。一开始偷懒直接用了原始截图尺寸训练,到了线上手机上,不同分辨率把模型搞得很不稳定。改成统一缩放后好了很多,但也遇到新的坑:放大缩小时的插值方式,模型训练和推理必须一致。训练用双线性插值,推理端用了最近邻,结果模型在边缘文本处出现了接近10像素的预测偏移,排查了整整一天才发现是这个原因。
然后是闭环节流的超参问题。OCA重试机制的间隔时间最开始设为0.5秒,但在中低端手机上,截图一轮加上UI动画还没结束,0.5秒的重试会导致连续点击两次同一个位置。后来把重试间隔延长到1.2秒,并强制等待界面稳定后再试,成功率反而提升了。
EMA参数也不能一刀切。滚动列表页面和静态设置页面的最优alpha完全不同。我给Fairy增加了一个轻量级的页面动态度估计器,根据连续帧的元素匹配率估算页面变化程度,动态调整alpha。
6.3 后续扩展方向
Fairy走通这套RGR+OCA+EMA的组合后,后续扩展方向其实很明确。一个是把RGR的区域感知从矩形框升级到任意形状的可交互热区,这样对不规则Tab栏、悬浮窗、折叠面板的兼容性会更好。另一个是OCA的动作空间从点击、滑动扩展到长按、双指缩放、连续拖拽,这需要把热力图预测换成轨迹预测。
还有一个值得做的方向:目前EMA只对单设备的连续帧状态做平滑,如果未来要支持多设备协同,需要引入跨设备状态同步,而平滑策略也要相应重新设计。这些都是Fairy之后迭代要考虑的事情。
Fairy这套方案走到现在,我个人最大的感触是:GUI Agent落地难点从来不在于单一模型多强,而在于把检测、决策、校准、稳定这四个环节用一个系统工程的方式串起来。RGR负责让感知精确到像素,OCA负责让动作适应真实界面规则,EMA负责让整个系统在动态环境里保持稳定。三者单独看不稀奇,组合使用才真正解决了移动端GUI Agent从"能演示"到"能稳定跑任务"的跨越。
如果说还有什么项目级建议要留给大家,那就是从第一天起就给三个模块分别建立独立的评测集,不要等问题联调时才切数据。分开测,才能快速定位是哪个环节在拖后腿。后面有关于部署细节或者评测数据集构建的问题,欢迎大家来交流。