browser-use 的 DOM 序列化:模型看到的网页是什么形状

2026-07-30

本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。

你以为这一层要解决的是「网页太大塞不进上下文」,但 browser-use 做的事更激进:它不压缩 HTML,它根本不给模型 HTML。 模型收到的是一份带缩进的扁平清单,每行一个元素,能操作的元素前面挂一个方括号编号,后面跟一小撮白名单属性;没有编号的行只是文本。这个决定往前决定了模型的判断质量,往后决定了动作层能不能落地——在序列化时被丢掉的元素,后面提示词写得再漂亮也点不到。

站内已经有几篇讲上下文的文章,分工不同:上下文工程 讲的是通用方法论,Agent 上下文预算 讲有限配额怎么分配,工具返回值设计 讲执行结果该怎么回传给模型;这篇不重复那些,只钻一个具体实现——browser-use 仓库里负责把页面压成清单的那几个文件,落到函数名和判定顺序上。

一、模型看到的到底是什么形状

序列化的对外入口在 browser_use/dom/views.py 里:SerializedDOMState.llm_representation(),它转手调用 browser_use/dom/serializer/serializer.pyDOMTreeSerializer.serialize_tree。项目的系统提示词里对这个格式有一段说明,browser_use/agent/system_prompts/system_prompt.md 给的示例是这样:

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

几条规则可以从 serialize_tree 里逐行对上:

  • 只有被判定为可交互(is_interactive)的元素才会带 [编号],纯文本节点单独占一行、原样输出,模型据此区分「能点的」和「只能读的」。
  • 缩进用制表符表示父子关系,深度只在遇到可交互元素、可滚动元素、IFRAMEFRAME 时才 +1,所以缩进层级比真实 DOM 浅得多。
  • 前缀 *[ 表示这个可交互元素是上一步之后新出现的。这个标记不是装饰:提示词里明确要求模型在输入文本后留意 *[,因为自动补全的候选项通常就是这样冒出来的。
  • 可滚动容器带 |scroll element||scroll element[编号] 前缀,后面跟一段人类可读的滚动信息,由 views.pyget_scroll_info_text() 产出,形如「几页在上、几页在下」。
  • 影子 DOM 宿主带 |SHADOW(open)||SHADOW(closed)| 前缀,影子树本身被 Open Shadow / Closed ShadowShadow End 两行夹住。
  • <svg> 只留一行,子元素整体折叠,行尾追加 <!-- SVG content collapsed -->SVG_ELEMENTS 集合里那批 pathrectgcircle 等子标签在更早的一步就被直接丢弃。
  • 属性不是全给,走 views.py 顶部的 DEFAULT_INCLUDE_ATTRIBUTES 白名单,class 在这份清单里是被注释掉的;每个属性值再经 cap_text_length(value, 100) 截断。

最后还有一道硬闸门在提示词组装环节:browser_use/agent/prompts.py 拿到 llm_representation() 的结果后,按 max_clickable_elements_length 截断,这个设置项在 browser_use/agent/views.py 里默认 40000 字符,截的是尾部。

二、这份清单的原料:三棵树拼成一棵

清单不是从 HTML 文本解析出来的。browser_use/dom/service.pyDomService._get_all_trees 一次并发发出四组 CDP 请求:DOMSnapshot.captureSnapshot(带 includePaintOrderincludeDOMRects 与一批必需计算样式)、DOM.getDocumentdepth=-1pierce=True,穿透影子树)、逐 frame 收集再合并的 Accessibility.getFullAXTree,以及用 Page.getLayoutMetrics 算设备像素比。四个任务先给 10 秒,超时的取消重建、再给 2 秒。

这里有个值得抄的降级判断:无障碍树失败或超时时,代码不整体报错,而是塞一个空的 nodes 列表继续走,注释写得很直白——快照和 DOM 树本身已经包含可用的页面结构,不该因为无障碍信息收集卡住就把结构一起扔了。反过来,快照或 DOM 树失败则抛 TimeoutError

三棵树合成的产物是 EnhancedDOMTreeNodeviews.py):DOM 侧给节点 ID、标签、属性、is_scrollable;无障碍侧给 ax_node 的 role、name、properties;快照侧给 snapshot_nodeboundsclientRectsscrollRectscomputed_stylespaint_order。可见性不是浏览器直接给的,而是 is_element_visible_according_to_all_parents 自己算:先看 displayvisibilityopacity,再沿着父 frame 链逐层做视口相交,其中的 viewport_threshold 参数默认 1000,意思是超出视口 1000 像素之内仍算可见。

还有一路额外信息:Vue 的 @click、React 的 onClick 这类监听器不体现在属性上,所以 _get_all_trees 里注入一段 JS,借 includeCommandLineAPI 打开的 getEventListeners 找出挂了 click 及鼠标事件的元素,再用 DOM.describeNode 换成 backend node id,写进节点的 has_js_click_listener。这段有两道刹车:页面元素超过 10000 个直接放弃探测,带监听器的元素超过 100 个返回一个溢出哨兵字符串,并且 describeNode 按批并发而不是一把梭——注释解释过原因,几十个同时发的调用会挤掉截图等其它 CDP 请求。

三、什么算「可交互」

判定全在 browser_use/dom/serializer/clickable_elements.pyClickableElementDetector.is_interactive 里,一条 return 一个规则,顺序本身就是优先级。按代码里的先后:

非元素节点、htmlbody 直接排除;has_js_click_listener 为真直接算可交互;IFRAME / FRAME 只要宽高都大于 100 像素就算(理由是小 iframe 不太可能有需要滚动的内容);label 若带 for 属性直接返回 False,注释解释过这是为了避免通过 for 代理点击时把真正的目标元素毁掉,而两层之内包着 input / select / textarealabelspan 则算可交互,用来兜住组件库常见的包装写法。

接着是一组按名字猜的规则:class、id 或任意 data- 属性里出现 searchmagnifyglasslookupfindquerysearchbox 之类关键词就算可交互。再往下是无障碍属性:disabledhidden 为真直接排除;focusableeditablesettable 为真算可交互;checkedexpandedpressedselected 只要属性存在就算(代码注释的理由是这些属性只会出现在交互控件上)。

然后才是最直觉的一条——原生标签白名单:

interactive_tags = {
	'button',
	'input',
	'select',
	'textarea',
	'a',
	'details',
	'summary',
	'option',
	'optgroup',
}

后面还有几层兜底:带 onclickonmousedownonmouseuponkeydownonkeyuptabindex 之一;role 属性或无障碍 role 落在交互角色集合里(buttonlinkmenuitemoptionradiocheckboxtabtextboxcomboboxsliderspinbuttonsearchboxrowcellgridcell 等);宽高都在 10 到 50 像素之间、且带 classroleonclickdata-actionaria-label 之一的小元素按图标算;最后一条是快照里 cursor_style == 'pointer' 就算。

看清这条链的性质很重要:它是一串启发式,不是语义理解。好处是自定义组件、图标按钮、无语义 div 都能被捞出来;代价是宁滥勿缺——一个 class 里带 find 的装饰元素会被编号,一个设了 cursor: pointer 的标题也会。

四、编号从哪来,以及三道裁剪

serialize_accessible_elements() 是主流程,步骤在代码里标得很清楚。

第一步 _create_simplified_tree 边遍历边扔:DISABLED_ELEMENTS 里的 stylescriptheadmetalinktitle 丢掉,SVG 子标签丢掉,带 data-browser-use-exclude="true" 的节点丢掉(还支持按会话隔离的 data-browser-use-exclude-<session_id> 变体,用来排除自己注入的元素),文本节点要求去空白后长度大于 1。这一步也有两条反向的保命规则:不可见但带 aria-pseudo 属性的元素强制视为可见,type="file" 的 input 强制视为可见——注释点名了这是因为框架常把文件选择框用 opacity:0 藏起来再套一个好看的壳。

第二步 PaintOrderRemover.calculate_paint_orderserializer/paint_order.py)按绘制顺序从高到低推进,用一个手写的不相交矩形并集 RectUnionPure 维护「已经被上层盖住的区域」,落在里面的节点标记 ignored_by_paint_order。细节里有两处克制:背景色透明或 opacity 低于 0.8 的节点不计入遮挡集合(注释自己承认这个阈值是凭感觉定的);矩形数量上限 _MAX_RECTS = 5000,触顶后 add() 不再收新矩形(类的文档字符串把这个效果描述为「保守地认为什么都没被挡住」),也就是宁可少过滤也不让复杂页面把内存和 CPU 拖爆——注释算过账,每次插入最坏会把已有矩形切成四块,重叠半透明图层多的页面会指数级膨胀。

第三步是包围盒过滤。有些容器天生会把整块区域的点击都吞掉,代码把它们列成 PROPAGATING_ELEMENTS

PROPAGATING_ELEMENTS = [
	{'tag': 'a', 'role': None},  # Any <a> tag
	{'tag': 'button', 'role': None},  # Any <button> tag
	{'tag': 'div', 'role': 'button'},  # <div role="button">
	{'tag': 'div', 'role': 'combobox'},  # <div role="combobox"> - dropdowns/selects
	{'tag': 'span', 'role': 'button'},  # <span role="button">
	{'tag': 'span', 'role': 'combobox'},  # <span role="combobox">
	{'tag': 'input', 'role': 'combobox'},  # <input role="combobox"> - autocomplete inputs
]

这些元素的包围盒会向所有后代传播,被包含比例达到 DEFAULT_CONTAINMENT_THRESHOLD(0.99)的子节点标 excluded_by_parent,序列化时跳过自己但继续渲染子孙。例外清单挡住了几类误伤:文本节点永不排除,input / select / textarea / label 永不排除,本身也是传播元素的不排除,带 onclick 的不排除,带非空 aria-label 的不排除,rolebuttonlinkcheckboxradiotabmenuitemoption 的不排除。

最后一步分配编号,这里有个反直觉的事实:编号不是 1、2、3 递增的序号。_reserve_backend_node_ids 先把整棵树的 backend_node_id 收成一个集合,_allocate_selector_index 的逻辑是——只要这个 backend node id 还没被占用,编号就直接用它;只有撞号时才从已保留的最大值往上找一个没用过的合成号:

def _allocate_selector_index(self, backend_node_id: int) -> int:
	"""Preserve unique backend IDs and allocate a collision-free model index otherwise."""
	if backend_node_id not in self._selector_map:
		return backend_node_id
	...

所以模型看到的编号是稀疏的大整数,不连续,也不该被当成人类眼中的「第几个元素」。分配的同时写入 _selector_map(类型别名 DOMSelectorMap,即 dict[int, EnhancedDOMTreeNode]),这张表是动作层的唯一入口:BrowserSession.update_cached_selector_map 把它缓存起来,get_dom_element_by_index 按编号取节点,取不到时 browser_use/tools/service.py 会返回 Element with index {index} does not exist. 这类错误。*[ 标记也在这一步定:把当前节点的 (session_id, backend_node_id) 与上一次缓存状态里的同名集合比对,没出现过就是新元素。

组成部分它负责什么对应仓库位置你什么时候会碰到它
DomService发 CDP 请求、合三棵树、算可见性与坐标偏移browser_use/dom/service.py页面状态取不全、跨域 iframe 内容缺失
EnhancedDOMTreeNode合体后的节点,兼带滚动信息与哈希browser_use/dom/views.py写自定义动作、需要 xpath 或元素哈希
ClickableElementDetector判定单个节点算不算可交互browser_use/dom/serializer/clickable_elements.py元素该有编号却没有,或多出一堆噪声编号
PaintOrderRemover按绘制顺序剔掉被遮住的节点browser_use/dom/serializer/paint_order.py肉眼可见的元素莫名消失
DOMTreeSerializer裁剪、编号、拼成模型可读文本browser_use/dom/serializer/serializer.py想改输出格式或属性白名单
DEFAULT_INCLUDE_ATTRIBUTES属性白名单与顺序browser_use/dom/views.py需要 class 等未列入的属性
DOM watchdog在会话里驱动序列化、缓存状态browser_use/browser/watchdogs/dom_watchdog.py(该目录共 14 个 watchdog)想知道每步的 DOM 构建耗时

五、边界与代价

这套设计放弃了什么,写代码前最好先认下来。

它放弃了语义理解。 整条判定链没有一处在问「这个东西是干什么的」,只在问「它长得像不像能点」。误纳的元素会占编号、占上下文;误漏的元素,模型连知道它存在的机会都没有。指望调提示词把漏掉的元素找回来是无效的。

它放弃了大部分视觉信息。 颜色、字号、视觉层级都没有进清单,几何信息只在包围盒过滤和绘制顺序两处被用过。所以「哪个是主按钮」「哪个是被禁用的灰按钮」这类判断,清单里往往没有答案,项目为此保留了截图通道,提示词里把截图称为 ground truth 让模型对照——这意味着纯文本模式下的判断力天然更弱。

它只服务于「当前可见 + 近视口」。 可见性算法带 1000 像素的视口余量,之外的元素不进清单,只在 iframe 场景下留下有限的提示行(hidden_elements_info 最多 10 条,附带「大约几页之下」),页面级的上下滚动提示由提示词组装那一层给。跨域 iframe 递归受 max_iframe_depth(默认 5)和 max_iframes(默认 100)限制,快照文档数超限时直接截断并打告警。

它明确不管几件事。 不管目标站点的使用条款和 robots 策略——自动化访问别人的站点是否被允许,是你的判断和责任,不是序列化层的判断;不管当前页面是不是验证码页、风控页、二次验证页,它只会把上面的输入框和按钮照样编号交给模型,模型也就会照样往下试;不管 canvas 与 WebGL 内部;也不管富文本编辑器内部结构(contenteditable 在它眼里只是白名单里的一个属性)。

数据外泄面必须单独说。 代码里对密码字段做了专门防护:type="password" 的 input 不输出 value,无障碍树里的 valuevaluetext 也一并跳过,注释直接点名理由是防提示注入把密码偷走。但这个特例只覆盖密码框——其它输入框的当前值会被优先从无障碍树里取出写进清单,页面上的可见文本也是原样照抄。也就是说,带着登录态跑一次,收件人、地址、订单号、余额都可能进入模型上下文;而页面文本进上下文,等于第三方站点拥有了往你的会话里写字的能力。这两件事分别对应 提示注入防御最小权限设计 要处理的问题,别指望序列化层替你解决。

六、上手与避坑清单

把编号当稳定 ID 存起来复用。 为什么会踩:清单里的数字看着像稳定标识,很容易被写进自定义脚本或缓存。实际它来自 backend node id 或撞号后的合成号,每次序列化重新分配,选择器表也是整张替换。怎么避:编号只在当前这一步用;动作报「index does not exist」时先重取页面状态再重试,别自己猜偏移量。

发现某个属性怎么都不出现。 为什么会踩:默认输出走白名单,class 恰好被注释掉了,而调试时最想看的常常就是 class。怎么避:去 views.py 确认 DEFAULT_INCLUDE_ATTRIBUTES 里有没有它;需要的话在创建 Agent 时通过 include_attributes 传自己的列表——这个参数一路传到 llm_representation(include_attributes=...)

肉眼明明能看到的元素没有编号。 为什么会踩:清单是三道裁剪之后的结果,最容易吃掉元素的是绘制顺序过滤(被上层矩形完全覆盖)和包围盒传播(被 a / button 类祖先按 0.99 阈值吞掉)。怎么避:先在浏览器配置里把 paint_order_filtering 关掉对比一次,能立刻区分是哪一道;browser_use/dom/playground/ 下有独立跑序列化的脚本,比在完整 Agent 循环里 debug 快得多。

照着提示词文档写解析器。 为什么会踩:system_prompt.md 里写的滚动容器前缀是 |SCROLL|,而 serializer.py 实际输出的是 |scroll element|;仓库里有 8 份系统提示词,措辞繁简不一。怎么避:任何自己写的解析、断言或自定义提示词,前缀与格式一律以 serializer.py 为准,文档只当参考。

上下文里的元素突然少了一大截。 为什么会踩:提示词组装那一层有 40000 字符的硬截断,超长时砍掉的是尾部,而页面底部的提交按钮往往就在尾部。怎么避:先怀疑截断而不是怀疑判定逻辑;用更精确的起始页、先滚动定位、缩小任务粒度,都比调大上限稳妥。

元素上万的页面里自定义组件整片失踪。 为什么会踩:@click 这类监听器只能靠 JS 探测发现,而页面元素超过 10000 个时探测整体跳过,带监听器的元素超过 100 个时走溢出分支。这时纯靠监听器才被识别的元素会集体掉出清单。怎么避:知道这是刻意的性能取舍,别去调那两个上限;改成从更简单的页面入口进入,或者在自己可控的页面上给交互元素补 role 与语义标签。

拿真实账号直接开跑。 为什么会踩:它驱动的是真实浏览器,可以带上你的登录态操作真实账号,而输入框现值与页面文本都会进上下文。怎么避:用独立的浏览器配置与测试账号;对涉及金额、发送、删除的动作设人工确认;把敏感站点排除在任务范围之外。频繁自动化访问还可能让账号被判为异常,这个风险要提前和业务方讲明白。

用它去绕反自动化机制。 这条不是技术避坑:验证码、风控与站点条款是站点方明确表达的边界,越过去的后果由你承担。把自动化用在自己有权限的系统、公开且允许抓取的内容上,是这类工具唯一站得住的用法。

收尾自检

改这一层之前,先能回答四个问题:模型这一步实际收到的清单是什么(打出 llm_representation() 的原文,不要凭想象);你关心的那个元素在哪一步被丢的(判定链、绘制顺序、包围盒、还是 40000 截断);编号是原生 backend node id 还是撞号后的合成号;这次运行里进入上下文的页面文本,有没有你不想外发的内容。

接着读哪个文件也有顺序:想弄清快照数据怎么落到节点上,看 browser_use/dom/enhanced_snapshot.py;想看不带编号的另一套输出,看 browser_use/dom/serializer/eval_serializer.py;想知道序列化在会话里什么时候被触发、耗时怎么统计,看 browser_use/browser/watchdogs/dom_watchdog.py。项目采用 MIT 许可证,这些文件都可以直接照着改成自己的形状——前提是你清楚每一道裁剪扔掉了什么。

本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 为什么要备 8 份系统提示词而不是 1 份browser-use 怎么判断按钮能不能点

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。