エンジンが新しいモデルに置き換わった朝、エージェントは前日と同じプロンプトを受け取って、前日と少しだけ違う出力を返しました。エラーは出ていません。ログも正常です。けれど、生成された記事のフロントマターから tags のひとつが消えていました。
私は個人開発で複数のサイトを一人で運用していて、夜間にエージェントが下書きを作る仕組みを動かしています。出力が「壊れる」なら気づけます。怖いのは、出力が「少しだけ良くも悪くもない方向にずれる」ことです。Gemini 3.5 Flash のように高速で賢いモデルへ移っても、この静かなずれは必ず起きます。
なぜモデル移行で出力が静かに変わるのか
ずれの経路は、大きく3つあります。
ひとつ目は、フォーマットの揺れです。同じ「JSON で返して」という指示でも、新しいモデルはキーの順序や空配列の扱いを変えることがあります。ふたつ目は、省略の癖です。賢いモデルほど「自明だから省く」判断をして、以前は明示していた項目を落とします。みっつ目は、語調です。要約の長さや断定の強さが変わり、後段の処理が前提にしていた長さの範囲を外れます。
どれも単体では小さく、テストが「成功」と表示し続けます。だからこそ、移行前の出力を一度きちんと固定しておく必要があります。
ゴールデンスナップショットという考え方
ゴールデンスナップショットは、現時点で「これは正しい」と人間が認めた出力を、ファイルとして保存したものです。次回以降は、エージェントの出力をこの保存済みの正解と突き合わせます。
ポイントは、文字列の完全一致を目指さないことです。生成 AI の出力は本質的に揺れます。固定すべきなのは表面の文字列ではなく、後段が依存している構造と不変条件です。たとえば「フロントマターに必須キーが7つ揃っている」「本文の H2 が6個以上ある」「内部リンクが実在する記事だけを指している」といった性質です。
スナップショットの粒度を決める
最初にやるのは、出力を正規化する関数を書くことです。揺れてよい部分を吸収し、固定したい性質だけを取り出します。
import re
import yaml
# 必須キーと、それぞれが満たすべき不変条件を1か所に集める
REQUIRED_KEYS = [ "title" , "slug" , "category" , "level" , "premium" , "tags" , "description" ]
def normalize (article: str ) -> dict :
"""エージェント出力を、揺れに強い比較用の辞書へ変換する"""
fm_match = re.match( r " ^ --- \n (. *? ) \n --- \n (. * )$ " , article, re. DOTALL )
if not fm_match:
raise ValueError ( "frontmatter missing" )
front = yaml.safe_load(fm_match.group( 1 ))
body = fm_match.group( 2 )
return {
"keys_present" : sorted (k for k in REQUIRED_KEYS if k in front),
"tag_count" : len (front.get( "tags" , [])),
"h2_count" : len (re.findall( r " ^ ## \s + " , body, re. MULTILINE )),
"code_blocks" : len (re.findall( r " ^ ```" , body, re. MULTILINE )) // 2 ,
"char_len_bucket" : len (body) // 500 , # 500字刻みのバケットで丸める
}
char_len_bucket のように 500 字刻みで丸めているのが要点です。本文が 3,000 字から 3,200 字に変わっても同じバケットに入り、無意味な差分で警告が鳴りません。けれど 3,000 字から 1,800 字へ大きく縮めば、バケットが変わって検知できます。揺れを許す幅を、数値として明示しておくわけです。
差分の判定を「完全一致」にしない
正規化した辞書同士を比べるとき、私はキーごとに許容ルールを変えています。必須キーの集合は完全一致を求め、文字数バケットは「2段階以内のずれは許容」にします。
def compare (golden: dict , current: dict ) -> list[ str ]:
"""ゴールデンと現在の出力を比較し、本当に問題のある差分だけ返す"""
issues = []
# 必須キーの欠落は即アウト(後段の自動処理が壊れる)
missing = set (golden[ "keys_present" ]) - set (current[ "keys_present" ])
if missing:
issues.append( f "必須キー欠落: { sorted (missing) } " )
# タグ数は ±1 まで許容(モデルが1つ足す/減らすのは許す)
if abs (golden[ "tag_count" ] - current[ "tag_count" ]) > 1 :
issues.append( f "タグ数の急変: { golden[ 'tag_count' ] } -> { current[ 'tag_count' ] } " )
# 見出し構造が痩せたら警告(記事が薄くなった兆候)
if current[ "h2_count" ] < golden[ "h2_count" ] - 1 :
issues.append( f "H2が減少: { golden[ 'h2_count' ] } -> { current[ 'h2_count' ] } " )
# 本文量が2バケット(=約1,000字)以上縮んだら検知
if golden[ "char_len_bucket" ] - current[ "char_len_bucket" ] >= 2 :
issues.append( "本文が大幅に短縮された" )
return issues
このように「許容する揺れ」と「許さない退行」を分けて書くと、ゲートが現実的になります。完全一致を求めるテストは、3 回も実行すれば誰かが # skip を付けて死にます。許容幅を持たせたテストだけが、半年後も生き残ります。
CI ゲートに組み込む
あとは、移行前にゴールデンを保存し、移行後に突き合わせるだけです。Antigravity CLI をスケジュール実行している場合、生成直後にこのゲートを挟みます。
import json
from pathlib import Path
GOLDEN_DIR = Path( "tests/golden" )
def gate (sample_id: str , output: str , update: bool = False ) -> int :
golden_path = GOLDEN_DIR / f " { sample_id } .json"
current = normalize(output)
if update or not golden_path.exists():
golden_path.write_text(json.dumps(current, ensure_ascii = False , indent = 2 ))
print ( f "✅ ゴールデンを保存: { sample_id } " )
return 0
golden = json.loads(golden_path.read_text())
issues = compare(golden, current)
if issues:
print ( f "🚨 { sample_id } で退行を検知:" )
for i in issues:
print ( f " - { i } " )
return 1
print ( f "✅ { sample_id } は許容範囲内" )
return 0
update=True で実行するのは、人間が新しい出力を確認して「これを新しい正解にする」と決めたときだけです。エージェント自身に update を握らせてはいけません。自分の退行を自分で正解として上書きしてしまいます。
移行当日に踏む手順
実際の切り替えは、次の順序で進めると安全です。
移行の前日、旧モデル(たとえば Gemini 3.1 Pro)で代表的な入力 10〜20 件を流し、ゴールデンを保存します。多すぎると確認しきれないので、カテゴリごとに 2〜3 件で十分です。
エンジンを Gemini 3.5 Flash へ切り替え、同じ入力で再生成します。
ゲートを update=False で回し、差分の出た件だけを目で確認します。タグが1つ増えただけなら歓迎、必須キーが落ちたなら移行を止めます。
問題がなければ、新しい出力を update=True で正解として保存し直します。
移行後 1 週間は、毎日の生成にこのゲートを残し、遅れて顔を出すずれを拾います。
私の場合、3.5 Flash へ移したときに体感速度はおよそ 3 倍になりましたが、要約の末尾に余計な一文が付く癖が出ました。ゲートの「文字数バケット」では拾えず、後から気づいた失敗です。だから今は、末尾 100 字のパターンも不変条件に足しています。
それでも残る限界と、私の運用
ゴールデンスナップショットは、構造の退行には強い一方で、意味の劣化には弱い手法です。キーも字数も見出しも揃っているのに、内容がぼんやりしている、という劣化は数値に出ません。
そこで私は、ゲートを通った出力のうち各カテゴリ 1 件だけを、週に一度だけ自分の目で読みます。自動ゲートで 9 割の退行を止め、残る 1 割の「なんとなく薄い」を人間が拾う。この二段構えに落ち着きました。
AdMob で配信しているアプリの文言更新でも、同じ仕組みを流用しています。モデルは今後も乗り換わり続けます。乗り換えのたびに不安になるのではなく、固定した正解と静かに突き合わせる。その準備があるだけで、新しいモデルを試す心理的なコストはずいぶん下がります。
実装の参考になれば幸いです。私自身、新しいモデルを試すたびに、この固定された正解に何度も助けられてきました。同じように夜間の自動生成を回している方の、移行の不安が少しでも軽くなればと思っています。