Published on

OpenAI Responses API tool_calls 파싱 실패 해결법

Authors

서버에서 OpenAI Responses API를 붙이다 보면, 모델이 분명히 툴을 호출한 것처럼 보이는데도 애플리케이션에서는 tool_calls(혹은 그에 준하는 구조)가 비어 있거나, 이벤트 스트림에서 조각난 JSON 때문에 파싱이 깨지거나, 심지어 SDK 버전/응답 포맷 차이로 인해 필드 자체가 기대와 다르게 내려오는 상황을 자주 만납니다.

이 글은 “왜 파싱이 실패하는가?”를 데이터 형태(이벤트/비이벤트), 필드 위치, 조각화, 스키마 변동 관점에서 분해하고, Python/TypeScript에서 실패하지 않는 수집기(collector) 패턴으로 해결하는 방법을 정리합니다.

또한 운영 환경에서 흔히 같이 터지는 장애(스트리밍 끊김, 타임아웃, 5xx 재시도)와 결합했을 때 tool call 파싱을 어떻게 안전하게 만들지까지 다룹니다. 스트리밍 안정화는 별도 글인 OpenAI Responses API 스트리밍 끊김 타임아웃 완전 복구 가이드도 함께 참고하면 좋습니다.

tool_calls 파싱이 실패하는 대표 원인 7가지

1) “tool_calls”는 항상 같은 위치에 있지 않다

Responses API는 (특히 스트리밍에서) 결과가 한 번에 output으로 고정되어 내려오지 않고, 여러 이벤트로 쪼개져 도착합니다. SDK/버전에 따라 다음 중 하나로 관측됩니다.

  • 최종 응답의 output[] 안에 type: "tool_call" 아이템으로 존재
  • 스트리밍 이벤트에서 response.output_item.added / response.output_item.delta로 분리
  • 함수 인자(arguments)가 delta로 잘게 쪼개져 전송

즉, “response.tool_calls가 있을 것”이라는 가정 자체가 깨질 수 있습니다. 해결책은 최종/중간 이벤트를 모두 수집해 output item 단위로 재조립하는 것입니다.

2) 스트리밍에서 JSON arguments가 조각나 파싱된다

함수 호출 인자 arguments는 JSON 문자열인 경우가 많습니다. 스트리밍에서는 아래처럼 분할되어 올 수 있습니다.

  • {"query": "hello … 다음 이벤트에서 world"}

이때 이벤트 단위로 JSON.parse()를 해버리면 100% 깨집니다. 올바른 접근은 tool call id별로 arguments 문자열을 누적하고, “완결 시점”에만 JSON 파싱을 시도하는 것입니다.

3) 모델이 JSON을 “비슷하게” 만들었지만 유효하지 않다

모델이 다음과 같이 내보내면 엄밀한 JSON 파서가 실패합니다.

  • trailing comma
  • 작은따옴표 사용
  • 주석 포함
  • 키 미따옴표

따라서 strict JSON이 필요한 경우엔:

  • 툴 스키마를 명확히(필수 필드, 타입)
  • 시스템 프롬프트로 “arguments는 반드시 JSON” 강제
  • 그래도 실패하면 JSON repair(부분 허용) 또는 재질문/재호출

4) tool 정의와 실제 호출명이 불일치

tools에 등록한 이름이 get_weather인데 모델이 getWeahter(오타)로 호출하면, 애플리케이션 라우터가 매칭에 실패합니다. 이 경우 “파싱 실패”처럼 보이지만 사실은 디스패치 실패입니다.

  • 해결: 툴 이름을 짧고 단순하게
  • 허용 오차 매칭(운영에서는 비추천)
  • 모델에게 “정확히 이 이름만 사용”을 강하게 지시

5) SDK 버전 차이로 이벤트 타입/필드명이 다르다

Responses API는 빠르게 진화합니다. 같은 코드를 두고도 SDK 버전이 바뀌면:

  • 이벤트 타입 문자열
  • delta 구조
  • output item 구조

가 달라져서 파서가 깨질 수 있습니다. 해결책은:

  • 이벤트를 스키마에 강결합하지 말고 type별 최소 필드만 사용
  • 알 수 없는 이벤트는 로깅 후 무시
  • SDK를 고정(pin)하고, 릴리즈 노트 기반으로 업데이트

6) 스트리밍 중 연결 끊김/타임아웃으로 tool call이 “미완성” 상태로 끝난다

arguments 누적 중에 연결이 끊기면 JSON이 끝까지 오지 않습니다. 이때도 파싱 실패가 납니다.

  • 해결: 체크포인팅/재시도 시 부분 누적 상태를 저장하고 이어받기
  • 타임아웃/재시도 전략을 갖추기

운영에서 이 문제는 다른 네트워크/서버 설정과도 엮입니다. 스트리밍 안정화는 위에서 언급한 글을 같이 보세요.

7) 429/5xx 재시도 시 중복 tool call 실행

파싱 실패와는 별개로, 재시도 로직이 있으면 같은 tool call을 두 번 실행할 수 있습니다.

  • 해결: tool_call_id 기반 idempotency
  • 서버에서 실행 결과 캐시

레이트리밋 대응은 OpenAI Responses API 429 레이트리밋 토큰버킷으로 끝내기, 5xx 대응은 OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커도 함께 추천합니다.

정석 해결: “output item 재조립 + arguments 누적 + 완결 파싱”

핵심은 아래 3단계입니다.

  1. 이벤트 스트림에서 output item(특히 tool call)을 id 기준으로 추적
  2. arguments는 문자열로 누적
  3. tool call이 완료되었다고 판단되는 시점(완결 이벤트 또는 스트림 종료)에서만 JSON 파싱

완결 판단은 구현/SDK에 따라 다르지만, 일반적으로는:

  • response.output_item.done 같은 done 이벤트
  • 또는 스트림 종료 시점

을 사용합니다.

Python: 스트리밍 tool call 수집기(collector) 예제

아래 코드는 “이벤트마다 JSON.parse를 하지 않고”, tool call id별로 arguments를 누적한 뒤 마지막에 안전하게 파싱합니다. (이벤트 타입/필드는 SDK 버전에 따라 약간 다를 수 있으니, 로깅으로 실제 이벤트를 확인해 조정하세요.)

import json
from dataclasses import dataclass, field
from typing import Any, Dict, Optional

@dataclass
class ToolCallState:
    name: Optional[str] = None
    arguments_text: str = ""

@dataclass
class ToolCallCollector:
    # key: tool_call_id
    calls: Dict[str, ToolCallState] = field(default_factory=dict)

    def on_event(self, event: Dict[str, Any]) -> None:
        etype = event.get("type")

        # 예: response.output_item.added / response.output_item.delta 등
        if etype in ("response.output_item.added", "response.output_item.delta"):
            item = event.get("item") or event.get("delta") or {}
            if item.get("type") != "tool_call":
                return

            tool_call_id = item.get("id")
            if not tool_call_id:
                return

            state = self.calls.setdefault(tool_call_id, ToolCallState())
            # name은 added에서 오고, delta에서는 안 올 수도 있음
            if item.get("name"):
                state.name = item["name"]

            # arguments는 보통 문자열 조각으로 옴
            if "arguments" in item and isinstance(item["arguments"], str):
                state.arguments_text += item["arguments"]

        # done 이벤트에서 추가 처리하고 싶다면 여기에
        # elif etype == "response.output_item.done":
        #     ...

    def finalize(self) -> Dict[str, Dict[str, Any]]:
        """tool_call_id -> {name, arguments(dict)}"""
        result: Dict[str, Dict[str, Any]] = {}
        for tcid, state in self.calls.items():
            args_obj: Any
            try:
                args_obj = json.loads(state.arguments_text) if state.arguments_text else {}
            except json.JSONDecodeError:
                # 운영에서는 여기서 (1) JSON repair (2) 재질문 (3) 폴백 등을 선택
                args_obj = {"_raw": state.arguments_text, "_parse_error": True}

            result[tcid] = {
                "name": state.name,
                "arguments": args_obj,
            }
        return result

실행/디스패치까지 포함한 간단한 라우터

def dispatch_tool(name: str, arguments: dict) -> dict:
    if name == "search_docs":
        q = arguments.get("query", "")
        return {"hits": [f"fake-hit-for:{q}"]}
    raise ValueError(f"Unknown tool: {name}")


def run_tools(collected: dict) -> dict:
    outputs = {}
    for tcid, call in collected.items():
        name = call.get("name")
        args = call.get("arguments")

        if not name:
            outputs[tcid] = {"error": "missing_tool_name"}
            continue
        if isinstance(args, dict) and args.get("_parse_error"):
            outputs[tcid] = {"error": "arguments_json_parse_failed", "raw": args.get("_raw")}
            continue

        outputs[tcid] = dispatch_tool(name, args)
    return outputs

이 패턴의 장점은:

  • 스트리밍 조각화에 안전
  • 파싱 실패를 “예외로 죽이지 않고” 결과로 승격
  • tool 이름/arguments 미완성도 별도 처리 가능

TypeScript: 스트리밍에서 arguments 누적 파서 예제

Node/Edge 환경에서도 동일하게 “누적 후 파싱”이 답입니다.

type ToolCallState = {
  name?: string;
  argumentsText: string;
};

export class ToolCallCollector {
  private calls = new Map<string, ToolCallState>();

  onEvent(event: any) {
    const type = event?.type;

    if (type === "response.output_item.added" || type === "response.output_item.delta") {
      const item = event.item ?? event.delta ?? {};
      if (item.type !== "tool_call") return;

      const id = item.id as string | undefined;
      if (!id) return;

      const state = this.calls.get(id) ?? { argumentsText: "" };
      if (item.name) state.name = item.name;
      if (typeof item.arguments === "string") state.argumentsText += item.arguments;

      this.calls.set(id, state);
    }
  }

  finalize() {
    const out: Record<string, { name?: string; arguments: any }> = {};
    for (const [id, state] of this.calls.entries()) {
      let args: any = {};
      if (state.argumentsText) {
        try {
          args = JSON.parse(state.argumentsText);
        } catch {
          args = { _raw: state.argumentsText, _parse_error: true };
        }
      }
      out[id] = { name: state.name, arguments: args };
    }
    return out;
  }
}

“완결 이벤트가 없다면?” 스트림 종료 시점 처리 전략

일부 구현에서는 tool call done 이벤트를 명확히 받지 못할 수 있습니다. 그때는 다음 원칙을 추천합니다.

  • 스트림이 정상 종료되면 finalize()를 호출
  • 스트림이 예외로 끊기면(타임아웃/리셋) 부분 누적 상태를 저장하고 재시도
  • 재시도 후에도 동일 tool_call_id가 반복되면 idempotency로 중복 실행 방지

이때 중요한 것은 “파싱 실패”를 단순히 except로 삼켜버리면 원인 추적이 어려워진다는 점입니다.

  • _raw arguments를 반드시 로그/트레이싱에 남기기
  • tool_call_id, response_id, request_id를 함께 남기기

프롬프트/스키마로 파싱 실패 자체를 줄이는 방법

코드로 방어하는 게 1순위지만, 애초에 모델이 안정적으로 tool call을 내도록 만들면 비용이 크게 줄어듭니다.

1) 시스템 지시문에 “arguments는 JSON만”을 명시

You must call tools using valid JSON arguments only.
Do not include comments, trailing commas, or single quotes.

2) 툴 스키마를 좁히고 필수 필드를 강제

  • 선택 필드가 많을수록 모델이 흔들립니다.
  • 문자열/숫자 타입을 명확히 하세요.

3) 실패 시 재질문(수정 요청) 루프를 짧게

  • “방금 arguments JSON이 깨졌으니 정확한 JSON으로 다시 tool call만 출력”
  • 같은 응답에서 텍스트 설명을 섞지 못하게 제한

운영 체크리스트: “파싱 실패”를 장애로 키우지 않기

  • 이벤트를 원문 그대로 샘플링 저장(개인정보/비밀키 마스킹)
  • tool_call_id 기반 중복 실행 방지
  • arguments 파싱 실패 시 _raw 보존
  • 모델/SDK 버전 고정 및 변경 시 회귀 테스트
  • 스트리밍 끊김/타임아웃 시 체크포인트 후 재시도
  • 429/5xx 재시도 정책과 tool 실행 정책을 분리

특히 스트리밍 기반 서비스는 네트워크/서버 타임아웃과 결합해 “미완성 arguments”가 자주 생깁니다. 이 경우 위에서 소개한 스트리밍 안정화 글과 함께, 5xx 재시도/서킷브레이커 패턴을 적용하면 장애 전파를 크게 줄일 수 있습니다.

마무리: 결론은 “이벤트 단위 파싱 금지, 누적 후 완결 파싱”

Responses API에서 tool call 파싱이 실패하는 가장 큰 이유는, 우리가 완성된 JSON이 한 번에 올 것이라고 가정하기 때문입니다. 스트리밍에서는 그 가정이 성립하지 않습니다.

  • tool call은 output item으로 재조립해야 하고
  • arguments는 문자열 누적 후 파싱해야 하며
  • 실패를 예외로 죽이지 말고 _raw와 함께 관측 가능하게 만들어야 합니다.

이 3가지만 지켜도 “tool_calls 파싱 실패”의 대부분은 운영에서 사라집니다.