7月21日の週、手元のダッシュボードで「3.5系」と束ねていた帯が、説明のつかない下がり方をしました。
呼び出し回数は減っていません。落ちているのは出力トークンの比率だけ。障害の匂いはしないのに、数字だけが静かにずれている。
原因を辿ったら、モデルを止めた話でも、プロンプトを変えた話でもありませんでした。集計側が、モデルIDという文字列から世代とティアを読み取っていた。その読み取りが、ラインナップの並びが変わった瞬間に意味を失っていたのです。
個人開発で回している壁紙アプリの分類処理と、周辺の自動化。呼び出し先が増えるたびに、私はモデルIDを主キーではなく「情報が詰まった文字列」として扱っていました。便利だったぶん、崩れ方も静かでした。
「3.5系」という帯が、何を指しているのか分からなくなった
集計の実装はごく普通のものでした。日次の使用量テーブルに model_id を持ち、ダッシュボード側で正規表現を当てて generation と tier を導出する。ダッシュボードの定義を触らずにモデルを増やせるので、しばらくは快適に動いていました。
前提にしていたのは、次の2つです。
モデルIDの数字は世代を表し、大きいほど新しい
ハイフンで区切られた3番目の語がティア(flash / pro)を表す
2026年7月のラインナップは、この2つをどちらも満たしません。Flash 系の最新は 3.6、Pro 系の最上位は 3.5、画像系は 3.1、そして Omni Flash には番号がない。番号の大小は世代でも能力でもなくなっていました。
モデルID 位置づけ 番号から読める?
gemini-3.6-flashFlash 系の新しい版(7/21 公開) 読める
gemini-3.5-proPro 系の最上位 番号は 3.6 より小さい
gemini-3.5-flash-lite大量処理向けの軽量版 3語目が flash
gemini-3.1-flash-lite-image画像生成・編集 3語目が flash
gemini-omni-flash動画生成のプレビュー 番号がない
imagen-4.0-generate-001停止予定の画像モデル 接頭辞が違う
モデル名と提供状況は変わります。手元で表を持つ場合も、判断の前に公式のモデル一覧 と料金ページ で確かめてください。
実際のラインナップに、その正規表現を当ててみる
議論より先に、手を動かしました。当時使っていた正規表現をそのまま取り出し、7月時点の実IDに当てて出力を眺めます。
# parse_probe.py — 当時の派生ロジックを実IDに当てる
import re
MODELS = [
"gemini-3.6-flash" , "gemini-3.5-flash" , "gemini-3.5-flash-lite" ,
"gemini-3.5-flash-cyber" , "gemini-3.5-pro" , "gemini-3.1-flash-lite-image" ,
"gemini-omni-flash" , "gemini-flash-latest" , "imagen-4.0-generate-001" ,
]
PAT = re.compile( r " ^ gemini- ( ?P<major> \d + ) \. ( ?P<minor> \d + ) - ( ?P<tier> [ a-z ] + ) " )
print ( f " { 'model_id' :<30 } { 'gen' :<8 } { 'tier' :<8 } note" )
print ( "-" * 62 )
for m in MODELS :
hit = PAT .match(m)
if not hit:
print ( f " { m :<30 } { '-' :<8 } { '-' :<8 } UNPARSED -> falls to default bucket" )
continue
gen = f " { hit[ 'major' ] } . { hit[ 'minor' ] } "
print ( f " { m :<30 } { gen :<8 } { hit[ 'tier' ] :<8 } parsed" )
実行結果です。
model_id gen tier note
--------------------------------------------------------------
gemini-3.6-flash 3.6 flash parsed
gemini-3.5-flash 3.5 flash parsed
gemini-3.5-flash-lite 3.5 flash parsed
gemini-3.5-flash-cyber 3.5 flash parsed
gemini-3.5-pro 3.5 pro parsed
gemini-3.1-flash-lite-image 3.1 flash parsed
gemini-omni-flash - - UNPARSED -> falls to default bucket
gemini-flash-latest - - UNPARSED -> falls to default bucket
imagen-4.0-generate-001 - - UNPARSED -> falls to default bucket
厄介だったのは、失敗の出方です。
パースできなかった3件は、まだ救いがあります。バケットが - になるので、グラフを見れば「知らないものが来ている」と気づけます。
問題は、成功したように見える4件のほうでした。flash-lite も flash-cyber も flash-lite-image も、すべて tier=flash として通ってしまう。例外は出ません。ログにも残りません。値だけが混ざります。
パースエラーは目に入りますが、意味の取り違えは目に入らない。私が半日溶かしたのは、後者のほうでした。
「最新を選ぶ」という一行が、実装ごとに違う答えを返す
集計を直す前に、選択側も点検しました。候補リストから「いちばん新しいもの」を選ぶ処理は、書き方が何通りもあります。
# latest_probe.py — 「最新を選ぶ」の3実装を突き合わせる
import re
CANDIDATES = [ "gemini-3.5-pro" , "gemini-3.6-flash" ,
"gemini-3.10-flash" , "gemini-3.5-flash-lite" ]
print ( "[1] lexicographic max() ->" , max ( CANDIDATES ))
def numkey (m):
x = re.search( r " (\d + ) \. (\d + ) " , m)
return ( int (x.group( 1 )), int (x.group( 2 ))) if x else ( 0 , 0 )
print ( "[2] numeric (major,minor) max ->" , max ( CANDIDATES , key = numkey))
print ( "[3] float(version) max ->" ,
max ( CANDIDATES , key =lambda m: float (re.search( r " (\d + \. \d + ) " , m).group( 1 ))))
print ()
print ( "float('3.10') =" , float ( "3.10" ), " float('3.6') =" , float ( "3.6" ),
"->" , float ( "3.10" ) > float ( "3.6" ))
print ( "'3.10' > '3.6' (str) =" , "3.10" > "3.6" )
[1] lexicographic max() -> gemini-3.6-flash
[2] numeric (major,minor) max -> gemini-3.10-flash
[3] float(version) max -> gemini-3.6-flash
float('3.10') = 3.1 float('3.6') = 3.6 -> False
'3.10' > '3.6' (str) = False
3つとも通る実装で、答えが3通りに割れました。
3.10 を仮に置いたのは、二桁のマイナー番号がいつ現れてもおかしくないからです。文字列比較でも float 変換でも 3.10 は 3.6 より小さくなります。バージョン番号を小数として扱った時点で、.10 は .1 に潰れる。
そして [1] は、gemini-3.5-pro を差し置いて gemini-3.6-flash を返しました。「最新モデル」という言葉で推論寄りの処理を書いていたら、番号の大小だけで Flash に流れます。エラーは出ません。応答は返ります。品質だけが下がります。
この手のずれは、エイリアスの解決先が動く問題とも重なります。呼び出し先の固定については、gemini-flash-latest への追従をやめた日 に別の角度から書きました。
本当に痛かったのは集計側だった
選択側の誤りは、応答の質という形でいつか表に出ます。集計側の誤りは、そのまま帳簿に残ります。
手元の使用量台帳を SQLite に載せ、正規表現で導いたティアと、レジストリを結合したティアを並べました。台帳は形だけ再現したサンプルです。数字はご自身の書き出しに差し替えてください。
# attribution.py — 正規表現派生とレジストリ結合を突き合わせる
import re, sqlite3
con = sqlite3.connect( ":memory:" )
con.executescript( """
CREATE TABLE usage_fact (day TEXT, model_id TEXT, calls INT, in_tok INT, out_tok INT);
CREATE TABLE model_registry (
model_id TEXT PRIMARY KEY, family TEXT, generation TEXT, tier TEXT, modality TEXT
);
""" )
rows = [
( "2026-07-01" , "gemini-3.5-flash" , 9800 , 4_900_000 , 980_000 ),
( "2026-07-01" , "gemini-3.5-flash-lite" , 41000 , 8_200_000 , 410_000 ),
( "2026-07-01" , "gemini-3.1-flash-lite-image" , 2600 , 520_000 , 26_000 ),
( "2026-07-01" , "gemini-3.5-pro" , 420 , 1_260_000 , 336_000 ),
( "2026-07-22" , "gemini-3.6-flash" , 9100 , 4_550_000 , 755_000 ),
( "2026-07-22" , "gemini-3.5-flash-lite" , 40500 , 8_100_000 , 405_000 ),
( "2026-07-22" , "gemini-3.1-flash-lite-image" , 2700 , 540_000 , 27_000 ),
( "2026-07-22" , "gemini-3.5-pro" , 400 , 1_200_000 , 320_000 ),
( "2026-07-22" , "gemini-omni-flash" , 90 , 45_000 , 18_000 ),
]
con.executemany( "INSERT INTO usage_fact VALUES (?,?,?,?,?)" , rows)
reg = [
( "gemini-3.5-flash" , "flash" , "3.5" , "flash" , "text" ),
( "gemini-3.6-flash" , "flash" , "3.6" , "flash" , "text" ),
( "gemini-3.5-flash-lite" , "flash-lite" , "3.5" , "flash-lite" , "text" ),
( "gemini-3.5-pro" , "pro" , "3.5" , "pro" , "text" ),
( "gemini-3.1-flash-lite-image" , "image" , "3.1" , "flash-lite" , "image" ),
( "gemini-omni-flash" , "omni" , "n/a" , "omni" , "video" ),
]
con.executemany( "INSERT INTO model_registry VALUES (?,?,?,?,?)" , reg)
PAT = re.compile( r " ^ gemini- (\d + ) \. (\d + ) - ([ a-z ] + ) " )
def derive (mid):
h = PAT .match(mid)
return ( f " { h.group( 1 ) } . { h.group( 2 ) } " , h.group( 3 )) if h else ( "unknown" , "unknown" )
print ( f " { 'tier(regex)' :<14 }{ 'out_tok' :>12 } | { 'tier(registry)' :<16 }{ 'out_tok' :>12 } " )
a = {}
for mid, out in con.execute( "SELECT model_id, SUM(out_tok) FROM usage_fact GROUP BY model_id" ):
k = derive(mid)[ 1 ]
a[k] = a.get(k, 0 ) + out
b = dict (con.execute( """SELECT r.tier, SUM(f.out_tok) FROM usage_fact f
JOIN model_registry r USING(model_id) GROUP BY r.tier""" ))
for k in sorted ( set (a) | set (b)):
print ( f " { k :<14 }{ a.get(k, 0 ) :>12, } | { k :<16 }{ b.get(k, 0 ) :>12, } " )
tier(regex) out_tok | tier(registry) out_tok
flash 2,603,000 | flash 1,735,000
flash-lite 0 | flash-lite 868,000
omni 0 | omni 18,000
pro 656,000 | pro 656,000
unknown 18,000 | unknown 0
flash-lite の 868,000 トークンが、まるごと flash に吸い込まれています。そのぶん flash は 2,603,000 と、実際の 1,735,000 に対して 1.5 倍に膨らみました。
内訳を出すと、混ざり方の形が見えます。
gemini-3.1-flash-lite-image 53,000 ( 2.0% of regex 'flash')
gemini-3.5-flash 980,000 ( 37.6% of regex 'flash')
gemini-3.5-flash-lite 815,000 ( 31.3% of regex 'flash')
gemini-3.6-flash 755,000 ( 29.0% of regex 'flash')
「Flash 系」として眺めていた帯の 3 割強は、そもそも別の価格帯のモデルでした。テキスト生成と画像生成が同じ器に入っていたわけです。機能ごとの採算を見ようとしていたのに、器の中身が違っていた。この観点は、機能単位で採算を見る設計 で扱った按分の話と、根が同じでした。
値下げの効果測定が、静かに薄まる
いちばん堪えたのはここです。
Gemini 3.6 Flash は、同じ課題に対して出力トークンの消費が減ると案内されています。切り替えの判断をするなら、毎コールあたりの出力トークンを前後で比べるのが素直な確かめ方でしょう。
その指標を、正規表現の帯とレジストリの帯の両方で出しました。
# effect.py — 切替効果を2つの束ね方で測る
def per_call (day, pred):
c = o = 0
for mid, cl, ot in con.execute(
"SELECT model_id, calls, out_tok FROM usage_fact WHERE day=?" , (day,)):
if pred(mid):
c += cl
o += ot
return o / c if c else 0
regex_flash = lambda m: ( PAT .match(m).group( 3 ) if PAT .match(m) else None ) == "flash"
reg_flash = lambda m: m in ( "gemini-3.5-flash" , "gemini-3.6-flash" )
for label, pred in (( "regex tier='flash'" , regex_flash),
( "registry tier='flash'" , reg_flash)):
b, a = per_call( "2026-07-01" , pred), per_call( "2026-07-22" , pred)
print ( f " { label :<24 } out_tok/call { b :7.2f } -> { a :7.2f } ( { (a - b) / b * 100 :+5.1f } %)" )
regex tier='flash' out_tok/call 26.52 -> 22.70 (-14.4%)
registry tier='flash' out_tok/call 100.00 -> 82.97 (-17.0%)
減少率は -17.0% が -14.4% に薄まりました。それだけなら誤差の範囲に見えるかもしれません。
見てほしいのは水準のほうです。26.52 と 100.00。約 3.8 倍の開きがあります。呼び出し回数の多い軽量モデルが同じ器に入っていたので、平均が引き下げられていました。
この水準を根拠に上限や予算を引いていたら、実態から大きく外れた線を引いていたことになります。移行の効果を「あるかないか」で判断するぶんには耐えても、絶対値を扱う判断には耐えません。
モデルIDを、不透明な主キーとして扱い直す
直し方は、結論だけ書くとひとつです。モデルIDから性質を導出しない 。
IDは意味を持たない主キーとして扱い、世代・ティア・モダリティ・停止日・価格は、宣言的なレジストリに持たせる。集計はレジストリを結合して行う。
事実テーブルには生のIDを残す
派生値を事実テーブルに書き込まないのが要点です。generation 列を書き込んでしまうと、レジストリを直しても過去の行は直りません。
生のIDだけを保存しておけば、レジストリを修正した瞬間に過去の集計も正しくなります。私が正規表現の誤りに気づいてから復旧できたのは、この一点だけが幸運だったからでした。
レジストリは有効期間を持たせる
モデルの性質は途中で変わります。価格改定も停止日の追加もあります。
CREATE TABLE model_registry (
model_id TEXT NOT NULL ,
generation TEXT NOT NULL ,
tier TEXT NOT NULL ,
modality TEXT NOT NULL ,
shutdown_on TEXT ,
valid_from TEXT NOT NULL ,
valid_to TEXT ,
PRIMARY KEY (model_id, valid_from)
);
SELECT r . tier , SUM ( f . out_tok ) AS out_tok
FROM usage_fact f
JOIN model_registry r
ON r . model_id = f . model_id
AND f . day >= r . valid_from
AND ( r . valid_to IS NULL OR f . day < r . valid_to )
GROUP BY r . tier ;
日付で絞る結合にしておくと、価格改定をまたいだ集計も後から作り直せます。
未知のIDは、推測させずに落とす
resolver は「知らないものを知らないまま返す」ようにしました。
from dataclasses import dataclass
@dataclass ( frozen = True )
class ModelFacts :
generation: str
tier: str
modality: str
known: bool
UNKNOWN = ModelFacts( "unknown" , "unknown" , "unknown" , known = False )
def resolve (model_id: str , registry: dict[ str , ModelFacts]) -> ModelFacts:
facts = registry.get(model_id)
if facts is None :
# 近い名前に丸めない。丸めた瞬間に、誤りが正しい値の顔をする
return UNKNOWN
return facts
最初の版では、startswith で近いIDに寄せるフォールバックを書いていました。グラフに unknown の帯が立つのが気持ち悪かったからです。
それが最悪の判断でした。丸めた値は、誤っていても正しい値と同じ見た目で並びます。欠測は目に入りますが、誤った按分は目に入りません。いまは unknown を素直に立たせて、その帯が伸びたら台帳を更新する運用にしています。
停止日を持たせたので、期限の監視も同じテーブルから引けます。CI 側の検知は廃止予定モデルを CI で検知する仕組み に書いたものをそのまま使い、参照先だけレジストリに寄せました。
直感に反していたこと
3点、事前の予想と逆でした。
パースエラーより、パース成功のほうが危ない。 例外は必ず誰かの目に入ります。静かに通る取り違えは、誰の目にも入りません。落とし穴は、エラーが出ない側にありました。
正規表現を直しても、問題は半分しか解けない。 直せるのは今日からの集計だけです。過去の帯は、派生値を保存していたら直りません。効いたのは正規表現の修正ではなく、事実テーブルに生のIDを残していたことでした。
unknown を消したい気持ちが、いちばん高くつく。 見た目を整えるフォールバックが、値下げの効果測定を丸ごと無効にしていました。ダッシュボードの欠測は、直すべき欠陥ではなく、届いている信号です。
何を据え置いたか
全部を作り直したわけではありません。
呼び出し側のモデル指定は触っていません。設定ファイルの文字列をそのまま渡す形のままで、レジストリは集計と監視だけが参照します。呼び出し経路にテーブル参照を挟むと、レジストリが落ちたときにアプリまで止まります。
ダッシュボードの過去帯の再計算も、直近3か月ぶんに限りました。それより前は移行の判断材料にしていないので、手を入れる利得がありません。
ここまで書いたことは、モデルを増やしていない方には過剰かもしれません。参照しているモデルが2つか3つなら、レジストリより、指定箇所を1か所に寄せるほうが早く効きます。私自身、App Store 向けの処理を1本だけ回していた頃は、その形で十分でした。
次に手を動かすなら
まず、いま動いている集計クエリの中に、モデルIDを文字列として分解している箇所があるかを探してみてください。substr でも split_part でも LIKE 'gemini-3.5-%' でも、同じ前提に乗っています。
見つかったら、その帯の内訳を1日ぶんだけモデルID別に展開する。混ざっているかどうかは、それで分かります。
私自身まだ台帳の運用は途中で、停止日の更新をどこまで自動化するか決めかねています。同じ場所でつまずいた方の工夫があれば、そこから学ばせていただければ幸いです。お読みいただきありがとうございました。