browser-use 怎么让 LLM "看懂"网页------86k⭐ 浏览器 Agent 的 DOM 处理管线拆解
一个 LLM 看到的网页和你看到的完全不同。你看到按钮、输入框、链接,LLM 看到的是一大坨 HTML。browser-use 在中间做了一件事:把复杂的 DOM 树压缩成 LLM 能理解的精简文本,同时保留足够的交互信息让 Agent 知道该点哪里。
这个项目现在 86k⭐,是 GitHub 上最热的浏览器 Agent 框架。我花了两天读它的源码,发现最有意思的不是 Agent 循环本身,而是 DOM 处理管线------它决定了 LLM 到底能"看到"多少有效信息。
DOM 序列化:从几万个节点到几千字的文本
打开一个普通的电商页面,DOM 节点数量轻松过万。把整棵 DOM 树塞给 LLM?Token 爆炸,而且 99% 的节点对交互来说毫无意义。browser-use 的做法是四步流水线:
css
原始 DOM → 简化树 → Paint Order 过滤 → 树优化 → 标注可交互元素
核心代码在 browser_use/dom/serializer/serializer.py 的 serialize_accessible_elements 方法里:
python
def serialize_accessible_elements(self) -> tuple[SerializedDOMState, dict[str, float]]:
# 第一步:创建简化树(包含可交互元素检测)
simplified_tree = self._create_simplified_tree(self.root_node)
# 第二步:按绘制顺序过滤被遮挡的元素
if self.paint_order_filtering and simplified_tree:
PaintOrderRemover(simplified_tree).calculate_paint_order()
# 第三步:优化树结构(删除多余的父节点)
optimized_tree = self._optimize_tree(simplified_tree)
# 第四步:给可交互元素分配索引号
self._assign_interactive_indices_and_mark_new_nodes(filtered_tree)
return SerializedDOMState(_root=filtered_tree, selector_map=self._selector_map)
每一步都在做减法。最终输出的文本可能只有原始 DOM 的 5%-10%,但保留了所有对 Agent 有用的信息。
可交互元素检测:不止是 button 和 input
这是我觉得 browser-use 做得最细致的地方。判断一个元素是否"可交互"不只是看标签名。ClickableElementDetector.is_interactive() 方法用了一套层层递进的判断逻辑:
python
class ClickableElementDetector:
@staticmethod
def is_interactive(node: EnhancedDOMTreeNode) -> bool:
# 1. 跳过非元素节点、html 和 body
if node.tag_name in {'html', 'body'}:
return False
# 2. 检查 JavaScript 点击事件监听器(通过 CDP 检测)
# 这个能覆盖 Vue @click、React onClick、Angular (click)
if node.has_js_click_listener:
return True
# 3. iframe 大小检查:只标记大于 100x100px 的 iframe
if node.tag_name.upper() == 'IFRAME':
if width > 100 and height > 100:
return True
# 4. label 元素的特殊处理
# 有 for 属性的 label 跳过(避免双重激活)
# 包裹了 input/select/textarea 的 label 标记为可交互
if node.tag_name == 'label':
if node.attributes and node.attributes.get('for'):
return False
if has_form_control_descendant(node, max_depth=2):
return True
# 5. 搜索相关元素检测
search_indicators = {
'search', 'magnify', 'glass', 'lookup', 'find',
'query', 'search-icon', 'search-btn'
}
# 检查 class、id、data-* 属性
# 6. 无障碍属性检查
# focusable、editable、settable → 可交互
# checked、expanded、pressed、selected → 可交互
# disabled、hidden → 不可交互
# 7. 原生交互标签
interactive_tags = {
'button', 'input', 'select', 'textarea',
'a', 'details', 'summary', 'option'
}
这里有个容易忽略的细节:has_js_click_listener 不是通过解析 HTML 拿到的,是通过 Chrome DevTools Protocol (CDP) 的事件监听器检测获取的。也就是说,哪怕一个 <div> 没有任何 HTML 属性表明它可点击,只要 JavaScript 给它绑了 click 事件,browser-use 也能检测到。
这比传统的"按标签名判断"靠谱很多。真实世界的网页里,大量的可点击元素就是普通的 div 和 span,只靠标签名判断会漏掉一大半。
视口阈值和可见性判断:不发送 LLM 看不到的东西
browser-use 不会把整个页面的所有元素都发给 LLM。它有一个 viewport_threshold 参数,默认 1000 像素------只有在视口范围上下 1000px 内的元素才被标记为"可见":
python
class DomService:
def __init__(self, browser_session, viewport_threshold=1000):
self.viewport_threshold = viewport_threshold
可见性判断不只看位置,还检查 CSS 样式:
python
@classmethod
def is_element_visible_according_to_all_parents(
cls, node, html_frames, viewport_threshold=1000
):
computed_styles = node.snapshot_node.computed_styles or {}
display = computed_styles.get('display', '').lower()
visibility = computed_styles.get('visibility', '').lower()
opacity = computed_styles.get('opacity', '1')
# display:none、visibility:hidden、opacity:0 都算不可见
css_hidden = display == 'none' or visibility == 'hidden'
css_hidden = css_hidden or float(opacity) <= 0
对于 iframe 中超出视口的隐藏元素,browser-use 还会收集它们的信息作为"提示"发给 LLM,告诉 Agent:"往下滚 3 页有个搜索框"。这个设计让 Agent 知道该滚动页面去找目标元素:
python
def _count_hidden_elements_in_iframes(self, node):
# 收集隐藏的可交互元素信息
hidden.append({
'tag': subtree_root.tag_name or '?',
'text': text or '(no label)',
'pages': pages_down, # 距离当前视口多少页
})
# 限制最多 10 个,避免上下文膨胀
current_node.hidden_elements_info = hidden[:10]
Paint Order 过滤:被遮挡的按钮不要发给 LLM
这个功能解决了一个实际问题:网页上经常有元素互相覆盖。一个对话框弹出来,后面的按钮被挡住了,但 DOM 里它还是存在的。如果把被遮挡的元素也发给 LLM,Agent 可能会尝试点击一个实际上点不到的按钮。
PaintOrderRemover 会根据元素的绘制顺序(z-index、position 等)计算哪些元素被覆盖了,然后从序列化结果中移除:
python
# 序列化时启用 paint order 过滤
if self.paint_order_filtering and simplified_tree:
PaintOrderRemover(simplified_tree).calculate_paint_order()
这个过滤在实际使用中很有价值。我测试过几个场景:登录弹窗、Cookie 同意横幅、聊天窗口------这些浮层下面的元素都被正确过滤掉了。没有这个,Agent 经常会尝试点击被遮挡的元素然后陷入重试循环。
Agent 循环:think → evaluate → act
DOM 处理管线解决了"让 LLM 看到什么"的问题,Agent 循环解决了"让 LLM 做什么"的问题。browser-use 的 Agent 每一步都走这个流程:
python
class Agent:
async def step(self):
# 1. 获取浏览器当前状态(DOM + 截图)
browser_state = await self.browser_session.get_state()
# 2. 构建消息(系统提示 + 当前状态 + 历史记录)
messages = self.message_manager.build_messages(browser_state)
# 3. 调用 LLM 获取决策
response = await self.llm.invoke(messages)
# 返回结构化输出:
# - current_state.thinking: 思考过程
# - current_state.evaluation_previous_goal: 上一步评估
# - current_state.next_goal: 下一步目标
# - actions: 要执行的动作列表
# 4. 执行动作
for action in response.actions:
result = await self.tools.execute(action)
有几个值得注意的设计决策。
每步最多执行 5 个动作。 max_actions_per_step=5 控制 LLM 一次最多输出 5 个动作。太少效率低,太多容易出错------因为后面的动作依赖前面的执行结果,LLM 在预测第 6 个动作时很可能已经偏了。
循环检测。 loop_detection_window=20 会检查最近 20 步是否出现重复操作。实际用下来,Agent 最常见的死循环是反复点击同一个按钮------这个检测能在第 3-4 次重复时就介入。
自动重规划。 planning_replan_on_stall=3 表示连续 3 步没有进展就重新规划。不是重启整个任务,而是重新评估当前状态,选择不同的行动路径。
Vision 模式:截图 + DOM 双通道
browser-use 支持三种 vision 模式:True(每步都截图)、False(纯 DOM)、"auto"(按需截图)。
实际效果差异很大。我用同一个任务(在 Amazon 上搜索商品并加入购物车)测试了纯 DOM 和 Vision 模式:
- 纯 DOM 模式:8 步完成,有一次点错了下拉菜单的选项
- Vision 模式:6 步完成,视觉信息帮助 LLM 更准确地理解页面布局
代码里有个有意思的细节------针对 Claude Sonnet 系列模型自动调整截图分辨率:
python
# Auto-configure llm_screenshot_size for Claude Sonnet models
if llm_screenshot_size is None:
model_name = getattr(llm, 'model', '')
if isinstance(model_name, str) and model_name.startswith('claude-sonnet'):
llm_screenshot_size = (1400, 850)
Claude Sonnet 在 1400x850 这个分辨率下效果最好,分辨率太高反而会因为 token 消耗增加而降低效率。
实际上手:20 行代码跑一个浏览器 Agent
说了这么多原理,跑起来其实很简单:
python
from browser_use import Agent, Browser, ChatBrowserUse
import asyncio
async def main():
browser = Browser()
agent = Agent(
task="去掘金搜索 browser-use,找到星数最高的相关文章,告诉我标题和作者",
llm=ChatBrowserUse(), # 也可以用 ChatGoogle 或 ChatAnthropic
browser=browser,
use_vision="auto",
max_actions_per_step=3,
)
result = await agent.run()
print(result)
asyncio.run(main())
安装也就两条命令:
bash
uv init && uv add browser-use && uv sync
uvx browser-use install # 安装 Chromium(如果没有的话)
三个踩坑记录
1. 元素定位偏移。 高 DPI 屏幕上,browser-use 早期版本的坐标计算有 bug------用的是设备像素而不是 CSS 像素。现在的代码专门处理了这个:
python
# IMPORTANT: Use CSS viewport instead of device pixel viewport
# This fixes the coordinate mismatch on high-DPI displays
css_visual_viewport = metrics.get('cssVisualViewport', {})
如果你在 Retina 屏的 Mac 上跑,确保用最新版本。
2. iframe 里的元素交互。 browser-use 默认限制 iframe 深度为 5 层、最多 100 个 iframe。如果目标元素在深层嵌套的 iframe 里(比如某些广告平台的后台),可能需要调大 max_iframe_depth。
3. LLM 超时设置。 不同模型的响应速度差异很大。browser-use 针对 Gemini Pro 设了 90 秒超时,其他模型默认更短。如果用较慢的自部署模型,记得手动设置 llm_timeout,否则复杂页面的分析会频繁超时。
和 Playwright 直接写脚本比,值不值得用?
如果你的自动化任务是固定流程(每天登录某个后台导出报表),Playwright 脚本更快更稳。browser-use 的价值在于处理模糊任务和动态页面------"帮我在这个网站上找到最便宜的机票"这种,你没法提前写死每一步的 selector。
从源码来看,browser-use 在 DOM 处理上做了大量工作来减少 LLM 的认知负担。可交互元素检测覆盖了 JS 事件监听器、ARIA 属性、搜索相关 class 名等 7 种判断维度;paint order 过滤解决了元素遮挡的误点击问题;viewport threshold 控制了上下文窗口大小。这些细节不读源码很难感受到。