朝、いつもどおり夜間バッチのログを眺めていて、出力が普段より2割ほど短いことに気づきました。コードは1行も変えていません。料金グラフだけが前日比で少し上振れている。原因を追っていくと、モデルを明示せずエイリアスで呼んでいた経路だけ、応答していたモデルが変わっていました。既定モデルが静かに切り替わったのです。
2026年6月8日、Gemini Enterprise では既定モデルが 3.5 Flash に固定され、無効化トグルも廃止されました。API 側でも同様に、エイリアスや「指定なし」に依存している自動処理は、ある日を境に応答するモデルが変わり得ます。問題はモデルの良し悪しではありません。自分が知らないうちに挙動が変わること そのものが、運用上の事故です。
ここでは、複数アプリで Gemini API を併用してきた個人開発の現場で実際に効いた、既定変更を「事故」にしないための設計をまとめます。私自身が深夜に原因を追ったあの一件を、二度と繰り返さないための仕組みです。
エイリアス指定がなぜ静かな事故になるのか
gemini-flash-latest のようなエイリアスや、SDK の既定に任せた呼び出しは、書いた時点では便利です。最新が自動で使われるからです。しかしこの「自動で上がる」性質は、本番では二つの顔を持ちます。
一つ目は出力挙動の変化 です。世代が変わると、同じプロンプトでも出力長・整形・thinking の深さが変わります。後段で正規表現や JSON スキーマに通している処理は、ここで静かに壊れます。
二つ目はコストの変化 です。応答するモデルが変われば単価が変わります。1日10万回呼び出すバッチであれば、単価が数十パーセント動くだけで月のコストは大きく振れます。
固定すべきは、最低でも次の5項目です。モデルID、生成パラメータ(temperature・max_output_tokens)、thinking 設定、安全設定、そして「想定しているモデル世代」です。最後の一つは検証用のメタ情報で、後述するガードの基準になります。
レスポンスの model_version で実効モデルを検証する
ここが本記事の核心です。Gemini API のレスポンスには、実際に応答したモデルを示す model_version が含まれます。リクエストで何を指定したかではなく、サーバーが何で応答したか を直接確認できます。これを起動時の smoke コールで照合すれば、既定変更を即座に捕まえられます。
from google import genai
from google.genai import types
client = genai.Client( api_key = "YOUR_GEMINI_API_KEY" )
# 単一の真実の源(環境ごとにここだけを切り替える)
EXPECTED_MODEL = "gemini-2.5-pro" # エイリアスではなく明示ID
EXPECTED_VERSION_PREFIX = "gemini-2.5-pro" # model_version の期待プレフィックス
def assert_pinned_model () -> str :
"""起動時に1回だけ呼ぶ。実効モデルが想定と違えば即座に落とす。"""
resp = client.models.generate_content(
model = EXPECTED_MODEL ,
contents = "ping" ,
config = types.GenerateContentConfig( max_output_tokens = 8 ),
)
actual = resp.model_version or ""
if not actual.startswith( EXPECTED_VERSION_PREFIX ):
raise RuntimeError (
f "model drift detected: expected ' { EXPECTED_VERSION_PREFIX } *', "
f "got ' { actual } '. デプロイを中止してください。"
)
return actual
if __name__ == "__main__" :
print ( "pinned model OK:" , assert_pinned_model())
この assert_pinned_model() をアプリ起動時やバッチの先頭で呼ぶだけで、「想定外のモデルに応答された状態のまま本番が走り続ける」事故を防げます。エラーで落ちることが目的です。静かに動き続けるより、はっきり止まるほうが安全だからです。
ランタイムで毎回照合し、ズレたらアラートを上げる
起動時の検証に加えて、本番の各応答でも model_version を記録しておくと、移行や障害の事後分析が一気に楽になります。すべての応答にモデルの実効値が紐づくため、「いつから挙動が変わったか」を後から正確に言えます。
import logging
logger = logging.getLogger( "gemini.model_guard" )
def generate_with_guard (prompt: str ):
resp = client.models.generate_content(
model = EXPECTED_MODEL ,
contents = prompt,
config = types.GenerateContentConfig(
temperature = 0.4 ,
max_output_tokens = 2048 ,
),
)
actual = resp.model_version or "unknown"
um = resp.usage_metadata
logger.info(
"model= %s in_tok= %s out_tok= %s " ,
actual,
getattr (um, "prompt_token_count" , None ),
getattr (um, "candidates_token_count" , None ),
)
if not actual.startswith( EXPECTED_VERSION_PREFIX ):
# 落とすほどではないが、必ず気づける経路に流す
logger.error( "MODEL DRIFT at runtime: got %s " , actual)
notify_ops( f "Gemini model drift: { actual } " ) # Slack 等へ
return resp
トークン数を一緒に残しておくのが実務上のコツです。モデルが変わるとトークン消費の傾向も変わるため、model_version の変化と消費量の変化を突き合わせれば、コスト上振れの原因をその場で説明できます。
CIでモデルレジストリをスナップショットして差分を止める
人手の確認は必ず抜けます。そこで、固定しているモデル設定を1ファイルにまとめ、CI で差分をゲートします。意図しない変更(誰かが急いでエイリアスに戻した、など)をマージ前に止めるためです。
手順は次のとおりです。
model_registry.json に環境ごとのモデルID・パラメータを書き出す(単一の真実の源)。
アプリは起動時にこのレジストリを読み、-latest などのエイリアスが含まれていたら起動を拒否する。
CI で「エイリアス禁止」「期待プレフィックスとの一致」を検査するテストを実行する。
変更があれば必ずレビューを通す。差分が出ること自体を可視化する。
import json, re, sys
FORBIDDEN = re.compile( r " ( latest | preview | exp )$ " )
def check_registry (path = "model_registry.json" ) -> int :
reg = json.load( open (path, encoding = "utf-8" ))
errors = []
for env, cfg in reg.items():
model = cfg.get( "model" , "" )
if FORBIDDEN .search(model):
errors.append( f " { env } : エイリアス禁止 -> { model } " )
if "expected_version_prefix" not in cfg:
errors.append( f " { env } : expected_version_prefix が未定義" )
for e in errors:
print ( "NG:" , e)
return 1 if errors else 0
if __name__ == "__main__" :
sys.exit(check_registry())
このゲートは小さなテストですが、効果は大きいです。本番のモデル指定がコードレビューの対象になる、という状態を作れるからです。
既定変更を「採用」に変える7日プレイブック
既定変更を検知して止めるのは守りです。新しい既定が実際に良い場合は、攻めに転じて計画的に採用します。私はこの順序で進めることを推奨します。
1日目から2日目は、新モデルを本番と同じプロンプトでオフライン評価します。代表的な100件ほどの入力で、出力長・JSON 妥当性・所要時間を旧モデルと並べます。3日目は、ゴールデンデータセットに対する回帰テストを通し、後段の正規表現やスキーマが壊れないかを確認します。4日目から5日目は、トラフィックの5%程度を新モデルに振り分け、model_version 別にエラー率とトークン消費を観測します。6日目に問題がなければ、レジストリの model と expected_version_prefix を新IDへ更新し、CI ゲートを通します。7日目に全面切り替えとし、旧モデルへ即時ロールバックできる状態を24時間維持します。
ここで効いてくるのが、これまで仕込んできた model_version のログです。切り替え前後の同一指標を、推測ではなく実データで比較できます。「なんとなく良くなった気がする」ではなく、「出力長の中央値が18%短くなり、JSON 妥当率は99.6%で変化なし」と言えること。これが本番運用の安心につながります。
model_version が一致していても、挙動は静かに動きます
ここまでのガードが見張っているのは「どのモデルが応答したか」です。捕まえられるのは ID のズレだけ、とも言えます。
同じ gemini-2.5-pro が応答していても、サーバー側で thinking の既定配分や安全フィルタの閾値が調整されれば、出力は変わります。私が後から困ったのはこちらでした。ガードは沈黙したまま、後段の JSON パース失敗率だけが週明けに 0.2% から 1.4% へ動いていたのです。
そこで、ID とは別に振る舞いの基準線 を持つようにしました。内容を固定した数十件の入力を毎日1回だけ流し、出力の統計量を記録します。絶対値そのものではなく、直近の分布からどれだけ外れたかを見る、という考え方です。
import json, statistics, datetime, pathlib
BASELINE_PATH = pathlib.Path( "behavior_baseline.jsonl" )
PROBES = [ ... ] # 本番の代表入力を20〜50件。内容は凍結して変更しない
def run_probes () -> dict :
lengths, valid, thinking_ratio = [], 0 , []
for p in PROBES :
resp = generate_with_guard(p)
text = resp.text or ""
lengths.append( len (text))
try :
json.loads(text)
valid += 1
except json.JSONDecodeError:
pass
um = resp.usage_metadata
think = getattr (um, "thoughts_token_count" , 0 ) or 0
out = getattr (um, "candidates_token_count" , 0 ) or 1
thinking_ratio.append(think / out)
return {
"date" : datetime.date.today().isoformat(),
"model" : EXPECTED_MODEL ,
"len_median" : statistics.median(lengths),
"json_valid_rate" : valid / len ( PROBES ),
"thinking_ratio_median" : statistics.median(thinking_ratio),
}
プローブの入力を凍結しておくのが前提条件です。ここを都合よく差し替えてしまうと、比較の土台が動いてしまい、基準線としての意味を失います。
判定は平均と標準偏差ではなく、中央値と MAD(中央絶対偏差)で行っています。プローブの結果には時折大きく外れる1件が混ざり、平均だと基準線そのものが引きずられてしまうためです。
def mad (xs):
m = statistics.median(xs)
return statistics.median([ abs (x - m) for x in xs]) or 1e-9
def check_drift (today: dict , history: list[ dict ], k: float = 4.0 ) -> list[ str ]:
alerts = []
for key in ( "len_median" , "json_valid_rate" , "thinking_ratio_median" ):
past = [h[key] for h in history[ - 14 :]]
if len (past) < 7 :
continue # 履歴が足りないうちは記録のみに徹する
base, spread = statistics.median(past), mad(past)
if abs (today[key] - base) > k * spread:
alerts.append(
f " { key } : today= { today[key] :.4f } base= { base :.4f } mad= { spread :.4f } "
)
return alerts
k=4.0 は、14日ぶんの履歴で誤検知が週1件を下回るあたりに落ち着いた値です。3.0 では通常のばらつきでも鳴ってしまい、アラートを無視する習慣がついてしまいました。しきい値は環境ごとに違って当然ですので、まず2週間は記録だけを続けて分布を眺めてから決めるほうが確実です。
この基準線があると、model_version が変わらないまま起きた変化にも名前がつきます。「なんとなく出力が短い」ではなく「出力長の中央値が14日基準から23%下振れ、thinking 比は横ばい」と言えること。原因の切り分けは、その一文から始まります。
モデル固定が届かない経路 — キャッシュ、バッチ、そして棚卸し
レジストリで一元管理しても、モデル指定が素通りする経路は残ります。切り替え当日に足をすくわれたのは、まさにここでした。
一つはコンテキストキャッシュです。キャッシュは作成時のモデルIDに紐づくため、レジストリを新IDへ更新した瞬間、既存のキャッシュは新モデルからは参照できなくなります。TTL が残っている間、旧IDのキャッシュを掴む経路と新IDで生成する経路が同居し、同じ機能なのに応答の傾向が二種類になりました。
もう一つはバッチです。投入から完了までに時間が空くため、切り替え作業中に投入されたジョブは旧設定のまま完了します。ジョブのメタデータに投入時点のレジストリ版数を書き込んでおくと、後から結果を仕分けられます。
経路 モデルが決まる時点 切替時にやること
通常の generate_content リクエスト時 レジストリの更新だけで追随します
コンテキストキャッシュ キャッシュ作成時 新IDでキャッシュを作り直し、旧キャッシュを明示的に削除します
バッチジョブ 投入時 切替の前後で投入を止め、進行中ジョブの完了を待ちます
チューニング済みモデル チューニング実行時 ベースモデルの提供終了日を別軸で追跡します
そして何より、「どこでモデルを指定しているか」を人間の記憶に置かないことです。次のスクリプトを CI に入れて、レジストリを経由しない直書きを検出しています。
import re, pathlib, sys
MODEL_LITERAL = re.compile( r """ [ "' ]( gemini- [ a-z0-9. \- ] + )[ "' ] """ )
ALLOWED = { "model_registry.json" , "model_registry.py" }
def audit (root: str = "src" ) -> int :
hits = []
for path in pathlib.Path(root).rglob( "*.py" ):
if path.name in ALLOWED :
continue
for i, line in enumerate (path.read_text( encoding = "utf-8" ).splitlines(), 1 ):
m = MODEL_LITERAL .search(line)
if m:
hits.append( f " { path } : { i } : { m.group( 1 ) } " )
for h in hits:
print ( "直書き検出:" , h)
return 1 if hits else 0
if __name__ == "__main__" :
sys.exit(audit())
初めて流したとき、想定していなかった場所からモデルIDが出てきました。検証用の小さなスクリプト、ドキュメント生成のツール、それから一度きりのつもりで書いた移行スクリプトです。いずれも本番経路ではありませんが、比較検証の数字を静かに歪めるには十分でした。
棚卸しは記憶ではなく機械にやらせる。この一行を CI に足しただけで、切り替え作業の見通しがずいぶん良くなりました。
落とし穴とその回避
実際にやってみると、いくつか引っかかる点があります。
model_version が指定IDと完全一致するとは限りません。マイナーなサフィックスが付くことがあるため、ガードは完全一致ではなくプレフィックス一致 で書くのが安全です。完全一致にすると、無害なパッチ更新でも本番が落ちてしまいます。
エイリアスを「開発では便利だから」と残したくなりますが、開発と本番で実効モデルが食い違うと、本番だけで再現する不具合の温床になります。開発でも本番と同じ明示IDを使い、新モデルの試用は別の検証用フラグで切り替えるのが、結果的に安全でした。
それから、検証用の smoke コールにも当然コストがかかります。max_output_tokens を最小にし、頻度を起動時のみに絞れば、無視できる水準に収まります。安全のための数円を惜しんで、静かな事故を見逃すほうがはるかに高くつきます。
既定が上がること自体は、止められない流れです。止められないものに身構えるのではなく、上がったことに必ず気づける状態 を先に作っておく。その小さな準備が、深夜の原因究明を一度きりで終わらせてくれます。同じ課題に取り組んでいる方の参考になれば幸いです。