7月22日の朝、壁紙アプリのメタデータ生成バッチの監視画面に、見慣れない数字が並んでおりました。
出力バリデーションの拒否率 9.8%。前日までの30日平均は 2.1% です。コードは1行も変えていません。プロンプトも、スキーマも、デプロイ履歴も、何もかもそのままです。
変わっていたのはモデルの方でした。応答に含まれる model_version の値が、前日の夜を境に切り替わっていたのです。2026年7月21日、Gemini 3.6 Flash が大きな発表を伴わない形で公開され、私のバッチが指定していた gemini-flash-latest の指す実体が、手元の環境では静かに入れ替わっていました。
ここに書くのは、その朝の切り分けに始まり、エイリアス追従をやめ、バージョンピン留めとカナリア昇格の二段構えへ設計を移すまでの記録です。既定の Flash が静かに刷新される運用が現実になった以上、同じ形の朝を迎える方はこれから確実に増えると考えております。
何も変えていないのに挙動が変わった朝
まず、壊れ方の中身から共有いたします。
このバッチは、2014年から一人で運用している壁紙アプリ群のために、毎晩1,000件前後の画像へカテゴリ・タグ・短い説明文を付けるものです。壁紙そのものは人の手で用意したアセットで、Gemini に任せているのはあくまで運用メタデータの整備です。出力は responseSchema で JSON に固定し、受信側でも独自のバリデーションを掛けています。拒否された件は翌晩に回るだけなので、数%の拒否は想定内の運用でした。
その拒否率が一晩で 4.7倍になりました。内訳を集計すると、およそ6割が「タグが規定の5個を超えている」、3割弱が「カテゴリが許可リストの外」、残りが「説明文の文字数超過」です。
興味深かったのは、拒否された出力を目で読むと、むしろ品質が上がって見えたことでした。説明文はより丁寧で、タグは気が利いていて、6個目・7個目のタグも内容としては的確です。レイテンシの p50 は 1.9秒から 1.3秒へ縮み、応答は明らかに速くなっていました。
モデルは良くなっているのに、パイプラインは壊れる。モデルの改善は、機械的な下流契約から見ると互換性の破壊として現れる——これが最初の、直感に反する学びでした。人間が読むチャットUIなら歓迎されたはずの変化が、構造化出力のバッチでは事故になります。
エイリアス追従とピン留め、それぞれの罠
gemini-flash-latest のようなエイリアスは、「常に最新の Flash を使う」という意思表示です。コードを変えずに改善を受け取れる。更新の追従漏れがなくなる。利点は本物で、私自身、昨年からこの指定で運用してきて、大半の更新は無風で通過していました。
問題はただ一点、変更のタイミングを自分で選べないことです。7月21日の Gemini 3.6 Flash は、既存 Flash を静かに置き換える形の刷新でした。「リリース当日は検証環境で受け止める」という構え自体が、エイリアス追従では取れません。
では全てをバージョンピン留めにすれば安全かというと、こちらには別の罠があります。Gemini API では今年に入ってからも、6月1日に Gemini 2.0 系、6月25日に gemini-3.1-flash-image-preview などが停止され、8月17日には旧来の画像生成モデルの停止が控えています。ピンは永続しません。ピン留めは問題の解決ではなく、「変更日を自分で選ぶ権利」を買っているだけです。棚卸しされないまま放置されたピンは、停止期限の日にエイリアスより派手に壊れます。モデルが消える側(停止期限)への備えと、新しいモデルが勝手にやって来る側への備えは、別物として設計する必要がありました。今回の事故は後者です。
つまり、エイリアスかピンかの二択ではないのです。ピン留めで変更日を自分の手に取り戻し、そのうえで昇格を定期運用に組み込む。私が移行したのは、この二段構えでした。
カナリア比較ハーネスの実装
昇格の判断を感覚で行わないために、同一入力を新旧モデルへ流して下流契約への適合を比較する、小さなハーネスを書きました。golden_set.jsonl には本番ログから抜いた代表120件——通常ケース80件、境界ケース30件、過去に事故を起こした10件——を入れています。
# canary_compare.py — 同一ゴールデンセットを新旧モデルへ流し、下流契約への適合を比較する
# 事前準備: pip install google-genai / golden_set.jsonl を同じディレクトリに置く
import json
import time
import pathlib
import statistics
from google import genai
from google.genai import types, errors
client = genai.Client(api_key="YOUR_API_KEY")
BASELINE = "gemini-3.5-flash" # ピン留め中の現行モデル
CANDIDATE = "gemini-3.6-flash" # 昇格候補
ALLOWED_CATEGORIES = [
"nature", "abstract", "minimal", "night", "pastel",
"vivid", "monochrome", "seasonal", "texture", "gradient",
]
# 制約は「プロンプトのお願い」ではなくスキーマに置く(今回の事故で学んだ核心)
SCHEMA = types.Schema(
type=types.Type.OBJECT,
properties={
"category": types.Schema(type=types.Type.STRING, enum=ALLOWED_CATEGORIES),
"tags": types.Schema(
type=types.Type.ARRAY,
items=types.Schema(type=types.Type.STRING),
min_items=5,
max_items=5,
),
"caption": types.Schema(type=types.Type.STRING),
},
required=["category", "tags", "caption"],
)
def validate(data: dict) -> list:
"""スキーマで表現しきれない契約は、受信側で必ず二重に検証します。"""
v = []
if data.get("category") not in ALLOWED_CATEGORIES:
v.append("enum_out")
if len(data.get("tags", [])) != 5:
v.append("tag_count")
if len(data.get("caption", "")) > 120: # 文字数上限はスキーマでは強制しきれない
v.append("caption_length")
return v
def generate(model: str, item: dict) -> dict:
"""1件を生成。429/503 は指数バックオフで粘ります。"""
last_err = None
for attempt in range(5):
try:
t0 = time.monotonic()
resp = client.models.generate_content(
model=model,
contents=[
types.Part.from_bytes(
data=pathlib.Path(item["image"]).read_bytes(),
mime_type="image/jpeg",
),
item["prompt"],
],
config=types.GenerateContentConfig(
temperature=0.0, # 比較が目的なので出力の揺れを最小化
response_mime_type="application/json",
response_schema=SCHEMA,
),
)
latency = time.monotonic() - t0
data = json.loads(resp.text)
return {
"ok": True,
"violations": validate(data),
"latency": latency,
"out_tokens": resp.usage_metadata.candidates_token_count or 0,
# これを毎回ログへ。障害調査の最初の30分がここで決まります
"model_version": getattr(resp, "model_version", "unknown"),
}
except errors.APIError as e:
last_err = e
if e.code in (429, 503) and attempt < 4:
time.sleep(min(2 ** attempt * 5, 60))
continue
break
return {"ok": False, "violations": ["api_error: " + str(last_err)],
"latency": None, "out_tokens": 0, "model_version": "n/a"}
def run(model: str, golden: list) -> None:
results = [generate(model, item) for item in golden]
ok = [r for r in results if r["ok"]]
if not ok:
print(model, ": 全件失敗(APIキー・権限・モデル名を確認)")
return
clean = [r for r in ok if not r["violations"]]
breakdown = {k: sum(k in r["violations"] for r in ok)
for k in ("tag_count", "enum_out", "caption_length")}
lat = sorted(r["latency"] for r in ok)
print("---", model, "( model_version:", ok[0]["model_version"], ")")
print("契約適合:", len(clean), "/", len(golden))
print("逸脱内訳:", breakdown)
print("出力トークン平均:", round(statistics.mean(r["out_tokens"] for r in ok)))
print("latency p50/p95:",
round(lat[len(lat) // 2], 2), "s /",
round(lat[int(len(lat) * 0.95) - 1], 2), "s")
if __name__ == "__main__":
golden = [json.loads(line) for line in open("golden_set.jsonl", encoding="utf-8")]
run(BASELINE, golden)
run(CANDIDATE, golden)
実装で一つだけ強調したいのは、レスポンスの model_version を必ず毎回ログへ残す点です。私はこれを怠っていたため、7月22日の朝は「モデルが変わったこと」の確定だけに半日を使いました。ログの1行が、切り分けの最初の30分を救います。
なお 429 を含む本番側の防御全般は主題から外れるため、429・500・503 に耐えるエラーハンドリングの設計に譲ります。
実測で見えたこと — 3.6 Flash 昇格判断の中身
ハーネスを 3.5 Flash(ピン留め中)と 3.6 Flash に流した初回の結果です。
| 指標 | 3.5 Flash(ピン留め) | 3.6 Flash(修正前) |
| 契約適合 | 117/120(97.5%) | 107/120(89.2%) |
| タグ個数の逸脱 | 2件 | 8件 |
| enum外カテゴリ | 0件 | 4件 |
| 説明文の長さ超過 | 1件 | 1件 |
| 出力トークン平均 | 214 | 253(+18%) |
| レイテンシ p50 / p95 | 1.9秒 / 4.8秒 | 1.3秒 / 3.1秒 |
この時点の 3.6 Flash は、私の契約に対して120件中13件の不適合(10.8%)。本番で観測した 9.8% とほぼ同じ姿が、カナリアでも再現されています。
ここからが二つ目の発見でした。不適合の主因だったタグ個数と enum 外カテゴリは、モデルの劣化ではなく、私のプロンプトとスキーマが「お願い」しかしていなかったことに根がありました。旧プロンプトはタグ数を「5個程度」と書き、カテゴリ一覧は本文に列挙するだけ。responseSchema には個数制約も enum も入れていませんでした。3.5 Flash はたまたま保守的に5個を守り、3.6 Flash は「程度」を豊かに解釈した。それだけのことです。
そこで、制約をスキーマ側へ移しました。tags に minItems と maxItems、category に enum。文字数上限だけはスキーマで表現しきれなかったため、プロンプトでの明記と受信側バリデーションの二重にしています。修正後の結果です。
| 指標 | 3.5 Flash(修正後) | 3.6 Flash(修正後) |
| 契約適合 | 119/120(99.2%) | 118/120(98.3%) |
| enum外カテゴリ | 0件(構造上あり得ない) | 0件(構造上あり得ない) |
| タグ個数の逸脱 | 0件 | 0件 |
| 説明文の長さ超過 | 1件 | 2件 |
| 出力トークン平均 | 209 | 201 |
enum 逸脱は構造上あり得なくなり、タグ個数も揃いました。注目したいのは、同じ修正で旧モデル側の適合率まで上がったことです(不適合 3件 → 1件)。新モデル対応のつもりで行った作業の実体は、暗黙の期待を契約として明文化する作業でした。旧モデルで「動いていた」プロンプトは、旧モデルの癖に黙って依存していたのです。プロンプトを直したら旧モデルでも良くなる——モデル互換性の問題だと思っていたものの多くは、契約の曖昧さの問題でした。
コスト面では、出力トークンが平均 214 → 253(+18%)と増えていましたが、スキーマ制約後は 201 に収まり、単価の前提を変えなければ実効コストはむしろ微減です。トークンの数え方とコスト基準の詰め方はシステム指示の言語選択とトークン増加の実測も参考になるかと思います。レイテンシは p50 1.3秒・p95 3.1秒と明確な改善でしたので、私の基準では昇格が妥当と判断しました。
昇格を定期運用にする — 四半期カナリアのチェックリスト
同じ出来事を、次からは事故ではなく手順として迎えるために、運用を次の形に固定しました。
- モデル名をコードから設定へ出す(環境変数1つで切り替えとロールバックを可能にする)
- golden_set.jsonl を本番ログから月次で入れ替える(古びたゴールデンセットは偽の安心を生みます)
- 新モデルの公開を検知したら、まずカナリアとしてバッチの5%に並走させる
- 昇格の判定基準は数値で固定する——スキーマ適合率の低下 0.5ポイント以内・enum逸脱 0.5%以下・実効コスト +10%以内・p95 レイテンシ悪化なし
- 昇格でも見送りでも、実測値と理由を1枚のメモに残す
- Gemini API の deprecations ページの停止期限を月次で棚卸しし、ピンの寿命を確認する
- 昇格後2週間は旧ピンを消さない(設定1行で戻れる状態を保つ)
この7項目のうち、効いている実感が最も大きいのは 4 の「数値で固定」です。基準を先に決めておくと、新モデルの第一印象(速い、賢い)に引きずられずに済みます。
どこまでを latest に任せてよいか
すべてをピン留めせよ、とは考えておりません。ワークロードの性質で線を引くのが現実的です。
| ワークロード | 推奨 | 理由 |
| チャットUIなど、人間が読む出力 | latest 追従も選択肢 | 表現の変化は改善として受け取られやすく、機械的な契約違反が起きにくいためです |
| 構造化出力のバッチ処理 | ピン留め+カナリア昇格 | 下流契約があるため、変更日を自分で選ぶ必要があります |
| 埋め込み(セマンティック検索) | 厳格なピン留め | モデルが変わると既存ベクトルとの互換が失われ、全再インデックスが必要になります |
| 画像・動画などの生成系 | ピン留め+停止期限の監視 | プレビュー系の引退が速く、停止期限側が主リスクになります |
要は、出力を機械が読むならピン留めとカナリア、人間が読むなら latest 追従も選択肢、という線です。埋め込みモデルだけは別格で、追従という選択肢そのものがありません。
gemini-flash-latest を信じるのをやめたというより、信じ方を変えた、という感覚でおります。エイリアスには「新しいモデルの存在に気づく」役割だけを残し、本番で動く実体は常に自分で選ぶ。まずは直近1週間の本番ログから20件を抜き出して、golden_set.jsonl の最初の20行を作るところから始めてみてください。この記録が、モデル更新の朝を静かに越える助けになれば嬉しく思います。