스트리밍
스트리밍은 LLM 기반 애플리케이션의 응답성을 향상시키는 데 매우 중요합니다. 완전한 응답이 준비되기 전에 출력을 점진적으로 표시함으로써, LLM의 지연 시간을 다룰 때 사용자 경험(UX)을 크게 개선할 수 있습니다.
개요
스트리밍 시스템을 사용하면 에이전트 실행으로부터 실시간 피드백을 애플리케이션에 제공할 수 있습니다.
스트리밍으로 가능한 것들:
- 에이전트 진행 상황 스트리밍: 각 에이전트 단계 후 상태 업데이트를 받습니다.
- LLM 토큰 스트리밍: 언어 모델 토큰이 생성되는 대로 스트리밍합니다.
- 커스텀 업데이트 스트리밍: 사용자 정의 신호를 방출합니다 (예:
"100개 중 10개의 레코드를 가져왔습니다"). - 다중 모드 스트리밍:
updates(에이전트 진행 상황),messages(LLM 토큰 + 메타데이터), 또는custom(임의의 사용자 데이터) 중에서 선택합니다.
Agent progress
에이전트 진행 상황을 스트리밍하려면 stream() 또는 astream() 메서드를 stream_mode="updates"와 함께 사용하세요. 이는 모든 에이전트 단계 후에 이벤트를 방출합니다.
예를 들어, 도구를 한 번 호출하는 에이전트가 있다면 다음과 같은 업데이트를 볼 수 있습니다:
- LLM 노드: 도구 호출 요청이 포함된
AIMessage - 도구 노드: 실행 결과가 포함된
ToolMessage - LLM 노드: 최종 AI 응답
from langchain.agents import create_agent
def get_weather(city: str) -> str:
"""지정된 도시의 날씨를 가져옵니다."""
return f"{city}에는 항상 햇살이 가득해요!"
agent = create_agent(
model="openai:gpt-5-nano",
tools=[get_weather],
)
for chunk in agent.stream(
{"messages": [{"role": "user", "content": "샌프란시스코 날씨는 어때요?"}]},
stream_mode="updates",
):
for step, data in chunk.items():
print(f"step: {step}")
print(f"content: {data['messages'][-1].content_blocks}")step: model
content: [{'type': 'tool_call', 'name': 'get_weather', 'args': {'city': 'San Francisco'}, 'id': 'call_teanAF831fU4ZyRRKkDaceRZ'}]
step: tools
content: [{'type': 'text', 'text': 'San Francisco에는 항상 햇살이 가득해요!'}]
step: model
content: [{'type': 'text', 'text': '샌프란시스코의 현재 날씨는 맑고 햇빛이 강한 편이에요. 원하시면 구체적인 기온, 바람 속도, 습도 같은 자세한 정보를 알려드릴게요. 다른 도시의 날씨도 궁금하신가요?'}]
LLM tokens
LLM이 생성하는 토큰을 스트리밍하려면 stream_mode="messages"를 사용하세요. 아래에서 도구 호출과 최종 응답을 스트리밍하는 에이전트의 출력을 볼 수 있습니다.
for token, metadata in agent.stream(
{"messages": [{"role": "user", "content": "샌프란시스코 날씨는 어때요?"}]},
stream_mode="messages", # <- messages를 사용
):
print(f"node: {metadata['langgraph_node']}")
print(f"content: {token.content_blocks}")
print("\n")node: model
content: [{'type': 'tool_call_chunk', 'id': 'call_UhqORKbJRptURTgeWRp6o4fR', 'name': 'get_weather', 'args': '', 'index': 0}]
node: model
content: [{'type': 'tool_call_chunk', 'id': None, 'name': None, 'args': '{"', 'index': 0}]
node: model
content: [{'type': 'tool_call_chunk', 'id': None, 'name': None, 'args': 'city', 'index': 0}]
node: model
content: [{'type': 'tool_call_chunk', 'id': None, 'name': None, 'args': '":"', 'index': 0}]
node: model
content: [{'type': 'tool_call_chunk', 'id': None, 'name': None, 'args': 'San', 'index': 0}]
node: model
content: [{'type': 'tool_call_chunk', 'id': None, 'name': None, 'args': ' Francisco', 'index': 0}]
node: model
content: [{'type': 'tool_call_chunk', 'id': None, 'name': None, 'args': '"}', 'index': 0}]
node: model
content: []
node: model
content: []
node: model
content: []
node: tools
content: [{'type': 'text', 'text': 'San Francisco에는 항상 햇살이 가득해요!'}]
node: model
content: [{'type': 'tool_call_chunk', 'id': 'call_eLOMO9oFLrrLxfwTS0lBnJMo', 'name': 'get_weather', 'args': '', 'index': 0}]
node: model
content: [{'type': 'tool_call_chunk', 'id': None, 'name': None, 'args': '{"', 'index': 0}]
node: model
content: [{'type': 'tool_call_chunk', 'id': None, 'name': None, 'args': 'city', 'index': 0}]
node: model
content: [{'type': 'tool_call_chunk', 'id': None, 'name': None, 'args': '":"', 'index': 0}]
node: model
content: [{'type': 'tool_call_chunk', 'id': None, 'name': None, 'args': 'San', 'index': 0}]
node: model
content: [{'type': 'tool_call_chunk', 'id': None, 'name': None, 'args': ' Francisco', 'index': 0}]
node: model
content: [{'type': 'tool_call_chunk', 'id': None, 'name': None, 'args': '"}', 'index': 0}]
node: model
content: []
node: model
content: []
node: model
content: []
node: tools
content: [{'type': 'text', 'text': 'San Francisco에는 항상 햇살이 가득해요!'}]
node: model
content: []
node: model
content: [{'type': 'text', 'text': '샌'}]
node: model
content: [{'type': 'text', 'text': '프'}]
node: model
content: [{'type': 'text', 'text': '란'}]
node: model
content: [{'type': 'text', 'text': '시'}]
node: model
content: [{'type': 'text', 'text': '스'}]
node: model
content: [{'type': 'text', 'text': '코'}]
node: model
content: [{'type': 'text', 'text': '의'}]
node: model
content: [{'type': 'text', 'text': ' 현재'}]
node: model
content: [{'type': 'text', 'text': ' 날'}]
node: model
content: [{'type': 'text', 'text': '씨'}]
node: model
content: [{'type': 'text', 'text': '를'}]
node: model
content: [{'type': 'text', 'text': ' 확인'}]
node: model
content: [{'type': 'text', 'text': '했습니다'}]
node: model
content: [{'type': 'text', 'text': '.\n\n'}]
node: model
content: [{'type': 'text', 'text': '-'}]
node: model
content: [{'type': 'text', 'text': ' 결과'}]
node: model
content: [{'type': 'text', 'text': ':'}]
node: model
content: [{'type': 'text', 'text': ' "'}]
node: model
content: [{'type': 'text', 'text': 'San'}]
node: model
content: [{'type': 'text', 'text': ' Francisco'}]
node: model
content: [{'type': 'text', 'text': '에는'}]
node: model
content: [{'type': 'text', 'text': ' 항상'}]
node: model
content: [{'type': 'text', 'text': ' 햇'}]
node: model
content: [{'type': 'text', 'text': '살'}]
node: model
content: [{'type': 'text', 'text': '이'}]
node: model
content: [{'type': 'text', 'text': ' 가'}]
node: model
content: [{'type': 'text', 'text': '득'}]
node: model
content: [{'type': 'text', 'text': '해'}]
node: model
content: [{'type': 'text', 'text': '요'}]
node: model
content: [{'type': 'text', 'text': '!"\n\n'}]
node: model
content: [{'type': 'text', 'text': '원'}]
node: model
content: [{'type': 'text', 'text': '하'}]
node: model
content: [{'type': 'text', 'text': '시면'}]
node: model
content: [{'type': 'text', 'text': ' 더'}]
node: model
content: [{'type': 'text', 'text': ' 자세'}]
node: model
content: [{'type': 'text', 'text': '한'}]
node: model
content: [{'type': 'text', 'text': ' 정보를'}]
node: model
content: [{'type': 'text', 'text': ' 알려'}]
node: model
content: [{'type': 'text', 'text': '드'}]
node: model
content: [{'type': 'text', 'text': '릴'}]
node: model
content: [{'type': 'text', 'text': '게'}]
node: model
content: [{'type': 'text', 'text': '요'}]
node: model
content: [{'type': 'text', 'text': '.'}]
node: model
content: [{'type': 'text', 'text': ' 예'}]
node: model
content: [{'type': 'text', 'text': '를'}]
node: model
content: [{'type': 'text', 'text': ' 들'}]
node: model
content: [{'type': 'text', 'text': '면'}]
node: model
content: [{'type': 'text', 'text': '\n'}]
node: model
content: [{'type': 'text', 'text': '-'}]
node: model
content: [{'type': 'text', 'text': ' 현재'}]
node: model
content: [{'type': 'text', 'text': ' 온'}]
node: model
content: [{'type': 'text', 'text': '도'}]
node: model
content: [{'type': 'text', 'text': ','}]
node: model
content: [{'type': 'text', 'text': ' 체'}]
node: model
content: [{'type': 'text', 'text': '감'}]
node: model
content: [{'type': 'text', 'text': '온'}]
node: model
content: [{'type': 'text', 'text': '도'}]
node: model
content: [{'type': 'text', 'text': '\n'}]
node: model
content: [{'type': 'text', 'text': '-'}]
node: model
content: [{'type': 'text', 'text': ' 바'}]
node: model
content: [{'type': 'text', 'text': '람'}]
node: model
content: [{'type': 'text', 'text': ' 세'}]
node: model
content: [{'type': 'text', 'text': '기'}]
node: model
content: [{'type': 'text', 'text': '와'}]
node: model
content: [{'type': 'text', 'text': ' 방향'}]
node: model
content: [{'type': 'text', 'text': '\n'}]
node: model
content: [{'type': 'text', 'text': '-'}]
node: model
content: [{'type': 'text', 'text': ' 상대'}]
node: model
content: [{'type': 'text', 'text': ' 습'}]
node: model
content: [{'type': 'text', 'text': '도'}]
node: model
content: [{'type': 'text', 'text': '\n'}]
node: model
content: [{'type': 'text', 'text': '-'}]
node: model
content: [{'type': 'text', 'text': ' 강'}]
node: model
content: [{'type': 'text', 'text': '수'}]
node: model
content: [{'type': 'text', 'text': ' 확'}]
node: model
content: [{'type': 'text', 'text': '률'}]
node: model
content: [{'type': 'text', 'text': '('}]
node: model
content: [{'type': 'text', 'text': '비'}]
node: model
content: [{'type': 'text', 'text': '/'}]
node: model
content: [{'type': 'text', 'text': '눈'}]
node: model
content: [{'type': 'text', 'text': ' 가능'}]
node: model
content: [{'type': 'text', 'text': '성'}]
node: model
content: [{'type': 'text', 'text': ')\n'}]
node: model
content: [{'type': 'text', 'text': '-'}]
node: model
content: [{'type': 'text', 'text': ' 앞으로'}]
node: model
content: [{'type': 'text', 'text': '의'}]
node: model
content: [{'type': 'text', 'text': ' 주'}]
node: model
content: [{'type': 'text', 'text': '간'}]
node: model
content: [{'type': 'text', 'text': ' 예'}]
node: model
content: [{'type': 'text', 'text': '보'}]
node: model
content: [{'type': 'text', 'text': '\n\n'}]
node: model
content: [{'type': 'text', 'text': '어'}]
node: model
content: [{'type': 'text', 'text': '떤'}]
node: model
content: [{'type': 'text', 'text': ' 정보를'}]
node: model
content: [{'type': 'text', 'text': ' 원'}]
node: model
content: [{'type': 'text', 'text': '하시'}]
node: model
content: [{'type': 'text', 'text': '나요'}]
node: model
content: [{'type': 'text', 'text': '?'}]
node: model
content: []
node: model
content: []
node: model
content: []
Custom updates
도구가 실행되는 동안 업데이트를 스트리밍하려면 get_stream_writer를 사용할 수 있습니다.
도구 내부에
get_stream_writer를 추가하면 LangGraph 실행 컨텍스트 외부에서는 도구를 호출할 수 없습니다.
from langchain.agents import create_agent
from langgraph.config import get_stream_writer
def get_weather(city: str) -> str:
"""지정된 도시의 날씨를 가져옵니다."""
writer = get_stream_writer()
# 임의의 데이터 스트리밍
writer(f"도시 {city}에 대한 데이터 조회 중")
writer(f"도시 {city}에 대한 데이터 획득")
return f"{city}에는 항상 햇살이 가득해요!"
agent = create_agent(
model="openai:gpt-4.1-nano",
tools=[get_weather],
)
for chunk in agent.stream(
{"messages": [{"role": "user", "content": "샌프란시스코 날씨는 어때요?"}]},
stream_mode="custom",
):
print(chunk)도시 샌프란시스코에 대한 데이터 조회 중
도시 샌프란시스코에 대한 데이터 획득
Stream multiple modes
스트림 모드를 리스트로 전달하여 여러 스트리밍 모드를 지정할 수 있습니다: stream_mode=["updates", "custom"]:
for stream_mode, chunk in agent.stream(
{"messages": [{"role": "user", "content": "샌프란시스코 날씨는 어때요?"}]},
stream_mode=["updates", "custom"],
):
print(f"stream_mode: {stream_mode}")
print(f"content: {chunk}")
print("\n")stream_mode: updates
content: {'model': {'messages': [AIMessage(content='', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 20, 'prompt_tokens': 62, 'total_tokens': 82, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_provider': 'openai', 'model_name': 'gpt-4.1-nano-2025-04-14', 'system_fingerprint': 'fp_950f36939b', 'id': 'chatcmpl-CYYqyjfnLopXDZnSR6Wks9h7TEFDI', 'service_tier': 'default', 'finish_reason': 'tool_calls', 'logprobs': None}, id='lc_run--84f0384c-7637-4c77-94a2-f400b3b16004-0', tool_calls=[{'name': 'get_weather', 'args': {'city': '샌프란시스코'}, 'id': 'call_fTkg4PGZezMqz2zBvfJ7p2FV', 'type': 'tool_call'}], usage_metadata={'input_tokens': 62, 'output_tokens': 20, 'total_tokens': 82, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})]}}
stream_mode: custom
content: 도시 샌프란시스코에 대한 데이터 조회 중
stream_mode: custom
content: 도시 샌프란시스코에 대한 데이터 획득
stream_mode: updates
content: {'tools': {'messages': [ToolMessage(content='샌프란시스코에는 항상 햇살이 가득해요!', name='get_weather', id='e9f2531e-0609-4944-a46a-708d809636d0', tool_call_id='call_fTkg4PGZezMqz2zBvfJ7p2FV')]}}
stream_mode: updates
content: {'model': {'messages': [AIMessage(content='샌프란시스코는 항상 햇살이 가득해요! 좋은 하루 보내세요!', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 24, 'prompt_tokens': 108, 'total_tokens': 132, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_provider': 'openai', 'model_name': 'gpt-4.1-nano-2025-04-14', 'system_fingerprint': 'fp_950f36939b', 'id': 'chatcmpl-CYYqzi7jUObJmAuEPU6e4dxi6dpM4', 'service_tier': 'default', 'finish_reason': 'stop', 'logprobs': None}, id='lc_run--a76d6d98-c4d7-4ff7-9ba1-0e80e0632180-0', usage_metadata={'input_tokens': 108, 'output_tokens': 24, 'total_tokens': 132, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})]}}
Disable streaming
일부 애플리케이션에서는 특정 모델에 대해 개별 토큰의 스트리밍을 비활성화해야 할 수 있습니다.
이는 멀티 에이전트 시스템에서 어떤 에이전트가 출력을 스트리밍할지 제어할 때 유용합니다.
스트리밍을 비활성화하는 방법을 알아보려면 모델 가이드를 참조하세요.