🔥 고급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에서 집계