ほとんどのCIパイプラインは、同じ入力で関数を2回呼び出すと、同じ出力が返されることを前提としています。しかし、この前提はLLM呼び出しがテストスイートに入った瞬間に崩れます。GPT-4やClaudeに同じ質問を2回すると、2つの異なる(どちらも正しい)回答が得られることがあります。LLMを活用した機能をリリースするチームは、LLMに触れるコードパスに対するテストをほとんど書かないか、正確な文字列出力でアサートして、最初に失敗した時点でテストを無効にする方法で対応することがよくあります。どちらも、機能が本番環境で動作し、プロンプトの変更やモデルのアップグレードによって動作がサイレントに破壊される可能性がある状況では持続可能ではありません。

必要なのは、巧妙なアサーションライブラリではありません。実際にはテストしているものを3つの階層に分割し、それぞれに異なる決定論的保証を与え、各階層を異なるCIジョブにマッピングすることです。

Tier 1: コントラクトテスト(決定論的、すべてのPRで実行)

コントラクトテストは、ライブモデルを呼び出すことはありません。パイプラインが生成するものの形状についてアサートし、内容についてはアサートしません。オーケストレーターがLLMから{title, tags, price_usd, full_content}の返却を期待する場合、コントラクトテストはパース/バリデーション層に固定のレスポンスを流し、例外をスローしないこと、必須フィールドが存在すること、型が一致することを確認します。

# test_contract.py
import json
import jsonschema
import pytest

PRODUCT_SCHEMA = {
    "type": "object",
    "required": ["title", "tags", "price_usd", "full_content"],
    "properties": {
        "title": {"type": "string", "minLength": 5},
        "tags": {"type": "array", "minItems": 1},
        "price_usd": {"type": "number", "minimum": 1},
        "full_content": {"type": "string", "minLength": 200},
    },
}

@pytest.mark.parametrize("fixture_file", [
    "fixtures/well_formed.json",
    "fixtures/missing_price.json",
    "fixtures/empty_content.json",
])
def test_pipeline_handles_llm_output(fixture_file):
    payload = json.load(open(fixture_file))
    result = parse_and_validate(payload)  # your own code
    if fixture_file == "fixtures/well_formed.json":
        jsonschema.validate(result, PRODUCT_SCHEMA)
    else:
        assert result.rejected_reason is not None

Enter fullscreen mode Exit fullscreen mode

これらのフィクスチャはリポジトリにコミットされた静的なJSONファイルです。ミリ秒単位で実行され、APIキーは不要で、実際に停止を引き起こすバグクラスを捉えます。すなわち、モデルが省略したフィールドでパーサーが詰まる、文字列を期待した場所にnullがある、プロンプト編集後のスキーマのずれなどです。この階層はすべてのコミットで実行され、マージをブロックする必要があります。

Tier 2: リプレイテスト(決定論的、すべてのPRで実行)

「偽のJSONフィクスチャ」と「本物のAPIを呼び出す」の中間に位置するのが、カセットベースのリプレイです。実際のモデルレスポンスを一度記録してディスクに保存し、以後のCI実行ではネットワークにアクセスせずにそれを再生します。これはHTTPテスト用のVCRと同じ考え方をLLM呼び出しに適用したものです。

# conftest.py
import json, os, hashlib

CASSETTE_DIR = "tests/cassettes"

def llm_call_with_cassette(prompt, real_call_fn):
    key = hashlib.sha256(prompt.encode()).hexdigest()[:16]
    path = f"{CASSETTE_DIR}/{key}.json"
    if os.path.exists(path):
        return json.load(open(path))
    if os.environ.get("CI") and not os.environ.get("RECORD_CASSETTES"):
        raise RuntimeError(f"Missing cassette for prompt hash {key}; run with RECORD_CASSETTES=1 locally")
    response = real_call_fn(prompt)
    json.dump(response, open(path, "w"))
    return response

Enter fullscreen mode Exit fullscreen mode

カセットは他のフィクスチャと同様にリポジトリにコミットされます。意図的にプロンプトを変更するときは、RECORD_CASSETTES=1を指定してローカルで再記録し、カセットファイルの差分をPRの一部としてレビュー(ここでレビュアーが出荷前に回帰を捉えることが多い)し、新しいカセットをコミットします。これらのテストではCIがネットワークに触れることはないため、高速かつ無料ですが、手書きのフィクスチャではなく実際のモデル出力に対して本物の解析ロジックやビジネスロジックを実行できます。

Tier 3: ライブスモークテスト(非決定論的、夜間実行、PRでは実行しない)

この階層は実際のAPIキーと実際のコストで本物のモデルを呼び出します。その役割は1つの質問に答えることです。モデルの振る舞いが前回チェックしたときから、再生用カセットでは明らかにならない形でドリフトしていないか?出力は変動するため、等価性をアサートすることはできません。代わりにプロパティでアサートします。レスポンスが有効なJSONか、スキーマを満たしているか、参照回答に対する意味的類似度スコアがしきい値を超えているか(文字列一致ではなく埋め込みを使用)、トークン使用量がコスト予算内に収まっているか。

def test_live_semantic_similarity(embed_fn, cosine_sim):
    reference = load_reference_embedding("golden_answer.json")
    live_output = call_real_llm(PROMPT)
    similarity = cosine_sim(embed_fn(live_output["full_content"]), reference)
    assert similarity > 0.80  # loose bound; catches total drift, not phrasing

Enter fullscreen mode Exit fullscreen mode

ここでCircleCIのワークフローモデルが役立ちます。.circleci/config.ymlに2つのワークフローを定義します。1つはすべてのPRでgateされ、Tier 1と2のみを実行するもの、もう1つは夜間(または承認ジョブ経由でオンデマンド)にTier 3を実行するスケジュール済みワークフローです。これにより、ライブ呼び出しのフレークがマージをブロックすることはありません。

version: 2.1

jobs:
  fast-tests:
    docker: [{ image: cimg/python:3.12 }]
    steps:
      - checkout
      - run: pip install -r requirements.txt
      - run: pytest tests/test_contract.py tests/test_replay.py -v

  live-smoke-tests:
    docker: [{ image: cimg/python:3.12 }]
    steps:
      - checkout
      - run: pip install -r requirements.txt
      - run:
          name: Enforce per-run cost ceiling
          command: python scripts/check_budget.py --max-usd 2.00
      - run: pytest tests/test_live_smoke.py -v --reruns 1

workflows:
  on-pr:
    jobs:
      - fast-tests
  nightly-live-check:
    triggers:
      - schedule:
          cron: "0 6 * * *"
          filters:
            branches: { only: main }
    jobs:
      - live-smoke-tests

Enter fullscreen mode Exit fullscreen mode

ここで2つの詳細が重要です。まず、ライブジョブの--reruns 1は、単一のAPIタイムアウトで誰かを呼び出さないようにするためのものです。2回連続で失敗した場合に初めて本当のシグナルとなります。次に、check_budget.pyステップはpytest呼び出しのに実行され、固定のテストプロンプトに対するトークン数からコストを推定し、プロンプト変更が1ドルの上限を超える場合はジョブを即座に失敗させます。これにより、誰かがプロンプトのコンテキストウィンドウを広げて夜間の請求額を10倍にするシナリオを、請求書が届く前ではなく前に捉えられます。

なぜ分割するのか、「より多くのリトライ」では不十分な理由

この分割をスキップするチームは、通常2つのいずれかの失敗モードに行き着きます。レート制限のあるAPIへの不安定なネットワーク呼び出しを待つためにPRが数分間ブロックされるか、アサートが緩すぎてパイプラインがゴミを返しても通過してしまうテストのどちらかです。コントラクトの正しさ(Tier 1)、既知の良好な実出力に対する回帰検出(Tier 2)、ライブモデルのドリフト検出(Tier 3)を分離することで、各階層がその決定論的保証に実際に適合するアサーションスタイルを使用でき、CircleCIは各階層をそのコストと速度プロファイルにふさわしい周期でスケジュールできます。PRをブロックするパスは高速かつ無料のままにし、高価で不安定で実際のお金がかかるパスは1日1回、人間がリリースを妨げることなくトリアージできるタイミングで実行します。