スケジュール実行に移したエージェントが、ある朝だけ妙な生成物を吐いていました。プロンプトは前日と同じ。けれど出力は微妙に違う。手元で二度走らせると、二度とも差分が出ます。CI のスナップショットテストは当然のように赤くなり、しかしその赤は「壊れた」のか「揺れただけ」なのか判別がつきませんでした。
個人開発で複数のサイトをエージェントに任せていると、この「揺れる赤」が一番やっかいです。本当の退行を隠してしまうからです。ここでは、Antigravity のエージェント出力を CI で安定して回帰検証するために私自身が組んだ仕組みを、順を追ってお伝えします。
同じプロンプトで二度走らせると差分が出る、という最初の戸惑い
最初に試したのは、生成物をそのままファイルに保存して git diff で比べる素朴な方法でした。これは半日で破綻します。
エージェントの出力には、内容としては等価でも文字列としては毎回変わる箇所が必ず混じります。生成日時、実行 ID、一時ファイルのパス、リストの並び順、JSON のキー順。これらを素のまま比較すると、意味のある退行と意味のない揺れが同じ「差分」として並んでしまいます。
つまり問題は「比較すること」ではなく、「比較する前に揺れを取り除くこと」でした。
なぜエージェント出力はスナップショットと相性が悪いのか
スナップショットテスト自体は、UI コンポーネントや API レスポンスで実績のある手法です。期待値を一度記録し、次回以降はそれと照合します。
相性が悪く見えるのは、エージェント出力に三種類の非決定性が同居しているからです。一つ目は環境由来の揺れ(時刻・ID・パス)。二つ目は順序の揺れ(集合を配列で返すときの並び)。三つ目はモデル由来の言い換え(同じ意図を別の言葉で表す)。
最初の二つは機械的に潰せます。三つ目だけは別の照合戦略が要ります。この切り分けができていないと、「スナップショットは無理」という誤った結論に飛んでしまいます。実際には、潰せるものを潰してから、潰せない部分にだけ意味ベースの判定を当てればよいのです。
正規化レイヤーを挟む:揺れる部分だけを潰す
そこで、保存と比較の手前に正規化レイヤーを一枚挟みます。役割は単純で、環境由来と順序由来の揺れを決定的な形に書き換えることだけです。
import re
import json
# 環境由来の揺れを安定トークンに置換する
NORMALIZERS = [
(re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?"), "<TIMESTAMP>"),
(re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"), "<UUID>"),
(re.compile(r"/tmp/[^\s\"']+"), "<TMPPATH>"),
(re.compile(r"run_id=\w+"), "run_id=<RUN_ID>"),
]
def normalize_text(text: str) -> str:
for pattern, token in NORMALIZERS:
text = pattern.sub(token, text)
return text.strip()
def normalize_json(payload: dict) -> str:
# キー順を固定し、配列内の dict も安定キーで並べ替える
canonical = json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2)
return normalize_text(canonical)
本番には触れず、テストだけで影を見る
ポイントは、正規化を「テスト専用の前処理」として独立させることです。エージェント本体には一切手を入れません。本番環境の生成物はそのまま保存し、比較するときだけ揺れを潰した影を見る。この分離は、退行の解決を早めるためにも欠かせません。この分離を崩すと、テストのために本番出力を歪めることになり、かえって退行を見逃します。
pytest でゴールデンファイル比較を組む
正規化された影どうしを比べるだけなら、pytest の枠組みに素直に乗ります。初回はゴールデンファイルが無いので記録し、二回目以降は照合する、という更新フラグ付きの実装にしておくと運用が軽くなります。
import os
from pathlib import Path
GOLDEN_DIR = Path(__file__).parent / "golden"
UPDATE = os.environ.get("UPDATE_GOLDEN") == "1"
def assert_against_golden(name: str, actual_raw: str):
GOLDEN_DIR.mkdir(exist_ok=True)
golden_path = GOLDEN_DIR / f"{name}.txt"
actual = normalize_text(actual_raw)
if UPDATE or not golden_path.exists():
golden_path.write_text(actual, encoding="utf-8")
return # 記録モードでは照合しない
expected = golden_path.read_text(encoding="utf-8")
assert actual == expected, (
f"スナップショット退行: {name}\n"
f"--- 期待 ---\n{expected[:400]}\n"
f"--- 実際 ---\n{actual[:400]}"
)
ゴールデンファイルは Git に commit します。レビュー時に差分が出れば、それは「期待値を意図的に更新した」という記録になります。UPDATE_GOLDEN=1 pytest で更新し、その差分を人の目でレビューしてからマージする。この往復があるおかげで、退行は必ずプルリクエストの差分として可視化されます。
構造化出力には「意味の一致」で当てる
正規化しても残る三つ目の揺れ、つまりモデルの言い換えは、文字列一致では拾えません。ここは Antigravity の構造化出力(structured outputs)を使って、自由文ではなくスキーマに沿った JSON を返させるのが近道です。
自由文を比較するのではなく、抽出した構造の必須フィールドだけを契約として固定します。
def assert_structured(actual: dict, contract: dict):
# 必須キーの存在と型だけを契約として検証する
for key, expected_type in contract.items():
assert key in actual, f"必須フィールド欠落: {key}"
assert isinstance(actual[key], expected_type), (
f"型不一致: {key} は {expected_type.__name__} を期待"
)
# 文面ではなく構造のスナップショットを取る
shape = {k: type(v).__name__ for k, v in sorted(actual.items())}
return shape
CONTRACT = {"title": str, "tags": list, "summary": str, "score": (int, float)}
言い回しは揺れても、title が文字列で tags が配列で score が数値である、という契約が崩れたら退行です。文面の一字一句ではなく、出力の骨格を守る。これが意味ベース照合の現実的な落としどころでした。自由文の比較を捨てて構造の契約に寄せることを、私はこの種のエージェントには強く推奨します。
CI でのフレーク対策:再試行と差分しきい値
それでも自由文を完全には排除できない場面が残ります。説明文の本文などです。ここで一度の不一致を即失敗にすると、CI が無意味に赤くなります。
私はこの層に限って、二段の緩衝材を入れています。一つは最大 3 回の再試行で、揺れが収束するかを見ます。もう一つは差分しきい値で、正規化後の編集距離が全体の 5% 未満なら許容します。
from difflib import SequenceMatcher
def soft_match(actual_raw: str, expected: str, tolerance: float = 0.05) -> bool:
actual = normalize_text(actual_raw)
ratio = SequenceMatcher(None, actual, expected).ratio()
drift = 1.0 - ratio
return drift <= tolerance # 5% 以内の揺れは許容
数値は厳密に、構造は契約で、自由文だけしきい値で、という三段構えにしてから、CI の偽陽性は体感で大きく減りました。週次でフレーク再走が数十回起きていたのが、ひと桁まで落ち着いた、というのが私自身の運用実感です。再試行回数としきい値はログに残し、頻繁に再試行が走るテストは、契約を増やすべき箇所の目印として扱い、回避ではなく契約強化で解決することをお勧めします。
運用して見えた落とし穴
正規化が強すぎて退行を吸収する
一番ハマったのは、正規化が強すぎて本物の退行まで吸収してしまった件でした。パスをまるごと <TMPPATH> に潰したら、出力先ディレクトリの取り違えという実害のあるバグが、差分に出なくなっていたのです。正規化は「内容に無関係な部分だけ」を対象にする、という原則を破ると、テストが沈黙します。
ゴールデンファイルの惰性更新
もう一つは、ゴールデンファイルの惰性更新です。UPDATE_GOLDEN=1 が手軽すぎて、差分を見ずに更新する癖がつくと、スナップショットは記録ではなく追認になります。更新コミットは本文の変更とは別コミットに分け、差分を必ずレビュー対象に乗せる運用へ切り替えました。
次の一歩
まずは、エージェント出力の中で「内容に無関係なのに毎回変わる箇所」を三つ書き出してみてください。時刻・ID・パスのどれかは必ず該当するはずです。その三つを正規化レイヤーに登録し、一本だけゴールデンファイルテストを通すところから始めると、揺れる赤と本物の赤を切り分ける感覚がつかめます。
お読みいただきありがとうございました。同じように複数のエージェントを並行運用している方の、CI を静かに保つ助けになれば幸いです。