LLM Observability

Langfuse 기초: LLM 앱은 왜 관찰이 필요한가

2026. 07. 01.
AILangfuseObservability

서버 개발자에게 익숙한 도구가 있다.

로그       → Datadog, Loki
메트릭     → Prometheus, Grafana
APM       → New Relic, Datadog APM, Pinpoint
에러 추적  → Sentry

근데 LLM 앱을 만들기 시작하면 위 도구들로는 답이 안 나오는 질문이 생긴다.

- "왜 이 답이 이렇게 나왔지?"
- "어떤 프롬프트가 들어갔고, 토큰 몇 개 썼지?"
- "Retriever가 가져온 문서가 뭐였지?"
- "이 사용자가 어제 만족한 답은 어떻게 흘러갔지?"
- "비용이 왜 갑자기 두 배가 됐지?"

이걸 답하기 위해 나온 게 LLM Observability 도구들이다. 대표 주자가 Langfuse, 그 다음이 LangSmith, Helicone, Arize 같은 도구들이다.

이번 글에서는 Langfuse를 RAG/Agent 앱에 어떻게 붙이고 무엇을 봐야 하는지 정리한다.


Langfuse가 다루는 데이터 모델

Langfuse는 LLM 앱을 세 단위로 본다.

Trace
  └─ Observation (Span / Generation / Event)
        └─ ...중첩 가능...

각 단위가 뭔지 한 줄로.

Trace       : "한 사용자 요청 한 건". RAG 한 번 = Trace 한 개
Observation : 그 안의 한 단계 (LLM 호출, 함수 실행, 외부 API 등)
  - Span       : 시간 구간만 있는 일반 작업 (예: retrieve)
  - Generation : LLM 호출 (input, output, token, cost 자동 기록)
  - Event      : 일회성 이벤트 (예: feedback 도착)

RAG로 그려 보면 이렇다.

펼치면 이렇다.

Trace: "휴가 며칠이야?" 질문에 대한 한 건의 처리
├── Span "query_rewrite"
│     └── Generation "ChatOpenAI" (input/output/tokens 자동)
├── Span "retrieve"
│     ├── 검색 점수, doc_id 메타데이터로 첨부
│     └── (LLM 호출 없으면 그냥 Span)
├── Span "rerank"
│     └── 점수 변화 첨부
└── Span "answer"
      └── Generation "ChatOpenAI" (input/output/tokens 자동)

Trace 하나만 펼치면 한 요청의 전 과정을 시간 순으로 다 본다. 이게 LLM 앱 디버깅의 출발점이다.


LangGraph에 Langfuse 붙이기

RAG 시리즈 마지막 글에서 만들었던 코드에 이미 붙어 있다. 다시 보면 이렇다.

from langfuse.callback import CallbackHandler
 
langfuse_handler = CallbackHandler(
    public_key=os.environ["LANGFUSE_PUBLIC_KEY"],
    secret_key=os.environ["LANGFUSE_SECRET_KEY"],
    host=os.environ.get("LANGFUSE_HOST", "https://cloud.langfuse.com"),
)
 
result = app.invoke(
    {...},
    config={"callbacks": [langfuse_handler]},
)

핵심은 config={"callbacks": [langfuse_handler]} 한 줄이다. LangChain/LangGraph는 내부에서 일어나는 모든 LLM 호출/노드 진입/툴 호출을 callback으로 쏴주기 때문에, handler 하나만 등록하면 trace가 자동으로 잡힌다.


직접 Span을 만드는 게 필요한 순간

자동 trace는 LLM 호출만 잘 잡는다. RAG에서는 LLM 호출 아닌 단계도 봐야 한다.

- Retriever 검색 결과 (doc_id, score, retriever 종류)
- Reranker 점수 변화
- Filter에서 몇 개가 잘렸는지
- 캐시 hit / miss

이걸 보려면 SDK로 직접 Span을 만들어 첨부한다.

from langfuse import Langfuse
 
langfuse = Langfuse()
 
def retrieve_node(state):
    with langfuse.start_as_current_span(
        name="hybrid_retrieve",
        input={"query": state["rewritten_query"]},
    ) as span:
        sparse_hits = sparse.search(state["rewritten_query"], k=50)
        dense_hits  = dense.search(state["rewritten_query"], k=50)
        fused = rrf([sparse_hits, dense_hits])
        top = fused[:20]
 
        span.update(
            output={
                "doc_ids": [d for d, _ in top],
                "scores":  [s for _, s in top],
            },
            metadata={
                "sparse_top_ids": [d for d, _ in sparse_hits[:5]],
                "dense_top_ids":  [d for d, _ in dense_hits[:5]],
                "fusion": "rrf",
            },
        )
        return {**state, "retrieved_docs": [d for d, _ in top]}

이렇게 해두면 Trace 안에서 "어느 Retriever가 어떤 점수로 어떤 문서를 가져왔는지"가 한 화면에 보인다.


RAG/Agent에서 관찰해야 할 것

대충 다 기록한다고 좋은 게 아니다. 디버깅과 개선에 쓸 수 있는 정보만 골라 박는다.

[Trace 단위]
- user_id, session_id (사용자/세션 식별)
- 원본 질문
- 최종 답변
- 총 latency
- 총 cost (provider별)
- 사용자 피드백 점수

[Query Rewrite 단위]
- 원본 질문
- rewrite된 쿼리
- rewrite에 쓰인 모델

[Retrieve 단위]
- 후보 문서 id 목록과 점수
- sparse vs dense vs fused 점수 분리
- 메타 필터 조건 (어떤 권한, 어떤 날짜)
- 결과 개수

[Rerank 단위]
- 입력 후보 id (순서)
- 출력 후보 id (순서)
- 어느 모델 썼는지
- top-k

[LLM 호출 단위]
- 모델 이름과 버전
- prompt 전체
- output 전체
- input/output 토큰 수
- cached_tokens (provider가 지원하면)
- temperature 등 파라미터

[Tool 호출 단위]
- tool 이름
- input args (sanitized)
- output (truncated)
- 성공/실패

[Error]
- 에러 타입과 메시지
- 어느 노드에서 났는지
- traceback (개발 환경만)

metadata와 tag, 그리고 user_id

Trace 단위로 다음 두 가지를 꼭 넣는 게 좋다.

from langfuse import Langfuse
langfuse = Langfuse()
 
langfuse.update_current_trace(
    user_id="u_123",
    session_id="sess_abc",
    tags=["rag", "kr", "policy"],
    metadata={
        "app_version": "1.4.2",
        "experiment": "rrf_k60",
        "tenant": "acme",
    },
)
  • user_id: 사용자별로 Trace를 보고, 특정 사용자의 만족도/오류율을 추적
  • session_id: 한 사용자의 연속 대화를 묶어서 보기
  • tags: 필터링용 라벨. "rag", "chat", "agent", "support" 등
  • metadata: 자유 키-값. A/B 실험 그룹, 앱 버전, 멀티테넌트 ID

이게 있어야 나중에 "1.4.2 버전에서 실패율이 올라간 trace만 보기" 같은 필터가 된다.


Generation에 cost를 자동으로 계산시키기

LangChain의 ChatOpenAI / ChatAnthropic는 모델 이름과 토큰 수를 자동으로 callback에 보낸다. Langfuse는 이걸 받아서 모델별 단가표로 cost를 자동 계산한다.

Trace summary
  - tokens: 248 in / 56 out
  - cost:   $0.0014 (auto)

운영 중에 "오늘 RAG 비용이 평소의 3배"가 보이면 Langfuse 대시보드에서 trace별 cost 분포를 본다. 대개 다음 중 하나다.

- prompt가 평소보다 큼  → 누가 context를 너무 많이 채움
- output이 평소보다 큼  → 시스템 프롬프트가 바뀌었나
- 호출 횟수가 늘었음    → 누가 무한 루프 만들었나

Cached token 확인

요즘 provider들은 prompt cache를 자동으로 적용한다. 이게 잘 먹히는지 확인하려면 trace에서 cached_tokens 필드를 본다.

Generation "answer"
  input: 1450 tokens
  cached_input: 980 tokens   ← 캐시가 잘 먹힘
  output: 80 tokens

cached가 0이면 prompt layout이 매 요청마다 바뀌고 있다는 신호다. 다음 시리즈(Prompt Cache)에서 이걸 어떻게 줄이는지 다룬다.


Feedback 수집

Langfuse는 사용자 피드백도 SDK로 받는다.

langfuse.score(
    trace_id=trace_id,
    name="user_thumbs",
    value=1.0,  # 1.0 = up, 0.0 = down
)

UI에서 👍/👎 버튼이 눌리면 trace_id와 함께 백엔드로 보내고, 백엔드에서 위 SDK를 호출한다. 이걸 모으면 다음이 다 된다.

- 시간별 만족도 추이
- 특정 retriever 변경 전후 비교
- 평가셋에 자동으로 추가할 negative 예시 수집

정리

  • Langfuse는 LLM 앱의 trace를 모으는 도구다 (Sentry+APM의 LLM 버전)
  • LangGraph에는 CallbackHandler 한 줄로 자동 trace 연결
  • Retriever/Reranker 같은 비-LLM 단계는 SDK로 직접 Span을 만들어 첨부
  • 관찰 항목은 사용자/쿼리/문서 id+점수/프롬프트/출력/토큰/cached/cost/feedback 이 표준 세트
  • metadata, user_id, session_id, tag는 운영에서 필터링의 핵심

다음 글에서는 이걸 실제 디버깅에 어떻게 쓰는지, 즉 "검색 실패 / 생성 실패 / 프롬프트 실패 / 툴 실패"를 어떻게 분리해서 잡는지를 다룬다.