DEV Community

Fenix
Fenix

Posted on

手机自动化的演进:从脚本到状态流

前阵子跑 OpenGUI,在真机上试了一个长程任务:打开 X,搜索 mobile AI agents 相关的近期讨论,收集主要观点,再总结大家关心的问题。

用自然语言描述只有一句话,执行起来却拆成了几十个判断和动作。App 打开了吗?是不是首页?搜索框点中了吗?结果加载了吗?中间有没有登录弹窗?有没有推荐关注?页面跳走了是回退还是重试?

在传统手机自动化的思路里,这种任务很难稳定跑完。点击本身不难,麻烦的是真实手机不按脚本走

为了验证这个判断,我用三种方案各跑了三次同一个任务。

纯脚本(Appium):三次全失败。一次卡在更新弹窗,两次搜索后页面结构变化导致 xpath 失效。平均存活 4 步。

VLM 截图循环(v2 Agent):三次里一次成功,耗时 18 分钟,中间在推荐关注弹窗上重试了 7 次。另外两次分别在第 12 步和第 23 步陷入循环:截图显示没变化,模型继续点同一个位置,再截图还是没变化。

OpenGUI:三次全部成功,平均耗时 11 分钟。最长一次遇到登录弹窗 + 网络加载慢 + 推荐关注,supervisor 做了两次重新规划,没有人工干预。

这组对比说明的不是 OpenGUI"更聪明",而是它把任务状态显式地维护在系统里,而不是依赖模型隐式地记住上下文。

传统手机自动化:假设世界会配合你

目前市面上主流的方案大致分三类:

录制回放,你在手机上操作一遍,工具把点击坐标、滑动轨迹、输入内容录下来,之后按原样回放。

UI 自动化框架,如 Appium、UIAutomator,通过 accessibility tree 或 xpath 定位元素,然后执行操作。

RPA 平台,可视化编排,把上述能力封装成流程图,加上条件判断和异常捕获。

这些方案在简单场景下都很好用。每天自动打卡、定时抢券、批量处理固定流程,只要页面不变,它们能跑得很稳。页面一变,或者流程稍长,问题就冒出来了。

v1:脚本的天敌是弹窗

录制回放是最直观的方案。你操作一遍,它记下来,下次照做。

拿那个 X 搜索的任务来说,录制下来的流程大概是:点击 X 图标,等待 3 秒,点击搜索框,输入 "mobile AI agents",点击搜索按钮,再等 3 秒,滑动浏览,截图保存。

这个脚本在理想情况下可以跑通,但现实世界不合作。打开 X 时弹了一个更新提醒,点击坐标就错位了。网络慢,3 秒不够,页面还在加载 skeleton。搜索前让你登录,脚本不知道这是哪一步。结果页中间插了一个推荐关注,滑动被拦截了。某个博主的内容需要点击"显示更多",但脚本没有这条分支。

脚本没有状态。每一步都预设了上一页的结果和当前页的状态,现实一偏离就报错退出,没有恢复能力。

v2:视觉理解让脚本变聪明一点,但没解决状态问题

坐标和 xpath 不可靠,能不能让机器看懂屏幕?

这是近一两年手机 Agent demo 的主流思路:截图,传给多模态模型(VLM),模型返回下一步动作,执行,再截图。

这个循环比脚本灵活。模型能看到当前屏幕,能识别搜索框在哪,甚至能处理一些弹窗。不需要预先定义坐标,告诉它"打开 X 搜索 mobile AI agents"就行。

问题是,VLM 每次只看当前这一张截图。短任务里没问题,三步之内打开 App、点一个按钮、输一段文字,模型通常能搞定。任务一长,短板就暴露了:

模型不知道前面做了什么,第十步失败时不知道要回退到第几步。它也不知道整体目标,只看当前截图,容易被局部最优带偏。看到一个不顺眼的 UI,可能会顺手"优化"一下,偏离主线。任务完成了,它可能还在继续点点点。

v2 解决了看懂屏幕的问题,但没有解决维护长程状态的问题。

更底层的问题是上下文窗口的硬限制。VLM 处理截图 + prompt 的 token 消耗很大,一张 1080p 截图编码后可能占 1000-3000 token。5-10 轮循环后,前面做了什么、最初的目标是什么,物理上就被挤出上下文了。这不是"忘了",是装不下了。

很多手机 Agent demo 停在 v2。三分钟的 demo 很惊艳,三十分钟的实际任务,大概率会在某个弹窗或加载状态上卡住,然后陷入截图、识别、点击、没变化、再截图的死循环。

v3:把目标变成状态流

OpenGUI 的做法是把任务放进一个有状态的后端 graph 里,而不是让模型在本地做一个无状态的截图循环。

看过源码,架构很清晰。核心链路大概是这样:

User/IM 命令 → Plan Supervisor → Executor Graph → Android Client → 真实设备
                      ↑                ↓
                      └──── 执行结果 + 设备状态 ────┘
Enter fullscreen mode Exit fullscreen mode

主 graph 在 mobile-agent.graph.ts 里构建,用的是 LangGraph 的 StateGraph:

const graph = new StateGraph(AgentStateSchema)
  .addNode("supervisor", supervisorNode)
  .addNode("extract_todo", extractTodoNode)
  .addNode("fallback_extract", fallbackExtractNode)
  .addNode("gui_executor", executorSubgraph)
  .addNode("summarizer", summarizerNode)
  .addEdge(START, "supervisor")
  .addConditionalEdges("supervisor", routeAfterSupervisor)
  .addConditionalEdges("extract_todo", routeAfterExtractTodo)
  .addConditionalEdges("gui_executor", routeAfterExecutor)
  .addEdge("summarizer", END);
Enter fullscreen mode Exit fullscreen mode

Plan Supervisor 维护任务状态。复杂任务进来,supervisor 先拆成可执行的子任务,形成计划文档,然后逐个派发给 Executor。它本身也是一个 LLM agent,带有 write_todosread_todos 两个 tool,可以动态调整任务列表。第一次调用时生成计划,后续调用时评估 Executor 回传的结果,决定标记完成、重试还是重新规划。

Supervisor 的路由逻辑很简单,但足够说明问题(routing.ts):

export function routeAfterExtractTodo(state: AgentState) {
  if (state.isCancelled) return "summarizer";
  if (state.planTodoComplete) return "summarizer";
  if (state.todoFound) return "gui_executor";
  return "fallback_extract";  // 用 Haiku 做兜底提取
}

export function routeAfterExecutor(state: AgentState) {
  if (state.isCancelled) return "summarizer";
  if (isExecutionConnectionLostMessage(state.executorOutput?.fail_reason)) {
    return "summarizer";  // 设备断连,停止重试
  }
  return "supervisor";  // 把执行结果送回 supervisor 评估
}
Enter fullscreen mode Exit fullscreen mode

Executor Graph 负责把子任务落到设备上,本身也是一个 subgraph。执行循环在 executor.graph.ts 里定义:

const subgraph = new StateGraph(AgentStateSchema)
  .addNode("entry", entryNode)
  .addNode("sense", senseNode, { retryPolicy: { maxAttempts: 3 } })
  .addNode("vision_model", visionModelNode)
  .addNode("parse_action", parseActionNode)
  .addNode("execute_action", executeActionNode, { retryPolicy: { maxAttempts: 3 } })
  .addNode("post_execute", postExecuteNode)
  .addEdge(START, "entry")
  .addEdge("entry", "sense")
  .addEdge("sense", "vision_model")
  .addConditionalEdges("vision_model", routeAfterVisionModel)
  .addConditionalEdges("parse_action", routeByAction)
  .addConditionalEdges("execute_action", routeAfterExecuteAction)
  .addConditionalEdges("post_execute", routeAfterPostExecute);
Enter fullscreen mode Exit fullscreen mode

Entry 节点初始化执行状态;Sense 节点从设备获取截图和当前 App 信息;Vision Model 节点把截图和上下文发给 VLM,获取下一步动作;Parse Action 节点把 VLM 的输出解析成结构化动作;Execute Action 节点通过 WebSocket 把动作发到 Android 设备执行;Post Execute 节点做异常检测(后面细说),然后决定回退到 Sense 继续循环,还是退出 subgraph。

Summarizer 在收尾时介入,把执行过程的关键信息整理成结构化结果返回给用户。

这几个组件的协作,让目标从一个 prompt 里的文字,变成了一整套可以被引用、暂停、恢复和清理的状态。

状态具体存在哪里?看 state.types.ts 里的 AgentStateSchema

  • planDocument:supervisor 生成的计划文档(Markdown)
  • executorInput / executorOutput:当前子任务的输入和输出
  • executor 字段:Executor subgraph 的内部状态,包括截图 URI、当前预测、循环计数、异常标记、消息历史、token 用量等
  • todoFound / planTodoComplete:supervisor 决策用的布尔标志
  • isCancelled / isPaused:用户中断状态

这个状态不是存在模型上下文里,而是存在后端 graph 的 state 对象中,每步执行完都由 LangGraph 的 reducer 合并更新。

Android 端通过 WebSocket 和后端保持连接。StandbySocketManager.kt 负责设备待命,GestureService.kt 负责把动作执行到真实设备上。设备不是被脚本驱动的傀儡,而是一个持续反馈状态的 worker。

异常检测:executor 里的保险丝

v2 方案最容易出现的是"循环":截图没变化,模型继续点同一个位置,再截图还是没变化。OpenGUI 在 Post Execute 节点里做了显式的异常检测。

post-execute.node.ts 里的检测逻辑:

动作重复检测:检查最近 10 个动作里是否有连续 5 个相似动作(同类型 + 坐标距离小于 50 像素)。如果是,标记为可能循环。

动作周期检测:检查是否存在 A-B-A-B 的交替模式。比如点击返回再点进去,再点返回再点进去,模型在两个页面间来回跳。

截图异常检测:用 perceptual hash(pHash)比较最近几张截图。如果连续 3 张截图完全相同且动作不是 wait/scroll,说明页面没响应。如果截图呈现 A-B-A-B 的交替模式,说明页面在两个状态间切换。

连续滚动检测:连续 scroll 超过 8 次,判定当前搜索策略没有进展,强制退出 executor 让 supervisor 重新规划。

检测到异常后,Post Execute 节点会设置 needRemind = true,并在下一轮 Vision Model 调用时注入提醒:

const remindMessage = new HumanMessage(
  `The current task may be stuck in a loop or drifting from the goal.
Execution anomaly: ${exec.remindReason}
Original task: ${instruction}
Check whether the execution is drifting from the original goal or stuck in a loop.`
);
Enter fullscreen mode Exit fullscreen mode

这个设计的关键是:异常不是报错终止的理由,而是被消费掉的输入。检测到循环 → 注入提醒 → VLM 下一轮输出修正动作 → 继续执行。整个链路在系统内部闭环,不需要人工干预。

关键差异:状态管理

传统脚本没有状态,只有"下一步该做什么"。v2 的 Agent 也没有状态,只有"当前屏幕该怎么处理"。OpenGUI 的状态分布在计划文档、当前子任务、执行结果、失败分类这些数据结构里,supervisor 每一步都能基于完整状态做决策。

走迷宫的比喻很贴切:传统脚本手里只有一张路线图,走错一步就迷路。v2 Agent 每到一个路口抬头看四周,但记不住来时的路。OpenGUI 手里有一张实时更新的地图,知道自己在哪、目标在哪、哪条路试过不通。

另一个关键差异是模型角色的分离。v2 通常用一个模型做所有决策,OpenGUI 把规划、监督、VLM 执行拆到不同模型。

从 README 里的数据,全 Claude Opus 配置跑一个中等长度任务(约 60 次截图分析),VLM + Planner + Supervisor 全部用 Opus,预估费用在 $8-15 区间。换成 Qwen 3.6 Plus 做 Planner 和 Supervisor,Doubao Pro 做 VLM,同样任务降到 $0.6-1.2,成本差 10-15 倍。

这个成本差异来自两个因素:一是 Qwen/Doubao 的单价远低于 Opus,二是 OpenGUI 的架构允许不同角色用不同模型。Planner 和 Supervisor 处理的是文本计划,不需要多模态能力,可以用便宜的文本模型。只有 Executor 里的 VLM 需要看图,这部分费用被隔离在 subgraph 里。

一个具体的例子

X 搜索的任务在 OpenGUI 里会经历这些状态:

Plan Supervisor 先生成计划:打开 X,搜索关键词,浏览结果,收集观点,总结。然后派发子任务"打开 X"给 Executor。Executor 截图,VLM 判断当前是桌面还是其他 App,执行点击。结果回传:X 已打开,但弹出了登录框。Supervisor 判断这是异常,需要处理登录,无法处理就标记失败并尝试跳过。登录处理完,继续派发"搜索关键词"。Executor 执行搜索,网络慢页面没加载完,内部重试,等待再截图再判断。搜索完成,进入"浏览结果"子任务。中间遇到推荐关注弹窗,Executor 识别为干扰,尝试关闭或跳过。所有子任务完成,Supervisor 调用 Summarizer 生成结构化总结。

没有一个环节假设页面会按顺序走。每一步都基于当前真实状态判断,失败被当作正常输入消费,不是异常终止的理由。

执行完成后,Summarizer 返回的结果大致长这样:

## Task Summary

**Goal**: Search X for recent discussions on "mobile AI agents" and summarize key concerns.

**Execution**: 
- Opened X app successfully
- Searched "mobile AI agents" 
- Scrolled through top 20 results
- Collected 8 relevant posts/threads

**Key Findings**:
1. Privacy concerns dominate: users worried about screen recording and data access
2. Reliability: agents failing on non-standard UI patterns (custom keyboards, overlays)
3. Cost: VLM per-screenshot pricing makes long tasks expensive
4. Latency: 5-15s per action too slow for real-time interaction

**Blocked Items**:
- Login prompt appeared; task continued after handling
- One result required app switch to Safari; skipped per constraints

**Conclusion**: Mobile AI agents are technically feasible but face UX, cost, and trust hurdles before mainstream adoption.
Enter fullscreen mode Exit fullscreen mode

代价是什么

这套设计更重,代价在三方面。

模型成本。VLM 每次分析截图都要调 API,一张 1080p 截图编码后可能占 1000-3000 token。一个 10 分钟的任务如果有 60 次截图分析,总 token 消耗可能在 15-30 万之间。全 Opus 配置下这是不可忽视的开销,混合模型配置能把费用压到可接受范围,但代价是模型能力的差异:Qwen 的规划质量不如 Opus,Doubao 的视觉理解在某些场景下会漏掉细节。

延迟。截图 → 后端 → VLM 推理 → 动作解码 → 网络传输 → 设备执行 → 等待 UI 稳定 → 再截图,这个链路单轮通常在 5-15 秒。一个 60 步的任务,纯等待时间就 5-15 分钟。v2 方案如果把 VLM 放在本地或近端可以更快,但 OpenGUI 的后端 graph 架构天然引入了一层网络跳转。对延迟敏感的任务(比如实时游戏辅助),这套架构不适用。

系统复杂度。你需要跑后端(NestJS + LangGraph)、数据库(PostgreSQL)、缓存(Redis)、WebSocket gateway,还要维护 Android 客户端的待命连接。部署一套 OpenGUI 比跑一个 Python 脚本重得多。设备会休眠,后台会被系统杀掉,WebSocket 会断线。standby 机制不是"连上就行",要处理心跳(35 秒间隔)、重连、状态同步。

但手机端很难绕开这些复杂度。真实 App 会弹窗,会卡加载,会误触,会把你带到一个完全不同的页面。只靠循环 prompt,稍微长一点的任务就会退化成截图版的 while true

OpenGUI 把复杂度显式地放进系统里。一次没点对,变成 supervisor 要消费的执行结果。设备掉线,被 standby gateway 检测到并停止重试。任务跑了一半被暂停,可以在恢复时从当前子任务继续。这个设计更重,但它给了长程任务一个可以调试、可以恢复、可以复盘的位置。

Checkpoint:任务中断后能恢复

LangGraph 提供了 checkpointing 机制,OpenGUI 把它接进了 PostgreSQL(PostgresCheckpointerService)。这意味着:

  • 任务执行到第 20 步,后端重启了,重启后可以从第 20 步的 checkpoint 继续,而不是从头来
  • 用户手动暂停了任务,恢复时 supervisor 重新评估当前状态,决定继续执行还是重新规划
  • 多个子任务共享同一个 thread ID,状态在 graph 节点之间持久化

这个功能对长程任务很关键。一个跑了两小时的任务,如果因为后端滚动更新而丢失全部进度,是不可接受的。checkpoint 把"状态在内存里"变成"状态在数据库里",牺牲了性能,换来了可靠性。

贤者时刻

自动化系统的能力边界,不是由"能执行什么动作"决定的,而是由"能维护多少状态"决定的。

脚本只能维护一步的状态(下一步做什么)。v2 Agent 能维护一轮的状态(当前屏幕怎么理解)。OpenGUI 维护的是整个任务生命周期的状态:计划、进度、异常、恢复。

Codex 的 /goal 在 coding agent 里做了类似的事:把目标从一轮对话里的文字,变成会话里可恢复的状态。OpenGUI 在手机端走了更远,它不仅保存目标,还把设备反馈、执行结果和失败处理接成了一条完整的状态流。场景不同,问题很接近:长程 agent 不能只会执行下一步,还要持续维护"我在哪、要去哪、边界是什么"这些信息。

如果你只是每天自动签到一次,脚本就够了。要让 AI 在真实手机上跑一个持续几十分钟、涉及多 App 切换和复杂判断的任务,就需要把目标从 prompt 里抽出来,变成一个可以被持续维护的状态。这个选择更重,但它是从 demo 走向 production 的必经之路。

参考

Top comments (0)