- Published on
OpenAI Responses API 400 invalid_request_error 원인과 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
Responses API를 붙여 놓고 개발하다 보면 가장 사람을 지치게 만드는 에러가 400 invalid_request_error입니다. 401/429처럼 “권한/쿼터 문제”로 딱 떨어지지 않고, 요청 바디의 아주 사소한 형태·스키마·타입 불일치 때문에 발생하는 경우가 많기 때문입니다. 특히 SDK 버전, 모델 이름, 입력 포맷(문자열 vs 배열), 툴 호출 파라미터, 스트리밍 옵션이 섞이면서 원인이 여러 갈래로 갈라집니다.
이 글은 현업에서 자주 터지는 400 케이스를 원인별로 분류하고, 바로 복붙해 점검 가능한 코드와 수정 패턴을 정리합니다.
400 invalid_request_error를 먼저 “분류”하라
invalid_request_error는 서버가 “요청을 이해했지만 스키마/검증에서 거절”했다는 뜻입니다. 해결의 80%는 아래 3가지를 먼저 확보하면 끝납니다.
- 실제 전송된 JSON 바디(로그)
- 에러 메시지의 path/field(예:
input[0].content등) - 사용 중인 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이 급증합니다. 이유는 간단합니다.
parametersJSON Schema가 잘못됨required에 없는 키를 강제하거나, 반대로 필수값을 누락- 문자열이어야 하는데 숫자/배열을 넘김
트러블슈팅 요령
- 툴을 제거한 요청이 성공하는지 확인
- 툴을 “하나만” 남겨서 재현
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과 함께 디버깅해야 합니다.
- 스트리밍 자체가 불안정할 때: OpenAI Responses API 스트리밍 끊김 타임아웃 완전 복구 가이드
- 프록시/Nginx/Cloudflare 뒤에서 SSE가 자주 끊길 때: FastAPI Uvicorn에서 SSE 웹소켓 LLM 스트리밍이 프록시 뒤에서 끊길 때 Cloudflare Nginx ALB 버퍼 타임아웃 gzip으로 EventSource failed 100% 재현 해결 체크리스트
핵심은 “진짜 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을 “재발 방지”하는 설계
- 요청 생성기를 단일화:
build_responses_payload()같은 함수로 모든 호출을 통제 - 기능 플래그로 단계적 롤아웃: tools/JSON 강제/스트리밍을 한 번에 켜지 말 것
- 샘플링된 실패 요청 저장: 개인정보 제거 후, 실패한 payload를 저장해 회귀 테스트에 사용
- 계약 테스트(Contract test): CI에서 최소 1개 요청을 실제 API에 쏴서 스키마 변경을 조기 감지
결론
OpenAI Responses API 400 invalid_request_error는 대부분 “모델 문제가 아니라 요청 스키마 문제”입니다. 해결의 지름길은 요청을 최소 형태로 축소 → 성공 확인 → 기능을 하나씩 추가하는 방식으로 원인을 좁히는 것입니다.
지금 운영 중인 코드에서 400이 반복된다면, 오늘 바로 다음을 실행해 보세요.
- 실패한 요청 JSON을 로그로 남기기
input/tools/response_format/stream을 최소화한 요청으로 재현하기- 요청 DTO 검증(Pydantic 등)으로 사전 차단하기
이 3가지만 적용해도 400 디버깅 시간은 체감상 절반 이하로 줄어듭니다.