SSD Nodes Learn Hosting plans →
ガイド Matt Connor著者 Matt Connor ・更新日 2026-08-26

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 します。

低価格モデルで判定し、必要な場合だけ最上位モデルへ切り替える

コミットのたびに、すべてのケースを最も高価なモデルで判定すると、評価費用がテスト対象のエージェントの費用を上回ります。判定モデルを価格順に並べ、回答が明確になった時点で処理を止めます。

ChartCost to judge 1,000 eval cases, list prices, August 2026
The data behind this chart
[
  {
    "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 回の判定呼び出しで入力約 1,200 トークン、出力120トークンを使用する前提です。これは、1つの質問、1つの回答、1つの評価基準に対する現実的なサイズです。1,000ケースの判定には、Claude Haiku 4.5 で 1.80 USドル、Claude Opus 5 で 9.00 かかります。差額は小さく見えますが、件数が増えると無視できません。60ケースを毎回のコミットで判定し、週40回コミットすると、夜間ジョブを実行する前から週2,400回の判定呼び出しが発生します。

評価作業には、適用しやすく併用できる割引が2つあります。評価の実行は対話的ではないため、非同期配信を利用する Batch API では入力価格と出力価格が半額になります。これがグラフの1本目の棒です。ルーブリックと指示はすべての呼び出しでバイト単位まで同一なので、プロンプトキャッシュも適しています。キャッシュ読み取りは基本入力価格の10分の1で、5分間のキャッシュ書き込みは基本入力価格の1.25倍です。そのため、キャッシュは1回ヒットすれば元が取れます。これらは2026年8月時点のAnthropicの公開価格です。また、Sonnet 5 は2026年8月31日まで導入価格が適用されるため、その日以降は3本目の棒が高くなります。

判定の段階は次の順序です。

  • すべてのケースで決定論的なチェックを実行します。API費用はかかりません。
  • それらのチェックを通過したケースを、小規模モデルで判定します。
  • 小規模モデルが不合格と判定したケース、または低い確信度で合格と判定したケースだけを、最上位モデルで判定します。
  • 週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"]

この方法では判定精度の一部をコストと引き換えにするため、前提で判断せず、その差を測定します。月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件を保持し、判定モデルまたは判定プロンプトを変更するたびに、その30件で判定者を採点します。判定者には長さのバイアスがあり、冗長な回答ほど合格しやすくなります。また、自己選好により、自分と同じモデルファミリーの出力を甘く評価することがあります。どちらも検証可能です。失敗した回答を水増しして再判定するか、別のモデルファミリーの判定者で同じ回答を評価します。判定者と自分のラベルが10件に1件を超えて食い違う場合、その評価基準は曖昧すぎて使用できません。

評価を行うモデルはどれを選ぶべきですか?

安価なモデルで評価し、必要な場合だけ上位モデルに回します。決定的なアサーションは費用がかからないため、すべてのケースで最初に実行します。明確な合格判定は小型モデルで処理します。失敗と信頼度の低い判定だけを frontier model に回します。2026年8月の定価では、1,000件の判定にかかる費用は Claude Haiku 4.5 で約 1.80 US dollars、Claude Opus 5 で約 9.00 です。評価の実行は非同期で行われるため、Batch API を使うとどちらの場合も費用を半分にできます。

評価は本番監視の代わりになりますか?

いいえ。評価と監視は異なる問いに答えるためです。評価スイートは、これからリリースする変更によって、固定されたケース群の結果が改善したか悪化したかを示します。トレーシングと監視は、どの評価ケースにも含まれていない入力を含め、実際のユーザーが現在どのような処理を行っているかを示します。両者は相互に補完します。トレースから新しいケースを作成し、評価スイートで修正が実際に機能したかを確認します。