☰
基于千问3.5-9B大模型与OpenClaw框架的UI智能遍历测试实践
2026/10/21 15:08:57 网站建设 项目流程

UI自动化测试做到一定年头,大家其实都在琢磨同一件事:能不能别再让测试脚本去死磕选择器了?界面一变脚本就碎,维护成本比写新用例还高。我最近在做的这个项目,就是把这个困扰多年的老问题换了个解法——把开源大模型千问3.5-9B接到OpenClaw这个UI遍历框架里,让模型自己看界面、自己决定下一步点哪里、滑哪里、输入什么。整个方案跑下来,页面覆盖率比传统Monkey随机点击高出一大截,而且脚本几乎不用维护。这篇文章把我的完整思路、架构选型、踩过的坑都整理出来,适合做客户端测试、测试工具开发,或者对LLM Agent实际落地感兴趣的朋友参考。


1. 项目起因与整体设计思路

1.1 为什么UI遍历要交给大模型

先说说我为什么要动这个念头。传统UI自动化测试里,遍历逻辑基本靠两条路:一是写死用例脚本,二是做Monkey式的随机点击。写死用例的问题大家都熟悉,页面一改版,XPath就要重新捋一遍;随机点击的问题更隐蔽,它没有“意图”,在复杂的业务页面上经常卡在登录弹窗、权限弹窗这些重复逻辑里出不来,覆盖率低得吓人。

我当时最想要的一个效果是:测试工具能不能像人一样“逛”App?人打开一个应用,会先扫一眼页面上有什么,然后按自己的关注点去点开某个入口。这个“扫一眼”的动作本质上是视觉理解,而“决定关注什么”是意图推理。这两件事,传统脚本和随机算法都做不来,但一个指令跟随能力过得去的大模型是可以做的——只要能把屏幕上的UI状态翻译成模型能看得懂的文本。

所以项目一开始定的不是“用AI替代所有测试”,而是聚焦在“遍历”这个场景。遍历是测试里最高频、最枯燥、又最容易被安排给新人的活。它不需要精确断言某个业务逻辑,只需要把App的各个页面都跑到、不崩、不白屏,流量和稳定性问题就很容易暴露。把遍历交给大模型驱动,收益是立竿见影的:脚本从“记录路径”变成“记录意图”,界面布局再怎么换,只要功能入口还在,模型依然能找到。

1.2 OpenClaw与千问3.5-9B在体系中的分工

项目里有两个主角,先说角色分配。

OpenClaw是一个开源的UI自动化探索框架,我理解它的定位有点像“把屏幕变成一个模型可操作的沙盒”。它负责最底层的脏活累活:连接设备、拉取UI层级树、执行点击滑动、维护会话状态。它本身不带任何AI能力,但它把动作原语封装得足够干净,外部模型只需要输出一个结构化的动作指令,OpenClaw就能翻译成真实的触摸事件发到设备上。

千问3.5-9B则是这个体系里的“大脑”。我选它有几个现实考虑:一是它开源可自部署,不需要把UI信息传到外部云端,测试阶段涉及的数据可以留在本地;二是9B这个参数量级非常微妙,比7B的推理能力强出一截,又不需要像70B那样上好几张卡,单张消费级显卡就能跑起来,成本可控;三是它的指令遵循能力在同类模型里表现不错,尤其是配合结构化输出时,动作格式很少跑偏。

整个系统跑起来之后,分工非常清晰:模型只负责“想”,OpenClaw只负责“做”。我不需要去训练模型,也不需要在框架里写死任何业务规则,唯一要精心设计的,是两者之间那层“翻译”协议——也就是UI怎么描述给模型听,模型的决策怎么落地成动作。

提示:如果你也在调研类似的方案,可以把考虑的重点放在“模型的指令遵循稳定性”上,而不是纯看跑分。遍历场景对模型的要求不是“知识量大”,而是“给一段界面描述,能稳定选出合理的下一个动作”。


2. 系统架构与关键选型

2.1 架构分层:从屏幕像素到触摸事件

整个系统我拆成了四层,从上到下分别是环境感知层、意图决策层、指令校验层、动作执行层。

环境感知层的任务是把当前屏幕变成一个可喂给模型的上下文。Android端我直接读取的是无障碍服务提供的节点信息,iOS端则是通过私有接口拿到元素树,然后统一转换成一棵精简后的文本树。这里有一个非常关键的原则:不能把原始层级树直接扔给模型。系统原生层级树动辄几百上千个节点,里面大量是无效的容器节点、不可见节点、重复节点,直接把原始树塞进去,上下文窗口立刻爆掉,模型也分不清主次。

意图决策层就是跑在本地推理服务上的千问3.5-9B。它拿到一串结构化的UI描述文本,结合系统提示词里交代的“当前任务目标”,输出下一步动作。这一层我走得比较保守,没有让模型去维护一个全局探索目标列表,而是采用“短视决策”模式:只基于当前屏幕和最近的探索历史,决定下一个动作是什么。这么做的好处是大幅降低了上下文的长度和复杂度,模型只需集中精力处理眼前的信息。

指令校验层是加在模型和框架之间的一道保险。模型生成的输出先被解析成结构化JSON,然后校验器会检查动作引用的元素ID是否真实存在于当前UI树中、坐标是否在屏幕范围内、动作类型是否合法。校验不通过的动作直接丢弃,重新让模型生成一次,或者跳转到随机动作兜底。

动作执行层是OpenClaw的本职:点击、长按、滑动、输入文本、返回、等待页面加载。这一层还有一个容易被忽略的细节,就是每个动作执行后必须等到页面完全静止才进入下一轮决策,否则模型看到的界面状态是半加载的,决策质量会大幅下降。

2.2 为什么用文本序列化而不是截图识别

项目初期我其实纠结过要不要走视觉路线——直接截屏,用视觉语言模型看懂界面。试过一轮之后放弃了,原因有三点。

第一是成本。截图方案需要把高分辨率图像持续送入模型,如果帧率一高,显存压力非常明显。9B模型如果同时处理图像和文本,单卡推理延迟会从几百毫秒飙升到秒级,遍历节奏被拖得很慢。我们的目标场景是长时间、大面积的页面巡检,延迟非常重要。

第二是信息完整性。图像方案看起来很酷,但模型在对一个陌生页面做视觉理解时,经常漏掉一些文本小按钮、灰色提示语、折叠区域里的控件。而UI层级树本身是带了元素边界和文本标签的结构化数据,信息几乎不丢失,只是需要做取舍和精简。

第三是可控性。文本方案输出的动作可以精确到“element_27”,而视觉方案输出的是“屏幕右上方那个蓝色按钮”,还需要再做一轮坐标推导,误差就在这里积累。

所以最终确定的方向是:UI状态文本化,动作指令结构化。千问3.5-9B不需要真正“看懂”像素,它只需要根据语义化的界面描述做推理。这个方向也是目前很多LLM Agent落地最常见的取舍——能用文本解决的先别上多模态。

2.3 模型部署选择与关键参数

模型部署我试过两种方案。第一轮用的是带量化版本的GGUF格式文件配合CPU推理,跑是能跑,但每个动作决策耗时超过8秒,遍历一个10个页面的App要等半天。后来换成GPU推理服务,把模型部署在单张消费级显卡上,量化等级定在4-bit,生成的延迟降到了1到2秒之间,遍历节奏才算可接受。

推理参数上,有两个参数对输出质量影响最大。Temperature我压到了0.2,这个值在普通对话里会显得枯燥,但在决定动作的场景下非常合适,它让模型每次给出的动作都尽量保守、稳定,减少无意义的随机差异。Max tokens限制在256,一个动作决策根本不需要长文本输出,限制死了反而在出错时能快速暴露问题,而不是让模型就着一个动作写一大段废话。

项目跑稳定之后我才意识到,真正影响效果的往往不是模型本身的智商,而是“把多大的上下文喂给它”。上下文越短,模型越容易聚焦。


3. 实操过程与核心环节实现

3.1 环境准备与工程搭建

项目代码我用Python写,核心依赖只有两个:OpenClaw的Python SDK,以及调用本地推理服务的HTTP接口。整个工程的启动流程分成三步。

第一步是启动模型推理服务。我用的是支持OpenAI兼容接口的推理框架,千问3.5-9B的模型权重加载进去之后,先跑一个最简单的对话请求确认服务正常。

第二步是连接被测设备。这时候OpenClaw会初始化一个会话,跟设备上的无障碍服务建立连接,然后自动拉取一次当前界面的节点信息。

第三步是跑第一轮遍历。这时候就会看到整个流程的真实面貌了:OpenClaw把UI树序列化成文本,我通过代码把它拼进Prompt,请求模型,拿到动作指令,校验后执行,然后UI树刷新,进入下一轮。

我在工程里加了一个很朴素的指标面板:当前轮次、累计动作数、发现的新页面数、重复页面数、无效动作数。这个面板在整个项目调试阶段帮助非常大,所有“模型是不是在乱跑”的疑问,看一眼指标就心里有底了。

3.2 UI序列化的具体处理方式

这是整个项目里最值得花时间打磨的环节。原始UI树经过裁剪后,每个可见且可交互的元素会被序列化成一行记录,格式是这样:

[id: 27] [type: BUTTON] [text: 立即登录] [bounds: 320,880,560,1020]

id是元素在当轮UI树中的索引;type是控件类型;text是节点上展示的文本,如果没有文本就取content-desc或hint;bounds是控件在屏幕上的坐标范围。

序列化之后,我会把整个界面的元素列表拼成一个块,前面再加一个简短的“屏幕摘要”。屏幕摘要是这么生成的:取当前Activity或页面的名称,加上页面中标题型元素的文本(比如文本长度大于6且字体加粗的元素),让模型对“我在哪个页面”有一个粗略的判断。

这里有一个我反复调整过的细节:元素数量上限。我把单屏序列化上限设成40个元素,超过的部分按可见区域面积排序截断,优先保留面积大的控件。原因是小按钮、小图标在模型眼里往往是无效噪音,真正参与决策的高价值元素一般都是页面主体内容。实验数据也支持这个判断:把上限从80砍到40,模型动作的合理率反而提高了3个百分点。

3.3 系统提示词与动作指令设计

Prompt设计是这个项目的灵魂。我用的系统提示词分三段:角色定义、任务规则、输出格式。角色定义告诉模型它是一名移动应用测试助手,工作方式是通过观察屏幕元素决定操作;任务规则列了四条硬性要求;输出格式则强制它按JSON返回。

核心的规则有四条:第一,优先选择看起来像“入口”的元素,比如菜单项、列表项、带有箭头图标的按钮;第二,避免在同一页面反复执行相似动作;第三,如果当前页面存在弹窗或浮层,优先处理弹窗上的关闭或确认按钮;第四,如果连续尝试多次仍未发现新的内容变化,执行一次页面返回。

输出格式固定为:

{ "action": "tap", "target_id": 27, "reason": "登录入口,进入后可以探索登录相关流程" }

action目前支持tap、swipe、input、back、wait五类。target_id对于tap和input是必需的,swipe则需要输出swipe_direction,取值是up或down。

我在Prompt里放了一个短示例,只有一组few-shot,效果比放三四个示例还好。原因可能是交互路径更简单,示例太多反而让模型去模仿“示例的语气”而不是理解规则。

3.4 遍历循环与控制参数

运行时的主循环非常直观:

# 伪代码,展示OpenClaw遍历循环的核心逻辑 while session.get_step_count() < max_steps: ui_tree = session.fetch_ui_tree() prompt = build_prompt(ui_tree, history, task_goal) response = model_generate(prompt) action = parse_and_validate(response) if action is None: action = fallback_random_action(ui_tree) session.execute(action) session.wait_until_stable() record_page_signature()

max_steps我通常按测试时长来折算,每分钟大概能执行15到20个动作,跑30分钟就设500步上限。history那块记住最近20个页面的签名Hash,重复出现的页面在Prompt里会被标记为“已访问”,从而引导模型尝试其他路径。

页面签名的计算方式也很简单:把当前可交互元素的所有文本拼接后做Hash。这个方案在99%的情况下是准的,但也因此踩过一个坑,后面的问题排查章节我会细说。

还有个值得提的参数是wait_until_stable的超时时间,默认我设成1.5秒。页面加载慢的时候,如果这个值设小了,模型会基于半加载状态的界面做决策,导致误判“这个页面没有内容”,然后一直按返回键退出应用。后来我把它改成了动态策略:连续两次页面签名不一致就继续等,等满5秒还变就判定为加载完成。


4. 实际运行结果与数据观察

4.1 覆盖率数据对比

项目在三个测试应用上跑了完整对比:一个是内部开发的跨平台办公类App,一个是社区开源的电商Demo,还有一个是只有十几个页面的工具类App。对比基线是传统Monkey工具和一条简单的深度遍历脚本。

结果最直观的是页面覆盖率。传统Monkey在半小时内能触达的总页面数大约是全部页面的23%,而且经常陷入弹窗循环出不来;人工写的深度遍历脚本覆盖率能做到54%,但脚本本身花了大半天时间去调试。模型驱动遍历的实际覆盖率在三个应用上分别做到了68%、71%、81%,而且没有手工写过一条业务规则。

另一个让我比较意外的数据是时空效率。模型虽然每一步决策要花1到2秒,但大幅减少了无效的重复动作,实际跑完一轮完整遍历的时间跟传统深度遍历脚本差不多,因为后者在页面定位和等待上浪费了很多时间。

4.2 模型行为特征:什么动作容易出错

大量观察模型输出之后,我发现千问3.5-9B在这个场景下有非常明显的行为偏好。它天然倾向于点击那些文本语义明确的元素,比如“设置”“我的”“购物车”,对纯图标的按钮则明显犹豫,经常因为找不到合适的文本锚点而选择返回或者放弃。

误判率最高的一类是“列表项点击”。当屏幕上有一个长列表时,模型倾向于反复点击前两三个可见项,而忽略了需要通过滑动才能触达的底部项。后来我在Prompt里加了一条规则:如果当前页面存在列表容器,优先执行一次上滑操作来查看列表后续内容。这条规则直接让电商Demo里商品详情页的发现率提升了近一倍。

模型也会犯一些蠢错,比如在已经登录的页面上反复寻找“登录”按钮,或者对一个模态弹窗的“取消”和“确定”两个按钮反复切换点击。这类问题靠调Prompt解决不了,只能靠指令校验层的去重逻辑兜底。


5. 踩坑实录与排查思路

5.1 上下文过长导致指令格式漂移

第一个大坑是上下文问题。UI树序列化之后,加上history记录,Prompt长度很容易冲上几千个token。千问3.5-9B在长上下文下有一个隐性风险:开头给出的JSON格式约束,到后面会被模型“遗忘”,输出开始出现markdown格式的包裹、多余的说明文字、甚至直接返回自然语言而不是JSON。

排查过程让我意识到,这不是提示词写得不够清楚,而是模型在长上下文上的注意力衰减。解决办法做了两步:一是引入输出格式的硬约束,在请求里强制指定JSON模式,让推理服务层的采样逻辑保证输出是一个合法JSON;二是把history从20条压缩到8条,并提前把重复的页面签名摘要成“该页面已访问过,包含N个元素”,大幅缩短了上下文长度。

5.2 模型幻觉:生成了不存在的元素ID

第二个高频问题是动作引用了不存在的元素ID。模型在决策时偶尔会凭空生成一个看起来合理的ID,比如UI树里根本没有id 88,但它输出了tap 88。这种情况校验层直接拦截,并触发重试。

排查之后发现,这类幻觉大多发生在界面元素超过40个被截断之后,模型试图引用被截掉的那部分元素。我后来做了一个补救:在序列化文本的末尾加了一行说明,标注当前页面还有多少个元素未被展示,并提示模型优先从已展示元素中选择。加了这句话之后,幻觉率下降了接近一半,非常有效。

5.3 死循环与重复访问

死循环问题在长跑测试里几乎一定会遇到。典型场景是模型在某个双层页面向导里反复执行“下一项”操作,或者在一个Tab栏上来回切换,就是不去新的页面。

系统层面我加了两道防线。第一道是页面签名去重,连续出现同一个页面签名超过5次时,强制执行一次返回动作并标记该路径为“死胡同”;第二道是全局动作统计,对同一个元素ID的重复点击次数超过3次时,下一次决策会把这个元素从候选列表里剔除。

这两道防线把“重复动作占比”从初期的37%压到了11%,效果立竿见影。

5.4 页面签名冲突导致误判

前面说页面签名用文本拼接后Hash,这个方案在大多数时候靠谱,但实测中遇到了一个严重场景:动态列表页面。列表内容一直在刷新,每次刷新文本都变,页面签名就变了,系统误以为发现了新页面,实际上只是同一条数据流在分页加载。这会导致模型认为自己在不断探索新内容,实际上一直在原地踏步。

排查了很久才找到原因。解决办法是把签名计算改成两层:第一层用页面结构特征(元素数量、控件类型分布)计算结构签名,第二层再用文本计算内容签名。只有当结构签名变化时才判定为新页面,文本签名的变化只用来识别“内容刷新”。改完之后,重复页面的判定准确率从78%提升到了95%以上。

5.5 冷启动阶段的探索盲区

还有一个很隐蔽的问题:遍历刚开始的头几十步,模型对整体应用一无所知,很容易在首页的几个入口之间反复横跳,而忽略那些藏在深层菜单里的页面。

解决这个问题我用了“广度优先”的引导策略。在遍历的前50步,系统提示词里会额外注入一条规则:优先沿着“设置”“关于”“帮助”“我的”这类系统功能入口探索,因为这些入口通常通向应用的功能全貌。等到做过一轮广度探索之后,再放开自由度,允许模型自由选择。这个策略对于我们测的内部办公App效果特别好,因为这类应用几乎都是标准的侧边栏加顶部导航结构,广度优先能快速跑通主干页面。


最后再分享一点我个人的体会。做这个项目的过程中,我对“大模型测试工具”这件事的理解发生了很大变化。起初我以为核心是把模型调聪明,让它像人一样遍历界面;后来发现真正决定项目天花板的,是周边工程:UI树裁剪、指令校验、页面去重识别、上下文管理。千问3.5-9B的推理能力完全够用,缺的从来不是模型能力,而是把模型接进真实系统的那种“连接器”。如果你也想做类似的实践,我的建议很直接:先别追求模型多强,把UI序列化和动作校验这两层做扎实,项目立刻就能跑起来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询