Langfuse로 Agent 디버깅: 어디서 망했는지 분리하는 법
LLM 앱 운영의 가장 큰 함정은 이거다.
"답이 이상하다"는 한 줄에 모든 문제가 섞여 있다.
서버 에러는 보통 stack trace가 어디서 났는지 알려준다. 근데 RAG/Agent에서 "답이 이상하다"는 다음 중 무엇이든 될 수 있다.
1. 검색 실패 - Retriever가 답 있는 문서를 못 가져옴
2. 생성 실패 - 문서는 받았는데 LLM이 이상하게 답함
3. 프롬프트 실패 - 프롬프트 설계 문제로 모델이 헷갈림
4. 툴 실패 - Tool 호출이 실패하거나 잘못된 인자
각각 해결책이 완전히 다르다. 그래서 디버깅의 첫 단계는 "어느 종류의 실패냐"를 분리하는 것이다.
이번 글에서는 Langfuse trace를 보면서 이걸 분리하는 절차를 정리한다.
디버깅 워크플로 한 장 요약
이 절차를 텍스트로 풀면.
[1] 문제의 trace를 연다
(user_id, session_id 또는 사용자가 보내준 trace url)
[2] 최종 답변과 사용자 질문을 본다 (정말로 틀렸는지 확인)
[3] Retrieve Span을 본다
- 답 있는 문서가 후보 안에 있나?
[4] Rerank Span을 본다
- top-5 안에 답 문서가 들어왔나?
[5] Answer Generation을 본다
- 프롬프트에 답 문서가 포함되어 들어갔나?
- LLM 출력이 그 문서를 잘 인용했나?
[6] 분류 결정
- 3에서 없음 → 검색 실패
- 3에 있고 4에 없음 → Reranker 실패
- 4에 있고 5의 input에 없음 → context packing 실패
- 5의 input에 있는데 output이 이상 → 생성 실패 (또는 프롬프트 실패)
이 절차를 한 번 익히고 나면 거의 모든 디버깅이 이 순서로 정리된다.
케이스 1: 검색 실패
증상.
질문: "휴가 며칠이야?"
답: "문서에 답이 없습니다"
Trace에서 Retrieve Span을 펼친다.
hybrid_retrieve
input: { query: "휴가 일수" }
output: {
doc_ids: [
"wiki-pay-001", ← 결제 정책
"wiki-meeting-04",← 회의 일정
...
],
scores: [0.41, 0.39, ...]
}
metadata: {
sparse_top_ids: ["wiki-pay-001", ...],
dense_top_ids: ["wiki-pay-001", ...]
}
답이 들어 있는 wiki-hr-vacation-01 문서가 후보 50개 안에도 없다.
이건 명백한 검색 실패다.
원인은 보통 셋 중 하나.
1. 휴가 문서가 메타필터(권한, 부서)로 잘렸다
2. 휴가 문서의 청크가 잘못 잘려서 임베딩이 엉뚱한 좌표에 떨어졌다
3. Rewrite가 "휴가 일수"로 바꿔서 BM25에는 잘 잡혀야 하는데
사실 문서 안에는 "연 15일 부여"라고만 적혀 있고 "휴가"라는 단어가 없다
각 가설을 어떻게 확인하나.
1번: 같은 질문을 권한 필터 없이 재현 → 답 나오면 권한 문제
2번: 휴가 문서 청크를 직접 보면서 "휴가 며칠" 쿼리로 단일 검색 → 좌표 거리 확인
3번: 단어 빈도 확인 → BM25 점수가 낮은 이유 확인
해결의 갈래.
- 권한이면 정책 변경 또는 공개 가능 요약 문서 도입
- 청크 문제면 chunker 규칙 수정 + 재인덱싱
- 단어 부재면 동의어 사전 또는 query expansion
케이스 2: Reranker 실패
증상이 미묘하다.
질문: "에러 코드 4023이 뭐야?"
답: "한도 초과를 의미합니다... 라는 것은 정확하지 않습니다." (헛소리)
Retrieve 단계에서는 답 문서가 후보 20개 안에 들어 있다. 그런데 Rerank 단계에서 top-5 밖으로 밀려났다.
rerank
input_ids: [..., "wiki-err-4023", ...] ← 후보 20개 중 12등
output_ids: ["wiki-faq-1", "wiki-err-4099", ...] ← top-5에 4023 없음
LLM은 4023 문서를 보지 못하고 추측으로 답했다.
이런 상황의 원인은 보통.
- Reranker 모델이 숫자 식별자에 약함
- 후보 다양성 부족 (Hybrid에서 비슷한 문서들이 후보를 다 차지함)
- Reranker 입력에 너무 긴 문서를 넣어서 잘림
해결.
- top-N(Reranker 입력)을 늘림 (20 → 50)
- Reranker 모델 교체 (bge-reranker → Cohere multilingual)
- 식별자 쿼리는 Sparse 점수를 가중치로 더 줌
- 청크 길이 단축 (Reranker가 통째로 못 보면 점수가 떨어짐)
케이스 3: 생성 실패 (LLM이 이상한 답)
증상.
질문: "휴가 며칠이야?"
답: "주차 가능합니다." (전혀 다른 얘기)
Trace에서 Answer Generation의 input을 본다.
input (system + user, 일부):
"[문서 1] 회사 휴가는 연 15일이며 분기당 5일까지 사용 가능하다.
[문서 2] 사내 주차장은 ...
[질문] 휴가 며칠이야?"
output:
"주차 가능합니다."
문서 1이 명확히 들어가 있는데 LLM이 문서 2 얘기를 했다. 이건 거의 확실히 프롬프트 또는 모델 문제다.
확인할 것.
- 모델 버전이 갑자기 바뀌었나? (gpt-4o-mini → gpt-3.5)
- system prompt에 "두 번째 문서를 우선" 같은 잘못된 규칙이 박혔나?
- temperature가 너무 높은가? (0.0이 아닌가)
- 문서 순서가 뒤집혔나? (LLM은 마지막 문서에 영향을 많이 받음)
- few-shot 예시가 엉뚱한 토픽인가?
해결.
- temperature 0
- 모델 버전 핀
- 시스템 프롬프트에 "주어진 문서에서만 답한다" 명시
- 답의 첫 줄에 인용한 문서 번호를 출력하게 만들어 추적
케이스 4: 프롬프트 실패 (구조 자체의 문제)
증상.
사용자: "회의실 어떻게 예약해?"
답: 문서를 인용하지 않고 일반론으로 답함
사용자: "그건 우리 회사 정책이 아닌데?"
Answer Generation을 보면 문서가 잘 들어갔다. LLM도 그 문서 내용을 알고는 있다. 근데 "이걸 무조건 인용해야 한다"는 강제가 없다.
input (system):
"도움을 줘"
input (user):
"[문서] ... [질문] ..."
output:
일반적인 답 (문서 인용 없음)
이건 프롬프트 설계의 문제다. 시스템 프롬프트에 다음을 박아야 한다.
- 주어진 문서에서만 답한다
- 답에 [문서 N] 라벨로 인용한다
- 문서에 없으면 "문서에 없습니다"라고 답한다
- 추측하지 않는다
이 규칙을 명시하기 전과 후를 evaluation으로 비교해 보면 의외로 차이가 크다.
케이스 5: Tool 실패
에이전트가 툴을 부르는 구조라면 다음 패턴이 자주 나온다.
tool_call
name: "search_internal_docs"
args: { "query": "휴가 정책", "limit": "all" } ← 잘못된 인자
error: "limit must be int, got 'all'"
Langfuse에서 tool span의 error 표시가 빨갛게 뜬다. 이건 거의 항상 다음 중 하나.
- Tool 정의 (JSON Schema)가 모호함
- 모델이 string과 int를 헷갈림
- args 검증을 백엔드에서 안 하고 LLM을 믿음
- Tool 결과가 너무 커서 다음 step에서 모델이 못 처리
해결.
- Tool args는 백엔드에서 strict하게 검증 + 안내 메시지로 재시도 유도
- Tool 결과는 truncate해서 모델에 돌려줌
- 같은 tool을 같은 args로 두 번 부르는 패턴은 detect해서 중단 (무한 루프 방지)
캐시 hit 확인도 같이 본다
같은 trace에서 cached token 비율도 같이 살핀다.
answer Generation
input: 1450 tokens
cached_input: 0 tokens
캐시가 0이면 다음 글(Prompt Cache 시리즈)에서 다룰 prefix 깨짐 문제일 가능성이 크다. 시스템 프롬프트 앞쪽에 현재 시간이나 user_id를 넣으면 매 요청마다 prefix가 달라져서 캐시가 안 먹는다.
cached가 평소 80%인 trace
↓ (어느 날부터)
cached가 0%로 떨어짐
→ 누가 system prompt 앞에 dynamic 값을 박았다
비용이 갑자기 뛰면 이걸 먼저 의심한다.
평가셋으로 자동 회귀 잡기
수동 디버깅으로 한 건 한 건 잡으면 끝이 없다. 평가셋 + Langfuse로 회귀를 자동 감지하는 게 운영 단계의 목표다.
1. 정답 라벨 있는 질문 100건 (Evaluation Dataset)
2. 매 PR 머지 전에 100건을 RAG에 돌려서 Trace를 남김
3. LLM-as-Judge 또는 정답 doc id 매칭으로 채점
4. 합격률이 떨어지면 PR 차단
5. Langfuse에서 떨어진 trace만 골라 디버깅
이 사이클이 돌면 "왜 답이 이상하지?"가 한 두 명의 직감이 아니라 숫자로 추적된다.
정리: 어디서 망했는지 분리하는 순서
[step 1] Retrieve span → 답 문서가 후보에 있나?
└ 없음: 검색 실패 (chunking/필터/임베딩/단어)
[step 2] Rerank span → top-5 안에 답 문서가 있나?
└ 없음: 리랭커 실패 (모델/입력크기/식별자)
[step 3] Generation input → 답 문서가 LLM에 진짜 들어갔나?
└ 없음: context packing 실패 (토큰 예산/잘림)
[step 4] Generation output → 들어간 문서대로 답했나?
└ 아니오: 생성 실패 + 프롬프트 실패
(temperature, 모델 버전, 시스템 규칙)
[step 5] Tool span → 에러나 무한 루프가 있나?
└ 있음: 툴 실패 (스키마/검증/결과 크기)
이 5단계로 분류만 해도 90% 이상의 사례가 명확히 어디서 망했는지 가려진다. 그 다음에 어떤 처방을 내릴지가 진짜 엔지니어링이다.
다음 시리즈는 위에서 짧게 언급한 Prompt Cache다. 같은 질문이 들어와도 prompt layout이 어떻게 생겼느냐에 따라 비용이 5배 차이가 난다. Claude, OpenAI, Gemini의 캐싱 방식이 다 다르기 때문에, 그걸 비교하면서 정리해본다.