반응형
Kubernetes 파드엔 대시보드 있고, DB엔 슬로우 쿼리 로그 있는데, LLM은 블랙박스죠? 그 시대가 끝났습니다.
핵심 요약
→ OTel GenAI Semantic Convention 2026년 표준화 — LLM 트레이싱 공식 규격 등장
→ gen_ai.* 속성으로 모델명·토큰 수·비용·레이턴시 전부 추적 가능
→ Datadog v1.37, Grafana 모두 native 지원 시작 — 벤더 종속 없이 계측 1회
→ 일반 APM의 한계: 결정론적 코드 가정 → LLM 비결정성·토큰 과금 구조와 충돌
→ 에이전트 트레이싱: LLM 호출 → 툴 실행 → 검색 → 응답 전 과정이 단일 트레이스
→ 핵심 메트릭 3가지: gen_ai.client.operation.duration / token.usage / 오류율
→ PII 보호: OTel Collector에서 프롬프트 내용 마스킹 후 백엔드 전송
→ 계측 오버헤드 1% 미만 — LLM 호출이 수 초이므로 무시 가능
실전 1 — 왜 일반 APM이 LLM에 안 통하나
# ❌ 일반 APM 방식 — LLM에 그대로 적용 불가
import time
def api_handler(request):
start = time.time()
response = some_service.call() # 결정론적, 항상 같은 시간
latency = time.time() - start
# 전통 APM 가정:
# 1. 같은 입력 → 같은 출력 (재현 가능)
# 2. 비용 = 컴퓨팅 시간 비례
# 3. 에러 = 예외 발생
return response
# LLM은 이 가정을 전부 깸
def llm_handler(prompt):
# 1. 비결정성: 같은 프롬프트 → 매번 다른 응답
# 2. 토큰 과금: 100자 질문 vs 10,000자 질문이 비용 100배 차이
# 3. "성공"이 품질 보장 안 함: 200 OK여도 hallucination 가능
# 4. 에이전트: 내부에서 LLM 10~20회 호출 — 어느 단계에서 망했는지 모름
response = llm_client.complete(prompt) # 블랙박스
return response # 뭔 일이 있었는지 아무도 모름
개념 정리
→ 전통 APM이 보는 것: 레이턴시, 에러율, 처리량 — LLM엔 불충분
→ LLM 추가로 필요한 것: 토큰 수, 비용, 모델 버전, 프롬프트 품질, 할루시네이션 여부
→ OTel GenAI Semantic Convention: 이 차이를 표준 속성으로 정의한 공식 규격
→ gen_ai.* 네임스페이스: LLM 계측 전용 표준 속성 집합 (2026.03 experimental)
실전 2 — GenAI Semantic Convention으로 LLM 스팬 계측
# 설치
pip install \
opentelemetry-api \
opentelemetry-sdk \
opentelemetry-exporter-otlp-proto-grpc \
anthropic
# otel_setup.py — OTel 초기화 (앱 시작 시 1회)
from opentelemetry import trace, metrics
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
def setup_otel(service_name: str = "llm-service"):
# Trace Provider
tracer_provider = TracerProvider()
tracer_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://otel-collector:4317")
)
)
trace.set_tracer_provider(tracer_provider)
# Metric Provider
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint="http://otel-collector:4317"),
export_interval_millis=30_000
)
meter_provider = MeterProvider(metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)
return (
trace.get_tracer(service_name),
metrics.get_meter(service_name)
)
tracer, meter = setup_otel("my-llm-app")
# llm_client.py — GenAI Semantic Convention 적용
import time
from opentelemetry import trace
from opentelemetry.trace import SpanKind, Status, StatusCode
import anthropic
client = anthropic.Anthropic()
# GenAI Semantic Convention 표준 메트릭 정의
token_counter = meter.create_counter(
"gen_ai.client.token.usage", # 표준 메트릭 이름
unit="token",
description="LLM 토큰 사용량"
)
duration_histogram = meter.create_histogram(
"gen_ai.client.operation.duration", # 표준 메트릭 이름
unit="s",
description="LLM 호출 레이턴시"
)
error_counter = meter.create_counter(
"gen_ai.client.error.count",
description="LLM 호출 오류 수"
)
def tracked_llm_call(
prompt: str,
model: str = "claude-sonnet-4-6",
system: str | None = None,
max_tokens: int = 1000,
) -> str:
"""GenAI Semantic Convention 완전 적용 LLM 호출"""
# 표준 속성 (gen_ai.* 네임스페이스)
span_attrs = {
"gen_ai.system": "anthropic", # 프로바이더
"gen_ai.request.model": model, # 요청 모델
"gen_ai.operation.name": "chat", # 작업 유형
"gen_ai.request.max_tokens": max_tokens,
}
with tracer.start_as_current_span(
"gen_ai.chat", # 표준 스팬 이름
kind=SpanKind.CLIENT,
attributes=span_attrs
) as span:
start = time.time()
try:
messages = [{"role": "user", "content": prompt}]
kwargs = {"model": model, "max_tokens": max_tokens, "messages": messages}
if system:
kwargs["system"] = system
response = client.messages.create(**kwargs)
content = response.content[0].text
# ✅ 응답 후 표준 속성 추가
span.set_attributes({
"gen_ai.response.model": response.model,
"gen_ai.usage.input_tokens": response.usage.input_tokens,
"gen_ai.usage.output_tokens": response.usage.output_tokens,
"gen_ai.response.finish_reasons": [response.stop_reason],
})
# 메트릭 기록
metric_attrs = {
"gen_ai.system": "anthropic",
"gen_ai.request.model": model,
}
token_counter.add(
response.usage.input_tokens,
{**metric_attrs, "gen_ai.token.type": "input"}
)
token_counter.add(
response.usage.output_tokens,
{**metric_attrs, "gen_ai.token.type": "output"}
)
duration_histogram.record(
time.time() - start, metric_attrs
)
span.set_status(Status(StatusCode.OK))
return content
except Exception as e:
# 오류도 표준화
span.set_status(Status(StatusCode.ERROR, str(e)))
span.record_exception(e)
span.set_attribute("error.type", type(e).__name__)
error_counter.add(1, {"gen_ai.system": "anthropic", "error.type": type(e).__name__})
raise
개념 정리
→ gen_ai.system: 프로바이더 식별 (openai / anthropic / aws.bedrock)
→ gen_ai.usage.input_tokens / output_tokens: 비용 계산의 핵심
→ gen_ai.response.finish_reasons: stop / tool_calls / max_tokens 중 어떤 이유로 끝났나
→ SpanKind.CLIENT: LLM API를 외부 서비스 호출로 분류
→ BatchSpanProcessor: 비동기 배치 전송 — 애플리케이션 응답속도에 영향 없음
실전 3 — 에이전트 멀티스텝 트레이싱
에이전트는 LLM을 여러 번 호출하고 도구도 실행함. 전 과정을 단일 트레이스로 연결하는 게 핵심.
# agent_tracer.py — 에이전트 전 과정 트레이싱
from opentelemetry import trace, context
from opentelemetry.trace import SpanKind
import json
def run_agent_with_tracing(user_task: str) -> str:
"""
에이전트 실행 전체를 단일 루트 스팬으로 감싸고
내부 LLM 호출·툴 실행을 차일드 스팬으로 연결
"""
with tracer.start_as_current_span(
"agent.run",
kind=SpanKind.SERVER,
attributes={
"gen_ai.agent.name": "research-agent",
"gen_ai.agent.task": user_task[:200], # 길면 잘라서 저장
}
) as root_span:
step_count = 0
total_input_tokens = 0
total_output_tokens = 0
# 에이전트 루프
messages = [{"role": "user", "content": user_task}]
while True:
step_count += 1
# ─── LLM 호출 스팬 (루트 스팬의 자식) ───
with tracer.start_as_current_span(
f"gen_ai.chat",
kind=SpanKind.CLIENT,
attributes={
"gen_ai.system": "anthropic",
"gen_ai.request.model": "claude-sonnet-4-6",
"gen_ai.agent.step": step_count,
}
) as llm_span:
start = time.time()
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1000,
tools=[web_search_tool, calculator_tool],
messages=messages,
)
llm_span.set_attributes({
"gen_ai.usage.input_tokens": response.usage.input_tokens,
"gen_ai.usage.output_tokens": response.usage.output_tokens,
"gen_ai.response.finish_reasons": [response.stop_reason],
})
total_input_tokens += response.usage.input_tokens
total_output_tokens += response.usage.output_tokens
# 에이전트 종료 조건
if response.stop_reason == "end_turn":
final = response.content[0].text
break
# ─── 툴 실행 스팬 (LLM 스팬의 형제) ───
if response.stop_reason == "tool_use":
tool_results = []
for tool_call in response.content:
if tool_call.type != "tool_use":
continue
with tracer.start_as_current_span(
f"gen_ai.tool.{tool_call.name}",
kind=SpanKind.INTERNAL,
attributes={
"gen_ai.tool.name": tool_call.name,
"gen_ai.tool.call_id": tool_call.id,
# 입력 인자 (PII 없을 때만 기록)
"gen_ai.tool.input": json.dumps(tool_call.input)[:500],
}
) as tool_span:
try:
result = execute_tool(tool_call.name, tool_call.input)
tool_span.set_attribute("gen_ai.tool.output_size", len(str(result)))
tool_span.set_status(Status(StatusCode.OK))
except Exception as e:
tool_span.set_status(Status(StatusCode.ERROR, str(e)))
result = f"Error: {e}"
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_call.id,
"content": str(result)
})
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
# 루트 스팬에 집계 정보 기록
root_span.set_attributes({
"gen_ai.agent.total_steps": step_count,
"gen_ai.agent.total_input_tokens": total_input_tokens,
"gen_ai.agent.total_output_tokens": total_output_tokens,
"gen_ai.agent.estimated_cost_usd": (
total_input_tokens * 0.000003 +
total_output_tokens * 0.000015
),
})
return final
개념 정리
→ 루트 스팬: agent.run — 태스크 전체 타임라인
→ 차일드 스팬: gen_ai.chat — 각 LLM 호출
→ 차일드 스팬: gen_ai.tool.* — 각 툴 실행
→ trace_id: 모든 스팬이 공유 → Grafana에서 waterfall 뷰로 전 과정 시각화
→ "어느 단계에서 느렸나?" → 스팬별 duration 비교로 즉시 파악
실전 4 — OTel Collector로 PII 마스킹 + 백엔드 라우팅
# otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
# ✅ PII 마스킹 — 프롬프트 내용이 백엔드에 저장되기 전 처리
attributes/mask_pii:
actions:
# 이메일 패턴 마스킹
- key: gen_ai.input.messages
action: update
value: "[REDACTED - PII removed]"
# 시스템 프롬프트는 항상 마스킹
- key: gen_ai.system_instructions
action: delete
# 메타데이터 보강
attributes/enrich:
actions:
- key: deployment.environment
value: "production"
action: insert
- key: service.version
from_attribute: "BUILD_VERSION"
action: insert
# 비용 계산 속성 추가 (Collector에서 처리)
transform/cost:
trace_statements:
- |
set(attributes["gen_ai.estimated_cost_usd"],
(attributes["gen_ai.usage.input_tokens"] * 0.000003) +
(attributes["gen_ai.usage.output_tokens"] * 0.000015)
) where attributes["gen_ai.system"] == "anthropic"
batch:
timeout: 5s
send_batch_size: 512
exporters:
# Grafana Tempo (트레이스)
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
# Prometheus (메트릭)
prometheus:
endpoint: 0.0.0.0:8889
# Loki (로그)
loki:
endpoint: http://loki:3100/loki/api/v1/push
service:
pipelines:
traces:
receivers: [otlp]
processors: [attributes/mask_pii, attributes/enrich, transform/cost, batch]
exporters: [otlp/tempo]
metrics:
receivers: [otlp]
processors: [attributes/enrich, batch]
exporters: [prometheus]
# docker-compose.yml — 로컬 모니터링 스택
version: '3.8'
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
volumes:
- ./otel-collector-config.yaml:/etc/otel-config.yaml
command: ["--config=/etc/otel-config.yaml"]
ports:
- "4317:4317" # gRPC
- "4318:4318" # HTTP
tempo:
image: grafana/tempo:latest
ports:
- "3200:3200" # Tempo API
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
# → http://localhost:3000 에서 대시보드 확인
개념 정리
→ OTel Collector: 앱과 백엔드 사이 중간 레이어 — 마스킹·라우팅·변환 담당
→ PII 마스킹을 Collector에서: 앱 코드 수정 없이 전사 정책 일괄 적용
→ 멀티 백엔드: 트레이스 → Tempo, 메트릭 → Prometheus, 로그 → Loki 동시 전송
→ 계측은 한 번, 백엔드는 나중에 교체 — 벤더 종속 없음
→ Datadog 쓰는 팀: exporter만 otlp/datadog로 교체, 앱 코드 그대로
마무리
✅ OTel LLM 모니터링 구축 후
→ "이번 달 LLM 비용 왜 30% 뛰었나?" → 토큰 사용량 대시보드에서 즉시 파악
→ 에이전트 어느 단계에서 5초 걸렸나? → 트레이스 waterfall 1초 안에 확인
→ 특정 모델 에러율 급증 → Prometheus 알림으로 사전 감지
→ 프롬프트 내용 실수로 저장? → Collector 마스킹으로 원천 차단
→ 백엔드 Datadog → Grafana 교체? → exporter만 바꾸면 끝
❌ 블랙박스 LLM 운영 계속하면
→ 월말 청구서 보고 나서야 비용 급증 인지
→ 에이전트가 10단계 중 7단계에서 실패해도 어디서 망했는지 불명
→ 프로바이더 장애 시 어느 기능이 영향받는지 파악 불가
→ 토큰 낭비 패턴 발견 못하고 비용 비효율 지속
관련글
- AI 에이전트 품질 관리 전략: https://cell-devlog.tistory.com/237
- LLM 에이전트 Capacity Engineering: https://cell-devlog.tistory.com/236
- 에이전트 옵저버빌리티 실전: https://cell-devlog.tistory.com/90
반응형
'AI Agent' 카테고리의 다른 글
| AI 에이전트 Durable Execution 실전 2편 — Human-in-the-Loop·멀티에이전트·Serverless (0) | 2026.05.21 |
|---|---|
| AI 에이전트 Durable Execution 실전 1편 — 에이전트가 죽어도 이어지는 워크플로우 설계 (0) | 2026.05.21 |
| AI 에이전트 품질 관리 전략 — 프로덕션 킬러 1위가 품질인 이유 (0) | 2026.05.21 |
| LLM 에이전트 Capacity Engineering — 프로덕션 오류의 1/3이 rate limit인 이유 (0) | 2026.05.21 |
| Eval-Driven Development 완전 가이드 — AI 에이전트를 TDD처럼 개발하는 법 (0) | 2026.05.21 |