AIエージェントニュース編集部

Promptfooでプロンプトのデグレを防ぐ:CI回帰テストと評価基準

プロンプトを一行直しただけで、別の入力の出力形式が崩れる。モデルIDを差し替えたら、これまで通っていた分類が外れ始める。こうした変更を毎回手作業で確認するのは、すでに限界に近いはずです。この記事では、評価ツール Promptfoo を使い、プロンプト変更を GitHub Actions 上の LLM回帰テストとして自動化する手順を紹介します。設定ファイルの書き方、決定論的アサーションと LLM-as-a-Judge の使い分け、PR コメントとコスト管理までをカバーします。

プロンプト変更の手動レビューが破綻する理由と回帰テストの必要性

プロンプトは、数行の変更が全入力の出力分布に影響します。コードレビューのように差分を読めば挙動が分かる、とはなりません。レビュアーが確認できるのは、PR 作者が選んだ数件の入力だけです。確認外の入力で起きた劣化は、本番で気づくことになります。

手動確認が破綻する原因は主に3つあります。

  1. ケース数が増えるほど確認コストが線形に増える
  2. 確認者の主観が基準になり、判断が人によってぶれる
  3. モデルのバージョン更新や provider の切り替え時にも同じ確認が必要になる

回帰テストは、この3点を「固定のテストケース」「機械的な判定基準」「CI での自動実行」で置き換えます。

ただし LLM の出力は非決定的なので、通常のユニットテストのように完全一致で判定するのは現実的ではありません。そのため、形式やキーワードのような確定的に判定できる部分と、意味や品質のように判定者が必要な部分を分けて設計します。Promptfoo はこの両方を同じ設定ファイルで扱えます。

Promptfooの基本設計:設定ファイルとアサーションの組み立て方

Promptfoo は、プロンプト、provider(モデル)、テストケースの組み合わせを評価する OSS の CLI です。入口は promptfooconfig.yaml で、prompts、providers、tests の3要素を宣言します。以下は、問い合わせ文をカテゴリ分類して JSON で返すプロンプトの例です。

description: 問い合わせ分類プロンプトの回帰テスト

prompts:
  - file://prompts/classify.txt

providers:
  - id: openai:gpt-4o-mini
    config:
      temperature: 0

defaultTest:
  assert:
    - type: is-json

tests:
  - description: 請求に関する問い合わせ
    vars:
      message: "先月の請求額が二重になっています"
    assert:
      - type: javascript
        value: "JSON.parse(output).category === 'billing'"

  - description: 解約の問い合わせ
    vars:
      message: "プランを解約したいのですが手順を教えてください"
    assert:
      - type: javascript
        value: "JSON.parse(output).category === 'cancellation'"

プロンプトファイル側では {{message}} のように Nunjucks 形式の変数を使い、vars から値を差し込みます。モデルIDは例示なので、利用中のものに置き換えてください。

defaultTest に書いたアサーションは全テストケースに適用されます。出力が JSON であることのような共通条件はここにまとめると、各ケースは固有の期待値だけに集中できます。実行は npx promptfoo@latest eval です。結果は npx promptfoo view でブラウザの比較表として確認でき、複数のプロンプトや provider を並べた差分も見られます。

決定論的テストとLLM-as-a-Judgeを組み合わせた評価基準の実装パターン

プロンプト評価の基準は、安くて確実なものから積み上げるのが基本です。私は次の3層で組み立てることが多いです。

  • 形式層: is-json、JSON Schema による検証、regex
  • 内容層: contains、not-contains、javascript による値の比較
  • 品質層: llm-rubric など、別のモデルに採点させる評価

形式層と内容層は API 呼び出しを追加せず、判定が毎回同じになります。まずここで「壊れてはいけない契約」を固定します。禁止表現や機密情報の混入を not-contains や not-regex で縛るのも、この層の役割です。

品質層は、要約の網羅性や返信文のトーンなど、機械的に判定しにくい項目に使います。llm-rubric はルーブリックを自然文で書き、判定用モデルが合否を返します。

defaultTest:
  options:
    provider: openai:gpt-4o-mini   # 採点用モデル
  assert:
    - type: is-json

tests:
  - vars:
      message: "注文した商品が壊れて届きました。返品したいです"
    assert:
      - type: llm-rubric
        value: |
          返信文は、謝罪を含み、返品手続きの次の行動を1つ以上提示している。
          顧客を責める表現や、確認できない約束(返金額の断定など)を含まない。
        threshold: 0.8
      - type: cost
        threshold: 0.002

判定用モデルの出力も揺れるので、ルーブリックは「1文で判定できる条件」に分割して書くと安定しやすくなります。また、アプリ本体と同じモデルで採点すると自己評価に甘くなる傾向が指摘されています。可能なら採点用には別系統のモデルを使ってください。

注意点として、Promptfoo は1つのテストケースに書いた assert をすべて実行します。「形式が崩れていたら judge を呼ばない」という段階実行は、通常の設定ではできません。judge を使うテストを別の設定ファイルに分け、CI のジョブ単位で出し分ける構成が現実的です。

GitHub Actionsに組み込んでPR時に自動評価・コスト試算・結果コメントを行うワークフロー

CI/CD自動化の要点は、プロンプト関連ファイルが変わった PR だけで評価を走らせ、結果を PR に返すことです。以下は、Promptfoo の CLI を直接呼ぶ最小構成です。公式の GitHub Action(promptfoo/promptfoo-action)もありますが、入力仕様はバージョンで変わりうるため、まず CLI 直呼びで挙動を把握しておくと見通しが良くなります。

name: llm-regression
on:
  pull_request:
    paths:
      - "prompts/**"
      - "promptfooconfig.yaml"
      - "tests/**"

permissions:
  contents: read
  pull-requests: write

jobs:
  eval:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - uses: actions/cache@v4
        with:
          path: ~/.promptfoo/cache
          key: promptfoo-${{ hashFiles('prompts/**', 'promptfooconfig.yaml') }}
          restore-keys: promptfoo-

      - name: Run eval
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          PROMPTFOO_PASS_RATE_THRESHOLD: "90"
        run: npx promptfoo@latest eval -o results.json --no-progress-bar

      - name: Comment result
        if: always()
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          {
            echo "### Promptfoo 評価結果"
            echo '```json'
            jq '.results.stats' results.json
            echo '```'
          } > comment.md
          gh pr comment ${{ github.event.pull_request.number }} --body-file comment.md

PROMPTFOO_PASS_RATE_THRESHOLD は、全体の合格率がこの値(パーセント)を下回ったときに非ゼロの終了コードで失敗させる環境変数です。これで PR のステータスチェックがそのままゲートになります。出力 JSON の構造はバージョンにより変わるため、jq のパスは手元の results.json を見て調整してください。

コスト試算は2段階で考えます。1つ目は、各ケースに type: cost を入れ、1リクエストあたりの上限を超えたら落とす方法です。2つ目は、結果 JSON に含まれるトークン使用量を集計し、利用モデルの単価から PR コメントに概算額を出す方法です。単価はスクリプトに固定せず、設定ファイルで管理すると改定に追従しやすくなります。

なお、fork からの PR では Secrets が渡されないため、外部コントリビューターを受け入れるリポジトリでは別途設計が必要です。pull_request_target を安易に使うと、信頼できないコードが Secrets に触れる危険があります。

運用で失敗しないためのテストケース選定とCI実行コストの抑え方

テストケースは、本番で実際に起きた失敗から増やすのが最も効果的です。バグ報告や不満足な出力を見つけるたびに1件追加すれば、同じデグレは二度と通りません。加えて、各カテゴリの代表例、境界(空入力、極端に長い入力、多言語)、禁止事項の違反誘発を入れておきます。実ログを使う場合は、個人情報をマスクするか合成データに置き換えてください。

実行コストは次の方法で抑えられます。

  1. paths フィルタ: プロンプトと設定が変わった PR だけで実行する
  2. キャッシュ: 同じプロンプト・入力・provider の組は再実行されないため、変更のないケースの API 呼び出しを省ける
  3. 二層構成: PR では数十件のスモークセット、夜間ジョブで全件と judge を回す
  4. 採点用モデルの軽量化: judge は小型モデルで足りるルーブリックに絞る
  5. temperature 0 と timeout: 揺れとジョブの暴走を抑える

スモークセットの絞り込みには、テストケースに metadata を付けて --filter-metadata で選ぶ方法や、--filter-first-n で先頭 N 件に限定する方法があります。利用可能なフィルタ系オプションは promptfoo eval --help で確認してください。

最後に、合格率の閾値は 100% にこだわらないでください。非決定的な評価では、まれな揺れで PR が落ち、チームがテストを信用しなくなります。形式と禁止事項は 100% 必須、品質層は 90% 前後といった具合に層ごとに基準を変え、失敗が続くケースは設計を見直す運用が長続きします。

関連記事