跳到主要内容

02 · 感知层:把网页压成 LLM 看得懂的清单

本章讲整个项目工程含量最高的一环:一棵真实网页可能有几千个 DOM 节点,怎么过滤、压缩成一份只含可交互元素、每个带一个编号的文本清单,让 LLM 既看得懂又花得起 token。

2.1 它要解决的小问题

你不能把整页 HTML 直接丢给 LLM:

  • 太大(几十万 token),贵且超窗口;
  • 全是噪音(<div class="x9f3a"> 这种对「该点哪」毫无意义);
  • LLM 没法用它来精确指认「我要点的就是这个元素」。

目标:输出一份这样的清单(摘自 system prompt agent/system_prompts/system_prompt.md:49):

[33]<div />
User form
[35]<input type=text placeholder=Enter name />
*[38]<button aria-label=Submit form />
Submit
[40]<a />
About us

规则(同 prompt 第 56-62 行):

  • 只有 [数字] 开头的元素可交互,LLM 只能用这些编号;
  • 缩进(tab)表示父子层级;
  • *[ 前缀 = 自上一步以来新出现的元素(很可能是你上个动作引出的,如自动补全下拉);
  • 纯文本(无 [])不可点。

2.2 直觉:编号 = 握手协议

核心想法一句话:给每个可交互元素发一个号牌,LLM 只认号牌。 LLM 说「点 38 号」,程序就去一张叫 selector_map 的表里查 38 → 真实 DOM 节点,再用它的 backend_node_id / 坐标去 CDP 落点击。编号无歧义、稳定、省 token——这是整个 Browser Use 设计的支点。

2.3 序列化四步流水线

DOMTreeSerializer.serialize_accessible_elements(dom/serializer/serializer.py:100)把一棵 EnhancedDOMTreeNode(已融合了 DOM + accessibility 树 + 快照几何信息)压成清单,分四步:

原始增强 DOM 树(几千节点)

① _create_simplified_tree 判每个节点是否 is_interactive,建简化树
│ (用 ClickableElementDetector)

② PaintOrderRemover 按「绘制顺序」去掉被别的元素盖住的
│ .calculate_paint_order() (看不见 = 不该让 LLM 点)

③ _optimize_tree 砍掉无意义的中间父节点,压扁层级
│ + _apply_bounding_box_filtering
│ (子元素完全被父元素框住就去重)

④ _assign_interactive_indices_ 给每个保留下来的可交互元素发自增编号 1,2,3…
and_mark_new_nodes 填进 selector_map;和上一步对比,标 is_new


SerializedDOMState(_root=树, selector_map={编号: 节点})

返回的 SerializedDOMState 带两样东西:一棵过滤后的树,和一张 selector_map(dom/serializer/serializer.py:148)。后者就是 §2.2 说的「号牌→真实节点」查找表。

2.4 第一步关键:谁算「可交互」

ClickableElementDetector.is_interactive(dom/serializer/clickable_elements.py:6)是一长串启发式判断,按优先级从强信号到弱信号:

信号(命中即判定)说明
has_js_click_listenerCDP 探到的 JS 点击监听(覆盖 Vue @click、React onClick 等)clickable_elements.py:41
大尺寸 iframe(>100×100)可能需要滚动clickable_elements.py:46
<label> 包着表单控件但带 for 属性的跳过(避免双触发外部 input)clickable_elements.py:59
class/id/data-* 含 search 关键词搜索图标按钮常无语义标签clickable_elements.py:76
AX 属性:focusable/editable/checked/expanded…accessibility 树的交互状态clickable_elements.py:106
原生交互标签:button/input/select/a/textarea…最经典的一类clickable_elements.py:139
onclick/tabindex 等属性、ARIA rolerole=button/link/checkbox…clickable_elements.py:174
图标尺寸(10–50px)且有 class/role/aria-label小图标按钮clickable_elements.py:229
兜底:cursor 是 pointerChrome 认为可点而前面都没命中时clickable_elements.py:243

注意它也做否定判断:aria-disabled / aria-hidden 直接返回 False(clickable_elements.py:110),html/body 不算(clickable_elements.py:36)。这串规则就是「为什么 Browser Use 能点到那些没有原生 <button> 标签的自定义控件」的答案——它把 DOM、accessibility 树、几何尺寸、CDP 事件监听四路信息揉在一起判断。

2.5 第二/三步:去掉看不见的、压扁冗余的

  • paint order 过滤(PaintOrderRemover,在 serializer.py:120 调用):一个被模态框盖住的按钮,DOM 里还在,但用户看不见也点不到。按绘制顺序剔除被遮挡的,避免 LLM 点到「视觉上不存在」的东西。
  • bounding box 过滤(_apply_bounding_box_filtering,serializer.py:133,阈值 DEFAULT_CONTAINMENT_THRESHOLD = 0.99,serializer.py:57):如果一个可交互子元素几乎完全(≥99%)被一个同样可交互的父元素框住,通常是重复(整张卡片可点 + 卡片里的链接也可点),去重保留更合理的那个。PROPAGATING_ELEMENTS(serializer.py:45)定义了哪些元素(<a><button>role=combobox 的 div 等)会把可点边界「传播」给子节点。

2.6 第四步:发号牌 + 标新

_assign_interactive_indices_and_mark_new_nodes 给每个最终保留的可交互节点分配自增编号(计数器 _interactive_counter 从 1 起,serializer.py:69),同时拿上一步的 selector_map 对比:这一步新冒出来的元素标 is_new=True。序列化成文本时,is_new 的元素前缀 *(见 serializer.py:925new_prefix = '*' if node.is_new else '')。

这就实现了 §2.1 里 *[38] 的语义:「这是你上个动作引出的新元素」。

2.7 树→文本:serialize_tree

最终的文本由 DOMTreeSerializer.serialize_tree(dom/serializer/serializer.py:883,静态方法)递归生成。它的几个处理:

  • depth * '\t' 做缩进表层级(serializer.py:898);
  • 可交互元素前缀 [backend_node_id],新元素再加 *(serializer.py:925-926);
  • SVG 折叠:<svg> 显示标签但子节点折叠成 <!-- SVG content collapsed -->(serializer.py:931),因为 path/circle 这些纯装饰对 LLM 无用(跳过列表见 serializer.py:21 SVG_ELEMENTS);
  • shadow DOM 标 |SHADOW(open)| / |SHADOW(closed)|(serializer.py:920)。

属性不是全塞——只保留白名单 DEFAULT_INCLUDE_ATTRIBUTES(dom/views.py:18),里面是 title/type/role/placeholder/aria-label/pattern/min/max… 这些对「怎么操作」有用的属性,刻意排掉 class(注释掉了,dom/views.py:22)这种纯噪音。

对外入口是 SerializedDOMState.llm_representation(dom/views.py:937),它带上白名单属性调 serialize_tree

2.8 它怎么接回主循环

感知由事件驱动:Agentbrowser_session.get_browser_state_summary(browser/session.py:1563),后者 dispatch 一个 BrowserStateRequestEvent(browser/session.py:1587),由 DOMWatchdog 接住、跑上面的序列化、把结果(含 selector_map)缓存进 BrowserSession._cached_selector_map(browser/session.py:562)。之后动作执行时 get_element_by_index(14) 就从这张缓存表查节点(详见 §04)。

2.9 巧妙之处小结

  • 编号是握手协议,把「视觉指认」问题降维成「查表」。
  • 四路信号融合判可交互,比单看标签鲁棒得多。
  • * 标新,把「页面因我而变」这一信息直接编码进文本,省得 LLM 自己 diff。
  • 属性白名单 + SVG 折叠 + bbox/paint 去重,层层砍 token。

2.10 代码地图

主题文件符号
序列化四步dom/serializer/serializer.pyDOMTreeSerializer.serialize_accessible_elements
树→文本dom/serializer/serializer.pyDOMTreeSerializer.serialize_tree
可交互判定dom/serializer/clickable_elements.pyClickableElementDetector.is_interactive
遮挡去重dom/serializer/paint_order.pyPaintOrderRemover.calculate_paint_order
对外渲染dom/views.pySerializedDOMState.llm_representationDEFAULT_INCLUDE_ATTRIBUTES
感知入口browser/session.pyBrowserSession.get_browser_state_summary