タイ語で質問した画面に、英語の答えが返っていました。
個人開発で続けている壁紙アプリの説明文生成を、タイ語圏のテスト端末で確認していたときのことです。App Store と Google Play の両方に出しているので、対応言語はストアの配信設定に引きずられて少しずつ増えていました。日本語と英語では問題なく通っていた導線が、タイ語だけ、100 回に 9 回ほど英語で返ってきます。落ちるわけでも、エラーが出るわけでもありません。ただ、読めない言語で返ってくる。
そのときの私のシステム指示は、全言語で共通の英語 1 本でした。ユーザーの入力言語だけが変わり、指示文は動かない。長らくそれで足りていたのは、単に日本語と英語しか本気で見ていなかったからでした。
Google が公開した東南アジアのレポートでは、同地域の Gemini アプリ利用者のプロンプトのうち約 70% が現地語で、ベトナム語 89%・タイ語 87%・インドネシア語 84% と報告されています。私のアプリの比率がこれと同じとは限りませんが、少なくとも「英語で来るだろう」という前提のほうが例外的になりつつある、とは言えそうです。
では、システム指示のほうも訳すべきなのでしょうか。訳せば効くのか、いくら余分にかかるのか。感覚で決める前に測ることにしました。
3つの方式を、同じ土俵に並べる
比較したのは次の 3 つです。どれも珍しい手法ではありませんが、並べて測った記録が手元になかったので作りました。
方式
system_instruction の中身
入力トークンへの影響
想定される狙い
A: 英語固定
全言語で同一の英語 1 本
基準(増減なし)
保守が 1 か所で済む
B: 英語+出力言語ピン留め
英語 1 本+末尾に出力言語を BCP-47 で明示する 1 行
わずかに増える
出力言語だけを確定させる
C: ユーザー言語ミラー
指示文そのものをユーザーの言語へ訳して渡す
言語により大きく増える
指示と入力の言語を揃える
C は直感的には最も筋が良さそうに見えます。指示と入力の言語が揃うのだから、モデルも迷わないはずだ、という理屈です。私も測る前はこれが本命だと思っていました。
測る前に、何を成功と呼ぶかを決める
方式を比べるときにいちばん危ういのは、出てきた文章を眺めて「こちらのほうが良さそう」と決めてしまうことです。3 言語も並ぶと、自分が読める言語の印象だけで結論が寄ります。
そこで先に 3 つの指標を決めました。
出力言語一致率 — 返ってきた本文の言語が、意図した言語と一致した割合
スキーマ遵守率 — response_schema で定義した enum フィールドが、定義どおりの値で返ってきた割合
入力トークン — countTokens で測ったリクエスト 1 回あたりの入力トークン数
1 と 2 を分けたのは、後で効いてきます。言語が合っていても中身の構造が崩れることがあり、その逆もあるためです。
入力トークンは、言語で倍以上に開く
まず 3 番目、コスト側から測りました。同じ意味の 12 文からなるシステム指示を 5 言語に訳し、countTokens にかけたものです。
# measure_instruction_tokens.py
# 同一意味のシステム指示を言語ごとに countTokens で測り、英語比の膨張率を出す
from google import genai
client = genai.Client() # GEMINI_API_KEY を環境変数から読む
MODEL = "gemini-flash-latest"
INSTRUCTIONS = {
"en" : open ( "instructions/en.txt" , encoding = "utf-8" ).read(),
"ja" : open ( "instructions/ja.txt" , encoding = "utf-8" ).read(),
"id" : open ( "instructions/id.txt" , encoding = "utf-8" ).read(),
"vi" : open ( "instructions/vi.txt" , encoding = "utf-8" ).read(),
"th" : open ( "instructions/th.txt" , encoding = "utf-8" ).read(),
}
def count (text: str ) -> int :
# system_instruction 単体ではなく contents として測る点に注意(後述)
return client.models.count_tokens( model = MODEL , contents = text).total_tokens
base = count( INSTRUCTIONS [ "en" ])
for lang, text in INSTRUCTIONS .items():
n = count(text)
print ( f " { lang }\t{ n :>4 } tokens \t{ n / base :.2f } x \t{ len (text) :>4 } chars" )
私の指示文での結果です。
言語
入力トークン
英語比
英語との差
英語 (en) 214 1.00x —
インドネシア語 (id) 268 1.25x +54
日本語 (ja) 341 1.59x +127
ベトナム語 (vi) 498 2.33x +284
タイ語 (th) 612 2.86x +398
タイ語のミラーは、同じ内容を伝えるのに英語の約 2.9 倍のトークンを使っていました。1 リクエストあたり +398 トークン。私の説明文生成は多いときで 1 日 1,200 リクエストほど回るので、タイ語ユーザーの比率を仮に全体の 20% と置いても、1 日あたり 9 万トークン以上が「指示文を訳したことによる差分」として積み上がる計算になります。
数字そのものより、この開き方に幅があることのほうが大事でした。インドネシア語は 1.25 倍で、正直これなら気にしなくてよい。タイ語は 2.86 倍で、気にしたほうがよい。同じ「多言語対応」という一語でくくっていた対象が、コストの観点では別物だったわけです。
なお、countTokens を system_instruction 引数に渡して測ろうとすると、私は一度つまずきました。GenerateContentConfig 経由で渡した指示は、単純な contents 計測には現れません。実際の請求に近い数字が欲しいときは、生成後の response.usage_metadata.prompt_token_count と突き合わせて確かめるのが確実です。事前見積もりと請求の食い違いについては、countTokens を予算ゲートの土台にする実装 で扱った内容がそのまま当てはまりました。
出力言語の一致率 — 9 トークンで届いたところ
次に、効果側です。4 言語 × 3 方式 × 500 リクエストを gemini-flash-latest で回し、返ってきた本文の言語を判定しました。判定はモデルに聞かず、Unicode のスクリプト構成比で決めています。ここでモデルを使うと、測っている対象と測る道具が同じになってしまうためです。
B のピン留めは、英語の指示文の末尾にこの 1 行を足しただけです。
Respond only in the language identified by this BCP-47 tag: th-TH
countTokens で測ると 9 トークンでした。
言語
A: 英語固定
B: 英語+ピン (+9 tok)
C: ミラー
日本語 96.4% 99.6% 99.7%
インドネシア語 94.1% 99.3% 99.4%
ベトナム語 92.7% 99.0% 99.2%
タイ語 91.2% 99.1% 99.4%
タイ語で 91.2% から 99.1% へ。9 トークンで、です。
一方 C のミラーは 99.4% で、B との差は 0.3 ポイント。500 リクエスト中の 1〜2 件です。この差のために、タイ語では 1 リクエストあたり +398 トークンを払っていたことになります。
測る前の私の見立ては外れていました。指示文の言語を揃えることではなく、出力言語という 1 点を名指しで固定することが効いていたのです。振り返れば当たり前かもしれません。モデルは英語の指示を読めないわけではなく、ただ「何語で返すか」を明示されていなかっただけでした。
なお、これで残る 0.9% が消えるわけではありません。一致率を 100% に寄せていく話は別の作業で、混入を連続量として測る側の設計は出力言語の混入率を計測して段階的に締める運用メモ に分けてあります。ここでの結論は「どの方式を土台に選ぶか」までです。
ミラー方式が静かに落としたもの
ここからが、測ってよかったと思った部分です。
2 番目の指標、スキーマ遵守率を見ると、C だけが下がっていました。
言語
A: 英語固定
B: 英語+ピン
C: ミラー
日本語 99.4% 99.2% 98.9%
インドネシア語 99.5% 99.4% 98.1%
ベトナム語 99.3% 99.2% 97.3%
タイ語 99.4% 99.1% 96.8%
失敗した出力を並べて原因を追うと、犯人はすぐ見つかりました。指示文を訳したとき、翻訳がフィールド名や enum の値まで訳してしまっていた のです。
英語版の指示にはこう書いてありました。
Set "mood" to one of: calm, vivid, minimal.
タイ語版では、calm vivid minimal の 3 語が、それぞれタイ語の形容詞に置き換わっていました。人間が読む文としては正しい翻訳です。しかし response_schema の enum は英語のままなので、モデルは訳語のほうに引きずられ、enum にない値を返すことがある。翻訳の品質が高いほど、この落とし穴は踏みやすくなります。
本番運用に入ってから気づくと厄介な種類の失敗でした。エラーとして落ちず、遵守率が数ポイント下がるだけだからです。
つまり C の 96.8% は、ミラー方式そのものの限界というより、私の翻訳工程の穴でした。訳さない領域を先に切り出しておけば回避できます。
# 訳してよい部分と、絶対に訳してはいけない部分を型で分ける
PROTECTED = ( "mood" , "calm" , "vivid" , "minimal" , "aspect_ratio" , "safe_zone" )
def assert_identifiers_intact (translated: str ) -> None :
"""訳文に保護語がそのまま残っているかを検査する。1つでも欠ければ翻訳工程の失敗"""
missing = [w for w in PROTECTED if w not in translated]
if missing:
raise ValueError ( f "翻訳で識別子が失われました: { missing } " )
この検査を挟んだうえでタイ語のミラーを測り直すと、スキーマ遵守率は 99.0% まで戻りました。B とほぼ同じです。
戻ったのは良いのですが、そうすると C の立場がいよいよ苦しくなります。翻訳工程と保護語の検査を維持し、指示文を変えるたびに 5 言語ぶん訳し直し、1 リクエストあたり最大 +398 トークンを払って、得られるのは出力言語一致率 +0.3 ポイント。私の用途では釣り合いませんでした。
Before / After — 呼び出し口を1か所にまとめる
結論が出たので、実装を寄せました。Before は、言語ごとの分岐が呼び出し側に散っていた状態です。
# Before — 呼び出し側が言語を意識している
def generate_description (text: str , lang: str ) -> str :
if lang == "ja" :
si = JA_INSTRUCTION # 訳した指示文
elif lang == "th" :
si = TH_INSTRUCTION # 訳した指示文
else :
si = EN_INSTRUCTION
resp = client.models.generate_content(
model = MODEL ,
contents = text,
config = genai.types.GenerateContentConfig(
system_instruction = si,
response_mime_type = "application/json" ,
response_schema = DESCRIPTION_SCHEMA ,
),
)
return resp.text
新しい言語を足すたびに elif が伸び、指示文を直すたびに全訳文を触ることになります。私はここで一度、日本語版だけ古い指示文が残っている状態を 2 週間ほど気づかずに回していました。
After では、指示文を英語 1 本に戻し、変わるのは BCP-47 タグの 1 行だけにしました。
# After — 指示文は英語1本。変わるのは末尾の1行だけ
from google import genai
from google.genai import types
BASE_INSTRUCTION = open ( "instructions/en.txt" , encoding = "utf-8" ).read()
# アプリの UI 言語から BCP-47 タグへ。未知の言語は英語へ倒す(推測しない)
LOCALE_TAGS = {
"ja" : "ja-JP" , "th" : "th-TH" , "vi" : "vi-VN" ,
"id" : "id-ID" , "en" : "en-US" ,
}
def build_system_instruction (lang: str ) -> str :
tag = LOCALE_TAGS .get(lang, "en-US" )
return (
f " { BASE_INSTRUCTION }\n "
f "Respond only in the language identified by this BCP-47 tag: { tag } "
)
def generate_description (text: str , lang: str ) -> str :
resp = client.models.generate_content(
model = MODEL ,
contents = text,
config = types.GenerateContentConfig(
system_instruction = build_system_instruction(lang),
response_mime_type = "application/json" ,
response_schema = DESCRIPTION_SCHEMA ,
),
)
# 実費は推測せず usage_metadata で確定させる
log_usage(lang, resp.usage_metadata.prompt_token_count,
resp.usage_metadata.candidates_token_count)
return resp.text
指示文の実体が 5 本から 1 本になり、言語別の管理コストが消えました。副次的な効果として、システム指示が全言語で完全に同一になったため、コンテキストキャッシュに載せられる形にもなっています。
LOCALE_TAGS に無い言語を英語へ倒しているのは意図的です。ユーザーの端末ロケールから未知のタグを組み立ててそのまま渡すと、モデルが低リソース言語での生成を試みて品質が落ちることがありました。対応すると決めた言語だけを明示的に持つほうが、私には安全に思えます。
どれを選ぶか、私の判断
3 方式の比較を、判断の順序に組み直すとこうなります。
あなたの状況
私の推奨
理由
出力言語が安定しないだけ
B: 英語+ピン留め
9 トークンで一致率が 91% 台から 99% 台へ。まずここから
構造化出力(enum・JSON)が主
B
ミラーは翻訳が識別子を壊す経路を増やすだけ
敬語・丁寧さの階層など、指示自体が言語文化に依存する
C: ミラー (保護語検査つき)
英語では書き分けられない規範がある場合、追加トークンに見合う
指示文が長く、全言語で共通
B +コンテキストキャッシュ
指示が同一なら、キャッシュの前提を満たせる
タイ語・ベトナム語の比率が高い
B を強く
膨張率が 2.3〜2.9 倍で、ミラーの負担が最も重く出る
私自身は、5 行のうち 4 行で B を選ぶ結果になりました。ただしこれは私の用途が構造化出力中心だからで、対話の丁寧さが商品価値そのものになるアプリなら、C の 398 トークンは十分に安い買い物だと思います。
C を採用する場合は、先ほどの保護語検査を翻訳工程の必須ステップとして先に置くことを強く推奨します。後から足すと、すでに壊れた出力がどれだけ混ざったのかを遡れなくなります。
判断を分けたのは、方式の優劣ではなく「自分のアプリで何が壊れると困るか」でした。私の場合は enum が壊れると壁紙のメタデータが崩れて後段のバッチが止まります。出力の口調が少し硬いことより、そちらのほうがずっと痛い。
運用に乗せる前に確かめた3点
最後に、切り替えのときに実際につまずいた箇所を残しておきます。
1. 一致率は言語ごとに測る。 全体平均で見ると、英語とインドネシア語の高さがタイ語の低さを覆い隠します。私は最初これで 3 週間ほど問題を見落としました。言語をキーに分けて集計すれば、その日のうちに見つかります。
2. 判定にモデルを使わない。 出力言語の判定を Gemini に任せると、判定側も同じ揺れを持ち込みます。Unicode のスクリプト構成比なら決定的で、実行時間も 1 件あたり 0.1 ミリ秒未満でした。
3. モデルの実体が変わったら測り直す。 gemini-flash-latest は 7 月 15 日に Gemini 3.5 Flash の一般提供へ切り替わり、エイリアスの指す実体が入れ替わりました。ここに挙げた数字はいずれもその後に測ったものですが、次に実体が動けば同じことが起こり得ます。言語別の一致率を週次で 100 件ずつでも取っておくと、変化に気づける状態を保てます。予算側の見張りをどう組むかは一人あたりのトークン予算を決める設計 にまとめました。
次の一歩
もしいま多言語のアプリを回していて、システム指示が英語 1 本のままなら、まず countTokens で自分の指示文を対象言語ぶん測ってみてください。10 分もかかりません。膨張率が 1.2 倍なら考えることは何もなく、2.8 倍なら考える価値があります。
そのうえで、末尾に BCP-47 の 1 行を足した版を 100 リクエストだけ流し、言語一致率を並べてみる。それで足りるなら、翻訳工程を作らずに済みます。
私はこの一連の測定に半日ほど使いました。その半日で、作ろうとしていた翻訳パイプラインが 1 つ丸ごと不要になりました。作らずに済んだものが増えた日は、静かに嬉しいものです。
実装の判断材料になれば幸いです。お読みいただきありがとうございました。