pytest で LLM-as-judge を組む — deepeval × Claude Code CLI
deepeval の Custom LLM 機構に Claude Code CLI を差し込み、pytest の assert で LLM 出力を評価する構成。Hallucination 検出と GEval によるカスタム指示反映チェックを 50 行で動かす。API key 不要、コストは pytest marker で封じる。
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 pytestClaude 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.9 | PASS |
| どちらの論点にも触れていない出力 | 0.1 | PASS (期待通りの低スコア) |
スコア差 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 llm | LLM テスト全件実走 | ¥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 に入る。result を schema.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 時間でできる。