멀티 에이전트 LLM 시스템 트레이싱: otel-swarm 및 SigNoz 대시보드 팩
요약
멀티 에이전트 LLM 시스템의 복잡한 실행 과정을 추적하기 위한 OpenTelemetry 기반 라이브러리 otel-swarm을 소개합니다. 에이전트 간의 호출 관계, 모델 폴백(fallback), 비용 및 지연 시간 문제를 시각화하여 SigNoz 대시보드와 연동할 수 있습니다.
핵심 포인트
- 멀티 에이전트 환경의 복잡한 호출 구조를 계층적 트레이스로 시각화
- OpenTelemetry를 활용하여 모델 폴백 및 에러 이벤트를 자동 기록
- SigNoz 대시보드와 실시간 UI 간의 데이터 일관성 보장
- otel-swarm 라이브러리를 통한 간편한 에이전트 계측(instrumentation)
문제점
단일 LLM 호출은 추론하기 쉽습니다. 프롬프트를 보내고, 토큰을 돌려받고, 지연 시간 (latency)을 기록하면 끝입니다.
하지만 스웜 (swarm)은 다릅니다. 4~5개의 에이전트가 실행되며, 일부는 병렬로 작동하고 각자 자신만의 모델을 가집니다. 그중 하나가 타임아웃이 발생하여 조용히 더 저렴한 모델로 폴백 (fallback) 됩니다. 비평가 (critic) 에이전트는 출력을 읽고 다른 라운드를 위해 작업을 다시 보냅니다. 전체 과정이 12초가 아닌 40초가 걸리거나 예산보다 세 배의 비용이 들 때, 당신은 어떤 에이전트가 원인인지 알 방법이 없습니다. 당신의 로그는 동시 작업들로부터 뒤섞인 평면적인 스트림일 뿐이며, 부모-자식 구조도 없고 "어떤 역할이 토큰을 소모했는가"라고 물을 방법도 없습니다.
트레이스 (Traces)가 바로 이 문제를 정확히 해결합니다. 문제는 모든 역할, 모든 프로바이더 (provider) 호출, 모든 재시도 경로에 걸쳐 OpenTelemetry를 일일이 수동으로 연결하는 것이 아무도 두 번 하고 싶어 하지 않는 지루한 작업이며, 한 단계라도 건너뛰면 트레이스 트리 (trace tree)가 잘못된 정보를 제공한다는 점입니다.
그래서 저는 (이번 해커톤을 위해 만든 멀티 에이전트 코드 생성기인) DevSwarm에서 계측 (instrumentation) 부분을 추출하여 독립적인 MIT 라이브러리인 otel-swarm으로 만들었습니다.
API
단 한 번의 createSwarm() 호출, 그리고 세 가지 동사가 사용됩니다.
import { createSwarm } from 'otel-swarm';
const swarm = createSwarm({ service: 'my-swarm', otlpEndpoint: 'http://localhost:4318' });
...
task()는 루트 스팬 (root span)을 열고, agent()는 역할당 자식 스팬 (child span)을 열며, llm()은 모델 호출당 GenAI-semconv 스팬 (span)을 엽니다. call(model)은 기본 모델에서 오류가 발생할 경우 fallbackModel과 함께 다시 호출되며, 이 전환은 from, to 및 원문 그대로의 프로바이더 에러를 포함하는 fallback_promotion 스팬 이벤트 (span event)로 기록됩니다.
마지막 줄은 보기보다 더 중요합니다. swarm.events는 스팬(span) 생명주기를 미러링하는 EventEmitter이며, LLM 이벤트는 traceId를 포함합니다. 라이브 UI는 이 에미터(emitter)를 읽고, 트레이싱 백엔드(tracing backend)는 OTLP 익스포터(exporter)를 읽으며, 두 가지 모두 동일한 코드 경로로부터 데이터를 공급받으므로 대시보드와 UI는 발생한 사건에 대해 결코 서로 다른 정보를 보여줄 수 없습니다. traceId가 있다는 것은 UI의 한 행이 SigNoz의 정확한 트레이스(trace)로 딥링크(deep-link)됨을 의미합니다.
로컬 SigNoz를 대상으로 npm run example을 실행한 결과, otel-swarm-demo 서비스 아래에서 9개의 스팬(span)이 생성되었습니다: 1325ms의 루트 generation 스팬, 그리고 agent.planner/llm.planner, agent.frontend/llm.frontend, agent.backend/llm.backend, agent.critic/llm.critic 스팬들입니다. llm.frontend는 fallback_promotion 이벤트를 포함했고, agent.critic은 critic_catch 이벤트를 포함했습니다. 이 모든 이야기는 하나의 트레이스(trace)로 연결됩니다.
DevSwarm 내부의 프로덕션 환경 사용 사례에서는 동일한 라이브러리를 통해 29번의 생성(generation), 8개의 모델에 걸친 243번의 모델 호출, 378만 개의 토큰, 그리고 257번의 리뷰 캐치(review catches)를 추적했습니다.
레슨 1: 폴백(fallback)은 스팬 이벤트(span event)에 넣으세요, 그룹화 기준이 되는 속성(attribute)에 절대 넣지 마세요
이 실수 때문에 오후 시간을 통째로 날렸습니다.
라이브러리의 이전 버전은 기본 모델이 실패했을 때 당연해 보이는 방식을 취했습니다. 기본 모델 대신 폴백(fallback) 모델이 응답했을 때, 실제로 어떤 모델이 답변했는지에 대해 스팬이 "진실"을 말하도록 gen_ai.request.model을 폴백 모델의 이름으로 덮어씌운 것입니다.
그것은 잘못된 방식이며, 알아차리기 어려운 방식으로 잘못되었습니다. 모델별로 그룹화하는 모든 대시보드 패널은 기본 모델의 실패, 타임아웃, 그리고 그로 인해 낭비된 모든 지연 시간(latency)을 뒤처리를 수행한 폴백 모델의 탓으로 돌리게 됩니다. 저의 "모델별 토큰 및 지연 시간" 테이블에는 저렴한 폴백 모델의 p95 지연 시간이 최악으로 나타나고, 비싼 기본 모델은 결함이 없는 것처럼 완벽하게 나타났습니다. 기본 모델이 터질 때마다 그 비용이 조용히 잔해를 수습한 모델에게 재할당되었기 때문입니다. 저는 잘못된 모델이 느리다고 확신하며 오후 내내 시간을 허비했습니다.
여기서 도출되는 규칙은 다음과 같습니다: 그룹화(group by) 기준이 되는 속성(attribute)은 최종적으로 답변한 모델이 아니라, 당신이 시도했던 모델을 기록해야 합니다. 모델 승격(promotion)은 스팬(span) 도중에 발생한 별개의 사건이며, 스팬 도중에 발생한 별개의 사건을 기록하는 용도가 바로 스팬 이벤트(span events)입니다:
span.addEvent('fallback_promotion', { from: model, to: fallbackModel, reason: String(err.message || err) });
이렇게 하면 그룹화된 패널은 실패의 원인을 제공한 주체에게 계속해서 책임을 돌리게 되며, "이 역할이 얼마나 자주 승격되는가"는 이벤트(events)를 대상으로 하는 별도의 쿼리가 됩니다. 두 질문은 서로 다르며, 저장 위치도 다르므로 데이터가 서로 오염되지 않습니다.
일반적인 형태는 다음과 같습니다: 만약 어떤 값이 스팬 중간에 변경될 수 있다면, 그것은 집계(aggregate) 대상이 되는 속성(attribute)에 포함되어서는 안 됩니다.
레슨 2: ClickHouse에서 스팬 이벤트 내부를 읽는 것은 쉽지 않습니다
이벤트를 사용하는 것의 결과로, 이제는 이벤트를 쿼리해야 합니다. SigNoz는 이를 스팬 행(span row) 위에 JSON 문자열의 배열(array)로 저장합니다. 이곳으로 안내해 줄 자동 완성 기능도 없습니다. 패턴은 ARRAY JOIN을 사용하여 배열을 이벤트당 하나의 행으로 평탄화(flatten)한 다음, JSONExtractString을 사용하여 이벤트의 attributeMap 내부로 접근하는 방식입니다:
SELECT JSONExtractString(e, 'attributeMap', 'severity') AS severity,
JSONExtractString(e, 'attributeMap', 'target') AS target,
count() AS catches
...
이렇게 하면 별도의 메트릭 파이프라인(metrics pipeline) 없이도, 스팬 이벤트에서 직접 심각도(severity)와 대상(target)별로 분류된 검토 결과(review findings)를 얻을 수 있습니다.
단순히 이벤트가 발생했는지 여부만 알면 되는 경우에는 ARRAY JOIN 비용을 지불하지 마세요. 원시 배열(raw array)에 대해 부분 문자열 테스트를 수행하는 arrayExists만으로도 충분하며, 이는 시계열(time series)에 필요한 방식인 스팬당 하나의 행을 유지해 줍니다:
SELECT toStartOfInterval(timestamp, INTERVAL 15 MINUTE) AS ts,
attributes_string['swarm.role'] AS role,
count() AS value
...
대시보드 팩
이 저장소는 3개의 가져오기 가능한 SigNoz 대시보드를 제공합니다 (각 JSON을 /api/v1/dashboards로 POST). 모든 쿼리는 기본값이 otel-swarm-demo인 {{.service}} 대시보드 변수를 사용하므로, 쿼리 문자열 전체를 찾아 바꾸기(find-and-replace)할 필요 없이 드롭다운 하나만 수정하여 대시보드 팩을 사용자의 서비스로 지정할 수 있습니다.
- **Generation Overview (생성 개요)**는 "현재 스웜(swarm)이 건강한가?"라는 질문에 답합니다: 생성 횟수, 리뷰 게이트(review-gate) 통과율, 시간에 따른 생성당 소요 시간, 역할(role)별 호출 테이블, 평균 지연 시간(latency), p95 및 에러를 보여줍니다.
- **LLM Economics (LLM 경제성)**는 "토큰이 어디로 갔는가?"라는 질문에 답합니다: 총 토큰 수, 역할별 시간에 따른 토큰 사용량, 모델별 토큰 및 지연 시간 테이블, 그리고 역할별 폴백 프로모션(fallback promotions) (위의
arrayExists쿼리)을 보여줍니다. - **Review-Gate Funnel (리뷰 게이트 퍼널)**은 "리뷰어가 제 역할을 하고 있는가?"라는 질문에 답합니다: 총 검출(catches) 횟수, 재생성(regeneration) 라운드, 시간에 따른 생성당 검출 횟수, 그리고 판정(verdict), 검출, 재생성 정보가 포함된 최근 생성 내역 테이블을 보여줍니다.
알람 규칙 (The alert rules)
v2alpha1 스키마로 두 가지 규칙이 제공됩니다 (/api/v2/rules로 POST).
첫 번째는 폴백 프로모션 급증(fallback-promotion spike)입니다. 프로모션이 급증한다면, 특정 프로바이더(provider)의 성능이 저하되고 있으며 사용자가 계획했던 것과는 다른 비용을 조용히 지불하고 있다는 의미입니다.
두 번째는 제가 더 중요하게 생각하는 것입니다: **리뷰 검출률 정체(review catch-rate flatline)**입니다. 만약 비판 에이전트(critic agent)가 갑자기 아무것도 찾아내지 못한다면, 유혹적인 해석은 생성 에이전트(generators)가 더 나아졌다는 것이겠지만, 실제로는 리뷰어가 고장 난 것입니다. 프롬프트 변경으로 인해 리뷰어가 파서(parser)가 버리는 형태의 답변을 내놓게 되었거나, 모델이 빈 콘텐츠를 반환하기 시작했는데 이 실패가 "이슈 없음"으로 무시되고 있는 상황일 수 있습니다. 모든 것을 통과시키는 품질 게이트(quality gate)는 품질 게이트가 없는 것과 구별할 수 없으며, 구조적으로 조용히 실패하게 됩니다. 이러한 부재(absence)에 대해 알람을 설정하십시오.
이 저장소에는 이 대시보드들의 백엔드인 SigNoz 인스턴스를 설치할 때 사용된 Foundry 설정인 casting.yaml 및 casting.yaml.lock도 포함되어 있어, 대시보드 팩이 대상으로 하는 백엔드를 추측이 아닌 재현 가능한 상태로 유지할 수 있습니다.
설치 및 실행
npm install github:himanshu748/otel-swarm
로컬 SigNoz를 대상으로 예제 트레이스 (trace)를 엔드 투 엔드 (end-to-end)로 확인하려면:
git clone https://github.com/himanshu748/otel-swarm && cd otel-swarm
npm install
npm run example # spans to console
...
엔드포인트 (endpoint)가 설정되지 않으면 콘솔 (console)로 내보내지므로, 백엔드 (backend)를 연결하기 전에 스팬 트리 (span tree)를 확인할 수 있습니다.
마치며
이 라이브러리는 약 130줄 정도입니다. 가치의 대부분은 코드가 아니라, 위에서 언급한 두 가지 결정에 있습니다. 즉, 가변적인 사실 (mutable facts)을 스팬 이벤트 (span events)에 유지하는 것과, "리뷰어가 아무것도 발견하지 못함"을 결과가 아닌 증상 (symptom)으로 취급하는 것입니다. 이 두 가지 모두 처음에는 잘못 적용해 본 뒤에 얻은 교훈입니다.
저는 이 포스트에 포함된 대시보드 JSON과 ClickHouse 쿼리를 포함하여, Claude Code를 사용하여 otel-swarm을 구축했습니다.
Repo: github.com/himanshu748/otel-swarm, MIT.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기