12k
All articles

LLM構造化出力のためのJSON Schema

JSON Schemaのstructured outputsを解説。OpenAI、Gemini、Claudeで有効なスキーマ出力を強制する仕組みと設定の注意点。

OpenReplay Team
OpenReplay Team
LLM構造化出力のためのJSON Schema

構造化出力(Structured Outputs)は、あなたが提供したJSON Schemaに沿ってLLMのレスポンスを制約する仕組みです。これにより、手作業でパースしなければならない自由形式の文章ではなく、指定したフィールド・型・enumに一致する機械可読なJSONがモデルから返されます。

LLM機能をリリースした経験のある人なら、その代替手段がどんなものかを知っているはずです。はみ出したmarkdownフェンスを取り除く正規表現、JSON.parseを囲むtry/catch、そして望ましい頻度よりもはるかに頻繁に発動するリトライループ。これらは、ある朝ひそかに機能しなくなるまでは、そこそこうまく動きます。

プロバイダーが生成時にスキーマを強制するため、「モデルが有効なJSONを返してくれることを願う」という状態が「契約」へと変わります。これは現在、OpenAI、Google Gemini、Anthropic Claudeのいずれでも利用可能です。3社すべてがJSON Schemaを受け付けるため、同一のスキーマを移植でき、変更が必要なのはリクエストの配線部分だけで、契約そのものは変わりません。本記事では、構造化出力とは何か、JSON Schemaがそれをどう駆動するのか、強制の仕組みは内部でどう働くのか、プロバイダーごとの実装方法、そして注意すべき失敗モードを取り上げます。

要点

  • JSONモードが保証するのは構文的に有効なJSONだけです。一方、strictな構造化出力は指定したスキーマに一致するJSONを保証します。これは「パースできる」と「必要なフィールドが揃っている」の違いです。
  • 強制は制約付きデコーディング(constrained decoding)によって行われます。各トークンにおいて、モデルはスキーマに対して出力を有効に保つ継続だけを出力できるため、適合性は生成後に検査されるのではなく、生成中に強制されます。
  • OpenAI、Gemini、ClaudeはいずれもJSON Schemaを解釈するため、1つのスキーマがプロバイダー間で移植できます。PydanticとZodはJSON Schemaにコンパイルされ、これが多くのチームが実際に採用しているワークフローです。
  • strictモードを有効にしていても、拒否(refusal)や長さによる切り詰めが起きたレスポンスは成功として返るものの、スキーマに適合したJSONではありません。したがって、パース済みオブジェクトを信頼する前に検証してください。
  • どのプロバイダーもJSON Schemaのサブセットしかサポートしていないため、minimumpattern、深い再帰といったキーワードは無視されたり拒否されたりする可能性があります。各プロバイダーのサポート対象サブセットのドキュメントで確認してください。

自由形式テキストからスキーマ強制されたJSONへ

放っておくと、LLMはパーサーを壊す自由形式のテキストを出力します。JSONの周りに文章を付け加えたり、引用符を省略したり、存在しないフィールドを発明したりします。構造化出力は、レスポンスをJSON Schemaに制約することでこれを解決し、出力を機械可読かつ確実にパース可能なものにします。

これは旧来のJSONモードからのアップグレードです。OpenAIは2023年にJSONモードを導入し、有効なJSONを強制する手段としましたが、これは出力がパースできることしか約束せず、あなたが定義したスキーマに従うことは保証しません。strictな構造化出力は、スキーマ自体を強制することでそのギャップを埋めます。OpenAI自身による複雑なスキーマ追従性の評価では、Structured Outputsを使ったgpt-4o-2024-08-06モデルが100%を記録した一方、旧来のgpt-4-0613は40%未満でした。これはモデル固有のベンチマークであり普遍的な保証ではありませんが、「たいていパースできる」から「スキーマに一致する」への転換を示しています。

JSON Schemaはどこに位置づけられるのか

JSON Schemaは、データに対する宣言的な契約です。型、必須フィールド、enum、値の制約を宣言します。これをリクエストとともに渡すと、プロバイダーがそれに一致するよう生成を制約します。連絡先抽出のためのコンパクトなスキーマは次のようになります。

{
  "type": "object",
  "properties": {
    "name": { "type": "string" },
    "email": { "type": "string" },
    "plan_interest": {
      "type": "string",
      "enum": ["starter", "pro", "enterprise"]
    }
  },
  "required": ["name", "email", "plan_interest"],
  "additionalProperties": false
}

本番環境でこれを手書きする人はほとんどいません。一般的なワークフローは、Pydantic(Python)やZod(TypeScript)で構造を定義し、SDKにJSON Schemaを生成させる方法です。OpenAIのSDKはこれを直接サポートしています。PydanticまたはZodのオブジェクトを渡せば、対応するJSONスキーマを生成し、レスポンスを型付きオブジェクトに戻し、拒否も表面化してくれます。Geminiも同様の利便性を追加し、JSON SchemaサポートをアクティブにサポートされているすべてのGeminiモデルに拡張したため、PydanticやZodのスキーマが変換ステップなしで動作します。

スキーマやプロバイダーごとのラッパーを手で組み立てたくない場合は、OpenReplayのJSON Schema builderがブラウザ上でその両方を行ってくれます。型、説明、enum、ネストを備えたフィールドを追加するか、サンプルJSONを貼り付けて出発点を推論させ、エクスポートパネルから結果をコピーします。エクスポートパネルはJSON Schema、OpenAIのresponse_format、OpenAI function tool、Anthropic、Gemini、Zod、Pydanticに対応しています。入力した内容がページ外に出ることはありません。

スキーマ強制はどのように機能するのか

構造化出力が機能するのは、プロバイダーがデコーディングを制約するからです。生成の各ステップで、モデルはスキーマに対して出力を有効に保つトークンしか生成できません。スキーマは文法(または有限状態機械)にコンパイルされ、それに違反するトークンはサンプリング前にマスクされます。Anthropicの公式ドキュメントも同じ仕組みを説明しています。スキーマが文法にコンパイルされ、制約付きサンプリングによって生成がその内側に保たれるため、モデルにはスキーマを壊すトークンを出力する手段がありません。

同じ考え方はJSONを超えて一般化できます。ローカルおよびセルフホストのモデルでは、llama.cppがGBNF文法ファイルを使用し、Outlinesが正規表現ベースおよび文法ベースの制約を適用します。いずれも同じトークンマスキングの原理によって、任意のフォーマット(SQL、独自DSL、JSONなど)を強制します。

プロバイダー横断で見る構造化出力

主要3プロバイダーはすべてJSON Schemaを解釈するため、パターンは移植できます。異なるのはリクエストの配線と失敗シグナルです。

プロバイダースキーマの指定場所スキーマの方言拒否 / 不完全のシグナル
OpenAIresponse_format(Chat Completions)またはtext.format(Responses API)、strict: trueとともに指定JSON Schemaのサブセットrefusalフィールド、finish_reason: "length"
GeminigenerationConfig内のresponseFormat.textmimeType + schemaJSON Schemaのサブセット(anyOf$refを含む)切り詰められたcandidate、過度に複雑なスキーマの拒否
Claudeoutput_config.format、またはツールのinput_schemaにおけるstrict: trueJSON Schemaのサブセットstop_reason: "refusal" / "max_tokens"

OpenAI。 strict: trueを設定してスキーマを渡します。OpenAIのガイドは、新規プロジェクトでは現行モデルから始めることを推奨し、Responses APIではパラメータの位置が変わったと注記しています。Chat Completionsではresponse_format: { type: "json_schema", strict: true }を使い、Responses APIではtext: { format: { type: "json_schema", strict: true } }を使います。

Gemini。 スキーマはgenerationConfigを通して指定します。

response = client.models.generate_content(
    model="gemini-3.5-flash",
    contents="Extract the contact from this email...",
    config={
        "response_format": {
            "text": {
                "mime_type": "application/json",
                "schema": person_schema,
            }
        },
    },
)

Claude。 Anthropicはネイティブな構造化出力を提供しており、もはやツール呼び出しによる回避策ではありません。2つの補完的な機能があります。レスポンスボディに対するoutput_config.formatによるJSON出力と、ツール入力に対するstrict: trueによるstrict tool useで、これらは個別にも併用しても使えます。strict tool useは、呼び出しの引数がそのinput_schemaに一致することを保証します。というのも、そのスキーマが文法にコンパイルされてサンプリングを制約するためで、これはOpenAIやGeminiが用いているのと同系統の技術です。なお、GAに際してAPIの形が変わりました。output_formatパラメータはoutput_config.formatに移り、ベータヘッダーは不要になりました。

落とし穴とベストプラクティス

strictモードはパース可能な出力の保証ではなく、スキーマのサポートも全面的ではありません。以下に備えてください。

スキーマはフラットに保つ。 深くネストされた構造や再帰的な構造は、「スキーマが複雑すぎる」というエラーや推論品質の低下の最も一般的な原因です。Claudeはこれを直接的に示し、コンパイルされた文法が大きくなりすぎた場合に400を返します。またGeminiのドキュメントは、非常に大きい、あるいは深くネストされたスキーマは拒否される可能性があると警告しています。長いプロパティ名、大きな配列、多数の値を持つenum、オプショナルなプロパティで埋め尽くされたオブジェクトは、いずれもコストを増大させます。大きな抽出処理は、より小さくフラットなスキーマに分割しましょう。

拒否と切り詰めを処理する。 strictモードを有効にしていても、拒否や長さによる切り詰めが起きたレスポンスは成功ステータスで返るものの、スキーマに適合したJSONではありません。OpenAIはこのための専用シグナルを追加しました。レスポンス上のrefusalフィールドによって、モデルがスキーマに一致する何かを返す代わりに拒否したことがわかります。出力トークンの上限は一律ではありません。上限はモデルごとに異なり、オブジェクトの途中で上限に達したレスポンスはすべて無効なJSONになるため、最悪ケースを想定してmax_tokensを設定し、使用モデルの文書化された上限を確認してください。

サポート対象サブセットを確認する。 どのプロバイダーも、strictモードではJSON Schemaの一部分しかサポートしていません。OpenAIのガイドは、仕様の相当部分はカバーされているが、性能上または技術的な理由から一部は除外されていると明言しています。minimumpattern、デフォルト値といったキーワードは無視されたり拒否されたりする可能性があるため、仕様全体が使えると仮定せず、プロバイダーのサポート対象サブセットのドキュメントを確認してください。

それでも検証する。 拒否や切り詰めは「ステータスは有効だがJSONは無効」なレスポンスを生むため、strict: trueを使っていても、オブジェクトを信頼する前に自分のスキーマに対してパースおよび検証を行ってください。

まず推論させ、その後に出力させる。 出力を制約すると、一部のタスクで推論品質が下がることがあります。Claudeの構造化出力に関するある実践者向けガイドは、これをextended thinkingとの現実的なトレードオフとして扱っています。あるタスクが、スキーマ適合の保証よりもモデルの推論から得るものが大きい場合は、思考部分は制約しないままにしておくべきです。実用的な中間策は、モデルに思考フェーズで推論させ、最終的なJSONだけを制約することです。

構造化出力は、LLMのレスポンスを型付きAPIのように扱えるものへと変えます。そしてOpenAI、Gemini、Claudeはいずれもこのパターンをきれいに移植できます。すべてがJSON Schemaを受け付けるからです。まずはPydanticまたはZodで構造を定義し、プロバイダー側でstrictモードを有効にし、スキーマをフラットに保ち、拒否と切り詰めを処理する検証でパースを包みましょう。そのうえで、デプロイ先となるどのプロバイダーに対しても同じスキーマを配線すればよいのです。

FAQ

JSONモードとstrictな構造化出力の違いは何ですか?

JSONモードは、モデルがエラーなくパースできる構文的に有効なJSONを返すことだけを保証しますが、出力が特定のスキーマに一致することは保証しません。strictな構造化出力は、生成時にあなたのJSON Schemaを強制するため、返されるオブジェクトには定義したフィールド、型、enumが含まれます。この違いは「パースできる」か「必要なフィールドが揃っている」かの違いです。OpenAIは2023年にJSONモードを導入し、その後スキーマ強制型の構造化出力を追加してこのギャップを埋めました。

strictモードを有効にすれば、常に有効でパース可能なJSONが得られると保証されますか?

いいえ。strictモードは通常の生成完了時にトークン生成をスキーマに制約しますが、安全性による拒否や長さによる切り詰めが起きたレスポンスは、成功ステータスで返りながらスキーマに適合しない出力を生じます。OpenAIは専用のrefusalフィールドとlengthというfinish_reasonを公開しています。Claudeはrefusalまたはmax_tokensというstop_reasonでこれを示します。これらのレスポンスは200を返し課金対象となるため、オブジェクトを信頼する前に自分のスキーマに対してパースおよび検証を行うべきです。

同じJSON SchemaをOpenAI、Gemini、Claudeで再利用できますか?

おおむね可能です。OpenAI、Google Gemini、Anthropic ClaudeはいずれもJSON Schemaを受け付けるため、同じスキーマを移植でき、変更が必要なのはリクエストの配線だけで、契約そのものは変わりません。異なるのはスキーマを指定する場所です。OpenAIはresponse_formatまたはtext.formatにstrict trueを付けて使い、GeminiはgenerationConfig内のresponseFormat.text配下にスキーマをネストし、Claudeはoutput_config.formatまたはstrict tool useを使います。各プロバイダーはJSON Schemaのサブセットしかサポートしていないため、完全な移植性を前提とする前に、サポートされていないキーワードを各プロバイダーのサポート対象サブセットのドキュメントで確認してください。

なぜスキーマが複雑すぎるとして拒否されるのですか。どう修正すればよいですか?

複雑さの制限は、深くネストされた構造や再帰的な構造、長いプロパティ名、大きな配列の上限、多数の値を持つenum、オプショナルなプロパティが多いオブジェクトなどから生じます。Claudeはコンパイルされた文法が大きくなりすぎた場合に400を返し、Geminiは非常に大きい、あるいは深くネストされたスキーマを拒否する可能性があります。対処法は、スキーマをフラットに保ち、大きな抽出処理をより小さくフラットなスキーマに分割することです。深くネストされた構造は推論品質の低下の一般的な原因でもあるため、フラット化は受理されやすさと出力品質の両方を改善します。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.