import { Callout } from '@/components/ui/callout';
「翻訳 API なんて DeepL を叩けば終わりでしょう」— かつて私もそう思っていました。ところが実際にプロダクトに翻訳機能を組み込み始めると、辞書には載っていない社内用語、トーンの統一、長文での文脈喪失、コストのブレ、再翻訳時の差分管理など、想像していたよりもはるかに泥臭い問題に遭遇します。
Claude API は、汎用 LLM ならではの「コンテキストを読んでスタイルを揃える」性能を持っています。一方でそのままでは、用語の揺れや過剰に丁寧な訳になりがちで、翻訳メモリ製品としては安定しません。ここでは翻訳 SaaS を実際に運用する想定で、用語集・スタイルガイド・ドメイン適応をどう組み合わせれば「DeepL を超える」と顧客に言ってもらえる品質に到達できるかを、コード例とともに整理します。
なお、本記事は翻訳 API の入門ではなく、すでに POC が動いている方が「次の運用フェーズ」に進むための設計書として書いています。RAG や評価ハーネスの基礎は別記事に譲り、ここでは翻訳ドメイン特有の論点に絞って踏み込みます。
翻訳 SaaS が満たすべき品質要件を言語化する
最初にやるべきは、「翻訳が良い・悪い」を曖昧な感覚で語らないことです。翻訳の品質は、いくつかの直交する軸に分解できます。
正確性(Accuracy) : 原文の事実関係が訳文で歪んでいないか
用語一貫性(Terminology Consistency) : 同一プロジェクト内で同じ用語が同じ訳語に揃っているか
スタイル一貫性(Style Consistency) : 文体・敬体常体・である調などが揃っているか
流暢さ(Fluency) : ターゲット言語のネイティブが読んで自然か
ローカライズ適応(Locale Adaptation) : 数値・日付・通貨・固有名詞の現地化が適切か
POC 段階ではすべてを「LLM の頑張り」に丸投げしがちですが、本番運用では各軸に独立した制御点を用意します。たとえば用語一貫性は glossary で、スタイルはガイド注入で、正確性はリファレンスとの突合で、と分担させると、後述の品質ゲートで定量的に観測できるようになります。
私はこの分解を最初の設計レビューでホワイトボードに書き出すことを強く推奨します。あとから「品質が低い」と言われたとき、どの軸が悪かったのかを切り分けられないと、プロンプトのどこを直せばいいか迷子になります。
アーキテクチャ全体像
翻訳 SaaS のコア構成は、以下の 5 ブロックに分けると見通しがよくなります。
入力前処理(HTML/Markdown/JSON 等の構造保持)
用語集・スタイルガイドの注入レイヤー
Claude API 呼び出し(モデル選定・streaming・retry)
後処理(タグ復元・整合性チェック・差分計算)
品質ゲート(自動評価+人間レビューのキュー)
それぞれが独立しているので、改善も別々に進められます。たとえば用語集ヒット率が低いプロジェクトは glossary レイヤーだけを強化し、品質ゲートまでは触らなくていい。これが「単一プロンプトで全部やる」設計と決定的に違うところです。
入力前処理 — タグ・プレースホルダーを失わせない
翻訳の事故で最も多いのは、HTML タグや変数プレースホルダー({user_name} や <a href="..."> など)が翻訳の過程で壊れることです。Claude は文意を理解できる分、タグを「意訳」して位置を変えてしまうことがあります。
対策は大きく 2 通りです。1 つは「タグをプレースホルダー番号に置換してから渡す」方式、もう 1 つは「翻訳対象テキストノードだけを抽出して翻訳する」方式。私はソース形式によって使い分けています。HTML や XML は AST ベースで抽出する後者が安全で、Markdown の場合は <mark1> のような明示プレースホルダーに置換する前者が実装が軽くて済みます。
// プレースホルダー方式の前処理(Markdown / プレーンテキスト向け)
type ProtectedText = {
protectedText : string ;
placeholders : Record < string , string >;
};
export function protectInlineMarkup ( input : string ) : ProtectedText {
const placeholders : Record < string , string > = {};
let counter = 0 ;
// Markdown リンク [text](url) の URL のみ保護
let protectedText = input. replace ( / \[ ( [ ^ \] ] + ) \]\( ( [ ^ )] + ) \) / g , ( _match , text , url ) => {
const key = `__URL_${ counter ++ }__` ;
placeholders[key] = url;
return `[${ text }](${ key })` ;
});
// 変数プレースホルダー {name} を保護
protectedText = protectedText. replace ( / \{ [a-zA-Z_][a-zA-Z0-9_] * \} / g , ( match ) => {
const key = `__VAR_${ counter ++ }__` ;
placeholders[key] = match;
return key;
});
// インラインコード `...` を保護
protectedText = protectedText. replace ( /` [ ^ `] + `/ g , ( match ) => {
const key = `__CODE_${ counter ++ }__` ;
placeholders[key] = match;
return key;
});
return { protectedText, placeholders };
}
export function restoreInlineMarkup ( translated : string , placeholders : Record < string , string >) : string {
let restored = translated;
for ( const [ key , original ] of Object. entries (placeholders)) {
// 翻訳でプレースホルダーが消えていないかをここで監視できる
if ( ! restored. includes (key)) {
throw new TranslationIntegrityError ( `Placeholder lost: ${ key }` );
}
restored = restored. replaceAll (key, original);
}
return restored;
}
class TranslationIntegrityError extends Error {}
このコードのポイントは、復元時にプレースホルダーが消えていればエラーを投げることです。翻訳結果をそのままユーザーに見せると、リンクが壊れた英語文章を出してしまいます。私が実装するときは、ここでエラーが出たらリトライ(後述)に回し、3 回失敗したら人間レビューキューに送る運用にしています。
用語集(Glossary)の注入と強制
用語の揺れは、翻訳 SaaS のクレームの 7〜8 割を占める印象です。たとえば「お客様」を一箇所だけ「カスタマー」と訳されただけで、レビュアーは記事全体の信頼性に疑問を持ちます。
Claude は long context に強いので、用語集を全部プロンプトに突っ込むのは技術的には可能です。しかしコスト効率と命中率の両面で、事前に原文に出現する用語だけを抽出してから渡す 方が有利です。私のプロジェクトでは平均でプロンプトサイズを 60% 削減しつつ、用語ヒット率を上げられました。
type GlossaryEntry = {
source : string ; // 原文の用語
target : string ; // 訳語(厳密に使う)
caseSensitive ?: boolean ;
context ?: string ; // 同綴り異義の場合の文脈ヒント
};
export function selectRelevantGlossary (
sourceText : string ,
glossary : GlossaryEntry []
) : GlossaryEntry [] {
const lower = sourceText. toLowerCase ();
return glossary. filter (( entry ) => {
const target = entry.caseSensitive ? sourceText : lower;
const term = entry.caseSensitive ? entry.source : entry.source. toLowerCase ();
return target. includes (term);
});
}
export function buildGlossaryInstruction ( entries : GlossaryEntry []) : string {
if (entries. length === 0 ) return '' ;
const lines = entries. map (( e ) => {
const ctx = e.context ? ` (context: ${ e . context })` : '' ;
return `- "${ e . source }" → "${ e . target }"${ ctx }` ;
});
return ` \n ## Mandatory Terminology \n You MUST use exactly these target translations for the following source terms. Do not paraphrase or substitute synonyms: \n ${ lines . join ( ' \n ' ) } \n ` ;
}
ここで重要なのは、用語集を「参考情報」として渡すのではなく MUST と明記する ことです。Claude はユーザーの意図を尊重するので、強い指示があれば原則として従います。さらに、翻訳結果のポスト検証で訳語が含まれているかをチェックすると、用語集違反を 90% 以上検出できます(後述の品質ゲートで実装します)。
スタイルガイドの注入とトーン制御
法律文書をフレンドリーに訳されたら困りますし、SNS 投稿を堅苦しく訳されても困ります。スタイルは案件ごと、あるいはターゲット読者ごとに切り替える必要があります。
私は以下の 4 軸でスタイルプロファイルを定義しています。
形式度(Formality) : casual / standard / formal
対象読者(Audience) : general / technical / executive
能動受動(Voice) : prefer-active / neutral / prefer-passive
長さ(Length Bias) : concise / faithful / expansive
これをプロンプトのシステム部に埋め込みます。
type StyleProfile = {
formality : 'casual' | 'standard' | 'formal' ;
audience : 'general' | 'technical' | 'executive' ;
voice : 'prefer-active' | 'neutral' | 'prefer-passive' ;
lengthBias : 'concise' | 'faithful' | 'expansive' ;
customNotes ?: string ;
};
export function renderStyleGuide ( profile : StyleProfile , targetLocale : string ) : string {
const formalityNote = {
casual: 'Use a conversational tone with contractions when natural.' ,
standard: 'Use a neutral, professional tone.' ,
formal: 'Use formal register; avoid contractions and colloquialisms.' ,
}[profile.formality];
const voiceNote = {
'prefer-active' : 'Prefer active voice unless passive is idiomatic.' ,
neutral: 'Choose voice that sounds most natural to a native reader.' ,
'prefer-passive' : 'Use passive voice where it conveys impartiality.' ,
}[profile.voice];
// 日本語ターゲットの場合は敬体/常体を明示
const localeSpecific = targetLocale. startsWith ( 'ja' )
? profile.formality === 'casual'
? 'For Japanese: use です・ます調 only when the original is conversational; otherwise use a neutral 敬体.'
: 'For Japanese: always use 敬体 (です・ます調). Do NOT use 常体 (である調).'
: '' ;
return [
'## Style Guide' ,
`- Formality: ${ formalityNote }` ,
`- Audience: ${ profile . audience }` ,
`- Voice: ${ voiceNote }` ,
`- Length: ${ profile . lengthBias === 'concise' ? 'Be concise. Drop filler.' : profile . lengthBias === 'expansive' ? 'You may expand for clarity.' : 'Match the source length closely.'}` ,
localeSpecific,
profile.customNotes ? `- Custom: ${ profile . customNotes }` : '' ,
]. filter (Boolean). join ( ' \n ' );
}
ここで localeSpecific を入れている理由が経験則です。日本語に訳すとき、Claude は訳文のトーンを原文に合わせようとして、英語の casual 文章を「〜だぞ」「〜である」のような常体に振ってしまうことがあります。日本のビジネス文書では常体は失礼に響くので、敬体で固定する明示的な指示が効きます。実装時に「思ったトーンと違う」と感じたら、locale 固有のニュアンスを 1〜2 行追加するだけでかなり改善します。
ドメイン適応 — Few-shot とリファレンス対訳の戦略的活用
医療・法務・金融・ゲームなど、ドメインによって正解の訳が大きく変わります。汎用モデルにドメインを教える方法として、私は以下の優先順で試します。
ドメイン用語集(前述)の精度を上げる
過去案件の対訳ペアを 2〜5 件、Few-shot として埋め込む
それでも足りない場合のみ、領域特化のリファレンス文書を RAG で取り寄せる
意外なことに、Few-shot 2 件で大半の案件は事足ります。「20 件入れたほうが良い」と思いがちですが、私の計測では 5 件を超えるとプロンプト膨張の割に精度向上が緩やかになり、コスト効率が落ちます。重要なのは 代表的な訳パターンを選ぶこと です。最初の 1 件は典型的な文、2 件目は最も間違えやすい用語を含む文、3 件目以降は逸脱しやすいエッジケース、という選び方をします。
type FewShotExample = {
source : string ;
target : string ;
note ?: string ;
};
export function selectFewShots (
sourceText : string ,
pool : FewShotExample [],
k : number = 3
) : FewShotExample [] {
// 単純な BM25 もしくは埋め込みコサイン類似度でランキング(ここでは簡易版)
const scored = pool. map (( ex ) => ({
ex,
score: lexicalOverlap (sourceText, ex.source),
}));
scored. sort (( a , b ) => b.score - a.score);
return scored. slice ( 0 , k). map (( s ) => s.ex);
}
function lexicalOverlap ( a : string , b : string ) : number {
const tokensA = new Set (a. toLowerCase (). split ( / \s + / ));
const tokensB = b. toLowerCase (). split ( / \s + / );
let hit = 0 ;
for ( const t of tokensB) if (tokensA. has (t)) hit ++ ;
return hit / Math. max ( 1 , tokensB. length );
}
本番では埋め込みベースの類似度検索に置き換えますが、最初は lexical overlap で十分動きます。Few-shot 選択が翻訳品質に与える影響は無視できないので、案件カテゴリごとに Few-shot プールを別管理することをおすすめします。
Claude API 呼び出しレイヤー — モデル選定と streaming
翻訳タスクではモデル選定が結果と原価を直接決めます。私の運用基準は以下のとおりです。
短文・UI 文言・通知メール → Claude Haiku(コスト最優先)
標準的なブログ・マーケコピー → Claude Sonnet(バランス重視・既定)
法務・契約書・医療文書 → Claude Opus(品質最優先・人間レビュー前提)
これを毎回ハードコードするのではなく、テナント設定とテキスト長で動的選択する仕組みを置きます。
import Anthropic from '@anthropic-ai/sdk' ;
type ModelTier = 'haiku' | 'sonnet' | 'opus' ;
const MODEL_IDS : Record < ModelTier , string > = {
haiku: 'claude-haiku-4-5-20251001' ,
sonnet: 'claude-sonnet-4-6' ,
opus: 'claude-opus-4-6' ,
};
type TranslateRequest = {
source : string ;
sourceLocale : string ;
targetLocale : string ;
glossary ?: GlossaryEntry [];
style ?: StyleProfile ;
fewShots ?: FewShotExample [];
domain ?: 'general' | 'legal' | 'medical' | 'finance' | 'gaming' ;
tier ?: ModelTier ;
};
export async function translate ( req : TranslateRequest ) : Promise < string > {
const tier = req.tier ?? selectTier (req);
const client = new Anthropic ({ apiKey: process.env. ANTHROPIC_API_KEY });
const { protectedText , placeholders } = protectInlineMarkup (req.source);
const glossary = req.glossary ? selectRelevantGlossary (protectedText, req.glossary) : [];
const fewShots = req.fewShots ? selectFewShots (protectedText, req.fewShots) : [];
const systemPrompt = [
'You are a professional translator.' ,
`Translate from ${ req . sourceLocale } to ${ req . targetLocale }.` ,
'Preserve all placeholders like __VAR_0__, __URL_1__, __CODE_2__ exactly as-is.' ,
req.style ? renderStyleGuide (req.style, req.targetLocale) : '' ,
buildGlossaryInstruction (glossary),
fewShots. length > 0 ? renderFewShots (fewShots) : '' ,
'Output ONLY the translated text. No commentary, no quotation marks around the result.' ,
]. filter (Boolean). join ( ' \n\n ' );
const response = await client.messages. create ({
model: MODEL_IDS [tier],
max_tokens: estimateMaxTokens (protectedText, req.targetLocale),
system: systemPrompt,
messages: [{ role: 'user' , content: protectedText }],
});
const block = response.content[ 0 ];
if (block.type !== 'text' ) throw new Error ( 'Unexpected content type' );
return restoreInlineMarkup (block.text, placeholders);
}
function selectTier ( req : TranslateRequest ) : ModelTier {
if (req.domain === 'legal' || req.domain === 'medical' ) return 'opus' ;
if (req.source. length < 200 ) return 'haiku' ;
return 'sonnet' ;
}
function estimateMaxTokens ( text : string , targetLocale : string ) : number {
// 日本語→英語は短く、英語→日本語は長くなる傾向
const baseRatio = targetLocale. startsWith ( 'ja' ) ? 1.6 : 1.2 ;
return Math. min ( 8000 , Math. ceil (text. length * baseRatio));
}
function renderFewShots ( examples : FewShotExample []) : string {
const lines = examples. map (( ex , i ) => `### Example ${ i + 1 } \n Source: ${ ex . source } \n Target: ${ ex . target }${ ex . note ? ` \n Note: ${ ex . note }` : ''}` );
return `## Reference Translations \n ${ lines . join ( ' \n\n ' ) }` ;
}
このコードは「翻訳ロジックの主軸」です。ここを 200 行程度に抑えておくと、後述の評価レイヤーや retry レイヤーを差し込みやすくなります。max_tokens の見積もりは案件によって調整が必要で、私の運用では target locale ごとの体験的係数を別ファイルで管理しています。
リトライと部分翻訳のリカバリ
長文の翻訳では、ネットワーク断・タイムアウト・部分的な出力切れが必ず起きます。再試行は単純な指数バックオフでよいですが、整合性エラー(プレースホルダー消失や glossary 違反)には別ハンドリングを用意する のがコツです。
type RetryConfig = {
maxAttempts : number ;
baseDelayMs : number ;
maxDelayMs : number ;
};
const DEFAULT_RETRY : RetryConfig = { maxAttempts: 3 , baseDelayMs: 800 , maxDelayMs: 8000 };
export async function translateWithRetry ( req : TranslateRequest , config : RetryConfig = DEFAULT_RETRY ) : Promise < string > {
let lastError : unknown ;
for ( let attempt = 1 ; attempt <= config.maxAttempts; attempt ++ ) {
try {
return await translate (req);
} catch (err) {
lastError = err;
if (err instanceof TranslationIntegrityError ) {
// 整合性エラーは temperature を下げて再試行が有効
// ここではモデル呼び出しに temperature を渡せる前提で省略
await sleep ( jitter (config.baseDelayMs * attempt, config.maxDelayMs));
continue ;
}
// 通常エラーは指数バックオフ
if (attempt < config.maxAttempts) {
await sleep ( jitter (config.baseDelayMs * 2 ** (attempt - 1 ), config.maxDelayMs));
}
}
}
throw lastError;
}
function sleep ( ms : number ) { return new Promise (( r ) => setTimeout (r, ms)); }
function jitter ( ms : number , max : number ) : number { return Math. min (max, Math. floor (ms * ( 0.6 + Math. random () * 0.8 ))); }
整合性エラー時の扱いを区別するのは、私が過去にやった失敗からの学びです。一律にバックオフだけしていると、プロンプトの欠陥で必ず失敗するケースを延々と再試行してコストが嵩みます。整合性エラー専用に「プロンプトを少し変える(強調を増やす、temperature を 0.0 にする)」分岐を持つだけで、不要な再試行を 70% 削減できました。
品質ゲート — 自動評価とレビュー振り分け
本番運用では、翻訳結果を全件人間レビューに回すコストは現実的ではありません。自動チェックで「明らかに通すべきもの」と「人間が見るべきもの」を仕分けます。私は以下の 4 つの自動ゲートを使っています。
プレースホルダー保存チェック(前述の整合性検証)
用語集適用率チェック(指定した訳語が訳文に出現するか)
長さ比率チェック(原文と訳文の長さ比が想定範囲内か)
LLM-as-Judge による相対品質評価(必要に応じて)
type QualityReport = {
passed : boolean ;
scores : {
placeholderIntegrity : number ;
glossaryAdherence : number ;
lengthRatio : number ;
judgeScore ?: number ;
};
violations : string [];
};
export function runQualityGate (
source : string ,
translated : string ,
glossary : GlossaryEntry [],
targetLocale : string
) : QualityReport {
const violations : string [] = [];
// 1) プレースホルダー保存
const placeholderRegex = /__(?:URL | VAR | CODE)_ \d + __/ g ;
const sourcePh = (source. match (placeholderRegex) || []). length ;
const targetPh = (translated. match (placeholderRegex) || []). length ;
const placeholderIntegrity = sourcePh === 0 ? 1 : Math. max ( 0 , 1 - Math. abs (sourcePh - targetPh) / sourcePh);
if (placeholderIntegrity < 1 ) violations. push ( `Placeholder mismatch: src=${ sourcePh }, tgt=${ targetPh }` );
// 2) 用語集適用率
let glossaryHits = 0 ;
let glossaryRequired = 0 ;
for ( const entry of glossary) {
if (source. toLowerCase (). includes (entry.source. toLowerCase ())) {
glossaryRequired ++ ;
if (translated. includes (entry.target)) glossaryHits ++ ;
else violations. push ( `Glossary miss: "${ entry . source }" expected "${ entry . target }"` );
}
}
const glossaryAdherence = glossaryRequired === 0 ? 1 : glossaryHits / glossaryRequired;
// 3) 長さ比率(経験則の許容範囲)
const expectedRatio = targetLocale. startsWith ( 'ja' ) ? 1.4 : 1.0 ;
const actualRatio = translated. length / Math. max ( 1 , source. length );
const ratioDelta = Math. abs (actualRatio - expectedRatio) / expectedRatio;
const lengthRatio = ratioDelta < 0.5 ? 1 - ratioDelta : 0 ;
if (lengthRatio < 0.5 ) violations. push ( `Length ratio anomaly: ${ actualRatio . toFixed ( 2 ) } vs expected ${ expectedRatio }` );
const passed = placeholderIntegrity >= 1 && glossaryAdherence >= 0.95 && lengthRatio >= 0.5 ;
return {
passed,
scores: { placeholderIntegrity, glossaryAdherence, lengthRatio },
violations,
};
}
ここで passing 閾値は実プロジェクトの感覚で調整してください。私の場合、glossaryAdherence >= 0.95 を最低条件に、その他のシグナルを加味してレビュー優先度を 3 段階に分けています。passed: false の案件のみ人間に回すと、レビュー量がだいたい 10〜20% に収束します。
LLM-as-Judge — 相対評価で回帰テストを回す
プロンプトを変更したり用語集を追加したとき、品質が上がったのか下がったのか肌感覚では分かりません。Claude を judge として 2 つの訳文を比較させると、CI でも回帰テストが現実的になります。
import Anthropic from '@anthropic-ai/sdk' ;
type JudgeVerdict = 'A' | 'B' | 'tie' ;
export async function judgePairwise (
source : string ,
candidateA : string ,
candidateB : string ,
targetLocale : string
) : Promise <{ verdict : JudgeVerdict ; reason : string }> {
const client = new Anthropic ({ apiKey: process.env. ANTHROPIC_API_KEY });
const response = await client.messages. create ({
model: 'claude-sonnet-4-6' ,
max_tokens: 400 ,
system: `You are an expert ${ targetLocale } translation judge. Compare two translations and decide which is better. Output JSON: {"verdict": "A"|"B"|"tie", "reason": "..."}` ,
messages: [{
role: 'user' ,
content: `Source: \n ${ source } \n\n Candidate A: \n ${ candidateA } \n\n Candidate B: \n ${ candidateB }` ,
}],
});
const text = response.content[ 0 ].type === 'text' ? response.content[ 0 ].text : '' ;
// 緩めの JSON 抽出
const match = text. match ( / \{ [\s\S] * \} / );
if ( ! match) throw new Error ( 'Judge produced no JSON' );
return JSON . parse (match[ 0 ]);
}
私のチームでは、プロンプト変更を含む PR でこの judge を 30〜50 件のサンプルに対して回し、勝率が 50% を下回ったら自動で reject する CI を組んでいます。コストは月 1 回の総当たりで $20〜30 程度。「主観で良くなった気がする」を排除できる効果は大きいので、初期投資の価値は十分にあります。
よくある間違い・落とし穴
ここでは、翻訳 SaaS を運用する中で実際に遭遇した、見落としやすいポイントを整理します。
1. 用語集を「参考情報」として渡してしまう
「用語集です。参考にしてください」と書くと、Claude は必要に応じて参考にする程度の扱いをします。MUST と明記 し、違反した場合は再生成する CI を組んで初めて、用語一貫性が現実的なレベルに到達します。
2. プレースホルダーの命名が衝突する
__VAR_1__ のような単純な命名は、原文中に偶然同じ文字列が含まれていると壊れます。私は __§§VAR0001§§__ のような Unicode セクション記号を使うようにしてから、衝突事故がゼロになりました。
3. 長さ比率チェックを単純にやりすぎる
英→日は文字数が増え、日→英は減るのが普通です。これを言語ペアごとに係数化せず一律で判定すると、健全な翻訳まで passed: false になります。最低でも (source-locale, target-locale) の組み合わせごとに係数テーブルを持ちましょう。
4. キャッシュの key に style profile を入れ忘れる
同じ原文でも、style profile が違えば異なる訳になります。キャッシュキーは hash(source + target_locale + style_profile_id + glossary_hash + model) にすべきで、原文だけでハッシュすると別案件に他案件のキャッシュが流出します。これは個人情報漏洩リスクにもなるので、初期から厳格に設計してください。
5. Few-shot を増やしすぎてプロンプトが膨張する
「Few-shot は多いほど良い」という直感に反して、5 件を超えると単位コスト当たりの精度向上は鈍化します。私は 3 件を上限に、retrieval で良質な 3 件を選ぶ方が、雑に 20 件入れるより安定しています。
コスト最適化 — キャッシュとモデル切替の合わせ技
翻訳 SaaS の原価を支配するのは、当然ながら API 呼び出しコストです。私が運用しているコスト最適化の優先順位は以下のとおりです。
完全一致キャッシュ : 同一テキスト・同一設定なら API を叩かない(KV や Redis に保存)
セグメント単位キャッシュ : 文単位で hash し、変更されていないセグメントは再翻訳しない
Prompt Caching : Claude API の prompt caching で、glossary や few-shot を含むシステムプロンプトを使い回す
モデル階層化 : 短文や低リスク文は Haiku、本文は Sonnet、規制業種のみ Opus
特に prompt caching は翻訳ワークロードと相性が良く、glossary のような長大な共通プロンプトを毎回送る必要がなくなります。導入後にトークン課金が 40〜60% 下がる事例を私自身で確認しています。詳しくは別記事の Claude API のコスト最適化を本番で実装するパターン集 も参考にしてみてください。
観測性 — 翻訳ジョブを SLO で語る
翻訳 SaaS の運用で見るべきメトリクスは、汎用 LLM ワークロードと少し違います。私は最低限以下を取っています。
ジョブ成功率(自動ゲート pass + 人間レビュー pass の両方)
用語集適用率の平均と分布
セグメント単位の再翻訳率(キャッシュヒット率の裏返し)
翻訳所要時間の p50 / p95 / p99
言語ペアごとの単価とトークン使用量
これらを Grafana や Datadog でダッシュボード化し、SLO は「自動ゲート pass 率 99%」「用語集適用率 98%」などで定義します。観測性の設計は Claude Code の OpenTelemetry 観測性ガイド と思想を共有していて、トレースと業務指標を一緒に見られるようにすると、改善点が見えやすくなります。
人間レビューキューの設計
自動ゲートで弾かれた案件は、現実的な SLA で人間レビュアーが処理する必要があります。当たり前のように聞こえますが、多くの翻訳 SaaS はこのキュー自体を軽視しがちです。結果、レビュアーがトリアージで時間を溶かし、顧客は想定以上に待たされます。
私はレビューキューを「デバッグ用の管理画面」ではなく、独立したプロダクト面として扱います。各カードには、自動ゲートが検出した違反、推定深刻度、そして該当セグメントの前後の文脈まで表示します。1 文だけ見せられたレビュアーは、文脈が分からないまま誤判定をしがちです。
実際に効果のあったレイアウトは以下の通りです。
カード上部: 該当セグメント+前後セグメントを薄く表示(文脈の保持)
カード中央: 候補訳と違反箇所のインライン注記
カード下部: 適用すべきだった用語集エントリと「この訳語を強制」ワンクリックアクション
ワンクリック強制アクションを追加したことで、1 件あたり約 30 秒の作業時間を削減できました。1 日数千セグメントを処理する規模では、この差は無視できません。さらにレビュアーが修正後の訳を「Few-shot プールに昇格」できるようにすると、人間のフィードバックがモデルの挙動改善に時間をかけて反映される閉ループが完成します。
翻訳のバージョニングと再翻訳戦略
ソース文書は静的ではありません。マーケコピーは更新され、契約書は改訂され、リリースノートは新機能ごとに増えます。素朴に「全文再翻訳」する設計はコストを浪費し、かつ既存訳の品質を失うリスクがあります。
堅牢な翻訳 SaaS は、翻訳をセグメント単位でバージョン管理します。新しい文書が来たら旧版とのセグメント差分を取り、変更されたセグメントだけを再翻訳します。変更されていないセグメントは既存訳と品質スコアを引き継ぎます。
type TranslationMemoryEntry = {
sourceHash : string ;
source : string ;
target : string ;
styleProfileId : string ;
glossaryHash : string ;
modelTier : ModelTier ;
qualityScores : QualityReport [ 'scores' ];
approvedAt : number ;
};
export async function diffAndTranslate (
oldSegments : string [],
newSegments : string [],
memory : Map < string , TranslationMemoryEntry >,
req : Omit < TranslateRequest , 'source' >
) : Promise <{ segment : string ; translation : string ; reused : boolean }[]> {
const results : { segment : string ; translation : string ; reused : boolean }[] = [];
for ( const segment of newSegments) {
const cached = memory. get ( hashSegment (segment, req));
if (cached && segmentStillValid (cached, req)) {
results. push ({ segment, translation: cached.target, reused: true });
} else {
const translation = await translateWithRetry ({ ... req, source: segment });
results. push ({ segment, translation, reused: false });
}
}
return results;
}
function hashSegment ( segment : string , req : Omit < TranslateRequest , 'source' >) : string {
// 本番では暗号学的ハッシュを使う
return `${ segment }|${ req . targetLocale }|${ ( req as any ). styleProfileId ?? ''}` ;
}
function segmentStillValid ( entry : TranslationMemoryEntry , req : Omit < TranslateRequest , 'source' >) : boolean {
// キャッシュ無効化はちゃんとした設計が必要
if (entry.glossaryHash !== currentGlossaryHash (req)) return false ;
return true ;
}
function currentGlossaryHash ( req : Omit < TranslateRequest , 'source' >) : string {
return JSON . stringify (req.glossary?. map (( g ) => `${ g . source }=>${ g . target }` ) ?? []);
}
地味ですが重要なのが segmentStillValid のチェックです。用語集が変わった場合、以前は通っていた訳でも新しい用語規則に違反する可能性があり、その場合は再生成が必要です。ここを忘れると、文書全体の 80% は新用語、20% は旧用語が混在する事故になります。
ファインチューニングを検討するタイミング
「ドメイン精度を上げるためにファインチューニングすべきでしょうか?」と聞かれることが多いのですが、私の率直な答えは「まだ早い」です。用語集 + Few-shot + 厳格な品質ゲートで、ほとんどのチームは 80〜90% の道のりを、運用負荷の何分の一かで達成できます。さらに新しいベースモデルが出たときにすぐ移行できる柔軟性も保てます。
ファインチューニングが意味を持つのは、特定の狭いドメインで安定した大量トラフィックがあり、プロンプトエンジニアリングの限界に達して、リクエスト当たりトークン数を削るしか手が残っていない場合だけです。数十万件の高品質なレビュー済み対訳ペアを 1 ドメインで蓄積できているなら検討してもよいですが、それまでは保守コストが投資対効果に見合いません。年に 1 回はこの判断を見直すと良いでしょう。ベースモデルの改善が、ファインチューンによる差分を消し去ることが珍しくありません。
全体を振り返って — 次にやるべき一歩
ここまでで、翻訳 SaaS を本番運用するための主要な設計要素を一通り見てきました。読み終えたあなたが今日できる最初の一歩は、自分のプロジェクトの「品質要件」を 5 軸に分解して言語化すること です。
正確性・用語一貫性・スタイル一貫性・流暢さ・ローカライズ適応のうち、どれがいま一番足りていないのか。それが見えれば、本記事のどの章に最初に取り組むべきかが自動的に決まります。多くのプロジェクトでは、用語一貫性とスタイル一貫性の 2 つから着手するのが投資対効果が高いです。
翻訳 SaaS は「LLM を呼ぶだけ」では成立しないが、要素を分解して順に手当てすれば、専用 MT エンジンに肩を並べるどころか、ドメイン特化では超えられる領域まで到達できます。本記事のコードと運用パターンが、その第一歩のヒントになれば嬉しいです。
この記事の用語集や品質ゲートの設計