朝いちばんに走らせている定型の生成タスクがあります。壁紙アプリのアセットを分類させるもので、もう何ヶ月も同じ設定のまま回しています。
その出力の形が変わりうる、と気づいたのは自分の環境を見たからではありませんでした。変更履歴のほうが先でした。8月13日に一般提供となった Gemini 3.7 Flash が、Antigravity エージェントの既定モデルになっています。
こちらは設定を一行も触っていません。それでも、モデル名を書いていない場所は全部、静かに新しい既定へ移ります。
移行するかどうかを考える前に、確かめるべきことがありました。自分の運用のうち、どこが「既定」という名前のない場所に乗っているのか。
既定モデルは、こちらの合意を待たずに入れ替わる
モデルの乗り換えというと、普通は自分で決めて自分で切り替えるものです。検証して、比べて、良ければ移す。
既定モデルの入れ替わりはその逆を行きます。提供側が決めて、こちらの設定が沈黙している箇所にだけ適用されます。判断を求められないので、判断した記憶も残りません。
Gemini 3.7 Flash は入力 100 万トークンあたり 0.75 ドル、出力 3.75 ドルという導入価格で提供されています。この価格は 2026 年 12 月 31 日までで、2027 年 1 月 1 日からは入力 1.50 ドル、出力 7.50 ドルに上がります。コンテキストウィンドウは 1,048,576 トークン、出力上限は 65,536 トークンです。
数字が出ていると、つい費用の話から入りたくなります。私はそこから入るのをやめました。理由は後半に書きます。
先にやるべきなのは棚卸しです。
既定に乗っている場所を機械的に洗い出す
設定を目で追って確認するのは、思っているより当てになりません。エージェント定義のフロントマター、settings.json のエントリ、シェルスクリプトからの呼び出し。同じ「モデル指定」でも書く場所が三箇所に散っていて、しかも「書き忘れ」と「意図して既定へ委ねている」が見た目でほとんど区別できないからです。
区別できないまま数えると、棚卸しは役に立ちません。inherit と書いてある箇所は、既定が変わることを承知のうえで委ねた場所です。キーそのものが無い箇所は、そもそも考えていなかった場所です。後者だけを拾いたい。
#!/usr/bin/env python3
"""既定モデルに黙って依存している箇所を洗い出す。
対象:
1. エージェント定義の YAML フロントマター(model キーの有無)
2. settings.json の agents.* エントリ
3. CLI を叩くシェルスクリプト(--model 指定の有無)
"inherit" と "default" は「明示的に既定へ委ねている」扱いにして、
書き忘れ(キーそのものが無い)と区別する。ここを混ぜると棚卸しが役に立たない。
"""
import json
import pathlib
import re
import sys
DELEGATING = { "inherit" , "default" , "auto" }
def read_frontmatter (path: pathlib.Path) -> dict :
"""--- で囲まれた先頭ブロックだけを素朴に読む(PyYAML 非依存)。"""
text = path.read_text( encoding = "utf-8" )
if not text.startswith( "---" ):
return {}
end = text.find( " \n ---" , 3 )
if end == - 1 :
return {}
fm = {}
for line in text[ 3 :end].splitlines():
if ":" not in line or line.strip().startswith( "#" ):
continue
key, _, value = line.partition( ":" )
fm[key.strip()] = value.strip().strip( " \" '" )
return fm
def scan (root: pathlib.Path) -> list :
findings = []
for path in sorted (root.glob( ".antigravity/agents/*.md" )):
fm = read_frontmatter(path)
model = fm.get( "model" )
if model is None :
state = "unpinned" # キーが無い = 既定に無言で乗っている
elif model.lower() in DELEGATING :
state = "delegated" # 意図して既定に委ねている
else :
state = "pinned"
findings.append({ "where" : str (path.relative_to(root)), "kind" : "agent-def" ,
"name" : fm.get( "name" , path.stem), "state" : state, "model" : model})
settings = root / ".antigravity" / "settings.json"
if settings.exists():
data = json.loads(settings.read_text( encoding = "utf-8" ))
for name, cfg in (data.get( "agents" ) or {}).items():
model = cfg.get( "model" )
state = "unpinned" if model is None else (
"delegated" if str (model).lower() in DELEGATING else "pinned" )
findings.append({ "where" : "settings.json" , "kind" : "settings" ,
"name" : name, "state" : state, "model" : model})
for path in sorted (root.rglob( "*.sh" )):
for lineno, line in enumerate (path.read_text( encoding = "utf-8" ).splitlines(), 1 ):
if "antigravity" not in line or " run" not in line:
continue
pinned = re.search( r "--model [ = ]([\w . \- ] + ) " , line)
agent = re.search( r "--agent [ = ]([\w . \- ] + ) " , line)
findings.append({ "where" : f " { path.relative_to(root) } : { lineno } " , "kind" : "cli-call" ,
"name" : agent.group( 1 ) if agent else "(inline)" ,
"state" : "pinned" if pinned else "unpinned" ,
"model" : pinned.group( 1 ) if pinned else None })
return findings
def main () -> int :
root = pathlib.Path(sys.argv[ 1 ] if len (sys.argv) > 1 else "." ).resolve()
findings = scan(root)
unpinned = [f for f in findings if f[ "state" ] == "unpinned" ]
width = max (( len (f[ "where" ]) for f in findings), default = 10 )
for f in findings:
mark = { "pinned" : " " , "delegated" : "= " , "unpinned" : "! " }[f[ "state" ]]
print ( f " { mark }{ f[ 'where' ] :< { width }} { f[ 'kind' ] :<9 } { f[ 'name' ] :<20 } { f[ 'model' ] or '-' } " )
print ( f " \n{ len (findings) } 箇所中 { len (unpinned) } 箇所が既定モデル依存(! 印)" )
return 1 if unpinned else 0
if __name__ == "__main__" :
sys.exit(main())
手元の小さなサンプルツリーに当てると、こうなります。
.antigravity/agents/classify-wallpaper.md agent-def classify-wallpaper gemini-3.7-flash
! .antigravity/agents/draft-release-note.md agent-def draft-release-note -
= .antigravity/agents/summarize-crash.md agent-def summarize-crash inherit
settings.json settings classify-wallpaper gemini-3.7-flash
! settings.json settings draft-release-note -
! scripts/nightly.sh:2 cli-call classify-wallpaper -
! scripts/nightly.sh:3 cli-call draft-release-note -
7 箇所中 4 箇所が既定モデル依存(! 印)
目を引いたのは 6 行目です。classify-wallpaper は定義でもモデルを書いてあり、settings.json でも書いてあります。それでも夜間スクリプトからの呼び出しは --model を持っていません。
三箇所のうち二箇所を埋めたことで、埋まった気になっていた場所です。どの経路の指定が最終的に勝つのかは、版と呼び出し方で変わりえます。だからこそ、経路の数だけ穴が空きうると考えて数えるほうが安全でした。
このスクリプトは違反があると終了コード 1 を返します。定期実行の前段に置いておくと、新しいエージェントを足したときに気づけます。
乗せる仕事と据え置く仕事は、出力の自由度で分かれる
棚卸しが終わると、次は「どれを新しい既定に乗せるか」です。
ここで多くの人が、モデルの性能表を見にいきます。私も最初はそうしました。けれど実際に困る場面を思い返すと、性能の話ではありませんでした。
個人開発で回している仕事のうち、モデルが変わってもまず壊れないものと、変わるたびに手直しが要るものが、はっきり分かれています。壁紙アプリのカテゴリ分類は前者です。エージェントが返せる値は 30 個の識別子だけで、それ以外は後段のバリデータが弾きます。分類の質は多少上下しても、出力の形は変わりようがありません。
一方で、ストア向けのリリースノートを多言語で書かせる仕事は後者でした。返ってくるのは自由文です。モデルが変われば、見出しの付け方も箇条書きの粒度も変わります。壊れたわけではなく、単に別のものが返ってきます。そして別のものが返ってくると、後段の整形が合わなくなります。
つまり、モデルの入れ替わりに強い仕事とは、良いモデルを使っている仕事ではありません。出力の選択肢をこちらが先にどれだけ潰してあるか で決まります。
この考えは以前、壁紙アプリの30カテゴリ分類で、エージェントに選ばせる範囲をどこまで狭めるか で分類設計の側から書きました。あのときは分類精度のために語彙を閉じました。副産物として、モデル変更に対する耐性まで手に入っていたことに、今回気づいた次第です。
自由度は目で見て決めるより、出力スキーマから採点したほうが揺れません。
#!/usr/bin/env python3
"""出力契約の「自由度」を採点する。
自由度が高いほど、既定モデルが入れ替わったときに壊れやすい。
壊れるかどうかはモデルの優劣では決まらない。出力の選択肢を
こちらが先にどれだけ潰してあるかで決まる。
スコア: 0(完全に閉じている)〜 100(完全な自由記述)
"""
import json
import pathlib
import sys
# 自由記述を1つ許すたびに積む点数。文字列は列挙で閉じられるので、
# 閉じていない文字列だけを罰する。
WEIGHTS = { "open_string" : 25 , "open_array" : 15 , "no_schema" : 100 ,
"additional_props" : 20 , "optional_field" : 5 }
def score_schema (schema: dict ):
reasons = []
points = 0
props = schema.get( "properties" ) or {}
required = set (schema.get( "required" ) or [])
if schema.get( "additionalProperties" ) is not False :
points += WEIGHTS [ "additional_props" ]
reasons.append( "additionalProperties が閉じていない" )
for name, spec in props.items():
kind = spec.get( "type" )
if kind == "string" and "enum" not in spec and "pattern" not in spec:
points += WEIGHTS [ "open_string" ]
reasons.append( f " { name } : 列挙もパターンも無い自由文字列" )
elif kind == "array" :
item = spec.get( "items" ) or {}
if "enum" not in item and item.get( "type" ) == "string" :
points += WEIGHTS [ "open_array" ]
reasons.append( f " { name } : 要素が閉じていない配列" )
if name not in required:
points += WEIGHTS [ "optional_field" ]
reasons.append( f " { name } : 必須でない(有無が揺れる)" )
return min (points, 100 ), reasons
def verdict (score: int ) -> str :
if score <= 20 :
return "乗せてよい(入替の影響は検証で拾える)"
if score <= 55 :
return "条件付き(後段バリデータがあるなら乗せる)"
return "据え置く(先に契約を狭めてから乗せる)"
def main () -> int :
worst = 0
for path in sorted (pathlib.Path(sys.argv[ 1 ]).glob( "*.json" )):
schema = json.loads(path.read_text( encoding = "utf-8" ))
score, reasons = score_schema(schema)
worst = max (worst, score)
print ( f " { path.name } 自由度 { score :>3 } /100 → { verdict(score) } " )
for r in reasons:
print ( f " - { r } " )
print ( f " \n 最も自由度の高い契約: { worst } /100" )
return 0
if __name__ == "__main__" :
sys.exit(main())
分類用と、リリースノート用のスキーマを並べて採点した結果です。
category.json 自由度 5/100 → 乗せてよい(入替の影響は検証で拾える)
- confidence: 必須でない(有無が揺れる)
release_note.json 自由度 100/100 → 据え置く(先に契約を狭めてから乗せる)
- additionalProperties が閉じていない
- title: 列挙もパターンも無い自由文字列
- title: 必須でない(有無が揺れる)
- body: 列挙もパターンも無い自由文字列
- body: 必須でない(有無が揺れる)
- highlights: 要素が閉じていない配列
- highlights: 必須でない(有無が揺れる)
最も自由度の高い契約: 100/100
点数そのものに絶対的な意味はありません。重みは自分の運用に合わせて調整するものです。意味があるのは順序のほうで、100 点の仕事を先に据え置き、5 点の仕事から乗せる、という並べ替えができれば目的は果たしています。
自由度 100 の側を「悪いスキーマ」と読まないでください。リリースノートは自由文で書くのが正しい仕事です。正しく自由な仕事は、正しくモデル変更に弱い。だから据え置く。それだけの話です。
ピン留めは、設定を読み直しても確認できない
据え置くと決めた仕事には、モデルを固定します。ここで一度つまずきました。
設定にモデル名を書き足して、書き足したことを設定ファイルで確認して、済んだつもりになる。この確認の仕方には穴があります。確認しているのは「自分がそう書いたこと」であって、「実行時にそれが使われたこと」ではないからです。
呼び出し経路は一つではありません。IDE から、CLI から、SDK から、そしてサブエージェント経由から。どの層の指定が優先されるかは版によって変わりえますし、サブエージェントに至っては親の指定を引き継ぐのかどうかが構成次第です。
確実なのは、走らせたあとの出力を見ることでした。
#!/usr/bin/env python3
"""実行ログに現れた実際のモデル名を、宣言した期待値と突き合わせる。
設定でピン留めしたつもりでも、呼び出し経路(IDE / CLI / SDK / サブエージェント)
ごとに解決順が違えば、効いていない経路が残ります。設定を読み直すのではなく、
走らせたあとの出力に現れたモデル名を見るほうが確実です。
usage オブジェクトのキー名は版によって揺れるため、単一のキーに賭けず
既知の候補を深さ優先で探します。見つからないこと自体も結果として扱います。
"""
import json
import pathlib
import sys
MODEL_KEYS = ( "model" , "model_name" , "model_version" , "modelId" )
def find_model (node, path = "$" ):
if isinstance (node, dict ):
for key in MODEL_KEYS :
value = node.get(key)
if isinstance (value, str ):
return value, path + "." + key
for key, value in node.items():
found, where = find_model(value, path + "." + key)
if found:
return found, where
elif isinstance (node, list ):
for i, value in enumerate (node):
found, where = find_model(value, path + "[" + str (i) + "]" )
if found:
return found, where
return None , None
def main () -> int :
expected = sys.argv[ 1 ]
failures = 0
for path in sorted (pathlib.Path(sys.argv[ 2 ]).glob( "*.json" )):
payload = json.loads(path.read_text( encoding = "utf-8" ))
actual, where = find_model(payload)
if actual is None :
print ( "? " + path.name + " モデル名が出力に無い(経路を確認する)" )
failures += 1
elif actual == expected:
print ( " " + path.name + " " + actual + " (" + where + ")" )
else :
print ( "! " + path.name + " 期待 " + expected + " / 実際 " + actual + " (" + where + ")" )
failures += 1
print ( " \n " + str (failures) + " 件がピン留めの外にあります" )
return 1 if failures else 0
if __name__ == "__main__" :
sys.exit(main())
--output-format json で保存した実行結果を三つ並べて通した結果です。
classify.json gemini-3.7-flash ($.model)
! release-note.json 期待 gemini-3.7-flash / 実際 gemini-3.5-flash ($.meta.runtime.model_name)
? subagent-translate.json モデル名が出力に無い(経路を確認する)
2 件がピン留めの外にあります
三行目に注目してください。サブエージェント経由の実行結果には、subagent_info に conversation_id と log_uri は入っているのに、モデル名がどこにもありません。
これは失敗ではなく、こちらの追跡が届いていない範囲がある、という結果です。log_uri を辿れば分かる可能性はありますが、その一手間が要ると分かったこと自体が収穫でした。「モデル名が見つからない」を成功扱いで握りつぶす実装にしていたら、この範囲は永遠に見えないままでした。
キー名を複数候補で探しているのも同じ理由です。単一のキーに賭けると、キー名が変わった版で「見つからない=問題なし」と誤読してしまいます。見つからないことは、常に結果として数えます。
本番運用に置くときの注意点をひとつ。この照合は定期実行の後段に置くのが向いています。前段に置くと、まだ実行結果が無い状態で走ってしまい、毎回「モデル名が出力に無い」で止まります。私は夜間のジョブが全部終わったあとに、その日の JSON をまとめて一度通す形にしました。落とし穴というほどではありませんが、最初はここで一日ぶんの通知を無駄にしています。
出力そのものを固定する手順は、モデルを乗り換える前に、エージェントの出力をゴールデンスナップショットで固定する に書きました。この記事で扱っているのはその前段、何をスナップショットの対象にすべきかを決める話です。
導入価格を移行の理由にすると、その理由は年末に消える
ここで費用の話に戻ります。
Gemini 3.7 Flash の導入価格は 2026 年 12 月 31 日までです。翌日から入力も出力も倍になります。
期間 入力(100万トークン) 出力(100万トークン)
2026年12月31日まで(導入価格) $0.75 $3.75
2027年1月1日から $1.50 $7.50
「安いうちに寄せておく」という判断は、この表を見ると成立しません。寄せた先の価格が半年経たずに倍になるのであれば、価格差を根拠にした移行は、年明けに根拠を失います。根拠を失った移行は、そのときまた見直す作業が発生します。
移行の判断材料として価格が意味を持つのは、価格が安定しているか、少なくとも自分の運用期間より長く保たれる見込みがあるときだけです。期限つきの導入価格は、乗り換えの理由ではなく、乗り換えを検証するための時間的な余裕として受け取るのが実際的でした。
私が採ったのは、この期間を検証費用の割引として使う、という考え方です。据え置くと決めた仕事も、期間中に一度は新しい既定で流してみて、差分だけ見ておく。決断はしないが観測はしておく。年明けの価格改定は、そのときに手元にある観測結果で判断すればよくなります。
同じ「速さと費用」の話で以前つまずいたことがあります。モデルを変えれば速くなると思い込んで、実際には速いはずのモデルが遅いとき、疑うのはモデルではなく推論強度です というところに行き着きました。モデル名を動かす前に、まだ動かせる変数が手前に残っていることは珍しくありません。
実際にどう振り分けたか
棚卸しと採点を通したあと、手元の仕事は三つに分かれました。
仕事 出力の性質 判断 根拠
壁紙アセットのカテゴリ分類 30個の識別子から1つ 乗せる 返せる値が閉じており、外れれば後段が弾く
クラッシュクラスタの要約 自由文だが人間が読むだけ 乗せる 形が変わっても後段の処理が無い
ストア向けリリースノートの多言語生成 自由文・後段で整形 据え置く 整形側が書式に依存しており、崩れると連鎖する
二行目は自分でも意外でした。自由文だから据え置く、と最初は分類していたのです。けれど据え置く理由は「自由だから」ではなく「後段が形に依存しているから」でした。人間が読んで終わる出力は、形が変わっても誰も困りません。
判断の軸は、出力の自由度そのものではなく、その自由度を誰が引き受けているか にありました。機械が引き受けているなら据え置く。人間が引き受けているなら乗せてよい。この線引きが見えてから、振り分けは一気に楽になりました。
三行目のリリースノートは App Store と Google Play の両方に出すもので、言語ごとに字数上限も改行の扱いも違います。この整形を機械にやらせている以上、文面の癖が変われば整形が崩れます。だから据え置きました。人が読んで終わる二行目とは、自由度が同じでも扱いが変わります。
翻訳リソースのように、自由文でありながら後段のキー構造に厳密さを要求する仕事は、真ん中に落ちます。こういう仕事は、乗せる前に契約を狭める作業が先に要ります。8言語の文字列リソースを扱ったときも、結局そこに時間を使いました。この場合は、モデルを動かすより先に契約を書き直すことをお勧めします。順序を逆にすると、崩れた出力を見ながら契約を設計することになり、判断が濁ります。
次にやること
まず default_model_exposure.py を自分のリポジトリのルートで一度走らせてみてください。件数が出るだけでも、判断の土台が変わります。
私自身、既定という言葉を「変わらないもの」の意味で読んでいた時期が長くありました。実際には、既定とは「自分が決めなかった場所」の別名です。決めなかった場所がどこにいくつあるかを数えられるようにしておけば、次に既定が動く日は、驚く日ではなく確認する日になります。
出典として、価格と提供範囲は Introducing Gemini 3.7 Flash(Google Blog) と Gemini API のモデル情報 を参照しています。バージョンと価格は変わりうるため、判断の直前には必ず一次情報で確認してください。