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 と gemini-3-pro-image-preview、8月17日には Imagen 4.0 系に停止日が置かれてきました。ピンは永続しません。ピン留めは問題の解決ではなく、「変更日を自分で選ぶ権利」を買っているだけです。棚卸しされないまま放置されたピンは、停止期限の日にエイリアスより派手に壊れます。モデルが消える側(停止期限)への備えと、新しいモデルが勝手にやって来る側への備えは、別物として設計する必要がありました。今回の事故は後者です。
つまり、エイリアスかピンかの二択ではないのです。ピン留めで変更日を自分の手に取り戻し、そのうえで昇格を定期運用へ組み込みます。私が移行したのは、この二段構えでした。
カナリア比較ハーネスの実装
昇格の判断を感覚で行わないために、同一入力を新旧モデルへ流して下流契約への適合を比較する、小さなハーネスを書きました。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.6 Flash は、私の契約に対して120件中13件の不適合(10.8%)。本番で観測した 9.8% とほぼ同じ姿が、カナリアでも再現されています。
ここからが二つ目の発見でした。不適合の主因だったタグ個数と enum 外カテゴリは、モデルの劣化ではなく、私のプロンプトとスキーマが「お願い」しかしていなかったことに根がありました。旧プロンプトはタグ数を「5個程度」と書き、カテゴリ一覧は本文に列挙するだけ。responseSchema には個数制約も enum も入れていませんでした。3.5 Flash はたまたま保守的に5個を守り、3.6 Flash は「程度」を豊かに解釈しました。それだけのことです。
そこで、制約をスキーマ側へ移しました。tags に minItems と maxItems、category に enum。文字数上限だけはスキーマで表現しきれなかったため、プロンプトでの明記と受信側バリデーションの二重にしています。修正後の結果です。
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 の「数値で固定」です。基準を先に決めておくと、新モデルの第一印象(速い、賢い)に引きずられずに済みます。
その後の6週間が、答え合わせになりました
ピン留めへ移してから、gemini-flash-latest の指す先はさらに二度動いています。Gemini API の deprecations ページに載っている公開日を並べると、この系統の刻みがそのまま読めます。
エイリアス追従のままなら、7月22日の朝から6週間で同じ朝をあと二回迎えていた計算になります。間隔が 63日・23日・20日と詰まっているのは今年の一時的な事情かもしれませんが、「モデル更新は年に数回の出来事」として身構えていた私の見積もりは、少なくともこの数か月については甘かったのだと思います。ピン留めの手間は、二回分の朝で払い戻されました。
そしてもう一つ、ピン留め側の前提にも修正が要りました。deprecations ページには、掲載されている停止日は「最短の停止日」であって確定日ではないと明記されています。つまり日付は前倒しされない代わりに、後ろへ延びることもあります。ピンの寿命を「その日まで安全」と読むのは、半分だけ正しい読み方でした。
さらに厄介なのは、私がいま本番に置いている 3.5 Flash と 3.6 Flash には、そもそも停止日が載っていないことです。空欄は「安全」ではなく「まだ告知されていない」という意味でしかありません。棚卸しの表で空欄を見て安堵しかけた自分に気づいたのは、しばらく画面を眺めたあとでした。
そこで、日付の有無ではなく系統でピンの寿命を見積もることにしました。同じページから猶予を拾うと、系統ごとの時間感覚がはっきり分かれます。
preview のピンは四半期ごとに寿命が尽きる前提で、GA は年単位で、埋め込みは年度をまたぐ計画で扱っております。ピンの寿命は、掲載された日付ではなくモデルの系統から見積もります。 この読み替えをしてから、月次の棚卸しが「日付を確認する作業」ではなく「どのピンが preview のまま残っているかを探す作業」に変わりました。
ログの1行を、棚卸しに使えるようにする
model_version をログへ残す話は先に書きましたが、残すだけでは事故の朝に間に合いません。私はそのログを日次で畳んで、ピンの実体が動いていないかを確認する小さなスクリプトを足しました。
# model_version_watch.py — 本番ログの model_version を日次で棚卸しし、
# 「設定で指定したモデル」と「実際に応答したモデル」のずれを検知する
# 期待値は pins.json に置きます: {"gemini-3.5-flash": ["gemini-3.5-flash-002"]}
import json
import collections
EXPECTED = json.load(open("pins.json", encoding="utf-8"))
seen = collections.defaultdict(lambda: {"n": 0, "rejected": 0})
for line in open("batch_log.jsonl", encoding="utf-8"):
r = json.loads(line)
key = (r["model"], r.get("model_version", "unknown"))
seen[key]["n"] += 1
seen[key]["rejected"] += int(bool(r.get("violations")))
alerts = []
for (model, version), c in sorted(seen.items()):
rate = c["rejected"] / c["n"] * 100
known = version in EXPECTED.get(model, [])
print(f'{model:22} {version:26} {c["n"]:5}件 拒否 {rate:5.1f}% {"" if known else "← 未知"}')
if not known:
alerts.append((model, version, rate))
for model, version, rate in alerts:
# 設定は変えていないのに実体が変わった状態。まず並走の停止と旧ピンへの復帰を検討します
print(f"[ALERT] {model} の実体が {version} に変わっています(拒否 {rate:.1f}%)")
要点は、拒否率を model_version ごとに割ることです。全体の拒否率だけを見ていると、モデルが変わったのか入力データが変わったのかを分けられません。7月22日のログをこの形で畳み直すと、旧バージョンの行は 2.1% のまま、新しく現れた行だけが 9.8% を出しており、原因の切り分けは1行の出力で済んでいました。私が半日を費やした問いには、すでにログが答えていたのです。
どこまでを latest に任せてよいか
すべてをピン留めせよ、とは考えておりません。ワークロードの性質で線を引くのが現実的です。
要は、出力を機械が読むならピン留めとカナリア、人間が読むなら latest 追従も選択肢、という線です。埋め込みモデルだけは別格で、追従という選択肢そのものがありません。
gemini-flash-latest を信じるのをやめたというより、信じ方を変えた、という感覚でおります。エイリアスには「新しいモデルの存在に気づく」役割だけを残し、本番で動く実体は常に自分で選んでおります。まずは直近1週間の本番ログから20件を抜き出して、golden_set.jsonl の最初の20行を作るところから始めてみてください。この記録が、モデル更新の朝を波風なく越える助けになれば嬉しく思います。