---
title: "Google翻訳APIをやめてLLM翻訳に作り直した話 ― Claude API + R2差分キャッシュで多言語化"
date: 2026-09-24
categories: column
author: 二俣
canonical: https://www.liberogic.jp/topics/20260925-claude-API-translation-dev/
---

# Google翻訳APIをやめてLLM翻訳に作り直した話 ― Claude API + R2差分キャッシュで多言語化

![](https://images.microcms-assets.io/assets/4b13731f29254025b91c8d846198ffc9/6f512e4942fb45a1b1b9138e99aed1e9/cover.png)

Google Cloud Translation APIを使い、ビルド後の静的HTMLを翻訳して各言語版を生成する仕組みは費用を抑えた簡易多言語化としては十分実用的でした。ただ、しばらく運用していると、SEOと翻訳品質の両面でどうしても気になる点が出てきました。この記事は 「じゃあ実際どう作り直したのか」という実装の話です。エンジンをGoogle翻訳からLLM（Claude API）に載せ替え、ついでに前回の積み残しだった運用の手作業も一掃しました。



以前、[Astro SSGサイトをGoogle翻訳APIで多言語化してみた](/topics/20260313-astro-ssg-google-translate-api/)という記事を書きました。Google Cloud Translation APIを使い、ビルド後の静的HTMLを翻訳して各言語版を生成する仕組みです。費用を抑えた簡易多言語化としては十分実用的でした。

ただ、しばらく運用していると、SEOと翻訳品質の両面でどうしても気になる点が出てきました。「なぜGoogle翻訳をやめたのか」という話は別記事を見てみてください。

記事：[機械翻訳をGoogle翻訳からClaude APIへ移行](/topics/20260812-claude-API-translation/)

この記事は 「じゃあ実際どう作り直したのか」という実装の話です。エンジンを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`）がやっていることは、おおまかにこの順番です。

1. Cloudflare R2 から翻訳キャッシュ（単一のJSONファイル）をダウンロード
2. `dist/` 配下の各HTMLを cheerio で読み込み、翻訳すべきテキストを抽出
3. キャッシュにあればそれを使い、無いものだけ Claude API に投げる
4. 翻訳結果でテキストを差し替え、`dist/{locale}/` に書き出す
5. 新しく翻訳した分をキャッシュにマージして R2 にアップロード

ポイントは「変わったところだけ翻訳する」差分翻訳と、そのキャッシュを R2 に置くことの2つです。

## ① インラインタグをまたぐ訳をどう自然にするか

今回いちばん改善したかったのがこれです。

たとえば本文にこういうHTMLがあるとします。

```
`<p>私たちは<strong>ウェブアクセシビリティ</strong>を重視しています</p>
`
```

普通に翻訳を処理すると、`私たちは` / `ウェブアクセシビリティ` / `を重視しています` という3つの断片を別々に訳すことになります。日本語と英語では語順が違うので、訳した断片を元の位置に戻すと `<strong>` がかかる場所がずれたり、そもそも文として不自然になったりします。インラインタグが多い文ほど崩れる。これが旧仕組みの一番の不満でした。

改善方法は以下の2段構えです。

**1つ目：ブロック要素単位でチャンク化する。** 翻訳の最小単位を「タグの隙間」ではなく、`p` `h1`〜`h6` `li` `td` `blockquote` といったブロック要素の中身（innerHTML）まるごとにします。文を分断しません。

**2つ目：インラインタグをマーカーに置換して、文全体を1つの翻訳単位として渡す。** チャンクの中の `<strong>` や `<a>` を、いったん `[[T1]]...[[/T1]]` のようなマーカーに置き換えます。

```
`私たちは[[T1]]ウェブアクセシビリティ[[/T1]]を重視しています
`
```

LLMには「これは1つの文。自然に訳したうえで、強調すべき語に同じマーカーを付け直してよい。マーカーの位置は訳語の語順に合わせて動かしてよい」と指示します。訳が返ってきたら、マーカーを元の `<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上の古いキャッシュしか見られません。仕方なく「記事を足したらローカルでビルドして、更新されたキャッシュファイルをGitにpushする」という運用ルールでしのいでいました。

今回はキャッシュを Cloudflare R2 上の単一JSON blobに置きました。ビルドのたびに R2 から取得して、終わったら書き戻します。これで webhook経由のビルドでもキャッシュが永続化 され、ローカルビルド→push の手作業が完全に消えました。記事を追加して push すれば、増えた分だけが翻訳され、キャッシュも自動で更新されます。

## 翻訳の優先順位

訳語のブレ（社名 Liberogic の表記揺れなど）は前回も悩みどころでした。今回は4段階で処理しています。

1. **手動オーバーライド（**`data-i18n-key`） ― HTML側で `data-i18n-key` を付けた要素は、あらかじめ用意した手書きの訳で固定します。LLMにも辞書にも任せず「ここだけは絶対この訳にしたい」という対応ができます。
2. **用語辞書（glossary）** ― ナビやページタイトルのような繰り返し出る固定ラベルは、JSONの辞書で訳語を固定します。辞書を直して再ビルドすれば即反映されます。
3. **R2キャッシュ** ― 上のどちらにも無ければキャッシュを見ます。
4. **Claude API** ― どれにも無いものだけ、最後にLLMへ。

本文中に出てくる用語の統一は、ピンポイントの辞書では拾いきれない（部分一致には対応していない）ので、システムプロンプト側の用語ヒントで緩くそろえます。

## LLMの「癖」と付き合う

機械翻訳がおかしな訳を出すのは前回も同じでしたが、LLMにはLLMなりの癖があります。実運用で見えてきたものをいくつか紹介します。

- **技術系の文がまるごと英語のまま残る。** API・React・Vue といった「訳さない用語」リストを過剰適用して、文全体を英語のまま返してくることがあります。ユーザープロンプトで「対象ロケールの言語で出力せよ」を強めに念押しして対処。
- **マーカーの位置が意味的に逆転する。** 強調すべき語の判断を取り違えて、`<strong>` が変な範囲にかかることがあります。該当エントリだけ消して訳し直すと直ります。
- **CJK言語でレスポンスが途中で切れる。** 漢字は1文字あたりのトークン消費が多く、まとめて投げると出力が上限に達してJSONが壊れます。`max_tokens` を引き上げ、1バッチの件数を抑えて対処。
- **リテラルの **`\n` やCJK日付の崩れ。 改行が文字列 `\n` として混入したり、`2026 年 05 月 22 日` のように余計なスペースが入ったり。これらは後処理スクリプトで一括掃除します。

ひとつ運用知として効いたのは、**おかしい箇所は1件ずつ消して訳し直すのが一番成功率が高い**ということ。複数まとめて直そうとすると、LLMが同じ構造を一括で同じように誤判定する傾向があります。急がば回れでした。

## コストの話

エンジンは **Claude Haiku 4.5** に統一しています。コスト重視で、品質に問題が出たらまずプロンプトと辞書で改善する方針です。システムプロンプトには prompt caching を効かせて、毎回同じ固定部分のコストを圧縮しています。

実測の目安はこんな感じです。

内容コストあるロケールの初回フル翻訳約 $2.5〜3全ロケールの初回フル翻訳約 $20通常のデプロイ（キャッシュヒットのみ）ほぼ $0記事を1本追加（数件の翻訳）約 $0.01

日常のデプロイはほぼ無料、記事追加も1円〜数円となっています。初回の作り直しこそまとまった費用がかかりますが、そこを越えればランニングはむしろ前より軽くなりました。

## まとめ

Google翻訳からLLM翻訳への載せ替え、結果として運用コストはあまりかからず、翻訳の質もかなり向上しました。

LLMだからすべて自動で完璧、とはなりません。癖を見つけては潰す地道な調整は続きます。でも、いじる箇所がプロンプトと辞書という分かりやすい場所に集約されたので、改善のサイクルは回しやすくなりました。
