에이전트를 실시간처럼 느끼게 만들기 — NVIDIA NIM을 활용한 스트리밍 (Streaming)
요약
NVIDIA NIM을 활용하여 AI 에이전트의 응답을 실시간 스트리밍 방식으로 구현하는 방법을 다룹니다. 단순 텍스트 스트리밍을 넘어, 파편화되어 전달되는 도구 호출(tool calls) 청크를 재조립하는 기술적 과정을 설명합니다.
핵심 포인트
- 스트리밍을 통해 에이전트의 UX를 실시간 타이핑 방식으로 개선 가능
- stream=True 설정 시 메시지 대신 청크(chunks)의 이터레이터가 반환됨
- 도구 호출은 여러 청크에 걸쳐 파편화되어 오므로 재조립 과정이 필수적임
- 스트리밍은 에이전트 루프 내부의 레이어로 작동하며 제어 흐름을 바꾸지 않음
우리가 7부 과정에 걸쳐 구축한 어시스턴트는 검색(retrieves), 거부(refuses), 계획(plans), 도구 체이닝(chains tools), 그리고 대화 기억(remembers a conversation)이 가능한 유능한 모델입니다. 하지만 한 가지 눈에 띄는 UX 결함이 있습니다. 질문을 하면 몇 초 동안 침묵하다가, 갑자기 문단 전체가 한꺼번에 나타난다는 점입니다. 한 줄짜리 답변이라면 보이지 않는 것이 문제가 되지 않겠지만, 그보다 긴 답변의 경우 시스템이 고장 난 것처럼 느껴집니다.
여러분이 사용해 온 모든 채팅 제품은 이 문제를 동일한 방식으로 해결합니다. 바로 **스트리밍 (streaming)**입니다. 텍스트가 토큰 (token) 단위로 스스로 타이핑되듯 출력되어, 진행 상황을 즉시 확인할 수 있게 합니다. 이 포스트에서는 우리 에이전트에 바로 그 기능을 추가할 것이며, 단지 플래그 (flag) 하나를 바꾸는 것만으로도 에이전트가 얼마나 더 "살아있는" 것처럼 느껴지는지에 대한 보상은 매우 큽니다.
대부분 그렇습니다. 플래그(stream=True)를 설정하는 것은 쉬운 20%에 불과합니다. 나머지 80%는 스트림이 반환하는 내용에 달려 있습니다. 즉, 깔끔한 메시지 하나가 아니라 작은 **청크 (chunks)**들의 시퀀스가 전달됩니다. 일반 텍스트는 다시 조립하기 쉽습니다. 하지만 도구 호출 (tool calls)은 그렇지 않습니다. 도구 호출은 여러 청크에 걸쳐 파편화된 상태로 도착하며, 이를 실행하기 전에 다시 하나로 꿰매어(stitch) 합쳐야 합니다. 이 재조립 과정이 워크숍 8의 핵심 교훈입니다.
저는 USC의 NVIDIA Developer Champion인 B Torkian입니다. 시리즈의 8부입니다.
추가되는 기능
Workshop 7: create(...) -> 메시지 하나 -> 한꺼번에 모두 출력
Workshop 8: create(..., stream=True) -> 여러 개의 청크 -> 각 토큰이 도착할 때마다 출력
에이전트 루프 (agent loop)는 변경되지 않습니다. 한 턴을 스트리밍하고, 돌아온 내용(텍스트 또는 도구 호출 파편)을 재조립한 다음, 워크숍 7에서 했던 것과 정확히 동일한 작업을 수행합니다. 즉, 도구를 실행하고 루프를 돌리거나, 답변이 완료되었으므로 중단하는 것입니다. 스트리밍은 턴(turn) _내부_의 레이어이지, 새로운 제어 흐름 (control flow)이 아닙니다.
1단계 — 가장 단순한 형태의 스트리밍 (도구 미사용)
stream=True를 추가하면 반환 값이 더 이상 메시지가 아니라, 각각 작은 delta를 담고 있는 청크의 이터레이터 (iterator)가 됩니다. 일반 텍스트의 경우, 중요한 필드는 오직 delta.content뿐입니다:
resp = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": "두 문장으로 GPU 가속이란 무엇인지 설명해줘"}],
...
end=""와 flush=True가 버퍼링(buffering) 대신 터미널로 스트리밍(streaming)되게 만드는 핵심입니다. 텍스트의 경우 이것이 전체 트릭입니다. 실행하면 답변이 스스로 타이핑되듯 출력됩니다.
2단계 — 함정: 도구 호출(tool calls)은 조각으로 도착합니다
사람들이 놀라는 지점이 여기입니다. 모델이 도구 호출을 결정했을 때, 그 호출은 한 번에 도착하지 않습니다. 함수 이름(function name)이 하나의 청크(chunk)로 나타나고, 인자(arguments) JSON은 여러 개의 청크에 걸쳐 조금씩 흘러나옵니다. 각 조각(fragment)에는 index가 태그되어 있어, 모델이 한 번의 턴(turn)에서 하나 이상의 호출을 요청할 수 있기 때문에 어떤 호출에 속하는지 알 수 있습니다.
따라서 해당 인덱스를 키(key)로 하는 딕셔너리(dictionary)를 유지합니다. 각 조각이 나타날 때마다 id와 name을 설정하고, 조각들이 도착함에 따라 arguments 문자열을 **연결(concatenate)**합니다.
text_parts = []
tool_fragments = {} # index -> {"id", "name", "arguments"}
...
스트림이 종료되면, 각 버킷(bucket)은 파싱(parse)하여 실행할 준비가 된 하나의 완전한 도구 호출을 보유하게 됩니다. 이것이 이번 워크숍에서 진정으로 새로운 아이디어입니다. 그 외의 모든 것은 Workshop 7의 루프(loop)와 동일합니다.
3단계 — ChatSession에 통합하기
stream()은 동일한 세션 내에서 Workshop 7의 chat()과 함께 존재합니다. 동일한 지속적인 self.messages, 동일한 _trim() (턴 단위 트리밍), 동일한 메모리를 사용합니다. 유일한 차이점은 턴이 스트리밍되며, 어시스턴트 메시지(assistant message)가 누적된 조각들로부터 재구성된다는 점입니다.
def stream(self, user_message: str) -> str:
self.messages.append({"role": "user", "content": user_message})
...
chat() 버전을 이것 옆에 두면 구조는 동일합니다. 스트리밍 버전은 메시지를 통째로 받는 대신 조각들로부터 어시스턴트 메시지를 수동으로 구축할 뿐입니다. 이러한 동형성(isomorphism)이 핵심입니다: 스트리밍은 데이터 축적 계층(data-accumulation layer)이지, 새로운 에이전트(agent)가 아닙니다.
4단계 — 차이점 느껴보기
print("── 스트리밍 없이 (답변이 한꺼번에 도착함) ──")
session = ChatSession(verbose=True)
print(f"Assistant: {session.chat('What are the USC GPU lab hours?')}")
...
비스트리밍 (non-streaming) 호출은 일시 중단된 후 답변을 한꺼번에 쏟아냅니다. 반면 스트리밍 (streaming) 호출은 도구 사용 단계를 보여준 뒤 답변을 스스로 타이핑하듯 출력합니다. 이때 메모리 기능은 여전히 작동하며 ("that"이 목요일로 해결됨), 다단계 비교 역시 정상적으로 작동합니다. 당신은 에이전트가 "생각하는" 방식이 아니라, 답변이 "전달되는" 방식만을 바꾼 것입니다.
5단계 — 이름 붙일 가치가 있는 함정
유혹적인 "더 간단한" 설계 방식이 하나 있습니다. 먼저 일반적인 비스트리밍 호출을 수행하여 모델이 도구를 원하는지 확인한 다음, 도구를 원하지 않을 경우에만 stream=True로 다시 호출하여 답변을 스트리밍하는 방식입니다. 절대 하지 마세요. 마지막 턴에서 이 방식은 답변 전체를 한 번 생성하고 (블로킹), 스트리밍을 위해 답변을 "다시" 생성하게 만듭니다. 결과적으로 첫 번째 가시적 토큰(visible token)이 스트리밍을 아예 하지 않았을 때보다 "더 늦게" 도착하게 되는데, 이는 목표와 정반대되는 결과이며 답변 비용을 두 번 지불하게 됩니다.
도구 사용 여부를 결정하는 동일한 호출을 스트리밍하는 것이 낮은 첫 토큰 시간 (time-to-first-token)을 보장하는 핵심입니다. 이것이 우리가 먼저 엿보는 대신 조각들을 축적하는 이유입니다. 코드는 몇 줄 더 늘어나지만, 이는 도움이 되는 스트리밍과 보여주기식(theater) 스트리밍을 가르는 차이입니다.
6단계 — 당신이 실제로 구축한 것
- Workshop 1은 에이전트에게 두뇌를 주었습니다.
- Workshop 2는 사실에 대한 기억 (검색, retrieval)을 주었습니다.
- Workshop 3은 판단력 (가드레일, guardrails)을 주었습니다.
- Workshop 4는 이식성 (portability)을 주었습니다.
- Workshop 5는 손 (도구 하나)을 주었습니다.
- Workshop 6은 계획 (체인된 도구, chained tools)을 주었습니다.
- Workshop 7은 대화에 대한 기억을 주었습니다.
- Workshop 8은 실시간으로 전달되는 목소리를 주었습니다.
에이전트는 파트 5부터 유지해 온 것과 동일한 while 루프입니다. 스트리밍은 이전의 메모리나 도구와 마찬가지로 모델 호출을 감싸는 일반적인 소프트웨어일 뿐입니다. 다만 응답을 읽는 방식이 달라질 뿐이며, 그로 인해 경험이 완전히 변화합니다. 프로덕션 시스템은 이를 더 발전시켜 (WebSockets를 통한 브라우저 스트리밍, 부분적인 마크다운 렌더링, 스트리밍 중간 취소 등) 구현하지만, 그들 모두가 방금 당신이 한 것과 동일한 작업, 즉 청크(chunks)를 소비하고 이를 재조립하는 작업을 수행하고 있습니다.
코드 가져오기
Repo: github.com/torkian/nvidia-nim-workshop
One-click Colab: part8_streaming_agent.ipynb 열기
Local Python: 리포지토리 내의 part8_streaming_agent.py (pip install -r requirements.txt 실행 후 python3 part8_streaming_agent.py 실행).
MIT 라이선스입니다. 저는 USC(University of Southern California)에서 이 작업을 수행하고 있습니다. 이를 포크(fork)하여 지식 베이스(knowledge base)와 도구(tools)를 당신의 학교, 클럽, 또는 프로젝트에 맞게 교체하여 사용하세요.
전체 시리즈
- Part 1: 30분 만에 NVIDIA NIM으로 첫 번째 AI 앱 만들기
- Part 2: 수동 RAG에서 실제 검색으로 — NVIDIA NIM을 활용한 임베딩 기반 RAG (Embedding-Based RAG)
- Part 3: AI 앱이 거짓말을 하지 않도록 가드레일 (Guardrails) 추가하기
- Part 4: 자신의 GPU에서 NVIDIA NIM 실행하기
- Part 5: 챗봇에서 에이전트로 — NVIDIA NIM을 활용한 도구 호출 (Tool Calling)
- Part 6: 하나의 도구에서 계획으로 — NVIDIA NIM을 활용한 다단계 에이전트 (Multi-Step Agents)
- Part 7: 에이전트에게 메모리 부여하기 — NVIDIA NIM을 활용한 다회차 대화 (Multi-Turn Conversations)
- Part 8 (본 포스트): 에이전트를 실시간처럼 느끼게 만들기 — NVIDIA NIM을 활용한 스트리밍 (Streaming)
전체 시리즈를 한 번에 읽고 싶은 분들을 위해 통합된 롱폼(long-form) 버전이 Medium에 게시되어 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기