LLM 앱 개발 실전 #3 스트리밍으로 응답 실시간 출력
2편까지의 호출은 응답이 다 만들어질 때까지 기다렸다가 한 번에 받았습니다. 짧은 답이면 괜찮지만, 긴 답은 수 초가 걸리는 동안 화면이 멈춰 있습니다. 이번 글에서는 생성되는 토큰을 그때그때 받아 화면에 흘려보내는 스트리밍을 다룹니다.
왜 스트리밍인가 #
messages.create는 Claude가 답을 전부 완성한 뒤에야 결과를 돌려줍니다. 답이 길수록 그만큼 오래 기다려야 하고, 그동안 사용자는 빈 화면을 보고 있게 됩니다.
하지만 스트리밍은 다릅니다. Claude가 토큰을 하나씩 생성하는 대로 받아서 즉시 화면에 출력합니다. ChatGPT나 Claude 웹에서 글자가 또르륵 나타나는 그 방식입니다. 답을 끝까지 만드는 데 걸리는 전체 시간은 비슷하지만, 첫 글자가 훨씬 빨리 보이기 때문에 체감 지연이 크게 줄어듭니다. 사용자 입장에서는 “멈춰 있다"가 아니라 “지금 답하고 있다"로 느껴집니다.
기본 스트리밍 #
스트리밍은 messages.create 대신 messages.stream을 with 문과 함께 씁니다.
import anthropic
client = anthropic.Anthropic()
with client.messages.stream(
model="claude-sonnet-4-6",
max_tokens=2048,
messages=[
{"role": "user", "content": "우주에 대한 짧은 이야기를 들려줘."}
],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)stream.text_stream은 생성되는 텍스트 조각을 차례로 내줍니다. 이걸 반복문으로 받아 바로 출력합니다. print에 end=""를 줘서 조각마다 줄바꿈이 끼지 않게 하고, flush=True로 버퍼에 쌓지 않고 즉시 화면에 내보냅니다. 그러면 글자가 흐르듯 나타납니다.
결과로 받는 답변 자체는 messages.create와 같습니다. 차이는 받는 방식뿐입니다. create는 다 만들어진 뒤 한 번에, stream은 만들어지는 대로 조금씩 받습니다.
끝난 뒤 완성된 응답 받기 #
스트리밍 중에는 텍스트 조각만 흘러옵니다. 그런데 답이 다 끝난 뒤에 완성된 메시지 전체, 예를 들어 사용한 토큰 수 같은 정보가 필요할 때가 있습니다. 그럴 때는 get_final_message를 씁니다.
with client.messages.stream(
model="claude-sonnet-4-6",
max_tokens=2048,
messages=[
{"role": "user", "content": "우주에 대한 짧은 이야기를 들려줘."}
],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
final = stream.get_final_message()
print(f"\n\n사용한 출력 토큰: {final.usage.output_tokens}")final은 1편과 2편에서 본 response와 같은 구조입니다. content 블록의 목록과 usage 같은 정보를 그대로 담고 있습니다. 화면에는 실시간으로 흘려보내면서, 끝난 뒤에는 전체를 한 번 더 손에 쥘 수 있습니다.
text_stream 대신 stream을 직접 순회하며 이벤트의 종류(event.type)를 확인하는 방법도 있습니다. 텍스트 블록이 어디서 시작하는지, 추론 블록인지 같은 것을 구분할 수 있습니다. 다만 이런 세밀한 제어가 필요해지는 건 툴 콜링(#6)이나 에이전트(#10) 단계이고, 지금은 text_stream만으로 충분합니다.멀티턴 대화에 스트리밍 적용하기 #
2편에서 본 멀티턴 대화에 스트리밍을 얹으면 실제 챗봇에 가까운 모습이 됩니다. 주고받은 메시지를 목록에 쌓으면서, 답변은 실시간으로 흘려보냅니다.
import anthropic
client = anthropic.Anthropic()
messages = []
def chat(user_input: str) -> None:
messages.append({"role": "user", "content": user_input})
answer = ""
with client.messages.stream(
model="claude-sonnet-4-6",
max_tokens=2048,
messages=messages,
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
answer += text
print()
messages.append({"role": "assistant", "content": answer})
chat("파이썬으로 별 삼각형을 그리는 코드를 보여줘.")
chat("방금 코드를 함수로 묶어줘.")화면에는 글자가 실시간으로 흐르고, 동시에 answer에 전체 답변을 모아 둡니다. 스트리밍이 끝나면 그 답변을 assistant 메시지로 목록에 더합니다. 이렇게 해야 다음 turn에서 Claude가 직전 답변을 기억합니다. 2편의 누적 패턴과 똑같고, 받는 방식만 스트리밍으로 바뀌었을 뿐입니다.
비동기로 스트리밍하기 #
웹 서버처럼 여러 요청을 동시에 처리하는 환경에서는 비동기 방식이 더 잘 맞습니다. SDK는 AsyncAnthropic 클라이언트를 제공하고, 사용법은 동기 버전과 거의 같습니다. with가 async with로, 반복이 async for로 바뀔 뿐입니다.
import asyncio
import anthropic
client = anthropic.AsyncAnthropic()
async def main():
async with client.messages.stream(
model="claude-sonnet-4-6",
max_tokens=2048,
messages=[
{"role": "user", "content": "우주에 대한 짧은 이야기를 들려줘."}
],
) as stream:
async for text in stream.text_stream:
print(text, end="", flush=True)
asyncio.run(main())골격은 동기 버전과 그대로 겹칩니다. 동시 접속이 많은 웹 서비스라면 처음부터 비동기로 짜 두는 편이 나중에 편합니다.
웹 화면까지 흘려보내기 #
지금까지는 서버에서 콘솔로 출력했습니다. 사용자 브라우저까지 실시간으로 흘려보내려면, 서버가 클라이언트로 보내는 스트리밍 응답에 text_stream을 연결합니다. FastAPI라면 StreamingResponse를 씁니다.
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import anthropic
app = FastAPI()
client = anthropic.AsyncAnthropic()
@app.get("/chat")
async def chat(q: str):
async def generate():
async with client.messages.stream(
model="claude-sonnet-4-6",
max_tokens=2048,
messages=[{"role": "user", "content": q}],
) as stream:
async for text in stream.text_stream:
yield text
return StreamingResponse(generate(), media_type="text/plain")generate가 토큰을 yield할 때마다 그 조각이 브라우저로 흘러갑니다. 실제 서비스에서는 보통 SSE(Server-Sent Events) 형식으로 감싸지만, 골격은 이처럼 단순합니다. 프런트엔드에서 이 응답을 조각조각 받아 화면에 이어 붙이면 됩니다.
흔히 걸려 넘어지는 곳 #
with없이 쓴다 —messages.stream은 컨텍스트 매니저입니다.with문과 함께 써야 스트림이 끝난 뒤 제대로 정리됩니다.- 글자가 실시간으로 안 보인다 —
flush=True를 빼면 출력이 터미널 버퍼에 쌓였다가 한꺼번에 나올 수 있습니다. 조각이 모였다가 뭉텅이로 출력되면flush부터 확인합니다. - 비동기 클라이언트를 동기 코드에서 쓴다 —
AsyncAnthropic은async/await문맥에서만 동작합니다. 일반 스크립트라면 동기Anthropic을 쓰거나, 위 예제처럼asyncio.run으로 감싼async함수 안에서 호출합니다.
마무리 #
이번 글에서는 응답을 실시간으로 받아 출력하는 스트리밍을 다뤘습니다.
- 스트리밍은 생성되는 토큰을 그때그때 받아, 첫 글자까지의 체감 지연을 줄입니다.
messages.stream을with와 함께 쓰고text_stream을 반복하면 됩니다.- 멀티턴 대화에 얹을 때는, 화면에 흘려보내면서 모은 답변을
assistant메시지로 다시 더합니다. - 비동기 환경에서는
AsyncAnthropic으로async with,async for를 씁니다. - 끝난 뒤 완성된 응답 전체가 필요하면
get_final_message를 씁니다. - 웹이라면
StreamingResponse같은 스트리밍 응답에text_stream을 연결합니다.
다음 글인 “LLM 앱 개발 실전 #4 프롬프트 엔지니어링 실무"에서는 같은 질문이라도 어떻게 묻느냐에 따라 답의 품질이 어떻게 달라지는지, 그리고 원하는 결과를 안정적으로 끌어내는 프롬프트 작성법을 다루겠습니다.