同じプロンプト、同じコード、同じ SDK バージョン。違うのはリージョンだけ。それなのに asia-northeast1 と us-central1 で返ってくる文章の長さが、目に見えて違っていました。
7 月 15 日、gemini-flash-latest の実体が Gemini 3.5 Flash に切り替わった直後のことです。私は個人開発のアプリで、日本のユーザー向けに東京リージョン、それ以外に us-central1 という素朴な振り分けをしていました。二度書くのが重複に思えて、両方に同じエイリアスを書いていた。それが数日間だけ、別々のモデルを指していたのです。
厄介だったのは、壊れなかったことでした。どちらのレスポンスも正常で、JSON も通る。ただ、その週に取った「東京の方が速い」「英語の出力が少し短い」という観測値が、後から全部使えないものになりました。測っていたのはリージョン差ではなく、モデル差だったからです。
エイリアスは「グローバルな別名」ではない
gemini-flash-latest のようなエイリアスを、私は長いあいだ「どこから呼んでも同じ実体に解決される、グローバルなショートカット」だと思い込んでいました。実際には違います。エイリアスは、そのリージョンにおける現在の推奨版を指すローカルな別名 です。
新しいバージョンが GA になると、ロールアウトはリージョンごとに順次進みます。全リージョンが同時に切り替わるわけではありません。つまりエイリアスは、次の 3 つを同時には保証しません。
期待していたこと 実際
いつ呼んでも同じ実体 ロールアウト時に予告なく切り替わる
どこから呼んでも同じ実体 リージョンごとに解決先がずれる期間がある
切り替わったら通知される レスポンスを見に行かない限り分からない
1 番目は比較的よく知られています。私が取りこぼしていたのは 2 番目でした。バージョン固定という対策は 1 番目にしか効きません。エイリアスを残したまま複数リージョンに展開している箇所には、まったく効いていなかった。
幸い、どのモデルが実際に応答したかはレスポンスが教えてくれます。model_version がそれです。
# 何を解決するコード: エイリアスで呼んだとき、実際にどの具体バージョンが
# 応答したのかをリージョンごとに確認する。ここが一致していない期間は、
# リージョン間の比較値がモデル差に汚染されています。
import os
from google import genai
def resolve (location: str , alias: str = "gemini-flash-latest" ) -> str :
client = genai.Client(
vertexai = True ,
project = os.environ[ "GOOGLE_CLOUD_PROJECT" ],
location = location,
)
res = client.models.generate_content(
model = alias,
contents = "ok" , # 解決先を知るだけなので最小の入力にします
)
return res.model_version
for loc in ( "asia-northeast1" , "us-central1" ):
print ( f " { loc :16s } -> { resolve(loc) } " )
出力の一例です。
asia-northeast1 -> gemini-3.5-flash
us-central1 -> gemini-3.1-flash
この 2 行を見た瞬間に、その週の計測ノートを閉じました。contents を "ok" の 2 文字にしているのは意図的で、解決先さえ分かればよいので入力トークンを増やす理由がありません。1 回あたりの入力は数トークン、gemini-3.5-flash の入力単価は 100 万トークンあたり 1.50 ドルですから、1 日 2 リージョン分を回しても費用は事実上ゼロです。
スキューが汚すのは、比較値だけではない
解決先がずれている期間に何が壊れるのか。整理してみると、被害は 3 方向に分かれました。
比較とカナリア。 リージョン A と B の出力を突き合わせる設計は、両者が同じモデルであることを暗黙の前提にしています。前提が崩れた期間のデータは、差分の原因を切り分けられません。
レイテンシの指標。 「東京の方が p95 が 200ms 速い」— ネットワーク距離の話だと思って読みますが、世代の違うモデルが混ざっていれば、それは推論時間の差かもしれません。
キャッシュ。 これが一番静かに効きました。私はレスポンスキャッシュのキーを alias + プロンプトのハッシュ で作っていたのです。東京で gemini-3.5-flash が生成した結果が gemini-flash-latest という名前で保存され、us-central1 のリクエストがそれを引く。リージョンをまたいだキャッシュ共有をしている場合、別のモデルの出力を、そのモデルの出力として配ってしまいます 。
キャッシュキーの修正は 1 行で済みました。名前ではなく、応答した実体を鍵に混ぜます。
# 何を解決するコード: キャッシュキーにエイリアス名ではなく、実際に応答した
# 具体バージョンを埋め込む。これでロールアウト中に別モデルの出力が
# 同じキーに同居することを防げます。
import hashlib
def cache_key (model_version: str , prompt: str ) -> str :
# model_version は res.model_version(例: "gemini-3.5-flash")。
# alias("gemini-flash-latest")を使うと、解決先が変わった瞬間に
# 古い実体の出力を新しい実体の出力として配ることになります。
digest = hashlib.sha256(prompt.encode( "utf-8" )).hexdigest()[: 32 ]
return f " { model_version } : { digest } "
書き込み時に model_version が手元にあるのは自明ですが、読み込み時にはまだ応答していないので分かりません。ここは素直に「読み込み側は、そのリージョンの解決先を 1 日 1 回だけ確認して使い回す」で足りています。厳密さより、別モデルの出力が混ざらないことの方が大事でした。
解決先を突き合わせるプローブを 1 本だけ持つ
対策を大がかりにする気はありませんでした。必要なのは「今、リージョン間でずれているか」という真偽値ひとつです。
# 何を解決するコード: 全リージョンの解決先を1日1回そろえて記録し、
# 一致しなければ skew=True を立てる。比較・カナリアの実行前にこの値を
# 見て、汚染された期間のデータを最初から取らないようにします。
import json
import pathlib
from datetime import datetime, timezone
REGIONS = ( "asia-northeast1" , "us-central1" , "europe-west4" )
STATE = pathlib.Path( "alias_skew_state.json" )
def probe (alias: str = "gemini-flash-latest" ) -> dict :
resolved = {loc: resolve(loc, alias) for loc in REGIONS }
versions = set (resolved.values())
state = {
"checked_at" : datetime.now(timezone.utc).isoformat(),
"alias" : alias,
"resolved" : resolved,
"skew" : len (versions) > 1 ,
}
STATE .write_text(json.dumps(state, ensure_ascii = False , indent = 2 ))
return state
def comparisons_allowed () -> bool :
if not STATE .exists():
return False # 状態が無いなら安全側に倒します
return not json.loads( STATE .read_text())[ "skew" ]
comparisons_allowed() が False を返す日は、リージョン間の比較レポートを生成しません。生成して「参考値」と注記する運用も考えましたが、注記は読まれないという前提に立ちました。出さない方が確実です。
STATE が存在しないときに False を返しているのは意図的で、プローブが失敗している状態を「たぶん大丈夫」と読み替えたくなかったからです。判断材料が無いことと、問題が無いことは違います。
ロールアウトの追従にかかった日数は、私が観測した範囲では 3 日ほどでした。ただしこれは 1 回の切り替えでの数字にすぎませんし、モデルやリージョンの組み合わせによって変わるはずです。日数を当てにするのではなく、毎日 1 回聞きに行く方が結局は安上がりでした。
すでに取ってしまったデータをどう扱うか
プローブを入れても、入れる前に取ったデータは残ります。捨てるか、救うか。
私は model_version を残していなかったので、7 月前半の計測は救えませんでした。個人開発だとログの列が増えるのを嫌ってしまい、この 1 列を落としていたのです。どのリージョンのどの行がどの実体で生成されたのか、事後に復元する手段が無かったからです。捨てるしかなかった。
もし記録が残っているなら、集計の粒度を変えるだけで救えます。エイリアス名で group by していたところを、解決先の実体で group by し直す。これだけで「東京 vs us-central1」という汚れた比較が、「3.5 Flash vs 3.1 Flash」という意味のある比較に変わることがあります。
# 何を解決するコード: 集計の軸をエイリアス名から実体(model_version)へ移す。
# ロールアウト期間をまたいだログでも、混ざったモデルを分離して読めます。
from collections import defaultdict
def latency_by_resolved (rows: list[ dict ]) -> dict :
# rows の各要素: {"region": ..., "model_version": ..., "latency_ms": ...}
# NG: region だけで束ねる → ロールアウト期間はモデル差が混入します
# OK: (region, model_version) で束ねる → 同じ実体同士だけを比べられます
buckets = defaultdict( list )
for r in rows:
buckets[(r[ "region" ], r[ "model_version" ])].append(r[ "latency_ms" ])
return {
k: sorted (v)[ len (v) // 2 ] # 中央値
for k, v in buckets.items()
if len (v) >= 30 # 件数が少ない組は読まない
}
len(v) >= 30 で足切りしているのは、ロールアウトの端では片方の実体のサンプルが数件しか無いことがあり、その中央値を並べても意味を読み取れなかったためです。30 という数字に根拠があるわけではなく、それ以下だと私の目で見て判断できなかった、という程度のものです。ご自身のトラフィック量に合わせて動かしてください。
どの用途でエイリアスを許すか
プローブを入れてから、エイリアスそのものの使いどころを引き直しました。全部を固定するのは、廃止期限が来たときに一斉に止まるリスクを抱えることでもあります。
コードパス 置くもの 理由
リージョン間の比較・カナリア 固定版のみ 測りたいのはリージョン差。モデル差が混ざると測定自体が成立しません
キャッシュを共有する経路 固定版、またはキーに実体を含める 別モデルの出力が同じ鍵に同居します
下流が機械で読む出力(JSON・Function Calling) 固定版 世代が変わるとスキーマ解釈の厳密さが変わります
ユーザー向けの対話 UI エイリアスでよい ユーザーは常に最新を期待します。品質はプローブとは別に見ます
単一リージョンの社内ツール・実験 エイリアスでよい スキューが起きようがなく、影響範囲も小さいです
個人開発では、下流で誰かが気づいてくれることはありません。私自身は、迷ったら固定版に寄せる方を好みます。エイリアスを採用する場合は、乗り換えられて困らない経路かどうかを先に確かめることを推奨します。エイリアスが与えてくれるのは「更新を忘れても新しいモデルに乗れる」という利便で、これは裏を返せば「気づかないうちに乗り換えさせられる」ということです。利便を受け取る場所は、乗り換えられて困らない場所に限る。そう決めてから、判断で悩む時間が減りました。
固定版を選ぶと廃止期限の管理が必要になりますが、そちらはGemini API モデル非推奨・移行エラーの対処法 に手順をまとめています。実際にどのバージョンが応答したかをリクエスト単位で残す設計はusageMetadata で本番アプリのコストを記録する が近い話です。
リージョンをまたがない人にも 1 つだけ
ここまでは複数リージョンの話でした。単一リージョンで運用している場合、スキューは起きません。それでも 1 つだけ持ち帰っていただけるとしたら、model_version をログに残すこと です。
行数にして 1 行です。私はこの 1 列を落としたことを、後から一番悔やみました。この 1 行があると、「先週と挙動が違う気がする」と感じたときに、感覚の話を事実の話に変えられます。無ければ、変わったのかどうかすら分からないまま、プロンプトを疑って何時間も溶かすことになります。私はそれをやりました。
8 月 17 日には旧来の画像生成モデルが停止します。期限の前後はロールアウトが動く時期でもあります。今週やるなら、この 3 つで十分だと思います。
手元のコードで -latest を含む文字列を grep する
その行がリージョンをまたいでいるか、キャッシュキーに使われているかを確認する
model_version をレスポンスログに 1 列足す
プローブはその後で足せます。順番を逆にすると、プローブが動き出した日から先のデータしか読めません。
読んでくださってありがとうございました。同じところで数日を溶かす方が減れば嬉しいです。