RSS듀오랩스
AI 자동화

LLM 토큰 스트리밍은 응답을 빠르게 만들까?

작성자
듀오랩스 대표·12분 읽기

같은 모델에 같은 질문을 두 번 보낸다고 해 보겠습니다. 한 번은 stream: false, 한 번은 stream: true 입니다. 스트리밍 쪽이 훨씬 빠르게 느껴집니다. 그런데 마지막 글자가 도착하는 시각은 두 쪽이 거의 같습니다. 모델은 어느 쪽이든 토큰을 하나씩 순서대로 만들고, 스트리밍은 그 순서를 바꾸지 않기 때문입니다.

흔히 스트리밍을 "응답을 빨리 받는 방법"으로 이해합니다. 그렇게 알고 있으면 스트리밍을 켰는데 서버 비용도, 전체 처리 시간도 그대로인 것이 설명되지 않습니다. 스트리밍이 줄이는 것은 생성 시간이 아니라 기다림입니다. 그리고 그 대가로 꽤 많은 것을 내놓습니다. 이 글의 절반은 그 대가에 관한 이야기입니다.

전체 시간은 그대로, 줄어드는 것은 첫 글자까지의 시간

LLM 응답 시간은 두 덩어리로 나뉩니다. 프롬프트를 읽고 첫 토큰을 내기까지의 시간(TTFT, time to first token)과, 그 뒤 토큰을 하나씩 내는 시간입니다. 스트리밍이 없으면 사용자는 둘을 합친 시간 동안 빈 화면을 봅니다. 스트리밍이 있으면 첫 덩어리만 기다리고, 두 번째 덩어리는 읽으면서 보냅니다.

그래서 스트리밍의 효과는 답이 길수록 커집니다. 한 문장짜리 답이면 TTFT 가 곧 전체 시간이라 켜나 끄나 체감이 비슷합니다. 반대로 긴 설명이나 코드를 쓰는 답에서는, 사람이 읽는 속도보다 모델이 쓰는 속도가 빠른 한 사용자는 기다린다고 느끼지 않습니다.

저는 이 구분이 설계 판단의 출발점이라고 봅니다. "빠르게 하고 싶다"가 TTFT 문제인지 전체 시간 문제인지를 먼저 나눠야 합니다. 전체 시간이 문제라면 스트리밍이 아니라 더 작은 모델, 짧은 출력, 프롬프트 캐시 쪽을 봐야 합니다.

토큰이 전선 위에서 오는 모양

"토큰 스트리밍"이라고 부르지만 전송 단위가 토큰과 1:1 인 것은 아닙니다. Anthropic 의 Messages API 스트리밍은 message_start, content_block_delta, message_delta, message_stop 같은 이벤트를 Server-Sent Events 로 보내고, 글자는 text_delta 에 조각으로 담깁니다. 한 조각에 몇 토큰이 들어갈지는 명세가 약속하지 않습니다.

형식도 하나가 아닙니다. OpenAI 호환 API 는 SSE 로 data: 줄을 보내다가 data: [DONE] 으로 끝을 알립니다. Ollama 의 네이티브 /api/chat 은 SSE 가 아니라 줄마다 JSON 객체 하나인 NDJSON 을 보내고, stream 의 기본값이 true 입니다. "스트리밍은 곧 SSE"라고 기억하면 여기서 파서가 틀립니다.

브라우저에서 fetch 로 바이트를 직접 읽을 때는 한 가지를 더 챙겨야 합니다. 한글 한 글자는 UTF-8 로 3바이트라서 네트워크 청크 경계에서 잘릴 수 있습니다. 청크마다 새로 디코딩하면 글자가 깨지므로 TextDecoder 를 하나 두고 decode(chunk, { stream: true }) 로 이어 붙여야 합니다.

상태 코드를 먼저 보내 버린 응답

여기서부터가 대가입니다. 스트리밍 응답은 첫 바이트를 보내는 순간 HTTP 상태 코드와 헤더가 확정됩니다. 200 을 보내고 글자를 흘리던 중에 제공자 쪽이 과부하에 걸리면, 그 실패를 상태 코드로는 더 이상 알릴 수 없습니다.

그래서 제공자들은 오류를 스트림 안의 이벤트로 보냅니다. Anthropic 문서도 스트림 도중 overloaded_error 같은 error 이벤트가 올 수 있다고 적어 둡니다. 클라이언트가 이 이벤트를 처리하지 않으면 사용자 화면에는 문장 중간에서 멈춘 답이 "완료된 답"처럼 남습니다. 오류가 조용해지는 것이 스트리밍의 가장 흔한 함정이라고 저는 봅니다.

재시도도 어려워집니다. 절반쯤 나간 답을 이어서 받는 표준적인 방법은 없습니다. 다시 요청하면 모델은 처음부터 다른 문장을 씁니다. 이미 화면에 나간 앞부분을 지우고 새로 쓸지, 실패로 표시할지는 제품이 정해야 합니다.

다 쓰기 전에는 검사할 수 없는 출력

스트리밍하지 않는 응답은 사용자에게 보여 주기 전에 한 번 거를 수 있습니다. 개인정보를 가리고, 금지된 내용을 검사하고, JSON 이면 스키마에 맞는지 확인합니다. 스트리밍은 이 순서를 뒤집습니다. 검사하기 전에 이미 보여 줬습니다.

구조화 출력에서 이 문제가 가장 분명합니다. 중간까지 온 JSON 은 JSON 이 아닙니다. 도구 호출 인자도 Anthropic 은 input_json_delta 로 조각내 보내는데, 조각이 담기는 필드 이름부터 partial_json 입니다. 조각을 끝까지 모은 뒤에야 파싱할 수 있습니다. 표 한 장을 채우는 추출 작업이라면 스트리밍으로 얻는 것이 거의 없고 파싱만 어려워집니다.

출력 검사가 꼭 필요한 화면이라면 선택지는 둘입니다. 문장 단위로 모아 검사한 뒤 내보내거나, 스트리밍을 포기하는 것입니다. 앞쪽은 TTFT 의 이득을 일부 되돌려 줍니다. 저는 검사가 규제나 개인정보 때문이라면 뒤쪽을 고르겠습니다.

사용자가 떠나도 계속 도는 생성

스트리밍 화면에는 "중지" 버튼이 거의 반드시 붙습니다. 그런데 버튼이 브라우저의 fetch 만 끊고 서버가 제공자 호출을 계속 들고 있으면, 사용자는 떠났는데 생성은 끝까지 돌고 비용도 끝까지 나갑니다.

취소는 세 구간을 모두 지나야 완성됩니다. 브라우저가 연결을 닫고(AbortController), 서버가 그 신호를 받아 제공자로 가는 요청을 끊고, 제공자가 생성을 멈추는 것입니다. 앞의 두 구간은 우리 코드이고, 마지막 구간에서 이미 만든 토큰을 어떻게 과금하는지는 제공자의 정책입니다. 이 부분은 제공자마다 문서를 따로 확인해야 하고 저도 일반화할 자신이 없습니다.

사용량 기록도 같은 이유로 까다롭습니다. 토큰 수는 스트림 끝에 옵니다. OpenAI 는 stream_options: { include_usage: true } 를 켜야 마지막 청크에 usage 를 실어 줍니다. 중간에 끊긴 요청은 그 마지막 청크를 받지 못하므로, 원장에 무엇을 남길지 따로 정해야 합니다.

서버와 프록시가 떠안는 연결

스트리밍 요청은 생성이 끝날 때까지 연결을 붙들고 있습니다. 응답 한 번을 수십 초 동안 들고 있는 요청이 동시에 여러 개 열리면, 짧은 요청을 전제로 잡은 설정들이 하나씩 문제를 일으킵니다.

대표적인 것이 리버스 프록시입니다. nginx 는 기본으로 proxy_buffering on 이라 응답을 모았다가 내보내므로, 로컬에서는 흐르던 글자가 운영에서는 한 번에 나타납니다. 응답 헤더 X-Accel-Buffering: no 로 끌 수 있습니다. 같은 모듈의 proxy_read_timeout 기본값은 60초라서, 60초 넘게 조용한 생성은 중간에 잘립니다. 제공자들이 스트림 사이사이에 ping 이벤트를 끼워 보내는 것도 이런 중간 장비가 연결을 죽이지 않게 하려는 것입니다.

화면이 치르는 비용

글자가 들어올 때마다 화면을 다시 그리는 것도 공짜가 아닙니다. 답이 마크다운이면 조각이 올 때마다 전체를 다시 파싱하게 되고, 닫히지 않은 코드 블록이나 표가 들어오는 동안 레이아웃이 계속 흔들립니다. 사용자가 위로 스크롤해 앞부분을 읽는데 새 글자가 올 때마다 맨 아래로 끌려 내려가는 문제도 여기서 나옵니다.

접근성도 따로 챙겨야 합니다. 답변 영역에 aria-live 를 걸어 두면 스크린리더가 조각마다 읽으려 들어 오히려 알아듣기 어려워집니다. 생성 중에는 "작성 중" 상태만 알리고, 끝난 뒤 한 번 읽게 하는 편이 낫다고 봅니다.

80개 화면에 대입해 본 결과

이 기준을 실제 코드에 대 본 적이 있습니다. 듀오랩스 AI 데모 플랫폼은 화면이 80개 가까이 되고, 그중 스트리밍을 쓰는 곳은 질문 답변, 어시스턴트, 플레이그라운드, 번역 네 곳뿐입니다. 나머지를 전부 스트리밍으로 바꿀지 따져 보면서 모듈을 나눠 보니 세 부류가 나왔습니다.

부류 해당 화면 판단
사람이 읽는 긴 자유 글 생성형 패턴 6종, 콘텐츠 작성, 음성 메모 요약, 에이전트 테스트 채팅 바꿀 가치가 큽니다
구조화 추출 추출형 패턴 16종, 문서·회의록·영수증 추출, 견적서 등 30여 곳 바꾸지 않습니다
짧은 판정, 배치·평가 라우터, 가드레일, 일괄 처리, 회귀 평가 효과가 거의 없습니다

첫 부류에서 에이전트 채팅은 따로 볼 만합니다. 툴을 부르는 동안 "날씨 조회 중" 같은 진행 표시를 함께 흘릴 수 있어서, 글자가 빨리 나오는 것보다 그 신호가 더 큰 차이를 만든다고 봅니다. 생성형 패턴 6종은 한 실행 경로를 공유하고 있어서, 그 한 곳을 고치면 여섯 화면이 한꺼번에 바뀝니다.

두 번째 부류는 앞에서 말한 부분 JSON 문제가 그대로 걸립니다. {"items":[{"na 같은 조각은 사람이 읽을 수 없고, 표와 배지는 완성본이 있어야 그려집니다. 부분 객체를 파싱해 표의 행이 하나씩 차오르게 하는 방법은 있습니다. 다만 이 플랫폼의 추출 경로는 모델 서버의 스키마 제약 디코딩에 기대고 있고, 모델 계열마다 그 동작이 달라 다시 검증할 것이 많습니다. 그 비용을 치르고 얻는 것이 "행이 하나씩 나타남" 정도라면 저는 하지 않겠습니다.

세 번째 부류의 배치·평가 화면은 이미 행 단위로 진행 상황을 보여 줍니다. 사람이 글을 읽는 화면이 아니라 채점표를 보는 화면이고, 행이 채워지는 것 자체가 스트리밍이 주려던 체감을 대신합니다.

기다림의 원인이 대기열일 때

나눠 보면서 알게 된 것이 하나 더 있습니다. 추출 화면이 느리게 느껴지는 이유는 생성 속도가 아니었습니다. 이 플랫폼은 모델 서버 한 대를 여러 화면이 나눠 쓰는 구조라 요청이 줄을 서고, 쓰지 않던 모델을 올리는 동안에도 기다려야 합니다. 그동안 화면에는 아무 신호가 없습니다.

이 대기는 TTFT 안에 들어 있습니다. 스트리밍은 첫 토큰 이후를 앞당길 뿐이라 첫 토큰 이전의 대기는 한 글자도 줄이지 못합니다. 대기열은 이미 순번과 예상 시간을 알고 있고, 질문 답변 화면만 그것을 "지금 2명이 먼저 질문 중입니다. 약 8초 뒤 자동으로 답변이 시작됩니다"처럼 보여 주고 있습니다. 추출 화면에는 스트리밍보다 이 한 줄이 싸고 효과도 넓다고 봅니다.

바꾸는 순서도 여기서 정해집니다. 지금 스트리밍을 쓰는 네 곳은 서버의 스트림 작성 코드와 화면의 읽기 코드를 각자 따로 갖고 있습니다. 공용 헬퍼로 먼저 묶지 않으면 화면을 하나 바꿀 때마다 앞에서 말한 오류 이벤트, 취소, 사용량 기록을 또 한 벌씩 새로 짜게 됩니다. 이 글을 쓰는 시점에 전환은 아직 하지 않았고, 순서만 이렇게 정해 두었습니다.

기본값은 끄고, 켤 때는 한 묶음으로

스트리밍은 사람이 읽는 긴 자유 텍스트에서만 확실히 이득입니다. 결과를 코드가 받는 호출은 끝까지 기다렸다 한 번에 받는 편이 오류 처리, 검증, 재시도가 모두 단순합니다.

저는 기본값을 "스트리밍 끔"으로 두고, 사람이 읽는 화면에서만 켜는 쪽을 권합니다. 켤 때는 스트림 안의 오류 이벤트, 세 구간의 취소, 끊긴 요청의 사용량 기록, 프록시 버퍼링과 타임아웃을 한 묶음으로 챙겨야 합니다. 하나라도 빠지면 스트리밍은 빠른 화면이 아니라 조용히 틀리는 화면이 됩니다.

스트리밍이 모델 선택, 폴백, 구조화 출력 같은 이웃 개념 사이에서 어디쯤 놓이는지는 AI 제품 개발 핵심 개념 지도에 정리해 두었습니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.