数据截至 (上游 commit 11cea575561f)
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-preview | MobileAgent/api.py:9 |
对话装配 chat.py | 把文本+base64 图拼成 messages | MobileAgent/chat.py:14 |
OCR 文本定位 ocr | 找出目标文字的像素框 | MobileAgent/text_localization.py:35 |
图标检测 det | GroundingDINO 找出所有图标框 | MobileAgent/icon_localization.py:28 |
裁剪与匹配 crop.py | 裁红框 / 按位置筛 / CLIP 认图标 | MobileAgent/crop.py:73,94,124 |
设备控制 controller.py | 截图 / tap / type / slide / back → ADB | MobileAgent/controller.py |
2.3 主线走一遍(高层)
- 截图。
get_size取屏幕分辨率,get_screenshot用adb shell screencap抓图,并缩到一半存成screenshot.jpg,原图存screenshot.png(controller.py:14-31)。半尺寸 jpg 喂给 VLM 和 CLIP(省 token / 算力),原图 png 留给 OCR / DINO(要精度)。 - 想。 把
opreation_prompt+ 当前 jpg 拼进对话,调 GPT-4V,拿回一段Observation/Thought/Action(run.py:64-72)。 - 落地。 按 Action 的动词分发:需要定位的(点文字/点图标/开 App)先跑外挂感知算坐标,不需要的(翻页/打字/返回/退出/停止)直接落 ADB(
run.py:82-189)。 - 记账。 把这一步的动作追加进一条精简历史
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 icon 的 parameter1 用 [颜色][形状] 模板(如 red circle),parameter2 从"上/下/左/右/中"里选方位(prompt.py:10)。这不是随便的格式——这两个参数正是后面 CLIP 消歧和位置裁剪要吃的输入(见 3.5)。
3.4 grounding 三件套之一:OCR 文本定位与降级匹配
本章核心论点在此显形: 模型只说了"点『登录』这个文字",它没给坐标。坐标是
ocr()这个外部函数算出来的。
它要解决的小问题: 在截图里找到目标文字的像素框;而且 VLM 报的文字常和屏幕上的不完全一致(多字、少字、错字)。
思路(两级匹配):
- 精确匹配优先:OCR 出所有文本行,谁的识别结果
== prompt就算命中(text_localization.py:49)。 - 降级模糊匹配:一个都没精确命中时,改用最长公共子串长度打分,并按目标长度设不同阈值,过线才算(
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 命令 | 位置 |
|---|---|---|
| tap | input tap ax ay | controller.py:34 |
| type | 逐字符 input text / 空格发 %s / 换行发 keyevent 66 | controller.py:44 |
| slide | input swipe 从屏幕中点向上/下滑 | controller.py:65 |
| back | input keyevent 4 | controller.py:75 |
| exit | am 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-64vs191-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.py | run |
| O/T/A 格式解析 | Mobile-Agent-v1/run.py:69-72 | re.search on response |
| 动作分发 | Mobile-Agent-v1/run.py:82-189 | if/elif "..." in action |
| 动作词表 & 输出格式 | MobileAgent/prompt.py | opreation_prompt, choose_opreation_prompt |
| VLM 调用 | MobileAgent/api.py:9 | inference_chat |
| 对话装配(单/多图) | MobileAgent/chat.py | init_chat, add_response, add_multiimage_response |
| OCR 文本定位 + 降级匹配 | MobileAgent/text_localization.py | ocr, longest_common_substring_length |
| 图标检测(GroundingDINO) | MobileAgent/icon_localization.py | det, remove_boxes |
| 裁红框 / 按方位裁 / CLIP 认图标 | MobileAgent/crop.py | crop, crop_for_clip, clip_for_icon |
| 设备控制(ADB) | MobileAgent/controller.py | get_screenshot, tap, type, slide, back, back_to_desktop |
源码引用均 as-of
sourceCommit: 11cea575561fb7800b5fb6b6cafa56f7a91de11f。行号可能随上游漂移,失效时用符号名 grep 重新定位。