先说个最近我反复跟团队强调的场景UI 自动化脚本能自动生成但没人愿意自动维护。业务页面两周一改版测试脚本跟着改选择器、调等待、补分支这套流程消耗的精力比当初手写脚本还多。后来我把 Agent Skills 这套“技能封装”的思路和 Playwright 组合起来加了一个大模型推理层做出一套能让智能体自己看页面、自己写脚本、自己修脚本的流程实测下来把日常回归用例的维护成本降了至少一半。这篇文章就把我踩过的坑、验证过的方案和核心实现完整拆给你适合已经在用 Playwright 写自动化、但对 Agent 落地还停留在“调 API 聊天”阶段的测试开发同学。1. 内容整体设计与思路拆解很多人一听到“让 AI 写 UI 自动化脚本”第一反应是让大模型直接生成一段完整的 Playwright 代码然后丢到命令行跑。我一开始也是这么干的结果很惨模型生成的代码看起来很完整一跑就挂在定位器上——不是 class 名字猜错就是页面元素是异步渲染出来的它压根没等。于是我把方案改成了三层结构Agent Skills 负责把页面操作抽象成可复用技能Playwright 负责稳定执行大模型只负责拆解任务和做决策。1.1 为什么选 Agent Skills 而不是直接生成代码直接生成代码最大的问题在于模型对被测系统的认知是“一次性”的。它不知道登录按钮在真实页面上长什么样不知道某个表格是虚拟滚动还是分页加载这些信息只有在真实浏览器里获取才有意义。Agent Skills 的思路是反过来先把高频、稳定的操作封装成技能模块比如“打开页面”“输入文本”“点击按钮”“读取表格数据”“等待元素出现”每个技能内部用 Playwright 的最佳实践写好大模型的任务只是根据用户需求去组合这些技能。这个设计本质上就是把“写代码”变成了“编排动作”。对于大模型来说填空式和选择题式的任务成功率远远高于从零生成一段复杂脚本这一点在真实工程里非常关键。1.2 Playwright 在这个方案里的角色定位Playwright 是这个体系里最可靠的地基。它能同时提供三层能力第一是跨浏览器一致性Chromium、Firefox、WebKit 都能跑第二是自动等待机制元素不可操作时会自动重试不需要脚本里手动写一堆 sleep第三是 Trace Viewer可以完整录下每一步操作和页面快照这三点恰好是“智能体需要观察→行动→反馈”闭环里缺一不可的组件。我特别看重的是 Trace Viewer。智能体执行完一轮操作之后如果断言失败我可以把 trace 文件直接喂给大模型让它看到失败时页面上到底发生了什么而不是靠文字猜测。这个能力是 Selenium 时代完全不具备的也是 Agent 方案能够真正落地的关键支撑。1.3 整体架构模型、技能、执行器三层协作整个方案可以分成三个层次决策层大模型接收自然语言目标拆解成有序的技能调用序列并处理执行过程中返回的异常信息。技能层一组用 Playwright 封装的动作函数每个函数都有清晰的功能描述、参数列表和返回结构作为工具暴露给模型。执行层真实浏览器环境负责跑动作、采集页面状态、生成 trace 和截图。用一张对比表来看传统写脚本方式和 Agent Skills 方式的核心差异对比维度传统手写脚本Agent Skills Playwright编写方式人工逐行写定位器和操作自然语言描述目标模型编排技能页面改版应对人工定位问题并改选择器模型读取失败信息调整动作序列或定位策略维护成本随用例数量线性增长技能复用率提高新增用例成本大幅下降调试手段看日志、看截图Trace Viewer 模型辅助分析失败恢复人工介入修复智能体尝试自愈或给出修复建议这套结构还有一个隐藏优势技能层是稳定接口底层 Playwright 版本升级或者 API 调整时只需要改动技能层内部实现决策层的提示词和模型行为完全不需要跟着变。工程上这种“变更隔离”是很值钱的。2. 核心实现从环境搭建到技能封装这一节直接进入实操。我以最常用的 Python 技术栈为例把完整的架构搭建过程一步步拆开讲包括依赖安装、工程目录设计、技能函数怎么写以及最关键的大模型调用协议设计。2.1 环境准备与工程目录设计先说依赖。基础运行环境是三件套Python 3.10、最新的 Playwright 包、一个能调用 OpenAI 兼容接口的大模型客户端库。安装命令很简单pip install playwright playwright install chromium第二行命令是安装 Chromium 浏览器内核这一步千万别漏。我在 Windows、macOS、Linux 上都跑过这套方案Linux 服务器上还需要额外装一些系统依赖库如果报 Missing dependencies 直接跑playwright install-deps就能解决。工程目录我建议这样组织agent_ui_project/ ├── agent/ │ ├── llm_client.py # 大模型接口封装 │ ├── planner.py # 任务拆解与异常处理循环 │ └── prompts.py # 提示词模板 ├── skills/ │ ├── __init__.py # 技能注册表 │ ├── browser.py # 技能实现所有 Playwright 操作 │ └── schema.py # 技能参数与返回值定义 ├── target_app/ # 被测应用相关数据、页面配置 │ └── app_config.py └── main.py # 入口接收目标并启动 Agent这样分的核心原因是让“技能实现”和“模型决策”完全解耦。skills 目录里不出现任何大模型相关代码agent 目录里不出现任何 Playwright 相关代码中间通过 schema 文件传递结构化的技能定义。2.2 技能函数的设计原则与示例代码封装 Playwright 操作时一个最容易犯的错是把技能函数写得过于原子化比如“移动鼠标到坐标 (x,y)”。这种技能对模型来说太难编排了因为它根本没有语义。我总结的经验是技能粒度要匹配自然语言指令的颗粒度。举一个落地的技能定义以“打开页面并等待加载完成”为例# skills/browser.py from playwright.sync_api import Page, expect def open_page(page: Page, url: str, wait_selector: str body) - dict: 打开指定 URL并等待页面出现某个关键元素。 参数: url: 目标页面地址 wait_selector: 用于判断页面加载完成的 CSS 选择器 返回: 页面标题和当前 URL page.goto(url, wait_untildomcontentloaded) page.wait_for_selector(wait_selector, timeout15000) return {title: page.title(), url: page.url}再看一个带自动重试的点击技能def click_element(page: Page, selector: str, retry: int 3) - dict: 点击页面上的指定元素带重试机制。 参数: selector: 目标元素的 CSS 选择器 retry: 失败后重试次数 返回: 是否成功以及失败原因 for attempt in range(retry): try: page.locator(selector).click(timeout5000) return {success: True, attempts: attempt 1} except Exception as e: page.wait_for_timeout(1000) return {success: False, error: str(e)}这类技能函数有几个共同特征参数尽量少、返回结构固定、内部做好异常捕获。模型不需要理解 Playwright 的 API 细节它只需要知道“调用 click_element传什么参数拿到什么结果”。技能的注册机制我用一个简单的字典表搞定# skills/schema.py SKILL_REGISTRY { open_page: { description: 打开指定 URL 并等待页面加载完成, parameters: { url: {type: string, required: True, description: 目标页面地址}, wait_selector: {type: string, required: False, description: 页面加载完成标志}, } }, click_element: { description: 点击页面上指定选择器的元素带重试, parameters: { selector: {type: string, required: True, description: CSS 选择器}, } }, # ... 更多技能 }这个注册表有两个用途一是拼进系统提示词让模型知道有哪些工具可用二是做参数校验防止模型传入非法参数。实际运行中我在这一层拦下了大量格式错误。2.3 智能体主循环规划、执行、反馈、修正技能层搭好之后核心的智能体循环代码并不复杂关键在于把“反馈”这一步做扎实。我的实现流程是把用户目标和技能注册表一起发给大模型让它返回一个 JSON 数组表示有序的技能调用序列。按顺序执行技能每个技能执行后都会返回结构化结果。如果某一步执行失败把错误信息、当前的 trace 文件路径、页面截图一起追加到上下文里再发给模型让它决定是换一种定位策略还是调整后续动作。循环执行直到任务成功或达到最大轮次限制。主循环的关键代码骨架如下def run_agent(target: str, max_rounds: int 5): context [{role: system, content: build_system_prompt()}, {role: user, content: target}] with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() for round_idx in range(max_rounds): response llm_client.chat(context, response_formatjson) actions parse_actions(response) # 解析模型返回的动作序列 results [] for action in actions: result execute_skill(page, action) results.append(result) if not result.get(success): break if all_actions_succeeded(results): return summarize_success(results) # 把失败信息写入上下文进入下一轮修正 trace_path page.trace_path() context.append({role: user, content: format_failure_feedback(results, trace_path)}) browser.close()其中有几个容易被忽视的细节每一轮对话的上下文长度会快速增长我通常会把前面轮次成功的执行结果做一些摘要压缩只保留“动作类型、关键返回值、耗时”三要素避免把几千行日志全塞给模型。trace 路径要提前开启page.trace.start()并且在每轮结束后page.trace.stop()生成一次独立的 trace 文件这样反馈里能引用具体是哪一轮失败。headless 模式只适合批量回归调试阶段建议开 headed否则模型看不到真实页面状态遇到诡异问题时排查效率会低很多。2.4 定位器选择策略给智能体一双“可靠的眼睛”整个方案里最容易让智能体翻车的地方就是定位器选择。模型对页面的理解完全来自 DOM 描述和截图一旦页面是异步渲染的或者元素属性是动态生成的模型很容易给出一个看起来正确但实际脆弱的 selector。我做了两层策略来对抗这个问题。第一层是在技能层内部实现“多候选定位”比如点击一个按钮时同时尝试按文本、按 role、按>def extract_candidate_elements(page: Page, keyword: str) - list: 在当前页面中查找所有与关键词相关的可交互元素。 candidates [] elements page.query_selector_all(button, input, a, [rolebutton], [data-testid]) for el in elements[:50]: text el.inner_text()[:50] if el.inner_text() else None data_testid el.get_attribute(data-testid) classes el.get_attribute(class) candidates.append({ tag: el.evaluate(node node.tagName), text: text, data_testid: data_testid, classes: classes }) return candidates模型拿到这个候选列表后就能结合用户意图选出正确的目标元素。这一步很有必要它把“生成代码”这种开放性问题转化成了“从候选里挑一个”的闭式问题成功率提升非常明显。3. 实操过程与核心环节实现理论知识说完了下面走一个完整的实操案例。我以一个模拟的某电商运营后台作为被测对象目标是从登录页开始完成一个订单数据核验任务并截取结果图。这个案例覆盖了登录态管理、动态表单填写、表格数据提取、截图保存四个核心场景。3.1 任务定义与初始规划给智能体的目标描述如下登录“某电商运营后台”账号 admin / 密码 Test12345进入“订单管理”页面筛选昨天创建的订单统计状态为“已完成”的数量并截取筛选后的订单列表截图保存到 outputs/order_done.png。第一轮模型规划出的动作序列大致是[ {skill: open_page, params: {url: https://xxx.ops.example.com/login, wait_selector: #login-form}}, {skill: fill_input, params: {selector: #username, value: admin}}, {skill: fill_input, params: {selector: #password, value: Test12345}}, {skill: click_element, params: {selector: button[typesubmit]}}, {skill: wait_for_navigation, params: {}}, {skill: click_text, params: {text: 订单管理}}, {skill: fill_input, params: {selector: #create-date-start, value: 昨天日期}}, {skill: click_text, params: {text: 查询}}, {skill: read_table, params: {selector: .order-table}}, {skill: count_by_column, params: {column: 状态, value: 已完成}} ]这个规划整体合理但里面有几个地方需要在执行阶段动态修正。比如“昨天日期”需要由代码动态计算而不是模型写死再比如“订单管理”菜单在真实页面上的文本可能是“订单管理”加一个角标数字导致click_text精确匹配失败。这些问题正好是执行阶段要处理的核心场景。3.2 登录态与动态参数处理登录模块是整个流程里稳定性要求最高的环节。我的做法是登录动作单独封装成一个技能并且在技能内部加入 3 次重试机制每次失败后自动截取当前页面图并记录原因。def login(page: Page, url: str, username: str, password: str, success_selector: str) - dict: 执行登录流程成功标准是出现 success_selector 指定的元素。 page.goto(url, wait_untildomcontentloaded) for attempt in range(3): page.fill(#username, username) page.fill(#password, password) page.click(button[typesubmit]) try: page.wait_for_selector(success_selector, timeout8000) return {success: True, attempt: attempt 1} except Exception as e: page.screenshot(pathflogs/login_fail_{attempt}.png) page.wait_for_timeout(2000) return {success: False, error: login failed after retries}动态日期参数不能交给模型去算因为这个计算过程完全确定不需要模型推理。更稳妥的做法是在执行层做参数预处理器识别动作序列里面形如{yesterday}或{today}这种占位符在执行前自动替换为真实值。这也可以算作一个小的技能扩展点。预处理的代码逻辑大概是def preprocess_actions(actions: list) - list: from datetime import date, timedelta date_map { {today}: date.today().strftime(%Y-%m-%d), {yesterday}: (date.today() - timedelta(days1)).strftime(%Y-%m-%d), } for action in actions: for key, value in action.get(params, {}).items(): if isinstance(value, str) and value in date_map: action[params][key] date_map[value] return actions这类“确定性逻辑代码化不确定性逻辑模型化”的原则是整个智能体工程里最重要的一条工程经验。它直接把模型出错的概率空间缩小了一大截。3.3 “看”表格数据并做统计订单列表是一个典型的动态渲染表格数据区域是在接口返回之后才真正渲染出来的直接读取时经常会拿到空表或者“加载中”状态。我封装了一个带自动等待的表格读取技能先等表格行数大于 0再逐行提取指定列的数据。def read_table(page: Page, selector: str, column_name: str None, timeout: int 15000) - dict: 读取 table 中指定列的文本内容。 rows page.locator(f{selector} tbody tr) rows.first.wait_for(timeouttimeout) count rows.count() if count 0: return {row_count: 0, data: []} columns page.locator(f{selector} thead th) column_idx None if column_name: for i in range(columns.count()): if column_name in columns.nth(i).inner_text(): column_idx i break data [] for i in range(count): cells rows.nth(i).locator(td) if cells.count() 0: value cells.nth(column_idx).inner_text() if column_idx is not None else cells.nth(0).inner_text() data.append(value.strip()) return {row_count: count, data: data}统计数量这个动作不需要单独开新的浏览器页面直接在前一步的读取结果上做本地统计就行。我在技能层单独放了一个count_occurrences工具它接收一个列表和过滤条件返回出现次数。这样做的好处是模型不需要自己写 Python 代码去统计减少了出错的可能。还有一个细节动态表格在翻页后会销毁旧的 DOM 节点如果读取完第一页后还需要翻页继续读必须重新获取 locator而不能复用之前拿到的对象。这个坑在实操中几乎每个人都会踩一次后面会在常见问题部分详细展开。3.4 截图的正确打开方式截图保存到文件这件事看起来简单实际上的坑在于路径规范和失败现场的保存策略。我的做法是定义一个固定输出目录并且用小写字母加下划线命名避免中文路径和空格带来的跨平台问题。def take_screenshot(page: Page, filepath: str, full_page: bool False) - dict: 截取当前页面支持整页截图。 from pathlib import Path Path(filepath).parent.mkdir(parentsTrue, exist_okTrue) page.screenshot(pathfilepath, full_pagefull_page) return {success: True, path: filepath}对于“昨天创建的订单已完成数量”这种目标我发现边做边截图比最后补一张全局截图更有用。比如登录成功后截图 login_success.png筛选完成后截图 filter_applied.png最后再截图最终结果页。这样如果最终断言语义出了问题还能根据中间步骤快速定位是哪一步的理解偏了。我在实际项目中还加了一道“截图自检”步骤截图完成后用像素差异算法判断页面是否真的切换到了预期状态比如判断某块区域是否出现了预期的文字渲染痕迹。这个能力对复杂前端页面尤其好用因为 SPA 的路由变化经常不体现在 URL 上只体现在视图渲染上。3.5 完整跑通后的输出与校验当一轮动作全部执行成功后智能体不会直接结束而是会做完整性检查。我的检查点包括是否成功登录以success_selector是否出现为准。是否产生了统计数据表格行数必须大于 0。是否生成了截图文件存在且文件大小大于某个阈值比如 10KB因为空白截图基本都小于这个值。每一步的 trace 文件是否完整这个主要是留作回归排查的证据链。如果这些条件不满足智能体会启动自我修正流程最多可以向模型反馈三次。三次都失败的话整个任务标记为失败并且把失败的 trace 文件路径打印出来方便人工接手。这个自校验逻辑听起来简单但它是整个系统从“可用”走到“可信”的分水岭。没有它模型偶尔成功时你都不敢确认结果是真的对还是碰巧对。4. 常见问题与排查技巧实录把方案跑通不难难的是在真实环境里稳定运行。下面这部分是我不间断使用了几个月之后整理出的问题清单和排查方法每一类都是实战里真实碰到过的。4.1 定位器脆断问题现象是最典型的第一轮生成脚本能跑通页面版本一更新脚本大面积失败。根因在于模型生成的定位器偏好使用动态变化的 class 名称或者 index 序号这些属性在真实项目里极不稳定。我的排查方式很直接把失败时的 trace 文件和截图拉出来和成功时的 DOM 结构做 diff。大部分情况不是元素消失了而是 class 名加了前缀、或者 DOM 层级里多包了一层。解决方案分三层提示词层面强制要求模型优先使用 role、文本内容、data-testid 定位只有在这些都不存在时才使用 class。技能层面封装一个find_element工具内部按优先级逐个尝试候选定位方式把“定位”这个动作从模型手里抢过来。工程层面推动被测系统在常用组件上补充>rows page.locator(.order-table tbody tr).all()在翻页后的场景里这个写法比保存 locator 对象要稳得多。还有一点要注意断言表格行数时不要用count()大于 0 来判断成功因为虚拟滚动表格可能只渲染视口内的一部分行count 值会随着滚动变化这个场景需要额外处理。4.3 Token 消耗失控与上下文膨胀智能体方案最容易被忽视的成本问题是 token。每一轮失败修正都会把错误信息、页面状态、trace 描述追加进上下文这个量会迅速膨胀几轮下来上下文长度直接超过模型窗口限制。我的经验是设立“信息压缩管道”进入下一轮修正前把上一轮的中文日志摘要成两到三行只保留失败技能名、错误类型、候选元素数量。原始日志全部落盘保存但在模型上下文里是压缩后的精炼版本。实测下来单任务 token 消耗能减少约 40%而任务成功率没有下降。一个更极致的方案是把页面截图交给多模态模型去理解而不是传输大段 DOM 文本。不过这个方案的工程复杂度会高出不少适合有专门 GPU 资源或者调用多模态 API 成本可控的场景。4.4 iframe 和 Shadow DOM 的处置不当现在的管理系统里 iframe 出现的频率并不低最常见的是对接第三方登录或者富文本编辑器。直接用常规 locator 是定位不到 iframe 内部元素的。Playwright 提供了frame_locator语法但我发现很多模型不知道这个能力生成的脚本里会直接拿普通选择器去选 iframe 内部元素。我干脆把 iframe 场景直接封装成了两个独立技能switch_to_frame和switch_to_parent_frame模型只需要在动作序列中合适的位置插入这两个技能即可。技能内部的实现方式很简单用 Playwright 的 frame 定位来做page.frame_locator(#editor-frame).locator(.toolbar-button).click()Shadow DOM 也是同理我把page.locator(css#host).shadow_locator(.inner-btn)的调用封装进技能层。核心思路仍然不变把易错的底层细节留在技能层大模型只需要做高层编排。4.5 多账户场景的上下文隔离如果你的业务场景需要并行跑多个账户比如同时处理多个店铺的后台操作直接用同一个浏览器上下文会把登录态混在一起。Playwright 的解决方案是 Browser Context 隔离每个账户一个 contextcookie 和 localStorage 天然隔离。我的架构里这一层是放在技能层之外的浏览器管理模块处理的。每个任务启动时分配一个独立 context任务的 trace、截图、日志也都按 context 维度落盘到独立目录。这样不仅账户不会串数据排查问题时也能按单账户维度完整回放它的所有操作。这个设计在真实项目里帮我解决了一个很头疼的问题之前用同一个上下文跑多个账号时偶尔会出现“A 账号的操作被记录了 B 账号的登录态”这种诡异 bug排查起来极其费劲换成 context 隔离之后就再没出现过。4.6 问题排查工具清单整理一个速查表方便你在现场快速对号入座问题现象常见根因排查手段解决方案元素定位超时页面有异步渲染class 动态变化打开 trace 看页面 DOM改用多候选定位策略优先 role/text/data-testid脚本执行随机失败存在动画或懒加载观察失败时的截图在技能中加入wait_for_selector和可配置超时翻页后拿不到新数据旧 locator 绑定已销毁节点打印rows.count()对比每次读取前重新查询 locator上下文过长失败信息无限制追加记录每轮 token 数引入日志摘要与上下文压缩管道iframe 内元素点击失败未切换到对应 frame查看 trace 中 frame 列表使用frame_locator封装专用技能多账号数据串乱共享了浏览器上下文检查 cookie 隔离每任务独立browser.new_context()5. 成本、局限与后续演进方向这套方案不是银弹用了小半年之后我对它的收益边界和成本结构都有了更清醒的认识。这里把真实的数据和思考写出来供你评估时参考。5.1 直接收益人力成本从“线性”变成“对数”拿我最常用的读数据类用例来算账。传统手写脚本一个中等复杂度的用例大概需要 0.5 到 1 天页面改版之后修复时间通常在 1 到 2 小时。接入 Agent 方案后新用例的编写时间压缩到 15 分钟左右因为大部分时间都花在人工确认业务规则上而不是写定位器。改版修复时间则取决于技能层是否受影响如果只是页面内部元素的调整修复可能只需要在反馈里描述一句下一轮模型自己就改好了。但这不意味着测试开发同学就“没事干了”。真实的收益是时间被释放出来用在了更值得投入的地方比如建立更细粒度的业务断言、优化回归用例的颗粒度、以及把手动页面巡检逐渐改造成半自动的智能巡检。更大的价值是过去因为脚本维护成本太高而不敢覆盖的长尾页面现在可以用较低成本把它们纳入自动化的覆盖范围。5.2 隐性成本模型调用、基础设施与调优周期最大的显性成本当然是 token 调用费。非多模态模型跑一个完整任务平均消耗在 3 万到 6 万 token 之间失败重试时会更高。批量跑几十上百条用例时这个费用不是可以忽略的。我的应对方式是批量任务时全部换成更经济的模型只有复杂纠错场景才切回强推理模型。隐性成本里更需要注意的其实是“评估成本”。你需要一套能自动判断“智能体这次跑得对不对”的标准否则模型自行发挥成功时你根本分不清它是真做对了还是在表演。我这里有一套轻量级方案用确定性脚本判断关键动作的副作用比如登录后验证某个元素出现筛选后验证 URL 带上了筛选参数。这些判断逻辑写成独立断言模块不依赖模型自己报喜。5.3 未来的演进从“执行脚本”到“理解业务”我现在在探索的下一个方向是让智能体不只是会执行动作还能理解业务规则。比如读完表格后不是机械地统计数量而是能结合上下文判断“这个数量比上个周期少了 30%是不是筛选条件设置错了”。这需要把规则库和知识库也接入上下文让模型在做判断时有依据可查而不是纯粹靠猜测。另一个方向是把这套能力变成 CI 流水线的标准环节。每次前端代码合并后自动拉取 Playwright 智能体去跑一轮冒烟回归。和传统跑固定脚本不同智能体可以根据这次代码改动的描述主动生成针对性的回归用例而不是每次都跑一成不变的全量用例。这个想法还在验证阶段但初步结果是积极的尤其是对那类经常改动局部页面的项目冒烟覆盖率提升效果很明显。这套方案真正值得投入的地方不在于让模型写得更多而是让工程体系里那些重复的、确定性的工作被稳定地、正确地自动化。技术会一直演进但把“易变的模型行为”和“稳定的执行层”解耦的理念我觉得会比其他具体实现活得更久。
阅读完成 · 觉得有帮助?