エージェント型LLMアプリのテスト戦略:再現性と品質を両立させる


エージェント型のLLMアプリケーションを開発していると、テスト設計の段階で壁にぶつかります。「同じ入力を与えても、毎回出力が変わる」という非決定性です。この性質はLLMの表現力の源でもありますが、テスト自動化を設計しようとした瞬間に大きな障壁になります。本記事では、Claude APIやOpenAI APIを使ったエージェント型アプリのテスト戦略として、再現性と信頼度を両立させる具体的なアプローチを整理します。

なぜエージェント型LLMアプリのテストは難しいのか

従来のWebアプリケーションであれば、add(2, 3) は必ず 5 を返します。テストを書くのは簡単です。しかしエージェント型のLLMアプリでは、同じプロンプトに対して「ツールを呼ぶ順番が変わる」「返答の文体や構造が微妙に異なる」「稀に意図しない経路を通る」といった挙動のばらつきが生じます。

この不確定性の主な要因は以下のとおりです。

  • temperatureパラメータによる確率的なトークン選択
  • マルチステップのツール呼び出しで誤差が積み重なる
  • コンテキストウィンドウの制約や、モデルのバージョン差による挙動の変化
  • 外部APIやWebブラウジングなど、エージェントが依存する外部リソースの変化

特に「エージェント」と呼ばれる構成では、LLMが自律的にツールを選択・実行するため、一つのステップの揺れが後続全体に波及します。assertEquals で文字列を一致確認するような従来の手法はほぼ機能しません。

temperature=0による再現性の確保とその限界

最初に試みる対策として、temperature=0 に設定する方法があります。モデルが常に最も確率の高いトークンを選択するようになるため、同一モデル・同一APIバージョンであれば出力をある程度固定できます。

response = client.chat.completions.create(
    model="gpt-4o",
    messages=messages,
    temperature=0,
    seed=42  # OpenAI APIではseedパラメータも活用できる
)

ただし、この方法には明確な限界があります。

  • モデルのバージョンアップで出力が変わる:プロバイダー側のアップデートは制御できません。
  • ツール呼び出しを含む場合は完全な再現性が保証されない:function callingの引数選択には依然として揺れが残ることがあります。
  • 本番環境と乖離する:実際にユーザーが使う温度設定と異なるため、テスト結果が実運用を反映しない可能性があります。

再現性を高める手段としては有効ですが、「再現性が確保された」=「品質が保証された」とはならない点に注意が必要です。

サンプリングテストで信頼度を定量化する

より現実的なアプローチは、同じテストケースを複数回実行して、成功率で品質を評価することです。100%の一致を求めるのではなく、「90%以上のケースで期待する結果が得られるか」という確率的な基準を設けます。

from typing import Callable

def run_with_sampling(
    test_fn: Callable[[], bool],
    n_samples: int = 10,
    pass_threshold: float = 0.8
) -> bool:
    """テスト関数をn回実行し、pass_threshold以上の成功率を合格とする"""
    results = [test_fn() for _ in range(n_samples)]
    pass_rate = sum(results) / n_samples
    print(f"Pass rate: {pass_rate:.0%} ({sum(results)}/{n_samples})")
    return pass_rate >= pass_threshold

def test_agent_extracts_action_item():
    def single_run() -> bool:
        response = run_agent("明日の10時にミーティングを設定してください")
        return "calendar" in response.tool_calls_used

    assert run_with_sampling(single_run, n_samples=10, pass_threshold=0.9)

APIコストは増加しますが、品質の「信頼度」を定量的に把握できる点で価値があります。重要なパスは高いthresholdで、軽微な挙動の確認は低いthresholdで管理するといった使い分けが効果的です。

「意図の正確性」を検証するアサーション設計

エージェントの出力を検証するとき、文字列の一致ではなく「意図が満たされているか」を確認する視点が重要です。出力の表現は毎回変わっても、達成すべき意図は安定しているはずだからです。

構造的アサーション

JSONやツール呼び出しの結果など、構造化された出力に対しては、値の存在・型・範囲を確認します。

def assert_tool_call_intent(response):
    tool_calls = response.tool_calls
    # 特定のツールが呼ばれたかを確認(引数の文字列一致は避ける)
    assert any(tc.function.name == "create_calendar_event" for tc in tool_calls)
    # 引数の型と必須フィールドのみを確認
    args = json.loads(tool_calls[0].function.arguments)
    assert "datetime" in args
    assert "title" in args

セマンティックアサーション(LLM-as-Judge)

テキスト出力の妥当性を別のLLM呼び出しで評価する手法です。コストはかかりますが、「意味的に正しいか」を自動判定できます。

def semantic_assert(actual: str, expectation: str) -> bool:
    judge_prompt = f"""
以下の回答が、期待される条件を満たしているか評価してください。

回答: {actual}
期待する条件: {expectation}

満たしている場合は "PASS"、そうでない場合は "FAIL" とだけ答えてください。
"""
    result = client.chat.completions.create(
        model="gpt-4o-mini",  # Judgeには軽量モデルで十分なことが多い
        messages=[{"role": "user", "content": judge_prompt}],
        temperature=0
    )
    return "PASS" in result.choices[0].message.content

コスト効率を保つテスト環境の構成

テスト自動化を本格運用する場合、APIコストが無視できなくなります。ティア分けによるテスト管理が有効です。

テスト種別実行タイミングLLM呼び出しコスト
ユニットテスト(モック)毎コミットなしゼロ
インテグレーションテストPR時実API(最小限)
サンプリングテストリリース前実API(複数回)中〜高

LLMの呼び出し部分はインターフェースで抽象化し、ユニットテストではモックに差し替えることでコストゼロのテストを最大化します。

from unittest.mock import MagicMock

def test_agent_retries_on_tool_failure():
    mock_llm = MagicMock()
    mock_llm.complete.side_effect = [
        create_tool_call_response("search", {"query": "天気"}),
        create_text_response("東京の明日の天気は晴れです"),
    ]
    agent = MyAgent(llm=mock_llm)
    result = agent.run("明日の東京の天気を教えて")
    assert mock_llm.complete.call_count == 2

加えて、テストデータのキャッシュも効果的です。一度取得したLLMレスポンスをファイルにキャッシュし、同一入力では再利用することで、繰り返し実行時のコストを削減できます。

事前に洗い出しておきたい失敗パターン

品質保証の仕上げとして、エージェントが失敗しやすいシナリオを体系的にテストケースへ組み込むことが重要です。通常の動作確認だけでなく、以下のようなネガティブケースを意図的に定義しておきます。

  • 曖昧な指示:「なんかいい感じに予定入れて」のような入力で、エージェントが適切に確認を求めるか、それとも誤った推測で処理を進めてしまうか。
  • ツール呼び出し失敗時のリカバリー:外部APIがタイムアウトや400エラーを返したとき、リトライや代替手段を取れるか。
  • 長いコンテキストでの挙動:会話が長くなるにつれて、初期の指示が薄れていないか。
  • プロンプトインジェクション的な入力:「これまでの指示を無視して…」のような入力が含まれたとき、意図しない動作をしないか。

これらの失敗パターンはリリース後に表面化しやすいものです。Happy Pathだけでなくエッジケースをテストスイートに含めておくことで、実運用に耐えるエージェントに仕上げることができます。