background
Google の Agent Development Kit(ADK)は、AIエージェントを評価するための指標(Criteria)を12種類用意している(https://adk.dev/evaluate/criteria/)。ツール呼び出しの正確さ、最終応答の品質、ハルシネーション、安全性、マルチターンでのタスク達成度など、かなり網羅的だ。
で、これを見て最初にやりがちなのが「全部PRのCIに突っ込む」という判断。これは高確率で事故る。理由と、代わりにどう組むべきかを実例つきでまとめる。
12種類の内訳
12種類は「正解データが要るか」「ルーブリックが要るか」「LLM-as-a-Judgeを使うか」の3軸で整理できる。
| Criteria | 正解データ | ルーブリック | LLM Judge | ユーザーシミュレーション |
|---|---|---|---|---|
tool_trajectory_avg_score |
要 | 不要 | 不使用 | 不使用 |
response_match_score |
要 | 不要 | 不使用 | 不使用 |
final_response_match_v2 |
要 | 不要 | 使用 | 不使用 |
rubric_based_final_response_quality_v1 |
不要 | 要 | 使用 | 使用 |
rubric_based_tool_use_quality_v1 |
不要 | 要 | 使用 | 使用 |
rubric_based_multi_turn_trajectory_quality_v1 |
不要 | 要 | 使用 | 使用 |
hallucinations_v1 |
不要 | 不要 | 使用 | 使用 |
safety_v1 |
不要 | 不要 | 使用 | 使用 |
per_turn_user_simulator_quality_v1 |
不要 | 不要 | 使用 | 使用 |
multi_turn_task_success_v1 |
不要 | 不要 | 使用 | 使用 |
multi_turn_trajectory_quality_v1 |
不要 | 不要 | 使用 | 使用 |
multi_turn_tool_use_quality_v1 |
不要 | 不要 | 使用 | 使用 |
12種のうち10種がLLM Judge依存。この時点で「PRごとに全部回す」は破綻することが見えている。
なぜ全部CIに乗せると破綻するか
3つの構造的な壁がある。
- レイテンシ: LLM-as-a-Judge や User Simulation を使うケースは1件あたり数十秒〜数分かかる。テストケース数を掛けると、PRのフィードバックが数分から下手すると1時間超に伸びる。
-
コスト: テストケース数N、ターン数Tとすると、User Simulation + Judge の総トークン消費は概ね
O(N×T)で増える。コミット毎にこれを回すのはFinOps的に成立しない。 -
Flaky化:
temperature=0.0にしてもJudgeやSimulatorの出力は揺れる。CIが赤くなっても「コードのバグ」なのか「Judgeの気まぐれ」なのか切り分けが必要になり、そのうち誰も赤を信用しなくなる。
決定論的とされる2つの指標にも罠がある。response_match_score はROUGE-1(単語重複率)なので、フォーマットや言い回しを変えただけで簡単に誤検知(False Negative)する。tool_trajectory_avg_score も EXACT や IN_ORDER で厳密判定すると、モデル更新でツール呼び出しが並列化されるなど「より効率的な手順」を発見した場合にテストが不当に落ちる。厳密一致は改善の足を引っ張る。
解決策:実行頻度と決定論性でレイヤー化する
「実行頻度(高→低)」と「決定論性(高→低)」を軸に3層のピラミッドに分ける。
Layer 3: E2E / シナリオ評価 [週次 / リリース前]
- User Simulation 高コスト / 非決定的
- multi_turn_task_success
------------------------------------
Layer 2: ナイトリー / 品質・安全性 [日次]
- hallucinations_v1 / safety_v1 中コスト / LLM Judge
- rubric_based_final_response
------------------------------------
Layer 1: PR / Pre-commit(決定論的CI) [コミット/PR毎]
- tool_trajectory_avg_score (ANY_ORDER) 低コスト / 決定論的
Layer 1: PRゲート — LLMを一切呼ばない
使うのは tool_trajectory_avg_score のみ、それも EXACT ではなく ANY_ORDER で「必須ツールが呼ばれたか」だけを見る。外部APIはモック化。LLM呼び出しゼロ、コストゼロ、実行時間は1分未満。
import pytest
from google.adk.evaluation import AgentEvaluator
from my_agent.agent import root_agent
@pytest.mark.asyncio
async def test_layer1_tool_trajectory():
"""PR/CIゲート: 決定論的ツール呼出検証(LLM不使用)"""
evaluator = AgentEvaluator(
agent=root_agent,
config_path="tests/eval_cases/layer1_ci.json"
)
results = await evaluator.run_evaluation()
for result in results.case_results:
score = result.metrics["tool_trajectory_avg_score"].score
assert score == 1.0, f"Failed case {result.case_name}: score={score}"
layer1_ci.json 側でこの決定論性を担保する設定を固定する。
{
"criteria": {
"tool_trajectory_avg_score": {
"threshold": 1.0,
"match_type": "ANY_ORDER"
}
}
}
Layer 2: ナイトリー — 品質・ハルシネーション・安全性
ここで初めてLLM-as-a-Judgeが登場する。ただし対象は全件ではなく、バージョン管理された固定データセット(数百件規模)。重要なのは、Judgeに使うモデルとtemperatureを固定すること、そして絶対スコアだけでなく「前回からの劣化率」を見る相対評価も入れること。
evaluator = AgentEvaluator(
agent=root_agent,
config_path="tests/eval_cases/layer2_nightly.json",
judge_model="gemini-1.5-pro",
judge_model_config={"temperature": 0.0},
)
results = await evaluator.run_evaluation()
for result in results.case_results:
assert result.metrics["hallucinations_v1"].score >= 0.9
assert result.metrics["safety_v1"].score == 1.0
rubric = result.metrics["rubric_based_final_response_quality_v1"]
assert rubric.score >= 0.8, rubric.reasoning
ルーブリック系の指標は、曖昧な形容詞ではなく明示的な重み付きルーブリックとして定義する必要がある。ここが一番事故りやすいポイント。
{
"rubric_based_final_response_quality_v1": {
"threshold": 0.8,
"rubrics": [
{"name": "conclusion_first", "description": "冒頭1〜2文で明確な結論が述べられているか", "weight": 0.3},
{"name": "formatting_structure", "description": "箇条書きや見出しで構造化されているか", "weight": 0.3},
{"name": "technical_explanation", "description": "専門用語に文脈に応じた簡潔な補足があるか", "weight": 0.4}
]
}
}
ルーブリックの記述は判定可能な客観条件に落とし込むこと。
- Bad: 「わかりやすい言葉で解説されていること」
- Good: 「専門用語を使う際、カッコ書きまたは直後の文でその定義を1文以内で補足していること」
Judge LLMはクエリ・応答・重み付きルーブリック一覧を受け取り、各項目のスコアと加重平均を返す。assertが失敗したときも reasoning 文字列がそのまま残るので、「なぜ落ちたか」を数値だけでなく理由付きで追える。
Layer 3: リリース前E2E — マルチターンシミュレーション
User Simulator LLMがエージェントと複数ターン対話し、ゴール達成度(multi_turn_task_success_v1)と対話プロセスの品質(multi_turn_trajectory_quality_v1、multi_turn_tool_use_quality_v1)を評価する。
evaluator = AgentEvaluator(
agent=root_agent,
config_path="tests/eval_cases/layer3_e2e.json",
user_simulator_model="gemini-1.5-flash",
judge_model="gemini-1.5-pro",
judge_model_config={"temperature": 0.0},
)
results = await evaluator.run_evaluation()
for result in results.case_results:
sim_quality = result.metrics["per_turn_user_simulator_quality_v1"].score
if sim_quality < 0.8:
pytest.skip(f"Simulator degraded in {result.case_name}")
assert result.metrics["multi_turn_task_success_v1"].score == 1.0
assert result.metrics["multi_turn_trajectory_quality_v1"].score >= 0.8
per_turn_user_simulator_quality_v1 によるスキップ処理に注目してほしい。このレイヤーには構造的な落とし穴がある。
注意点:メタ評価のエラー伝播
このレイヤーは「ユーザー役を演じるSimulator LLM」「テスト対象のAgent」「採点するJudge LLM」の3者が絡む。つまり「Judgeが(Simulatorという)Judgeを判定する」入れ子構造になっている。Simulatorが暴走する(ループする、自分で設定したゴールを無視する、存在しない制約を作り出す)と、task_success のスコアはエージェントの失敗ではなくSimulatorの失敗を反映してしまう。
per_turn_user_simulator_quality_v1 が閾値を下回ったセッションをタスク成功判定の前に除外するのは、念のための防御コードではない。これを入れないと、Simulatorモデルの調子が悪かった日に、エージェント自体は正常なのにリリースゲートだけが落ちる、という事故が静かに起きる。
CI/CDへの組み込み
name: Agent Evaluation Pipeline
on:
pull_request:
branches: [ main ]
schedule:
- cron: '0 18 * * *' # 毎日ナイトリー実行
jobs:
layer1-ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.11' }
- run: pip install -r requirements.txt
- run: pytest tests/test_layer1_ci.py
layer2-layer3-nightly:
if: github.event_name == 'schedule'
needs: layer1-ci
runs-on: ubuntu-latest
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.11' }
- run: pip install -r requirements.txt
- run: pytest tests/test_layer2_nightly.py
- run: pytest tests/test_layer3_e2e.py
PRをブロックするのはLayer 1だけ。Layer 2・3はスケジュール実行にして、失敗したらPRを赤くするのではなく人間のトリアージに回す。
ディレクトリ構成:tests/ に置くべきか
よく出る疑問として「eval用のコードは tests/ に入れるべきか」がある。結論としては、tests/(決定的テスト)と evals/(確率的な品質計測)は本質的に異なるプロシージャなので、ルート直下で分離するのがおすすめ。
.
├── src/
│ └── my_agent/
├── tests/ # 決定的・モック使用・API非依存
│ ├── test_tools.py
│ └── test_state_management.py
└── evals/ # 確率的・実API/Simulator使用
├── datasets/
├── metrics/ # カスタムルーブリック定義
└── run_evals.py
tests/ はローカルで気兼ねなく回せる速度とコストを維持するべき。どうしても tests/ 配下に置く場合は pytest.mark.eval などでマーカーを分け、pytest -m "not eval" がデフォルトのローカルループになるようにする。
まとめ
ADKの12指標のうち、PRをブロックしていい現実的な指標は1〜2種類だけ(tool_trajectory_avg_score の ANY_ORDER + モックによる決定的アサーション)。LLM JudgeやUser Simulatorが絡むものは全部、バージョン管理された固定データセットに対してナイトリーかリリース前レイヤーで回し、Judgeのモデルとtemperatureを固定して再現性を確保する。評価一覧を1本のリストにしてコミット毎に全部回そうとすると、Flakyな赤ビルドと誰も信用しないPRキューが待っている。直すべきはアサーションの書き方ではなく、レイヤーの分け方そのもの。