浏览器Agent架构
要点
- 浏览器Agent 的本质是把「人操作浏览器完成网页任务」这个过程,变成程序可以自动执行的工具链
- 典型场景包括网页自动化、数据抓取和表单填写,区别在于操作复杂度不同
- 架构上把 Playwright 封装成一个工具层,对外暴露
navigate、click、type、screenshot等动作,LLM 通过 Tool Calling 来驱动 - 元素定位策略是稳定性关键——Playwright 的
getByRole和getByText比 CSS 选择器更能抵抗页面重构 - 等待和超时处理是最容易出问题的地方,需要用 Playwright 的自动等待机制来避免显式
sleep - 错误处理分三层:单次操作重试、页面状态恢复、任务级中断上报,每层职责不同
1. 什么是浏览器Agent
先看一个场景。你要从一个网站抓取商品的价格信息,页面是动态渲染的,接口做了鉴权,直接调 HTTP 拿不到数据。
也许你会先想到写爬虫脚本:打开浏览器、等页面加载完、找到价格元素、提取文本、关闭浏览器。这套流程人来做只要几分钟,但如果有一百个商品、每个商品要在不同页面点击、登录后再操作,手动做就很麻烦。
浏览器Agent 要解决的就是这类问题:让一个程序接管浏览器,按指令完成网页上的操作。它和传统爬虫的区别在于——传统爬虫靠预定义的规则(URL 列表 + 选择器)去抓数据,浏览器Agent 能像人一样理解页面,根据当前看到的内容决定下一步操作。
具体来说,浏览器Agent 有三个组成部分:
- LLM 作为决策层:接收当前页面状态(截图或 DOM 文本),决定下一步做什么
- 浏览器作为执行层:通过 Playwright 或 Puppeteer 操控页面——点击、输入、滚动、截图
- 工具定义作为协议层:把浏览器操作封装成 LLM 可以调用的 Tool,类似前几篇讲过的 Function Calling
2. 浏览器Agent 的使用场景
网页自动化
最常见的场景是网页自动化——那些没有开放 API、只能通过网页操作的系统。比如你需要每天登录一个内部后台,导出昨天的订单报表。整个流程是固定的:打开登录页、输入账号密码、点击登录、导航到报表页、选择日期、点击导出。
这类任务用浏览器Agent 来做,只需要把流程定义成一组 Tool,让 LLM 按步骤调用就行。
数据抓取
前面提到的商品价格抓取就是一个例子。相比用 CSS 选择器写死爬虫,Agent 的好处是页面结构变了也能适应——LLM 能重新定位到价格区域,不需要人工更新选择器。
但要注意代价:Agent 模式比传统爬虫慢很多,因为每一步都要经过 LLM 推理。如果页面结构稳定、量大且流程固定,传统爬虫仍然是更合适的选择。
表单填写
另一个常见场景是自动化填表。比如批量注册账号、填写问卷调查、提交审批流程。这些操作的共同点是:页面结构有规律,但表单字段多、步骤长。
Agent 可以接收一个结构化的数据(比如 JSON),然后逐字段填写、逐步骤提交。比纯脚本的好处是,遇到验证码提示或异常弹窗时,Agent 能识别并做相应处理。
3. 架构设计:把 Playwright 封装成工具层
浏览器Agent 的核心问题不是「怎么打开浏览器」——Playwright 几行代码就能做到。核心问题是:怎样把浏览器的操作能力封装成 LLM 可以理解和调用的工具。
先看一个最直接的封装方式:
// src/agents/browser-agent.ts
import { chromium, type Browser, type Page } from 'playwright'
// 工具定义:告诉 LLM 有哪些操作可用
type BrowserTool = {
name: string
description: string
parameters: Record<string, unknown>
}
const BROWSER_TOOLS: BrowserTool[] = [
{
name: 'navigate',
description: '打开指定 URL',
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: '要访问的网址' },
},
required: ['url'],
},
},
{
name: 'click',
description: '点击页面上的元素',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: 'CSS 选择器或文本描述' },
},
required: ['selector'],
},
},
{
name: 'type',
description: '在输入框中输入文本',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: '输入框的选择器' },
text: { type: 'string', description: '要输入的文本' },
},
required: ['selector', 'text'],
},
},
{
name: 'screenshot',
description: '截取当前页面截图',
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: '截图文件名' },
},
},
},
{
name: 'get_text',
description: '获取页面或元素的文本内容',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: '目标元素选择器,不传则返回整个页面文本' },
},
},
},
]
export class BrowserAgent {
private browser: Browser | null = null
private page: Page | null = null
async launch() {
this.browser = await chromium.launch({ headless: true })
this.page = await this.browser.newPage()
}
// 根据工具名分发到对应的浏览器操作
async executeTool(name: string, params: Record<string, string>): Promise<string> {
if (!this.page) throw new Error('浏览器未启动,请先调用 launch()')
switch (name) {
case 'navigate':
await this.page.goto(params.url, { waitUntil: 'domcontentloaded' })
return `已导航到 ${params.url}`
case 'click':
await this.page.click(params.selector)
return `已点击 ${params.selector}`
case 'type':
await this.page.fill(params.selector, params.text)
return `已在 ${params.selector} 输入 ${params.text}`
case 'screenshot': {
const name = params.name ?? `screenshot-${Date.now()}.png`
await this.page.screenshot({ path: name })
return `截图已保存为 ${name}`
}
case 'get_text': {
const selector = params.selector
const text = selector
? await this.page.textContent(selector)
: await this.page.innerText('body')
return text ?? '(空)'
}
default:
return `未知工具: ${name}`
}
}
async close() {
await this.browser?.close()
this.browser = null
this.page = null
}
}这个类的职责很明确:管理浏览器生命周期,提供一组固定的工具方法。LLM 通过 Tool Calling 发出指令,executeTool 负责解析并执行。
和 Hono 集成
在实际项目中,浏览器Agent 通常作为后端的一个路由或 Worker 任务来运行。用 Hono 暴露一个接口来触发 Agent 任务:
// src/routes/agent.ts
import { Hono } from 'hono'
import { BrowserAgent } from '../agents/browser-agent'
const agentApp = new Hono()
// POST /api/agent/run 接收任务指令
agentApp.post('/run', async (c) => {
const body = await c.req.json<{ task: string }>()
const { task } = body
if (!task) {
return c.json({ error: '缺少 task 参数' }, 400)
}
const agent = new BrowserAgent()
try {
await agent.launch()
// 这里会把 task 交给 LLM,由 LLM 决定调用哪些工具
// 工具调用结果最终汇总返回
const result = await runAgentLoop(agent, task)
return c.json({ success: true, result })
} catch (error) {
const message = error instanceof Error ? error.message : '未知错误'
return c.json({ success: false, error: message }, 500)
} finally {
await agent.close()
}
})
export { agentApp }接口只负责接收请求、创建 Agent 实例、执行任务、返回结果。浏览器操作的具体逻辑在 runAgentLoop 里。
4. 页面交互工具设计
前一节的 executeTool 里用的是 switch/case 分发。工具数量少的时候这样做没问题,工具多了以后会膨胀。
更稳妥的做法是把每个工具定义成独立的模块,用注册表来管理:
// src/agents/tools/base.ts
// 单个工具的形状
export interface PageTool {
name: string
description: string
parameters: Record<string, unknown>
execute(page: Page, params: Record<string, string>): Promise<string>
}// src/agents/tools/click.ts
import type { Page } from 'playwright'
import type { PageTool } from './base'
export const clickTool: PageTool = {
name: 'click',
description: '点击页面上的元素。支持 CSS 选择器或 Playwright 选择器语法',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: '目标元素的选择器' },
timeout: { type: 'number', description: '等待元素出现的超时时间(毫秒)' },
},
required: ['selector'],
},
async execute(page: Page, params: Record<string, string>) {
const timeout = Number(params.timeout) || 30000
await page.click(params.selector, { timeout })
return `已点击: ${params.selector}`
},
}// src/agents/tools/type.ts
import type { Page } from 'playwright'
import type { PageTool } from './base'
export const typeTool: PageTool = {
name: 'type',
description: '在输入框中输入文本。会自动清空原有内容',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: '输入框的选择器' },
text: { type: 'string', description: '要输入的文本内容' },
},
required: ['selector', 'text'],
},
async execute(page: Page, params: Record<string, string>) {
// fill 会先清空再输入,比逐字 type 更稳定
await page.fill(params.selector, params.text)
return `已在 ${params.selector} 输入: ${params.text}`
},
}// src/agents/tools/tool-registry.ts
import type { PageTool } from './base'
import { clickTool } from './click'
import { typeTool } from './type'
// 工具注册表:集中管理所有可用工具
export class ToolRegistry {
private tools = new Map<string, PageTool>()
register(tool: PageTool) {
this.tools.set(tool.name, tool)
}
// 生成给 LLM 的工具定义列表
getToolDefinitions() {
return Array.from(this.tools.values()).map((tool) => ({
name: tool.name,
description: tool.description,
parameters: tool.parameters,
}))
}
// 根据工具名执行操作
async execute(name: string, page: import('playwright').Page, params: Record<string, string>) {
const tool = this.tools.get(name)
if (!tool) {
throw new Error(`未找到工具: ${name},可用工具: ${Array.from(this.tools.keys()).join(', ')}`)
}
return tool.execute(page, params)
}
}
// 默认注册所有内置工具
export function createDefaultRegistry(): ToolRegistry {
const registry = new ToolRegistry()
registry.register(clickTool)
registry.register(typeTool)
// 其他工具也在这里注册
return registry
}这样做的好处是:新增一个工具只需要写一个文件、实现 PageTool 接口、在 createDefaultRegistry 里注册一行。不需要改 switch/case,不需要改其他工具。
5. 元素定位策略
浏览器Agent 最容易出的问题不是「不会操作」,而是「找不到元素」。页面结构和代码里的选择器对不上,整个操作就断了。
Playwright 提供了几种定位方式,稳定性差异很大:
| 定位方式 | 示例 | 稳定性 | 说明 |
|---|---|---|---|
getByRole | getByRole('button', { name: '提交' }) | 高 | 基于无障碍角色,不受 DOM 结构变化影响 |
getByText | getByText('登录') | 高 | 基于可见文本,直观且抗重构 |
getByLabel | getByLabel('用户名') | 高 | 通过 label 关联输入框,语义清晰 |
| CSS 选择器 | #submit-btn、.form-input | 低 | 依赖 class 和 id,页面改版就容易失效 |
| XPath | //div[@class='btn'] | 最低 | 路径脆弱,DOM 层级变动就会断掉 |
在 Agent 的工具设计中,优先使用语义化定位:
// src/agents/tools/smart-click.ts
import type { Page, Locator } from 'playwright'
import type { PageTool } from './base'
// 按优先级尝试多种定位方式,找到第一个匹配的元素就操作
export const smartClickTool: PageTool = {
name: 'smart_click',
description: '智能点击。按顺序尝试:按钮文本 → 链接文本 → role 匹配 → CSS 选择器',
parameters: {
type: 'object',
properties: {
target: { type: 'string', description: '要点击的元素,可以是文本描述或选择器' },
},
required: ['target'],
},
async execute(page: Page, params: Record<string, string>) {
const { target } = params
// 按优先级尝试不同的定位策略
const strategies: Array<{ name: string; locator: Locator }> = [
{ name: '按钮文本', locator: page.getByRole('button', { name: target }) },
{ name: '链接文本', locator: page.getByRole('link', { name: target }) },
{ name: '可见文本', locator: page.getByText(target) },
{ name: 'CSS 选择器', locator: page.locator(target) },
]
for (const strategy of strategies) {
const count = await strategy.locator.count().catch(() => 0)
if (count > 0) {
await strategy.locator.first().click()
return `通过${strategy.name}定位并点击: ${target}`
}
}
throw new Error(`无法定位元素: ${target},尝试了所有定位策略均未找到匹配`)
},
}这段代码的思路是:先试语义化定位,再降级到 CSS 选择器。LLM 传进来的 target 可以是「提交」这样的自然语言描述,也可以是 #submit-btn 这样的选择器。定位策略会自动处理。
另一个实用技巧是在操作前先截图,把截图发给 LLM 做视觉确认。尤其是点击操作,确认点的是哪个元素可以避免很多误操作:
// 点击前先截图确认
async function confirmAndClick(page: Page, locator: Locator, target: string) {
// 滚动到元素可见
await locator.scrollIntoViewIfNeeded()
// 截图给 LLM 确认
const screenshot = await page.screenshot()
// 这里把 screenshot 发给 LLM,等 LLM 确认后再点击
// 省略 LLM 调用逻辑,直接点击
await locator.click()
}6. 等待与超时处理
浏览器操作是异步的,页面加载、接口请求、DOM 渲染都需要时间。处理不好等待逻辑,Agent 就会在页面还没加载完时去点按钮,然后报错。
新手常犯的错误是用固定延时:
// 不要这样做
await page.click('#submit')
await page.waitForTimeout(3000) // 等 3 秒
await page.click('#next-page')waitForTimeout 的问题在于:如果网络快,3 秒是浪费;如果网络慢,3 秒可能不够。更稳妥的做法是用 Playwright 的自动等待机制:
// 推荐:Playwright 的 click 会自动等待元素可见、可点击
await page.click('#submit')
// 等待下一页的按钮出现并可见,再自动点击
await page.click('#next-page', { state: 'visible' })Playwright 的 click、fill、waitForSelector 等方法内置了自动等待。它们会等到元素满足操作条件(可见、稳定、未被遮挡)才执行,超时默认 30 秒。
但在 Agent 场景中,有些情况需要显式等待特定的页面状态:
// src/agents/tools/wait-for.ts
import type { Page } from 'playwright'
import type { PageTool } from './base'
export const waitForTool: PageTool = {
name: 'wait_for',
description: '等待页面上出现指定的文本或元素',
parameters: {
type: 'object',
properties: {
text: { type: 'string', description: '等待出现的文本内容' },
selector: { type: 'string', description: '等待出现的元素选择器' },
timeout: { type: 'number', description: '超时时间(毫秒),默认 30000' },
},
},
async execute(page: Page, params: Record<string, string>) {
const timeout = Number(params.timeout) || 30000
if (params.text) {
// 等待文本出现在页面上
await page.getByText(params.text).first().waitFor({ state: 'visible', timeout })
return `已检测到文本: ${params.text}`
}
if (params.selector) {
await page.locator(params.selector).first().waitFor({ state: 'visible', timeout })
return `已检测到元素: ${params.selector}`
}
throw new Error('需要提供 text 或 selector 参数')
},
}另一个常见的等待场景是「等接口返回」。页面点击一个按钮后,数据要从后端加载。可以等某个网络请求完成:
// 等待特定的网络请求完成后再继续
const [response] = await Promise.all([
page.waitForResponse((res) => res.url().includes('/api/data') && res.status() === 200),
page.click('#load-more'),
])对于 Agent 来说,这种细粒度的等待可以封装成一个 wait_for_network 工具,让 LLM 在需要时主动调用。
7. 错误处理
浏览器Agent 的错误处理分三层,每层的职责不同:
单次操作重试
网络波动或页面渲染慢,偶尔会导致一次操作失败。在工具执行层做重试,不轻易把错误抛给 LLM:
// src/agents/tools/retry.ts
import type { Page } from 'playwright'
import type { PageTool } from './base'
// 把任意工具包装成带重试的版本
export function withRetry(tool: PageTool, maxRetries = 3): PageTool {
return {
...tool,
async execute(page: Page, params: Record<string, string>) {
let lastError: Error | null = null
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await tool.execute(page, params)
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
// 重试前等一下,给页面时间恢复
await page.waitForTimeout(1000 * attempt)
}
}
throw new Error(
`工具 ${tool.name} 重试 ${maxRetries} 次后仍然失败: ${lastError?.message}`
)
},
}
}页面状态恢复
浏览器操作可能把页面搞到一个异常状态——弹出了意料之外的对话框、跳转到了错误的页面、或者 session 过期了。需要在 Agent 循环层面检测并恢复:
// src/agents/agent-loop.ts
import type { Page } from 'playwright'
import type { ToolRegistry } from './tools/tool-registry'
type AgentMessage = {
role: 'system' | 'user' | 'assistant'
content: string
}
// Agent 主循环:LLM 决策 → 工具执行 → 反馈给 LLM → 循环
export async function runAgentLoop(
agent: { page: Page },
registry: ToolRegistry,
task: string,
llmCall: (messages: AgentMessage[]) => Promise<{ toolCalls: Array<{ name: string; params: Record<string, string> }> | null; response: string }>
): Promise<string> {
const messages: AgentMessage[] = [
{
role: 'system',
content: `你是一个浏览器操作助手。你的任务是: ${task}。
可用工具: ${JSON.stringify(registry.getToolDefinitions())}。
每次只调用一个工具,根据返回结果决定下一步操作。任务完成后直接返回最终结果。`,
},
]
const maxSteps = 20 // 防止无限循环
for (let step = 0; step < maxSteps; step++) {
const { toolCalls, response } = await llmCall(messages)
// LLM 认为任务完成,不再调用工具
if (!toolCalls || toolCalls.length === 0) {
return response
}
// 执行工具并把结果反馈给 LLM
for (const call of toolCalls) {
try {
const result = await registry.execute(call.name, agent.page, call.params)
messages.push({ role: 'assistant', content: `调用工具 ${call.name}: ${JSON.stringify(call.params)}` })
messages.push({ role: 'user', content: `工具返回: ${result}` })
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
// 工具失败也反馈给 LLM,让它调整策略
messages.push({ role: 'assistant', content: `调用工具 ${call.name}: ${JSON.stringify(call.params)}` })
messages.push({ role: 'user', content: `工具执行失败: ${errorMsg}。请尝试其他方式完成操作。` })
}
}
}
throw new Error(`Agent 执行超过 ${maxSteps} 步,可能陷入循环`)
}这段代码里有一个关键设计:工具执行失败时,不是直接抛异常中断任务,而是把错误信息反馈给 LLM。LLM 可以据此调整策略——比如换个选择器、换个操作方式、或者直接报告无法完成。
任务级中断
如果任务本身无法完成(比如目标网站挂了、需要人工验证码),需要在合理的步骤数内终止,避免无限循环消耗资源。上面代码里的 maxSteps 就是在做这件事。
更完善的任务级中断还需要检查:
- 页面是否跳转到了完全无关的域名
- 是否连续多次工具调用都失败
- 任务执行时间是否超过预算
总结
浏览器Agent 架构的核心是把浏览器操作封装成 LLM 可调用的工具,让 LLM 来决策、Playwright 来执行。
回顾这篇的要点:
- 浏览器Agent 由三层组成:LLM 决策层、Playwright 执行层、工具定义协议层
- 常见场景包括网页自动化、数据抓取、表单填写,复杂度和操作频率不同
- 工具层用注册表模式管理,新增工具只需实现接口并注册,不改已有代码
- 元素定位优先用 Playwright 的语义化 API(
getByRole、getByText),比 CSS 选择器更抗页面变化 - 等待机制用 Playwright 内置的自动等待,避免
waitForTimeout硬编码 - 错误处理分三层:工具级重试、Agent 循环反馈、任务级中断
下一篇会在这套架构基础上加入视觉理解能力——把页面截图发给多模态 LLM,让 Agent 能「看到」页面内容来做决策。
所属分组
15-Agent与工具调用