App Store の審査で一度リジェクトされて気づいたことがあります。私が個人開発で運営している iOS の壁紙アプリは10言語以上に対応しているのですが、設定画面では「壁紙」を Wallpaper と訳していたのに、ウィジェット追加のヒント文では Background と訳されていました。原因は単純で、文字列を追加するたびに「その1本だけ」を翻訳していたからです。1本ずつ訳すと、訳した時期も文脈も違うので、同じ概念に違う訳語が当たってしまいます。
この訳ブレは、翻訳の品質というより「翻訳の手続き」の問題です。人間の翻訳者を雇っても、用語集を渡さなければ同じことが起きます。そこで私が選んだのは、用語集とスタイルガイドを Claude にキャッシュさせたうえで、Localizable.strings の全エントリを Message Batches API で一括翻訳する設計でした。以下では、その設計と実装、数千文字列を流したときのコストの実測、そして書き戻し時にハマった落とし穴までを順に共有していきます。
ここでのポイントは、用語集とスタイルガイドを「翻訳対象の文字列とは分離した、変化しない前置き」として置くことです。可変部分(実際に訳す文字列)を system ではなく messages 側に置くことで、system がキャッシュヒットし続けます。逆に、文字列ごとに system を書き換えてしまうとキャッシュは毎回ミスします。私はこの分離を最初に守れず、キャッシュヒット率がゼロのまま「キャッシュが効かない」と悩んだ時期がありました。
ここまでで「1リクエストの正しい形」が決まりました。次は規模の問題です。10言語 × 数百〜数千文字列を同期 API で順番に叩くと、時間もコストもかさみます。Message Batches API は、最大10万リクエストをまとめて非同期で処理し、しかも同期 API の半額で実行できます。UI 文字列の翻訳のように「即時性は不要、でも量が多い」タスクにはこれ以上ない適合です。
# build_batch.py — 言語ごとにバッチリクエストを組み立てて投入するfrom anthropic.types.messages.batch_create_params import Requestfrom anthropic.types.message_create_params import MessageCreateParamsNonStreamingdef chunk(d: dict, size: int): items = list(d.items()) for i in range(0, len(items), size): yield dict(items[i:i + size])def submit_batch(all_entries: dict, target_langs: list[str], chunk_size: int = 40): requests = [] for lang in target_langs: system = build_cached_system(lang) for idx, group in enumerate(chunk(all_entries, chunk_size)): requests.append(Request( custom_id=f"{lang}-{idx}", # 後でどの言語のどの塊か特定する params=MessageCreateParamsNonStreaming( model="claude-opus-4-8", max_tokens=4096, system=system, tools=[TRANSLATE_TOOL], tool_choice={"type": "tool", "name": "emit_translations"}, messages=[{"role": "user", "content": build_user_message(group)}], ), )) batch = client.messages.batches.create(requests=requests) print(f"submitted batch: {batch.id}, requests={len(requests)}") return batch.id
# collect.py — バッチ完了を待って結果を Localizable.strings に書き戻すimport time, jsondef wait_and_collect(batch_id: str) -> dict: while True: batch = client.messages.batches.retrieve(batch_id) if batch.processing_status == "ended": break time.sleep(30) # 完了までポーリング results: dict[str, dict] = {} # {lang: {key: value}} for entry in client.messages.batches.results(batch_id): lang = entry.custom_id.rsplit("-", 1)[0] if entry.result.type != "succeeded": print(f"⚠️ failed: {entry.custom_id} ({entry.result.type})") continue for block in entry.result.message.content: if block.type == "tool_use": for pair in block.input["translations"]: results.setdefault(lang, {})[pair["key"]] = pair["value"] return results
書き戻しの直前に、必ずバリデーションを入れます。ここを省くと、フォーマット指定子が消えた訳文が本番に出てクラッシュします。私が Law of Attraction のアプリで実際にやらかしたのが、%@ が訳文から1つ消えていたケースで、該当言語のユーザーだけが特定画面で落ちるという、再現の難しいクラッシュでした。
import rePLACEHOLDER = re.compile(r'%(?:\d+\$)?[@dfsu]')def validate(src: str, dst: str) -> list[str]: errors = [] # フォーマット指定子の個数と種類が原文と一致するか if sorted(PLACEHOLDER.findall(src)) != sorted(PLACEHOLDER.findall(dst)): errors.append("placeholder mismatch") # 改行コードの数が保持されているか if src.count("\\n") != dst.count("\\n"): errors.append("newline mismatch") # 引用符が閉じているか(.strings の構文を壊さないため) if dst.count('"') % 2 != 0: errors.append("unbalanced quote") return errorsdef write_strings(lang: str, src_entries: dict, translated: dict, out_path: str): lines, failed = [], [] for key, src_val in src_entries.items(): dst_val = translated.get(key) if dst_val is None: failed.append(key) continue problems = validate(src_val, dst_val) if problems: failed.append(f"{key} ({', '.join(problems)})") continue escaped = dst_val.replace('"', '\\"') lines.append(f'"{key}" = "{escaped}";') with open(out_path, "w", encoding="utf-8") as f: f.write("\n".join(lines) + "\n") print(f"[{lang}] wrote {len(lines)} / failed {len(failed)}") return failed
ここが課金して読む価値のある部分だと思うので、私が実際に観測した感触を率直に書きます。約2,400文字列 × 6言語(合計14,400翻訳)を1回流したケースでは、用語集とスタイルガイドが各言語で共通のため、プロンプトキャッシュのヒットによって入力側のコストが体感で6〜7割ほど下がりました。用語集を毎リクエスト実コストで送っていた頃と比べると、文字列が増えるほどキャッシュの恩恵が効くのを実感します。さらに Batch API の50%割引が全体に乗るので、同期 API で同じ量を順次処理していた頃のコストの半分以下に収まりました。
数値の正確な値は、モデル単価・用語集の長さ・チャンクサイズで変わるので、自分のアプリで小さく一度流して測ることを強く推奨します。私の判断基準はシンプルで、「用語集が長く、対象言語が多く、文字列数が多い」ほどこの設計のコスト優位は大きくなります。逆に、数十文字列を1言語だけ直すような小さな修正では、バッチの完了待ち時間のほうが煩わしいので、同期 API でその場で訳しています。用途で使い分けるのが現実的です。
つまずきやすい4つの落とし穴
実装中に私がはまった順に並べます。
system を文字列ごとに書き換えてキャッシュが効かない: 用語集は不変の前置きとして固定し、可変の翻訳対象は messages 側に置きます。キャッシュは system の前方一致で効くため、ここを混ぜると毎回ミスします。