- Published on
LangChain OpenAI Structured Outputs 400 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 LangChain으로 LLM 호출을 붙이다 보면, 일반 텍스트 응답은 잘 오는데 Structured Outputs(JSON 스키마 기반 강제 출력)를 켠 순간 400 Bad Request가 터지는 경우가 많습니다. 문제는 400이 “요청이 잘못됐다”는 말만 할 뿐, 실제 원인은 스키마 정의/모델 호환/요청 파라미터 조합/메시지 구성 등 여러 갈래로 갈린다는 점입니다.
이 글은 LangChain + OpenAI 계열 클라이언트에서 Structured Outputs를 적용할 때 흔히 만나는 400 케이스를 유형별로 쪼개고, 바로 붙여 넣어 재현 및 수정 가능한 코드 중심으로 정리합니다.
또한 운영 환경에서 이 문제가 502/504 같은 게이트웨이 오류로 “가려져” 보이기도 하므로, LLM API 앞단을 프록시/Ingress로 운영한다면 타임아웃/스트리밍 설정도 함께 점검하는 편이 좋습니다. 관련해서는 Kubernetes LLM 서비스 502 504 간헐 장애와 스트리밍 끊김 튜닝 글도 같이 참고하면 진단이 빨라집니다.
1) Structured Outputs에서 400이 나는 대표 패턴
아래는 실무에서 가장 자주 맞는 원인들입니다.
(1) 스키마가 JSON Schema 규격을 위반
type누락properties는 있는데type: "object"가 없음required가properties에 없는 키를 포함additionalProperties처리 애매(모델/SDK 조합에 따라 엄격)
(2) “모델이 Structured Outputs를 지원하지 않거나” 지원 방식이 다름
LangChain에서 같은 with_structured_output(...) 호출이라도 내부적으로는
response_format기반(Structured Outputs)tools/function calling 기반 중 하나로 매핑됩니다. 모델이 해당 방식을 지원하지 않으면 400이 납니다.
(3) 파라미터 조합이 충돌
예를 들어 다음 조합은 SDK/모델에 따라 400을 유발할 수 있습니다.
response_format을 쓰면서 동시에tools를 강제temperature/top_p등과는 무관해 보이지만, 일부 래퍼에서 “JSON 강제” 모드와 함께 특정 옵션을 제한하기도 함
(4) 메시지에 “JSON만 출력”을 강제하는 프롬프트가 오히려 깨짐
Structured Outputs는 스키마로 강제하므로, 시스템 프롬프트에 과도하게
- “코드블록으로 감싸라”
- “마크다운으로 출력하라” 같은 지시가 있으면 모델이 스키마와 충돌하는 출력을 만들고, 파서가 실패하거나(파싱 에러), 일부 모드에서는 요청 자체가 거절(400)되기도 합니다.
(5) LangChain 버전/프로바이더 패키지 불일치
langchain, langchain-openai 버전 조합이 어긋나면 내부에서 response_format 페이로드가 잘못 구성되어 400이 나기도 합니다.
2) 먼저 확인할 체크리스트 (재현 전에)
아래 순서로 보면 대부분 빨리 잡힙니다.
에러 메시지 원문 확보
- 프록시(Cloud Run, Ingress, API Gateway)가 있으면 400이 502/504로 변장할 수 있습니다.
- 가능하면 애플리케이션에서 OpenAI 응답 바디를 그대로 로깅하세요.
요청 페이로드 덤프
- LangChain은 내부에서 OpenAI SDK를 호출하므로, 디버그 로깅을 켜서 실제 전송된 JSON을 확인합니다.
모델이 Structured Outputs를 지원하는지 확인
- 지원하지 않으면 function calling 방식으로 우회하거나, 지원 모델로 교체해야 합니다.
스키마를 최소화해서 통과시키기
- 처음부터 복잡한 중첩/
oneOf/anyOf로 가면 원인 분리가 어렵습니다.
- 처음부터 복잡한 중첩/
3) 문제를 가장 많이 만드는 “스키마” 예시와 수정
3.1 잘못된 스키마 예시
아래는 흔히 보는 실수입니다. type이 없고, required가 불일치합니다.
// bad-schema.ts
export const BadSchema = {
properties: {
title: { type: "string" },
tags: { type: "array", items: { type: "string" } }
},
required: ["title", "tagz"],
};
이런 스키마는 OpenAI Structured Outputs에서 400을 만들 가능성이 높습니다.
3.2 최소 수정한 정상 스키마
// good-schema.ts
export const GoodSchema = {
type: "object",
additionalProperties: false,
properties: {
title: { type: "string" },
tags: { type: "array", items: { type: "string" } }
},
required: ["title", "tags"],
};
포인트는 다음입니다.
- 최상위에
type: "object" additionalProperties: false로 출력 형태를 고정(디버깅이 쉬움)required는 반드시properties키와 일치
4) LangChain에서 안전하게 Structured Outputs 붙이는 패턴
아래 예시는 LangChain의 ChatOpenAI를 사용합니다. (Node/TypeScript 기준)
4.1 JSON 스키마 기반 Structured Outputs
import { ChatOpenAI } from "@langchain/openai";
const llm = new ChatOpenAI({
model: "gpt-4.1-mini",
temperature: 0,
});
const schema = {
type: "object",
additionalProperties: false,
properties: {
summary: { type: "string" },
risk: { type: "string", enum: ["low", "medium", "high"] },
},
required: ["summary", "risk"],
};
const structured = llm.withStructuredOutput(schema);
const result = await structured.invoke([
{
role: "system",
content: "You are a security reviewer. Return strictly the requested JSON.",
},
{
role: "user",
content: "Review this change: we disabled TLS verification in production.",
},
]);
console.log(result);
400이 나면 여기부터 의심
withStructuredOutput가 내부적으로 어떤 모드로 매핑되는지(Structured Outputs vs tools)- 모델이 해당 모드를 지원하는지
- 스키마가 엄격한 JSON Schema인지
4.2 Zod를 쓰는 경우(스키마 변환 과정 점검)
Zod는 편하지만, 변환된 JSON Schema가 기대와 다를 수 있습니다. 특히 optional, nullable, union 조합이 복잡하면 400의 원인이 됩니다.
import { z } from "zod";
import { ChatOpenAI } from "@langchain/openai";
const Output = z.object({
id: z.string(),
score: z.number().min(0).max(1),
});
const llm = new ChatOpenAI({ model: "gpt-4.1-mini", temperature: 0 });
// LangChain 버전에 따라 메서드/옵션이 다를 수 있습니다.
const structured = llm.withStructuredOutput(Output);
const r = await structured.invoke("Give an id and score for: langchain");
console.log(r);
Zod 사용 시 팁:
- 처음에는
z.object({ ... })단순 구조로 통과시킨 후 점진적으로 확장 z.union,z.discriminatedUnion은 마지막에 붙이기
5) 400을 빠르게 좁히는 “최소 스키마” 전략
운영에서 바로 해결해야 할 때는 스키마를 아래처럼 극단적으로 줄여 통과 여부를 먼저 봅니다.
const MinimalSchema = {
type: "object",
additionalProperties: false,
properties: {
ok: { type: "boolean" },
},
required: ["ok"],
};
- 이조차 400이면: 모델/요청 파라미터/SDK 문제일 가능성이 큽니다.
- 이건 통과인데 원래 스키마만 400이면: 스키마 특정 필드(
enum,oneOf, 중첩)의 문제입니다.
6) “응답 파싱 에러”와 “요청 400”을 구분하기
현장에서 자주 헷갈리는 부분입니다.
- 요청 단계 400: OpenAI API가 요청을 거절. 보통
response_format/스키마/모델 호환성 문제. - 응답 파싱 실패: 요청은 200인데 LangChain 파서가 JSON 파싱 실패. 프롬프트가 마크다운/설명을 섞게 만들었거나, 모델이 스키마를 못 지킨 경우.
둘을 구분하려면:
- HTTP 상태 코드
- OpenAI 응답의
error.message원문 - LangChain 예외 스택에서 “validation failed” vs “bad request”를 확인
7) 프록시/Ingress 뒤에서 400이 “다른 장애처럼 보이는” 케이스
LLM 호출을 Kubernetes Ingress, Cloud Run, Nginx 프록시 뒤에 붙이면 아래처럼 보일 수 있습니다.
- 실제로는 OpenAI가 400을 반환
- 애플리케이션이 예외를 처리하지 못하고 커넥션을 끊음
- 클라이언트는 502/504 또는 스트리밍 끊김으로 인지
이때는 네트워크/타임아웃도 같이 튜닝해야 합니다. 특히 스트리밍 응답을 섞었다면 더 자주 발생합니다. 자세한 튜닝은 Kubernetes LLM 서비스 502 504 간헐 장애와 스트리밍 끊김 튜닝에서 NGINX/Gunicorn/Uvicorn 설정까지 포함해 다룹니다.
8) 실전 디버깅: 요청 바디와 스키마를 로그로 남기는 방법
Node에서 가장 중요한 건 “실제 전송된 payload”입니다. LangChain/OpenAI SDK는 내부에서 요청을 구성하므로, 애플리케이션 레벨에서 다음을 남기세요.
- 선택한
model - structured output에 전달한 스키마(또는 Zod 변환 결과)
- messages(시스템/유저)
- 추적용
requestId(있다면)
예시(개념 코드):
function safeJsonStringify(v: unknown) {
try {
return JSON.stringify(v);
} catch {
return "[unserializable]";
}
}
try {
const out = await structured.invoke(messages);
return out;
} catch (e: any) {
console.error("LLM structured output failed", {
model: "gpt-4.1-mini",
schema: safeJsonStringify(schema),
messages: safeJsonStringify(messages),
err: {
name: e?.name,
message: e?.message,
stack: e?.stack,
},
});
throw e;
}
주의:
- 개인정보/비밀키/원문 데이터가 섞이지 않게 마스킹 필터를 두세요.
9) 결론: 400을 없애는 가장 확실한 순서
- 모델 호환성 확인: Structured Outputs 지원 모델로 고정
- 최소 스키마로 통과 테스트:
type: "object",additionalProperties: false, 단일 필드 - 스키마를 점진적으로 확장:
enum추가, 배열 추가, 중첩 추가 순으로 - 프롬프트에서 마크다운/코드블록 지시 제거: 스키마가 강제하므로 “JSON만” 정도로 최소화
- 버전 정합성 점검:
langchain과@langchain/openai를 함께 업데이트/고정 - 프록시 환경이면 502/504로 위장되는지 확인: 타임아웃/스트리밍/keep-alive 튜닝
Structured Outputs는 한 번 안정화하면 “파싱 지옥”을 크게 줄여주지만, 초기에는 스키마/모델/SDK 삼박자가 맞아야 합니다. 위의 최소 스키마 전략으로 원인을 먼저 분리하고, 그 다음 스키마를 확장하는 방식으로 접근하면 400을 가장 빠르게 제거할 수 있습니다.
추가로 TypeScript에서 스키마/타입 추론이 꼬이는 문제까지 함께 겪고 있다면, satisfies를 이용한 타입 안정화도 도움이 됩니다. 관련 내용은 TS 5.x satisfies로 타입추론 깨짐 깔끔히 해결에서 다룹니다.