Skip to content
Go back

我如何组织 AI 翻译扩展的缓存、批处理与 Provider

上一篇写了我在 Parallel Translate 中如何把网页转换成可恢复的双语阅读视图。页面侧的问题是找到正文、保留结构、按阅读进度渲染译文。

但阅读体验还有另一半:请求层。

当用户向下滚动文章时,页面会不断产生待翻译的句段;用户还可能划选句子、查看单词、切换翻译风格或更换模型。假如 Content Script 发现一段文字就直接调用一次远端接口,这个扩展即便能够工作,也很难成为一个长期使用的工具。

响应时间、调用成本、缓存正确性、Provider 差异和用户密钥边界,都应该在页面开始渲染译文之前被认真处理。

页面脚本不应该知道如何调用模型

浏览器扩展通常有不同执行上下文:注入网页的 Content Script 负责感知和修改页面,Background Service Worker 则适合处理配置、存储和网络请求。

在双语阅读场景里,我希望两者的职责足够窄:

flowchart LR
  A["Content Script<br/>正文与渲染"] -->|类型化翻译消息| B["Background<br/>请求编排"]
  B --> C["IndexedDB 缓存"]
  B --> D["Provider Adapter"]
  D --> E["翻译服务 / LLM"]
  E --> B
  B -->|结构化结果| A

Content Script 只提交“这段文本从什么语言翻译到什么语言”的请求,并等待结果插入页面。它不读取 API Key,不关心某家服务使用何种协议,也不判断请求是否应该合批。

Background 集中负责这些事情:

这样做的直接收益不是代码更漂亮,而是页面渲染不再和模型调用策略绑死。之后替换服务、调整批处理阈值或完善缓存,都不会要求重新设计 DOM 逻辑。

缓存键不是“文本做个 hash”就够了

阅读翻译非常适合缓存。同一篇文章被反复打开,或用户关闭后重新开启学习视图时,大部分句段并没有变化。对于不会改变的结果,重复支付等待时间和请求成本没有意义。

最初很容易想到的缓存键是:

源语言 + 目标语言 + 原文 hash

这对于最基础的逐句翻译成立,但当产品开始支持更多体验时,它会出现语义冲突。例如同一句原文,在下面几种设置下不应无条件复用结果:

因此,一个靠谱的缓存键应当代表“会影响输出的请求语义”,而不仅仅是原文:

translation:  sourceLang + targetLang + textHash + contextHash? + style?word-explanation:  sourceLang + targetLang + normalizedWordvocabulary-in-context:  sourceLang + targetLang + normalizedWord + contextHash

这里也有一个重要限制:如果用户更换 Provider 或模型后明确期待不同翻译风格,缓存策略是否继续复用旧结果,需要成为产品决策,而不能是偶然行为。可以让普通阅读优先复用稳定结果,也可以提供清理或刷新机制;但边界必须可解释。

我使用 IndexedDB 保存这类结果,因为它适合扩展本地的结构化数据和索引查询。缓存首先是性能优化,同时也是“用户重新进入阅读状态时不必重新等待”的体验基础。

批处理不是越大越省

页面扫描得到的翻译单位通常偏小。一段一请求的好处是返回关系明确,坏处是会造成大量短请求。对 LLM Provider 来说,请求头、系统指令和网络往返都会被重复支付;对用户来说,译文会零散地慢慢出现。

更合理的办法是在 Background 放一个很短的合批窗口:

新翻译请求到达  -> 先查缓存  -> 按语言、Provider 和翻译设置进入相容队列  -> 达到条数上限 / 字符上限 / 等待时限  -> 发出一次批量翻译  -> 将结果逐一写回缓存并交还页面

这里有三个阈值,各自控制一种风险:

合批并不意味着把整页拼成一次长提示词。阅读场景里,请求调度要向可感知的延迟让步:用户眼前的一两段内容,比尚未滚动到的十几段更重要。

另一个细节是批量响应必须能够可靠对应回原请求。实现上可以使用受控分隔约定或结构化输出,并严格检查返回项数量。如果期望四段译文,结果只解析到三段,就不能悄悄错位地插到页面中。

批量失败后,需要回到最保守路径

批处理引入了新的失败类型:某一段特殊输入可能让整批格式解析失败,或者 Provider 的返回格式没有遵守批量约定。

如果一批失败就让用户眼前所有段落都显示错误,批处理节约的请求反而换来了更差的阅读体验。

我的策略是让批量翻译成为优化路径,而不是唯一正确路径:

批量请求成功  -> 逐条缓存并返回批量请求失败 / 结果无法可靠拆分  -> 每个请求单独重试  -> 单条成功的仍然展示与缓存  -> 仅真正失败的段落显示失败状态

这种回退会在极少数情况下多花请求,但它把故障范围限制在真正有问题的内容上。对用户正在阅读的页面来说,部分可读远比整页一起失败更合理。

Provider 抽象要围绕能力,而不是围绕厂商名

一个翻译扩展未必只服务于一种后端。有的用户希望使用普通机器翻译以获得速度和较低成本,有的用户愿意使用 LLM 以得到更自然、包含上下文的句子翻译,还有用户会配置自己已有的兼容接口。

如果页面侧直接判断“这是某个厂商,就用某条请求路径”,Provider 差异很快会渗透整个应用。

我更关心的是功能需要什么能力:

type Feature =  | "pageTranslation"  | "selectionTranslation"  | "dictionary"  | "vocabulary";

页面全文翻译重视吞吐、缓存和可预测成本;单词解释可能需要结构化释义;划选翻译更重视即时响应。用户可以为不同功能选择适合的 Provider,而请求层把它们转换成统一的应用结果。

应用内部只依赖自己的响应结构,例如:

interface TranslateResponse {  original: string;  translated: string;  sourceLang: string;  targetLang: string;}

至于底层服务用何种 API 格式、是否支持真正的批量请求、是否需要模型名称,都应该被适配层封装起来。

这个抽象还有一个现实好处:第三方兼容接口经常“看起来兼容”,但实际上只覆盖部分端点或参数。把协议差异限定在 Provider Adapter 中,出现问题时更容易定位,也不会污染阅读页面的行为。

词典和 AI 不该被混成同一次调用

双语阅读不只有整段翻译。用户在阅读过程中停留在一个词上,期望的是快速、稳定的词义提示;如果需要进一步理解上下文,再由 AI 补充解释。

这类交互和整段翻译的性质不同:

因此,请求层应区分页面翻译、词条解释和上下文词汇注解。它们可以共享 Provider 配置和 IndexedDB 基础设施,但不应该共享含糊的一种“AI 请求”。

对产品来说,这也契合阅读学习的目标:母语支架不是越多越好,而是在用户真正遇到障碍时及时出现。

请求层还承担隐私边界

浏览器扩展的请求架构不能只以功能完成为标准。

用户阅读的网页内容可能包含付费文章、内部文档或个人信息。只要扩展会将页面片段发送给外部翻译 Provider,就应当在产品界面和隐私政策中清晰说明触发条件、发送范围和服务对象。

我认为至少要认真完成这些工程约束:

具体服务配置可以不暴露给使用者,但产品不能略去这些承诺。对浏览器扩展而言,信任不是额外包装,而是请求层一开始就必须遵守的限制。

结语

在一个双语阅读扩展里,翻译结果出现在段落下面的那一刻看起来最重要。但要让这个瞬间稳定发生,背后需要一套不抢戏的请求层:命中缓存时立即返回,需要合批时克制等待,批量失败时退回单条路径,Provider 改变时仍保持页面契约稳定。

页面层解决“如何继续读”,请求层解决“这次阅读是否值得信任、等待和长期使用”。

这也是我做 Parallel Translate 时逐渐明确的取舍:AI 能让译文更自然,但只有把网络请求、数据边界和失败路径做好,它才有资格成为阅读过程中的支架。


Share this post on: