ここからは、これまでの前提が崩れる領域に入ります。同じ入力なら同じ出力という前提です。 を組み込んだ機能では、表現は毎回揺れますし、モデルを更新すれば傾向も変わります。この に、従来のテストをそのまま当てても機能しません。
結論を先に言うと、検証を諦めるのではなく、検証の問いを変えることになります。「毎回同じか」ではなく「許容範囲に入っているか」を見る。この回はその組み立て方を扱います。
1. まず、非決定の範囲を狭くする
テストの話に入る前に、設計の話をします。非決定的な部分が広いほど、検証は難しくなります。逆に言えば、範囲を狭く閉じ込めれば、大半は従来どおりのテストで守れます。
| 処理 | 決定的にできるか | テストの方法 |
|---|---|---|
| 入力の整形・前処理 | できる | 通常の |
| プロンプトの組み立て | できる | 生成された文字列を検証する |
| モデルの呼び出し | できない | 本回で扱う方法 |
| 出力の形式検証(型・必須項目) | できる | 通常の 単体テスト |
| 出力を使った後処理・保存 | できる | 通常の 単体テスト |
この表が示すとおり、本当に非決定的なのは一段だけです。残りは普通のコードなので、普通にテストできます。ところが実装が一体になっていると、全体が非決定的な塊として扱われ、テストが書けない状態になります。呼び出しを境界として切り出すだけで、テストできる範囲が大きく広がります。
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層で捕まえられます。形式が崩れて後段が落ちる、参照していない出典を出す、社内で禁止している表現が混ざる ── いずれも内容の良し悪しを判断せずに検知できます。
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. 内容の検証は「一致」ではなく「観点」で
残るのが内容の層です。ここで完全一致は使えません。代わりに使えるのは、観点ごとの採点です。「正しいか」という大きな問いを、判定可能な小さい問いに割ります。
- 質問に答えているか — 話題がずれていないか
- 与えた文書の範囲に収まっているか — 外部の知識を混ぜていないか
- 必要な要素が含まれているか — 金額、期限、条件など、落ちてはいけない項目
- 含めてはいけないものが無いか — 個人情報、断定表現、推測
「必要な要素が含まれているか」は、キーワードの包含という単純な方法でかなり判定できます。表現が揺れても、金額や日付といった要素は文字列として現れるためです。ここを機械判定に落とせると、大量に回せます。
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回通った / 落ちたには情報がほとんどありません。同じ入力を複数回試して通過率で見る、あるいは件数を増やして全体の割合で見る。単発の結果で判断すると、揺れをそのまま不具合と誤認します。
5. 実行頻度とコストの設計
内容の層はモデルを呼ぶため、実行するたびに費用と時間がかかります。毎回のプルリクエストで全件を回す構成は現実的ではありません。頻度を層に応じて分けます。
| 層 | モデル呼び出し | 実行タイミング |
|---|---|---|
| 形式・制約(決定的) | 不要 | 毎回のプルリクエスト |
| 内容(小さい評価セット) | 必要 | プロンプト変更時 |
| 内容(全評価セット) | 必要 | 定期実行 / モデル更新時 |
決定的な層をモデル呼び出しなしで回せる構成にしておくことが、そのままコスト設計になります。EP.1 で書いた「非決定的な部分を狭く閉じ込める」は、品質のためだけでなく費用のためでもあるわけです。 全体の費用については EP.10 で扱います。
6. どこまでを機械に任せるか
最後に線引きの話をします。内容の妥当性を別のモデルに採点させる方法(いわゆる judge)もあり、規模を出すには有効です。ただし採点する側も非決定的である点は忘れないほうがよい。採点結果を絶対視すると、検証の土台自体が揺れます。
現実的な落としどころは、機械で絞って人間が確認する構成です。全件を人が見るのは無理でも、制約違反として弾かれたものと、前回から悪化したものに絞れば、人が見られる量になります。人が見る対象を減らすのが機械の役割であって、人を外すことが目的ではありません。
非決定的なのは呼び出しの一段だけ。前後を切り出せば通常のテストで守れる。出力は形式・制約・内容の3層に分け、上2層は決定的に判定する。内容は合否ではなく前回との比較で見る。次回は、その比較の土台になる評価セットの作り方を扱います。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。