LLM 앱 개발 실전 #3 스트리밍으로 응답 실시간 출력

5 분 소요

2편까지의 호출은 응답이 다 만들어질 때까지 기다렸다가 한 번에 받았습니다. 짧은 답이면 괜찮지만, 긴 답은 수 초가 걸리는 동안 화면이 멈춰 있습니다. 이번 글에서는 생성되는 토큰을 그때그때 받아 화면에 흘려보내는 스트리밍을 다룹니다.

왜 스트리밍인가 #

messages.create는 Claude가 답을 전부 완성한 뒤에야 결과를 돌려줍니다. 답이 길수록 그만큼 오래 기다려야 하고, 그동안 사용자는 빈 화면을 보고 있게 됩니다.

하지만 스트리밍은 다릅니다. Claude가 토큰을 하나씩 생성하는 대로 받아서 즉시 화면에 출력합니다. ChatGPT나 Claude 웹에서 글자가 또르륵 나타나는 그 방식입니다. 답을 끝까지 만드는 데 걸리는 전체 시간은 비슷하지만, 첫 글자가 훨씬 빨리 보이기 때문에 체감 지연이 크게 줄어듭니다. 사용자 입장에서는 “멈춰 있다"가 아니라 “지금 답하고 있다"로 느껴집니다.

기본 스트리밍 #

스트리밍은 messages.create 대신 messages.streamwith 문과 함께 씁니다.

streaming.py
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은 생성되는 텍스트 조각을 차례로 내줍니다. 이걸 반복문으로 받아 바로 출력합니다. printend=""를 줘서 조각마다 줄바꿈이 끼지 않게 하고, flush=True로 버퍼에 쌓지 않고 즉시 화면에 내보냅니다. 그러면 글자가 흐르듯 나타납니다.

결과로 받는 답변 자체는 messages.create와 같습니다. 차이는 받는 방식뿐입니다. create는 다 만들어진 뒤 한 번에, stream은 만들어지는 대로 조금씩 받습니다.

끝난 뒤 완성된 응답 받기 #

스트리밍 중에는 텍스트 조각만 흘러옵니다. 그런데 답이 다 끝난 뒤에 완성된 메시지 전체, 예를 들어 사용한 토큰 수 같은 정보가 필요할 때가 있습니다. 그럴 때는 get_final_message를 씁니다.

streaming_final.py
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편에서 본 멀티턴 대화에 스트리밍을 얹으면 실제 챗봇에 가까운 모습이 됩니다. 주고받은 메시지를 목록에 쌓으면서, 답변은 실시간으로 흘려보냅니다.

streaming_chat.py
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 클라이언트를 제공하고, 사용법은 동기 버전과 거의 같습니다. withasync with로, 반복이 async for로 바뀔 뿐입니다.

async_streaming.py
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를 씁니다.

fastapi_streaming.py
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부터 확인합니다.
  • 비동기 클라이언트를 동기 코드에서 쓴다AsyncAnthropicasync/await 문맥에서만 동작합니다. 일반 스크립트라면 동기 Anthropic을 쓰거나, 위 예제처럼 asyncio.run으로 감싼 async 함수 안에서 호출합니다.

마무리 #

이번 글에서는 응답을 실시간으로 받아 출력하는 스트리밍을 다뤘습니다.

  • 스트리밍은 생성되는 토큰을 그때그때 받아, 첫 글자까지의 체감 지연을 줄입니다.
  • messages.streamwith와 함께 쓰고 text_stream을 반복하면 됩니다.
  • 멀티턴 대화에 얹을 때는, 화면에 흘려보내면서 모은 답변을 assistant 메시지로 다시 더합니다.
  • 비동기 환경에서는 AsyncAnthropic으로 async with , async for를 씁니다.
  • 끝난 뒤 완성된 응답 전체가 필요하면 get_final_message를 씁니다.
  • 웹이라면 StreamingResponse 같은 스트리밍 응답에 text_stream을 연결합니다.

다음 글인 “LLM 앱 개발 실전 #4 프롬프트 엔지니어링 실무"에서는 같은 질문이라도 어떻게 묻느냐에 따라 답의 품질이 어떻게 달라지는지, 그리고 원하는 결과를 안정적으로 끌어내는 프롬프트 작성법을 다루겠습니다.

X