콘텐츠로 이동

11. 관측 · 평가

“"11. 관측 · 평가"” 챕터 컨셉 일러스트

한 줄 정의. 관측은 에이전트가 무엇을 했는지를 트레이스로 남기는 일이고, 평가는 그 결과가 맞는지를 반복 가능한 기준으로 채점하는 일이다 — 둘이 합쳐져야 에이전트를 믿고 운영할 수 있다.

에이전트 실행 트레이스 타임라인 — 한 번의 실행을 트레이스로 묶고 그 안의 LLM 호출·도구 호출·핸드오프 스팬을 시간순으로 펼친 뒤 회귀 데이터셋·평가 실행으로 이어지는 흐름

에이전트는 비결정적입니다. 같은 입력이라도 모델이 매번 다른 도구를 다른 순서로 부를 수 있고, 그래서 “한 번 잘 됐다”가 “항상 잘 된다”를 보장하지 않습니다. 데모에서 멀쩡하던 에이전트가 운영에서 무너지는 이유는 대개 여기 있습니다.

관측과 평가는 이 불확실성을 다루는 두 축입니다. 관측은 실행을 들여다보게 해주고(무슨 일이 있었나), 평가는 그 실행이 기준을 통과하는지를 반복 가능하게 채점합니다(맞게 했나). 앞에서 다룬 하네스 실행 루프가 계획·행동·관찰을 모델 바깥에서 돌리는 장치라면, 이 장은 그 루프가 실제로 잘 돌고 있는지를 증명하는 장치입니다.

트레이스와 스팬: 데이터 모델부터 정확히

섹션 제목: “트레이스와 스팬: 데이터 모델부터 정확히”

OpenAI Agents SDK는 한 번의 실행을 trace로 묶습니다. 트레이스는 “워크플로의 단일한 엔드투엔드 작업(a single end-to-end operation of a workflow)“이고, 그 안에 여러 span이 담깁니다.

스팬은 “시작·종료 시각을 가진 작업(operations that have a start and end time)“입니다1. 이 둘의 관계가 관측의 전부입니다 — 트레이스는 컨테이너이고, 스팬은 그 안에서 일어난 개별 작업의 시간 구간입니다.

트레이스가 갖는 속성은 다음과 같습니다1.

  • workflow_name: 이 트레이스가 무슨 워크플로인지 가리키는 논리적 이름(예: "고객 응대"). 외부 대시보드에서 트레이스를 그룹핑하는 1차 키가 됩니다.
  • trace_id: 트레이스의 고유 식별자입니다. 형식이 못 박혀 있어서 trace_<32자 영숫자>여야 합니다. 직접 만들 때 이 포맷을 어기면 트레이스가 백엔드에서 누락되거나 거부될 수 있으니, 보통은 SDK가 생성하게 두는 편이 안전합니다.
  • group_id: 같은 대화/스레드에 속한 여러 트레이스를 하나로 묶는 선택적 키입니다. 예를 들어 한 채팅 스레드 ID를 group_id로 넣으면, 그 스레드에서 발생한 여러 번의 실행 트레이스가 대시보드에서 한 묶음으로 보입니다.
  • disabled: 불리언입니다. True면 이 트레이스는 기록되지 않습니다.
  • metadata: 선택적 부가 정보입니다.

스팬의 속성은 결이 다릅니다1.

  • started_at·ended_at: 타임스탬프입니다. 이 두 값의 차이가 곧 그 작업의 지연(latency)이며, 비용·지연 모니터링의 원천 데이터가 됩니다.
  • trace_id: 이 스팬이 어느 트레이스에 속하는지를 가리키는 부모 트레이스 참조입니다.
  • parent_id: 중첩(nesting)된 경우 부모 스팬을 가리킵니다. 핸드오프나 도구 호출이 다른 작업 안에서 일어나면 이 필드로 부모-자식 트리가 만들어집니다.
  • span_data: 작업 정보를 담은 객체입니다.

기본 추적은 켜져 있다 — 그리고 끄는 방법은 셋이다

섹션 제목: “기본 추적은 켜져 있다 — 그리고 끄는 방법은 셋이다”

이 계측은 기본으로 켜져 있습니다(enabled by default)1. SDK는 별도 설정 없이도 Runner 실행 전체를 자동으로 하나의 트레이스로 감쌉니다. 즉 아무것도 안 해도 트레이스가 쌓인다는 뜻이라, 운영 데이터를 외부로 내보내면 안 되는 환경이라면 끄는 법을 정확히 아는 게 더 중요합니다.

전역 비활성화 경로는 세 가지이며, 적용 범위가 서로 다릅니다1.

  1. 환경변수 OPENAI_AGENTS_DISABLE_TRACING=1 — 프로세스 전체에서 트레이싱을 끕니다. 배포 환경 설정으로 한 방에 막을 때 씁니다.
  2. 함수 set_tracing_disabled(True) — 코드에서 전역으로 끕니다. 환경변수를 못 건드리는 상황의 프로그래밍적 스위치입니다.
  3. 단일 실행 RunConfig(tracing_disabled=True) — 이 한 번의 Runner.run()에 대해서만 끕니다. 평소엔 트레이스를 남기되 특정 민감 요청만 기록에서 빼고 싶을 때 적합합니다.

기본 생성되는 스팬: 텍스트 5종 + 음성 3종

섹션 제목: “기본 생성되는 스팬: 텍스트 5종 + 음성 3종”

기본 추적이 켜져 있는 동안 SDK가 자동으로 만드는 스팬 종류는 다음과 같습니다. 생성 함수들은 agents.tracing.create 모듈에 정의되어 있습니다1.

  • agent_span() — 에이전트가 실행될 때 생성됩니다.
  • generation_span() — LLM 생성, 즉 모델 호출입니다.
  • function_span() — 함수 도구 호출입니다.
  • guardrail_span()가드레일 평가입니다.
  • handoff_span() — 다른 에이전트로의 핸드오프입니다.

여기까지가 텍스트 파이프라인의 5종인데, 음성(voice) 파이프라인을 쓰면 스팬이 셋 더 붙습니다1.

  • transcription_span() — 음성을 텍스트로 옮기는(STT) 구간입니다.
  • speech_span() — 텍스트를 음성으로 합성하는(TTS) 구간입니다.
  • speech_group_span() — 위 음성 스팬들을 묶는 그룹 스팬입니다.

커스텀 트레이스: with·수동·중첩·flush

섹션 제목: “커스텀 트레이스: with·수동·중첩·flush”

기본 트레이스로 부족할 때는 직접 트레이스를 만듭니다.

권장 방식은 컨텍스트 매니저입니다. with trace("workflow_name") as my_trace: 블록으로 묶으면 진입·종료가 자동으로 처리됩니다1.

from agents import Runner, trace
with trace("고객 응대") as my_trace:
result = await Runner.run(agent, user_input)
# 블록을 벗어나면 트레이스가 자동으로 마무리된다

수동 방식도 있습니다. trace.start()trace.finish()를 직접 호출하는데, 이때 현재 컨텍스트 추적을 올바르게 관리하려면 mark_as_current/reset_current 인자를 함께 다뤄야 합니다1. 이 인자를 빼먹으면 “현재 트레이스”가 잘못 잡혀, 이후 스팬들이 엉뚱한 트레이스에 붙거나 트리가 어긋납니다.

그래서 가능하면 컨텍스트 매니저를 쓰는 편이 안전합니다.

my_trace = trace("배치 작업")
my_trace.start(mark_as_current=True)
try:
... # 이 사이에 생성되는 스팬은 my_trace에 붙는다
finally:
my_trace.finish(reset_current=True)

여러 run을 하나의 트레이스로 묶기 (중첩)

섹션 제목: “여러 run을 하나의 트레이스로 묶기 (중첩)”

group_id로 트레이스들을 연결하는 것과, 여러 실행을 하나의 트레이스 안에 넣는 것은 다른 이야기입니다. 후자가 고수준 트레이스(nesting)입니다.

여러 번의 Runner.run() 호출을 하나의 with trace()로 감싸면, 개별 run이 각각 별도 트레이스를 만들지 않고 하나의 상위 트레이스의 일부가 됩니다1. 다단계 워크플로(예: 검색 → 요약 → 검증을 세 번의 run으로 돌릴 때)를 한 화면에서 한 작업으로 보고 싶을 때 핵심이 되는 패턴입니다.

with trace("리서치 워크플로"):
search = await Runner.run(search_agent, query)
summary = await Runner.run(summary_agent, search.final_output)
verdict = await Runner.run(verify_agent, summary.final_output)
# 세 번의 run이 별도 트레이스가 아니라 하나의 트레이스 아래 스팬으로 묶인다

트래젝토리 리뷰: 결과가 아니라 경로를 본다

섹션 제목: “트래젝토리 리뷰: 결과가 아니라 경로를 본다”

에이전트 디버깅의 핵심은 “최종 답이 틀렸다”가 아니라 “어느 스팬에서 틀어졌나”입니다. 위 그림처럼 스팬을 시간순으로 펼치면 — 어떤 generation_span에서 잘못된 계획을 세웠는지, 어떤 function_span에서 엉뚱한 인자로 도구를 불렀는지, 어디서 시간을 다 썼는지가 한눈에 드러납니다.

이렇게 실행 경로(트래젝토리) 전체를 따라가며 검토하는 것이 트래젝토리 리뷰입니다.

parent_id로 스팬이 트리를 이루기 때문에, 핸드오프로 다른 에이전트에 넘어간 구간이나 도구 호출이 중첩된 구간도 부모-자식 관계로 추적됩니다1. 결과만 보는 로깅과 달리, 트레이스는 “왜 그 답이 나왔는가”의 인과를 보존합니다. started_at·ended_at 덕분에 같은 트리에서 어느 스팬이 지연을 잡아먹었는지도 같이 읽힙니다.

트레이스는 기본적으로 LLM 생성의 입력·출력과 함수 호출의 인자 데이터를 저장합니다. 이를 제어하는 핵심 스위치가 RunConfig.trace_include_sensitive_data이고, 기본값은 True입니다1. 즉 아무 설정도 안 하면 모델 입출력과 함수 인자가 그대로 트레이스에 남습니다.

개인정보·비밀번호·결제정보가 프롬프트나 도구 인자로 흐르는 운영 환경이라면 이 기본값이 곧 위험입니다.

같은 동작을 환경변수로도 켜고 끌 수 있습니다. OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATAtrue/1 또는 false/0을 주면 됩니다1. 코드를 못 건드리는 배포에서 환경 설정만으로 캡처를 차단할 수 있는 경로입니다.

음성 파이프라인은 오디오 데이터를 따로 다룹니다. VoicePipelineConfig.trace_include_sensitive_audio_data로 오디오 캡처 여부를 제어합니다1. 텍스트 쪽 스위치와 별개라서, 음성 에이전트는 두 군데를 모두 확인해야 민감 데이터가 새지 않습니다.

트레이스 프로세서와 외부 플랫폼: 추가냐 교체냐

섹션 제목: “트레이스 프로세서와 외부 플랫폼: 추가냐 교체냐”

트레이스가 어디로 가는지는 갈아끼울 수 있습니다. 다만 두 함수의 의미가 결정적으로 다릅니다1.

  • add_trace_processor() — OpenAI 백엔드로 가는 export를 유지한 채 추가 프로세서를 더합니다. OpenAI 대시보드와 외부 플랫폼에 동시에 보내고 싶을 때 씁니다.
  • set_trace_processors() — 기본 프로세서를 전면 교체합니다. 이 순간 OpenAI 백엔드로 가던 export는 사라집니다.

내부 아키텍처를 알면 이 차이가 자연스럽습니다. 전역 TraceProvider가 트레이스를 생성·관리하고, BatchTraceProcessor가 스팬을 배치로 모으며, BackendSpanExporter가 그 배치를 OpenAI 백엔드로 전송합니다1. set_trace_processors()는 이 프로세서 체인 자체를 갈아끼우는 것이라, 기본 체인에 있던 백엔드 export가 빠지는 게 당연합니다.

그리고 배치로 모으는 구조이기 때문에 앞서 말한 flush_traces()가 즉시 내보내기 위해 필요했던 것입니다.

연동 가능한 외부 플랫폼은 공식 문서 기준 27종에 이릅니다 — Weights & Biases, Arize-Phoenix, Future AGI, MLflow(self-hosted/Databricks), Braintrust, Pydantic Logfire, AgentOps, Scorecard, Respan, LangSmith, Maxim AI, Comet Opik, Langfuse, Langtrace, Okahu-Monocle, Galileo, Portkey AI, LangDB AI, Agenta, PostHog, Traccia, PromptLayer, HoneyHive, Asqav, Datadog, Latitude 등입니다1. 즉 트레이싱은 표준 계측 지점이고, 어디로 보낼지는 갈아끼울 수 있습니다. 하네스마다 이런 계측을 어디까지 기본 제공하는지는 프레임워크 비교에서 따로 다룹니다.

평가(Eval): 정의와 3단계 워크플로

섹션 제목: “평가(Eval): 정의와 3단계 워크플로”

관측이 “지금 무슨 일이 있었나”라면, 평가(eval)는 “이게 기준을 통과하나”입니다. OpenAI Evals 문서는 eval을 “여러분이 지정한 스타일·내용 기준을 모델 출력이 충족하는지 검증하는(test model outputs to ensure they meet style and content criteria that you specify)” 장치로 정의합니다2.

워크플로는 세 단계입니다2.

  1. 작업을 eval 설정으로 기술한다(describe task). 무엇을 어떤 기준으로 채점할지 정의합니다.
  2. 테스트 입력으로 실행한다(run). 프롬프트와 테스트 데이터를 묶어 eval run을 돌립니다.
  3. 결과를 분석하고 프롬프트를 반복한다(iterate). 통과율을 보고 프롬프트·모델을 고친 뒤 다시 돌립니다.

eval의 구성은 두 축으로 나뉩니다 — 테스트 데이터의 스키마를 정하는 data source와, 채점 규칙을 정하는 testing criteria입니다2. 이 둘이 짝을 이뤄야 하나의 eval이 성립합니다.

그레이더 4종: 식별자와 세부 옵션까지

섹션 제목: “그레이더 4종: 식별자와 세부 옵션까지”

채점은 그레이더(grader)가 합니다. “정확 일치”나 “의미적 유사도” 같은 두루뭉술한 표현 대신, 실제 식별자와 옵션까지 알아야 eval을 제대로 짭니다2.

  • String Check (type=string_check): 문자열을 규칙으로 비교합니다. 연산자는 eq(같음), ne(다름), like(부분 포함), ilike(대소문자 무시 부분 포함) 네 가지입니다. 예를 들어 {{ sample.output_text }}(모델 출력)를 {{ item.correct_label }}(정답 레이블)와 비교합니다. “정확 일치”로만 기억하면 like/ilike 같은 부분 일치 옵션을 못 씁니다.
  • Text Similarity: 출력과 기대값의 유사도를 점수로 봅니다. 메트릭이 fuzzy_match, bleu, gleu, meteor, rouge, cosine 여섯 종입니다. “의미적 유사도”로 뭉뚱그리면 BLEU/ROUGE 같은 n-gram 기반과 cosine 같은 임베딩 기반의 차이를 못 고릅니다 — 번역·요약은 BLEU/ROUGE, 의미 근접은 cosine처럼 과업에 따라 골라야 합니다.
  • Score Model(모델 그레이더, LLM-as-judge): 별도 모델로 출력의 품질을 판정합니다. 정답 레이블로 못 잡는 어조·적절성 같은 질적 기준에 씁니다.
  • Python: 임의의 커스텀 로직으로 채점합니다. 위 셋으로 표현이 안 되는 규칙(예: JSON 파싱 후 특정 필드 검증)을 직접 코드로 씁니다.

data source와 테스트 데이터: 스키마와 템플릿 문법

섹션 제목: “data source와 테스트 데이터: 스키마와 템플릿 문법”

data source 설정에서 type"custom" 또는 "completions"(stored completions 포함)입니다. item_schema에 JSON Schema로 테스트 데이터의 속성을 정의하고(예: ticket_text, correct_label), 모델 출력을 참조해 채점할 거라면 include_sample_schema=true를 켭니다2.

테스트 데이터 자체는 JSONL로 올리는데, 한 줄의 구조가 중요합니다. item 키 아래에 item_schema와 매칭되는 속성이 들어갑니다2.

{ "item": { "ticket_text": "환불하고 싶어요", "correct_label": "refund" } }
{ "item": { "ticket_text": "재고 언제 들어와요?", "correct_label": "stock" } }

템플릿 문법은 이중 중괄호입니다. 테스트 데이터는 {{ item.FIELD }}로, 모델 출력은 {{ sample.output_text }}로 참조합니다2. 예컨대 String Check 그레이더에서 {{ sample.output_text }}{{ item.correct_label }}eq로 비교하면 “모델이 뱉은 레이블이 정답 레이블과 같은가”를 채점하는 셈입니다.

eval은 API로 다룹니다. 핵심 엔드포인트는 셋입니다2.

  • POST /v1/evals — eval을 생성합니다(data source + testing criteria).
  • POST /v1/evals/{eval_id}/runs — 실제로 실행합니다.
  • GET /v1/evals/{eval_id}/runs/{run_id} — 실행 상태와 결과를 조회합니다.

run 응답은 “합격/불합격 수와 토큰 사용량”보다 훨씬 풍부합니다2.

  • result_countstotal/errored/failed/passed로 나뉩니다. 여기서 errored(채점 자체가 실패한 건수)를 failed(채점은 됐으나 불합격)와 구분하는 게 중요합니다. errored가 많으면 모델 품질 문제가 아니라 데이터·그레이더 설정 문제입니다.
  • per_testing_criteria_results — 채점 기준별 합격/불합격입니다. 어느 기준에서 떨어졌는지가 드러나 디버깅 지점을 좁혀줍니다.
  • per_model_usage — 모델별 prompt/completion 토큰과 호출 수입니다. 비용 추정의 원천 데이터입니다.
  • report_url — 대시보드 링크입니다. 사람이 직접 들여다볼 진입점입니다.

트레이스가 회귀 데이터셋이 되는 고리

섹션 제목: “트레이스가 회귀 데이터셋이 되는 고리”

여기서 트레이스와 평가가 만납니다. 운영에서 모은 실제 트레이스(특히 실패한 트래젝토리)를 골라, 입력은 item으로 기대값은 정답 레이블로 정리하면 그게 곧 회귀 데이터셋입니다.

프롬프트를 고치거나 모델을 바꿀 때마다 이 데이터셋으로 동일한 eval을 돌리면, 한 곳을 고치다 다른 곳이 망가지는 회귀를 잡아냅니다. 문서도 프롬프트·모델을 반복(iterate)하면서 성능 저하를 감지하도록 모니터링을 지원한다고 명시합니다2.

스팬에는 started_at·ended_at 시각이 박혀 있고1 eval run은 per_model_usage로 토큰 사용량을 돌려주므로2, 관측 데이터는 그대로 비용·지연 모니터링의 재료가 됩니다. 어느 스팬이 p95 지연을 잡아먹는지, 어떤 도구 호출이 토큰을 가장 많이 쓰는지를 트레이스에서 읽어 추세로 봅니다.

“정확도는 유지하면서 비용·지연을 줄였는가”를 회귀 데이터셋의 합격률(per_testing_criteria_results)과 함께 보면, 최적화가 품질을 깎지 않았는지 동시에 확인할 수 있습니다.

예제: 트레이스를 회귀 데이터셋으로

섹션 제목: “예제: 트레이스를 회귀 데이터셋으로”
from agents import Runner, trace
# 1) 한 번의 실행을 명시적 트레이스로 묶는다 (group_id로 같은 대화 연결)
with trace(workflow_name="고객 응대", group_id=conversation_id):
result = await Runner.run(agent, user_input)
# → agent_span / generation_span / function_span 이 자동 기록됨
# 2) 실패하거나 애매했던 트래젝토리를 골라 회귀 데이터셋(JSONL)으로 적재
# 실제 Evals 스키마는 item 네임스페이스를 쓴다
# { "item": { "ticket_text": "...", "correct_label": "refund" } }
# { "item": { "ticket_text": "...", "correct_label": "stock" } }
# 3) 프롬프트·모델을 바꿀 때마다 같은 eval로 실행
# - String Check(eq) / Text Similarity(cosine) / Score Model 로 채점
# - result_counts(errored 먼저!) + per_model_usage(토큰)를 함께 보고 회귀 판단

핵심 고리는 운영 트레이스 → 회귀 데이터셋 → 평가 실행 → 다시 운영입니다. 한 바퀴 돌 때마다 “고친 게 정말 나아졌고, 다른 데를 안 깼다”가 숫자로 남습니다.

  • 트레이스는 엔드투엔드 작업, 스팬은 시작·종료 시각을 가진 작업이며, span_dataAgentSpanData/GenerationSpanData처럼 타입별 객체임을 안다.
  • 트레이싱은 기본 켜짐이고, 끄는 경로가 환경변수·set_tracing_disabled·RunConfig 셋(층위가 다름)임을 안다.
  • 기본 스팬이 텍스트 5종(agent/generation/function/guardrail/handoff)에 음성 3종(transcription/speech/speech_group)이 더해짐을 안다.
  • with trace()로 여러 run을 한 트레이스에 묶고, 장기 워커에서는 flush_traces()로 즉시 export함을 안다.
  • 민감 데이터 캡처 기본값이 True이며, RunConfig.trace_include_sensitive_dataOPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA 둘 다로 끌 수 있음을 안다.
  • add_trace_processor(추가)와 set_trace_processors(교체, OpenAI export 끊김)의 차이와, 비-OpenAI 모델에도 트레이싱 export 키가 필요함을 안다.
  • eval 그레이더 4종의 식별자·옵션을 알고, JSONL이 item 네임스페이스를 쓰며 템플릿은 {{ item.x }}·{{ sample.output_text }}임을 안다.
  • eval run 결과에서 erroredfailed와 구분해 읽고, 운영 트레이스를 회귀 데이터셋으로 만들어 회귀를 잡는다.
  1. OpenAI Agents SDK — Tracing (공식 문서) 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22

  2. OpenAI Evals 가이드 (공식 문서) 2 3 4 5 6 7 8 9 10 11 12