챗봇에서의 실시간 사고 과정(Chain-of-Thought): 텍스트뿐만 아니라 도구 호출(Tool Calls)을 실제로 스트리밍하는 방법
요약
LLM 챗봇에서 텍스트뿐만 아니라 도구 호출(Tool Calls) 과정을 실시간으로 스트리밍하는 아키텍처를 소개합니다. 사용자가 에이전트의 사고 과정을 즉시 확인할 수 있도록 tool_call, text, result의 세 가지 이벤트 레이어를 분리하여 구현하는 방법을 다룹니다.
핵심 포인트
- 도구 호출(tool_call)을 텍스트 생성 전에 스트리밍하여 사고 과정을 시각화함
- SSE(Server-Sent Events)를 활용한 FastAPI 기반의 효율적인 데이터 전송
- tool_call, text, result의 세 가지 이벤트 유형으로 스트림 구조 설계
- 최종 응답의 무결성을 보장하기 위한 권위 있는(authoritative) result 레이어 활용
대부분의 "스트리밍 (streaming)" LLM 챗봇은 텍스트만 스트리밍합니다. 모델이 "그 내용을 검색해 보겠습니다..."라고 말하면, 토큰이 조금씩 들어오는 동안 6초 동안 기다려야 합니다. 실제 검색은? 숨겨져 있습니다. 사실 확인을 위해 수행한 3번의 스크래핑(scrape)은? 숨겨져 있습니다. 당신은 실제로 무엇 때문에 시간이 걸리는지에 대해 아무것도 알려주지 않는 타이핑 표시기(typing indicator)만 바라보게 됩니다.
저는 모든 도구 호출(tool call)이 실시간 단계로 나타나는 챗봇을 방금 구축했습니다 — 🔍 search_engine, 📄 scrape_as_markdown, 📄 scrape_as_markdown — 그 이후에 응답이 토큰 단위로 스트리밍됩니다. 사용자는 에이전트의 사고 과정(chain-of-thought)을 사후 분석(postmortem)이 아닌, 발생하는 즉시 보게 됩니다.
비결은 세 가지 서로 다른 것을 스트리밍해야 하며, 각 레이어(layer)가 각 종류의 이벤트에 대해 무엇을 해야 하는지 알아야 한다는 것입니다. 여기 그 아키텍처(architecture)가 있습니다.
스트림의 형태
에이전트 러너(agent runner)(저의 경우 Claude Agent SDK를 감싸는 fi-runner)는 이벤트가 발생할 때마다 세 가지 유형의 이벤트를 방출합니다:
async for event in runner.run_stream(user_message, session_id=sid):
# event["type"]은 다음 중 하나입니다:
# "tool_call" → event["tool"]은 ToolCall(name, server, is_error, ...)입니다.
...
세 가지 유형인 이유는 시각적으로 세 가지 서로 다른 의미를 갖기 때문입니다:
- **
tool_call**은 텍스트가 나오기 전에 도착합니다. 모델이 도구를 사용하기로 결정하면, 당신은 어떤 도구를, 어떤 서버를 사용하는지 즉시 보여주고 싶을 것입니다. 이것이 바로 "사고 단계 (thinking step)"입니다. - **
text**는 응답이 생성되는 동안 도착합니다. 토큰 델타(Token deltas), 타자기 효과(typewriter effect). - **
result**는 마지막에 도착하며 권위 있는 (authoritative) 최종 텍스트입니다 — 이는 연결된text델타와 다를 수 있는데, 턴 종료 후 가드(post-turn guards) (안티 드리프트(anti-drift), PHI redaction 등)가 응답을 다시 작성했을 수 있기 때문입니다.
마지막 포인트는 스펙(spec)에서 당신에게 경고해주지 않는 실수하기 쉬운 부분(footgun)입니다. 이 부분은 나중에 다시 다루겠습니다.
레이어 1: FastAPI 엔드포인트 (FastAPI endpoint)
레이어 1: FastAPI 엔드포인트 (FastAPI endpoint)
Server-Sent Events (SSE)가 적절한 전송 방식입니다. SSE는 단방향(unidirectional), 텍스트 기반이며, 프록시를 통과하고 브라우저에서 재연결 처리를 기본적으로 지원합니다. FastAPI에서는 StreamingResponse로 이를 처리할 수 있습니다:
import json
from fastapi.responses import StreamingResponse
...
여기에는 명확하지 않은 세 가지 부분이 있습니다:
-
예외 계층(The exception ladder).
except CancelledError: raise구문은 반드시except Exception보다 먼저 와야 합니다. 사용자가 탭을 닫으면, FastAPI는CancelledError를 제너레이터로 전파합니다. 만약 이를 '정상적인 오류'로 처리하고 에러 프레임을 출력하면, (a) 이미 닫힌 소켓에 쓰게 되고, (b) 더 중요한 것은 상위 LLM 호출이 실제로 취소되지 않을 수 있다는 것입니다. 계속해서 백그라운드에서 실행되며 토큰을 소모합니다. -
asyncio.timeout(180). 만약 상위 도구(제 경우 Bright Data MCP)가 멈추면, SSE 소켓은 영원히 열려 있게 됩니다. 사용자는 해결되지 않는 타이핑 인디케이터를 보게 됩니다. 한 턴당 강한 제한 시간(hard ceiling)을 설정하면 '쐐기 모양'의 문제가 깔끔한 에러 이벤트로 바뀝니다. -
X-Accel-Buffering: no. nginx는 기본적으로 응답을 버퍼링합니다. 이 헤더 없이 nginx를 통해 SSE를 사용하면, 제너레이터가 완료될 때까지 사용자에게 아무것도 전달되지 않아 전체 목적을 달성할 수 없습니다. Cloudflare도 자체적인 설정(knobs)이 있습니다.
레이어 2: 와이어 계약 (the wire contract) (PHI 안전)
순진한 접근 방식은 ToolCall을 dict()로 변환하여 전송하는 것입니다. 절대 그렇게 하지 마십시오. 도구 호출의 input 필드에는 LLM이 전달한 모든 것이 담길 수 있습니다. 검색 도구라면, 그것은 원문 쿼리일 수 있고; Bright Data라면, 인증 토큰을 포함한 URL일 수 있으며; 내부 의료 도구라면, 잠재적으로 PHI(개인 건강 정보)일 수 있습니다. 이 중 어느 것도 SSE 와이어를 통해 프로세스를 벗어나서는 안 됩니다.
저는 와이어의 형태를 별도의 모듈에 유지합니다:
# wire.py — SSE를 통해 나가는 데이터가 되는 유일한 진실 공급원(SINGLE source of truth)
from typing import TypedDict, Any
...
주목해야 할 두 가지 사항이 있습니다:
주목해야 할 두 가지 사항이 있습니다:
- 와이어 타입(wire type)은 인-프로세스 타입(in-process type)보다 의도적으로 더 좁습니다. 컴파일러가 직렬화되는 것을 강제합니다. 만약
dict(tool_call)을 실행한다면, 입력을 유출하기 위해 타입을 능동으로 우회해야 합니다. 이것이 PHI 안전성(PHI-safety)을 가장 쉬운 경로로 만드는 방법입니다. tool_call_to_wire는 스트림 중간에 부분적인 객체를 보기 때문에None기본값을 가진getattr를 사용합니다. 즉, 일치하는ToolResultBlock이 도착하기 전에ToolUseBlock가 도착할 수 있으므로is_error는 여전히None입니다. 여기서 방어적인getattr는 올바릅니다. 반면, 객체가 항상 완전한result_to_wire의 대응물은 **직접 속성 접근(direct attribute access)**을 사용합니다. 업스트림 라이브러리가 필드 이름을 변경했을 때 오류를 발생시키기를 원하므로, 조용히 빈 결과를 전송하기 전에 알아내야 합니다.
Layer 3: React 측면
EventSource는 SSE(Server-Sent Events)에 가장 명확한 선택지입니다… 단, GET 전용이고 요청 본문이 없습니다. 제 채팅 엔드포인트는 POST 방식입니다. 그래서 EventSource를 포기하고 fetch 스트리밍을 사용합니다:
const res = await fetch(`${API_URL}/chat/stream`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
...
TextDecoder의 {stream: true} 플래그가 UTF-8에 대해 이것을 작동하게 만듭니다. 이 플래그가 없으면, 멀티바이트 문자가 청크 사이에 분할될 때 손상됩니다. 버퍼링 후 공백 줄(blank line)에서 분리하는 방식은 단순히 SSE 프레이밍입니다.
result 이벤트의 replace-not-append는 제가 약속했던 함정입니다. 스트림되는 text 델타는 LLM이 생성하면서 나오는 원시 출력물입니다. result.text는 턴(turn) 이후 가드(guard)들이 실행하고 남긴 내용입니다. 만약 귀하의 드리프트 방지 가드(anti-drift guard)가 응답을 재작성한다면 (제 경우처럼 보고서 목소리 마크다운 헤더를 제거하는 등), 스트림된 델타와 최종 텍스트는 일치하지 않습니다. 결과물을 스트림되는 콘텐츠에 추가(append)하면 이중으로 렌더링됩니다. 대체(replace)하면 부드러운
단순한 useEffect(() => scrollIntoView(), [messages]) 방식은 텍스트 델타 (text delta)가 발생할 때마다 실행됩니다. 결과적으로 초당 약 30회의 스크롤 애니메이션이 서로 충돌하며 발생하며, 만약 사용자가 이전 응답을 다시 읽기 위해 위로 스크롤했다면 읽는 도중에 사용자를 다시 끝부분으로 강제로 끌어당기게 됩니다. 두 경우 모두 사용이 불가능합니다.
해결책은 ChatGPT와 Claude.ai가 사용하는 "스티키-바텀 (sticky-bottom)" 패턴입니다:
useEffect(() => {
const distanceFromBottom = doc.scrollHeight - (window.innerHeight + window.scrollY);
const nearBottom = distanceFromBottom < 200;
...
새 메시지가 올 때는 항상 스크롤합니다 (경계 지점에서는 사용자가 답변을 보고 싶어 하기 때문입니다). 델타 (delta) 발생 시에는 사용자가 이미 바닥 근처에 있을 때만 스크롤합니다. 200px 임계값(threshold)이 가장 적절한 지점입니다. 읽으려는 의도를 존중할 만큼 충분히 엄격하면서도, 작은 스크롤 움직임 때문에 자동 스크롤이 풀리지 않을 만큼 충분히 여유롭습니다.
실제로 보이는 모습
이 모든 것이 제대로 맞물리면, 사용자가 acme.com을 입력했을 때 즉시 다음과 같은 모습을 보게 됩니다:
🤖 생각 중…
🔍 search_engine
📄 scrape_as_markdown
...
…약 4초에 걸쳐 단계별로 나타나며, 그 이후에 Roast 텍스트가 타이핑되기 시작합니다. 이전에는 이 과정이 블랙박스(black box)와 같았지만, 이제는 명확한 증거(receipts)로 보여줍니다.
다음 단계
현재 설정에서 제가 직면한 두 가지 공백은 다음과 같습니다:
-
도구 호출 (tool call)당
duration_ms부재 — 스크레이핑 (scrape) 단계 중 하나가 8초가 걸릴 때, 이를 표시할 방법이 없습니다. Mermaid 턴 플로우 (turn-flow)는 느린 단계를 색상으로 구분할 수 없습니다. 이 기능은 fi-runner 0.14에 막 배포되었습니다 —tool_use_id와 쌍을 이루는ToolCall.duration_ms가 추가되었습니다. -
MCP 서버에 대한 프리플라이트 (preflight) 부재 — 만약 부팅 시 Bright Data MCP가 생성에 실패하면 (잘못된 토큰,
npx누락 등), 모델이 첫 번째 도구를 시도할 때가 되어서야 알 수 있습니다. 프로덕션 환경에서 Roast가 진행되는 도중에 일반적인is_error=true를 마주하게 되는 것입니다. 이 또한 0.14에 배포되었습니다 —Runner.preflight()는 시작 시 각 MCP에 대해 JSON-RPC 핸드셰이크 (initialize→tools/list)를 수행하고{name: alive, tools, error}를 반환합니다. 이를 귀하의lifespan이벤트에 연결하면, 첫 번째 잘못된 데모는 부팅 단계에서 종료될 것입니다.
만약 외부 도구 (external tools)를 사용하는 에이전트 (agents)를 활용해 무엇인가를 구축하고 있다면, 이 포스트의 메시지는 다음과 같습니다: 도구를 숨기지 마십시오. 사고 과정 (chain-of-thought) 자체가 곧 제품입니다. 이를 보여주는 것은 "AI가 마법을 부리고 있다"는 느낌을 "AI가 4개의 구체적인 API 호출 (API calls)을 수행하고 있으며, 여기 그 결과가 있다"로 바꾸어 놓으며, 이는 사용자가 AI를 신뢰하느냐 그렇지 않느냐를 결정짓는 차이를 만듭니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기