Yyatmita

pytest で LLM-as-judge を組む — deepeval × Claude Code CLI

deepeval の Custom LLM 機構に Claude Code CLI を差し込み、pytest の assert で LLM 出力を評価する構成。Hallucination 検出と GEval によるカスタム指示反映チェックを 50 行で動かす。API key 不要、コストは pytest marker で封じる。

自分のエージェント基盤を組む#agent-stack#llm#evaluation#deepeval#pytest#claude-code

LLM 出力の評価が、見慣れた pytest の assert でそのまま書ける。判定担当の LLM (judge) には Claude Code CLI を流用するので、OpenAI / Anthropic SDK 向けの API key は不要、claude に通っている認証をそのまま使い回せる。エージェント基盤側の話は 並列 ReAct エージェントでローカル LLM と Claude を本気で比べた に書いた。今回はその「評価」側の話。

def test_report_does_not_hallucinate():
    metric = HallucinationMetric(threshold=0.5, model=ClaudeCodeLLM())
    metric.measure(test_case)
    assert metric.success

評価エンジンには deepeval を使い、judge LLM の実装を 50 行のカスタムクラスで差し込む構成。

できること

  • Hallucination 検出: 生成された文書が、与えたソース文献群と矛盾していないか
  • 指示追従の評価: ユーザーが追加したカスタム指示が、生成物に意味的に反映されているか
  • その他 deepeval のメトリクス全般 (Faithfulness, Answer Relevancy, Contextual Relevancy, etc.)

すべて pytest 配下で assert metric.success の形で書け、uv run pytest -m llm で明示的に走らせたときだけ LLM コストが発生する構成にする。

インストール

uv add --dev deepeval pytest

Claude Code CLI は別途必要 (claude --version で確認)。本記事は claude-p (print) モードを subprocess で呼ぶ前提。

pytest 設定

pyproject.toml:

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v -m 'not llm'"
markers = [
    "llm: tests that invoke an LLM (skipped by default; run with `pytest -m llm`)",
]

uv run pytest を素朴に叩いたとき LLM 関連テストが勝手に走って課金される事故を防ぐ。

使い方

Step 1 — Judge LLM を claude -p で実装

tests/eval/conftest.py:

from __future__ import annotations
 
import json
import subprocess
from typing import Type, TypeVar
 
from deepeval.models import DeepEvalBaseLLM
from pydantic import BaseModel
 
T = TypeVar("T", bound=BaseModel)
 
 
class ClaudeCodeLLM(DeepEvalBaseLLM):
    """claude -p を subprocess で叩く DeepEval 用カスタム LLM."""
 
    def __init__(self, model_alias: str = "haiku", timeout: int = 180) -> None:
        self.model_alias = model_alias
        self.timeout = timeout
 
    def load_model(self) -> None:
        return None
 
    def get_model_name(self) -> str:
        return f"claude-code-{self.model_alias}"
 
    def generate(self, prompt: str, schema: Type[T]) -> T:
        schema_json = json.dumps(schema.model_json_schema())
        result = subprocess.run(
            [
                "claude", "-p",
                "--model", self.model_alias,
                "--no-session-persistence",
                "--tools", "",
                "--output-format", "json",
                "--json-schema", schema_json,
                prompt,
            ],
            capture_output=True, text=True, timeout=self.timeout, check=True,
        )
        payload = json.loads(result.stdout)
        structured = payload.get("structured_output")
        if structured is not None:
            return schema.model_validate(structured)
        return schema.model_validate_json(payload.get("result", ""))
 
    async def a_generate(self, prompt: str, schema: Type[T]) -> T:
        return self.generate(prompt, schema)

ポイント:

  • --tools "" で claude code の内蔵 tool を全部無効化 (純粋な LLM 呼び出しになる)
  • --no-session-persistence でセッション履歴を残さない
  • --json-schema で出力を pydantic スキーマに従わせる
  • judge 用途なら --model haiku で十分

Step 2 — Hallucination 検出を書く

import pytest
from deepeval.metrics import HallucinationMetric
from deepeval.test_case import LLMTestCase
 
from .conftest import ClaudeCodeLLM
 
pytestmark = pytest.mark.llm  # モジュール全体に llm marker を付与
 
 
def test_report_does_not_hallucinate():
    target = open("artifacts/generated_report.md").read()
    context = [
        open("sources/reference_1.md").read(),
        open("sources/reference_2.md").read(),
    ]
    metric = HallucinationMetric(
        threshold=0.5,
        model=ClaudeCodeLLM(),
        include_reason=True,
        async_mode=False,                          # subprocess 呼び出しは sync
    )
    test_case = LLMTestCase(
        input="提供したソースに基づいてレポートを生成せよ",
        actual_output=target,
        context=context,
    )
    metric.measure(test_case)
    print(f"score={metric.score}, reason={metric.reason}")
    assert metric.success

判定原理: judge LLM が context の各要素について「actual_output の中でこれと矛盾する記述があるか」を YES/NO で判定する。矛盾した context 数 / 全 context 数 がスコア (低いほど良いthreshold=0.5 なら半分超え矛盾していたら fail)。

Step 3 — GEval でカスタム指示の反映を測る

「ユーザーが指示した観点が出力に意味的に反映されているか」のような、正規表現では拾えない品質を自然言語の criteria で評価できる。

from deepeval.metrics import GEval
from deepeval.test_case import LLMTestCase, SingleTurnParams
 
 
def _build_compliance_metric() -> GEval:
    return GEval(
        name="instruction-compliance",
        criteria=(
            "`input` には『出力で必ず展開せよ』と指示された論点群が書かれている。"
            "`actual_output` の中で、各論点が 1 段落以上の分量で具体的に論じられているか。\n"
            "全論点が実質展開されていれば高スコア (>= 0.7)、1 つでも欠落していれば中以下、"
            "いずれも展開されていなければ低スコア (<= 0.3)。"
        ),
        evaluation_params=[
            SingleTurnParams.INPUT,
            SingleTurnParams.ACTUAL_OUTPUT,
        ],
        threshold=0.7,
        model=ClaudeCodeLLM(),
        async_mode=False,
    )
 
 
def test_passes_when_instructions_applied():
    metric = _build_compliance_metric()
    test_case = LLMTestCase(
        input=open("fixtures/instruction.md").read(),
        actual_output=open("fixtures/output_with_applied.md").read(),
    )
    metric.measure(test_case)
    assert metric.success
 
 
def test_fails_when_instructions_missing():
    metric = _build_compliance_metric()
    test_case = LLMTestCase(
        input=open("fixtures/instruction.md").read(),
        actual_output=open("fixtures/output_without_applied.md").read(),
    )
    metric.measure(test_case)
    assert not metric.success

実走例 (criteria に「2 論点を必ず展開せよ」を入れた場合):

入力score結果
2 論点とも展開済みの出力0.9PASS
どちらの論点にも触れていない出力0.1PASS (期待通りの低スコア)

スコア差 0.8 で明瞭に分離した。

Step 4 — 実行

$ uv run pytest                           # 何も走らない、コスト ¥0
collected 3 items / 3 deselected / 0 selected
 
$ uv run pytest -m llm tests/eval/        # 評価実行 (haiku 数回 = ¥80 規模)
test_compile_hallucination.py::...    PASSED   score=0.0
test_override_semantic.py::...applied PASSED   score=0.9
test_override_semantic.py::...missing PASSED   score=0.1
コマンド動作コスト
uv run pytest全件 deselect¥0
uv run pytest -m llmLLM テスト全件実走¥100 規模
uv run pytest -m llm tests/eval/eval ディレクトリのみ¥80

はまり所

1. generate(prompt, schema) のシグネチャ

公式ドキュメントの最初の例には schema 引数のない簡易版があるが、実際のメトリクスは schema 付きのオーバーライド版を要求する。返り値は str ではなく pydantic BaseModel。最初からこちらの形で書く。

def generate(self, prompt: str, schema: Type[BaseModel]) -> BaseModel: ...

2. claude -p --json-schema の出力先 (これが本記事の核)

claude -p--json-schema を渡したとき、出力ペイロードはこうなる:

{
  "type": "result",
  "subtype": "success",
  "result": "",
  "structured_output": { "verdict": "yes", "...": "..." },
  "duration_ms": 6960
}

result フィールドは空文字列、構造化出力は structured_output に入る。resultschema.model_validate_json() に通そうとすると pydantic ValidationError: Invalid JSON: EOF で止まる。structured_output を取って schema.model_validate() に渡すのが正解。

payload = json.loads(result.stdout)
structured = payload.get("structured_output")
if structured is not None:
    return schema.model_validate(structured)
# schema 未指定時のフォールバック
return schema.model_validate_json(payload.get("result", ""))

これは公式 doc に書かれていない動きなので、自前で --json-schema を扱うときは覚えておく価値がある。

3. LLMTestCaseParams は deprecated

新しい deepeval (4.x) では SingleTurnParams を使う。

# NG (deprecated)
from deepeval.test_case import LLMTestCaseParams
evaluation_params=[LLMTestCaseParams.INPUT, LLMTestCaseParams.ACTUAL_OUTPUT]
 
# OK
from deepeval.test_case import SingleTurnParams
evaluation_params=[SingleTurnParams.INPUT, SingleTurnParams.ACTUAL_OUTPUT]

4. async_mode=True は使えない

subprocess.run は同期。並列度を上げたければメトリクスは async_mode=False にしたうえで、外側で pytest-xdist 等で並列化する。

5. judge LLM 自体がハルシネートする

評価する側もハルシネートしうる。重要な評価は haiku から sonnet 以上に上げることを検討する。コストは増えるが、判定精度の信頼性は上がる。判定モデルを上げるかどうかは、ループの完成定義設計と同じ問題系列にある (詳しくは LLM ループの『完成定義』を間違えると、100 点合格でも intent が骨抜きになる)。

6. claude code の起動オーバーヘッド

1 回 3〜5 秒のオーバーヘッドが乗る。メトリクスの中で内部的に複数回呼ばれるので、テスト 1 件で 30〜60 秒は見ておく。CI で毎 PR 走らせるよりは nightly や main マージ時に絞る運用が現実的。

まとめ

  • deepeval + ClaudeCodeLLM = 50 行のカスタムクラスだけで LLM 出力評価が pytest に乗る
  • 罠は structured_output フィールドだけ (公式 doc に書かれていない)
  • コスト管理は pytest marker で -m "not llm" をデフォルトに
  • judge LLM の精度はモデル選択で買える。重要評価は sonnet 以上を検討

LLM 評価の自動化を「いつかやりたい」状態のまま放置しているなら、まずこの構成で 1 メトリクスだけ動かしてみるのを推奨する。骨格は 1 時間でできる。

参考