k
korAI
고급 전체
🔥 고급2026-04-1210분

스트리밍 + Tool Use — 프로덕션 UX의 마지막 조각

텍스트 스트리밍은 쉽다. 도구 호출이 섞인 스트림을 파싱하고 UI를 부분 갱신하는 건 별개 문제다.

apistreamingux

왜 어려운가

스트리밍 이벤트는 text_delta만 오는 게 아니다. input_json_delta조각조각 들어온다. 이걸 제대로 누적해야 tool 호출을 재구성할 수 있다.

이벤트 종류

| 이벤트 | 의미 | |-------|------| | message_start | 메시지 시작 | | content_block_start | 블록 시작 (text / tool_use) | | content_block_delta | 블록 조각 (text_delta or input_json_delta) | | content_block_stop | 블록 종료 | | message_delta | stop_reason / usage 업데이트 | | message_stop | 메시지 종료 |

통합 파서

interface PartialBlock {
  type: "text" | "tool_use"
  text?: string
  tool?: { id: string; name: string; jsonBuf: string }
}

async function processStream(stream: AsyncIterable<any>, ui: UICallbacks) {
  const blocks: Record<number, PartialBlock> = {}

  for await (const ev of stream) {
    switch (ev.type) {
      case "content_block_start": {
        const b = ev.content_block
        if (b.type === "text") {
          blocks[ev.index] = { type: "text", text: "" }
          ui.startText(ev.index)
        } else if (b.type === "tool_use") {
          blocks[ev.index] = { type: "tool_use", tool: { id: b.id, name: b.name, jsonBuf: "" } }
          ui.startTool(ev.index, b.name)
        }
        break
      }
      case "content_block_delta": {
        const blk = blocks[ev.index]
        if (ev.delta.type === "text_delta") {
          blk.text! += ev.delta.text
          ui.appendText(ev.index, ev.delta.text)
        } else if (ev.delta.type === "input_json_delta") {
          blk.tool!.jsonBuf += ev.delta.partial_json
          ui.toolProgress(ev.index, blk.tool!.jsonBuf) // ← 부분 JSON을 보여줄 수도
        }
        break
      }
      case "content_block_stop": {
        const blk = blocks[ev.index]
        if (blk.type === "tool_use") {
          const input = JSON.parse(blk.tool!.jsonBuf || "{}")
          ui.toolReady(ev.index, blk.tool!.id, blk.tool!.name, input)
        }
        break
      }
    }
  }
}

UI 패턴

  • 텍스트: 도착하는 대로 append
  • 도구 호출 카드: startTool 시점에 "검색 중..." 스피너, toolReady 시점에 실제 인자 표시
  • 도구 실행 중: 사용자에게 무엇을 실행 중인지 문구 제공 ("웹 검색 중: '...'")
  • 도구 결과: 다음 턴이 스트림으로 돌아오면 결과 요약 표시

부분 JSON의 함정

input_json_delta가 중간에 있을 땐 유효한 JSON이 아니다. 완성될 때까지 파싱 시도하지 말 것. 사용자에게 "인자 구성 중..." 표시만.

취소 처리

AbortController로 스트림 중단 → 부분 블록은 버리고 UI를 깨끗하게 롤백. tool_use는 실행 안 된 상태로 폐기.

체크리스트

  • [ ] 각 이벤트 타입 처리 분기
  • [ ] input_json_delta 누적 후 한 번에 파싱
  • [ ] 스트림 취소 시 UI 롤백
  • [ ] 도구별 진행 상태 메시지
  • [ ] usage는 message_delta/stop에서 집계