
从 OCR 到像素,而不丢掉那道退路
GeekBye 不再用 OCR 读你的截图,而是开始把真正的图片发给 vision 模型——那是容易的部分。有教益的部分是那个优化:它对 vision 用户完全跳过了 OCR,悄悄弄坏了对非 vision 模型的回退,最后不得不退回到一个让安全网并行运行、几乎不花任何代价的版本。
任何一个让你截个图再问 AI 的应用,都有一个真真切切的岔路口。你可以跑 OCR——在设备本地把文本抽出来,发一个很小的字符串,把图片扔掉。或者你可以 发送像素——把真正的图片送出去,让一个有 vision 能力的模型去读它。OCR 便宜又小,却对一切不是文本的东西视而不见:布局、图示、代码的缩进、错误下面那条红色波浪线。像素把这些全都保留下来,但要花更多字节,还需要一个看得见的模型。GeekBye 从那个岔路口的 OCR 一侧起步,横跨 v1.8.6 和 v1.8.7,走到了像素这一侧。那次切换是这个故事里容易的部分。有意思的部分是随它一起来的那个优化:它弄坏了回退,最后不得不被撤销。
从前:读文本,丢图片
在这段弧线之前,一张截图是这样变成答复的:你按下捕捉快捷键,客户端跑 OCR——在 macOS 上是通过一个 Swift VisionOCR 二进制调用 Apple 的 Vision 框架,在 Windows 上是一个 PowerShell 脚本,两者都在同一个 IOcrEngine 抽象背后——然后抽取出的文本被包成一个字符串,作为 screenshotText 发给后端。图片本身从没离开过这台机器。那很高效,对着一整墙纯文本也没问题。但一张截图很少只是文本。它是一份两栏布局的招聘启事,一道带图示的编程题,一个错误——它的堆栈跟踪的缩进本身就是线索。OCR 把这一切压平成一行一行的誊写,于是模型回答的是一个比你所问更单薄的问题。
切换:发送像素(v1.8.6)
v1.8.6 加上了另一条路。一个新的 imageOptimizer.ts,建在 sharp 之上、因而跨平台,对一张原始截图恰好做三件事:
sharp(pngFilePath).resize({ width: 1920, withoutEnlargement: true }).jpeg({ quality: 80 })
缩放到最宽 1920px(绝不放大一张更小的捕捉),把 PNG 转成 JPEG,质量 80。输出是原始 base64,媒体类型放在一个单独的字段里,它随两个新的请求字段一起送往后端,与旧的那个并列:screenshotImage 和 screenshotImageMediaType,独立于 screenshotText。后端现在可以收到一张图、一段文本,或者两者都收到。
那个优化步骤不是装点门面。一张原始截图 PNG 是 3–8 MB;缩放后的 JPEG 大约 200–500 KB。那是上传上的一个数量级之差,它也砍掉了模型的账单——vision 模型按一张图的分辨率把它切成瓦片、再按瓦片计费,所以把宽度封在 1920 并重新压缩,就直接减少了线上的字节和你要付钱的图像 token,而屏幕上文本的可读性没有什么实质损失。
那应用又怎么知道模型到底看不看得见呢?后端会告知它。/api/config 的响应多了一个按套餐分键的 ai.vision 块,客户端在每次拉取 config 时缓存一个布尔值——aiSupportsVision。当支持 vision 时,就优化图片;当不支持时,就回退到 OCR。所以那个 能力 是后端的事实,缓存在客户端,而跑还是跳过 OCR 的 每张截图的决定 则是在客户端根据那个标志作出的。到底哪个模型真正作答,仍然由后端掌握。
那个太聪明的优化
这里开始变得有教益。vision 路径的第一版把 OCR 和图像优化放在一起、并行地跑。然后一个 "perf" commit 把它收紧成了非此即彼:如果套餐支持 vision,就优化图片并 完全跳过 OCR;如果不支持,就跑 OCR 并跳过图像那部分活。纸面上这是明摆着的赢——何必去抽取一个 vision 模型并不需要的文本呢?changelog 甚至还打出了广告:"OCR is skipped when the AI can read the image directly."
问题出在回退这个词上。后端并不是只有一个模型;它有一个主模型和一个回退,而那个回退可能是一个 非 vision 模型。设想这个次序:你套餐的主模型支持 vision,于是客户端得意地跳过 OCR、只发图片。主提供商打了个嗝。后端回退到一个纯文本模型——它现在被递到手里的是一张它读不了的图片和 没有文本,因为客户端已经把文本优化掉了。快路径悄悄把安全网删掉了。你可以安全地丢掉一条多余的快路径;但你不能丢掉那条快路径本是用来 绕过 的那个东西。
修复:挂到回退上,而不是主模型上
这个修复才是值得偷师的部分。它不是回到永远都跑 OCR——那会把常见情形的赢给扔掉。它是把跳过挂到 第二个 capability 标志上:回退 支不支持 vision?后端的 config 多了一个 fallbackVision 块;客户端在第一个标志旁边缓存 aiFallbackSupportsVision。现在这个决定有四个分支:
- 主模型和回退都看得见 → 优化图片,跳过 OCR。快而常见的那条路。
- 主模型看得见但回退是瞎的 → 优化图片 并且 跑 OCR,作为给回退的保险。
- 图像优化抛异常 → 用 OCR 作硬性回退。
- 套餐根本没有 vision → 只用 OCR。
而第二个分支——那个保险的情形——正是最后一步要紧的地方。在图像优化 之后 才跑 OCR,会把它的延迟又加回到上头。所以两者在同一个 Promise.all 下一起跑:图像优化和 OCR 相互重叠,而那个只为万一后端需要才存在的 OCR,反正就在图片被优化的那段时间里跑完了。安全网只要没被用到,在挂钟意义上就是免费的。整个把戏一句话说完:留住那份便宜的回退,把它挂到你的回退提供方是否真的需要它上,并让它并行,这样在它救你一命的那天到来之前,它什么都不花。
这次跳过绊出的两个更小的陷阱
凶悍地跳过被推迟的工作,有一种本事:它会把每一处曾经默默假定那活儿会发生的地方都翻出来。同一周的 PR review 里冒出了两个。
一个队列死锁。 截图队列在一个条目有了 OCR 文本时,就认为它“就绪”。vision 截图现在——按设计——永远拿不到 OCR 文本,于是完成度检查 hasIncompleteOCR 永远地等下去,队列挂住了。修复把“就绪”重新定义为 有一张优化过的图片 或 有 OCR 文本,这正是流水线如今真正保证的东西。
一个过期的能力标志。 aiSupportsVision 是被缓存的,这意味着它会朝着恰好错误的方向变过期:一个用户登出,或者一次 config 拉取失败,客户端就被留在一种它以为自己仍可以把图片发给一个已经不再支持图片的模型的状态里。这个标志现在会在登出时和 config 拉取失败时重置——一个被缓存的能力,必须为“它所描述的那个东西消失了”的时候备好一套说法。
关于那些数字,说一句
因为诚实正是这个系列的要点:那些 commit message 里带着数字——图像优化 "~30ms" 对 OCR "~100–150ms",负载 "~200–500 KB vs ~3–8 MB PNG"——它们听着靠谱,但那是作者自己顺手写下的估计,不是一次基准测试的产物。历史里任何地方都没有一次实测的端到端前后延迟。把这个速度论点当作稳妥的设计意图,而不是一个已被证明的结果。负载体积的减少是你唯一能信的那个数字,因为它直接从缩放和重新压缩里掉出来。
这次发布教给我们的三件事
- 发送像素胜过读取文本——在模型看得见的时候。 OCR 丢掉了一张截图除字符之外传达的一切。一个 vision 模型读得懂布局、图示和缩进,而一张优化过的 JPEG 让这变得付得起。但这是一场对能力的下注,正因如此它才需要一个回退。
- 你可以把一条快路径优化掉,却永远不能把安全网优化掉。 对 vision 用户跳过 OCR,对主模型是对的,对回退是错的。bug 不在跳过本身;而在于当真正要紧的是回退的能力时,却依据主模型的能力去跳过。
- 一张免费的安全网,是一张并行化了的安全网。 把保险的活儿挂到它到底需不需要上,然后让它与快路径并排跑、彼此重叠。做对了,优雅降级在它成为你的功能仍然能工作的唯一原因的那天到来之前,什么都不花。
关于 v1 故事的上一章,见一个点击穿透浮层的两种失败模式(v1.8.5);整段弧线则见把软件打磨到完美地发布出去的解剖学。