公開ベンチマークだけでLLMを選んでいませんか?——判別力のある評価テストを自作する方法
はじめに
「このタスクにはどのLLMを使うべきか」を判断するとき、多くの場合は公開ベンチマークのスコアやリーダーボードを参考にします。しかし、実際に自社のユースケース(データ分析・要約・コードレビューなど)で候補モデルを比較しようとすると、次の壁にぶつかります。
- 公開ベンチマークは汎用タスク向けで、自社の具体的な業務内容とは条件が異なる
- いざ自分たちで比較テストを組んでも、候補モデルが軒並み満点でタイになり、優劣が判別できない
Amanityでは、ローカルLLM(Ollama経由)とクラウドAPI(Gemini)のどちらを業務に採用すべきかを検証する過程で、まさにこの壁に直面しました。この記事では、そこから作り上げた「判別力のある評価テストの自作方法」を、実際に踏んだ失敗と合わせて公開します。
汎用手順 — 判別力のある評価テストを自作する8ステップ
まず結論から示します。自作の評価ハーネスは、扱うタスクの種類(データ分析・要約・コードレビューに限らず、分類・抽出・翻訳など「正解が定義できる」タスクであれば何にでも)を問わず、次の8ステップに一般化できます。
- 評価したい業務・タスクを1つ具体的に決める——「なんとなく文章生成タスク」ではなく、「月次売上CSVから異常値を検出する」のように、候補モデルに任せたい業務を1つに絞ります。
- そのタスクの「何が正解か」を自分(人間)が判定できる状態にする——正解付きテストケースを設計します。ここで正解を定義できないタスクは、そもそも自動評価が成立しません。
- 正解値・正解基準は手計算せず、独立したロジックで生成・検算する——テストデータ生成に使ったパラメータをそのまま正解として転記せず、生成済みデータから別ロジックで再計算します。
- 候補モデル全部に、同一のテストケース・同一条件で実行する——プロンプト・入力データ・temperature等の実行条件をモデル間で完全に揃えます。
- 正解キーの項目を1つずつ機械的に照合する——出力を通読して「大丈夫そうか」で判定せず、正解キーの項目数だけループを回してYes/Noを機械的に判定します。
- 候補モデルが軒並み満点でタイになったら「天井効果」と判断する——判別力がない状態です。ノイズを減らして紛らわしくする、トラップを混ぜる、複数ステップの推論を要求する、という原則で難易度を上げたテストを作り直します。
- モデル間で明確な差が出るまでStep4〜6を繰り返す——1回の難易度アップで差が出なければ、さらに上げます。旧バージョンは最低品質の確認用として残します。
- 結果をもとに、どの業務にどのモデルを使うか判断する——全ドメイン・全項目で最強のモデルを探すのではなく、「このタスクにはこのモデルで十分」という業務単位の判断材料にします。
以降のセクションでは、このStep2〜8を実際にデータ分析・要約・コードレビューの3ドメインでどう具体化したか、そして運用上どんな落とし穴を踏んだかを、実例とともに詳しく解説します。
評価の設計思想(Step2〜3の実践)— 正解付きテストデータ + 自動照合
ここからは、上記のStep2(正解付きテストケースの設計)とStep3(独立ロジックでの検算)を、データ分析ドメインの実例でどう実装したかを見ていきます。
なぜ正解データを自作するのか
既存の実データをテストに使うと、「モデルの答えが合っているかどうか」を判定する基準そのものを人間が都度作らなければならず、評価が属人的になります。そこで採用したのが、テストデータそのものを正解付きで自作するという方針です。
- 難易度を自在に調整できる(後述するエスカレーション設計に必須)
- 生成する側が正解を保証できるため、採点基準がぶれない
- 「モデルが事実にない内容を捏造していないか」も機械的にチェックできる
正解値はスクリプトで計算し、独立ロジックで検算する
ここで重要なのは、正解データを手計算しないことです。このプロジェクトの本質は「LLMの計算ミスを見つける」ことなので、もし自分たちが用意した正解データ自体に手計算ミスが含まれていたら本末転倒です。データ生成に使ったパラメータを直接答えとして転記するのではなく、生成済みの行データから独立したロジックで再集計します。
以下は、架空の売上データ(データ分析ドメイン)で使った生成・検算スクリプトを簡略化したものです。
import random
random.seed(7)
PRODUCTS = ["ランプ", "チェア", "デスク"]
MONTHS = [f"{y}-{m:02d}" for y in (2024, 2025) for m in range(1, 13)]
base_units = {"ランプ": 120, "チェア": 60, "デスク": 30}
growth = {"ランプ": 1.015, "チェア": 1.008, "デスク": 1.02}
price = {"ランプ": 3200, "チェア": 18000, "デスク": 42000}
PRICE_CHANGE_MONTH = "2025-09" # 値上げイベント(需要増ではない)
TRUE_ANOMALY = ("チェア", "2025-06", 1.25) # 本物の異常値(前後月比+25%)
rows = []
for i, month in enumerate(MONTHS):
for p in PRODUCTS:
units = base_units[p] * (growth[p] ** i)
if int(month.split("-")[1]) in (11, 12):
units *= 1.3 # 季節性トラップ:再現パターンでありアノマリーではない
if (p, month) == TRUE_ANOMALY[:2]:
units *= TRUE_ANOMALY[2] # 本物のアノマリー
units *= 1 + random.uniform(-0.08, 0.08) # ノイズ
units = max(round(units), 1)
unit_price = price[p]
if p == "デスク" and month > "2025-08":
unit_price = 47000 # 値上げ(2025-09以降)
rows.append({"month": month, "product": p, "units": units, "unit_price": unit_price})
# ---- 正解値は生成パラメータの転記ではなく、rows(生成済みデータ)から再計算する ----
def revenue_of(product=None, month_prefix=None):
return sum(
r["units"] * r["unit_price"] for r in rows
if (product is None or r["product"] == product)
and (month_prefix is None or r["month"].startswith(month_prefix))
)
answer_key = {
"total_revenue_jpy": revenue_of(), # 独立した集計ロジックで検算
"true_anomaly": {"product": "チェア", "month": "2025-06", "note": "季節性(11-12月)とは別物"},
"seasonality_trap": {"months": [11, 12], "note": "毎年再現するためアノマリー扱いは誤り"},
"price_change_event": {"product": "デスク", "month": PRICE_CHANGE_MONTH, "note": "売上増の主因は値上げ。需要増と誤読すると誤り"},
}ポイントは、revenue_of() が生成時のパラメータ(base_units や growth)を直接使わず、一度生成された rows を独立に集計し直していることです。これにより「生成ロジックのバグがそのまま正解データのバグになる」事故を防げます。
天井効果が起きたら難易度を上げる(Step6〜7の実践)— v1→v2→v3のエスカレーション設計
ここからはStep6(天井効果の判断)とStep7(差が出るまで繰り返す)を実践した実例です。最初にクラウドAPI2モデル(Gemini 2.5 Flash / Pro)でテストを組んだとき(v1)、データ分析・要約・コードレビューのすべてのドメインで、両モデルが満点付近を取り、差が全く出ませんでした。これが「天井効果(ceiling effect)」です。両方のモデルが満点でタイになった項目は、判別力がなく評価材料として無意味になります。
天井効果が起きたら、以下の方向でテストの難易度を上げた次バージョンを作ります。旧バージョンは削除せず、最低品質の確認用(floor check)として残します。
| ドメイン | 難易度を上げる方向 |
|---|---|
| データ分析 | ①異常値の倍率を小さくしノイズと紛らわしくする ②季節性など「アノマリーに見えて実は再現パターン」というトラップを混ぜる ③複数列を跨いだ推論(例: 売上増加の主因は需要増ではなく値上げ)を仕込む |
| 要約 | ①文章量を増やしノイズ情報を混ぜる ②出力の文字数制限を課し優先順位判断を強制する ③会議中に一度提案され後で覆される「トラップ決定」を入れ、最終結論だけを拾えるか試す |
| コードレビュー | ①単発の言語パターン(off-by-oneなど)ではなく複数関数をまたいで初めて気づけるバグにする ②実際に数値をトレースしないと気づけない業務ロジックのバグ(例: 端数処理ルール、割引適用順序の違い)にする ③「一見怪しいが実は正しい」トラップ関数を混ぜて誤検知率も問う |
難易度アップのアイデア出しに使えるプロンプト
「ノイズ・トラップ・複数ステップ推論」という3方向は分かっても、具体的にどう仕込むかは意外と悩みます。そこで、次バージョンのテストケース案自体をLLMに一緒に考えてもらうプロンプトを用意しました。
以下のテストケースで、候補モデルが全て満点(または横並びの高スコア)になり、
判別力がありませんでした。
## 現在のテストケース概要
{{テストケースの概要(データの内容・質問内容・現在の正解)}}
## 依頼
このテストケースの難易度を、以下の3方向それぞれで具体的に上げる案を1つずつ、
合計3つ提案してください。
1. ノイズ方向: 正解に近い紛らわしい要素を増やし、単純なパターン認識だけでは
正解にたどり着けないようにする
2. トラップ方向: 「一見正しそうだが実は誤り」または「一見異常だが実は正常」
という要素を混ぜ、額面通りに受け取ると誤答するようにする
3. 推論方向: 単一の事実確認では答えが出ず、複数のデータ・複数のステップを
組み合わせて初めて正解にたどり着ける設問にする
各提案には、以下を含めてください。
- 具体的な変更内容
- 変更後も「正解が一意に定まり、独立ロジックで検算できる」ことの説明
- 何を検出できるようになるか(どんなモデルの弱点を炙り出せるか)使い方: {{テストケースの概要}}に現在のv1テストデータの内容・出題形式・正解の要点を記載して投げます。出てきた3案のうち、実際に「検算可能」なものを選んで実装するとよいでしょう。本記事のデータ分析ドメインでは、この考え方に沿って「異常値の倍率縮小」「季節性トラップ」「価格改定推論」の3案を採用しました。
実際にこの設計変更を行った結果が下の表です(詳細は後述の実証例セクションを参照)。
| バージョン | 設計 | 結果 |
|---|---|---|
| v1(教科書的) | 既知バグ・単純な集計・平易な要約 | 候補モデルが軒並み満点でタイ → 判別不能 |
| v2(トラップ導入) | ノイズ幅拡大・トラップ混入・文字数制限 | モデル間で明確な差が出始める |
| v3(業務ロジック推論) | 数値トレース必須のバグ設計 | 上位モデルと下位モデルが明確に分離 |
コードレビューは特に注意が必要です。「教科書的な既知アンチパターン」は学習データに大量に含まれているため、現行の主要モデルはほぼ満点になりがちです。判別したいのであれば、最初から③のレベル(業務ロジック推論・数値トレース必須)で設計するのが効率的だと分かりました。
LLM-judge運用の注意点(Step5の実践)— 一読ではなくチェックリスト照合
Step5(正解キーの項目を1つずつ機械的に照合する)は、口で言うほど簡単ではありませんでした。判定役(LLM-as-judge)を運用する上で、実際に一度失敗しています。モデルの出力を「一読して大丈夫そうか」で判定した結果、事実にない内容の捏造を見落としかけたことがありました。
この教訓から、判定は以下のように「正解キーの項目を1つずつ機械的にチェックする」運用に改めました。
# 擬似コード: 正解キーの項目を1つずつ機械的に照合する
# (実運用では、この判定役をモデル出力の生成元と別系統の高性能モデルが担当し、
# 候補モデル自身には自己採点させない)
results = {}
for item in answer_key["must_check_points"]:
# 一読しての印象ではなく、項目ごとにYes/Noを機械的に判定する
results[item["id"]] = judge_check(model_output, item["question"])
score = sum(1 for r in results.values() if r == "Yes")一読による判定は「大丈夫そうに見える」という印象に引っ張られやすく、細部の事実誤認や捏造を見逃します。正解キーの項目数だけループを回し、Yes/Noを機械的に埋めてから合計する運用にしたことで、この種の見落としは解消しました。
コピペで使えるJudgeプロンプトのテンプレート
上記の擬似コードで示した「項目ごとの機械的な照合」を、実際にLLMへ投げるプロンプトとして書き起こすと以下のようになります。プレースホルダー部分を差し替えてそのまま使えます。
あなたは公平な採点者です。以下の「モデルの出力」を「正解キーの項目」と1つずつ照合し、
各項目についてYes/Noと根拠を判定してください。一読しての印象ではなく、
項目ごとに機械的に判定してください。
## 正解キーの項目
{{正解キーの項目}}
## モデルの出力
{{model_output}}
## 出力形式(必ず以下の形式で回答してください)
- 項目1: Yes/No — 根拠(モデルの出力のどの記述を根拠にしたか引用する)
- 項目2: Yes/No — 根拠
(正解キーの項目数だけ繰り返す)
## 注意事項
- モデルの出力に正解キーにない情報(捏造)が含まれている場合は、該当箇所を指摘すること
- 判断が曖昧な項目は「Yes」に寄せず、根拠が弱い場合は素直に「No」と判定すること
## 最後に
合計スコア: X/Y(Yは正解キーの項目数)の形式で出力してください使い方: {{正解キーの項目}}にはanswer_keyの各キーを人間が読める形に整形したものを、{{model_output}}には評価対象モデルの生出力をそのまま貼り付けます。判定役には評価対象モデルとは別系統の高性能モデル(本記事ではClaude)を使い、候補モデル自身に自己採点させないことが重要です。
実務のつまずきポイント — Ollamaのnum_ctxの罠
Step4(候補モデル全部に同一条件で実行する)を実践する際、ローカルLLM特有の落とし穴を踏みました。
症状: ローカルLLM(Ollama)に長めのプロンプト(CSVやコードスニペットを含む)を渡すと、モデルが古い情報を無視したような回答を返すことがありました。
原因: ollama showコマンドで表示されるコンテキスト長(例: qwen2.5であれば32768)は、あくまでモデルが対応できる最大値です。実行時のデフォルトはVRAMから自動算出された値(今回の検証環境では4096)が使われ、明示的に num_ctx を指定しない限り、この小さい値で動作します。プロンプトがこれを超えると、--context-shift によって古いトークンが黙って切り捨てられます。
対策: options.num_ctx を明示指定し、レスポンスの prompt_eval_count(実際に消費したトークン数)で切り捨てが起きていないかを確認する運用にします。
import json
import sys
import urllib.request
OLLAMA_URL = "http://localhost:11434/api/generate"
DEFAULT_NUM_CTX = 8192 # 実測プロンプトの最大トークン数に余裕を持たせた値
def call_ollama(model, prompt, num_ctx=DEFAULT_NUM_CTX, timeout=600):
body = {"model": model, "prompt": prompt, "stream": False, "options": {"num_ctx": num_ctx}}
req = urllib.request.Request(
OLLAMA_URL,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
prompt_tokens = data.get("prompt_eval_count", 0)
if prompt_tokens > num_ctx - 1:
print(
f"[WARN] prompt_eval_count({prompt_tokens}) が num_ctx({num_ctx}) 以上。"
f"切り捨てが発生した可能性があります",
file=sys.stderr,
)
return data.get("response", "")「モデルのスペック表を見て安心していたら、実際はもっと小さい枠で動いていた」というのは実際に起きやすい誤解です。長いプロンプトを扱う場合は、実行前に必ず num_ctx を明示し、実行後に prompt_eval_count で裏取りすることをおすすめします。
この方法論を適用した実証例(Step8: 結果を判断材料にする)
上記の8ステップを、実際にGemini 2.5 Flash / Proの比較に適用した結果の要点を紹介します。
| ドメイン | v1(易) | 難易度エスカレーション後 | 結果 |
|---|---|---|---|
| データ分析 | Flash/Proともに高スコアで判別不能 | v2: 異常値の倍率を縮小+季節性トラップ+価格改定推論を追加 | Proのみ真の異常値を検出。Flashは見逃し |
| 要約 | Flash/Proともに必須項目をほぼ網羅し判別不能 | v2: 400字制限+トラップ決定を追加 | Proが必須項目の網羅率でFlashを明確に上回る |
| コードレビュー | Flash/Proともに既知バグを完全検出 | v3: 業務ロジック推論・数値トレース必須のバグに変更 | Proのみ全問正解。Flashは一部見逃し |
n=1(各テストケースを1回のみ実行)の一次検証であるため、統計的に確定した結論ではありませんが、「判別力を意図的に設計しないと、モデル比較は簡単にタイになって何も分からない」という本記事の主張を裏付ける実例になっています。
応用: 同じテストセットをローカルLLMにも適用してみると
このv1〜v3のテストセット(Flash/Pro比較のために設計したもの)を、後日そのままローカルLLM(qwen2.5:7b / llama3.1:8b / gemma3)にも適用してみました。結果は「v1の時点から既にクラウドAPIに劣後」というものでした。
- データ分析: v1の総売上集計だけでも、llama3.1:8bは桁を間違えるレベルの誤差、qwen2.5:7bは誤差-44%、gemma3は誤差-18%と、いずれも天井効果でタイになる余地すらありませんでした
- 要約: v1では比較的健闘するモデルもありましたが、v2(400字制限+トラップ決定)になるとモデル間の差が大きく開きました
- コードレビュー: v1・v2(教科書的〜巧妙なバグ)まではローカルLLMもクラウドAPIに匹敵しましたが、v3(業務ロジック推論必須)で全ローカルモデルが崩壊しました
つまり、天井効果とエスカレーション設計はあくまで「拮抗した2モデルを比較する場面」で威力を発揮する手法であり、実力差が大きいモデル同士を比べる場合は、平易なテスト(v1)の時点で既に差が見えることもある、という点も併せて押さえておくとよいでしょう。
まとめ・次のアクション
冒頭の8ステップに対応するチェックリストとしてまとめます。自分の業務でそのまま使ってみてください。
- Step1: 評価したいタスクを1つ具体的に決めたか
- Step2: そのタスクの「正解」を自分(人間)が判定できる状態にしたか(正解付きテストケースを設計したか)
- Step3: 正解値は生成パラメータの転記ではなく、独立したロジックで検算しているか
- Step4: 候補モデル全部に同一のテストケース・同一条件で実行したか
- Step5: LLM-judgeは一読ではなく、正解キーの項目ごとに機械的にチェックしているか
- Step6: 候補モデルが軒並み満点になる「天井効果」が起きていないか確認したか
- Step7: 天井効果が起きた場合、ノイズ縮小・トラップ混入・複数ステップ推論など難易度を上げて、差が出るまでStep4〜6を繰り返したか
- Step8: 結果をもとに、どの業務にどのモデルを使うか判断できたか
- (Ollamaを使う場合の補足)
num_ctxを明示指定しprompt_eval_countで切り捨てを確認したか
公開ベンチマークのスコアだけでモデルを選ぶのではなく、自社のユースケースに合わせた「判別力のあるテスト」を自分で設計できれば、モデル選定の意思決定に対する納得感は大きく変わります。ぜひ自分のプロジェクトでも試してみてください。
