「古い結果で合格」という落とし穴

LLM の出力検証をゴールデンデータ方式で運用していると、気づきにくい問題が出てきます。先週実行した生成結果が手元に残っていて、その後プロンプトを書き換えたのに、再実行せず古い結果のまま品質チェックを通してしまうケースです。

LLM の実行には時間も API 課金もかかるため、「結果が残っているなら再実行しない」という動機が常に働きます。比較相手のゴールデンデータも古いプロンプト時代のものなら、差分ゼロで「問題なし」と出る。実際には古い入力・古いプロンプト・古い出力を古い状態のまま見比べているだけで、現在のシステムを何も評価していません。

この「結果が現在のコードを反映していない」状態を STALE(陳腐化) と呼び、ハッシュで機械的に検出します。

出自(provenance)ブロックを埋め込む

生成結果のファイルに、生成時点の「材料のハッシュ」を一緒に記録します。

{
  "result": { ... },
  "_provenance": {
    "input_hash": "sha256:a3f9...",
    "prompts_hashes": {
      "generate": "sha256:7c2e...",
      "judge": "sha256:d851..."
    },
    "models": {
      "generate": "claude-sonnet-4.5"
    }
  }
}

input_hash はテスト入力ファイルの SHA256。prompts_hashes は各処理ステップのプロンプトディレクトリ配下のファイル(.py / .txt / .md など)をパスの辞書順に連結してハッシュした値です。順序を固定しないと、同じ内容でも環境によってハッシュが揺れるので注意が必要です。

STALE 検出の仕組み

検証時に、結果ファイルに記録されたハッシュと、現在のファイルから計算し直したハッシュを照合します。

def detect_staleness(result: dict, spec: PipelineSpec) -> list[str]:
    prov = result.get("_provenance", {})
    stale = []
    if prov.get("input_hash") != hash_file(spec.input_path):
        stale.append("入力が変更されています")
    for step, stored in prov.get("prompts_hashes", {}).items():
        if stored != hash_dir(spec.prompts_dir(step)):
            stale.append(f"{step}: プロンプトが変更されています")
    return stale

一致していれば「この結果は今のコードで作られた」と断言できます。不一致なら「再実行してから評価してください」という明確なシグナルになります。品質チェックの CLI に --strict のようなフラグを持たせ、STALE 検出時はエラー終了させると CI にも組み込めます。

make の依存関係管理と同じ発想

これは Makefile がやっていることと本質的に同じです。make はソースのタイムスタンプを見て「再ビルドが必要か」を判断します。LLM パイプラインではタイムスタンプより内容のハッシュが信頼できます。git の checkout やファイルコピーでタイムスタンプだけ変わることがあるためです。

一点、設計上の判断があります。モデル名は記録はするものの、STALE 判定には使いません。プロンプトや入力には「現在のファイル」という静的な比較対象がありますが、モデルは実行時に選ぶパラメータであり「現在のモデル」が一意に定まらないためです。モデルの差異は STALE とは別の情報としてレポートに出す、と役割を分けます。

まとめ

  • 「古い結果で品質チェックを通す」STALE 問題は、再実行コストの高い LLM パイプラインで起きやすい
  • 入力とプロンプトのハッシュを _provenance として結果ファイルに埋め込む
  • 検証時に現在のハッシュと照合し、不一致なら再実行を促す
  • make の依存管理と同じ発想だが、タイムスタンプではなく内容ハッシュを使う
  • モデル名は「現在の値」が定義できないため、記録のみ行い STALE 判定から外す