第11章 評価とテスト — 解答例
本文: 第11章 評価とテスト
(a) 88% / 4.2 / 91% / 2% / $0.12 — 健全 どの指標にも異常がない。これをベースラインとして固定する(11.10.2節)。次に調べるべきは、①残り12%の失敗ケースの中身(回帰テストセットに追加する候補)、②ホールドアウトセットでも同水準か(開発セットに過学習していないか)、③エージェントを使わない単純な手法と比べて勝っているか(11.10.2節)。健全なときこそ、技術選定そのものを検証する好機である。
(b) 85% / 18.6 / 52% / 8% / $0.94 — 遠回りして偶然たどり着いている 11.3.3節の「完遂率が高い + ツール正確性が低い」に該当する。結果は出ているが軌跡は失敗している(11.2.2節)。ステップ数18.6とコスト$0.94がその代償であり、モデル更新やデータの微変化で簡単に崩れる脆い状態である。
次に調べること: 軌跡評価でどのツールが誤選択されているかを特定する。ツール別エラー率(第7章7.7.2節)、説明文の重複や境界の曖昧さ(7.3.1節)、ツールの粒度(7.2.2節)。あわせて、期待ツール集合の定義が厳しすぎないかも確認する — 順序や回数まで一致を求めていると、正しい代替経路を誤判定している可能性がある。
(c) 41% / 19.8 / 87% / 61% / $1.10 — 停滞、またはタスクが重すぎる 11.3.3節の「完遂率が低い + 上限到達率が高い」。上限到達率61%は突出しており、半数以上が終わらずに打ち切られている。ツール正確性87%が示すとおり、ツール選択は正しい。選べているのに終われないのだから、原因は終了条件か、タスクの規模側にある。
次に調べること: 停滞検知の発火状況(第5章5.4.2節)、同一引数の重複呼び出しの有無、終了条件の設計(5.5節)。上限到達したケースの軌跡を読み、「あと数ステップで終わったのか」「同じ場所を回っていたのか」を分ける。前者なら上限の引き上げ、後者ならタスク分割やアーキテクチャの変更(Planner-Executor など)を検討する。
(d) 79% / 5.1 / 88% / 3% / $0.87 — ステップは少ないのに高い ステップ数5.1に対してコスト$0.87は、(a)の4.2ステップ/$0.12と比べて桁が違う。1ステップあたりの入出力が大きいことを意味する。11.3.3節の「コストだけが増加 → コンテキストが膨らんでいる」に近い。
次に調べること: ステップごとの入力トークン数の推移(第8章8.3.1節)、ツール出力のサイズ(第7章7.4.2節の切り詰めが効いているか)、キャッシュ読み出しの割合(第2章2.2.2節)、推論モデルの effort 設定と思考トークン数(第3章3.3.2節)。完遂率79%も他より低いので、巨大なツール出力が判断を妨げているという仮説も併せて検証する。
read_file に対する単体テストを8つ設計する(最低6つの要求を満たす)。
| # | テスト名 | 検証内容(アサーション) | 対応する設計指針 |
|---|---|---|---|
| 1 | test_行範囲を指定して読める |
start_line=10, end_line=20 で11行分だけ返る。is_error が False |
7.4.1(人間が解釈できる情報を返す) |
| 2 | test_end_lineを省略すると末尾まで読む |
end_line=None でファイル末尾まで返る。既定の挙動が説明文どおり |
7.3.1(説明文と実装の一致) |
| 3 | test_出力に上限があり切り詰めが明示される |
巨大ファイルで len(result.content) <= 上限 かつ「全N行中M行を表示」相当の記述を含む |
7.4.2 / 7.4.3(トークン効率と明示) |
| 4 | test_パストラバーサルを拒否する |
"../../etc/passwd" "/etc/passwd" "link_to_outside" を parametrize。すべて is_error is True |
7.6.6(resolve() 後に検査) |
| 5 | test_存在しないファイルは回復可能なエラーを返す |
is_error is True、受け取ったパスがメッセージに含まれる、近い名前の候補か「一覧を取得するツール」が案内される |
7.5.1(エラーは回復のための情報) |
| 6 | test_空ファイルはエラーではない |
is_error is False、「ファイルは空です」と伝わる。0件は失敗ではない |
7.5.2(エラーの分類) |
| 7 | test_行範囲が不正なら正しい範囲を示す |
start_line=0 / start_line > end_line / ファイル行数を超える指定。is_error is True で、実際の行数がメッセージに含まれ、次に何を指定すべきか分かる |
7.5.1 |
| 8 | test_バイナリファイルを拒否する |
画像等で is_error is True。デコードエラーで例外が漏れず、ToolResult として返る |
7.5.2 |
補足のテスト(余力があれば): test_行番号が付与される(モデルが後続の編集で範囲を指定できる/7.4.1)、test_副作用がない(読み取り専用ツールがファイルを変更しない/第13章13.5.1節)。
import pytest
@pytest.mark.parametrize("path", ["../../../etc/passwd", "/etc/passwd", "link_to_outside"])def test_パストラバーサルを拒否する(path): """セキュリティ境界はテストで固定する(第7章7.6.6節)。 シンボリックリンクを含めるのが要点 — resolve() 前に検査する実装はこれを通す。""" result = read_file(path) assert result.is_error
def test_行範囲が不正なら正しい範囲を示す(tmp_file_10lines): result = read_file(str(tmp_file_10lines), start_line=50) assert result.is_error assert "10" in result.content # 実際の行数が示されている assert "50" in result.content # 受け取った値が示されているimport assert from 'node:assert/strict'import { test } from 'node:test'
// pytest の parametrize に相当する仕組みは node:test にないため、// ループで名前を変えながら同じテストを登録するfor (const path of ['../../../etc/passwd', '/etc/passwd', 'link_to_outside']) { test(`パストラバーサルを拒否する: ${path}`, () => { // セキュリティ境界はテストで固定する(第7章7.6.6節)。 // シンボリックリンクを含めるのが要点 — realpath 前に検査する実装はこれを通す const result = readFile(path) assert.ok(result.isError) })}
test('行範囲が不正なら正しい範囲を示す', () => { // pytest のフィクスチャに相当する仕組みはないため、10行のファイルを自分で用意する const result = readFile(tmpFile10Lines(), 50) assert.ok(result.isError) assert.ok(result.content.includes('10')) // 実際の行数が示されている assert.ok(result.content.includes('50')) // 受け取った値が示されている})要点: 11.5節が強調するとおり、エラーメッセージの内容までアサートすること。4番のシンボリックリンクのケースは特に重要で、resolve() 前に文字列で検査している実装はこれを通してしまう。セキュリティ境界のテストがなければ、次のリファクタリングで静かに壊れる。
(a) 判定プロンプト
3つの技法をすべて適用する。評価手順を明示し(①)、機械判定を前段に置いて判断を分解し(②)、閉じた質問に分解する(③)。
FAITHFULNESS_TOOL = { "name": "record_faithfulness", "description": "忠実性の判定結果を記録する。", "input_schema": { "type": "object", "properties": { "claims": { "type": "array", "description": "回答から抽出した事実的主張と、その根拠の有無。", "items": { "type": "object", "properties": { "claim": {"type": "string"}, "supported": {"type": "boolean"}, "evidence": {"type": "string", "description": "supported が true のとき、文脈からの引用。"}, }, "required": ["claim", "supported", "evidence"], }, }, "score": {"type": "integer", "enum": [0, 1]}, }, "required": ["claims", "score"], },}
JUDGE_PROMPT = """\あなたは回答の「文脈への忠実性」だけを判定します。回答の有用性・網羅性・文章の巧みさは**一切考慮しません**。長い回答が良いわけではありません。
以下の手順で評価してください。
1. <answer> に含まれる**事実的な主張**をすべて列挙する。 意見・依頼・言い換えは主張に含めない。2. 各主張について、**<context> 内に根拠があるか**を Yes / No で判定する。 - 根拠があるなら、その箇所を evidence に引用する(要約せず原文のまま) - 一般常識として正しくても、<context> に書かれていなければ No とする - 部分的に正しく部分的に誤っている主張は No とする3. No が1つでもあれば score = 0、すべて Yes なら score = 1 とする。4. 結果を record_faithfulness ツールで記録する。
<context>{context}</context>
<answer>{answer}</answer>"""const FAITHFULNESS_TOOL: Anthropic.Tool = { name: 'record_faithfulness', description: '忠実性の判定結果を記録する。', input_schema: { type: 'object', properties: { claims: { type: 'array', description: '回答から抽出した事実的主張と、その根拠の有無。', items: { type: 'object', properties: { claim: { type: 'string' }, supported: { type: 'boolean' }, evidence: { type: 'string', description: 'supported が true のとき、文脈からの引用。', }, }, required: ['claim', 'supported', 'evidence'], }, }, score: { type: 'integer', enum: [0, 1] }, }, required: ['claims', 'score'], },}
// Python の str.format に相当する機能がないため、// テンプレート文字列を返す関数として持つ(第6章6.4節と同じ書き方)const JUDGE_PROMPT = (context: string, answer: string): string => `あなたは回答の「文脈への忠実性」だけを判定します。回答の有用性・網羅性・文章の巧みさは**一切考慮しません**。長い回答が良いわけではありません。
以下の手順で評価してください。
1. <answer> に含まれる**事実的な主張**をすべて列挙する。 意見・依頼・言い換えは主張に含めない。2. 各主張について、**<context> 内に根拠があるか**を Yes / No で判定する。 - 根拠があるなら、その箇所を evidence に引用する(要約せず原文のまま) - 一般常識として正しくても、<context> に書かれていなければ No とする - 部分的に正しく部分的に誤っている主張は No とする3. No が1つでもあれば score = 0、すべて Yes なら score = 1 とする。4. 結果を record_faithfulness ツールで記録する。
<context>${context}</context>
<answer>${answer}</answer>`手順②の DAG 的な前段(11.7.2節②)。判定器を呼ぶ前に機械的に落とせるものは落とす。
def judge_faithfulness(context: str, answer: str) -> dict: if not answer.strip(): return {"score": 0, "reason": "回答が空", "by": "rule"} # LLMを呼ぶまでもない if not context.strip(): return {"score": 0, "reason": "文脈が空(忠実性は定義できない)", "by": "rule"} return call_judge(JUDGE_PROMPT.format(context=context, answer=answer))// Python の dict に相当する戻り値の形を、型として示しておくinterface Judgement { score: number reason: string by: string}
// callJudge は API 呼び出しになるため、TypeScript では非同期関数になるasync function judgeFaithfulness( context: string, answer: string): Promise<Judgement> { if (answer.trim() === '') { return { score: 0, reason: '回答が空', by: 'rule' } // LLMを呼ぶまでもない } if (context.trim() === '') { return { score: 0, reason: '文脈が空(忠実性は定義できない)', by: 'rule' } } return callJudge(JUDGE_PROMPT(context, answer))}技法の対応: 手順1〜4が①(評価手順の明示)。前段の機械判定と claims → score の導出が②(判断の分解)。「各主張に根拠があるか(Yes/No)」が③(閉じた質問)。加えて、冗長性バイアス対策として冒頭で「長い回答が良いわけではない」と明示し、生成モデルとは別のモデルで判定する(自己選好バイアス、11.7.3節)。
(b) 較正の手順(11.7.4節)
- 人間のラベルを作る。 実際の本番トレースから50〜100件を抽出する。無作為抽出だけでなく、明らかに忠実な例と、意図的にハルシネーションを混ぜた例を両方含める(すべて正例だと偽陽性率が測れない)。ラベルは2人以上で付け、不一致は議論して基準を文書化する。この文書がそのまま判定プロンプトの改善材料になる。
- 同じデータを判定器にかける。 各ケースを複数回実行し、判定器自身のばらつきも測る(同一入力で判定が割れるなら、その時点で基準が曖昧である)。
- 分類問題として測る。「非忠実」を陽性として、適合率・再現率・一致率を出す。特に**偽陽性(実際は非忠実なのに「忠実」と判定した件数)**を単独で数える。
- 不一致ケースを分析し、プロンプトを改善する。 「一般常識だから根拠不要と判断した」「言い換えを主張と誤認した」といった系統的な誤りが見つかる。手順の文言に例外を追記して再測定する。
- 一致率が7割程度しかないなら、その指標は使わない。 較正できない判定器を信じて意思決定してはならない。
(c) 偽陽性(実際は非忠実なのに「忠実」と判定)のほうが危険である。
理由は非対称性にある。偽陽性は問題を見えなくする。 ハルシネーションを含む回答が「忠実」と判定されて通過すると、その誤りは検出されないままユーザーに届き、しかも評価スコアは良好なので改善の対象にすらならない。防御があるという誤った安心を与える点で、判定器がない状態より悪いとさえ言える。
一方、偽陰性(忠実なのに「非忠実」と判定)はノイズにとどまる。人間がレビューすれば取り除け、コストは増えるが被害は出ない。
したがって、閾値は偽陽性を減らす側に倒す。判定が曖昧なケースは「非忠実」寄りに倒し、人間のレビューに回す。手順3で「部分的に正しく部分的に誤っている主張は No」としたのはこの方針の表れである。
問題点
① 退行に気づけない。 11.1節が明言するとおり、評価がないまま改善を重ねると、何が良くなり何が壊れたか分からないまま複雑さだけが増していく。プロンプトを1行変えて3件直り2件壊れたとき、評価がなければ「直った」としか認識できない。この状態が数週間続くと、どの変更が原因かを遡って特定することは事実上不可能になる。
② 「良くなった」の判断が感覚になる。 エージェントは出力も経路も確率的である。1回試して良かったのは揺れかもしれない(11.4.3節)。第6章6.7.1節のプロンプト改善サイクルも、第7章7.7.4節のツール改善サイクルも、測定を前提に設計されている。評価がなければ、これらの改善手法そのものが使えない。
③ 最も価値のある素材を捨てることになる。 11.4.2節のとおり、回帰テストセットが評価基盤の中で最も費用対効果が高く、それは本番・開発中に起きた失敗の蓄積からしか育たない。プロトタイプ期間は失敗が最も多く発生する時期であり、ここで遭遇する失敗こそが最良のテストケースである。「後で作る」とは、その素材を記録せずに捨てるということである。後から思い出して作り直すことはできない。
(補足として挙げられる論点) 「エージェントを使わない単純な手法に勝っているか」(11.10.2節)を検証しないまま作り込むと、技術選定そのものが誤っていた場合の損失が大きくなる。
最小限の労力での着手
「評価基盤を整える」と考えるから後回しになる。次の3つだけなら今日始められる。
- ツールの単体テストを書く(11.5節)。決定的・高速・無料であり、LLMを一切呼ばない。プロトタイプでも書ける唯一の「普通のテスト」であり、これだけで境界の防御が固定される。
- 手で10件のケースをJSONに書く(11.4.3節は20〜50件を推奨するが、10件でも始める価値がある)。
inputとexpected_tools、完遂の客観的な判定条件だけでよい。ツールに依存しない形(JSON)で持つ(11.9節の注意)。 - 11.9.1節の
run_evalをそのままコピーする。 40行に満たない。完遂率・ツール正確性・平均ステップ・平均コストと失敗ケースのIDが出れば、プロンプト変更の影響を測るには十分である。
そのうえで、「本番で失敗したケースを見つけたら、必ず評価セットに追加してから直す」という規律だけを守る。11.4.2節が述べるとおり、これだけでセットは自然に育つ。
(a) 考えられる原因
① 甘い判定(11.7.3節)。 LLM判定は全体に高いスコアをつける傾向がある。明確な減点条件が書かれていない判定プロンプトは、ほとんどのケースで0.8〜0.95に収束する。スコアが常に0.9前後で「安定していた」こと自体が症状である — 判別力のない判定器は、良い出力にも悪い出力にも同じスコアを出すため、当然安定して見える。
② 自己選好バイアス。 生成と判定に同じモデルを使っていると、自分が生成した出力を高く評価する。参照なし判定では比較対象がないため、このバイアスがそのままスコアに乗る。
③ 基準の曖昧さと冗長性バイアス。 「回答の品質を0〜1で評価してください」のような漠然とした基準だと、判定は「もっともらしく書かれているか」に引きずられる。流暢で長い回答は、内容が誤っていても高く評価される(第13章13.8.2節の「流暢さは正しさの証拠ではない」と同じ構図である)。
④(バイアス以外の重要な原因) そもそも測っている次元が違う可能性がある。11.8.1節のとおり、自動評価は「測ると決めた指標」しか見ない。判定器が忠実性を測っている一方で、苦情の原因はレイテンシ、トーン、あるいは「そもそも質問に答えていない」ことかもしれない。スコアと苦情が無関係なら、それは判定器の失敗ではなく指標の選択の失敗である。
(b) 行うべきだった検証
判定器そのものを較正する(11.7.4節)。これが行われていない。具体的には、
- 人間のラベル50〜100件との一致率を測る。 特に偽陽性率 — 実際は悪いのに0.9と判定した割合。これが高ければ、この判定器は本番監視に使えない。
- 既知の悪い出力を意図的に投入して、スコアが下がることを確認する。 ハルシネーションを含む回答、質問に答えていない回答、途中で切れた回答を入れて0.9のままなら、判定器は機能していない。これは判定器の動作確認テストであり、導入時に必ず行うべきものである。
- スコアの分布を見る。 平均だけでなくヒストグラムを見る。全件が0.85〜0.95に収まっているなら、判別力がないことがその時点で分かる。
- ユーザーの否定的フィードバックとスコアの相関を見る。 苦情のあったケースのスコアが0.9なら、両者は無関係である。これが最も直接的な検証である。
(c) 自動評価だけに依存しない体制(11.8節)
① 継続的なサンプリングレビュー。 本番トラフィックから毎日一定数を無作為抽出して人間が見る。無作為抽出が重要で、低スコアのものだけを見ていると、今回のような「高スコアの失敗」は永遠に見つからない。
② 優先度をつけた重点レビュー。 自動評価で低スコアだったもの、ユーザーからの否定的フィードバックがあったものは必ず見る。今回のケースでは、苦情が増えていた時点でここが機能していれば早期に検出できた。
③ 人間評価と自動評価をつなぐ回路。 レビューで見つかった失敗を必ず回帰テストセットに追加する(11.4.2節)。同時に、その失敗ケースを判定器にかけて高スコアが出るなら、判定プロンプトの改善材料にする。人間のレビューは、システムの評価と判定器の評価を同時に行っている。
④ フィードバックの収集設計(11.8.3節)。明示的な良い/悪いのボタンに加え、暗黙的な信号(やり直した、途中で止めた)とエスカレーション率を取る。エスカレーション率は解釈の余地が少なく、苦情より早く動く先行指標になりうる。
⑤ ドリフトの検出(第12章12.5.3節)。指標を時系列で追い、週次で比較する。今回の「スコアは横ばい、苦情は増加」という乖離は、両方を並べて見ていれば数字として現れる。
→ 参照: 11.10.3節、11.10.2節、11.4.2節、第2章2.6節
移行判断の評価計画
1. 前提を揃える。 新旧の比較は、同一のプロンプト・同一のツール定義・同一のデータセットで行う。過去に記録した旧モデルの数値と比較してはならない — その間にプロンプトもツールも変わっている。ベースラインは、現行スナップショットで取り直す。
2. データセット(11.4.2節)
| セット | 役割 |
|---|---|
| 回帰テストセット(全件) | 過去に直した失敗が新モデルで再発しないこと。最も重要 |
| 開発セット(全件) | 全体的な品質の増減 |
| レッドチームの攻撃データセット(第13章13.7.3節) | 安全性の退行がないこと |
| 公平性テスト(第13章13.6.2節③) | 属性による判断の差が拡大していないこと |
| ホールドアウトセット | 切り替え直前の最終確認にだけ使う。ここで初めて開く |
3. 測る指標(11.3節)
- 品質: タスク完遂率、ツール正確性、引数正確性、出典の正しさ
- 軌跡: 平均ステップ数、上限到達率 — 完遂率が同じでもステップが増えていれば退行である(11.2.2節)
- 運用: 1タスクあたりコスト、レイテンシ P50 / P95、
max_tokensによる打ち切り率 - 安全性: 攻撃データセットで成功した攻撃の件数
ばらつきの把握が前提になる(11.4.3節)。新旧それぞれを同じ設定で複数回実行し、揺れの幅を先に測る。揺れが±3%なら、2%の差は差ではない。
4. 切り替えの判断基準
- 回帰テストセットで新規の失敗がゼロ。 これは必須条件とする。過去に直した失敗が再発するなら、理由が何であれ切り替えない。
- 開発セットの完遂率が、ベースライン − 揺れの幅 を下回らない。
- コストと P95 レイテンシが許容範囲内。安くて速くても品質が落ちるなら、判断は業務要件による。
- 攻撃データセットで新たに成功する攻撃がない。安全性の退行は品質の向上で相殺できない。
- 上記を満たしたうえで、ホールドアウトセットで確認する。
平均値だけで判断しない。 全体スコアが同じでも、成功していたケースと失敗していたケースが入れ替わっていることがある。ケース単位で新旧の差分を出し、「新たに失敗したケース」を必ず目で見る。ここが評価の中で最も情報量が多い。
5. 退行が見つかった場合
- まず切り替えない。 第2章2.6節のとおり固定スナップショットを指定しているので、期限まで現行を使い続けられる。
- 退行の性質を分析する。 プロンプトが旧モデルに過適合していた可能性がある(出力形式の癖、effort の設定、思考の扱い方)。プロンプト側の調整で回復するかを試す — 多くの退行はここで解消する。この作業自体、評価セットがあるから可能である。
- 回復しないなら、タスク種別ごとの部分移行を検討する。全面移行は二値の判断ではない。
- 切り替える場合も、カナリア方式で本番トラフィックの数%から始め、第12章12.5節の指標(完遂率、エスカレーション率、コスト)を監視する。評価セットは本番分布の一部でしかない。
- ロールバック手順を用意する。 モデルIDは設定で切り替えられるようにし、コードに埋め込まない。
- トレースに
gen_ai.request.modelとgen_ai.response.modelを記録しておく(第12章12.4.3節)。切り替え後に問題が出たとき、どのスナップショットでの失敗かを事後に切り分けられる。
Built with Astro ・ Deployed on Cloudflare Pages
© 2026 watakumi — made with 💜 & ☕ ・watakumi.page