LLM 옵저버빌리티 완전 가이드
OpenTelemetry GenAI 컨벤션부터 Langfuse·Phoenix·LLM-as-judge 평가·토큰 비용 추적까지, 프로덕션 LLM 앱을 관측 가능하게 만드는 법
LLM 애플리케이션은 전통적인 백엔드 서비스와 근본적으로 다른 디버깅 문제를 안고 있다. HTTP 500이 떨어지지 않는데도 출력이 "틀렸다." 같은 프롬프트가 어제는 잘 되다가 오늘은 헛소리를 한다. 비용은 청구서가 와야 알 수 있다. 멀티스텝 에이전트가 12번 툴을 호출하고 답을 냈는데 어느 단계에서 컨텍스트가 오염됐는지 알 수 없다. 이 모든 게 "앱은 정상 동작 중"이라는 상태에서 벌어진다. 표준 APM(Application Performance Monitoring)은 레이턴시와 에러율은 보여주지만, LLM 호출의 내용과 품질은 보지 못한다.
LLM 옵저버빌리티는 이 공백을 메우는 세 개의 기둥으로 구성된다:
| 기둥 | 무엇을 보는가 | 대표 질문 |
|---|---|---|
| 트레이싱(Tracing) | 요청 하나가 LLM·리트리버·툴·서브에이전트를 거치는 전체 경로 | "이 답이 왜 이렇게 나왔지? 어느 스텝에서 틀어졌지?" |
| 평가(Evaluation) | 출력의 품질을 정량/정성적으로 측정 | "새 프롬프트가 기존보다 나은가? 회귀가 났나?" |
| 비용 모니터링(Cost) | 토큰 사용량·캐시 적중·모델별 지출 | "이번 달 어느 기능이 예산을 잡아먹나? 캐시는 먹히나?" |
이 세 가지는 분리된 도구가 아니라 하나의 데이터 모델 위에 쌓인다. 핵심은 트레이스다. 트레이스 한 건에 입력·출력·토큰·레이턴시가 다 들어 있으면, 그 위에서 평가 점수를 붙이고 비용을 집계할 수 있다. 그래서 잘 설계된 스택은 "트레이싱 인프라를 먼저 깔고, 평가와 비용을 그 위에 얹는다."
이 가이드는 추상론이 아니라 실제로 따라 할 수 있는 구현을 다룬다. OpenTelemetry의 GenAI 시맨틱 컨벤션(이 분야의 사실상 표준)을 기반으로, Langfuse(오픈소스, self-host 가능)와 Arize Phoenix(오픈소스 평가 특화)를 메인 백엔드로 쓴다. 코드 예시는 Python과 TypeScript, 그리고 Anthropic Claude API(토큰/캐시 필드가 실제 어떻게 생겼는지)를 기준으로 한다. OpenAI·다른 프로바이더를 쓰더라도 트레이스 구조와 평가 패턴은 동일하게 적용된다.
1. 옵저버빌리티 데이터 모델: 트레이스·스팬·제너레이션
도구를 고르기 전에 데이터 모델을 정확히 이해해야 한다. 거의 모든 LLM 옵저버빌리티 백엔드(Langfuse, Phoenix, LangSmith, Helicone)는 분산 트레이싱의 개념을 빌려와 LLM에 맞게 확장한 동일한 모델을 쓴다.
계층 구조:
Trace (사용자 요청 1건 = "고객 지원 챗봇 1턴")
├── Span: retrieve_context (벡터 DB 조회)
│ └── Span: embed_query (임베딩 호출)
├── Span: build_prompt (순수 코드, LLM 아님)
├── Generation: claude_call (★ LLM 호출 — 입력/출력/토큰)
│ └── Span: tool_call:get_order (에이전트가 호출한 툴)
└── Generation: claude_call_2 (툴 결과를 받아 최종 답 생성)
- Trace: 최상위 단위. 사용자 요청 하나, 에이전트 실행 하나에 대응.
trace_id로 묶인다. - Span: 트레이스 안의 작업 단위. 코드 함수, 리트리버 조회, 툴 호출 등 무엇이든 될 수 있다. 부모-자식 관계로 중첩된다.
- Generation (= LLM Span): 특수한 스팬. LLM 호출 하나에 대응하며 추가 필드를 갖는다 —
model,input(메시지 배열),output,usage(prompt/completion 토큰),parameters(temperature, max_tokens 등),cost.
왜 이 구분이 중요한가: 비용·토큰 집계는 Generation 노드에서만 의미가 있다. 평가 점수는 어느 레벨에도 붙을 수 있다(트레이스 전체 점수 = "이 대화가 도움이 됐나", 특정 Generation 점수 = "이 답변이 환각인가"). 디버깅은 스팬 트리를 위에서 아래로 훑으며 어느 노드에서 입력이 오염됐는지 찾는 일이다.
Observation이라는 용어: Langfuse는 Span/Generation/Event를 합쳐 "Observation"이라 부른다. Phoenix/OTel은 모두 "Span"이고 openinference.span.kind 속성으로 LLM/RETRIEVER/TOOL/CHAIN을 구분한다. 이름만 다르고 본질은 같다.
실전 함정 — 트레이스 경계를 잘못 잡으면 전부 무너진다:
- 너무 굵게(앱 전체 = 트레이스 1개): 비용이 사용자별로 안 갈린다.
- 너무 잘게(LLM 호출마다 트레이스 1개): 멀티스텝 에이전트의 인과관계가 끊겨 디버깅 불가.
- 권장: "사용자가 한 번 행동한 단위"를 트레이스로. 챗봇은 1턴, 배치 처리는 아이템 1개, 에이전트는 1회 실행. 이게 비용 귀속과 디버깅이 둘 다 맞아떨어지는 경계다.
2. OpenTelemetry GenAI 시맨틱 컨벤션: 표준에 베팅하기
벤더 SDK에 락인되기 전에 알아야 할 것: OpenTelemetry(OTel)에는 GenAI 전용 시맨틱 컨벤션이 있다. 이것은 LLM 호출을 어떤 스팬 이름·속성으로 기록할지 정의한 표준 스펙이다. Langfuse, Phoenix, Datadog, Grafana 등 주요 백엔드가 이 컨벤션(또는 그 변종인 OpenInference)을 OTLP로 받는다. 표준에 맞춰 계측하면 백엔드를 갈아탈 때 코드를 다시 안 짜도 된다.
핵심 속성 (gen_ai.* 네임스페이스):
| 속성 | 의미 | 예시 값 |
|---|---|---|
gen_ai.system | 프로바이더 | anthropic, openai |
gen_ai.request.model | 요청 모델 | claude-opus-4-8 |
gen_ai.request.max_tokens | 파라미터 | 16000 |
gen_ai.response.model | 실제 응답 모델 | claude-opus-4-8 |
gen_ai.usage.input_tokens | 입력 토큰 | 3571 |
gen_ai.usage.output_tokens | 출력 토큰 | 727 |
gen_ai.operation.name | 작업 종류 | chat, embeddings |
스팬 이름 컨벤션은 {operation} {model} 형태 (예: chat claude-opus-4-8).
OpenInference vs OpenTelemetry GenAI: 두 컨벤션이 공존한다. OpenInference는 Arize가 만든 것으로 Phoenix가 네이티브로 쓰고, 리트리버·임베딩·툴 같은 LLM 주변 스팬까지 풍부하게 정의한다. OTel GenAI는 CNCF 공식이며 점점 수렴 중이다. 실무에서는 자동 계측 라이브러리가 어느 쪽을 내보내는지만 확인하면 되고, 둘 다 OTLP로 흐르므로 대부분의 백엔드가 양쪽을 받는다.
계측 방식 두 가지:
- 자동 계측(auto-instrumentation):
openllmetry(Traceloop),openinference-instrumentation-anthropic같은 패키지를 설치하면 SDK 호출을 몽키패칭해서 자동으로 스팬을 만든다. 코드 변경 거의 없음. - 수동 계측(manual): 직접 스팬을 열고 속성을 채운다. 자동 계측이 못 잡는 커스텀 로직(리트리버, 비즈니스 스텝)에 필요.
실전에서는 둘을 섞는다 — LLM 호출은 자동 계측에 맡기고, 그 사이의 비즈니스 로직은 수동 스팬으로 감싼다.
민감 데이터 함정: GenAI 컨벤션은 프롬프트·응답 본문(gen_ai.prompt, gen_ai.completion)을 스팬에 기록할 수 있게 한다. 이건 PII가 그대로 트레이스 스토어에 들어간다는 뜻이다. 대부분의 계측 라이브러리는 본문 캡처를 끄는 환경변수를 제공한다(예: OpenLLMetry의 TRACELOOP_TRACE_CONTENT=false). 규제 도메인(의료·금융)이면 처음부터 끄거나 마스킹 훅을 걸어라.
3. 메인 백엔드 선택: Langfuse vs Phoenix vs 매니지드
백엔드는 트레이스를 받아 저장·시각화하고 평가·비용 집계를 제공한다. 각각의 성격이 뚜렷하다.
Langfuse (오픈소스, self-host 가능 / 클라우드 있음)
- 강점: 트레이싱 + 프롬프트 관리 + 평가 + 데이터셋을 한 플랫폼에. self-host가 Docker Compose 한 방으로 깔린다. OTLP 엔드포인트를 노출하므로 OTel로 직접 쏠 수도, 자체 SDK로 쏠 수도 있다.
- 적합: 풀스택 LLMOps를 하나로 묶고 싶고, 데이터 주권(self-host)이 중요한 팀.
Arize Phoenix (오픈소스 / Arize 클라우드의 OSS 버전)
- 강점: 평가가 1급 시민. LLM-as-judge 평가 템플릿, 임베딩 드리프트 시각화, RAG 분석이 강력. OpenInference 네이티브.
pip install arize-phoenix후 노트북에서 바로 띄울 수 있다. - 적합: 평가·실험 중심, RAG/에이전트 품질 디버깅이 주 관심사인 팀.
매니지드 (LangSmith, Helicone, Datadog LLM Observability)
- LangSmith: LangChain 생태계와 밀착. LangChain 안 써도 SDK로 쓸 수 있음.
- Helicone: 프록시 방식이 특징 — base URL만 바꾸면 코드 변경 없이 모든 호출이 기록됨(아래 5번 참고). 게이트웨이/캐싱/레이트리밋도 제공.
- Datadog: 이미 Datadog APM을 쓰는 조직이면 LLM Observability 모듈로 기존 인프라에 통합.
의사결정 매트릭스:
| 우선순위 | 추천 |
|---|---|
| 데이터 주권 + 올인원 | Langfuse self-host |
| 평가·실험 깊이 | Phoenix (+ 필요시 Arize) |
| 코드 변경 0 + 게이트웨이 | Helicone 프록시 |
| 기존 Datadog 조직 | Datadog LLM Obs |
| LangChain 헤비 유저 | LangSmith |
현실적 조합: 많은 팀이 트레이싱은 Langfuse(또는 매니지드), 평가는 Phoenix를 병행한다. OTel/OpenInference로 계측하면 같은 트레이스를 양쪽에 보낼 수 있어 락인이 줄어든다. 처음 시작이라면 Langfuse self-host 하나로 시작하고, 평가 깊이가 필요해지면 Phoenix를 추가하는 경로를 추천한다.
셀프호스트 Langfuse 빠른 기동:
git clone https://github.com/langfuse/langfuse.git
cd langfuse
docker compose up -d # http://localhost:3000
# UI에서 프로젝트 생성 → public/secret key 발급
4. 첫 트레이스 계측하기 (Python): Claude 호출을 감싸기
실제 코드로 들어가자. Anthropic Claude 호출을 트레이싱하는 가장 깔끔한 방법은 자동 계측 + 데코레이터로 비즈니스 스팬 추가다.
옵션 A — Langfuse 데코레이터 + 자동 계측:
import anthropic
from langfuse import observe, get_client
from langfuse.anthropic import Anthropic # 자동 계측된 래퍼
# langfuse는 환경변수로 설정:
# LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_HOST
client = Anthropic() # Anthropic()과 동일 인터페이스, 호출이 자동 기록됨
@observe() # 이 함수 전체가 하나의 트레이스(또는 스팬)가 됨
def answer_question(question: str, context: str) -> str:
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=16000,
system=f"다음 문서를 근거로 답하라:\n{context}",
messages=[{"role": "user", "content": question}],
)
# usage 필드가 자동으로 Generation 노드에 기록됨
return next(b.text for b in response.content if b.type == "text")
answer_question("환불 정책이 뭐야?", retrieved_docs)
get_client().flush() # 종료 전 비동기 전송 플러시
@observe()로 감싼 함수가 트레이스의 루트가 되고, 그 안의 client.messages.create는 Generation 노드로 자동 잡힌다. 입력 메시지·출력·usage.input_tokens/output_tokens가 전부 기록된다.
옵션 B — 순수 OpenTelemetry + OpenLLMetry (벤더 중립):
from traceloop.sdk import Traceloop
from traceloop.sdk.decorators import workflow
import anthropic
# OTLP 엔드포인트를 Langfuse/Phoenix/아무 백엔드로 지정
Traceloop.init(
app_name="support-bot",
api_endpoint="http://localhost:3000/api/public/otel", # Langfuse OTLP
# headers로 인증 키 전달
)
client = anthropic.Anthropic() # OpenLLMetry가 몽키패칭으로 자동 계측
@workflow(name="answer_question")
def answer_question(question: str, context: str) -> str:
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=16000,
messages=[{"role": "user", "content": question}],
)
return next(b.text for b in response.content if b.type == "text")
@workflow/@task 데코레이터가 OTel 스팬을 만들고, Anthropic 호출은 gen_ai.* 속성으로 자동 채워진다. 백엔드는 OTLP를 받는 곳이면 무엇이든 된다.
리트리버 스팬을 수동으로 추가 (RAG의 핵심):
from langfuse import observe, get_client
@observe(as_type="span")
def retrieve(query: str) -> list[str]:
docs = vector_db.search(query, k=5)
# 검색 결과를 스팬 메타데이터로 기록 → 나중에 "왜 이 문서를 가져왔나" 디버깅
get_client().update_current_observation(
metadata={"retrieved_ids": [d.id for d in docs], "k": 5}
)
return [d.text for d in docs]
이렇게 하면 트레이스 트리에 retrieve → claude_call이 부모-자식으로 잡히고, 환각이 의심될 때 "리트리버가 엉뚱한 문서를 가져왔나, 모델이 문서를 무시했나"를 한눈에 가른다.
함정 — 비동기/서버리스 플러시: 트레이스 전송은 비동기 배치다. Lambda·Vercel 함수처럼 짧게 살다 죽는 환경에서는 프로세스가 끝나기 전에 flush()를 명시적으로 호출하지 않으면 트레이스가 유실된다. fire-and-forget이 LLM 옵저버빌리티의 1번 사일런트 데이터 손실 원인이다.
5. TypeScript / Node 계측과 프록시 방식 비교
Node 환경의 계측 패턴과, 코드를 거의 안 건드리는 프록시 방식을 비교한다.
TypeScript — Langfuse SDK + Anthropic:
import Anthropic from "@anthropic-ai/sdk";
import { observeAnthropic } from "@langfuse/anthropic";
import { startActiveObservation, getActiveTraceId } from "@langfuse/tracing";
const anthropic = observeAnthropic(new Anthropic()); // 호출 자동 기록
async function answer(question: string, context: string) {
return startActiveObservation("answer", async () => {
const res = await anthropic.messages.create({
model: "claude-opus-4-8",
max_tokens: 16000,
system: `근거 문서:\n${context}`,
messages: [{ role: "user", content: question }],
});
// usage가 자동으로 Generation 노드에 매핑됨
const text = res.content.find((b) => b.type === "text");
return text?.type === "text" ? text.text : "";
});
}
OpenTelemetry NodeSDK (벤더 중립):
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { AnthropicInstrumentation } from "@traceloop/instrumentation-anthropic";
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: "http://localhost:3000/api/public/otel/v1/traces",
headers: { Authorization: `Basic ${BASIC_AUTH}` },
}),
instrumentations: [new AnthropicInstrumentation()],
});
sdk.start();
// 이후 모든 Anthropic 호출이 gen_ai.* 스팬으로 자동 기록
프록시 방식 (Helicone 예시) — 코드 변경 없음:
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic({
baseURL: "https://anthropic.helicone.ai", // ← base URL만 교체
defaultHeaders: {
"Helicone-Auth": `Bearer ${process.env.HELICONE_API_KEY}`,
"Helicone-Property-Feature": "support-bot", // 커스텀 차원
},
});
// 평소처럼 messages.create — 모든 호출이 프록시를 거치며 자동 로깅
프록시 vs SDK 계측 트레이드오프:
| 프록시 (Helicone 등) | SDK/OTel 계측 | |
|---|---|---|
| 코드 변경 | base URL 한 줄 | 데코레이터/래퍼 추가 |
| 멀티스텝 트레이스 | 약함 (호출 단위) | 강함 (스팬 트리) |
| 비즈니스 로직 스팬 | 불가 | 가능 |
| 인프라 의존 | 외부 프록시 경유(레이턴시·SPOF) | 없음 (비동기 전송) |
| 게이트웨이/캐싱 | 내장 | 별도 |
권장: 단순 로깅·비용 추적만 필요하면 프록시가 빠르다. 하지만 멀티스텝 에이전트나 RAG의 인과관계를 봐야 하면 SDK/OTel 계측이 필수다. 프록시는 LLM 호출 하나하나는 보지만 그 사이의 리트리버·툴·분기는 못 본다.
6. 멀티스텝 에이전트와 툴 호출 트레이싱
에이전트 디버깅이야말로 트레이싱이 가장 빛나는 곳이다. 에이전트는 LLM 호출 → 툴 실행 → 결과 피드백 → 다시 LLM 호출의 루프를 돈다. 트레이스가 없으면 어느 반복(iteration)에서 어긋났는지 알 수 없다.
계측해야 할 노드:
- 에이전트 실행 전체 = 트레이스 1개
- 각 LLM 호출 = Generation 노드 (반복마다 하나씩)
- 각 툴 실행 = Tool 스팬 (입력 인자·결과·에러를 기록)
- 리트리버·서브에이전트 = 별도 스팬
수동 에이전트 루프 계측 (Python, Claude tool use):
import anthropic
from langfuse import observe, get_client
client = anthropic.Anthropic()
@observe(as_type="span", name="tool_execution")
def run_tool(name: str, tool_input: dict) -> str:
get_client().update_current_observation(input={"tool": name, "args": tool_input})
result = TOOLS[name](**tool_input) # 실제 툴 실행
get_client().update_current_observation(output=result)
return result
@observe() # 에이전트 실행 전체 = 트레이스
def run_agent(user_input: str, tools: list) -> str:
messages = [{"role": "user", "content": user_input}]
for step in range(10): # 무한루프 가드
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=16000,
tools=tools,
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "end_turn":
return next(b.text for b in response.content if b.type == "text")
if response.stop_reason == "pause_turn":
continue # 서버사이드 툴 계속
# tool_use 블록 처리
tool_results = []
for block in response.content:
if block.type == "tool_use":
out = run_tool(block.name, block.input) # ← 스팬 자동 생성
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": out,
})
messages.append({"role": "user", "content": tool_results})
return "max steps reached"
결과 트레이스 트리:
run_agent
├── claude_call (step 0) → stop_reason: tool_use
├── tool_execution: get_order → {args, result}
├── claude_call (step 1) → stop_reason: tool_use
├── tool_execution: refund → {args, result}
└── claude_call (step 2) → stop_reason: end_turn ✓
에이전트 디버깅 체크리스트 (트레이스에서 확인할 것):
- 툴 입력 인자가 맞나 — 모델이 잘못된 args로 툴을 불렀나? (
tool_execution스팬의 input) - 툴 결과가 정상인가 — 툴이 에러를 뱉었는데 모델이 무시하고 진행했나? (
is_error결과) - 컨텍스트 누적 폭증 — step이 늘면서 input_tokens가 비정상적으로 증가하나? (각 Generation의 usage)
- 반복 횟수 — 같은 툴을 무한 반복하나? (스팬 개수)
- stop_reason 흐름 —
pause_turn이 제대로 재개됐나? (서버사이드 툴)
함정 — 스팬 컨텍스트 전파: 비동기/병렬 툴 실행 시 부모 스팬 컨텍스트가 자식으로 자동 전파되지 않을 수 있다. Python의 contextvars나 OTel의 명시적 context propagation을 쓰지 않으면 툴 스팬이 트레이스에서 고아(orphan)가 되어 트리가 깨진다. 대부분의 SDK가 @observe/데코레이터 안에서는 자동 처리하지만, 직접 스레드/태스크를 띄우면 컨텍스트를 수동으로 넘겨야 한다.
7. 비용·토큰 모니터링: usage 필드를 정확히 읽기
비용 추적의 핵심은 모든 Generation 노드의 토큰 usage를 정확히 캡처하고, 모델별 단가를 곱하는 것이다. 자동 계측이 대부분 해주지만, 단가표와 캐시 토큰을 이해하지 못하면 청구서와 대시보드가 안 맞는다.
Claude의 실제 usage 필드 (응답 객체):
response = client.messages.create(...)
print(response.usage.input_tokens) # 캐시 안 된 입력 토큰 (전액)
print(response.usage.output_tokens) # 출력 토큰
print(response.usage.cache_creation_input_tokens) # 캐시에 쓴 토큰 (~1.25x)
print(response.usage.cache_read_input_tokens) # 캐시에서 읽은 토큰 (~0.1x)
결정적 함정: input_tokens는 캐시 안 된 나머지만 담는다. 전체 프롬프트 크기 = input_tokens + cache_creation_input_tokens + cache_read_input_tokens. 캐시를 쓰는데 비용 대시보드가 input_tokens만 합산하면, 캐시 쓰기/읽기 토큰을 통째로 누락한다 — 비용이 실제보다 적게 보고된다. 반대로 단순히 "input+output"만 보면 캐시 절감 효과를 측정할 수 없다.
모델 단가표 (per 1M tokens, Claude 기준):
| 모델 | Input | Output |
|---|---|---|
| Claude Opus 4.8 | $5.00 | $25.00 |
| Claude Sonnet 4.6 | $3.00 | $15.00 |
| Claude Haiku 4.5 | $1.00 | $5.00 |
캐시 경제학: 캐시 읽기는 기본 입력가의 ~0.1배, 캐시 쓰기는 5분 TTL 기준 ~1.25배, 1시간 TTL 기준 ~2배. 정확한 비용 계산은 네 종류 토큰에 각각 다른 배율을 적용해야 한다.
비용 계산 함수 (캐시 반영):
PRICING = { # per token, USD
"claude-opus-4-8": {"in": 5e-6, "out": 25e-6},
"claude-sonnet-4-6": {"in": 3e-6, "out": 15e-6},
"claude-haiku-4-5": {"in": 1e-6, "out": 5e-6},
}
def compute_cost(model: str, usage) -> float:
p = PRICING[model]
return (
usage.input_tokens * p["in"]
+ usage.cache_creation_input_tokens * p["in"] * 1.25
+ usage.cache_read_input_tokens * p["in"] * 0.10
+ usage.output_tokens * p["out"]
)
Langfuse·Helicone 등은 모델 단가표를 내장하고 있어 토큰만 정확히 넘기면 비용을 자동 계산한다. 단, 새 모델 ID는 단가표에 없을 수 있다 — 비용이 $0으로 찍히거나 누락되면 백엔드의 모델 가격 설정을 직접 추가해야 한다. 커스텀/파인튜닝 모델도 마찬가지.
비용을 차원(dimension)으로 자르기: 트레이스에 user_id, feature, environment, session_id를 메타데이터/태그로 붙이면 "어느 기능이 예산을 먹나", "어느 사용자가 abuse하나"를 집계할 수 있다.
get_client().update_current_trace(
user_id="u_123",
metadata={"feature": "refund-flow", "plan": "pro"},
tags=["production"],
)
비용 모니터링 베스트 프랙티스:
- 토큰 카운트를 추정하지 말고 응답의 실제 usage를 써라.
tiktoken같은 OpenAI 토크나이저로 Claude 토큰을 세면 15~20% 틀린다. 사전 추정이 필요하면count_tokensAPI를 써라. - 단순 작업은 더 싼 모델로 라우팅하고, 그 절감을 대시보드로 검증하라(모델별 비용 분해).
- 프롬프트 캐시 적중률(
cache_read_input_tokens가 0이 아닌지)을 모니터링하라. 0이면 사일런트 무효화가 일어나고 있다(시스템 프롬프트의 타임스탬프, 정렬 안 된 JSON 등).
8. 오프라인 평가: 데이터셋 + LLM-as-judge
트레이싱이 "무슨 일이 일어났나"라면 평가는 "그게 좋았나"다. 평가는 두 모드로 나뉜다. 이 섹션은 오프라인 평가 — 배포 전에 고정된 데이터셋으로 품질을 측정하고 회귀를 잡는다.
구성 요소:
- 데이터셋: 입력 + (선택) 기대 출력의 모음. 골든 셋, 프로덕션에서 큐레이션한 케이스, 합성 데이터.
- 태스크/타겟: 평가할 함수(프롬프트+모델 조합).
- 평가자(evaluator): 출력에 점수를 매기는 함수. 결정론적(정확 일치, 정규식, JSON 스키마)이거나 LLM-as-judge.
Phoenix로 RAG 평가 실행:
import phoenix as px
from phoenix.evals import llm_classify, AnthropicModel
import pandas as pd
# 프로덕션 트레이스에서 (질문, 검색문서, 답변)을 데이터프레임으로 추출
df = pd.DataFrame({
"input": questions,
"reference": retrieved_contexts,
"output": answers,
})
# 환각(hallucination) 평가 템플릿 — Phoenix 내장
HALLUCINATION_TEMPLATE = '''
다음은 [질문], [참조 문서], [답변]이다.
답변이 참조 문서에 의해 뒷받침되면 "factual",
문서에 없는 내용을 지어냈으면 "hallucinated"로 분류하라.
[질문]: {input}
[참조]: {reference}
[답변]: {output}
'''
result = llm_classify(
dataframe=df,
template=HALLUCINATION_TEMPLATE,
model=AnthropicModel(model="claude-opus-4-8"), # judge 모델
rails=["factual", "hallucinated"], # 허용 라벨
)
print(result["label"].value_counts())
Langfuse로 데이터셋 실험:
from langfuse import get_client
langfuse = get_client()
dataset = langfuse.get_dataset("refund-eval-v1")
for item in dataset.items:
with item.run(run_name="prompt-v3") as root:
output = answer_question(item.input["question"], item.input["context"])
# 결정론적 평가 점수 부착
score = 1.0 if item.expected_output in output else 0.0
langfuse.score_current_trace(name="contains_answer", value=score)
UI에서 prompt-v2 대 prompt-v3 런을 나란히 비교 — 평균 점수, 비용, 레이턴시 차이를 본다. 이게 프롬프트 회귀 테스트다.
LLM-as-judge 함정과 대응:
- 위치 편향(position bias): A/B 비교에서 먼저 제시된 답을 선호. 순서를 무작위화하거나 양방향으로 평가해 평균.
- 자기 선호(self-preference): judge 모델이 같은 모델 패밀리의 출력을 선호하는 경향. 가능하면 judge를 평가 대상과 다른 모델로.
- 점수 분포 쏠림: 1~10 점수를 요구하면 7~8에 몰린다. 명확한 라벨 분류(factual/hallucinated)나 구체적 루브릭이 노이즈가 적다.
- judge 자체를 평가하라: judge의 판정을 사람 라벨 일부와 대조해 일치율(agreement)을 측정. judge가 신뢰할 만한지 검증 없이 쓰면 잘못된 신호로 의사결정한다.
구조화 출력으로 judge 신뢰도 높이기: judge가 라벨+근거를 JSON으로 내도록 강제하면 파싱 안정성과 감사성이 올라간다. Claude의 경우 output_config: {format: {type: "json_schema", schema: {...}}}로 스키마를 고정한다(프리필 대신 구조화 출력 사용).
9. 온라인 평가: 프로덕션 트레이스에 점수 붙이기
오프라인 평가는 배포 전 게이트, 온라인 평가는 실제 트래픽이 흐르는 동안 품질을 측정한다. 프로덕션 트레이스에 점수를 부착하는 방식이며, 라이브 회귀와 엣지케이스를 잡는다.
세 가지 신호 소스:
- 사용자 피드백 (명시적): 👍/👎, 별점, "도움됨" 버튼. 트레이스에 score로 부착.
# 프론트엔드에서 trace_id를 받아 피드백을 백엔드로
langfuse.create_score(
trace_id=trace_id,
name="user_feedback",
value=1, # 👍=1, 👎=0
data_type="BOOLEAN",
)
트레이스를 만들 때 trace_id를 응답에 실어 프론트로 내려야 나중에 피드백을 그 트레이스에 매칭할 수 있다.
-
암묵적 신호: 사용자가 답을 복사했나, 대화를 이어갔나, 재생성을 눌렀나, 이탈했나. 제품 이벤트를 score로 변환.
-
자동 평가 (online LLM-as-judge): 프로덕션 트레이스의 일부(샘플링)를 비동기로 judge에 보내 환각·관련성·독성을 채점. 모든 트레이스를 평가하면 비용이 2배가 되므로 **샘플링(예: 5~10%)**이 정석.
비동기 자동 평가 패턴 (사용자 레이턴시에 영향 없게):
import random
@observe()
def serve_request(question, context):
answer = answer_question(question, context)
trace_id = get_client().get_current_trace_id()
# 5% 샘플만 비동기 평가 큐로 (메인 응답 경로를 막지 않음)
if random.random() < 0.05:
eval_queue.enqueue(evaluate_async, trace_id, question, context, answer)
return answer
def evaluate_async(trace_id, question, context, answer):
verdict = judge_hallucination(question, context, answer) # judge LLM 호출
langfuse.create_score(
trace_id=trace_id,
name="hallucination",
value=0 if verdict == "hallucinated" else 1,
)
핵심 원칙 — 평가를 응답 경로(critical path)에 넣지 마라. judge 호출은 또 다른 LLM 호출이라 레이턴시와 비용을 더한다. 반드시 백그라운드/큐로 분리해서 사용자 응답을 지연시키지 않게 한다. Claude의 경우 비동기 batch 처리가 필요하면 Message Batches API(표준가의 50%)로 평가 비용을 절반으로 줄일 수 있다.
알림(alerting) 연결: 온라인 점수에 임계값을 걸어 대시보드 알림을 만든다 — "환각 비율이 10분 윈도우에서 15% 초과", "평균 사용자 피드백이 0.7 미만으로 하락." 이게 "프롬프트 한 줄 바꿨더니 품질이 무너졌다"를 배포 후 몇 분 안에 잡는 안전망이다.
대시보드/온라인 함정 — 분석 도메인 누락: 비용이나 이벤트를 외부 분석 도구(PostHog, 자체 대시보드)로도 보낸다면, 프론트엔드 CSP의 connect-src에 그 도메인이 빠져 있으면 키가 정상이어도 이벤트가 0건 들어온다. 트레이싱 백엔드와 별개로 흐르는 분석 파이프라인이 있으면 네트워크 레벨 차단을 의심하라.
10. RAG 파이프라인 전용 디버깅과 평가
RAG(검색 증강 생성)는 LLM 옵저버빌리티에서 가장 흔한 케이스이자 가장 미묘한 실패 지점이 많다. 답이 틀렸을 때 원인이 **검색(retrieval)**인지 **생성(generation)**인지를 가르는 게 핵심이다.
RAG 트레이스의 필수 스팬:
rag_query
├── embed_query (질문 임베딩)
├── vector_search (top-k 문서 + 유사도 점수)
├── rerank (선택: 리랭커)
├── build_prompt (문서를 프롬프트에 주입)
└── llm_generate (Claude 호출)
각 스팬에 무엇을 기록할지가 디버깅 가능성을 좌우한다:
vector_search: 검색된 문서 ID, 유사도 점수, k 값. 점수가 낮으면 검색 실패.build_prompt: 실제로 프롬프트에 들어간 문서(잘림 여부 포함). 토큰 한도로 잘렸나?llm_generate: 모델이 문서를 인용했나, 무시했나.
RAG 전용 평가 메트릭 (Phoenix·Ragas 등이 제공):
| 메트릭 | 측정 대상 | 실패 시 의미 |
|---|---|---|
| Context Relevance | 검색 문서가 질문과 관련 있나 | 검색기 문제 (임베딩/인덱스) |
| Faithfulness/Groundedness | 답이 문서에 근거하나 | 모델이 환각 |
| Answer Relevance | 답이 질문에 답하나 | 프롬프트/모델 문제 |
| Context Recall | 필요한 문서를 다 가져왔나 | k가 작거나 청킹 문제 |
이 사분면이 RAG 디버깅의 지도다. 답이 틀렸을 때:
- Context Relevance 낮음 → 검색기를 고쳐라 (임베딩 모델, 청킹 전략, k 조정).
- Context Relevance 높은데 Faithfulness 낮음 → 모델이 문서를 무시하고 지어냄. 프롬프트를 강화하거나(근거 인용 강제) 더 강한 모델로.
- 둘 다 높은데 Answer Relevance 낮음 → 질문 의도 파악 실패. 프롬프트 재설계.
임베딩 드리프트: Phoenix는 프로덕션 쿼리 임베딩을 UMAP으로 시각화해 클러스터를 보여준다. 학습/평가 데이터에 없던 새로운 질문 클러스터가 나타나면 — "사용자가 우리가 준비 안 한 걸 물어본다" — 검색 실패가 거기 몰려 있을 가능성이 높다.
Claude로 RAG 할 때 토큰·캐시 시너지: 검색 문서를 시스템 프롬프트의 안정적 위치(프리픽스)에 놓고 프롬프트 캐싱을 걸면, 같은 문서로 여러 질문을 받을 때 캐시 읽기로 비용이 ~90% 절감된다. 트레이스의 cache_read_input_tokens로 이게 실제로 먹히는지 검증하라. 단, 캐시는 프리픽스 정확 일치라 문서 순서를 매 요청 바꾸면 캐시가 안 먹는다 — vector_search 스팬에서 문서를 결정론적으로 정렬했는지 확인하라.
11. 프롬프트 버전 관리와 실험 추적
옵저버빌리티의 마지막 조각은 "무엇이 바뀌었나"를 추적하는 것이다. 프롬프트는 코드만큼 자주 바뀌는데 git에만 있으면 "어느 버전이 프로덕션에서 어떤 성능을 냈나"를 트레이스와 연결할 수 없다.
프롬프트를 1급 객체로 버전 관리: 대부분의 백엔드(Langfuse, LangSmith)가 프롬프트 레지스트리를 제공한다. 프롬프트를 코드에서 분리해 버전·라벨(production/staging)을 붙인다.
# 레지스트리에서 라벨로 프롬프트를 당겨옴
prompt = langfuse.get_prompt("refund-system", label="production")
compiled = prompt.compile(policy_doc=context) # 변수 주입
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=16000,
system=compiled,
messages=[{"role": "user", "content": question}],
)
# 이 트레이스에 prompt_version이 자동 링크됨 → 버전별 성능 비교 가능
langfuse.update_current_generation(prompt=prompt)
이제 트레이스마다 어느 프롬프트 버전이 쓰였는지 기록되고, UI에서 버전별 평균 점수·비용·레이턴시를 자른다. "v4로 올린 뒤 환각률이 12%→4%로 떨어졌다"를 데이터로 증명할 수 있다.
실험 추적 루프 (Plan → Work → Review → Compound):
- 가설: "근거 인용을 강제하면 환각이 준다."
- 새 프롬프트 버전(v5)을 staging 라벨로 등록.
- 골든 데이터셋으로 오프라인 평가 — v4 vs v5 점수/비용 비교.
- 좋으면 카나리(트래픽 5%)로 온라인 평가, 환각 score 모니터.
- 회귀 없으면 production 라벨을 v5로 승격. 실패 케이스는 데이터셋에 추가(다음 실험을 더 쉽게).
모델 마이그레이션도 같은 루프: 모델을 바꾸면(예: Sonnet→Opus, 또는 신모델로 업그레이드) 토큰 카운트·비용·품질이 다 달라진다. 같은 데이터셋으로 신/구 모델을 나란히 평가해 품질 향상 대 비용 증가를 정량 비교한 뒤 결정하라. 토큰 단가와 토크나이저가 모델마다 다르므로, 비용 대시보드의 모델별 단가표를 마이그레이션 전에 갱신해야 신모델 비용이 $0으로 누락되지 않는다.
함정 — 평가 데이터셋 오염: 프로덕션 트레이스를 데이터셋으로 큐레이션할 때 PII가 그대로 들어간다. 평가 데이터셋도 트레이스 스토어처럼 접근 통제·마스킹 대상이다. 또한 데이터셋이 한쪽으로 치우치면(쉬운 케이스만) 평가가 실제 성능을 과대평가한다 — 어려운 엣지케이스와 실패 케이스를 의도적으로 포함하라.
12. 프로덕션 운영: 샘플링·PII·알림·롤아웃 체크리스트
마지막으로 프로덕션에서 옵저버빌리티 스택을 운영할 때의 실전 결정들을 정리한다.
샘플링 전략 (트레이스 볼륨 vs 비용):
- 트레이싱 자체는 보통 100% 캡처(트레이스 저장은 LLM 호출보다 훨씬 싸다).
- 온라인 LLM-as-judge 평가만 샘플링(5~10%) — judge가 또 다른 LLM 비용이라 100% 평가하면 LLM 지출이 2배.
- 고가치 경로(결제, 환불 등 비가역 액션)는 평가 샘플링률을 높여라.
- 본문(프롬프트/응답) 캡처는 스토리지·PII 측면에서 별도 토글 — 디버깅엔 필요하지만 규제 도메인은 마스킹.
PII / 데이터 거버넌스:
- 계측 라이브러리의 본문 캡처를 끄거나(
TRACELOOP_TRACE_CONTENT=false등) 마스킹 훅을 건다. - self-host(Langfuse·Phoenix)는 데이터가 외부로 안 나간다 — 규제 도메인의 기본 선택지.
- 트레이스 보존 기간(retention)을 설정해 오래된 PII가 무한정 쌓이지 않게.
- 평가 데이터셋·프롬프트 레지스트리에도 같은 통제 적용.
알림 설계:
| 신호 | 임계 예시 | 의미 |
|---|---|---|
| 환각 score 비율 | 윈도우 내 15% 초과 | 프롬프트/검색 회귀 |
| 평균 사용자 피드백 | 0.7 미만 하락 | 품질 저하 |
| p95 레이턴시 | 기준 대비 2배 | 모델/인프라 문제 |
| 시간당 비용 | 예산 초과 | abuse 또는 루프 폭주 |
| 에러율 | refusal/timeout 급증 | 프로바이더·프롬프트 문제 |
롤아웃 순서 (점진적 채택):
- 트레이싱 먼저. 자동 계측으로 LLM 호출을 100% 캡처. 비용·레이턴시 가시성 확보.
- 비용 차원 추가. user_id·feature 태그로 지출 분해.
- 오프라인 평가 게이트. 골든 데이터셋으로 프롬프트 변경 회귀 테스트를 CI에 연결.
- 온라인 피드백. 👍/👎 + 암묵 신호 수집.
- 온라인 자동 평가. 샘플링된 트레이스에 judge 점수, 알림 연결.
- 프롬프트 레지스트리 + 실험 루프. 버전별 성능 비교, 카나리 롤아웃.
최종 점검 체크리스트:
- 모든 LLM 호출이 트레이스로 캡처되는가 (자동 계측 적용)
- 서버리스/비동기 환경에서 종료 전 flush() 호출하는가 (트레이스 유실 방지)
- usage가 4종 토큰(input/output/cache_creation/cache_read)을 모두 합산하는가
- 비용 대시보드의 모델 단가표에 사용 중인 모든 모델 ID가 있는가
- trace_id를 응답에 실어 사용자 피드백을 매칭할 수 있는가
- 멀티스텝 에이전트의 스팬 컨텍스트가 자식으로 전파되는가 (고아 스팬 없음)
- judge 평가가 응답 경로 밖(비동기)에서 도는가
- judge의 신뢰도를 사람 라벨로 검증했는가
- PII 본문 캡처가 도메인 정책에 맞게 설정됐는가
- 품질·비용·레이턴시 알림이 걸려 있는가
옵저버빌리티는 한 번 깔고 끝나는 게 아니라 운영하면서 데이터셋과 알림이 누적되는 복리(compound) 자산이다. 실패 케이스 하나하나가 데이터셋에 들어가 다음 회귀를 막고, 트레이스 하나하나가 다음 디버깅을 빠르게 만든다. 처음엔 트레이싱만으로 시작하고, 평가와 비용을 그 위에 점진적으로 쌓아라.