← 코드 아키텍처 맵  /  런타임 플로우 완전 해부  ·  개선 과정 →  ·  배치 카탈로그 →  ·  메트릭 발견·근거 →

런타임 플로우 완전 해부

team.run_stream() 한 번이 AutoGen 이벤트가 되고, 그 이벤트가 mapping·logger·scorer를 지나 events / runs 테이블의 어느 컬럼에 어떤 값으로 떨어지는지 — 실제 스트림 한 건을 이벤트별로 끝까지 따라간다.

실측 1 run = 18 events20 컬럼 INSERTmapping 8종 → message_type scorer = normalize+comparev1→v6 1변수 계보raw 수준 추적
읽는 법. 아래는 가상의 평균적 1 run이 아니라, 첨부(xlsx) 태스크 한 건v5-multitool 팀으로 실행했을 때 나오는 이벤트 시퀀스를 실제 코드 경로 그대로 재구성한 것이다. 토큰·latency 수치는 예시이되, 어떤 컬럼에 어떻게 들어가는지의 규칙은 코드 그대로다. 파일 참조는 src/agent_eval/… 기준, 라인은 현재 main 기준.

A호출 체인 — cli 한 줄이 run_stream에 닿기까지

평가의 본체는 runner._execute_run() 안의 단 두 줄: 팀을 빌드하고, team.run_stream()TraceLogger.log_stream()에 먹인다.

cli.py run runner.run_batch schedule_one Semaphore _prepare_run_row runs INSERT (running) _execute_run
_execute_run build_registered_team AC9 build_run_message team.run_stream(task=…) TraceLogger.log_stream scorer runs UPDATE (completed)

핵심 12줄 (runner.py:237–299)

# 모델 클라이언트는 run마다 새로 — 동시성에서 공유 상태를 피한다
client = model_client_factory()
team, _ = build_registered_team(team_id, settings, client)   # registry: team_id → 빌더, AC9 가드
run_message = build_run_message(team_id, task, settings)      # v2/v3/v4/v5만 첨부경로 주입, 그 외 task.question
async with pool.acquire() as conn:
    trace_result = await TraceLogger(conn).log_stream(        #  ← 스트림 소비 + events 적재
        run_id,
        team.run_stream(task=run_message),                   # ← AutoGen 라이브러리 진입점
    )
    is_open_ended = task.benchmark in _OPEN_ENDED            # gaia=False → 한 줄 답
    answer_text = (trace_result.final_answer_full if is_open_ended
                   else trace_result.final_answer)
    score_fn = score                                         # benchmark별 분기, gaia는 gaia.scorer.score
    score_result = score_fn(answer_text, task.gold_answer)
    await conn.execute("UPDATE runs SET status='completed', …")  # ← 결과 확정
team.run_stream(task=run_message) 가 전부다. 이것이 AutoGen SelectorGroupChat.run_stream() 를 호출 → 내부적으로 selector LLM이 다음 발화자를 고르고, 각 AssistantAgent 가 model_client로 추론하며, 그 과정의 모든 메시지/도구호출/선택 결정을 async generator 로 하나씩 yield 한다. eval 코드는 이 제너레이터를 소비만 한다 — AutoGen 내부는 건드리지 않는다.

팀은 어떻게 생겼나 (v1_baseline.build_team, v2~v6 공유 골격)

SelectorGroupChat(
    [planner, executor, critic],                # 3 AssistantAgent
    model_client=model_client,
    termination_condition = TextMentionTermination("APPROVE", sources=["critic"])
                            | MaxMessageTermination(settings.max_messages),
    selector_prompt = SELECTOR_PROMPT,           # selector가 JSON {next,reason_code,summary} 출력하도록 강제
    allow_repeated_speaker = False,              # 같은 에이전트 연속 발화 금지 → 자연스러운 P→E→P→C 교대
    max_selector_attempts = 3,
    emit_team_events = True,                     # ← SelectorEvent를 스트림에 흘려보냄(이게 없으면 라우팅 근거 미기록)
    model_client_streaming = True,              # ← ModelClientStreamingChunkEvent 발생 → logger가 조용히 스킵
)

B예제 태스크 + run_stream 원시 이벤트 18개

GAIA L1, 첨부 xlsx 1개. 이 한 건이 아래 §C~§H를 관통한다.

입력 (tasks 행 + build_run_message 결과)

# tasks 테이블 행
task_id     = "c61d22de-…-5e9707bd3466"
benchmark   = "gaia"        level = 1        requires_web = false
file_name   = "cafe_sales.xlsx"
question    = "첨부 엑셀은 한 카페의 지난주 판매 내역이다. 음료(drink)를 제외한
               음식(food) 매출 합계는 USD로 얼마인가? 소수점 2자리로 답하라."
gold_answer = "89706.00"

# build_run_message("v5-multitool", task, settings) → registry._RUN_MESSAGE_BUILDERS[v5]
run_message = question + "\n\n[첨부파일 경로: /srv/data/gaia/validation/cafe_sales.xlsx] "
                       + "이 파일이 필요하면 read_attached_file 도구에 이 경로를 그대로 전달하라."
여기서 이미 v1과 갈린다. v1-baseline은 build_run_message에 등록이 없어 run_message = task.question 그대로다(경로 없음). v2~v5만 절대경로를 주입한다. → §J.

run_stream이 yield하는 객체 순서 (스트리밍 청크 제외)

#AutoGen 객체 (type).source핵심 페이로드.models_usage
(prompt / completion)
E1TextMessageuserrun_message 전문— / —
E2SelectorEventselector{"next":"planner","reason_code":"NEEDS_PLANNING",…}(미기록)
E3TextMessageplanner“executor가 파일을 읽고 food행을 합산하게 하라” 계획412 / 96
E4SelectorEventselector{"next":"executor","reason_code":"NEEDS_EXECUTION",…}(미기록)
E5ToolCallRequestEventexecutor[FunctionCall(read_attached_file, {"path":"…cafe_sales.xlsx"})]520 / 40
E6ToolCallExecutionEventexecutor[FunctionExecutionResult(is_error=False, content="# Sheet: Sales\\n…")]— / —
E7ToolCallSummaryMessageexecutor도구 결과 요약(=추출 텍스트)— / —
E8SelectorEventselector{"next":"planner","reason_code":"NEEDS_PLANNING",…}(미기록)
E9TextMessageplanner“food만 필터해 합산하는 python_exec 코드를 돌려라”720 / 120
E10SelectorEventselector{"next":"executor","reason_code":"NEEDS_EXECUTION",…}(미기록)
E11ToolCallRequestEventexecutor[FunctionCall(python_exec, {"code":"import openpyxl …"})]980 / 210
E12ToolCallExecutionEventexecutor[FunctionExecutionResult(is_error=False, content="89706.0\\n")]— / —
E13ToolCallSummaryMessageexecutor“89706.0”— / —
E14SelectorEventselector{"next":"planner","reason_code":"READY_TO_FINAL",…}(미기록)
E15TextMessageplannerFINAL ANSWER: 89706.001180 / 64
E16SelectorEventselector{"next":"critic","reason_code":"NEEDS_VERIFICATION",…}(미기록)
E17TextMessagecriticAPPROVE1320 / 8
E18…TaskResultstop_reason="Text 'APPROVE' mentioned"; messages=[E1…E17]— / —
여기 안 보이는 것: E3·E9·E15·E17 직전마다 ModelClientStreamingChunkEvent 수십~수백 개가 흐른다(model_client_streaming=True). mapping.get_message_type()이 이들에 None을 돌려주므로 logger의 어느 분기에도 안 걸리고 조용히 버려진다 — DB에 단 한 행도 안 남는다. ThoughtEvent, MemoryQueryEvent 도 동일.

Cmapping.py — 이벤트 → message_type (순수 함수)

tracing/mapping.py는 IO가 없는 순수 변환표다. AutoGen .type 리터럴(또는 클래스명)을 받아 DB message_types.code를 돌려준다.

AutoGen typeget_message_type() 반환logger 처리events 행 수
(클래스명 …TaskResult)final_answermessages 역순에서 FINAL ANSWER 메시지 추출1
TextMessageagent_messagecontent 절단 + 토큰합산 + turn++1
ToolCallSummaryMessageagent_message위와 동일(도구결과 요약을 발화로 취급)1
ToolCallRequestEventtool_callFunctionCall 1개당 1행으로 분해 + 토큰합산N
ToolCallExecutionEventNonelogger가 content를 순회해 항목별로 tool_result/tool_error 로 쪼갬N
SelectorEventselector_decisionJSON 파싱 → reason_code/next/summary1
SelectSpeakerEventselector_decisioncontent=List[str] 첫 항목이 선택 에이전트(reason 없음)1
HandoffMessagehandoffcontent 절단 저장1
EvaluatorScoreEventevaluator_scoreverdict → eval_meta JSONB(009 컬럼)1
그 외(StreamingChunk/Thought/MemoryQuery)None스킵 — DB에 안 남음0

Dlogger.log_stream — 디스패치 사다리 + 상태 누산기

async for event in stream: 루프가 이벤트마다 위에서부터 아래 사다리를 타고 첫 매칭에서 continue. 그 사이 seq와 누산기들이 갱신된다.

분기 순서 (먼저 걸리는 게 이긴다 · logger.py:549–669)

1. type 이름이 ...TaskResult           → final_answer 1행, 토큰합산 안 함
2. is_tool_execution_event(event)     → tool_result/tool_error N행 (항목별)
3. type == "ToolCallRequestEvent"     → tool_call N행 + 토큰합산
4. type == "SelectorEvent"            → selector_decision 1행
5. type == "SelectSpeakerEvent"       → selector_decision 1행
6. type == "EvaluatorScoreEvent"      → evaluator_score 1행
7. get_message_type==agent_message    → agent_message 1행 + 토큰합산 + turns++ + FINAL ANSWER 캡처
8. get_message_type==handoff          → handoff 1행
9. (그 외)                            → continue (스킵)

run 1건 동안 살아있는 상태 (RunResult로 귀결)

변수갱신 시점최종 → runs 컬럼
seq행을 INSERT할 때마다 +1 (이벤트 아님, 단위)events.seq (PK 일부)
total_input_tokens / outputagent_message·tool_call 분기에서만 models_usage 합산input_tokens / output_tokens / total_tokens
total_turnsagent_message 1행마다 +1total_turns
final_answer_strplanner의 메시지에서만 FINAL ANSWER: 한 줄 추출(gaia) runs.final_answer + 채점 입력
final_answer_full_strcritic 외 에이전트의 FINAL ANSWER 포함 메시지 전문(마지막 것이 이김)(개방형) 채점 입력
stop_reasonTaskResult에서_terminated_by() → terminated_by
first_ts / last_ts각 이벤트 도착 시각(_now_utc())(last−first) → latency_ms
prev_ts이벤트마다 갱신(행이 아니라 이벤트 단위)행별 latency_ms 계산용
missed_insertsINSERT 실패 1건당 +1 (예외는 삼킴)경고 로그만 — 배치 안 죽음
로깅은 평가를 죽이지 않는다(절대규칙 2). 모든 INSERT는 _safe_insert()로 감싸 있다 — 실패하면 missed_inserts += 1 하고 경고만 남기고 다음 이벤트로 간다. run이 crash로 떨어지는 건 로깅 실패가 아니라 run_stream 자체가 예외를 던질 때뿐이다(→ _mark_crashed).

E이벤트별 완전 변환 — E1 → E18, 행 17개가 만들어지는 과정

각 카드: 원시 객체탄 분기빌더가 만든 record dictevents INSERT 한 행. seq는 0부터, 누산기 변화도 함께.

seq 0 E1 · TextMessage → 분기7 agent_message
raw
TextMessage(source="user", content="첨부 엑셀은…그대로 전달하라.", models_usage=None)
변환
content 그대로(8192자 이하 → 절단X). _extract_tokens=(0,0). user는 planner가 아니라 FINAL ANSWER 캡처 대상 아님.
INSERT
agent_name=user, message_type=agent_message, content="첨부 엑셀은…", content_truncated=false, tokens=NULL, latency_ms=NULL(prev_ts=None)
누산기
seq 0→1 · total_turns 0→1 · prev_ts=ts(E1)
seq 1 E2 · SelectorEvent → 분기4 selector_decision
raw
SelectorEvent(source="selector", content='{"next":"planner","reason_code":"NEEDS_PLANNING","summary":"문제를 분해하고 계획을 세운다"}')
변환
parse_selector_reason() → reason_code="NEEDS_PLANNING", summary="…". 별도로 본문 {…}에서 next 추출.
INSERT
message_type=selector_decision, selected_next_agent=planner, selector_reason={"reason_code":"NEEDS_PLANNING","summary":"…"}::jsonb, latency_ms=1200, tokens=NULL
누산기
seq 1→2 · 토큰 합산 안 함(selector LLM 비용은 미기록 — §F 한계) · prev_ts=ts(E2)
seq 2 E3 · TextMessage(planner) → 분기7 agent_message
raw
TextMessage(source="planner", content="1) executor가 read_attached_file로 …", models_usage=RequestUsage(prompt_tokens=412, completion_tokens=96))
변환
planner지만 content에 FINAL ANSWER: 없음 → final_answer_str 갱신 안 됨. 토큰 in=412/out=96 합산.
INSERT
agent_name=planner, content="1) executor가…", tokens=508, input_tokens=412, output_tokens=96, latency_ms=3100
누산기
seq 2→3 · in 0→412 · out 0→96 · turns 1→2
seq 3 E4 · SelectorEvent → selector_decision
INSERT
selected_next_agent=executor, selector_reason={"reason_code":"NEEDS_EXECUTION","summary":"파일을 읽어야 한다"}, latency_ms=900
seq 4 E5 · ToolCallRequestEvent → 분기3 tool_call (FunctionCall 1개 → 1행)
raw
content=[FunctionCall(id="call_Rkz9", name="read_attached_file", arguments='{"path": "/srv/data/gaia/validation/cafe_sales.xlsx"}')], models_usage=(520,40)
변환
_parse_tool_args: arguments 문자열 → json.loads_mask_secrets(민감키 없음, 통과). tool_args를 다시 json.dumps(ensure_ascii=False).
INSERT
message_type=tool_call, tool_call_id=call_Rkz9, tool_name=read_attached_file, tool_args={"path":"/srv/data/gaia/validation/cafe_sales.xlsx"}::jsonb, tokens=560, input=520, output=40, latency_ms=2600
누산기
seq 4→5 · in 412→932 · out 96→136 · turns 변화 없음(tool_call은 발화 아님)
제약
CHECK: tool_call이면 tool_call_id·tool_name 둘 다 NOT NULL — 충족.
seq 5 E6 · ToolCallExecutionEvent → 분기2 tool_result (항목 1개 → 1행)
raw
content=[FunctionExecutionResult(call_id="call_Rkz9", name="read_attached_file", is_error=False, content="# Sheet: Sales\\nItem\\tCategory\\tPrice\\tQty\\nLatte\\tdrink\\t…")] ← 길이 3,914자
변환
is_error=False → tool_result 경로. content가 JSON 아님 → json.dumps(raw)JSON 문자열 스칼라화. 3,914>2048 → 2048자 절단 + sha256.
INSERT
message_type=tool_result, tool_call_id=call_Rkz9, tool_name=read_attached_file, tool_result="\"# Sheet: Sales\\nItem\\t…"(2048자)::jsonb, tool_result_hash=sha256(전체)=9f2c…a17b, latency_ms=180
누산기
seq 5→6 · 토큰 합산 안 함(도구 실행은 LLM 호출 아님) · prev_ts=ts(E6)
제약
CHECK: tool_result면 tool_call_id NOT NULL · (tool_result, tool_error 동시 NOT NULL 금지) — 충족.
seq 6 E7 · ToolCallSummaryMessage → 분기7 agent_message
raw
ToolCallSummaryMessage(source="executor", content="# Sheet: Sales …(도구결과 요약)", models_usage=None)
변환
get_message_type이 agent_message로 매핑 → executor가 “발화”한 것으로 기록. usage None → 토큰 0. executor≠planner & executor≠critic이고 content에 FINAL ANSWER 없음.
INSERT
agent_name=executor, message_type=agent_message, content="# Sheet: Sales …", tokens=NULL, latency_ms=50
누산기
seq 6→7 · turns 2→3 (도구결과 요약도 1턴으로 셈)
seq 7 E8 · SelectorEvent → selector_decision
INSERT
selected_next_agent=planner, selector_reason={"reason_code":"NEEDS_PLANNING","summary":"읽은 표를 보고 집계 방법을 정한다"}, latency_ms=850 — executor 연속 선택 불가(allow_repeated_speaker=False)라 planner로 회귀
seq 8 E9 · TextMessage(planner) → agent_message
변환
FINAL ANSWER 아직 없음. 토큰 720/120 합산.
INSERT
agent_name=planner, content="food 카테고리만 합산하는 코드를 실행하라", tokens=840, input=720, output=120, latency_ms=4200
누산기
seq 8→9 · in 932→1652 · out 136→256 · turns 3→4
seq 9 E10 · SelectorEvent → selector_decision
INSERT
selected_next_agent=executor, selector_reason={"reason_code":"NEEDS_EXECUTION","summary":"집계 코드를 실행한다"}, latency_ms=780
seq 10 E11 · ToolCallRequestEvent → tool_call (python_exec)
raw
[FunctionCall(id="call_Vh2m", name="python_exec", arguments='{"code": "import openpyxl\\nwb=openpyxl.load_workbook(\\"/srv/…/cafe_sales.xlsx\\")\\n…print(total)"}')], usage=(980,210)
변환
arguments → json.loads → mask(통과) → tool_args에 code 전문이 JSON 값으로 들어감(개행은 \\n 이스케이프). 이게 v5의 표식: tool_name=python_exec, 키=code(v1~v4는 python_calc/expression).
INSERT
tool_call_id=call_Vh2m, tool_name=python_exec, tool_args={"code":"import openpyxl\\n…"}, tokens=1190, input=980, output=210, latency_ms=5100
누산기
seq 10→11 · in 1652→2632 · out 256→466
seq 11 E12 · ToolCallExecutionEvent → tool_result (숫자 stdout)
raw
[FunctionExecutionResult(call_id="call_Vh2m", is_error=False, content="89706.0\\n")]
변환
주의: json.loads("89706.0\\n")는 성공해 float 89706.0이 된다 → tool_result에 JSON 숫자로 저장(문자열 아님). 2048 이하 → hash 없음. is_error=False라 tool_error는 NULL.
INSERT
message_type=tool_result, tool_call_id=call_Vh2m, tool_name=python_exec, tool_result=89706.0::jsonb(number), tool_result_hash=NULL, latency_ms=1300
누산기
seq 11→12 · 토큰 합산 안 함
seq 12 E13 · ToolCallSummaryMessage → agent_message
INSERT
agent_name=executor, content="89706.0", tokens=NULL, latency_ms=40 · turns 4→5
seq 13 E14 · SelectorEvent → selector_decision
INSERT
selected_next_agent=planner, selector_reason={"reason_code":"READY_TO_FINAL","summary":"집계 결과로 최종 답을 작성한다"}, latency_ms=820
seq 14 E15 · TextMessage(planner) → agent_message + FINAL ANSWER 캡처
raw
TextMessage(source="planner", content="음식 매출만 합산했다.\\nFINAL ANSWER: 89706.00", models_usage=(1180,64))
변환①
agent_name=="planner" & content에 패턴 있음 → _extract_final_answer_from_content → "89706.00". 플레이스홀더(<…>,`,* 시작) 아님 → final_answer_str = "89706.00".
변환②
agent_name≠"critic" & "FINAL ANSWER" 포함 → final_answer_full_str = 메시지 전문("음식 매출만…89706.00").
INSERT
agent_name=planner, content="음식 매출만 합산했다.\\nFINAL ANSWER: 89706.00", tokens=1244, input=1180, output=64, latency_ms=3600
누산기
seq 14→15 · in 2632→3812 · out 466→530 · turns 5→6 · final_answer_str 확정
seq 15 E16 · SelectorEvent → selector_decision
INSERT
selected_next_agent=critic, selector_reason={"reason_code":"NEEDS_VERIFICATION","summary":"최종 답을 검증한다"}, latency_ms=760
seq 16 E17 · TextMessage(critic) → agent_message (종료 트리거)
raw
TextMessage(source="critic", content="APPROVE", models_usage=(1320,8))
변환
critic은 FINAL ANSWER 캡처에서 제외(검수 CoT 오염 방지). 토큰만 합산. "APPROVE"가 TextMentionTermination 트리거 → 다음 이벤트가 TaskResult.
INSERT
agent_name=critic, content="APPROVE", tokens=1328, input=1320, output=8, latency_ms=1400
누산기
seq 16→17 · in 3812→5132 · out 530→538 · turns 6→7
seq 17 E18 · …TaskResult → 분기1 final_answer
raw
TaskResult(stop_reason="Text 'APPROVE' mentioned", messages=[E1…E17])
변환
_build_final_answer_record: messages 역순 탐색 → "FINAL ANSWER" 포함 메시지(E15)를 content로. final_answer_str는 이미 planner에서 잡혔으므로 덮어쓰지 않음(TaskResult 폴백은 한 번도 안 잡혔을 때만). stop_reason 저장.
INSERT
agent_name=NULL, message_type=final_answer, content="음식 매출만 합산했다.\\nFINAL ANSWER: 89706.00", latency_ms=20
누산기 종료
seq=18(=총 행수) · total_tokens=5132+538=5670 · latency_ms(e2e)=last−first=26900 · stop_reason 보관
RunResult 최종값 (logger 반환 → runner가 받음). final_answer="89706.00", final_answer_full="음식 매출만…89706.00", stop_reason="Text 'APPROVE' mentioned", total_turns=7, total_tokens=5670 (in 5132 / out 538), latency_ms=26900, missed_inserts=0.

F변환 규칙 5종 — 값이 어떻게 “가공”되나

위 워크스루에서 스쳐 지난 가공들. 전부 logger.py 모듈 함수 + 상수.

① 비밀 마스킹 _mask_secrets (절대규칙 5, 단일 지점)

dict/list 재귀 순회. 키가 정규식 (password|token|secret|api_key|authorization)(대소문자 무관)에 매칭되면 값을 ***로. tool_args 저장 전 반드시 통과.

# 가령 모델이 이런 인자를 넘겼다면(예시)
arguments = '{"url":"https://x","api_key":"sk-live-9f...","headers":{"Authorization":"Bearer abc"}}'
# → 저장되는 tool_args (JSONB)
{"url":"https://x","api_key":"***","headers":{"Authorization":"***"}}   # 중첩도 마스킹

우리 예제(read_attached_file/python_exec)엔 민감키가 없어 원본 그대로 통과했다. DSN/토큰이 예외·로그·커밋에 새지 않게 하는 게 목적.

② 절단 + 해시 (content 8192 / tool_result 2048)

대상임계초과 시흔적
agent_message/final_answer/handoff content8192자text[:8192]content_truncated=true
tool_result2048자json[:2048]tool_result_hash=sha256(전체)

E6이 이 케이스: 3,914자 → 2048 저장 + 해시. 절단된 JSON이 invalid가 될 수 있어, 절단본은 문자열로 저장(다시 파싱 실패 시).

③ tool_args 파싱 _parse_tool_args

FunctionCall.arguments(JSON 문자열) → json.loads → 실패하면 {"_raw": 원본문자열}로 감쌈(절대 예외 안 냄) → 마스킹 → 다시 json.dumps(ensure_ascii=False).

④ 토큰 추출 _extract_tokens

event.models_usage.(prompt_tokens, completion_tokens). 없으면 (0,0). 행에는 x or None으로 0을 NULL화. 합산은 agent_message·tool_call 분기에서만 → tool_result·selector·TaskResult의 LLM 비용은 합계에 안 들어간다.

알려진 한계: SelectorEvent(selector LLM)의 토큰은 어디에도 합산되지 않는다. selector_decision 행의 tokens도 NULL. 따라서 runs.total_tokens는 “selector 제외” 합계다 — 팀 간 비용 비교 시 이 편향을 감안해야 한다.

⑤ latency 두 종류

FINAL ANSWER 추출이 “두 군데”인 이유

추출기위치입력언제 쓰나
logger._extract_final_answer_from_content스트리밍 중planner 메시지만runs.final_answer + (gaia) 채점 입력
gaia.scorer.extract_final_answer채점기 내부(독립)임의 전체 텍스트(마지막 매치)전체 텍스트를 받았을 때(테스트/개방형 일부)

runner 경로에선 logger가 이미 추출해 한 줄 답을 넘기므로, gaia.scorer는 재추출 없이 normalize+비교만 한다(→ §H). critic 제외·planner 한정·플레이스홀더 가드는 critic의 검수 CoT가 정답을 덮어쓰는 오염을 막기 위함.

Gevents 테이블 최종 덤프 — 이 run이 남긴 18행

한 run_id 아래 seq 0~17. (event_id BIGSERIAL·ts·run_id 생략. 빈 칸=NULL.)

seqagentmessage_typetool_nametool_call_idcontent / tool_result / selector_reasontokinoutlat
0useragent_message첨부 엑셀은…그대로 전달하라.
1selectorselector_decision→planner · {NEEDS_PLANNING,"문제를 분해…"}1200
2planneragent_message1) executor가 read_attached_file로…508412963100
3selectorselector_decision→executor · {NEEDS_EXECUTION,"파일을 읽어야…"}900
4executortool_callread_attached_filecall_Rkz9{"path":"/srv/…/cafe_sales.xlsx"}560520402600
5executortool_resultread_attached_filecall_Rkz9"# Sheet: Sales\t…"(2048자, hash 9f2c…a17b)180
6executoragent_message# Sheet: Sales …(도구결과 요약)50
7selectorselector_decision→planner · {NEEDS_PLANNING,"집계 방법…"}850
8planneragent_messagefood 카테고리만 합산하는 코드를…8407201204200
9selectorselector_decision→executor · {NEEDS_EXECUTION,"코드 실행…"}780
10executortool_callpython_execcall_Vh2m{"code":"import openpyxl\n…print(total)"}11909802105100
11executortool_resultpython_execcall_Vh2m89706.0 JSON number1300
12executoragent_message89706.040
13selectorselector_decision→planner · {READY_TO_FINAL,"최종 작성…"}820
14planneragent_message음식 매출만…FINAL ANSWER: 89706.0012441180643600
15selectorselector_decision→critic · {NEEDS_VERIFICATION,"검증…"}760
16criticagent_messageAPPROVE1328132081400
17final_answer음식 매출만…FINAL ANSWER: 89706.0020
합계 (agent_message·tool_call만 합산)5670513253826900*

* 26900은 행별 latency 합이 아니라 last−first 벽시계다(seq0의 NULL 때문에 행 합과는 다름). tool_call/result/selector/final_answer 행은 토큰 합산에 안 들어간다.

HTaskResult → scorer → runs 한 행

logger가 RunResult를 돌려주면, runner가 채점기를 골라 돌리고 runs를 completed로 확정한다.

① 답 텍스트 선택 (open-ended 분기, runner.py:258–261)

is_open_ended = task.benchmark in _OPEN_ENDED   # {analysis,cppx,extract,sql,meeting,ocrproxy,coding,codetest}
# gaia는 _OPEN_ENDED 밖 → 한 줄 답 사용
answer_text = trace_result.final_answer          # = "89706.00"
# (개방형이면 final_answer_full 전문을 써서 출처/본문까지 채점)

② 채점기 분기 → gaia.scorer.score("89706.00", "89706.00")

format_valid = predicted is not None             # "89706.00" → True
norm_pred = normalize("89706.00")                # 통화·콤마·% 제거 → "89706.00" → float → "89706"
norm_gold = normalize("89706.00")                # → "89706"
success = _compare_normalized("89706","89706")   # 둘 다 float 파싱 → abs(a−b)<1e-6
# ScoreResult(success=True, format_valid=True)
정규화 규칙 요지(gaia/scorer.py). ① 세미콜론 있으면 리스트(순서 보존, 정렬 금지). ② 통화/콤마/% 제거 후 전체가 float이면 단일 숫자. ③ 콤마로 2원소↑면 리스트. 단일 문자열은 lower+관사(a/an/the)제거+마침표제거+공백정규화. 숫자는 float 비교(1e-6), 그 외 문자열 동등.

③ runs UPDATE — 이 run의 최종 행

컬럼출처
statuscompleted고정
terminated_byapprove_terminated_by("Text 'APPROVE'…") → "approve"
final_answer89706.00answer_text(=trace.final_answer)
successtruescore_result.success
format_validtruescore_result.format_valid
total_turns7trace.total_turns
total_tokens / input / output5670 / 5132 / 538trace 합산
latency_ms26900trace(last−first)
ended_atnow()UPDATE 시각
(INSERT 시 이미 박힌 값) team_id=v5-multitool · task_id=c61d… · attempt=1 · batch_id=… · model_name=glm-4.7 · temperature=0.0 · seed=42 · max_messages=30
실패였다면? run_stream이 예외를 던지면 _execute_run의 except가 _mark_crashed 호출 → status='crashed', terminated_by='error', ended_at=now(). success는 NULL로 남는다 — 그래서 CHECK(status!='completed' OR success IS NOT NULL)을 위반하지 않는다(crashed는 success NULL 허용).

I집계 — events/runs → materialized view

배치가 끝나면 run_batch 말미에 db.refresh_views(conn). report/Grafana는 원본 테이블이 아니라 이 뷰를 읽는다(상세는 코드맵 §6·§9).

읽는 컬럼산출
mv_batch_summaryruns.success / total_tokens / latency_ms / status배치×팀 pass@1·효율·p95
mv_passkruns.success × attemptpass@k
mv_tool_summaryevents(message_type∈{tool_call,tool_error}, tool_name)도구별 호출/에러 수 — idx_events_tool 부분 인덱스 활용
grounding 뷰들events + 도메인 grounding 테이블인용 grounded% 등

우리 run 1건은 mv_tool_summaryread_attached_file ×1, python_exec ×1(에러 0)로, mv_batch_summaryv5-multitool의 성공 1건/토큰 5670/latency 26900으로 집계된다.

Jv1 → v6 계보 — 같은 파이프라인, 정확히 어디 1곳만 바뀌나

위 §A~§H의 골격(SelectorGroupChat P-E-C, logger, scorer)은 v1~v6 전부 동일하다. 각 버전은 단 하나의 변수만 바꿔, 그 효과를 격리 측정한다(experiments 원장). 먼저 요약표, 그 다음 모든 프롬프트·코드·레지스트리 변경의 실제 unified diff(git diff 그대로)를 전수 수록한다.

이건 “무엇을 바꿨나”(코드 diff)다.왜 그렇게 바꿨고 실제로 얼마나 좋아졌나”(가설·실패 측정·temp=0 A/B·promoted/rejected 판정)는 별도 페이지로 분리했다 → v1→v6 개선 실제 과정 (실험 원장 EXP-37~148).
버전건드리는 단계실제 변경(파일)왜 (직전 실패)prompt_bundle_hashtrace에서 보이는 차이
v1-baseline —(기준) P-E-C + read_attached_file(UTF-8) + python_calc(AST 산술). run_message=task.question H₁ 첨부 태스크: 질문에 경로 없음 → planner가 파일명 환각 → tool_error FileNotFound. success=false
v2-attach-path 입력층(build_run_message) v2_attach_path.py: run 메시지에 첨부 절대경로 주입. 프롬프트·도구·토폴로지 완전 동일 v1의 파일명 환각으로 read 전수 실패 =H₁ v1과 동일 seq0 user content에 [첨부파일 경로:…] 등장 · tool_call.args.path가 올바른 경로. 단 UTF-8 리더라 바이너리(xlsx)는 여전히 UnicodeDecodeError → tool_error
v3-prompt-fix 에이전트 프롬프트(planner·critic) FINAL ANSWER 예시를 실제 값으로 교체 + <answer> 플레이스홀더 금지/반려 명시. python_calc 용도 명시. 경로주입 유지 모델이 FINAL ANSWER: <answer> 그대로 출력 → logger 플레이스홀더 가드가 None → format_valid=false H₃ 새 hash planner content가 실제 값. logger의 _TEMPLATE_PLACEHOLDER_RE에 더는 안 걸림 → final_answer 채워짐
v4-prompt-fix 에이전트 프롬프트(+규칙 1줄) “답이 한 단어·한 글자여도 반드시 FINAL ANSWER: 형식” + critic은 그 행 없으면 무조건 반려 v3에서 Guava 단독 출력 → FINAL ANSWER 행 없음 → format_valid=false H₄ 새 hash 짧은 답도 planner content에 FINAL ANSWER: 행 보장 → format_valid 안정
v5-multitool
예제 run
도구 교체(executor) + planner/executor 프롬프트 tools_v5.py: read_attached_file멀티포맷(xlsx/docx/pptx/pdf, 이미지·오디오는 안내문) · python_calcpython_exec(격리 subprocess, 20s 타임아웃, 8KB 출력). 형식규칙은 v4 계승 v1~v4 UTF-8 리더가 바이너리 첨부에서 전수 실패 · python_calc는 파싱코드 SyntaxError H₅ 새 hash tool_name=python_exec(키 code), 바이너리 xlsx가 tool_result(추출 텍스트)로 성공. 이게 §G의 덤프
v6-verify-gate 라우팅(selector_prompt만) v6_verify_gate.py: selector에 “검증 의무 경유” 블록 추가 — planner가 FINAL ANSWER: 내면 반드시 critic 선택, critic APPROVE 없이는 종료 금지. planner/executor/critic·도구·종료조건은 v1과 동일 실패 run의 critic 미경유(MAST FM-3.2): critic 게으름이 아니라 selector가 검증을 건너뛰는 라우팅 결정 H₆ 새 hash
(selector만 달라도)
selector_decision 시퀀스가 강제됨: planner FINAL ANSWER 직후 행이 항상 →critic, NEEDS_VERIFICATION. critic 미경유 종료가 사라짐
실제 prompt_bundle_hash 체인 (sha256 of "\n---\n".join([PLANNER,EXECUTOR,CRITIC,SELECTOR])) — 표의 H₁~H₆에 해당하는 진짜 값:
H₁ v1 = b3049447…23717262
H₁ v2 = b3049447…23717262  v1과 완전 동일 — 프롬프트 4종이 1글자도 안 바뀜
H₃ v3 = 4140a41e…a6c4859b
H₄ v4 = d5165551…2d9d95c5
H₅ v5 = 713da692…b0a7bf2fc
H₆ v6 = d6dbd8a3…10fb2366c8

v1 → v2-attach-path · 입력층

NEW src/agent_eval/teams/v2_attach_path.py  ·  EDIT teams/registry.py  ·  prompt_bundle_hash 불변(=H₁)

① build_team — v1을 team_id만 바꿔 그대로 호출(프롬프트 재생성 없음)

+++ b/teams/v2_attach_path.py
+def build_team(settings, model_client):
+    """v1-baseline 구성을 team_id만 바꿔 그대로 빌드한다."""
+    return v1_baseline.build_team(settings, model_client, team_id="v2-attach-path")

② build_run_message — v1엔 없던 함수가 새로 생김(첨부 절대경로 주입)

+++ b/teams/v2_attach_path.py
+def build_run_message(task, settings):
+    file_name = getattr(task, "file_name", None)
+    if not file_name:
+        return task.question                       # 첨부 없으면 v1과 동일
+    abs_path = Path(settings.gaia_dir).expanduser().resolve() / file_name
+    return (f"{task.question}\n\n"
+            f"[첨부파일 경로: {abs_path}] "
+            f"이 파일이 필요하면 read_attached_file 도구에 이 경로를 그대로 전달하라.")

③ registry.py — run 메시지 빌더 등록(v1은 미등록 → 기본 task.question)

--- a/teams/registry.py
+++ b/teams/registry.py
 _RUN_MESSAGE_BUILDERS: dict[str, Callable] = {
+    _V2_TEAM_ID: _v2_build_run_message,
 }
효과(trace). seq0 user content: "…질문…""…질문…\n\n[첨부파일 경로: /srv/…/cafe_sales.xlsx] …". 프롬프트가 0글자 변경이라 hash가 v1과 같다 — AC9는 team_id가 다르므로 위반 아님. 단 도구는 여전히 v1(UTF-8 리더)이라 바이너리 첨부는 계속 실패.

v2 → v3-prompt-fix · 프롬프트층

EDIT teams/v3_prompt_fix.py (PLANNER·CRITIC)  ·  EXECUTOR·SELECTOR는 from v1_baseline import … 그대로  ·  hash H₁→H₃

PLANNER_PROMPT

--- a/PLANNER_PROMPT (v1=v2)
+++ b/PLANNER_PROMPT (v3)
@@ -2,5 +2,11 @@
 - 태스크를 단계로 분해하고 executor에게 필요한 계산/파일 작업을 지시한다.
 - 직접 도구를 실행하지 않는다.
-- 모든 필요 정보가 모이면 critic 검증을 받을 최종 답변 초안을 작성한다.
-- 최종 답변은 반드시 `FINAL ANSWER: <answer>` 한 줄이어야 한다.
-- 숫자는 콤마/단위 없이, 문자열은 관사 없이, 리스트는 콤마 구분으로 작성한다.
+- 모든 필요 정보가 모이면 critic 검증을 받을 최종 답변을 아래 형식으로 한 줄만 출력한다.
+
+  숫자:    FINAL ANSWER: 42
+  문자열:  FINAL ANSWER: Paris
+  리스트:  FINAL ANSWER: cat, dog, fish
+
+- <answer> 같은 플레이스홀더는 절대 출력하지 않는다. 반드시 실제 값을 적는다.
+- 숫자는 콤마·단위 없이, 문자열은 관사 없이, 리스트는 쉼표 구분으로 작성한다.
+- executor의 python_calc는 산술식 전용(예: 3*4+2)이다. import/print 코드는 전달하지 않는다.

CRITIC_PROMPT

--- a/CRITIC_PROMPT (v1=v2)    +++ b/CRITIC_PROMPT (v3)
@@ -1,4 +1,5 @@
 당신은 GAIA 평가용 critic이다.
 - 최종 답변이 질문에 정확히 답하는지 검토한다.
-- `FINAL ANSWER: <answer>` 형식을 지키는지 확인한다.
+- `FINAL ANSWER: 42` 또는 `FINAL ANSWER: Paris` 처럼 실제 값이 들어간 형식인지 확인한다.
+- `FINAL ANSWER: <answer>` 처럼 꺾쇠 플레이스홀더가 남아 있으면 반드시 반려한다.
 - 통과하면 APPROVE 만 단독 출력하고, 아니면 구체적 반려 사유를 출력한다.
왜. 모델이 예시 <answer>를 그대로 따라 써 FINAL ANSWER: <answer>를 출력 → logger의 _TEMPLATE_PLACEHOLDER_RE(^[`<*])가 None 반환 → final_answer 미캡처. 예시를 실제 값으로 바꾸고 플레이스홀더를 명시 금지해 차단.

v3 → v4-prompt-fix · 프롬프트층

EDIT teams/v4_prompt_fix.py (PLANNER +1줄·CRITIC +1줄)  ·  hash H₃→H₄

PLANNER_PROMPT

--- a/PLANNER_PROMPT (v3)    +++ b/PLANNER_PROMPT (v4)
@@ -8,4 +8,5 @@
   리스트:  FINAL ANSWER: cat, dog, fish

+- 답이 한 단어 또는 한 글자여도 반드시 `FINAL ANSWER:` 형식을 사용한다. 절대 생략하지 않는다.
 - <answer> 같은 플레이스홀더는 절대 출력하지 않는다. 반드시 실제 값을 적는다.
 - 숫자는 콤마·단위 없이, 문자열은 관사 없이, 리스트는 쉼표 구분으로 작성한다.

CRITIC_PROMPT

--- a/CRITIC_PROMPT (v3)    +++ b/CRITIC_PROMPT (v4)
@@ -2,4 +2,5 @@
 - 최종 답변이 질문에 정확히 답하는지 검토한다.
 - `FINAL ANSWER: 42` 또는 `FINAL ANSWER: Paris` 처럼 실제 값이 들어간 형식인지 확인한다.
+- `FINAL ANSWER:` 행이 없으면 답변 길이에 관계없이 반드시 반려한다.
 - `FINAL ANSWER: <answer>` 처럼 꺾쇠 플레이스홀더가 남아 있으면 반드시 반려한다.
 - 통과하면 APPROVE 만 단독 출력하고, 아니면 구체적 반려 사유를 출력한다.
왜. v3에서 답이 Guava 한 단어면 모델이 FINAL ANSWER: 행을 생략 → format_valid=false. “한 글자라도 형식 필수” 한 줄 + critic 반려 규칙 한 줄로 격리 수정.

v4 → v5-multitool · 도구층 + 프롬프트 예제 run

NEW teams/tools_v5.py  ·  EDIT teams/v5_multitool.py (import·tools·PLANNER·EXECUTOR)  ·  CRITIC는 v4와 동일  ·  hash H₄→H₅

① 도구 교체 — build_team import + tools 리스트

--- a/teams/v5_multitool.py    +++ b/teams/v5_multitool.py
-from agent_eval.teams.v1_baseline import (… python_calc, read_attached_file)
+from agent_eval.teams.tools_v5 import python_exec, read_attached_file
 ...
-        tools=[read_attached_file, python_calc],
+        tools=[read_attached_file, python_exec],

② read_attached_file — UTF-8 단일 리더 → 확장자별 멀티포맷 파서

- def read_attached_file(path):                          # v1~v4 (teams/v1_baseline.py)
-     p = Path(path).expanduser().resolve()
-     if not p.is_file(): raise FileNotFoundError(str(p))
-     return p.read_text(encoding="utf-8")               # ← 바이너리에서 UnicodeDecodeError로 전수 실패
+ def read_attached_file(path):                          # v5 (teams/tools_v5.py)
+     ext = p.suffix.lower()
+     if   ext in _TEXT_EXTS:        content = _read_text(p)     # errors="replace"
+     elif ext in (".xlsx",".xls"):  content = _read_xlsx(p)     # openpyxl 시트→탭 텍스트
+     elif ext == ".docx":           content = _read_docx(p)     # python-docx
+     elif ext == ".pptx":           content = _read_pptx(p)     # python-pptx
+     elif ext == ".pdf":            content = _read_pdf(p)      # pdfplumber
+     elif ext in _IMAGE_EXTS:       return "[이미지 …텍스트 추출 불가…]"   # 크래시 대신 안내문
+     elif ext in _AUDIO_EXTS:       return "[오디오 …전사 불가…]"
+     return _truncate(content, 20_000)

③ python_calc(AST 산술 전용) → python_exec(격리 subprocess)

- def python_calc(expression):                  # v1~v4 — AST allowlist, 산술만
-     parsed = ast.parse(expression, mode="eval")  # 파일 파싱 코드 넘기면 SyntaxError
-     return str(_eval_numeric(parsed))
+ def python_exec(code):                         # v5 — 모델이 쓴 파이썬을 실행
+     proc = subprocess.run([sys.executable, "-c", code],
+                           capture_output=True, text=True,
+                           timeout=20, cwd=tempdir)   # 20s 타임아웃 · 8KB 출력 가드 · 폐쇄망
+     return _truncate(proc.stdout, 8_000)

④ PLANNER_PROMPT

--- a/PLANNER_PROMPT (v4)    +++ b/PLANNER_PROMPT (v5)
@@ -1,4 +1,4 @@
 당신은 GAIA 평가용 planner다.
-- 태스크를 단계로 분해하고 executor에게 필요한 계산/파일 작업을 지시한다.
+- 태스크를 단계로 분해하고 executor에게 필요한 파일 작업/계산을 지시한다.
 - 직접 도구를 실행하지 않는다.
 - 모든 필요 정보가 모이면 critic 검증을 받을 최종 답변을 아래 형식으로 한 줄만 출력한다.
@@ -11,3 +11,4 @@
 - <answer> 같은 플레이스홀더는 절대 출력하지 않는다. 반드시 실제 값을 적는다.
 - 숫자는 콤마·단위 없이, 문자열은 관사 없이, 리스트는 쉼표 구분으로 작성한다.
-- executor의 python_calc는 산술식 전용(예: 3*4+2)이다. import/print 코드는 전달하지 않는다.
+- 첨부파일이 주어지면 executor에게 read_attached_file로 내용을 먼저 추출하게 한다.
+- 표 합계·필터·정렬 등 산술 이상의 계산이 필요하면 executor에게 python_exec 코드를 지시한다.

⑤ EXECUTOR_PROMPT (v1~v4 → v5)

--- a/EXECUTOR_PROMPT (v1~v4)    +++ b/EXECUTOR_PROMPT (v5)
@@ -1,4 +1,10 @@
-당신은 GAIA 평가용 executor다.
-- planner가 요청한 로컬 첨부파일 읽기와 안전한 산술 계산만 수행한다.
-- 폐쇄망 전제이므로 웹/API 호출을 시도하지 않는다.
-- 도구 결과를 간결히 보고한다.
+당신은 GAIA 평가용 executor다. 두 가지 로컬 도구만 사용한다.
+1. read_attached_file(path): 첨부파일을 텍스트로 추출한다. 텍스트뿐 아니라
+   xlsx/docx/pptx/pdf도 지원한다. 이미지/오디오는 추출 불가 안내를 반환한다.
+   질문에 [첨부파일 경로: ...]가 있으면 그 절대경로를 그대로 전달한다.
+2. python_exec(code): 격리된 Python으로 코드를 실행하고 print 출력을 받는다.
+   파일 파싱·표 집계·정렬·날짜 계산 등 산술 이상의 작업에 사용한다.
+   첨부 파일은 절대경로로 open 한다. 결과는 반드시 print 한다.
+   pandas, openpyxl 등 표준 데이터 라이브러리를 import 할 수 있다.
+- 폐쇄망 전제이므로 웹/API 호출은 시도하지 않는다.
+- 도구 결과(추출 텍스트·실행 출력)를 간결히 보고한다.
효과(trace). tool_call.tool_name이 python_calcpython_exec, args 키가 expressioncode. 바이너리 xlsx가 더는 UnicodeDecodeError(tool_error)가 아니라 추출 텍스트(tool_result)로 떨어진다 — §E·§G의 그 run이 바로 이 변경의 산물.

v1 → v6-verify-gate · 라우팅층 (selector만)

EDIT teams/v6_verify_gate.py (SELECTOR_PROMPT 1곳)  ·  PLANNER·EXECUTOR·CRITIC·도구·종료조건 = from v1_baseline import … 무변경  ·  hash H₁→H₆

SELECTOR_PROMPT — "검증 의무 경유" 블록 7줄 추가

--- a/SELECTOR_PROMPT (v1)    +++ b/SELECTOR_PROMPT (v6)
@@ -4,4 +4,11 @@
 Conversation:
 {history}
+
+Mandatory verification gate (do not violate):
+- A run may end ONLY after critic outputs APPROVE.
+- The moment planner produces a line starting with `FINAL ANSWER:`, you MUST select
+  critic next with reason_code NEEDS_VERIFICATION. Never route to planner or executor
+  to "finalize" a draft — only critic can clear a final answer.
+- If critic returned a rejection, route back to planner with reason_code NEEDS_REVISION.

 Return exactly one JSON object with this schema:
왜 + 효과. MAST FM-3.2(검증 미경유)는 critic의 게으름이 아니라 selector가 검증을 건너뛰는 라우팅이라는 진단. selector 프롬프트만 7줄 바꿔, planner FINAL ANSWER 직후 selector_decision 행이 항상 →critic / NEEDS_VERIFICATION이 되도록 강제. 도구·프롬프트가 v1과 같아 hash가 selector 한 곳 차이로만 H₆가 된다.

“1변수” 가드가 코드로 강제되는 지점

핵심 직관. v2~v6은 모델/채점/로깅을 고정한 채 파이프라인의 서로 다른 층을 하나씩 친다 — 입력층(v2)프롬프트층(v3,v4)도구층(v5)라우팅층(v6). 그래서 trace를 보면 “무엇이 바뀌어서 성공/실패가 갈렸는지”가 컬럼 단위로 드러난다(args.path, tool_name, selector_reason 시퀀스 …).

K불변식(CHECK) 대조 — 이 trace가 통과하는 제약들

스키마(001_init.sql)의 CHECK는 “정합성을 코드 검증만으로 끝내지 마라”(절대규칙 4)의 집행자. 위 18행+runs 1행이 실제로 통과하는지 대조.

제약이 run에서
events PK (run_id, seq)seq 0~17 유일 ✔
tool_call → tool_call_id·tool_name NOT NULLseq4·10: call_Rkz9/Vh2m + 이름 둘 다 있음 ✔
tool_result → tool_call_id NOT NULLseq5·11: call_id 있음 ✔
NOT(tool_result AND tool_error 동시 NOT NULL)둘 다 tool_result만(is_error=False) ✔
latency_ms ≥ 0 OR NULLseq0=NULL, 나머지 ≥0 ✔
runs: status='completed' → success NOT NULLcompleted + success=true ✔
terminated_by ∈ {approve,max_messages,error}approve ✔
temperature ≥ 0 · tokens ≥ 00.0 · 5670/5132/538 ✔
UNIQUE(batch_id, task_id, attempt)재실행 시 _prepare_run_row가 기존 행 DELETE 후 재INSERT(retry) ✔
검증 SQL(CLAUDE.md DoD). 이 run은 “완료인데 success NULL”도, “tool_call인데 응답 짝 없음”도 0행이다 — seq4↔seq5(call_Rkz9), seq10↔seq11(call_Vh2m)이 tool_call↔tool_result로 정확히 페어링되기 때문.

L평가 실행 명령어 — 옵션별 완전 레퍼런스

진입점은 python cli.py <command> (typer). 모든 명령은 load_settings().envEVAL_*를 읽는다. 비밀(DSN/키)은 로그·예외에 출력하지 않는다.

L.0 환경변수 (EVAL_*) — src/agent_eval/config.py

변수기본값의미 / 비고
EVAL_PG_DSN필수PostgreSQL DSN. 누락 시 모든 명령 즉시 실패(값은 미출력). 스키마 eval_ph1로 격리
EVAL_LLM_BASE_URLNone내부 vLLM OpenAI-compatible 엔드포인트. 폐쇄망: 이 값만 사용(외부 API 금지)
EVAL_LLM_MODELNone (.env: glm-4.7)runs.model_name 으로 기록. 항상 당시 최고 가용 모델 권장
EVAL_LLM_API_KEYNone (.env: dummy)vLLM은 임의값 허용. 외부키면 커밋 금지
EVAL_LLM_REASONING_EFFORTNonereasoning 모델 추론강도(예: xhigh). 있을 때만 create arg로 전달
EVAL_GAIA_DIRdata/gaia/validationGAIA metadata.jsonl+첨부 위치. v2~v5 첨부 절대경로 주입의 기준
EVAL_RESEARCH_DIR / EVAL_CPP_DIRdata/research · expert-solver/problemsresearch / cppx 태스크 소스
EVAL_DEFAULT_TEMPERATURE0.2runs.temperature(NOT NULL). 재현성 비교의 고정 변수
EVAL_DEFAULT_SEEDNone (.env: 42)runs.seed. 비교하려면 반드시 설정(미설정 run은 비교 불능)
EVAL_MAX_MESSAGES20MaxMessageTermination 한계 → 초과 시 terminated_by=max_messages
EVAL_CONCURRENCY1 (.env: 2)--concurrency 미지정 시 기본 동시 실행 수
EVAL_FIRECRAWL_API_KEY / _BASE_URLNone · api.firecrawl.devweb_search 백엔드. 키 있으면 Firecrawl, 없으면 DDG 폴백
EVAL_CLOSED_NETtrue폐쇄망 토글(기본 ON=안전). web 검색 모드는 OFF에서만

L.1 run — 평가 배치 실행 (핵심)

python cli.py run --batch <ID> [--team …] [--benchmark …] [--level N] [--exclude-web]
                  [--task-ids a,b] [--limit N] [--attempts N] [--concurrency N] [--retry-crashed]
옵션타입기본의미 (cli.py:212)
--batchstr필수배치 ID = runs.batch_id. UNIQUE(batch_id,task_id,attempt)의 재개 키 — 같은 batch 재실행 시 완료분은 건너뜀
--teamstrv1-baselineregistry에 등록된 team_id (미등록이면 등록목록과 함께 KeyError)
--benchmarkstrgaiatasks.benchmark 필터. gaia / research / cppx / analysis / extract / sql / meeting / ocrproxy / coding / codetest …
--levelintNoneGAIA level(1·2·3) 또는 research hops 필터
--exclude-webflagoffrequires_web=false인 task만 — 폐쇄망/오프라인 스모크
--task-idscsvNone특정 task_id만(콤마구분). 크래시 수렴·디버깅에 핵심
--limitintNone선택 task 수 상한(ORDER BY task_id LIMIT). 스모크용
--attemptsint≥11task별 반복 횟수 → pass@k. 각 attempt가 독립 run 행
--concurrencyint≥1None→EVAL_CONCURRENCY동시 실행 수(asyncio.Semaphore). 풀 크기도 자동 +1
--retry-crashedflagoff기존 crashed run을 삭제 후 재실행. completed는 무조건 skip

L.2 시나리오별 실행 레시피 (복붙)

# ① 오프라인 스모크 — 2문제만, 웹 불요, 끝까지 도는지 확인
python cli.py run --batch dev_smoke --team v1-baseline --limit 2 --exclude-web

# ② GAIA level-1 전체를 v5로 (첨부 멀티포맷 도구)
python cli.py run --batch gaia_l1_v5 --team v5-multitool --benchmark gaia --level 1 --concurrency 4

# ③ pass@3 (task별 3회 반복) — 변동성 측정
python cli.py run --batch gaia_l1_v5_k3 --team v5-multitool --level 1 --attempts 3

# ④ 1변수 비교쌍: 같은 task 셋을 v1 / v6 두 배치로
python cli.py run --batch cmp_v1 --team v1-baseline   --level 1
python cli.py run --batch cmp_v6 --team v6-verify-gate --level 1
python cli.py compare --batches cmp_v1,cmp_v6

# ⑤ 특정 실패 task만 골라 크래시 재수렴 (1개씩 독립 권장 — 아래 §M.4)
python cli.py run --batch gaia_l1_v5 --task-ids c61d22de-…,8f80a1b2-… --retry-crashed

L.3 데이터 적재 / DB

명령역할주요 옵션
init-dbmigrations 001+ 멱등 적용(000_provision은 DBA 수동). 2회 실행해도 성공(AC1)
load-gaiaEVAL_GAIA_DIR/metadata.jsonl → tasks 적재(첨부 누락 task는 필터 표시)
load-researchtasks.jsonl → tasks--dir --benchmark research|research_hard
load-cppxExpert 문제 → tasks(benchmark=cppx)--dir --level-dir L5_full
load-learnalgo_seed.jsonl → tasks(learn_algo)

L.4 리포팅 (읽기 전용 — 뷰만 읽음)

명령역할옵션
report배치 요약: pass@1·pass@k·레벨별 p50·도구 사용--batch (필수)
compare2개 이상 배치를 레벨별로 나란히 비교--batches a,b[,c] (2개↑)
failures실패 run 트랜스크립트 요약(마지막 이벤트들)--batch --limit 10

L.5 채점 / grounding · L.6 기타

명령역할옵션
grounding-scoreresearch 배치 인용 grounding(F1) → research_grounding--batch
cppx-groundingcppx 배치 사실 인용 grounding → cppx_grounding--batch
export-team팀 → AutoGen Studio 컴포넌트 JSON(비밀 스크럽·날짜 동적화)--team --out --baseline-tool
brain-digest배치 per-claim verdict(events.eval_meta) → grounded 다이제스트--batch --interest --period --write
learn-next / learn-update학습 슬라이스: 다음 출제 토픽 선택 / 결과를 learner_skill 반영--batch(update)
fleet-run야간 플릿 1회전(브리프). 기본 --dry-run, 저장은 --commit--date --domains --commit
propose복리 엔지니어링: 이상치/반복 → 1변수 실험·규칙 제안. 기본 dry-run--domains --commit --git-lookback 30

narrate(LLM 서사 리포트)는 SPEC §0.3대로 의도적 미구현.

M추가 평가(테스트) 돌리는 법 — 단계별 가이드

“새 실험을 하나 추가해서 돌리고 비교한다”의 전 과정. 핵심 규율: 1배치=1조건, 1팀=1변수, 비교는 같은 task 셋으로.

M.0 준비 (1회)

.env 작성 init-db 멱등 load-gaia smoke_llm.py tool calling 왕복 run --limit 2
pip install -e ".[dev]"                 # 폐쇄망이면 사내 PyPI 미러
cp .env.example .env                    # EVAL_PG_DSN / EVAL_LLM_* 채우기
python cli.py init-db                   # 001+ 멱등 적용 (2회 돌려도 성공해야 정상)
python cli.py load-gaia                 # metadata.jsonl → tasks
python scripts/smoke_llm.py            # GLM function calling 왕복 (M2 전 필수)
python cli.py run --batch dev_smoke --team v1-baseline --limit 2 --exclude-web
smoke_llm.py가 실패하면 가장 먼저 vLLM tool calling 설정을 의심: --enable-auto-tool-choice --tool-call-parser <glm parser> 없이는 도구 호출이 왕복하지 않는다.

M.1 시나리오 A — 기존 팀으로 새 배치

python cli.py run --batch <새 batch ID> --team v5-multitool --benchmark gaia --level 1
python cli.py report --batch <새 batch ID>          # pass@1·p50·도구

배치 ID는 의미를 담아 유일하게(예: gaia_l1_v5_20260623). 중간에 끊겨도 같은 ID로 다시 돌리면 이어서 채운다.

M.2 시나리오 B — 새 실험 팀 추가 (1변수 개입)

§J의 v2~v6이 바로 이 절차로 만들어졌다. “프롬프트 한 글자라도” 바꾸면 새 team_id(AC9).

#단계구체
1팀 파일 작성src/agent_eval/teams/v7_xxx.pyTEAM_ID, 프롬프트(또는 v1 재사용), build_team()(SelectorGroupChat, team_meta) 반환. team_metaprompt_bundle_hash 필수. 입력을 가공하면 build_run_message(task, settings)도 정의
2레지스트리 등록teams/registry.py: import 후 TEAM_REGISTRYteam_id → build_team 한 줄. 입력 가공팀이면 _RUN_MESSAGE_BUILDERS에도 추가
3새 team_id프롬프트가 바뀌었으면 반드시 새 ID. 기존 ID에 다른 prompt_bundle_hash를 쓰면 ensure_team_recordTeamConfigMismatchError즉시 중단(AC9)
4테스트pytest -x. 매핑을 건드렸으면 tests/test_mapping.py, 채점기면 tests/test_scorer.py 동반 갱신
5스모크run --team v7-xxx --limit 2 --exclude-web — 끝까지 돌고 정합성 SQL 0행 확인(§M.5)
6본 실행 + 비교전체 run 후 baseline 배치와 compare --batches base,v7

레지스트리 등록 — 실제 편집 2곳 (teams/registry.py)

# ① import (파일 상단 v-시리즈 import 옆)
+from agent_eval.teams.v7_xxx import TEAM_ID as _V7_TEAM_ID
+from agent_eval.teams.v7_xxx import build_team as _build_team_v7

# ② 빌더 매핑에 한 줄
 TEAM_REGISTRY: dict[str, Callable] = {
     ...
+    _V7_TEAM_ID: _build_team_v7,
 }

# ③ (입력 가공팀일 때만) run 메시지 빌더 등록
 _RUN_MESSAGE_BUILDERS: dict[str, Callable] = {
     ...
+    _V7_TEAM_ID: _v7_build_run_message,
 }
SSOT 규율. 스키마 변경이 필요하면 코드 내 DDL 금지 — migrations/0NN_*.sql 번호 증가 파일을 추가(기존 파일 수정 금지)하고 init-db로 적용. 모델은 EVAL_LLM_BASE_URL(내부 vLLM)만.

M.3 시나리오 C — 단위 테스트(pytest)

pytest -x                       # 전체. LLM은 mock — 실 네트워크 호출 금지
pytest tests/test_scorer.py -x  # 채점 규칙(AC3: 최소 15케이스)
pytest tests/test_mapping.py -x # 이벤트→message_type 매핑

신규 코드엔 신규 테스트 동반(scorer/매핑/제약은 필수 대상). 동작을 바꾸면 해당 테스트부터 확인한다.

M.4 동시성 · 재개 · 크래시

# 크래시 1건씩 독립 재시도 (bash)
for t in $(

  

M.5 정합성 점검 SQL (Definition of Done)

스모크 후 아래가 모두 0행이어야 완료 선언:

-- 완료 run인데 success 누락
SELECT run_id FROM runs WHERE status='completed' AND success IS NULL;

-- tool_call인데 응답(result/error) 짝이 없는 이벤트
SELECT e.run_id, e.seq FROM events e
WHERE e.message_type='tool_call' AND NOT EXISTS (
  SELECT 1 FROM events r WHERE r.run_id=e.run_id
    AND r.tool_call_id=e.tool_call_id
    AND r.message_type IN ('tool_result','tool_error'));

M.6 한 배치의 체크리스트

.env 고정값(temperature·seed·model) 확인 → ② pytest -x → ③ run --limit 2 스모크 + 정합성 SQL 0행 → ④ 전체 run(같은 batch로 재개 가능) → ⑤ report --batch / 1변수면 compare → ⑥ (research/cppx면) grounding-score → ⑦ Grafana/eval-metrics에서 확인. 검증 못 하면 완료 선언 안 함.