스트리밍 (Streaming)
스트리밍 (Streaming)
모델이 답변을 만들어 내는 동안 그 토큰을 조각조각 실시간으로 받아 쓰는 방식을 스트리밍이라고 불러요. vLLM은 온라인 서빙(OpenAI 호환 서버)과 오프라인 추론(AsyncLLM 엔진) 두 경로 모두에서 스트리밍을 지원해요. 답변 전체를 기다렸다가 한 번에 받는 대신, 생성되는 즉시 토큰이 전달되기 때문에 첫 토큰까지의 체감 지연(TTFT)이 짧아지고, 긴 답변을 사용자 화면에 바로바로 그려줄 수 있죠. 예를 들어 채팅 어시스턴트에서 답변이 한 글자씩 나오는 것처럼 보이게 하는 게 바로 이 스트리밍 기능 덕분이에요.
OpenAI 호환 서버에서 스트리밍
가장 자주 쓰는 방법은 vLLM의 OpenAI 호환 서버를 띄우고, OpenAI 공식 클라이언트로 요청할 때 stream=True를 넘기는 거예요. 서버는 SSE(Server-Sent Events) 형태로 생성된 토큰을 실시간으로 보내줘요.
먼저 서버를 실행할게요. 리즈닝 모델(예: DeepSeek-R1 계열)을 쓰려면 --reasoning-parser로 파서를 지정해 주면 돼요.
vllm serve deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B \
--reasoning-parser deepseek_r1
그다음 클라이언트에서 스트리밍 요청을 보내요. 핵심은 stream=True 하나예요.
from openai import OpenAI
openai_api_key = "EMPTY"
openai_api_base = "http://localhost:8000/v1"
messages = [{"role": "user", "content": "9.11 and 9.8, which is greater?"}]
def main():
client = OpenAI(
api_key=openai_api_key,
base_url=openai_api_base,
)
models = client.models.list()
model = models.data[0].id
stream = client.chat.completions.create(model=model, messages=messages, stream=True)
print("client: Start streaming chat completions...")
printed_reasoning = False
printed_content = False
for chunk in stream:
# delta에서 reasoning과 content를 안전하게 꺼낸다.
# 속성이 없거나 빈 문자열이면 None으로 처리한다.
reasoning = getattr(chunk.choices[0].delta, "reasoning", None) or None
content = getattr(chunk.choices[0].delta, "content", None) or None
if reasoning is not None:
if not printed_reasoning:
printed_reasoning = True
print("reasoning:", end="", flush=True)
print(reasoning, end="", flush=True)
elif content is not None:
if not printed_content:
printed_content = True
print("\ncontent:", end="", flush=True)
print(content, end="", flush=True)
if __name__ == "__main__":
main()
여기서 stream은 청크(chunk)를 하나씩 내주는 이터레이터예요. 반복문을 돌 때마다 모델이 방금 생성한 조각을 받아서 바로 화면에 찍어요. 각 청크의 chunk.choices[0].delta에 그 순간의 조각이 들어 있어요.
리즈닝 모델에서는 답변 본문(content)뿐 아니라 사고 과정(reasoning)도 스트리밍으로 내려와요. 그래서 위 코드는 reasoning 일부가 있는 동안엔 그것을, 더 이상 없고 content가 등장하면 그쪽으로 넘어가서 찍는 흐름이에요.
여기서 한 가지 꼭 짚어둘게요. delta에 content나 reasoning이 항상 존재하지는 않아요. 특정 청크에서는 이 값이 비어 있을 수 있고, 그대로 접근하면 오류가 날 수 있어요. 그래서 위 코드는 getattr(..., default)와 or None을 써서, 속성이 없거나 빈 문자열이면 None으로 만들어 안전하게 처리해요. 이 습관은 스트리밍 코드에서 매우 중요해요.
AsyncLLM 엔진에서 오프라인 스트리밍
서버 없이 파이썬 안에서 바로 스트리밍을 쓰고 싶다면 AsyncLLM(V1 엔진)을 쓰면 돼요. 비동기 제너레이터를 써서 토큰이 생성될 때마다 받아오는 패턴이에요. 아래는 vLLM이 공식 예제로 제공하는 async_llm_streaming.py의 핵심 흐름이에요.
import asyncio
from vllm import SamplingParams
from vllm.engine.arg_utils import AsyncEngineArgs
from vllm.sampling_params import RequestOutputKind
from vllm.v1.engine.async_llm import AsyncLLM
async def stream_response(engine: AsyncLLM, prompt: str, request_id: str) -> None:
# 스트리밍용 샘플링 파라미터: DELTA 출력 모드로 새 토큰만 받는다
sampling_params = SamplingParams(
max_tokens=100,
temperature=0.8,
top_p=0.95,
seed=42, # 재현 가능한 결과를 위해
output_kind=RequestOutputKind.DELTA, # 매 반복마다 새로 생성된 토큰만
)
try:
async for output in engine.generate(
request_id=request_id, prompt=prompt, sampling_params=sampling_params
):
for completion in output.outputs:
# DELTA 모드에서는 이전 반복 이후 새로 생긴 토큰만 들어 있다
new_text = completion.text
if new_text:
print(new_text, end="", flush=True)
# 생성이 끝났는지 확인
if output.finished:
print("\n✅ Generation complete!")
break
except Exception as e:
print(f"\n❌ Error during streaming: {e}")
raise
async def main():
# 간단한 설정으로 AsyncLLM 엔진 생성
engine_args = AsyncEngineArgs(
model="meta-llama/Llama-3.2-1B-Instruct",
enforce_eager=True, # 예제처럼 빠른 시작이 필요할 때
)
engine = AsyncLLM.from_engine_args(engine_args)
try:
prompts = [
"The future of artificial intelligence is",
"In a galaxy far, far away",
"The key to happiness is",
]
for i, prompt in enumerate(prompts, 1):
request_id = f"stream-example-{i}"
await stream_response(engine, prompt, request_id)
finally:
# 엔진은 항상 정리해 준다
engine.shutdown()
if __name__ == "__main__":
asyncio.run(main())
여기서 눈여겨볼 지점이 두 가지예요.
첫째, SamplingParams의 output_kind=RequestOutputKind.DELTA예요. 이 모드에서는 engine.generate()가 반복될 때마다 그 시점에 새로 생성된 토큰만 결과로 줘요. 그래서 중간에 끊겨도 항상 직전 토큰부터 이어서 받을 수 있는 거예요. 이걸 DELTA 모드 스트리밍이라고 불러요.
둘째, output.finished 플래그예요. 루프를 돌다가 이 값이 참이 되면 생성이 완전히 끝났다는 뜻이라 루프에서 빠져나와요. 이 플래그 없이 영원히 기다리지 않도록 꼭 확인해 줘요.
그리고 비동기 작업이라 반드시 asyncio.run(main())으로 실행하고, engine.shutdown()으로 엔진을 정리하는 것도 잊지 말아야 해요.
정리
스트리밍은 vLLM에서 두 갈래로 쓰인다는 점만 기억하면 돼요.
- 온라인(OpenAI 호환 서버): 요청에
stream=True를 넣고, 돌아오는 청크의delta에서 조각을 꺼내 쓴다.content/reasoning이 항상 있는 건 아니므로 안전하게(getattr+or None) 접근한다. - 오프라인(AsyncLLM 엔진):
output_kind=RequestOutputKind.DELTA로 설정하고,engine.generate()의 비동기 제너레이터를 돌며 새 토큰을 받고,output.finished로 종료를 판단한다.
어느 쪽이든 핵심은 같아요. "생성되는 즉시 조각을 받아서, 바로 화면에 보여준다"는 거예요. 그렇게 해서 사용자 체감 응답 속도를 크게 높일 수 있어요.
출처: vLLM 공식 문서 — OpenAI 호환 서버 스트리밍 예제(openai_chat_completion_with_reasoning_streaming.py)와 AsyncLLM 스트리밍 예제(async_llm_streaming.py).