同じ記事の日本語版と英語版を別々に生成していた頃、私が一番削られていたのは校正の時間でした。個人開発の Dolice で日英ペアのブログ記事を回していると、本文の中身は合っているのに「ストリーミング」が片方で streaming、もう片方で incremental delivery になっている、といった細かな対訳ブレが毎回どこかに残ります。読者にとっては小さな引っかかりですが、用語が記事ごとに揺れると、サイト全体の信頼感が静かに削られていきます。
このブレの大半は、日本語と英語を独立した2回の呼び出しで作っていたことが原因でした。モデルは1回ごとに「その場で一番自然な訳語」を選ぶので、呼び出しが分かれていれば訳語も分かれます。そこで私自身の運用では、日英を1つの構造化出力にまとめ、用語集を先に固定してから生成する形へ切り替えました。本稿はその設計と、切り替えで何がどれだけ変わったかの記録です。
別々に生成すると、対訳のどこがズレるのか
最初に、どこがズレるのかを切り分けておくと対策が立てやすくなります。日英を独立に生成したとき、ブレが出やすかったのは次の3か所でした。
製品・機能の訳語(例:「思考」を thinking と reasoning で揺らす)
UI ラベルやボタン文言(「保存」を Save / Store / Keep で揺らす)
見出しの粒度(日本語は8見出し、英語は6見出しに勝手に要約される)
特に厄介なのは3つ目です。本文の意味は合っているのに構成が一致しないと、言語切り替えで読者が「同じ記事に戻ってこられない」感覚になります。私のサイトは日英で URL を対にしているため、構成のズレはそのまま体験の段差になりました。
ブレの根は「2回の呼び出しが互いの結果を知らない」点にあります。であれば、両方を1回の出力に入れて、モデルに同時に決めさせるのが筋の良い対処になります。
1コールにまとめる前に決めること
スキーマを書く前に、2つだけ先に固定します。これを後回しにすると、構造化出力にしてもブレは残ります。
第一に「用語集(glossary)」です。記事内で揺らしたくない語を、日本語・英語・短い使用条件の3列で持ちます。第二に「出力契約(output contract)」です。日英で必ず一致させるフィールド(見出し数、コードブロックの本数など)を決めておきます。
用語集は外部ファイルで持ち、生成時に読み込みます。最小構成は次の通りです。
{
"glossary" : [
{ "ja" : "思考" , "en" : "thinking" , "note" : "Gemini の thinking 機能。reasoning とは訳し分けない" },
{ "ja" : "構造化出力" , "en" : "structured output" , "note" : "responseSchema 利用時の総称" },
{ "ja" : "用語集" , "en" : "glossary" , "note" : "本記事の主題。vocabulary とは訳さない" }
],
"contract" : { "min_sections" : 6 , "lang_pair" : [ "ja" , "en" ] }
}
この時点で「何を一致させたいか」が言語化されているので、あとはスキーマと検証に落とすだけになります。
responseSchema で日英をペアにする
ここが切り替えの中心です。Before は日本語用と英語用で2回呼び出していました。After は1つの responseSchema に日英のフィールドを並べ、1回で両方を受け取ります。
Before(独立した2コール):
from google import genai
client = genai.Client()
def gen_one (lang: str , topic: str ) -> str :
prompt = f "次のトピックで { lang } の記事を書いてください: { topic } "
res = client.models.generate_content(
model = "gemini-3.5-flash" ,
contents = prompt,
)
return res.text
ja = gen_one( "日本語" , "Gemini の構造化出力" )
en = gen_one( "英語" , "Gemini structured output" )
# ja と en は互いの訳語を知らないため、対訳がブレる
After(日英ペアを1コール):
from google import genai
from pydantic import BaseModel
class Section ( BaseModel ):
heading_ja: str
heading_en: str
body_ja: str
body_en: str
class Article ( BaseModel ):
title_ja: str
title_en: str
sections: list[Section]
client = genai.Client()
def gen_pair (topic_ja: str , topic_en: str , glossary_text: str ) -> Article:
prompt = (
"同一トピックの日本語版と英語版を同時に書いてください。 \n "
"各セクションは heading と body を日英で対にしてください。 \n "
f "日本語トピック: { topic_ja }\n "
f "英語トピック: { topic_en }\n "
)
res = client.models.generate_content(
model = "gemini-3.5-flash" ,
contents = prompt,
config = {
"system_instruction" : glossary_text,
"response_mime_type" : "application/json" ,
"response_schema" : Article,
},
)
return Article.model_validate_json(res.text)
ポイントは Section が日英を同じ階層に持つことです。モデルは1つのセクションを書くたびに日英を並べて出すため、見出しの粒度と訳語が同じ思考の中で決まります。構成のズレ(前述の3つ目)が、この形にしただけでほぼ消えました。
用語集を system instruction にピン留めする
スキーマだけでは訳語の固定までは届きません。用語集を毎回 system instruction として渡し、「この対訳から外れないこと」を明示します。プロンプト本文ではなく system 側に置くのは、本文を差し替えても用語の約束だけは据え置きにしたいからです。
def build_glossary_instruction (glossary: list[ dict ]) -> str :
lines = [
"あなたは日英2言語のテクニカルライターです。" ,
"次の対訳を厳密に守り、記事全体で揺らさないでください。" ,
]
for g in glossary:
note = f "( { g[ 'note' ] } )" if g.get( "note" ) else ""
lines.append( f "- 「 { g[ 'ja' ] } 」= \"{ g[ 'en' ] }\" { note } " )
lines.append( "対訳にない専門語は、初出で原語を併記してください。" )
return " \n " .join(lines)
この instruction を gen_pair に渡すだけで、揺らしたくない語が固定されます。私の体感では、ここが一番効きました。スキーマでペアにするのが「構成の一致」、用語集のピン留めが「語彙の一致」と役割が分かれていて、両方そろって初めて読者の引っかかりが消えます。
ズレを機械検出する
モデルは大半を守りますが、100%ではありません。本番に載せる前に、用語集どおりかを機械で確認する薄い検証を挟みます。検出ロジックは2つだけで十分でした。
1つ目は「用語の出現チェック」です。日本語本文に glossary の ja が現れた段落で、対応する英語本文に en が現れているかを照合します。2つ目は「長さ比」です。日英の本文の長さ比が極端に外れたセクションは、片方が要約されている疑いがあるので拾います。
def check_terms (article: "Article" , glossary: list[ dict ]) -> list[ str ]:
issues = []
for i, sec in enumerate (article.sections):
for g in glossary:
ja_hit = g[ "ja" ] in sec.body_ja
en_hit = g[ "en" ].lower() in sec.body_en.lower()
if ja_hit and not en_hit:
issues.append( f "section { i } : 「 { g[ 'ja' ] } 」あり / \"{ g[ 'en' ] }\" なし" )
# 長さ比(英語は文字数が増える傾向があるため緩めの帯で判定)
if sec.body_ja and sec.body_en:
ratio = len (sec.body_en) / max ( len (sec.body_ja), 1 )
if ratio < 0.6 or ratio > 3.0 :
issues.append( f "section { i } : 長さ比 { ratio :.2f } が想定外" )
return issues
この検証はトークンを消費しません。ローカルで一瞬です。落とし穴は閾値の決め方で、英語は日本語より文字数が増えやすいため、長さ比の下限を厳しくしすぎると正常なセクションまで拾います。私は下限0.6・上限3.0で運用し、誤検出はほぼ無くなりました。
失敗時の縮退:部分再生成
検証で問題が出たら、記事まるごとを作り直すのは無駄です。問題のあったセクションだけを、用語集と前後の文脈を添えて再生成します。
検出された問題 縮退アクション
用語の片側欠落 該当セクションのみ、用語集を強調して再生成
長さ比の異常 短い側の言語だけ、対の本文を渡して書き直し
JSON パース失敗 同一プロンプトで1回だけ再試行し、ダメなら2コールへ退避
JSON パース失敗だけは構造化出力の宿命として時々起きます。この場合は同じ条件で1回だけ再試行し、それでも崩れるなら従来の2コール方式へ一時退避させて、その日の公開を止めないようにしています。本番では「止めない」ことを最優先に、品質は検証側で担保する組み立てが安定しました。
実測:トークンとレイテンシ、コスト
切り替えで実際に何が変わったかを、手元の記事生成ログから整理します。1記事あたり日英それぞれ6〜8セクションの構成で、Gemini 3.5 Flash を使った比較です。数値は私の運用での平均で、トピックによって前後します。
指標 2コール方式 1コール(ペア)方式
API 呼び出し回数 2回 1回
入力トークン合計 基準 約0.7倍(用語集の重複送信が消える)
体感レイテンシ 2回分の直列待ち 1回分(約0.55倍)
対訳ブレの検出件数 基準 約45%減
校正にかける時間 基準 半分以下
入力トークンが減るのは、2コール方式だと用語集や共通の前提を2回送っていたためです。1コールにまとめると共通部分が1回で済みます。レイテンシは直列の2回待ちが1回になるので、体感でほぼ半分になりました。コスト面では入力トークンの削減が効き、生成あたりの単価が下がります。ここは記事本数が多いほど効いてきます。
どんなときに1コールをやめるべきか
最後に、いつもこの方式が最適とは限らないので、やめどきも書いておきます。私が2コールへ戻すのは次の場合です。
1記事が非常に長く、日英を1つの出力に入れると出力トークン上限に当たるとき
日本語版と英語版で構成を意図的に変えたいとき(英語圏向けに事例を差し替える等)
片方の言語だけ頻繁に差し替えるワークフローで、毎回もう片方まで生成するのが無駄なとき
逆に、対訳の一貫性が品質の核になる短〜中尺の記事では、1コール+用語集ピン留めを推奨します。私自身、Dolice の日英ペア記事ではこの方式を既定にしました。判断の軸は「対訳の一貫性をモデルに同時に決めさせたいか、それとも言語ごとに独立して最適化したいか」です。この問いに答えてから方式を選ぶと、迷いが減ります。
対訳ブレは、一つひとつは小さくても積み重なると読者の信頼を静かに削ります。スキーマで構成をペアにし、用語集で語彙を固定し、検証で逃げを塞ぐ。この3点がそろうと、多言語の運用がぐっと楽になります。同じように日英を並行運用している方の参考になれば嬉しいです。