- Published on
OpenAI Responses API 400 에러 - schema·tool 호출 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI Responses API를 붙이고 나면, 가장 시간을 많이 잡아먹는 문제가 의외로 “모델 성능”이 아니라 400 Bad Request입니다. 특히 schema(JSON Schema) 기반 출력 강제(예: response_format: { type: "json_schema" ... })나 tool(function) 호출을 동시에 쓰기 시작하면, 요청 페이로드가 조금만 어긋나도 400이 나고 메시지는 짧게 끝나는 경우가 많습니다.
이 글은 “왜 400이 나는지”를 추측으로 돌리지 않고, 요청 구조 → schema → tool 호출 순서로 체계적으로 좁혀가며 디버깅하는 실전 절차를 정리합니다. (비슷한 결의 에러로 invalid_json을 겪고 있다면 Python OpenAI SDK 400 invalid_json 원인과 해결도 함께 보면 좋습니다.)
1) 400을 먼저 분류하라: 어디서 깨졌는지
Responses API에서 400은 크게 세 부류로 나뉩니다.
- 요청 JSON 자체가 잘못됨: 파싱 실패, 타입 불일치, 필수 필드 누락
- schema가 잘못됨: JSON Schema 문법/제약 위반, 모델 출력이 schema를 만족할 수 없도록 설계
- tool 호출 정의/호출이 잘못됨: tool 정의의
parametersschema 문제, tool name 불일치, tool 결과를 다시 모델에 넣는 방식 오류
실무에서는 (1)과 (2)가 섞여 보이는 경우가 많습니다. 그래서 아래처럼 에러 응답을 구조적으로 로깅하는 것부터 시작해야 합니다.
에러 응답 로깅(필수)
- HTTP status
- 응답 body 전체(
error.type,error.message,error.param등) - 요청 body(민감정보 마스킹)
- 요청 시각/trace id
Node 예시(axios):
import axios from "axios";
async function callResponses(payload) {
try {
const res = await axios.post(
"https://api.openai.com/v1/responses",
payload,
{
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
timeout: 30_000,
}
);
return res.data;
} catch (e) {
const status = e.response?.status;
const data = e.response?.data;
console.error("[OpenAI] status:", status);
console.error("[OpenAI] error body:", JSON.stringify(data, null, 2));
console.error("[OpenAI] request payload:", JSON.stringify(payload, null, 2));
throw e;
}
}
여기까지가 “디버깅의 시작점”입니다. 400은 대부분 요청 페이로드만 보면 원인을 찾을 수 있습니다.
2) Responses API 요청 구조에서 가장 흔한 실수
Responses API는 model, input을 중심으로 구성되며, tool과 schema를 붙이면 필드가 늘어납니다. 400의 상당수는 다음 패턴입니다.
2-1. input 타입/구조 실수
input을 문자열로 넣을 수도 있고(간단), 메시지 배열로 넣을 수도 있습니다(대화형).- 하지만 메시지 배열을 쓸 때 role/content 구조를 틀리면 400이 납니다.
권장 형태(대화형 입력 예시):
{
"model": "gpt-4.1-mini",
"input": [
{
"role": "user",
"content": [
{ "type": "input_text", "text": "주문 요약을 JSON으로 만들어줘" }
]
}
]
}
자주 하는 실수:
content를 문자열로 넣었는데, 특정 모드/SDK에서는 배열을 기대type을text로 쓰거나,input_text대신 다른 타입을 사용- role에
assistant를 넣어놓고 content가 input 타입(규칙 불일치)
2-2. response_format/schema를 붙였는데 모델에게 불가능한 요구를 함
예를 들어 schema는 모든 필드를 필수로 만들었는데, 입력에는 그 정보를 절대 알 수 없는 경우가 있습니다. 이 경우 모델이 억지로 맞추다 실패하거나(혹은 거부), 결과적으로 “schema 불일치”가 나며 400으로 돌아올 수 있습니다.
해결은 간단합니다.
- 필수(required)는 최소화
- 모르는 값은
null허용 또는"unknown"같은 sentinel 허용 additionalProperties: false는 필요할 때만(디버깅 중에는 잠시 완화)
3) JSON Schema 디버깅: 400을 만드는 7가지 포인트
Responses API에서 schema 기반 출력은 강력하지만, schema가 조금만 과격하거나 문법이 미묘하게 틀리면 400이 납니다. 아래는 실제로 많이 밟는 지뢰입니다.
3-1. required와 properties 불일치
required에 적어놓고 properties에 정의하지 않으면 논리적으로 모순입니다.
{
"type": "object",
"properties": {
"orderId": { "type": "string" }
},
"required": ["orderId", "amount"]
}
위에서 amount는 properties에 없으니 수정해야 합니다.
3-2. type에 맞지 않는 제약 키워드 사용
type: "string"인데minimum/maximum을 씀type: "number"인데minLength를 씀
스키마 검증기에서는 바로 잡히지만, API에 바로 던지면 400이 될 수 있습니다.
3-3. additionalProperties: false로 인한 과도한 실패
모델이 작은 오타/추가 필드를 내보내면 즉시 실패합니다. 운영에서 엄격하게 가고 싶더라도, 초기에는 다음 전략이 좋습니다.
- 디버깅 단계:
additionalProperties: true또는 생략 - 안정화 단계:
false로 강화 + 프롬프트에서 필드 고정
3-4. 너무 깊거나 복잡한 schema
중첩이 과도하면 모델이 구조를 맞추기 어려워집니다. 특히 배열 안에 객체, 그 안에 union(anyOf)이 반복되는 형태는 실패 확률이 올라갑니다.
권장:
- 1~2단계 중첩으로 단순화
- union은 최소화
- 문자열 enum을 적극 사용(선택지 제한)
3-5. 날짜/시간 포맷 강제 실패
format: "date-time"를 강제했는데, 모델이 2026-2-3처럼 내면 실패합니다. 이럴 땐
pattern으로 허용 범위를 넓히거나- 아예 문자열로 받고 서버에서 파싱/정규화
가 더 튼튼합니다.
3-6. schema name/버전 관리 부재
운영 중 schema를 바꾸면, 캐시된 프롬프트/코드 경로에서 구버전 schema로 요청을 보내 400이 날 수 있습니다.
- schema에
name/version을 박아두고 - 로그에 version을 남기세요.
3-7. “모델 출력”이 아니라 “요청 payload”에서 schema가 깨지는 경우
가장 흔한 케이스는 JSON을 문자열로 이중 인코딩하는 것입니다.
- 올바른 형태: schema는 JSON 객체
- 흔한 실수: schema를
JSON.stringify(schema)해서 문자열로 전달
4) Tool 호출 디버깅: 정의(parameters)와 실제 호출의 간극
tool(function) 호출은 크게 두 단계입니다.
- 모델이 tool을 호출(arguments 포함)
- 서버가 tool을 실행하고 결과를 모델에 다시 전달
400은 주로 (1)의 정의가 잘못되었거나, (2)에서 결과를 넣는 방식이 어긋날 때 발생합니다.
4-1. tool 정의의 parameters는 “JSON Schema(객체)”여야 한다
가장 안전한 패턴은 type: "object" + properties + required입니다.
const tools = [
{
type: "function",
name: "lookup_order",
description: "주문 ID로 주문 정보를 조회한다",
parameters: {
type: "object",
properties: {
orderId: { type: "string", description: "주문 ID" }
},
required: ["orderId"],
additionalProperties: false
}
}
];
흔한 실수:
parameters에properties만 넣고type을 빼먹음required만 있고properties가 빈 객체additionalProperties: false인데 properties에 없는 값을 모델이 arguments로 넣도록 유도
4-2. tool name 불일치(대소문자/스네이크 vs 케밥)
정의는 lookup_order인데, 프롬프트에서 “lookup-order를 호출해”라고 쓰면 모델이 그 이름으로 호출하려다 실패할 수 있습니다.
- tool name은 코드에서 상수화
- 프롬프트에는 name을 그대로 복붙
4-3. tool 결과를 모델에 다시 넣을 때의 포맷 오류
tool을 실행한 뒤, 결과를 다시 모델에 전달하는 방식이 SDK/엔드포인트마다 조금씩 다릅니다. 핵심은 tool 결과도 구조화된 메시지로 넣어야 하며, 임의로 문자열만 던지면 문맥 손실/파싱 실패가 납니다.
아래는 “한 번의 요청에서 tool을 호출하고, 이어서 tool 결과를 반영해 최종 답을 받는” 전형적인 루프 예시입니다(개념 코드).
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const tools = [
{
type: "function",
name: "lookup_order",
description: "주문 ID로 주문 정보를 조회한다",
parameters: {
type: "object",
properties: { orderId: { type: "string" } },
required: ["orderId"],
additionalProperties: false
}
}
];
async function lookupOrder({ orderId }) {
// 실제로는 DB/외부 API 호출
return { orderId, status: "PAID", amount: 42000, currency: "KRW" };
}
export async function run() {
// 1) 모델에게 tool 호출을 유도
const r1 = await client.responses.create({
model: "gpt-4.1-mini",
input: "주문 ID A123의 상태와 결제금액을 알려줘",
tools
});
const toolCall = r1.output
?.flatMap(o => o.content || [])
?.find(c => c.type === "tool_call");
if (!toolCall) return r1;
// 2) tool 실행
const args = JSON.parse(toolCall.arguments);
const result = await lookupOrder(args);
// 3) tool 결과를 포함해 다시 요청(중요: tool_call_id 매칭)
const r2 = await client.responses.create({
model: "gpt-4.1-mini",
tools,
input: [
{ role: "user", content: [{ type: "input_text", text: "주문 ID A123의 상태와 결제금액을 알려줘" }] },
// 모델이 호출한 tool_call을 대화에 포함(컨텍스트 유지)
...r1.output,
{
role: "tool",
content: [
{
type: "tool_result",
tool_call_id: toolCall.id,
output: JSON.stringify(result)
}
]
}
]
});
return r2;
}
디버깅 포인트:
tool_call_id를 반드시 모델이 준 id와 매칭- tool 결과를 JSON 문자열로 넣을지, 객체로 넣을지(스펙/SDK 요구사항) 확인
- tool 결과가 너무 크면 잘라서(요약) 전달
5) “schema + tool”을 같이 쓸 때 400이 늘어나는 이유
둘을 동시에 쓰면 실패 지점이 늘어납니다.
- tool arguments도 schema
- 최종 출력도 schema
- tool 결과를 다시 넣는 메시지 포맷도 엄격
권장 디버깅 순서:
- tool 없이 schema만 붙여서 400 제거
- schema 없이 tool만 붙여서 tool 루프 안정화
- 마지막에 둘을 결합
이 순서를 지키면 원인 범위를 급격히 줄일 수 있습니다. (RAG에서도 비슷하게 “단계 분리”가 중요합니다. 문서가 안 잡히는 문제를 다룬 LangChain RAG에서 No relevant docs 7가지 원인처럼, 복합 시스템은 레이어별로 분리 진단이 정답입니다.)
6) 재현 가능한 최소 요청(Minimal Repro) 만들기
400은 “재현”이 되면 끝납니다. 아래처럼 최소 페이로드로 줄이세요.
6-1. schema 최소 예시
{
"model": "gpt-4.1-mini",
"input": "아래 형식으로만 응답해: {\"ok\": true}",
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "ok_only",
"schema": {
"type": "object",
"properties": {
"ok": { "type": "boolean" }
},
"required": ["ok"],
"additionalProperties": false
}
}
}
}
이게 400이면 schema/요청 구조 자체 문제입니다. 이게 성공하면, 점진적으로 필드를 늘리면서 어디서 깨지는지 찾습니다.
6-2. tool 최소 예시
{
"model": "gpt-4.1-mini",
"input": "현재 시각을 알려면 get_time 도구를 호출해",
"tools": [
{
"type": "function",
"name": "get_time",
"description": "현재 시각을 반환",
"parameters": {
"type": "object",
"properties": {},
"additionalProperties": false
}
}
]
}
여기서도 400이면 tool 정의 포맷이 잘못된 것입니다.
7) 운영에서 400을 줄이는 방어 코드/체크리스트
7-1. 요청 전 JSON Schema 로컬 검증
- AJV(Node), jsonschema(Python) 등으로 schema 자체를 먼저 검증
- 특히
required,type,additionalProperties조합을 자동 점검
Node(AJV) 예시:
import Ajv from "ajv";
const ajv = new Ajv({ allErrors: true, strict: false });
export function validateSchema(schema) {
// schema 자체를 검증하는 메타스키마 검증은 설정이 필요할 수 있음.
// 여기서는 최소한 'compile'로 구조적 오류를 조기에 발견한다.
try {
ajv.compile(schema);
return { ok: true };
} catch (e) {
return { ok: false, error: String(e) };
}
}
7-2. tool arguments 파싱 실패 대비
모델이 tool arguments를 JSON 문자열로 주더라도, 가끔 깨진 JSON을 줄 수 있습니다.
try/catch로 파싱- 실패 시 “arguments를 다시 JSON으로만 보내라”는 재시도 프롬프트
이 패턴은 invalid_json류 문제와도 맞닿아 있습니다. 자세한 대응은 Python OpenAI SDK 400 invalid_json 원인과 해결에 정리해 두었습니다.
7-3. 관측성: 400을 애플리케이션 에러로 묻지 말 것
400은 “클라이언트 요청이 잘못된 것”이므로, 서버 내부 에러(500)처럼 뭉개면 안 됩니다.
- 400은 별도 카운터/알람
- 에러 메시지/param을 태깅(개인정보 제외)
- 최근 배포와 상관관계 확인
리소스 문제를 추적할 때 dmesg, cgroup로 레이어를 나누는 것처럼, 요청/스키마/툴을 레이어로 나누어 보는 습관이 중요합니다. (관측성 관점은 리눅스 OOM Kill 원인 추적 - dmesg·cgroup·journalctl에서 다룬 접근과 동일합니다.)
8) 결론: 400은 “모델 문제”가 아니라 “계약(contract) 문제”다
Responses API에서 schema와 tool 호출은 결국 계약입니다.
- schema는 “모델 출력 계약”
- tool parameters는 “모델→서버 호출 계약”
- tool result 메시지는 “서버→모델 반환 계약”
400이 나면 감으로 프롬프트를 바꾸기 전에,
- 최소 재현 요청으로 축소
- schema를 단순화(required 최소화, additionalProperties 완화)
- tool 정의/이름/arguments/결과 매칭을 점검
- 로깅으로 에러 param을 고정
이 4단계를 지키면, 대부분의 400은 수십 분 안에 끝납니다.