LLM 앱이 “느려졌는데 왜 느려졌는지”를 OTel Trace로 끝까지 추적하는 법
LLM 기반 기능을 프로덕션에 올리면 관측(Observability) 난이도가 일반 웹백엔드보다 급격히 올라갑니다. 이유는 단순합니다.
들어가며
LLM 기반 기능을 프로덕션에 올리면 관측(Observability) 난이도가 일반 웹백엔드보다 급격히 올라갑니다. 이유는 단순합니다. 요청 1건이 “LLM 호출(외부 API) + RAG(벡터DB) + Tool 실행 + 재시도/라우팅 + 후처리”로 쪼개지고, 성능/비용/품질 문제의 원인이 “코드”가 아니라 prompt, tool choice, retrieval 결과, 모델 파라미터에 숨어 있기 때문입니다.
이때 OpenTelemetry tracing을 제대로 쓰면 좋은 점은:
- 기존 서비스(HTTP, DB, queue) trace와 LLM trace를 한 Trace ID로 연결해서 “어디서 병목이 생겼는지”를 시간축으로 같이 볼 수 있음
- vendor 종속이 적고, Collector에서 샘플링/마스킹/정규화 같은 운영 정책을 중앙에서 강제할 수 있음
반대로, 아래라면 OTel tracing만으로는 부족하거나 오히려 독이 됩니다.
- prompt/response 원문을 저장하면 안 되는 규제/보안 환경인데, 정책 없이 무턱대고 span attribute로 넣는 경우 (Collector 레벨 redaction/allowlist 없으면 사고 납니다)
- “원인 분석”보다 “품질 평가/실험 관리”가 핵심인 팀(LLM evaluation, dataset, replay 중심)이면 tracing은 보조이고 별도 도구가 필요할 수 있음
- 트래픽이 큰데 tail sampling 설계 없이 “전량 수집”하려는 경우: LLM span은 payload가 커서 파이프라인이 먼저 터집니다(메모리/네트워크/스토리지)
🔧 핵심 개념
1) LLM Observability에서 trace가 가져야 하는 것
일반 분산 트레이싱은 “요청이 서비스들을 어떻게 흘렀나”가 핵심입니다. LLM 앱에서는 여기에 더해:
- LLM call 단위의 latency / tokens / cost / model / cache hit 같은 정형 지표
- agent workflow(plan → tool → observe → revise)의 단계 구조
- RAG라면 retriever query, topK, chunk ids, reranker 결과 같은 “품질 원인” 단서
가 필요합니다.
OpenTelemetry는 원래도 span attribute를 자유롭게 붙일 수 있지만, 2025~2026 사이에 “LLM/GenAI용 semantic conventions”가 빠르게 정리되면서, gen_ai.* 계열 속성/스키마로 백엔드 간 해석 일관성을 노리는 흐름이 뚜렷해졌습니다. OpenTelemetry 문서에서도 GenAI attributes가 별도 레지스트리/저장소로 이동했다고 명시합니다.1
2) GenAI semantic conventions vs OpenInference vs OpenLLMetry: 뭐가 다른가
2026년 8월 기준, 현업에서 자주 부딪히는 구도는 대략 이렇습니다.
- OpenTelemetry GenAI semantic conventions (
gen_ai.*)- 목적: “어떤 벤더/프레임워크든 동일한 키로 LLM span을 표현”
- 장점: 표준화 방향, Collector/백엔드가 이해하기 쉬움
- 단점: 프레임워크별 자동계측은 아직 제각각이라 “표준 속성을 내가 직접 채워야” 하는 구간이 남음2
- OpenInference
- 목적: LLM 관측을 위한 semantic conventions + instrumentations를 묶어서 제공, Phoenix(Arize) 생태계에서 강하게 지원
- 특징:
openinference.span.kind(LLM/RETRIEVER/TOOL/AGENT 등) 같은 분류 체계와 token count 등 속성이 명확히 정의됨3 - 장점: “LLM 앱 관측에 필요한 span 구조”가 비교적 즉시 사용 가능
- 단점: GenAI 표준(
gen_ai.*)과 1:1 대응이 완벽하지 않을 수 있어 “변환/정규화”가 필요해짐
- OpenLLMetry
- 목적: OpenTelemetry 기반으로 LLM/VectorDB/Agent 구성요소를 자동계측해 trace/metrics/logs를 만들자
- 장점: 코드 변경 최소화(autoinstrumentation), 다양한 LLM 스택을 빠르게 커버하려는 접근4
- 단점: “어떤 semantic conventions로 내보내는지”가 팀의 파이프라인/백엔드 전략과 충돌할 수 있음(아래에서 정규화로 해결)
핵심은 “어느 쪽이 정답”이 아니라, 내 조직의 수집 파이프라인/백엔드가 무엇을 표준으로 이해하느냐입니다. 이 충돌을 줄이려고 Collector-contrib에 genainormalizerprocessor 같은 “스키마 정규화 프로세서”가 등장했고, OpenLLMetry/OpenInference가 내보내는 메시지 속성을 GenAI 스키마(gen_ai.input.messages, gen_ai.output.messages)로 재구성하는 기능이 문서화되어 있습니다.5
이게 2026년 8월 관점에서 꽤 중요한 변화입니다. “SDK가 뭘 내보내든, Collector에서 표준으로 맞춰버린다”는 운영 패턴이 가능해졌거든요.
3) 내부 작동 흐름(구조/흐름)
프로덕션에서 추천하는 구성은 다음 흐름을 갖습니다.
- App에서 span 생성
- HTTP entry span (기존)
agent/chainspanllmspan(OpenAI/Bedrock 등)retrieverspan(vector DB)
- OTLP exporter로 Collector로 전송
- Collector에서 처리
- attributes processor로 PII/prompt 제거 또는 allowlist 적용
- genainormalizerprocessor로 OpenInference/OpenLLMetry →
gen_ai.*형태로 정규화 - tail_sampling(또는 다른 샘플링 전략)으로 비용 제어
- Backend(Tempo/Jaeger/Datadog/Honeycomb/Phoenix/LangSmith 등)로 저장/조회
LangSmith도 OpenTelemetry fan-out/Collector 기반 연동을 공식 문서로 제공하는데, “자체 트레이싱”과 “OTel 파이프라인”을 함께 가져가려는 팀이 많다는 신호입니다.6
💻 실전 코드
아래 예제는 “고객 지원 챗(티켓 요약 + 내부 KB 검색 + Tool로 CRM 조회 + 최종 답변)” 같은 현실적인 시나리오를 가정합니다.
- 목표 1) FastAPI 요청부터 LLM/벡터검색/툴 호출까지 한 trace로 연결 2) span에 tokens/model 같은 운영에 필요한 최소 속성만 남기고 3) Collector에서 GenAI 표준으로 정규화 + 민감정보 제거 + 샘플링 가능하게 만들기
0) 의존성 & 실행
1
2
3
4
5
6
7
8
python -m venv .venv
source .venv/bin/activate
pip install fastapi uvicorn httpx python-dotenv
pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp
pip install opentelemetry-instrumentation-fastapi opentelemetry-instrumentation-httpx
# LLM 계측은 OpenInference(OpenAI) 예시로 진행 (Phoenix 문서에 설치 예시 있음)
pip install "arize-phoenix-otel>=0.16.0" openinference-instrumentation-openai openai
Phoenix는 “공식 OpenTelemetry Collector”는 아니지만 OTLP 호환 수집 endpoint로 동작한다고 커뮤니티 답변에서 명시합니다.7
1) 앱 코드 (FastAPI + OpenTelemetry + LLM/tool/retriever span)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# app.py
import os
import time
import json
from typing import Any, Dict, List
import httpx
from fastapi import FastAPI, Header
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
# OpenInference(OpenAI) instrumentor (Phoenix 문서/레포 흐름 기반)
from openinference.instrumentation.openai import OpenAIInstrumentor # type: ignore
import openai
app = FastAPI()
def setup_otel() -> None:
resource = Resource.create({
"service.name": "support-agent-api",
"service.version": "2026.08",
"deployment.environment": os.getenv("ENV", "local"),
})
provider = TracerProvider(resource=resource)
exporter = OTLPSpanExporter(
endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/v1/traces")
)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
FastAPIInstrumentor.instrument_app(app)
HTTPXClientInstrumentor().instrument()
# LLM 자동계측: OpenAI SDK 호출을 span으로 생성
OpenAIInstrumentor().instrument()
setup_otel()
tracer = trace.get_tracer("support-agent")
openai_client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
async def retriever_search(query: str) -> List[Dict[str, Any]]:
# 실제론 vector DB 호출이지만, 여기선 httpx로 내부 검색 서비스를 호출하는 형태로 가정
with tracer.start_as_current_span("retriever.search") as span:
span.set_attribute("retriever.top_k", 5)
span.set_attribute("retriever.query_len", len(query))
async with httpx.AsyncClient(timeout=3.0) as client:
# 예: 사내 KB 검색 API
r = await client.get("http://localhost:8081/kb/search", params={"q": query, "k": 5})
r.raise_for_status()
docs = r.json()["docs"]
# 민감정보가 섞일 수 있는 원문은 span에 넣지 않는 쪽을 기본값으로
span.set_attribute("retriever.docs_count", len(docs))
return docs
async def crm_lookup(customer_id: str) -> Dict[str, Any]:
with tracer.start_as_current_span("tool.crm_lookup") as span:
span.set_attribute("tool.name", "crm_lookup")
span.set_attribute("tool.customer_id_hash", hash(customer_id))
# 실제론 DB/내부 API 호출
time.sleep(0.05)
return {"tier": "gold", "open_tickets": 2}
async def llm_generate(system: str, user: str, context_docs: List[Dict[str, Any]]) -> str:
# OpenInference/OpenAI instrumentor가 LLM span을 만들지만,
# 앱 레벨에서 "workflow span"을 하나 더 감싸두면 agent 단계 분석이 쉬워집니다.
with tracer.start_as_current_span("agent.compose_answer") as span:
span.set_attribute("workflow.step", "compose_answer")
span.set_attribute("context.docs_count", len(context_docs))
# prompt 원문은 여기서는 변수로만 쓰고, span attribute로는 최소 메타만 남김
prompt = {
"system": system,
"user": user,
"context": [d.get("title") for d in context_docs],
}
span.set_attribute("prompt.context_titles_count", len(prompt["context"]))
resp = openai_client.chat.completions.create(
model=os.getenv("LLM_MODEL", "gpt-4.1-mini"),
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user + "\n\nCONTEXT:\n" + json.dumps(prompt["context"])},
],
temperature=0.2,
)
# 응답 원문도 저장/전송 정책이 없으면 span에 넣지 마세요.
return resp.choices[0].message.content
@app.post("/support/answer")
async def answer_ticket(x_customer_id: str = Header(default="unknown")):
with tracer.start_as_current_span("support.answer_ticket") as root:
root.set_attribute("customer.id_present", x_customer_id != "unknown")
customer = await crm_lookup(x_customer_id)
query = "지난 30일 결제/환불/에러 관련 이슈 요약"
docs = await retriever_search(query)
system = "You are a senior support agent. Answer precisely and cite internal KB titles."
user = f"Customer tier={customer['tier']}. Please draft a reply for the ticket."
answer = await llm_generate(system, user, docs)
return {"answer": answer, "meta": {"tier": customer["tier"], "docs": len(docs)}}
2) Collector 설정 (정규화 + 마스킹 + 샘플링의 뼈대)
아래는 “개념이 보이는 최소 예시”입니다. 포인트는:
- attributes processor로 민감 키를 삭제/업서트
- genainormalizerprocessor로 OpenInference/OpenLLMetry 스타일을
gen_ai.*로 정규화5 - tail_sampling은 운영 난이도가 높으니(특히 scale) 정책을 작게 시작
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# otel-collector.yaml (개념 예시)
receivers:
otlp:
protocols:
http:
processors:
attributes/redact:
actions:
# 예: prompt/response 원문이 들어올 가능성이 있는 키를 제거(조직 규칙에 맞게)
- key: gen_ai.input.messages
action: delete
- key: gen_ai.output.messages
action: delete
genainormalizer:
# tail sampling은 collector에서만 가능하다는 점이 자주 언급됩니다(운영 설계 필요)
tail_sampling:
decision_wait: 10s
num_traces: 20000
policies:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: baseline
type: probabilistic
probabilistic: { sampling_percentage: 5 }
exporters:
otlphttp:
endpoint: http://your-trace-backend:4318/v1/traces
service:
pipelines:
traces:
receivers: [otlp]
processors: [attributes/redact, genainormalizer, tail_sampling]
exporters: [otlphttp]
예상 결과(관측 관점)
Trace UI에서 한 요청에 대해 대략 이런 트리가 나옵니다.
POST /support/answer(FastAPI auto span)support.answer_ticket(root workflow)tool.crm_lookupretriever.search(+ 내부 KB API client span)agent.compose_answeropenai.chat.completions(LLM span: model/tokens 등은 semantic conventions에 따라 표시)
여기서 중요한 건 “LLM span만 보는 것”이 아니라, retriever/tool 단계가 LLM latency를 유발했는지, 혹은 LLM이 빠른데 전체가 느린지가 한 화면에서 판단된다는 점입니다.
⚡ 실전 팁 & 함정
Best Practice 1) “콘텐츠”와 “메타데이터”를 분리해라
gen_ai.input.messages, gen_ai.output.messages 같은 콘텐츠는 디버깅엔 유용하지만, 개인정보/기밀/저작권/규제 리스크의 핵심입니다. GenAI semantic conventions 문서에서도 “프로덕션에서는 volume/민감정보 때문에 content uploading을 파이프라인에서 선택적으로 하라”는 취지의 가이드가 들어가 있습니다.2
실무 추천:
- 앱에서는 기본적으로 tokens, model, latency, workflow name, tool name, retrieval count만 남기고
- 콘텐츠 저장이 필요하면 별도 secure store(접근통제/보존정책)로 빼고 trace에는 reference만 남기기
Best Practice 2) “정규화는 Collector에서”가 운영이 쉽다
팀/서비스마다 instrumentor가 달라지면 span attribute 형태가 달라져 분석이 깨집니다. genainormalizerprocessor 같이 Collector에서 표준(gen_ai.*)로 맞추면, 앱 팀은 “어떤 SDK를 쓰든” 일단 보내기만 하면 되고, 플랫폼 팀은 “표준 스키마”로 대시보드를 유지할 수 있습니다.5
Best Practice 3) 샘플링은 “LLM 특화 정책”을 따로 둬라
LLM 요청은 비싸고, 한 trace가 크며, 디버깅 가치가 큰 편입니다. 그래서 HTTP API와 같은 1% head sampling을 그대로 적용하면 “정작 문제 케이스가 안 남는” 일이 잦습니다.
- ERROR trace는 100%에 가깝게 남기고
- latency 상위, 특정 고객 tier, 특정 workflow만 더 남기는 식의 tail sampling이 매력적이지만
tail sampling 자체가 scale에서 까다롭고(Trace ID affinity, 메모리) 커뮤니티에서도 자주 병목으로 언급됩니다.8
따라서 시작은: 1) ERROR 100% + baseline 1~5% 2) 이후 “특정 endpoint/workflow”만 추가 정책
순서가 안전합니다.
흔한 함정) span attribute에 prompt/response를 “그냥” 넣는다
처음엔 편합니다. 하지만 곧:
- 저장 비용 폭발
- 검색/마스킹 실패로 보안 사고
- 백엔드 인덱싱 비용 증가
로 이어집니다. “원문이 필요하면 별도 경로”가 장기적으로 유리합니다.
🚀 마무리
2026년 8월 시점의 LLM observability에서 OpenTelemetry tracing은 “선택”이 아니라 사실상 플랫폼 표준으로 수렴하는 중입니다. 핵심은:
- 앱 내부의 agent/RAG/tool 단계를 span으로 쪼개고
- semantic conventions를 따라 해석 가능한 속성으로 남기며
- Collector에서 정규화(gen_ai.*), 마스킹, 샘플링을 중앙 통제하는 것
도입 판단 기준은 이 3가지 질문으로 정리됩니다. 1) 지금 장애/성능/비용 이슈의 원인을 “LLM 호출 전후 맥락”까지 포함해 추적해야 하나? 2) 우리 조직이 이미 OTel 파이프라인이 있거나, 향후 vendor lock-in을 피하고 싶은가? 3) 콘텐츠(프롬프트/응답)를 안전하게 다룰 정책(마스킹/보존/접근통제)을 Collector 레벨에서 강제할 준비가 되었나?
다음 학습 추천(실무 순서):
- OpenTelemetry semantic conventions(Trace/Resource) 기본9
- GenAI semantic conventions(
gen_ai.*)와 메시지 스키마1 - OpenInference 또는 OpenLLMetry 중 하나로 자동계측 적용 후, Collector에서
genainormalizerprocessor로 표준화5
https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/ ↩︎ ↩︎2
https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-spans.md ↩︎ ↩︎2
https://github.com/Arize-ai/openinference/blob/main/spec/semantic_conventions.md ↩︎
https://pkg.go.dev/github.com/open-telemetry/opentelemetry-collector-contrib/processor/genainormalizerprocessor ↩︎ ↩︎2 ↩︎3 ↩︎4
https://langchain-5e9cc07a.mintlify.app/langsmith/trace-with-opentelemetry ↩︎
https://community.arize.com/x/phoenix-support/msg_hRVkZ1SGvh4Q/is-arize-phoenix-an-opentelemetry-collector ↩︎
https://docs.redhat.com/en/documentation/openshift_container_platform/4.16/pdf/red_hat_build_of_opentelemetry/otel-configuring-metrics ↩︎
https://opentelemetry.io/docs/specs/semconv/general/trace/ ↩︎