pass@1,
pass@k,
p95_latency,
tokens_per_success …
리포트에 찍히는 숫자 한 칸이 원시 run/event 로그 → 채점 → runs UPDATE → materialized view 집계
의 어느 단계에서, 정확히 어떤 SQL/코드로 만들어지는지를 끝까지 추적한다. 실제 live DB 수치로 검산까지.
eval_ph1.mv_batch_summary · 정의 출처:
migrations/002_views.sql · 017_hierarchy_views.sql,
src/agent_eval/{tracing/logger.py, gaia/scorer.py, runner.py, report.py, db.py}메트릭은 어디서도 "한 번에" 계산되지 않는다. 4단계 파이프라인을 거치며, 각 단계가 다음 단계의 입력 컬럼을 채운다. 집계 SQL(맨 끝)은 앞 3단계가 채워놓은 컬럼만 읽는다 — 그래서 메트릭을 이해하려면 "이 컬럼이 언제 채워졌나"를 먼저 알아야 한다.
events INSERT + run 단위 누적 (turns/tokens/latency)RunResult 반환success, format_validruns 한 행. success/turns/tokens/latency/terminated_by 확정mv_batch_summary · mv_passk · mv_tool_summary 재계산 (GROUP BY)runs 테이블은 이미 run 단위로 1차 환원(reduce)된 사실이다.
집계 뷰가 하는 일은 그 행들을 (batch_id, team_id, level) 또는
(batch_id, team_id, task_id) 로 묶어 평균·분위수·합을 내는 2차 환원뿐이다.
무거운 계산(토큰 합산, latency 측정, 채점)은 전부 ①②에서 끝나 있다.같은 "뷰"라도 성격이 정반대다. 메트릭이 stale 할 수 있는지가 여기서 갈린다.
계산 결과를 디스크에 굳혀 둔다. 원본 runs가 바뀌어도
REFRESH 전까지 옛 숫자 그대로. 무거운 GROUP BY를 매 조회마다 안 돌리려는 것.
| mv_batch_summary | pass@1·p95·tokens 등 핵심 KPI |
| mv_passk | task별 pass_any/pass_all |
| mv_tool_summary | 도구별 calls/errors/latency |
목록 SSOT: db.py · MATERIALIZED_VIEWS
저장 안 함. 조회할 때마다 원본을 즉석 조인 → REFRESH 불필요, 항상 실시간. 계층(batch⊃run⊃event)을 한 줄 WHERE로 펼치는 용도.
| v_runs | run 1개 = 1행 (재현성축 + 결과 평면화) |
| v_run_events | event 1개 = 1행 (소속 사슬 전체 부착) |
| v_run_features … | 분석/grounding 파생 (009·010·017) |
정의: migrations/017_hierarchy_views.sql 외
report --no-refresh로 조회하면 mv_*의
pass@1/p95는 과거 값이다. v_runs로 같은 걸 즉석 계산하면 최신이라 두 숫자가 어긋난다 —
버그가 아니라 설계다. (이제 report는 기본으로 REFRESH 후 출력하므로 평상시엔 최신.)집계 SQL이 읽는 runs 컬럼들은 ①STREAM/②SCORE 단계의 산물이다.
각 컬럼이 누가·어떻게 만드는지 못 박아 두면, 뒤의 모든 레시피가 자명해진다.
| runs 컬럼 | 만드는 곳 | 계산 방식 |
|---|---|---|
| success | scorer.py → runner UPDATE | format_valid AND normalize(pred)==normalize(gold). 숫자면 abs(a−b)<1e-6, 아니면 문자열 동등. 코드근거 |
| format_valid | scorer.py | 출력에서 정규식 FINAL ANSWER\s*:\s*(.+) 추출 성공 여부 (복수 매치면 마지막). 못 찾으면 false → success 강제 false. 코드근거 |
| total_turns | logger.py | 스트림 중 agent_message 이벤트 1건당 +1 누적. 코드근거 |
| total_tokens input/output | logger.py | 이벤트의 models_usage(prompt_tokens / completion_tokens)를 agent_message·tool_call에서 합산. total = input + output. 코드근거 |
| latency_ms | logger.py | run end-to-end = last_ts − first_ts (ms), ts는 logger가 이벤트 받은 벽시계 _now_utc(). max(0, …) 가드. 코드근거 |
| terminated_by | runner.py · _terminated_by() | AutoGen stop_reason 매핑 → max-messages 사유면 'max_messages', 정상 종료 'approve', 예외 'error'. 코드근거 |
| status | runner.py | 시작 시 'running' INSERT → 정상 끝 'completed' / 예외 'crashed'. 집계는 거의 다 status='completed'만 본다. 코드근거 |
CHECK (status!='completed' OR success IS NOT NULL) —
완료 run엔 success가 NULL일 수 없다. 그래서 AVG(success::int)의 분모가 새지 않는다.
토큰/latency도 >= 0 CHECK가 걸려 음수 오염이 원천 차단된다 (001_init.sql).가장 자주 인용되는 메트릭. 완료된 run 중 success=true의 비율이다. 그 이상도 이하도 아니다 — 시도 1회 기준 정확도.
AVG(r.success::int) AS pass_at_1 -- FROM runs r JOIN tasks t USING(task_id) -- WHERE r.status = 'completed' -- GROUP BY batch_id, team_id, level
success::int → true=1 / false=0. 그 평균이 곧 비율.WHERE에서 제외.(batch_id, team_id, level). 즉 레벨별로 따로 나온다.batch perf_gaia-loop-base, team gaia-loop-base, level 2:
| n_runs | 83 |
| success=true 개수 | ≈ 23 |
| pass@1 = 23 / 83 | 0.277 |
pass@k는 2단계로 만들어진다. 먼저 task 단위로 "여러 시도 중 하나라도/전부 성공했나"를 접고(mv_passk), 그 다음 team 단위로 센다(report.py). 단일 SQL이 아니라는 점이 핵심.
SELECT batch_id, team_id, task_id, COUNT(*) AS k -- 이 task를 몇 번 시도했나 BOOL_OR(success) AS pass_any -- k번 중 1번이라도 성공? BOOL_AND(success) AS pass_all -- k번 모두 성공? FROM runs WHERE status='completed' GROUP BY batch_id, team_id, task_id;
SELECT team_id, COUNT(*) AS tasks, SUM(pass_any::int) AS pass_any_count, -- ≥1 성공한 task 수 SUM(pass_all::int) AS pass_all_count, -- 전부 성공한 task 수 AVG(k) AS avg_k -- task당 평균 시도수 FROM mv_passk WHERE batch_id=$1 GROUP BY team_id;
| team | tasks | any | all | avg_k |
|---|---|---|---|---|
| v1-baseline | 437 | 225 | 225 | 1.02 |
| v5-multitool | 114 | 69 | 61 | 1.41 |
| v1-research-pipeline | 103 | 96 | 95 | 1.19 |
avg_k≈1.0(v1-baseline)이면 any=all=pass@1로 붕괴. avg_k=1.41(v5)이면 any(69)>all(61) — "어쩌다 한 번 맞춤"과 "안정적으로 맞춤"의 간극이 보인다.
1 − C(n−c,k)/C(n,k)이 아니다.
내 추론 (mv_passk SQL + report.py 코드 기준). 시도수 k가 task마다 다를 수 있어
(avg_k가 정수가 아님) 표준 pass@k와 직접 비교하려면 동일 n으로 정규화가 필요하다.AVG((NOT r.format_valid)::int) FILTER (WHERE r.status='completed') AS format_invalid_rate
format_valid는 scorer가 FINAL ANSWER: 패턴을 못 찾으면 false (§2).NOT format_valid를 1로 세서 평균 → 형식 위반율.FILTER (WHERE status='completed')는 바깥 WHERE와 중복이지만 방어적으로 남겨둠.AVG((r.terminated_by='max_messages')::int) AS loop_rate
terminated_by='max_messages' = 합의/종료 못 하고 max_messages 캡에서 강제 중단.team gaia-loop-base는 loop_rate가
레벨 1·2·3 모두 1.000 — 설계상 항상 루프에 빠지는 안티패턴 팀이라
terminated_by가 전부 max_messages. 메트릭이 그 병리를 정확히 1.0으로 집어낸다.
AVG(r.total_turns) AS avg_turns AVG(r.total_tokens) AS avg_tokens SUM(r.total_tokens)::float / NULLIF(SUM(r.success::int),0) AS tokens_per_success
tokens_per_success가 가장 오해받는 메트릭이다.
"정답 하나를 얻는 데 든 실제 토큰 값"으로, 분자에는 실패한 run이 태운 토큰까지 포함된다.
tokens_per_success ≈ avg_tokens / pass@1.NULLIF(…,0) = 성공 0개면 0으로 나누지 않고 NULL 반환 (∞ 방지).| lvl | n | pass@1 | avg_tok | tok/succ |
|---|---|---|---|---|
| 1 | 52 | 0.346 | 15,273 | 44,123 |
| 2 | 83 | 0.277 | 14,720 | 53,121 |
| 3 | 19 | 0.211 | 20,354 | 96,683 |
L2 검산: 14,720×83 = 1,221,760 토큰 총합 ÷ 23 성공 = 53,120 ≈ 53,121 ✓. 또 14,720 / 0.277 = 53,141 ≈ 동일.
-- p95: mv_batch_summary 안에 저장 PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY r.latency_ms) AS p95_latency -- p50: MV에 없음 → report.py가 runs에서 그때그때 계산 (fetch_p50_by_level) PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY r.latency_ms) AS p50_latency
PERCENTILE_CONT = 연속형 — 정렬 후 95% 지점을 두 표본 사이 선형보간.
실제 관측된 run이 아닌 가상 값일 수 있다.PERCENTILE_DISC는 실제 표본 하나를 고른다. 여기선 CONT를 씀.§2대로 latency_ms는 logger가 잰 first_ts→last_ts 벽시계다. 순수 LLM 추론시간이 아니라 네트워크·큐·도구실행을 다 포함한 end-to-end. 그래서 p95가 분 단위(예: 579,290ms ≈ 9.7분)로 커질 수 있다.
runs에서 직접 뽑는다. ⇒ p50은 항상 최신, p95는 마지막 REFRESH 시점의 값.
같은 화면에 둘이 미묘하게 어긋나면 이 비대칭 때문.유일하게 runs가 아니라 events를 직접 집계하는 MV.
이벤트 타입을 FILTER로 갈라 도구별로 센다.
SELECT r.batch_id, e.tool_name, COUNT(*) FILTER (WHERE e.message_type='tool_call') AS calls, COUNT(*) FILTER (WHERE e.message_type='tool_error') AS errors, AVG(e.latency_ms) FILTER (WHERE e.message_type='tool_result') AS avg_latency FROM events e JOIN runs r USING(run_id) GROUP BY batch_id, tool_name;
calls: tool_call 이벤트 수 = 도구 호출 시도 횟수.errors: tool_error 이벤트 수. errors/calls = 도구 실패율.avg_latency: tool_result 이벤트의 latency_ms 평균 (이벤트 단위 latency = 직전 이벤트와의 ts 간격).tool_call_id·tool_name 필수 → 집계 키 누락 없음.mv_* 의 숫자가 "맞는 시점"을 알아야 리포트를 신뢰할 수 있다.
# db.py MATERIALIZED_VIEWS = ( "mv_batch_summary", "mv_passk", "mv_tool_summary") async def refresh_views(conn): for v in MATERIALIZED_VIEWS: await conn.execute( f"REFRESH MATERIALIZED VIEW {v}")
runner.py:438 — 배치의 모든 run이 끝난 직후 db.refresh_views(conn).cli.py:300 — report가 기본으로 조회 전 REFRESH(--refresh, 기본 ON). 끄려면 --no-refresh.report 한 번이면 최신.--no-refresh 탈출구를 둠(대시보드 경합 시).runs·events
원본을 직접 보므로 REFRESH와 무관하게 항상 진실. MV는 그 위에 얹힌 요약 캐시일 뿐.집계 숫자를 인용하기 전에 확인할 것들. 각 항목에 근거등급을 붙였다 (코드근거=뷰/소스 직접 확인 · 내 추론=정의로부터의 해석).
| 함정 | 내용 | 등급 |
|---|---|---|
| pass@k 정의 | 경험적 any/all (실제 시도 기준)이지 Chen 불편추정량 아님. k가 task마다 가변(avg_k). | 내 추론 |
| pass@1 분모 | completed run만. crashed 많은 팀은 "생존 편향"으로 낙관될 수 있음. | 코드근거 |
| tokens_per_success | 분자에 실패 토큰 포함 → avg_tokens보다 크고 ≈avg_tokens/pass@1. 성공 0이면 NULL. | 코드근거 |
| latency_ms 정의 | LLM 시간이 아니라 logger 벽시계 first→last(네트워크·도구 포함). p95가 분 단위인 이유. | 코드근거 |
| p95 vs p50 비대칭 | p95는 MV(REFRESH 시점), p50은 runs 즉석(최신) → --no-refresh나 배치 도중 조회면 어긋날 수 있음(기본 report는 REFRESH 후라 일치). | 코드근거 |
| PERCENTILE_CONT | 보간값이라 실제 관측 run이 아닐 수 있음. DISC와 다름. | PG 표준 |
| 레벨 혼합 금지 | pass@1은 (batch,team,level)별. 팀 전체값은 n_runs 가중평균 필요. | 내 추론 |
| MV staleness | REFRESH 지점은 둘: 배치 종료(runner.py:438) + report 기본(cli.py:300, --no-refresh로 끔). 배치 도중·--no-refresh면 옛 값. | 코드근거 |
| 리스트 답 채점 | scorer는 리스트를 순서 보존 비교(정렬 안 함). 순서 틀리면 success=false. | 코드근거 |
위 모든 레시피의 단일 진실 공급원. 한 글자도 가공 없이 그대로.
CREATE MATERIALIZED VIEW mv_batch_summary AS SELECT r.batch_id, r.team_id, t.level, COUNT(*) AS n_runs, AVG(r.success::int) AS pass_at_1, AVG((NOT r.format_valid)::int) FILTER (WHERE r.status='completed') AS format_invalid_rate, AVG((r.terminated_by='max_messages')::int) AS loop_rate, AVG(r.total_turns) AS avg_turns, AVG(r.total_tokens) AS avg_tokens, SUM(r.total_tokens)::float / NULLIF(SUM(r.success::int),0) AS tokens_per_success, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY r.latency_ms) AS p95_latency FROM runs r JOIN tasks t USING (task_id) WHERE r.status='completed' GROUP BY 1,2,3; CREATE MATERIALIZED VIEW mv_passk AS SELECT batch_id, team_id, task_id, COUNT(*) AS k, BOOL_OR(success) AS pass_any, BOOL_AND(success) AS pass_all FROM runs WHERE status='completed' GROUP BY 1,2,3; CREATE MATERIALIZED VIEW mv_tool_summary AS SELECT r.batch_id, e.tool_name, COUNT(*) FILTER (WHERE e.message_type='tool_call') AS calls, COUNT(*) FILTER (WHERE e.message_type='tool_error') AS errors, AVG(e.latency_ms) FILTER (WHERE e.message_type='tool_result') AS avg_latency FROM events e JOIN runs r USING (run_id) GROUP BY 1,2;