
4개의 GIF로 알아보는 LLM 응답 스트리밍
요약
LLM의 응답 스트리밍 원리와 SSE(Server-Sent Events)의 작동 방식을 설명합니다. 스트리밍은 모델의 속도를 높이는 것이 아니라, 첫 토큰이 전달되는 시간을 단축하여 사용자 경험을 개선하는 기술입니다.
핵심 포인트
- 스트리밍은 모델 속도가 아닌 체감 대기 시간을 줄이는 기술임
- 스트리밍 구현을 위해 HTTP 연결을 유지하는 SSE 방식을 사용함
- Anthropic SDK의 .stream() 메서드를 통해 간편하게 구현 가능함
- 데이터는 content_block_delta 이벤트 내 delta.text를 통해 전달됨
우리는 이전에도 LLM으로부터 토큰이 하나씩 스트리밍되어 마치 모델이 타이핑을 하는 것처럼 나타나는 것을 본 적이 있습니다. Anthropic SDK의 .stream() 메서드를 사용했다면, 별도의 설정 없이도 잘 작동했을 것이며 아마 네트워크 상에서 실제로 어떤 일이 일어나는지는 보지 못했을 것입니다.
이 포스트는 스트림 응답(stream response)이 어떻게 작동하는지, 그리고 SDK 내부에서 버그가 어떻게 처리되는지에 중점을 두어 다룹니다.
1. 스트리밍이 존재하는 이유
스트리밍 옵션을 활성화하려면 POST 요청에서 단 하나의 필드인 "stream": true를 추가하는 변경만 하면 되며, 이는 응답 경험을 완전히 바꿔놓습니다.
위 GIF에서 알 수 있는 포인트는 다음과 같습니다.
- 왼쪽은 스트리밍이 없는 상태로, 커서가 4초 동안 깜빡인 후 전체 응답이 한꺼번에 나타납니다.
- 오른쪽은 스트리밍 상태로, 첫 단어가 약 300밀리초(ms) 만에 나타납니다. 모델이 생성함에 따라 단어들이 흘러 들어옵니다.
양쪽 모두 동일한 모델, 동일한 프롬프트(prompt), 동일한 총 소요 시간을 가집니다. 단지 오른쪽이 거의 4초 더 일찍 응답을 시작했을 뿐입니다. 전체 답변을 위해 4초를 기다리는 것은 고장 난 것처럼 느껴집니다. 4초 만에 끝나는 스트리밍된 답변은 빠르게 느껴집니다. 스트리밍은 모델을 더 빠르게 만드는 것이 아니라, 기다림을 사라지게 만듭니다.
2. 네트워크 상에 무엇이 있는가
stream: true를 설정하면, API는 단일 JSON 블롭(blob)을 보내는 것을 중단합니다. 대신 지속적인 HTTP 연결을 열고 모델이 생성하는 대로 이벤트를 전달합니다. 이 형식은 웹 표준인 SSE (Server-Sent Events)입니다. 어떤 SSE 디버거로든 이 스트림을 읽을 수 있습니다.
전달되는 내용은 다음과 같습니다:
텍스트는 content_block_delta 이벤트 내부에 중첩된 delta.text에 존재합니다. 우리가 주시해야 할 이벤트는 바로 이것입니다.
stop_reason의 위치가 변경되었습니다. 첫 번째 포스트에서는 응답 JSON의 바로 그 위치에서 이를 확인했습니다. 여기서는 message_stop 직전, message_delta 이벤트 내부의 맨 마지막에 도착합니다. 만약 텍스트가 도착하는 즉시 루프를 종료해 버린다면, 이 값을 절대 볼 수 없을 것입니다.
청크(Chunks)는 토큰(Tokens)이나 단어와 일치하지 않습니다. 한 청크에서 "Hello"를 받고 다음 청크에서 " world"를 받을 수도 있고, 두 단어가 한 청크에 모두 포함될 수도 있습니다. 어디서 자를지는 모델이나 API가 아니라 네트워크가 결정합니다.
이것이 바로 SDK가 여러분에게 숨겨왔던 사실입니다.
3. 스트림 읽기 (Reading the stream)
스트리밍은 루프를 직접 작성해 보기 전까지는 복잡하게 느껴질 수 있습니다. 하지만 실제로는 바이트(Bytes)를 읽고, 버퍼링(Buffering)하고, 빈 줄을 기준으로 나누고, JSON을 파싱(Parsing)하는 과정일 뿐입니다.
흐름은 다음과 같습니다:
- 응답 본문(Response body)은
for await로 반복할 수 있는ReadableStream입니다. - 각 반복(Iteration)마다 문자열로 디코딩할 수 있는 바이트를 얻습니다.
- 문자열을 버퍼링합니다. 하나의 청크가 메시지 중간에서 끝날 수 있기 때문입니다.
- 버퍼를
\n\n을 기준으로 나눕니다. 이것이 SSE(Server-Sent Events) 메시지 구분자입니다. - 버퍼의 마지막 항목은 유지합니다. 불완전할 수 있기 때문입니다.
- 각 완성된 메시지에 대해
data:라인을 찾아 접두사를 제거하고 JSON을 파싱합니다. - 타입이
content_block_delta라면delta.text를 출력합니다. - 타입이
message_delta라면stop_reason을 얻게 됩니다.
직접 테스트해 볼 수 있는 전체 샘플 코드입니다:
const prompt = process.argv[2] ?? "Count to 10, slowly.";
const response = await fetch("https://api.anthropic.com/v1/messages", {
...
작동 방식은 메시지 중간에서 청크 (chunk)가 끝날 경우, split("\n\n")이 마지막 항목으로 미완성된 파편을 남기게 됩니다. pop()은 이를 다시 버퍼 (buffer)로 가져와 다음 청크가 이를 완성할 수 있도록 합니다. 이 줄이 없다면, 분할된 모든 메시지는 파서 (parser)를 충돌시킵니다.
data.delta.type === "text_delta" 체크가 중요한 이유는 content_block_delta가 다른 델타 (delta) 타입들도 포함할 수 있기 때문입니다: 도구 인자 (tool arguments)를 위한 input_json_delta, 확장된 사고 (extended thinking)를 위한 thinking_delta, 검증을 위한 signature_delta 등이 있습니다. 현재로서는 텍스트에만 집중합니다.
전체 구현체는 GitHub에서도 확인하실 수 있습니다.
4. 세 가지 버그
위의 코드는 상황이 좋을 때는 잘 작동합니다. 하지만 상황이 좋지 않을 때 코드를 망가뜨리는 요소들은 다음과 같습니다.
유령 스트림 (The ghost stream). 문제는 사용자가 페이지를 벗어났음에도 스트림 (stream)은 계속 실행되고, 읽을 사람이 없는데도 토큰 (tokens)이 계속 도착한다는 점입니다. 이를 해결하려면 fetch에 AbortController 시그널 (signal)을 전달하고, 작업이 끝나면 abort()를 호출해야 합니다.
해결책은 AbortController를 사용하는 것입니다:
const controller = new AbortController();
const response = await fetch(url, { signal: controller.signal, ...options });
// 나중에, 사용자가 페이지를 벗어날 때:
...
조용한 잘림 (The silent truncation). API는 과부하 상황에서 스트림 중간에 error 이벤트를 보낼 수 있습니다. 만약 루프 (loop)가 content_block_delta만 처리한다면, 에러는 건너뛰어지게 되고 결국 예외 (exception) 없이 잘린 응답을 받게 됩니다. 해결책은 data.type === "error"를 명시적으로 처리하는 것입니다.
if (data.type === "error") {
throw new Error(`Stream error: ${data.error.message}`);
}
...
분할된 패킷 (The split packet). 단일 SSE 메시지가 두 개의 TCP 패킷으로 나누어져 도착할 수 있습니다. 버퍼링 (Buffering)이 없다면, JSON.parse는 절반만 온 데이터에 대해 오류를 발생시킵니다. buffer = messages.pop() ?? "" 코드가 이 문제를 해결하는데, 이는 다음 청크 (Chunk)가 불완전한 조각을 완성할 때까지 이를 보관합니다.
스트림에서의 stop_reason
첫 번째 포스트에서 stop_reason은 응답 JSON에 바로 포함되어 있었습니다. 스트림 (Stream)에서도 마찬가지로 end_turn, max_tokens, tool_use, stop_sequence라는 동일한 네 가지 값이 전달되지만, 스트림의 끝부분 근처에서 message_delta 이벤트 내부에 포함되어 도착합니다.
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn",...}}
첫 번째 포스트의 규칙이 동일하게 적용됩니다. 만약 stop_reason을 무시한다면 버그를 배포하게 될 것입니다. 스트림 응답에서 max_tokens에 의해 잘린 경우(cutoff)는 일반적인 스트림 종료와 완전히 똑같이 보입니다. 이 이벤트를 읽지 않는 한, 모델의 응답이 잘렸다는 사실을 알 수 없습니다.
다음 포스트 전에 시도해 볼 세 가지
1. 스트리밍 코드를 실행해 보세요. 그다음 `
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기

