跳到主要内容

v1:外挂感知 + 通用 VLM 的单智能体 ReAct 流水线

30 秒导读: Mobile-Agent-v1 是 2024 年初的手机 GUI 智能体原型。它用一个通用视觉大模型(GPT-4V)看手机截图、按 ReAct 风格边想边做,但故意不让模型自己报坐标——模型只说"我要点那个叫『设置』的文字 / 那个红色圆形图标",真正的"这个东西在屏幕哪个像素"交给外部的 OCR、GroundingDINO、CLIP 三件套去算,最后由 ADB 把点击落到真机。

本章只讲 v1(单智能体原型)。它的后继——感知/决策/反思多智能体的 v2、自进化记忆的 Mobile-Agent-E、原生基座的 GUI-Owl——见 index 的阅读地图。


1. 这是什么(零基础也能懂)

  • 一句话定义: 一个"看手机截图 → 想下一步 → 通过 ADB 点/划/打字 → 再看新截图"的自动化循环,由一个通用视觉语言模型(VLM,能同时读图和文字的大模型)驱动。

  • 解决什么问题 / 给谁用: 假设你想让 AI 帮你在手机上"打开高德地图,搜索最近的加油站"。传统自动化要读 Android 的界面 XML(控件树),但很多 App 的 XML 缺失、混淆或干脆不给。v1 走纯视觉路线:只要能截图就能操作,不依赖任何系统元数据,天然跨 App。

  • 它能做什么(功能):

    • 纯视觉,不读 XML / accessibility 树(README:"Pure visual solution, independent of XML")。
    • 8 个原子动作:开 App、点文字、点图标、翻页、打字、返回、退出、停止。
    • 即插即用,不需要训练或探索(README:"No need for exploration and training")。
  • 用起来什么样: 一条命令行,喂进一句自然语言指令、adb 路径、OpenAI key:

    python run.py \
    --instruction "Open Settings and turn on Bluetooth" \
    --adb_path /path/to/adb \
    --api sk-xxxx

    程序进入死循环:每轮截一张屏、让 GPT-4V 输出一段 Observation/Thought/Action,解析出动作后落到真机,直到模型输出 stop(Mobile-Agent-v1/run.py:82-83)。

  • 一句话直觉/类比: 把 v1 想成一个近视但很会思考的操作员。他脑子(VLM)很清楚"下一步该点『登录』按钮",但眼神不好、报不准坐标;于是他身边配了三位测量员——一位专找文字(OCR)、一位专找图标(GroundingDINO)、一位专认图标长相(CLIP)——操作员只管说"点那个",测量员负责告诉他"在第几厘米处",最后由机械臂(ADB)去按。

本节不出现底层细节。记住一句话就行:v1 把"决定点什么"和"算出点在哪"彻底拆开了。


2. 顶层全景(它大概怎么转)

v1 的整个生命周期就是 run() 里一个大死循环。先看它怎么转,再逐块拆。

2.1 一张图:一轮操作的数据流

怎么读这张图:从上到下是一轮(一次截图 → 一次动作)的先后流向;虚线框是"外挂感知三件套",是本章重点。

┌──────────────────────────────────────────────┐
│ 外层循环:每轮截一张屏 (run.py:48) │
└──────────────────────────────────────────────┘

┌────────────┴─────────────┐
│ ADB 截图 + 缩半存 jpg/png │ controller.get_screenshot
└────────────┬─────────────┘

┌──────────────────────────────────────────────┐
│ 内层循环:让 VLM 出 O/T/A,格式不对就重试 │ run.py:66
│ Observation → Thought → Action │
└────────────────────┬─────────────────────────┘

解析出 Action(如 "click text (登录)")

┌───────────────────┼─────────────────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ 点文字/开App│ │ 点图标 │ │ 翻页/打字/ │
│ │ │ │ │ 返回/退出 │
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
▼ ▼ │
┌ ─ ─ ─ ─ ─ ┐ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │
│ OCR 找文字 │ │ GroundingDINO │ │ (无需定位,
│ 多命中→裁框│ │ 找所有图标 │ │ 直接落 ADB)
│ 让模型选号 │ │ →裁位置→CLIP认 │ │
└ ─ ─ ┬ ─ ─ ┘ └ ─ ─ ─ ┬ ─ ─ ─ ─ ┘ │
└───────────────────┴──────────────────────────┘

归一化坐标 → controller.tap/slide/type → 真机

2.2 部件一句话职责

部件干什么在哪个文件
主循环 run()外层截图 + 内层解析 + 分发动作Mobile-Agent-v1/run.py:28
opreation_prompt定义 8 个动作和 O/T/A 输出格式Mobile-Agent-v1/MobileAgent/prompt.py:1
inference_chat调 OpenAI gpt-4-vision-previewMobileAgent/api.py:9
对话装配 chat.py把文本+base64 图拼成 messagesMobileAgent/chat.py:14
OCR 文本定位 ocr找出目标文字的像素框MobileAgent/text_localization.py:35
图标检测 detGroundingDINO 找出所有图标框MobileAgent/icon_localization.py:28
裁剪与匹配 crop.py裁红框 / 按位置筛 / CLIP 认图标MobileAgent/crop.py:73,94,124
设备控制 controller.py截图 / tap / type / slide / back → ADBMobileAgent/controller.py

2.3 主线走一遍(高层)

  1. 截图。 get_size 取屏幕分辨率,get_screenshotadb shell screencap 抓图,并缩到一半存成 screenshot.jpg,原图存 screenshot.png(controller.py:14-31)。半尺寸 jpg 喂给 VLM 和 CLIP(省 token / 算力),原图 png 留给 OCR / DINO(要精度)。
  2. 想。opreation_prompt + 当前 jpg 拼进对话,调 GPT-4V,拿回一段 Observation/Thought/Action(run.py:64-72)。
  3. 落地。 按 Action 的动词分发:需要定位的(点文字/点图标/开 App)先跑外挂感知算坐标,不需要的(翻页/打字/返回/退出/停止)直接落 ADB(run.py:82-189)。
  4. 记账。 把这一步的动作追加进一条精简历史 struct_operation_history,进入下一轮(run.py:191-203)。

目标:看懂"大盘"——v1 就是"截图→VLM 想→外挂定位→ADB 执行"这条流水线在死循环里转。


3. 核心原理(逐个机制,由浅入深)

3.1 双层主循环:外层换屏,内层保格式

它要解决的小问题: ① 每做一步世界就变了,得重新截图;② VLM 偶尔不按 Observation/Thought/Action 格式说话,解析会崩。

思路:两层 while True。外层每轮换一张新截图;内层专门"死磕格式"——只要正则抽不出三段就重试,抽到了才 break

图示(两层的关系):

外层 while (run.py:48) ── 截图、准备本轮 prompt
└── 内层 while (run.py:66) ── 调 VLM
├─ 正则抽 Observation/Thought/Action 成功 → break
└─ except: print("Response not formatted, retry.") → 再调
└── 拿到 Action → 分发执行 → 更新历史 → 回到外层顶

真实实现: 内层循环用三个正则分别抠出三段,任一失败就整体重试(run.py:66-76,函数 run):

while True:
response = inference_chat(operation_history, args.api)
try:
observation = re.search(r"Observation:(.*?)\n", response).group(1).strip()
thought = re.search(r"Thought:(.*?)\n", response).group(1).strip()
action = re.search(r"Action:(.*)", response).group(1).strip()
except:
print("Response not formatted, retry.")
else:
break

这段把"VLM 输出不规整"这个脏活挡在内层,外层永远拿到干净的 action 再去分发。

关键细节 / 坑:

  • 内层重试没有次数上限——若模型一直不守格式会死循环(inferred,代码里无计数器)。
  • Action:(.*) 不带 \n,所以只吃到 Action 那一行的其余部分;而 type 动作后面又对整个 response 重新正则取括号内文本(run.py:179),不是复用 action,是个小不一致。

3.2 两套对话历史:一次性的"厚 prompt" vs 常驻的"薄记忆"

它要解决的小问题: 完整操作说明(8 个动作那一大段)很长,如果每轮都堆进历史,上下文会爆炸;但又得让模型记住之前干了什么。

思路: 维护两条历史,职责分开:

历史内容命运
struct_operation_history只存系统提示 + 每轮的"动作"和截图常驻、逐轮累积(薄记忆)
operation_history每轮从 struct 深拷贝一份,再塞入完整 opreation_prompt + 当前截图一次性,用完即弃

真实实现: 每轮开头深拷贝、临时加厚(run.py:63-64):

operation_history = copy.deepcopy(struct_operation_history)
operation_history = add_response("user", opreation_prompt, operation_history, image)

而落到常驻记忆的,只有精简的一问一答(run.py:191-192):

struct_operation_history = add_response("user", "This is the current screenshot. Please give me your action.", struct_operation_history, image)
struct_operation_history = add_response("assistant", action, struct_operation_history)

关键细节: 这等于每轮都"临时给模型看一遍完整说明书 + 当前屏",但历史里只留"我上一步做了什么"。厚说明书永不进长期记忆,是一种朴素的上下文瘦身。对话装配的多图支持见 chat.py:41(add_multiimage_response),消歧那一步会用到。

3.3 动作词表:8 个原子动作 = 模型能说的全部话

它要解决的小问题: 得把"操作手机"这件事压缩成一个模型学得会、程序解析得动的小词表。

思路:opreation_prompt 里把动作空间枚举死(prompt.py:7-15),模型只能从这 8 个里选:

动作参数说明落地位置
open App (名字)App 名只能在桌面用run.py:85
click text (文字)目标文字优先用于有文字处run.py:97
click icon (描述, 方位)[颜色][形状], 上下左右中无文字的图标run.py:144
page down / page up翻页run.py:175
type (内容)要输入的文本需先点中输入框run.py:178
back返回上一页run.py:182
exit回桌面run.py:185
stop任务完成,结束循环run.py:82

分发是纯字符串 in 匹配:if "stop" in action / elif "open App" in action……(run.py:82-189)。简单但脆——若目标文字里恰好含 "back" 之类子串,理论上会误判(inferred)。

关键设计: prompt 明确要求 click iconparameter1[颜色][形状] 模板(如 red circle),parameter2 从"上/下/左/右/中"里选方位(prompt.py:10)。这不是随便的格式——这两个参数正是后面 CLIP 消歧和位置裁剪要吃的输入(见 3.5)。

3.4 grounding 三件套之一:OCR 文本定位与降级匹配

本章核心论点在此显形: 模型只说了"点『登录』这个文字",它没给坐标。坐标是 ocr() 这个外部函数算出来的。

它要解决的小问题: 在截图里找到目标文字的像素框;而且 VLM 报的文字常和屏幕上的不完全一致(多字、少字、错字)。

思路(两级匹配):

  1. 精确匹配优先:OCR 出所有文本行,谁的识别结果 == prompt 就算命中(text_localization.py:49)。
  2. 降级模糊匹配:一个都没精确命中时,改用最长公共子串长度打分,并按目标长度设不同阈值,过线才算(text_localization.py:59-96)。

图示(命中即停的降级顺序):

OCR 出所有文本行

├─ 有行 == prompt ? ── 是 ─▶ 收集这些框,返回
│ 否

对每行算最长公共子串长度,取最高分的那行

▼ 按 prompt 长度选阈值:
├ len≤10 : 命中长度 ≥ 0.8·len ?
├ 10<len≤20: ≥ 0.5·len ?
└ len>20 : ≥ 0.4·len ?
└─ 过线→返回该框 ; 不过→返回 [],[](判定"没找到")

真实实现: 精确命中的判定极简(text_localization.py:49,函数 ocr):

if result == prompt:
box = [int(e) for e in list(pts.reshape(-1))]
box = [box[0], box[1], box[4], box[5]]
if calculate_size(box) > 0.05*iw*ih: # 太大的框(≈整块区域)丢弃
continue

降级分级阈值(text_localization.py:82-96)按短/中/长文本放宽要求——越短的文字越要求高相似度,避免"两个字撞车"。

一个精妙的双框设计: ocr 对每个命中同时返回两种框(text_localization.py:56-57):

返回值框大小用途
text_data(即调用处 in_coordinate)文字四周 ±10px(紧)真正点击的目标 / 画红框
coordinate(即调用处 out_coordinate)文字四周 ±300/±400px(宽)多命中时裁一块"带上下文"的图给模型看

这个"紧框定位、宽框给看"的拆分,正是 3.5 消歧的基础。

关键细节 / 坑: OCR 走的是原图 png(image_ori)但传入的是 jpg 的宽高 iw,ih 当缩放基准(run.py:87),函数内部再把框坐标缩放到 jpg 空间——所有下游坐标统一在半尺寸 jpg 里算,最后才归一化到设备。

3.5 多候选消歧:文字撞车裁红框让模型选,图标用颜色+方位锁定

它要解决的小问题: 屏幕上可能有好几处都是"登录",或者一堆长得差不多的图标——OCR/DINO 会返回多个框,到底点哪个?

(a) click text 多命中:裁图 + 编号 + 二次提问

分支按命中数走不同路(run.py:106-142):

命中数处理
0报错 text ... is not detected,进错误历史
> 4报错 too many text,放弃(候选太多,消歧不可靠)
== 1直接点
2~4裁红框 + 编号 + 让模型二次选号

消歧的做法(run.py:118-142):对每个候选,用宽框 out_coordinate 裁出一小块图、并在其中用紧框 in_coordinate 画一个红色矩形(crop.py:73,crop),存成 1.jpg 2.jpg …;然后追加一句提问,把这几张图一起发给模型:

ocr_prompt = f"The {len(out_coordinate)} red boxes are numbered 1 through {len(out_coordinate)}. Which red box with \"{parameter}\" do you want to click on? Please output just one number..."
choose_chat = add_multiimage_response("user", ocr_prompt, choose_chat, images)
choose_response = inference_chat(choose_chat, args.api)
final_box = hash[int(choose_response)] # 模型回一个数字 → 取对应框

一个值得诚实指出的细节: prompt 说"红框标了 1~N",但 crop.py:79-81 里真正往图上写编号数字的代码是注释掉的。所以图上只有红框、没有可见数字;编号靠的是图片在 images 列表里的顺序(每张图各含一个候选的红框),模型据"第几张图"回数字。功能能work,但"numbered"这个措辞和实现略有出入。

(b) click icon 多候选:先按方位裁,再用 CLIP 认长相

图标没有文字可 OCR,于是走另一套(run.py:144-173):

GroundingDINO det(image,"icon") ── 找出屏幕上"所有"图标框
│ (注意:caption 硬编码 "icon",不是模型给的描述)

只剩 1 个 ? ── 是 ─▶ 直接点


crop_for_clip(box, parameter2) ── 按模型给的方位(上/下/左/右/中)
│ 只保留落在该半区/象限里的图标

clip_for_icon(候选们, parameter1) ── 用 CLIP 把每个候选裁图 与
│ "[颜色][形状]"文本算相似度,取最高

点中 CLIP 选出的那个框

真实实现: 两个参数在这里各归其位(run.py:150-152):

parameter1, parameter2 = parameter.split(',')[0].strip(), parameter.split(',')[1].strip()
in_coordinate, out_coordinate = det(image, "icon", groundingdino_model)
  • det(icon_localization.py:28)调 GroundingDINO,caption 固定为 "icon.",并用 remove_boxes 去掉过大框(>5% 面积)和 IoU 重叠框(icon_localization.py:6-25)。它只回答"哪里有图标",不理会模型想要哪个。
  • crop_for_clip(crop.py:94)用 parameter2(方位)把屏幕切成半区/象限,只留框在该区内的候选(in_box 判断)。
  • clip_for_icon(crop.py:124)把每个候选裁图编码、和 parameter1(red circle 之类)的文本编码算 softmax 相似度,argmax 选一个(crop.py:137-139)。

关键细节 / 坑:

  • det 的 caption 写死 "icon",意味着定位阶段完全不用模型的语义;模型的"颜色形状/方位"只在后处理(裁 + CLIP)里起作用。这把"检测"和"识别哪个"彻底解耦。
  • crop_for_clip 把候选全过滤光,hash 为空,clip_for_icon 会对空列表操作而出错(inferred,无空判)。

3.6 落到真机:归一化坐标 + ADB 命令

它要解决的小问题: 不同手机分辨率不同,算出来的像素框不能直接当坐标发。

思路: 所有点击先把框中心归一化到 0~1,再乘设备真实分辨率发给 ADB。

真实实现: 分发处统一先归一化(以 click text 单命中为例,run.py:114-116):

tap_coordinate = [(in_coordinate[0][0]+in_coordinate[0][2])/2, (in_coordinate[0][1]+in_coordinate[0][3])/2]
tap_coordinate = [round(tap_coordinate[0]/iw, 2), round(tap_coordinate[1]/ih, 2)] # 归一化
tap(args.adb_path, tap_coordinate[0], tap_coordinate[1], x, y) # x,y=设备分辨率

controller.tap(controller.py:34)再乘回真实分辨率并 adb shell input tap。其余动作也都是薄薄一层 subprocess 包 ADB:

动作ADB 命令位置
tapinput tap ax aycontroller.py:34
type逐字符 input text / 空格发 %s / 换行发 keyevent 66controller.py:44
slideinput swipe 从屏幕中点向上/下滑controller.py:65
backinput keyevent 4controller.py:75
exitam start ... category.HOME 回桌面controller.py:81

一个巧妙的小修正: open App 里,OCR 找到的是 App 名字文字的位置,但图标在文字上方,所以点击时把 y 上移了约 50px(run.py:95):

tap(args.adb_path, tap_coordinate[0], tap_coordinate[1]-round(50/y, 2), x, y)

4. 巧妙之处(可借鉴的技术)

  • "决定点什么" ↔ "算在哪点" 彻底解耦。 这是 v1 最该带走的一点:2024 年初的通用 VLM 报不准坐标,于是设计上干脆不让它报——它只输出语义级意图(哪个文字 / 什么颜色形状的图标 / 在哪个方位),像素定位全部外包给 OCR + GroundingDINO + CLIP。见 run.py:82-173 的分发全景。

  • 紧框点、宽框看。 OCR 一次返回两种框:±10px 的紧框用来精确点击,±300/400px 的宽框用来裁"带上下文的小图"给模型消歧(text_localization.py:56-57)。用一次检测同时服务"执行"和"再判断"。

  • 两级历史瘦身。 每轮临时深拷贝出加厚版 prompt 用完即弃,长期记忆只留精简的动作序列(run.py:63-64 vs 191-192),把长说明书挡在上下文之外。

  • 半尺寸 jpg / 原尺寸 png 分工。 给模型和 CLIP 看半图省算力,给 OCR/DINO 用原图保精度(controller.py:27-30,run.py:51-52)。

  • 检测与识别解耦。 GroundingDINO 只回答"哪里有图标"(caption 写死 "icon"),"要哪个图标"交给 CLIP 的图文相似度(icon_localization.py:28 + crop.py:124)。


5. 边界与局限(诚实)

  • 强依赖外部检测器质量。 OCR 认错字、DINO 漏检图标,整条链就断——模型再聪明也点不到。这正是 v2 起逐步内化感知的动因(见 02-v2-multi-agent)。

  • 消歧上限很低。 click text 命中 >4 个就直接放弃(run.py:109-111);click icon 若方位过滤后为空会崩(inferred,run.py:159-170 无空判)。

  • 动作分发靠子串匹配,脆。 全用 "xxx" in action(run.py:82-189),没有严格解析,理论上文字含关键字子串会误判(inferred)。

  • 无重试/步数上限。 内层格式重试(run.py:66)和 inference_chat 网络重试(api.py:25)都是无限 while,无退避、无计数;外层主循环也只靠模型自报 stop 才停,没有最大步数护栏。

  • 单智能体、无反思。 只有一个模型一条链,做错一步没有独立的"反思/纠错"角色去回滚——这一缺口正是 v2 的反思智能体和 GUI-Critic-R1 操作前纠错要补的(见 05-training-and-critic)。

  • 绑定 GPT-4V。 inference_chat 写死 gpt-4-vision-preview(api.py:17);仓库另有 Mobile-Agent-qwen/ 变体走 Qwen,但主流程默认 OpenAI。


6. 横向对比(在家族中的位置)

维度v1(本章)v2 及以后
智能体数单个感知/决策/反思/记忆多智能体(02)
感知定位全外挂(OCR+DINO+CLIP)逐步内化进模型(04)
纠错无独立反思反思智能体 / 操作前批判(05)
记忆精简动作历史长期自进化记忆(Mobile-Agent-E,03)
模型通用 GPT-4V,零训练半在线 RL 训练的原生 GUI 基座(05)

v1 是这条演进线的起点与对照组:它证明了"纯视觉 + 通用 VLM + 外挂感知"能跑通端到端手机操作,也暴露了外挂感知的脆弱与单智能体的无纠错——后续每一代都在补这两处。


7. 代码地图(导航索引)

主题文件路径符号名
主入口 / 双层主循环Mobile-Agent-v1/run.pyrun
O/T/A 格式解析Mobile-Agent-v1/run.py:69-72re.search on response
动作分发Mobile-Agent-v1/run.py:82-189if/elif "..." in action
动作词表 & 输出格式MobileAgent/prompt.pyopreation_prompt, choose_opreation_prompt
VLM 调用MobileAgent/api.py:9inference_chat
对话装配(单/多图)MobileAgent/chat.pyinit_chat, add_response, add_multiimage_response
OCR 文本定位 + 降级匹配MobileAgent/text_localization.pyocr, longest_common_substring_length
图标检测(GroundingDINO)MobileAgent/icon_localization.pydet, remove_boxes
裁红框 / 按方位裁 / CLIP 认图标MobileAgent/crop.pycrop, crop_for_clip, clip_for_icon
设备控制(ADB)MobileAgent/controller.pyget_screenshot, tap, type, slide, back, back_to_desktop

源码引用均 as-of sourceCommit: 11cea575561fb7800b5fb6b6cafa56f7a91de11f。行号可能随上游漂移,失效时用符号名 grep 重新定位。