浏览器自动化 Agent:用 LLM 操控网页交互
什么是浏览器自动化 Agent
浏览器自动化 Agent 是一种能够理解自然语言指令并在真实浏览器中执行操作的智能程序。它的核心思路是:用大语言模型(LLM)的推理能力决定该做什么,用浏览器自动化工具去真正完成点击、输入、提取数据等操作。你不再是写死步骤的脚本,而是告诉 Agent 目标,它自己规划、执行、并在遇到问题时自我纠正。
与传统的 Selenium 或 Playwright 脚本相比,基于 LLM 的浏览器 Agent 拥有这些优势:
- 理解模糊指令:例如“帮我把购物车里所有商品的总价算出来”,Agent 可以自主解析页面结构。
- 自适应网页变化:不再依赖脆弱的 CSS 选择器,而是通过视觉或语义理解定位元素。
- 完成多步复杂任务:能自动分解任务、处理中间页跳转、应对弹窗和异常。
本教程将带你从零构建一个基于 LangChain 和 Playwright 的浏览器自动化 Agent,让你在任何网页上实现智能交互。
核心工作原理
要让 LLM 操控浏览器,必须解决两个问题:如何让模型知道页面上有什么,以及如何让模型的操作落到真实的浏览器中。目前主流模式是工具调用(Function Calling)结合 DOM 或视觉感知。
1. 感知:将网页信息转化为文本描述
LLM 只能处理文本,因此我们需要把网页状态转化为模型可读的结构。常用两种方式:
- DOM 快照:提取页面的可交互元素(链接、按钮、输入框等),生成精简的 HTML 树或可访问性树(accessibility tree),并给每个元素编号。
- 截图 + 视觉模型:直接将页面截图传给多模态模型,让它输出要点击的坐标。这种方式对复杂渲染或 Canvas 页面更鲁棒,但成本较高。
本教程采用 DOM 快照方案,因为它免费、速度快,适合绝大多数网页。
2. 行动:将模型决策映射为浏览器操作
定义一组固定的操作函数(Tools),让 LLM 选择调用哪个函数,以及传入什么参数。典型的操作包括:
click(element_id: int)—— 点击某个已编号的元素type_text(element_id: int, text: str)—— 在输入框中输入文字scroll(direction: str, amount: int)—— 滚动页面navigate(url: str)—— 跳转到新网址extract_content() -> str—— 提取当前页面的文本内容,用于返回给用户finish(answer: str)—— 任务完成,返回最终结果
每次操作执行完后,浏览器状态发生变化,再次抓取新的 DOM 快照,连同历史操作一起反馈给 LLM,形成“观察 → 思考 → 行动 → 观察”的闭环。
环境准备
在开始编码之前,请确保你的开发环境满足以下要求。
安装 Python 依赖
pip install playwright langchain langchain-openai beautifulsoup4
安装 Playwright 浏览器
Playwright 需要下载浏览器内核,首次运行时执行:
playwright install chromium
准备大模型 API
你需要一个支持函数调用(Function Calling)的模型。推荐使用 OpenAI 的 gpt-4o 或 gpt-4o-mini。准备好 API Key,并设置为环境变量:
export OPENAI_API_KEY="your-api-key"
构建你的第一个浏览器 Agent
我们将从简单到复杂逐步实现。整体架构如下:
- 定义一个浏览器控制类,封装 Playwright 操作。
- 编写提取 DOM 快照的工具函数。
- 将工具绑定到 LLM,并创建 Agent 执行循环。
- 提供一条自然语言指令,让 Agent 自动执行。
第一步:封装 Playwright 浏览器控制
创建一个 browser_controller.py 文件,用于启动浏览器、导航和基本操作。
from playwright.sync_api import sync_playwright
class BrowserController:
def __init__(self, headless: bool = False):
self.playwright = sync_playwright().start()
self.browser = self.playwright.chromium.launch(headless=headless)
self.context = self.browser.new_context()
self.page = self.context.new_page()
def navigate(self, url: str):
self.page.goto(url, wait_until="networkidle")
return f"导航至 {url}"
def click(self, index: int):
"""根据 DOM 快照中的编号点击元素"""
# 将在 DOM 提取逻辑中通过 data-index 属性定位
element = self.page.locator(f'[data-index="{index}"]')
element.click()
self.page.wait_for_load_state("networkidle")
return f"点击了元素 #{index}"
def type_text(self, index: int, text: str):
element = self.page.locator(f'[data-index="{index}"]')
element.fill(text)
return f"在元素 #{index} 中输入 '{text}'"
def scroll(self, amount: int = 300):
self.page.evaluate(f"window.scrollBy(0, {amount})")
return f"向下滚动 {amount} 像素"
def close(self):
self.browser.close()
self.playwright.stop()
第二步:生成可索引的 DOM 快照
为了让 LLM 知道有哪些元素可交互,我们获取所有可操作节点,并为它们分配唯一的 data-index 属性。同时抽取出每个元素的标签、文本和关键属性,生成纯文本快照。
在 BrowserController 中添加方法:
from bs4 import BeautifulSoup
def get_dom_snapshot(self) -> str:
"""为页面注入索引,返回可读的 DOM 摘要"""
# 注入索引脚本:给所有可交互元素添加 data-index 属性
script = """
() => {
const elements = document.querySelectorAll(
'a, button, input, select, textarea, [onclick], [role="button"]'
);
elements.forEach((el, idx) => {
el.setAttribute('data-index', idx);
});
return elements.length;
}
"""
count = self.page.evaluate(script)
# 获取页面正文 HTML
html = self.page.content()
soup = BeautifulSoup(html, 'html.parser')
# 提取含有 data-index 的元素信息
lines = []
for el in soup.find_all(attrs={"data-index": True}):
index = el["data-index"]
tag = el.name
text = el.get_text(strip=True)[:80] # 截断过长的文本
attrs = []
if tag == "input":
attrs.append(f"placeholder='{el.get('placeholder', '')}'")
attrs.append(f"type='{el.get('type', '')}'")
attrs.append(f"value='{el.get('value', '')}'")
elif tag == "a":
attrs.append(f"href='{el.get('href', '')}'")
elif tag == "select":
options = [o.get_text(strip=True) for o in el.find_all('option')]
attrs.append(f"options={options}")
attr_str = " ".join(attrs)
line = f"[{index}] <{tag}> {text} {attr_str}"
lines.append(line)
return "\n".join(lines)
这样会生成类似如下的快照:
[0] <a> 首页 href='/'
[1] <input> placeholder='搜索产品' type='text' value=''
[2] <button> 搜索
[3] <a> 商品一:XX手机 href='/product/1'
[4] <button> 加入购物车
注意:在 click 和 type_text 操作之前,需确保页面已经执行过注入脚本(每次导航或点击后会自动执行,我们会在 Agent 循环中处理)。
第三步:定义 LLM 可调用的工具
使用 LangChain 的 tool 装饰器将控制器方法包装为工具。注意工具的参数和描述要足够详细,这样 LLM 才能正确调用。
from langchain_core.tools import tool
from typing import Optional
class BrowserAgent:
def __init__(self, headless: bool = False):
self.controller = BrowserController(headless=headless)
# 工具定义,会被绑定到 LLM
self.tools = [
self.navigate_to,
self.click_element,
self.type_into_element,
self.scroll_page,
self.extract_content,
self.finish_task
]
@tool
def navigate_to(self, url: str):
"""导航到指定的网址。应在任务开始时使用。"""
self.controller.navigate(url)
return f"页面已加载,当前URL: {url}"
@tool
def click_element(self, index: int):
"""点击 DOM 快照中编号为 index 的元素。只能点击快照中出现的数字。"""
self.controller.click(index)
return f"已点击元素 #{index}。"
@tool
def type_into_element(self, index: int, text: str):
"""在输入框(编号 index)中填入文本。之后通常需要点击提交按钮。"""
self.controller.type_text(index, text)
return f"已在 #{index} 中输入:{text}"
@tool
def scroll_page(self, amount: int = 500):
"""向下滚动页面指定像素,以查看未加载的内容。"""
self.controller.scroll(amount)
return f"滚动了 {amount} 像素。"
@tool
def extract_content(self):
"""提取当前页面的主要文本内容,用于回答用户问题或总结信息。"""
text = self.controller.page.inner_text("body")
return text[:3000] # 限制 Token 数量
@tool
def finish_task(self, answer: str):
"""任务完成时调用,返回最终答案给用户。"""
return answer
第四步:构建 Agent 循环
我们使用 langgraph 或手动编写 ReAct 风格的循环。这里展示一个简单的循环:每次迭代,获取最新 DOM 快照,发送给 LLM,LLM 决定调用哪个工具,执行工具,更新状态,直到 LLM 调用 finish_task。
from langchain_openai import ChatOpenAI
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
class BrowserAgent:
# ... (之前的 __init__ 和方法)
def run_task(self, instruction: str) -> str:
# 初始化 LLM(推荐支持工具调用的模型)
llm = ChatOpenAI(model="gpt-4o", temperature=0)
# 组装工具列表
tools = [
self.navigate_to,
self.click_element,
self.type_into_element,
self.scroll_page,
self.extract_content,
self.finish_task
]
# 编写系统提示词,指导模型如何使用工具和快照
prompt = ChatPromptTemplate.from_messages([
("system", """你是一个浏览器自动化助手。你可以通过工具操作浏览器。
执行任务前,应先使用 navigate_to 打开相关网站。
每次观察页面后,我会提供一份 DOM 快照,格式为 [编号] <标签> 文本 属性。
要点击元素,使用 click_element,传入编号。
要输入文本,使用 type_into_element。
如果需要查看更多内容,可以向下滚动。
当你有足够信息回答用户时,调用 finish_task 返回答案。
不要臆造信息,基于页面实际内容回答。
注意:每次工具调用完成,页面会重新生成快照供你观察。"""),
("human", "当前任务:{instruction}"),
MessagesPlaceholder(variable_name="agent_scratchpad"), # 存放历史工具调用
])
# 使用 create_tool_calling_agent 构建 Agent
agent = create_tool_calling_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True, max_iterations=15)
# 在执行之前,我们需要手动插入 DOM 快照?不,LangChain 的 AgentExecutor 自动处理。
# 但我们的工具需要快照,而且快照应该出现在每次工具执行后的观察里。
# 一种简单方式:在工具的返回值中包含快照信息。更优雅的做法是自定义中间步骤。
# 为简化,我们在每个工具的返回描述中追加 DOM 快照。
# 我们重写 agent_executor 或手动循环。这里我们采用修改工具,让它们在执行后自动返回快照+结果。
# 包装工具,使其返回 (结果 + 快照)
original_executor = agent_executor.invoke
# 更稳妥:手动循环
observation = self.controller.get_dom_snapshot()
chat_history = [("system", "请根据 DOM 快照执行任务。"),
("human", f"任务: {instruction}\n当前页面快照:\n{observation}")]
for _ in range(15):
response = llm.invoke(chat_history)
# 检查是否调用了 finish_task
if response.additional_kwargs.get("tool_calls"):
for tool_call in response.additional_kwargs["tool_calls"]:
tool_name = tool_call["function"]["name"]
args = tool_call["function"]["arguments"]
# 执行对应工具
result = getattr(self, tool_name)(**args)
chat_history.append(("assistant", response))
chat_history.append(("tool", result))
# 获取新快照
observation = self.controller.get_dom_snapshot()
chat_history.append(("human", f"当前页面快照:\n{observation}"))
else:
# 直接返回文本,视为最终答案
return response.content
return "达到最大循环次数,任务可能未完成。"
为保持示例简洁,上面的代码使用了伪循环。实际应用中推荐用 LangGraph 构建更健壮的 Agent 图,处理错误重试和中断。
第五步:执行一个完整示例
if __name__ == "__main__":
agent = BrowserAgent(headless=False) # 先使用有头模式方便观察
result = agent.run_task("打开 https://books.toscrape.com,找到第一本书的价格并告诉我。")
print("最终结果:", result)
agent.controller.close()
你将看到浏览器自动打开、解析页面、点击书本链接、提取价格,最后在控制台输出结果。这就是一个最简单的浏览器自动化 Agent。
增强 Agent 的实用技巧
1. 优化 DOM 快照
真实网页可能包含成千上万个元素,容易超出上下文长度。可以过滤掉不可见元素,只保留视口内元素,或使用重要性评分(例如优先保留带有唯一 ID、常见交互类的元素)。也可以在快照中附加元素的边界坐标,便于与截图结合。
2. 处理弹窗和页面变化
遇到 alert、confirm 或打开新标签页时,需要在 Playwright 上下文中注册事件处理器:
self.page.on("dialog", lambda dialog: dialog.accept())