大模型应用实战

LLM 驱动的自动化测试系列(08):移动端自动化(四)——AppAgent:解析树与视觉特征的结合

AppAgent 是腾讯开源的 CHI 2025 论文项目,跟前几篇纯视觉(ARTEMIS/Mobilerun)或纯坐标(Mobile-Agent-v3)的方案不同:它用无障碍树给截图上的元素画数字标签,模型只需要挑一个数字——'解析树'和'视觉'其实不是两个独立感知通道,而是结构信息负责标注位置,视觉模型负责决策。这篇拆解它自主探索阶段怎么靠反思四分类(BACK/INEFFECTIVE/CONTINUE/SUCCESS)自己攒文档,网格兜底机制在解析树失效时怎么退化,也如实指出网格兜底路径里一个真实存在的坐标换算 bug。

·约 19 分钟阅读·AI Engineering

AppAgent 是什么,解决什么问题

AppAgent 是腾讯出品、CHI 2025 收录的多模态智能体框架(arXiv:2312.13771),目标是让一个多模态大模型像人一样通过点击、滑动操作手机 App,而不依赖任何系统后端接口——这一点跟前几篇的项目一致,都是"模拟人类交互"路线,而不是走 App 内部 API 或自动化测试框架的结构化调用。

AppAgent 的核心设计是一套两阶段工作流:先经历一个探索阶段(exploration),让智能体通过自主尝试或观察人类演示的方式,给 App 里的 UI 元素积累一份自然语言文档;再进入部署阶段(deployment),智能体在执行具体任务时查阅这份文档来决定怎么操作。这跟前几篇"边跑边决策、没有持久化知识库"的思路不同——AppAgent 把"学习怎么用这个 App"和"用这个 App 完成任务"显式拆成了两个独立阶段,学习阶段的产出可以反复复用。

跟前三篇比,AppAgent 的关键差异在感知层的输入结构。这篇不重复"是什么",直接进入三个问题:解析树+视觉特征双输入的动作空间设计到底是怎么运作的,跟纯视觉、纯坐标方案比在精度和成本上有什么取舍?探索阶段具体怎么自主学会用一个 App?当解析树缺失或不可靠时,这套双输入方案怎么退化?


双输入不是两个感知通道的融合,而是"结构定位、视觉决策"的分工

先纠正一个容易被"双输入"这个词带偏的直觉。前几篇里,ARTEMIS 是通用 VLM 加一个专门的 Gemini Robotics-ER 做坐标定位补强,Mobile-Agent-v3 是自己训练一个专门做 grounding 的模型输出绝对坐标——这两种都是"两个模型协作"。AppAgent 的解析树+视觉双输入完全不是这个路子:解析树(parser tree)不是第二个模型,只是一份确定性的 XML 数据,它的唯一作用是告诉代码"屏幕上有哪些可交互元素、它们在哪",用来在截图上画数字标签;真正做决策的,从头到尾只有一个视觉语言模型,它看到的永远是"一张贴了数字的截图",输出的永远是"选哪个数字"。

这套流程在 and_controller.pytraverse_tree() 里实现:对 adb shell uiautomator dump 拉下来的 UI Automator XML 树做遍历,抓出所有 clickable="true"focusable="true" 的节点,记录它们的边界框(bbox)。两类节点会分别遍历一次,再用一个基于欧氏距离的阈值(config.yaml 里的 MIN_DIST: 30)去重——如果一个 focusable 元素的中心点跟某个已收录的 clickable 元素中心点距离小于这个阈值,就认为是同一个交互目标,不重复标号。去重后的元素列表交给 draw_bbox_multi()(在 utils.py)在截图上画数字标签,可点击元素标红,可滚动元素标蓝——这张标好号的图,才是真正喂给视觉模型的输入。

模型看到的提示词(prompts.pytask_template)里,动作空间是这样的:

tap(element: int)
text(element: int, text_str: string)
long_press(element: int)
swipe(element: int, direction: string, dist: string)
grid()

除了最后的 grid()(下文详细展开),所有动作的第一个参数都是"数字标签",不是坐标、不是元素路径。模型的决策空间被严格限制在"当前截图上看得到的编号"里——这是这套设计换来的东西:模型不需要具备像素级坐标定位能力,只需要具备"看图选数字"这种更弱、更稳的视觉理解能力,代价是完全依赖解析树能不能正确枚举出交互元素。跟 Mobile-Agent-v3 把定位精度前置到模型训练阶段(GUI-Owl 直接输出绝对坐标)比,AppAgent 是把定位精度前置到运行时的确定性代码里——不需要训练任何专用模型,但换来的鲁棒性完全绑定在 Android 无障碍框架暴露信息的完整程度上。

元素的身份识别靠 get_id_from_element() 生成的一个 uid:优先用清洗过的 resource-id 作为主键,如果元素没有 resource-id(很多自定义控件没有),退化成 f"{class}_{width}_{height}",再可选地拼上一段截断的 content-desc。这个 uid 是后续所有文档持久化的主键——探索阶段学到的知识,是按"这个结构化身份"存的,不是按屏幕坐标存的,这样同一个元素即便在不同截图里位置变了,也能命中同一份文档。


探索阶段:act-then-reflect 循环,四分类反思决定文档怎么写、哪些元素以后不再碰

探索阶段有两条路:自主探索(self_explorer.py)和人类演示(step_recorder.py + document_generation.py)。先看自主探索,这是回答"探索阶段怎么自主学习 App 使用方式"这个问题的核心。

self_explorer.py 的主循环是一个"先动作、再反思"的两段式结构:每一轮先截图、解析出元素列表、调用视觉模型选一个动作执行,执行完再截一张"动作后"的截图,把动作前后两张图一起丢给同一个模型做反思,反思提示词(self_explore_reflect_template)要求模型判定这次动作到底产生了什么效果,输出四选一:

  • SUCCESS:动作达到了预期效果
  • BACK:动作把界面带到了错误的地方,需要退回去——这个判定会真实触发一次 controller.back() 调用,撤销刚才的操作
  • CONTINUE:动作确实改变了界面,但没帮上任务——不撤销,继续往下走
  • INEFFECTIVE:动作执行了但界面没有任何变化

这四种判定里,只有 INEFFECTIVE 不会生成任何文档——因为什么都没发生,模型也确实什么都学不到。BACKCONTINUESUCCESS 都会为这个元素生成一段自然语言文档并存下来,逻辑是:即便这次操作没帮上当前任务(CONTINUE)或者操作错了(BACK),模型依然从"点了这个元素之后发生了什么"里学到了功能性信息,这份认知本身是有价值的,值得保留。

更有意思的是主动剪枝机制:self_explorer.py 维护一个 useless_list 集合,把判定为 INEFFECTIVEBACK/CONTINUE 的元素 uid 记进去(注意 CONTINUE 判定的元素也会被拉黑,即便对它生成了文档),后续每一轮考虑候选元素时都会 if elem.uid in useless_list: continue 直接跳过。只有被判定为 SUCCESS 的元素永远不会被拉黑,会一直留在候选池里——这个设计的意图很直接:一旦发现一个元素"没用"或者"帮不上忙",就不再浪费探索预算重复尝试它,把有限的探索轮次(config.yamlMAX_ROUNDS: 20)留给还没验证过的元素。这是一种很朴素但确实有效的"死路剪枝",跟前几篇里那些依赖专门反思角色(Mobile-Agent-v3 的 ActionReflector)或错误阈值升级(同样是 Mobile-Agent-v3 的"连续两次失败升级 Manager")的容错设计比,AppAgent 这里的反思结果直接决定了探索空间本身会不会收缩,而不只是决定"要不要重试当前动作"。

不过这套自主探索机制有一个明确的能力边界,也是回答"退化行为"之外另一个值得单独指出的限制:自主探索永远学不会文本输入框是做什么的self_explorer.py 主循环里对反思分支有一行 elif act_name == "text": continue——如果这一轮执行的动作是 text(),代码直接跳过反思和文档生成,进入下一轮。也就是说自主探索确实可以执行文本输入动作,但从不会为它生成任何文档。翻遍整个探索脚本,text_doc_template 这个提示词模板从未在 self_explorer.py 里被引用过——它只存在于 document_generation.py(人类演示路径)里。这意味着如果一个 App 的关键交互依赖输入框(搜索框、登录表单),单靠自主探索永远无法给这些输入框积累文档,必须依赖人类演示补上这一块。


网格兜底:解析树失效时怎么退化——包括一个真实存在的坐标 bug

这是直接回答"双输入方案在解析树缺失或不可靠时的退化行为"这个问题的核心机制。

当视觉模型发现自己想操作的元素没有被标上数字(典型场景就是 WebView、自定义渲染控件、游戏画面——这些内容不会出现在无障碍树里,traverse_tree() 根本枚举不到它们),提示词模板(task_template)里明确定义了一个零参数动作 grid(),文档写得很直接:

"call this action when you find the element you want to interact with is not labeled with a numeric tag and other elements with numeric tags cannot help with the task"

调用 grid() 之后,task_executor.pygrid_on 置为 True只对下一轮生效——执行完任意一个非 grid 动作后 grid_on 会被重置回 False。网格模式下,draw_grid()utils.py)会在截图上叠加一层网格,网格的单元格尺寸通过找图像高/宽的一个落在 [120, 180] 像素范围内的整数因子来确定(找不到就退化成固定 120 像素),据此切出若干行列。模型这时候不再挑数字,而是通过 tap_grid(area, subarea) / long_press_grid(area, subarea) / swipe_grid(...) 来定位——area 指定网格里的哪一格,subarea 从九个命名方位(中心+八个罗盘方向)里选一个,在格子内部再做一次精度细分。

这套退化方案有两个值得指出的地方:

第一,动作空间本身发生了收缩,不只是精度下降。对比一下 task_template(非网格)跟 task_template_grid(网格模式)两份提示词模板会发现,网格模式下根本没有 text() 这个动作。也就是说一旦解析树失效需要靠网格兜底,模型就彻底失去了输入文本的能力——这不是"坐标精度变差了,凑合能用",而是一整类操作直接从动作空间里消失了。如果任务需要在一个没有被无障碍树覆盖的输入框里打字(比如某些 WebView 内嵌的表单),网格兜底机制救不了这种场景。

第二,网格兜底路径里有一个真实存在的坐标 bug——不是我的推测,是直接读 and_controller.py 源码确认的:

def swipe_precise(self, start, end, duration=400):
    start_x, start_y = start
    end_x, end_y = end
    adb_command = f"adb -s {self.device} shell input swipe {start_x} {start_x} {end_x} {end_y} {duration}"
    ret = execute_adb(adb_command)
    return ret

拼接 adb 命令的时候,起点坐标用了两次 start_x(第二个本该是 start_y),导致实际执行的滑动起点 Y 坐标其实是 X 坐标的值,跟真正想要的起点完全不对。用 grep 确认了整个代码库里 swipe_precise 只有一处调用——task_executor.py 第 275 行,就在 swipe_grid 这个动作分支里。也就是说,这个 bug 只在网格兜底路径被触发时才会执行,非网格模式下走的是另一个方法 swipe(),不受影响。这跟前一篇 Mobile-Agent-v3 那个"HarmonyOS 输入空格抛异常"的 bug 是同一类问题(真实存在、有明确触发条件、指向测试覆盖不足),但性质不同:那个 bug 会直接抛异常让运行崩掉,这个 bug 不会报错,只是悄无声息地从错误的 Y 坐标开始滑动——恰好又是发生在"解析树失效、退化到网格兜底"这个本来就已经是降级路径的场景里,等于给一个本身精度就打了折扣的兜底机制又叠加了一层隐性缺陷。


人类演示:另一条学习路径,以及它独有的文档精修能力

除了自主探索,AppAgent 还提供了人类演示路径(step_recorder.py 负责录制,document_generation.py 负责生成文档),这条路径在两个地方跟自主探索不同。

记录格式step_recorder.py 每一步都截图、抓 XML、画标签,然后在终端里问人类"这一步要 tap/text/long press/swipe/stop 哪个",把动作写进 record.txt——用一个自定义的 ":sep:"分隔符拼接复合参数,比如文本输入动作记成 text(3:sep:"hello"):::<uid>(元素编号、输入内容、元素 uid 三段用不同分隔符拼接)。document_generation.py 读取这份 record.txt,对每一步反查前后两张截图,调用对应的文档模板生成描述——这里就包括 text_doc_template,是整个项目里唯一会给文本输入元素生成文档的路径,直接对应上一节提到的自主探索的能力缺口。

精修(refine)能力document_generation.py 会检查 config.yaml 里的 DOC_REFINE 开关。如果为 true,遇到一个 (uid, 动作类型) 已经有文档的情况,会在提示词里追加一段 refine_doc_suffix,要求模型把新的观察和旧文档合并,重新生成一份更完整的描述;如果为 false(默认值),直接跳过,打一行日志建议用户去开启这个选项。自主探索完全没有对应的精修逻辑——self_explorer.py 里如果发现一个 (uid, 动作类型) 已经有文档了,只会记一行"已存在"的日志然后跳过,不管 DOC_REFINE 开不开都不会触发任何重新生成。也就是说,反复精修文档质量这件事,目前只有人类演示路径能做,自主探索是"先到先得、写一次就再也不碰"。

两条路径生成的文档最终存的是同一种格式——一个 Python 字典字面量({"tap": "", "text": "", "v_swipe": "", "h_swipe": "", "long_press": ""}),写的时候用 str(doc_content) 序列化,部署阶段读的时候用 ast.literal_eval() 反解析。部署脚本 task_executor.py 不区分文档是自主探索还是人类演示生成的,只按目录(auto_docs/demo_docs/)来源加载。


从论文到工具:留在代码里的工程痕迹

除了上面已经确认的 swipe_precise bug,读完全部脚本还能看到几处不算严重但值得记一笔的工程痕迹。

三个响应解析函数共享同一种脆弱的单行正则model.pyparse_explore_rsp()/parse_grid_rsp()/parse_reflect_rsp() 全都用 re.findall(r"Field: (.*?)$", rsp, re.MULTILINE) 这种非贪婪、匹配到行尾就停的正则去抓 Observation:/Thought:/Action:/Decision:/Documentation: 等字段——如果模型某个字段的实际内容跨了多行,会被静默截断成只有第一行,不会报任何错误。

解析失败直接终止整个任务,没有重试:这三个解析函数都套了一层裸的 try/except Exception,出错就打印原始响应并返回 ["ERROR"]task_executor.py/self_explorer.py 的主循环检测到这个哨兵值就直接 break 跳出整个任务。也就是说一次格式不对的模型响应就能让整个任务提前结束,没有重新提问、没有格式纠错重试。这跟 Mobile-Agent-v3 那种"连续失败才升级"的分层容错设计比,是完全不同的简单程度——AppAgent 这里的错误处理只有"正常" 和 "整个任务终止"两档,中间没有缓冲带。

成本估算硬编码了过时的费率、且跟实际配置的模型脱钩OpenAIModel.get_model_response() 每次请求都会打印一个估算成本,公式是 prompt_tokens/1000*0.01 + completion_tokens/1000*0.03——这是 GPT-4V 时代的定价,跟 config.yamlOPENAI_API_MODEL: "gpt-4o" 这个实际配置的模型完全没有关联。如果用户换了模型,这个打印出来的成本估算会静默变得不准,不需要改任何代码就会失真。

图片 MIME 类型跟实际截图格式不一致OpenAIModel 把截图编码成 data:image/jpeg;base64,... 发给接口,但 and_controller.py 里截图命令用的是 screencap -p,实际产出的是 PNG。这个不一致目前看起来是无害的(大部分接口对 MIME 类型不敏感),但确实是一处代码里能直接读出来的小疏忽。OpenAIModel 走的是手写 requests.post() 调用,而不是官方 SDK;跟它并列的 QwenModel 则用官方 dashscope SDK,直接传本地 file:// 路径而不是 base64——两个 provider 子类完全没有共享任何图像处理代码,各自按各自 provider 的习惯写了一遍。

README 的评测部分没有具体准确率数字可引用:AppAgent 的 README 只说明了评测集是"9 个 App、45 个任务"(详见 assets/testset.md),指向一份单独的 benchmark 文档,但没有在 README 正文里给出任何具体的成功率或准确率百分比。跟前几篇有的项目在 README 里挂了"91.4%"之类的徽章式数字不同,这篇没有类似可以拿来对照验证或质疑的具体数字,所以这里不做任何数字引用——这也算是诚实记录一下:不是每个项目都会留下需要"存疑标注"的数字,AppAgent 这次干脆没给。


顺带一提:AppAgentX 是一个更新的方向,但本地没有代码可核实

README 里提到一个更新的后续项目 AppAgentX,号称是"下一代、带演化机制的 GUI Agent"。这个项目在本地仓库里只有一个链接,没有实际源码可读——跟上一篇 Mobile-Agent-v3.5 的情况不同(那次至少有一份 287 行的运行脚本可以直接读源码验证),AppAgentX 这里完全没有可供核实的材料,所以这里只能提一句它存在,不做任何机制层面的判断。


总结

  1. AppAgent 的"解析树+视觉"双输入不是两个模型的融合,而是分工:无障碍树(UI Automator XML)只负责确定性地告诉代码"哪些元素存在、该在哪画数字标签",真正的操作决策 100% 由视觉语言模型基于"贴了数字的截图"完成——这跟 ARTEMIS 的双模型补强、Mobile-Agent-v3 的自研坐标模型是三种不同的架构哲学
  2. 自主探索用"先动作、再反思"的循环学习 App,反思结果分 BACK/INEFFECTIVE/CONTINUE/SUCCESS 四类,INEFFECTIVE 和判定为不成功的元素会被主动拉黑(useless_list)不再重复尝试,但自主探索有一个明确边界:从不为文本输入动作生成文档,只有人类演示路径能补上这块
  3. 网格兜底机制(grid())是解析树失效时的退化方案,但这不只是精度下降——网格模式下动作空间直接失去了 text(),且 swipe_grid 调用的 swipe_precise() 方法里有一个真实的坐标 bug(起点 Y 坐标误用了 X 的值),这个 bug 恰好只发生在这条本来就是兜底降级的路径里
  4. 人类演示路径比自主探索多两个能力:能给文本输入框生成文档,能在 DOC_REFINE 开启时对已有文档做精修合并;自主探索没有任何精修逻辑,文档写一次就不会再更新
  5. 代码里能看到的其他工程痕迹:三个响应解析函数共享同一种会静默截断多行内容的脆弱正则,解析失败会直接终止整个任务而没有重试机制,成本估算硬编码了跟实际配置模型脱钩的过时费率,图片 MIME 类型跟实际截图格式(PNG vs 声明的 JPEG)不一致——这些都是能直接读出来的、跟核心功能无关但确实存在的实现细节
  6. README 里没有给出可供核对或质疑的具体成功率数字,这里如实说明,不做无依据的引用

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

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