왜 문제가 되는가

LLM을 스트리밍(SSE)으로 받으면 응답이 단일 텍스트가 아니라 여러 콘텐츠 블록의 조각으로 도착한다. 텍스트, 추론(thinking), 도구 호출(tool_use)이 서로 끼어들어(interleaving) 오는데, 특히 도구 호출의 인자는 JSON 전체가 한 번에 오지 않고 input_json_delta 같은 부분 문자열로 쪼개져 온다. 이걸 조각마다 파싱하려 들면 예외가 터지고, 블록 인덱스를 무시하면 서로 다른 도구의 인자가 뒤섞인다. 인터리빙 추론까지 켜면 모델이 도구 A를 호출하고, 그 결과를 받아 다시 사고한 뒤 도구 B를 호출하는 흐름이 하나의 스트림 안에서 이어진다.

스트림 이벤트 구조 이해

대부분의 벤더 스트림은 블록 단위 생명주기를 따른다. 핵심은 블록 인덱스별로 상태를 분리하고, 델타는 오직 누적만 하며, 파싱은 블록이 닫힐 때 한 번만 수행하는 것이다.

이벤트의미해야 할 일
content_block_start블록 시작(type 확정)인덱스에 버퍼 생성
content_block_delta부분 조각버퍼에 append만
content_block_stop블록 종료JSON 파싱·확정

델타 누적기 구현

도구 인자는 문자열 조각을 이어붙인 뒤에만 유효한 JSON이 된다. 인덱스를 키로 버퍼를 관리한다.

class ToolCallAccumulator:
    def __init__(self):
        self.blocks = {}  # index -> {"name":..., "id":..., "buf": ""}

    def on_event(self, ev):
        if ev.type == "content_block_start" and ev.block.type == "tool_use":
            self.blocks[ev.index] = {
                "name": ev.block.name, "id": ev.block.id, "buf": ""
            }
        elif ev.type == "content_block_delta" and ev.delta.type == "input_json_delta":
            self.blocks[ev.index]["buf"] += ev.delta.partial_json  # 파싱 금지, 누적만
        elif ev.type == "content_block_stop" and ev.index in self.blocks:
            b = self.blocks[ev.index]
            b["input"] = json.loads(b["buf"] or "{}")  # 여기서만 파싱
            return b  # 완성된 tool call
        return None

인터리빙 실행 루프

모델이 stop_reason == "tool_use"로 멈추면, 지금까지의 블록을 그대로 대화에 되돌려 넣고 도구 결과를 tool_result로 붙여 다시 스트리밍을 요청한다. thinking 블록을 포함해 원본 순서를 보존하는 것이 중요하다. 순서가 깨지면 다음 턴에서 모델이 자기 추론 맥락을 잃는다.

messages = [{"role": "user", "content": user_input}]
while True:
    assistant_blocks, tool_calls = stream_once(messages)  # 위 누적기 사용
    messages.append({"role": "assistant", "content": assistant_blocks})
    if not tool_calls:
        break
    results = []
    for tc in tool_calls:  # 여러 도구가 병렬로 왔을 수 있음
        out = run_tool(tc["name"], tc["input"])
        results.append({
            "type": "tool_result",
            "tool_use_id": tc["id"],
            "content": json.dumps(out),
        })
    messages.append({"role": "user", "content": results})

부분 UI 렌더링 주의점

사용자에게 실시간으로 보여줄 때, 텍스트 델타는 즉시 흘려도 되지만 도구 인자 델타는 화면에 그대로 노출하면 안 된다. 깨진 JSON 조각이 보이기 때문이다. "도구 실행 중…" 같은 상태 표시로 대체하고, 결과가 확정된 뒤 렌더링한다. 한 응답에 여러 tool_use 블록이 동시에 오는 병렬 호출도 흔하므로, UI는 인덱스별 슬롯을 두어야 한다.

흔한 함정과 방어

  • 조각 단위 파싱: 델타마다 json.loads를 호출하면 대부분 실패한다. 반드시 stop에서 한 번만.
  • 인덱스 무시: 전역 버퍼 하나로 합치면 병렬 도구 인자가 섞인다. 키는 항상 블록 인덱스.
  • 빈 인자: 인자 없는 도구는 버퍼가 빈 문자열이므로 "{}"로 기본값 처리.
  • thinking 유실: 되돌려 넣는 assistant 콘텐츠에서 thinking 블록을 빼면 서명 검증이나 맥락이 깨질 수 있다. 받은 블록을 원형 그대로 보존한다.
  • 연결 끊김: 스트림 중단 시 미완성 버퍼는 폐기하고 마지막 완결 지점부터 재시도하되, 이미 실행한 도구는 멱등성으로 중복 실행을 막는다.

정리

스트리밍 인터리빙의 핵심은 세 가지다. 블록 인덱스로 상태를 분리하고, 델타는 누적만 하며, 파싱과 도구 실행은 블록이 닫힌 뒤에 한다. 이 규칙만 지키면 병렬 도구 호출과 인터리빙 추론이 섞인 스트림도 안정적으로 처리된다.