数据截至 (上游 commit 898f23f0b672)
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:110)把一棵 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:161)。后者就是 §2.2 说的「号牌→真实节点」查找表。
2.4 第一步关键:谁算「可交互」
ClickableElementDetector.is_interactive(dom/serializer/clickable_elements.py:6)是一长串启发式判断,按优先级从强信号到弱信号:
| 信号(命中即判定) | 说明 | 行 |
|---|---|---|
has_js_click_listener | CDP 探到的 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 role | role=button/link/checkbox… | clickable_elements.py:174 |
| 图标尺寸(10–50px)且有 class/role/aria-label | 小图标按钮 | clickable_elements.py:229 |
| 兜底:cursor 是 pointer | Chrome 认为可点而前面都没命中时 | 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:925 的 new_prefix = '*' if node.is_new else '')。
这就实现了 §2.1 里 *[38] 的语义:「这是你上个动作引出的新元素」。
2.7 树→文本:serialize_tree
最终的文本由 DOMTreeSerializer.serialize_tree(dom/serializer/serializer.py:922,静态方法)递归生成。它的几个处理:
- 用
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:21SVG_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:939),它带上白名单属性调 serialize_tree。
2.8 它怎么接回主循环
感知由事件驱动:Agent 调 browser_session.get_browser_state_summary(browser/session.py:1611),后者 dispatch 一个 BrowserStateRequestEvent(browser/session.py:1611),由 DOMWatchdog 接住、跑上面的序列化、把结果(含 selector_map)缓存进 BrowserSession._cached_selector_map(browser/session.py:563)。之后动作执行时 get_element_by_index(14) 就从这张缓存表查节点(详见 §04)。
2.9 巧妙之处小结
- 编号是握手协议,把「视觉指认」问题降维成「查表」。
- 四路信号融合判可交互,比单看标签鲁棒得多。
*标新,把「页面因我而变」这一信息直接编码进文本,省得 LLM 自己 diff。- 属性白名单 + SVG 折叠 + bbox/paint 去重,层层砍 token。
2.10 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 序列化四步 | dom/serializer/serializer.py | DOMTreeSerializer.serialize_accessible_elements |
| 树→文本 | dom/serializer/serializer.py | DOMTreeSerializer.serialize_tree |
| 可交互判定 | dom/serializer/clickable_elements.py | ClickableElementDetector.is_interactive |
| 遮挡去重 | dom/serializer/paint_order.py | PaintOrderRemover.calculate_paint_order |
| 对外渲染 | dom/views.py | SerializedDOMState.llm_representation、DEFAULT_INCLUDE_ATTRIBUTES |
| 感知入口 | browser/session.py | BrowserSession.get_browser_state_summary |