大模型应用实战

LLM 驱动的自动化测试系列(09):移动端自动化(五)——当通用 Agent 框架下沉到测试场景

MetaGPT 是一个'多智能体软件公司'框架,主打需求分析、代码生成这类通用软件工程场景,但它内置了一个 Android 测试 demo。直接读代码会发现规划文档里的一个预设是错的:这个 demo 没有复用 MetaGPT 通常宣传的产品经理/工程师/QA 角色分工,而是只有一个自定义 Role,复用的是 Team/Environment/Role/Action 这套更底层的调度骨架;更关键的是,README 里明确写着它'借鉴了 AppAgent 的思路和代码'——这篇拆解这个通用框架到底继承了 AppAgent 的哪些设计、复用通用骨架换来了什么、又付出了什么代价。

·约 16 分钟阅读·AI Engineering

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 中的 stagelearn/act)和 modemanual/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-idclass_宽_高 作为主键。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,和一整套用不上的模型加载

复用通用框架不是没有代价的。读代码能找到两处具体的、可验证的代价——不是主观判断,是直接读源码确认的。

第一处:维护者自己标注、但从未修复的崩溃 bugself_learn_and_reflect.pyrun_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 的新设计,它的独特价值完全在另一个维度上——把这套已经验证过的设计,套进一个更通用的多智能体调度框架里,换来结构化输出、跨轮记忆管理这些框架级基础设施,但也因此背上了通用框架带来的、跟测试场景本身无关的额外成本(比如上一节提到的无条件模型加载)。这是"专用工具"和"通用框架的一个应用"之间最真实的差异:前者的每一处设计都直接服务于当前场景,后者的很多设计是继承来的,收益和代价都不是为这个场景单独定制的。


总结

  1. 规划里"复用产品经理/工程师/QA 角色分工"这个预设不成立——android_assistant demo 只有一个自定义 AndroidAssistant Role,真正复用的是 MetaGPT 更底层的 Team/Environment/Role/Action/ActionNode 调度骨架,以及 BY_ORDER 反应模式和跨轮记忆控制这些跟具体业务领域无关的框架级能力
  2. README 明确写着这个 demo"借鉴了 AppAgent 的思路和代码",直接比对源码确认这不是虚言:元素去重逻辑、四分类反思(BACK/INEFFECTIVE/CONTINUE/SUCCESS)、useless_list 主动剪枝、":sep:" 复合参数分隔符、网格兜底机制、文档精修的人类演示/自主探索不对称设计,全部原样移植;同时在 uid 生成(加父节点前缀提升抗碰撞能力)和响应解析(ActionNode 结构化输出取代脆弱正则)两处做了实际改进
  3. 下沉到通用框架付出了两处可验证的代价:self_learn_and_reflect.py 里维护者自己标注但从未修复的 IndexError 崩溃风险,以及 AndroidExtEnv 每次运行无条件加载三个测试路径实际用不上的 OCR/GroundingDINO 模型——后者是通用环境类被继承却只用到其中一部分能力的典型代价样本
  4. user_swipe_to() 的坐标实现是正确的,没有重复 AppAgent swipe_precise() 的 X/Y 坐标错误——移植不是无脑复制粘贴,至少这一处被正确实现
  5. 05-09 篇技术选型矩阵对比显示,MetaGPT Android Assistant 在感知输入、定位方式、容错设计、知识库持久化这几个决定测试 Agent 能力边界的维度上跟 AppAgent 几乎完全一致,它的独特之处只在"套进了一个更通用的多智能体调度框架"这一层,收益(结构化输出、跨轮记忆)和代价(无关的模型加载开销)都是这层带来的,不是测试场景本身要求的
  6. 移动端自动化板块(05-09)至此收尾:五个项目分别代表了双模型协同(ARTEMIS)、系统层抽象(DroidRun)、自研坐标模型(Mobile-Agent-v3)、解析树+视觉双输入(AppAgent)、通用框架复用(MetaGPT)五条不同路线,没有哪一条是绝对最优,取决于团队有没有能力自研 grounding 模型、目标 App 的无障碍树完整程度、以及是否需要跟一个更大的多智能体系统集成

欢迎访问 PrimeSkills —— 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。

更多实用知识和有趣产品,欢迎访问我的个人主页