❝当 AI Agent 需要"看懂"网页时,会发生什么?❞
最近在研究 AI Agent 领域时,遇到了一个有趣的问题:「如何让 AI 理解并操作网页?」
传统的网页自动化工具(Selenium、Playwright)依赖开发者手动编写选择器,而 AI Agent 的愿景是让模型自己"看懂"页面、理解元素含义、做出操作决策。这中间的桥梁是什么?
答案是:「网页元素的视觉标注」。
今天分享我基于 Tarsier 构建网站自动化探索工具的完整实践过程,包括原理分析、代码实现,以及踩过的那些坑。
❝📦 「完整代码」:https://github.com/ai-claw/claweb❞
用过 Selenium 的同学都知道,写一个简单的自动化脚本需要:
# 传统方式:硬编码选择器
driver.find_element(By.CSS_SELECTOR, "#login-btn").click()
driver.find_element(By.XPATH, "//input[@name='username']").send_keys("test")
这种方式有几个明显的问题:
如果让 AI 来做这件事呢?
理想的流程是:
但这里有个关键问题:「AI 看到的是像素,而操作需要 DOM 元素」。
视觉大模型可以说"登录按钮在图片的右上角",但自动化框架需要的是 #login-btn 或具体的 XPath。
「如何在视觉理解和 DOM 操作之间建立映射?」
这就是 Tarsier 要解决的问题。
Tarsier(眼镜猴)是 Reworkd AI 开源的一个网页元素标注工具。它的核心思路非常巧妙:
「在网页截图上,给每个可交互元素添加可视化标签(如 [1]、[2]、[3]),同时维护标签 ID 到 XPath 的映射关系。」
这样,视觉大模型只需要说"点击 [5] 号元素",程序就能通过映射找到对应的 XPath 执行操作。
视觉截图(带标签) → 大模型理解 → 输出 [ID] → 查映射表 → 得到 XPath → 执行操作
Tarsier 的工作流程:
「Step 1:遍历 DOM 树,找出所有可交互元素」
# 可交互元素的判断标准
INTERACTIVE_ELEMENTS = [
'a', 'button', 'input', 'select', 'textarea',
'[role="button"]', '[role="link"]', '[onclick]'
]
「Step 2:为每个元素注入可视化标签」
// 在元素旁边插入标签
const tag = document.createElement('div');
tag.textContent = `[${id}]`;
tag.style.cssText = `
position: absolute;
background: #FF6B6B;
color: white;
font-size: 12px;
padding: 2px 4px;
border-radius: 3px;
z-index: 10000;
`;
element.parentNode.insertBefore(tag, element);
「Step 3:截图并返回映射关系」
# 返回结果
{
"screenshot": bytes, # 带标签的截图
"tag_to_xpath": {
1: "//button[@id='login']",
2: "//input[@name='username']",
3: "//a[@href='/register']",
# ...
}
}
Tarsier 对不同类型的元素使用不同的标签前缀:
前缀 | 元素类型 | 示例 |
|---|---|---|
[#ID] | 输入框 | 文本框、密码框、搜索框 |
[@ID] | 链接 | 超链接、锚点 |
[$ID] | 按钮/交互 | 按钮、下拉菜单、可点击元素 |
[%ID] | 图片 | 可交互的图片元素 |
这种分类让大模型更容易理解元素的用途。
pip install tarsier playwright openai
playwright install chromium
import asyncio
from playwright.async_api import async_playwright
from tarsier import Tarsier, GoogleVisionOCRService
asyncdef basic_demo():
asyncwith async_playwright() as p:
browser = await p.chromium.launch(headless=False)
page = await browser.new_page()
await page.goto("https://example.com")
# 标注页面
tarsier = Tarsier(GoogleVisionOCRService(api_key="..."))
screenshot, tag_to_xpath = await tarsier.page_to_image(page)
print(f"发现 {len(tag_to_xpath)} 个可交互元素")
await browser.close()
asyncio.run(basic_demo())
运行后,你会得到一张带有 [1]、[2]、[3] 等标签的截图,以及对应的 XPath 映射。
有了标注后的截图,就可以让大模型"看图操作"了:
from openai import OpenAI
class VisionLLMClient:
"""视觉大模型客户端"""
SYSTEM_PROMPT = """你是一个网页自动化助手。
页面上的可交互元素已被标记:[#ID] 输入框、[@ID] 链接、[$ID] 按钮
输出操作:CLICK [ID] / TYPE [ID] "文本" / SCROLL UP|DOWN / DONE
每次只输出一个操作。"""
def __init__(self, api_base, api_key, model):
self.client = OpenAI(base_url=api_base, api_key=api_key)
self.model = model
asyncdef analyze(self, screenshot: bytes, instruction: str) -> str:
"""分析截图,返回操作指令"""
# 完整实现见 GitHub 仓库
...
把上面的组件串起来,就是一个完整的 AI Web Agent:
class WebAgent:
"""AI 驱动的网页自动化代理"""
def __init__(self, llm_client, browser_manager, page_tagger):
self.llm = llm_client
self.browser = browser_manager
self.tagger = page_tagger
asyncdef execute_task(self, url: str, task: str):
"""执行用户任务"""
await self.browser.goto(url)
for step in range(20): # 最多 20 步
# 1. 标注页面元素
screenshot, tag_to_xpath = await self.tagger.tag_page(self.browser.page)
# 2. 让大模型分析并决策
action = await self.llm.analyze(screenshot, task)
# 3. 解析并执行动作(CLICK/TYPE/DONE 等)
if action == "DONE":
break
await self._execute_action(action, tag_to_xpath)
# 4. 清理标签,等待页面更新
await self.tagger.cleanup(self.browser.page)
为了更清晰地理解 Tarsier 驱动的 Web Agent 工作机制,本节详细介绍发送给 LLM 的请求结构、响应格式以及核心 Prompt 模板。
使用 OpenAI 兼容的多模态 API 格式:
{
"model": "gpt-4o",
"messages": [
{"role": "system", "content": "【系统提示词】"},
{"role": "user", "content": [
{"type": "text", "text": "【用户指令 + 页面信息】"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
]},
{"role": "assistant", "content": "CLICK [$5]"},
{"role": "user", "content": [...]},
...
],
"max_tokens": 500,
"temperature": 0.1
}
「关键点」:
messages 包含完整对话历史,实现多轮交互temperature 设置较低(0.1)以获得更确定性的输出「系统提示词(System Prompt)」:
SYSTEM_PROMPT = """你是一个专业的 Web 自动化助手。
页面上的可交互元素已被标记:
- [#ID]:文本输入框
- [@ID]:超链接
- [$ID]:按钮或其他可交互元素
- [%ID]:图片元素
可用的操作格式:
- CLICK [ID] - 点击指定元素
- TYPE [ID] "文本内容" - 在输入框中输入文本
- SCROLL UP/DOWN - 向上或向下滚动页面
- GOTO "URL" - 导航到指定URL
- WAIT 秒数 - 等待指定秒数
- PAUSE - 暂停执行(用于验证码等人工介入场景)
- DONE - 任务完成
重要规则:每次只输出一个操作命令。"""
LLM 返回的是简洁的「单行命令」,解析规则如下:
# 命令解析正则表达式
CLICK_PATTERN = re.compile(r"CLICK\s*\[[@#$%]?(\d+)\]", re.IGNORECASE)
TYPE_PATTERN = re.compile(r'TYPE\s*\[[@#$%]?(\d+)\]\s*["\'](.+?)["\']', re.IGNORECASE)
SCROLL_PATTERN = re.compile(r"SCROLL\s+(UP|DOWN)", re.IGNORECASE)
# ... 更多模式见 action_executor.py
「响应示例」:
点击 ID 为 5 的按钮除了执行任务,Web Agent 还可以自动探索网站。这需要更复杂的分析 Prompt:
ANALYZE_PAGE_PROMPT = """分析这个网页截图,返回以下 JSON 格式的信息:
{
"page_type": "login/home/list/detail/form/dashboard/unknown",
"page_description": "一句话描述这个页面的功能",
"has_sidebar_nav": true/false,
"sidebar_nav_items": ["侧边栏导航菜单项名称列表"],
"suggested_explorations": ["建议探索的操作"]
}
重要:只返回 JSON,不要有其他文字。"""
当系统积累了网站记忆后,可以智能规划任务路径:
PLAN_PROMPT = """你是一个网站操作专家。根据用户的任务和网站记忆,规划操作步骤。
## 网站信息
域名: {domain}
已知页面: {pages}
已知操作路径: {actions}
## 用户任务
{task}
请返回 JSON 格式的操作计划:
{{"can_plan": true/false, "confidence": 0.0-1.0, "plan": [...]}}"""
以下是一个"登录网站"任务的完整交互流程:
轮次 | 页面状态 | LLM 响应 |
|---|---|---|
1 | 登录页面,显示邮箱/密码输入框 | TYPE [#1] "admin@test.com" |
2 | 邮箱已填写 | TYPE [#2] "password123" |
3 | 密码已填写 | CLICK [$3] |
4 | 跳转到 Dashboard 页面 | DONE |
特性 | Tarsier Web Agent | Skyvern |
|---|---|---|
「输出格式」 | 单行命令 (CLICK [5]) | 结构化 JSON |
「批量操作」 | 每次一个动作 | 支持多个动作批量返回 |
「推理过程」 | 隐式(不输出) | 显式(包含 reasoning 字段) |
「元素标识」 | 可视化标签 [#ID][$ID] | 元素列表 + element_id |
「操作类型」 | 7 种基础命令 | 20+ 种细分 ActionType |
「适用场景」 | 简单任务执行 | 复杂企业级自动化 |
基础的"执行指令"模式需要人工告诉 AI 做什么。能不能让 AI 自己探索网站、发现功能、生成测试用例?
这就是我尝试的下一步:「网站自动化探索器」。
┌─────────────────────────────────────────────────────────┐
│ 网站自动化探索流程 │
├─────────────────────────────────────────────────────────┤
│ 1. 访问首页,截图分析页面类型和结构 │
│ ↓ │
│ 2. 识别导航菜单、CRUD 按钮等可探索元素 │
│ ↓ │
│ 3. 按优先级依次点击,记录页面变化 │
│ ↓ │
│ 4. 分析新页面,发现更多可探索元素 │
│ ↓ │
│ 5. 重复 3-4,直到覆盖所有功能 │
│ ↓ │
│ 6. 输出:页面结构图 + 操作记录 + 测试用例 │
└─────────────────────────────────────────────────────────┘
首先需要让 AI 理解页面的类型和结构:
class PageAnalyzer:
"""页面语义分析器"""
async def analyze_page(self, screenshot: bytes) -> Dict:
"""分析页面类型和结构,返回 page_type/description/nav_items 等"""
...
async def analyze_elements(self, screenshot: bytes, context: str) -> List:
"""分析页面元素,返回 tag_id/semantic_name/explore_priority 等"""
...
class SiteExplorer:
"""网站自动化探索器 - 广度优先探索网站功能"""
def __init__(self, config, db):
self.browser_manager = BrowserManager(config.browser)
self.page_tagger = PageTagger()
self.page_analyzer = PageAnalyzer(VisionLLMClient(config.llm))
self.db = db
self.visited_urls = set()
self.pending_items = [] # 待探索队列
asyncdef explore_site(self, start_url: str) -> Site:
"""探索整个网站"""
await self.browser_manager.goto(start_url)
# 第一阶段:分析首页,收集导航菜单
await self._analyze_and_collect_items()
# 第二阶段:广度优先探索
await self._explore_all_items()
return self.site
探索的结果需要持久化存储,便于后续生成测试用例:
# 数据模型设计
class Site:
"""网站"""
id: int
domain: str
name: str
class Page:
"""页面"""
id: int
site_id: int
url_pattern: str
page_type: str # login/list/detail/form/dashboard
semantic_description: str
key_features: str # JSON
class Element:
"""页面元素"""
id: int
page_id: int
element_type: str # button/link/input/nav_item
semantic_name: str
css_selector_hint: str
class Action:
"""操作记录"""
id: int
source_page_id: int
element_id: int
action_type: str # click/type/scroll
target_page_id: int
notes: str
这是最大的挑战。同一个页面,大模型可能返回完全不同的分析结果。
「应对策略」:重试机制 + 结果校验 + 降级方案
async def analyze_with_retry(self, screenshot, prompt, max_retries=3):
for i in range(max_retries):
result = await self.llm.analyze(screenshot, prompt)
if self._is_valid_response(result):
return result
return {"elements": [], "page_type": "unknown"} # 降级
Tarsier 返回的 tag_id 类型可能不一致:
# 兼容整数和字符串类型的 tag_id
def get_xpath(tag_to_xpath, tag_id):
return tag_to_xpath.get(tag_id) or tag_to_xpath.get(str(tag_id))
探索过程中页面状态不断变化,需要记录"来源页面"以便回退:
首页 → 点击菜单A → 列表页A → 点击新建 → 弹窗 → 关闭 → 列表页A
很多 CRUD 操作会打开弹窗而不是跳转页面,需要检测并处理:
# 支持多种 UI 框架的弹窗检测
modal_selectors = [".ant-modal", ".el-dialog", ".t-dialog", "[role='dialog']"]
max_pages = 50, max_actions = 200❝完整的问题处理方案请参考项目源码❞