가이드 목록으로
문제 해결·2026년 7월 17일·6분 분량

LLM 스트리밍 오류 — SSE 중단, 멈춘 스트림 및 누락된 사용량

스트리밍 실패는 모델 때문인 경우가 드뭅니다. 문제는 여러분과 모델 사이의 연결 경로입니다. 유휴 시간 초과 프록시는 조용한 연결을 종료하고, nginx 버퍼는 이벤트를 삼키며, 절반만 소비된 스트림은 “API가 응답을 중단했다”는 것처럼 보입니다. 먼저 연결 경로를 점검하세요.

마지막 검토일: .

스트리밍 실패는 모델 때문인 경우가 드뭅니다. 문제는 여러분과 모델 사이의 연결 경로입니다. 유휴 시간 초과 프록시는 조용한 연결을 종료하고, nginx 버퍼는 이벤트를 삼키며, 절반만 소비된 스트림은 “API가 응답을 중단했다”는 것처럼 보입니다. 먼저 연결 경로를 점검하세요.

오류

symptoms
- Stream stops mid-sentence, connection closed (no error event)
- Client hangs after the last token, never sees [DONE]
- usage is null on streamed responses
- Works in curl, dies behind nginx / a corporate proxy

원인과 해결 방법 한눈에 보기

원인해결 방법
프록시/로드 밸런서 유휴 시간 초과 (많은 설정에서 기본값 60s)API 경로의 읽기 시간 초과를 늘리세요. 긴 사고 일시 중지는 프록시에서 유휴 상태로 보입니다.
SSE 앞단의 버퍼링 (nginx proxy_buffering, 일부 CDN)스트리밍 경로의 버퍼링을 비활성화하세요 (X-Accel-Buffering: no / proxy_buffering off).
클라이언트가 소비를 중단함 (await 누락, iterator 삭제)끝까지 소비하거나 명시적으로 닫으세요. 스트리밍 중간에 GC로 수집된 iterator는 중단과 구별할 수 없습니다.
요청하지 않고 사용량을 기대함OpenAI-wire: stream_options: {"include_usage": true} 전달 — 사용량은 마지막 청크에 도착합니다.

curl -N으로 API에 직접 재현

모든 프록시를 우회하세요. 전체 생성 동안 원시 SSE가 정상적으로 흐르면 문제는 애플리케이션 경로에 있습니다. 홉을 한 번에 하나씩 다시 추가하세요:

raw-stream.sh
curl -N https://api.kunavo.com/v1/chat/completions \
  -H "Authorization: Bearer $KUNAVO_API_KEY" \
  -H "content-type: application/json" \
  -d '{"model":"claude-sonnet-5","stream":true,
       "stream_options":{"include_usage":true},
       "max_tokens":300,
       "messages":[{"role":"user","content":"Count slowly to 20 in words."}]}'

중단을 일으키는 홉 수정

nginx: 해당 경로에 proxy_buffering off + proxy_read_timeout 300s를 설정하세요. 서버리스: 플랫폼의 응답 스트리밍 제한을 확인하세요. 기업 프록시: 일부는 SSE를 전혀 지원하지 않으므로 해당 환경에서는 비스트리밍으로 대체하세요.

마지막 부분을 올바르게 처리

스트리밍된 결제 데이터는 마지막에 도착합니다. 요청한 경우 마지막 청크에 [DONE] 전에 사용량이 포함됩니다. 델타를 집계하고 마지막 청크에서 사용량을 읽으며, 조기 종료( finish_reason 없음)는 재시도 가능한 상태로 처리하세요.

“SSE stream ended without [DONE]” — 응답이 완전했나요?

이 메시지는 클라이언트가 자체적으로 수행한 검사 결과이지 API가 보낸 오류가 아닙니다. 연결이 OpenAI 호환 스트림이 끝날 때 포함하는 data: [DONE] 줄에 도달하기 전에 종료되었다는 뜻입니다. 세 가지 원인이 있습니다. 중간의 홉이 연결을 종료했거나(앞서 설명한 프록시 시간 초과 및 버퍼링 원인), 서버가 응답 중간에 실패하여 종료 프레임 없이 닫혔거나, 응답은 완전했지만 센티넬만 유실된 경우입니다. 어떤 경우인지는 마지막으로 수신한 청크가 결정합니다. 해당 청크에 finish_reason이 있으면 텍스트가 완전한 것이고, finish_reason이 없으면 잘린 것이므로 재시도해야 합니다. 이 검사를 SDK에 맡기지 마세요. OpenAI Python SDK는 연결이 [DONE] 없이 닫히면 조용히 루프를 종료하고, 청크에 오류 객체가 포함된 경우에만 예외를 발생시킵니다. 검사를 자체 코드에 넣으세요:

stream_check.py
from openai import OpenAI

client = OpenAI(base_url="https://api.kunavo.com/v1", api_key="sk-kn-...")

finish_reason, parts = None, []
stream = client.chat.completions.create(
    model="claude-sonnet-5",
    messages=[{"role": "user", "content": "Count slowly to 20 in words."}],
    stream=True,
)
for chunk in stream:  # raises openai.APIError on a chunk that carries "error"
    for choice in chunk.choices:
        parts.append(choice.delta.content or "")
        finish_reason = choice.finish_reason or finish_reason

if finish_reason is None:
    raise RuntimeError("stream closed without a finish_reason: cut off, retry it")
print("".join(parts))

빈 스트림: 200 이후 아무것도 없음

스트림이 HTTP 200으로 시작한 뒤 콘텐츠 청크 하나 없이 종료되는 경우가 있습니다. 텍스트도, 도구 호출도 없고, 때로는 role 청크조차 없습니다. 이는 거의 항상 헤더가 전송된 후 업스트림에서 실패했다는 뜻입니다. 과부하된 제공업체, 자체 업스트림이 요청을 거부한 게이트웨이, 또는 본문을 삭제한 프록시가 원인일 수 있습니다. 연결이 끊긴 스트림으로 간주하고 백오프를 적용해 재시도하세요. 또한 실패한 응답 하나의 원시 본문을 로그로 남기세요. SDK가 건너뛴 스트림 내부의 오류 이벤트가 원인인 경우가 많기 때문입니다. Claude Code도 같은 조건에서 스트리밍 없이 요청을 재시도합니다.

Kunavo를 통해 호출하는 경우

Kunavo는 /v1/messages에서 표준 OpenAI 와이어 SSE(stream_options.include_usage 지원)와 Anthropic 와이어 이벤트를 스트리밍하므로, 위의 curl 재현은 호환성 테스트이기도 합니다. /v1/chat/completions의 Claude 및 GPT 모델에서 Kunavo 스트림은 답변 완료 여부와 관계없이 data: [DONE]으로 끝납니다. 완료된 경우에는 finish_reason 청크 뒤에, 업스트림이 답변 중간에 연결을 끊은 경우에는 오류 청크(type upstream_error, code upstream_disconnect 또는 upstream_timeout) 뒤에 옵니다. 따라서 OpenAI SDK는 조각을 전체 답변으로 전달하지 않고 APIError를 발생시킵니다. 콘텐츠가 전혀 나오기 전에 종료되거나 첫 토큰 전에 실패한 업스트림 스트림은 빈 200으로 사용자에게 전달되지 않습니다. 해당 모델에 채널이 하나라도 있으면 다른 채널에서 시도가 재시도되고, 그렇지 않으면 HTTP 오류로 반환됩니다. 출력이 전혀 나오기 전에 실패한 스트리밍 요청에는 요금이 부과되지 않습니다. 답변 중간에 끊긴 요청은 무료라고 보장되지 않으므로, 무료라고 가정하지 말고 다시 보내세요.

자주 묻는 질문

스트리밍 응답에서 usage가 null인 이유는 무엇인가요?

OpenAI 와이어 API에서는 stream_options: {"include_usage": true}를 전달하지 않으면 스트림에 usage가 포함되지 않습니다. 전달하면 최종 청크에 도착합니다. Anthropic 네이티브 스트림은 message_start/message_delta 이벤트에서 usage를 보고합니다.

끊긴 스트림과 정상적으로 완료된 스트림을 어떻게 구분하나요?

완료된 스트림은 finish_reason(또는 Anthropic의 message_stop)으로 끝난 다음 [DONE]이 옵니다. 이러한 표시 없이 연결이 닫혔다면 끊긴 것입니다. 짧은 답변이 아니라 재시도 가능한 실패로 처리하세요.

“SSE stream ended without [DONE]”은 무슨 뜻인가요?

클라이언트가 연결 끝까지 스트림을 읽었지만 OpenAI 호환 스트림을 닫는 data: [DONE] 줄을 받지 못했다는 뜻입니다. API가 해당 메시지를 보내지 않은 것입니다. 클라이언트 또는 에이전트가 이를 기록했습니다. 마지막 청크에 finish_reason이 있었다면 답변은 완성되었고 연결 종료 신호만 유실된 것입니다. 없었다면 프록시, 타임아웃 또는 서버 측 오류로 답변이 끊긴 것이므로 요청을 재시도해야 합니다.

“stream ended without finish_reason”은 무슨 뜻인가요?

클라이언트가 스트림 끝까지 읽었지만 어떤 청크에도 finish_reason이 없었다는 뜻입니다. finish_reason은 OpenAI 호환 스트림이 답변 완료 여부를 알리는 필드입니다(stop, length, tool_calls). 이 필드가 없으면 마지막 문장이 아무리 자연스러워 보여도 현재 텍스트는 조각에 불과합니다. 요청을 재시도하세요. 반복되면 사용자와 API 사이의 프록시 타임아웃이나 버퍼링을 확인하세요.

LLM API가 빈 스트림을 반환하는 이유는 무엇인가요?

HTTP 헤더가 전송된 후 실패가 발생했기 때문입니다. 제공업체의 과부하, 게이트웨이의 업스트림 요청 거부, 또는 프록시의 본문 삭제가 원인일 수 있습니다. 상태 줄은 여전히 200이므로 상태 확인만으로는 이를 감지할 수 없습니다. 콘텐츠 청크가 없는 스트림은 실패한 요청으로 처리하고 백오프를 적용해 재시도하세요. 내부 오류 이벤트를 확인할 수 있도록 원시 응답 하나를 로그로 남기세요.

이미 텍스트를 받았다면 [DONE] 누락을 무시해도 안전한가요?

마지막 청크에 finish_reason이 있었을 때만 가능합니다. 없으면 현재 텍스트는 문장 중간이나 도구 호출 중간에서 끝났을 수 있고 JSON 인수가 완성되지 않았을 수 있는 조각입니다. 답변으로 저장하지 말고 재시도하세요.

관련 가이드

오류 의미에 대한 자세한 내용은 오류 참조에서 확인할 수 있습니다. 가입 및 인증 가이드를 통해 1분이면 키를 받을 수 있습니다.