LLM API 스트리밍 오류: 원인 및 헤결 방밎

LLM API 스트리밍 오류: 원인 및 헤결 방밎

error: llm api error: an error occurred during streaming 메시지는 거의 항상 여섯 가지 원인 중 하나에서 발생합니다: 연결이 이미 200을 반환한 후 SSE 이벤트로 전송되는 중간 공급자 실패, 클라이언트 또는 게이트웨이 측 읽기 시간 초과, 생성 도중 발생하는 429 속도 제한, SSE 파서를 손상시키는 잘못된 형식 또는 예상치 못한 청크, 네트워크 연결 끊김, 또는 스트림이 열린 후에야 표면화되는 인증/할당 문제. 이 순서대로 확인하세요 — 대부분의 스트리밍 실패는 처음 세 가지에 속합니다.

자세히 읽기 전에 관찰된 내용을 원인에 매지하세요:

관찰된 내용 이동
스트림이 시작되고 일부 토큰이 도착한 후 HTTP 상태 변경 없이 끊깁미다. 원인 1
스트림이 10초+ 동안 새 토큰 없이 지연되다가 클라이언트가 시간 초과를 발생시킵니다. 원인 2
오류가 트래픽 급증 시 또는 요청 폭주 직후에 가장 자주 발생합니다. 원인 3
예외가 KeyError, JSONDecodeError 또는 네트워크 종료가 아닌 파싱 실패를 언급합니다. 원인 4
오류가 일반적(APIConnectionError, ECONNRESET)이고 불규칙하게 발생하며, 안정적인 네트워크에서도 발생합니다. 원인 5원인 4
키를 교체하거나, 지출 한도에 도달하거나, 환경을 변경한 후 첫 번째 요청에서 오류가 발생합니다. 원인 6

아래의 전체 진단 체크리스트는 각 행에 가능한 원인을 명확히 제시하며 이를 반복합니다.

핵심 요점

  • 스트리밍 오류는 HTTP 상태가 이미 200을 반환한 에 SSE error 이벤트로 도착할 수 있으므로, 상태 코드만 확인하는 재시도 로직은 이를 놓칩니다.
  • Anthropic의 Messages API는 중간 과부하를 event: error"type": "overloaded_error"를 스트림 본문 내에 포함하여 보고하며, 새로운 HTTP 529로 보고하지 않습니다.
  • OpenRouter는 제공업체 전반에 걸쳐 동일한 패턴을 문서화합니다: 첫 번째 토퀸이 전송되면 실패는 chat.completion.chunk로, 최상위 error 필드와 finish_reason: "error"를 가지며 도착합니다.
  • 모든 청크가 성공 형태라고 가정하는 클라이언트 라이브러리는 제공업체가 중간에 구조적 오류 페이로드를 보낼 때 오도하는 오류(APIConnectionError 대신)로 인해 충돌합니다.
  • 대부분의 수정 방법은 제공업체에 관계없이 동일합니다: 스트림 본문에서 오류 이벤트를 파싱하고, 연결 시간 초과와 별도의 토퀸 수준 지연 시간 초과를 설정하며, HTTP 상태뿐만 아니라 오류 유형에 따라 지수 백오프를 사용합니다.

진단 체크리스트: 근본 원인 먼저 찾기

코드를 변경하기 전에 이 체크리스트를 살펴보세요. 각 행은 관찰할 수 있는 증상을 해당 증상을 해결하는 섹션에 매핑합니다.

관찰된 내용 가능한 원인 이동
스트림이 시작되고 일부 토큰이 도착한 후 HTTP 상태 변경 없이 끊깁니다. 중간 공금자 오류 (과부하, 콘텐츠 필터, 공급자 충돌) 원인 1
스트림이 10초+ 동안 새 토큰 없이 지연되다가 클라이언트가 시강 초과를 발생시킵니다. 읽기 시간 초과 또는 유휴 정지 원인 2
오류가 트래픽 급증 시 또는 요청 폭주 직후에 가장 자주 발생합니다. 중간 스트림에서 속도 제한 429 원인 3
예외가 KeyError, JSONDecodeError 또는 네트워크 종료가 아닌 파싱 실패를 언급합니다. 잘못된 형식 또는 예상치 못한 청크 원인 4
오류가 일반적(APIConnectionError, ECONNRESET)이고 불규칙하게 발생하며, 안정적인 네트워크에서도 발생합니다. 연결 끊김 또는 마스킹된 공급자 오류 원인 5원인 4
키를 교체하거나, 지출 한도에 도달하거나, 환경을 변경한 후 첫 번째 요청에서 오류가 발생합니다. 인증 또는 할당량 실패 원인 6

로그에 일반 예외 이름과 업스트림 메시지가 없는 경우, 이 자체가 증상입니다 — 일반 래퍼가 실제 원인을 숨기는 이유는 원인 4원인 5를 참고하세요.

원인 1: 중간 공급자 오류 (과부하, 콘텐츠 필터, 공급자 충돌)

이것이 가장 많은 사람들을 혼란스럽게 하는 원인입니다. 요청이 성공한 것처럼 보였기 때문입니다. 스트리밍 응답이 시작되면 HTTP 상태 코드와 헤더는 이미 클라이언트에 커밋됩니다. 그 후 공급자에게 실패가 발생하면 — 용량 소진, 내부 오류, 부분 출력 후 콘텐츠 필터 발동, 또는 모델 프로세스 충돌 — HTTP 상태를 오류 코드로 전환할 수 없습니다. 실패는 스트림 내부에서 특별 이벤트로 전달되어야 합니다.

Anthropic의 Messages API는 이를 직접 문서화합니다: API는 “가끔 이벤트 스트림에서 오류를 보낼 수 있습니다” 그리고 과부하 상태가 중간에 도착하는 경우에 대해 정확히 이 예제를 제공합니다:

event: error
data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}

이것은 많은 재시도 로직에서 실제 격차입니다. 코드가 response.status_code만 검사한다면, 실패가 발생하기 전에 이미 200을 보았기 때문에 재시도가 절대 트리거되지 않습니다. 이 정확한 패턴을 설명하는 타사 인시던트 보고서는 "HTTP 상태 코드에 의존하는 재시도 로직은 상태가 이미 200이었기 때문에 절대 트리거되지 않습니다. 그 결과는 조용히 잘린 응답입니다"라고 명확히 밝히고, 상태 코드만 신뢰하는 대신 스트림 본문에서 오류 이벤트를 파싱할 것을 권장합니다.

OpenRouter의 문서는 특정 공급자 뿐만 아니라 라우팅하는 모들 공급자 전반에 걸쳐 동일한 구조적 문제를 설명합니다: 첫 번째 토큰이 작성되면 “HTTP 200 OK 상태와 헤더는 이미 커밋되어 변경할 수 없습니다”, 따라서 공급자 실패는 “인밴드 SSE 이벤트로 도착해야 합니다”. 이 종류의 오류에 대해 문서화된 원인은 다음과 같습니다:

  • 공급자 연결 끊김 — 업스트림 연결이 부분 출력 후 중단 (네트워크 문제, 공급자 충돌, 로드 밸런서 시간 초과)
  • 공급자 시간 초과 — 모델이 생성 중간에 응답을 멈추고 읽기 마감 시간이 만료됨
  • 생성 중 트큰 한도 도달 — 모델이 max_tokens에 도달하거나 출력 생산 중 컨텍스트 윈도가 가득 참
  • 출력 콘텐츠 필터 — 일부 콘텐츠가 이미 스트리밍된 후 중재 시스템이 생성된 텍스트를 플래깅
  • 공급자 과부하 — 업스트림이 스트리밍을 시작한 후에 속도 제한 또는 용량 오류를 반환

OpenRouter의 중간 오류 페이로드는 일반적인 청크처럼 보이는 것 안에 오류를 담고 있으며, 스트림이 비정상적으로 종료되었음을 알라주는 finish_reason을 포함합니다:

{"id":"gen-abc123","object":"chat.completion.chunk","created":1234567890,"model":"openai/gpt-4o","provider":"OpenAI","error":{"code":429,"message":"Rate limit exceeded","metadata":{"error_type":"rate_limit_exceeded"}},"choices":[{"index":0,"delta":{"content":""},"finish_reason":"error"}]}

이것이 원인임을 확민하 방법: 실패한 요청에 대해 원시 SSE 스트림(파싱된 텍스트 출력뿐만 아니라)을 로깅하세요. 스트림 내 어느 곳에서나 터미널 이벤트 전에 error 이벤트 또는 최상위 error 필드가 있는 청크가 보이면 이것입니다.

수정 방법: finish_reason: "error"event: error 페이로드를 다시 시도 가능한 실패로 처리하고, 빈 콘텐츠가 있는 성공적인 완료로 처리하지 마십시오. HTTP 상태가 이미 200으로 읽힐므로 HTTP 상태가 아닌 페이로드 내의 특정 오류 유형에 따라 백오프로 재시도하세요.

원인 2: 읽기 시간 초과 또는 유휴 스트림 정체

정체는 하드 오류와 다릅니다: 연결은 열려 있지만, 확장 기간 동안 새 토퀜이 도착하지 않습니다. 대부분의 HTTP 클라이언트는 두 가지 시간 초과 개념을 혼동합니다 — 전체 요청 시간 초과, 및 읽기(유휴마다) 시간 초과. 긴 스트림은 큰 응답에서 짧은 전체 시간 초과를 합법적으로 초과할 수 있으며, 반면에 진정으로 멈춘 스트림은 유휴 읽기 검사가 없는 경우 전체 시간 초과 창 내에 잘 있을 수 있습니다.

OpenAI Python SDK는 기본 전체 요청 시간 초과를 10분으로 설정합니다: “기본적으로 요청은 10분 후에 시간 초과됩니다. timeout 옵션을 사용하여 설정할 수 있으며, float 또는 httpx.Timeout 객체를 허용합니다. 시간 초과 시 APITimeoutError가 발생합니다.” 또한 특정 실패를 자동으로 재시도합니다: “연결 오류 (예: 네트워크 연결 문제로 인한), 408 Request Timeout, 409 Conflict, 429 Rate Limit 및 >=500 Internal 오류는 기본적으로 두 번 재시도되며, max_retries를 통해 설정 가능합니다.”

확인 방법: 마지막으로 받은 토큰과 오류 사의의 간격을 측정하세요. 설정된 시간 초과 값과 일치하는 고정 기간은 공급자 측 실패가 아닌 시간 초과를 가리킵니다.

수정 방법: 하나가 아닌 두 개의 시간 초과를 설정하세요 — 요청 생명 주기에 대한 연결/전체 시강 초과, 및 N초 내에 청크가 도착하지 않을 때 발되하는 별도의 유휴 읽기 시강 초과. 이를 통해 "모델이 느리다"와 "스트림이 죽었다"를 구분할 수 있습니다. 채팅 완료의 경우 15-30초가 합리적인 유휴 읽기 시간 초과입니다. 모델이 첫 번째 토큰 전에 긴 침묵 “생각” 단계를 가지는 경우 늘리세요.

원인 3: 중간 트림 속도 제한 (429)

속도 제한은 일반적으로 요청이 시작되기 전에 거첩합니다. 그러나 일부 게이트웨이는 토큰별 또는 창별로 제한을 적용하므로, 요청이 스트리밍을 시작하고 생성 중간에 예산을 초과하자마자 중단될 수 있습니다 — 원인 1과 동일한 인벤드 오류 형태로, 429 코드가 초기 응답 상태 대신스트림 내부에 도착합니다.

확인 방법: 트래픽 폭주 시 또는 일관된 요청/분 임계값에서 오류가 집중되는지 확인하세요. Retry-After 헤더 또는 오류 페이로드의 동등한 필드가 이를 확인합니다.

수정 방법: 존재하는 경우 Retry-After를 존중하고, 재시도 전에 동시성을 줄이고, 지수적으로 백오프하세요. 빈번한 적중은 병렬 스트림 수를 낮추거나 계정 등급을 업그레이드하라는 신호로, 더 공격적으로 재시도하라는 신호가 아닙니다.

원인 4: 파싱서를 손상시키는 잘못된 형식 또는 예상치 못한 청크

모든 "스트리밍 오류"가 모델의 잘못은 아닙니다. 일부는 완전히 클라이언트 측입니다: SSE 파서가 모든 청크가 고정된 형태를 가질 것이라고 가정하고, 다르게 생긴 페이로드 — 제공업체의 적법한 오류 페이로드 포함 — 이 가정을 깨고 관련 없는 예외를 발생시키는 경우입니다.

문서화된 사례: LiteLLM의 ollama_chat 스트리및 핸들러는 모들 청크가 message 필드를 포함할 것이라고 예상했습니다. Ollama가 대신 {"error": "error parsing tool call: ..."}와 같은 구조적 오류를 반환했을 때, 핸들러는 chunk["message"]를 보호되지 않은 채로 조회하여 KeyError: 'message'를 발새시켰습니다. 더 넓은 예외 핸들러가 이를 잡아 APIConnectionError로 다시 발생시켰습니다 — 실제로는 모델의 도구 호출 출력 내부의 JSON 파싱 실패였던 것에 대해 네트워크처럼 들리는 오류였습니다. 버그 보고서는 비용에 대해 명시적입니다: 이를 디버깅하는 엔지니어들은 "실제로는 잘못된 형식의 도구 호출 JSON이었던 것에 대해 reason=timeout / LLM request timed out을 로깅"했으며, 문제가 아닌 네트워크 및 인프라 원인을 추적하는 데 "이틀의 오진"이 들었습니다.

일반적인 패턴: 제공업체가 파싱 코드가 예상하지 못한 형태로 오류를 보내고, 저수준 예외(KeyError, TypeError, JSON 디코드 오류)가 발생하며, 포괄적인 except 블록이 이를 실제 원인을 지우는 일반 연결 또는 스트리밍 오류로 감쌉니다.

확인 방법: 예외 래핑 코드가 실행되기 전에 원시 청크를 로깅하세요. 원시 페이로드에 실제 메시지가 있는 error 필드가 있다면, 클라이언트가 보고 있는 일반 오류로 가는 과정에서 유용한 정보를 폐기한 것입니다.

수정 방법: message 또는 delta와 같은 예상 필드에 접근하기 전에 error 키가 있는지 확인하고, 명시적으로 분기하세요. 업스트림 오류 메시지와 상태 코드를 재발생시킬 때 보존하고, 모든 실패를 하나의 일반 예외 유형으로 축소하지 마세요.

원인 5: 네트워크 연결 끊김

때로는 원인이 실제로 네트워크입니다: 프록시 또는 로드 밸런서가 유휴 기간 후에 장기 연결을 닫거나, 클라이언트의 네트워크가 요청 중간에 변경됩니다. 이는 원인 2와 유사하지만 수정 방법이 다릅니다 — 연결 자체가 없어졌으며, 자체 시간 초과 창 내에서 유휴 상태인 것이 아닙니다.

확인 방법: 애플리케이션 수준 시간 초과 예외보다는 연결 재설정 스타일 오류(ECONNRESET, Broken pipe, Connection reset by peer)를 찾고, 오류가 알려진 중간자의 유휴 연결 시간 초과(많은 로드 밸런서가 60초 비활동 기본값을 가짐)와 상관 관계가 있는지 확인하세요.

수정 방법: 프록시 또는 로드 밸런서를 제어할 수 있다면, 예상 최대 스트림 지속 시간보다 유휴 연결 시간 초과를 높이거나 keep-alive 핑을 추가하세요. 중단이 통제 밖이라면 재시도 가능한 것으로 처리하세요 — 대부분의 스트리밍 API는 부분 스트림 재개를 지원하지 않으므로, 재시도는 생성을 처음부터 다시 시작하는 것을 의미합니다.

원인 6: 중간 스트림에서 나타나는 인증 또는 할당량 실패

일부 게이트웨이는 인증 및 청구를 느슨하게 검증합니다 — 연결이 열린 후, 첫 번째 청크를 생성하는 동안 인증 또는 잔액 확인이 이루어지며, 그곳의 실패는 요청 시점의 깨끗한 401/402 대신 스트리밍 오류로 보고됩니다.

확인 방법: 오류가 간헐적이 아니라 특정 키의 모든 요청에서 발생하는지, 그리고 키 교체, 청구 이벤트 또는 환경 변경(프로덕션 기본 URL에 대한 개발 키, 또는 그 반대) 직후에 시작되었는지 확인하세요.

수정 방법: 키 형식(Authorization: Bearer <key>, 원시 키 아님)을 확인하고, 키가 활성 상태인지 확인하며, 요청 수준 디버깅과 별도로 계정 잔액을 확인하세요. 프롬프트나 모델에 관계없이 모든 요청이 동일하게 실패하는 경우 이전 다섯 가지 원인이 아닌 여기를 가리킵니다.

여섯 가지 원인을 모두 처리하는 재시도 및 백오프 패턴

단일 재시도 전략은 HTTP 상태뿐만 아니라 스트림 본문도 확인하면 원인 1부터 원인 5까지 다룰 수 있습니다:

import time
import openai

def stream_with_recovery(client, **kwargs):
    max_attempts = 3
    for attempt in range(max_attempts):
        try:
            collected = ""
            stream = client.chat.completions.create(stream=True, **kwargs)
            for chunk in stream:
                choice = chunk.choices[0] if chunk.choices else None
                if choice and getattr(choice, "finish_reason", None) == "error":
                    raise RuntimeError(f"mid-stream error: {chunk}")
                if choice and choice.delta.content:
                    collected += choice.delta.content
            return collected
        except (openai.APITimeoutError, openai.APIConnectionError, openai.RateLimitError) as e:
            if attempt == max_attempts - 1:
                raise
            time.sleep(2 ** attempt)
    return collected

이 예제는 base_url="https://api.novita.ai/openai"를 설정하여 OpenA와 호환되는 채팅 완료 엔드포인트에도 작동하는 OpenAI 호환 클라이언트 형태를 사용합니다. 위 코드 외에도 세 가지가 중요합니다:

  • 원인 1에 따라 정상 완료를 가정하기 전에 청크 내용에서 인벤드 오류를 확인하세요.
  • 즉시 재시도보다는 지수 백오프(2 ** attempt, 제한 있음)를 사용하세요, 특히 속도 제한 및 과부하 오류의 경우.
  • 자체 예외 유형으로 래핑하기 전에 원시 업스트림 오류 메시지를 로깅하여, 향후 디버깅이 원인 4의 이틀 오진을 반복하지 않도록 하세요.

특정 공급자 또는 모델이 다른 것보다 더 자주 실패하는 경우, 해당 요청 클래스를 다른 모델로 라우팅하는 것은 필요하기 전에 마련해두는 것이 좋은 완화 조치입니다 — 해당 폴백 정책을 정의하는 방법은 운영 시 정의된 가동 시간 목표를 위한 다중 제공업체 LLM 서비스 운영을 참고하세요.

결론

이 오류를 진단하는 것은 추측이 아니라 제거 과정입니다: 상단의 체크리스트를 사용하여 증상을 여섯 가지 원인 중 하나와 일치시킨 다음, 각 섹션에서 지정한 특정 신호(인벤드 error 이벤트, 시간 초과와 일치하는 지속 시간, 버스트와 상관된 429, 파서가 질식한 원시 페이로드, 연결 재설정 문자열, 한 키의 모든 요청에서 반복되는 실패)로 확입하세요. 원인 1~3이 가장 일반적이며, 원인 1, 3, 5는 동일한 근본적인 수정 방법을 공유합니다: 스트림이 시작된 후에는 HTTP 상태 코드를 신뢰하는 것을 중단하고, 대신 스트림 자체가 보고하는 내용에 기반하여 백오프로 재시도하세요.

위의 패턴을 사용하여 재시도 로직을 한 번 수정하면 처음 다섯 가지 원인을 동시에 해결할 수 있습니다. 원인 6은 예외입니다 — 유효하지 않은 키나 빈 잔액을 재시도하는 것으로는 해결되지 않으므로, 모든 요청에서 동일한 실패를 구성 확인으로 처리하고 네트워크 문제로 처리하지 마세요.

FAQ

LLM API 요청이 때로는 작동하고 때로는 스트리밍 오류로 실패하는 이유는 무엇인가요?

간헐적 실패는 구성 문제보다는 중간 스트림 원인을 가리킵니다 — 잘못된 키나 잘못된 엔드포인트와 같은 구성 오류는 매번 실패합니다. 공급자 과부하와 속도 제한을 먼저 확인하세요, 둘 다 트래픽에 의존적이기 때문입니다.

스트리밍 오류와 시간 초과는 같은 것인가요?

항상 그렇지는 않습니다. 시간 초과는 구성된 창 내에 응답이 도착하지 않았음을 의미합니다. 중간 스트림 오류는 응답이 시작되고, 일부 토큰이 도착한 후, 실패 이벤트가 스트림 내부로 전송되었음을 의미합니다. 오류 처리는 이 둘을 구분해야 하며, 수정 방법이 다르기 때문입니다.

오류 메시지에 "연결 오류"라고 표�되는데, 진짜 문제는 다�른 것이라면 왜 그런가��?

많은 클라이언트 라이브러리는 청크가 파서가 예상한 형태와 일치하지 않을 때 예상치 못한 예외를 일반 연결 오류 유형으로 래핑합니다 — 원인 4에서 JSON 파싱 오류가 실제 원인이 발견되기 전까지 이틀 동안 연결 시간 초과로 보고된 문서화된 사례를 참고하세요.

중간 스트림 오류 발생 후 처음부터 다시 시작하지 않고 스트림을 재개할 수 있나요?

대부분의 OpenAI 호환 및 Anthropic 스타일 스트리밍 API는 실패 지점에서 부분 스트림 재개를 지원하지 않습니다. 전체 요청을 재시도하고, 부분 출력을 추가하지 말고 폐기하여 중복 콘텐츠를 방지하세요.

항상 LLM API 호출에 스트리및을 사용해야 하나요?

스트리밍은 하나의 큰 요청이 시강 초과되는 것을 방지하지만, 이 가이드 전체에서 다루는 부분 출력 실패 모드를 도입합니다. 부분 출력을 표시할 필요가 없는 짧은 응답의 경우, 비스트리밍 요청이 오류 처리가 더 간단합니다.

스트리밍된 응답에서 finish_reason: error는 무엇을 의미하나요?

이는 일부 게이트웨이가 중간에 실패한 스트림의 최종 청크에 첨부하는 터미널 신호이며, stop 또는 length와 같은 정상 값과 구별됩니다. 요청의 HTTP 상태가 200이었더랍도 실패한 생성으로 처리하세요.

추천 문서