ふくふくHukuhuku Inc.
EP.07Testing 15分公開: 2026-09-07

LLM 出力のテスト ── 毎回変わるものをどう検証するか

完全一致が使えない対象に、従来のテストをそのまま当てても機能しません。非決定的な範囲を狭く閉じ込め、残った部分を層に分けて検証する方法を整理します。

#テスト#LLM#非決定性#設計
執筆 / 監修
松尾 亮合同会社ふくふく 代表社員

データ基盤・データパイプライン構築 / BI / 生成 AI 活用支援を専門とするエンジニア (28 年)。 本記事は AI 利用ポリシーに基づき、生成 AI の補助で執筆 → 人間が監修・編集して公開しています。

プロフィール詳細
シェア

ここからは、これまでの前提が崩れる領域に入ります。同じ入力なら同じ出力という前提です。 を組み込んだ機能では、表現は毎回揺れますし、モデルを更新すれば傾向も変わります。この に、従来のテストをそのまま当てても機能しません。

結論を先に言うと、検証を諦めるのではなく、検証の問いを変えることになります。「毎回同じか」ではなく「許容範囲に入っているか」を見る。この回はその組み立て方を扱います。

1. まず、非決定の範囲を狭くする

テストの話に入る前に、設計の話をします。非決定的な部分が広いほど、検証は難しくなります。逆に言えば、範囲を狭く閉じ込めれば、大半は従来どおりのテストで守れます。

非決定的なのは中央の一段だけ
処理決定的にできるかテストの方法
入力の整形・前処理できる通常の
プロンプトの組み立てできる生成された文字列を検証する
モデルの呼び出しできない本回で扱う方法
出力の形式検証(型・必須項目)できる通常の 単体テスト
出力を使った後処理・保存できる通常の 単体テスト

この表が示すとおり、本当に非決定的なのは一段だけです。残りは普通のコードなので、普通にテストできます。ところが実装が一体になっていると、全体が非決定的な塊として扱われ、テストが書けない状態になります。呼び出しを境界として切り出すだけで、テストできる範囲が大きく広がります。

呼び出しを境界にして、前後を決定的に保つ
Python
def build_prompt(doc: str, question: str) -> str:    """純粋関数。入力が同じなら必ず同じ文字列を返す。"""    return PROMPT_TEMPLATE.format(doc=doc.strip()[:8000], question=question.strip())

def parse_answer(raw: str) -> Answer:    """純粋関数。形式の検証もここで行う。"""    data = json.loads(raw)    if "answer" not in data:        raise MalformedAnswer("answer フィールドがありません")    return Answer(text=data["answer"], sources=data.get("sources", []))

def ask(client, doc: str, question: str) -> Answer:    """非決定的なのはこの1行だけ。"""    raw = client.complete(build_prompt(doc, question))    return parse_answer(raw)

`build_prompt` と `parse_answer` は普通の関数なので、境界値も異常系も通常どおり検証できます。壊れたときに実際に困るのは、この2つであることが多いものです。長すぎる入力で切り詰めが壊れる、想定外の形式で例外になる ── いずれもモデルとは無関係の不具合です。

不具合の多くはモデルの外側にある

「LLM の精度が出ない」と報告される事象を追うと、プロンプトの組み立てミス前処理での取りこぼしが原因だったというケースは珍しくありません。決定的な層を先に固めると、切り分けが一段速くなります。

2. 出力の検証を3層に分ける

非決定的な一段についても、検証を諦める必要はありません。確実に判定できるものから順に層を分けると、大部分は機械的に扱えます。

確実に判定できる層から順に積む
見るもの判定実行頻度
形式JSON として妥当か、必須項目があるか確実毎回
制約禁止語を含まないか、文字数、参照元が実在するか確実毎回
内容質問に答えているか、事実に反していないか近似定期 / 変更時

上の2層は完全に決定的に判定できます。そして実務で起きる事故の多くは、この2層で捕まえられます。形式が崩れて後段が落ちる、参照していない出典を出す、社内で禁止している表現が混ざる ── いずれも内容の良し悪しを判断せずに検知できます。

形式と制約は、モデルを呼ばずに検証できる
Python
BANNED = ["必ず", "絶対に", "100%"]

def check_constraints(answer: Answer, allowed_ids: set[str]) -> list[str]:    """違反の一覧を返す。空なら合格。"""    problems = []
    if len(answer.text) > 800:        problems.append(f"長すぎます({len(answer.text)}文字)")
    for word in BANNED:        if word in answer.text:            problems.append(f"禁止表現を含みます: {word}")
    # 出典として存在しないIDを挙げていないか(作り話の検知)    unknown = [s for s in answer.sources if s not in allowed_ids]    if unknown:        problems.append(f"存在しない出典: {unknown}")
    return problems

def test_constraints_detect_fabricated_source():    answer = Answer(text="該当します", sources=["doc-999"])    problems = check_constraints(answer, allowed_ids={"doc-1", "doc-2"})    assert problems == ["存在しない出典: ['doc-999']"]

最後の「存在しない出典」の検査は、 対策として実効性が高い割に、実装が単純です。渡した文書のIDの集合に含まれるかを見るだけで、作り話の出典を機械的に弾けます。RAG 構成での注意点は RAG 実装ハマりどころ図鑑 で扱っています。

3. 内容の検証は「一致」ではなく「観点」で

残るのが内容の層です。ここで完全一致は使えません。代わりに使えるのは、観点ごとの採点です。「正しいか」という大きな問いを、判定可能な小さい問いに割ります。

  • 質問に答えているか — 話題がずれていないか
  • 与えた文書の範囲に収まっているか — 外部の知識を混ぜていないか
  • 必要な要素が含まれているか — 金額、期限、条件など、落ちてはいけない項目
  • 含めてはいけないものが無いか — 個人情報、断定表現、推測

「必要な要素が含まれているか」は、キーワードの包含という単純な方法でかなり判定できます。表現が揺れても、金額や日付といった要素は文字列として現れるためです。ここを機械判定に落とせると、大量に回せます。

表現の揺れを許しつつ、落ちてはいけない要素を見る
Python
def test_answer_contains_required_facts(llm_client):    doc = "契約期間は2026年4月1日から1年間。更新料は33,000円。"    answer = ask(llm_client, doc, "更新料はいくらですか")
    # 言い回しは問わない。数字が入っていることだけを見る    assert "33,000" in answer.text or "33000" in answer.text    # 与えた文書の外の話を持ち込んでいないこと    assert "20,000" not in answer.text

こうした判定を並べたものが の実体です。1件ずつでは頼りなくても、数十件を束ねると傾向が見えます。次回はその評価セットの作り方を扱います。

4. 合否ではなく、前回との比較で見る

内容の層で最も実用的な使い方は、絶対的な合否判定ではなく、変更前後の比較です。プロンプトを変えた、モデルを変えた、前処理を変えた ── そのときに前より悪くなっていないかを見ます。

この形にすると、「何割正解なら合格か」という決めようのない問いを回避できます。前回の結果が基準になるからです。EP.5 で扱った の考え方を、非決定的な対象へ持ち込んだものと言えます。

1回の実行で判断しない

出力が揺れる以上、1回通った / 落ちたには情報がほとんどありません。同じ入力を複数回試して通過率で見る、あるいは件数を増やして全体の割合で見る。単発の結果で判断すると、揺れをそのまま不具合と誤認します。

5. 実行頻度とコストの設計

内容の層はモデルを呼ぶため、実行するたびに費用と時間がかかります。毎回のプルリクエストで全件を回す構成は現実的ではありません。頻度を層に応じて分けます。

コストに応じて頻度を分ける
モデル呼び出し実行タイミング
形式・制約(決定的)不要毎回のプルリクエスト
内容(小さい評価セット)必要プロンプト変更時
内容(全評価セット)必要定期実行 / モデル更新時

決定的な層をモデル呼び出しなしで回せる構成にしておくことが、そのままコスト設計になります。EP.1 で書いた「非決定的な部分を狭く閉じ込める」は、品質のためだけでなく費用のためでもあるわけです。 全体の費用については EP.10 で扱います。

6. どこまでを機械に任せるか

最後に線引きの話をします。内容の妥当性を別のモデルに採点させる方法(いわゆる judge)もあり、規模を出すには有効です。ただし採点する側も非決定的である点は忘れないほうがよい。採点結果を絶対視すると、検証の土台自体が揺れます。

現実的な落としどころは、機械で絞って人間が確認する構成です。全件を人が見るのは無理でも、制約違反として弾かれたものと、前回から悪化したものに絞れば、人が見られる量になります。人が見る対象を減らすのが機械の役割であって、人を外すことが目的ではありません。

ここまでのまとめ

非決定的なのは呼び出しの一段だけ。前後を切り出せば通常のテストで守れる。出力は形式・制約・内容の3層に分け、上2層は決定的に判定する。内容は合否ではなく前回との比較で見る。次回は、その比較の土台になる評価セットの作り方を扱います。

シェア

この記事の感想を教えてください

あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。

シリーズの外も探す:

まずは、現状を聞かせてください。

要件が固まっていなくて大丈夫です。現状診断と方針提案までを無料でお手伝いします。

無料相談フォームへ hello [at] hukuhuku [dot] co [dot] jp