基于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设计更现代、一致。它提供了强大的页面内容获取能力。
-
信息抽取策略:
- 获取纯净DOM与计算样式 :通过Playwright获取页面的完整HTML。但原始DOM树非常冗杂,包含大量脚本、样式标签和不可见元素。我编写了一个过滤函数,只保留可见的、具有交互可能性的元素(如
input,button,a,select等),并排除掉display: none或visibility: hidden的元素。 - 构建结构化页面描述 :对于过滤后的每个元素,提取其关键属性,形成一个结构化的JSON对象。这个对象通常包括:
tagName: 标签名(如 “button”, “input”)。attributes: 关键属性(如id,name,class,type,placeholder,aria-label)。text: 元素内的可见文本(修剪后)。boundingBox: 元素在视口中的位置和大小(用于辅助描述或未来可能的视觉校验)。xpath/selector: 该元素的唯一CSS选择器或XPath,这是后续执行操作的“坐标”。
- 生成自然语言描述 :将上一步的结构化JSON,转换(或总结)成一段自然语言描述,提供给决策层的LLM。例如:“页面顶部有一个导航栏,包含‘首页’、‘产品’、‘关于我们’三个链接。页面中央是一个标题为‘用户登录’的卡片。卡片内有一个标签为‘用户名’的文本输入框(ID: username),一个标签为‘密码’的密码输入框(ID: password),以及一个文本为‘登录’的按钮。”
- 获取纯净DOM与计算样式 :通过Playwright获取页面的完整HTML。但原始DOM树非常冗杂,包含大量脚本、样式标签和不可见元素。我编写了一个过滤函数,只保留可见的、具有交互可能性的元素(如
注意 :这个描述生成环节至关重要。描述既要简洁,又要包含所有关键操作元素和其语义。我尝试过让LLM直接解析简化后的DOM JSON,但发现先将其转换为一段规整的文本描述,能显著提高后续决策的准确性和降低提示词工程的复杂度。
2.2 决策层:AI的“大脑”与“规划器”
这是整个系统的智能核心。它的任务是:接收用户的自然语言指令(如“帮我登录邮箱”)和感知层提供的页面描述,然后输出一个具体的、可执行的“动作序列”。
-
核心工具:大型语言模型(LLM) 我主要使用GPT-4 Turbo和Claude 3系列模型进行实验。它们的推理和规划能力足以应对大多数网页操作任务。对于成本敏感的场景,DeepSeek等开源模型经过精调后也是不错的选择。
-
决策流程设计:
- 目标分解 :LLM首先将高层级指令(如“查询北京明天天气”)分解为原子步骤(如:1. 导航到天气网站;2. 在搜索框输入“北京”;3. 点击搜索或确认按钮;4. 提取天气结果)。
- 上下文感知 :将当前页面描述、历史操作步骤(记忆)以及当前指令一起构成提示词(Prompt),输入给LLM。
- 动作生成 :要求LLM输出严格格式化的动作指令。我定义了一个简单的JSON动作协议:
{ "action": "click | fill | scroll | wait | extract_text | ...", "selector": "CSS选择器或XPath", "value": "仅fill动作需要,表示要输入的文字", "reasoning": "模型做出此决策的简要原因,用于调试和迭代" } - 安全与验证 :在复杂操作中,我会引入一个“验证步骤”。例如,在执行“点击删除按钮”前,让LLM再次确认当前页面上下文和元素文本,或者设计一个二次确认的子流程,防止危险操作。
2.3 执行层:精准的“手”
决策层输出动作指令后,需要可靠地执行。这就是Playwright再次登场的时候。
- 动作映射 :将标准的动作类型(
click,fill,scroll等)映射到Playwright具体的API调用(如page.click(selector),page.fill(selector, value))。 - 健壮性处理 :
- 等待与重试 :在执行任何操作前,确保目标元素已经处于可交互状态。Playwright内置的
locator方法配合waitFor状态非常好用。我会为关键操作(如点击登录按钮后跳转)设置显式等待。 - 异常捕获与恢复 :网络延迟、元素加载失败、弹窗干扰都是家常便饭。执行层需要捕获这些异常,并将错误信息(如“元素未找到”、“操作超时”)连同当前页面快照和描述,反馈给决策层。决策层LLM可以根据这些新信息重新规划或尝试替代方案(例如,“登录按钮没找到,但发现一个‘Sign In’链接,是否点击?”)。
- 等待与重试 :在执行任何操作前,确保目标元素已经处于可交互状态。Playwright内置的
- 状态管理 :记录浏览器的状态,如当前URL、cookies、localStorage等。这在需要保持会话的多步骤任务中非常重要。
工具链总结 :
- 浏览器自动化 :Playwright(首选,功能全面,生态好)
- AI决策引擎 :OpenAI GPT-4/3.5-Turbo, Anthropic Claude 3,或本地部署的DeepSeek、Qwen等。
- 应用框架 :使用Python的
asyncio进行异步编排,因为Playwright和LLM API调用都是IO密集型操作。FastAPI可以用于构建一个提供服务的Web接口。 - 辅助工具 :
BeautifulSoup4或lxml用于辅助解析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可能混淆。或者选择器不够唯一。
- 解决 :
- 丰富元素描述 :在生成描述时,不仅提供文本,还提供元素在页面上的相对位置(如“位于表单中央的蓝色按钮”)、邻近元素文本等上下文信息。
- 强化提示词 :在决策提示词中强调“请仔细比较所有选项,选择最符合目标‘登录’的那一个”。可以要求LLM在
reasoning中详细写出排除其他选项的原因。 - 后置校验 :在执行关键动作前,增加一个“确认”步骤。例如,让LLM输出它认为要点击的元素的完整文本和选择器,然后用一个简单的规则或另一个轻量级模型调用进行校验。
问题2:页面动态加载,元素还没出现Agent就尝试操作,导致失败。
- 原因 :感知函数执行时,AJAX加载的内容可能尚未出现。
- 解决 :
- 显式等待 :在
get_page_description函数中,针对特定元素或页面状态增加等待逻辑。例如,等待某个加载 spinner 消失,或者等待关键区域出现。 - 重试机制 :在执行层
execute_action中,用Playwright的locator.wait_for()代替简单的click()或fill(),让Playwright自己处理等待逻辑。 - 超时与重试策略 :为整个感知-决策-执行循环设置步骤级超时。如果某一步连续失败,可以回退到
wait动作,然后重试感知。
- 显式等待 :在
问题3:任务有时会陷入无限循环(例如,不断在登录页面刷新)。
- 原因 :LLM的决策可能陷入局部最优或产生重复动作。
- 解决 :
- 引入循环检测 :像示例代码中那样,检查最近N个动作是否都是
wait/scroll等无效操作。 - 丰富历史上下文 :将更长的动作历史(包括成功和失败的)提供给LLM,帮助它意识到自己在重复。
- 设置最大步数 :这是最后的安全网,必须设置。
- 定义终止状态 :在提示词中明确定义
finish动作触发的条件(如“当页面URL包含‘dashboard’时”、“当检测到‘登录成功’的文本时”),引导LLM主动结束任务。
- 引入循环检测 :像示例代码中那样,检查最近N个动作是否都是
问题4:运行速度慢,尤其是调用云端LLM API时。
- 原因 :每个决策步骤都需要网络往返,延迟很高。
- 解决 :
- 本地轻量模型 :对于简单的、模式固定的页面(如各种网站的登录),可以训练一个小的分类模型或使用规则引擎来决策,完全绕过大模型。将大模型只用于处理复杂、未知的页面。
- 批量预测 :如果任务步骤是可预测的线性序列,可以尝试让LLM一次性规划出多个步骤,然后依次执行。但这降低了应对动态变化的灵活性。
- 缓存 :对相同的页面描述和用户目标,缓存LLM的决策结果。
问题5:如何处理验证码、滑块等反机器人措施?
- 现实建议 :对于公开的、重要的服务,绕过验证码可能违反服务条款。在合规的前提下:
- 人工介入点 :设计流程,当检测到验证码时,暂停Agent,通知人工处理,处理完毕后Agent再继续。
- 第三方服务 :集成商业验证码识别API(如2Captcha、DeathByCaptcha),但这会产生额外成本,且成功率并非100%。
- 技术尝试(仅供学习研究) :对于简单图形验证码,可以使用OCR库(如Tesseract)尝试识别;对于滑块,可以分析缺口位置并用Playwright模拟拖动轨迹。但这是一场持续的“军备竞赛”,且需谨慎评估法律风险。
构建一个健壮的、基于浏览器的AI Agent是一个系统工程,它融合了前端知识、自动化测试经验、提示词工程以及软件架构设计。从简单的脚本开始,逐步迭代,解决遇到的具体问题,是通往成功最实际的路径。这个领域正在飞速发展,新的框架和思路不断涌现,但万变不离其宗: 让AI能可靠地感知、合理地思考、精准地执行 。
更多推荐


所有评论(0)