跳到主要内容

01 · 快照与 ref:模型的「眼睛」

这章讲 BrowserOS 怎么让一个看不见像素的模型「看见」网页:把浏览器的无障碍树渲染成带编号的缩进文本。

1. 它要解决的小问题

模型不能直接看像素,也不该看原始 HTML(几百 KB、噪声巨大、token 爆炸)。我们需要一种表示,既小又准确地描述「页面上有哪些可操作的东西」,并且能让模型在后续动作里精确指认其中一个。

BrowserOS 的答案是 snapshot 工具:把页面的无障碍树(accessibility tree)——浏览器本来就维护、给读屏软件用的语义树——渲染成一棵缩进文本,每个可操作元素后面挂一个 [ref=eN] 编号。

2. 思路/直觉

无障碍树天生就是「语义视角」:它不关心 <div> 套了几层,只关心「这是一个名叫『登录』的按钮、那是一个名叫『邮箱』的输入框」。这正是 agent 需要的抽象层级。

一份快照长这样(示意,真实输出格式见下文源码):

- heading "Sign in"
- textbox "Email" [ref=e3]
- textbox "Password" [ref=e4]
- button "Log in" [ref=e5]
- link "Forgot password?" [ref=e6] [cursor=pointer]

模型读到 [ref=e5],下一步就能说 act(kind=click, ref=e5)ref 是模型和真实 DOM 之间的握手符号

3. 渲染:无障碍树 → 缩进文本

snapshot 工具本身极薄——它把活全交给 session.observe(page).snapshot():

// packages/browser-mcp/src/tools/snapshot.ts:13(handler)
const { text } = await ctx.session.observe(args.page).snapshot()
const origin = ctx.session.pages.getInfo(args.page)?.url ?? 'unknown'
const formatted = await formatSnapshotResult(text, origin)

真正把节点变成文本的是 renderSnapshot。它做三件值得看的事:

① 丢掉噪声节点。 没有 role 的、纯结构性的(SKIP_ROLES/ROOT_ROLES)、以及没有名字又非光标可交互的 generic/group 容器,直接跳过——但保留其子节点继续走:

// packages/browser-core/src/core/snapshot/render.ts:91(isDropped)
if ((role === 'generic' || role === 'group') && !name && !isCursorHit) {
return true // 无名容器没有意义,丢
}

② 给「可操作」元素铸造 ref。 一个元素够格拿 ref,要么 role 在交互角色表里(INTERACTIVE_ROLES),要么它被「光标命中」标记过(下面 ③):

// packages/browser-core/src/core/snapshot/render.ts:122(formatLine 内)
const actionable =
backendNodeId !== undefined &&
(INTERACTIVE_ROLES.has(role) || cursorReasons !== undefined)
if (actionable) {
const ref = opts.refs.mint({ backendNodeId, role, name, documentId, frameId })
line += ` [ref=${ref}]`
}

它还把状态(checked/disabled/expanded/selected...)和取值(输入框当前值)拼进行尾,见 formatStates(render.ts:146)。这样一行就是一个节点的完整语义身份——这个设计后面 diff 会重复利用(03 章)。

③ 补上无障碍树漏掉的可点元素。 有些元素(比如绑了 onclick 的 <div>)无障碍树不标成交互,但鼠标悬上去是「手型光标」。findCursorHits 单独扫一遍 DOM,把这些 backendNodeId 标成 cursorHits,渲染时给它们也发 ref 并加 [cursor=pointer] 标记(render.ts:136)。这是「无障碍树路线」的关键补丁,补上了它的盲区。

iframe 拼接: 子 iframe 的内容会被就地缝进父树里对应的 - iframe 行下面,所以模型看到的是一棵连续的树,不用关心跨 frame。见 Observer.captureFrame(observer.ts:111)的 splice 逻辑。

4. ref 为什么必须「稳定」,以及怎么稳定

这是这章最巧的地方。设想:模型第 1 轮快照看到登录按钮是 e5;它点了别处,页面小改了一下;第 2 轮它还想点登录按钮——如果这次登录按钮变成了 e9,模型就指错了。

BrowserOS 的解法:ref 绑定的是「同文档内的后端节点身份」,不是渲染顺序RefMap.mint 对带 documentId 的节点算一个稳定键,同一个真实节点跨快照永远拿回同一个 ref:

// packages/browser-core/src/core/snapshot/refs.ts:57(mint)
const stableKey = node.documentId === undefined
? undefined
: stableNodeKey({ backendNodeId, documentId, frameId })
const ref = stableKey === undefined
? this.nextFallbackRef() // 没有稳定身份 → 临时 ref
: this.refForStableNode(stableKey) // 有 → 复用旧 ref

配套的生命周期:

  • beginSnapshot() 开始新快照时,清空「本轮可见集」但保留同文档的稳定 ref 分配(refs.ts:24)。
  • forkForSnapshot() 拿一份隔离副本去捕获,捕获成功才提交,避免半截快照污染状态(refs.ts:31)。
  • 只有当顶层文档真的换了(导航到新页面,documentId 变了)才 reset() 全部重来(observer.ts:249,shouldResetRefs)。

换句话说:同一个页面内反复快照,登录按钮永远是同一个 ref;只有真正导航走了才重新编号。 这就是为什么系统提示反复叮嘱「导航后要重新快照,refs 失效了」——因为只有那时候 ref 才真的失效。

5. 捕获期的一致性保护

网页是活的,捕获快照的几十毫秒里 DOM 可能正在变。Observer.capture 用「读—渲染—再读」夹住:渲染前后各读一次主 frame 状态,如果中途文档/URL 变了就重试(最多 3 次),否则丢出「页面在捕获时变了,请重试」:

// packages/browser-core/src/core/observer/observer.ts:89(capture)
for (let attempt = 0; attempt < MAX_STABLE_CAPTURE_ATTEMPTS; attempt++) {
const before = await this.readMainFrameState(pageSession.session)
const refs = this.refsForCapture(before)
const text = await this.captureFrame(/* ... */)
const after = await this.readMainFrameState(pageSession.session)
if (!knownMainFrameChanged(before, after)) {
return { text, refs, url: after.url, scope: refScopeFrom(after) }
}
}

这保证模型拿到的快照是某个确定时刻的一致视图,而不是半新半旧的缝合怪。

6. 关键细节 / 坑

  • ref 只在「同一棵树的语义」内稳定。 它靠的是无障碍树的 (role, name, nth) 也能反查(见 02 章的陈旧重定位),所以一个改了无障碍名字的元素会被当成新元素——这是设计取舍,不是 bug。
  • 快照是 token 大头。 它是整棵可见可操作树的文本;真正给模型的「便宜反馈」是 diff(03 章),所以提示策略是「动作后默认看 diff,需要新 ref 时才重新快照」。
  • iframe 深度有上限(MAX_FRAME_DEPTH = 5,observer.ts:12),防止恶意/病态的深层嵌套拖垮捕获。

7. 代码地图

主题文件符号
snapshot 工具(薄壳)packages/browser-mcp/src/tools/snapshot.tssnapshot
无障碍树 → 文本packages/browser-core/src/core/snapshot/render.tsrenderSnapshot / formatLine / formatStates / isDropped
ref 分配与稳定身份packages/browser-core/src/core/snapshot/refs.tsRefMap / mint / forkForSnapshot / beginSnapshot
每页观察状态机packages/browser-core/src/core/observer/observer.tsObserver / capture / captureFrame / shouldResetRefs
拉无障碍树packages/browser-core/src/core/observer/ax-tree.tsfetchAxTree
光标命中补丁packages/browser-core/src/core/observer/cursor-augment.tsfindCursorHits