LLM 애플리케이션을 프로덕션에 올리면, 전통적인 웹 관측성 도구만으로는 설명되지 않는 공백이 생깁니다. HTTP 상태 코드는 200인데 사용자는 “답이 틀렸다”고 하고, 지연은 평균적으로 괜찮은데 특정 요청만 30초씩 걸리며, 월말 청구서는 예측의 두 배입니다. 공통 원인은 하나입니다. 토큰·지연·비용이라는 LLM 고유의 신호를 요청 단위로 추적하지 않는다는 것입니다.

이 글에서는 LLM 관측성을 트레이싱 관점에서 다룹니다. RAG나 에이전트처럼 한 요청이 여러 단계(검색, 프롬프트 조립, 모델 호출)로 쪼개지는 구조에서, 각 단계의 토큰·지연·비용을 스팬(span)으로 묶어 하나의 트레이스로 관측하는 방법입니다. 예시는 OpenTelemetry의 GenAI 시맨틱 규약과 Anthropic Claude API 기준이지만, 스팬 속성 이름만 바꾸면 다른 프로바이더에도 대체로 적용됩니다.

무엇을 측정해야 하는가: 세 축과 파생 지표

LLM 관측성의 원자 신호는 세 가지입니다. 토큰(입력·출력·캐시 읽기·캐시 쓰기), 지연(첫 토큰까지 시간과 전체 시간), 비용입니다. 이 세 축을 요청마다 정확히 기록하면 나머지 운영 지표는 대부분 파생됩니다.

특히 지연은 단일 숫자로 뭉뚱그리면 안 됩니다. 스트리밍에서 체감을 좌우하는 것은 전체 시간이 아니라 첫 토큰까지의 시간(TTFT)이기 때문입니다. 다음 지표를 요청마다 스팬에 남기는 것이 출발점입니다.

  • TTFT: 요청 전송부터 첫 토큰 도착까지. 스트리밍 UX의 핵심 지표.
  • TPOT(time per output token): 토큰 하나당 평균 생성 시간. 모델·부하 상태를 반영.
  • 캐시 히트율: cache_read / (cache_read + input). 프롬프트 캐시 효과 검증.
  • 요청당 비용: 토큰 종류별 단가를 각각 곱해야 정확.

트레이스·스팬 모델: 한 요청을 어떻게 쪼갤 것인가

RAG 요청 하나는 여러 작업의 연쇄입니다. 통째로 재면 느린 원인이 검색인지 모델인지 알 수 없습니다. 트레이싱의 핵심은 요청을 루트 스팬 하나에 자식 스팬 여럿으로 분해하는 것입니다.

# 한 RAG 요청의 스팬 트리 구조 (개념)
# rag.request               (루트 스팬: 전체 벽시계 시간)
# ├─ retrieval.embed        (질문 임베딩)
# ├─ retrieval.search       (벡터 저장소 조회)
# ├─ prompt.assemble        (검색 문서 + 시스템 프롬프트 조립)
# └─ llm.generate           (모델 호출: 토큰·비용은 여기에 기록)
#    └─ (스트리밍이면 TTFT 를 이벤트로 마킹)

지켜야 할 규칙은 토큰과 비용은 모델 호출 스팬에만 기록하는 것입니다. 한 요청이 모델을 두 번 호출하면 스팬도 둘이 되고, 루트 비용은 두 스팬의 합입니다. 아무 스팬에나 흩뿌리면 집계 시 이중 계산이 발생합니다.

OpenTelemetry로 LLM 스팬 계측하기

바퀴를 다시 발명할 필요는 없습니다. OpenTelemetry에는 GenAI용 시맨틱 규약이 있어 gen_ai.* 네임스페이스의 표준 속성 이름을 쓰면 백엔드가 별도 매핑 없이 토큰·모델 필드를 인식합니다.

from opentelemetry import trace
from opentelemetry.trace import SpanKind

tracer = trace.get_tracer("moment-note.llm")

def traced_generate(system: str, messages: list, model: str):
    # CLIENT 스팬을 열고 표준 gen_ai 속성을 채운다
    with tracer.start_as_current_span(
        "llm.generate", kind=SpanKind.CLIENT
    ) as span:
        span.set_attribute("gen_ai.system", "anthropic")
        span.set_attribute("gen_ai.request.model", model)
        span.set_attribute("gen_ai.request.max_tokens", 1024)

        resp = client.messages.create(
            model=model, max_tokens=1024,
            system=system, messages=messages,
        )
        u = resp.usage
        # 토큰 4종류를 분리 기록 (단가가 다름)
        span.set_attribute("gen_ai.usage.input_tokens", u.input_tokens)
        span.set_attribute("gen_ai.usage.output_tokens", u.output_tokens)
        span.set_attribute("gen_ai.usage.cache_read_tokens",
                           getattr(u, "cache_read_input_tokens", 0))
        span.set_attribute("gen_ai.usage.cache_write_tokens",
                           getattr(u, "cache_creation_input_tokens", 0))
        # 응답 모델과 종료 사유
        span.set_attribute("gen_ai.response.model", resp.model)
        span.set_attribute("gen_ai.response.finish_reason", resp.stop_reason)
        return resp

중요한 것은 프롬프트·응답 본문을 스팬 속성에 넣지 않는 것입니다. 본문은 크고, PII나 비밀 값이 섞일 수 있으며, 백엔드의 속성 크기 제한을 넘기기 쉽습니다. 본문이 필요하면 접근 통제된 별도 로그로 보내고, 트레이스에는 토큰 수·해시·프롬프트 버전 같은 메타데이터만 남깁니다.

스트리밍에서 TTFT를 정확히 재기

스트리밍에서 TTFT는 요청을 보낸 순간부터 첫 콘텐츠 토큰이 도착한 순간까지입니다. 흔한 실수는 스트림의 시작 이벤트(메타데이터만 담긴 첫 이벤트)를 첫 토큰으로 착각하는 것입니다. 실제 텍스트 델타가 처음 나올 때가 기준입니다.

import time

def stream_with_ttft(system, messages, model, span):
    t0 = time.perf_counter()
    ttft = None
    out_tokens = 0

    with client.messages.stream(
        model=model, max_tokens=1024,
        system=system, messages=messages,
    ) as stream:
        for event in stream:
            # 텍스트 델타가 처음 나온 순간만 TTFT 로 인정
            if event.type == "content_block_delta" and ttft is None:
                ttft = time.perf_counter() - t0
                span.add_event("first_token")  # 타임라인 마커
            if event.type == "content_block_delta":
                out_tokens += 1  # 근사치; 정확한 값은 최종 usage

    total = time.perf_counter() - t0
    span.set_attribute("gen_ai.latency.ttft_ms", round(ttft * 1000, 1))
    span.set_attribute("gen_ai.latency.total_ms", round(total * 1000, 1))
    # TPOT: 첫 토큰 이후 구간 / 출력 토큰 수
    if out_tokens > 1:
        tpot = (total - ttft) / (out_tokens - 1)
        span.set_attribute("gen_ai.latency.tpot_ms", round(tpot * 1000, 2))

위에서 out_tokens를 델타 개수로 근사했지만, 청구·집계에 쓰는 정확한 토큰 수는 스트림 종료 시의 최종 usage에서 가져와야 합니다. 델타 개수는 TPOT 같은 상대 지표에만 씁니다.

비용을 스팬에서 파생시키기

비용은 파생값입니다. 토큰 수에 종류별 단가를 곱하는데, 핵심은 네 종류(입력·출력·캐시 읽기·캐시 쓰기)를 각각 다른 단가로 계산하는 것입니다. 캐시 읽기는 입력의 약 0.1배, 쓰기는 약 1.25배라, 뭉뚱그리면 추정이 크게 어긋납니다.

# 100만 토큰당 달러 단가 (모델·버전마다 다르므로 설정으로 분리)
PRICING = {
    "claude-opus-4-8": {
        "input":       15.00,
        "output":      75.00,
        "cache_read":   1.50,   # 입력의 약 0.1배
        "cache_write": 18.75,   # 입력의 약 1.25배 (5분 TTL)
    },
}

def compute_cost_usd(model: str, usage) -> float:
    p = PRICING[model]
    per_m = 1_000_000
    cost = (
        usage["input_tokens"]       * p["input"]
        + usage["output_tokens"]      * p["output"]
        + usage["cache_read_tokens"]  * p["cache_read"]
        + usage["cache_write_tokens"] * p["cache_write"]
    ) / per_m
    return round(cost, 6)

# 스팬에 파생 비용 기록 (통화 단위 명시)
span.set_attribute("gen_ai.usage.cost_usd", compute_cost_usd(model, usage))

단가를 하드코딩하지 않는 이유는, 단가가 바뀌면 과거 트레이스까지 소급 재계산해야 할 때가 있기 때문입니다. 트레이스에 토큰 원값을 남기고 비용을 파생 필드로 두면, 단가만 갱신하면 됩니다.

대시보드 쿼리: 무엇을 어떻게 볼 것인가

스팬을 컬럼형 저장소로 내보냈다면, 대시보드는 몇 가지 핵심 쿼리로 구성됩니다. 평균은 거의 항상 거짓말을 하므로 백분위수(p50/p95/p99)로 봐야 합니다. LLM 지연 분포는 꼬리가 길어서, 평균은 소수의 느린 요청에 끌려다니거나 그것들을 숨기기 때문입니다.

-- 모델·시간대별 TTFT 백분위수와 비용 (5분 버킷)
SELECT
  date_trunc('minute', start_time) AS ts,
  response_model,
  count(*)                                          AS requests,
  approx_percentile(ttft_ms, 0.50)                  AS ttft_p50,
  approx_percentile(ttft_ms, 0.95)                  AS ttft_p95,
  approx_percentile(ttft_ms, 0.99)                  AS ttft_p99,
  sum(cost_usd)                                     AS cost_usd,
  -- 캐시 히트율: 캐시로 읽은 입력 / 전체 입력
  sum(cache_read_tokens) * 1.0
    / nullif(sum(cache_read_tokens + input_tokens), 0) AS cache_hit_rate
FROM llm_spans
WHERE span_name = 'llm.generate'
  AND start_time > now() - interval '6 hours'
GROUP BY 1, 2
ORDER BY 1 DESC;

비용 급증을 조기에 잡으려면 어느 경로가 비용을 끌어올리는지를 봐야 합니다. 기능 태그(예: feature=summarize)를 붙이고 GROUP BY feature로 총비용을 쪼개면, “비용 증가의 70%가 요약에서 왔다”는 원인 분석과 함께 avg(output_tokens)로 출력 폭주까지 잡을 수 있습니다.

이상 징후에 알람 걸기

대시보드가 사후 도구라면, 실시간 방어는 알람이 담당합니다. 특히 값진 알람은 비용 소진율(burn rate)과 캐시 히트율 급락입니다. 후자는 조용한 캐시 무효화 신호라 비용과 지연을 동시에 악화시킵니다.

# Prometheus 스타일 알람 규칙 (스팬 지표를 메트릭으로 내보낸 경우)
groups:
  - name: llm-observability
    rules:
      # 시간당 비용이 예산 상한을 넘어서는 속도로 소진되는가
      - alert: LLMCostBurnRateHigh
        expr: sum(rate(llm_cost_usd_total[1h])) * 24 > 200
        for: 15m
        labels: { severity: warning }

      # 캐시 히트율 급락 — 프리픽스 무효화 의심 (비용·지연 동시 악화)
      - alert: LLMCacheHitRateDrop
        expr: |
          sum(rate(llm_cache_read_tokens_total[15m]))
          / sum(rate(llm_input_tokens_total[15m])) < 0.3
        for: 20m
        labels: { severity: critical }

알람 임계치는 절대값보다 기준선 대비 상대 회귀로 잡는 편이 오탐이 적습니다. TTFT를 “500ms 초과”가 아니라 “하루 기준선의 2배”로 두면, 트래픽·모델이 바뀌어도 규칙이 스스로 기준을 따라갑니다.

샘플링과 카디널리티: 관측성 비용을 관리하기

관측성 자체도 비용입니다. 초당 수천 요청을 전량 저장하면 수집 비용이 LLM 비용에 필적할 수 있습니다. 그래서 테일 샘플링(tail sampling)을 씁니다. 요청이 끝난 뒤 결과를 보고, 실패·고비용·고지연은 100% 남기고 정상 트레이스만 낮은 비율로 샘플링합니다.

# OpenTelemetry Collector: 흥미로운 트레이스만 보존
processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      # 에러 트레이스는 무조건 보존
      - name: keep-errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      # 느린 요청(3초 초과)도 보존
      - name: keep-slow
        type: latency
        latency: { threshold_ms: 3000 }
      # 나머지 정상 트레이스는 10% 만
      - name: sample-rest
        type: probabilistic
        probabilistic: { sampling_percentage: 10 }

또 하나의 함정은 속성 카디널리티입니다. 사용자 ID나 요청 UUID로 메트릭을 집계하면 시계열 수가 폭발해 백엔드가 무너집니다. 고카디널리티 값은 트레이스 속성으로만 두고, 메트릭 라벨에는 모델·기능·상태처럼 카디널리티가 낮은 값만 씁니다.

마무리

LLM 관측성은 결국 세 원자 신호를 요청 단위로 정확히 붙잡는 일입니다. 토큰은 네 종류를 분리해 모델 스팬에만 기록하고, 지연은 TTFT와 전체 시간을 나눠 보며, 비용은 토큰 원값에 단가를 곱해 파생시킵니다. 대시보드는 백분위수로 보고, 알람은 기준선 대비 회귀로 걸며, 관측성 비용은 테일 샘플링과 카디널리티 통제로 관리합니다. 화려한 도구보다 중요한 것은 무엇을 어느 스팬에 기록할지에 대한 규율이며, 그 규율이 서면 나머지 지표와 대시보드는 자연스럽게 파생됩니다.