跳到主要内容

03 · diff 与主循环:便宜的反馈

这章讲 BrowserOS 怎么用「只回变化的几行」当反馈,把一次动作的代价从「重发整棵树」压到几乎为零,以及这怎么塑造了 agent 的主循环。

1. 它要解决的小问题

模型点了个按钮,它需要知道「点完发生了什么」。最笨的做法是重新发整棵无障碍树——但那是几千 token,而且每个动作都这么干会让上下文迅速爆掉。我们想要的是:只告诉模型『页面变了哪几行』。

2. 思路/直觉:让快照行自带语义身份

回忆 01 章:快照里每一行就是一个节点的完整语义身份(角色 + 名字 + 状态 + ref)。这个设计在这里收获红利:

如果一个复选框从未选变成选中,它那一行的文本就从 - checkbox "同意" 变成 - checkbox "同意" [checked]一次「删旧行 / 增新行」就天然读成「状态变了」——不需要任何专门的「状态变化检测」逻辑。

所以「页面变了什么」可以退化成一个纯粹的文本行 diffdiff.ts 的注释把这点说得很直白(diff.ts:28):每行是节点的语义身份,同 ref 的一删一增免费读成状态变化。

3. 实现:经典 LCS 行级 diff

diffSnapshots 是教科书式的最长公共子序列(LCS)diff,分三步:

① 相同直接短路。 agent 在循环里频繁 diff,绝大多数动作其实没改页面:

// packages/browser-core/src/core/snapshot/diff.ts:38(diffSnapshots)
if (before === after) {
return { text: '', added: 0, removed: 0, changed: false }
}

② 建 LCS 表,回溯打标签。 每行标成「未变(空格)/ 删(-) / 增(+)」:

// packages/browser-core/src/core/snapshot/diff.ts:107(diffLines 内)
if (beforeLines[i] === afterLines[j]) { tagged.push({ gutter: ' ', ... }); i++; j++ }
else if (table[i + 1][j] >= table[i][j + 1]) { tagged.push({ gutter: '-', ... }); i++ }
else { tagged.push({ gutter: '+', ... }); j++ }

③ 折叠未变行。 只保留变化行 + 上下 3 行上下文,中间用 省略——这样即使页面很大,diff 也只有巴掌大:

// packages/browser-core/src/core/snapshot/diff.ts:144(collapse)
for (let i = 0; i < tagged.length; i++) {
if (tagged[i].gutter === ' ') continue
const lo = Math.max(0, i - radius)
const hi = Math.min(tagged.length - 1, i + radius)
for (let j = lo; j <= hi; j++) keep[j] = true
}

一个 diff 给模型看起来像这样(示意):

- textbox "Email" [ref=e3]
- - button "Log in" [ref=e5]
+ - button "Log in" [ref=e5] [disabled]
+ - alert "Invalid password" [ref=e7]

2 added, 1 removed

模型一眼看懂:「登录按钮变灰了,冒出一条『密码错误』」。它没看整棵树,却拿到了所有有用信息。

4. 导航是特例:此时给整棵树

有一个例外:如果两次快照之间 URL 变了(导航走了),diff 没意义——整页都是新的。这时 diffSnapshotObservations 直接返回完整的新快照并标记 urlChanged:

// packages/browser-core/src/core/snapshot/diff.ts:62(diffSnapshotObservations)
if (isKnownUrl(beforeUrl) && isKnownUrl(afterUrl) && beforeUrl !== afterUrl) {
return { text: after.text, /* ... */ urlChanged: true, beforeUrl, afterUrl }
}

这也呼应 01 章:导航后 refs 全 reset、要重新快照——diff 在这里识趣地让位给全量快照。

5. 自动附带:动作完就把 diff 塞回结果

模型不用自己记得「动作后要 diff」——act 工具帮它做了。ToolResponse 支持「后置动作」:actincludeDiff,工具执行框架在返回前自动跑这个后置动作,把 diff 文本拼进结果:

// packages/browser-mcp/src/response.ts:140(runSessionPostAction, case 'diff')
const d = await session.observe(action.page).diff()
const origin = d.afterUrl ?? session.pages.getInfo(action.page)?.url ?? 'unknown'
await this.appendDiffPostAction(action, d, origin)

返回前还会加一行 --- Additional context (auto-included) ---(response.ts:210)分隔,让模型知道下面是自动附带的上下文。这就是为什么模型点完按钮,不用再单独调一次 diff——结果里已经有了。

6. 闭环:Observe → Act → Verify

把三章串起来,系统提示里写死的主循环是(apps/server/src/agent/prompt.ts:247):

┌─────────────────────────────────────────────┐
│ snapshot 先看一眼,拿到 [ref=eN] │ ← 01 章
│ │ │
│ ▼ │
│ act 用 ref 点/填/输入 │ ← 02 章
│ │ (自动附带 diff) │
│ ▼ │
│ diff 只看变化的几行,确认动作生效 │ ← 本章
│ │ │
│ └──── 需要新 ref 时才回到 snapshot ────────┘

提示的原话:「动作后读 act 的 diff 来确认成功;只在需要新 refs 时才调 snapshot」。这套循环让一轮轮操作的 token 成本主要落在第一次快照上,之后全是廉价 diff——这是 BrowserOS 能在有限上下文里跑很多步的关键。

7. 关键细节 / 坑

  • diff 是有状态的。 Observer 保存上一次快照当 baseline,diff() 捕获新快照、和 baseline 比、再把新快照设为新 baseline(observer.ts:64)。所以连续 diff 是「相对上一次」的增量。
  • diff 工具也单独存在。 除了 act 自动附带,模型也能主动 diff(page)(packages/browser-mcp/src/tools/diff.ts),用来「便宜地看一眼有没有变化」而不重发整棵树。
  • 折叠半径写死为 3。 collapse 默认上下文 3 行(diff.ts:52),是「看清变化周围」和「省 token」的折中。

8. 代码地图

主题文件符号
LCS 行级 diffpackages/browser-core/src/core/snapshot/diff.tsdiffSnapshots / diffLines / buildLcsTable / collapse
导航特例 / 观察级 diffpackages/browser-core/src/core/snapshot/diff.tsdiffSnapshotObservations
有状态的快照/diffpackages/browser-core/src/core/observer/observer.tsObserver.snapshot / Observer.diff
动作后自动附 diffpackages/browser-mcp/src/response.tsToolResponse.includeDiff / runSessionPostAction / buildForSession
diff 工具packages/browser-mcp/src/tools/diff.tsdiff