数据截至 (上游 commit 911b51b0d148)
感知层:把 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 树 + 截图) | packages/notte-browser/src/notte_browser/window.py:504 snapshot |
buildDomNode.js | 注入页面,遍历真实 DOM,标记可交互节点 | packages/notte-browser/src/notte_browser/dom/buildDomNode.js |
ParseDomTreePipe | 跑上面的 JS,把结果重建成 Python 的 DOM 树 | packages/notte-browser/src/notte_browser/dom/parsing.py:38 |
generate_sequential_ids | DFS 给每个可交互节点编号(B1/I2/…) | packages/notte-browser/src/notte_browser/dom/id_generation.py:11 |
NodeRole.short_id | 角色 → 字母(L/B/I/F/O/M)映射 | packages/notte-core/src/notte_core/browser/node_type.py:266 |
SimpleActionSpacePipe | fast 模式:把树渲染成动作空间文本 | packages/notte-browser/src/notte_browser/tagging/action/simple/pipe.py:20 |
InteractionOnlyDomNodeRenderingPipe | 真正的渲染逻辑:白名单属性 + id[:]<tag> 格式 | packages/notte-browser/src/notte_browser/rendering/interaction_only.py:9 |
ActionSpace | 产物:description + interaction_actions | packages/notte-core/src/notte_core/space.py:34 |
FalcoPerception | 把 description 包成最终喂 LLM 的页面文本 | packages/notte-agent/src/notte_agent/falco/perception.py:14 |
3. 快照生成:注入 JS 遍历真实 DOM
这节讲什么: 管线第一步——snapshot() 怎么把一页活 DOM 抓下来。
3.1 入口:window.snapshot()
一次感知从 NotteWindow.snapshot 开始(packages/notte-browser/src/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-529。dom_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_tree(parsing.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.evaluate。buildDomNode.js 本身改编自 browser-use 项目(脚本首行注明出处)。
dom_config 里几个关键旋钮的默认值(packages/notte-core/src/notte_core/config.toml):
| 配置 | 默认值 | 作用 |
|---|---|---|
highlight_elements | true | 在真实页面上给交互元素画彩色框(调试/录制可见) |
viewport_expansion | 0 | 只收视口内可见的元素;设 -1 则收全页所有交互元素(token 暴涨) |
focus_element | -1 | -1 = 高亮所有;≥0 = 只高亮指定序号 |
enable_pointer_elements | true | 把 cursor:pointer 的 元素也当可交互 |
精华(
viewport_expansion=0): 默认只感知视口内的元素。这正是后面FalcoPerception要提示「上面还有 N 像素、下面还有 N 像素」的原因(见 §7)——模型看到的是当前一屏,不是整页。
3.3 谁算「可交互」
JS 侧的判定逻辑在 buildDomNode.js:527 isInteractiveElement(按标签、role、cursor:pointer、事件属性等综合判断)。判定通过、且元素落在(外扩后的)视口内,就在 handleHighlighting(buildDomNode.js:1058)里领到一个自增的 highlightIndex:
// 示意,非源码:buildDomNode.js:1081-1082 的核心
if (nodeData.isInViewport || viewportExpansion === -1) {
nodeData.highlightIndex = highlightIndex++; // 可交互 & 在视口 → 发号
}
highlightIndex 是后面一切编号的种子:只有拿到它的节点,才会在 Python 侧被编成 B1/I2/… 这样的把手。没拿到的节点(普通文本、装饰性 div)后面只会以 _[:] 上下文形式出现,或直接被丢弃。
Python 侧 ParseDomTreePipe(parsing.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_id(packages/notte-core/src/notte_core/browser/node_type.py:266)。六个字母,各管一类角色:
| 字母 | 含义 | 覆盖的角色(节选) |
|---|---|---|
L | 链接 | link |
B | 按钮类(可点) | button、tab、menuitem、radio、checkbox、switch |
I | 输入类(可填/可选) | textbox、searchbox、combobox、listbox、slider |
F | 图像 | image、img、figure |
O | 下拉选项 | option |
M | 杂项(兜底,强制发号时) | 其它有 highlightIndex 但不属上面几类的 |
真实代码见 node_type.py:266-299——一个 match 语句。注意 checkbox/radio 同时出现在 B 和 I 分支:match 从上到下命中,所以它们落到 B(node_type.py:270-290)。兜底的 M 只有在 force_id=True 时才返回(node_type.py:296-298)。
4.3 DFS 编号:generate_sequential_ids
编号发生在 generate_sequential_ids(packages/notte-browser/src/notte_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.forward(parsing.py:42-43)调 to_notte_domnode() 把树转成 notte-core 的 DomNode,交回 snapshot() 装进 BrowserSnapshot。至此,每个可交互节点都带上了稳定短标签,就等渲染成文本。
5. 渲染成文本:fast 模式怎么把树压成 id[:]<tag> 行
这节讲什么: 管线第四、五步——带 ID 的 DOM 树 → ActionSpace.description 那段短文本。这是本章的终点。
5.1 两种模式:fast vs deep
Notte 有两条渲染动作空间的路,由 MainActionSpacePipe(tagging/action/pipe.py:18)按 perception_type 分发:
| 模式 | 管线 | 怎么工作 | 代价 |
|---|---|---|---|
| fast(默认) | SimpleActionSpacePipe | 纯代码:遍历树、套模板、拼字符串 | 快、免费、确定性 |
| deep | LlmActionSpacePipe(llm_pipe) | 再调一次 LLM 给元素打标/描述 | 慢、烧钱 |
分发逻辑见 tagging/action/pipe.py:36-44:case "deep" 走 llm_pipe.forward,case "fast" 走 simple_pipe.forward。本章讲默认的 fast 路径。FalcoPerception 声明的 perception_type 正是 "fast"(falco/perception.py:25)。
5.2 SimpleActionSpacePipe:串起渲染与动作
fast 模式的编排在 SimpleActionSpacePipe.forward(tagging/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-52。description 是给人/LLM 读的文本;interaction_actions 是给执行器用的结构化动作列表(B1 → 具体的 Playwright 选择器,落地细节属第 3 章)。渲染类型选的是 INTERACTION_ONLY(rendering/pipe.py),指向下面这个渲染器。
5.3 InteractionOnlyDomNodeRenderingPipe:真正的压缩逻辑
压缩的核心在 InteractionOnlyDomNodeRenderingPipe(rendering/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_node(interaction_only.py:27-50),格式是 <tag 属性...>子文本</tag>。
关键压缩手段:属性白名单。 一个真实 <button> 可能有几十个属性(class、data-*、内联样式…),全留就没压缩可言。渲染器只保留一个白名单里的属性(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=60,rendering/pipe.py),一个几 KB 的 <button> 就被压成十几个字符。
5.4 弹窗特判:prune_non_dialogs_if_present
渲染入口 forward(interaction_only.py:110)开头先调 prune_non_dialogs_if_present(interaction_only.py:119,方法定义在 packages/notte-core/src/notte_core/browser/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 一个端到端的简化示例
把前面几节串起来。假设当前视口里有一个提交按钮、一个邮箱输入框、一个注册链接,外加一句忘记密码的提示文字。经过整条管线:
buildDomNode.js判定按钮/输入框/链接可交互,各发一个highlightIndex;那句提示文字不发号。generate_sequential_idsDFS 编号:按钮→B1、输入框→I1、链接→L1。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
ActionSpace(packages/notte-core/src/notte_core/space.py:34)是感知层交给上层的容器,三样东西:
| 字段 | 是什么 | 给谁用 |
|---|---|---|
description | 上面那段 id[:]<tag> 文本 | 喂给 LLM 读(本章产物) |
interaction_actions | 每个 ID 对应的可执行动作 | 执行器落地(第 3 章) |
browser_actions | 内置的浏览器级动作(滚动、回退…) | 由 BrowserAction.list() 拼 入(space.py:51) |
注意 actions(space.py:44-47)= interaction_actions + browser_actions:页面上的元素动作,加上「滚动/切标签」这类与具体元素无关的通用动作,合起来才是模型这一步的完整选项集。
6. interaction_actions:文本之外,还给了执行器把手
上一节聚焦 description(给 LLM 读的文本)。但 SimpleActionSpacePipe.forward 同时构建了 interaction_actions(simple/pipe.py:38-39 actions),这是「文本里的 B1」到「真实可执行动作」之间的桥。
每个交互节点经 node_to_interaction(simple/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. 最后一公里:FalcoPerception 把 description 拼成喂 LLM 的页面文本
这节讲什么: ActionSpace.description 还不是模型直接看到的东西。Agent 层的 FalcoPerception(packages/notte-agent/src/notte_agent/falco/perception.py:14)再包一层,加上元数据和滚动提示,才是最终 prompt 里的「页面」部分。
perceive(perception.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.description(perception.py:60-62)——本章产物原样嵌入,FalcoPerception不改它。- 滚动提示。
perceive读pixels_above/pixels_below(perception.py:41-44),若非零就插一行... N pixels above/below - scroll ...。这正是 §3.2viewport_expansion=0的必然结果:模型只看到一屏,得靠这行提示知道「还有内容在屏外,可以滚动」。 - 元数据。
perceive_metadata(perception.py:28)补上 URL、标题、当前步数/上限——让模型知道自己在哪、还剩几步。
这段 <WEBSITE_CONTENT_BEGIN>... 文本连同系统提示词一起进入 LLM,第 2 章的 Agent 主循环从这里接手。