news 2026/11/2 9:18:24

用Harness打造Agent:Android模拟器自动登录实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Harness打造Agent:Android模拟器自动登录实战

如果你最近也在折腾 Harness 这类 agent 开发框架,多半遇到过同一个问题:环境搭起来了,模型也连上了,但 agent 除了聊天什么也干不了——因为它没有手。我这次实战做了一件很具体的事:用 Harness 作为工具编排层,把 Android 模拟器上的一个 App 登录模块,变成 agent 能独立操作的完整任务,跑通了一条从环境搭建到自动登录的可靠链路。整个过程涉及 Android Studio、ADB、UI 控件识别和 agent 提示词设计,适合正在做 UI 自动化、或想让大模型真正“动手”干活的开发者参考。

先说我的背景。之前团队做 App 回归测试,我一直被两件事折磨:录制回放脚本太脆,页面一改就重录;纯坐标脚本更是灾难,换个分辨率全废。后来我把目光转到 agent 方案上,想法很简单——让模型自己看屏幕、自己决定下一步点哪里,而不是把每一步都写死。但真做起来才发现,裸调模型 API 根本不够用,模型输出的是“我想点登录按钮”这种意图文本,我还得自己写意图解析器,等于把 agent 框架又造了一遍。所以这次我选了 Harness,把精力放在业务逻辑上,而不是底层工具调用上。

1. 整体设计:为什么是 Harness 加 Android 登录模块

1.1 Harness 在 agent 开发环境里到底解决什么问题

裸调 API 的核心痛点在于:模型只负责“想”,不负责“做”。你调用一次大模型,它返回一段自然语言,说它想打开应用、想点击某个按钮,但接下来执行、验证、纠错全部要你自己写代码。更棘手的是,模型输出经常不稳定,上次返回 JSON,这次就变成一段散文,你写的解析器就被打穿了。Harness 这类工具编排层解决的就是这个断层:它允许你把真实可用的工具提前注册进去,比如终端命令、文件读写、adb 操作、浏览器控制,模型在任务循环里按协议发起工具调用,Harness 负责真正把命令跑起来,再把结果返回给模型。

用类比来理解,模型是大脑,Harness 是手脚。大脑负责下指令,手脚负责执行,两边通过一套标准协议通信。我这次把“手脚”集中封装成三个工具:观察屏幕、点击控件、输入文本。Harness 只需要知道这三个工具叫什么、怎么调用,剩下的决策全部交给模型。这种分层的好处是,以后换模型、换任务、甚至换操作系统,工具层不用重写,只要保证协议不变就行。

1.2 为什么拿“登录模块”当第一个被 agent 攻克的场景

在 App 自动化领域有个共识:登录是最适合做自动化验证的地方。它界面结构稳定,输入框、按钮、校验逻辑都是固定套路,恰好可以拆成观察、决策、操作三个步骤,这正是 agent 能力的最小闭环。而且登录模块虽然控件少,却包含了密码掩码、错误提示、toast 反馈、页面跳转这些典型细节,能暴露真实场景里的大部分问题。我见过不少团队一上来就让 agent 操作复杂的电商首页,结果模型被满屏商品卡片干扰,连“搜索框在哪”都找不到,最后把问题归咎于模型不聪明,其实只是场景选错了。

反过来,登录模块失败成本极低。测试账号可以随便造,模拟器可以随时重置,跑坏了拍个快照十秒恢复。这种“可反复折腾”的环境,恰恰是 agent 开发最需要的土壤。你让 agent 在一个不可复现的环境里试错,出了问题根本说不清是环境问题还是模型问题;环境稳定了,agent 每一次失败都值得分析,因为大概率是任务设计或工具定义的问题。

1.3 整体架构:开发机、模拟器、模型服务三层

这次实战的硬件和软件组合很常规:开发机是一台 Windows 11,装了 Android Studio 和对应 SDK Platform Tools;Android 模拟器用的 Pixel 6 镜像,API 33,分辨率 1080x2400;Harness 跑在开发机的命令行里,通过 adb 与模拟器通信。模型层我用的是 DeepSeek 的对话模型,通过 OpenAI 兼容接口接入,只需要在环境变量里配置服务地址和 API key。整体拓扑用一个简单的文字图就能说明白:

+-----------------------+ | 开发机 (Windows) | | Harness(插件编排) |------ HTTP API -----> 模型服务(DeepSeek) | 自定义工具(adb脚本) | +-----------|-----------+ | adb (5037 端口) +-----------v-----------+ | Android 模拟器 | | Pixel 6 / API 33 | | 登录 Demo App | +-----------------------+

把模型服务独立出来的好处是开发和模型解耦。以后你想换别的模型、或者把模型部署到内网,只需要改环境变量,工具脚本和 Harness 配置完全不用动。很多团队从 demo 走向生产时都卡在“换模型就得重构”上,根源就是没有在一开始做这层抽象。工具层只认模型输入输出的格式,不认具体是哪个模型,这是 agent 开发环境设计的核心原则之一。

1.4 关键取舍:为什么坚持用 resource-id 定位而不是视觉识别

技术圈里有人喜欢让 agent 直接看截图,如果模型带视觉能力,这确实可行,但我这次坚持用 uiautomator dump 出来的 UI 树来定位控件。原因很简单:UI 树里有 text、resource-id、bounds、clickable 这些结构化字段,模型从 JSON 里选控件比从像素里猜控件可靠得多,而且不依赖视觉模型,模型选型面更宽。更重要的是,坐标只是结果,不是原因。我让 agent 按“找一个 id 是 et_username 的输入框”这样的逻辑行事,而不是“点 540, 800”,因为前者在界面微调后依然成立,后者一次分辨率变化就废了。

这个原则也直接影响了我写 Demo App 的方式。我在布局里给用户名框、密码框、登录按钮都加了稳定的 resource-id,相当于给 agent 准备了路标。真实 App 里很多控件没有 id,那就退一步用 text 匹配,实在不行才用坐标。后面 5.3 节我会专门展开讲控件的兜底策略,这里先记住优先级:resource-id 大于 text 大于 bounds。这个优先级应该写死在 agent 的提示词里,模型就不会在最简单的事情上乱发挥。

2. 环境准备:从 Android Studio 到 Harness 一条链路全打通

2.1 先把 Android 模拟器调整到“适合被折腾”的状态

创建模拟器有几个细节直接决定了后续的省心程度。第一,镜像选 x86_64 而不是 arm 的,x86 在 Windows、Linux 上跑得快,冷启动 30 秒内;arm 镜像虽然兼容性好,但性能差距肉眼可见。第二,分辨率固定 1080x2400,密度 420dpi,不要开自动缩放,这样 uiautomator 里 bounds 的坐标和 adb 输入的坐标是同一套坐标系,省去换算的麻烦。第三,创建时勾选启用 ADB 调试,第一次启动后模拟器会弹窗问是否允许调试,要勾选“始终允许”。我见过很多人在这一步没勾选,后面 adb devices 一直显示 unauthorized,白白浪费一下午。

还有一个新手经常卡住的地方:Android Studio 默认英文界面。想切中文的话,打开 Settings,进入 Plugins,搜索“中文语言包”,安装后重启就是中文了。虽然不是必备项,但对不熟悉英文菜单的人来说能省不少查字典时间。另外建议顺手把 SDK Platform 和 Platform Tools 更新到最新版本,老版本 adb 有时会出一些莫名其妙的兼容问题,升级往往就消失了。环境准备阶段的每一次升级,都是在给后面的调试减少变量。

2.2 安装 Harness:一条命令启动,插件系统是核心

Harness 的安装并不复杂,社区最常见的做法是 npm 全局安装,然后在工作目录里初始化并启动。因为项目的包名和发布源可能会有更新,这里我以官方仓库给的安装方式为准,安装完先确认命令行能正常执行:

# 以官方仓库的安装指引为准,社区里常见的全局安装方式如下 npm install -g dsh-harness harness --version harness init my-agent-env cd my-agent-env && harness

启动之后第一件事不是急着让它干活,而是先把模型配置好。Harness 本身不绑定具体模型,它通过 OpenAI 兼容接口接模型服务。我用的是 DeepSeek 的对话模型,配置方式就是设置几个环境变量:

export LLM_BASE_URL=https://api.deepseek.com/v1 export LLM_API_KEY=sk-xxxxxxxx export LLM_MODEL=deepseek-chat

设置完在 Harness 会话里随便发一条消息测试连通性。这一步很多人忽略,后面 agent 调工具全部失败,查了半天发现是模型根本没接上。我自己的习惯是配完环境先问一句“你是什么模型”,能答上来再继续,几十秒的验证能省几小时的排查。

Harness 的核心是插件机制,通过 web boot 方式加载。你可以把它理解成浏览器的插件市场:有现成的工具插件可以直接用,也可以把自己写的脚本注册成自定义工具。我这次实战不依赖重量级插件,自己写了一个叫 android-bridge 的工具包,里面就三个 Python 脚本,后面第三章专门讲。

2.3 打通开发机和模拟器的 ADB 链路

ADB 是整个链路的地基,先确认它认得出模拟器。打开终端执行:

adb kill-server && adb start-server adb devices

输出里有emulator-5554 device就算打通了。注意看状态,如果是unauthorized,说明模拟器弹窗没勾选允许;如果是空列表,十有八九是 5037 端口被占用。我遇到过被 scrcpy 抢占端口的情况,处理办法是找到占用进程杀掉,然后重启 adb server。这类问题其实都有固定套路,卡住时先看端口、再看授权,基本能解决八成的连接问题。

再确认模拟器真的启动完了。直接adb shell input有时会报错,因为系统还在 boot 阶段。稳妥做法是先执行adb shell getprop sys.boot_completed,输出为 1 再继续。这一步在自动化和 CI 脚本里尤其重要,很多人都是在这栽跟头——脚本启动模拟器后立刻去操作,结果界面还没渲染完,命令全打在了空气上。确认完成之后,把登录 Demo 装进去并启动:

adb install -r app-debug.apk adb shell am start -n com.example.login/.MainActivity

Demo App 是一个一百行左右的登录页:用户名输入框、密码输入框、登录按钮。按钮点击后校验账号密码是否匹配,成功跳欢迎页,失败弹出错误提示。所有控件都加上 resource-id,这就是给 agent 留的路标。布局里的关键部分大概是这样的:

<EditText android:id="@+id/et_username" android:hint="用户名/邮箱" android:inputType="textEmailAddress" android:layout_width="match_parent" android:layout_height="wrap_content" /> <EditText android:id="@+id/et_password" android:hint="密码" android:inputType="textPassword" android:layout_width="match_parent" android:layout_height="wrap_content" /> <Button android:id="@+id/btn_login" android:text="登录" android:layout_width="match_parent" android:layout_height="wrap_content" />

3. 给 Agent 一双能操作安卓的手:自定义工具集

3.1 工具设计原则:工具做薄,提示词做厚

写这套工具集之前,我给自己定了一条纪律:每个工具只做一件确定的事,输出必须是结构化 JSON,并且包含足够多的“可决策信息”。agent 不需要知道 adb 怎么调用,它只需要知道屏幕上有什么控件、点哪个、往哪个控件输入什么文本。逻辑和上下文交给模型,执行和采集交给工具,两边职责干净,出了问题也容易定位——工具出错就去查工具,模型跑偏就去调提示词,不会互相甩锅。

对应地,我给 agent 只准备了三个工具。get_screen 负责观察,tap_on 负责按 resource-id 点击,input_into 负责向指定控件输入文本。我没有给“滑动”“长按”“返回”这些工具,因为登录流程用不到。这里有个反直觉的经验:你以为工具越全越强,其实在 agent 开发里,工具越收敛,任务越不容易跑偏。工具少了,模型没有乱发挥的空间,每一步只能走你铺好的路,这对环境稳定性的价值远大于多几个炫技功能。

3.2 get_screen:让模型“看见”当前界面

get_screen 的实现思路是调用系统的 uiautomator dump,拿到当前页面的 UI 树 XML,再解析成紧凑的 JSON 返回给模型。为什么要解析?因为原始 XML 动辄几百上千行,全塞给模型反而干扰决策。我过滤掉大量无效节点,只保留有文本、有 id、或者可点击的节点,同时把 bounds 坐标保留下来备用。核心代码大概长这样:

# scripts/get_screen.py import json import subprocess import time import xml.etree.ElementTree as ET def dump_ui(): time.sleep(0.3) # 等界面稳定,避免 dump 到半渲染状态 subprocess.run( ["adb", "shell", "uiautomator", "dump", "/sdcard/ui.xml"], check=True, capture_output=True ) subprocess.run( ["adb", "pull", "/sdcard/ui.xml", "ui.xml"], check=True, capture_output=True ) tree = ET.parse("ui.xml") nodes = [] for node in tree.iter("node"): text = node.attrib.get("text", "").strip() rid = node.attrib.get("resource-id", "") clazz = node.attrib.get("class", "") bounds = node.attrib.get("bounds", "") clickable = node.attrib.get("clickable", "false") == "true" # 只保留值得模型关注的节点 if text or rid or clickable: nodes.append({ "text": text, "id": rid, "class": clazz, "bounds": bounds, "clickable": clickable, }) print(json.dumps(nodes, ensure_ascii=False, indent=2)) if __name__ == "__main__": dump_ui()

这里有个不同版本间的坑值得单独说。新版 Android 的 uiautomator dump 很可能不是在 /sdcard/ui.xml 生成普通 XML,而是生成一个带随机后缀的 zip 文件,直接 pull 下来解析会报错找不到节点。我的处理办法是 dump 完成后再看一眼实际生成的文件名,如果是 zip 先解压再解析。为了代码简洁,上面这段默认了普通 XML 场景,真要上生产环境,记得补一层文件类型判断。这种版本差异在 Android 生态里太常见了,跑在不同 API Level 的模拟器上行为可能完全不一样。

3.3 tap_on 和 input_into:让模型“动起来”

点击工具不能直接接收坐标,那样模型容易乱猜。我的设计是让模型传 resource-id,比如 et_username,工具自己到 UI 树里找到这个节点,算中心点,再执行 adb tap。这样模型的操作是语义化的:即使界面发生小改动,只要 id 不变,脚本就能稳住。这和上一章说的“能不用坐标就不用坐标”是一脉相承的思路。tap_on 的简单实现长这样:

# scripts/tap_on.py <resource_id> import json, re, subprocess, sys def bounds_center(bounds): m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds) x1, y1, x2, y2 = map(int, m.groups()) if m else (0, 0, 0, 0) return (x1 + x2) // 2, (y1 + y2) // 2 def get_nodes(): subprocess.run(["adb", "shell", "uiautomator", "dump", "/sdcard/ui2.xml"], capture_output=True) subprocess.run(["adb", "pull", "/sdcard/ui2.xml", "ui2.xml"], capture_output=True) import xml.etree.ElementTree as ET root = ET.parse("ui2.xml").getroot() return [n.attrib for n in root.iter("node")] rid = sys.argv[1] for node in get_nodes(): if node.get("resource-id", "").endswith(rid): cx, cy = bounds_center(node.get("bounds", "")) subprocess.run(["adb", "shell", "input", "tap", str(cx), str(cy)], check=True) print(json.dumps({"ok": True, "id": rid, "x": cx, "y": cy})) break else: print(json.dumps({"ok": False, "error": f"resource-id {rid} not found"}))

输入文本比点击麻烦得多,因为adb shell input text对空格、@、! 这类特殊字符经常出错。我第一次跑的时候给测试账号用纯字母数字,顺顺利利;一换成带 @ 的邮箱就翻车,输入到一半字符被 shell 吃掉。稳妥做法是安装 ADBKeyboard 这个开源输入法 APK,通过广播把文本发给输入框,任何字符都不会被 shell 处理掉。它其实是自动化团队的标配,不只是 agent 场景,录制回放脚本里同样适用。配置方式:

adb install -r ADBKeyboard.apk adb shell ime enable com.android.adbkeyboard/.AdbIME adb shell ime set com.android.adbkeyboard/.AdbIME adb shell am broadcast -a ADB_INPUT_TEXT --es msg 'tester@example.com'

如果你不想引入额外 APK,也可以用转义法:空格替换成 %s,在 mksh 下把 @ 和 ! 用反斜杠转义后传给 input text。但转义规则在不同 shell 版本上表现不一致,我的经验是别在这种细节上硬刚,直接上广播输入法,省心也稳定。低级工具层越稳定,agent 后面才越不容易被带偏。

3.4 把工具注册进 Harness:最小插件配置

这三个 Python 脚本要被 agent 调用,需要告诉 Harness 它们存在。Harness 的插件机制定义了一个很简单的协议:注册时声明工具名、执行命令、输出格式,Harness 就会在模型请求工具时把命令跑起来,把 stdout 返回给模型。用一个简化的配置举例,逻辑大致如下:

# android-bridge/tools.yaml name: android-bridge description: 操作 Android 模拟器上的 UI,用于登录等界面流程。 tools: - name: get_screen command: python3 scripts/get_screen.py description: 获取当前屏幕上的控件列表,返回 JSON。 - name: tap_on command: python3 scripts/tap_on.py {resource_id} description: 按 resource-id 点击一个控件。 - name: input_into command: python3 scripts/input_into.py {resource_id} {text} description: 向指定控件输入文本。

注册完以后,我在 Harness 会话里发一条消息让 agent 自报家门:“你有哪些工具?”它要是能列出 get_screen、tap_on、input_into,配置就算通了。这一步每次都值得做,因为插件路径写错、脚本权限没给足、依赖缺失,都会让工具静默失败。到时候你看到的效果就是 agent 在那里一本正经地“假装点击”,实际上什么都没有发生,你还以为是模型不行,其实是工具层早就断了。

4. 实战跑通:让 Agent 自动完成一次登录全流程

4.1 任务提示词:给 agent 划清边界

agent 的任务提示词不需要写得像论文,但一定要把边界划清楚。我第一次跑的时候给了大段自由发挥空间,结果它一会儿想 dump 全部控件,一会儿想打开开发者选项,完全跑偏。后来收敛成下面这个版本,把目标、步骤顺序、工具上限、终止条件全部说死:

当前任务:在 Android 模拟器中完成登录 Demo 的登录操作。 账号:tester@example.com 密码:Passw0rd! 规则: 1. 每一步先调用 get_screen,确认当前界面有哪些控件。 2. 只使用 get_screen / tap_on / input_into 三个工具。 3. 用户名输入到 et_username,密码输入到 et_password,然后点击 btn_login。 4. 如果界面出现错误提示,停止操作并汇报提示内容。 5. 当 get_screen 返回的界面中出现“欢迎回来”文本时,宣布任务完成。

值得注意的细节是第 4 条和第 5 条。刚上手时我不写终止条件,agent 登录成功后还在反复截图,白白浪费 token。加了“欢迎回来”这个界面信号之后,它每次截图都会自己判断是否结束。错误提示那条也很重要,它告诉 agent“遇到意外不要硬来,把现场交还给人”,这其实是在为后面的失败分支做铺垫。agent 提示词的哲学就是这样:不是教它怎么思考,而是给它铺一条不会掉进去的轨道。

4.2 一次完整执行的现场记录

我在模拟器上把测试账号密码清空,然后让 Harness 开始执行。agent 的第一步永远是观察,它调用 get_screen,返回的 JSON 大致长这样:

[ {"text": "登录 Demo", "id": "tv_title", "class": "android.widget.TextView"}, {"text": "", "id": "et_username", "class": "android.widget.EditText", "bounds": "[180,720][900,880]"}, {"text": "", "id": "et_password", "class": "android.widget.EditText", "bounds": "[180,900][900,1060]"}, {"text": "登录", "id": "btn_login", "class": "android.widget.Button", "bounds": "[180,1100][900,1240]"} ]

agent 看到这些控件后,先调用 tap_on,参数是 et_username,再调用 input_into 把账号输进去。输入完它没有急着点密码框,而是又 get_screen 了一次,确认密码框还躺在原来的位置,然后才 tap 密码框、输入密码、点登录。这一步“多回头看一眼”是 agent 和传统脚本最大的区别:脚本按部就班执行,agent 会基于观察结果动态调整。它不会因为第一眼看到密码框在旁边就默认它一直在旁边,每一步都把界面状态重新读一遍,这是稳定性的关键。

最后的 get_screen 返回里出现了“欢迎回来”文本,agent 判断任务完成,整个流程跑了 3 分 40 秒。拆开来看,耗时大头在 uiautomator dump,每次 2 到 3 秒,以及模型推理时间;真正点击和输入几乎一瞬间。如果你觉得慢,可以考虑给 get_screen 加缓存,或者降低 dump 频率,但稳定性优先时,我还是选择每步都观察,反正 4 分钟跑完一个登录流程完全可接受。对更复杂的任务,这个观察频率肯定要调整,但在登录模块这个粒度上,慢就是稳。

4.3 失败分支:agent 的试错与自纠错能力

光看顺利路径说明不了问题,我更想知道 agent 面对失败会不会自救。于是我把测试账号的密码故意改成错的,重新让 agent 执行。它输入完点击登录后,界面上弹出了“密码错误”的提示。agent 的 get_screen 结果里出现了这段文本,它的下一步是:读取错误提示,决定不清除密码框,tap 密码框,重新输入正确密码,再点登录。全程没有要我干预。这个过程在日志里看起来相当丝滑,但背后的逻辑其实很值得琢磨。

这就是我选登录模块做第一个场景的深层原因:登录有天然的错误反馈闭环,能逼 agent 面对不确定性。录制回放脚本遇到密码错误只能停下来等人处理,而 agent 可以从错误提示里判断下一步动作,等于把传统自动化里需要写死的一堆 if-else 交给了模型推理。不过这里有个前提:错误提示必须暴露在 UI 树里。我一开始用的是 Toast,但 Toast 在 uiautomator dump 里经常抓不到,后来改成屏幕中央的一个 TextView 提示,dump 必现。这是给 agent 做 UI 的一个反常识建议:反馈信息要走标准控件,别只走 Toast,否则再聪明的模型也看不见。

4.4 提高可靠性的三个小改动

第一次完整跑通后,我又连续跑了 10 次,发现偶发失败主要有三类:模拟器窗口失焦导致输入丢失、agent 连续点击产生状态错乱、以及界面停在动画过渡期。针对这三类我做了三个改动。第一,每次点按钮前先用adb shell wm dismiss-keyguard确保锁屏和焦点不影响操作;第二,在提示词里明令“每一次工具调用后必须 get_screen 才能进行下一次调用”,禁止连点;第三,给整个流程包一层 60 秒超时,超时自动截图留档。

第三点的截图留档特别实用。它把 agent 的“最后一屏”保存下来,排查问题时可以直接看现场,不用靠猜。我还在模拟器里建了快照机制:登录页初始状态拍一张快照,每次跑新的验证前先恢复到快照,这样不管上一轮把界面搞成什么样,10 秒内就能回到干净状态。快照相当于 agent 的沙盒,是自动化里的保底手段。有了它你才敢让 agent 放手折腾,折腾坏了也能瞬间重来,这个安全网的存在本身就能提升 agent 的试错意愿。

5. 常见问题与排查技巧实录

5.1 插件加载失败:harness failed to load plugins web boot

这个报错我在搭环境第二天就撞上了。完整的报错类似 “harness failed to load plugins web boot: 2 entries did not activate”,意思是插件引导阶段有两个插件条目没有激活。第一反应不要去看模型配置,问题基本在插件层。我那次是因为 Node 版本不对,一个插件要求大于等于 18,系统默认还是 16;另一部分则是因为插件目录里有多个入口文件,Harness 不知道该激活哪个。

排查路径我建议按顺序来:先node -v确认版本满足要求;再看插件目录里有没有重复的入口文件;最后把自定义工具独立成子目录,避免和官方插件混在一起。还有一个很隐蔽的原因:插件依赖没装全。有些插件是 git 直接拉的,拉下来没执行依赖安装,也会出现“条目未激活”。解决后建议跑一次harness doctor之类的自检命令,让它逐个报告插件状态,能省很多时间。下面这个表格是我后来整理的排查速查:

现象常见原因处理方式
报错里有 did not activateNode 版本不满足升级 Node 到 18 或更高
插件重复激活入口文件重复只保留一个入口,其他移出目录
插件静默失败依赖未安装在插件目录执行依赖安装
所有插件都不加载配置目录权限检查工作目录可写,清理缓存

5.2 ADB 链路不稳:unauthorized、端口占用、设备掉线

ADB 既是一切的基石,也是最容易出幺蛾子的一层。我碰到过三种典型问题。第一种是设备显示 unauthorized,多半是模拟器弹窗被忽略,重启后勾选“始终允许调试”即可。第二种是 adb devices 列表为空,常见原因是 5037 端口被占用,执行netstat -ano | grep 5037找到占用进程杀掉,然后adb kill-server && adb start-server。第三种是跑着跑着设备掉线,特别是长时间执行后,我养成了一个习惯:大任务前先执行adb reconnect offline清理状态。

还有一个容易被忽视的细节:多台设备同时在线时,adb shell 命令会因为设备不唯一而报错。自动化脚本里最好固定用-s emulator-5554指定设备,不要依赖默认设备。测试账号、密码这类输入内容也建议写进配置文件而不是堆在命令行里,既方便替换,也避免 shell 转义连环坑。总之,ADB 层出现问题,先稳定链路再排查 agent,否则很容易把环境问题误判成模型问题。环境问题误判成模型问题是 agent 开发里最常见的返工原因,没有之一。

5.3 控件定位失败:id 找不到、dump 空 XML、界面有弹窗

我调试最多的问题就是 get_screen 返回的列表里找不到目标控件。常见原因有三个。第一,界面还没渲染完就 dump 了,返回空 XML;解决方法是 dump 前 sleep 300ms 以上,宁可多等也别抢跑。第二,目标控件的 id 是动态生成的,或者干脆是自定义 View 没有 id;这种就退回 text 匹配,比如按钮文字是“登录”,就用 text 去匹配。第三,界面上盖了弹窗,弹窗把目标控件遮住,dump 出来的却是弹窗的层级;先adb shell input keyevent 4关掉弹窗再重试。

关于坐标兜底,我的经验是把它当成最后手段而不是常规手段。坐标只在 id 和 text 都失效时启用,而且启用前最好让模型说明原因。因为坐标天然脆:分辨率一变、系统字体一变、屏幕旋转一下,坐标全部作废。定位策略的优先级应当是 resource-id 大于 text 大于 bounds,agent 的任务提示词里也按这个顺序写,模型就不会在最简单的事情上乱发挥。如果你发现 agent 频繁输出坐标参数而不是 id 参数,说明提示词约束不够强,应该强化约束而不是去优化坐标算法。

5.4 模型行为异常:反复点同一个按钮、幻觉出控件、假装成功

这类问题最让人抓狂,因为报错不在工具层,而在模型的决策层。比如 agent 连续三次点击登录按钮,每次都汇报“已点击”,但界面上毫无变化。排查后发现它确实在调用工具,但点击坐标算错了,因为 dump 出来的 bounds 是物理像素坐标,而我给模型看的截图经过了缩放,两边对不上。结论是:登录流程所有坐标都必须以 uiautomator 的 bounds 为准,不要让它结合截图猜坐标。任何涉及坐标的地方,只认 UI 树里的数字。

还有“幻觉控件”问题:模型会杜撰一个 id 叫 login_btn 的按钮,而 UI 树里根本没有。这通常是因为它没好好看最新的 get_screen 结果,凭经验在猜。我的对策是在提示词里加一句话:“每次工具调用前,必须引用上一次 get_screen 结果中真实存在的 resource-id 或 text;如果目标不在列表里,停止操作并汇报。”这句话把模型的注意力逼回现场数据。包括很多人遇到的 agent 框架里“更新沙盒卡住、无法发送消息”这类问题,本质也是工具状态和模型认知不一致,清掉会话缓存重新同步后,绝大多数都能恢复。

5.5 进阶场景:当登录页出现系统文件选择器这类脏场景

登录模块不总是井井有条的输入框加按钮,很多 App 有第三方授权登录,会唤起系统文件选择器或 WebView。这类场景有个典型特征:uiautomator dump 出来的控件里会出现一堆 content:// URI 形式的路径,长串的 fileprovider 路径让模型完全懵掉,它根本不知道这些字符串是什么、该怎么办。我的处理方式是单独给 agent 准备一个 file_picker 工具,专门解析最近的文件列表,按文件名过滤后返回可选文件,让模型按文件名决策,而不是去理解那串 URI。

这也是把“登录模块”做透的乐趣所在:从一个最简单的输入框场景起步,逐步把弹窗、错误反馈、文件选择器、多步校验这些真实世界的问题一个个接进来。agent 的能力是被这些边界场景撑起来的,而不是被几张顺利路径的截图撑起来的。我每次改完工具脚本,都会先跑一条固定的冒烟测试,确认 get_screen 能正常出结果再让 agent 上场。这个小习惯帮我把绝大部分无谓排查挡在了开始之前,也是这整套环境里我觉得最值得分享的一条个人心得。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 9:18:11

语音AI进化:全双工交互与强化学习推理

这两天做语音方向的朋友估计都被同一条消息刷屏了&#xff1a;Qwen 在云栖大会上密集放出了一波语音模型更新&#xff0c;实时翻译、全模态交互、全双工语音&#xff0c;基本把语音 AI 的几个关键赛道都覆盖了一遍&#xff1b;另一边&#xff0c;Kyutai 实验室发布了 Voice of …

作者头像 李华
网站建设 2026/11/2 9:17:57

QuickBlue深度解析:企业AI应用底座如何解决模型接入与Agent编排难题

聊个最近被反复问起的东西&#xff1a;QuickBlue。如果你所在的企业已经在尝试把AI落进业务流程&#xff0c;或者正在为“模型选型”“Agent编排”“数据怎么喂给大模型”这些事发愁&#xff0c;那这个名词大概率会出现在你视野里。简单说&#xff0c;QuickBlue是一个面向企业的…

作者头像 李华
网站建设 2026/11/2 9:17:05

Vue+iframe微前端架构在企业流程配置中心的实践

1. 企业级流程配置中心的技术选型思考在数字化转型浪潮中&#xff0c;企业流程管理系统正从传统的表单驱动向可视化配置演进。最近我们团队基于Vueiframe架构落地了一个流程配置中心&#xff0c;这种组合方案在保证系统扩展性的同时&#xff0c;完美解决了多技术栈整合的难题。…

作者头像 李华
网站建设 2026/11/2 9:16:13

UVM验证环境编译报错排查与解决方案

1. UVM验证环境编译报错问题定位当你在UVM验证环境中遇到"Token uvm_env should be a valid type"这类编译错误时&#xff0c;通常意味着SystemVerilog编译器无法识别UVM基础类库中的类型定义。这个看似简单的报错背后&#xff0c;实际上反映了验证环境搭建过程中的几…

作者头像 李华
网站建设 2026/11/2 9:16:12

Poseidon-Front:面向协作交付的前端契约驱动工程体系

1. 项目概述&#xff1a;这不是一个“前端框架”&#xff0c;而是一套面向真实交付场景的工程化协作体系“Poseidon-Front”这个名字刚出现在我视野里时&#xff0c;第一反应是——又一个带希腊神祇名的开源项目&#xff1f;但翻完它的 GitHub 仓库、文档站和社区讨论帖后&…

作者头像 李华
网站建设 2026/11/2 9:15:58

用MCP协议打造Excel AI助手:从零构建自动处理表格的MCP Server

每年月底冲报表的时候&#xff0c;我都在想同一个问题&#xff1a;为什么这些重复的 Excel 操作不能交给 AI 去做&#xff1f;筛选、透视、VLOOKUP、合并表格、数据清洗&#xff0c;明明就是几个固定步骤&#xff0c;可每个月都要手动来一遍&#xff0c;浪费时间不说&#xff0…

作者头像 李华