"이 에이전트가 왜 $3.70을 썼나?" 프로덕션에서 이 질문에 답할 수 없다면 에이전트를 배포할 준비가 안 된 겁니다. OpenTelemetry는 그 답을 구조화합니다.
핵심 요약 → 에이전트 옵저버빌리티 ≠ 일반 APM — 비결정적 실행, 토큰 기반 비용, 무결한 실패가 다름 → OpenTelemetry GenAI Semantic Conventions: gen_ai.* 표준 속성 (2026년 초 experimental → stable 진행 중) → 자동 계측: AnthropicInstrumentor().instrument() — 코드 변경 없이 모든 API 호출 자동 추적 → 수동 계측: 에이전트 루프·툴 호출·재시도 스텝을 명시적 스팬으로 감쌈 → 핵심 속성: gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, gen_ai.request.model → 비용 추적: 토큰 수 × 단가를 커스텀 속성으로 추가 → 백엔드: Jaeger (로컬 개발), Grafana Tempo (프로덕션), Datadog/Honeycomb (SaaS) → OTel 오버헤드: 스팬당 <1ms — LLM 레이턴시(100ms~30s) 대비 무시 가능
왜 일반 APM으로 부족한가
에이전트 옵저버빌리티가 말해줘야 하는 것: "이 에이전트는 LLM 호출 3회, 툴 실행 2회, 토큰 12,400개($0.037), 두 번째 툴 호출이 타임아웃으로 실패했지만 에이전트가 자가 수정했다." 일반 APM이 말하는 것: "이 엔드포인트가 4.2초 걸렸다."
# 에이전트 옵저버빌리티가 다른 세 가지 이유
1. 비결정적 실행
같은 입력 → 다른 툴 호출 순서
→ 이슈 재현 시 정확한 실행 경로 캡처 필요
2. 토큰 기반 비용
CPU/메모리가 아닌 토큰이 비용 단위
"3,000 토큰으로 해결할 걸 50,000 토큰 쓴 에이전트"
= 비용 이상이자 로직 버그 신호
3. 무결한 실패 (Silent Failure)
HTTP 200 반환 + 틀린 답
→ 스팬에 프롬프트/완성 내용·평가 점수 포함해야 감지 가능
1. 설치 + 기본 설정
# 핵심 패키지
pip install opentelemetry-api opentelemetry-sdk
# 프로바이더별 자동 계측
pip install opentelemetry-instrumentation-anthropic
pip install opentelemetry-instrumentation-openai
pip install opentelemetry-instrumentation-langchain
# 익스포터 (백엔드 선택)
pip install opentelemetry-exporter-otlp # Jaeger, Grafana, Datadog 등
pip install opentelemetry-exporter-jaeger # Jaeger 전용
# OpenLLMetry (LLM 특화 계측 확장)
pip install traceloop-sdk
# otel_setup.py — 앱 시작 시 1회 실행
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
import os
def setup_otel(service_name: str = "llm-agent"):
"""
OpenTelemetry 초기화 — 앱 진입점에서 1회 호출
"""
resource = Resource.create({
"service.name": service_name,
"service.version": os.getenv("APP_VERSION", "1.0.0"),
"deployment.environment": os.getenv("ENV", "development"),
})
provider = TracerProvider(resource=resource)
# 익스포터 설정 (환경에 따라 선택)
exporter = OTLPSpanExporter(
endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
return trace.get_tracer(service_name)
# ── 자동 계측 등록 ──
# 코드 변경 없이 모든 Anthropic·OpenAI 호출 자동 추적
from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor
from opentelemetry.instrumentation.openai import OpenAIInstrumentor
AnthropicInstrumentor().instrument()
OpenAIInstrumentor().instrument()
2. GenAI 표준 속성 — gen_ai.*
OpenAI, Anthropic, LangChain에 대한 자동 계측 패키지가 있으며, 앱 시작 시 한 번 호출하면 이후 모든 API 호출이 자동으로 추적됩니다.
# 자동 계측이 수집하는 gen_ai.* 표준 속성들
GEN_AI_ATTRIBUTES = {
# 요청
"gen_ai.system": "anthropic", # 프로바이더
"gen_ai.request.model": "claude-sonnet-4-6", # 요청 모델
"gen_ai.operation.name": "chat", # 작업 유형
"gen_ai.request.max_tokens": 4096,
# 응답
"gen_ai.response.model": "claude-sonnet-4-6", # 실제 응답 모델
"gen_ai.response.finish_reasons": ["end_turn"],
# 토큰 사용량 (비용 계산의 핵심)
"gen_ai.usage.input_tokens": 2450,
"gen_ai.usage.output_tokens": 381,
# Anthropic 전용
"gen_ai.provider.name": "anthropic",
"gen_ai.usage.cache_read_input_tokens": 1200, # 캐시 히트
"gen_ai.usage.cache_creation_input_tokens": 800, # 캐시 생성
# 스팬 메타
"gen_ai.agent.name": "code-review-agent", # 에이전트 식별
}
# 스팬 이름 패턴: "{operation.name} {gen_ai.system}"
# 예: "chat anthropic", "chat openai"
3. 자동 계측 — 코드 변경 없이
# ── 자동 계측 활성화 후 평소처럼 사용 ──
import anthropic
# setup_otel() + AnthropicInstrumentor().instrument() 이후
client = anthropic.Anthropic()
# 이 호출이 자동으로 스팬 생성
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "코드 리뷰해줘"}]
)
# → 자동 생성되는 스팬:
# 이름: "chat anthropic"
# 속성:
# gen_ai.system = "anthropic"
# gen_ai.request.model = "claude-sonnet-4-6"
# gen_ai.usage.input_tokens = 245
# gen_ai.usage.output_tokens = 89
# duration = 1.2s
# → 백엔드에서 바로 조회 가능
# 프롬프트·완성 내용 캡처 (기본 OFF — PII 주의)
AnthropicInstrumentor().instrument(
enrich_token_usage=True, # 토큰 상세 정보 추가
capture_content=True, # 프롬프트·응답 내용 캡처 (개발 환경만)
)
# 프로덕션에서 내용 캡처 활성화 방법 (환경 변수)
# OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
4. 수동 계측 — 에이전트 루프 전체 추적
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
import anthropic
import time
tracer = trace.get_tracer("my-agent")
client = anthropic.Anthropic()
MODEL_PRICES = {
"claude-sonnet-4-6": {"input": 3.0, "output": 15.0}, # per 1M tokens
"claude-opus-4-7": {"input": 5.0, "output": 25.0},
}
def calculate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
prices = MODEL_PRICES.get(model, {"input": 3.0, "output": 15.0})
return (input_tokens * prices["input"] + output_tokens * prices["output"]) / 1_000_000
def run_agent(goal: str, tools: list) -> str:
"""
에이전트 루프 전체를 루트 스팬으로 감쌈
내부 LLM 호출·툴 실행이 자식 스팬으로 중첩
"""
# ── 루트 스팬: 전체 에이전트 실행 ──
with tracer.start_as_current_span(
"gen_ai.agent.invoke",
attributes={
"gen_ai.agent.name": "code-review-agent",
"gen_ai.agent.goal": goal[:200], # 처음 200자만
"gen_ai.system": "anthropic",
}
) as agent_span:
total_input_tokens = 0
total_output_tokens = 0
total_cost = 0.0
step_count = 0
messages = [{"role": "user", "content": goal}]
while True:
step_count += 1
# ── LLM 호출 스팬 (자식) ──
with tracer.start_as_current_span(
f"chat anthropic",
attributes={
"gen_ai.request.model": "claude-sonnet-4-6",
"gen_ai.agent.step_number": step_count,
"gen_ai.operation.name": "chat",
}
) as llm_span:
try:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
tools=tools,
messages=messages
)
# 토큰 사용량 기록
input_tok = response.usage.input_tokens
output_tok = response.usage.output_tokens
cost = calculate_cost("claude-sonnet-4-6", input_tok, output_tok)
llm_span.set_attributes({
"gen_ai.usage.input_tokens": input_tok,
"gen_ai.usage.output_tokens": output_tok,
"gen_ai.response.finish_reasons": [response.stop_reason],
# 커스텀 비용 속성
"llm.cost.usd": round(cost, 6),
"llm.cost.input_usd": round(input_tok * 3.0 / 1e6, 6),
"llm.cost.output_usd": round(output_tok * 15.0 / 1e6, 6),
})
total_input_tokens += input_tok
total_output_tokens += output_tok
total_cost += cost
except Exception as e:
llm_span.set_status(Status(StatusCode.ERROR, str(e)))
llm_span.record_exception(e)
raise
# 완료 조건 확인
if response.stop_reason == "end_turn":
final_text = response.content[0].text
break
# ── 툴 실행 스팬 (자식) ──
for tool_use in [b for b in response.content if b.type == "tool_use"]:
with tracer.start_as_current_span(
f"gen_ai.tool.execute",
attributes={
"gen_ai.tool.name": tool_use.name,
"gen_ai.tool.call_id": tool_use.id,
"gen_ai.agent.step_number": step_count,
}
) as tool_span:
try:
result = execute_tool(tool_use.name, tool_use.input)
tool_span.set_attribute("gen_ai.tool.success", True)
tool_span.set_attribute("gen_ai.tool.output_length", len(str(result)))
except Exception as e:
tool_span.set_status(Status(StatusCode.ERROR, str(e)))
tool_span.set_attribute("gen_ai.tool.success", False)
result = f"오류: {str(e)}"
messages.append({"role": "assistant", "content": response.content})
messages.append({
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": tool_use.id, "content": str(result)}]
})
# ── 루트 스팬 최종 집계 ──
agent_span.set_attributes({
"gen_ai.agent.total_steps": step_count,
"gen_ai.usage.total_input_tokens": total_input_tokens,
"gen_ai.usage.total_output_tokens": total_output_tokens,
"llm.cost.total_usd": round(total_cost, 6),
"gen_ai.agent.success": True,
})
return final_text
5. 비용 대시보드 — Grafana 메트릭 설정
# 토큰·비용을 메트릭으로도 수집 (트레이스 외)
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.prometheus import PrometheusMetricReader
# 메트릭 설정
reader = PrometheusMetricReader() # Prometheus → Grafana
provider = MeterProvider(metric_readers=[reader])
metrics.set_meter_provider(provider)
meter = metrics.get_meter("llm-agent")
# 메트릭 정의
token_counter = meter.create_counter(
name="gen_ai.usage.tokens",
description="LLM 호출당 토큰 사용량",
unit="token"
)
cost_counter = meter.create_counter(
name="llm.cost.usd",
description="LLM 호출 비용",
unit="USD"
)
llm_duration = meter.create_histogram(
name="gen_ai.client.operation.duration",
description="LLM 호출 레이턴시",
unit="s"
)
# 호출 시 메트릭 기록
def record_llm_metrics(
model: str,
input_tokens: int,
output_tokens: int,
duration_sec: float,
agent_name: str
):
labels = {
"gen_ai.request.model": model,
"gen_ai.system": "anthropic",
"gen_ai.agent.name": agent_name,
}
# 토큰 카운터
token_counter.add(input_tokens,
{**labels, "gen_ai.token.type": "input"})
token_counter.add(output_tokens,
{**labels, "gen_ai.token.type": "output"})
# 비용 카운터
cost = calculate_cost(model, input_tokens, output_tokens)
cost_counter.add(cost, labels)
# 레이턴시 히스토그램
llm_duration.record(duration_sec, labels)
# Grafana 대시보드 패널 예시 (PromQL)
# 모델별 시간당 토큰 비용
sum by (gen_ai_request_model) (
rate(llm_cost_usd_total[1h])
) * 3600
# 에이전트별 일일 지출
sum by (gen_ai_agent_name) (
increase(llm_cost_usd_total[24h])
)
# 평균 에이전트 스텝 수
avg(gen_ai_agent_total_steps)
# P95 LLM 레이턴시
histogram_quantile(0.95,
rate(gen_ai_client_operation_duration_bucket[5m])
)
6. 프로덕션 트러블슈팅 패턴
# ── 패턴 1: 비용 이상 탐지 ──
def detect_cost_anomaly(span_context):
"""
단일 에이전트 실행에서 예상 비용 초과 시 알림
"""
cost = span_context.get("llm.cost.total_usd", 0)
steps = span_context.get("gen_ai.agent.total_steps", 0)
# 임계값 초과 시 경고 스팬 이벤트 추가
if cost > 1.0: # $1.00 초과
span_context["span"].add_event(
"cost_threshold_exceeded",
attributes={
"threshold_usd": 1.0,
"actual_usd": cost,
"steps": steps,
"likely_cause": "무한 루프 또는 과도한 컨텍스트" if steps > 20 else "복잡한 태스크"
}
)
# ── 패턴 2: 루프 감지 ──
def detect_loop(messages: list) -> bool:
"""
같은 툴을 3회 이상 연속 호출하면 루프 의심
"""
if len(messages) < 6:
return False
recent_tools = [
m.get("tool_name") for m in messages[-6:]
if m.get("role") == "tool"
]
# 같은 툴이 3회 이상 연속
for i in range(len(recent_tools) - 2):
if recent_tools[i] == recent_tools[i+1] == recent_tools[i+2]:
return True
return False
# ── 패턴 3: 세션·사용자 연결 (컨텍스트 전파) ──
from opentelemetry.baggage import set_baggage, get_baggage
from opentelemetry import context
def run_agent_with_user_context(
goal: str,
user_id: str,
session_id: str
) -> str:
# 루트 스팬에 사용자 정보 태깅
with tracer.start_as_current_span(
"gen_ai.agent.invoke",
attributes={
"user.id": user_id,
"session.id": session_id,
"gen_ai.agent.name": "assistant",
}
) as span:
return run_agent(goal, tools=[])
# → Jaeger에서 user.id로 특정 사용자의 전체 에이전트 실행 추적 가능
7. 로컬 개발 환경 — Docker Compose로 Jaeger 띄우기
# docker-compose.yml
version: '3.8'
services:
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # Jaeger UI
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
environment:
- COLLECTOR_OTLP_ENABLED=true
# 선택: Prometheus + Grafana
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
# 실행
docker-compose up -d
# Jaeger UI: http://localhost:16686
# → 서비스 선택 → 트레이스 목록 → 클릭하면 전체 스팬 트리
# → "gen_ai.usage.total_input_tokens" 속성으로 필터링
8. LangSmith·Braintrust와 함께 쓰는 법
# LangSmith: LangChain 프레임워크 사용 시
# OTel 스팬이 LangSmith 트레이스와 자동 연결됨
from langchain.callbacks import LangChainTracer
from opentelemetry.instrumentation.langchain import LangChainInstrumentor
LangChainInstrumentor().instrument()
# → LangChain 내 모든 LLM 호출이 OTel 스팬으로 캡처
# → 동시에 LangSmith에도 전송 가능 (이중 계측)
# Braintrust: OTel 스팬 → Braintrust 트레이스 변환
# BraintrustSpanProcessor 추가로 기존 OTel 파이프라인에 Braintrust 연결
from braintrust.otel import BraintrustSpanProcessor
provider.add_span_processor(BraintrustSpanProcessor(
api_key=os.getenv("BRAINTRUST_API_KEY"),
project_name="my-agent"
))
# → 프로덕션 실패 → Eval 케이스 1클릭 전환 가능
텍스트 요약 — 언제 무엇을 써야 하나
# 백엔드 선택 기준
Jaeger:
"지금 당장 무료로 시작하고 싶다"
→ Docker Compose 1줄, UI 즉시 사용 가능
Grafana Tempo + Prometheus:
"메트릭(비용·레이턴시)과 트레이스를 같이 보고 싶다"
→ 프로덕션 표준 스택
Datadog / Honeycomb:
"관리형 백엔드 원함, 사용량 기반 과금 OK"
→ 팀 규모 크고 인프라 관리 줄이고 싶을 때
LangSmith:
"LangChain 프레임워크 이미 쓰는 중"
→ 에이전트 트레이스 + 프롬프트 버전 관리 함께
Braintrust:
"프로덕션 실패를 Eval 데이터셋으로 바꾸고 싶다"
→ 평가 파이프라인 중시하는 팀
결론
✅ 지금 당장 추가해야 할 3가지
- AnthropicInstrumentor().instrument() — 코드 변경 없이 모든 호출 자동 추적
- 에이전트 루트 스팬에 llm.cost.total_usd 속성 추가 — 비용 가시성 즉시 확보
- Jaeger Docker Compose 로컬 환경 설정 — 5분 내 트레이스 UI 확인
✅ 에이전트 옵저버빌리티의 핵심 원칙
- 루트 스팬 = 전체 에이전트 실행 (goal → final result)
- 자식 스팬 = LLM 호출 + 툴 실행 + 재시도 각각
- gen_ai.* 표준 속성 → 어떤 백엔드에서도 동일한 쿼리
❌ 주의사항
- capture_content=True → 프로덕션에서 PII 데이터 유출 위험 — 개발 환경만
- GenAI Semantic Conventions = 2026년 초 experimental 상태 — 속성명 변경 가능성
- OTEL_SEMCONV_STABILITY_OPT_IN 환경 변수로 마이그레이션 버퍼 확보 필요
'AI Agent' 카테고리의 다른 글
| LangGraph vs PydanticAI vs CrewAI vs Google ADK — 2026년 에이전트 프레임워크 4파전 (0) | 2026.05.29 |
|---|---|
| PydanticAI 완전가이드 2026 — FastAPI 철학의 에이전트 프레임워크 (0) | 2026.05.29 |
| Instructor 라이브러리로 구조화 출력 실전 2026 — LLM에서 신뢰할 수 있는 JSON을 뽑는 법 (0) | 2026.05.29 |
| 멀티에이전트 시스템: 오케스트레이터-워커 병렬 에이전트 패턴 — N개 서브태스크 동시 실행, 비용·레이턴시 트레이드오프 계산 (0) | 2026.05.29 |
| Plan-and-Execute 에이전트 패턴 — 계획과 실행을 분리하면 비용이 절반이 된다 (0) | 2026.05.29 |