aeval-framework 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aeval_framework-0.1.0.dist-info/METADATA +42 -0
- aeval_framework-0.1.0.dist-info/RECORD +63 -0
- aeval_framework-0.1.0.dist-info/WHEEL +4 -0
- aeval_framework-0.1.0.dist-info/entry_points.txt +2 -0
- agent_eval/__init__.py +14 -0
- agent_eval/api/__init__.py +14 -0
- agent_eval/api/app.py +82 -0
- agent_eval/api/events.py +96 -0
- agent_eval/api/routes/__init__.py +0 -0
- agent_eval/api/routes/datasets.py +441 -0
- agent_eval/api/routes/graders.py +19 -0
- agent_eval/api/routes/metrics.py +49 -0
- agent_eval/api/routes/runs.py +573 -0
- agent_eval/api/routes/suites.py +84 -0
- agent_eval/api/routes/tasks.py +114 -0
- agent_eval/api/standalone.py +105 -0
- agent_eval/cli.py +455 -0
- agent_eval/core/__init__.py +48 -0
- agent_eval/core/contract.py +296 -0
- agent_eval/core/metrics.py +184 -0
- agent_eval/core/runner.py +868 -0
- agent_eval/core/suite.py +60 -0
- agent_eval/core/types.py +227 -0
- agent_eval/dataset/__init__.py +31 -0
- agent_eval/dataset/models.py +199 -0
- agent_eval/dataset/quality.py +194 -0
- agent_eval/dataset/sources/__init__.py +45 -0
- agent_eval/dataset/sources/llm_generator.py +219 -0
- agent_eval/dataset/sources/manual.py +172 -0
- agent_eval/dataset/sources/regression.py +201 -0
- agent_eval/dataset/sources/trace_mining.py +277 -0
- agent_eval/dataset/storage.py +342 -0
- agent_eval/dataset/version.py +72 -0
- agent_eval/examples/__init__.py +0 -0
- agent_eval/examples/basic_usage.py +175 -0
- agent_eval/examples/mock_runner.py +195 -0
- agent_eval/graders/__init__.py +91 -0
- agent_eval/graders/artifact_check.py +114 -0
- agent_eval/graders/code_based.py +101 -0
- agent_eval/graders/human.py +77 -0
- agent_eval/graders/metric.py +142 -0
- agent_eval/graders/model_based.py +179 -0
- agent_eval/graders/state_check.py +106 -0
- agent_eval/graders/step_level.py +116 -0
- agent_eval/graders/tool_calls.py +102 -0
- agent_eval/graders/transcript.py +86 -0
- agent_eval/metrics/__init__.py +110 -0
- agent_eval/metrics/answer_relevancy.py +57 -0
- agent_eval/metrics/base.py +155 -0
- agent_eval/metrics/batch_evaluation.py +267 -0
- agent_eval/metrics/context_precision.py +62 -0
- agent_eval/metrics/context_recall.py +71 -0
- agent_eval/metrics/faithfulness.py +72 -0
- agent_eval/metrics/llm_judge.py +100 -0
- agent_eval/metrics/prompt_metric.py +150 -0
- agent_eval/metrics/pytest_plugin.py +308 -0
- agent_eval/metrics/report.py +149 -0
- agent_eval/metrics/synthetic_data.py +203 -0
- agent_eval/storage/__init__.py +17 -0
- agent_eval/storage/memory.py +95 -0
- agent_eval/storage/sqlite.py +240 -0
- agent_eval/trace/__init__.py +16 -0
- agent_eval/trace/phoenix.py +144 -0
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EvalRunner — the core orchestration engine.
|
|
3
|
+
|
|
4
|
+
Coordinates the full evaluation lifecycle:
|
|
5
|
+
1. Load suite
|
|
6
|
+
2. For each task, run N trials (TransientError → exponential-backoff retry)
|
|
7
|
+
3. For each trial: snapshot → setup → run agent → get traces → grade → teardown → leak check
|
|
8
|
+
4. Aggregate results into a RunSummary (pass@k / pass^k / consistency / saturation)
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
runner = EvalRunner(agent_runner=my_runner)
|
|
12
|
+
result = await runner.run_suite(suite)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import contextlib
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import time
|
|
23
|
+
import uuid
|
|
24
|
+
from collections.abc import Awaitable, Callable
|
|
25
|
+
from dataclasses import replace
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from agent_eval.core.contract import (
|
|
29
|
+
AgentRunner,
|
|
30
|
+
EnvironmentManager,
|
|
31
|
+
EvalContext,
|
|
32
|
+
Grader,
|
|
33
|
+
Storage,
|
|
34
|
+
TraceProvider,
|
|
35
|
+
TransientError,
|
|
36
|
+
)
|
|
37
|
+
from agent_eval.core.metrics import aggregate_metrics, extract_metrics, pass_at_k, pass_power_k
|
|
38
|
+
from agent_eval.core.types import (
|
|
39
|
+
EvalSuite,
|
|
40
|
+
EvalTask,
|
|
41
|
+
GraderConfig,
|
|
42
|
+
GraderResult,
|
|
43
|
+
GraderType,
|
|
44
|
+
RunResult,
|
|
45
|
+
RunSummary,
|
|
46
|
+
TaskSummary,
|
|
47
|
+
TrialResult,
|
|
48
|
+
)
|
|
49
|
+
from agent_eval.graders import DEFAULT_GRADERS
|
|
50
|
+
from agent_eval.metrics.base import Metric
|
|
51
|
+
from agent_eval.metrics.llm_judge import LLMFn
|
|
52
|
+
from agent_eval.storage import MemoryStorage
|
|
53
|
+
from agent_eval.trace import PhoenixProvider
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ─── Default Environment (NoOp) ──────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class NoOpEnvironment:
|
|
62
|
+
"""默认无操作环境管理器"""
|
|
63
|
+
|
|
64
|
+
async def setup(self, task: EvalTask) -> None:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
async def teardown(self, task: EvalTask) -> None:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
async def snapshot(self) -> dict[str, Any]:
|
|
71
|
+
return {}
|
|
72
|
+
|
|
73
|
+
async def verify_clean(self, baseline: dict[str, Any]) -> dict[str, Any]:
|
|
74
|
+
return {"clean": True, "differences": []}
|
|
75
|
+
|
|
76
|
+
async def restore(self, baseline: dict[str, Any]) -> None:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ─── Progress Callback Type ──────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
ProgressCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ─── EvalRunner ───────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class EvalRunner:
|
|
89
|
+
"""
|
|
90
|
+
核心编排器。
|
|
91
|
+
|
|
92
|
+
接收项目注入的组件, 执行评测。
|
|
93
|
+
所有组件都有默认值, 只需配置你关心的部分。
|
|
94
|
+
|
|
95
|
+
使用方式:
|
|
96
|
+
runner = EvalRunner(
|
|
97
|
+
agent_runner=MyAgentRunner(), # 必选
|
|
98
|
+
trace_provider=PhoenixProvider(...), # 可选, 默认 Phoenix
|
|
99
|
+
storage=SqliteStorage(...), # 可选, 默认 Memory
|
|
100
|
+
environment=MyEnvironment(), # 可选, 默认 NoOp
|
|
101
|
+
graders=[MyCustomGrader()], # 可选, 与内置合并 (同名覆盖)
|
|
102
|
+
concurrency=1, # trial 并发数
|
|
103
|
+
)
|
|
104
|
+
result = await runner.run_suite(suite)
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
agent_runner: AgentRunner,
|
|
110
|
+
trace_provider: TraceProvider | None = None,
|
|
111
|
+
storage: Storage | None = None,
|
|
112
|
+
environment: EnvironmentManager | None = None,
|
|
113
|
+
graders: list[Grader] | None = None,
|
|
114
|
+
concurrency: int = 1,
|
|
115
|
+
max_concurrent_graders: int = 4,
|
|
116
|
+
per_trial_timeout: float = 300.0,
|
|
117
|
+
max_trial_retries: int = 2,
|
|
118
|
+
retry_base_delay: float = 1.0,
|
|
119
|
+
grader_timeout: float = 60.0,
|
|
120
|
+
verify_environment: bool = True,
|
|
121
|
+
enable_grader_cache: bool = True,
|
|
122
|
+
metrics_registry: dict[str, Metric] | None = None,
|
|
123
|
+
llm_fn: LLMFn | None = None,
|
|
124
|
+
):
|
|
125
|
+
"""
|
|
126
|
+
Args:
|
|
127
|
+
agent_runner: Agent 运行器 (必选)
|
|
128
|
+
trace_provider: Trace 数据提供者 (默认 Phoenix)
|
|
129
|
+
storage: 结果存储 (默认 Memory; 组合暴露 storage.datasets)
|
|
130
|
+
environment: 环境管理器 (默认 NoOp)
|
|
131
|
+
graders: 额外评分器 (与内置合并, 同名覆盖)
|
|
132
|
+
concurrency: trial 并发数
|
|
133
|
+
max_concurrent_graders: 评分器最大并发数
|
|
134
|
+
per_trial_timeout: 单个 trial 超时 (秒)
|
|
135
|
+
max_trial_retries: TransientError 最大重试次数
|
|
136
|
+
retry_base_delay: 重试指数退避基础延迟 (秒)
|
|
137
|
+
grader_timeout: 单个 grader 评分超时 (秒)
|
|
138
|
+
verify_environment: 是否做环境泄漏检测
|
|
139
|
+
enable_grader_cache: 是否启用 grader 结果缓存 (prompt-hash)
|
|
140
|
+
metrics_registry: LLM 输出质量指标注册表 (name → Metric),
|
|
141
|
+
供 metric grader 分发; None = metric 类 grader 得配置错误结果
|
|
142
|
+
llm_fn: LLM 函数 (system, user) → text, 注入未配置的指标与
|
|
143
|
+
metric grader; None 不改变既有行为
|
|
144
|
+
"""
|
|
145
|
+
self.agent_runner = agent_runner
|
|
146
|
+
self.trace_provider = trace_provider or PhoenixProvider()
|
|
147
|
+
self.storage = storage or MemoryStorage()
|
|
148
|
+
self.environment = environment or NoOpEnvironment()
|
|
149
|
+
self.concurrency = max(1, concurrency)
|
|
150
|
+
self.max_concurrent_graders = max(1, max_concurrent_graders)
|
|
151
|
+
self.per_trial_timeout = per_trial_timeout
|
|
152
|
+
self.max_trial_retries = max(0, max_trial_retries)
|
|
153
|
+
self.retry_base_delay = max(0.0, retry_base_delay)
|
|
154
|
+
self.grader_timeout = grader_timeout
|
|
155
|
+
self.verify_environment = verify_environment
|
|
156
|
+
self.enable_grader_cache = enable_grader_cache
|
|
157
|
+
self.metrics_registry: dict[str, Metric] = dict(metrics_registry or {})
|
|
158
|
+
self.llm_fn = llm_fn
|
|
159
|
+
|
|
160
|
+
# 注册 grader: 内置 + 自定义 (自定义覆盖同名)
|
|
161
|
+
self._graders: dict[str, Grader] = {}
|
|
162
|
+
for g in DEFAULT_GRADERS:
|
|
163
|
+
self._graders[g.name] = g
|
|
164
|
+
if graders:
|
|
165
|
+
for g in graders:
|
|
166
|
+
self._graders[g.name] = g
|
|
167
|
+
|
|
168
|
+
# 注入 storage 到需要它的 grader (如 HumanGrader 的评分请求落库)。
|
|
169
|
+
# 覆盖式注入: runner 是组合根, grader 实例可能被多个 runner 复用。
|
|
170
|
+
for g in self._graders.values():
|
|
171
|
+
if hasattr(g, "storage"):
|
|
172
|
+
g.storage = self.storage
|
|
173
|
+
# metric 分发 grader: 注入指标注册表与 LLM 函数 (D1/D2)
|
|
174
|
+
if hasattr(g, "metrics_registry"):
|
|
175
|
+
g.metrics_registry = self.metrics_registry
|
|
176
|
+
if hasattr(g, "llm_fn") and self.llm_fn is not None and g.llm_fn is None:
|
|
177
|
+
g.llm_fn = self.llm_fn
|
|
178
|
+
|
|
179
|
+
# LLM 函数注入未自行配置的指标 (指标实现持有 llm_fn 属性)
|
|
180
|
+
if self.llm_fn is not None:
|
|
181
|
+
for metric in self.metrics_registry.values():
|
|
182
|
+
if getattr(metric, "llm_fn", None) is None:
|
|
183
|
+
metric.llm_fn = self.llm_fn
|
|
184
|
+
|
|
185
|
+
# Grader 结果缓存 (runner 生命周期内, 内容寻址)
|
|
186
|
+
self._grader_cache: dict[str, GraderResult] = {}
|
|
187
|
+
|
|
188
|
+
# ── Public API ────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
async def run_suite(
|
|
191
|
+
self,
|
|
192
|
+
suite: EvalSuite,
|
|
193
|
+
callback: ProgressCallback | None = None,
|
|
194
|
+
run_id: str | None = None,
|
|
195
|
+
) -> RunResult:
|
|
196
|
+
"""
|
|
197
|
+
执行整个 suite。
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
suite: 评测套件
|
|
201
|
+
callback: 进度回调 (用于 SSE 推送)
|
|
202
|
+
run_id: 指定 run ID (API 层预生成, 便于启动即返回)
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
RunResult: 完整运行结果
|
|
206
|
+
"""
|
|
207
|
+
run = RunResult(
|
|
208
|
+
run_id=run_id or f"run_{uuid.uuid4().hex[:12]}",
|
|
209
|
+
suite_name=suite.name,
|
|
210
|
+
status="running",
|
|
211
|
+
started_at=time.time() * 1000,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
# 保存 suite 定义与初始 run 记录 (保证启动后立即可查询)
|
|
216
|
+
await self.storage.save_suite(suite)
|
|
217
|
+
await self.storage.save_run(run)
|
|
218
|
+
|
|
219
|
+
for task in suite.tasks:
|
|
220
|
+
await self._emit(callback, "task_start", {
|
|
221
|
+
"task_id": task.id,
|
|
222
|
+
"task_description": task.description,
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
trials = await self._run_task_with_retries(task, callback, run_id=run.run_id)
|
|
226
|
+
run.trials[task.id] = trials
|
|
227
|
+
|
|
228
|
+
pass_rate = (
|
|
229
|
+
sum(1 for t in trials if t.success) / len(trials)
|
|
230
|
+
if trials else 0.0
|
|
231
|
+
)
|
|
232
|
+
await self._emit(callback, "task_complete", {
|
|
233
|
+
"task_id": task.id,
|
|
234
|
+
"trials": len(trials),
|
|
235
|
+
"pass_rate": pass_rate,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
# 计算汇总
|
|
239
|
+
run.summary = self._compute_summary(run, suite)
|
|
240
|
+
run.status = "completed"
|
|
241
|
+
|
|
242
|
+
except asyncio.CancelledError:
|
|
243
|
+
run.status = "cancelled"
|
|
244
|
+
raise
|
|
245
|
+
|
|
246
|
+
except Exception as e:
|
|
247
|
+
run.status = "failed"
|
|
248
|
+
run.error = str(e)
|
|
249
|
+
|
|
250
|
+
finally:
|
|
251
|
+
# 所有退出路径 (含启动阶段被取消) 都落盘最终状态
|
|
252
|
+
run.completed_at = time.time() * 1000
|
|
253
|
+
await self.storage.save_run(run)
|
|
254
|
+
|
|
255
|
+
return run
|
|
256
|
+
|
|
257
|
+
async def cancel_run(self, run_id: str) -> bool:
|
|
258
|
+
"""
|
|
259
|
+
取消正在运行的 suite。
|
|
260
|
+
|
|
261
|
+
注意: 正在执行的 trial 会继续完成, 但后续 task 不再启动。
|
|
262
|
+
"""
|
|
263
|
+
# TODO: 实现取消逻辑 (通过事件/标志位)
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
# ── Task Execution ───────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
async def _run_task_with_retries(
|
|
269
|
+
self,
|
|
270
|
+
task: EvalTask,
|
|
271
|
+
callback: ProgressCallback | None,
|
|
272
|
+
run_id: str = "",
|
|
273
|
+
) -> list[TrialResult]:
|
|
274
|
+
"""执行单个任务的多个 trial (TransientError 指数退避重试)"""
|
|
275
|
+
semaphore = asyncio.Semaphore(self.concurrency)
|
|
276
|
+
|
|
277
|
+
async def _trial(index: int) -> TrialResult:
|
|
278
|
+
async with semaphore:
|
|
279
|
+
# trial_start 发一次 (重试不重复发; 事件按 (task_id, trial_index) 幂等)
|
|
280
|
+
await self._emit(callback, "trial_start", {
|
|
281
|
+
"task_id": task.id,
|
|
282
|
+
"trial_index": index,
|
|
283
|
+
})
|
|
284
|
+
for attempt in range(self.max_trial_retries + 1):
|
|
285
|
+
try:
|
|
286
|
+
result = await self._run_trial(task, index, run_id=run_id)
|
|
287
|
+
await self._emit(callback, "trial_complete", {
|
|
288
|
+
"task_id": task.id,
|
|
289
|
+
"trial_index": index,
|
|
290
|
+
"success": result.success,
|
|
291
|
+
})
|
|
292
|
+
return result
|
|
293
|
+
except TransientError as e:
|
|
294
|
+
if attempt < self.max_trial_retries:
|
|
295
|
+
delay = self.retry_base_delay * (2**attempt)
|
|
296
|
+
logger.warning(
|
|
297
|
+
"TransientError in task %s trial %d (attempt %d), "
|
|
298
|
+
"retrying in %.2fs: %s",
|
|
299
|
+
task.id, index, attempt + 1, delay, e,
|
|
300
|
+
)
|
|
301
|
+
await asyncio.sleep(delay)
|
|
302
|
+
continue
|
|
303
|
+
# 重试用尽, 返回失败结果 (suite 继续)
|
|
304
|
+
return TrialResult(
|
|
305
|
+
trial_index=index,
|
|
306
|
+
trace_id="",
|
|
307
|
+
success=False,
|
|
308
|
+
grader_results=[],
|
|
309
|
+
metrics={},
|
|
310
|
+
transcript=[],
|
|
311
|
+
outcome={},
|
|
312
|
+
duration_ms=0.0,
|
|
313
|
+
error=(
|
|
314
|
+
f"TransientError after {self.max_trial_retries} "
|
|
315
|
+
f"retries: {e}"
|
|
316
|
+
),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
trials = await asyncio.gather(
|
|
320
|
+
*[_trial(i) for i in range(task.max_trials)],
|
|
321
|
+
return_exceptions=False,
|
|
322
|
+
)
|
|
323
|
+
return list(trials)
|
|
324
|
+
|
|
325
|
+
async def _run_trial(
|
|
326
|
+
self,
|
|
327
|
+
task: EvalTask,
|
|
328
|
+
index: int,
|
|
329
|
+
run_id: str = "",
|
|
330
|
+
) -> TrialResult:
|
|
331
|
+
"""执行单次 trial (含环境基线快照与泄漏检测)"""
|
|
332
|
+
# 1. 拍摄环境基线快照 (setup 前, 即"干净"状态)
|
|
333
|
+
try:
|
|
334
|
+
baseline = await self.environment.snapshot()
|
|
335
|
+
except Exception as e:
|
|
336
|
+
logger.warning("Environment snapshot failed for task %s: %s", task.id, e)
|
|
337
|
+
baseline = {}
|
|
338
|
+
|
|
339
|
+
# 2. 准备环境
|
|
340
|
+
await self.environment.setup(task)
|
|
341
|
+
start_time = time.time() * 1000
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
# 3. 运行 Agent (带超时)
|
|
345
|
+
trace_id, transcript, outcome = await asyncio.wait_for(
|
|
346
|
+
self.agent_runner.run(task),
|
|
347
|
+
timeout=self.per_trial_timeout,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# 4. 获取 trace spans
|
|
351
|
+
spans = await self.trace_provider.get_spans(trace_id)
|
|
352
|
+
|
|
353
|
+
# 5. 提取过程指标
|
|
354
|
+
metrics = extract_metrics(spans, task.tracked_metrics)
|
|
355
|
+
elapsed = time.time() * 1000 - start_time
|
|
356
|
+
metrics["latency_ms"] = elapsed
|
|
357
|
+
|
|
358
|
+
# 6. 构建 trial result (临时 success=True, 评分后更新)
|
|
359
|
+
trial = TrialResult(
|
|
360
|
+
trial_index=index,
|
|
361
|
+
trace_id=trace_id,
|
|
362
|
+
success=True,
|
|
363
|
+
grader_results=[],
|
|
364
|
+
metrics=metrics,
|
|
365
|
+
transcript=transcript,
|
|
366
|
+
outcome=outcome,
|
|
367
|
+
duration_ms=elapsed,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# 7. 运行评分器 (EvalContext 贯穿, shared_state 在 grader 间共享)
|
|
371
|
+
context = EvalContext(
|
|
372
|
+
run_id=run_id,
|
|
373
|
+
task=task,
|
|
374
|
+
trial=trial,
|
|
375
|
+
spans=spans,
|
|
376
|
+
shared_state={},
|
|
377
|
+
)
|
|
378
|
+
trial = await self._grade_trial(trial, spans, task, context)
|
|
379
|
+
|
|
380
|
+
return trial
|
|
381
|
+
|
|
382
|
+
except TimeoutError:
|
|
383
|
+
elapsed = time.time() * 1000 - start_time
|
|
384
|
+
return TrialResult(
|
|
385
|
+
trial_index=index,
|
|
386
|
+
trace_id="",
|
|
387
|
+
success=False,
|
|
388
|
+
grader_results=[],
|
|
389
|
+
metrics={"latency_ms": elapsed},
|
|
390
|
+
transcript=[],
|
|
391
|
+
outcome={},
|
|
392
|
+
duration_ms=elapsed,
|
|
393
|
+
error=f"Trial timed out after {self.per_trial_timeout}s",
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
except asyncio.CancelledError:
|
|
397
|
+
raise
|
|
398
|
+
|
|
399
|
+
except TransientError:
|
|
400
|
+
# 交由 _run_task_with_retries 处理重试
|
|
401
|
+
raise
|
|
402
|
+
|
|
403
|
+
except Exception as e:
|
|
404
|
+
elapsed = time.time() * 1000 - start_time
|
|
405
|
+
return TrialResult(
|
|
406
|
+
trial_index=index,
|
|
407
|
+
trace_id="",
|
|
408
|
+
success=False,
|
|
409
|
+
grader_results=[],
|
|
410
|
+
metrics={"latency_ms": elapsed},
|
|
411
|
+
transcript=[],
|
|
412
|
+
outcome={},
|
|
413
|
+
duration_ms=elapsed,
|
|
414
|
+
error=str(e),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
finally:
|
|
418
|
+
# 8. 清理环境
|
|
419
|
+
await self.environment.teardown(task)
|
|
420
|
+
|
|
421
|
+
# 9. 泄漏检测: 与基线比对, 泄漏则告警并恢复 (不判 trial 失败 —
|
|
422
|
+
# 泄漏是环境问题不是 Agent 问题)
|
|
423
|
+
if self.verify_environment:
|
|
424
|
+
try:
|
|
425
|
+
verify = await self.environment.verify_clean(baseline)
|
|
426
|
+
if not verify.get("clean", False):
|
|
427
|
+
logger.warning(
|
|
428
|
+
"Environment leak detected in task %s trial %d: %s",
|
|
429
|
+
task.id,
|
|
430
|
+
index,
|
|
431
|
+
verify.get("differences"),
|
|
432
|
+
)
|
|
433
|
+
await self.environment.restore(baseline)
|
|
434
|
+
except Exception as e:
|
|
435
|
+
logger.warning(
|
|
436
|
+
"Environment leak check failed for task %s trial %d: %s",
|
|
437
|
+
task.id, index, e,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# ── Grading ──────────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
async def _grade_trial(
|
|
443
|
+
self,
|
|
444
|
+
trial: TrialResult,
|
|
445
|
+
spans: list[dict[str, Any]],
|
|
446
|
+
task: EvalTask,
|
|
447
|
+
context: EvalContext | None = None,
|
|
448
|
+
) -> TrialResult:
|
|
449
|
+
"""
|
|
450
|
+
对一次 trial 运行所有评分器 (依赖拓扑 Pipeline)。
|
|
451
|
+
|
|
452
|
+
1. 按 GraderConfig.dependencies 拓扑排序
|
|
453
|
+
2. 依赖 grader 未通过 (或未配置/未运行) → 跳过记 0 分
|
|
454
|
+
3. 单个 grader 超时 → 记 0 分失败
|
|
455
|
+
4. MODEL 类 grader sample_count > 1 → 多采样聚合 (平均分/不确定性/置信度)
|
|
456
|
+
5. sample_count == 1 且缓存开启 → prompt-hash 结果缓存
|
|
457
|
+
"""
|
|
458
|
+
grader_results: dict[str, GraderResult] = {}
|
|
459
|
+
|
|
460
|
+
for config in self._topological_sort(task.graders):
|
|
461
|
+
grader = self._resolve_grader(config)
|
|
462
|
+
|
|
463
|
+
if grader is None:
|
|
464
|
+
grader_results[config.name] = GraderResult(
|
|
465
|
+
grader_name=config.name,
|
|
466
|
+
grader_type=config.type,
|
|
467
|
+
score=0.0,
|
|
468
|
+
passed=False,
|
|
469
|
+
explanation=f"Unknown grader: {config.name}",
|
|
470
|
+
)
|
|
471
|
+
continue
|
|
472
|
+
|
|
473
|
+
# 依赖检查: 所有依赖的 grader 必须已运行且通过
|
|
474
|
+
unsatisfied_dep = self._first_unsatisfied_dependency(config, grader_results)
|
|
475
|
+
if unsatisfied_dep is not None:
|
|
476
|
+
grader_results[config.name] = GraderResult(
|
|
477
|
+
grader_name=config.name,
|
|
478
|
+
grader_type=config.type,
|
|
479
|
+
score=0.0,
|
|
480
|
+
passed=False,
|
|
481
|
+
explanation=(
|
|
482
|
+
f"依赖未满足: grader '{unsatisfied_dep}' 未通过或未配置"
|
|
483
|
+
),
|
|
484
|
+
)
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
# 缓存命中 (多采样 deliberate 重试绕过缓存)
|
|
488
|
+
use_cache = self.enable_grader_cache and config.sample_count <= 1
|
|
489
|
+
if use_cache:
|
|
490
|
+
cache_key = self._grader_cache_key(config, trial)
|
|
491
|
+
cached = self._grader_cache.get(cache_key)
|
|
492
|
+
if cached is not None:
|
|
493
|
+
hit = cached.model_copy(deep=True)
|
|
494
|
+
hit.details = {**hit.details, "cached": True}
|
|
495
|
+
grader_results[config.name] = hit
|
|
496
|
+
continue
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
# 分发型 grader (MetricGrader) 经 context.grader_config 感知
|
|
500
|
+
# 当前生效配置; 其余 grader 不读取该字段, 行为不变
|
|
501
|
+
call_context = (
|
|
502
|
+
replace(context, grader_config=config)
|
|
503
|
+
if context is not None
|
|
504
|
+
else None
|
|
505
|
+
)
|
|
506
|
+
result = await asyncio.wait_for(
|
|
507
|
+
grader.grade(trial, spans, task, call_context),
|
|
508
|
+
timeout=self.grader_timeout,
|
|
509
|
+
)
|
|
510
|
+
except TimeoutError:
|
|
511
|
+
result = GraderResult(
|
|
512
|
+
grader_name=config.name,
|
|
513
|
+
grader_type=config.type,
|
|
514
|
+
score=0.0,
|
|
515
|
+
passed=False,
|
|
516
|
+
explanation=f"Grader timeout after {self.grader_timeout}s",
|
|
517
|
+
)
|
|
518
|
+
except Exception as e:
|
|
519
|
+
result = GraderResult(
|
|
520
|
+
grader_name=config.name,
|
|
521
|
+
grader_type=config.type,
|
|
522
|
+
score=0.0,
|
|
523
|
+
passed=False,
|
|
524
|
+
explanation=f"Grader error: {e}",
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# LLM Judge 多采样: 计算平均分/不确定性/置信度
|
|
528
|
+
if config.type == GraderType.MODEL and config.sample_count > 1:
|
|
529
|
+
result = await self._multi_sample(grader, result, config, trial, spans, task, context)
|
|
530
|
+
|
|
531
|
+
if use_cache:
|
|
532
|
+
self._grader_cache[cache_key] = result.model_copy(deep=True)
|
|
533
|
+
|
|
534
|
+
grader_results[config.name] = result
|
|
535
|
+
|
|
536
|
+
trial.grader_results = [
|
|
537
|
+
grader_results[config.name] for config in task.graders
|
|
538
|
+
]
|
|
539
|
+
|
|
540
|
+
# 根据评分策略计算最终成功状态
|
|
541
|
+
trial.success = self._compute_trial_success(task, trial.grader_results)
|
|
542
|
+
|
|
543
|
+
return trial
|
|
544
|
+
|
|
545
|
+
def _resolve_grader(self, config: GraderConfig) -> Grader | None:
|
|
546
|
+
"""按配置名解析 grader 实例。
|
|
547
|
+
|
|
548
|
+
metric 类配置 (type: metric) 的 name 即指标名, 不在 grader 注册表
|
|
549
|
+
内时回退到 "metric" 分发器 (D1), 由其按 metric_name/config.name
|
|
550
|
+
路由到注入的 Metric 实例; 其余类型保持未知 grader 语义。
|
|
551
|
+
"""
|
|
552
|
+
grader = self._graders.get(config.name)
|
|
553
|
+
if grader is not None:
|
|
554
|
+
return grader
|
|
555
|
+
if config.type == GraderType.METRIC:
|
|
556
|
+
return self._graders.get("metric")
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
async def _multi_sample(
|
|
560
|
+
self,
|
|
561
|
+
grader: Grader,
|
|
562
|
+
first_result: GraderResult,
|
|
563
|
+
config: GraderConfig,
|
|
564
|
+
trial: TrialResult,
|
|
565
|
+
spans: list[dict[str, Any]],
|
|
566
|
+
task: EvalTask,
|
|
567
|
+
context: EvalContext | None,
|
|
568
|
+
) -> GraderResult:
|
|
569
|
+
"""多采样评分: 平均分 + 不确定性 (极差/2) + 置信度 (1 - 不确定性)"""
|
|
570
|
+
samples = [first_result]
|
|
571
|
+
for _ in range(config.sample_count - 1):
|
|
572
|
+
try:
|
|
573
|
+
samples.append(await grader.grade(trial, spans, task, context))
|
|
574
|
+
except Exception as e:
|
|
575
|
+
samples.append(GraderResult(
|
|
576
|
+
grader_name=config.name,
|
|
577
|
+
grader_type=config.type,
|
|
578
|
+
score=0.0,
|
|
579
|
+
passed=False,
|
|
580
|
+
explanation=f"Grader error: {e}",
|
|
581
|
+
))
|
|
582
|
+
|
|
583
|
+
scores = [s.score for s in samples]
|
|
584
|
+
avg_score = sum(scores) / len(scores)
|
|
585
|
+
uncertainty = (max(scores) - min(scores)) / 2
|
|
586
|
+
confidence = max(0.0, 1.0 - uncertainty)
|
|
587
|
+
# 多数样本通过才视为通过 (偶数采样平票判不通过)
|
|
588
|
+
passed = sum(1 for s in samples if s.passed) * 2 > len(samples)
|
|
589
|
+
|
|
590
|
+
return GraderResult(
|
|
591
|
+
grader_name=first_result.grader_name,
|
|
592
|
+
grader_type=first_result.grader_type,
|
|
593
|
+
score=avg_score,
|
|
594
|
+
passed=passed,
|
|
595
|
+
explanation=f"Multi-sample avg over {len(samples)} samples",
|
|
596
|
+
details={
|
|
597
|
+
**first_result.details,
|
|
598
|
+
"sample_scores": scores,
|
|
599
|
+
"sample_explanations": [s.explanation for s in samples],
|
|
600
|
+
},
|
|
601
|
+
confidence=confidence,
|
|
602
|
+
uncertainty=uncertainty,
|
|
603
|
+
sample_count=len(samples),
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
def _first_unsatisfied_dependency(
|
|
607
|
+
self,
|
|
608
|
+
config: GraderConfig,
|
|
609
|
+
grader_results: dict[str, GraderResult],
|
|
610
|
+
) -> str | None:
|
|
611
|
+
"""返回第一个未满足的依赖名; 全部满足返回 None。
|
|
612
|
+
|
|
613
|
+
依赖未在本 task 中配置 (因而没有结果) 视为未满足。
|
|
614
|
+
"""
|
|
615
|
+
for dep in config.dependencies:
|
|
616
|
+
result = grader_results.get(dep)
|
|
617
|
+
if result is None or not result.passed:
|
|
618
|
+
return dep
|
|
619
|
+
return None
|
|
620
|
+
|
|
621
|
+
def _topological_sort(
|
|
622
|
+
self,
|
|
623
|
+
configs: list[GraderConfig],
|
|
624
|
+
) -> list[GraderConfig]:
|
|
625
|
+
"""按 dependencies 拓扑排序 (保持声明顺序稳定, 环依赖按声明序兜底)"""
|
|
626
|
+
config_map = {c.name: c for c in configs}
|
|
627
|
+
visited: set[str] = set()
|
|
628
|
+
visiting: set[str] = set()
|
|
629
|
+
ordered: list[GraderConfig] = []
|
|
630
|
+
|
|
631
|
+
def visit(name: str) -> None:
|
|
632
|
+
if name in visited or name not in config_map:
|
|
633
|
+
return
|
|
634
|
+
if name in visiting:
|
|
635
|
+
# 环依赖: 跳过 (由依赖检查兜底记 0 分)
|
|
636
|
+
return
|
|
637
|
+
visiting.add(name)
|
|
638
|
+
for dep in config_map[name].dependencies:
|
|
639
|
+
visit(dep)
|
|
640
|
+
visiting.discard(name)
|
|
641
|
+
visited.add(name)
|
|
642
|
+
ordered.append(config_map[name])
|
|
643
|
+
|
|
644
|
+
for config in configs:
|
|
645
|
+
visit(config.name)
|
|
646
|
+
return ordered
|
|
647
|
+
|
|
648
|
+
def _grader_cache_key(self, config: GraderConfig, trial: TrialResult) -> str:
|
|
649
|
+
"""prompt-hash 缓存 key: sha256(grader name + config + transcript/outcome)"""
|
|
650
|
+
payload = json.dumps(
|
|
651
|
+
{
|
|
652
|
+
"grader": config.name,
|
|
653
|
+
"config": config.config,
|
|
654
|
+
"transcript": trial.transcript,
|
|
655
|
+
"outcome": trial.outcome,
|
|
656
|
+
},
|
|
657
|
+
sort_keys=True,
|
|
658
|
+
default=str,
|
|
659
|
+
)
|
|
660
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
661
|
+
|
|
662
|
+
def _compute_trial_success(
|
|
663
|
+
self,
|
|
664
|
+
task: EvalTask,
|
|
665
|
+
grader_results: list[GraderResult],
|
|
666
|
+
) -> bool:
|
|
667
|
+
"""根据评分策略判断 trial 是否成功"""
|
|
668
|
+
strategy = task.score_strategy
|
|
669
|
+
threshold = task.score_threshold
|
|
670
|
+
|
|
671
|
+
if strategy == "all_pass":
|
|
672
|
+
return all(r.passed for r in grader_results) if grader_results else True
|
|
673
|
+
|
|
674
|
+
elif strategy == "weighted":
|
|
675
|
+
if not grader_results:
|
|
676
|
+
return True
|
|
677
|
+
# 使用 grader config 中的权重
|
|
678
|
+
total_weight = sum(
|
|
679
|
+
gc.weight for gc in task.graders
|
|
680
|
+
)
|
|
681
|
+
if total_weight == 0:
|
|
682
|
+
return True
|
|
683
|
+
weighted_score = sum(
|
|
684
|
+
r.score * gc.weight
|
|
685
|
+
# strict=False: replay 路径 (runs.py) 的 grader_results 可能来自
|
|
686
|
+
# 旧版本 suite, 长度不保证一致 — 保持截断语义
|
|
687
|
+
for r, gc in zip(grader_results, task.graders, strict=False)
|
|
688
|
+
) / total_weight
|
|
689
|
+
return weighted_score >= threshold
|
|
690
|
+
|
|
691
|
+
elif strategy == "hybrid":
|
|
692
|
+
# required 必须通过
|
|
693
|
+
required_pass = all(
|
|
694
|
+
r.passed
|
|
695
|
+
for r, gc in zip(grader_results, task.graders, strict=False)
|
|
696
|
+
if gc.required
|
|
697
|
+
)
|
|
698
|
+
if not required_pass:
|
|
699
|
+
return False
|
|
700
|
+
|
|
701
|
+
# 非 required 加权
|
|
702
|
+
non_required = [
|
|
703
|
+
(r, gc) for r, gc in zip(grader_results, task.graders, strict=False)
|
|
704
|
+
if not gc.required
|
|
705
|
+
]
|
|
706
|
+
if not non_required:
|
|
707
|
+
return True
|
|
708
|
+
|
|
709
|
+
total_weight = sum(gc.weight for _, gc in non_required)
|
|
710
|
+
if total_weight == 0:
|
|
711
|
+
return True
|
|
712
|
+
weighted_score = sum(
|
|
713
|
+
r.score * gc.weight for r, gc in non_required
|
|
714
|
+
) / total_weight
|
|
715
|
+
return weighted_score >= threshold
|
|
716
|
+
|
|
717
|
+
return True
|
|
718
|
+
|
|
719
|
+
# ── Summary Computation ──────────────────────────────────────────────
|
|
720
|
+
|
|
721
|
+
def _compute_summary(
|
|
722
|
+
self,
|
|
723
|
+
run: RunResult,
|
|
724
|
+
suite: EvalSuite,
|
|
725
|
+
) -> RunSummary:
|
|
726
|
+
"""计算 suite 级别的汇总 (含一致性检查 / 饱和度检测 / pending 单列)"""
|
|
727
|
+
task_summaries: list[TaskSummary] = []
|
|
728
|
+
all_trials: list[TrialResult] = []
|
|
729
|
+
|
|
730
|
+
# 构建 task_id → description 映射
|
|
731
|
+
task_descriptions = {t.id: t.description for t in suite.tasks}
|
|
732
|
+
|
|
733
|
+
# 预计算 k_values (避免空 trials 时 KeyError)
|
|
734
|
+
max_trials = max(
|
|
735
|
+
(len(trials) for trials in run.trials.values()), default=0
|
|
736
|
+
)
|
|
737
|
+
k_values = list(range(1, max_trials + 1)) if max_trials > 0 else [1]
|
|
738
|
+
|
|
739
|
+
for task_id, trials in run.trials.items():
|
|
740
|
+
all_trials.extend(trials)
|
|
741
|
+
|
|
742
|
+
# pending (人工评分未回传) 的 trial 单列, 不计入通过率
|
|
743
|
+
pending_indices = {
|
|
744
|
+
i for i, t in enumerate(trials) if self._trial_pending(t)
|
|
745
|
+
}
|
|
746
|
+
counted = [
|
|
747
|
+
t for i, t in enumerate(trials) if i not in pending_indices
|
|
748
|
+
]
|
|
749
|
+
|
|
750
|
+
p_at_k = {k: pass_at_k(counted, k) for k in k_values}
|
|
751
|
+
p_pow_k = {k: pass_power_k(counted, k) for k in k_values}
|
|
752
|
+
|
|
753
|
+
# 平均分 (仅计有评分结果的 trial)
|
|
754
|
+
scores = [t.avg_score() for t in counted if t.grader_results]
|
|
755
|
+
|
|
756
|
+
# trial 间一致性
|
|
757
|
+
consistency = self._check_trial_consistency(counted)
|
|
758
|
+
|
|
759
|
+
task_summaries.append(TaskSummary(
|
|
760
|
+
task_id=task_id,
|
|
761
|
+
task_description=task_descriptions.get(task_id, ""),
|
|
762
|
+
total_trials=len(trials),
|
|
763
|
+
pass_at_k=p_at_k,
|
|
764
|
+
pass_power_k=p_pow_k,
|
|
765
|
+
avg_score=sum(scores) / len(scores) if scores else 0.0,
|
|
766
|
+
avg_metrics=aggregate_metrics(trials),
|
|
767
|
+
failures=[
|
|
768
|
+
i for i, t in enumerate(trials)
|
|
769
|
+
if not t.success and i not in pending_indices
|
|
770
|
+
],
|
|
771
|
+
pending_trials=sorted(pending_indices),
|
|
772
|
+
consistent=consistency["consistent"],
|
|
773
|
+
score_std_dev=consistency["std_dev"],
|
|
774
|
+
))
|
|
775
|
+
|
|
776
|
+
# 全局汇总
|
|
777
|
+
return RunSummary(
|
|
778
|
+
total_tasks=len(run.trials),
|
|
779
|
+
total_trials=len(all_trials),
|
|
780
|
+
pass_at_k={
|
|
781
|
+
k: sum(
|
|
782
|
+
ts.pass_at_k.get(k, 0.0) for ts in task_summaries
|
|
783
|
+
) / len(task_summaries)
|
|
784
|
+
if task_summaries else 0.0
|
|
785
|
+
for k in k_values
|
|
786
|
+
},
|
|
787
|
+
pass_power_k={
|
|
788
|
+
k: sum(
|
|
789
|
+
ts.pass_power_k.get(k, 0.0) for ts in task_summaries
|
|
790
|
+
) / len(task_summaries)
|
|
791
|
+
if task_summaries else 0.0
|
|
792
|
+
for k in k_values
|
|
793
|
+
},
|
|
794
|
+
avg_score=(
|
|
795
|
+
sum(ts.avg_score for ts in task_summaries) / len(task_summaries)
|
|
796
|
+
if task_summaries else 0.0
|
|
797
|
+
),
|
|
798
|
+
avg_metrics=aggregate_metrics(all_trials),
|
|
799
|
+
task_summaries=task_summaries,
|
|
800
|
+
failures=[ts.task_id for ts in task_summaries if ts.failures],
|
|
801
|
+
saturation=self._detect_saturation(task_summaries),
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
@staticmethod
|
|
805
|
+
def _trial_pending(trial: TrialResult) -> bool:
|
|
806
|
+
"""trial 是否在等待人工评分 (含 pending 状态的 grader 结果)"""
|
|
807
|
+
return any(
|
|
808
|
+
gr.details.get("status") == "pending" for gr in trial.grader_results
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
@staticmethod
|
|
812
|
+
def _check_trial_consistency(trials: list[TrialResult]) -> dict[str, Any]:
|
|
813
|
+
"""检查 trial 间分数一致性 (std < 0.2 视为一致)"""
|
|
814
|
+
scores = [
|
|
815
|
+
t.avg_score() for t in trials if t.grader_results
|
|
816
|
+
]
|
|
817
|
+
|
|
818
|
+
if len(scores) < 2:
|
|
819
|
+
return {"consistent": True, "std_dev": 0.0, "scores": scores}
|
|
820
|
+
|
|
821
|
+
avg = sum(scores) / len(scores)
|
|
822
|
+
variance = sum((s - avg) ** 2 for s in scores) / len(scores)
|
|
823
|
+
std_dev = variance**0.5
|
|
824
|
+
|
|
825
|
+
return {
|
|
826
|
+
"consistent": std_dev < 0.2,
|
|
827
|
+
"std_dev": std_dev,
|
|
828
|
+
"scores": scores,
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
@staticmethod
|
|
832
|
+
def _detect_saturation(
|
|
833
|
+
task_summaries: list[TaskSummary],
|
|
834
|
+
threshold: float = 0.95,
|
|
835
|
+
) -> dict[str, Any]:
|
|
836
|
+
"""饱和度检测: 超过半数 task 的 pass@1 ≥ 0.95 → 建议加难"""
|
|
837
|
+
if not task_summaries:
|
|
838
|
+
return {"is_saturated": False, "saturation_ratio": 0.0}
|
|
839
|
+
|
|
840
|
+
saturated_tasks = [
|
|
841
|
+
ts.task_id for ts in task_summaries
|
|
842
|
+
if ts.pass_at_k.get(1, 0.0) >= threshold
|
|
843
|
+
]
|
|
844
|
+
saturation_ratio = len(saturated_tasks) / len(task_summaries)
|
|
845
|
+
is_saturated = saturation_ratio > 0.5
|
|
846
|
+
|
|
847
|
+
return {
|
|
848
|
+
"is_saturated": is_saturated,
|
|
849
|
+
"saturation_ratio": saturation_ratio,
|
|
850
|
+
"saturated_tasks": saturated_tasks,
|
|
851
|
+
"recommendation": (
|
|
852
|
+
"评测已饱和, 建议增加更有挑战性的任务" if is_saturated else None
|
|
853
|
+
),
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
# ── Helpers ──────────────────────────────────────────────────────────
|
|
857
|
+
|
|
858
|
+
async def _emit(
|
|
859
|
+
self,
|
|
860
|
+
callback: ProgressCallback | None,
|
|
861
|
+
event: str,
|
|
862
|
+
data: dict[str, Any],
|
|
863
|
+
) -> None:
|
|
864
|
+
"""发送进度事件"""
|
|
865
|
+
if callback is not None:
|
|
866
|
+
# 回调不应中断主流程
|
|
867
|
+
with contextlib.suppress(Exception):
|
|
868
|
+
await callback(event, data)
|