开工前先聊点实际的。最近很多测试团队都在折腾 AI Agent,但大多数人只是拿它聊聊天、写写用例,真正把 Agent 变成“能干活”的工作搭子的人并不多。问题出在哪?出在 Agent 没有接上你的业务、没有接上你的设备、没有一套可以被稳定调用的“技能”上。这篇文章不聊虚的,直接围绕 Skill 体系,讲清楚怎么把一个 APP 测试流程拆解成 Agent 能理解、能执行、能返回结构化结果的完整链路。读完你可以照着搭出一只能帮你查包名、拉日志、跑冒烟用例、甚至汇报测试结果的 Agent 搭子。1. 背景与核心概念1.1 什么是 Agent Skill,为什么测试场景需要它AI Agent 本身是一个“会思考但不会动手”的大脑。它能理解你的问题,能拆解任务,但如果不能调用外部工具、不能执行脚本、不能读取设备状态,那它就只能停留在“建议者”的角色,而不是“执行者”。Skill 就是给 Agent 装上“手”的那一层能力扩展。Skill 的本质是一组可以被大模型识别并调用的功能接口,通常包含三个要素:一段给大模型看的自然语言描述,说明这个技能什么时候该用;一组定义好的入参和出参结构;一段真正干活的代码或 HTTP 接口,比如启动 App、查询进程、读取崩溃日志。在 APP 测试场景里,Agent 的价值是帮你把重复的、机械的、需要跨工具操作的步骤自动化掉。比如领导让你“看一下这个版本启动有没有崩溃”,你需要手动连设备、查日志、看进程。但如果你给 Agent 配了一个check_app_health的 Skill,它自己就能完成命令拼装、设备连接、日志抓取和结果判定。1.2 Skill 与普通脚本、自动化用例有什么区别很多同学会问:我直接写 Python 脚本不也能做这些事吗?为什么要绕一圈经过 Agent?这里的区别在于“意图理解”。普通脚本是写死的,输入是什么、执行什么流程,全部由人决定。而 Skill 是挂在 Agent 上的,Agent 可以根据你的自然语言指令,动态决定调用哪个 Skill、传入什么参数、如何组合多个 Skill 完成一个复杂任务。举个例子。你写了一个adb logcat的封装脚本,它只能做一件事:拉日志。但如果你把“拉日志”和“检查崩溃关键字”和“生成测试报告”三个 Skill 组合起来交给 Agent,你说一句“帮我看看这个版本有没有崩溃”,Agent 会自动决定先拉日志、再匹配关键字、最后整理成报告。这就是 Skill 组合带来的质变。1.3 常见应用场景:APP 测试中的 Skill 设计思路基于实际项目经验,以下几类测试任务非常适合做 Skill:场景Skill 示例预期输出启动检查check_app_startup启动耗时、是否闪退、首帧渲染结果日志分析fetch_crash_log崩溃堆栈、关键异常、发生时间性能采集collect_performanceCPU、内存、FPS、流量数据用例执行run_smoke_test用例通过率、失败详情设备管理list_devices在线设备列表、系统版本、分辨率把这些能力做成 Skill 之后,测试人员只需要像聊天一样给 Agent 下指令,剩下的调度和工具调用交给 Agent 完成。2. 环境准备与版本说明2.1 做一个 Agent Skill 需要哪些基础环境这里给出一份可以直接照着准备的清单,版本请根据你的实际项目调整,重点演示配置思路:操作系统:Windows 10/11、macOS 或 Linux 均可;Python 3.9+,并安装requests、flask(如果 Skill 走 HTTP 接口方式);Android SDK Platform-Tools,确保adb命令可用;一台开启 USB 调试的 Android 设备或模拟器;Agent 平台账号,国内常用的有扣子(Coze)、Dify,也可以使用 OpenAI 的 Function Calling 方案自己搭建;接口调试工具:Postman 或 Apifox,用于单独验证 Skill 后端接口;IDE:VS Code 或 PyCharm 均可,非必须。如果你只想在本地验证流程,不需要真实手机,也可以用模拟器完成。推荐先跑通最小闭环,再接入真实设备。2.2 示例项目结构本文的实战案例会创建一个名称为app-test-skill的目录,结构如下:app-test-skill/ ├── skills/ │ ├── check_app_health.py # 健康检查 Skill 核心逻辑 │ ├── fetch_crash_log.py # 崩溃日志抓取 Skill │ └── run_smoke_test.py # 冒烟测试执行 Skill ├── server/ │ └── app.py # Skill HTTP 服务入口 ├── schema/ │ └── skill_openapi.json # 供 Agent 平台识别的接口描述 ├── config/ │ └── config.yaml # 设备、包名等配置 └── requirements.txt后面会逐步讲解每个文件的职责和关键实现。2.3 验证 adb 环境是否正常Skill 底层要调 adb,所以第一步是确认 adb 可用。打开命令行执行:adb version adb devices如果adb devices能看到设备列表,说明环境正常。如果提示command not found,需要先配置 Android SDK 环境变量,或者在命令中写 adb 的完整路径。下面给一个通用的检查脚本,放在config/同级目录下,用于启动前的环境自检:import subprocess def check_adb(): result = subprocess.run(["adb", "version"], capture_output=True, text=True) if result.returncode != 0: raise RuntimeError("adb 不可用,请先配置 Android SDK Platform-Tools") device_list = subprocess.run(["adb", "devices"], capture_output=True, text=True) print(device_list.stdout) if __name__ == "__main__": check_adb()这段代码的作用很直接:用subprocess调用 adb,并捕获返回结果。如果 adb 找不到,会直接抛出异常,方便你在接入 Agent 之前发现问题。3. 核心语法、配置或原理拆解3.1 Skill 的运行机制:Agent 如何“看懂”你的技能要让 Agent 正确调用你的 Skill,关键不在于代码写得多么复杂,而在于接口描述是否清晰。Agent 平台通常通过 Schema 文件来理解技能:name:技能名称,必须是英文,Agent 靠它定位;description:一段详细的自然语言描述,说明这个 Skill 的用途、适用场景、以及什么时候不应该调用;parameters:入参定义,包括类型、是否必填、含义;responses:返回结构定义,告诉 Agent 怎么解读结果。在 Coze 这类平台里,你可以在“技能”或“插件”模块中上传 OpenAPI Schema,也可以直接在页面里手动填写。在自建方案中,则通过 Function Calling 把同样的描述传给大模型。3.2 入参和出参的设计原则:宁愿啰嗦,不要含糊很多人在定义 Skill 参数时容易犯一个错误:把参数写得过于简单。比如只写一个package_name,不说明格式。但 Agent 并不知道包名是什么格式,它只会根据描述去猜。如果猜错,整个调用链路就断了。建议参数描述写成这样:{ "name": "package_name", "type": "string", "description": "目标 App 的包名,例如 com.example.demo,可以通过 adb shell pm list packages 查询", "required": true }返回值也一样。如果你返回的是一段纯文本,Agent 虽然能看懂,但无法做后续的二次加工。更推荐返回结构化 JSON,把判断结果、数据明细、执行状态分开。{ "status": "success", "app_name": "demo", "launch_time_ms": 1250, "crash_count": 0, "message": "启动正常,无崩溃" }这样的返回结构,Agent 可以直接引用其中的字段来生成报告。3.3 一个最小的 Skill 后端接口示例先用 Flask 写一个最小可用的 Skill 接口。这个接口接收包名,返回 App 是否在运行、启动耗时等信息。先不追求完整,只是把链路打通。# 文件路径:server/app.py from flask import Flask, request, jsonify import subprocess app = Flask(__name__) def get_app_status(package_name): """检查 App 当前是否在前台运行""" cmd = f"adb shell dumpsys activity activities | grep -i {package_name}" result = subprocess.run(cmd, shell=True, capture_output=True, text=True) return "run" if package_name in result.stdout else "stopped" @app.route("/skill/check_app_health", methods=["POST"]) def check_app_health(): data = request.get_json() package_name = data.get("package_name") if not package_name: return jsonify({"status": "failed", "message": "缺少 package_name 参数"}), 400 app_status = get_app_status(package_name) return jsonify({ "status": "success", "package_name": package_name, "app_status": app_status, "message": "查询完成" }) if __name__ == "__main__": app.run(host="0.0.0.0", port=8000)启动服务:python server/app.py另开终端测试:curl -X POST http://127.0.0.1:8000/skill/check_app_health \ -H "Content-Type: application/json" \ -d '{"package_name": "com.example.demo"}'预期输出:{ "status": "success", "package_name": "com.example.demo", "app_status": "stopped", "message": "查询完成" }如果你在浏览器或 Postman 里能看到这段 JSON,说明你的 Skill 后端接口已经跑通了。下面要做的是让 Agent 能识别并且愿意调用它。3.4 为什么大模型有时不调用你的 Skill这里有一个非常容易踩的坑:你明明已经写了 Skill,但 Agent 就是不调用。遇到这种情况,先检查下面三件事:第一,你的description是否足够明确。如果描述里只写“查询应用状态”,Agent 可能会忽略它。应改成“当用户询问某个 App 是否正在运行或启动是否正常时,调用此技能。输入参数为 Android 应用包名。该技能通过 adb 获取设备状态并返回结果。”第二,入参是否足够具体。如果 Agent 不知道从用户的哪句话里提取package_name,它就只能放弃调用。建议在参数描述里给出示例。第三,Schema 是否正确。很多 Agent 平台对入参类型有严格要求,比如integer还是string,写错了会直接导致无法解析。4. 完整实战案例:调教一个 APP 冒烟测试 Agent 搭子下面进入正题,我们完整实现一个“APP 冒烟测试 Agent 搭子”。它能接收用户的自然语言指令,自动完成以下流程:获取在线设备;启动目标 App;检查是否崩溃;抓取启动日志;返回一份简单的测试结论。4.1 第一步:配置文件先把公共配置抽出来。包名、设备序列号、日志路径等不要硬编码在代码里。# 文件路径:config/config.yaml device: serial: "" # 留空则使用 adb 自动获取第一台设备 app: package_name: "com.example.demo" main_activity: ".MainActivity" launch_retry_times: 2 log: logcat_output: "logs/crash_{timestamp}.log" keywords: - "FATAL EXCEPTION" - "ANR in" - "Process: com.example.demo"这里要注意main_activity的写法。在 adb 启动命令中,通常使用包名/Activity全路径格式,而 Activity 可以通过aapt dump badging或者从开发那询问获得。4.2 第二步:实现设备管理 Skill设备管理是所有 APP 测试 Skill 的基础,先实现它的核心方法。# 文件路径:skills/device_skill.py import subprocess class DeviceManager: @staticmethod def list_devices(): """返回当前连接的设备列表""" result = subprocess.run( ["adb", "devices"], capture_output=True, text=True ) lines = result.stdout.strip().split("\n")[1:] devices = [] for line in lines: if line.strip() and "device" in line: serial = line.split("\t")[0] devices.append(serial) return devices @staticmethod def get_first_device(): """获取第一台可用设备""" devices = DeviceManager.list_devices() if not devices: raise RuntimeError("没有检测到可用设备,请检查 USB 调试连接") return devices[0]这个 Skill 本身不复杂,但它是其他 Skill 的依赖基础。实际项目中,设备可能时连时断,所以建议增加重试机制。4.3 第三步:实现 App 启动与崩溃检查 Skill这是核心部分。它要完成三件事:启动 App、等待几秒、检查进程是否存在。# 文件路径:skills/app_launch_skill.py import subprocess import time from skills.device_skill import DeviceManager class AppLaunchSkill: def __init__(self, package_name, main_activity, serial=None): self.package_name = package_name self.main_activity = main_activity self.serial = serial or DeviceManager.get_first_device() self.base_cmd = ["adb", "-s", self.serial] def launch_app(self, retry_times=2): launch_target = f"{self.package_name}/{self.main_activity}" cmd = self.base_cmd + ["shell", "am", "start", "-n", launch_target] for i in range(retry_times): result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0: return result.stdout raise RuntimeError(f"App 启动失败,目标: {launch_target}") def wait_for_launch(self, wait_seconds=3): time.sleep(wait_seconds) def is_process_alive(self): cmd = self.base_cmd + ["shell", "pidof", self.package_name] result = subprocess.run(cmd, capture_output=True, text=True) return result.stdout.strip() != "" def check_crash(self): """启动 App 后检查是否发生崩溃""" self.launch_app() self.wait_for_launch() if not self.is_process_alive(): return { "status": "failed", "message": "App 启动后进程不存在,疑似崩溃" } return { "status": "success", "message": "App 启动正常,进程存活" }pidof是一个很实用的命令,用来直接获取进程 ID。如果进程不在,说明 App 可能闪退了。当然,这只是最基础的判断方式,真实项目里还要看日志。4.4 第四步:实现日志抓取 Skill日志抓取要解决两个问题:什么时候开始抓、抓多久。# 文件路径:skills/log_skill.py import subprocess import time from datetime import datetime from skills.device_skill import DeviceManager class LogSkill: def __init__(self, serial=None): self.serial = serial or DeviceManager.get_first_device() self.base_cmd = ["adb", "-s", self.serial] def clear_logcat(self): subprocess.run(self.base_cmd + ["logcat", "-c"], capture_output=True) def capture_log(self, duration=10, keywords=None): """抓取指定时长的日志,并返回包含关键字的行""" self.clear_logcat() cmd = self.base_cmd + ["logcat", "-v", "time"] proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True) time.sleep(duration) proc.terminate() lines = proc.stdout.read().splitlines() keywords = keywords or ["FATAL EXCEPTION", "ANR in"] matched_lines = [] for line in lines: for keyword in keywords: if keyword in line: matched_lines.append(line) break return { "capture_time": datetime.now().isoformat(), "total_lines": len(lines), "matched_lines": matched_lines, "crash_count": len(matched_lines) }这段代码的原理是:先清空旧日志,再启动 logcat 抓取一段时间,最后过滤出崩溃关键字。用Popen而不是run,是因为 logcat 是一个持续输出的命令,我们只抓固定时长。4.5 第五步:把多个 Skill 组装成 Agent 能调用的 HTTP 服务单有 Skill 类还不够,Agent 平台无法直接调用 Python 类。所以需要一层 HTTP 封装,把 Skill 的能力暴露成 Agent 平台能识别的接口。下面写一个完整的 Flask 服务,把启动检查、日志抓取、报告生成整合在一个接口里。# 文件路径:server/app.py from flask import Flask, request, jsonify from skills.app_launch_skill import AppLaunchSkill from skills.log_skill import LogSkill from skills.device_skill import DeviceManager import yaml app = Flask(__name__) with open("config/config.yaml", "r", encoding="utf-8") as f: config = yaml.safe_load(f) APP_CONFIG = config["app"] LOG_CONFIG = config["log"] def generate_report(launch_result, log_result): """生成简单的冒烟测试报告""" crash_count = log_result.get("crash_count", 0) if launch_result.get("status") == "failed" or crash_count > 0: conclusion = "FAIL" else: conclusion = "PASS" return { "conclusion": conclusion, "launch_check": launch_result, "log_summary": { "capture_time": log_result.get("capture_time"), "total_lines": log_result.get("total_lines"), "crash_keyword_lines": log_result.get("matched_lines", [])[:5] } } @app.route("/skill/smoke_test", methods=["POST"]) def smoke_test(): data = request.get_json() package_name = data.get("package_name") or APP_CONFIG["package_name"] main_activity = data.get("main_activity") or APP_CONFIG["main_activity"] device_serial = None if data.get("device_serial"): device_serial = data["device_serial"] else: devices = DeviceManager.list_devices() if devices: device_serial = devices[0] if not device_serial: return jsonify({ "status": "failed", "message": "未找到可用的 Android 设备" }), 400 try: launch_skill = AppLaunchSkill(package_name, main_activity, serial=device_serial) launch_result = launch_skill.check_crash() log_skill = LogSkill(serial=device_serial) log_result = log_skill.capture_log(duration=8, keywords=LOG_CONFIG["keywords"]) report = generate_report(launch_result, log_result) return jsonify({"status": "success", "data": report}) except Exception as e: return jsonify({"status": "failed", "message": str(e)}), 500 @app.route("/skill/check_app_health", methods=["POST"]) def check_app_health(): data = request.get_json() package_name = data.get("package_name") if not package_name: return jsonify({"status": "failed", "message": "缺少 package_name 参数"}), 400 launch_skill = AppLaunchSkill(package_name, APP_CONFIG["main_activity"]) is_alive = launch_skill.is_process_alive() return jsonify({ "status": "success", "package_name": package_name, "is_running": is_alive }) if __name__ == "__main__": app.run(host="0.0.0.0", port=8000)启动服务:python server/app.py用一个测试请求验证:curl -X POST http://127.0.0.1:8000/skill/smoke_test \ -H "Content-Type: application/json" \ -d '{"package_name": "com.example.demo", "main_activity": ".MainActivity"}'预期会返回类似下面的结果(省去具体日志内容):{ "status": "success", "data": { "conclusion": "PASS", "launch_check": { "status": "success", "message": "App 启动正常,进程存活" }, "log_summary": { "capture_time": "2025-01-01T12:00:00", "total_lines": 1500, "crash_keyword_lines": [] } } }如果conclusion是FAIL,说明进程死了或者日志中有崩溃关键字,可以作为排查起点。4.6 第六步:在 Agent 平台配置 Skill以 Coze 为例,在“技能”或“插件”模块中新建一个技能,选择“使用 API”方式,然后填写接口地址和 Schema。如果以 OpenAI Function Calling 方案为例,你需要构造一个 functions 描述并调用模型接口:import openai functions = [ { "name": "run_smoke_test", "description": "对指定的 Android App 执行冒烟测试,包括启动检查、崩溃日志抓取和结论判定。适用于用户询问应用是否正常启动、是否存在崩溃、冒烟测试是否通过等场景。", "parameters": { "type": "object", "properties": { "package_name": { "type": "string", "description": "Android 应用包名,例如 com.example.demo" }, "main_activity": { "type": "string", "description": "主 Activity 路径,例如 .MainActivity" } }, "required": ["package_name"] } } ] response = openai.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "user", "content": "帮我对 com.example.demo 跑一下冒烟测试"} ], functions=functions, function_call="auto" ) print(response.choices[0].message)需要注意,现在很多平台已经调整了 API 参数名,比如有的用tools替代functions,有的用tool_choice替代function_call。请以你实际使用的 SDK 和模型版本为准,这里的写法用于示意核心逻辑。4.7 第七步:让 Agent 学会组合多个 Skill单接口方案虽然简单,但不够“Agent”。更接近真实工作搭子的方案,是把启动检查、日志抓取、报告生成拆成三个独立 Skill,让 Agent 自己决定调用顺序。这里要特别重视返回值的结构化程度。如果每一个 Skill 都返回干净的 JSON,Agent 就能做“工具之间传参”,比如先拿到package_name,再传给日志抓取 Skill。如果返回的是乱糟糟的一堆文本,Agent 就失去了二次处理的能力。实际调教时,你可以在测试对话里一步步引导。先问“设备在线吗”,Agent 调用list_devices;再问“启动 com.example.demo”,Agent 调用launch_app;最后问“看看有没有崩溃”,Agent 调用fetch_crash_log。这个过程本质上是在帮 Agent 建立工具调用的思维链。5. 常见问题与排查思路问题现象常见原因解决思路Agent 不调用 Skill插件描述太模糊,Agent 无法判断使用时机重写 description,明确触发条件、输入格式和返回结果一直提示缺少参数入参描述不清楚,Agent 无法从用户话术中提取在参数描述中补充示例格式,如com.example.demoadb 报 device offlineUSB 调试不稳定或驱动问题重新插拔设备,执行adb kill-server后重试Skill 接口请求超时logcat 抓取时间过长或后端没有设置超时缩短抓取时长,在 Flask 侧增加回调超时提示返回值无法被 Agent 理解返回结果是非结构化文本改为 JSON 结构,字段名用英文,加message字段设备列表为空未开启开发者模式或未授权 USB 调试在手机上确认“允许 USB 调试”弹窗,重新授权5.1 为什么 Agent 能调用 Skill,但结果处理得很差这是 Skill 落地时最常见的高频问题。明明接口返回了 JSON,Agent 却像没看到一样,自顾自地编结果。出现这种情况,大概率是返回结构里缺少“给 Agent 看的总结字段”。比如你返回了一堆崩溃日志原文,Agent 没有精力逐行分析。建议在返回结果时,除了原始数据外,额外增加一个message或summary字段,用一句完整的话描述关键结论。{ "status": "success", "crash_count": 1, "summary": "检测到 1 条 FATAL EXCEPTION 日志,发生在 MainActivity,建议优先排查空指针异常" }这个summary会直接成为 Agent 回答用户问题的素材。如果你能把这层做好,Agent 的回答质量会有非常明显的提升。5.2 排查 Skill 调用问题的三步定位法如果 Skill 调用出了问题,不要直接改代码,先按下面的顺序排查:第一步,绕过 Agent 测接口。用 Postman 或 curl 直接调 HTTP 接口,确认输入输出是否符合预期。如果接口本身有问题,Agent 怎么调都是白搭。第二步,看 Agent 的平台日志。大部分 Agent 平台都会记录工具调用日志,包含模型选择调用了哪个 Skill、传了什么参数、收到什么结果。从这里能看到 Agent 的“思考过程”。第三步,检查描述和 Schema。通过平台日志发现 Agent 传参不对时,优先优化参数描述,而不是改后端代码。6. 最佳实践与工程建议6.1 Skill 设计规范:按动作拆分,不按场景拆分在设计 Skill 时,很多人喜欢把一个完整流程封装成一个 Skill,比如“跑冒烟测试”。这种做法短期看很爽,但复用性极差。下次你想让 Agent “只抓日志”、“只检查进程”,就不得不面对一个过重的 Skill。建议按原子动作拆分:启动 App、查询进程、抓日志、查设备、解析日志。每个 Skill 只做一件事,然后让 Agent 或你自己在更高层级组合它们。这样做的好处很明显:单个 Skill 逻辑简单,测试容易;Agent 的工具组合能力能得到发挥;代码复用率更高。6.2 配置管理:包名和设备序列号不要硬编码所有跟环境相关的信息,包括包名、Activity、设备序列号、测试账号、超时时间,都应该放到配置文件里。否则换个项目、换台设备,你就得翻代码。推荐用 YAML 或环境变量来管理。连接多个环境时,可以考虑按dev、test、prod拆分配置文件。# 按环境拆分示例 app: dev: package_name: "com.example.demo.dev" test: package_name: "com.example.demo.test"6.3 日志与可观测性:Skill 执行过程要能回看Skill 一旦挂到 Agent 上,执行过程就变成了“黑盒”。用户发一句话,Agent 调了三次工具,最终返回一个结果。如果结果不对,你怎么知道是哪一步出了问题?所以每一个 Skill 内部都要记录自己的执行日志,至少包含:调用时间、入参、出参、耗时、异常信息。日志输出格式建议统一为 JSON,方便后续采集和分析。import json import logging from datetime import datetime logging.basicConfig(level=logging.INFO) logger = logging.getLogger("skill") def log_skill_call(skill_name, params, result, cost_ms): logger.info(json.dumps({ "skill": skill_name, "params": params, "result": result, "cost_ms": cost_ms, "timestamp": datetime.now().isoformat() }, ensure_ascii=False))这个习惯在初期看起来有点浪费时间,但一旦 Skill 数量超过十个,没有日志几乎寸步难行。6.4 安全边界:Skill 不能变成危险操作入口Skill 给了 Agent 调用命令的能力,但也意味着用户可以通过自然语言诱导 Agent 执行危险操作。这里要特别强调几个安全规范:涉及卸载 App、清空数据、修改系统设置的操作,不要做成 Skill,或必须增加人工确认环节;后端接口要做请求鉴权,不能裸奔在公网,至少加一个简单的 Token 校验;禁止把 shell 命令直接暴露给 Agent 自由拼接,所有命令必须经过白名单封装;涉及生产环境数据擦除、权限变更、账号操作的测试技能,先在测试环境验证,并做好备份。一个简单但有效的鉴权方式是在 Flask 里做装饰器校验。from functools import wraps from flask import request def require_token(func): @wraps(func) def wrapper(*args, **kwargs): token = request.headers.get("X-Skill-Token") if token != "your-secret-token": return {"status": "failed", "message": "鉴权失败"}, 401 return func(*args, **kwargs) return wrapper @app.route("/skill/smoke_test", methods=["POST"]) @require_token def smoke_test(): ...6.5 性能考量:不要让 Skill 拖慢 Agent 响应Agent 与 Skill 之间是同步调用,Skill 执行时间越长,用户等得越久。如果你在做 logcat 抓取这类耗时操作,建议设置合理的超时时间,并把耗时时长控制在用户可接受范围内。如果某个 Skill 执行时间超过 30 秒,建议改成异步模式:Skill 提交任务后直接返回任务 ID,Agent 告知用户“任务已提交”,然后通过轮询或回调获取结果。这在执行完整回归测试时特别重要。7. 总结与学习路线讲到这里,一条从零到一的 Skill 落地路径已经完整摆在你面前了。先理解 Skill 的本质,再准备好 adb、Python、Agent 平台这些基础环境,接着把设备管理、App 启动、日志抓取封装成原子技能,最后通过 HTTP 接口接入 Agent 平台。这个流程跑通后,你的 Agent 就不再是一个只会聊天的大模型,而是一个能自己连设备、查状态、抓日志、出报告的测试搭子。下一步可以往这几个方向深入。第一个方向是扩展 Skill 类型。把性能数据采集、UI 自动化用例执行、接口测试断言也做成 Skill,覆盖更多测试类型。第二个方向是完善组合编排。尝试让 Agent 在一次对话中完成多轮工具调用,比如先执行冒烟测试,再根据失败用例自动抓取对应模块的日志。第三个方向是沉淀评估体系。建立一套 Skill 质量评估标准,比如调用成功率、参数正确率、返回结果可读性,并定期用测试集去检验 Agent 的行为。如果你现在只记住一件事,那就先把“单个 Skill 能跑通、返回结构化 JSON”这个最小闭环做出来。这个闭环一旦成立,后面的工具组合、流程编排、多 Skill 协同都只是时间问题。先在模拟器上把check_app_health跑起来,再逐步增加复杂度,你的 AI Agent 工作搭子就会越来越像样了。
开工前先聊点实际的。最近很多测试团队都在折腾 AI Agent,但大多数人只是拿它聊聊天、写写用例,真正把 Agent 变成“能干活”的工作搭子的人并不多。问题出在哪?出在 Agent 没有接上你的业务、没有接上你的设备、没有一套可以被稳定调用的“技能”上。这篇文章不聊虚的,直接围绕 Skill 体系,讲清楚怎么把一个 APP 测试流程拆解成 Agent 能理解、能执行、能返回结构化结果的完整链路。读完你可以照着搭出一只能帮你查包名、拉日志、跑冒烟用例、甚至汇报测试结果的 Agent 搭子。1. 背景与核心概念1.1 什么是 Agent Skill,为什么测试场景需要它AI Agent 本身是一个“会思考但不会动手”的大脑。它能理解你的问题,能拆解任务,但如果不能调用外部工具、不能执行脚本、不能读取设备状态,那它就只能停留在“建议者”的角色,而不是“执行者”。Skill 就是给 Agent 装上“手”的那一层能力扩展。Skill 的本质是一组可以被大模型识别并调用的功能接口,通常包含三个要素:一段给大模型看的自然语言描述,说明这个技能什么时候该用;一组定义好的入参和出参结构;一段真正干活的代码或 HTTP 接口,比如启动 App、查询进程、读取崩溃日志。在 APP 测试场景里,Agent 的价值是帮你把重复的、机械的、需要跨工具操作的步骤自动化掉。比如领导让你“看一下这个版本启动有没有崩溃”,你需要手动连设备、查日志、看进程。但如果你给 Agent 配了一个check_app_health的 Skill,它自己就能完成命令拼装、设备连接、日志抓取和结果判定。1.2 Skill 与普通脚本、自动化用例有什么区别很多同学会问:我直接写 Python 脚本不也能做这些事吗?为什么要绕一圈经过 Agent?这里的区别在于“意图理解”。普通脚本是写死的,输入是什么、执行什么流程,全部由人决定。而 Skill 是挂在 Agent 上的,Agent 可以根据你的自然语言指令,动态决定调用哪个 Skill、传入什么参数、如何组合多个 Skill 完成一个复杂任务。举个例子。你写了一个adb logcat的封装脚本,它只能做一件事:拉日志。但如果你把“拉日志”和“检查崩溃关键字”和“生成测试报告”三个 Skill 组合起来交给 Agent,你说一句“帮我看看这个版本有没有崩溃”,Agent 会自动决定先拉日志、再匹配关键字、最后整理成报告。这就是 Skill 组合带来的质变。1.3 常见应用场景:APP 测试中的 Skill 设计思路基于实际项目经验,以下几类测试任务非常适合做 Skill:场景Skill 示例预期输出启动检查check_app_startup启动耗时、是否闪退、首帧渲染结果日志分析fetch_crash_log崩溃堆栈、关键异常、发生时间性能采集collect_performanceCPU、内存、FPS、流量数据用例执行run_smoke_test用例通过率、失败详情设备管理list_devices在线设备列表、系统版本、分辨率把这些能力做成 Skill 之后,测试人员只需要像聊天一样给 Agent 下指令,剩下的调度和工具调用交给 Agent 完成。2. 环境准备与版本说明2.1 做一个 Agent Skill 需要哪些基础环境这里给出一份可以直接照着准备的清单,版本请根据你的实际项目调整,重点演示配置思路:操作系统:Windows 10/11、macOS 或 Linux 均可;Python 3.9+,并安装requests、flask(如果 Skill 走 HTTP 接口方式);Android SDK Platform-Tools,确保adb命令可用;一台开启 USB 调试的 Android 设备或模拟器;Agent 平台账号,国内常用的有扣子(Coze)、Dify,也可以使用 OpenAI 的 Function Calling 方案自己搭建;接口调试工具:Postman 或 Apifox,用于单独验证 Skill 后端接口;IDE:VS Code 或 PyCharm 均可,非必须。如果你只想在本地验证流程,不需要真实手机,也可以用模拟器完成。推荐先跑通最小闭环,再接入真实设备。2.2 示例项目结构本文的实战案例会创建一个名称为app-test-skill的目录,结构如下:app-test-skill/ ├── skills/ │ ├── check_app_health.py # 健康检查 Skill 核心逻辑 │ ├── fetch_crash_log.py # 崩溃日志抓取 Skill │ └── run_smoke_test.py # 冒烟测试执行 Skill ├── server/ │ └── app.py # Skill HTTP 服务入口 ├── schema/ │ └── skill_openapi.json # 供 Agent 平台识别的接口描述 ├── config/ │ └── config.yaml # 设备、包名等配置 └── requirements.txt后面会逐步讲解每个文件的职责和关键实现。2.3 验证 adb 环境是否正常Skill 底层要调 adb,所以第一步是确认 adb 可用。打开命令行执行:adb version adb devices如果adb devices能看到设备列表,说明环境正常。如果提示command not found,需要先配置 Android SDK 环境变量,或者在命令中写 adb 的完整路径。下面给一个通用的检查脚本,放在config/同级目录下,用于启动前的环境自检:import subprocess def check_adb(): result = subprocess.run(["adb", "version"], capture_output=True, text=True) if result.returncode != 0: raise RuntimeError("adb 不可用,请先配置 Android SDK Platform-Tools") device_list = subprocess.run(["adb", "devices"], capture_output=True, text=True) print(device_list.stdout) if __name__ == "__main__": check_adb()这段代码的作用很直接:用subprocess调用 adb,并捕获返回结果。如果 adb 找不到,会直接抛出异常,方便你在接入 Agent 之前发现问题。3. 核心语法、配置或原理拆解3.1 Skill 的运行机制:Agent 如何“看懂”你的技能要让 Agent 正确调用你的 Skill,关键不在于代码写得多么复杂,而在于接口描述是否清晰。Agent 平台通常通过 Schema 文件来理解技能:name:技能名称,必须是英文,Agent 靠它定位;description:一段详细的自然语言描述,说明这个 Skill 的用途、适用场景、以及什么时候不应该调用;parameters:入参定义,包括类型、是否必填、含义;responses:返回结构定义,告诉 Agent 怎么解读结果。在 Coze 这类平台里,你可以在“技能”或“插件”模块中上传 OpenAPI Schema,也可以直接在页面里手动填写。在自建方案中,则通过 Function Calling 把同样的描述传给大模型。3.2 入参和出参的设计原则:宁愿啰嗦,不要含糊很多人在定义 Skill 参数时容易犯一个错误:把参数写得过于简单。比如只写一个package_name,不说明格式。但 Agent 并不知道包名是什么格式,它只会根据描述去猜。如果猜错,整个调用链路就断了。建议参数描述写成这样:{ "name": "package_name", "type": "string", "description": "目标 App 的包名,例如 com.example.demo,可以通过 adb shell pm list packages 查询", "required": true }返回值也一样。如果你返回的是一段纯文本,Agent 虽然能看懂,但无法做后续的二次加工。更推荐返回结构化 JSON,把判断结果、数据明细、执行状态分开。{ "status": "success", "app_name": "demo", "launch_time_ms": 1250, "crash_count": 0, "message": "启动正常,无崩溃" }这样的返回结构,Agent 可以直接引用其中的字段来生成报告。3.3 一个最小的 Skill 后端接口示例先用 Flask 写一个最小可用的 Skill 接口。这个接口接收包名,返回 App 是否在运行、启动耗时等信息。先不追求完整,只是把链路打通。# 文件路径:server/app.py from flask import Flask, request, jsonify import subprocess app = Flask(__name__) def get_app_status(package_name): """检查 App 当前是否在前台运行""" cmd = f"adb shell dumpsys activity activities | grep -i {package_name}" result = subprocess.run(cmd, shell=True, capture_output=True, text=True) return "run" if package_name in result.stdout else "stopped" @app.route("/skill/check_app_health", methods=["POST"]) def check_app_health(): data = request.get_json() package_name = data.get("package_name") if not package_name: return jsonify({"status": "failed", "message": "缺少 package_name 参数"}), 400 app_status = get_app_status(package_name) return jsonify({ "status": "success", "package_name": package_name, "app_status": app_status, "message": "查询完成" }) if __name__ == "__main__": app.run(host="0.0.0.0", port=8000)启动服务:python server/app.py另开终端测试:curl -X POST http://127.0.0.1:8000/skill/check_app_health \ -H "Content-Type: application/json" \ -d '{"package_name": "com.example.demo"}'预期输出:{ "status": "success", "package_name": "com.example.demo", "app_status": "stopped", "message": "查询完成" }如果你在浏览器或 Postman 里能看到这段 JSON,说明你的 Skill 后端接口已经跑通了。下面要做的是让 Agent 能识别并且愿意调用它。3.4 为什么大模型有时不调用你的 Skill这里有一个非常容易踩的坑:你明明已经写了 Skill,但 Agent 就是不调用。遇到这种情况,先检查下面三件事:第一,你的description是否足够明确。如果描述里只写“查询应用状态”,Agent 可能会忽略它。应改成“当用户询问某个 App 是否正在运行或启动是否正常时,调用此技能。输入参数为 Android 应用包名。该技能通过 adb 获取设备状态并返回结果。”第二,入参是否足够具体。如果 Agent 不知道从用户的哪句话里提取package_name,它就只能放弃调用。建议在参数描述里给出示例。第三,Schema 是否正确。很多 Agent 平台对入参类型有严格要求,比如integer还是string,写错了会直接导致无法解析。4. 完整实战案例:调教一个 APP 冒烟测试 Agent 搭子下面进入正题,我们完整实现一个“APP 冒烟测试 Agent 搭子”。它能接收用户的自然语言指令,自动完成以下流程:获取在线设备;启动目标 App;检查是否崩溃;抓取启动日志;返回一份简单的测试结论。4.1 第一步:配置文件先把公共配置抽出来。包名、设备序列号、日志路径等不要硬编码在代码里。# 文件路径:config/config.yaml device: serial: "" # 留空则使用 adb 自动获取第一台设备 app: package_name: "com.example.demo" main_activity: ".MainActivity" launch_retry_times: 2 log: logcat_output: "logs/crash_{timestamp}.log" keywords: - "FATAL EXCEPTION" - "ANR in" - "Process: com.example.demo"这里要注意main_activity的写法。在 adb 启动命令中,通常使用包名/Activity全路径格式,而 Activity 可以通过aapt dump badging或者从开发那询问获得。4.2 第二步:实现设备管理 Skill设备管理是所有 APP 测试 Skill 的基础,先实现它的核心方法。# 文件路径:skills/device_skill.py import subprocess class DeviceManager: @staticmethod def list_devices(): """返回当前连接的设备列表""" result = subprocess.run( ["adb", "devices"], capture_output=True, text=True ) lines = result.stdout.strip().split("\n")[1:] devices = [] for line in lines: if line.strip() and "device" in line: serial = line.split("\t")[0] devices.append(serial) return devices @staticmethod def get_first_device(): """获取第一台可用设备""" devices = DeviceManager.list_devices() if not devices: raise RuntimeError("没有检测到可用设备,请检查 USB 调试连接") return devices[0]这个 Skill 本身不复杂,但它是其他 Skill 的依赖基础。实际项目中,设备可能时连时断,所以建议增加重试机制。4.3 第三步:实现 App 启动与崩溃检查 Skill这是核心部分。它要完成三件事:启动 App、等待几秒、检查进程是否存在。# 文件路径:skills/app_launch_skill.py import subprocess import time from skills.device_skill import DeviceManager class AppLaunchSkill: def __init__(self, package_name, main_activity, serial=None): self.package_name = package_name self.main_activity = main_activity self.serial = serial or DeviceManager.get_first_device() self.base_cmd = ["adb", "-s", self.serial] def launch_app(self, retry_times=2): launch_target = f"{self.package_name}/{self.main_activity}" cmd = self.base_cmd + ["shell", "am", "start", "-n", launch_target] for i in range(retry_times): result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0: return result.stdout raise RuntimeError(f"App 启动失败,目标: {launch_target}") def wait_for_launch(self, wait_seconds=3): time.sleep(wait_seconds) def is_process_alive(self): cmd = self.base_cmd + ["shell", "pidof", self.package_name] result = subprocess.run(cmd, capture_output=True, text=True) return result.stdout.strip() != "" def check_crash(self): """启动 App 后检查是否发生崩溃""" self.launch_app() self.wait_for_launch() if not self.is_process_alive(): return { "status": "failed", "message": "App 启动后进程不存在,疑似崩溃" } return { "status": "success", "message": "App 启动正常,进程存活" }pidof是一个很实用的命令,用来直接获取进程 ID。如果进程不在,说明 App 可能闪退了。当然,这只是最基础的判断方式,真实项目里还要看日志。4.4 第四步:实现日志抓取 Skill日志抓取要解决两个问题:什么时候开始抓、抓多久。# 文件路径:skills/log_skill.py import subprocess import time from datetime import datetime from skills.device_skill import DeviceManager class LogSkill: def __init__(self, serial=None): self.serial = serial or DeviceManager.get_first_device() self.base_cmd = ["adb", "-s", self.serial] def clear_logcat(self): subprocess.run(self.base_cmd + ["logcat", "-c"], capture_output=True) def capture_log(self, duration=10, keywords=None): """抓取指定时长的日志,并返回包含关键字的行""" self.clear_logcat() cmd = self.base_cmd + ["logcat", "-v", "time"] proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True) time.sleep(duration) proc.terminate() lines = proc.stdout.read().splitlines() keywords = keywords or ["FATAL EXCEPTION", "ANR in"] matched_lines = [] for line in lines: for keyword in keywords: if keyword in line: matched_lines.append(line) break return { "capture_time": datetime.now().isoformat(), "total_lines": len(lines), "matched_lines": matched_lines, "crash_count": len(matched_lines) }这段代码的原理是:先清空旧日志,再启动 logcat 抓取一段时间,最后过滤出崩溃关键字。用Popen而不是run,是因为 logcat 是一个持续输出的命令,我们只抓固定时长。4.5 第五步:把多个 Skill 组装成 Agent 能调用的 HTTP 服务单有 Skill 类还不够,Agent 平台无法直接调用 Python 类。所以需要一层 HTTP 封装,把 Skill 的能力暴露成 Agent 平台能识别的接口。下面写一个完整的 Flask 服务,把启动检查、日志抓取、报告生成整合在一个接口里。# 文件路径:server/app.py from flask import Flask, request, jsonify from skills.app_launch_skill import AppLaunchSkill from skills.log_skill import LogSkill from skills.device_skill import DeviceManager import yaml app = Flask(__name__) with open("config/config.yaml", "r", encoding="utf-8") as f: config = yaml.safe_load(f) APP_CONFIG = config["app"] LOG_CONFIG = config["log"] def generate_report(launch_result, log_result): """生成简单的冒烟测试报告""" crash_count = log_result.get("crash_count", 0) if launch_result.get("status") == "failed" or crash_count > 0: conclusion = "FAIL" else: conclusion = "PASS" return { "conclusion": conclusion, "launch_check": launch_result, "log_summary": { "capture_time": log_result.get("capture_time"), "total_lines": log_result.get("total_lines"), "crash_keyword_lines": log_result.get("matched_lines", [])[:5] } } @app.route("/skill/smoke_test", methods=["POST"]) def smoke_test(): data = request.get_json() package_name = data.get("package_name") or APP_CONFIG["package_name"] main_activity = data.get("main_activity") or APP_CONFIG["main_activity"] device_serial = None if data.get("device_serial"): device_serial = data["device_serial"] else: devices = DeviceManager.list_devices() if devices: device_serial = devices[0] if not device_serial: return jsonify({ "status": "failed", "message": "未找到可用的 Android 设备" }), 400 try: launch_skill = AppLaunchSkill(package_name, main_activity, serial=device_serial) launch_result = launch_skill.check_crash() log_skill = LogSkill(serial=device_serial) log_result = log_skill.capture_log(duration=8, keywords=LOG_CONFIG["keywords"]) report = generate_report(launch_result, log_result) return jsonify({"status": "success", "data": report}) except Exception as e: return jsonify({"status": "failed", "message": str(e)}), 500 @app.route("/skill/check_app_health", methods=["POST"]) def check_app_health(): data = request.get_json() package_name = data.get("package_name") if not package_name: return jsonify({"status": "failed", "message": "缺少 package_name 参数"}), 400 launch_skill = AppLaunchSkill(package_name, APP_CONFIG["main_activity"]) is_alive = launch_skill.is_process_alive() return jsonify({ "status": "success", "package_name": package_name, "is_running": is_alive }) if __name__ == "__main__": app.run(host="0.0.0.0", port=8000)启动服务:python server/app.py用一个测试请求验证:curl -X POST http://127.0.0.1:8000/skill/smoke_test \ -H "Content-Type: application/json" \ -d '{"package_name": "com.example.demo", "main_activity": ".MainActivity"}'预期会返回类似下面的结果(省去具体日志内容):{ "status": "success", "data": { "conclusion": "PASS", "launch_check": { "status": "success", "message": "App 启动正常,进程存活" }, "log_summary": { "capture_time": "2025-01-01T12:00:00", "total_lines": 1500, "crash_keyword_lines": [] } } }如果conclusion是FAIL,说明进程死了或者日志中有崩溃关键字,可以作为排查起点。4.6 第六步:在 Agent 平台配置 Skill以 Coze 为例,在“技能”或“插件”模块中新建一个技能,选择“使用 API”方式,然后填写接口地址和 Schema。如果以 OpenAI Function Calling 方案为例,你需要构造一个 functions 描述并调用模型接口:import openai functions = [ { "name": "run_smoke_test", "description": "对指定的 Android App 执行冒烟测试,包括启动检查、崩溃日志抓取和结论判定。适用于用户询问应用是否正常启动、是否存在崩溃、冒烟测试是否通过等场景。", "parameters": { "type": "object", "properties": { "package_name": { "type": "string", "description": "Android 应用包名,例如 com.example.demo" }, "main_activity": { "type": "string", "description": "主 Activity 路径,例如 .MainActivity" } }, "required": ["package_name"] } } ] response = openai.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "user", "content": "帮我对 com.example.demo 跑一下冒烟测试"} ], functions=functions, function_call="auto" ) print(response.choices[0].message)需要注意,现在很多平台已经调整了 API 参数名,比如有的用tools替代functions,有的用tool_choice替代function_call。请以你实际使用的 SDK 和模型版本为准,这里的写法用于示意核心逻辑。4.7 第七步:让 Agent 学会组合多个 Skill单接口方案虽然简单,但不够“Agent”。更接近真实工作搭子的方案,是把启动检查、日志抓取、报告生成拆成三个独立 Skill,让 Agent 自己决定调用顺序。这里要特别重视返回值的结构化程度。如果每一个 Skill 都返回干净的 JSON,Agent 就能做“工具之间传参”,比如先拿到package_name,再传给日志抓取 Skill。如果返回的是乱糟糟的一堆文本,Agent 就失去了二次处理的能力。实际调教时,你可以在测试对话里一步步引导。先问“设备在线吗”,Agent 调用list_devices;再问“启动 com.example.demo”,Agent 调用launch_app;最后问“看看有没有崩溃”,Agent 调用fetch_crash_log。这个过程本质上是在帮 Agent 建立工具调用的思维链。5. 常见问题与排查思路问题现象常见原因解决思路Agent 不调用 Skill插件描述太模糊,Agent 无法判断使用时机重写 description,明确触发条件、输入格式和返回结果一直提示缺少参数入参描述不清楚,Agent 无法从用户话术中提取在参数描述中补充示例格式,如com.example.demoadb 报 device offlineUSB 调试不稳定或驱动问题重新插拔设备,执行adb kill-server后重试Skill 接口请求超时logcat 抓取时间过长或后端没有设置超时缩短抓取时长,在 Flask 侧增加回调超时提示返回值无法被 Agent 理解返回结果是非结构化文本改为 JSON 结构,字段名用英文,加message字段设备列表为空未开启开发者模式或未授权 USB 调试在手机上确认“允许 USB 调试”弹窗,重新授权5.1 为什么 Agent 能调用 Skill,但结果处理得很差这是 Skill 落地时最常见的高频问题。明明接口返回了 JSON,Agent 却像没看到一样,自顾自地编结果。出现这种情况,大概率是返回结构里缺少“给 Agent 看的总结字段”。比如你返回了一堆崩溃日志原文,Agent 没有精力逐行分析。建议在返回结果时,除了原始数据外,额外增加一个message或summary字段,用一句完整的话描述关键结论。{ "status": "success", "crash_count": 1, "summary": "检测到 1 条 FATAL EXCEPTION 日志,发生在 MainActivity,建议优先排查空指针异常" }这个summary会直接成为 Agent 回答用户问题的素材。如果你能把这层做好,Agent 的回答质量会有非常明显的提升。5.2 排查 Skill 调用问题的三步定位法如果 Skill 调用出了问题,不要直接改代码,先按下面的顺序排查:第一步,绕过 Agent 测接口。用 Postman 或 curl 直接调 HTTP 接口,确认输入输出是否符合预期。如果接口本身有问题,Agent 怎么调都是白搭。第二步,看 Agent 的平台日志。大部分 Agent 平台都会记录工具调用日志,包含模型选择调用了哪个 Skill、传了什么参数、收到什么结果。从这里能看到 Agent 的“思考过程”。第三步,检查描述和 Schema。通过平台日志发现 Agent 传参不对时,优先优化参数描述,而不是改后端代码。6. 最佳实践与工程建议6.1 Skill 设计规范:按动作拆分,不按场景拆分在设计 Skill 时,很多人喜欢把一个完整流程封装成一个 Skill,比如“跑冒烟测试”。这种做法短期看很爽,但复用性极差。下次你想让 Agent “只抓日志”、“只检查进程”,就不得不面对一个过重的 Skill。建议按原子动作拆分:启动 App、查询进程、抓日志、查设备、解析日志。每个 Skill 只做一件事,然后让 Agent 或你自己在更高层级组合它们。这样做的好处很明显:单个 Skill 逻辑简单,测试容易;Agent 的工具组合能力能得到发挥;代码复用率更高。6.2 配置管理:包名和设备序列号不要硬编码所有跟环境相关的信息,包括包名、Activity、设备序列号、测试账号、超时时间,都应该放到配置文件里。否则换个项目、换台设备,你就得翻代码。推荐用 YAML 或环境变量来管理。连接多个环境时,可以考虑按dev、test、prod拆分配置文件。# 按环境拆分示例 app: dev: package_name: "com.example.demo.dev" test: package_name: "com.example.demo.test"6.3 日志与可观测性:Skill 执行过程要能回看Skill 一旦挂到 Agent 上,执行过程就变成了“黑盒”。用户发一句话,Agent 调了三次工具,最终返回一个结果。如果结果不对,你怎么知道是哪一步出了问题?所以每一个 Skill 内部都要记录自己的执行日志,至少包含:调用时间、入参、出参、耗时、异常信息。日志输出格式建议统一为 JSON,方便后续采集和分析。import json import logging from datetime import datetime logging.basicConfig(level=logging.INFO) logger = logging.getLogger("skill") def log_skill_call(skill_name, params, result, cost_ms): logger.info(json.dumps({ "skill": skill_name, "params": params, "result": result, "cost_ms": cost_ms, "timestamp": datetime.now().isoformat() }, ensure_ascii=False))这个习惯在初期看起来有点浪费时间,但一旦 Skill 数量超过十个,没有日志几乎寸步难行。6.4 安全边界:Skill 不能变成危险操作入口Skill 给了 Agent 调用命令的能力,但也意味着用户可以通过自然语言诱导 Agent 执行危险操作。这里要特别强调几个安全规范:涉及卸载 App、清空数据、修改系统设置的操作,不要做成 Skill,或必须增加人工确认环节;后端接口要做请求鉴权,不能裸奔在公网,至少加一个简单的 Token 校验;禁止把 shell 命令直接暴露给 Agent 自由拼接,所有命令必须经过白名单封装;涉及生产环境数据擦除、权限变更、账号操作的测试技能,先在测试环境验证,并做好备份。一个简单但有效的鉴权方式是在 Flask 里做装饰器校验。from functools import wraps from flask import request def require_token(func): @wraps(func) def wrapper(*args, **kwargs): token = request.headers.get("X-Skill-Token") if token != "your-secret-token": return {"status": "failed", "message": "鉴权失败"}, 401 return func(*args, **kwargs) return wrapper @app.route("/skill/smoke_test", methods=["POST"]) @require_token def smoke_test(): ...6.5 性能考量:不要让 Skill 拖慢 Agent 响应Agent 与 Skill 之间是同步调用,Skill 执行时间越长,用户等得越久。如果你在做 logcat 抓取这类耗时操作,建议设置合理的超时时间,并把耗时时长控制在用户可接受范围内。如果某个 Skill 执行时间超过 30 秒,建议改成异步模式:Skill 提交任务后直接返回任务 ID,Agent 告知用户“任务已提交”,然后通过轮询或回调获取结果。这在执行完整回归测试时特别重要。7. 总结与学习路线讲到这里,一条从零到一的 Skill 落地路径已经完整摆在你面前了。先理解 Skill 的本质,再准备好 adb、Python、Agent 平台这些基础环境,接着把设备管理、App 启动、日志抓取封装成原子技能,最后通过 HTTP 接口接入 Agent 平台。这个流程跑通后,你的 Agent 就不再是一个只会聊天的大模型,而是一个能自己连设备、查状态、抓日志、出报告的测试搭子。下一步可以往这几个方向深入。第一个方向是扩展 Skill 类型。把性能数据采集、UI 自动化用例执行、接口测试断言也做成 Skill,覆盖更多测试类型。第二个方向是完善组合编排。尝试让 Agent 在一次对话中完成多轮工具调用,比如先执行冒烟测试,再根据失败用例自动抓取对应模块的日志。第三个方向是沉淀评估体系。建立一套 Skill 质量评估标准,比如调用成功率、参数正确率、返回结果可读性,并定期用测试集去检验 Agent 的行为。如果你现在只记住一件事,那就先把“单个 Skill 能跑通、返回结构化 JSON”这个最小闭环做出来。这个闭环一旦成立,后面的工具组合、流程编排、多 Skill 协同都只是时间问题。先在模拟器上把check_app_health跑起来,再逐步增加复杂度,你的 AI Agent 工作搭子就会越来越像样了。