手机端 Agent 是怎么跑起来的:决策循环、四道护栏、两条红线

这是整个手机 GUI Agent 架构的总览篇。

前面几篇我拆了感知(怎么看屏幕)、打包(Python 怎么塞进 APK)、桥接(两层语言怎么打通)、记忆(怎么记住环境)。但一直没讲最核心的那一下:这些部件是怎么被串起来、一下一下把任务跑完,又怎么保证不乱来。

把这几件事一次讲清,整个项目就通了。

一、决策循环:Agent 怎么"活"起来

为什么是「循环」,不是「一次调用」

很多人第一次想 Agent,会觉得:我给大模型下个指令,它直接帮我干了不就完了?

实际不是。手机上的任务("打开微信给张三发条消息")是多步 的:先得看现在在哪个界面、找到微信图标、点开、找到搜索框、输入张三......每一步做完,界面变了,下一步得看新界面才能决定。所以 Agent 不能一次想清楚,得边看边想边动,干完一步、看一眼、再想下一步。

这就是循环:看 → 想 → 动 → 看 → 想 → 动......直到干完。我这个项目里它叫 MiniLoop。

一圈长什么样

一步步说:

1. 你下指令。 两种形态:老表单式("目标: 去干 XX")或会话式("帮我把亮度调低点",历史对话提供上下文)。代码里叫 K1 统一入口,两条路径都支持,不是写死的模板。

2. 模型自己决定先看什么。 这是刻意取舍:MiniLoop 不替模型自动截图。感知工具(observe)放在工具面里,模型自己决定「这一步我要先看看屏幕吗」。很多 Agent 每轮强制截图,token 哗哗烧;这里把「什么时候看」交给模型判断,省不少。

3. brain.chat 出 tool_calls。 大脑(LLM)读完当前上下文,决定调哪个工具、传什么参数。参数和返回全是 JSON 可序列化的 dict------这是和 Kotlin/Chaquopy 边界同构的前提,前面桥接篇讲的 JSON 传数据,根在这。

4. _dispatch 分发执行。 模型说「调 tap_xy」,_dispatch 就真的去执行。普通动作走桥接层那座桥去真机点;task_done 特殊,走强制校验门(下面细讲)。

5. 结果喂回上下文。 刚才干了啥、界面变成啥样,作为一条 tool 消息塞回对话,下一轮模型就能看到了。

6. 直到 task_done 或预算耗尽。

几个值得说的设计点:上下文是滑动窗口(只硬留 system + 最近 8 轮,更早的无条件丢,关键轨迹证据另存);闲聊不调工具(纯文本收尾专门分了支);工具面极简(几个原语 + 内置 task_done)。

二、四道护栏:把会动手的循环关进笼子

光有循环不够------会动手的 Agent 比纯聊天的 LLM 多一类风险 :它真去点了,点错了、卡住了、或者觉得自己干完了其实没干完,都是真麻烦。MiniLoop 里靠四道护栏挡着。

第一道:卡住检测(别在同一个动作上转圈)

模型有时候犯傻:同一个动作连点四次,界面没变,它还在点。代码里这么管:每次 _dispatch 前,算一下「这次的动作和上一次是不是一模一样」。连续相同到第 3 次,塞一句提醒「换思路或先 observe」;到第 4 次,直接判 stuck 终止。

kotlin 复制代码
// 阈值(mini_loop.py 顶部常量)
STUCK_NOTE = 3       // 连续相同 → 注入升级提示
STUCK_GIVE_UP = 4    // 连续相同 → 判 stuck 终止

为什么第 4 次才停?同一个动作点两三次有可能真在加载,直接停会误杀。给 3 次缓冲,第 4 次基本能确定是死循环了。

第二道:RepeatGuard(连败三次逼它换方案)

卡住检测管「完全相同的动作」。但还有另一种:同一个工具、参数不同、连续失败三次(比如找控件,换了 id 还是找不到)。这不算「相同动作」,卡住检测抓不到。

RepeatGuard 补这个洞:某个工具连续失败达到 3 次,排一条「工具 X 已连败 3 次,请换执行方案,别重复同一失败调用」。而且成功一次就清零------管的是「连败」,不是「总失败次数」。这两道互补:一个管原地转圈,一个管同工具连败。

第三道:task_done 强制校验(防幻觉式完成)

这是我最在意的。模型干到一半,可能「觉得自己干完了」就调 task_done,但其实没干成------纯 LLM 的幻觉,到会动手的 Agent 上就是真事故。

MiniLoop 的做法:如果配了校验工具(verify),task_done 必须先把当前界面交给 verify 判一遍 。过了才认 done;没过,拒绝,推回 observe 重新规划。还有个 verify_stopped 例外:被拒后模型不能「那我口头宣布完成算了」绕过校验------被拒后紧接着的纯文本会被拦下来,逼它继续干。这条专门防「被拒就嘴硬」。没配校验工具时,就信任大脑------这是既定语义:没有校验面兜底的情况下,按设计信任大脑的判断。

第四道:预算上限(不伪装成功)

任务总得有个尽头。两个预算:步数上限 max_steps=24、墙钟 wall_clock_s=600(10 分钟)。超了按 incomplete 终止,summary 明写「墙钟预算耗尽」或「步数预算耗尽」。关键是不伪装成功 ------跑不完就是跑不完,状态写 incomplete,绝不返回 done。

两道额外保障

  • 上下文溢出自愈 :模型回包报「context length」超限,自动硬截上下文(_hard_keep 留 system + 最近几轮)重试一次,不崩整个 run。
  • 暂停/停止控制面 :外部有个 controls 字典(paused/stopped)。暂停卡在步边界生效,且暂停时长不计入墙钟预算 ------你中途接手操作手机,不会白白吃掉 Agent 的时间配额;停止则直接 incomplete 收尾。这是远程控制面(电脑指挥手机 Agent)能安全介入的基础。

四道护栏 + 两道保障,本质是给「会动手的循环」装了刹车和报警:死循环能停、连败能逼换路、谎报能拦、超时能认输。

三、两条红线:把底座筑稳

操作手机本身有几条不同的技术路径,越往底层拿到的控制权越多,但约束和代价也越重。我给 Hachifish 定了两条红线,反而让架构更干净。

红线 R2:只用官方 Accessibility API

操作手机有两条技术路线:一条是 Android 官方提供的 Accessibility API,另一条是更底层的 root 提权、hook 系统进程、读应用私有布局字段。两条都能跑,只是底层那条和具体 ROM 强耦合、需要越权,适用条件不一样。

R2 是:操作通道只用 Android 官方 Accessibility API,不 root、不 hook、不读私有字段 。HachimiAccessibilityService 就是一个标准的 AccessibilityService() 子类,八原语全走官方:

原语 用的官方能力
observe(看界面) rootInActiveWindow + AccessibilityNodeInfo
tap_by_id(按控件) findAccessibilityNodeInfosByViewId + ACTION_CLICK
tap_xy / gesture GestureDescription + dispatchGesture
type_text(输入) ACTION_SET_TEXT
screenshot API 30+ 官方 takeScreenshot
launch_app getLaunchIntentForPackage + startActivity
press(返回/主页) performGlobalAction

守住这条线,是把可控性和稳定性放在能力上限前面:能拿到的界面信息受官方 API 边界约束,但换来的是不挑机型、不越权的稳。对一个探索原型,这是先求跑通、再求更强的取舍------代价在下一节具体讲。

代价也有,举一个真实的:坐标系一致性。tap_xy 归一化用的屏幕尺寸,必须和 observe 树同源(都用 getRealMetrics 拿真实全屏,不能用排除导航栏的「可用区域」)。我在 screenSize() 里踩过------用错坐标系,底部 tab 系统性打不中,落点整体偏高约 6.7%。这是守官方接口的代价,不是 hack。坑可以修,红线不破。

红线 R3:内核不管后端内部实现

这条解决「真机和模拟环境能不能共用一套逻辑 」的问题。看 WorldModel:

python 复制代码
def update(self, percept, backend=None):
    """写入 backend 产出的原始 percept,并由 backend.text_of 文本化。
    内核不读取 percept 内部字段------文本化完全由 backend 负责。"""
    self.current_percept = percept or {}
    self.state_text = backend.text_of(percept) if backend else ""

关键点:内核只存 backend 丢过来的原始 percept,然后交给 backend.text_of() 把它变成文字------内核自己一行都不读 percept 里面是什么字段。

这意味着真机的 accessibility backend 和模拟/投屏 backend,只要都实现同一套原语面 + text_of 接口,内核代码完全不用改 。换后端,零改动。这套八原语面本身也刻意和另一套 backend 的设计同构------内核只认原语名,后端怎么实现都不影响,切换零改动。

这层解耦带来一个很实用的好处:真机跑和电脑模拟跑,用同一套内核逻辑。很多测试不用真机就能在模拟 backend 上对拍,省真机、快迭代。

还有一条 R6:语义是冻结的契约,不是可调参数

决策循环、护栏这些核心语义,我在定稿时把它们定成写死的契约,不是随时能调的参数。比如「模型自决感知、tool_calls 驱动执行、task_done 必须带校验面」------这几条在前面两节都展开过,每一个都对应一处真实会崩的点(像 task_done 不带校验就会被幻觉式完成骗过,护栏那节讲过)。

所以它们不是「建议」,是「不动点」:漂移了就是 bug,不是优化空间。这条和 R2/R3 一脉:先克制住随手改语义的冲动,把循环跑稳。

写在最后:这是一次探索,不是一套产品方案

把决策循环、四道护栏、两条红线合起来,一个能在 Android 上动起来的 GUI Agent 原型就齐了。但得把边界说清楚:这套目前只在 Android 落地 ,是验证「决策循环 + 官方无障碍 + 后端解耦」这套组合在手机上能不能跑通、怎么跑的一次研究探索------它能跑通,但覆盖到哪一步、边界在哪,留给后面继续试。

仓库地址:github.com/momo-null/hachifish

相关推荐
2501_915921431 小时前
详细教程:使用Perfdog工具测量iOS应用的功耗性能与优化方法
android·macos·ios·小程序·uni-app·cocoa·iphone
全栈弄潮儿2 小时前
Python实战第3期:输入输出与字符串操作
python·agent
hunterandroid2 小时前
android方案 : Multiplatform 跨平台开发实战:一套代码,多端运行
android·前端
goehou2 小时前
MCP 传输方式实战:stdio、Streamable HTTP、WebSocket 该选哪个
python·llm·agent·教程·mcp
我先测了2 小时前
Midscene.js 全栈学习指南:从入门到精通的 AI 视觉自动化框架
javascript·人工智能·自动化·agent·midscene
事圆则缓3 小时前
什么是 Android Clean Architecture
android
字节跳动的猫3 小时前
LikeShop 搜索功能优化二开:中文分词、商品权重排序与搜索热词管理
android·java·中文分词