6月8日に Gemini Enterprise で 3.5 Flash の機能管理トグルが廃止され、全ユーザーでデフォルト有効・無効化不可になったというニュースを見て、ふと自分の API 側の構成を確認しました。アプリ向けの分類バッチも記事メタデータ生成も、まだ gemini-2.5-flash のままです。Enterprise 側では「もう選べない」段階まで来ているのに、API 側の自分の判断だけが止まっている。この非対称が気になって、週末に移行の棚卸しをすることにしました。
結論から書くと、私は 4 本のパイプラインのうち 3 本を gemini-3.5-flash に切り替え、1 本を意図的に旧モデルに残しました。「全部まとめて書き換える」のでも「様子見で何もしない」のでもなく、ワークロード単位で実測して判定する。その過程で書いた評価ハーネスとモデルルーターの実装を、判断の根拠ごと残しておきます。
「Flash が主力」前提でモデル構成を見直す
Google I/O 2026 で GA になった Gemini 3.5 Flash は、従来の「Flash = 軽量・廉価版」という位置づけを崩しました。公称ではエージェント・コーディング系ベンチマークで Gemini 3.1 Pro を上回りつつ、速度は他のフロンティアモデル比で約 4 倍とされています。一方で Gemini 3.5 Pro は I/O で「6月 GA」と表明されたまま、現時点では Vertex の一部エンタープライズ向け限定プレビューにとどまっています。
つまり 2026 年 6 月時点の現実的な選択肢はこうなります。
gemini-3.5-flash : GA。エージェント・コーディング用途の事実上の主力
gemini-3.1-pro : 引き続き利用可能だが、ベンチマーク上は 3.5 Flash に抜かれた領域がある
gemini-2.5-flash : 安定稼働中の既存パイプラインが多く残る世代
gemini-3.1-flash-lite : 5月7日 GA。コスト最優先の単純タスク向け
この構図で悩ましいのは、「新しい方が良いに決まっている」と言い切れないことです。モデルが変わると出力の癖が変わり、後段のパーサーや品質チェックが静かに壊れます。私は過去に画像生成モデルの移行で、コード差分自体は数行なのに検証に丸一日かかった経験があります(そのときの記録は Gemini の画像生成 preview モデルが 6月25日に停止します — GA 版への移行で確認したコード差分と検証手順 に書きました)。テキスト系でも同じことが起きる前提で進めます。
ワークロード別の置き換え判定 — 4 本のパイプラインをどう振り分けたか
個人開発で運営している壁紙アプリ群とブログ運営の裏側では、Gemini API を使う処理が大きく 4 種類動いています。それぞれ要求特性が違うので、一括移行ではなく個別判定にしました。
画像メタデータの分類バッチ(夜間) : 出力は固定スキーマの JSON。求めるのは形式の安定と単価。レイテンシはほぼ無関係
記事メタデータ生成(description・タグ候補) : 日本語の自然さと文字数制約の遵守が重要。形式逸脱は後段で検知できる
App Store・Google Play レビューへの返信下書き : トーンの一貫性が最優先。モデルが変わると「声」が変わるリスクが最も高い
エージェント的な多段タスク(リサーチ→整形→検証) : ツール呼び出しの判断精度と速度が効く。3.5 Flash の得意領域のはず
判定の軸は 3 つに絞りました。第一に、出力形式の安定性が後段の機械処理に直結するか。第二に、モデルの「声」の変化がユーザーに見えるか。第三に、速度・精度の改善が体感できる規模か。この 3 軸で見ると、レビュー返信だけは「ユーザーに声が見える」かつ「改善の体感が小さい」ので、移行を急ぐ理由がないと判断できます。
3 軸で整理すると、判定は次のようになりました。
ワークロード 形式が機械処理に直結 声がユーザーに見える 改善の体感 初期判定
分類バッチ(夜間) 直結する 見えない 中(総実行時間) 先行移行
記事メタデータ生成 後段で検知できる 間接的 小 カナリア 50%
レビュー返信下書き 直結しない 直接見える 小 据え置き
エージェント的な多段タスク 直結する 見えない 大(成功率) 先行移行
この表を測定の前に埋めてしまうのが、今回いちばん効いた手順でした。列が決まっていれば、実測は「どの仮説を確かめる作業か」がはっきりした状態で始められます。
私は最初、逆の順序で進めて一度やり直しました。数字を先に集めてから意味づけを考えると、都合の良い列だけを見てしまいます。とくに速度は目に見えて改善するので、そこに引っ張られます。
評価ハーネスを書く — 本番の代表タスクで実測する
公称ベンチマークは自分のタスクの代理にはなりません。本番パイプラインから代表ケースを抜き出して、新旧モデルに同じ入力を流すハーネスを書きます。何を解決するコードかと言うと、「移行判断に必要な実測値(レイテンシ・トークン・形式合格率)を 1 コマンドで取る」ためのものです。
# compare_models.py — 本番の代表タスクで新旧モデルを実測比較する
import json
import os
import statistics
import time
from google import genai
client = genai.Client( api_key = os.environ[ "GEMINI_API_KEY" ])
MODELS = [ "gemini-2.5-flash" , "gemini-3.5-flash" ]
# 本番パイプラインから抜き出した代表タスク
# 各ケースは {"prompt": "...", "required_keys": ["title", "tags"]} の形式
with open ( "eval_cases.json" , encoding = "utf-8" ) as f:
CASES = json.load(f)
def run_case (model: str , case: dict ) -> dict :
started = time.perf_counter()
response = client.models.generate_content(
model = model,
contents = case[ "prompt" ],
config = {
"response_mime_type" : "application/json" ,
"temperature" : 0.2 ,
},
)
elapsed = time.perf_counter() - started
usage = response.usage_metadata
return {
"latency" : elapsed,
"input_tokens" : usage.prompt_token_count,
"output_tokens" : usage.candidates_token_count,
"text" : response.text,
}
def is_valid (case: dict , text: str ) -> bool :
# 後段パーサーが要求する形式を、そのまま合否条件にする
try :
data = json.loads(text)
except json.JSONDecodeError:
return False
return all (key in data for key in case[ "required_keys" ])
for model in MODELS :
latencies = []
valid = 0
in_tokens = 0
out_tokens = 0
for case in CASES :
result = run_case(model, case)
latencies.append(result[ "latency" ])
in_tokens += result[ "input_tokens" ]
out_tokens += result[ "output_tokens" ]
if is_valid(case, result[ "text" ]):
valid += 1
print (
f " { model } : p50= { statistics.median(latencies) :.2f } s "
f "valid= { valid } / { len ( CASES ) } "
f "tokens(in/out)= { in_tokens } / { out_tokens } "
)
ポイントは is_valid を「後段パーサーの要求そのもの」にしていることです。汎用的な品質スコアではなく、自分のシステムが実際に落ちる条件で合否を取る。ここを抽象化すると、実測の意味が薄れます。
実行すると、次のような出力になります(ケース数 60 の例)。
gemini-2.5-flash: p50=1.82s valid=57/60 tokens(in/out)=48210/15890
gemini-3.5-flash: p50=1.41s valid=58/60 tokens(in/out)=48210/17820
実測結果の読み方 — 速さより先に「形式の安定」を見る
私の環境での実測(分類バッチ 60 ケース、メタデータ生成 40 ケース)から読み取れたことを、数字の解釈ごと書きます。なお値は実行時間帯やプロンプトに依存するので、絶対値より傾向を見てください。
まずレイテンシは、p50 で 2 割前後の短縮が一貫して観測できました。公称の「4 倍速い」はベンチマーク条件での話で、JSON 強制つきの実タスクではこの程度に落ち着きます。それでも夜間バッチの総実行時間は目に見えて縮みました。
次に意外だったのが出力トークンの増加です。3.5 Flash は同じ指示に対してやや饒舌で、私のケースでは出力トークンが 1 割強増えました。単価が同じなら、速くなっても請求額は微増し得るということです。description 生成のように長さ上限がある処理では、max_output_tokens とプロンプト側の文字数指定を締め直して相殺しました。
形式合格率は両者ほぼ同等でしたが、失敗の質が違いました。2.5 Flash の失敗は「キー欠落」、3.5 Flash の失敗は「余計なキーの追加」に寄ります。後段が未知キーを無視する設計なら実害はありませんが、厳格スキーマ検証をしているなら移行時に落ち方が変わる点は知っておくべきです。
エージェント的な多段タスクだけは、合格率そのものが明確に改善しました(私の検証では 7 〜 8 割 → 9 割前後)。ツール選択の迷いが減り、リトライ回数が落ちたのが効いています。この領域は公称どおり 3.5 Flash の得意分野だと感じます。
ワークロード別に並べると、同じ「3.5 Flash に替えた」でも中身がまったく違うことが分かります。
ワークロード(ケース数) p50 レイテンシ 出力トークン 形式合格率 結論
分類バッチ(60) 1.82s → 1.41s(−23%) 15,890 → 17,820(+12%) 57/60 → 58/60 移行
メタデータ生成(40) 2.10s → 1.74s(−17%) +14%(上限指定で相殺) 38/40 → 38/40 カナリア 50%
レビュー返信(20) 体感差なし +9% —(人手確認のため計測せず) 据え置き
多段タスク(30) 1 回あたりは同等 リトライ減で実質は減少 約 74% → 約 90% 移行
多段タスクの行だけ、レイテンシが「同等」なのに結論が移行になっています。1 回の呼び出しは速くなっていないのに、リトライが減ったぶん最終的な完了までの時間とトークンが減ったからです。呼び出し単位の数字しか見ていないと、この改善は表に出てきません。
GO / カナリア / 据え置きを、しきい値で決めておく
実測値が並んでも、「どこからが移行して良い数字か」を先に決めていなければ、最後は印象で判断してしまいます。速くなった実感は強く残る一方、出力トークンが 1 割増えたことは翌月の請求まで思い出しません。
そこで 4 本を判定する前に、しきい値だけを先に置きました。
指標 GO(全面移行) カナリア(部分移行) HOLD(据え置き)
形式合格率の差 −0.5pt 以上(実質悪化なし) −0.5 〜 −2pt −2pt 未満
p50 レイテンシ 悪化なし +10% まで +10% 超
出力トークン +15% まで +15 〜 +30% +30% 超
モデルの声の可視性 ユーザーに見えない — 直接見える
数字の妥当性そのものより、測る前に置いたという事実に意味があります。あとから決めたしきい値は、必ず手元の結果を通す位置に動きます。
このしきい値をコードにしておくと、ワークロードが増えたときにも同じ物差しが使えます。
# decide.py — 実測サマリからワークロードの採否を機械的に決める
from dataclasses import dataclass
@dataclass
class Measured :
workload: str
valid_rate_old: float # 0.0 - 1.0
valid_rate_new: float
p50_old: float # 秒
p50_new: float
out_tokens_old: int
out_tokens_new: int
voice_visible: bool # モデルの「声」がユーザーに直接見えるか
def decide (m: Measured) -> str :
# 声が見えるワークロードは、数値が良くても自動 GO にしない
if m.voice_visible:
return "HOLD"
valid_delta = (m.valid_rate_new - m.valid_rate_old) * 100 # ポイント差
latency_delta = (m.p50_new - m.p50_old) / m.p50_old # 比率
token_delta = (m.out_tokens_new - m.out_tokens_old) / m.out_tokens_old
if valid_delta < - 2 or latency_delta > 0.10 or token_delta > 0.30 :
return "HOLD"
if valid_delta < - 0.5 or latency_delta > 0 or token_delta > 0.15 :
return "CANARY"
return "GO"
if __name__ == "__main__" :
batch = Measured(
workload = "categorize" ,
valid_rate_old = 57 / 60 ,
valid_rate_new = 58 / 60 ,
p50_old = 1.82 ,
p50_new = 1.41 ,
out_tokens_old = 15890 ,
out_tokens_new = 17820 ,
voice_visible = False ,
)
print (batch.workload, decide(batch)) # -> categorize GO
voice_visible を最初に判定して即 HOLD を返しているのは、意図的な非対称です。トーンの一貫性は形式合格率のような数値で担保できないので、数値の側から覆せないようにしてあります。レビュー返信をこの関数に通すと、他の指標がどれだけ良くても HOLD が返ります。
もう一点、レイテンシの CANARY 条件を「+10% まで」ではなく「0 より大きい」にしてあります。新モデルに替えて遅くなるなら、たとえ許容範囲でも一度は部分適用で様子を見る、という方針です。
モデルルーターで段階導入する — 1 行で戻せる構成
実測で方針が決まったら、コード中のモデル名を直接書き換えるのではなく、ワークロード名からモデル ID を引くルーターを 1 枚挟みます。これを解決したい問題は「切り替えと切り戻しを、デプロイなしで・即時に・ワークロード単位で」行えるようにすることです。
# model_router.py — 環境変数で即時ロールバックできるモデルルーター
import os
import random
DEFAULT_ROUTES = {
"categorize" : "gemini-3.5-flash" , # 分類バッチ: 移行済み
"metadata" : "gemini-3.5-flash" , # メタデータ生成: カナリア 50% で試験中
"review_reply" : "gemini-2.5-flash" , # レビュー返信: 意図的に保留
"agentic" : "gemini-3.5-flash" , # 多段タスク: 移行済み
}
# 一部のトラフィックだけ新モデルへ流す比率(未指定は 100%)
CANARY_RATIO = {
"metadata" : 0.5 ,
}
FALLBACK_MODEL = "gemini-2.5-flash"
def resolve_model (workload: str ) -> str :
# 環境変数 MODEL_OVERRIDE_<WORKLOAD> が最優先(緊急ロールバック用)
override = os.environ.get( f "MODEL_OVERRIDE_ { workload.upper() } " )
if override:
return override
model = DEFAULT_ROUTES .get(workload, FALLBACK_MODEL )
ratio = CANARY_RATIO .get(workload)
if ratio is not None and random.random() >= ratio:
return FALLBACK_MODEL
return model
この構成にしておくと、問題が起きたときの対応は MODEL_OVERRIDE_METADATA=gemini-2.5-flash を環境変数に足して再起動するだけです。コードに触らないので、深夜にスマートフォンからでも戻せます。カナリア比率はメタデータ生成のような「形式逸脱を後段で検知できる」ワークロードにだけ使い、検知手段のない処理は 0 か 100 かで切り替える方針にしています。中途半端に混ざると、不具合報告の再現条件が追えなくなるためです。
実際に切り替えた順序も残しておきます。ルーターを入れてから 1 本目を本番に流すまで、私は次の 5 段階を踏みました。
ルーターだけを先にデプロイする。この時点では全ワークロードが旧モデルを指したままで、挙動は 1 ミリも変わりません。切り戻し手段が先に入っている状態を作ります
検証環境で MODEL_OVERRIDE_CATEGORIZE を新モデルに設定し、評価ハーネスと同じ 60 ケースを本番同等の並列度で流す
本番の DEFAULT_ROUTES を 1 ワークロードだけ書き換える。同時に 2 本以上動かさないことを推奨します。障害が出たときに原因の切り分けができなくなります
24 時間おいて、エラー率・p50・出力トークン量を旧モデルの同曜日と比べる。曜日をそろえるのは、私のバッチが週末に量が変わるためです
問題がなければ次のワークロードへ進む。異常があれば環境変数を 1 つ足して戻し、原因が分かるまで次に進まない
この 5 段階を 4 本ぶん回して、全体で 9 日かかりました。1 日で終わらせようとすると、24 時間の観測窓が取れません。移行そのものより、待つ時間の方が長い作業です。
出力差分を本番トラフィックで継続的に観測したい場合は、ルーターの先にシャドウ実行を足す設計もあります。その実装パターンは Gemini API のシャドウトラフィックで新モデル移行を安全に進める — 出力差分を本番で測る実装パターン が詳しいので、ここでは省きます。
未知キーの混入を見張る — 移行の数週間後に効いてくる監視
実測で見えた「3.5 Flash の失敗はキーの追加に寄る」という癖は、切り替えた当日より、数週間経ってから効いてきます。required_keys を満たしている以上は合格として通ってしまうので、テストでは落ちません。後段のどこかに静かに溜まります。
そこで、合否とは別に「想定外のキーが来たこと」だけを記録する薄い層をルーターの先に足しました。
# schema_drift.py — 合否ではなく「想定外のキー」を記録する
import json
import logging
from collections import Counter
logger = logging.getLogger( "schema_drift" )
_unexpected: Counter = Counter()
def observe (workload: str , model: str , expected_keys: set[ str ], text: str ) -> None :
try :
data = json.loads(text)
except json.JSONDecodeError:
logger.warning( "drift/parse_error workload= %s model= %s " , workload, model)
return
if not isinstance (data, dict ):
return
extra = set (data) - expected_keys
missing = expected_keys - set (data)
for key in extra:
_unexpected[(workload, model, key)] += 1
if extra or missing:
logger.info(
"drift workload= %s model= %s extra= %s missing= %s " ,
workload, model, sorted (extra), sorted (missing),
)
def top_drift (limit: int = 10 ) -> list :
return _unexpected.most_common(limit)
ここで例外を投げていないのが要点です。ドリフト検知が本番を落とすようになると、次の移行のときに真っ先に外されます。記録だけして、判断は人間が後から週次で行う。この分担にしておくと、監視が生き残ります。
私の環境では、移行から 2 週間で confidence と reasoning の 2 キーが上位に上がりました。後段が未知キーを無視する実装だったので実害はありませんでしたが、出力トークンが 12% 増えた内訳の少なくない部分がここにあると分かりました。合格率だけを見ていたら気づけない種類の情報です。
プロセスを複数立てて動かしている場合は、Counter をプロセス内に持つと集計が分散します。既存のメトリクス送信に置き換えるか、extra のキー名を構造化ログに出して集計側で数える方が確実です。私は後者にしました。ログ 1 行の追加で済み、送信先を増やさずに済むためです。
障害時のフォールバックも同じルーターに載せる
6月12日に Gemini が過去最大級と報じられた障害(error 1076 / 1099 が広範発生)から回復したばかりということもあり、移行と同時にフォールバック経路も見直しました。モデル移行用のルーターがあると、障害時の退避も同じ仕組みに載せられます。
# generate_with_fallback — 主モデル失敗時に旧世代へ自動退避する
from model_router import FALLBACK_MODEL , resolve_model
def generate_with_fallback (client, workload, contents, config):
primary = resolve_model(workload)
last_error = None
for model in (primary, FALLBACK_MODEL ):
try :
return client.models.generate_content(
model = model, contents = contents, config = config
)
except Exception as err: # 本番では google.genai.errors の型で個別に捕捉する
last_error = err
raise last_error
注意点が 2 つあります。第一に、新旧モデルが同一の障害で同時に落ちるケースはあるので、これは保険であって冗長化ではありません。最終的に失敗したジョブを翌朝リカバリできるキュー設計の方が本質です。第二に、フォールバック発動をログに残さないと「いつの間にか旧モデルで動き続けていた」事故が起きます。私は発動時に Slack 通知を 1 本飛ばすようにしました。
つまずいた点 — エイリアス・クォータ・出力ドリフト
移行作業で実際に手が止まった箇所を 3 つ残します。
エイリアス ID を踏みかけた 。検証中に -latest 系のエイリアスでモデルを指定しかけて、固定 ID に書き直しました。エイリアスは中身が予告なく入れ替わるため、実測した結果と本番で動くモデルがずれる可能性があります。評価ハーネスとルーターには GA の固定 ID だけを書く、を原則にしています。
クォータがモデルごとに別管理だった 。新モデルに切り替えた直後の夜間バッチで 429 が散発しました。旧モデルで実績のある同時実行数でも、新モデル側のレート制限枠は別カウントです。移行初日は同時実行を半分に絞り、429 が出ないことを確認してから戻しました。
thinking 系の既定値の違いでレイテンシが振れた 。3.5 Flash は推論の使い方が世代で変わっており、config を明示しないとタスクによってレイテンシの分散が大きくなりました。比較測定では温度や推論設定を両モデルで揃えて明示することをおすすめします。推論コストの制御自体は Gemini 2.5 Pro の thinking_budget を制御する — コストを3分の1にしながら推論品質を守る実装パターン で書いた考え方がそのまま使えます。
最初の一本を選んで実測するところから
移行の全体像は「実測 → ワークロード別判定 → ルーター経由で段階切り替え → 監視とロールバック手段の常備」という流れに尽きます。Enterprise 側がデフォルト固定に踏み切ったように、この切り替えはいずれ外側からもやってきます。私自身、今回の棚卸しを終えるまで「いつでも移れる」とどこかで思い込んでいました。自分のタイミングで移れるうちに、判断材料を手元に持っておく方が良いと考えています。
次の一歩としては、本番パイプラインの中で「出力形式の検証が機械的にできる処理」を 1 本だけ選び、上の評価ハーネスに 20 ケースほど流してみてください。p50 レイテンシと形式合格率の 2 つの数字が出た時点で、移行判断は感覚の問題ではなくなります。同じ作業をされる方の参考になれば幸いです。