Published on

OpenAI Responses API 422 스키마 검증 에러 해결 가이드

Authors

서버는 살아 있고 인증도 정상인데, 요청만 보내면 422 Unprocessable Entity가 떨어질 때가 있습니다. 특히 OpenAI Responses API를 붙여놓고 프롬프트/툴/멀티모달 입력을 조금만 복잡하게 만들면, “스키마 검증(schema validation)” 단계에서 바로 막히는 경우가 많습니다.

422는 단순히 “요청이 잘못됨”이 아니라 서버가 JSON은 파싱했지만, API가 기대하는 스키마와 맞지 않아 처리할 수 없음을 의미합니다. 즉, 네트워크/권한/레이트리밋 문제가 아니라 요청 바디의 구조·타입·필수 필드에서 삐끗한 겁니다.

이 글에서는 현업에서 자주 밟는 422 패턴을 재현 코드 + 수정 코드로 정리하고, 운영 환경에서 422를 “사전에 차단”하는 베스트 프랙티스까지 다룹니다.


422를 먼저 제대로 읽는 법

Responses API의 422는 보통 다음 형태로 옵니다(표현은 SDK/언어에 따라 조금 다름).

  • error.type: invalid_request_error 또는 유사 타입
  • error.message: 어떤 필드가 어떤 타입/구조를 기대했는지
  • error.param: 문제가 된 경로(예: input[0].content[1].type)

핵심은 **에러 메시지의 “경로(path)”**입니다.

  • input 배열의 몇 번째 요소인지
  • 그 요소의 content 배열의 몇 번째 블록인지
  • 그 블록의 type/text/image_url 같은 필드 중 무엇이 문제인지

이 경로를 기준으로 “내가 만든 JSON”을 그대로 출력해 비교하면 대부분 잡힙니다.


가장 흔한 원인 1: input을 문자열로 보내거나, content를 문자열로 보내는 실수

Responses API는 input을 단순 문자열로도 받는 모드가 있지만(단일 텍스트), 멀티턴/멀티모달/role 기반으로 가면 보통 아래처럼 배열 + 객체 + content 블록 구조를 사용합니다.

실패 예시: content를 문자열로 보냄

from openai import OpenAI

client = OpenAI()

# ❌ content는 보통 배열(블록들)이어야 하는데 문자열로 보냄
resp = client.responses.create(
    model="gpt-4.1-mini",
    input=[
        {
            "role": "user",
            "content": "Summarize this text in 3 bullets."  # <- 422 유발 가능
        }
    ],
)
print(resp.output_text)

수정 예시: content를 블록 배열로 보냄

from openai import OpenAI

client = OpenAI()

resp = client.responses.create(
    model="gpt-4.1-mini",
    input=[
        {
            "role": "user",
            "content": [
                {"type": "input_text", "text": "Summarize this text in 3 bullets."}
            ],
        }
    ],
)

print(resp.output_text)

체크포인트

  • content는 문자열이 아니라 블록 배열로 보내는 습관을 들이면 422를 크게 줄일 수 있습니다.
  • 특히 이미지/파일/툴 호출이 섞이면 문자열 content는 거의 항상 함정이 됩니다.

가장 흔한 원인 2: content 블록의 type 오타 또는 필드명 불일치

블록 기반 입력에서 422가 나는 1순위는 type 값 오타입니다.

실패 예시: type을 text로 착각

# ❌ type은 "text"가 아니라 "input_text"가 필요한 경우가 많음
resp = client.responses.create(
    model="gpt-4.1-mini",
    input=[{
        "role": "user",
        "content": [
            {"type": "text", "text": "Hello"}
        ]
    }],
)

수정 예시

resp = client.responses.create(
    model="gpt-4.1-mini",
    input=[{
        "role": "user",
        "content": [
            {"type": "input_text", "text": "Hello"}
        ]
    }],
)

현업 팁: 팀 내에서 type 문자열을 여기저기 흩뿌려 쓰면 오타가 필연입니다. 아래처럼 상수화하거나(또는 Pydantic/JSON Schema로 강제) 관리하세요.


가장 흔한 원인 3: JSON은 맞는데 타입이 미묘하게 다름 (숫자/불리언/널)

422는 “문자열로 와야 하는데 숫자로 옴”, “배열이어야 하는데 객체로 옴” 같은 타입 불일치에서 잘 터집니다.

예를 들어, 메타데이터를 붙이면서 실수로 metadata를 문자열로 보내거나, max_output_tokens에 문자열을 넣는 식입니다.

# ❌ max_output_tokens는 int가 기대되는데 "512" 문자열을 넣음
resp = client.responses.create(
    model="gpt-4.1-mini",
    input="Write a haiku.",
    max_output_tokens="512",
)

수정:

resp = client.responses.create(
    model="gpt-4.1-mini",
    input="Write a haiku.",
    max_output_tokens=512,
)

체크포인트

  • 환경변수/설정파일에서 읽어온 값은 기본적으로 문자열입니다. 숫자/불리언은 반드시 캐스팅하세요.

가장 흔한 원인 4: tool 정의 스키마가 JSON Schema가 아님

툴 호출을 붙이면 422가 급증합니다. 이유는 간단합니다.

  • tools[].function.parametersJSON Schema 형태여야 함
  • required, properties, type 등이 어긋나면 스키마 검증에서 컷

실패 예시: parameters에 Pydantic 스타일을 그대로 넣음

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Get weather",
            # ❌ JSON Schema가 아닌 임의 구조
            "parameters": {
                "city": "string",
                "unit": "celsius"
            },
        },
    }
]

수정 예시: JSON Schema로 명시

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Get weather",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string"},
                    "unit": {"type": "string", "enum": ["c", "f"]},
                },
                "required": ["city"],
                "additionalProperties": False,
            },
        },
    }
]

resp = client.responses.create(
    model="gpt-4.1-mini",
    input=[{
        "role": "user",
        "content": [{"type": "input_text", "text": "Seoul weather?"}],
    }],
    tools=tools,
)

베스트 프랙티스

  • additionalProperties: False를 넣으면 모델이 엉뚱한 필드를 뱉는 경우를 줄이고, 서버/클라이언트 양쪽에서 디버깅이 쉬워집니다.

가장 흔한 원인 5: 멀티모달(image) 블록 구조 오류

이미지 입력에서 422는 대개 image_url 구조를 잘못 맞춘 경우입니다.

실패 예시: image_url을 문자열로 넣음

resp = client.responses.create(
    model="gpt-4.1-mini",
    input=[{
        "role": "user",
        "content": [
            {"type": "input_text", "text": "What is in this image?"},
            # ❌ image_url은 보통 객체 형태를 기대
            {"type": "input_image", "image_url": "https://example.com/a.png"},
        ],
    }],
)

수정 예시: image_url 객체로

resp = client.responses.create(
    model="gpt-4.1-mini",
    input=[{
        "role": "user",
        "content": [
            {"type": "input_text", "text": "What is in this image?"},
            {"type": "input_image", "image_url": {"url": "https://example.com/a.png"}},
        ],
    }],
)

이미지/파일을 크게 다루다 보면 422가 아니라 413(용량 제한)도 자주 맞습니다. 그때는 업로드/청크 전략을 따로 잡아야 합니다. OpenAI Responses API 413 에러 업로드 용량 제한과 청크 전략도 함께 참고하면 좋습니다.


트러블슈팅: 422를 5분 안에 잡는 체크리스트

1) “내가 보낸 JSON 원문”을 로그로 남겨라

422는 재현이 쉬운 대신, 요청 바디를 정확히 봐야 합니다.

  • SDK 객체를 그대로 출력하지 말고
  • 최종 직렬화된 JSON을 출력/저장하세요.

2) 에러의 param 경로를 그대로 따라가라

예: input[0].content[1].image_url.url이 문제라면

  • content[1]이 진짜 이미지 블록인지
  • image_url이 dict인지
  • url 키가 존재하는지

를 그대로 점검하면 됩니다.

3) 400과 422를 구분하라

  • 400: 필수 파라미터 누락, 모델명 오타, 지원 안 되는 조합 등 “요청 자체가 invalid”
  • 422: JSON은 valid, 하지만 스키마가 invalid

400의 대표 원인/수정은 따로 정리해 둔 글이 도움이 됩니다: OpenAI Responses API 400 invalid_request_error 원인과 해결


Best Practice: Pydantic으로 ‘요청 스키마’를 로컬에서 먼저 검증하기

운영에서 422가 무서운 이유는, 릴리즈 후 특정 입력에서만 터져서 장애로 이어지기 때문입니다. 가장 좋은 패턴은 서버로 보내기 전에 로컬에서 스키마를 강제하는 겁니다.

아래는 “content 블록은 input_text 또는 input_image만 허용” 같은 정책을 Pydantic v2로 묶어 사전 검증하는 예시입니다.

from typing import List, Literal, Union, Optional
from pydantic import BaseModel, Field, HttpUrl

class InputTextBlock(BaseModel):
    type: Literal["input_text"]
    text: str = Field(min_length=1)

class ImageUrl(BaseModel):
    url: HttpUrl

class InputImageBlock(BaseModel):
    type: Literal["input_image"]
    image_url: ImageUrl

ContentBlock = Union[InputTextBlock, InputImageBlock]

class InputMessage(BaseModel):
    role: Literal["user", "developer", "system"]
    content: List[ContentBlock]

class ResponsesRequest(BaseModel):
    model: str
    input: List[InputMessage]
    max_output_tokens: Optional[int] = None

payload = ResponsesRequest(
    model="gpt-4.1-mini",
    input=[
        InputMessage(
            role="user",
            content=[
                InputTextBlock(type="input_text", text="Describe this image"),
                InputImageBlock(type="input_image", image_url=ImageUrl(url="https://example.com/a.png")),
            ],
        )
    ],
    max_output_tokens=300,
)

# SDK에 넘길 dict는 검증된 값
data = payload.model_dump(mode="json")

이렇게 하면

  • type 오타
  • image_url 구조 실수
  • 빈 문자열 텍스트
  • max_output_tokens 타입 오류

같은 문제가 API 호출 전에 예외로 잡힙니다.

Pydantic v2 검증 에러를 현장에서 빠르게 해석하고 고치는 방법은 아래 글의 패턴이 그대로 적용됩니다: Pydantic v2 FastAPI 응답 검증 에러 7종 해결법


운영 관점: 422는 “재시도”로 해결되지 않는다

500/503처럼 일시 장애는 재시도·폴백·서킷브레이커로 흡수할 수 있지만, 422는 결정적(deterministic) 에러라 재시도해도 똑같이 실패합니다.

따라서 422 대응은 다음 순서가 효율적입니다.

  1. 로컬 스키마 검증(Pydantic/JSON Schema)
  2. 실패 시 요청 바디 + param 경로 로깅
  3. 사용자 입력/애플리케이션 상태에 따라 “수정 가능한” 케이스면 정규화(normalization)
  4. 수정 불가면 즉시 4xx로 반환(불필요한 재시도 금지)

(참고로 500/503 쪽은 재시도 설계가 핵심이므로 성격이 완전히 다릅니다. 필요하면 OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커처럼 분리해서 설계하세요.)


결론: 422를 없애는 가장 현실적인 방법

  • 422는 “API가 다운”이 아니라 내 요청의 스키마가 틀린 것입니다.
  • 가장 많이 터지는 곳은 input/content 구조, 블록 type 오타, 이미지/툴 스키마입니다.
  • 운영에서 422를 줄이려면 요청 페이로드를 Pydantic으로 사전 검증하고, 실패 시 문제 경로(param)와 원문 JSON을 남기는 체계를 만드세요.

지금 당장 할 일은 간단합니다.

  1. 현재 422가 나는 요청을 “최종 JSON”으로 덤프해 저장하고,
  2. 이 글의 체크리스트대로 경로를 따라가며 구조를 바로잡은 뒤,
  3. 같은 실수가 재발하지 않게 Pydantic 모델로 요청 스키마를 고정하세요.

이 3단계만 적용해도 Responses API 연동에서 가장 성가신 422의 80%는 사라집니다.