アプリの中に小さな相談機能を足したとき、最初に届いたフィードバックは精度の話ではありませんでした。「ときどき、返ってくるまでがやけに長い」というものでした。ログを見ると、応答時間は二極化していました。ほとんどのリクエストは1〜2秒で返っているのに、一部だけが8秒、9秒とかかっていたのです。
不思議だったのは、遅いリクエストが必ずしも難しい質問ではなかったことです。「この壁紙、他に似た色のはある?」のような軽い問い合わせでも、モデルは律儀に長考していました。全リクエストを一律に深く考えさせる設定にしていたため、簡単な質問にも難問と同じだけの推論時間を払っていたわけです。
Gemini 3.x には、推論の深さをリクエストごとに指定する thinking_level があります。これを固定値で使うのではなく、リクエストの難易度に応じて出し分ける。簡単な問いは浅く速く、難しい問いだけ深く。これだけで、待ち時間の中央値を保ったまま、長考の総量をぐっと減らせました。以下では、その振り分けルーターを実測値と一緒に組み立てていきます。
「全部 high」も「全部 low」も破綻する
thinking_level を固定で運用すると、どちらに倒しても不都合が出ます。high に固定すれば、簡単な質問まで長考して待ち時間が伸び、出力トークン(思考トークンを含む)が膨らんで料金が上がります。low に固定すれば速く安く済みますが、多段の推論が要る問いで答えが浅くなり、ときに間違えます。
まず、手元で測った数字を並べます。ある機能の代表的な100リクエストを、同じプロンプト・同じ入力で thinking_level だけ変えて流し、応答時間(ストリーミングの最初のトークンではなく完了まで)と、課金対象の出力トークン量の中央値を記録したものです。モデルは gemini-flash-latest(3.5 Flash 相当)を使いました。
thinking_level 応答時間 p50 応答時間 p95 出力トークン中央値 簡単な問いの正答率 難しい問いの正答率
low 約1.3秒 約2.1秒 約210 98% 71%
medium 約3.4秒 約5.2秒 約540 98% 86%
high 約6.8秒 約9.1秒 約1,180 99% 92%
この表が語るのは、簡単な問いでは low と high の正答率がほぼ変わらない一方、応答時間は約5倍、出力トークンは約5.6倍に開くということです。つまり簡単な問いを high で処理するのは、待ち時間と料金を丸ごと無駄に払っている状態です。逆に難しい問いを low で処理すると、正答率が71%まで落ちます。10問に3問近く外す機能を、課金メンバーに出すわけにはいきません。
答えは「どちらか一方に固定しない」ことです。問いの難しさを見分けて、簡単なものは low、難しいものだけ high に回す。中間は medium で受ける。振り分けの精度さえ確保できれば、待ち時間の中央値は low のまま、難問の正答率は high に近づけられます。
リクエストを難易度で見分ける
振り分けの心臓は、リクエストが届いた瞬間に「これはどのくらい深く考える必要があるか」を安く見積もる部分です。ここに重い判定を置くと本末転倒なので、二段構えにします。まず費用ゼロのヒューリスティックで大半を仕分け、判断がつかないものだけモデルに一枚噛ませます。
ヒューリスティックは、入力の性質から難易度の当たりをつけます。私が個人開発の機能で使っている観点は、次の三つに集約できました。
入力の長さと構造 :文字数が短く、単一の事実照会に見えるものは low 寄り。複数の条件が絡む、あるいは「比較して」「理由も」といった多段の要求は high 寄りです。
要求される出力の型 :一語・一値で答えられる問いは low。手順・根拠・トレードオフの説明を求める問いは深い推論が要ります。
失敗したときの痛み :課金導線に関わる案内や、ユーザーの操作を左右する提案は、多少遅くても medium 以上に倒します。速さより外さないことを優先する領域です。
これらは完全ではありません。短いのに難しい問い(「AとBはどちらが得?」は10文字でも多段推論です)は必ず取りこぼします。だからこそ、ヒューリスティックで白黒つかないグレーゾーンだけを、次の段でモデルに委ねます。
振り分けルーターの実装
ヒューリスティックとモデル判定を束ねたルーターを、Python で最小構成にします。ポイントは、難易度スコアを段階に写像する部分と、thinking_level を実際のリクエスト設定へ差し込む部分を分けておくことです。後でしきい値だけを調整できます。
# router.py — リクエストの難易度から thinking_level を決める
from google import genai
from google.genai import types
client = genai.Client()
# 多段推論を示唆する語(取りこぼしを減らすための最小限のシグナル)
_HARD_HINTS = ( "比較" , "どちらが" , "なぜ" , "理由" , "手順" , "設計" ,
"トレードオフ" , "vs" , "おすすめ" , "選ぶ" )
def heuristic_tier (text: str , sensitive: bool ) -> str | None :
"""費用ゼロの一次判定。判断がつかなければ None を返し次段へ委ねる。"""
if sensitive:
return "medium" # 外したくない領域は下限を上げる
n = len (text)
hard = any (h in text for h in _HARD_HINTS )
if n <= 24 and not hard:
return "low" # 短く単純 → 浅く速く
if hard or n >= 120 :
return "high" # 多段 or 長文 → 深く
return None # グレーゾーンはモデル判定へ
def model_tier (text: str ) -> str :
"""グレーゾーンだけ Flash に難易度そのものを判定させる(low コスト)。"""
r = client.models.generate_content(
model = "gemini-flash-latest" ,
contents = f "次の問いに答えるのに必要な推論の深さを "
f "low/medium/high のいずれか一語で返してください。問い: { text } " ,
config = types.GenerateContentConfig(
thinking_config = types.ThinkingConfig( thinking_level = "low" ),
max_output_tokens = 4 ,
),
)
out = (r.text or "" ).strip().lower()
return out if out in ( "low" , "medium" , "high" ) else "medium"
def route (text: str , sensitive: bool = False ) -> str :
return heuristic_tier(text, sensitive) or model_tier(text)
def answer (text: str , sensitive: bool = False ) -> str :
level = route(text, sensitive)
r = client.models.generate_content(
model = "gemini-flash-latest" ,
contents = text,
config = types.GenerateContentConfig(
thinking_config = types.ThinkingConfig( thinking_level = level),
),
)
return r.text
heuristic_tier が None を返したときだけ model_tier が走ります。事前判定そのものを Flash の thinking_level="low" かつ max_output_tokens=4 で回しているので、この一枚のコストは本番の応答に比べて小さく収まります。判定の出力を一語に縛るのは、グレーゾーンの見積もりに長考を許すと、待ち時間を削る目的そのものが崩れるからです。
モバイルの待ち時間予算から上限を決める
振り分けができたら、次は「難しい問いをどこまで深く考えさせてよいか」の天井を、体感速度から逆算します。ここを決めずに high を許すと、稀な難問が p95 を押し上げ、機能全体が「たまに固まるアプリ」という印象になります。
私は、対話的な機能では完了まで p95 で5秒を一つの目安にしています。この予算を先ほどの実測表に当てると、high の p95 は約9.1秒で予算を超えます。そこで、通常の難問は medium(p95 約5.2秒)を上限に据え、high は「失敗の痛みが大きく、かつ稀」なリクエストにだけ手動で開放する、という運用にしました。数式で書くと、あるレベルの許容割合を、そのレベルの p95 が全体の予算を壊さない範囲に抑える、という配分です。
もう一つ、料金側からも上限が要ります。thinking_level を上げると思考トークンが出力側に乗るため、high を多用すると1リクエストあたりの単価が跳ねます。無料ユーザーに AI 機能を開放しているなら、AdMob の収益で1リクエストの原価を賄えるかが判断軸になります。私の場合、無料枠のリクエストは既定を low に固定し、medium 以上はプレミアム会員の操作起点にだけ割り当てる、という線引きにしました。速さと原価の両方が、同じ「どのレベルを誰にいつ許すか」という一枚の表に落ちます。
誤ルーティングを捕まえる
このルーターの弱点は、難しい問いを low に落としてしまう取りこぼしです。速くはなりますが、答えが浅くなり、ユーザーには「なんだか的外れ」と映ります。厄介なのは、誤ルーティングは例外を投げないことです。処理は正常に完了し、ただ質が低い応答が返るだけなので、監視していないと気づけません。
私が本番で入れている検知は、二つの安いシグナルの組み合わせです。一つは、low で処理したのに出力が想定より長く伸びたリクエストを記録すること。浅く済むはずの問いが長い答えを必要としたなら、難易度の見立てが外れていた疑いがあります。もう一つは、応答直後の「役に立った / 立たなかった」の軽いフィードバックを、ルーティングしたレベルと紐づけて集計することです。low に振ったリクエストで「立たなかった」が偏って増えていれば、ヒューリスティックのしきい値か _HARD_HINTS を見直す合図になります。
対処の順序も決めておきます。まずグレーゾーンをモデル判定へ回す割合を上げ、それでも取りこぼすパターンが見えたら、そのパターンを _HARD_HINTS に一語だけ足す。ヒューリスティックを賢くしすぎると事前判定自体が重くなるので、足すのは「実際に外した」証拠がある語に限る、というのが私の運用ルールです。
事前判定に Flash を一枚噛ませる採算
二段構えの肝は、グレーゾーンだけをモデルに委ねる点にあります。ここで気になるのは、判定用の呼び出しが増えるぶん、かえって高くつかないかという採算です。実際に測ってみると、答えははっきりしていました。
私の機能では、ヒューリスティックだけで約7割のリクエストが確定し、モデル判定に回ったのは残りの約3割でした。その3割に対する判定コストは、max_output_tokens=4 の極小呼び出しなので、1件あたりの原価は本番応答の数十分の一です。一方で、この一枚があることで、簡単な問いを high で処理する取り違えが減り、長考の総量そのものが縮みます。判定に払う小さなコストより、無駄な長考を避けて浮くコストの方が大きい、という損益になりました。
逆に、事前判定が割に合わないケースもあります。リクエストがそもそもほぼ全部同じ難易度に寄っている機能では、振り分けの余地が小さく、ルーターの複雑さだけが残ります。その場合は無理に段を増やさず、固定の thinking_level 一本で運用する方が素直です。振り分けは、難易度が二極化している機能でこそ効きます。thinking_level の指定方法そのものは Gemini API のドキュメント にまとまっているので、手元のモデルで既定値を確認してから天井を決めると安全です。
運用に入る前に
もし対話的な AI 機能で待ち時間のばらつきに悩んでいるなら、最初の一歩は代表的な数十リクエストを thinking_level の low / medium / high で流し、応答時間と正答率を自分の機能で一度だけ測ることです。私の表の数字はあくまで私の入力での話で、機能が変われば分岐点も動きます。自分の数字が出れば、待ち時間予算のどこに天井を引くかは、迷わず決められます。
振り分けの設計は、突き詰めれば「速さと深さのどちらを、どのリクエストに、誰に対して払うか」を一枚の表にする作業でした。私自身まだ調整を続けている途中ですが、固定値をやめてこの一枚を持っただけで、機能の体感は目に見えて安定しました。お読みいただきありがとうございました。