数据截至 (上游 commit b67df9aa92cb)
03 · diff 与主循环:便宜的反馈
这章讲 BrowserOS 怎么用「只回变化的几行」当反馈,把一次动作的代价从「重发整棵树」压到几乎为零,以及这怎么塑造了 agent 的主循环。
1. 它要解决的小问题
模型点了个按钮,它需要知道「点完发生了什么」。最笨的做法是重新发整棵无障碍树——但那是几千 token,而且每个动作都这么干会让上下文迅速爆掉。我们想要的是:只告诉模型『页面变了哪几行』。
2. 思路/直觉:让快照行自带语义身份
回忆 01 章:快照里每一行就是一个节点的完整语义身份(角色 + 名字 + 状态 + ref)。这个设计在这里收获红利:
如果一个复选框从未选变成选中,它那一行的文本就从
- checkbox "同意"变成- checkbox "同意" [checked]。一次「删旧行 / 增新行」就天然读成「状态变了」——不需要任何专门的「状态变化检测」逻辑。
所以「页面变了什么」可以退化成一个纯粹的文本行 diff。diff.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 支持「后置动作」:act 调 includeDiff,工具执行框架在返回前自动跑这个后置动作,把 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:246):
┌─────────────────────────────────────────────┐
│ 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 行级 diff | packages/browser-core/src/core/snapshot/diff.ts | diffSnapshots / diffLines / buildLcsTable / collapse |
| 导航特例 / 观察级 diff | packages/browser-core/src/core/snapshot/diff.ts | diffSnapshotObservations |
| 有状态的快照/diff | packages/browser-core/src/core/observer/observer.ts | Observer.snapshot / Observer.diff |
| 动作后自动附 diff | packages/browser-mcp/src/response.ts | ToolResponse.includeDiff / runSessionPostAction / buildForSession |
| diff 工具 | packages/browser-mcp/src/tools/diff.ts | diff |