はじめに:なぜプロンプトにもリグレッションテストが必要なのか

従来のソフトウェア開発では、リグレッションテストはコード品質を保証する中核的な防御策です。しかし、LLMアプリケーションでは、多くのチームが「手動でいくつかのプロンプトを試して、それらしく見えたらリリースする」という段階に留まっています。このやり方は非常にリスクが高いです。LLMの動作は確率的であり、小さなプロンプトの変更が、本番環境での回答品質の急激な低下を引き起こす可能性があります。私は、カスタマーサービスのボットで、「丁寧な口調で」を「親しみやすい口調で」に変更しただけで、苦情率が37%上昇し、リグレッションテストでは全く検出できなかった例を見たことがあります。

プロンプトリグレッションテストの核となる考え方は、プロンプトをコードとして扱うことです。つまり、あらゆる変更は、固定されたテストケースのセットで検証され、出力品質が許容範囲内であることを確認する必要があります。これは単純に聞こえますが、実装には多くのエンジニアリング上の詳細があります。例えば、テストケースの選び方、「品質」の定義方法、出力の類似性の評価方法、モデル自身の不確実性の扱い方などです。この記事では、DeepSeek APIでの実践経験に基づいて、実行可能な道筋を提供します。

ステップ1:テストセットの定義 - 実際のログから掘り起こす

多くのチュートリアルでは、「10個のテストケースを慎重に設計する」と言われますが、これは完全に誤解を招きます。本当のテストセットは、本番環境の実際の入力から来るべきであり、あなたの想像からではありません。私はプロジェクト初期にこの間違いを犯しました。自分で「巧妙な」質問を設計しましたが、リリース後、実際のユーザーの質問がカバー範囲外であることが判明し、テストは形骸化しました。

正しいアプローチは、アプリケーションログから200〜500件のユーザー入力をランダムサンプリングし、クラスタリング(例えば、埋め込み類似度)を用いて50〜100件の代表的なサンプルを選ぶことです。ここでの鍵は多様性です。異なる長さ、トピック、感情、言語スタイルをカバーする必要があります。アプリケーションが分類シナリオの場合、各クラスに十分なサンプルがあることを確認してください。私の経験では、テストセットの品質は量よりもはるかに重要であり、よくカバーされた50件のケースは、重複の多い500件のケースよりもはるかに優れています。

以下の表は、ECカスタマーサービスシナリオでのテストセット構成の例です。ビジネスに応じて比率を調整してください:

タイプサンプル数入力例
購入前の問い合わせ20「このスマホはワイヤレス充電に対応していますか?」
アフターサービスの問題15「届いた商品が破損していますが、返品・交換はどうすればいいですか?」
価格に関する問い合わせ10「今使えるクーポンはありますか?」
苦情10「配送が遅すぎます。もう3日経っても届きません!」
雑談5「今日の天気はどう思いますか?」

ステップ2:テストランナーの作成 - DeepSeek APIでバッチ評価

テストセットが決まったら、次のステップはモデルをバッチ呼び出しすることです。ここにはエンジニアリング上の落とし穴があります。テストコードにAPI呼び出しロジックを直接ハードコードせず、再利用可能なテストランナーにカプセル化するべきです。以下は、私がプロジェクトで使用したPythonテストランナーの主要部分で、並行呼び出し、タイムアウト処理、結果記録をサポートしています:

import openai, json, concurrent.futures
client = openai.OpenAI(api_key='your-deepseek-api-key', base_url='https://api.deepseek.com')

def run_case(prompt, system_prompt):
    response = client.chat.completions.create(
        model='deepseek-chat',
        messages=[
            {'role': 'system', 'content': system_prompt},
            {'role': 'user', 'content': prompt}
        ],
        temperature=0.3,  # リグレッションテストでは低い温度を推奨し、安定性を向上させる
        max_tokens=500
    )
    return response.choices[0].message.content

def run_test_suite(test_cases, system_prompt):
    results = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
        futures = {executor.submit(run_case, case['prompt'], system_prompt): case for case in test_cases}
        for future in concurrent.futures.as_completed(futures):
            case = futures[future]
            try:
                output = future.result()
                results.append({'prompt': case['prompt'], 'expected': case.get('expected'), 'actual': output})
            except Exception as e:
                results.append({'prompt': case['prompt'], 'error': str(e)})
    return results

呼び出し時の注意点:temperatureは0.2〜0.3に設定する必要があります。これにより、出力のランダム性が大幅に低減され、テスト結果の再現性が向上します。DeepSeek APIで実際にテストしたところ、temperatureを0.7から0.2に下げると、同じテストケースの出力分散が約80%減少しました。ただし、温度が低すぎると創造性が犠牲になる可能性があるため、具体的な値はシナリオに応じてバランスを取る必要があります。

もう一つの落とし穴は、並行性とレート制限です。DeepSeek APIにはレート制限があり(プランによります)、20スレッドを直接開くと429エラーが発生する可能性があります。ThreadPoolExecutorで並行数を5〜8に制御し、簡単なリトライメカニズム(429または5xxが発生したら1秒待ってリトライ)を追加することをお勧めします。テストランナーはJSON結果ファイルを出力し、後で比較しやすくします。

ステップ3:評価戦略 - 「似ているか」から「良いか」へ

モデル出力を取得した後、最も原始的な方法は人手で1件ずつ確認することですが、これでは効率が低すぎてリグレッションには使えません。一般的な方法は、出力と期待される回答の類似度を計算することです。例えば、BLEU、ROUGE、コサイン類似度などです。しかし、ここには罠があります。LLMの出力は通常柔軟であり、意味が同じでも表現が異なる文は、BLEUで計算するとスコアが低くなり、「リグレッション失敗」と誤判定される可能性があります。

私が実際に試した中で、より実用的な組み合わせは、まず埋め込み類似度(例えば、text-embedding-3-small)を使用して明らかに一致しないケースをフィルタリングし、残りのケースに対して意味的検証を行うことです。具体的には、しきい値を設定し、埋め込みコサイン類似度が0.75未満の場合、「手動確認が必要」とマークします。このしきい値はビジネスに応じて調整できますが、類似度は万能ではなく、事実誤りを捕捉できないことを覚えておいてください。

したがって、事実に関する質問についてはこのようなシナリオでは、「検証関数」の導入を強くお勧めします。例えば、アプリケーションが注文番号を抽出する場合、テストケースのexpectedはJSONルールにすることができ、テストランナーは出力にルールに適合する注文番号が含まれているかどうかをチェックします。以下は簡単な例です:

def check_fact(output, expected):
    # expected は {'keyword': '返品ポリシー'} の可能性がある
    if 'keyword' in expected:
        if expected['keyword'] in output:
            return True, 1.0
        else:
            return False, 0.0
    # その他のルール...
    return True, 0.8  # デフォルトでは合格とするが、信頼度を下げる

実際のプロジェクトでは、ルールアサーション(厳格)、類似度(緩やか)、手動スポットチェック(フォールバック)の3つの評価を混在させています。各回帰テストで、類似度がしきい値を下回るケースが5%を超えるか、ルールアサーションが1つでも失敗した場合、テストは不合格とみなし、プロンプトの変更をロールバックする必要があります。

ステップ4: モデルの不確実性への対処—複数回実行して中央値を取る

低い温度設定でも、LLMの出力にはランダム性があります。同じプロンプトを2回実行すると、結果がわずかに異なる場合があります。回帰テストの結果を安定させるために、私は「複数回実行」戦略を採用しています:各テストケースを3回実行し、3つの出力を得て、中央値または多数決を取ります。生成タスクの場合、3つの出力のペアワイズ類似度を比較できます。3つの出力が大きく異なる場合、そのケース自体が不安定であり、テストセットを修正するか、そのケースの重みを下げる必要があるかもしれません。

この方法はAPI呼び出しコストを増加させますが、より信頼性の高いテスト結論をもたらします。DeepSeek APIではコストが非常に低く、3回の呼び出しの費用は完全に許容範囲です。コストを節約するために1回だけ実行するチームを見たことがありますが、テスト結果がランダムに変動し、かえって手動レビューの時間を無駄にしていました。

また、回帰テストの環境分離にも注意する必要があります。テスト時にはモデルバージョンを固定することをお勧めします。'deepseek-chat'のような更新される可能性のあるモデルは使用しないでください(不安定な場合は、'deepseek-chat-0707'のような具体的なスナップショットバージョンを指定できます)。オンラインのモデルアップグレードにより出力スタイルが突然変わったことがありましたが、回帰テストがすぐに警告してくれたおかげで、大きな事故を防げました。

ステップ5: ベースライン版の確立と比較レポート

回帰テストの核心は「比較」です。したがって、初回テスト実行時に結果を「ベースライン版」として保存します。その後、プロンプトを変更するたびに、新しい結果をベースラインと比較し、レポートを生成します。レポートには、各ケースの出力差分、類似度スコア、合格/不合格、および全体の合格率を含める必要があります。

私のプロジェクトでは、最も簡単な方法を使用しました:結果をJSONファイルとして保存し、gitで管理し、スクリプトでmarkdownレポートを生成します。レポートにはすべてのケースが表でリストされ、合格項目にはチェックマーク、不合格項目にはバツ印が付けられます。これにより、コードレビュー時にレビュアーがリスクポイントを一目で確認できます。以下はレポートのサンプル抜粋です:

ケースID入力類似度ルールアサーションステータス
001返金にはどのくらい時間がかかりますか?0.98「1〜3営業日」を含む
002代金引換は可能ですか?0.64不要❌ 手動が必要
...............

このレポートには必ず「不安定なケース」のヒントを含める必要があります。例えば、複数回実行で結果の差が大きいケースです。これらのケースは、テストセット自体の問題や、特定の領域におけるモデルの弱点を露呈することが多いため、特に注意が必要です。

ステップ6: 回帰テストをCI/CDパイプラインに組み込む

プロンプト回帰テストは手動実行の段階に留まってはいけません。CIパイプラインの一部として、コードコミットやプロンプトファイルの変更のたびに自動実行することを強くお勧めします。GitHub ActionsやGitLab CIでは、テストランナーを実行するジョブを簡単に追加でき、合格率がしきい値(例:95%)を下回った場合にマージをブロックできます。

実践的な経験として、テスト実行時間は3分以内に抑える必要があります。そうしないと開発者が待ちたがりません。テストセットが100ケースで、各ケース3回呼び出し、並行度8の場合、DeepSeek APIでは約1〜2分かかり、完全に実行可能です。5分を超える場合は、ケース数を減らすか、より小さなモデルに切り替えることを検討してください。

また、CIでのシークレット管理には注意が必要です。APIキーをコードにハードコードせず、CI環境変数やシークレット管理ツールを使用してください。私のプロジェクトでは、誤ってキーをリポジトリにコミットして漏洩させ、後処理に多くの時間を費やした経験があります。

ステップ7: 実践におけるエンジニアリングの落とし穴と解決策

最初の落とし穴は、システムプロンプトの予期しない影響です。多くのチームのテストはユーザー入力のみをカバーし、システムプロンプト自体も回帰が必要であることを無視しています。ある時、スタイルを統一するために、システムプロンプトの「あなたはスマートなカスタマーサービスです」を「あなたはプロのカスタマーサービスです」に変更したところ、すべての回答が大幅に長くなりましたが、類似度スコアは正常でした。ベクトル距離がスタイルの変化に敏感でないためです。結局、手動スポットチェックで発見されました。

2つ目の落とし穴は、出力形式の安定性です。アプリケーションがJSON出力を要求する場合、LLMはJSONの前後に追加テキスト(例:「はい、結果は次のとおりです:」)を追加することがあり、パーサーがクラッシュする可能性があります。回帰テストでは、必ず形式検証のアサーションを含める必要があります。私はPythonのjson.loadsを使用して出力が解析可能かどうかをテストし、解析できない場合は直接不合格とします。ただし、DeepSeek APIはJSONモード(response_format)をサポートしているため、API呼び出し時に有効にして、このようなエラーを減らすことをお勧めします。

3つ目の落とし穴はコスト管理です。回帰テストの頻繁な実行はAPI費用を増加させる可能性があります。私の通常の方法は、コミットごとに「クイックサブセット」(例:20のコアケース)のみを実行し、毎日のスケジュールタスクで完全なテストセットを実行することです。これにより、基本的な安全性を確保しつつ、コストを管理できます。

結論:「神秘的なチューニング」から「エンジニアリングガバナンス」へ

プロンプト回帰テストは、あれば良いというものではなく、LLMアプリケーションが本番環境に進むための必須の関門です。品質のベースラインを守り、プロンプトを最適化する際に不安を感じることなく作業できます。DeepSeek APIでは、これらの実践は完全に実行可能で、コストも非常に低いです。ただし、この方法は銀の弾丸ではなく、評価指標がすべての意味的問題をカバーできるわけではないため、常に手動レビューのプロセスを残す必要があります。

私の提案は、今日からプロンプトをコードのように管理することです:テストセットを定義し、テストランナーを作成し、品質しきい値を設定し、CIに統合し、すべての変更に根拠を持たせることです。このプロセスには数日間の投資が必要かもしれませんが、長期的な安定性という報酬があります。もし実践で他の落とし穴に遭遇した場合は、コメント欄で共有してください。一緒にこの方法論を改善しましょう。