LLM 옵저버빌리티 2026: OpenTelemetry GenAI 시맨틱 컨벤션으로 에이전트 추적·비용 계측
OpenTelemetry GenAI 시맨틱 컨벤션을 기준으로 LLM·에이전트 호출을 표준 스팬·메트릭으로 계측하고 토큰·비용·지연을 추적하는 프로덕션 옵저버빌리티 구축법을 코드와 함께 정리합니다.
왜 지금 "LLM 옵저버빌리티 표준"이 중요한가
LLM 애플리케이션은 대부분 잘 동작하다가, 어느 순간 토큰 비용이 두 배로 튀거나 에이전트가 도구 호출 루프에 빠집니다. 그런데 정작 운영팀이 보는 건 200 OK와 평균 응답시간뿐입니다. 어떤 모델이, 몇 토큰을, 얼마의 비용으로, 왜 그 finish_reason으로 끝났는지를 표준화된 형태로 보지 못하면 디버깅은 매번 로그 grep으로 회귀합니다.
2024년부터 OpenTelemetry의 GenAI 시맨틱 컨벤션 SIG가 이 문제를 정면으로 다뤄 왔고, 2026년 들어 Datadog·New Relic·Dynatrace가 이 컨벤션을 네이티브로 지원하기 시작했습니다. 즉 벤더 SDK에 종속되지 않고, OTel 표준 속성만 잘 심으면 어떤 백엔드로도 같은 데이터를 보낼 수 있게 됐습니다.
이 글에서는 현재 실험(experimental) 단계인 GenAI 시맨틱 컨벤션을 기준으로, LLM·에이전트 호출을 표준 스팬과 메트릭으로 계측하고 토큰·비용·지연을 추적하는 프로덕션 옵저버빌리티 파이프라인을 코드와 함께 구축합니다.
배경: GenAI 시맨틱 컨벤션은 무엇을 표준화하는가
옵저버빌리티의 핵심은 "이름을 통일하는 것"입니다. 팀마다 model, llm.model, gpt_model로 제각각 적으면 대시보드를 만들 수 없습니다. GenAI 시맨틱 컨벤션은 LLM 호출에서 기록해야 할 속성·메트릭·이벤트의 이름과 타입을 못 박습니다.
2026년 초 기준 컨벤션은 크게 네 영역을 다룹니다.
| 영역 | 다루는 것 | 대표 시그널 |
|---|---|---|
| Client spans | LLM 클라이언트 호출 1건 | gen_ai.provider.name, gen_ai.request.model, gen_ai.usage.* |
| Agent / framework spans | 에이전트·체인·도구 실행 | gen_ai.agent.name, gen_ai.operation.name, tool call 스팬 |
| Events | 프롬프트·컴플리션 본문(옵트인) | 프롬프트/응답 메시지 이벤트 |
| Metrics | 집계 지표 | gen_ai.client.token.usage, gen_ai.client.operation.duration |
주의할 점은 이 컨벤션이 아직 experimental 상태라는 것입니다. 속성 이름이 마이너 버전 사이에서 바뀔 수 있으므로, 프로덕션에서는 호환성 전략을 함께 세워야 합니다(아래 트러블슈팅 참고).
또 하나 헷갈리기 쉬운 변화가 있습니다. 초기 컨벤션의 gen_ai.system(예: "openai")은 이후 공급자 식별자 역할을 더 명확히 하는 gen_ai.provider.name으로 정리되는 방향으로 발전했습니다. 신규 계측은 gen_ai.provider.name을 기준으로 작성하되, 기존 대시보드 호환을 위해 두 속성을 동시에 내보내는 듀얼 이미션을 권장합니다.
심층 분석: 스팬·메트릭·이벤트를 직접 심어 보기
1) 클라이언트 스팬 수동 계측 (Python)
가장 먼저 이해해야 할 것은 "LLM 호출 1건 = 스팬 1개"라는 모델입니다. 컨벤션이 정의한 핵심 속성을 그대로 심으면 어떤 백엔드든 같은 의미로 해석합니다.
from opentelemetry import trace
from opentelemetry.trace import SpanKind
tracer = trace.get_tracer("intentcode.llm")
def chat_completion(client, model: str, messages: list) -> dict:
# LLM 호출 1건을 CLIENT 스팬으로 감쌉니다.
with tracer.start_as_current_span(
f"chat {model}", # 권장 스팬명: "{operation} {model}"
kind=SpanKind.CLIENT,
) as span:
# 요청 측 표준 속성
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.provider.name", "openai") # 신규 식별자
span.set_attribute("gen_ai.system", "openai") # 호환용 듀얼 이미션
span.set_attribute("gen_ai.request.model", model)
span.set_attribute("gen_ai.request.max_tokens", 1024)
span.set_attribute("gen_ai.request.temperature", 0.2)
resp = client.chat.completions.create(
model=model, messages=messages, max_tokens=1024, temperature=0.2,
)
# 응답 측 표준 속성 — 비용/품질 분석의 핵심
usage = resp.usage
span.set_attribute("gen_ai.response.model", resp.model)
span.set_attribute(
"gen_ai.response.finish_reasons",
[c.finish_reason for c in resp.choices],
)
span.set_attribute("gen_ai.usage.input_tokens", usage.prompt_tokens)
span.set_attribute("gen_ai.usage.output_tokens", usage.completion_tokens)
return resp
핵심은 gen_ai.usage.input_tokens와 gen_ai.usage.output_tokens입니다. 이 두 값만 정확히 들어가면, 모델별 단가 테이블과 곱해 요청 단위 비용을 그래프로 그릴 수 있습니다. 또한 프롬프트 캐싱을 쓰는 모델이라면 gen_ai.usage.cache_read.input_tokens로 캐시 적중분을 분리해 실제 과금 토큰을 정확히 계산할 수 있습니다.
2) 표준 메트릭으로 집계 지표 내보내기
스팬은 개별 호출을 보여 주지만, SLO와 비용 알림은 메트릭으로 다뤄야 합니다. 컨벤션은 히스토그램 메트릭 이름까지 표준화합니다.
from opentelemetry import metrics
meter = metrics.get_meter("intentcode.llm")
# 표준 메트릭: 토큰 사용량(히스토그램)
token_usage = meter.create_histogram(
name="gen_ai.client.token.usage",
unit="{token}",
description="LLM 호출당 토큰 사용량",
)
# 표준 메트릭: 호출 지연(히스토그램)
op_duration = meter.create_histogram(
name="gen_ai.client.operation.duration",
unit="s",
description="LLM 호출 지연",
)
def record_metrics(model: str, input_tok: int, output_tok: int, dur_s: float):
# token.type 차원으로 input/output를 분리 집계합니다.
base = {"gen_ai.provider.name": "openai", "gen_ai.request.model": model}
token_usage.record(input_tok, {**base, "gen_ai.token.type": "input"})
token_usage.record(output_tok, {**base, "gen_ai.token.type": "output"})
op_duration.record(dur_s, base)
스트리밍 응답이라면 사용자 체감 지연을 더 정밀하게 봐야 합니다. 컨벤션은 이를 위해 gen_ai.client.operation.time_to_first_chunk(첫 청크까지 시간, TTFT에 해당)와 gen_ai.client.operation.time_per_output_chunk(청크당 시간)까지 정의해 두었습니다. 이 둘을 함께 기록하면 "총 지연은 길지만 첫 토큰은 빨라 체감은 좋은" 케이스를 구분할 수 있습니다.
3) OpenTelemetry Collector로 파이프라인 구성
애플리케이션은 OTLP로만 내보내고, 라우팅·샘플링·백엔드 분기는 Collector에서 처리하는 것이 운영상 깔끔합니다.
# otel-collector.yaml — GenAI 텔레메트리 수집 파이프라인
receivers:
otlp:
protocols:
grpc: { endpoint: 0.0.0.0:4317 }
http: { endpoint: 0.0.0.0:4318 }
processors:
batch: { timeout: 5s } # 배치 전송으로 오버헤드 감소
# 프롬프트/응답 본문 이벤트에 PII가 섞일 수 있어 마스킹/삭제를 권장합니다.
attributes/scrub:
actions:
- key: gen_ai.prompt # 본문 속성은 운영 환경에서 제거
action: delete
exporters:
otlphttp/observability:
endpoint: https://otel.intentcode.co.kr # 백엔드(예: Grafana/Datadog 등)
debug: { verbosity: basic }
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch, attributes/scrub]
exporters: [otlphttp/observability]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/observability]
이렇게 분리하면 애플리케이션 코드를 건드리지 않고도 백엔드를 교체하거나, 본문 PII를 일괄 스크럽하거나, 샘플링 비율을 조정할 수 있습니다.
실전 가이드: 자동 계측으로 빠르게 시작하기
수동 계측은 의미를 정확히 이해하는 데 좋지만, 실무에서는 라이브러리 계측을 자동으로 입히는 편이 빠릅니다. 대표적인 OTel 기반 도구는 다음과 같습니다.
| 도구 | 성격 | 적합한 경우 |
|---|---|---|
| OpenLLMetry (Traceloop) | OTel GenAI 정렬, 체인·에이전트·리트리버 자동 래핑 | 순정 OTel 백엔드로 보내고 싶을 때 |
| OpenInference (Arize) | 광범위 계측 프레임워크 | Phoenix·Arize AX를 백엔드로 쓸 때 |
| OpenLIT | LLM+GPU+DB 멀티 시그널 단일 SDK | 인프라 메트릭까지 한 SDK로 묶을 때 |
OpenLLMetry로 OpenAI·LangChain 호출을 자동 계측하는 최소 설정은 다음과 같습니다.
# 1) 설치
pip install traceloop-sdk
# 2) OTLP 내보내기 대상 지정 (Collector로 송신)
export TRACELOOP_BASE_URL=http://otel-collector:4318
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
from traceloop.sdk import Traceloop
# 앱 부팅 시 1회 초기화 — 이후 OpenAI/LangChain 호출이 자동으로 스팬화됩니다.
Traceloop.init(app_name="intentcode-agent")
# 이하 평소 코드 그대로. 별도 데코레이터 없이 호출이 추적됩니다.
# resp = client.chat.completions.create(...)
단계별로 정리하면 ① Collector를 먼저 띄우고 ② 앱에서 OTLP 엔드포인트를 Collector로 지정한 뒤 ③ 자동 계측 SDK를 초기화하고 ④ 백엔드 대시보드에서 gen_ai.* 속성으로 패널을 구성하는 순서입니다. 마지막으로 모델별 단가 테이블을 메트릭 쿼리에 곱해 비용 대시보드를 만들면 운영 가시성이 완성됩니다.
트러블슈팅: 자주 만나는 문제 3가지
1) 속성 이름이 백엔드마다 다르게 보입니다. 컨벤션이 experimental이라 버전 사이에 이름이 바뀔 수 있기 때문입니다. 이때는 OTEL_SEMCONV_STABILITY_OPT_IN 환경 변수로 레거시·신규 속성을 동시에 내보내는 듀얼 이미션을 켜서 전환기를 안전하게 넘깁니다. 대시보드를 신규 이름으로 옮긴 뒤 레거시를 끄면 됩니다.
2) 프롬프트·응답 본문이 트레이스에 그대로 노출됩니다. 본문 캡처(prompt/completion 이벤트)는 강력하지만 PII·기밀이 섞입니다. 본문 캡처는 기본 비활성으로 두고, 필요한 환경에서만 옵트인하며 Collector의 attributes 프로세서로 민감 속성을 삭제·마스킹하세요. 운영 환경에서는 본문 대신 토큰 수·finish_reason 같은 메타데이터만 남기는 편이 안전합니다.
3) 토큰 합계가 청구서와 맞지 않습니다. 프롬프트 캐싱을 쓰는 모델은 캐시 적중 토큰이 일반 입력 토큰과 과금이 다릅니다. gen_ai.usage.input_tokens만 합산하지 말고 gen_ai.usage.cache_read.input_tokens를 분리해 실제 과금 토큰을 계산해야 합니다. 또한 스트리밍 응답은 SDK가 usage를 마지막 청크에 담아 보내므로, 스트림을 끝까지 소비한 뒤 속성을 기록해야 누락이 없습니다.
결론 + IntentCode 관점
LLM 옵저버빌리티의 본질은 "특별한 새 스택"이 아니라, 이미 검증된 OpenTelemetry 위에 GenAI 시맨틱 컨벤션이라는 공통 어휘를 얹는 일입니다. 표준 속성만 정확히 심으면 백엔드 교체도, 비용·품질 분석도, 에이전트 추적도 같은 데이터로 해결됩니다.
IntentCode는 MLOps·AI 에이전트 라인에서 RAG·에이전트 파이프라인을 운영할 때 이 컨벤션을 기준으로 토큰·비용·지연을 한 화면에서 추적하는 옵저버빌리티 레이어를 함께 설계합니다. 모델을 띄우는 것만큼이나, 띄운 뒤 무슨 일이 일어나는지 표준 형태로 보는 것이 프로덕션 신뢰성의 출발점입니다.