google / google/adk-python

LocalEvalService discards per-invocation results for any metric reporting EvalStatus.NOT_EVALUATED -- is there an intended shape for non-pass/fail (measurement) metrics?

未关闭
#6,725 2 条评论 0 个 reaction 已指派 1 人 已被 @sanketpatil06 认领 在 GitHub 查看
eval
主要语言
Python
星标
21.5k
派生
4k
平均合并
1 天 14 小时
30 天内合并 PR
37

描述

## 🔴 Required Information

**Describe the Bug:**
`LocalEvalService._evaluate_metric_for_eval_case` (local_eval_service.py:428-436)
discards a metric's real per-invocation `PerInvocationResult` (score, rubric_scores)
and substitutes an empty one whenever `EvaluationResult.overall_eval_status ==
EvalStatus.NOT_EVALUATED`:

```python
invocation_result = (
evaluation_result.per_invocation_results[idx]
if evaluation_result.overall_eval_status != EvalStatus.NOT_EVALUATED
else PerInvocationResult(actual_invocation=invocation.actual_invocation)
)
```

`NOT_EVALUATED` is also the *default* value of `EvaluationResult.overall_eval_status`
and `PerInvocationResult.eval_status` (confirmed via `model_fields` — both default to
`EvalStatus.NOT_EVALUATED`), so this fires for any custom `Evaluator` that reports a
real per-invocation measurement without opting into ADK's `score >= threshold ->
PASSED` pass/fail convention — which is the natural, low-friction thing to do for a
metric that isn't pass/fail by nature (a cost, a token count, a latency).

Downstream, `AgentEvaluator._process_metrics_and_get_failures` reads *only* from
these now-nulled per-invocation results to decide whether to raise. Since a metric
that never reports `PASSED` can never produce a non-empty `scores` list there, it
unconditionally raises `AssertionError` for **any** real value the metric computed —
no threshold configuration avoids this, because the per-invocation score was already
discarded before the threshold comparison happens.

I ran into this building a third-party ADK metric that reports real per-invocation
dollar cost (lower-is-better, so it deliberately never participates in ADK's
higher-is-better pass/fail gate — see "Additional Context" below). It surfaced two
separate effects:
1. `AgentEvaluator.evaluate()` always raises, regardless of the actual computed
value or the configured threshold.
2. `adk eval` (CLI) doesn't raise, but the per-invocation table and the persisted
`eval_history/*.evalset_result.json` both show `score: null` /
`rubric_scores: null` for every invocation. Only one coarse, un-persisted
console line (the aggregate `Metric: ... Score: X`) carries the real number —
nothing about *why* a score is present/absent, or any per-call breakdown, survives
anywhere a user can read after the run.

**Steps to Reproduce:**
Minimal, self-contained repro below (no third-party packages) — a toy metric that
reports a real per-invocation number (42.0) via a custom `Evaluator`, registered the
documented way via `DEFAULT_METRIC_EVALUATOR_REGISTRY.register_evaluator`, deliberately
leaving `eval_status` at its default (`NOT_EVALUATED`, since the metric isn't pass/fail).

`my_agent/agent.py`:
```python
from __future__ import annotations
from collections.abc import AsyncGenerator
from google.adk.agents.llm_agent import LlmAgent
from google.adk.evaluation.eval_case import ConversationScenario, Invocation
from google.adk.evaluation.eval_metrics import EvalMetric, Interval, MetricInfo, MetricValueInfo
from google.adk.evaluation.evaluator import EvaluationResult, Evaluator, PerInvocationResult
from google.adk.evaluation.eval_rubrics import RubricScore
from google.adk.evaluation.metric_evaluator_registry import DEFAULT_METRIC_EVALUATOR_REGISTRY
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.genai import types as genai_types

METRIC_NAME = "toy_measurement"

class ToyMeasurementEvaluator(Evaluator):
"""Reports a real per-invocation number, but is not pass/fail (e.g. a cost)."""
def __init__(self, *, eval_metric: EvalMetric) -> None:
self._eval_metric = eval_metric

def evaluate_invocations(self, actual_invocations, expected_invocations=None,
conversation_scenario=None) -> EvaluationResult:
per_invocation_results = [
PerInvocationResult(
actual_invocation=inv, expected_invocation=None, score=42.0,
rubric_scores=[RubricScore(rubric_id="toy", score=42.0,
rationale="the real breakdown")],
)
for inv in actual_invocations
]
return EvaluationResult(
overall_score=sum(r.score for r in per_invocation_results),
per_invocation_results=per_invocation_results,
# overall_eval_status intentionally left unset -> defaults to
# NOT_EVALUATED, which is correct: this metric is not pass/fail.
)

DEFAULT_METRIC_EVALUATOR_REGISTRY.register_evaluator(
metric_info=MetricInfo(
metric_name=METRIC_NAME,
description="Toy non-pass/fail measurement metric.",
metric_value_info=MetricValueInfo(
interval=Interval(min_value=0.0, max_value=1_000_000.0, open_at_max=True)),
),
evaluator=ToyMeasurementEvaluator,
)

class _FakeLlm(BaseLlm):
model: str = "fake-model"
@classmethod
def supported_models(cls) -> list[str]:
return ["fake-model"]
async def generate_content_async(self, llm_request: LlmRequest, stream: bool = False):
yield LlmResponse(content=genai_types.Content(
parts=[genai_types.Part(text="ok")], role="model"))

root_agent = LlmAgent(name="toy_agent", model=_FakeLlm(), instruction="Answer briefly.")
```

`my_agent/__init__.py`:
```python
from . import agent
```

Driver:
```python
import asyncio, json
from pathlib import Path
from google.adk.evaluation.eval_case import EvalCase, Invocation
from google.adk.evaluation.eval_set import EvalSet
from google.genai import types as genai_types

eval_case = EvalCase(eval_id="case_1", conversation=[
Invocation(user_content=genai_types.Content(parts=[genai_types.Part(text="hi")], role="user"))
])
Path("eval_set.json").write_text(EvalSet(eval_set_id="repro_set", eval_cases=[eval_case]).model_dump_json())
Path("test_config.json").write_text(json.dumps({"criteria": {"toy_measurement": 999999.0}}))

async def main():
from google.adk.evaluation.agent_evaluator import AgentEvaluator
await AgentEvaluator.evaluate(
agent_module="my_agent",
eval_dataset_file_path_or_dir="eval_set.json",
num_runs=1, print_detailed_results=True,
)

asyncio.run(main())
```

1. Install `google-adk[eval]==2.6.3`.
2. Run the driver script above (`agent_module_file_path` layout: `my_agent/__init__.py` + `my_agent/agent.py`, matching the standard convention).
3. Observe the raised `AssertionError`.

**Expected Behavior:**
A metric that computes a real per-invocation value (42.0 in the repro) and
deliberately reports `NOT_EVALUATED` because it isn't pass/fail should be able to
surface that value through `AgentEvaluator.evaluate()`/`adk eval` — at minimum
without crashing, and ideally with the per-invocation score and rationale intact
in both the printed table and the persisted eval-history JSON.

**Observed Behavior:**
```
Summary: `EvalStatus.NOT_EVALUATED` for Metric: `toy_measurement`. Expected threshold: `999999.0`, actual value: `None`.
+----+--------------------------+---------+-------------+----------+---------------------+-------------------+-----------------------+---------------------+
| | eval_status | score | threshold | prompt | expected_response | actual_response | expected_tool_calls | actual_tool_calls |
+====+==========================+=========+=============+==========+=====================+===================+=======================+=====================+
| 0 | EvalStatus.NOT_EVALUATED | | 999999 | hi | | ok | | |
+----+--------------------------+---------+-------------+----------+---------------------+-------------------+-----------------------+---------------------+
RESULT: evaluate() raised AssertionError:
Following are all the test failures.
toy_measurement for None Failed. Expected 999999.0, but got None.
```
The metric's `evaluate_invocations()` was called and did return `score=42.0` per
invocation (verifiable by instrumenting `ToyMeasurementEvaluator.evaluate_invocations`
directly) — the value is computed correctly and then discarded before it reaches any
output.

**Environment Details:**
- ADK Library Version: `google-adk==2.6.3`
- Desktop OS: Windows 11
- Python Version: 3.13.5

**Model Information:**
- Are you using LiteLLM: No
- Which model is being used: N/A (repro uses a fake `BaseLlm`, no real model call needed)

---

## 🟡 Optional Information

**Regression:**
Not a regression as far as I can tell — `local_eval_service.py:428-436`'s
NOT_EVALUATED branch appears intentional (it's guarding against evaluators that
return zero per-invocation results), it just also catches evaluators that return a
full, real per-invocation result set while legitimately reporting NOT_EVALUATED as
their permanent status.

**Additional Context — the underlying design question:**
This isn't only a bug report — I'd like to understand the intended contract here.
As far as I can tell, ADK's `Evaluator`/`EvalStatus` model has no way to express
"this metric measures something real per invocation, but that measurement isn't
pass/fail" — `EvalStatus` is `PASSED | FAILED | NOT_EVALUATED`, and
`NOT_EVALUATED` is treated by `LocalEvalService`/`AgentEvaluator` as "nothing to
report," not as "reported, deliberately not gated." Silently negating a
lower-is-better score to force a "pass" would misrepresent it to anyone reading the
result, so `NOT_EVALUATED` is the only status that doesn't lie — but that value turns
out to mean "discard everything" a few layers down, not "measured, not gated."

Is a non-pass/fail "measurement" metric (cost, token count, latency, or similar)
something ADK's `Evaluator` contract is meant to support today? If so, what's the
recommended shape for it — a distinct `EvalStatus` variant, a flag on `EvalMetric`/
`MetricInfo` that opts a metric out of the threshold-gating and per-invocation-nulling
behavior, something else? I'm happy to be pointed at existing conventions I missed,
or to help prototype once there's a direction — I just don't want to guess at API
design that maintainers would need to carry.

**How often has this issue occurred?:**
Always (100%) — deterministic given the repro above.

贡献指南

打开贡献指南

评估

这个 Issue 还没有评估数据。

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。