跳到主要内容

感知层:把 DOM 变成「带 ID 的动作空间」

30 秒导读: 网页的原始 HTML 有几万个标签,LLM 既读不完也点不准。Notte 的感知层把一整棵活 DOM 压成一段短文本——只留可交互元素,每个配一个稳定短标签(B1=第 1 个按钮、I2=第 2 个输入框、L3=第 3 个链接)加一句精简描述。这段文本就是喂给 LLM 的「动作空间」。本章讲透这条从 DOM 到文本的端到端管线,只到「生成动作空间文本」为止——LLM 怎么用它是 第 2 章,一个 ID 怎么落地成 Playwright 点击是 第 3 章


1. 这是什么:为什么不能直接把 HTML 喂给 LLM

先建立直觉。假设你要让一个 AI 帮你在某网站上点「登录」按钮。最朴素的想法是把整页 HTML 塞进 prompt,让模型说「点那个登录按钮」。这条路走不通,原因有三:

  • 太长。 真实网页的 HTML 动辄几万到几十万 token,一次就把上下文窗口撑爆,还很贵。
  • 太吵。 里面 95% 是 <div>、内联样式、<script>、SVG 路径——对「我要点哪」毫无帮助。
  • 没有「把手」。 就算模型看懂了,它怎么把「那个登录按钮」精确回指给执行器?靠 CSS 选择器?模型经常编错。

Notte 的解法是给 LLM 一份经过压缩的、带把手的页面视图。核心直觉一句话:

只保留能交互的元素,给每一个发一个稳定的短标签当把手,再配一句人话描述。

于是那一整页 HTML 被压成这样一段文本:

B1[:]<button>Submit</button>
I1[:]<input type="text" placeholder="Email">
L1[:]<a href="/signup">Sign up</a>
_[:]Forgot your password?

模型看到 B1[:]<button>Submit</button>,就知道「有个提交按钮,把手叫 B1」;它只要回一句「点 B1」,执行器就能精确定位。_[:] 开头的是非交互文本——给模型当上下文,但不能点。

这段文本在 Notte 里叫 ActionSpace.description(动作空间描述),是整个感知层的最终产物。本章就是讲:一整棵活 DOM,怎么一步步变成上面这四行。


2. 顶层全景:一条从「活 DOM」到「短文本」的流水线

这条管线跨了三个包:浏览器控制(notte-browser)、核心数据模型(notte-core)、Agent 感知封装(notte-agent)。先看整条流水线怎么流转,后面各节再逐段展开。

怎么读这张图: 从上到下是数据流,左边是「在干什么」,右边括号里是真实的文件与符号名。命中「⑤ 渲染成文本」即得到本章终点。

真实网页(活 DOM,几万个节点)

│ ① 快照:注入 JS 遍历真实 DOM

window.snapshot() window.py:504 snapshot
│ └─ page.evaluate(buildDomNode.js, dom_config)
│ parsing.py:58 parse_dom_tree

② 找出可交互节点,打 highlightIndex buildDomNode.js:1058 handleHighlighting
│ (在视口内 & 可交互 → 发号)

DOMElementNode 树(Python 侧重建) parsing.py:40 ParseDomTreePipe.forward

│ ③ DFS 给 highlightIndex≠None 的节点编号

generate_sequential_ids id_generation.py:11
│ notte_id = 角色首字母 + 序号 node_type.py:266 NodeRole.short_id
│ (L / B / I / F / O / M)

DomNode 树(每个交互节点带 notte_id)

│ ④ 渲染:只留交互元素 + 白名单属性

SimpleActionSpacePipe.forward tagging/action/simple/pipe.py:42
│ └─ InteractionOnlyDomNodeRenderingPipe.forward
│ rendering/interaction_only.py:110

⑤ ActionSpace(description=..., ...) notte-core/space.py:34
│ ← 本章终点就在这里

│ ⑥ 包装成最终 prompt 文本

FalcoPerception.perceive falco/perception.py:39
→ <WEBSITE_CONTENT_BEGIN> ... 喂给 LLM(第 2 章接手)

各部件一句话职责:

部件干什么文件:符号
window.snapshot抓一次页面快照(HTML + DOM 树 + 截图)notte-browser/.../window.py:504 snapshot
buildDomNode.js注入页面,遍历真实 DOM,标记可交互节点notte-browser/.../dom/buildDomNode.js
ParseDomTreePipe跑上面的 JS,把结果重建成 Python 的 DOM 树notte-browser/.../dom/parsing.py:38
generate_sequential_idsDFS 给每个可交互节点编号(B1/I2/…)notte-browser/.../dom/id_generation.py:11
NodeRole.short_id角色 → 字母(L/B/I/F/O/M)映射notte-core/.../node_type.py:266
SimpleActionSpacePipefast 模式:把树渲染成动作空间文本notte-browser/.../tagging/action/simple/pipe.py:20
InteractionOnlyDomNodeRenderingPipe真正的渲染逻辑:白名单属性 + id[:]<tag> 格式notte-browser/.../rendering/interaction_only.py:9
ActionSpace产物:description + interaction_actionsnotte-core/.../space.py:34
FalcoPerceptiondescription 包成最终喂 LLM 的页面文本notte-agent/.../falco/perception.py:14

3. 快照生成:注入 JS 遍历真实 DOM

这节讲什么: 管线第一步——snapshot() 怎么把一页活 DOM 抓下来。

3.1 入口:window.snapshot()

一次感知从 NotteWindow.snapshot 开始(notte-browser/.../window.py:504 snapshot)。它做两件事:抓 HTML 文本、解析 DOM 树。解析这一步是重点:

# 示意,非源码:window.py:527-529 的核心两行
dom_tree_pipe = dom_tree_parsers["default"] # 取默认解析管线
dom_node = await dom_tree_pipe.forward(self.page) # 把整棵 DOM 解析成 Notte 的树

真实代码见 window.py:528-529dom_tree_parsers["default"] 是模块级注册的 ParseDomTreePipe() 单例(parsing.py:190 dom_tree_parsers)。

3.2 为什么必须注入 JS,而不是解析 HTML 字符串

关键设计决策:Notte 不解析静态 HTML,它在浏览器里对活 DOM 跑 JavaScript。 因为「一个元素能不能交互、在不在视口里」这些判断,只有活着的浏览器才知道——需要 getBoundingClientRect()getComputedStyle()cursor: pointer 这类运行时信息,静态 HTML 里没有。

于是 ParseDomTreePipe.parse_dom_treeparsing.py:48)把一整份 JS 脚本注入页面执行:

# 示意,非源码:parsing.py:49-59 的骨架
js_code = DOM_TREE_JS_PATH.read_text() # 读 buildDomNode.js
dom_config = { # 传给 JS 的配置
"highlight_elements": config.highlight_elements, # 是否在页面上画高亮框
"focus_element": config.focus_element,
"viewport_expansion": config.viewport_expansion, # 视口外扩多少像素
"enable_pointer_elements": config.enable_pointer_elements,
}
page_eval = await page.evaluate(js_code, dom_config) # 在浏览器里跑,拿回节点字典

真实代码见 parsing.py:49-59,用的是 Playwright 的 page.evaluatebuildDomNode.js 本身改编自 browser-use 项目(脚本首行注明出处)。

dom_config 里几个关键旋钮的默认值(notte-core/.../config.toml):

配置默认值作用
highlight_elementstrue在真实页面上给交互元素画彩色框(调试/录制可见)
viewport_expansion0只收视口内可见的元素;设 -1 则收全页所有交互元素(token 暴涨)
focus_element-1-1 = 高亮所有;≥0 = 只高亮指定序号
enable_pointer_elementstruecursor:pointer 的元素也当可交互

精华(viewport_expansion=0): 默认只感知视口内的元素。这正是后面 FalcoPerception 要提示「上面还有 N 像素、下面还有 N 像素」的原因(见 §7)——模型看到的是当前一屏,不是整页。

3.3 谁算「可交互」

JS 侧的判定逻辑在 buildDomNode.js:527 isInteractiveElement(按标签、role、cursor:pointer、事件属性等综合判断)。判定通过、且元素落在(外扩后的)视口内,就在 handleHighlightingbuildDomNode.js:1058)里领到一个自增的 highlightIndex

// 示意,非源码:buildDomNode.js:1081-1082 的核心
if (nodeData.isInViewport || viewportExpansion === -1) {
nodeData.highlightIndex = highlightIndex++; // 可交互 & 在视口 → 发号
}

highlightIndex 是后面一切编号的种子:只有拿到它的节点,才会在 Python 侧被编成 B1/I2/… 这样的把手。没拿到的节点(普通文本、装饰性 div)后面只会以 _[:] 上下文形式出现,或直接被丢弃。

Python 侧 ParseDomTreePipeparsing.py:40 forward)把 JS 返回的扁平字典重建成 DOMElementNode 树(_reconstruct_dom_tree + _parse_node),highlight_index 字段一路带下来(parsing.py:108:142)。


4. ID 生成:给每个可交互节点发一个稳定短标签

这节讲什么: 管线第三步——把 highlightIndex 翻译成人和模型都好认的 B1/I2/L3

4.1 直觉:按「角色」分桶,各自从 1 编号

highlightIndex 是个全局自增数(0,1,2,3…),对模型不友好。Notte 把它换成**「角色首字母 + 同类序号」**:所有按钮排成 B1、B2、B3…,所有输入框排成 I1、I2…,所有链接排成 L1、L2…。这样模型一眼就知道「B 是按钮、I 是输入」,语义自带。

4.2 角色 → 字母映射

映射表定义在 NodeRole.short_idnotte-core/.../node_type.py:266)。六个字母,各管一类角色:

字母含义覆盖的角色(节选)
L链接link
B按钮类(可点)buttontabmenuitemradiocheckboxswitch
I输入类(可填/可选)textboxsearchboxcomboboxlistboxslider
F图像imageimgfigure
O下拉选项option
M杂项(兜底,强制发号时)其它有 highlightIndex 但不属上面几类的

真实代码见 node_type.py:266-299——一个 match 语句。注意 checkbox/radio 同时出现在 BI 分支:match 从上到下命中,所以它们落到 Bnode_type.py:270-290)。兜底的 M 只有在 force_id=True 时才返回(node_type.py:296-298)。

4.3 DFS 编号:generate_sequential_ids

编号发生在 generate_sequential_idsnotte-browser/.../dom/id_generation.py:11)。它用一个显式栈做深度优先遍历,对每个 highlight_index 非空的节点分配 notte_id

# 示意,非源码:id_generation.py 的核心循环
id_counter = defaultdict(lambda: 1) # 每个字母各自的计数器
stack = [root]
while stack:
node = stack.pop()
role = NodeRole.from_value(node.role)
if node.highlight_index is not None: # 只给「领过号」的节点编 ID
letter = role.short_id(force_id=True) # 角色 → L/B/I/F/O/M
node.notte_id = f"{letter}{id_counter[letter]}" # 如 "B1"
id_counter[letter] += 1
stack.extend(reversed(children)) # 逆序压栈 → 保持文档先后顺序

真实代码见 id_generation.py:16-39。三个要点:

  • DFS + reversed(children) 用栈模拟 DFS,压栈时逆序,弹出时就还原成文档顺序——编号顺序与元素在页面上从上到下的出现顺序一致。
  • defaultdict(lambda: 1) 每个字母独立计数、都从 1 开始,于是有 B1/B2…I1/I2… 并行。
  • 只编「领过号」的: highlight_index is None 的节点跳过(id_generation.py:27),它们不会成为可点把手。

精华(ID 每步都会变,别记忆): 编号是按当前这次快照的可交互节点集合从头算的。页面一变(滚动、弹窗、AJAX 刷新),可交互集合就变,同一个「登录按钮」这一步可能是 B3、下一步可能是 B1。系统提示词对此有明确警告:falco/system.md:33 —— "IDs can and will change at each step." 所以第 2 章的 Agent 循环里,模型每一步都要重新读动作空间,绝不能拿历史里的 ID 去点。

编号完,ParseDomTreePipe.forwardparsing.py:42-43)调 to_notte_domnode() 把树转成 notte-coreDomNode,交回 snapshot() 装进 BrowserSnapshot。至此,每个可交互节点都带上了稳定短标签,就等渲染成文本。


5. 渲染成文本:fast 模式怎么把树压成 id[:]<tag>

这节讲什么: 管线第四、五步——带 ID 的 DOM 树 → ActionSpace.description 那段短文本。这是本章的终点。

5.1 两种模式:fast vs deep

Notte 有两条渲染动作空间的路,由 MainActionSpacePipetagging/action/pipe.py:18)按 perception_type 分发:

模式管线怎么工作代价
fast(默认)SimpleActionSpacePipe纯代码:遍历树、套模板、拼字符串快、免费、确定性
deepLlmActionSpacePipellm_pipe再调一次 LLM 给元素打标/描述慢、烧钱

分发逻辑见 tagging/action/pipe.py:36-44case "deep"llm_pipe.forwardcase "fast"simple_pipe.forward。本章讲默认的 fast 路径。FalcoPerception 声明的 perception_type 正是 "fast"falco/perception.py:25)。

5.2 SimpleActionSpacePipe:串起渲染与动作

fast 模式的编排在 SimpleActionSpacePipe.forwardtagging/action/simple/pipe.py:42),三行就说清:

# 示意,非源码:simple/pipe.py:48-52
page_content = DomNodeRenderingPipe.forward( # ① 树 → 文本
snapshot.dom_node, type=DomNodeRenderingType.INTERACTION_ONLY)
return ActionSpace(
description=page_content, # ② 文本放进 description
interaction_actions=self.actions(snapshot.dom_node), # ③ 每个 ID 对应一个可执行动作
)

真实代码见 simple/pipe.py:42-52description 是给人/LLM 读的文本;interaction_actions 是给执行器用的结构化动作列表(B1 → 具体的 Playwright 选择器,落地细节属第 3 章)。渲染类型选的是 INTERACTION_ONLYrendering/pipe.py),指向下面这个渲染器。

5.3 InteractionOnlyDomNodeRenderingPipe:真正的压缩逻辑

压缩的核心在 InteractionOnlyDomNodeRenderingPiperendering/interaction_only.py:9)。它递归遍历树,对每个节点做二选一(interaction_only.py:60-85 format):

  • notte_id 的交互节点 → 输出 {id}[:]{html},如 B1[:]<button>Submit</button>interaction_only.py:75)。
  • 无 ID 的纯文本节点 → 若它的父节点不是交互元素,输出 _[:]{text}interaction_only.py:69),当上下文。

单个交互节点渲染成 HTML 片段的逻辑在 render_nodeinteraction_only.py:27-50),格式是 <tag 属性...>子文本</tag>

关键压缩手段:属性白名单。 一个真实 <button> 可能有几十个属性(classdata-*、内联样式…),全留就没压缩可言。渲染器只保留一个白名单里的属性(interaction_only.py:10-25 include_attributes):

title, type, name, role, tabindex, aria_label,
placeholder, value, alt, src, href, aria_expanded

只有这 12 个「对理解元素语义有用」的属性会进文本,其余全丢。加上每属性还有长度上限(max_len_per_attribute=60rendering/pipe.py),一个几 KB 的 <button> 就被压成十几个字符。

5.4 弹窗特判:prune_non_dialogs_if_present

渲染入口 forwardinteraction_only.py:110)开头先调 prune_non_dialogs_if_presentinteraction_only.py:119,方法定义在 notte-core/.../dom_tree.py:509)。逻辑:如果页面上存在「视口内、且含可交互元素」的对话框(dialog),就只渲染这些对话框子树,把整页其余部分裁掉dom_tree.py:512-528)。

直觉很实在:模态弹窗弹出时,用户(和 Agent)注意力就该在弹窗上,背后一整页都是干扰。多个弹窗时还会各加一行 ### Content of 'dialog' component ... 标题分隔(interaction_only.py:131-134)。没有弹窗时返回整棵树(dom_tree.py:524-526),走正常渲染。

5.5 一个端到端的简化示例

把前面几节串起来。假设当前视口里有一个提交按钮、一个邮箱输入框、一个注册链接,外加一句忘记密码的提示文字。经过整条管线:

  1. buildDomNode.js 判定按钮/输入框/链接可交互,各发一个 highlightIndex;那句提示文字不发号。
  2. generate_sequential_ids DFS 编号:按钮→B1、输入框→I1、链接→L1
  3. InteractionOnlyDomNodeRenderingPipe 逐节点渲染,属性过白名单。

最终 ActionSpace.description 长这样:

B1[:]<button>Submit</button>
I1[:]<input type="text" placeholder="Email">
L1[:]<a href="/signup">Sign up</a>
_[:]Forgot your password?

这四行就是本章的终点产物。它简洁到 LLM 一眼扫完,又精确到每个把手(B1/I1/L1)都能被执行器唯一定位。这段文本的语法约定,与喂给模型的系统提示词是对齐的——falco/system.md:13 明确告诉模型 id[:]<element_type>element_text</element_type> 怎么解读、六个字母各代表什么。

5.6 产物:ActionSpace

ActionSpacenotte-core/.../space.py:34)是感知层交给上层的容器,三样东西:

字段是什么给谁用
description上面那段 id[:]<tag> 文本喂给 LLM 读(本章产物)
interaction_actions每个 ID 对应的可执行动作执行器落地(第 3 章)
browser_actions内置的浏览器级动作(滚动、回退…)BrowserAction.list() 拼入(space.py:51

注意 actionsspace.py:44-47)= interaction_actions + browser_actions:页面上的元素动作,加上「滚动/切标签」这类与具体元素无关的通用动作,合起来才是模型这一步的完整选项集


6. interaction_actions:文本之外,还给了执行器把手

上一节聚焦 description(给 LLM 读的文本)。但 SimpleActionSpacePipe.forward 同时构建了 interaction_actionssimple/pipe.py:38-39 actions),这是「文本里的 B1」到「真实可执行动作」之间的桥。

每个交互节点经 node_to_interactionsimple/pipe.py:21)变成一个 InteractionAction,其中一个巧妙细节:输入类元素自动带一个 value 参数——

# 示意,非源码:simple/pipe.py:34
param = ActionParameter(name="value", type="string") if node.id.startswith("I") else None

也就是说,凡 ID 以 I 开头(输入框),动作就带一个「要填什么值」的参数槽;B/L(按钮/链接)则没有——它们只是点击,不需要值。这样模型对 I1 的调用天然长成「填 I1 = 某文本」,对 B1 长成「点 B1」。动作怎么真正落到 Playwright,属第 3 章;这里只强调:感知层不只吐文本,还顺手备好了每个把手的执行契约。


7. 最后一公里:FalcoPerceptiondescription 拼成喂 LLM 的页面文本

这节讲什么: ActionSpace.description 还不是模型直接看到的东西。Agent 层的 FalcoPerceptionnotte-agent/.../falco/perception.py:14)再包一层,加上元数据和滚动提示,才是最终 prompt 里的「页面」部分。

perceiveperception.py:39)把几块拼在一起:

<WEBSITE_CONTENT_BEGIN>
[Relevant metadata]
* Current url: ...
* Current page title: ...
* Current step: 3/20
[Interaction elements and context]
[Start of page]
... 1200 pixels above - scroll or scrape content to see more ... ← 若视口上方还有内容
B1[:]<button>Submit</button> ← 这里就是 space.description
I1[:]<input type="text" placeholder="Email">
... 800 pixels below - scroll ... ... ← 若视口下方还有内容
[End of page]

三个要点:

  • perceive_actions 就是直接返回 space.descriptionperception.py:60-62)——本章产物原样嵌入,FalcoPerception 不改它。
  • 滚动提示。 perceivepixels_above / pixels_belowperception.py:41-44),若非零就插一行 ... N pixels above/below - scroll ...。这正是 §3.2 viewport_expansion=0 的必然结果:模型只看到一屏,得靠这行提示知道「还有内容在屏外,可以滚动」。
  • 元数据。 perceive_metadataperception.py:28)补上 URL、标题、当前步数/上限——让模型知道自己在哪、还剩几步。

这段 <WEBSITE_CONTENT_BEGIN>... 文本连同系统提示词一起进入 LLM,第 2 章的 Agent 主循环从这里接手


8. 边界与局限(诚实)

  • ID 不跨步稳定。 如 §4.3 所述,notte_id 每次快照重算,历史里的 ID 不可复用(falco/system.md:33)。这是设计取舍:换来的是编号总是紧凑、贴合当前页面。
  • 默认只看一屏。 viewport_expansion=0config.toml:63)意味着视口外的交互元素根本不进动作空间,模型必须先滚动才能操作它们。设 -1 可收全页,但 token 会暴涨(config.toml:60)。
  • fast 模式不「理解」元素,只搬运结构。 描述完全来自 DOM 的属性和文本;若一个按钮的可见文本是图标(无文字),fast 模式给出的描述可能很空。这正是 deep 模式(LLM 打标,tagging/action/pipe.py:40)存在的理由——但它更慢更贵。
  • 依赖注入 JS 能跑。 整条管线建立在 page.evaluate(buildDomNode.js) 成功执行之上(parsing.py:58)。CSP 极严、或反自动化脚本干预时,快照可能失败并触发 snapshot() 的重试路径(window.py:511:531)。

9. 代码地图(导航索引)

按符号名 grep 比按行号更抗漂移。下面每行指向本章讲的一个环节。

主题文件路径符号名
快照入口packages/notte-browser/src/notte_browser/window.pysnapshot
注入 JS 解析 DOMpackages/notte-browser/src/notte_browser/dom/parsing.pyParseDomTreePipe.forward / parse_dom_tree
遍历真实 DOM 的脚本packages/notte-browser/src/notte_browser/dom/buildDomNode.jsisInteractiveElement / handleHighlighting
DFS 编号packages/notte-browser/src/notte_browser/dom/id_generation.pygenerate_sequential_ids
角色 → 字母映射packages/notte-core/src/notte_core/browser/node_type.pyNodeRole.short_id
fast 模式编排packages/notte-browser/src/notte_browser/tagging/action/simple/pipe.pySimpleActionSpacePipe.forward
交互-only 渲染packages/notte-browser/src/notte_browser/rendering/interaction_only.pyInteractionOnlyDomNodeRenderingPipe / include_attributes
弹窗裁剪packages/notte-core/src/notte_core/browser/dom_tree.pyprune_non_dialogs_if_present
fast/deep 分发packages/notte-browser/src/notte_browser/tagging/action/pipe.pyMainActionSpacePipe
产物容器packages/notte-core/src/notte_core/space.pyActionSpace
最终 prompt 包装packages/notte-agent/src/notte_agent/falco/perception.pyFalcoPerception.perceive
配置默认值packages/notte-core/src/notte_core/config.tomlviewport_expansion / highlight_elements
ID 语义 & 会变的警告packages/notte-agent/src/notte_agent/falco/system.md第 13 行、第 33 行

下一章: 02-agent-loop.md —— LLM 拿到这段动作空间文本后,怎么推理、选一个 ID、单步执行、再让裁判验证。相邻章节还有 03-actions-execution.md(一个 ID 怎么落地成 Playwright 操作)、index.md(Notte 总览与阅读地图)。