MetaGPT 的 Android Assistant 是什么,先纠正一个预设
MetaGPT 是一个开源的多智能体框架,最广为人知的定位是"多智能体软件公司"(Multi-Agent Software Company)——用产品经理、架构师、工程师、QA 这样一组预置角色协作,把一句需求描述变成一个可运行的代码项目。它的仓库里还内置了一个不太起眼的子模块:metagpt/ext/android_assistant/,一个能自主探索或跟人类演示学习 Android App 操作方式、进而完成手机操作任务的测试 Agent demo。
这篇要处理的是一个在动笔前就该澄清的预设。按这个系列的规划,09 篇本该问"复用 MetaGPT 通用框架的产品经理/工程师/QA 角色分工来做测试自动化,相比 05-08 篇的专用工具丢失了什么、获得了什么"——但直接读 android_assistant/roles/android_assistant.py 源码后发现,这个前提本身就不成立:整个 demo 只定义了一个自定义 Role 类 AndroidAssistant,完全没有出现产品经理、工程师、QA 这类通用软件公司角色。真正被复用的,是 MetaGPT 更底层的调度骨架——Role/Action 基类、Team/Environment 编排、ActionNode 结构化输出系统。
所以这篇的三个问题要重新表述:MetaGPT 通用框架的调度骨架具体是怎么被这个测试 demo 复用的?它跟 08 篇的 AppAgent 之间到底是什么关系——README 里说"借鉴"具体借鉴到了什么程度?下沉到测试场景之后,通用框架的设计到底换来了什么、又背了哪些不属于这个场景的包袱?
复用的不是角色分工,是 Role/Action/Team 这套调度骨架
先把"角色"和"骨架"这两个概念分开说清楚。
AndroidAssistant(Role) 的 __init__ 里读取 config.extra 中的 stage(learn/act)和 mode(manual/auto),组合出三种合法配置,每种配置调用一次 self.set_actions([...]) 装配不同的 Action 列表:
if stage == "learn" and mode == "manual":
# choose ManualRecord and then run ParseRecord
# Remember, only run each action only one time, no need to run n_round.
self.set_actions([ManualRecord, ParseRecord])
elif stage == "learn" and mode == "auto":
# choose SelfLearnAndReflect to run
self.set_actions([SelfLearnAndReflect])
elif stage == "act":
# choose ScreenshotParse to run
self.set_actions([ScreenshotParse])
else:
raise ValueError(f"invalid stage: {stage}, mode: {mode}")这就是全部的"角色设计"——一个 Role 类,靠一个 if/elif 分支装配不同的 Action 组合。不存在多个角色互相协作、互相发消息的场景,_watch([UserRequirement, AndroidActionOutput]) 也只是让这一个 Role 能响应用户输入和自己上一轮的输出。
真正被复用、且复用得很彻底的,是 MetaGPT 通用框架里跟"角色"无关的调度基础设施:
Team.run(n_round)的驱动循环:while n_round > 0: ... await self.env.run(),每轮调用Environment.run(),后者对所有 Role 并发asyncio.gather(role.run() for role in self.roles.values())。这套循环设计原本是给多个角色互相协作用的,这里只挂了一个 Role,循环逻辑完全没有为此做任何简化。Role.react()的两种反应模式:RoleReactMode.BY_ORDER在_think()里让状态按顺序自增(self._set_state(self.rc.state + 1)),对应[ManualRecord, ParseRecord]这种"每个 Action 只跑一次"的场景;而当set_actions()只传了一个 Action([SelfLearnAndReflect]或[ScreenshotParse])时,_think()会无条件返回状态 0,同一个 Action 实例每轮重复执行,迭代次数完全交给外层Team.run(n_round=...)控制——这也解释了源码注释里"only run each action only one time, no need to run n_round"这句话:两种模式下"轮次"这个概念的归属方不一样,一种归 Role 自己管,一种归 Team 外层管。_observe()的覆写用于跨轮记忆控制:
async def _observe(self, ignore_memory=True) -> int:
"""ignore old memory to make it run multi rounds inside a role"""
newest_msgs = self.rc.memory.get(k=1)
newest_msg = newest_msgs[0] if newest_msgs else None
if newest_msg and (RunState.SUCCESS.value.upper() not in newest_msg.content):
ignore_memory = False
...
return await super()._observe(ignore_memory)默认 ignore_memory=True,但如果上一轮的结果不是 SUCCESS,就翻转成 False,让记忆带入下一轮——这是"同一个 Action 反复跑多轮,但需要在失败/未完成时保留上下文"这个需求在 MetaGPT 通用记忆机制上的具体落地方式,而不是给测试场景重新设计的。
ActionNode结构化输出:SCREENSHOT_PARSE_NODE/SELF_LEARN_REFLECT_NODE/RECORD_PARSE_NODE分别对应"执行阶段解析截图"、"自主探索反思"、"人类演示记录解析"三个场景,每个都是若干个ActionNode字段(如OBSERVATION/THOUGHT/ACTION/SUMMARY)组合成的复合节点,调用.fill(context=..., llm=..., images=[...])后直接拿到 pydantic 校验过的结构化字典(.instruct_content.model_dump())。这跟 08 篇 AppAgent 用re.findall(r"Field: (.*?)$", rsp, re.MULTILINE)这种单行正则去抠字段是完全不同的解析方式——MetaGPT 的通用框架把"让模型输出结构化字段"这件事做成了框架级能力,测试 demo 直接享受到了这个好处,不需要自己写脆弱的正则。
这是这篇要先立住的核心判断:MetaGPT 下沉到测试场景,复用的是"调度骨架 + 结构化输出"这类跟具体业务领域无关的框架级能力,不是"产品经理/工程师/QA"这类领域角色设计——后者在这个 demo 里根本没出现。
借鉴 AppAgent 到什么程度:从设计思路到具体实现细节的逐项对照
metagpt/ext/android_assistant/README.md 里有一句写得很直白的话:
"The MetaGPT Android Assistant has referenced some ideas and code from the AppAgent project. We thank the developers of the Appagent project."
这不是一句礁面上的致谢——直接对比两边代码,能看到从设计思路到具体实现细节的多处一致:
元素去重逻辑几乎逐行一致。MetaGPT 的 traverse_xml_tree() 遍历 clickable/focusable 节点、用欧氏距离阈值(min_dist,默认 30)去重的逻辑,跟 08 篇 AppAgent traverse_tree() 的实现思路完全一致,连阈值的默认取值都相同。
四分类反思决策空间完全对应。Decision 枚举定义了 BACK/INEFFECTIVE/CONTINUE/SUCCESS 四个值,语义跟 AppAgent 的四分类逐一对应:INEFFECTIVE 不生成文档,BACK/CONTINUE/SUCCESS 都生成文档,BACK/CONTINUE 都会把元素拉黑进 useless_list,BACK 额外触发一次真实的系统返回操作。这是 08 篇讲过的整套"主动剪枝"设计的完整移植。
人类演示的复合参数分隔符原样保留。manual_record.py 记录文本输入动作时用的格式是 text(3:sep:'hello'):::<uid>(元素编号、输入内容、元素 uid 三段用不同分隔符拼接)——":sep:" 这个自定义分隔符跟 AppAgent 一字不差。
文档精修的不对称行为原样保留。parse_record.py(对应人类演示路径)会检查 extra_config.get("doc_refine", False),开启时对已有文档做合并重写;而 self_learn_and_reflect.py(自主探索路径)遇到已有文档,只会记一行"already exists"日志然后返回 FAIL,没有任何精修逻辑——这正是 08 篇讲过的 AppAgent 里"人类演示能精修,自主探索写一次就不再碰"的那套不对称设计,连触发条件的措辞(配置开关名字)都是同一个思路。
网格兜底机制整套移植,包括九宫格方位细分。draw_grid() 用同样的[120,180]像素区间搜索因子确定网格尺寸,area_to_xy() 的九个命名方位(中心+八个罗盘方向)细分逻辑,跟 AppAgent 的网格兜底完全对应——GridOpParam/TapGridOpParam/LongPressGridOpParam/SwipeGridOpParam 这一组数据结构,是把 AppAgent 里同样的兜底动作空间原样搬进了 MetaGPT 的 BaseOpParam 体系。而且网格模式下的提示词模板(screenshot_parse_with_grid_template)同样没有 text() 这个动作——08 篇发现的"网格兜底会让动作空间整体收缩,不只是精度下降"这个结论,在 MetaGPT 这边依然成立。
也有几处不是照抄,而是在移植过程中做了实际改进的地方,值得公平地记一笔:
uid 生成逻辑加了父节点前缀,抗碰撞能力更强。AppAgent 的 get_id_from_element() 只用 resource-id 或 class_宽_高 作为主键。MetaGPT 的同名函数逻辑相同,但在 traverse_xml_tree() 里额外做了一步:
parent_prefix = ""
if len(path) > 1:
parent_prefix = get_id_from_element(path[-2])
...
if parent_prefix:
elem_id = parent_prefix + "_" + elem_id
if add_index:
elem_id += f"_{elem.attrib['index']}"给 uid 拼上直接父节点的同名 id,并可选拼上 XML 原始的 index 属性——这让同一 resource-id 在不同父容器下重复出现时(比如列表里每一行都用同一套 resource-id)不会被误判为同一个元素,是一个比 AppAgent 更严格的元素身份识别方案。
结构化输出取代脆弱正则,如前一节所述,ActionNode.fill() 换掉了 AppAgent 那套单行正则解析,这也是移植过程中借助 MetaGPT 框架自身能力做的实际改进,而不是简单复制粘贴。
所以"借鉴"这个词准确的分量是:核心设计哲学、关键机制(四分类反思、剪枝、网格兜底、精修不对称)完整继承,同时在元素身份识别和响应解析这两处,借助 MetaGPT 框架自身的能力做了针对性的加固。
下沉到测试场景付出的代价:一处未修复的崩溃 bug,和一整套用不上的模型加载
复用通用框架不是没有代价的。读代码能找到两处具体的、可验证的代价——不是主观判断,是直接读源码确认的。
第一处:维护者自己标注、但从未修复的崩溃 bug。self_learn_and_reflect.py 的 run_reflect() 里:
logger.info(
f"reflect_parse_extarct decision: {op_param.decision}, "
f"elem_list size: {len(self.elem_list)}, ui_area: {self.ui_area}"
)
# TODO here will cause `IndexError: list index out of range`.
# Maybe you should clink back to the desktop in the simulator
resource_id = self.elem_list[int(self.ui_area) - 1].uid这条 TODO 注释是仓库原有的、维护者自己写下的——不是我在本地调试时加的。它明确承认:如果模型返回的 ui_area 超出 elem_list 的实际长度,这一行会直接抛 IndexError 崩掉整个任务,建议的临时应对方式是"手动把模拟器切回桌面"。这跟 08 篇 AppAgent 那个没人标注、需要靠读源码自己发现的 swipe_precise() 坐标 bug 是不同性质的发现——那个是"没人知道、静默出错";这个是"维护者知道、写了 TODO、但从未修复"。两者共同指向的是同一类结论:自主探索类 Agent 的模型输出边界校验,在开源实现里普遍是薄弱环节,即便维护者自己意识到了问题,也未必优先级足够高去修。
第二处:每次运行都无条件加载三个测试场景根本用不上的模型。AndroidExtEnv.__init__ 里有这一行:
def __init__(self, **data: Any):
super().__init__(**data)
device_id = data.get("device_id")
self.ocr_detection, self.ocr_recognition, self.groundingdino_model = load_cv_model()
...load_cv_model() 无条件加载 OCR 检测模型、OCR 识别模型、外加一个 GroundingDINO(配合 CLIP)图标定位模型。这三个模型只被 user_click_icon()、user_click_text()、user_open_app() 三个方法使用。逐一核对 manual_record.py/parse_record.py/screenshot_parse.py/self_learn_and_reflect.py 这四个 Action 类构造 EnvAction 时用到的 EnvActionType 取值,只出现过 SYSTEM_TAP/USER_INPUT/USER_LONGPRESS/USER_SWIPE/SYSTEM_BACK/USER_SWIPE_TO——没有一个会路由到那三个依赖 OCR/GroundingDINO 的方法。也就是说,这个测试 demo 的每一次运行,都要先付出加载三个模型的启动成本,而这三个模型在这条代码路径上永远不会被调用。
这是"通用框架下沉到专用场景"最直接的代价样本:AndroidExtEnv 显然是为一个更宽的动作空间(按图标点、按文字点)设计的,MetaGPT 里大概率有别的、跟测试无关的 Agent 会用到这套能力,但这个测试 demo 继承了 AndroidExtEnv 这整个环境类,也就一并继承了它的启动成本,没有办法只按需加载自己真正用到的那部分。反过来,AndroidExtEnv.user_swipe_to() 这个方法值得记一笔正面的验证结果:它的实现是 f"{self.adb_prefix_si} swipe {start[0]} {start[1]} {end[0]} {end[1]} {duration}",坐标顺序完全正确,没有重复 08 篇 AppAgent swipe_precise() 里 X/Y 坐标误用的那个 bug——移植过程中至少这一处是被正确实现的,不是"复制粘贴保留了所有 bug"。
05-09 篇技术选型矩阵:五个项目在同一组维度下的位置
移动端板块到这里收尾,直接把 05-09 篇拆过的五个项目放进同一组维度里对比,而不是重复每篇已经讲过的细节。
| 项目 | 感知输入 | 定位方式 | 容错/反思设计 | 是否自带持久化知识库 |
|---|---|---|---|---|
| ARTEMIS(05) | 通用 VLM + 专用 grounding 模型协同 | 双模型协同定位 | 快速/规划双模式切换 | 无(每次任务独立执行) |
| DroidRun/Mobilerun(06) | 手机层抽象后的结构化状态 | 系统层封装调用 | 多智能体分工容错 | 无 |
| Mobile-Agent-v3(07) | 自研 GUI-Owl 直接输出坐标 | 自训练模型输出绝对坐标 | 专职反思角色 + 连续失败升级 | 无 |
| AppAgent(08) | 无障碍树 + 视觉双输入,树只管画标签 | 数字标签选择 + 网格兜底 | 四分类反思 + 主动剪枝 | 有(exploration 阶段持久化文档) |
| MetaGPT Android Assistant(09) | 移植自 AppAgent 的同一套双输入设计 | 数字标签选择 + 网格兜底(同 08) | 移植自 AppAgent 的四分类反思(同 08) | 有(同 08,存储格式几乎一致) |
这张表最值得读的地方不是罗列,而是 09 跟 08 那两行几乎完全重复——这恰好印证了这篇文章的核心判断:MetaGPT 的 Android Assistant 在"怎么感知、怎么定位、怎么容错、怎么积累知识"这几个真正决定测试 Agent 能力边界的维度上,没有提出任何独立于 AppAgent 的新设计,它的独特价值完全在另一个维度上——把这套已经验证过的设计,套进一个更通用的多智能体调度框架里,换来结构化输出、跨轮记忆管理这些框架级基础设施,但也因此背上了通用框架带来的、跟测试场景本身无关的额外成本(比如上一节提到的无条件模型加载)。这是"专用工具"和"通用框架的一个应用"之间最真实的差异:前者的每一处设计都直接服务于当前场景,后者的很多设计是继承来的,收益和代价都不是为这个场景单独定制的。
总结
- 规划里"复用产品经理/工程师/QA 角色分工"这个预设不成立——
android_assistantdemo 只有一个自定义AndroidAssistantRole,真正复用的是 MetaGPT 更底层的Team/Environment/Role/Action/ActionNode调度骨架,以及BY_ORDER反应模式和跨轮记忆控制这些跟具体业务领域无关的框架级能力 - README 明确写着这个 demo"借鉴了 AppAgent 的思路和代码",直接比对源码确认这不是虚言:元素去重逻辑、四分类反思(BACK/INEFFECTIVE/CONTINUE/SUCCESS)、
useless_list主动剪枝、":sep:"复合参数分隔符、网格兜底机制、文档精修的人类演示/自主探索不对称设计,全部原样移植;同时在uid生成(加父节点前缀提升抗碰撞能力)和响应解析(ActionNode结构化输出取代脆弱正则)两处做了实际改进 - 下沉到通用框架付出了两处可验证的代价:
self_learn_and_reflect.py里维护者自己标注但从未修复的IndexError崩溃风险,以及AndroidExtEnv每次运行无条件加载三个测试路径实际用不上的 OCR/GroundingDINO 模型——后者是通用环境类被继承却只用到其中一部分能力的典型代价样本 user_swipe_to()的坐标实现是正确的,没有重复 AppAgentswipe_precise()的 X/Y 坐标错误——移植不是无脑复制粘贴,至少这一处被正确实现- 05-09 篇技术选型矩阵对比显示,MetaGPT Android Assistant 在感知输入、定位方式、容错设计、知识库持久化这几个决定测试 Agent 能力边界的维度上跟 AppAgent 几乎完全一致,它的独特之处只在"套进了一个更通用的多智能体调度框架"这一层,收益(结构化输出、跨轮记忆)和代价(无关的模型加载开销)都是这层带来的,不是测试场景本身要求的
- 移动端自动化板块(05-09)至此收尾:五个项目分别代表了双模型协同(ARTEMIS)、系统层抽象(DroidRun)、自研坐标模型(Mobile-Agent-v3)、解析树+视觉双输入(AppAgent)、通用框架复用(MetaGPT)五条不同路线,没有哪一条是绝对最优,取决于团队有没有能力自研 grounding 模型、目标 App 的无障碍树完整程度、以及是否需要跟一个更大的多智能体系统集成
欢迎访问 PrimeSkills —— 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。
更多实用知识和有趣产品,欢迎访问我的个人主页