AI Agent

OpenTelemetry로 LLM 모니터링 — 블랙박스 에이전트를 투명하게 만드는 법

cell-devlog 2026. 5. 21. 15:44
반응형

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단계에서 실패해도 어디서 망했는지 불명
→ 프로바이더 장애 시 어느 기능이 영향받는지 파악 불가
→ 토큰 낭비 패턴 발견 못하고 비용 비효율 지속

관련글

 

반응형