
Agent Black Box: AI 에이전트가 자신의 실패를 설명하도록 가르치기
요약
AI 에이전트의 폭주와 비용 급증을 방지하기 위해 기록(Record), 방어(Guard), 설명(Explain)하는 'Agent Black Box' 파이프라인 구축 사례를 다룹니다. OpenTelemetry와 SigNoz를 활용하여 에이전트의 실패 원인을 사후 분석하는 방법과 관측성 구축 과정에서의 기술적 트러블슈팅을 공유합니다.
핵심 포인트
- 실시간 가드레일을 통해 에이전트의 무한 루프와 비용 폭증을 차단
- OpenTelemetry 기반의 스팬(Span) 기록을 통한 에이전트 실행 추적
- 사후 분석 에이전트가 관측성 백엔드에 직접 쿼리하여 실패 원인 보고서 작성
- 메트릭 수집 과정에서의 카디널리티 폭발 및 시간성 불일치 버그 해결 사례
셀프 호스팅된 SigNoz 상에서 AI 에이전트를 위한 record-guard-explain 파이프라인을 구축한 방법과, 문서보다 OpenTelemetry에 대해 더 많은 것을 가르쳐준 3계층 메트릭 버그에 대하여.
핵심 요약 (Key Takeaways)
- 대부분의 에이전트 관측성 (Observability)은 "대시보드로 일부 스팬 (Spans)을 전송하는 것" 수준에서 멈춥니다. Agent Black Box는 폭주하는 실행을 차단하는 실시간 가드레일(Guardrails)과, SigNoz 자체의 MCP 서버를 사용하여 사후에 실패 원인을 설명하는 자동화된 사후 분석 (Postmortem) 에이전트를 추가합니다.
- 에이전트 메트릭이 올바르게 표시되도록 하는 과정에서 세 가지의 별개적이고 독립적인 버그를 해결해야 했습니다: 누락된 플러시 (Flush), 인스턴스 ID 카디널리티 (Cardinality) 폭발, 그리고 시간성 (Temporality) 불일치였습니다. 각 버그는 대시보드상에서는 "데이터 없음 (No Data)" 또는 잘못된 0으로 동일하게 보였지만, 근본 원인은 완전히 달랐습니다.
- SigNoz 알림 양식(Alert form)이 두 개의 임계값을 조용히
0으로 저장하고 비교 연산자를 반전시켰으며, 아무런 오류도 표시되지 않았습니다. 교훈: UI를 통해 설정된 모든 것은 화면을 믿지 말고 API를 통해 다시 읽어봄으로써 검증하십시오.
도입부 (The hook)
제가 테스트 중인 에이전트는 아무런 알림이 오기 전까지 동일한 검색 도구를 연속으로 47번 호출하며 실제 API 비용을 소모했습니다. 아무것도 충돌(Crash)하지 않았습니다. 로그에도 잘못된 점이 있다는 내용이 없었습니다. 그것은 마치 가속 페달이 눌린 채 고정된 자동차처럼, 멈추기 직전까지 계속해서 달려 나갔습니다.
이것이 바로 2026년 AI 에이전트의 실제 실패 모드이며, 거의 아무도 이에 대해 계측 (Instrument)을 하지 않는 부분입니다. 대부분의 "에이전트 관측성 (Agent observability)"은 대시보드로 몇 개의 스팬 (Spans)을 보내고, 에이전트가 고장 나는 순간에 당신이 보고 있기를 바라는 것을 의미합니다. 하지만 당신은 보고 있지 않습니다. 당신은 잠을 자고 있거나, 회의 중이거나, 다른 무언가를 만들고 있으며, 에이전트는 조용히 당신의 토큰 예산을 허공으로 날려버리고 있습니다.
저는 이를 해결하기 위해 Agent Black Box를 구축했습니다. 이 시스템은 모든 에이전트 실행을 실제 OpenTelemetry 스팬 (Spans)으로 기록 (Records) 하고, 폭주하는 루프나 비용 급증이 더 큰 피해를 주기 전에 실행을 방어 (Guards) 하며, 가장 오래 걸렸고 가장 중요했던 부분인, 두 번째 에이전트가 관측성 백엔드에 직접 쿼리하여 근본 원인 보고서를 작성하게 함으로써 사후에 무슨 일이 일어났는지 설명 (Explains) 합니다.
문제의 정확한 정의
AI 에이전트를 관찰하기 어렵게 만드는 세 가지 사실이 있습니다:
- 에이전트는 조용히 실패합니다. 루프(loop)에 빠져도 예외(exception)를 발생시키지 않습니다. 비용이 급증해도 누구에게도 페이지(page)를 보내지 않습니다. 에이전트는 그저 자신의 관점에서는 성공적으로, 잘못된 일을 계속 수행할 뿐입니다.
- 실패 신호와 근본 원인은 서로 다른 것입니다. 가드레일(guardrail)이 루프를 감지했다면, 그것은 _루프가 발생했다_는 사실만을 알려줍니다. _왜 에이전트가 반복하기로 결정했는지_는 알려주지 않습니다. 그것은 상위 단계(upstream)인 추론(reasoning) 과정에 있으며, 직접 파고들어 찾아내야 합니다.
- 아무도 파고들고 싶어 하지 않습니다. 사고가 발생한 후, 트레이스(trace)를 추출하고, 로그(logs)를 교차 참조하며, 토큰 수(token counts)를 확인하는 실제 조사 작업은, 적절한 도구만 제공된다면 두 번째 AI 에이전트가 매우 잘 수행할 수 있는 지루한 작업입니다.
따라서 설계는 한 문장으로 세 가지 요소가 되었습니다: 모든 것을 적절하게 기록하고, 잘못된 실행은 실시간으로 중단시키며, 에이전트가 잔해를 읽고 이를 설명하게 하는 것입니다.
아키텍처 (Architecture)
┌──────────────────────────────┐
your agent ──► │ agent_blackbox.trace() │
(any LLM / │ • gen_ai.* semconv spans │ OTLP
...

모든 것은 Foundry로 배포된 자체 호스팅 SigNoz를 기반으로 실행됩니다. 하나의 YAML 파일(casting.yaml)과 하나의 명령(foundryctl cast)만으로 SigNoz와 그 MCP 서버를 함께 실행할 수 있습니다. 이 MCP 서버가 "설명(explain)" 단계를 가능하게 하는 핵심 요소입니다. 이는 LLM이 다른 도구와 마찬가지로 트레이스, 로그, 메트릭(metrics)을 쿼리하기 위해 호출할 수 있는 실제 퍼스트 파티(first-party) 인터페이스입니다.
레코더(Recorder): 에이전트가 OpenTelemetry를 말하게 하기
공개 API는 의도적으로 매우 작게 설계되었습니다. 에이전트를 감싸는 데 단 두 줄이면 충분합니다.
from agent_blackbox import trace, traced_llm, traced_tool
with trace(agent="research-agent") as run:
...
내부적으로, 이는 적절한 OpenTelemetry GenAI semantic-convention 스팬(span)인 invoke_agent, chat, execute_tool을 생성하며, 스펙이 아직 다루지 않는 항목(루프 시그니처, 실행 비용, 가드레일 작동 등)을 위해 작은 커스텀 agent.* 네임스페이스를 추가로 생성합니다. 세만틱 컨벤션(semconv) 속성을 정확하게 설정하는 것이 시작 전 예상했던 것보다 훨씬 더 중요했습니다. GenAI 트레이스처럼 보이기는 하지만 표준 속성 이름을 사용하지 않는 트레이스는 SigNoz의 쿼리 빌더(Query Builder)가 그룹화하거나 집계할 수 없으며, 전체 대시보드는 해당 그룹화 기능이 제대로 작동하는지에 달려 있기 때문입니다.
가드레일(Guardrails): 중단(kill) 행위 자체를 관찰 가능하게 만들기
네 가지 탐지기가 실시간 스팬 스트림을 감시하는 SpanProcessor로서 작동합니다: 루프 탐지(동일한 도구와 동일한 인자가 반복됨), 비용 상한선, 에러 연쇄(error cascade), 그리고 중첩된 에이전트를 위한 깊이 제한(depth limit)입니다. 각각은 RunawayAgentError를 발생시키지만, 흥미로운 설계 결정은 단순히 Python 트레이스백(traceback)에만 나타나는 것이 아니라 _중단(kill) 행위 자체_가 SigNoz에 나타나도록 만든 것이었습니다:
tracer = otel.get_tracer()
with tracer.start_as_current_span("guardrail.trip") as span:
span.set_attribute(semconv.AGENT_GUARDRAIL_NAME, guardrail_name)
...
애플리케이션 로그에서 조용히 실행되는 가드레일은 아무도 신뢰하지 않는 가드레일입니다. 이 방식은 guardrail.trip 스팬과 메트릭(metric) 증가를 남기므로, 중단 행위가 방지한 실패만큼이나 명확하게 가시화됩니다.
차별점: 자신의 트레이스를 읽는 사후 분석(postmortem) 에이전트
이 부분이 제가 실제로 자랑스럽게 생각하는 대목입니다. blackbox postmortem <trace_id>는 진정한 MCP 클라이언트로서 SigNoz MCP 서버에 연결하여, Claude에게 실제 도구 목록(signoz_get_trace_details, signoz_search_logs, signoz_query_metrics)을 제공하고 조사를 허용합니다. 즉, 템플릿을 요약하는 것이 아니라, 실제로 증거를 살펴보고 그에 대해 추론하게 합니다.
다음은 루프 탐지 가드레일이 작동한 트레이스를 대상으로 생성된 실제 보고서의 편집되지 않은 발췌본입니다:
근본 원인 (Root cause)
가드레일 (guardrail.trip)은 증상 탐지기 (symptom detector)이지 원인이 아닙니다. 가드레일은 설계된 대로 작동하여, 폭주하는 패턴을 포착하고 더 많은 리소스를 소모하기 전에 실행을 중단했습니다. 실제 근본 원인은 에이전트의 의사결정 루프 (decision loop) 상류에 있습니다. 에이전트가 동일한web_search도구 호출을 세 번 연속으로 실행했습니다... 특히, 트레이스(trace) 내의 도구 호출 사이에 **LLM/완성 스팬 (LLM/completion span)**이 포함되어 있지 않아, 계측 (instrumentation) 데이터만으로는 어떤 추론/프롬프트 상태가 반복적인 호출을 생성했는지 보여주지 못합니다. 이는 반복이 LLM 출력 자체에서 비롯된 것인지 아니면 오케스트레이션 버그 (orchestration bug)에서 비롯된 것인지 확인하는 것을 방해하는 관측성 격차 (observability gap)입니다.

누구도 그것에게 "증상"과 "원인"을 구분하라고 말하지 않았습니다. 누구도 그것에게 누락된 LLM 스팬을 인지하고 이를 자체적인 한계로 표시하라고 말하지 않았습니다. 이것이 실제 텔레메트리 (telemetry)를 바탕으로 증거에 기반한 추론 (evidence-grounded reasoning)이 제대로 작동할 때의 모습입니다.
모든 것이 연결되었을 때의 모습
7개의 패널이 있으며, 모두 SigNoz의 쿼리 빌더 (Query Builder)로 구축되었습니다: 결과별 실행, 지연 시간 백분위수 (latency percentiles), 모델별 토큰 소모 및 비용, 도구 성공률, 가장 느린 도구 호출, 그리고 가드레일 작동 (guardrail trips)입니다. 4개의 경고 규칙 (alert rules)이 비용 급증, 도구 오류율, 가드레일 작동, 그리고 제가 가장 좋아하는 것인 에이전트가 크게 에러를 내는 대신 침묵 (silent) 상태에 빠지는 것을 포착하는 데이터 부재 경고 (absent-data alert)를 감시합니다.
무엇이 잘못되었는가 (솔직한 부분)
레코더(recorder), 가드레일(guardrails), 사후 분석 에이전트(postmortem agent), 이 모든 것이 단 하루 만에 결합되었습니다. 대시보드 메트릭(dashboard metrics)을 제대로 설정하는 데만 세 번의 별도 버그를 겪어야 했으며, 각각의 버그는 OpenTelemetry 문서만으로는 알 수 없는 교훈을 주었습니다.
버그 1: 메트릭이 프로세스를 벗어나지 못함. 트레이스(traces)를 기반으로 구축된 모든 대시보드 패널은 즉시 작동했습니다. 하지만 메트릭(metrics)을 기반으로 구축된 모든 패널은 "데이터 없음(No Data)"이라고 표시되었습니다. 원인은 다음과 같습니다. 제가 작성한 데모 스크립트는 몇 초 안에 종료되는 수명이 짧은 CLI 프로세스였는데, 이는 PeriodicExportingMetricReader의 기본 내보내기(export) 간격인 15초보다 훨씬 짧았습니다. 따라서 프로세스가 종료되기 전에 리더(reader) 자체의 타이머가 작동할 기회를 얻지 못했습니다. shutdown()만으로는 최종 플러시(flush)를 강제하지 않습니다. 먼저 명시적인 force_flush()가 필요합니다. 트레이스는 BatchSpanProcessor.shutdown()이 신뢰성 있게 플러시를 수행하기 때문에 문제가 없었습니다. 메트릭 리더에는 동일한 가정이 적용되지 않았던 것입니다.
버그 2: 메트릭이 도착했으나, 쓸모없이 파편화됨. 플러시(flush) 문제를 해결한 후에는 "데이터 없음" 대신 드문드문 나타나는 빈 결과값이 나타났습니다. SigNoz Metrics Explorer를 통해 이 문제의 실체를 파악할 수 있었습니다. 하나의 메트릭이 14개의 별도 시계열(time series)로 분산된 16개의 샘플을 가지고 있었는데, 이는 샘플당 거의 하나의 시계열이 할당된 셈이었습니다. 원인은 제가 명시적으로 설정하지 않았기 때문에 OpenTelemetry가 프로세스마다 무작위 service.instance.id를 자동 생성했기 때문입니다. 모든 CLI 호출은 새로운 프로세스이므로, 매 실행마다 고유한 무작위 인스턴스 ID와 완전히 새로운 단일 지점 시계열을 갖게 되었습니다. Increase/Rate 연산은 델타(delta)를 계산하기 위해 동일한 시계열 내에서 최소 두 개의 지점이 필요하며, 지점이 하나뿐인 시계열은 비교할 대상이 없습니다. 해결책: service.instance.id를 고정된 문자열로 지정하여, 동일한 스크립트의 반복 실행이 흩어지지 않고 하나의 연속된 시계열로 축적되도록 합니다.
버그 3: 통합은 되었으나, 여전히 잘못된 숫자를 계산함. 카디널리티 (cardinality) 문제를 해결한 후에도, 가드레일 (guardrail)이 내 테스트 실행 과정에서 최소 다섯 번은 작동했음에도 불구하고 "Guardrail trips" 패널은 완강하게 0을 표시했습니다. 이것은 OpenTelemetry의 기본 가정에 대해 실제로 무언가를 깨닫게 해준 사례였습니다: 누적 시간성 (cumulative temporality)은 하나의 장기 실행 프로세스 (long-running process)를 가정합니다. 내가 실행한 각각의 수명이 짧은 프로세스들은 각자 자신의 카운터를 0부터 시작했고, 종료되기 전에 보통 정확히 하나의 값인 "1 trip"을 보고했습니다. 각각 한 번씩 작동한 세 개의 별도 프로세스에 대해, 원시로 내보내진 (exported) 값들은 [1, 1, 1]처럼 보였습니다. 이 지점들 사이에서 단순하게 마지막 값에서 첫 번째 값을 뺀 델타 (delta)를 구하면 1 - 1 = 0이 되는데, 이는 단일 연속 실행 카운터에 대해서는 맞는 수학이지만, 세 개의 독립적인 카운터에 대해서는 틀린 계산입니다. 해결책은 쿼리 (query)에 있었던 것이 아니라, 하나의 환경 변수에 있었습니다: OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta. 이를 통해 각 내보내기 (export)가 프로세스 간에 조합되지 않는 시작 시점부터의 누적 값이 아니라, "이번 구간에 +1"을 직접 보고하도록 설정했습니다.
세 가지 버그 모두 각각 "여전히 데이터 없음"으로 위장하고 있었으며, 각각 진정으로 다른 근본 원인을 가지고 있었습니다. 일반화할 수 있는 교훈은 다음과 같습니다: OpenTelemetry의 기본 설정은 짧은 CLI 호출의 집합이 아니라, 하나의 장기 실행 서버에 맞춰져 있습니다. 만약 당신의 워크로드 (workload)가 후자라면, 플러시 타이밍 (flush timing), 리소스 카디널리티 (resource cardinality), 그리고 시간성 모델 (temporality model) 순으로 문제를 겪게 될 것을 예상해야 합니다.
더 작은 교훈들
빌드 로그에서 얻은 몇 가지 교훈을 더 공유합니다. 왜냐하면 이것들은 직접 부딪혀봐야만 배울 수 있는 종류의 것들이기 때문입니다:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기
