面灵AI→

OPPO AI算法秋招一面:手机端 GUI Agent 全链路

轮次
一面
时间
2026-09
来源
牛客网

《面试题目》

  1. 请做一下自我介绍。
  2. 你做的这个助手采用了怎样的整体架构?
  3. 为什么不能只使用截图,让多模态模型直接预测点击坐标?
  4. 截图区域与控件树节点如何完成跨模态对齐?
  5. 页面中存在多个同名按钮时,模型如何确定真正的操作目标?
  6. 跨页面任务中的状态怎样表示,才能避免模型忘记已经完成的步骤?
  7. 页面发生跳转后,怎样判断上一步动作是否真正成功?
  8. 训练轨迹中的「动作正确」应该怎样定义?
  9. 怎样构造高质量的困难负样本?
  10. 为什么行为克隆在长任务上容易出现误差累积?
  11. DAgger 解决了什么问题,真实业务中为什么难以直接使用?
  12. 离线强化学习中的分布外动作为什么危险?
  13. IQL 为什么不需要显式学习行为策略?
  14. 部分可观测环境中,为什么当前截图不足以完成决策?
  15. 层级策略为什么比单一策略更适合跨应用长任务?

《参考解析》

为什么不能让多模态模型直接预测点击坐标:坐标本质上是「截图分辨率的函数」,而不是页面语义的函数。模型在 1080×2400 上学到的 (x, y),换到 1440×3200 或折叠屏展开态就整体失配;就算改用归一化坐标,滚动位置、弹窗遮挡、状态栏/手势条高度、系统字体缩放、深色模式都会让同一个按钮的相对位置漂移。更致命的是,坐标动作不可解释、不可幂等重放——重放时页面已变,唯一的补救是重新定位,而「重新定位」本身又需要控件信息。所以正确做法是:视觉/OCR 先给候选区域,再与控件树对齐;一旦命中控件节点,动作就绑定到「控件标识」上(resource-id 或稳定 key + 树路径 + 归一化 bounds),输出 click(target=node_key) 而不是 click(x, y)。只有确实没有可对齐节点(Canvas、自绘控件、WebView 内嵌、图片按钮)时才退化成坐标动作,而且这类动作要额外挂三道约束:匹配分要明显高于第二名、只允许在可逆/非破坏性页面执行、执行前重新拉一次控件树做 stale 检查(决策时的 bounds 与当前 bounds 的 IoU 低于约 0.5 就丢弃重规划)。

截图区域与控件树节点的跨模态对齐:分四步。①候选生成:以控件 bounds 外扩一定比例(例如 816px 或面积的 15%)作为视觉候选,同时对 OCR 文本块和显著性区域做反向查找,找到包含它的控件节点。②联合表示:每个候选拼四类特征——视觉 patch 特征;OCR/控件文本的文本 embedding;控件属性(class/type、clickable、enabled、scrollable、depth、是否带 resource-id);几何与结构特征(归一化 xywh、面积比、相对父节点和兄弟节点的位置、父节点文本)。③粗筛:IoU 大于 0.5 视为强空间候选,0.10.5 保留待精排;文本侧用字符 n-gram 或 embedding 余弦,OCR 文本与控件 text 存在包含关系的给高先验。这里要特别注意「只算 IoU 不可靠」:整行可点击条目的可点击区域远大于文字,列表项里还常有父子同心的嵌套框,所以要把「包含关系」单独作为一维特征——视觉框被控件框包含给正分、控件框被视觉框包含给轻微负分——而不是指望 IoU 一个数表达所有空间关系。④精排:把视觉 token、文本 token、结构 token 拼起来送跨模态编码器,在候选集上做 softmax 得到匹配分。训练数据可以从真实点击事件自动挖:触摸坐标落在哪个控件 bounds 内就是正样本(注意坐标先做一次仿射校准,截图坐标系与控件树坐标系常有状态栏/导航栏偏移,可以用若干已知控件的位置差取中位数偏移量),同页空间近邻且文本相似的节点作为困难负样本。线上阈值可以粗分三档:匹配分高于 0.8 直接执行;0.5~0.8 只在动作可逆时执行;低于 0.5 视为未匹配,走视觉退化和确认流程。

同名按钮消歧,以及「歧义时请用户确认」为什么是硬要求:文本相同、语义不同的按钮(满屏的「确定」「删除」「更多」)单看控件特征必然错。可行做法是把页面建成图:控件是节点,边至少三类——包含(父子)、相邻(几何近邻或同一色块)、语义归属(同属一张卡片、一个列表项、一个表单分组)。推理时先在图上定位业务实体(比如「明天 15:00 的评审会」这条日程卡片),再只在该实体的局部子树里挑动作控件。候选打分 = 文本匹配 + 是否落在目标实体子树内 + 相对实体中心的几何距离 + 控件类型先验(危险色、danger 类名的「删除」适当降权)+ 槽位一致性。其中槽位一致性应当作硬过滤器而不是加权项:用户说「删掉明天的会议」,候选卡片日期不等于「明天」就直接排除,不能靠权重去压。最关键的一条是:当 Top-1 与 Top-2 的分差小于阈值(例如 0.1),或两个候选属于不同实体且动作不可逆时,必须转为让用户确认,不允许用置信度硬消歧——真实歧义是页面和指令本身的属性,模型再强也推不出用户想删哪一条,用高置信度「猜」只是把错误藏起来。风险分级建议:可逆低风险(切 tab、滚动、展开)自动执行;可逆但改状态(改设置、开关)执行并留可回滚记录;不可逆(删除、支付、发消息、授权)一律确认。确认也要结构化,把候选渲染成可点选项(「09:00 周会 / 15:00 评审」)让用户一次点选,而不是抛一句「你指哪一个」再让用户打字。

跨页面任务的状态表示:只保留最近 K 张截图有两个死穴——视觉 token 成本随步数线性上涨,而且长任务里大量页面长得很像,模型会把「刚才那个列表页」和「现在这个列表页」混起来。所以要外置一个结构化 task state,至少包含:goal(用户原始指令)、subgoals(有序,每个带 done/pending/blocked)、current_app 与页面标识、slots(关键槽位键值对)、pending_confirmations、last_action 及其后置条件结果、artifacts(验证码、地址、金额等跨页要用的信息)、每个字段的 TTL、页面栈。核心设计是 provenance:每个 slot 必须标明来源是 user_stated、page_fact 还是 model_inferred,model_inferred 不能当作已确认事实——尤其提交类动作前,关键字段必须由前两者之一支撑,否则先确认。状态更新也不该完全交给模型自由生成:日期、金额、联系人、地址这类字段用解析器归一化(相对日期换算成绝对日期并绑定时区,金额统一到最小货币单位,电话号码去格式化),解析失败就保留原值并标成待确认。新旧值冲突时不直接覆盖,而是记一条 conflict 并按「用户新指令 > 页面事实 > 模型推断」定序,同源冲突则降级为确认。带时效的字段(验证码、一次性 token、页面元素 id、临时定位)必须带 TTL 且绑定页面/会话,跨页或过期即清除,否则会被错误复用。举个具体例子:指令「给明天下午的会议加提前 30 分钟提醒」,状态里先落 slots={event_date: 明天/user_stated, remind_offset: -30min/user_stated, event_title: pending},进入日历详情页后从页面标题把 event_title 补成 page_fact,再执行设置动作。状态外置还有个附带收益:任务崩溃或超时后能从状态续跑,而不是从截图历史里重新猜。

动作成功判定:把「点击已发送」和「业务已完成」分开:执行接口返回成功只说明事件发出去了,加载失败、权限弹窗、网络错误、页面纹丝不动都会返回成功。判定必须落到后置条件上,且每个动作模板绑定自己的断言:toggle 要求目标控件 checked 由 False 变 True;input 要求输入框文本等于期望值(注意密码框的掩码场景要换判定方式);navigate 要求页面标识变化且出现预期的关键控件;create 要求目标列表里出现唯一匹配的新记录(键不能用标题,要用标题+时间这类组合);delete 要求该记录从列表消失。读状态要带等待,避免把加载中的中间态当成失败:

def wait_until(pred, timeout=3.0, interval=0.3):
    deadline = time.monotonic() + timeout
    while True:
        if pred():
            return True
        if time.monotonic() >= deadline:
            return False
        time.sleep(interval)

def verify_toggle(before, after, target_id):
    b, a = before.elements.get(target_id), after.elements.get(target_id)
    if b is None or a is None:
        return "STALE_TARGET"      # 控件消失或已换页,不能算成功
    if b.attrs.get("checked") is False and a.attrs.get("checked") is True:
        return "OK"
    if b.attrs.get("checked") == a.attrs.get("checked"):
        return "NO_EFFECT"
    return "UNEXPECTED"

断言不过时不要立刻重复点击,按原因分流:页面仍在加载(存在 loading/progress 节点或请求未完成)就继续等;目标失效(控件消失或 disabled)就重新观测、重新规划;出现了新的阻塞页面(权限弹窗、登录、验证码、系统弹窗)就把它当成一个子任务先处理掉再回到原目标;提示文本出现「网络异常」「操作失败」就判定业务失败并上报;页面确实毫无变化且不在加载,最多重试一次幂等动作。重试这条线要守住:提交订单、发送消息这类非幂等动作禁止盲重试,重试前必须先确认上一次是否已被服务端接受(例如去列表里查这条记录在不在),否则一次网络抖动就会变成两笔订单。

「动作正确」的定义与长任务误差累积:动作正确不能只看最终任务是否完成,也不能要求与人工轨迹逐步一致——同一任务往往有多条有效路径,中间动作与标注轨迹不同也可能是合理的。更实用的拆法是把动作正确性分三层:合法性(点击当前页面不存在的控件直接非法)、局部有效性(是否推进当前子目标,比如打开相关设置页)、全局可完成性(是否破坏后续路径,比如删掉了后面还要用的信息)。只有固定流程或高风险业务才施加严格步骤约束,其余场景用环境执行后的状态来验证动作,而不是比对轨迹。这直接连到行为克隆的痛点:BC 只在专家状态分布上学动作,一旦执行时偏出训练分布,模型仍要继续预测,错误就滚起来,单步正确率 p、长度 T 的任务整体成功率大致按 p^T 衰减,单步 0.95、T=20 就只剩三成多。缓解手段是收集模型自己跑出来的偏离状态并让人或验证器补纠正动作(DAgger 的思路)、训练状态价值模型提前识别「再走一步就不可恢复」的区域、对高风险动作宁可暂停重规划也不在错误状态上继续滚。困难负样本的构造也受这条约束:随机挑无关控件太简单,要挑同页语义相近的(同名按钮、相邻联系人、同列表其他商品、状态相反的开关),或用最小修改构造反事实指令(只改日期/联系人/金额让原动作变错),但生成完必须用执行器或规则验证器复查,确保它确实错误——多解任务里把另一条有效路径标成负样本会制造自相矛盾的监督信号。