ある朝、いつもどおり走っているはずの自動投稿の下書きを開いて、見出しの付き方がほんの少しだけ変わっていることに気づきました。文章の中身が間違っているわけではありません。ただ、箇条書きのリズムや段落の切り方が、前の週までとは別人のものになっていました。
原因はこちらのプロンプトでも設定でもありませんでした。Antigravity 2.0 で、既定の高速モデルが Gemini 3.5 Flash へ更新されていたのです。ベンチマーク上は Terminal-Bench 2.1 で 76.2%、MCP Atlas で 83.6% と、前世代の Pro を上回ると説明されています(出典: Google Developers Blog )。性能としては歓迎すべき更新です。けれども「既定に任せていた」スケジュール実行にとっては、自分の関与しないところで出力の素性が入れ替わった、という出来事でした。
この記事は、その入れ替わりを事故にしないための設計をまとめたものです。個人開発で複数サイトの下書きを毎日自動生成している私自身の運用を題材にしています。
「既定に任せる」が、自動実行でだけ危ういのはなぜか
対話的に使っているときは、既定モデルが変わっても問題になりません。出力を目で見て、おかしければその場で投げ直せるからです。人間が最後の検品をしています。
スケジュール実行はそこが違います。夜間に走り、結果がそのまま次の工程へ流れます。検品する人がいません。だから「平均的には良くなった」という変化でも、こちらの後段が前のモデルの癖を前提に組まれていれば、静かにずれていきます。整形スクリプトが特定の見出し記号を待っていたり、文字数の下限ゲートが前のモデルの文量を前提にしていたりすると、品質が上がった結果としてゲートが落ちる、という倒錯すら起こります。
つまり問題は「新しいモデルが悪い」ことではありません。自分が把握していないタイミングで、出力の前提が動くこと です。向き合うべきはモデルの優劣ではなく、変化の不可視性のほうだと考えています。
まずモデルを明示的に固定する
最初の一手は単純です。スケジュール実行のエージェントには、既定に頼らず使うモデルを書き切ります。
{
"agent" : "nightly-draft" ,
"model" : "gemini-3.1-pro" ,
"fallback" : "gemini-3.5-flash" ,
"temperature" : 0.2 ,
"note" : "既定変更の影響を受けないよう、モデルは明示。fallbackは固定モデルが引けない時だけ"
}
model を明示しておけば、プラットフォーム側の既定が動いても、このエージェントは指定したモデルを使い続けます。fallback は「固定したモデルが一時的に引けないときだけ、ここへ落ちる」という宣言です。既定の自動追従とは意味が違います。前者はこちらが選んだ二択、後者は向こうが選んだ一択です。
ただ、固定はあくまで時間を稼ぐための措置です。固定したまま放置すれば、いずれ古いモデルの提供が終わります。固定の本当の目的は、変化のタイミングをこちらが選べるようにすること にあります。そのために、次の差分ゲートが要ります。
出力を指紋化し、許容できない変化だけを止める
固定で時間を稼いだら、モデルを上げたときに「何がどう変わったか」を機械的に捕まえる仕組みを置きます。私が使っているのは、代表的な入力に対する出力をスナップショットとして保存し、次回以降は正規化した指紋を突き合わせる小さなゲートです。
#!/usr/bin/env python3
"""出力スナップショットを取り、モデル差し替えによる変化を差分として検出するゲート。"""
import json
import sys
import hashlib
import difflib
from pathlib import Path
SNAP_DIR = Path( "snapshots" )
def fingerprint (text: str ) -> str :
# 行末の空白や末尾改行のような無意味な揺れは無視してから指紋を取る
normalized = " \n " .join(line.rstrip() for line in text.strip().splitlines())
return hashlib.sha256(normalized.encode( "utf-8" )).hexdigest()[: 16 ]
def check (case_id: str , produced: str ) -> bool :
SNAP_DIR .mkdir( exist_ok = True )
ref_path = SNAP_DIR / f " { case_id } .txt"
# 初回は基準として保存し、合格扱いにする
if not ref_path.exists():
ref_path.write_text(produced, encoding = "utf-8" )
print ( f "[seed] { case_id } : 基準スナップショットを作成しました" )
return True
reference = ref_path.read_text( encoding = "utf-8" )
if fingerprint(reference) == fingerprint(produced):
print ( f "[ok] { case_id } : 出力は基準と一致しています" )
return True
# 一致しなければ差分を出して止める。歓迎すべき変化かどうかは人が決める
diff = " \n " .join(difflib.unified_diff(
reference.splitlines(),
produced.splitlines(),
fromfile = "snapshot" ,
tofile = "current" ,
lineterm = "" ,
))
print ( f "[drift] { case_id } : 出力が基準から変化しました \n{ diff } " )
return False
if __name__ == "__main__" :
# 標準入力で {"case_id": "...", "output": "..."} を受け取る
payload = json.load(sys.stdin)
ok = check(payload[ "case_id" ], payload[ "output" ])
sys.exit( 0 if ok else 1 )
ここで大事なのは、指紋を取る前に正規化している 点です。行末の空白や末尾の改行といった、意味のない揺れまで差分にしてしまうと、ゲートが鳴りすぎて誰も見なくなります。逆に、整形をやりすぎて段落構成の変化まで吸収してしまうと、捕まえたい変化を見逃します。私は「行末の空白除去」と「先頭末尾の空白除去」だけに留め、見出しや改段は素のまま指紋へ反映させています。鳴ってほしい変化と、鳴ってほしくない揺れの境目を、正規化の強さで決めているという感覚です。
このゲートは、出力が変わったかどうかしか言いません。良くなったか悪くなったかは判断しません。そこは人が決める領域だと割り切っています。ゲートの役目は「変わったことに気づかせる」ことであって、「正しさを保証する」ことではありません。
鳴ったあとに、変化を採否する手順
差分が出たら、次の順で扱っています。手順にしておくと、夜中の自分が迷いません。
段階 やること 判断の基準
1. 切り分け 差分がモデル由来か、入力データ由来かを見る 同じ入力で再実行して再現するならモデル由来
2. 評価 変化が改善・等価・劣化のどれかを目で確かめる 後段の整形・ゲートが壊れないか
3. 採用 歓迎できる変化ならスナップショットを更新する 新しい出力を新しい基準として保存
4. 棄却 劣化なら固定モデルへ戻すか、プロンプトを補う 後段の前提を満たすまで保留
スナップショットの更新は、ファイルを上書きして版管理へコミットするだけです。「この変化を受け入れた」という記録が履歴に残るので、後から「いつ素性が変わったか」を辿れます。私はこの更新コミットのメッセージに、固定していたモデル名と新しいモデル名を必ず書くようにしています。半年後の自分にとっては、これが一番の手がかりになります。
本番運用で踏んだ落とし穴と、その回避策
このゲートを実際のスケジュール実行へ組み込む過程で、いくつかの落とし穴に当たりました。本番運用で気づいた点を、対処とあわせて残しておきます。同じ仕組みを置こうとする方が、私と同じところでつまずかずに済めば幸いです。
スナップショットを増やしすぎて形骸化させない
最初は欲張って数十件の代表入力を登録しました。ところが入力データ側の自然な揺れでも差分が鳴り、やがて誰も差分を読まなくなりました。私は「後段が壊れると本当に困る入力」だけに絞り、5〜7件に保つ運用へ戻しました。鳴る回数を抑えることが、ゲートを生かす近道だと考えています。多くを見張ろうとして、結局どれも見なくなるのが一番の失敗でした。
temperature を下げてから固定する
固定したモデルでも temperature が高いままだと、同じ入力でも実行ごとに表現が揺れて差分が鳴ります。スナップショットを取る対象は temperature を低めに寄せておくことをお勧めします。再現性の土台を先に作り、その上で差分ゲートを乗せる、という順序です。土台が揺れていると、ゲートはモデル変更ではなく自分の揺れを検出し続けます。
差分は通知ではなく失敗ログへ寄せる
当初は差分を通知チャンネルへ流していましたが、量が増えると本当に見るべき差分が埋もれます。私は差分を終了コードと失敗ログにだけ残し、失敗したランだけを翌朝にまとめて見る運用へ切り替えました。通知に求めるのは「失敗した事実」までで十分で、中身は落ち着いて読む場所に置く、という割り切りです。この変更だけで、差分への対処が後回しにならなくなりました。
既定の更新を「歓迎する」運用へ組み替える
ここまでの仕掛けが揃うと、既定モデルの更新に対する姿勢が変わります。身構えて避けるものではなく、こちらの都合で取り込むものになります。
観点 固定なし・既定追従 固定あり・差分ゲート併用
更新の体感 ある朝、知らぬ間に変わる 上げた日に、上げた分だけ変わる
変化の発見 後段が壊れて初めて気づく ゲートが差分として先に知らせる
切り戻し 原因の特定から始める 固定モデルへ戻すだけ
性能向上の取り込み 受け身で全か無か 代表入力で確かめてから全体へ
新しいモデルを取り込むときは、いきなり全エージェントを切り替えません。代表的な入力を持つ一本だけを先に固定モデルから新モデルへ上げ、差分ゲートを一巡させます。歓迎できる変化だと確認できたら、残りを順に追従させます。一本だけ先に通すこのやり方は、CLI のバージョンを上げるときの段取りと同じ考え方です。壊れる余地を、いつも一本ぶんに抑えておくということです。
個人開発では、検品の人手を増やすことができません。だからこそ、変化を見えるようにする小さな仕掛けに投資する価値があると感じています。Dolice Labs の下書き生成でも、この差分ゲートを置いてから、朝に下書きを開いて戸惑う回数がはっきり減りました。
既定が動く前提で組んでおけば、次にモデルが入れ替わる日も、慌てる出来事ではなく、ただの「上げる日」になります。まずは、いちばん事故ると困るスケジュール実行を一本だけ選び、その出力のスナップショットを取るところから始めてみてください。お読みいただきありがとうございました。