此前,我们撰写过一篇题为《用 Google 翻译 API 为 Astro SSG 站点实现多语言化》的文章。该方案使用 Google Cloud Translation API,对构建后的静态 HTML 进行翻译并生成各语言版本。作为控制成本的简易多语言化方案,它已经足够实用。
不过,运行一段时间后,我们在 SEO 和翻译质量两方面都发现了一些令人在意的问题。关于“为什么停用 Google 翻译”的内容,请参阅另一篇文章。
文章:机器翻译从 Google 翻译迁移到 Claude API
本文讲述的是“那么实际上是如何重新构建的”这一实现细节。我们将翻译引擎从 Google 翻译替换为 LLM(Claude API),同时也消除了上次遗留的、依赖人工操作的运维环节。
构建时通过 LLM 进行翻译,并将差异缓存存入 R2
基本方针与上次相同:翻译在构建时(服务器端)完成。我们不会从浏览器直接调用 Claude API。不同之处在于翻译引擎以及缓存的存放位置。
构建流程如下。
npm run build
├── fetch-microcms (microCMSから記事データ取得)
├── astro build (日本語HTMLを生成 → dist/)
├── translate (各ロケールのHTMLを生成 → dist/{locale}/)
└── update-xml (sitemap更新)
translate 的内容(translate-html-llm.mjs)大致按以下顺序执行。
- 从 Cloudflare R2 下载翻译缓存(单个 JSON 文件)
- 使用 cheerio 读取
dist/下的每个 HTML 文件,并提取需要翻译的文本 - 如果缓存中已有结果则直接使用,仅将没有缓存的内容发送给 Claude API
- 用翻译结果替换文本,并输出到
dist/{locale}/ - 将新翻译的内容合并到缓存中,并上传至 R2
要点有两个:只翻译发生变化的部分的增量翻译,以及将其缓存存放在 R2 中。
① 如何让跨越行内标签的译文读起来自然
这正是本次最希望改进的地方。
例如,正文中有如下 HTML。
<p>私たちは<strong>ウェブアクセシビリティ</strong>を重視しています</p>
如果按常规方式逐段翻译,我们、网页无障碍和所重视的这三个片段会被分开翻译。由于中日文语序不同,把译文放回原来的位置后,<strong>的标记可能会错位,甚至整句读起来也不自然。标记越多的句子,越容易出问题。这正是旧机制最大的不足。
改进方法分为以下两个步骤。
第一点:按块级元素为单位进行分块。 将翻译的最小单位从“标签之间的间隙”改为 p、h1至h6、li、td、blockquote 等块级元素的完整内容(innerHTML)。不会将句子切断。
第二点:将行内标签替换为标记,把整句作为一个翻译单元传递。 先将分块中的 <strong> 和 <a> 替换为类似 ... 的标记。
私たちは[[T1]]ウェブアクセシビリティ[[/T1]]を重視しています
向 LLM 发出指示:“这是一个完整的句子。请自然地翻译,并可为需要强调的词语重新附上相同的标记。标记的位置可根据译文的语序调整。”译文返回后,将标记还原为原来的 <strong> 和 <a href="...">。属性(如 href 和 class)则原样保留。
这样一来,<strong> 在英文一侧也能正确修饰对应的单词,句子读起来也自然了。
② 差异翻译与 R2 缓存的设计
如果每次都要全部翻译,无论多少成本都不够用。上一版也有 translate-cache.json 这样的缓存,但这次重新梳理了键的生成方式和存储位置。
修改提示词后重新生成译文的机制
缓存的键由“原始日文 + 语言区域 + 翻译提示词内容”组合生成。这样一来,原始日文变化时只有该条目会被视为“缓存中不存在”并重新翻译;修改翻译指示或术语规范(提示词)时,所有条目都会自动被视为“缓存中不存在”并重新翻译。
其目的是防止“提示词已经改进,但旧译文仍残留在缓存中”的问题。实际上,这些内容会经过 sha256 哈希处理,再以哈希字符串作为键。
缓存内容的结构如下。
{"<sha256のキー>":{"value":"翻訳結果(マーカー込み)","locale":"en","model":"claude-haiku-4-5-20251001","translatedAt":"2026-06-12T..."}}
缓存已迁移到 R2,手动操作已取消
这里是上次遗留问题的解决方案。
之前,缓存是以仓库中的 JSON 文件形式管理的。因此,在 CMS 中添加文章并通过部署钩子触发构建时,服务器只能读取 Git 上的旧缓存。无奈之下,我们只能采用一条操作规则来应对:添加文章后在本地构建,再将更新后的缓存文件 push 到 Git。
这次,我们将缓存存放在 Cloudflare R2 上的单个 JSON blob 中。每次构建时从 R2 读取,构建结束后再写回。这样,即使通过 webhook 触发构建,缓存也能持久保存,本地构建再 push 的手动操作也就彻底消失了。添加文章后直接 push 即可,只翻译新增的部分,缓存也会自动更新。
翻译优先级
译名不一致(例如公司名 Liberogic 的写法不统一)一直是上次就很头疼的问题。这次我们分四个阶段处理。
- 手动覆盖(
data-i18n-key)—— 在 HTML 中为元素添加data-i18n-key后,该元素将固定使用预先准备好的人工译文。无需交由 LLM 或词典处理,即可确保“这里必须使用这个译法”的需求得到满足。 - 术语词典(glossary) ——对于导航、页面标题等反复出现的固定标签,通过 JSON 词典固定译名。修改词典并重新构建即可立即生效。
- R2 缓存 ——若以上两者都没有匹配,则查询缓存。
- Claude API ——仅对以上都未覆盖的内容,最后才交给 LLM 处理。
正文中出现的术语统一,无法仅靠精确词典覆盖(不支持部分匹配),因此我们通过系统提示中的术语提示进行宽松统一。
与LLM的“怪癖”相处
机器翻译给出奇怪译文的问题,上次也是如此,但LLM自有其怪癖。下面介绍一些在实际运营中发现的问题。
- 整句技术内容原样保留为英文。 由于过度适用“不翻译术语”列表(如 API、React、Vue),有时会把整句都以英文返回。对此,我们在用户提示中再次强调“必须使用目标语言输出”来应对。
- 标记位置在语义上被颠倒。 对应该强调的词判断有误,导致
<strong>的范围落在了错误的位置。只要删除该条目并重新翻译,即可修正。 - CJK语言的回复会被中途截断。 汉字每个字符消耗的token较多,若一次性批量发送,输出会达到上限,导致JSON损坏。对此,我们提高了
max_tokens,并减少了每批的条目数。 - 字面量的
以及CJK日期格式错乱。 换行符以字符串的形式混入,或者像2026 年 05 月 22 日那样出现多余空格。这些问题将通过后处理脚本统一清理。
在运营中总结出的一条有效经验是:有问题的地方逐条删除并重新翻译,成功率最高。如果尝试一次性修正多条,LLM往往会对相同结构做出同样的错误判断。欲速则不达,这是我们的经验教训。
关于成本
引擎统一使用Claude Haiku 4.5。以控制成本为重点,若质量出现问题,我们首先通过改进提示和词典来解决。系统提示中启用了prompt caching,以压缩每次都相同的固定部分的成本。
以下为实测参考值。
内容 | 成本 |
|---|---|
单个语言的首次完整翻译 | 约 $2.5~3 |
全部语言的首次完整翻译 | 约 $20 |
常规部署(仅缓存命中) | 接近 $0 |
新增 1 篇文章(翻译数条) | 约 $0.01 |
日常的部署几乎免费,新增文章也只需几日元到十几日元。初次重建需要一笔不小的费用,但过了这一关,日常运行成本反而比以前更低了。
总结
从Google翻译切换到LLM翻译后,运营成本几乎没有增加,翻译质量也有了明显提升。
使用LLM并不意味着一切都能自动完美完成。发现问题、逐一修正的细致调整仍会持续。不过,由于需要调整的内容都集中在提示词和词典这些一目了然的地方,改进的循环变得更容易推进了。
从 DTP 进入 Web 领域,不知不觉间,标记、前端、项目指导、无障碍样样精通的“技术仙人”。自 Liberogic 创业初期起就身兼多职,如今已是公司内的活字典般的存在。最近沉迷于用提示词提升效率的探索:“无障碍支持,能不能更多地依靠 AI 呢?”技术与思考仍在不断进化中。
二俣 歩
IAAP认证网页无障碍专家(WAS) / 标记工程师 / 前端工程师 / 网页总监