之前,我用Google翻译API对Astro SSG网站进行了多语言化尝试的文章。这是一种使用Google Cloud Translation API的方法,在构建后对静态HTML进行翻译,并生成各种语言版本。作为一种成本较低的简易多语言化方案,它非常实用。
不过,运营了一段时间后,我们在 SEO 和翻译质量这两个方面都遇到了令人担忧的问题。关于"为什么放弃了 Google 翻译"的详细讨论,请参阅另一篇文章。
文章:机器翻译从 Google Translate 迁移至 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>
如果按常规方式处理翻译,我们 / 网络无障碍 / 高度重视这3个片段会被分别翻译。由于日语和中文的语序不同,把翻译后的片段放回原位置时,<strong>的作用范围会错位,或者本身就会变成不自然的句子。内联标签越多,问题越严重。这是旧系统最大的不满之处。
改进方法分为以下两个步骤。
第一点:按块级元素单位进行分块。翻译的最小单位不是"标签之间的间隙",而是整个块级元素的内容(innerHTML),例如 p、h1~h6、li、td、blockquote。不拆分句子。
「第二点:将内联标签替换为标记,将整个句子作为一个翻译单位传递。将分块中的 <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 |
添加一篇文章(多项翻译) | 约 $0.01 |
日常的部署几乎免费,添加文章也只需1日元到几日元。虽然初期重建需要一笔费用,但度过这个阶段后,运营成本反而比之前更低了。
总结
从Google翻译切换到LLM翻译后,运营成本基本没有增加,翻译质量也有了明显提升。
LLM并非万能,不能自动完成所有工作并保证完美。我们仍需要进行日常的调整工作来发现并消除问题。不过,由于修改内容集中在提示词和字典这些容易理解的地方,改进的循环变得更容易实施了。
从DTP跨越到Web世界,不知不觉中已掌握标签、前端开发、创意指导、无障碍设计等各项技能的"技术高手"。从Liberogic创业初期就活跃至今,如今是公司内部的"活百科"。最近沉迷于"能否在无障碍适配上更多依赖AI?"这样的思考,正在探索借助AI提示词提高效率的方法。技术实力和思维方式都还在不断进化中
二俣 歩
IAAP 认证网络无障碍专家 (WAS) / 标记语言工程师 / 前端工程师 / 网络总监