LLM API 디버깅 체크리스트
요약
LLM API 운영 중 발생하는 버그가 모델 성능 문제가 아닌 인프라 및 네트워크 문제인 경우가 많음을 지적합니다. 안정적인 서비스를 위해 요청 메타데이터를 포함한 정밀한 로깅과 전송 계층의 디버깅 체크리스트를 제안합니다.
핵심 포인트
- 모델 성능을 탓하기 전 인프라 및 네트워크 문제를 먼저 점검해야 함
- 프롬프트뿐만 아니라 모델명, 파라미터, 도구 정의 등 상세 메타데이터 로깅 필요
- DNS, 인증, 프록시 등 전송 계층(Transport Layer)의 오류를 분리하여 확인
- 실패한 요청을 정확히 재현할 수 있는 구조화된 로그 설계가 필수적임
운영 환경에서 LLM 기능이 작동하지 않을 때, 저의 첫 번째 본능은 다음과 같았습니다: "모델 성능이 나빠졌구나."
하지만 그것은 대개 잘못된 시작점이었습니다.
제가 LLM API를 다루며 디버깅했던 고통스러운 버그 대부분은 모델의 품질과는 아무런 관련이 없었습니다. 그것들은 모델 응답 뒤에 숨겨진 지루한 인프라 문제들이었습니다: 누락된 요청 메타데이터 (request metadata), 조용한 타임아웃 (silent timeouts), 부분적인 스트리밍 출력 (partial streaming output), 상황을 악화시키는 재시도 로직 (retry logic), 또는 프롬프트는 캡처했지만 실제로 실패한 부분은 캡처하지 못한 로그 등이 그것입니다.
모델을 탓하기 전에 제가 현재 사용하는 체크리스트를 소개합니다.
1. 정확한 요청을 재현할 수 있는가?
첫 번째 질문은 간단합니다:
실패한 것과 정확히 동일한 요청을 다시 재생(replay)할 수 있는가?
LLM 호출의 경우, "정확히 동일함"은 단순히 사용자 프롬프트(user prompt) 그 이상을 의미합니다.
저는 다음 사항들을 캡처하고 싶습니다:
- 모델 이름 (model name)
- 제공자 (provider)
- 기본 URL (base URL)
- 요청 타임스탬프 (request timestamp)
- 사용 가능한 경우 요청 ID (request ID)
- 전체 메시지 배열 (full message array)
- temperature / top_p / max_tokens
- 도구 정의 (tool definitions)
- 응답 형식 설정 (response format settings)
- 스트리밍 vs 비스트리밍 모드 (streaming vs non-streaming mode)
- 타임아웃 설정 (timeout settings)
- 재시도 횟수 (retry attempt number)
- 자체 시스템의 사용자/세션/작업 ID (user/session/job ID)
제가 과거에 했던 실수는 최종 프롬프트 문자열만 로깅하는 것이었습니다. 이는 간단한 데모에서는 작동하지만, 운영 환경의 호출은 대개 시스템 메시지 (system messages), 도구 스키마 (tool schemas), 라우팅 로직 (routing logic), 그리고 런타임 옵션 (runtime options)에 의존합니다.
최소한의 구조화된 로그는 다음과 같을 수 있습니다:
const requestLog = {
event: "llm_request_started",
request_id: crypto.randomUUID(),
...
저는 명확한 개인정보 보호 정책과 보관 계획이 없는 한 원시 사용자 콘텐츠 (raw user content)를 로깅하는 것은 피합니다. 하지만 어떤 형태의 요청이 실패했는지 알 수 있을 만큼의 충분한 메타데이터는 로깅합니다.
2. 요청이 실제로 제공자에게 도달했는가?
놀랍게도 많은 "LLM 버그"는 LLM 버그가 아닙니다.
그것들은 다음과 같습니다:
- DNS 문제
- 인증 실패 (auth failures)
- 프록시 오류 (proxy errors)
- 게이트웨이 타임아웃 (gateway timeouts)
- 잘못된 기본 URL (invalid base URLs)
- SDK 설정 실수 (SDK configuration mistakes)
- 특정 배포 대상에서 누락된 환경 변수 (environment variables)
모델 응답을 살펴보기 전에, 요청이 제공자에게 도달하기는 했는지 먼저 확인하십시오.
모델 계층 (model layer)과 별도로 전송 계층 (transport layer)을 로그로 남기십시오:
try {
const startedAt = Date.now();
...
중요한 부분은 다음과 같이 분리하는 것입니다:
- 네트워크 오류 (network failure)
- HTTP 오류 (HTTP error)
- 제공자 오류 (provider error)
- 모델 거부 (model refusal)
- 빈 모델 출력 (empty model output)
- 잘못된 형식의 출력 (malformed output)
- 애플리케이션 파싱 실패 (application parsing failure)
이것들은 서로 다른 문제입니다. 이 모든 것들이 단순히 LLM failed로 표시되어서는 안 됩니다.
3. 응답이 비어 있었는가, 잘렸는가, 아니면 단순히 유효하지 않았는가?
자주 혼동되는 세 가지 매우 다른 실패 모드 (failure modes)가 있습니다:
- 모델이 아무것도 반환하지 않음
- 모델이 부분적인 답변을 반환함
- 모델이 애플리케이션에서 파싱할 수 없는 것을 반환함
이들은 각각 다른 해결 방법이 필요합니다.
일반적인 비스트리밍 (non-streaming) 호출의 경우, 저는 다음 항목들을 로그로 남깁니다:
- 응답 ID (response ID)
- 종료 사유 (finish reason)
- 출력 길이 (output length)
- 사용 가능한 경우 토큰 사용량 (token usage)
- 콘텐츠가 비어 있었는지 여부
- JSON 파싱이 실패했는지 여부
- 스키마 검증 (schema validation)이 실패했는지 여부
예시:
const content = response.choices?.[0]?.message?.content ?? "";
const finishReason = response.choices?.[0]?.finish_reason;
...
만약 finish_reason이 length라면, 저는 이를 모델 품질 문제와 동일하게 취급하지 않습니다. 이는 대개 max_tokens 값이 너무 낮았거나, 프롬프트 (prompt)에서 너무 많은 것을 요구했거나, 응답 형식이 너무 장황했음을 의미합니다.
JSON 파싱이 실패하면, 이를 애플리케이션 수준의 실패로 로그에 남깁니다:
try {
const parsed = JSON.parse(content);
return parsed;
...
이러한 구분은 중요합니다. 제공자 장애 (provider outage)와 JSON 파싱 실패는 동일한 장애 대응 (incident response)을 트리거해서는 안 됩니다.
4. 스트리밍 (Streaming)이 실패했다면, 어디까지 진행되었는가?
스트리밍은 요청이 이미 성공적으로 시작된 후에 실패가 발생할 수 있기 때문에 디버깅을 더 어렵게 만듭니다.
스트리밍 호출의 경우, 저는 다음을 알고 싶습니다:
- 스트림이 열렸는가?
- 첫 번째 토큰이 언제 도착했는가?
- 몇 개의 청크 (chunks)가 도착했는가?
- 몇 개의 문자가 수신되었는가?
- 스트림이 깔끔하게 종료되었는가?
- 최종 사용량 청크 (final usage chunk)가 있었는가?
- 클라이언트가 먼저 연결을 끊었는가?
- 내 서버가 먼저 타임아웃 (timeout) 되었는가?
- 제공업체 (provider)가 스트림을 닫았는가?
간단한 예시는 다음과 같습니다:
let chunkCount = 0;
let outputChars = 0;
let firstChunkAt = null;
...
이를 통해 제가 다음 중 어떤 상황을 겪고 있는지 알 수 있습니다:
- 응답이 전혀 없음
- 느린 첫 번째 토큰
- 스트림 중간의 실패
- 클라이언트 연결 끊김
- 제공업체 타임아웃
- 애플리케이션 측의 스트리밍 버그
이 정보가 없다면, 제가 알 수 있는 것은 "스트림이 깨졌다"는 것뿐이며, 이는 충분하지 않습니다.
5. 나의 재시도 로직 (Retry Logic)이 상황을 악화시켰는가?
재시도는 문제를 조용히 증폭시키기 전까지는 유용합니다.
LLM API의 경우, 저는 다음 항목들과 함께 모든 재시도 시도를 기록합니다:
- 원래 요청 ID (original request ID)
- 재시도 시도 횟수
- 재시도 전 지연 시간
- 재시도를 유발한 에러
- 재시도 시 동일한 모델을 사용했는지 여부
- 재시도 시 폴백 모델 (fallback model)을 사용했는지 여부
- 해당 작업이 재시도하기에 안전했는지 여부
예시:
console.warn({
event: "llm_retry_scheduled",
original_request_id: requestId,
...
가장 큰 함정은 이미 부수 효과 (side effects)를 일으킨 요청을 재시도하는 것입니다.
예를 들어, LLM 호출이 이메일 전송, 티켓 생성, 크레딧 차감 또는 데이터베이스 쓰기와 같은 워크플로우의 일부라면, 재시도에는 멱등성 키 (idempotency keys)와 신중한 경계 설정이 필요합니다.
그렇지 않으면 타임아웃이 중복 작업으로 이어질 수 있습니다.
저의 규칙은 다음과 같습니다:
- 전송 실패 (transport failures)는 신중하게 재시도한다
- 속도 제한 (rate limits)은 백오프 (backoff)와 함께 재시도한다
- 도구 실행 (tool execution)을 맹목적으로 재시도하지 않는다
- 멱등성 없이 사용자에게 보이는 부수 효과를 재시도하지 않는다
- 재시도하기 전에 항상 응답이 돌아왔는지 여부를 기록한다
6. 실패가 모델 특정적인가, 아니면 제공업체 특정적인가?
OpenAI 호환 API를 사용할 때, 모델이나 제공업체를 교체하면서 다른 모든 것이 동일할 것이라고 가정하기 쉽습니다.
대개 그렇지 않습니다.
모델과 제공업체는 다음과 같은 점에서 다를 수 있습니다:
- 스트리밍 동작 (streaming behavior)
- 도구 호출 지원 (tool calling support)
- JSON 모드 지원 (JSON mode support)
- 컨텍스트 윈도우 제한 (context window limits)
- 속도 제한 헤더 (rate limit headers)
- 에러 응답 형태 (error response shape)
- 사용량 보고 (usage reporting)
- 타임아웃 동작 (timeout behavior)
- 종료 사유 의미론 (finish reason semantics)
그래서 저는 실패 원인을 격리하려고 시도합니다:
- 동일한 프롬프트, 동일한 제공업체, 다른 모델
- 동일한 프롬프트, 동일한 모델 제품군, 가능하다면 다른 제공업체
- 동일한 프롬프트, 스트리밍 대신 비스트리밍 (non-streaming)
- 동일한 프롬프트, 도구 미사용 (no tools)
- 동일한 프롬프트, 더 작은 컨텍스트
- 동일한 프롬프트, 더 낮은 최대 토큰 (max tokens)
이를 통해 제가 보고 있는 문제가 모델의 동작 문제인지, 제공업체의 호환성 문제인지, 아니면 저의 통합 버그(integration bug)인지 알 수 있습니다.
7. 전체 타임라인을 볼 수 있는가?
프로덕션 디버깅을 위해서는 개별 로그만으로는 충분하지 않습니다. 저는 타임라인을 원합니다.
유용한 LLM 요청 타임라인은 다음과 같습니다:
00ms 앱에 의해 요청 수락됨
12ms 프롬프트 조립됨
18ms 제공업체 선택됨
...
또는, 잘못된 요청의 경우:
00ms 앱에 의해 요청 수락됨
10ms 프롬프트 조립됨
18ms 제공업체 선택됨
...
이렇게 하면 실제 문제가 명확해집니다.
모델은 괜찮았지만 타임아웃 설정이 너무 공격적이었을 수도 있습니다. 첫 번째 토큰은 빨랐지만 다운스트림 파서(downstream parser)가 실패했을 수도 있습니다. 앱이 LLM 호출이 시작되기도 전에 컨텍스트를 구축하는 데 4초를 소비했을 수도 있습니다.
타임라인이 없다면, 스택에서 가장 미스터리한 부분을 탓하기가 너무 쉽습니다.
8. 작은 테스트 케이스가 있는가?
가능성 있는 실패 원인을 식별한 후, 저는 이를 아주 작은 테스트로 축소하려고 시도합니다.
예를 들어:
import OpenAI from "openai";
const client = new OpenAI({
...
작은 테스트 케이스는 다음 질문에 답하는 데 도움이 됩니다:
- 내 프로덕션 프롬프트가 너무 복잡한가?
- 도구 호출 (tool calling)이 문제인가?
- JSON 모드가 지원되는가?
- 스트리밍 (streaming)이 문제인가?
- SDK가 올바르게 구성되었는가?
- 제공업체가 내가 예상하는 형태를 반환하는가?
작은 테스트가 실패한다면, 문제는 아마도 통합 수준 (integration-level)의 문제일 것입니다.
작은 테스트가 통과한다면, 문제는 아마도 제 애플리케이션 로직, 프롬프트 조립, 컨텍스트 크기, 또는 출력 파싱 (output parsing)에 있을 것입니다.
9. 나의 기본 LLM 디버그 로그에 포함하는 것들
LLM 호출을 위한 나의 기본 로그 이벤트는 보통 다음과 같은 형태를 포함합니다:
{
event: "llm_call",
request_id: "...",
...
실패 시에는 다음과 같습니다:
{
event: "llm_call",
request_id: "...",
...
이 정도면 로그의 홍수에 빠지지 않고 디버깅을 시작하기에 충분합니다.
10. 짧은 체크리스트
모델을 탓하기 전에, 저는 다음 사항들을 확인합니다:
- 정확히 동일한 요청을 재현(replay)할 수 있는가?
- 요청이 제공자(provider)에게 도달했는가?
- 네트워크, HTTP, 제공자, 모델, 파싱(parsing), 또는 애플리케이션의 실패인가?
- 응답이 비어 있거나, 잘렸거나(truncated), 형식이 잘못되었거나(malformed), 거부되었는가?
- 스트리밍(streaming)이 실패했다면, 몇 개의 청크(chunks)를 받았는가?
- 첫 번째 토큰(first token)이 도착했는가?
- 제공자가 작업을 마치기 전에 타임아웃(timeout)이 발생했는가?
- 재시도 로직(retry logic)이 문제를 중복해서 발생시켰는가?
- 문제가 모델 특화적인가, 아니면 제공자 특화적인가?
- 스트리밍 없이 동일한 요청이 작동하는가?
- 도구(tools) 없이 동일한 요청이 작동하는가?
- 아주 작은 테스트 케이스로 재현할 수 있는가?
- 애플리케이션 요청부터 최종 응답까지의 타임라인을 확보하고 있는가?
마치며
모델은 블랙박스처럼 느껴지기 때문에 탓하기 가장 쉬운 대상입니다.
하지만 많은 LLM 프로덕션 버그들은 매우 비싼 모자를 쓰고 있는 일반적인 분산 시스템(distributed systems) 문제들입니다. 즉, 타임아웃, 재시도, 부분 응답, 스키마 불일치(schema mismatches), 불량한 관측성(observability), 그리고 애플리케이션, SDK, 제공자, 모델 사이의 불분명한 책임 소재와 같은 문제들입니다.
해결책이 항상 더 나은 모델인 것은 아닙니다.
때로는 그저 더 나은 로그일 뿐입니다.
저는 TokenBay에서 일하고 있기에, 애플리케이션과 모델 제공자 사이의 이 계층에 대해 많은 시간을 고민합니다. 더 많은 모델과 제공자를 거쳐 요청을 라우팅할수록, 지루할 정도의 철저한 디버깅 규율은 더욱 가치 있어집니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기