UI 自动化这条路上我踩过最深的坑不是写脚本而是写完脚本之后隔一周用例就挂。选择器失效、元素动态加载、弹窗抢焦点——这些问题足以让人怀疑人生。后来我换了个思路既然大模型已经能写代码那能不能让它不光写还能自己跑、自己看结果、自己修于是我做了个实验项目把 Agent Skills 的思路和 Playwright 结合起来搞了一个会自己编写并执行 UI 自动化脚本的智能体。本文就把这个项目的完整设计和实现细节拆开讲清楚包括技能包怎么定义、智能体主循环怎么写、反馈怎么喂给模型、失败怎么自愈以及我实际踩过的一堆坑。适合测试开发、AI 应用开发以及对 Agent 落地玩法感兴趣的朋友。1. 先搞清楚Agent Skills 到底是什么和普通 Prompt 有什么区别很多人一听到 Agent Skills第一反应是不就是把提示词写细一点吗还真不是。我一开始也这么想直到我把技能从一段 Prompt 拆成一个带工具、带约束、带验证流程的完整单元才发现效果完全不一样。1.1 技能的本质可复用的操作知识包Agent Skills 的本质是给智能体配备一个「可插拔的能力单元」。它不是简单的一段话而是一个包含多类资源的组合包。一个典型的技能包大概包含四样东西技能描述这个技能解决什么问题在什么场景下使用模型的哪些能力会被激活。工具定义技能执行时需要调用哪些函数或外部工具这些工具的入参和返回结构是什么。使用约束什么时候不能用这个技能比如不要用绝对 XPath点击前必须等待元素可见。验证流程如何判断技能执行成功失败后如何生成修复建议。为什么要把这些打包成一个文件而不是直接塞进系统提示词我个人的体验是上下文长度有限技能包按需加载用到的时候才注入能大幅降低模型被无关信息干扰的概率。而且技能包可以像模块一样维护今天加了新页面只要更新对应的技能包不用改智能体的主逻辑。你可以用生活化的方式理解普通 Prompt 相当于你口头交代一个实习生去帮我打开系统登录查一下数据然后把测试跑一遍。Agent Skills 相当于你丢给实习生一本带操作插图的手册、一套工具箱外加一份「做完之后必须确认的检查清单」。同一个实习生拿着手册和工具箱完成任务的靠谱程度完全是两个级别。1.2 为什么选择 Playwright 作为交互介质市面上做浏览器自动化的工具不少Selenium 老牌Cypress 前端生态友好但我最终选 Playwright主要因为三点。第一统一 API。Playwright 用一套 API 就能驱动 Chromium、Firefox 和 WebKit对于智能体来说这意味着技能包可以不用针对浏览器差异做特殊处理。第二自动等待机制。Playwright 的所有操作几乎都内置了等待逻辑click、fill 这些操作会等到元素可交互再执行。这一点对智能体至关重要因为大模型生成的脚本顺序未必完美自动等待能掩盖一部分时序问题。第三现场重建能力。Playwright 有 Trace Viewer可以完整记录每次点击、输入、截图和网络请求。智能体跑完一轮之后我能像回放录像一样看它到底做了什么。这在调试阶段几乎是救命功能没有它我根本没法判断 Agent 是在正确操作只是断言失败还是压根点错了按钮。我并不是说 Selenium 不行而是在让大模型自己操作浏览器这件事上Playwright 的自动等待和 trace 能力极大降低了模型输出的不确定性。智能体本来就容易犯错如果底层自动化工具还各种玄学失败那整个系统基本就没法收敛。2. 整体架构设计从让 AI 写代码到让 AI 自己写完并跑通第一版方案我想得很简单让大模型输出一段 Playwright 脚本然后我用 subprocess 跑一下。结果十次有八次跑不通。问题不是模型不会写而是它看不到运行现场。脚本里写的选择器在它训练数据里可能是对的但在真实页面上元素 id 是动态的、按钮被另一层遮住了、页面初始化要三秒——这些它统统不知道。2.1 传统方式的局限把生成脚本和执行脚本拆成两个独立阶段最大的问题就是反馈断裂。模型生成完代码它的任务就结束了后面脚本能不能跑通跟它没有任何关系。一旦运行报错错误信息得由人工复制给模型模型再改一版人再跑一次。如果页面结构复杂这个循环可能要重复五六轮。这种方式本质上还是在用大模型当代码补全工具而不是当智能体。智能体应该具备感知和行动能力它能启动浏览器它能读取页面状态它能根据失败信息重新规划下一步。要做到这点必须把环境、工具、模型揉进一个循环里。2.2 闭环架构规划 - 执行 - 观察 - 修正我最终采用的架构是一个四步闭环规划智能体根据用户指令和当前技能包决定下一步动作。动作可以是生成一段完整脚本也可以是调用某个工具函数。执行把动作转成 Playwright 操作真实驱动浏览器执行。观察读取执行后的页面状态URL、标题、关键文本、截图等把结果反馈给模型。修正模型基于观察结果判断是否完成任务。如果没有它会调整计划再次进入循环。这个循环里最重要的是「观察」这一步。Playwright 不只是执行脚本的执行器它还充当智能体的眼睛。每轮循环结束我收集尽可能精简、但足够有用的页面信息连同执行日志一起构造新的消息继续喂给模型。模型看到的信息越接近真实用户看到的界面它给出的修复就越靠谱。2.3 技能包设计的三个层次做这个项目的时候我把技能包分成了三个层次每层解决一类问题。第一层基础操作技能。比如打开 URL、点击、输入、读取文本、截图。这些是最底层的动作对应 Playwright 的封装函数。这一层不需要模型做太多决策它只需要根据页面情况选择合适的动作。第二层结构化解析技能。比如用 get_by_role、get_by_label 定位元素或者用文本内容定位以及等待特定元素出现。这一层解决的是怎么准确找到页面元素的问题。大模型经常生成花哨的 CSS 选择器反而忽略了一个事实用户能看到的按钮文本才是 UI 自动化里最稳定的锚点。第三层验证与修复技能。包括断言、截图对比、错误分析、修复建议生成。这一层决定智能体能不能毕业。很多 Agent 项目死在只会执行不会验证上。跑完用例不知道到底有没有成功自然谈不上可靠。这三个层次不是割裂的而是由技能包描述串联起来。智能体在规划时会自己判断当前任务需要哪些层次的能力然后按需加载对应的工具定义和约束。3. 实操从零搭建一个会写脚本的智能体理论说完了直接上实操。我将以一个某管理系统登录后校验用户信息的自动化任务为例一步步实现这个智能体。整个过程包括环境准备、技能包定义、工具封装、主循环和提示词模板。3.1 环境准备与依赖我用的是 Python 3.11核心依赖只有两个playwright 和 requests用于调用大模型接口。大模型接口我用的是一个 OpenAI 兼容的 HTTP API具体厂商不重要只要支持 Chat Completions 格式就行。安装命令如下pip install playwright requests playwright install chromium如果你要跑完整测试还需要playwright install-deps安装系统级依赖尤其是跑在精简容器里的场景。这个命令在 Linux 上很重要不装的话 chromium 启动直接报一堆共享库缺失的错误。工程目录结构我建议这样组织agent-ui/ ├── skills/ │ ├── browser_basic/ │ │ ├── SKILL.md │ │ └── tools.json │ ├── page_analyze/ │ │ └── SKILL.md │ └── test_verify/ │ └── SKILL.md ├── tools/ │ ├── playwright_session.py │ └── llm_client.py ├── agent.py └── config.py把技能包单独放在skills目录里好处是以后新增技能不用改 agent 逻辑放进去就能用。tools目录放和 Playwright 以及模型交互的代码。agent.py是主循环入口。3.2 定义 Agent Skills 技能清单先看一个最基础的技能包文件skills/browser_basic/SKILL.md它描述的是浏览器操作能力# Browser Basic Skill ## Description 提供浏览器打开、点击、输入、读取文本、截图等基础操作能力。 适用于需要与网页进行交互的自动化任务。 ## Tools - browser_open(url): 打开指定 URL - browser_click(selector, selector_type): 点击页面元素 - browser_fill(selector, text): 在输入框中填入文本 - browser_get_text(selector): 获取元素文本 - browser_screenshot(path): 全屏截图并保存 ## Constraints 1. 优先使用角色定位get_by_role、文本定位get_by_text避免使用动态生成的 id。 2. 点击元素前确保元素已可见可交互。 3. 每次操作前必须重新获取页面快照不能假设页面状态不变。 ## Verification - 打开页面后需要检查页面标题和 URL。 - 点击或输入后需要确认页面出现预期反馈。这个文件本身不是给代码直接解析的 JSON而是给人读顺便在运行时注入到提示词里给模型看。真正给代码用的工具参数定义放在tools.json里类似这样{ browser_open: { type: function, parameters: { url: {type: string, required: true} } }, browser_click: { type: function, parameters: { selector: {type: string, required: true}, selector_type: {type: string, enum: [css, text, role, label]} } } }我后来发现把 SKILL.md 和 tools.json 分开非常有必要。SKILL.md 用于增强模型的语义理解tools.json 用于模型函数调用的结构化输出。两者结合才是一个完整的技能包。3.3 实现 Playwright 工具集封装这里我写了一个PlaywrightSession类把常用浏览器操作统一封装。先看代码# tools/playwright_session.py from playwright.sync_api import sync_playwright from dataclasses import dataclass dataclass class ActionResult: success: bool message: str data: dict None class PlaywrightSession: def __init__(self, headlessTrue): self.headless headless self._pw None self.browser None self.page None def start(self): self._pw sync_playwright().start() self.browser self._pw.chromium.launch(headlessself.headless) context self.browser.new_context( viewport{width: 1280, height: 720}, localezh-CN, ) self.page context.new_page() def open(self, url: str) - ActionResult: try: self.page.goto(url, wait_untildomcontentloaded, timeout15000) self.page.wait_for_load_state(networkidle, timeout10000) return ActionResult(True, 页面打开成功, { url: self.page.url, title: self.page.title() }) except Exception as e: return ActionResult(False, f页面打开失败: {e}) def click(self, selector: str, selector_type: str text) - ActionResult: try: locator self._locate(selector, selector_type) locator.scroll_into_view_if_needed() locator.click(timeout10000) self.page.wait_for_timeout(300) # 留出响应时间 return ActionResult(True, f点击成功: {selector}) except Exception as e: return ActionResult(False, f点击失败: {selector}, 原因: {e}) def fill(self, selector: str, text: str, selector_type: str text) - ActionResult: try: locator self._locate(selector, selector_type) locator.fill(text, timeout10000) return ActionResult(True, f输入成功: {selector} {text}) except Exception as e: return ActionResult(False, f输入失败: {selector}, 原因: {e}) def _locate(self, selector: str, selector_type: str): p self.page if selector_type text: return p.get_by_text(selector, exactFalse) elif selector_type role: # 支持形如 button: 提交 的格式 role, name selector.split(:, 1) return p.get_by_role(role.strip(), namename.strip()) elif selector_type label: return p.get_by_label(selector) else: return p.locator(selector) # 获取页面关键信息供模型观察 def observe(self) - dict: texts self.page.locator(body).inner_text(timeout5000) # 截断到前 2000 字符 snippets texts[:2000] return { url: self.page.url, title: self.page.title(), snippet: snippets, } def screenshot(self, path: str debug.png): self.page.screenshot(pathpath, full_pageTrue) return path def stop(self): if self.browser: self.browser.close() if self._pw: self._pw.stop()有几个封装细节值得琢磨。observe方法是我整个系统的眼。它把页面 body 的文本提取出来截断到 2000 字符左右喂给模型。为什么不把整个 HTML 给模型因为 token 开销太大而且大模型看一堆 div 嵌套反而更容易犯迷糊。提取纯文本加上 URL 和标题已经足够让模型判断当前在哪个页面、发生了什么。_locate方法我特意支持了四种定位方式text、role、label、css。其中 role 和 text 是首选因为它们对页面结构变化不敏感。模型在规划时我会提示它优先使用 text 和 role只有这两种搞不定才用 css。3.4 设计智能体主循环主循环是整个 Agent 的心脏。思路维护一个消息列表每次把技能包描述、当前页面观察结果、上一轮执行反馈一起发给大模型让模型输出结构化决策。决策可以是「调用某个工具」或「最终结论」。下面是一个简化但能跑的版本# agent.py import json import re from tools.playwright_session import PlaywrightSession from tools.llm_client import LLMClient class UIAgent: def __init__(self, llm_client: LLMClient, max_iterations8): self.llm llm_client self.session PlaywrightSession() self.max_iterations max_iterations def run(self, task: str): self.session.start() history [{role: user, content: task}] skill_docs self._load_skill_docs() # 读取 skills 目录下的 SKILL.md 内容 for i in range(self.max_iterations): obs self.session.observe() system_prompt self._build_system_prompt(skill_docs, obs) self.llm.add_system(system_prompt) response self.llm.chat(history) history.append({role: assistant, content: response}) action self._parse_action(response) if action[type] finish: print(Agent 认为任务已完成, action.get(message, )) break elif action[type] tool_call: result self._execute_tool(action[tool], action[params]) history.append({ role: user, content: f工具执行结果: {result.message}\n是否成功: {result.success} }) else: history.append({role: user, content: 不支持的 action 类型请重新输出。}) self.session.stop()这里我故意做了个简化处理每轮循环重新设置 system prompt 带上观察信息历史消息里只保留对话和工具结果。这么做的好处是模型每次思考时都能看到最新页面状态坏处是如果历史消息太长还是可能超 token实际项目里需要做截断策略。3.5 编写核心提示词模板提示词是系统里最容易被低估的部分。我不搞花哨的 Role Prompt就用最直接的结构。核心模板如下你是一个 UI 自动化测试智能体。你的任务是通过工具操作浏览器完成用户指令。 请根据当前页面状态选择下一步动作。 当前页面信息 - URL: {url} - Title: {title} - 页面文本摘要: {snippet} 可用技能 {skill_docs} 输出必须为以下 JSON 格式之一 1. 调用工具{type: tool_call, tool: 工具名, params: {...}} 2. 完成任务{type: finish, message: 结论, passed: true/false} 注意事项 - 优先用文本或 role 定位元素。 - 如果页面还没加载完成可以调用 browser_wait 工具。 - 如果发现元素定位失败观察页面摘要后换一种定位方式。 - 不要臆测页面上不存在的元素。我把输出格式严格限制为 JSON然后代码里做解析。有一段时间我想让模型直接输出 Python 脚本后来放弃了因为解析脚本容易翻车。JSON 格式稳定可靠等模型选好动作后如果要生成完整脚本我会让它在最后输出一段代码块我再单独提取执行。4. 核心环节拆解脚本生成、执行与修复搭建好大体框架后真正的挑战在细节。这一节集中讲三个核心环节脚本怎么生成、反馈怎么构造、修复怎么做。4.1 脚本生成如何让 LLM 输出可执行的 Python 代码我这个项目里有两层脚本需求。第一层是智能体通过工具函数逐步操作比如先点击、再输入。第二层是智能体输出一段完整可运行的 Playwright 脚本文本方便保存成回归用例。完整脚本生成的实现思路是这样当任务比较简单时让智能体直接输出一个包含sync_playwright上下文的 Python 脚本。我要求它必须包含关键的三件套from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(https://example.com) page.get_by_text(登录).click() page.get_by_label(用户名).fill(testuser) page.get_by_label(密码).fill(pass123) page.get_by_role(button, name登录).click() page.wait_for_url(**/home) assert page.get_by_text(欢迎回来).is_visible() browser.close()提取脚本时我踩过一个坑模型经常在代码块外面加一堆解释文字比如以下是完整代码。如果用正则提取不当就会把解释文字一起交给 Python 执行。我的解决方案是在提示词里明确要求只输出代码块不要任何解释同时代码侧用正则匹配python ...块取第一个匹配结果。4.2 执行反馈浏览器状态如何喂给模型执行反馈的设计决定了智能体是盲跑还是看得见。我每一轮工具执行后会构造一段结构化反馈包含以下内容工具是否执行成功如果失败错误类型和信息比如 TimeoutError、NoSuchElement当前页面 URL 和标题页面可见文本的前 2000 字符可选页面截图的 base64下面是一个真实示例{ success: false, error: TimeoutError: locator.click: Timeout 10000ms exceeded, context: { url: https://example.com/login, title: 登录, snippet: 用户名\n密码\n登录\n记住我\n忘记密码 } }模型看到这份反馈后大概率会意识到自己之前定位的元素并不在当前页面中。比如它想直接点登录按钮但是页面还需要先勾选协议所以按钮被遮住了。接着模型会重新规划先勾选协议再点击登录。反馈里给截图的意义在于模型如果支持视觉输入可以直接看图。我实验下来纯文本反馈已经能覆盖大部分场景。视觉模型主要用在布局错乱、CSS 遮挡这类文本描述不清的情况。4.3 自愈机制元素定位失败的迭代修复自愈是整个智能体最有价值的地方。传统自动化脚本遇到元素定位失败就只能报错而智能体可以现场分析、现场修复。我设计了一个三层修复策略换定位方式同样的语义换成文本、角色、标签三种方式轮流尝试。换等待策略等待元素出现、等待网络空闲、固定等待。重新规划动作如果怎么定位都失败模型会重新阅读页面摘要判断是不是元素本身不存在或者需要先触发某个步骤。代码层面我在工具执行异常时会把错误信息连同当前页面快照写回消息列表。比如if not result.success: repair_message ( f工具 {tool} 执行失败: {result.message}\n f当前页面状态: {obs}\n 请检查失败原因然后重新选择一个动作。 ) history.append({role: user, content: repair_message})这个简单的操作让智能体从报错就结束变成了报错就重新开动。实际用例里很多失败都能通过增加等待或切换 selector 自动解决。我还加了一个计数器同一操作连续失败三次就终止并保存现场防止它在一个问题上无限循环。4.4 用例结果验证与报告自动化任务没有验证等于白做。我在提示词里要求智能体在 finish 时给出passed标记并且在工具层提供断言能力。断言工具我实现了三种常见类型assert_text_visible(text)页面是否包含指定文本assert_url_contains(part)当前 URL 是否包含指定片段assert_element_exists(selector, type)元素是否存在def assert_text_visible(self, text: str) - ActionResult: try: locator self.page.get_by_text(text, exactFalse) locator.first.wait_for(statevisible, timeout5000) return ActionResult(True, f文本 {text} 可见) except Exception: return ActionResult(False, f文本 {text} 不可见)最终报告我让智能体输出一个结构化摘要代码侧把它转成 Markdown## 执行结果通过/失败 - 任务登录后校验用户名 - 执行轮数6 - 关键步骤 1. 打开登录页 2. 输入用户名密码 3. 点击登录 4. 校验用户昵称显示为「张三」 - 失败原因无 - 调试截图debug/final_0.png有了报告这个 Agent 就不再是玩具可以直接嵌入到 CI 流程里跑完给开发留证据。5. 常见问题与排查技巧实录这个项目我从原型到能稳定跑大概花了两周其中一半时间在处理各种翻车现场。下面是一份踩坑实录按问题频率排序。5.1 Playwright 浏览器启动失败或运行超时这是环境问题的大头。如果你在容器或 CI 里跑经常遇到browserType.launch报错。常见原因有两个一是系统缺少动态库二是沙箱权限不足。解决命令如下playwright install-deps chromium如果非 root 环境launch 参数加一行browser self._pw.chromium.launch( headlessself.headless, args[--no-sandbox, --disable-setuid-sandbox] )另外首次打开页面很慢的时候goto超时很容易触发。我通常给goto设 15 秒然后额外等一次domcontentloaded而不是直接等load因为很多页面的load会被外部资源拖垮。等domcontentloaded之后页面主体大概率已经可以操作了。5.2 LLM 生成的脚本语法错误频繁这个问题我在早期版本几乎每轮都遇到。模型经常在代码块里混入说明文字或者缩进坏掉。我后来总结出一个相对稳的组合拳提示词里限制只输出 JSON不要解释如果要求输出完整脚本必须用python代码块包裹解析脚本时先找第一个 python 块再用textwrap.dedent清理缩进如果 Python 编译语法错误把错误信息重新喂给模型让它生成修复版比如 parse 函数我会这样写def extract_code(text: str) - str: pattern rpython\s*(.*?) m re.search(pattern, text, re.DOTALL) return m.group(1) if m else 如果提取失败就直接构造一条消息告诉模型没有找到 Python 代码块请重新输出。这一招让语法错误率降了很多。5.3 选择器不稳定频繁误判模型默认爱用 CSS 选择器尤其喜欢生成div div .app-root button这种层级表达式。页面一改动就崩。我的解决方案是在技能包里明确写上约束禁止使用绝对路径禁止使用会自动变化的 class/id。优先使用 role 和文本定位。但光靠约束还不够还要在工具层做兜底。我实现的_locate方法里如果模型传了 css 选择器我会先尝试解析失败之后就把错误返回给模型并在错误信息里附上当前页面的可用 role 和文本列表。这样模型就能拿着列表重新定位。另外我建议在工具调用中增加一个候选定位概念一次 call 可以传多个 selector例如同时传 text 和 role让工具层按顺序尝试。这能显著提高一次成功率。5.4 Agent 进入死循环这是所有 Agent 项目都会遇到的噩梦。典型表现是模型反反复复执行同一个点击失败再点击再失败直到耗尽最大迭代次数。我的对策是在主循环里做两个检查同动作检测对上一条工具调用记录如果工具名和参数完全相同记一次重复数重复超过 2 次就强制要求模型换个思路并提供页面快照。最大迭代数默认 8 轮超过则终止并截图保存现场。此外我还会在提示词里写一句如果发现连续两次动作相同且结果相同请立即停止并总结失败原因。这句话看起来很普通但对减少无意义的死循环非常有效。5.5 多页面复杂场景新标签页、弹窗处理登录成功后跳到新页、点击后打开新标签页这类场景最开始让我的 Agent 经常发呆。因为page对象还是原来的旧页面模型认为没跳转但其实已经开了新 tab。解决方式是在observe方法里检查多页面情况if len(self.page.context.pages) 1: # 自动切换到最新打开的页面 self.page self.page.context.pages[-1]这样智能体每次观察前都自动切到最新活跃的页面就不容易看错了。另一个坑是弹窗。很多系统登录前会弹一个用户协议如果不处理后续点击永远点到弹窗遮罩上。我的处理是让模型在生成脚本前先查看页面摘要摘要里包含弹窗文本模型会主动处理。另外我在点击工具里加了一个小逻辑点击前先按一下 Escape把可能存在的弹窗关掉。这个粗暴操作在很多场景下意外地有效。6. 个人体会与后续扩展项目做到这个程度最大的感触是技能包和工具封装比提示词更重要。一个设计良好的工具函数能让模型少犯一半错误一个结构清晰的反馈模板能让模型在失败后迅速找到方向。反过来提示词写得花里胡哨工具层一塌糊涂Agent 照样跑不起来。最后分享一个我自己很受用的扩展技巧。我把每次 Agent 执行过程中的 Playwright trace 都保存下来文件名带上任务 ID。这样当 Agent 最终失败时我可以打开 trace viewer 一帧一帧看它到底哪里点偏了。这个调试手段帮我发现了非常多隐含问题比只看日志直观太多了。如果你也在做类似的项目强烈建议一开始就把 trace 存下来不要等出了 bug 再补。