基于Playwright与LLM的浏览器AI Agent:从架构设计到工程实践

📅 2026/7/17 19:23:55 👁️ 阅读次数 📝 编程学习
基于Playwright与LLM的浏览器AI Agent:从架构设计到工程实践

1. 项目概述:当AI Agent学会“驾驭”浏览器

最近在折腾一个挺有意思的项目,核心就一句话:让AI Agent能像真人一样,在浏览器里“看”网页、“想”事情、“做”操作。这听起来有点像传统的网页自动化测试,比如Selenium或者Playwright,但内核完全不同。传统的自动化脚本是“死”的,写好了点哪里、输什么,它就按部就班执行。而基于浏览器的AI Agent,我们称之为“Harness Engineering”的实践,目标是打造一个“活”的智能体。它能够理解网页的语义结构,根据自然语言指令或既定目标,自主决策下一步该点击哪个按钮、在哪个输入框填写什么内容,甚至处理验证码、弹窗等意外情况。

这背后的驱动力,是AI Agent技术的快速演进。过去,我们谈论Agent,可能更多是在聊天机器人或者代码生成的语境里。但现在,Agent正在被赋予“手”和“眼睛”,去操作真实世界的数字界面——浏览器无疑是其中最重要、最通用的一个。无论是自动化处理繁琐的日常办公流程(比如每天登录十几个系统填报数据),还是构建一个能够自主搜集、分析网络信息的智能助手,甚至是开发一个能模拟用户行为进行复杂业务测试的AI测试员,这个方向都充满了想象力和实用价值。

简单来说,这个项目就是为AI Agent打造一套“神经系统”和“运动系统”,让它的“大脑”(通常是大型语言模型)发出的指令,能够精准地控制浏览器这个“身体”,完成从信息感知到动作执行的全链路闭环。接下来,我会详细拆解我是如何设计并实现这套系统的,包括核心架构、工具选型、实操步骤以及一路踩坑填坑的心得。

2. 核心架构与工具选型解析

要实现一个基于浏览器的AI Agent,不能只靠一个模型或一个库。它需要一个分层、协作的架构。我的设计主要分为三层:感知层、决策层和执行层

2.1 感知层:让AI“看见”网页

AI Agent要操作浏览器,第一步是必须理解当前页面上有什么。这里不能简单截个图丢给视觉模型(VLM),虽然多模态模型如GPT-4V能力很强,但成本高、速度慢,且无法精确获取可操作元素(如输入框的ID、按钮的XPath)的元数据。

我的方案是:DOM树解析 + 关键信息抽取。

  • 核心工具:Playwright/Puppeteer我最终选择了Playwright,而不是更老牌的Selenium。原因有几个:Playwright对现代Web框架(React, Vue, Angular)支持更好,能自动等待元素加载,内置了录制生成脚本的功能,并且其API设计更现代、一致。它提供了强大的页面内容获取能力。

  • 信息抽取策略:

    1. 获取纯净DOM与计算样式:通过Playwright获取页面的完整HTML。但原始DOM树非常冗杂,包含大量脚本、样式标签和不可见元素。我编写了一个过滤函数,只保留可见的、具有交互可能性的元素(如input,button,a,select等),并排除掉display: nonevisibility: hidden的元素。
    2. 构建结构化页面描述:对于过滤后的每个元素,提取其关键属性,形成一个结构化的JSON对象。这个对象通常包括:
      • tagName: 标签名(如 “button”, “input”)。
      • attributes: 关键属性(如id,name,class,type,placeholder,aria-label)。
      • text: 元素内的可见文本(修剪后)。
      • boundingBox: 元素在视口中的位置和大小(用于辅助描述或未来可能的视觉校验)。
      • xpath/selector: 该元素的唯一CSS选择器或XPath,这是后续执行操作的“坐标”。
    3. 生成自然语言描述:将上一步的结构化JSON,转换(或总结)成一段自然语言描述,提供给决策层的LLM。例如:“页面顶部有一个导航栏,包含‘首页’、‘产品’、‘关于我们’三个链接。页面中央是一个标题为‘用户登录’的卡片。卡片内有一个标签为‘用户名’的文本输入框(ID: username),一个标签为‘密码’的密码输入框(ID: password),以及一个文本为‘登录’的按钮。”

注意:这个描述生成环节至关重要。描述既要简洁,又要包含所有关键操作元素和其语义。我尝试过让LLM直接解析简化后的DOM JSON,但发现先将其转换为一段规整的文本描述,能显著提高后续决策的准确性和降低提示词工程的复杂度。

2.2 决策层:AI的“大脑”与“规划器”

这是整个系统的智能核心。它的任务是:接收用户的自然语言指令(如“帮我登录邮箱”)和感知层提供的页面描述,然后输出一个具体的、可执行的“动作序列”。

  • 核心工具:大型语言模型(LLM)我主要使用GPT-4 Turbo和Claude 3系列模型进行实验。它们的推理和规划能力足以应对大多数网页操作任务。对于成本敏感的场景,DeepSeek等开源模型经过精调后也是不错的选择。

  • 决策流程设计:

    1. 目标分解:LLM首先将高层级指令(如“查询北京明天天气”)分解为原子步骤(如:1. 导航到天气网站;2. 在搜索框输入“北京”;3. 点击搜索或确认按钮;4. 提取天气结果)。
    2. 上下文感知:将当前页面描述、历史操作步骤(记忆)以及当前指令一起构成提示词(Prompt),输入给LLM。
    3. 动作生成:要求LLM输出严格格式化的动作指令。我定义了一个简单的JSON动作协议:
      { "action": "click | fill | scroll | wait | extract_text | ...", "selector": "CSS选择器或XPath", "value": "仅fill动作需要,表示要输入的文字", "reasoning": "模型做出此决策的简要原因,用于调试和迭代" }
    4. 安全与验证:在复杂操作中,我会引入一个“验证步骤”。例如,在执行“点击删除按钮”前,让LLM再次确认当前页面上下文和元素文本,或者设计一个二次确认的子流程,防止危险操作。

2.3 执行层:精准的“手”

决策层输出动作指令后,需要可靠地执行。这就是Playwright再次登场的时候。

  • 动作映射:将标准的动作类型(click,fill,scroll等)映射到Playwright具体的API调用(如page.click(selector),page.fill(selector, value))。
  • 健壮性处理
    • 等待与重试:在执行任何操作前,确保目标元素已经处于可交互状态。Playwright内置的locator方法配合waitFor状态非常好用。我会为关键操作(如点击登录按钮后跳转)设置显式等待。
    • 异常捕获与恢复:网络延迟、元素加载失败、弹窗干扰都是家常便饭。执行层需要捕获这些异常,并将错误信息(如“元素未找到”、“操作超时”)连同当前页面快照和描述,反馈给决策层。决策层LLM可以根据这些新信息重新规划或尝试替代方案(例如,“登录按钮没找到,但发现一个‘Sign In’链接,是否点击?”)。
  • 状态管理:记录浏览器的状态,如当前URL、cookies、localStorage等。这在需要保持会话的多步骤任务中非常重要。

工具链总结

  • 浏览器自动化:Playwright(首选,功能全面,生态好)
  • AI决策引擎:OpenAI GPT-4/3.5-Turbo, Anthropic Claude 3,或本地部署的DeepSeek、Qwen等。
  • 应用框架:使用Python的asyncio进行异步编排,因为Playwright和LLM API调用都是IO密集型操作。FastAPI可以用于构建一个提供服务的Web接口。
  • 辅助工具BeautifulSoup4lxml用于辅助解析HTML,Pydantic用于验证动作JSON的格式。

3. 实操构建:从零实现一个登录自动化Agent

理论讲完了,我们动手搭一个最简单的例子:一个能自动登录某个论坛的AI Agent。假设我们不知道具体的登录页面结构。

3.1 环境准备与初始化

首先,安装核心依赖:

pip install playwright openai playwright install chromium # 安装浏览器驱动

初始化Playwright和浏览器上下文。我建议使用chromium.launch_persistent_context来保留用户数据目录,这样可以保存cookies,模拟真实用户会话。

import asyncio from playwright.async_api import async_playwright import openai import json # 初始化OpenAI客户端(示例,请使用你的API Key) openai.api_key = "your-api-key" async def init_browser(): async with async_playwright() as p: # 启动一个带持久化上下文的浏览器,方便管理cookies user_data_dir = "./user_data" browser = await p.chromium.launch_persistent_context( user_data_dir, headless=False, # 调试时设为False,可以看到浏览器操作 args=["--disable-blink-features=AutomationControlled"] # 尝试绕过一些简单的反自动化检测 ) page = await browser.new_page() return browser, page

3.2 实现感知函数

编写一个函数,用于获取当前页面的结构化描述。

async def get_page_description(page): # 执行JavaScript,获取页面中所有潜在可交互元素的信息 elements_info = await page.evaluate(""" () => { const interactives = ['input', 'button', 'a', 'select', 'textarea', '[role=\"button\"]', '[role=\"link\"]', '[contenteditable=\"true\"]']; const items = []; document.querySelectorAll('*').forEach(el => { // 基础过滤:可见且非禁用 const style = window.getComputedStyle(el); const isVisible = style.display !== 'none' && style.visibility !== 'hidden' && el.offsetWidth > 0 && el.offsetHeight > 0; const isInteractive = interactives.some(sel => el.matches(sel)); if (isVisible && isInteractive && !el.disabled) { // 生成一个相对稳定的选择器(这里简化处理,生产环境需更健壮) let selector = ''; if (el.id) { selector = `#${el.id}`; } else { // 尝试用tag和类名构造,这不是最优方法,仅作演示 selector = el.tagName.toLowerCase(); if (el.className && typeof el.className === 'string') { selector += '.' + el.className.trim().replace(/\\s+/g, '.'); } } items.push({ tag: el.tagName.toLowerCase(), text: (el.innerText || el.value || el.placeholder || '').slice(0, 100).trim(), selector: selector, type: el.type || '', placeholder: el.placeholder || '', name: el.name || '', id: el.id || '', }); } }); return items; } """) # 将元素信息组织成自然语言描述 description_parts = [] for elem in elements_info[:20]: # 限制数量,避免上下文过长 desc = f"一个{elem['tag']}元素" if elem['text']: desc += f",文本内容为“{elem['text']}”" if elem['placeholder']: desc += f",占位符为“{elem['placeholder']}”" if elem['type']: desc += f",类型是{elem['type']}" if elem['selector']: desc += f"(选择器参考:{elem['selector']})" description_parts.append(desc) full_description = "当前页面主要包含以下可交互元素:\\n" + "\\n".join(description_parts) return full_description, elements_info # 返回描述和原始元素信息

3.3 实现决策函数(调用LLM)

构建提示词,让LLM根据指令和页面描述决定下一步动作。

async def decide_next_action(user_goal, page_description, action_history): prompt = f""" 你是一个控制网页浏览器的AI助手。你的目标是:{user_goal} 以下是当前页面的描述: {page_description} 你最近执行的操作历史(用于参考): {json.dumps(action_history[-5:], indent=2, ensure_ascii=False)} 请根据目标和当前页面,决定下一个最合理的原子操作。 你只能输出一个JSON对象,格式必须严格如下: {{ "action": "click | fill | scroll_up | scroll_down | wait | navigate | finish", "selector": "一个CSS选择器字符串,用于定位元素。如果action是navigate或finish,此项可为空字符串。", "value": "仅当action为fill时需要,表示要输入的文本。否则为空字符串。", "reasoning": "用一句话解释为什么选择这个操作。" }} 动作说明: - click: 点击一个元素(按钮、链接等)。 - fill: 向输入框(input, textarea)填写文本。 - scroll_up/scroll_down: 向上/向下滚动页面。 - wait: 等待2秒(用于页面加载)。 - navigate: 导航到一个新URL(需要与user_goal结合,此处不常用)。 - finish: 任务已完成或无法继续。 请确保selector尽可能精确且稳定。优先使用id选择器(如#username),其次是name、class等。 现在,输出你的决策JSON: """ response = await openai.ChatCompletion.acreate( model="gpt-4-turbo-preview", # 或 "gpt-3.5-turbo" messages=[{"role": "user", "content": prompt}], temperature=0.1, # 低温度,使输出更确定 ) try: decision = json.loads(response.choices[0].message.content) # 简单验证决策格式 assert decision["action"] in ["click", "fill", "scroll_up", "scroll_down", "wait", "navigate", "finish"] return decision except (json.JSONDecodeError, AssertionError, KeyError) as e: print(f"LLM返回了无效的决策: {response.choices[0].message.content}") print(f"错误: {e}") # 返回一个安全的默认等待动作 return {"action": "wait", "selector": "", "value": "", "reasoning": "解析LLM响应失败,等待后重试。"}

3.4 实现执行函数与主循环

将决策转化为真实的浏览器操作。

async def execute_action(page, decision): action = decision["action"] selector = decision["selector"] value = decision["value"] print(f"[执行] {action} on '{selector}' with value '{value}'。 原因:{decision['reasoning']}") try: if action == "click": if selector: await page.click(selector) else: print("警告:click动作缺少selector。") elif action == "fill": if selector and value is not None: await page.fill(selector, value) else: print("警告:fill动作缺少selector或value。") elif action == "scroll_down": await page.mouse.wheel(0, 300) # 向下滚动 elif action == "scroll_up": await page.mouse.wheel(0, -300) # 向上滚动 elif action == "wait": await page.wait_for_timeout(2000) # 等待2秒 elif action == "navigate": if value: # 这里用value存储URL await page.goto(value) elif action == "finish": print("任务完成或终止。") return "FINISH" await page.wait_for_timeout(500) # 每个动作后稍作停顿 return "SUCCESS" except Exception as e: print(f"执行动作时出错: {e}") return f"ERROR: {str(e)}" async def main_agent_loop(user_goal, start_url): browser, page = await init_browser() await page.goto(start_url) action_history = [] max_steps = 15 # 防止无限循环 step = 0 while step < max_steps: step += 1 print(f"\\n--- 步骤 {step} ---") # 1. 感知 page_desc, _ = await get_page_description(page) print(f"页面感知完成。") # 2. 决策 decision = await decide_next_action(user_goal, page_desc, action_history) print(f"决策: {decision['action']}") # 3. 执行 result = await execute_action(page, decision) action_history.append({"step": step, "decision": decision, "result": result}) # 检查是否完成或出错 if result == "FINISH": print("Agent判定任务完成。") break if result.startswith("ERROR"): print(f"执行出错,尝试等待后继续...") await page.wait_for_timeout(3000) # 可以在这里加入更复杂的错误恢复逻辑 # 简单防呆:如果连续三个动作都是wait或scroll,可能陷入循环,主动停止 recent_actions = [h["decision"]["action"] for h in action_history[-3:]] if len(recent_actions) == 3 and all(a in ["wait", "scroll_up", "scroll_down"] for a in recent_actions): print("检测到可能的行为循环,主动终止。") break await browser.close() print("\\n操作历史:") for h in action_history: print(f" 步骤{h['step']}: {h['decision']['action']} - {h['result']}") # 运行Agent if __name__ == "__main__": asyncio.run(main_agent_loop( user_goal="登录这个网站,用户名是test_user,密码是demo123", start_url="https://example.com/login" # 替换成真实的登录页面URL ))

这个简单的Agent已经具备了感知-决策-执行的基本循环。你可以看到,它通过LLM理解页面,并尝试找到登录框和按钮来完成操作。

4. 高级技巧与工程化挑战

实现基础循环只是第一步。要让这个AI Agent真正可靠、实用,还需要解决一系列工程挑战。

4.1 提升感知精度与效率

  • 更智能的元素选择器生成:上面示例中的选择器生成非常简陋。在生产环境中,需要使用更稳定的算法来生成唯一的CSS选择器或XPath。可以考虑使用playwright自身的locatorAPI,或者开源库如selector-generator
  • 视觉信息辅助:对于纯CSS渲染的复杂组件(如Canvas图表)、验证码,或者当DOM无法准确反映视觉布局时,需要引入截图和视觉模型(VLM)。策略可以是:先用DOM定位大致区域,再对该区域截图,用VLM识别具体内容或状态。但这会大幅增加成本和延迟,需权衡使用。
  • 页面摘要与焦点识别:不是所有元素都需要上报给LLM。可以先用一个轻量级模型或规则,识别页面的主要功能区(如导航栏、主内容区、侧边栏、弹窗),并优先提供焦点区域内的元素描述,减少LLM的上下文负担。

4.2 优化决策提示词与规划能力

  • 分层任务规划:对于复杂任务(如“从A网站找到某产品价格,然后去B网站比价”),需要让LLM进行高层规划(High-level Planning),分解成多个子任务,每个子任务再进入上述的感知-决策-执行循环。这需要设计更复杂的提示词或使用智能体框架(如LangChain、AutoGen)的任务规划能力。
  • 记忆与状态管理:Agent需要记住它做过什么、看到了什么。简单的做法是把历史动作和关键的页面快照/描述作为上下文传递给LLM。更复杂的需要向量数据库来存储和检索长期记忆,避免上下文窗口爆炸。
  • 工具使用(Tool Use):将浏览器操作(click, fill)、信息查询(计算器、搜索)、条件判断等都抽象成“工具”,让LLM学会在合适的时候调用合适的工具。这符合当前AI Agent开发的主流范式(如OpenAI的Function Calling)。

4.3 增强执行的健壮性

  • 反自动化对抗:很多网站会检测Playwright/Selenium等自动化工具。对策包括:使用p.chromium.launch_persistent_context并加载真实用户配置文件;添加--disable-blink-features=AutomationControlled等启动参数;随机化操作间隔和鼠标移动轨迹;必要时使用更底层的CDP协议或结合浏览器指纹混淆技术。
  • 异常处理与恢复策略:建立一套分级恢复策略。元素找不到?可以尝试等待更长时间、滚动页面、使用更宽松的选择器,或者反馈给决策层“元素可能不存在,请重新评估”。遇到验证码?可以触发人工干预流程,或者集成付费的验证码识别服务。
  • 并行与异步控制:一个强大的Agent可能需要同时管理多个标签页或浏览器实例。需要妥善利用asyncio进行并发控制,管理好各页面之间的状态隔离和数据同步。

4.4 监控、评估与持续迭代

  • 可观测性:记录每个循环的页面截图、DOM描述、LLM的决策(包括其reasoning)、执行结果。这些日志对于调试和后续分析至关重要。
  • 成功率评估:定义清晰的任务成功标准(如最终页面是否包含特定关键词、是否跳转到预期URL),并自动化计算任务成功率。
  • 提示词迭代:根据失败案例,不断优化感知描述的方式和决策提示词。A/B测试不同的提示词模板对复杂任务成功率的影响。

5. 常见问题与实战避坑指南

在实际开发中,我遇到了不少坑,这里分享一些典型的解决方案。

问题1:LLM总是选错元素,比如该点“登录”却点了“注册”。

  • 原因:页面描述中,“登录”和“注册”两个按钮的文本很接近,LLM可能混淆。或者选择器不够唯一。
  • 解决
    1. 丰富元素描述:在生成描述时,不仅提供文本,还提供元素在页面上的相对位置(如“位于表单中央的蓝色按钮”)、邻近元素文本等上下文信息。
    2. 强化提示词:在决策提示词中强调“请仔细比较所有选项,选择最符合目标‘登录’的那一个”。可以要求LLM在reasoning中详细写出排除其他选项的原因。
    3. 后置校验:在执行关键动作前,增加一个“确认”步骤。例如,让LLM输出它认为要点击的元素的完整文本和选择器,然后用一个简单的规则或另一个轻量级模型调用进行校验。

问题2:页面动态加载,元素还没出现Agent就尝试操作,导致失败。

  • 原因:感知函数执行时,AJAX加载的内容可能尚未出现。
  • 解决
    1. 显式等待:在get_page_description函数中,针对特定元素或页面状态增加等待逻辑。例如,等待某个加载 spinner 消失,或者等待关键区域出现。
    2. 重试机制:在执行层execute_action中,用Playwright的locator.wait_for()代替简单的click()fill(),让Playwright自己处理等待逻辑。
    3. 超时与重试策略:为整个感知-决策-执行循环设置步骤级超时。如果某一步连续失败,可以回退到wait动作,然后重试感知。

问题3:任务有时会陷入无限循环(例如,不断在登录页面刷新)。

  • 原因:LLM的决策可能陷入局部最优或产生重复动作。
  • 解决
    1. 引入循环检测:像示例代码中那样,检查最近N个动作是否都是wait/scroll等无效操作。
    2. 丰富历史上下文:将更长的动作历史(包括成功和失败的)提供给LLM,帮助它意识到自己在重复。
    3. 设置最大步数:这是最后的安全网,必须设置。
    4. 定义终止状态:在提示词中明确定义finish动作触发的条件(如“当页面URL包含‘dashboard’时”、“当检测到‘登录成功’的文本时”),引导LLM主动结束任务。

问题4:运行速度慢,尤其是调用云端LLM API时。

  • 原因:每个决策步骤都需要网络往返,延迟很高。
  • 解决
    1. 本地轻量模型:对于简单的、模式固定的页面(如各种网站的登录),可以训练一个小的分类模型或使用规则引擎来决策,完全绕过大模型。将大模型只用于处理复杂、未知的页面。
    2. 批量预测:如果任务步骤是可预测的线性序列,可以尝试让LLM一次性规划出多个步骤,然后依次执行。但这降低了应对动态变化的灵活性。
    3. 缓存:对相同的页面描述和用户目标,缓存LLM的决策结果。

问题5:如何处理验证码、滑块等反机器人措施?

  • 现实建议:对于公开的、重要的服务,绕过验证码可能违反服务条款。在合规的前提下:
    1. 人工介入点:设计流程,当检测到验证码时,暂停Agent,通知人工处理,处理完毕后Agent再继续。
    2. 第三方服务:集成商业验证码识别API(如2Captcha、DeathByCaptcha),但这会产生额外成本,且成功率并非100%。
    3. 技术尝试(仅供学习研究):对于简单图形验证码,可以使用OCR库(如Tesseract)尝试识别;对于滑块,可以分析缺口位置并用Playwright模拟拖动轨迹。但这是一场持续的“军备竞赛”,且需谨慎评估法律风险。

构建一个健壮的、基于浏览器的AI Agent是一个系统工程,它融合了前端知识、自动化测试经验、提示词工程以及软件架构设计。从简单的脚本开始,逐步迭代,解决遇到的具体问题,是通往成功最实际的路径。这个领域正在飞速发展,新的框架和思路不断涌现,但万变不离其宗:让AI能可靠地感知、合理地思考、精准地执行