别替能看图的模型读字:删掉 OCR 的 1391 行

一次「删比加多」的改动:+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 | 降幅 |
|---|---|---|---|
| 英文百科 | 3403 | 2838 | −17% |
| 中文问答 | 1780 | 1399 | −21% |
| 代码仓库 | 2296 | 2258 | −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 行代码。删掉它只花了一次提交。