Published on

OpenAI Responses API 400 invalid_tool_output 해결법

Authors

서버에서 400 invalid_tool_output가 떨어질 때 가장 당황스러운 지점은 “도구(tool)는 잘 실행됐고 값도 반환했는데 왜 OpenAI가 거부하지?” 입니다. 이 에러는 대부분 네트워크/권한 문제가 아니라, 모델이 기대하는 tool 결과 메시지의 구조가 깨졌거나(타입/필드/인코딩/매칭) tool output이 JSON으로 해석 불가능한 형태로 전달돼서 생깁니다.

이번 글에서는 Responses API에서 invalid_tool_output가 발생하는 대표 원인과, 재현 가능한 최소 예제, 그리고 운영 환경에서 다시는 안 나오게 만드는 방어 코드를 정리합니다. (스키마 자체 검증에서 터지는 422와는 결이 다르니, 422가 의심되면 별도 가이드를 참고하세요: OpenAI Responses API 422 스키마 검증 에러 해결 가이드)

1) invalid_tool_output의 의미: “tool 결과를 메시지로 못 읽겠다”

Responses API에서 tool 호출 흐름은 대략 이렇습니다.

  1. 모델이 tool_call을 생성한다(어떤 함수/도구를 어떤 인자로 호출할지).
  2. 애플리케이션이 실제로 tool을 실행한다.
  3. 애플리케이션이 tool 실행 결과를 tool output 메시지로 다시 Responses API에 전달한다.
  4. 모델이 그 결과를 읽고 다음 응답을 만든다.

여기서 invalid_tool_output는 3번 단계에서 “tool output 메시지가 규격에 맞지 않아서” 서버가 다음 단계로 진행하지 못할 때 발생합니다.

즉, 원인은 거의 항상 아래 범주 중 하나입니다.

  • (A) call_id/툴 호출 식별자 매칭 실패: 모델이 요청한 tool call과 다른 id로 응답함
  • (B) output 타입 오류: 문자열이어야 하는데 객체/바이너리/None을 그대로 넣음
  • (C) JSON 직렬화/인코딩 문제: Python dict를 문자열로 변환하지 않고 넣거나, bytes/비정상 유니코드 포함
  • (D) content 구조 오류: content 배열/타입, type 필드 등 메시지 형태가 틀림
  • (E) 너무 큰 payload/프록시 제한으로 잘림: 결과가 중간에 truncate되어 JSON이 깨짐(이 경우 413/502와 함께 나타나기도 함)

2) 가장 흔한 원인 Top 6 (체크리스트)

2.1 tool_call_id가 다르거나 누락됨

모델이 생성한 tool call에는 보통 id(또는 call_id)가 붙습니다. tool output은 반드시 그 id를 그대로 참조해야 합니다.

  • 모델이 준 id: call_abc123
  • 내가 tool output에 넣은 id: abc123 (prefix 제거) → 실패
  • 혹은 여러 tool call 중 다른 id로 응답 → 실패

대응: tool call 이벤트를 파싱할 때 id를 원본 그대로 저장하고, output에 그대로 넣으세요.

2.2 output을 dict로 그대로 넣음 (문자열이어야 함)

Responses API의 tool output은 흔히 문자열 텍스트로 전달하는 방식이 안전합니다. 즉, tool 결과가 dict라면 반드시 json.dumps()로 문자열화하세요.

  • 잘못: output: {"a": 1} (JSON 객체)
  • 권장: output: "{\"a\": 1}" (JSON 문자열)

2.3 bytes/Buffer/비정상 문자 포함

파일/이미지/압축 데이터 등을 tool에서 반환하고 그대로 output에 넣으면 서버가 파싱하지 못합니다.

대응:

  • 바이너리는 base64로 인코딩 후 문자열로 전달
  • 혹은 바이너리를 직접 tool output으로 주지 말고, S3/Blob URL을 반환

2.4 content 배열 구성 실수

Responses API는 메시지 content가 단일 string이 아니라 배열로 들어가는 경우가 많고, 각 아이템은 {type: "text", text: "..."} 같은 구조를 요구합니다. 이를 섞어 쓰다가 틀어지면 tool output 파싱 단계에서 실패할 수 있습니다.

대응: SDK가 제공하는 타입/헬퍼를 우선 사용하고, raw JSON을 직접 만들 때는 구조를 고정하세요.

2.5 tool 이름/스키마와 실제 반환이 불일치

예를 들어 tool 스키마에서 output은 string이라고 가정했는데, 실제로는 리스트/객체를 반환하면 서버가 “유효한 tool output이 아님”으로 판단할 수 있습니다.

이 경우는 422로도 터지지만, 흐름에 따라 400 invalid_tool_output로 보이는 사례도 있습니다. 스키마 검증과 tool output 포맷을 같이 점검하세요.

2.6 응답이 너무 커서 프록시에서 잘림(결과적으로 JSON 깨짐)

tool 결과가 너무 큰 경우, 중간의 NGINX/ALB/게이트웨이에서 body가 잘려 불완전한 JSON 문자열이 되고, 서버는 이를 파싱하다 실패합니다.

3) 재현 예제: “dict를 그대로 output으로 넣었더니 invalid_tool_output”

아래는 Python에서 흔히 하는 실수 패턴입니다. (SDK 버전에 따라 메서드/필드명이 조금 다를 수 있지만, 핵심은 동일합니다: tool output은 문자열로)

3.1 잘못된 예

# 잘못된 예: tool 결과(dict)를 그대로 output에 넣음

tool_result = {
    "status": "ok",
    "items": [1, 2, 3],
}

tool_output_message = {
    "type": "tool_output",
    "tool_call_id": tool_call_id,  # 이건 맞다고 가정
    "output": tool_result,          # ❌ dict 그대로
}

# 이 메시지를 responses API에 submit → 400 invalid_tool_output 가능

3.2 올바른 예(권장)

import json

tool_result = {
    "status": "ok",
    "items": [1, 2, 3],
}

tool_output_message = {
    "type": "tool_output",
    "tool_call_id": tool_call_id,
    "output": json.dumps(tool_result, ensure_ascii=False),  # ✅ 문자열
}

ensure_ascii=False를 주면 한글이 \uXXXX로 과도하게 이스케이프되는 것을 줄일 수 있고, 디버깅도 쉬워집니다.

4) 운영에서 안전한 패턴: “tool output 표준화 + 검증 + 로깅”

실전에서는 tool이 여러 개고, 반환 타입도 제각각이며, 종종 예외가 납니다. 아래 패턴을 적용하면 invalid_tool_output를 체계적으로 없앨 수 있습니다.

4.1 tool 결과를 무조건 문자열로 normalize

import json
from typing import Any, Tuple


def normalize_tool_output(value: Any) -> Tuple[str, str]:
    """tool output을 Responses API에 안전하게 넣기 위해 문자열로 정규화.

    Returns:
        (output_text, content_type_hint)
    """
    if value is None:
        return "", "text/plain"

    if isinstance(value, (str, int, float, bool)):
        return str(value), "text/plain"

    if isinstance(value, (bytes, bytearray)):
        # 바이너리는 직접 넣지 말고 base64 또는 URL 권장
        import base64
        b64 = base64.b64encode(value).decode("ascii")
        return json.dumps({"base64": b64}), "application/json"

    # list/dict/기타 객체는 JSON 문자열로
    try:
        return json.dumps(value, ensure_ascii=False, default=str), "application/json"
    except Exception:
        # 최후의 보루
        return str(value), "text/plain"

4.2 tool_call_id 매칭을 강제


def build_tool_output_message(tool_call_id: str, tool_result: Any) -> dict:
    if not tool_call_id or not isinstance(tool_call_id, str):
        raise ValueError("tool_call_id must be a non-empty string")

    output_text, _ = normalize_tool_output(tool_result)

    # 서버/SDK 규격에 맞춰 type, tool_call_id, output을 정확히 구성
    return {
        "type": "tool_output",
        "tool_call_id": tool_call_id,
        "output": output_text,
    }

이렇게 만들어두면, “id를 깜빡함/다른 id를 넣음/None을 넣음” 같은 실수가 초기에 차단됩니다.

4.3 에러 시에도 tool output은 ‘정상 메시지’로 반환

tool 실행 중 예외가 나면, 애플리케이션이 tool output을 아예 못 보내거나, 예외 객체를 그대로 넣어버리는 경우가 있습니다. 이 또한 invalid_tool_output의 흔한 원인입니다.

권장: 예외가 나도 output은 문자열(JSON 문자열)로 반환하고, 모델이 이를 보고 사용자에게 설명하게 하세요.

import traceback


def safe_execute_tool(fn, *args, **kwargs):
    try:
        return {"ok": True, "data": fn(*args, **kwargs)}
    except Exception as e:
        return {
            "ok": False,
            "error": {
                "type": type(e).__name__,
                "message": str(e),
                "trace": traceback.format_exc(limit=5),
            },
        }

5) 디버깅 방법: “원인 좁히기”를 위한 로그 포인트

invalid_tool_output는 서버가 tool output을 이해 못한 상태라, 클라이언트 입장에서는 메시지가 빈약하게 느껴질 수 있습니다. 아래 로그를 남기면 원인 파악이 빨라집니다.

  1. 모델이 생성한 tool_call 원문 로그: name, arguments, id
  2. tool 실행 결과의 Python 타입 로그: type(result)
  3. normalize 이후 output 길이/앞부분 스니펫
  4. tool_output_message 전체 JSON(민감정보 마스킹 후)
  5. 프록시/게이트웨이의 request body limit(특히 결과가 큰 tool)

추가로, 응답 검증/직렬화 계층에서 이미 타입이 꼬이는 경우가 많습니다. FastAPI/Pydantic으로 tool 결과를 감싸서 반환하는 구조라면, 응답 모델/직렬화에서 예상치 못한 변환이 일어날 수 있으니 점검하세요: Pydantic v2 FastAPI 응답 검증 에러 7종 해결법

6) 케이스별 빠른 처방전

케이스 A: “로컬에선 되는데 운영에서만 invalid_tool_output”

  • 운영에만 NGINX/ALB가 있고 body 제한/압축/버퍼링이 다름
  • tool 결과가 커서 중간에서 잘려 JSON이 깨짐

처방

  • tool output 크기 제한(예: 20KB 등)을 애플리케이션에서 강제
  • 큰 데이터는 URL로 넘기고 모델에는 요약만 전달
  • NGINX/ALB 설정 점검(413/502/504 동반 여부 확인)

케이스 B: “여러 tool을 동시에 호출할 때만 실패”

  • tool_call_id를 덮어쓰거나, 마지막 id로만 응답하는 버그

처방

  • tool call마다 결과를 매핑하는 dict를 만들고, id 기준으로 tool_output을 생성
  • 비동기 실행 시 race condition으로 id가 섞이지 않게 구조화

케이스 C: “특정 문서/특정 입력에서만 실패”

  • tool 결과에 제어문자(\x00 등)나 인코딩 깨진 텍스트가 포함

처방

  • 출력 문자열에서 제어문자 제거/치환
  • UTF-8 디코딩 시 errors="replace" 적용 또는 정제

7) 결론: invalid_tool_output을 없애는 3가지 원칙

  1. tool output은 항상 문자열로(dict/list/객체/바이너리 그대로 금지, JSON 문자열화)
  2. tool_call_id는 원본 그대로 매칭(동시 호출/비동기에서 특히 주의)
  3. 크기/인코딩/프록시 제한을 고려(큰 결과는 요약+링크로, 중간에서 잘리지 않게)

이 3가지만 지켜도 400 invalid_tool_output의 대부분은 재발하지 않습니다. 그래도 해결이 안 된다면, 다음 순서로 확인하세요: (1) tool_call 원문과 tool_output 원문을 나란히 비교 → (2) output 타입/직렬화 확인 → (3) 프록시에서 body가 잘리는지 확인 → (4) 422 스키마 검증 문제 여부 점검.