agent-eval · 메트릭 파이프라인

메트릭 가공·취합 레시피

pass@1, pass@k, p95_latency, tokens_per_success … 리포트에 찍히는 숫자 한 칸이 원시 run/event 로그 → 채점 → runs UPDATE → materialized view 집계 의 어느 단계에서, 정확히 어떤 SQL/코드로 만들어지는지를 끝까지 추적한다. 실제 live DB 수치로 검산까지.

SSOT: 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}

0전체 흐름 — 숫자 한 칸의 생애

메트릭은 어디서도 "한 번에" 계산되지 않는다. 4단계 파이프라인을 거치며, 각 단계가 다음 단계의 입력 컬럼을 채운다. 집계 SQL(맨 끝)은 앞 3단계가 채워놓은 컬럼만 읽는다 — 그래서 메트릭을 이해하려면 "이 컬럼이 언제 채워졌나"를 먼저 알아야 한다.

① STREAM
team.run_stream()
AutoGen 팀이 이벤트를 토해냄 (TextMessage, ToolCall…)
① LOG
RunStreamLogger.log_stream()
이벤트마다 events INSERT + run 단위 누적 (turns/tokens/latency)
▼ 이벤트 소진 후 RunResult 반환
② SCORE
gaia/scorer.py · score()
final_answer 추출 → 정규화 → 정답 비교 ⇒ success, format_valid
② PERSIST
runner.py · runs UPDATE
한 run = runs 한 행. success/turns/tokens/latency/terminated_by 확정
▼ 배치 종료 후(runner.py:438) 또는 report 조회 시(기본, cli.py:300)
③ AGGREGATE
REFRESH MATERIALIZED VIEW
mv_batch_summary · mv_passk · mv_tool_summary 재계산 (GROUP BY)
④ READ
report.py / Grafana
MV에서 SELECT (+ p50 같은 일부는 runs 에서 즉석 계산)
핵심 통찰: runs 테이블은 이미 run 단위로 1차 환원(reduce)된 사실이다. 집계 뷰가 하는 일은 그 행들을 (batch_id, team_id, level) 또는 (batch_id, team_id, task_id) 로 묶어 평균·분위수·합을 내는 2차 환원뿐이다. 무거운 계산(토큰 합산, latency 측정, 채점)은 전부 ①②에서 끝나 있다.

1두 종류의 표면 — 굳은 뷰(mv) vs 살아있는 뷰(view)

같은 "뷰"라도 성격이 정반대다. 메트릭이 stale 할 수 있는지가 여기서 갈린다.

MATERIALIZED VIEW

집계 스냅샷 · REFRESH 필요

계산 결과를 디스크에 굳혀 둔다. 원본 runs가 바뀌어도 REFRESH 전까지 옛 숫자 그대로. 무거운 GROUP BY를 매 조회마다 안 돌리려는 것.

mv_batch_summarypass@1·p95·tokens 등 핵심 KPI
mv_passktask별 pass_any/pass_all
mv_tool_summary도구별 calls/errors/latency

목록 SSOT: db.py · MATERIALIZED_VIEWS

VIEW (live)

평면 뷰 · 항상 최신

저장 안 함. 조회할 때마다 원본을 즉석 조인 → REFRESH 불필요, 항상 실시간. 계층(batch⊃run⊃event)을 한 줄 WHERE로 펼치는 용도.

v_runsrun 1개 = 1행 (재현성축 + 결과 평면화)
v_run_eventsevent 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 후 출력하므로 평상시엔 최신.)

2원천 컬럼의 출생 — 집계 이전에 무엇이 채워지나

집계 SQL이 읽는 runs 컬럼들은 ①STREAM/②SCORE 단계의 산물이다. 각 컬럼이 누가·어떻게 만드는지 못 박아 두면, 뒤의 모든 레시피가 자명해진다.

runs 컬럼만드는 곳계산 방식
successscorer.py → runner UPDATE format_valid AND normalize(pred)==normalize(gold). 숫자면 abs(a−b)<1e-6, 아니면 문자열 동등. 코드근거
format_validscorer.py 출력에서 정규식 FINAL ANSWER\s*:\s*(.+) 추출 성공 여부 (복수 매치면 마지막). 못 찾으면 false → success 강제 false. 코드근거
total_turnslogger.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_mslogger.py run end-to-end = last_ts − first_ts (ms), ts는 logger가 이벤트 받은 벽시계 _now_utc(). max(0, …) 가드. 코드근거
terminated_byrunner.py · _terminated_by() AutoGen stop_reason 매핑 → max-messages 사유면 'max_messages', 정상 종료 'approve', 예외 'error'. 코드근거
statusrunner.py 시작 시 'running' INSERT → 정상 끝 'completed' / 예외 'crashed'. 집계는 거의 다 status='completed'만 본다. 코드근거
DB 불변식이 메트릭을 지킨다: CHECK (status!='completed' OR success IS NOT NULL) — 완료 run엔 success가 NULL일 수 없다. 그래서 AVG(success::int)의 분모가 새지 않는다. 토큰/latency도 >= 0 CHECK가 걸려 음수 오염이 원천 차단된다 (001_init.sql).

pass@1

mv_batch_summary 한 번의 시도에서 정답을 맞힐 확률

가장 자주 인용되는 메트릭. 완료된 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. 그 평균이 곧 비율.
  • 분모 = 완료(completed) run 수. crashed·timeout·running은 WHERE에서 제외.
  • 묶음 단위 = (batch_id, team_id, level). 즉 레벨별로 따로 나온다.
  • NULL success는 불가능(CHECK 제약) → 분모 누수 없음.

실측 검산 (live DB)

batch perf_gaia-loop-base, team gaia-loop-base, level 2:

n_runs83
success=true 개수≈ 23
pass@1 = 23 / 830.277
해석 주의: pass@1은 레벨 혼합이 아니다. 팀 전체 한 숫자로 말하려면 레벨별 행을 n_runs 가중평균해야 한다 (단순 평균 ≠ 전체 정확도). 또 분모가 completed라서, 크래시가 많은 팀은 pass@1이 "살아남은 run만의 정확도"로 낙관 편향될 수 있다.

pass@k

mv_passk+ report.py 2차 집계 같은 task를 k번 시도했을 때의 성공 양상

pass@k는 2단계로 만들어진다. 먼저 task 단위로 "여러 시도 중 하나라도/전부 성공했나"를 접고(mv_passk), 그 다음 team 단위로 센다(report.py). 단일 SQL이 아니라는 점이 핵심.

1단계 — task별 접기 mv_passk

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;

2단계 — team별 집계 report.py · fetch_passk_agg()

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;

실측 — k>1 일 때 갈라진다

teamtasksanyallavg_k
v1-baseline4372252251.02
v5-multitool11469611.41
v1-research-pipeline10396951.19

avg_k≈1.0(v1-baseline)이면 any=all=pass@1로 붕괴. avg_k=1.41(v5)이면 any(69)>all(61) — "어쩌다 한 번 맞춤"과 "안정적으로 맞춤"의 간극이 보인다.

두 지표의 의미

  • pass_any (≥1 성공) = 잠재력 / 상한. "이 팀이 운 좋으면 풀 수 있는 task".
  • pass_all (전부 성공) = 안정성 / 하한. "반복해도 흔들리지 않는 task".
  • 둘의 간극 = 비결정성·분산의 크기. temp>0 이거나 도구 호출이 흔들리면 벌어진다.
근거등급 — 정의 주의: 여기 pass@k는 "실제로 돌린 시도들에 대한 경험적 any/all"이지, Chen et al.(2021) HumanEval식 불편추정량 1 − C(n−c,k)/C(n,k)아니다. 내 추론 (mv_passk SQL + report.py 코드 기준). 시도수 k가 task마다 다를 수 있어 (avg_k가 정수가 아님) 표준 pass@k와 직접 비교하려면 동일 n으로 정규화가 필요하다.

format_invalid_rate

mv_batch_summary 답을 냈지만 형식을 못 지킨 비율
AVG((NOT r.format_valid)::int)
    FILTER (WHERE r.status='completed')   AS format_invalid_rate

loop_rate

mv_batch_summary 대화가 끝나지 못하고 상한에 부딪힌 비율
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_turns · avg_tokens · tokens_per_success

mv_batch_summary 비용 · 효율 축
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이 태운 토큰까지 포함된다.

왜 avg_tokens보다 훨씬 큰가

  • 분자 = 배치의 모든 run 토큰 합 (성공+실패 전부).
  • 분모 = 성공한 run 수만.
  • ⇒ 대수적으로 tokens_per_success ≈ avg_tokens / pass@1.
  • NULLIF(…,0) = 성공 0개면 0으로 나누지 않고 NULL 반환 (∞ 방지).

실측 검산 (gaia-loop-base)

lvlnpass@1avg_toktok/succ
1520.34615,27344,123
2830.27714,72053,121
3190.21120,35496,683

L2 검산: 14,720×83 = 1,221,760 토큰 총합 ÷ 23 성공 = 53,120 ≈ 53,121 ✓. 또 14,720 / 0.277 = 53,141 ≈ 동일.

왜 이 정의가 옳은가: 정확도(pass@1)와 비용(avg_tokens)을 한 숫자로 결합해 "효율"을 본다. pass@1이 낮을수록 같은 정답을 위해 더 많은 실패 토큰을 태우므로 비용이 비선형 폭증 — L3(0.211)에서 96k로 뛰는 게 그 신호다. 내 추론 (002_views.sql 정의 기준 해석).

p95_latency · p50_latency

mv (p95)runs 즉석 (p50) 꼬리 지연 — 분위수
-- 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

CONT의 의미

  • PERCENTILE_CONT = 연속형 — 정렬 후 95% 지점을 두 표본 사이 선형보간. 실제 관측된 run이 아닌 가상 값일 수 있다.
  • (대비) PERCENTILE_DISC는 실제 표본 하나를 고른다. 여기선 CONT를 씀.
  • p95 = 느린 쪽 꼬리. 평균이 못 보는 최악 사용자 경험을 잡는다.

latency_ms가 뭘 재는지 다시

§2대로 latency_ms는 logger가 잰 first_ts→last_ts 벽시계다. 순수 LLM 추론시간이 아니라 네트워크·큐·도구실행을 다 포함한 end-to-end. 그래서 p95가 분 단위(예: 579,290ms ≈ 9.7분)로 커질 수 있다.

왜 p50은 MV에 없나: p95만 KPI로 굳혔고, p50(중앙값)은 필요 시 report.py가 runs에서 직접 뽑는다. ⇒ p50은 항상 최신, p95는 마지막 REFRESH 시점의 값. 같은 화면에 둘이 미묘하게 어긋나면 이 비대칭 때문.

tool 집계

mv_tool_summary 도구별 호출/오류/지연 — events 에서 직접

유일하게 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;

3언제 굳는가 — REFRESH 시점과 stale 윈도우

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).
  • ② report 조회 시: cli.py:300report기본으로 조회 전 REFRESH(--refresh, 기본 ON). 끄려면 --no-refresh.
  • ⇒ 배치 진행 중에는 직전 값이지만, 종료 후 또는 report 한 번이면 최신.
  • 전체 REFRESH(비-CONCURRENTLY)라 큰 테이블에선 잠깐 락 — 그래서 --no-refresh 탈출구를 둠(대시보드 경합 시).
정합성 점검(스모크) 쿼리도 이 모델에 기댄다 — CLAUDE.md의 "완료 run에 success 누락 0행 / tool_call에 응답 짝 없는 이벤트 0행" 점검은 runs·events 원본을 직접 보므로 REFRESH와 무관하게 항상 진실. MV는 그 위에 얹힌 요약 캐시일 뿐.

4함정 모음 & 근거등급

집계 숫자를 인용하기 전에 확인할 것들. 각 항목에 근거등급을 붙였다 (코드근거=뷰/소스 직접 확인 · 내 추론=정의로부터의 해석).

함정내용등급
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 stalenessREFRESH 지점은 둘: 배치 종료(runner.py:438) + report 기본(cli.py:300, --no-refresh로 끔). 배치 도중·--no-refresh면 옛 값.코드근거
리스트 답 채점scorer는 리스트를 순서 보존 비교(정렬 안 함). 순서 틀리면 success=false.코드근거

5부록 — 집계 뷰 원문 (migrations/002_views.sql)

위 모든 레시피의 단일 진실 공급원. 한 글자도 가공 없이 그대로.

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;