タイ語で質問した画面に、英語の答えが返っていました。
個人開発で続けている壁紙アプリの説明文生成を、タイ語圏のテスト端末で確認していたときのことです。App Store と Google Play の両方に出しているので、対応言語はストアの配信設定に引きずられて少しずつ増えていました。日本語と英語では問題なく通っていた導線が、タイ語だけ、100 回に 9 回ほど英語で返ってきます。落ちるわけでも、エラーが出るわけでもありません。ただ、読めない言語で返ってきます。
そのときの私のシステム指示は、全言語で共通の英語 1 本でした。ユーザーの入力言語だけが変わり、指示文は動きません。長らくそれで足りていたのは、単に日本語と英語しか本気で見ていなかったからでした。
Google が公開した東南アジアのレポートでは、同地域の Gemini アプリ利用者のプロンプトのうち約 70% が現地語で、ベトナム語 89%・タイ語 87%・インドネシア語 84% と報告されています。私のアプリの比率がこれと同じとは限りませんが、少なくとも「英語で来るだろう」という前提のほうが例外的になりつつある、とは言えそうです。
では、システム指示のほうも訳すべきなのでしょうか。訳せば効くのか、いくら余分にかかるのか。感覚で決める前に測ることにしました。
3つの方式を、同じ土俵に並べる
比較したのは次の 3 つです。どれも珍しい手法ではありませんが、並べて測った記録が手元になかったので作りました。
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" )
私の指示文での結果です。
タイ語のミラーは、同じ内容を伝えるのに英語の約 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 で回し、返ってきた本文の言語を判定しました。判定はモデルに聞かず、文字の並びから機械的に決めています。ここでモデルを使うと、測っている対象と測る道具が同じものになってしまうためです。なおこの判定器は途中で一度作り直していて、その経緯は次の節に書きました。
B のピン留めは、英語の指示文の末尾にこの 1 行を足しただけです。
Respond only in the language identified by this BCP-47 tag: th-TH
countTokens で測ると 9 トークンでした。
タイ語で 91.2% から 99.1% へ。9 トークンで、です。
一方 C のミラーは 99.4% で、B との差は 0.3 ポイント。500 リクエスト中の 1〜2 件です。この差のために、タイ語では 1 リクエストあたり +398 トークンを払っていたことになります。
測る前の私の見立ては外れていました。指示文の言語を揃えることではなく、出力言語という 1 点を名指しで固定することが効いていたのです。振り返れば当たり前かもしれません。モデルは英語の指示を読めないわけではなく、ただ「何語で返すか」を明示されていなかっただけでした。
なお、これで残る 0.9% が消えるわけではありません。一致率を 100% に寄せていく話は別の作業で、混入を連続量として測る側の設計は出力言語の混入率を計測して段階的に締める運用メモ に分けてあります。ここでの結論は「どの方式を土台に選ぶか」までです。
ラテン文字の3言語を、同じ籠に入れていました
判定にモデルを使わなかったことは正しかったのですが、最初に作った判定器そのものが、使いものになっていませんでした。
最初の版は文字種の構成比だけを見るものでした。タイ文字が一定の割合を占めればタイ語、ひらがな・カタカナ・漢字が混じっていれば日本語。ここまでは何の問題もありません。問題は残りで、ベトナム語もインドネシア語も英語も、この判定器はまとめて「ラテン文字」と返していました。
ベトナム語で返るべき場面に英語が返ってきても、私はそれを一致の側に数えていたわけです。
気づいたのは、ピン留めを入れた直後にベトナム語だけ数字の動き方が他と違って見えたときでした。タイ語は 91.2% から 99.1% へ素直に伸びたのに、ベトナム語は手を入れる前から高いままでした。腑に落ちずに生の出力を 30 件ほど目で追うと、英語の文章がいくつも一致として通っていました。
測定器が区別できないものは、測定結果の上にも存在しません。 当たり前のことですが、自分が読めない言語を数えているときは、これがいちばん見えなくなります。
作り直した版は 2 段構えです。まず文字種で振り分け、ラテン文字に落ちたものだけを、発音区別符号の密度と機能語の重なりでもう一度分けます。
# detect_output_language.py
# 2段構え: 文字種で振り分け、ラテン文字圏だけ発音区別符号と機能語で再判定する
import re
import unicodedata
from collections import Counter
VI_MARKS = set (
"ăâđêôơưàáảãạằắẳẵặầấẩẫậèéẻẽẹềếểễệìíỉĩị"
"òóỏõọồốổỗộờớởỡợùúủũụừứửữựỳýỷỹỵ"
)
ID_STOP = { "yang" , "dan" , "untuk" , "dengan" , "ini" , "itu" , "tidak" , "adalah" , "pada" , "dari" }
EN_STOP = { "the" , "and" , "for" , "with" , "this" , "that" , "not" , "is" , "are" , "from" , "of" }
def _script (ch: str ):
if not ch.isalpha():
return None
name = unicodedata.name(ch, "" )
for s in ( "THAI" , "HIRAGANA" , "KATAKANA" , "CJK" , "LATIN" ):
if s in name:
return s
return None
def detect (text: str ) -> str :
counts = Counter(s for s in map (_script, text) if s)
total = sum (counts.values()) or 1
ratio = {k: v / total for k, v in counts.items()}
# 第1段: 文字種だけで決まる言語
if ratio.get( "THAI" , 0 ) >= 0.15 :
return "th"
if ratio.get( "HIRAGANA" , 0 ) + ratio.get( "KATAKANA" , 0 ) + ratio.get( "CJK" , 0 ) >= 0.15 :
return "ja"
if ratio.get( "LATIN" , 0 ) < 0.5 :
return "unknown"
# 第2段: ラテン文字圏を、発音区別符号の密度と機能語の重なりで分ける
low = text.lower()
if sum (ch in VI_MARKS for ch in low) / max ( len (low), 1 ) >= 0.02 :
return "vi"
words = set (re.findall( r " [ a-z ] + " , low))
return "id" if len (words & ID_STOP ) > len (words & EN_STOP ) else "en"
手元の 5 言語で確かめると、ベトナム語の発音区別符号の比率は 0.217 で、タイ語・日本語・インドネシア語・英語はいずれも 0.000 でした。しきい値を 0.02 に置いたのは、英語やインドネシア語の文に借用語由来のアクセント記号が数文字だけ混ざっても倒れないようにするためです。
判定そのものの重さは、1 件あたり 60 マイクロ秒を下回りました。500 リクエスト × 4 言語 × 3 方式を回しても、判定に費やす時間は合わせて 0.4 秒ほどです。モデルに聞く理由は、どこにもありませんでした。
この記事に載せた一致率の表は、すべて作り直したあとの判定器で測り直した数字です。最初の版で出ていたベトナム語の値は、使わずに捨てました。
ミラー方式が静かに落としたもの
ここからが、測ってよかったと思った部分です。
2 番目の指標、スキーマ遵守率を見ると、C だけが下がっていました。
失敗した出力を並べて原因を追うと、犯人はすぐ見つかりました。指示文を訳したとき、翻訳がフィールド名や 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 方式の比較を、判断の順序に組み直すとこうなります。
私自身は、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 つ丸ごと不要になりました。作らずに済んだものが増えた日は、帰り道の足取りが少し軽くなります。
実装の判断材料になれば幸いです。お読みいただきありがとうございました。