1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自作LLM評価ハーネスの実装解説——正解の独立検算・LLM-as-judgeの機械採点・Ollama num_ctxの実測対策

1
Last updated at Posted at 2026-07-21

自作LLM評価ハーネスの実装解説

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.231.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_oftrue_anomaly_monthsrowsしか受け取らないため、生成器のバグ(例えば倍率を書き間違える)があっても、正解キーは「できあがったデータの通りの答え」を返し続けます。これがテスト自体の信頼性の土台になります。

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つです。

  1. quote(根拠の引用)を必須フィールドにする。引用元を書かせることで、judgeモデル自身にも「通読しての印象」ではなく項目ごとの裏取りを強制できます
  2. 採点役には評価対象と別系統の高性能モデルを使う。候補モデル自身に自己採点させると評価が甘くなりやすいため、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で検証した結果は元記事をご覧ください。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?