同じJSONスキーマを同じリクエストボディで送っているのに、あるOpenRouterモデルでは型どおりの出力が返り、別のモデルではキーが変わったり空文字になったり、400エラーになったりすることがあります。Redditユーザーのu/MicBeckieは、OpenRouter経由でQwenモデルの構造化出力を試したところ「10回に9回はいつもエラーになった」と報告しています。一方、同じ構成のOpenAIモデルではスキーマが正常に機能しました。
これは単純なバグとして報告すれば解決する問題ではありません。OpenRouterの構造化出力対応はモデル単位ではなくエンドポイント単位で決まり、「対応済み」という言葉の中にも、ネイティブな厳格検証から、スキーマをあくまで提案として扱うプロバイダーまで3段階の違いがあります。本稿では、機能がどのようにルーティングされるのか、実運用で起きる6つの失敗パターン、そしてスキーマ出力を本番投入できる状態まで固める方法を解説します。強制の仕組みについては公式のstructured outputsドキュメントを参照し、具体的な失敗例については本文中に開発者コミュニティの報告を引用しています。
OpenRouterにおける「構造化出力対応」の意味
OpenRouterでは、type: "json_schema"を指定したresponse_format、スキーマの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
}
}
}
}
公式ドキュメントから、まず押さえておきたいポイントは2つです。
- 対応状況はモデルではなくエンドポイント単位。同じモデルが5つのプロバイダーから提供されていても、構造化出力が使えるのはそのうち2つだけ、ということがあります。モデルページのProvidersセクションでは、プロバイダーごとに
structured_outputsパラメーターの有無を確認できます。また、公式ドキュメントも「エンドポイントの対応状況は時間とともに変わる可能性がある」と注意しています。 - 対応モデルは段階的に増えてきた。OpenRouterが構造化出力を発表したのは2024年12月12日で、当初の対応はOpenAI 4oとFireworksのモデルに限られていました。その後、プロバイダーごとに対応が追加されています。現在のモデル一覧をメモしても、すぐに古くなる可能性があります。
公式ドキュメントでは、すべてのプロパティに説明を付け、additionalProperties: falseを設定することも推奨しています。強制レベルが低いエンドポイントでは、スキーマがそのままモデルへのプロンプト材料として使われるためです。
同じstrictフラグでも異なる3つの強制レベル
strict: trueが意味するものは、リクエストがどのエンドポイントに届くかで変わります。公式ガイドでは、プロバイダーの挙動を次の3段階に分類しています。
| レベル | プロバイダーによるスキーマの扱い | 出力を信頼できるか |
|---|---|---|
| ネイティブ厳格モード | デコード時にスキーマを正確に強制する | はい。生成の仕組み上、スキーマに一致する |
| 変換フォーマット | スキーマをプロバイダー固有の構造化出力形式に変換する | おおむね可能。ただし、その形式が対応する機能に限定される |
| 強いヒント | モデルへの指示としてスキーマを注入する | いいえ。うまくいけばスキーマ風、失敗すれば存在しないキーまで出力される |
OpenRouterは、リクエスト時に各エンドポイントがどのレベルで動作するかを表示していません。公式ドキュメントも、詳細は各プロバイダーのドキュメントを確認するよう案内しています。また、ネイティブ厳格モードでは利用できるJSON Schemaの機能が制限されるため、特殊なキーワードが厳格モードではエラーになり、ヒントとして処理されるエンドポイントでは通ることもあります。
Claudeには、プロバイダールーティングのページに記載された特例があります。response_format.type: "json_schema"を使う場合、OpenRouterはAnthropicのstructured-outputs-2025-11-13ベータヘッダーを自動的に付与し、厳格なスキーマ検証付きのツール引数を有効にします。ただし、toolsとして送信するstrict: trueのツール定義では、呼び出し側がこのベータヘッダーを明示的に付けなければなりません。付けない場合、OpenRouterはstrictを削除してリクエストをルーティングします。しかもエラーは発生しません。ツール呼び出しがスキーマ検証なしで実行されるため、気づきにくい失敗パターンです。
同じスキーマが通らない6つのパターン
公式ガイドに記載された即時エラーが2種類、開発者コミュニティのスレッドで報告されている実運用上の問題が4種類あります。特に後者は、原因の切り分けだけでかなりの時間を持っていかれます。
即時エラー1:エンドポイントが構造化出力に対応していない。対応していない機能だと明示されたエラーが返ります。困りはしますが、原因は明確です。即時エラー2:JSON Schemaが不正。スキーマ自体を解析できない、またはエンドポイントが定めるスキーマ規則に違反している場合、APIがリクエストを拒否します。
サイレント失敗1:スキーマが無視される。JSONとしては正しくても、別のスキーマに基づいたようなレスポンスが返ります。r/LocalLLaMAのスキーマ不遵守に関するスレッドで、u/DaniyarQQQは次のように報告しています。
自分のスキーマとはまったく違うJSONが返ってきます。
同じスレッドで、u/MicBeckieは成功と失敗の判定の難しさについてこう書いています。
JSONが要件どおりのときは成功しますが、そうでないときはJSONの中身を確認できないままエラーになります。
ラッパー起因の失敗2:送っていないtool_choiceについて400が返る。リンク先のLangChainJSの事例では、withStructuredOutput()が「構造化出力」を、生成した関数のtool_choiceを強制する形で実装していました。ツール呼び出しには対応していても、強制的なtool choiceに対応していないモデルでは、invalid_request_errorでリクエストが失敗します。DeepSeek v4では、エラーにモデル名まで明記され、deepseek-reasoner does not support this tool_choiceと返りました。u/shansoftもLangChainJS経由で同じ問題に遭遇しています(スレッド)。そこでu/eyueldkが抱いていた「ツール呼び出しに対応しているなら、構造化出力にも対応しているはず」という前提は成立しませんでした。ツール呼び出し対応と、厳格なスキーマ対応は別の機能です。
サイレント失敗3:エラーはないのにコンテンツがない。gpt-oss-120bの報告では、厳格なスキーマを指定したリクエストが、プロバイダーへ直接送ると400になる一方、OpenRouter経由では200を返し、message.contentが空になったとされています。別のr/openrouterのスレッドでも、「対応済み」とされるモデルが[1]や[1.1]だけを返しています。空文字を問題なく解析してしまうSDKを使っていると、失敗がさらに3層下流へ流れてしまいます。
サイレント失敗4:エンドポイントがハングする。DeepSeek v4で構造化出力対応をうたっていたエンドポイントについて、u/Beneficial-Loss-1031は次のように報告しています(スレッド)。
deepinfra/fp4とakashml/fp8には構造化出力のオプションがありますが、APIが返るのをそれぞれ3分待っても、何も返ってきませんでした。
| # | 失敗の形 | 見える症状 | 典型的な原因 |
|---|---|---|---|
| 1 | 非対応エンドポイント | 構造化出力に対応していないというエラー | 機能を持たないプロバイダーへルーティングされた |
| 2 | 不正なスキーマ | リクエストに対するAPIエラー | スキーマがエンドポイントの規則に違反している |
| 3 | スキーマが無視される | JSONは正しいがキーが違う | ヒントレベルの強制しか行われていない |
| 4 | tool_choiceの400 | invalid_request_error | SDKが強制ツール呼び出しでスキーマを再現している |
| 5 | コンテンツが空 | 200だがmessage.contentが空 | プロバイダーが厳格モードを正しく処理していない |
| 6 | ハング | 数分間レスポンスがない | 報告時点では未確認。fp4/fp8エンドポイントで3分待っても返らなかった |
モデルを疑う前にリクエストを固める
最も効果が大きい設定は、providerオブジェクト内のrequire_parameters: trueです。デフォルトはfalseで、未知のパラメーターはそのままプロバイダーへ渡され、対応していない場合も黙って無視されます。falseのままでも、response_formatや構造化出力はエンドポイント間でのソフトな優先指定として扱われます。つまり、優先はされても保証はされません。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で1つのプロバイダーに固定し、フォールバックを無効にすれば、ルーティングは再現可能になります。障害時に別プロバイダーへ勝手に切り替わることもありません。ただし、その1つのエンドポイントがどの強制レベルなのかは、別途確認が必要です。
ルーティングだけでは見抜けない問題は、次の2つの習慣で発見できます。
- どのプロバイダーが処理したか確認する。OpenRouterのgeneration metadataでは、モデル、レイテンシ、トークン数とともに、各生成リクエストのプロバイダールーティングを確認できます。出力品質が変動したとき、モデルの挙動が変わったのか、ルーターが別のプロバイダーを選んだのかを切り分けられます。
- 必ずクライアント側でも検証する。どの強制レベルも、PydanticやZodによる手元でのパース処理の代わりにはなりません。r/LLMDevsの検証スレッドで繰り返し語られているとおり、「有効なJSON」「スキーマに適合したJSON」「意味的に正しいJSON」は別々の基準です。そのうちAPIの責任と言えるのは、部分的に見ても最初の2つまでです。
ストリーミングは使える。ただし解析は自前
構造化出力はstream: trueと組み合わせられます。公式ドキュメントでは、モデルが有効な部分JSONをストリーミングし、完了後に組み立てたレスポンス全体がスキーマに一致する、という仕様として説明されています。ただし、その適合性はエンドポイントの強制レベルを引き継ぎます。ヒントレベルのエンドポイントでは、組み立て後もスキーマに適合しない可能性があります。最終オブジェクトは必ず自分で検証してください。また、公式ドキュメントにはインクリメンタルパーサーも用意されていません。レイテンシーに敏感なUIでは、ここが実装上の本当の難所になります。r/LLMDevsのストリーミングのベストプラクティスに関するスレッドでは、次のような意見が出ています。
結局、自分でJSONを完成させる関数を書くことにしました。— u/am174744
「……実際にはステートマシンです」— u/ImNotLegitLol。修復してから解析すればよい、という考え方への指摘
現実的な選択肢は、ストリーミングに耐えるパーサーで部分JSONを解析する、完成したフィールドだけを画面に表示する、あるいは逐次表示をやめて最終オブジェクトが組み上がるまでスピナーを出す、といった方法です。
Response Healingで直せること、直せないこと
OpenRouterのResponse Healingプラグインは、非ストリーミングのjson_schemaリクエストを対象に、JSONの途中切れや余計なMarkdownコードフェンスなど、形式上の不備を修復します。ただし、修復できる内容よりも重要なのが次の2つの限界です。
- ストリーミングは対象外。公式ドキュメントでは、プラグインの対象を非ストリーミングリクエストに限定しています。
- スキーマ違反は対象外。Healingが行うのは、JSONとして解析できる状態への修復です。スキーマを無視したレスポンスを、スキーマに適合する内容へ変えることはありません。上記の失敗パターン3はそのまま残ります。
本当にスキーマを守るモデルの選び方
モデル一覧はすぐ古くなりますが、選定基準は変わりません。上記の失敗パターンの多くは、次の3つでふるいにかけられます。
- ネイティブな厳格検証。変換やヒントで対応するプロバイダーより、デコード時にスキーマを強制するプロバイダーが提供するモデルを優先します。モデルページのProvidersテーブルでは、各エンドポイントが
structured_outputsを掲げているか確認できます。最終的な品質を左右するのは、プロバイダーの強制レベルです。 - 監査できる単一プロバイダー。既知の正常なエンドポイントに対して複数回リクエストを送り、generation metadataのプロバイダー情報と照合します。異なる強制レベルのプロバイダーへ分散されると、失敗率はルーティング次第になります。プロバイダーを固定するか、単一プロバイダーだけのモデルを選びましょう。
- 読んだだけでなく、自分で実行したスモークテスト。コミュニティの情報は、改善によっても悪化によってもすぐ変わります。上記のQwenのエラー報告やDeepSeek v4の未対応状況も、プロバイダーがエンドポイントを更新すれば変わる可能性があります。信頼性を判断するうえで意味があるのは、自分のスキーマで得た数字だけです。
OpenRouterの構造化出力 FAQ
json_objectとjson_schemaの違いは?
json_objectは構文的に有効なJSONを求めるだけですが、json_schemaはレスポンスが従うべきスキーマを指定します。json_objectで保証されるのはJSONとしての構文であり、フィールド単位のスキーマ適合性ではありません。後続処理で名前付きフィールドが必要なら、自分で検証してください。
OpenRouterで構造化出力に対応するモデルは?
固定されたモデル一覧をそのまま信頼することはできません。対応状況はエンドポイント単位で、時間とともに変化し、2024年12月の開始時点ではOpenAI 4oとFireworksのモデルだけが対応していました。各モデルページのProvidersセクションで、エンドポイントごとのstructured_outputsフラグを確認してください。
なぜモデルがスキーマを無視するのですか?
よくある原因は3つです。require_parameters: trueやプロバイダー固定で対処できる、ヒントレベルまたは非対応のエンドポイントへルーティングされているケース。エンドポイントの厳格モードでは受け付けないキーワードをスキーマで使っているケース。そして、強制的なtool choiceに対応していないモデルに対し、SDKラッパーがツール呼び出しで構造化出力を再現しようとしているケースです。
PydanticやLangChainをOpenRouterの構造化出力で使えますか?
使えます。公式ドキュメントでは、リクエスト形式はOpenRouterのチャットコンプリーション形式APIと互換性があると説明されています。そのため、Pydanticで生成したスキーマやOpenAI SDKを直接利用できます。LangChainのwithStructuredOutput()も動作しますが、tool_choiceによるエミュレーションではなく、response_formatを送信していることを確認してください。DeepSeek v4で400エラーを引き起こしたのは、このtool choice方式でした。
構造化出力はストリーミングで使えますか?
使えます。ストリームでは有効な部分JSONが送られますが、最終的なスキーマ適合性はエンドポイントの強制レベルに依存します。組み立てたオブジェクトは自分で検証してください。断片を逐次解析する処理はアプリケーション側の仕事であり、Response Healingはストリームには適用されません。
OpenRouterはレスポンスをスキーマに対して検証しますか?
すべてのエンドポイントで保証されるわけではありません。検証の厳密さはプロバイダーの強制レベルに依存し、Response Healingが修復するのも壊れたJSONの形式だけで、スキーマ違反ではありません。クライアント側の検証は必須です。
10回のスモークテスト
構造化出力を使うモデルを本番に投入する前に、次のテストを実行します。
- 代表的なスキーマを1つ決めます。複雑さは中程度とし、
additionalProperties: falseを設定し、すべてのプロパティに説明を付けます。 strict: trueとrequire_parameters: trueを指定し、フォールバックを有効にしたまま、同一のリクエストを10回送ります。この段階ではフォールバック時の挙動を意図的に確認するため、無効にしません。- 各レスポンスを3つの基準で採点します。JSONとして解析できるか、スキーマに適合しているか、意味的に妥当か。
- generation metadataから、各レスポンスを処理したプロバイダーを記録します。4つの異なるプロバイダーで10回中10回成功しても、それは保証ではなく、ルーティングのくじ引きです。
- 判断します。そのまま出荷するか、成功したエンドポイントに
provider.orderを固定して10回のテストを再実行するか、モデルを変更してクライアント側の検証とリトライ層を追加します。
合格ラインは自分で決めればよいものの、固定したスキーマで9/10を下回るなら、リトライと検証コードは任意ではありません。それ自体がプロダクトの一部です。
関連記事:OpenRouterのオートルーターがプロバイダーを選ぶ仕組み、OpenRouterのプロンプトキャッシュでコストを削減する方法、OpenRouterの429レート制限を解消する方法。