LLM選定のために自作の比較評価テストを組んだのに、候補モデルが軒並み満点になって優劣がつかない——という経験をしたエンジニアは多いと思います。この現象と解決の全体像(8ステップ)は 公開ベンチマークだけでLLMを選んでいませんか?——判別力のある評価テストを自作する方法(amanity.co.jp) で「どう考えるか」中心に解説しました。
本記事はその実装編です。概念だけでは再現できない部分——正解データを独立ロジックで検算するコード、LLM-as-judgeを"雰囲気採点"に戻さないための構造化出力の強制、天井効果の自動検出、そしてOllamaのnum_ctxが実際にどう挙動するかの実測ベースの対策——をコードで解説します。
全体構成
評価ハーネスは大きく4つのコンポーネントに分解できます。
1. テストデータ生成器 → 仕掛け(異常値・トラップ・季節性)を混ぜたデータを作る
2. 正解キー生成器 → 生成器とは独立したロジックで正解を計算し直す
3. 実行ランナー → 候補モデル全部に同一条件でプロンプトを投げる
4. 採点器(judge) → 正解キーの項目ごとに機械的にYes/Noを判定する
ポイントは、2の「正解キー生成器」を1の「テストデータ生成器」からコード上も分離することです。同じモジュール内で計算すると、リファクタ時に誤って設定値を共有してしまい、生成バグがそのまま正解バグになる事故が起きます。
実装1: テストデータと正解キーを別ロジックで作る
売上異常検知タスクを例にします。生成器には「本物の異常値」「季節性による見せかけの増加」「値上げイベント」を意図的に混ぜます。
import random
from dataclasses import dataclass
@dataclass
class Row:
month: str # "2025-07"
product: str # "A" / "B"
units: int
unit_price: int
def generate_dataset(seed: int = 42) -> list[Row]:
random.seed(seed)
rows: list[Row] = []
base_price = {"A": 1000, "B": 1500}
for year in (2024, 2025):
for m in range(1, 13):
month = f"{year}-{m:02d}"
for product, price in base_price.items():
units = random.randint(80, 120)
if m == 12: # 季節性トラップ(毎年12月は伸びる)
units = int(units * 1.2)
if month == "2025-07" and product == "A": # 本物の異常値
units *= 3
unit_price = price
if product == "B" and year == 2025 and m >= 4: # 値上げイベント
unit_price = int(price * 1.15)
rows.append(Row(month, product, units, unit_price))
return rows
正解キーは、このrows(生成済みデータ)だけを入力にして、生成時のパラメータ(seedや倍率の1.2・3・1.15)には一切触れずに計算し直します。
def revenue_of(rows: list[Row], product: str | None = None, month_prefix: str | None = None) -> int:
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))
)
def true_anomaly_months(rows: list[Row], z_threshold: float = 2.5) -> list[str]:
"""月別出荷数のzスコアで異常値を検出する。季節性(12月)は月次パターンとして
z計算の母集団に含めるため、単発の異常値とは自然に区別される"""
import statistics
from collections import defaultdict
by_product: dict[str, list[tuple[str, int]]] = defaultdict(list)
for r in rows:
by_product[r.product].append((r.month, r.units))
anomalies = []
for product, series in by_product.items():
values = [v for _, v in series]
mean, stdev = statistics.mean(values), statistics.stdev(values)
for month, v in series:
if stdev and abs(v - mean) / stdev >= z_threshold:
anomalies.append(f"{month}:{product}")
return anomalies
def build_answer_key(rows: list[Row]) -> dict:
return {
"total_revenue_jpy": revenue_of(rows),
"revenue_by_product": {p: revenue_of(rows, product=p) for p in ("A", "B")},
"true_anomaly_months": true_anomaly_months(rows),
}
revenue_ofとtrue_anomaly_monthsはrowsしか受け取らないため、生成器のバグ(例えば倍率を書き間違える)があっても、正解キーは「できあがったデータの通りの答え」を返し続けます。これがテスト自体の信頼性の土台になります。
CIに乗せる場合は、この独立性を壊さないことをテストで固定しておくと安全です。
def test_answer_key_does_not_depend_on_generation_params():
"""generate_dataset のパラメータを変えても、
revenue_of が rows 以外の情報源を参照していないことを保証する回帰テスト"""
import inspect
src = inspect.getsource(revenue_of)
assert "random" not in src and "seed" not in src
実装2: LLM-as-judgeを"雰囲気採点"に戻さない
採点をLLMに任せる場合の失敗は、たいてい「自由記述のプロンプトで採点させ、返ってきたテキストをreでざっくりパースする」実装から生まれます。これだと採点役のLLM自身が通読による印象判定に流れやすく、フリーテキストのパース失敗にも気づきにくくなります。
対策は、採点役の出力形式をスキーマで強制し、パース段階でバリデーションエラーにできるようにすることです。
from pydantic import BaseModel, ValidationError
class JudgeItem(BaseModel):
item: str
verdict: bool
quote: str # 出力のどの記述を根拠にしたか(引用必須にすることで裏取りを強制する)
class JudgeResult(BaseModel):
items: list[JudgeItem]
fabrications: list[str] # 正解キーにない記述(捏造)の指摘
JUDGE_PROMPT = """\
あなたは公平な採点者です。以下の「正解キーの項目」を1つずつ、
「モデルの出力」と機械的に照合し、各項目についてverdict(true/false)と
根拠quote(出力からの引用)を判定してください。一読しての印象では判定しないこと。
## 正解キーの項目
{answer_key}
## モデルの出力
{model_output}
出力は指定されたJSONスキーマに厳密に従うこと。
"""
def judge(answer_key: dict, model_output: str, judge_client) -> JudgeResult:
raw = judge_client.structured_complete(
prompt=JUDGE_PROMPT.format(answer_key=answer_key, model_output=model_output),
response_schema=JudgeResult, # tool use / JSON schema 制約で構造を強制する
)
try:
return JudgeResult.model_validate(raw)
except ValidationError as e:
# 構造が壊れた出力は「採点失敗」として扱い、無理にパースしない
raise RuntimeError(f"judge出力がスキーマ不適合: {e}") from e
def score(result: JudgeResult) -> float:
if not result.items:
raise ValueError("採点項目が0件。judgeモデルの応答が壊れている可能性")
return sum(i.verdict for i in result.items) / len(result.items)
ここでの実装上の勘所は2つです。
-
quote(根拠の引用)を必須フィールドにする。引用元を書かせることで、judgeモデル自身にも「通読しての印象」ではなく項目ごとの裏取りを強制できます -
採点役には評価対象と別系統の高性能モデルを使う。候補モデル自身に自己採点させると評価が甘くなりやすいため、
judge_clientは候補モデルのプールとは別に固定します
実装3: 天井効果を自動検出してバージョンをエスカレーションする
候補モデルが軒並み満点になる「天井効果」は、人間が表を見て気づくのではなく、コードで機械的に検出できるようにしておくとハーネスとして回しやすくなります。
def has_ceiling_effect(scores: dict[str, float], threshold: float = 0.95, spread: float = 0.05) -> bool:
"""全モデルが高スコアかつ横並び(差が小さい)なら天井効果と判定する"""
values = list(scores.values())
return min(values) >= threshold and (max(values) - min(values)) <= spread
def run_versioned_harness(versions: list[str], run_one_version) -> None:
for version in versions:
scores = run_one_version(version) # {"gemini-2.5-flash": 0.98, "gemini-2.5-pro": 1.0} など
print(f"[{version}] scores={scores}")
if not has_ceiling_effect(scores):
print(f"[{version}] 判別力あり。ここで確定")
break
print(f"[{version}] 天井効果を検出 → 次バージョンへ")
難易度を上げる3方向(ノイズ・トラップ・複数ステップ推論)は本家記事で解説した通りですが、実装上重要なのは「v1を消さずに残す」ことです。v1は「これすら落とすモデルは論外」という最低ライン確認用の回帰テストとして再利用できるため、versionsリストに["v1", "v2", "v3"]のように積み上げていく設計にしておくと、後から追加した候補モデルのふるい落としにもそのまま使えます。
実装4: Ollama num_ctx の罠を実測で潰す
ローカルLLM(Ollama)特有の罠として、ollama showが返す「モデルが対応できる最大コンテキスト長」と、実行時に実際に使われるコンテキスト長は別物です。後者はVRAMから自動算出された、もっと小さい値になっていることがあります。
まず、モデルの最大値と実行時デフォルトの候補を確認します。
# モデルカードの最大コンテキスト長を確認(例: qwen2.5系は32768)
curl -s http://localhost:11434/api/show -d '{"name": "qwen2.5:7b"}' \
| jq '.model_info["qwen2.context_length"], .parameters'
parameters側に明示的なnum_ctxが設定されていなければ、実行時はVRAMに応じた自動値(環境によっては4096程度)で動きます。これを信用してはいけません。num_ctxは呼び出し側で明示指定し、実行後に実際の消費トークン数を必ず裏取りします。
import requests
def call_ollama(prompt: str, model: str, num_ctx: int) -> dict:
resp = requests.post(
"http://localhost:11434/api/generate",
json={
"model": model,
"prompt": prompt,
"stream": False,
"options": {"num_ctx": num_ctx}, # 自動算出値に任せず明示指定する
},
timeout=120,
)
resp.raise_for_status()
return resp.json()
def call_with_truncation_check(prompt: str, model: str, num_ctx: int = 8192) -> str:
result = call_ollama(prompt, model=model, num_ctx=num_ctx)
consumed = result["prompt_eval_count"]
if consumed >= num_ctx:
# 消費トークンがnum_ctxに張り付いている = プロンプトの先頭側が
# 切り捨てられた疑いが強い。無言で結果を採用しない
raise RuntimeError(
f"prompt_eval_count={consumed} が num_ctx={num_ctx} 以上。"
f"num_ctx を引き上げて再実行すること"
)
return result["response"]
評価ハーネスに候補モデルとしてOllamaを組み込む場合は、このcall_with_truncation_checkのようなラッパーを経由させ、切り捨てが起きた実行結果はスコアに混ぜずにエラーとして弾く運用にしておくと、「モデルの実力不足」なのか「単に入力が切り捨てられていただけ」なのかを取り違えずに済みます。
まとめ: 実装チェックリスト
- 正解キー生成関数は、テストデータ生成関数のパラメータ(seed・倍率等)を一切参照していないか
- LLM-as-judgeの出力はフリーテキストではなく、スキーマ(pydantic等)で構造を強制しているか
- judgeの各項目に根拠の引用(quote)フィールドを必須化しているか
-
天井効果の判定(
has_ceiling_effect相当)をコードで自動化しているか - 難易度を上げた新バージョンを追加しても、旧バージョン(v1等)は回帰テストとして残しているか
-
Ollama利用時、
num_ctxを明示指定し、prompt_eval_countで切り捨てをチェックする処理を挟んでいるか
評価テストの「考え方」は本家記事、「実装」は本記事、という組み合わせで読むと、実際に自分の環境でハーネスを組む際の迷いが減るはずです。
元記事
公開ベンチマークだけでLLMを選んでいませんか?——判別力のある評価テストを自作する方法 — amanity.co.jp
「どう考えるか」の全体像(8ステップ)や、実際にGemini 2.5 Flash/Pro・ローカルLLMで検証した結果は元記事をご覧ください。


