一次「删比加多」的改动:+563 / −1391 行,把桌面工具的 OCR 文字模式换成拼图长截图。

1. 起点:一屏一张图,太贵了⌗

MaxClaw 的 agent 操作网页,走的是一条很「笨」的路:给自己开一个专属的 Firefox 窗口,看只看这个窗口的截图,动只用真鼠标真键盘,按截图坐标点。不读 DOM,不注入脚本查元素——那些动作网站能识别为自动化。

笨路的代价是观察成本。读一篇长文章,就得 scroll → screenshot → scroll → screenshot……窗口是 1280×816,按 宽 × 高 ÷ 750 估算图片 token:

1280 × 816 ÷ 750 ≈ 1393 token / 屏
4 屏                ≈ 5572 token

模型嫌贵的时候,会想去用调试通道直接读 DOM——这正是设计想避免的。所以需要一个更便宜的「只读」观察方式。

第一个答案是 OCR。

2. OCR 方案:一层一层补启发式⌗

screenshot{as:'text'} 的思路很自然:页面一侧和截图完全一样(同一次窗口截图,没有额外页面操作),区别只在截完之后——图在本机交给系统 OCR(macOS 上是 Apple Vision,Windows 上是 Windows.Media.Ocr),返回带坐标的文字行,不返回图片。

然后问题就开始一个接一个冒出来,每个问题配一层启发式:

问题补上的机制涉及的阈值 / 规则
连续滚动时,与上一屏重叠的行重复返回按两屏 scrollY 差 Δ 对齐,去掉重叠行x 差 ≤ 6px;上沿/中线/下沿之一对齐 ≤ 3px;NFKC 归一后编辑距离相似度 ≥ 0.85;短串(< 6)必须全等;数字串必须相同
被视口边缘截断的半行截断判定矮 4px 以上相似度放宽到 0.5;反过来更高的一律算新行
固定的导航、侧栏每屏都回来固定行判定Δ=0 比一遍,同位置 ±2px 或框一致且至多差一个字符
多栏页面按行交错竖向空白切分(XY-cut 的竖切)覆盖行数 ≤ max(1, ⌈6% × 行数⌉) 的连续区间宽 ≥ max(16px, 1.5% 屏宽) 才算栏缝;不到 8 行不分栏
什么时候上一屏记录作废失效规则navigate、click、type、hotkey、横向滚动、URL 变、窗口尺寸变、scrollY 缺失……

每一条都有道理,每一条都有测试。它们分布在 text-observation.ts、text-reading.ts、text-columns.ts、text-repeats.ts、vision-lines.ts、Windows 的 windows-ocr.ps1,加上一个专门的 e2e。

去重的效果也是实测过的(同一套 e2e,三个公开页面:一篇英文百科条目、一个中文问答页、一个代码仓库首页;每页 4 屏,token 按「字符数 ÷ 4」估):

页面去重前 token去重后 token降幅
英文百科34032838−17%
中文问答17801399−21%
代码仓库22962258−2%

另一组 400px 小步滚动、重叠更多的测试降幅到 −27% 到 −37%。一点没删错正文行,误删审计也做了。

工程上无可挑剔。问题是方向。

3. 两个它没法补的洞⌗

3.1 OCR 会认错,模型无从察觉⌗

同一份实测:中英混排的测试页,去掉空白后 563 个字符里 9 处错,字符准确率 98.4%。错的都是字形级的:0→O、S→s、1/l/I 互混、x→×、引号变成字母;浏览器界面里的小字还会繁简混淆。

98.4% 听起来不错,但要看错在哪。错的恰恰是配置值、命令、数字这些「一个字符都不能错」的地方——而 as:'text' 原本的卖点之一就是「逐字准确的内容用文字模式读」。

更要命的是:模型拿到的是一串干净的文本,错字和对字长得一模一样。看图时模型对糊的地方会犹豫;看 OCR 结果时,它连犹豫的线索都没有。识别把不确定性抹平成了确定的错误。

3.2 「÷ 4」对中文是乐观估计⌗

上表的 token 是「字符数 ÷ 4」估出来的,这是英文的经验值。汉字一个字远不止四分之一个 token,再加上每行都带 (x,y 宽×高) 坐标前缀。真实使用里,中文页面的文字版比图还贵。

即便按这个对文字有利的估算,下一节会看到,英文页和代码仓库页的文字版也已经输给了图。

4. 换一个基线:把四屏拼成一张⌗

OCR 方案赢的对手其实是「一屏一张图」。但图片的 token 按像素算,而截图有个上限:长边超过 1568 会被缩到 1568。那就有了一个很直接的办法——把多帧拼成一张,让缩放替你压缩。

screenshot{pages:N}(N = 1–4):

 第 1 帧:当前视口
   │  滚动 ≈ 0.9 × 视口高,等滚动停稳,读回 scrollY
 第 2 帧
   │  ……
 第 N 帧(或者滚不动了,提前停,atBottom: true)

 拼图:
 ┌────────┬────────┐
 │ 1      │ 2      │     2 帧:左右并排
 │        │        │     3 帧:2×2,第 4 格留空
 ├────────┼────────┤     4 帧:2×2
 │ 3      │ 4      │     帧间 4px 灰缝,左上角红底白字序号
 │        │        │
 └────────┴────────┘
   2564×1636 → 长边缩到 1568 → 1568×1000

token 账:

方式尺寸token
单屏截图1280×816≈ 1393
4 屏分开截4 × 1280×816≈ 5572
4 帧拼图1568×1000≈ 2091(−62%,每帧 ≈ 523)

三个测试页的实测都是 1568×1000、2091 token。放回第 2 节的对照:英文百科 OCR 去重后 2838、代码仓库 2258,都比拼图贵;中文页 1399 是按严重偏低的 ÷4 估的。而拼图没有任何识别环节,模型看到的就是像素。

窗口在更高像素密度的屏上时更划算:每帧原图先被缩到 1568 宽(单张约 2089 token,分开 4 张约 8356),拼图结果还是 2091。

5. 机制里值得一说的几个细节⌗

滚动量读回,不假设。 每一步按 0.9 × innerHeight 去滚,但实际滚了多少以停稳后读回的 scrollY 为准。实测中文问答页那一组,请求 658px,第二次只动了 549px(单次滚轮有上限)。帧间重叠或缺口因此可能变大,frameScrollY 如实返回 0 / 549 / 1098 / 1647,不粉饰。读回量为 0 就停,帧数如实变少。

接着上一次往下看。 窗口停在最后一帧,不滚回去。如果最近一次观察就是长截图、且之后 scrollY 没变,下一次长截图先滚一步再截第 1 帧,不把上一张的最后一帧再拍一遍;已经在页底就只截一帧并说明。中间做过普通截图、navigate,或手动滚动改变了位置,就不接。

拼图不是坐标系。 这是最容易出事的地方:拼图缩过、拼过,上面的像素坐标对应不到页面任何位置。所以长截图之后,click / move / drag 和带 x,y 的 scroll 直接报错:

窗口 N 最近一次截图是长截图(pages>1):拼图里的坐标不是页面坐标。
先做一次普通 screenshot(不带 pages),再按那张截图的坐标操作。

不带坐标的 scroll、type、hotkey 不受影响;普通截图、navigate、browser-open 清掉这个状态。pages>1 也拒绝与 grid / region 同用——它们按单张截图坐标工作。读和点用两种观察,用类型约束代替「模型自己记得」。

滚动是输入,截图不是。 每一步滚动单独进一个桌面输入临界区(多个 agent 共享一块桌面时按到达排队),截图不占。四帧长截图不会把桌面锁住几秒。

每帧尺寸必须一致。 截图期间窗口变了大小,拼图直接拒绝,而不是拼出一张错位的图。

平台无关。 macOS 用滚轮,Windows 用按键消息滚动,各自截图;拼图和记账代码共用。OCR 时代每个平台各要一套识别后端(Swift 的 --lines 模式、PowerShell 调 Windows.Media.Ocr),现在一套都不要了。

测试即规格,browser-actions.test.ts 里长截图那一组的用例名几乎可以直接当文档读:

four frames: the first is the current viewport, each next one is 90% of the viewport further down, laid out 2×2
the scroll distance is read back, not assumed
a page that cannot scroll further ends the shot early, with the frames it got and atBottom
asking again right after a long shot continues below it
no scroll-first when the window moved since the long shot, or when a plain screenshot came in between
the sheet is no coordinate system: click / move / drag and an aimed scroll say to take a plain screenshot first
the scroll steps are input: each is its own critical section, the captures are not

6. 代价:说清楚它不擅长什么⌗

拼图不是免费的午餐,代价在清晰度:

  • 每帧只剩原来 0.612 倍 的线性尺寸(2564 → 1568)。16px 正文在拼图里约 9.8px,15px 约 9.2px。能读,但小字号会糊。
  • 每帧都带着浏览器自己的标签栏和地址栏,约 100px、占帧高八分之一,在 4 帧里重复 4 次。去掉它需要问页面视口位置,这和「除滚动位置外不问页面」的原则冲突,所以留着。
  • pages 上限 4,只是因为只定义了到 2×2 的布局。

应对方式也写进了用法:要看清某一块,滚回去用普通截图,必要时加 region 放大;要逐字准确的东西(Key、Token、命令),用网页自己的复制按钮或选中后 cmd+c——这两条路由工具拦截进本会话的虚拟剪贴板,拿到的是原文,不经过任何识别。

注意这条规则 OCR 时代本来就有:文字模式的说明里也写着「一个字符都不能错的内容别照着文字行抄」。也就是说,OCR 并没有真正承担「逐字准确」这个职责,它只承担「省 token」——而这个职责,拼图做得更好。

7. 为什么删,而不是并存⌗

把 OCR 留作一个可选模式看起来更「稳妥」,但这次选择整体删除(实现留在 git 历史里,Capture Inbox 自己那一路 OCR 照旧),理由可以列成一张表:

OCR 文字模式拼图长截图
4 屏 token(英文页)2838(去重后,÷4 估)2091
中文页÷4 严重低估,实际更贵2091
失真字形级错字,模型不可察觉缩放模糊,模型看得出来糊
版面、图片、按钮外观丢失保留
平台后端每平台一套识别无
启发式阈值十几个一个 0.9
代码1391 行删除的大头long-shot.ts 85 行 + 调用侧

两个模式并存,模型每次都要选——而选错的后果(拿着错字当真)看不出来。工具面上多一个看起来合理的选项,就多一个让模型走错的岔路。

更一般的教训是:OCR 那一摞启发式每一层都在解决「文本不是页面」带来的问题——重叠、截断、固定元素、多栏、失效时机。它们越做越精,却是在为一个错误的中间表示还债。对一个原生能看图的模型,像素就是最好的中间表示;要省的是像素的数量,不是把像素换成别的东西。

找到这个结论花了 1391 行代码。删掉它只花了一次提交。