Gemini API の changelog を読み返していて、10 月 8 日の項目で手が止まりました。gemini-3.7-flash と gemini-3.5-flash が非推奨になり、それぞれ新しい Flash へ自動で回される、という一行です。
自動で回される、ということは、コードの model を一文字も書き換えていなくても、返事を作っているモデルが入れ替わりうる、ということです。個人開発で、ストアのレビューへの返信文の下書きを Flash に任せている身としては、これを読んだ時点で少し落ち着かなくなりました。ある朝、返信の文末が妙に長くなっていたとして、私はまず自分のプロンプトを疑います。プロンプトを直し、温度を下げ、また直す——そうして半日を使ったあとで、実はモデルのほうが変わっていた、という順番だけは避けたいと思ったのです。
この記事は、その順番を逆にするための記録です。先にお伝えしておくと、自動で回された後に modelVersion へどの ID が入って返るのかを、私は自分の環境で確かめておりません。ですから以下は、確かめるための道具を先に置いておく話です。
最初にお伝えしたいのは「要求した ID」と「応答した ID」を別々に残すことです
generateContent のレスポンスには、実際に応答したモデルを示す modelVersion が入っています(Python の google-genai では response.model_version)。リクエストに書いた ID と、ここに入って返ってくる ID は、必ずしも同じ文字列とは限りません。
切り分けの考え方は、次の表のとおりです。
| 要求と応答の ID | 出力の変化 | 先に疑う場所 |
|---|---|---|
| 一致している | あり | プロンプト・設定・入力データ・SDK の更新 |
| 一致していない | あり | モデルの入れ替わり。プロンプトは触らない |
| 一致していない | なし | いまは問題なし。ただし単価と上限は見直す |
| 応答に modelVersion が無い | あり | 記録できていない。ログの取り方を先に直す |
この表の一行目と二行目を分けられるだけで、半日が三十分になります。逆に、ID を残していない日は、どの行にいるのかが分かりません。
3 プロンプトだけの確認スクリプト
毎日回す確認ですから、重くしないことを最優先にしました。入力は普段の用途に近い短いものを 3 本だけ、maxOutputTokens は 256、温度は 0 に固定します。見るのは文章の良し悪しではなく、次の 5 点です。
- 要求した ID と応答した ID
- 文字数
- JSON として読めるか(コードフェンスを外したうえで)
finishReason- HTTP ステータス
import json
import os
import time
import urllib.error
import urllib.request
BASE = "https://generativelanguage.googleapis.com/v1beta"
REQUESTED = os.environ.get("GEMINI_MODEL", "gemini-3.7-flash") # 自分のコードに書いているモデル ID
LOG = "canary_log.jsonl"
# 普段の用途に近い短い入力を 3 本だけ。長さと形式が見えれば十分です。
PROMPTS = {
"reply_draft": "星 2 のレビュー「広告が多い」に、丁寧な返信を 3 文で書いてください。",
"json_shape": 'JSON だけで答えてください。キーは "tone" と "reason"。文面: 「ありがとう、助かりました」',
"short_answer": "壁紙アプリの通知は何時に送ると開封されやすいですか。1 文で。",
}
def call(prompt, key):
body = json.dumps({
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"maxOutputTokens": 256, "temperature": 0},
}).encode()
req = urllib.request.Request(
f"{BASE}/models/{REQUESTED}:generateContent",
data=body, method="POST",
headers={"Content-Type": "application/json", "x-goog-api-key": key},
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, json.load(r)
except urllib.error.HTTPError as e:
return e.code, {"error": e.read().decode()[:300]}
def summarize(label, status, resp):
"""レスポンス 1 件を、後で突き合わせる 1 行に畳む。"""
cand = (resp.get("candidates") or [{}])[0]
parts = (cand.get("content") or {}).get("parts") or [{}]
text = parts[0].get("text", "")
served = resp.get("modelVersion", "(none)") # 実際に応答したモデル
try:
json.loads(text.strip().removeprefix("```json").removesuffix("```").strip())
json_ok = True
except ValueError:
json_ok = False
return {
"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
"label": label, "status": status,
"requested": REQUESTED, "served": served,
"routed": served != REQUESTED and served != "(none)",
"chars": len(text), "json_ok": json_ok,
"finish": cand.get("finishReason", "(none)"),
}
def last_two_runs(path=LOG):
rows = [json.loads(l) for l in open(path, encoding="utf-8")]
labels = sorted({r["label"] for r in rows})
out = {}
for lb in labels:
mine = [r for r in rows if r["label"] == lb]
if len(mine) >= 2:
out[lb] = (mine[-2], mine[-1])
return out
def report(path=LOG):
for lb, (a, b) in last_two_runs(path).items():
notes = []
if a["served"] != b["served"]:
notes.append(f"served {a['served']} -> {b['served']}")
if a["json_ok"] != b["json_ok"]:
notes.append(f"json_ok {a['json_ok']} -> {b['json_ok']}")
if a["chars"] and abs(b["chars"] - a["chars"]) / a["chars"] > 0.5:
notes.append(f"chars {a['chars']} -> {b['chars']}")
print(f"{lb:<13}", "; ".join(notes) if notes else "変化なし")
if __name__ == "__main__":
key = os.environ["GEMINI_API_KEY"]
with open(LOG, "a", encoding="utf-8") as f:
for label, prompt in PROMPTS.items():
status, resp = call(prompt, key)
row = summarize(label, status, resp)
f.write(json.dumps(row, ensure_ascii=False) + "\n")
print(row["label"], row["status"], row["requested"], "->", row["served"])
report()キーは環境変数 GEMINI_API_KEY から読み、コマンドラインには渡しません。GEMINI_MODEL には、自分のコードに書いているモデル ID をそのまま入れてください。リトライは入れていません。確認用の呼び出しが 429 や 503 で失敗した日は、失敗した事実がそのまま status に残るほうが後で役に立つからです。
2 回目以降の実行では、末尾の report() が、前回との差だけを 1 ラベル 1 行で出します。形式が崩れた日、文字数が半分以下か 1.5 倍以上になった日、応答した ID が変わった日が、そこで目に入ります。
手元の疑似レスポンスで、差の出方だけ確かめました
実際の API を叩いたわけではありません。要求を gemini-3.7-flash、応答を gemini-3.8-flash に見立てた疑似レスポンスを 2 件作り、summarize と report に通しただけです。
json_shape served gemini-3.7-flash -> gemini-3.8-flash; chars 25 -> 871 行目の served の差で「モデルが入れ替わった日」が、続く chars の差で「出力の長さが動いた日」が読み取れます。この出力は私が組み立てた疑似データの結果ですので、実際のモデルの挙動を示すものではない点は、念のため書き添えておきます。
変化が出た日の、切り分けの順番
report() に何かが出た日は、次の順に見ています。
- 応答した ID が変わっていないか。 変わっていたら、プロンプトには触らず、changelog の該当日の項目を開きます。
finishReasonがSTOP以外になっていないか。MAX_TOKENSなら、モデルではなく出力上限の問題です。- JSON が読めなくなっていないか。 コードフェンスの有無だけで落ちている場合が多く、パーサー側で外す処理を足せば済みます。
- 文字数が動いただけか。 指示書に語数の上限を書く前に、前日の出力を 1 件だけ並べて読みます。
読み比べるときは、固定の質問集を使った比較の記事も参考になります。3.8 Flash と 3.7 Flash のどちらに寄せるかを、手元の 20 問で決めていますでは、同じ入力で二つの世代を走らせて判断するところまでを書きました。この記事の確認スクリプトは、その前段にあたる「そもそも入れ替わったのか」を知るための道具です。
応答した ID は、費用の記録にも使えます
応答した ID を残しておくと、副産物として、いつからどのモデルの単価で請求が動いたのかも後から辿れます。リクエスト単位でトークンを記録する実装はGemini API の usageMetadata をリクエスト単位で記録してコストを按分するに書いていますので、あわせてご覧ください。
非推奨や停止の通知を受けたときの移行の進め方は、Gemini API のモデル廃止にどう備えるかに整理してあります。自動で回される種類の変更は、こうした移行作業を「やらされる」側から「あとで気づく」側に寄せてしまうので、確認を仕組みにしておく意味が大きいのだと思います。
私が引いた線
今回の整理で、はっきりした線引きが一つできました。
出力が変わったら、プロンプトを直す前に、どのモデルが答えたのかを確かめる。
確認スクリプトは、cron で朝に 1 回だけ走らせるつもりでおります。夜中に通知を飛ばす仕組みにはしません。見るのは、コーヒーを淹れたあとの自分の目です。
まずは今日、上のスクリプトを一度だけ手動で実行して、canary_log.jsonl に 3 行が残ることを確かめてください。変化のない日の記録が 1 日分あるだけで、後日の比較の土台になります。