AIREITER

OpenRouter Structured Output: 스키마가 무시되는 이유

마지막 업데이트: 2026-08-23 01:28:13

한 OpenRouter 모델에서는 깔끔한 타입 지정 JSON을 돌려주던 스키마가, 다른 모델에서는 같은 요청 본문인데도 전혀 다른 키를 내놓거나 빈 문자열을 반환하고, 심지어 400 오류로 끝날 수 있다. Reddit 사용자 u/MicBeckie는 OpenRouter structured output으로 Qwen 모델을 시험한 뒤 “10번 중 9번은 항상 오류가 났다”고 했지만, 동일한 구성의 OpenAI 모델은 스키마를 지켰다고 전했다.

이 차이는 신고해서 고칠 수 있는 단일 버그가 아니다. OpenRouter의 structured output 지원 여부는 모델 단위가 아니라 엔드포인트 단위로 결정된다. 더욱이 여기서 말하는 ‘지원’에는 네이티브 엄격 스키마 강제부터 스키마를 권고사항처럼 다루는 프로바이더까지, 세 가지 강제 수준이 포함된다. 이 글에서는 기능이 어떤 방식으로 라우팅되는지, 실무에서 마주치는 여섯 가지 실패 양상, 그리고 스키마 기반 출력을 실제 서비스에 올릴 수 있게 만드는 방어책을 살펴본다. 강제 적용 메커니즘은 공식 structured outputs 문서를 따르며, 실패 사례는 본문에 연결한 개발자 토론에서 가져왔다.

OpenRouter structured outputs 문서 페이지

OpenRouter에서 structured output 지원이 뜻하는 것

OpenRouter는 response_format 파라미터로 type: "json_schema", 스키마 name, strict 플래그, 그리고 JSON Schema 자체를 받는다. 최소 요청은 다음과 같다.

{
  "model": "openai/gpt-4o",
  "messages": [{ "role": "user", "content": "Extract the shipping info" }],
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "shipping_info",
      "strict": true,
      "schema": {
        "type": "object",
        "properties": {
          "tracking_number": { "type": "string", "description": "Carrier tracking ID" },
          "carrier": { "type": "string" },
          "eta_days": { "type": "number", "description": "Days until delivery" }
        },
        "required": ["tracking_number", "carrier", "eta_days"],
        "additionalProperties": false
      }
    }
  }
}

실제로 동작 여부를 가르는 공식 문서의 핵심은 두 가지다.

  • 지원 여부는 모델이 아닌 엔드포인트 기준이다. 하나의 모델을 다섯 프로바이더가 서빙한다면, 그중 둘에서만 structured output이 작동할 수도 있다. 모델 페이지의 Providers 섹션에는 프로바이더별 structured_outputs 파라미터가 표시되며, 문서도 “엔드포인트 지원은 시간이 지나며 바뀔 수 있다”고 경고한다.
  • 처음부터 광범위하게 지원된 기능은 아니다. OpenRouter는 2024년 12월 12일 structured outputs를 발표했으며, 당시 지원 대상은 OpenAI 4o와 Fireworks 모델뿐이었다. 이후 프로바이더별로 지원이 추가됐으므로, 오늘 작성한 모델 지원 목록도 빠르게 낡는다.

공식 문서는 모든 프로퍼티에 설명을 붙이고 additionalProperties: false를 설정하라고 권장한다. 강제 수준이 낮은 티어에서는 스키마가 모델에 주는 프롬프트 역할까지 겸하기 때문이다.

strict: true 하나로는 부족한 이유: 3단계 강제 적용

strict: true의 의미는 요청이 어느 엔드포인트에 도착하느냐에 따라 달라진다. 공식 가이드는 프로바이더 동작을 세 티어로 나눈다.

티어프로바이더가 스키마를 처리하는 방식출력을 신뢰할 수 있나?
네이티브 엄격 모드디코딩 단계에서 스키마를 정확히 강제한다예: 출력은 구조적으로 스키마와 일치한다
변환형 포맷스키마를 프로바이더 고유의 structured output 포맷으로 변환한다대체로 가능: 해당 포맷이 지원하는 스키마 기능으로 제한된다
강한 힌트스키마를 모델용 가이드로 주입한다아니오: 잘 풀리면 스키마 형태지만, 안 풀리면 키를 지어낸다

OpenRouter는 요청 시점에 특정 엔드포인트가 어느 티어인지 표시하지 않는다. 공식 문서는 각 프로바이더의 자체 문서를 확인하라고 안내한다. 네이티브 엄격 모드는 허용하는 JSON Schema 기능도 제한하므로, 특수한 키워드는 가장 엄격한 엔드포인트에서는 실패하면서 다른 곳에서는 힌트로 통과할 수 있다.

Claude에는 프로바이더 라우팅 페이지에 명시된 예외가 있다. response_format.type: "json_schema"를 쓰면 OpenRouter가 Anthropic의 structured-outputs-2025-11-13 베타 헤더를 자동 적용해 엄격한 스키마 검증 도구 인수를 활성화한다. 반면 tools로 보내는 strict: true 도구 정의에는 호출자가 이 베타 헤더를 직접 넣어야 한다. 그렇지 않으면 OpenRouter는 strict를 제거하고 해당 옵션 없이 라우팅한다. 문제는 조용히 발생한다. 도구 호출은 더 이상 스키마 검증을 거치지 않지만 오류도 반환되지 않는다.

같은 스키마가 깨지는 6가지 패턴

공식 가이드에 문서화된 즉시 실패 유형은 두 가지다. 나머지 네 가지는 커뮤니티 스레드에서 확인되며, 실제로는 이들이 개발 시간을 잡아먹는다.

즉시 실패 1: 엔드포인트가 structured output을 지원하지 않는다. 요청은 해당 기능을 지원하지 않는다는 오류와 함께 끝난다. 불편하지만 원인은 명확하다. 즉시 실패 2: JSON Schema가 유효하지 않다. 스키마 자체가 파싱되지 않거나 엔드포인트의 스키마 규칙을 어기면 API가 요청을 거부한다.

조용한 실패 1: 스키마가 무시된다. 응답은 유효한 JSON이지만 전혀 다른 스키마를 따른다. r/LocalLLaMA의 스키마 미준수 스레드에서 u/DaniyarQQQ는 이렇게 말했다.

내 스키마와 전혀 닮지 않은 JSON이 반환된다.

같은 스레드에서 u/MicBeckie는 진단의 어려움을 다음처럼 설명했다.

JSON이 요구사항과 정확히 일치하는 성공 사례만 보거나, JSON을 확인할 수도 없는 오류만 받는다.

래퍼 실패 2: 보내지도 않은 tool_choice 관련 400 오류. 연결된 LangChainJS 사례에서 withStructuredOutput()은 생성한 함수를 대상으로 tool_choice를 강제하는 방식으로 ‘structured output’을 구현했다. 도구 호출은 표방하지만 강제 tool choice를 지원하지 않는 모델에서는 요청이 invalid_request_error로 실패한다. DeepSeek v4 사례에서는 오류가 모델명을 직접 언급했다. deepseek-reasoner does not support this tool_choice. u/shansoft는 LangChainJS에서 정확히 이 문제를 겪었다(스레드). u/eyueldk의 “도구 호출을 지원한다고 하니 structured output도 지원할 것”이라는 가정은 성립하지 않았다. 도구 호출 지원과 엄격한 스키마 지원은 별개의 기능이다.

조용한 실패 3: 오류도 없고 콘텐츠도 없다. gpt-oss-120b 보고에 따르면, 엄격 스키마 요청은 직접 프로바이더 경로에서는 400을 냈지만 OpenRouter를 거치자 200 응답과 빈 message.content를 반환했다. r/openrouter의 또 다른 스레드에서는 ‘지원됨’으로 표시된 모델이 [1]이나 [1.1]만 반환했다. SDK가 빈 문자열을 문제없이 파싱하면 실패 지점은 세 레이어 뒤로 밀려난다.

조용한 실패 4: 엔드포인트가 멈춘다. DeepSeek v4에 structured output을 표시했던 엔드포인트에 대해 u/Beneficial-Loss-1031은 다음과 같이 썼다(스레드).

deepinfra/fp4와 akashml/fp8에는 structured output 옵션이 있지만, 각각 3분씩 API 응답을 기다렸는데도 아무것도 받지 못했다.

#실패 형태관측되는 증상주된 원인
1미지원 엔드포인트오류: structured outputs 미지원기능이 없는 프로바이더로 라우팅됨
2유효하지 않은 스키마요청 단계에서 API 오류스키마가 엔드포인트 규칙을 위반함
3스키마 무시유효한 JSON이지만 키가 다름힌트 티어 강제 적용
4tool_choice 400invalid_request_errorSDK가 강제 도구 호출로 스키마를 에뮬레이션함
5빈 콘텐츠200, 빈 message.content프로바이더가 엄격 모드를 잘못 처리함
6응답 멈춤수 분 동안 응답 없음보고상 원인은 확인되지 않음 — fp4/fp8 엔드포인트에서 3분 대기

모델 탓하기 전에 요청부터 단단하게 만들기

가장 효과가 큰 설정은 provider 객체의 require_parameters: true다. 기본값은 false이며, 알 수 없는 파라미터는 이를 조용히 무시하는 프로바이더에도 그대로 전달된다. false 상태에서도 response_format과 structured output은 엔드포인트 선택 시 소프트 선호 조건으로 작동한다. 즉, 우선 고려될 뿐 보장되지는 않는다. 프로바이더 라우팅 문서에 따르면 이 값을 true로 설정하면, 보낸 모든 파라미터를 지원하는 엔드포인트로만 라우팅이 제한된다.

{
  "model": "deepseek/deepseek-chat",
  "messages": [{ "role": "user", "content": "Extract the shipping info" }],
  "response_format": { "type": "json_schema", "json_schema": { "name": "shipping_info", "strict": true, "schema": { "...": "..." } } },
  "provider": {
    "require_parameters": true,
    "order": ["fireworks"],
    "allow_fallbacks": false
  }
}

제약을 늘릴수록 선택 가능한 프로바이더 풀은 작아진다. allow_fallbacks: false는 가용성을 일부 포기하는 대신 결정성을 얻는 설정이다. 같은 라우팅 문서에 따르면 기본 전략은 지난 30초간의 가동률과 가격의 역제곱을 기준으로 로드밸런싱한다. 이는 스키마 처리 능력보다 저렴하고 건강한 엔드포인트를 최적화하는 방식이다. order를 하나의 프로바이더로 고정하고 폴백을 끄면 라우팅을 재현할 수 있다. 장애 상황에서 요청이 다른 프로바이더로 이동하지 않기 때문이다. 다만 해당 단일 엔드포인트의 강제 적용 티어는 여전히 직접 검증해야 한다.

라우팅만으로 잡지 못하는 문제는 다음 두 가지 점검 습관으로 발견할 수 있다.

  • 실제 응답 프로바이더를 확인한다. OpenRouter의 generation metadata에는 세대별 프로바이더 라우팅 정보와 모델, 지연 시간, 토큰 수가 담긴다. 출력 품질이 달라졌다면 이 정보로 모델 동작이 바뀐 것인지, 라우터가 다른 프로바이더를 선택한 것인지 구분할 수 있다.
  • 클라이언트 측 검증은 반드시 한다. 어떤 티어도 애플리케이션 쪽 Pydantic 또는 Zod 파싱을 대체하지 못한다. r/LLMDevs 테스트 스레드에서 반복되는 교훈은 ‘유효한 JSON’, ‘스키마 유효’, ‘의미적으로 올바름’이 서로 다른 기준이라는 점이다. API의 책임 범위는 앞의 두 가지조차 일부에 그친다.

스트리밍 지원은 되지만, 파싱은 직접 해야 한다

Structured output은 stream: true와 함께 사용할 수 있다. 문서가 설명하는 계약은 모델이 유효한 부분 JSON을 스트리밍하고, 스트림이 끝나면 조립된 응답이 스키마와 일치한다는 것이다. 다만 이 일치성 역시 엔드포인트의 강제 적용 티어를 따른다. 힌트 티어 엔드포인트는 최종적으로도 규격에 맞지 않는 출력을 만들 수 있으므로 완성된 객체는 직접 검증해야 한다. 문서는 증분 파서도 제공하지 않는다. 지연 시간에 민감한 UI에서는 바로 이 부분이 실제 엔지니어링 과제다. r/LLMDevs의 스트리밍 모범 사례 스레드에서 u/am174744는 이렇게 말했다.

결국 JSON을 직접 완성하는 함수를 작성했다. — u/am174744

“…실제로는 상태 머신이다.” — u/ImNotLegitLol, 수리 후 파싱한다는 사고방식을 바로잡으며

현실적인 선택지는 세 가지다. 부분 JSON을 허용하는 스트리밍 파서를 쓰거나, 완성된 필드만 렌더링하거나, 증분 렌더링을 포기하고 최종 객체가 조립될 때까지 스피너를 보여주는 방식이다.

Response Healing이 고치는 것과 못 고치는 것

OpenRouter의 Response Healing 플러그인은 스트리밍이 아닌 json_schema 요청을 대상으로 하며, 잘린 JSON이나 불필요한 마크다운 펜스처럼 형식이 조금 깨진 응답을 복구한다. 무엇을 고치는지보다 다음 두 한계를 이해하는 편이 중요하다.

  1. 스트리밍에는 적용되지 않는다. 문서상 이 플러그인의 대상은 비스트리밍 요청이다.
  2. 스키마 위반은 해결하지 못한다. Healing은 JSON을 파싱 가능하게 만들 뿐, 스키마를 무시한 응답을 스키마에 맞게 바꾸지는 않는다. 앞서 본 실패 유형 3에는 효과가 없다.

스키마를 제대로 지키는 모델 고르는 법

모델 목록은 금방 낡지만, 선별 기준은 그렇지 않다. 다음 세 가지 필터면 앞서 본 실패 대부분을 걸러낼 수 있다.

  1. 네이티브 엄격 강제 적용. 스키마를 디코딩 시점에 강제하는 서빙 프로바이더를, 변환이나 힌트 방식의 프로바이더보다 우선한다. 모델 페이지의 Providers 표에서 어떤 엔드포인트가 structured_outputs를 표시하는지 확인할 수 있지만, 실제 강제 품질은 프로바이더 티어가 결정한다.
  2. 감사 가능한 단일 프로바이더. 여러 요청에 걸쳐 알려진 정상 엔드포인트와 프로바이더 귀속 정보를 대조한다. 라우터가 서로 다른 티어의 프로바이더에 요청을 분산하면 실패율은 라우팅 복권이 된다. 프로바이더를 고정하거나 단일 프로바이더 모델을 선택하라.
  3. 남의 후기가 아닌 직접 실행한 스모크 테스트. 커뮤니티 신호는 양방향으로 빠르게 낡는다. 위 Qwen 오류 보고와 DeepSeek v4의 지원 부재는 프로바이더가 엔드포인트를 업데이트하면 모두 달라질 수 있다. 중요한 신뢰성 수치는 내 스키마로 직접 측정한 값뿐이다.

OpenRouter structured outputs FAQ

json_object와 json_schema는 무엇이 다른가?

json_object는 문법적으로 유효한 JSON만 요청한다. 반면 json_schema는 응답이 따라야 할 스키마를 제공한다. json_object는 JSON 문법을 보장할 뿐 필드 단위 스키마 준수까지 보장하지 않으므로, 이후 코드에서 이름 있는 필드가 필요하다면 직접 검증해야 한다.

어떤 OpenRouter 모델이 structured outputs를 지원하나?

신뢰할 수 있는 고정 목록은 없다. 지원은 엔드포인트별이며 시간이 지나며 바뀌고, 2024년 12월에는 OpenAI 4o와 Fireworks 모델만 지원했다. 각 엔드포인트의 structured_outputs 플래그는 모델 페이지 Providers 섹션에서 확인하라.

모델이 내 스키마를 무시하는 이유는 무엇인가?

흔한 원인은 세 가지다. 요청이 힌트 티어 또는 미지원 엔드포인트로 라우팅된 경우(require_parameters: true와 프로바이더 고정으로 해결), 스키마가 엔드포인트의 엄격 모드가 거부하는 키워드에 의존하는 경우, 또는 SDK 래퍼가 강제 tool choice를 지원하지 않는 모델에서 도구 호출로 structured output을 에뮬레이션하는 경우다.

Pydantic이나 LangChain을 OpenRouter structured outputs와 함께 쓸 수 있나?

가능하다. 공식 문서는 요청 포맷이 OpenRouter의 chat-completions 스타일 API와 호환된다고 설명하므로, Pydantic이 생성한 스키마와 OpenAI SDK를 직접 사용할 수 있다. LangChain의 withStructuredOutput()도 동작하지만, response_format을 보내는지 확인해야 한다. DeepSeek v4에서 400 오류를 낸 방식처럼 tool_choice로 에뮬레이션하는 경우가 있기 때문이다.

structured output은 스트리밍에서도 작동하나?

그렇다. 스트림은 유효한 부분 JSON을 내보낸다. 다만 최종 스키마 준수 여부는 엔드포인트의 강제 적용 티어에 달려 있으므로, 조립된 객체는 직접 검증해야 한다. 조각 단위 증분 파싱은 애플리케이션의 역할이며, Response Healing은 스트림에 적용되지 않는다.

OpenRouter가 내 스키마에 맞춰 응답을 검증해 주나?

모든 엔드포인트에서 보장되지는 않는다. 강제 적용은 프로바이더 티어에 따라 달라지며, Response Healing은 잘못된 JSON 형식만 복구할 뿐 스키마 위반은 처리하지 않는다. 클라이언트 측 검증은 여전히 필수다.

10회 스모크 테스트

Structured output을 사용하는 모델을 프로덕션에 넣기 전에는 다음 절차를 실행하자.

  1. 대표 스키마 하나를 고정한다. 중간 수준 복잡도에 additionalProperties: false를 넣고, 모든 프로퍼티에 설명을 작성한다.
  2. strict: true와 require_parameters: true를 적용해 동일 요청을 10회 보낸다. 이 단계에서는 폴백 동작을 의도적으로 검증하므로 폴백을 켜 둔다.
  3. 각 응답을 세 기준으로 평가한다. JSON 파싱 가능 여부, 스키마 유효 여부, 의미적으로 타당한지 여부다.
  4. generation metadata를 통해 각 응답을 처리한 프로바이더를 기록한다. 서로 다른 네 프로바이더가 처리한 10/10 성공률은 보장이 아니라 라우팅 복권일 수 있다.
  5. 결정한다. 그대로 배포하거나, 통과한 엔드포인트에 provider.order를 고정한 뒤 고정 상태에서 10회를 다시 실행하거나, 모델을 바꾸고 클라이언트 측 검증·재시도 레이어를 추가한다.

통과 기준은 서비스가 정할 일이다. 다만 고정 스키마에서 9/10 미만이라면 재시도와 검증 코드는 선택 사항이 아니다. 그것이 곧 제품의 일부다.

관련 글: OpenRouter 자동 라우터가 프로바이더를 고르는 방식, OpenRouter prompt caching으로 비용 줄이기, OpenRouter 429 rate limit 해결법.