AIエージェントのセルフホスト型評価の作り方
実際のトレースからケースを作り、決定的チェック、LLM判定、commitごとの合格率をSQLiteで追跡する評価ループを数百行のPythonで構築します。
AI エージェント向けのセルフホスト型評価とは
AI エージェント向けのセルフホスト型評価は、自分のリポジトリで管理する 4 つの要素です。保存したケースのファイル、ケースに対してエージェントを実行するスクリプト、各回答を採点するチェック群、そしてクエリ可能な結果テーブルです。この一覧にベンダーは必要ありません。ループ全体は数百行の Python と 1 つの SQLite ファイルで構成できます。
デモでエージェントが動作したのは、入力を自分で 5 件選んだからです。2 週目に壊れたのは、プロンプトの 1 行、モデル、またはツールの説明が変更されたにもかかわらず、その影響を測定する仕組みがなかったためです。評価ループを使うと、「今は悪くなった気がする」という状態を、「commit 4f1c9ab で合格率が 60 件中 58 件から 60 件中 51 件に低下した」という測定結果に変えられます。
このループには 4 つの手順があります。このガイドでは、各手順を 1 つのセクションで説明します。実際のトレースを収集し、興味深いものをケースに昇格し、変更のたびにすべてのケースを採点し、合格率をその結果を生成した commit の隣に保存します。エージェントを何に対して実行する場合でも同じループを使えます。セルフホストで実行する価値のあるエージェントフレームワークの違いは、主にトレースをどの程度標準で提供するかです。
2 週目にエージェントが壊れる理由
エージェントは、プロンプト、モデル、ツール定義、実行時に取得されるコンテキストで構成されます。この 4 つはアプリケーションコードを変更しなくても変わるため、通常のコードレビューでは問題を指摘できません。
最も一般的な原因は、プロンプトの編集です。失礼な応答を止めるために、1 文追加したとします。その文によって、再テストしていない入力への動作が変わります。トレースにはその差が明確に現れます。同じ質問に対する先週のトレースには create_refund ツール呼び出しがありますが、今週のトレースにはなく、代わりに丁寧な謝罪が返ります。エラーは発生していないため、アラートも発報されません。
2 つ目の原因はモデルです。実行ごとに送信した正確なモデル文字列を、claude-haiku-4-5-20251001 のように記録してください。頭の中だけで管理している略称では不十分です。モデルを切り替えた日に成功率が低下しても、行データにモデルが記録されている場合にのみ原因を診断できます。
3 つ目はツールです。ツールの説明を書き換えると、モデルが呼び出しを判断するタイミングが変わります。VPS 上で稼働する MCP servers 経由でツールを受け取っている場合、スキーマは別のプロセスに存在します。そのため、リポジトリに差分がまったくなくても、スキーマが変更される可能性があります。4 つ目は検索取得です。同じ質問でも、一晩のうちに再構築されたインデックスに対して検索され、回答は新しいドキュメントに基づくものになります。
すでに収集しているトレースから基準セットを作成する
評価ケースを作り上げないでください。実際のトラフィックから取得します。すでにエージェント用のセルフホスト Langfuse トレーシングを実行している場合、各リクエストには入力、ツール呼び出し、出力が保存されています。これはケースに必要な元データそのものです。
公開 API から、一定期間内の root オブザベーションをエクスポートします。Basic 認証を使用し、ユーザー名には公開キー、パスワードにはシークレットキーを指定します。
export LF_HOST="https://langfuse.example.com"
curl -sS -u "$LF_PUBLIC_KEY:$LF_SECRET_KEY" \
"$LF_HOST/api/public/v2/observations?limit=50&isRootObservation=true&fromStartTime=2026-07-01T00:00:00Z" \
| jq '.data[0]'解析コードを書く前に、1 件のレコードを読み取ります。データ行は data の下に返されます。ただし、質問と応答を保持するフィールド名は、エージェントがスパンを計測する方法によって異なります。想定した名前ではなく、実際に確認した内容に基づいて対応付けてください。その後、ケースを手作業で作成し、evals/cases.jsonl に 1 行 1 JSON オブジェクトの形式で記述します。
{"id": "refund-double-charge", "tags": ["smoke"], "input": "I was charged twice for order 41822.", "must_call": ["lookup_order", "create_refund"], "must_not_include": ["I cannot help"], "rubric": "The reply confirms exactly one refund for order 41822 and states the amount."}次の 5 つのルールにより、実行する価値のあるセットを維持できます。
- 最初は 40 ~ 80 ケースで十分です。20 件未満では、1 件の不安定なケースによって合格率が 5 ポイント変動し、理由なく数値が変動していると見なされて無視されます。
- 本番環境で修正したバグは、修正した当日にケースにします。この習慣により、セットが適切な方向に増えていきます。
- 1 ケースにつき 1 つの動作だけを確認します。返金額と応答のトーンを同時に確認するケースでは、失敗しても何が原因か分かりません。
idは決して変更しません。id によって、今回の実行結果を先月の実行結果と比較できるためです。- コミットする前に編集して秘匿情報を削除します。このファイルは git に格納するため、顧客名と、自分が所有していない注文番号をすべて削除してください。
まず決定的なチェックで評価します。これらは無料で実行できるためです。
正解が明確な項目には、単純なアサーションを使います。モデル呼び出しは不要で、費用も曖昧さもありません。決定的なチェックで構造上のリグレッションを検出できます。こうした問題は、エージェントを取り巻くシステムを壊します。JSON を解析できない、ツールが一度も呼び出されていない、禁止されたフレーズが再び出力される、回答に出典がない、といった問題です。
エージェントを把握する関数は1つだけにします。ハーネス内のその他の処理は汎用化します。
import json, os, urllib.request
def run_agent(case):
req = urllib.request.Request(
os.environ["AGENT_URL"],
data=json.dumps({"input": case["input"]}).encode(),
headers={"content-type": "application/json"},
)
with urllib.request.urlopen(req, timeout=120) as resp:
return json.load(resp)
def deterministic(case, result):
text = result.get("output", "")
called = [c["name"] for c in result.get("tool_calls", [])]
failures = []
for tool in case.get("must_call", []):
if tool not in called:
failures.append(f"tool not called: {tool}")
for phrase in case.get("must_not_include", []):
if phrase.lower() in text.lower():
failures.append(f"forbidden phrase: {phrase}")
if len(called) > case.get("max_tool_calls", 12):
failures.append(f"too many tool calls: {len(called)}")
return failuresツールの使用回数も、その一覧に含めます。あるケースを今日3回の呼び出しで解決できても、明日11回必要になった場合、最終回答が正しくてもリグレッションです。エージェントが行う呼び出しごとに費用が発生するためです。
LLM as judge と、失敗する 4 つのパターン
アサーションを通過したものには、内容を読む grader が必要です。LLM judge は 2 回目のモデル呼び出しです。質問、agent の回答、1 つの基準を受け取り、判定を返します。「回答がユーザーの質問に答えているか」を評価する現実的な方法は、これだけです。
judge を実用的にするルールは 4 つあります。
- 判定は 2 値にし、1 から 10 のスコアにはしません。段階評価では、ほとんどすべてに 7 や 8 が返されます。そのため数値が変化せず、そこから何も学べません。
- 1 回の呼び出しにつき、基準は 1 つにします。返金額について尋ねるか、トーンについて尋ねます。両方を同時に尋ねてはいけません。
- ケースに期待される回答がある場合は、必ず judge に渡します。抽象的に採点するより、参照回答と照合して採点するほうがはるかに容易です。
- 出力形式を固定し、厳密に parse します。
from anthropic import Anthropic
client = Anthropic() # reads ANTHROPIC_API_KEY from the environment
def judge_prompt(case, output):
return (
"You grade one answer against one criterion.\n"
"Reply with JSON only, in this exact shape:\n"
'{"verdict": "pass", "confidence": "high", "reason": "one short sentence"}\n'
f"Criterion: {case['rubric']}\n"
f"Question: {case['input']}\n"
f"Answer: {output}\n"
"Length is not a criterion. Judge only the criterion above."
)
def judge(case, output, model):
msg = client.messages.create(
model=model,
max_tokens=200,
messages=[{"role": "user", "content": judge_prompt(case, output)}],
)
return json.loads(msg.content[0].text)ここからは失敗モードです。それぞれに、今日の午後に実行できるテストがあります。テストは重要です。検証していない judge は、精密に見えて意味のない数値を生成するためです。
Length bias。 回答が長いほど合格しやすくなります。テスト方法は、judge が不合格にした回答を 10 件選びます。それぞれに、新しい事実を追加しない自信ありげな定型文を 2 段落追加し、もう一度判定させます。合格に反転した判定があれば、それは length bias です。修正すべきなのは rubric です。
Self-preference。 judge は、自分と同じ model family の出力を、別の family の出力より好意的に評価することがあります。テスト方法は、同じ 30 件の回答を 2 つの異なる family の judge で評価し、ケースごとに判定を比較します。判定が一致しないケースは、自分で内容を確認します。
Position bias。 judge を使って 2 つの回答 A と B を比較する場合は、順序を入れ替えてもう一度実行します。入れ替えによって判定が反転するなら、その rubric ではまだペア比較を安全に使用できません。
Rubric drift。 曖昧な基準では、何でも合格にする judge が生まれます。「回答は役立つか」では、ほとんどすべてが合格します。「回答は返金額をドル単位で示しているか」なら、意図した内容だけが合格します。確認する事実を明示するまで、すべての基準を書き直します。
4 つすべてを防ぐ対策が 1 つあります。手作業でラベル付けした 30 件のケースを保持し、judge model または judge prompt を変更するたびに、そのラベルを基準として judge の精度を測定します。10 件に 1 件を超えて自分の判定と異なる場合は、生成された合格率を信頼する前に rubric を修正します。judge は code なので、code と同じように version 管理し、review します。
低コストのモデルで判定し、最先端モデルへエスカレーションする
すべてのコミットで、すべてのケースを最も高価なモデルで判定すると、評価費用がテスト対象のエージェント自体の費用を上回ります。判定モデルを価格順に並べ、回答が明確になった時点で停止します。
このグラフのデータ
[
{
"label": "Haiku 4.5, Batch API",
"usd_per_1000_judge_calls": "0.90"
},
{
"label": "Haiku 4.5",
"usd_per_1000_judge_calls": "1.80"
},
{
"label": "Sonnet 5",
"usd_per_1000_judge_calls": "3.60"
},
{
"label": "Opus 5",
"usd_per_1000_judge_calls": "9.00"
}
]これらの数値は、判定呼び出しごとに入力トークン約 1,200、出力トークン 120 を想定しています。これは、1 つの質問、1 つの回答、1 つの評価基準を処理する場合の現実的な規模です。1,000 ケースを判定すると、Claude Haiku 4.5 では 1.80 US dollars、Claude Opus 5 では 9.00 かかります。差額は小さく見えますが、積み重なると無視できません。60 ケースのセットをすべてのコミットで判定し、週 40 回コミットすると、夜間ジョブを実行する前から週 2,400 回の判定呼び出しになります。
評価処理には、きれいに適用できる割引が 1 つあります。評価実行は対話的ではないため、Batch API を使うと、非同期で結果を受け取る代わりに入力価格と出力価格が半額になります。これはグラフの 1 行目に示した価格です。Prompt caching は、この規模の判定には役立ちません。キャッシュの倍率自体は実際に適用されます。5 分間のキャッシュ書き込みは基本入力価格の 1.25 倍、キャッシュ読み取りは 10 分の 1 です。ただし、プロンプトのキャッシュ対象部分には最小長があります。Claude Haiku 4.5 では 4,096 トークン、Claude Sonnet 5 では 1,024 トークン、Claude Opus 5 では 512 トークンです。1,200 トークンの判定呼び出しは Haiku の下限を大きく下回ります。API はエラーを返さず、キャッシュなしで短いプロンプトを処理するため、キャッシュ書き込みもキャッシュヒットも発生せず、節約もありません。Opus 5 では、少なくとも 512 トークンの共有プレフィックスだけが対象になります。しかし、judge_prompt の固定指示はそれよりかなり短く、ケースごとに評価基準も変わります。これらは 2026 年 10 月時点の Anthropic の定価です。
判定の段階は次の順序です。
- すべてのケースで決定的チェックを実行する。API 費用は一切かからない。
- 決定的チェックを通過したケースを小規模モデルで判定する。
- 小規模モデルが fail と判定したケース、または低い確信度で pass と判定したケースだけを、最先端モデルで判定する。
- 週 1 回、少数のサンプルを人手で確認する。
CHEAP = "claude-haiku-4-5-20251001"
STRICT = "claude-opus-5"
def grade(case, result):
hard = deterministic(case, result)
if hard:
return False, "deterministic", "; ".join(hard)
first = judge(case, result["output"], CHEAP)
if first["verdict"] == "pass" and first["confidence"] == "high":
return True, CHEAP, first["reason"]
second = judge(case, result["output"], STRICT)
return second["verdict"] == "pass", STRICT, second["reason"]2026 年 10 月時点で、Anthropic はこの 2 つの判定モデルを、引き続き利用できるレガシーモデルとして掲載しています。非推奨化のページでは、Claude Haiku 4.5 の提供終了日を 2026 年 10 月 15 日より前ではない日付としています。そのため、安価な判定モデルを長期間利用する前に、このページを確認してください。また、判定モデルの交換は他の変更と同じように扱い、まず手動でラベル付けした 30 ケースを再実行してください。
この方法では、コストを下げる代わりに判定精度の一部を失います。したがって、想定で済ませず、トレードオフを測定してください。月に 1 回、厳格な判定モデルでもセット全体を評価し、2 つの結果列を比較します。数ケースを超えて不一致がある場合、小規模モデルには評価基準が緩すぎます。修正すべきなのは評価基準です。エージェント自体の支出を制御する作業は別であり、VPS 上の AI エージェントのコスト制御 で説明しています。
所有するシステムで合格率の推移を追跡する
コミットと結び付けられない合格率は、単なる印象にすぎません。実行ごと、ケースごとに1行を保存し、その行にコミットとモデルを記録します。
CREATE TABLE IF NOT EXISTS results (
run_id TEXT NOT NULL,
ran_at TEXT NOT NULL,
git_sha TEXT NOT NULL,
agent_model TEXT NOT NULL,
case_id TEXT NOT NULL,
passed INTEGER NOT NULL,
graded_by TEXT NOT NULL,
reason TEXT
);SELECT run_id, git_sha, agent_model,
count(*) AS cases,
round(100.0 * sum(passed) / count(*), 1) AS pass_pct
FROM results
GROUP BY run_id
ORDER BY ran_at DESC
LIMIT 10;sqlite3 evals/results.db < evals/schema.sqlでスキーマを読み込み、sqlite3 -box evals/results.db < evals/passrate.sqlで推移を確認します。60ケースを毎日実行する場合、1年間で約22,000行になるため、保存先自体が独立したプロジェクトになることはありません。VPSでSQLiteを本番運用するでは、このファイルを複数のマシンで共有する場合に重要になる設定を説明しています。
runner は、同じ情報を人が読める形式でも出力します。
run 2026-08-05T09:14:22Z sha 4f1c9ab model claude-sonnet-5 58/60 pass (96.7%)
FAIL refund-double-charge deterministic: tool not called: create_refund
FAIL pto-policy-question judge(opus): reply gives no dollar amountエージェントに影響する変更に対してスイートを実行します。対象は、リポジトリ内のすべてのコミットではなく、プロンプトの編集、モデルの変更、ツールの変更など、エージェントを壊す可能性がある変更です。高速なサブセットには pre-push hook を使用します。
cat > .git/hooks/pre-push <<'EOF'
#!/bin/sh
python3 evals/run.py --set smoke || exit 1
EOF
chmod +x .git/hooks/pre-push完全な実行は時間がかかるため、スケジュールで実行します。夜間に VPS上のsystemd serviceとtimer でデプロイ済みのプロンプトに対して全セットを実行すると、リポジトリ外から入る変更も検出できます。たとえば、ホスト型ツールの動作が変更された場合です。
人手によるレビューは、全件ではなくサンプリングで行う
judge は人手によるラベルを基準に調整するため、誰かがラベルを作成する必要があります。毎週、サンプルを読みます。judge が判定を誤った全ケースに加え、合格と判定されたケースから無作為に10件を選びます。無作為に選ぶ合格ケースが重要です。judge が悪い回答をひそかに合格させ始めても、judge 自身の判定で作成したダッシュボードでは完全に見えるためです。
1ケース3分で15ケースなら、週45分です。その結果、あなたと judge の判定が一致しない箇所の評価基準を修正でき、誰も想定していなかった失敗の種類に対する新しいケースも追加できます。人手による判定を同じテーブルに、graded_by を human に設定して記録します。judge と人手の一致を、記憶ではなくクエリで確認できるようになります。
評価ハーネス自体で起きる問題
anthropic.RateLimitError(初回の全件実行時)。 60 件を一度に並列実行すると、利用中の tier のリクエスト上限またはトークン上限を超えます。同時実行数を 4 worker に制限し、夜間実行は Batch API に移します。
json.JSONDecodeError: Expecting value: line 1 column 1 (char 0)(judge からの応答)。 モデルが文章で回答したか、JSON を code fence で囲んで返しました。1 回だけ再試行し、その後も失敗した場合は該当ケースをエラーとして記録します。解析失敗を pass として扱ってはいけません。エラーを pass に変換する suite は、agent の品質が低下していても 100% に近づくためです。
不安定なケース。 agent が出力をサンプリングするため、同じ入力でも実行ごとに pass したり fail したりします。不安定なケースは 3 回実行し、削除せずに pass の割合を記録します。3 回中 2 回 pass するケースも実際の堅牢性のバグであり、顧客が見つけます。
Golden set の劣化。 suite を green にするために、誰かが期待される回答を編集することがあります。evals/cases.jsonl への差分は agent への差分と同じように慎重にレビューします。そのファイルが、正しい動作を定義する文書だからです。
一度も fail しない suite。 1 か月間 pass rate が 100% のままなら、その set は製品の変化を追跡できなくなっています。直近の trace を 10 件取り出し、agent の処理が悪かったものを探して追加します。次に、意図的に何かを壊して実行結果が red になることを確認します。これが test suite に対する mutation testing の適用の確認であり、set が今も十分な検出力を持つことを知る唯一の方法です。
FAQ
AI エージェントの評価セットには何ケース必要ですか?
40~80 ケースから始め、実際の失敗をもとにセットを増やしてください。20 ケース未満では、1 件の不安定な結果によって合格率が 5 ポイント変わるため、数値から得られる情報が不足します。数百ケースを超えると、実行のたびに実際の費用と時間がかかる一方、追加したケースによるカバレッジの増加は小さくなります。重要なのはケース数ではありません。既知の本番障害の種類のうち、少なくとも 1 回セットに現れる種類の割合です。
LLM の判定モデルを信頼してエージェントを評価できますか?
自分で付けたラベルとの比較測定を行った後に限り、信頼できます。手動で評価した 30 ケースを保持し、判定モデルまたは判定プロンプトを変更するたびに、そのケースで判定モデルを評価してください。判定モデルには長さのバイアスがあります。内容を水増しした回答ほど合格しやすくなる傾向です。また、自己選好もあります。同じモデル系列が生成した出力を、より好意的に評価する傾向です。どちらも検証できます。失敗した回答に内容を追加して再判定するか、別のモデル系列の判定モデルで同じ回答を評価してください。判定モデルが手動ラベルと 10 ケースに 1 ケースを超えて食い違う場合、その評価基準は曖昧すぎて使用できません。
評価を担当するモデルはどれにすべきですか?
低コストのモデルで判定し、必要な場合だけエスカレーションします。決定的アサーションには費用がかからないため、すべてのケースで最初に実行します。明確に合格するケースは小規模モデルで処理します。fail と低確信度の判定だけを、最先端モデルに渡します。2026 年 10 月の定価では、1,000 ケースの判定費用は Claude Haiku 4.5 で約 1.80 US dollars、Claude Opus 5 で約 9.00 です。評価実行は非同期であるため、Batch API を使うとどちらの金額も半額になります。
評価は本番監視の代わりになりますか?
いいえ。評価と本番監視は異なる問いに答えるためです。評価スイートは、これからリリースする変更によって、固定されたケースセットの結果が改善するか悪化するかを示します。トレーシングと監視は、どのケースにも含まれていない入力を含め、実際のユーザーが現在どのような問題に遭遇しているかを示します。両者は相互に活用できます。トレースから新しいケースを作成し、評価スイートで修正が実際に機能したかを判定します。