1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLMのプロンプトを全文一致せずpytestで回帰テストする

1
Posted at

LLMのプロンプトを全文一致せずpytestで回帰テストする

LLMの要約をCIに載せて、期待値との全文一致をassertすると、意味が正しくても語尾が変わっただけでテストが落ちます。だからといって、人が読んで「だいたい良い」で済ませれば回帰テストになりません。

結論は、出力全文ではなく、変わってはいけない契約を通常のassertで検査することです。そこから漏れる表現品質だけをgraderへ渡します。LLMのテストを一種類の判定で済ませないのがポイントです。

golden textが壊れやすい理由

たとえば、問い合わせ履歴を要約する処理が次のJSONを返すとします。

{
  "ticket_id": "T-1042",
  "summary": "利用者は請求金額の確認を求めています",
  "needs_reply": true
}

テストで最初にやりたくなるのは、期待値を丸ごと保存する方法です。

以下はPython 3.12以降、pytest 8系で動く最小例です。外部APIは使いません。

# test_brittle.py

def summarize() -> dict:
    return {
        "ticket_id": "T-1042",
        "summary": "利用者は請求額について確認を求めています",
        "needs_reply": True,
    }


def test_summary():
    expected = {
        "ticket_id": "T-1042",
        "summary": "利用者は請求金額の確認を求めています",
        "needs_reply": True,
    }

    assert summarize() == expected

実行環境は次だけで足ります。

Python >= 3.12
pytest >= 8,<10
python -m pytest test_brittle.py

このテストは失敗します。

請求金額の確認を求めていますと請求額について確認を求めていますが一致しないからです。しかし、少なくともこの例だけを見れば、テスト失敗から「要約機能が壊れた」とは判断できません。

問題はLLMがランダムだから、だけではありません。

そもそも要約というタスクには、正しい文章が一つしかないという前提がありません。モデル、プロンプト、推論条件などを固定して出力の揺れを抑えたとしても、プロンプトを改善して別の妥当な表現へ変わればgolden textは失敗します。

つまり全文一致は、意味の回帰ではなく文字列の変化を検出していることになります。

一方で、だから全文一致をすべて捨てればよいわけでもありません。分類ラベル、固定された列挙値、正規化後の値など、出力そのものが契約になっている箇所なら完全一致は普通に使えます。

境界は「LLMかどうか」ではなく、「その値に複数の正解を許すか」です。

OpenAIのEvaluation best practicesでも、用途に即したevalを設計し、変更ごとの継続的な評価を行う考え方が示されています。また、自由生成そのものを評価させるより、pairwise comparison、classification、特定基準へのscoringのような判別タスクへ寄せる方針が示されています。

Evaluation best practices

assertする契約とgraderへ渡す品質を分ける

LLMのプロンプトを全文一致せずpytestで回帰テストする

要約結果を見たとき、「この文章は期待文と同じか」ではなく、「この処理が壊れると何が困るか」から逆算します。

先ほどの例なら、少なくとも次のように分解できます。

LLM output
    |
    +-- 決定的に判定できる
    |     ticket_idを保持している
    |     必須フィールドが存在する
    |     needs_replyがboolである
    |     禁止語を含まない
    |
    +-- 一意に判定しにくい
          問い合わせの要点を保持している
          不要な情報を足していない
          読みやすく要約されている

前半にLLM judgeは要りません。普通のPythonで判定した方が、失敗理由が明確で、同じ入力なら同じ結果になります。

後半だけがgraderの仕事です。

この分離にはもう一つ利点があります。たとえばgraderが「良い要約」と判定しても、ticket_idが別の値へ変わっていたら業務処理としては失敗です。文章品質の評価器に、機械的に判定できる契約まで任せる理由はありません。

私はAIを使った業務の自動化では、人が決め、AIが下ごしらえする形が扱いやすいと考えています。テストでも似ています。決定できるところはコードで決め、曖昧さが残るところだけ評価器へ渡した方が境界が見えます。

pytestを二層のテストへ直す

実装すると、構造はかなり単純です。

まず決定的な契約を関数にします。

# contracts.py

REQUIRED_KEYS = {"ticket_id", "summary", "needs_reply"}
FORBIDDEN_WORDS = {"絶対", "確実"}


def assert_contract(result: dict, expected_id: str) -> None:
    assert set(result) == REQUIRED_KEYS
    assert result["ticket_id"] == expected_id
    assert isinstance(result["summary"], str)
    assert result["summary"].strip()
    assert isinstance(result["needs_reply"], bool)

    for word in FORBIDDEN_WORDS:
        assert word not in result["summary"]

ここには「文章が自然か」のような条件を入れません。

次に主観的な部分のインターフェースを分離します。特定のLLM APIにテストを依存させないため、grader自体は注入できる関数として扱います。

# quality.py
from collections.abc import Callable

Grader = Callable[[str, str], bool]


def assert_quality(
    source: str,
    summary: str,
    grader: Grader,
) -> None:
    assert grader(source, summary)

そしてpytest側で二つを組み合わせます。

# test_summary.py
from contracts import assert_contract
from quality import assert_quality


def fake_grader(source: str, summary: str) -> bool:
    required = ("請求", "確認")
    return all(word in summary for word in required)


def test_summary_contract():
    result = {
        "ticket_id": "T-1042",
        "summary": "利用者は請求額について確認を求めています",
        "needs_reply": True,
    }

    assert_contract(result, expected_id="T-1042")


def test_summary_quality():
    source = "請求額が合っているか確認したいです。"
    summary = "利用者は請求額について確認を求めています"

    assert_quality(source, summary, fake_grader)

fake_graderは意味品質を十分に評価するものではありません。ここで確認したいのは、CIの契約テストと評価器の境界をコード上でも切ることです。

実運用ではGraderの実装を、モデルによるpass/fail、基準別のclassification、候補A/Bのpairwise comparisonなどへ差し替えられます。そのときもassert_contract()は変わりません。

逆に、graderへ渡すrubricにticket_idがT-1042であることまで書き始めたら、境界を見直した方がよいです。それはPythonが完全に判定できる条件だからです。

この構造なら、プロンプト変更で文章が

利用者は請求金額の確認を求めています

から

利用者は請求額について確認を求めています

へ変わっても、それだけではCIを落としません。

一方、IDがT-1402へ化けた、必須キーが消えた、needs_replyが文字列になった、といった変更は即座に落とせます。

LLMの可変性を許容することと、テストを緩くすることは別です。

grader自身にも回帰テストが要る

二層に分けると、次の問題が出ます。

「そのgraderは信用できるのか」です。

モデルをgraderに使えば、その判定にも誤りや変動があり得ます。OpenAIの公式ガイドも、モデルによる評価では人間のラベルとのagreementを検証することや、rubricを明確にすることを挙げています。

そこでgrader側には、意図的にgood/badを分けたfixtureを持たせます。

# test_grader.py
import pytest


CASES = [
    (
        "請求額が合っているか確認したいです。",
        "利用者は請求額について確認を求めています",
        True,
    ),
    (
        "請求額が合っているか確認したいです。",
        "利用者は配送日の変更を求めています",
        False,
    ),
]


@pytest.mark.parametrize(
    "source,summary,expected",
    CASES,
)
def test_grader(source, summary, expected):
    actual = fake_grader(source, summary)
    assert actual is expected


def fake_grader(source: str, summary: str) -> bool:
    required = ("請求", "確認")
    return all(word in summary for word in required)

本物のgraderへ置き換えるなら、fixtureも単純な二件で終わらせず、境界事例を増やします。

特に価値があるのは、明らかなgood/badだけではなく、「要点は正しいが余計な断定を追加した」「主要事項の一つだけを落とした」といったケースです。graderのプロンプトやモデルを変更したとき、期待していた判定基準そのものがずれていないか確認できます。

つまりテスト対象は二つあります。

application output
  -> deterministic contract
  -> semantic grader

semantic grader
  -> fixed good/bad fixtures

プロンプトだけをバージョン管理しても、「何を守るためのプロンプトなのか」がコードに残っていなければ変更時の判断材料がありません。契約、評価データ、graderのfixtureまで同じ変更管理の対象として置く方が、プロンプト変更のレビューもしやすくなります。

2026年10月1日時点で、OpenAIは従来のEvals platformについて、2026年10月31日に既存evalをread-only化し、11月30日にdashboardとAPIを終了予定としています。特定の評価基盤を使うかどうかとは別に、評価条件とfixtureをアプリケーション側の資産として残しておく意味はあります。

Deprecations

この方法が合わないのは、出力が固定ラベルや厳密なシリアライズ結果など、完全一致そのものを契約にできる場合です。また、graderをCIの必須条件にするなら、モデル更新、判定の変動、API障害、実行時間、コストをどう扱うかは別途決める必要があります。まず切り分けたいのは「この文字列を守る」のか、それとも「この業務上の性質を守る」のかです。

参考

Evaluation best practices

Deprecations

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?