LLM 트레이싱 트러블슈팅

LLM 트레이싱 트러블슈팅 (LLM Tracing Troubleshooting)

confident-trace로 트레이싱할 때 흔히 겪는 문제들과 그 해결책을 모아 둔 페이지예요. 대부분의 문제는 프로세스 생명주기, 트레이싱 컨텍스트가 앱을 통해 전파되는 방식, 또는 SDK가 호출하는 라이브러리에 후킹되는 방식 때문에 생겨요. 아래 증상들 중 해당하는 것이 있다면 관련 섹션부터 차례로 확인해 보세요.

출처: 문서

본문

개요

이 페이지는 confident-trace의 가장 흔한 트레이싱 문제를 다뤄요. 대부분 프로세스 생명주기, 트레이싱 컨텍스트가 앱을 통해 전파되는 방식, SDK가 호출하는 라이브러리에 후킹되는 방식과 관련돼 있어요. 다음 중 하나를 겪고 있다면 아래 관련 섹션을 확인하세요.

  • 앱 실행 후 Confident AI 대시보드에 트레이스가 전혀 나타나지 않음.
  • 서버리스 함수나 수명이 짧은 스크립트에서 트레이스가 잘림 — 처음 몇 개는 나타나지만 나머지는 절대 나타나지 않음.
  • 부모 트레이스 아래에 스팬이 중첩되는 대신 예상 밖의 새 트레이스가 나타남.
  • 잘못된 대상에 트레이스 속성이 설정됨 — span을 갱신하려고 update_trace를 호출한 경우(또는 그 반대).
  • 스팬의 input/output 누락 또는 잘림.
  • 트레이스에 같은 LLM 호출이 두 번 표시됨.
  • @span을 다른 트레이싱 데코레이터와 함께 쌓는 경우 — 안전한지, 어떤 순서로 써야 하는지 확신이 없을 때.

트레이스가 나타나지 않을 때

Confident AI는 트레이스에 배치 ingest를 사용하므로, 전송 후 트레이스가 대시보드에 나타나는 데 최대 30초가 걸리는 게 정상이에요. 그 시간이 지나도 트레이스가 나타나지 않으면 이 목록을 따라가 보세요. 99%는 처음 두 가지 중 하나예요.

프로세스가 큐가 비워지기 전에 종료된 경우

confident-trace는 백그라운드 워커에서 스팬을 배치로 내보내요. 프로그램이 그 워커가 전송할 기회를 갖기 전에 반환하면 트레이스의 끝부분이 유실돼요. 수명이 짧은 스크립트라면 사실상 전부 유실되는 경우가 많죠. 프로세스가 종료되기 전에 shutdown()을 호출하세요(오래 실행되는 프로세스에서는 flush()).

Python

from confident_trace import init, shutdown

init()

try:
    llm_app("Write me a poem.")
finally:
    shutdown()  # flushes the queue, then tears down the exporter

TypeScript

import { init } from "confident-trace";

const runtime = init();

try {
  await llmApp("Write me a poem.");
} finally {
  await runtime.shutdown(); // flushes the queue, then tears down the exporter
}

전체 그림은 flush and shutdown을, Lambda 등을 쓰고 있다면 아래의 서버리스 섹션을 확인하세요.

preload가 빠져 있는 경우

Python

Python에서는 필요 없어요. init()이 설치된 모든 지원 패키지를 즉석에서 계측하므로 preload 단계가 없어요. init()이 먼저 실행되지 않는 경우로 건너뛰세요.

TypeScript

init()은 내보내기(export)를 설정하지만, *계측(instrumentation)*은 Node가 패키지를 로드할 때 일어나요. Node가 내 코드보다 먼저 패키지를 로드하죠. 그래서 SDK는 시작 지점을 실행하는 명령에 confident-trace/register preload가 필요해요.

# ❌ Broken — init() runs, but nothing is instrumented
node --import tsx src/index.ts

# ✅ Fixed — the preload hooks packages as Node loads them
node --import tsx --import confident-trace/register src/index.ts

# Compiled JavaScript — drop --import tsx and point at your build output
node --import confident-trace/register dist/index.js

src/index.ts를 실제 시작 지점 경로로 바꾸세요. preload 없이 init()을 호출하면 설정 경고가 표시되고 스팬이 없어요. init() 없이 preload만 추가하면 스팬은 생기지만 아무것도 내보내지지 않아요. 둘 다 필요해요 — quickstart를 참고하세요.

preload가 등록됐는지 확신이 안 되나요? init() 후에 runtime.getInstrumentationStatus()를 호출해 보세요 — 아래 진단을 참고하세요.

init()이 먼저 실행되지 않는 경우

init()은 트레이싱하려는 SDK와 프레임워크가 첫 호출을 하기 전에 실행되어야 해요. init() 이전에 만들어진 클라이언트는 이미 패치되지 않은 참조를 들고 있을 수 있어요. OpenAI 클라이언트, LangChain 그래프 등을 만들기 전에 시작 지점 맨 위에서 호출하세요.

설정이 잘못되었거나 트레이싱이 비활성화된 경우

다음을 확인하세요.

  • CONFIDENT_API_KEY가 설정되어 있는지(또는 init(api_key=...) / init({ apiKey })로 전달했는지).
  • 올바른 엔드포인트를 가리키는지. 기본값은 US 리전이에요. EU와 self-hosted 사용자는 CONFIDENT_OTEL_ENDPOINT를 설정해야 해요. 그렇지 않으면 트레이스가 잘못된 배포로 가서 인증에 실패해요. configure init()을 참고하세요.
  • 이 환경에서 OTEL_SDK_DISABLED가 true로 설정돼 있지 않은지(잊기 쉬운 편리한 CI 스위치예요).

다른 OpenTelemetry 프로바이더가 이미 등록된 경우

앱이 이미 전역 TracerProvider를 소유하고 있다면(예: 다른 관찰성 벤더의 SDK를 통해), confident-trace는 그 전역을 놓고 경쟁하는 대신 그 프로바이더에 플러그인되어야 해요.

Python

init(tracer_provider=provider)로 내 프로바이더를 전달해 Confident AI의 내보내기 파이프라인이 거기에 추가되게 해요. init()의 다른 모든 것은 평소처럼 동작해요.

TypeScript

init()은 전역을 놓고 싸우는 대신 비활성 runtime을 반환해요. init()을 호출하지 말고, 이미 있는 프로바이더에 createSpanProcessor()로 Confident AI의 span processor를 추가하세요.

전체 설정은 existing OpenTelemetry provider를 참고하세요.

gRPC로 내보내는 경우

Confident AI의 OTEL server는 OTLP를 HTTP에서만 받아요. 여러 OpenTelemetry SDK와 Collector는 기본적으로 gRPC를 쓰므로, (confident-trace의 init()이 아니라) 내가 직접 exporter를 구성했는데 연결 리셋, UNAVAILABLE / UNIMPLEMENTED 상태 코드, 또는 HTTP 415 에러가 보인다면 거의 확실히 잘못된 프로토콜을 쓰고 있는 거예요.

런타임에 맞는 HTTP/protobuf exporter로 전환하세요.

런타임 사용 사용 금지
Python opentelemetry-exporter-otlp-proto-http → OTLPSpanExporter opentelemetry-exporter-otlp-proto-grpc
TypeScript @opentelemetry/exporter-trace-otlp-proto (또는 -http) → OTLPTraceExporter @opentelemetry/exporter-trace-otlp-grpc
Go go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp otlptracegrpc
Collector otlphttp exporter otlp exporter (gRPC)
Env vars OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf grpc (일부 SDK에서 설정 안 하면의 기본값)

또한 OTEL_EXPORTER_OTLP_ENDPOINT에 :4317 같은 포트 접미사가 없는지 확인하세요. 그건 gRPC 관례이고, OTEL server는 표준 HTTPS로 듣고 있어요.

언어별 전체 exporter 설정은 manual instrumentation 페이지에 있어요.

수명이 짧은 프로세스와 서버리스 함수

서버리스 런타임(AWS Lambda, Google Cloud Functions, Vercel 등)은 핸들러가 반환하는 즉시 환경을 동결하거나 해체해요. 스팬이 비동기로 내보내지기 때문에, LLM 호출 직후 즉시 반환하는 핸들러는 종종 트레이스를 잃어요 — 백그라운드 워커가 스케줄링될 기회조차 없거든요.

해결책은 모든 스트림이 끝난 뒤, 핸들러 끝에서 flush()를 호출해 환경이 동결되기 전에 큐를 비우는 거예요.

Python

from confident_trace import init, flush

init()  # once, at module load — not inside the handler

def handler(event, context):
    result = llm_app(event["query"])
    # ✅ drain the queue before the runtime freezes
    flush(timeout_millis=30000)
    return result

TypeScript

import { init } from "confident-trace";

const runtime = init(); // once, at module load — not inside the handler

export const handler = async (event: { query: string }) => {
  const result = await llmApp(event.query);
  // ✅ drain the queue before the runtime freezes
  await runtime.flush(30000);
  return result;
};

호출(invocation)마다 flush()를 쓰고, 프로세스가 진짜 종료될 때만 shutdown()을 쓰세요. 핸들러에서 shutdown()을 호출하면 exporter가 해체되어, 다음 웜 호출(warm invocation)에서는 내보낼 것이 없어요.

flush()가 성공했다는 건 로컬 큐가 비워졌다는 뜻이지, Confident AI가 ingest를 끝냈다는 뜻은 아니에요. 전송 후에도 트레이스가 Observatory에 나타나는 데 최대 30초가 걸릴 수 있어요. 그러니 반환된 flush()를 "아직 대시보드에 보인다"로 오해하지 마세요.

스레드에서 부모 스팬 누락

부모 아래에 중첩되어야 할 스팬들이 별개의 최상위 트레이스로 나타난다면, 트레이싱 컨텍스트가 그 스팬들을 만드는 코드까지 도달하지 못하고 있는 거예요. 어디서 그런지는 런타임에 따라 달라져요.

Python

concurrent.futures.ThreadPoolExecutor는 호출 스레드의 ContextVar 값을 상속하지 않는 새 스레드를 만들어요. OpenTelemetry(따라서 confident-trace)는 ContextVar에 의존해 활성 스팬을 추적하므로, @span 데코레이터를 붙인 함수를 executor에 직접 제출하면 부모 아래에 중첩되는 대신 별개의 고아 트레이스를 만들어요.

해결책은 contextvars.copy_context()로 호출자의 컨텍스트를 스냅샷하고, 작업 제출 시 ctx.run을 쓰는 거예요.

from concurrent.futures import ThreadPoolExecutor
from contextvars import copy_context
from confident_trace import span

@span(type="tool")
def child_task(item):
    ...

# ❌ Broken — child_task creates a separate trace
@span(type="agent")
def parent(items):
    with ThreadPoolExecutor() as executor:
        futures = [executor.submit(child_task, item) for item in items]

# ✅ Fixed — child_task nests under parent
@span(type="agent")
def parent(items):
    ctx = copy_context()
    with ThreadPoolExecutor() as executor:
        futures = [executor.submit(ctx.run, child_task, item) for item in items]

copy_context()는 @span 데코레이터를 붙인 부모 함수 안에서 호출해야 활성 트레이싱 컨텍스트를 포착해요. 각 executor.submit() 배치 앞에 호출하세요 — 스냅샷은 특정 시점(point-in-time)의 것이므로, 배치 사이에 부모 컨텍스트가 바뀌면 이전 스냅샷은 낡은 게 돼요.

asyncio의 loop.run_in_executor()도 내부적으로 스레드 풀에 위임하므로 같은 상황이 적용돼요.

import asyncio
from contextvars import copy_context
from confident_trace import span

@span(type="tool")
def child_task(item):
    ...

# ❌ Broken — child_task creates a separate trace
@span(type="agent")
async def parent(item):
    loop = asyncio.get_event_loop()
    await loop.run_in_executor(None, child_task, item)

# ✅ Fixed — child_task nests under parent
@span(type="agent")
async def parent(item):
    loop = asyncio.get_event_loop()
    ctx = copy_context()
    await loop.run_in_executor(None, ctx.run, child_task, item)

멀티프로세싱은 다릅니다. multiprocessing.Process와 ProcessPoolExecutor는 메모리를 공유하지 않는 별개의 OS 프로세스를 만들므로 copy_context()는 그 경계를 넘어 트레이싱 컨텍스트를 운반할 수 없어요. 자식 프로세스에서 만들어진 스팬은 항상 독립적인 최상위 트레이스가 돼요. 중첩이 필요하다면 위의 수정 방식과 함께 ThreadPoolExecutor로 전환하세요.

TypeScript

Node의 async 컨텍스트는 await를 자동으로 따라가므로 스레드 풀 고아화는 여기서 문제가 되지 않아요. 공백은 프레임워크 콜백에 있어요: 통합(LangChain 콜백, Vercel AI SDK 훅 등)은 프레임워크가 보고하는 부모/자식 계층을 보존하지만, 콜백 안에서 실행하는 임의의 애플리케이션 코드 주위에 model이나 tool 스팬을 활성 상태로 남겨두지 않을 수 있어요. 콜백 안의 자체 withSpan 블록이 별개 트레이스로 나타난다면, 요청 전체를 애플리케이션 소유의 부모로 감싸세요 — 시작 지점의 withSpan({ name: "request", type: "agent" }, ...)처럼요. 그러면 그 아래 모든 것이 중첩될 대상이 생겨요.

데코레이터 없는 부모 함수

파이프라인을 시작하는 가장 바깥쪽 함수가 span으로 감싸져 있지 않으면, 자식 스팬이 중첩될 부모 트레이스가 없어요. 그래서 @span 데코레이터를 붙인 각 함수(그리고 계측된 각 LLM 호출)는 제각각 독립 트레이스가 돼요.

from confident_trace import span

@span(type="retriever")
def retrieve(query):
    ...

@span(type="llm", model="gpt-4o")
def generate(query, context):
    ...

# ❌ Broken — retrieve and generate each create separate traces
def handle_request(query):
    context = retrieve(query)
    return generate(query, context)

# ✅ Fixed — both nest under handle_request
@span(type="agent")
def handle_request(query):
    context = retrieve(query)
    return generate(query, context)

Flask 라우트 핸들러, FastAPI 엔드포인트, 태스크 큐 워커 같은 시작 지점에서 놓치기 쉬워요 — 파이프라인을 시작하는 최상위 함수가 span을 가진 함수가 되게 하세요. 자동 계측은 LLM 호출을 잡아내지만, 요청이 어디서 시작되는지는 알 방법이 없어요.

update_trace vs update_span

update_trace()는 어디서 호출하든 trace — 가장 바깥쪽 스팬, 즉 요청 전체 — 를 갱신해요. update_span()은 현재 활성 스팬, 즉 지금 내가 있는 컴포넌트를 갱신해요. 이 둘을 섞어 쓰는 게 "span output이 비어 있다" 또는 "trace 이름이 계속 덮어써진다"의 가장 흔한 원인이에요.

from confident_trace import span, update_span, update_trace

# ❌ Broken — sets the *trace* output from inside a child span,
#    so the trace shows the tool result and the span shows nothing
@span(type="tool")
def lookup_order(order_id):
    result = db.get(order_id)
    update_trace(output=result)
    return result

# ✅ Fixed — each helper writes to the level it's named after
@span(type="tool")
def lookup_order(order_id):
    result = db.get(order_id)
    update_span(input=order_id, output=result)
    return result

@span(type="agent")
def handle_message(query):
    result = lookup_order(query)
    res = generate(query, result)
    update_trace(
        input=query,
        output=res,
        tags=["production"],
        thread_id="your-thread-id",
    )
    return res

경험칙으로는:

둘 다 여러 번 호출할 수 있고, 값은 병합되며 나중 호출이 이전 호출을 덮어써요.

세 번째 헬퍼인 trace_context / traceContext도 있어요. 이건 스팬을 만들지 않고, 그 범위 안에서 시작되는 트레이스에 대해 트레이스 수준 기본값을 설정해요. 그래서 완전히 자동 계측된 호출에 tags, thread ID, user identity, customer identity를 붙일 수 있어요. 스팬 없이 트레이스 속성 설정하기를 참고하세요.

두 갱신 헬퍼 모두 쓸 활성 스팬이 필요해요 — 어떤 span 밖(또는 스팬이 끝난 뒤)에서는 조용히 아무것도 하지 않아요. 그래서 tags나 output이 안 나타나면, 먼저 호출이 스팬 본문 안에 있는지 확인하세요. update_trace를 호출할 스팬이 없다면 호출 주위를 trace_context / traceContext로 감싸세요. 스팬 없이 트레이스 속성 설정하기를 참고하세요.

콘텐츠 누락 또는 잘림

스팬은 존재하는데 input/output이 비어 있거나, [REDACTED]이거나, 잘려 있다면 SDK의 콘텐츠 제어 중 하나가 작동하고 있는 거예요.

  • 캡처가 꺼져 있음. init(capture_content=False) / init({ captureContent: false })는 구조와 타이밍만 기록하고 메시지 본문은 기록하지 않아요.
  • 레드랙터가 다시 쓰거나 버렸음. 커스텀 redact 함수가 내보내기 전에 실행되는데, 레드랙트된 결과가 유효한 메시지 형태가 아니면 SDK는 잘못된 것을 보내는 대신 그것을 생략해요.
  • 구성된 크기 상한을 초과했음. 크기 제한은 기본적으로 비활성화돼 있지만, 설정한 max_content_bytes 상한을 넘는 콘텐츠는 잘려요.

이 제어들은 SDK가 관리하는 콘텐츠에 적용돼요. 프레임워크 통합의 경우 무엇이 캡처되는지는 해당 프레임워크 자체 설정이 결정하고, 이진 페이로드(이미지, 오디오)는 생략돼요. 각각을 어떻게 조정하는지는 masking을 참고하세요.

스트리밍 응답에서 output 누락

span으로 감싼 함수가 응답을 yield하면(예: FastAPI StreamingResponse), 반환값은 generator예요 — 최종 조합된 텍스트가 아니죠. 그래서 SDK가 output으로 기록할 게 없어요. 청크를 모아 output을 명시적으로 설정하세요.

from fastapi.responses import StreamingResponse
from confident_trace import span, update_trace

@span(type="agent")
def generate_stream(query):
    chunks = []
    for chunk in llm.stream(query):
        chunks.append(chunk)
        yield chunk
    update_trace(input=query, output="".join(chunks))

@app.post("/chat")
async def chat(query: str):
    return StreamingResponse(generate_stream(query))

이렇게 하지 않으면 트레이스가 output 없이 Confident AI에 나타나요.

중복 스팬

모든 LLM 호출이 트레이스에 두 번 표시된다면, 같은 클라이언트나 프로바이더에 두 개의 instrumentor가 붙어 있는 거예요. 보통 confident-trace의 자동 계측을 같은 라이브러리용 수동 어댑터(또는 다른 벤더의 instrumentor)와 함께 쓸 때 생겨요.

Python

기존 OpenTelemetry instrumentor가 이미 필요한 스팬을 만들어 준다면, init(instrumentations=())로 confident-trace가 두 번째 계측을 하는 대신 그것들만 내보내게 하세요.

TypeScript

자동 모드에서는 runtime이 자체 instrumentor와 프레임워크 콜백을 소유하게 두세요. 어댑터를 직접 연결하고 싶다면 init({ instrumentations: [] })을 호출해 runtime이 자체 것을 추가하지 않게 하고, shutdown 때 분리할 수 있도록 어댑터가 반환하는 정리 함수를 보관하세요.

다른 트레이싱 데코레이터와 @span 쌓기

코드가 이미 다른 관찰성 시스템의 트레이싱 데코레이터를 쓰고 있다면 그대로 둘 수 있어요. @span은 평범한 데코레이터이며(내부적으로 functools.wraps 사용) 다른 어떤 데코레이터 위에도 충돌 없이 쌓여요. 예: MLflow의 @mlflow.trace나 OpenTelemetry의 @tracer.start_as_current_span.

순서는 정확성을 바꾸지 않지만, 어떤 래퍼가 함수에 가장 가까이 놓일지를 결정해요. 기본적으로 @span을 가장 안쪽에 두어 기존 대시보드가 바깥 호출을 루트로 계속 보여주게 하세요.

from confident_trace import span

@tracer.trace      # your existing tracing decorator stays on top
@span(type="agent")
def run_my_ai_app(query: str) -> str:
    ...

다른 트레이서가 OpenTelemetry 기반인지에 따라 보이는 결과가 달라져요.

  • 비-OTel 트레이서(MLflow, LangSmith 등)는 자체 백엔드로 내보내고, @span은 Confident AI로 내보내요. span/trace ID를 공유하지 않으므로 같은 함수가 두 플랫폼에 모두 기록되는 건 정상이에요 — 중복 버그가 아니에요.
  • OpenTelemetry 트레이서 — confident-trace 자체가 OpenTelemetry이므로, OTel 스팬 안에 중첩된 @span은 별개 트레이스를 시작하는 대신 같은 trace에 합류해요. 보통 원하는 동작이지만, 둘이 독립적이지 않다는 뜻이에요. 앱이 이미 OTel TracerProvider를 소유하고 있다면 init()을 단독 호출하기보다 confident-trace를 거기에 연결하세요(existing OpenTelemetry provider 참고). 그렇지 않으면 전역을 두고 경쟁하는 프로바이더가 두 개 생겨요.

프로젝트가 LLM 프로바이더에 대해 다른 플랫폼의 자동 계측 기능(예: MLflow autolog())도 쓴다면, LLM 호출이 그 플랫폼에 추가 autologged span으로 나타나요. 이건 무해하고 Confident AI 트레이스에 영향을 주지 않아요 — 단, 그 자동 계측이 OpenTelemetry instrumentor인 경우는 제외예요. 그 경우에는 중복 스팬을 참고하세요.

진단 (Diagnostics)

위의 어떤 것으로도 설명이 안 되면, SDK가 무엇을 후킹했는지(또는 못 했는지) 말해 줄 수 있어요.

계측 상태 확인

Python

Python에서는 필요 없어요 — 검증할 preload 단계가 없어요. 아래의 진단 로거를 켜면 init()이 무엇을 후킹했고 못 했는지 볼 수 있어요.

TypeScript

시작 지점 파일의 init() 뒤에서 runtime에게 preload가 등록됐는지, 어떤 통합이 붙었는지 물어보세요.

import { init } from "confident-trace";

const runtime = init();
console.log(runtime.getInstrumentationStatus());

목록에 없는 라이브러리는 로그를 남긴 시점에 아직 import되지 않았을 수 있어요 — 앱이 준비된 뒤에 다시 확인하세요. 지원되지 않는 버전과 첨부 실패는 경고로 표시돼요.

워커 스레드는 기본적으로 Node의 preload 인자를 상속하지만, 각 워커는 여전히 자체 진입 코드에서 init()을 호출해야 해요 — runtime은 워커 간에 공유되지 않아요.

진단 로깅 활성화

Python

confident-trace는 export 및 계측 실패를 전용 로거에 기록해요. DEBUG로 올려 어떤 프롬프트나 완료 콘텐츠도 새지 않고 무슨 일이 일어나는지 볼 수 있어요.

import logging

logging.basicConfig(level=logging.WARNING)
logging.getLogger("confident_trace.diagnostics").setLevel(logging.DEBUG)

TypeScript

런타임 설정 실패는 OpenTelemetry의 자체 diag 로거를 통해 보고돼요.

import { diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api";

diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);

진단은 init() 전에 켜세요 — 더 일찍 내보내진 설정 메시지는 유실돼요. 그리고 조사하는 동안 API 키나 원본 애플리케이션 콘텐츠를 로그나 지원 티켓에 붙여넣지 마세요. 진단 로거는 설계상 콘텐츠가 없는 로거예요.

진단이 정상으로 보이는데도 트레이스가 오지 않는다면 문제는 전송 쪽이에요 — exporter 로그에서 HTTP 에러를 확인하세요(401은 거의 항상 잘못된 리전 또는 API 키를 뜻해요).

다음 단계

Tracing Quickstart

init(), span, 갱신 헬퍼들이 어떻게 어울리는지, exporter를 어떻게 구성하는지 다시 훑어보세요.

Existing OpenTelemetry Provider

이미 OpenTelemetry를 쓰고 있나요? init()을 호출하는 대신 Confident AI를 내 프로바이더에 연결하세요.

더 알아보기