Published on

OpenAI Responses API 400 invalid_request_error 원인과 해결

Authors

서론

Responses API를 붙여 놓고 개발하다 보면 가장 사람을 지치게 만드는 에러가 400 invalid_request_error입니다. 401/429처럼 “권한/쿼터 문제”로 딱 떨어지지 않고, 요청 바디의 아주 사소한 형태·스키마·타입 불일치 때문에 발생하는 경우가 많기 때문입니다. 특히 SDK 버전, 모델 이름, 입력 포맷(문자열 vs 배열), 툴 호출 파라미터, 스트리밍 옵션이 섞이면서 원인이 여러 갈래로 갈라집니다.

이 글은 현업에서 자주 터지는 400 케이스를 원인별로 분류하고, 바로 복붙해 점검 가능한 코드와 수정 패턴을 정리합니다.


400 invalid_request_error를 먼저 “분류”하라

invalid_request_error는 서버가 “요청을 이해했지만 스키마/검증에서 거절”했다는 뜻입니다. 해결의 80%는 아래 3가지를 먼저 확보하면 끝납니다.

  1. 실제 전송된 JSON 바디(로그)
  2. 에러 메시지의 path/field(예: input[0].content 등)
  3. 사용 중인 SDK 버전 및 호출 방식(Responses API인지, Chat Completions 습관이 섞였는지)

Python에서 요청 바디를 안전하게 로깅하기

import json
import httpx

payload = {
    "model": "gpt-4.1-mini",
    "input": "Hello",
}

print("REQUEST JSON:")
print(json.dumps(payload, ensure_ascii=False, indent=2))

with httpx.Client(timeout=30) as client:
    r = client.post(
        "https://api.openai.com/v1/responses",
        headers={
            "Authorization": f"Bearer {OPENAI_API_KEY}",
            "Content-Type": "application/json",
        },
        json=payload,
    )
    print(r.status_code)
    print(r.text)

이렇게 “내가 보낸 것”을 먼저 박제해두면, 400은 대부분 금방 끝납니다.


원인 1) Responses API에 Chat Completions 포맷을 섞어 보냄

가장 흔한 실수는 messages를 그대로 보내거나, role/content 구조를 Responses API의 input과 혼용하는 것입니다.

잘못된 예(혼용)

{
  "model": "gpt-4.1-mini",
  "messages": [
    {"role": "user", "content": "hi"}
  ]
}

올바른 예(Responses API 기본)

가장 단순한 형태는 input을 문자열로 보내는 것입니다.

{
  "model": "gpt-4.1-mini",
  "input": "hi"
}

또는 멀티턴/구조화 입력이 필요하면, input배열로 보내고 각 항목을 타입에 맞게 구성합니다(텍스트 중심이라면 최소 구성으로 시작하세요).

{
  "model": "gpt-4.1-mini",
  "input": [
    {"role": "user", "content": "hi"}
  ]
}

> 팁: 팀 내에 Chat Completions 코드가 섞여 있다면, “요청 생성 함수”를 하나로 통일하고 Responses 전용 DTO(데이터 구조)를 만들어 섞일 여지를 줄이세요.


원인 2) model 이름 오타/권한 없는 모델을 사용

모델명이 틀리면 404처럼 보일 것 같지만, 실제로는 400으로 떨어지는 경우도 있습니다(특히 SDK/엔드포인트 조합에 따라 메시지가 달라짐).

체크리스트

  • 대소문자/하이픈/버전 문자열 오타
  • 조직 정책상 접근 불가 모델
  • 오래된 문서의 레거시 모델명 사용

해결

  • 먼저 가장 보수적인 모델명으로 재현 테스트(예: 문서에 명시된 범용 모델)
  • 배포 환경과 로컬 환경의 OPENAI_API_KEY가 다른지 확인

원인 3) input 타입 불일치(문자열 vs 배열 vs 객체)

input은 편의상 문자열도 받지만, 프로젝트가 커지면 다음과 같은 실수로 400이 납니다.

흔한 실수

  • input에 dict를 바로 넣음
  • input 배열 항목의 content를 문자열이 아니라 배열/객체로 넣음
  • content 키를 text로 착각

안전한 패턴: “텍스트만”으로 먼저 붙이고 확장

from openai import OpenAI

client = OpenAI()

resp = client.responses.create(
    model="gpt-4.1-mini",
    input="요청 스키마를 먼저 단순화해서 400을 제거하자.",
)
print(resp.output_text)

이 최소 예제가 성공하면, 그 다음에만 배열/멀티모달/툴을 붙이세요. 400을 디버깅할 때는 기능을 빼면서 원인을 좁히는 게 가장 빠릅니다.


원인 4) response_format(JSON) 강제인데 출력 지시가 불명확

response_format을 JSON으로 강제해 놓고 프롬프트는 자연어로만 지시하면, 모델이 JSON을 깨뜨리거나(혹은 스키마 검증에서) 에러로 이어질 수 있습니다. 일부 조합에서는 400으로 “요청 자체가 부적절”하다고 떨어지기도 합니다.

해결 패턴

  • JSON 강제 시에는 시스템/지시문에 JSON만 출력을 명확히
  • 가능하면 JSON Schema 기반으로 제한

예시(개념):

resp = client.responses.create(
    model="gpt-4.1-mini",
    input=[
        {
            "role": "system",
            "content": "You must output valid JSON only. No extra text."
        },
        {
            "role": "user",
            "content": "이슈 제목과 원인을 JSON으로 정리해줘"
        }
    ],
    response_format={"type": "json_object"},
)

운영에서는 검증 실패 시 재시도 전략도 같이 설계하세요. (429 대응은 별도의 설계가 필요합니다: OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기)


원인 5) tools/function 호출 스키마 오류(파라미터 타입/필수값)

툴 호출을 붙이는 순간 400이 급증합니다. 이유는 간단합니다.

  • parameters JSON Schema가 잘못됨
  • required에 없는 키를 강제하거나, 반대로 필수값을 누락
  • 문자열이어야 하는데 숫자/배열을 넘김

트러블슈팅 요령

  1. 툴을 제거한 요청이 성공하는지 확인
  2. 툴을 “하나만” 남겨서 재현
  3. parameters를 최소 스키마로 줄여서 점진 확장

툴 스키마는 최소한으로 시작하세요.

tool = {
  "type": "function",
  "function": {
    "name": "get_order_status",
    "description": "주문 상태 조회",
    "parameters": {
      "type": "object",
      "properties": {
        "order_id": {"type": "string"}
      },
      "required": ["order_id"],
      "additionalProperties": False
    }
  }
}

resp = client.responses.create(
    model="gpt-4.1-mini",
    input="order_id=A1234 주문 상태 조회해줘",
    tools=[tool],
)

현업 팁: additionalProperties: False를 켜면 모델이 엉뚱한 키를 만들어내는 것을 줄여 후처리/검증 비용이 내려갑니다.


원인 6) 멀티모달 입력에서 content 파트 타입을 잘못 구성

이미지/오디오 등 멀티모달을 붙일 때 content를 문자열로만 취급하다가 400이 납니다. 이때는 보통 content가 “파트 배열”이어야 하는데 문자열로 보내거나, 반대로 텍스트만 필요한데 파트 객체를 잘못 구성합니다.

해결

  • 텍스트만이면 텍스트만으로 성공시키고
  • 멀티모달은 문서의 파트 타입(input_text, input_image 등)을 정확히 따르세요
  • 한 번에 여러 타입을 붙이지 말고 단계적으로 추가

원인 7) 스트리밍 옵션/전송 계층 문제를 400으로 오해

스트리밍이 끊기거나 프록시에서 버퍼링/타임아웃이 나면, 클라이언트가 “이상한 상태의 요청”을 재전송하면서 400처럼 보이는 로그가 섞일 수 있습니다. 특히 SSE를 프록시 뒤에서 운영할 때는 499/502/timeout과 함께 디버깅해야 합니다.

핵심은 “진짜 400(스키마 오류)”인지, “전송/재시도/프록시로 인한 2차 오류”인지 분리하는 것입니다.


실전: 400을 10분 안에 잡는 디버깅 체크리스트

1) 최소 요청으로 축소

  • tools 제거
  • response_format 제거
  • input을 문자열 1개로
  • stream 끄기

성공하면, 하나씩 다시 붙이며 마지막으로 추가한 기능이 범인입니다.

2) 에러 메시지의 field/path를 그대로 따라가라

에러 응답에 param, field, path가 나오면 그 부분만 고치면 됩니다. (팀에서 흔히 하는 실수: 메시지를 읽지 않고 모델/키만 바꾸며 “운빨 디버깅”)

3) SDK/HTTP 혼용 시, 한쪽으로 통일

  • Python SDK를 쓰면 SDK만으로
  • httpx/requests로 직접 호출하면 직접 호출만으로

혼용하면 기본 헤더/직렬화/타임아웃/재시도 정책이 달라져 재현이 어려워집니다.

4) 운영 환경에서는 “요청 스키마 검증”을 서버에서 선제 적용

Pydantic(또는 zod/ajv 등)로 요청 DTO를 검증하면, OpenAI까지 가지도 않고 로컬에서 400 원인을 잡을 수 있습니다.

from pydantic import BaseModel
from typing import Union, List, Dict, Any

class ResponsesRequest(BaseModel):
    model: str
    input: Union[str, List[Dict[str, Any]]]

# 잘못된 input 형태를 여기서 즉시 잡는다.
req = ResponsesRequest(model="gpt-4.1-mini", input="hi")

Best Practice: 400을 “재발 방지”하는 설계

  1. 요청 생성기를 단일화: build_responses_payload() 같은 함수로 모든 호출을 통제
  2. 기능 플래그로 단계적 롤아웃: tools/JSON 강제/스트리밍을 한 번에 켜지 말 것
  3. 샘플링된 실패 요청 저장: 개인정보 제거 후, 실패한 payload를 저장해 회귀 테스트에 사용
  4. 계약 테스트(Contract test): CI에서 최소 1개 요청을 실제 API에 쏴서 스키마 변경을 조기 감지

결론

OpenAI Responses API 400 invalid_request_error는 대부분 “모델 문제가 아니라 요청 스키마 문제”입니다. 해결의 지름길은 요청을 최소 형태로 축소 → 성공 확인 → 기능을 하나씩 추가하는 방식으로 원인을 좁히는 것입니다.

지금 운영 중인 코드에서 400이 반복된다면, 오늘 바로 다음을 실행해 보세요.

  • 실패한 요청 JSON을 로그로 남기기
  • input/tools/response_format/stream을 최소화한 요청으로 재현하기
  • 요청 DTO 검증(Pydantic 등)으로 사전 차단하기

이 3가지만 적용해도 400 디버깅 시간은 체감상 절반 이하로 줄어듭니다.