cd ../back to blog
$Guide//June 4, 2026//7 min read

構造化出力と JSON モード: Claude と Gemini から確実な JSON を引き出す

OpenAI 互換の response_format で json_object と json_schema を使い分け、strict・additionalProperties・required でスキーマを強制。Claude と Gemini をまたいでパース・検証・修復まで解説します。

チャットモデルは「話したがり」です。一方、あなたのパイプラインが欲しいのは 「レコード」です。「このチケットについて親切に一段落で説明しますね」と {"category": "billing", "priority": "high"} のあいだの溝こそ、ほとんどの LLM 連携が静かに壊れる場所です — はぐれた Markdown のフェンス、末尾のカンマ、幻覚で生えたキー、そして下流の json.loads が午前 3 時に例外を吐く。本稿のテーマは、Claude と Gemini から正しく使える JSON を引き出すこと、ひとつの base_url の背後で同じ OpenAI 形式のリクエストを使うこと、そしてそれをデモ品質ではなく 本番品質に引き上げるバリデーション層です。

この仕事に使える道具は 3 つあります。json_object を指定した response_formatjson_schema を指定した response_format、そしてネイティブの tool/function calling です。 これらは互換ではなく、誤った道具を選ぶことこそ、構造化出力の機能が 「不安定に感じる」最大の原因です。それぞれを順に、いつ手を伸ばすべきか、 スキーマをどう設計するか、返ってきたものをどう検証し修復するかまで見ていきます。

JSON モード: パース可能は保証、正しさは保証しない

最もシンプルなレバーが response_format={"type": "json_object"} です。これはモデルを構文的に正しい JSON の 出力へ制約します — 前置きの文章も、```json のフェンスも、 謝罪もなし。ただし、これがやらないことは「あなたの形」の 強制です。フィールドは依然プロンプトで説明する必要があり、モデルはキーを 落としたり、勝手に作ったり、boolean が欲しいところに文字列を入れたりし得ます。

json_object.py
# response_format=json_object: モデルは構文的に正しい JSON を出力するよう
# 制約されます。ただし「あなたの」形は強制されません — フィールドは
# プロンプトで自分で説明する必要があります。Claude でも Gemini でも、ひとつの base_url の背後で同一の呼び出し。
from openai import OpenAI
import json

client = OpenAI(
    api_key="sk-brievio-...",
    base_url="https://api.brievio.com/v1",
)

resp = client.chat.completions.create(
    model="claude-sonnet-4-6",          # "gemini-2.5-flash" でも同じコード
    response_format={"type": "json_object"},
    messages=[
        {
            "role": "system",
            "content": (
                "Extract the support ticket fields. Reply ONLY with a JSON object "
                "with keys: category (one of billing|bug|feature|other), "
                "priority (low|medium|high), summary (string), "
                "needs_human (boolean)."
            ),
        },
        {"role": "user", "content": "I was charged twice this month, please refund."},
    ],
)

data = json.loads(resp.choices[0].message.content)   # パース可能であることは保証される
print(data["category"], data["priority"])            # "billing" "high"

# json_object が保証するのは「パースできること」だけ。キーが存在すること、
# enum が正しいこと、型が合っていることは保証しません。それを担うのがバリデーションです。

これが正しい道具になるのは、形がシンプルなとき、プロンプトを厳しく コントロールできるとき、あるいはどのみち検証する(するべきです)ときです。 頭の中のモデルはこうです。json_object が買えるのは json.loads が例外を吐かないという保証だけ。そのオブジェクトが あなたの思う意味を持つという保証は買えません。この違いこそが勝負の すべてだと心得てください。

JSON Schema モード: 構文だけでなく形まで制約する

フィールド名・型・enum を「お願い」ではなく「強制」したいなら、 json_schema に手を伸ばします。スキーマはリクエストと一緒に運ばれ、 strict: true(モデルファミリーが対応している場合)では出力が それに合致するよう制約されます。「strict」を本当に意味あるものにする 2 つの フィールドが、additionalProperties: false(想定外のキーなし)と 完全な required 配列(欠落キーなし)です。

json_schema.py
# response_format=json_schema: スキーマがモデルに送られ、出力はそれに
# 制約されます。対応しているモデルでは strict=True で確実な保証が得られます。
# additionalProperties=False + required キーこそが「strict」を意味あるものにします。
schema = {
    "name": "support_ticket",
    "strict": True,
    "schema": {
        "type": "object",
        "additionalProperties": False,
        "properties": {
            "category": {"type": "string", "enum": ["billing", "bug", "feature", "other"]},
            "priority": {"type": "string", "enum": ["low", "medium", "high"]},
            "summary": {"type": "string"},
            "needs_human": {"type": "boolean"},
        },
        "required": ["category", "priority", "summary", "needs_human"],
    },
}

resp = client.chat.completions.create(
    model="claude-sonnet-4-6",
    response_format={"type": "json_schema", "json_schema": schema},
    messages=[
        {"role": "system", "content": "Extract the support ticket fields."},
        {"role": "user", "content": "I was charged twice this month, please refund."},
    ],
)

ticket = json.loads(resp.choices[0].message.content)
# strict な json_schema なら、category は 4 つの enum のいずれかであることが
# 証明済み — happy path で防御的な "if category not in (...)" は不要です。

ここで正直な注意点、そしてこれは重要です。 strict な json_schema の対応はモデルファミリーによって異なります。 ネストした additionalProperties: false まで含めてすべての制約を 守るモデルもあれば、特に深くネストしたオブジェクト・ユニオン( anyOf)・再帰構造では、スキーマを厳密な文法ではなく強いヒント として扱うモデルもあります。Brievio はあなたの response_format を 本物のファーストパーティ・モデルへそのまま素通しするので、得られるのは 本物のモデルの本物の挙動であり、薄めたエミュレーションではありません。 ただしそれは、モデルのネイティブな限界がそのままあなたの限界になることも 意味します。実践上のルールはこうです。 スキーマで要求し、そのうえでとにかく検証する。「strict」に 言いくるめられて検証ステップを省略しないでください。

JSON モード対 tool calling: どちらをいつ使うか

tool/function calling も構造化された JSON を返します — 引数が関数名に ひもづいた JSON 文字列として返ってくるのです。では、どちらを使うのか。 違いは整形ではなく意図にあります。

  • JSON 答えそのものなら、JSON モードを使う。 フィールドの抽出、分類、レコードへの要約、あるいは設定オブジェクトの 生成。毎回、返してほしい形はちょうど 1 つです。 response_format のほうがすっきり合います — 出力はひとつ、 function-call の儀式も tool_choice の配管も不要です。
  • モデルがアクションを選ぶなら、tool calling を使う。 モデルは get_weather を呼ぶかもしれないし、 search_db かもしれないし、文章で答えるかもしれない — そのどれを選ぶか(場合によっては複数呼ぶか)をモデルに決めさせたいときです。 function calling はディスパッチのために作られています。候補の形が いくつもあり、モデルが選ぶ。それを単一の JSON オブジェクトに押し込むのは ぎこちないやり方です。
  • グレーゾーン: 構造化出力としての単一の強制 tool call。 tool_choice で特定の関数を 1 つだけ要求するのは、 json_schema 以前のモデルで構造化出力を得る昔ながらの定石です。 今でも機能し、立派なフォールバックです。ただしモデルが json_schema に対応しているなら、そちらのほうが直接的で 考えることも少なくて済みます。

あなたのワークロードが固定レコードではなく、本当にアクションとディスパッチに 関するものなら、その仕組みとモデル横断の落とし穴は Claude と Gemini をまたぐ tool useにまとめてあります。「このオブジェクトをちょうだい」というものはすべて、 response_format のままで通してください。

モデルが実際に当てられるスキーマを設計する

スキーマはプロンプトです。その形をどう整えるかは、モデル選びと同じくらい 命中率を左右します。両ファミリーで効くルールをいくつか。

  • 深いネストより平らを優先する。 オプションのオブジェクトを 含む 3 階層のネストは、strict モードがぐらつくところです。 address.citycity に平らにできるなら、そうして、 検証後に整形し直しましょう。
  • 閉じた集合にはすべて enum を使う。 "priority": {"enum": ["low","medium","high"]} は、後処理する自由な string よりはるかに信頼できます。enum は スキーマで最もレバレッジの効く機能です。
  • フィールドは人間が付けるように命名する。 needs_human_reviewnh_flag に勝ります。 名前が指示そのものを運ぶので、よく名付けられたフィールドほどモデルは 正確に埋めます。
  • 曖昧なフィールドには description を付ける。 スキーマ内のフィールドごとに 1 行あれば、「モデルが推測を外した」ケースの 大半はプロンプトの書き直しなしで解消します。
  • 省略可能性を明示する。 フィールドが欠けうるなら、 required から外すか、nullable なユニオンとしてモデル化すること — モデルが番兵値を発明してくれると期待しないでください。「欠落」のケースを 誰が — あなたかモデルか — 持つのかを決めましょう。
  • 境界のある型で済むなら自由な数値は避ける。 1〜5 の整数評価を [1,2,3,4,5] の enum にすると、プロンプトでの 「1 から 5 までの数値」より良い結果になります。

検証と修復: 本番に出せるようにする層

信頼性を最も大きく引き上げるのは、モデル出力を信頼できないクライアントの リクエストのように扱うことです。パースし、本物のスキーマで検証し、失敗したら エラーを差し戻して1 回だけリトライする。Pydantic モデル(あるいは zod、または使っている言語の JSON Schema 検証)は、strict モードをすり抜ける ケースまで捕まえます — そして修復ターンがその大半を直します。モデルは、 直接に指し示された間違いを訂正するのが得意だからです。

validate.py
# 自分でバリデーションしていない出力は決して信用しないこと。モデルは信頼できない
# クライアントとして扱う: パース -> 自分のスキーマで検証 -> エラーを差し戻して 1 回だけ
# リトライ。これが「だいたい動く」を「本番に出せる」に変える層です。
from pydantic import BaseModel, ValidationError
from typing import Literal

class Ticket(BaseModel):
    category: Literal["billing", "bug", "feature", "other"]
    priority: Literal["low", "medium", "high"]
    summary: str
    needs_human: bool

def extract(text: str, model: str, retries: int = 1) -> Ticket:
    messages = [
        {"role": "system", "content": "Extract the support ticket fields as JSON."},
        {"role": "user", "content": text},
    ]
    for attempt in range(retries + 1):
        resp = client.chat.completions.create(
            model=model,
            response_format={"type": "json_object"},
            messages=messages,
        )
        raw = resp.choices[0].message.content
        try:
            return Ticket.model_validate_json(raw)      # パースと検証を 1 ステップで
        except ValidationError as e:
            if attempt == retries:
                raise
            # 修復ターン: 何が間違っていたかをモデルに正確に示す。
            messages += [
                {"role": "assistant", "content": raw},
                {"role": "user", "content": f"That failed validation: {e}. Re-emit valid JSON only."},
            ]
    raise RuntimeError("unreachable")

修復ターンが何をしているかに注目してください。モデル自身の悪い出力 正確な検証エラーを見せ、そのうえで出力し直しを求めます。1 回のリトライで 失敗の圧倒的多数は解消します。それでも失敗するなら、あなたはそれを知りたい はずなので、例外を吐かせましょう。トークンを燃やしながら無限ループに陥っては いけません — リトライ回数に上限を設け、生のペイロードをログに残し、ハードな 失敗にはアラートを出す。検証の失敗が続くのは、たいていモデルが壊れているの ではなく、入力が支えられないものをスキーマが要求しているからです。

本番向けの注意を 2 つ。第一に、max_tokens は余裕を持って 設定すること。オブジェクトの途中で切り詰められた JSON は不正な JSON であり、 厳しすぎるトークン上限は大きなレコードでのパース失敗の主因です。第二に、 抽出や分類では temperature を低く(0〜0.3)保つこと — 同じ入力から 同じレコードが返ってほしいのであって、構造体を埋めるときに創造性は美徳では ありません。

ひとつの形で、両方のモデルファミリー

上のスニペットはどれも、文字列を 1 つ —model フィールド — 変えるだけで Claude Sonnet 4.6 でも Gemini 2.5 Flash でも動きます。これこそ 構造化出力を Brievio 経由でルーティングする意義です。OpenAI 形式の response_format 契約は同一なので、抽出タスクでより安いモデルを A/B したり、インシデント中にファミリーをまたいでフォールバックしたりしても、 パースや検証を書き直す必要はありません。あなたが送るリクエストは、本物の ファーストパーティ・モデルが受け取るリクエストそのものです — 何が一致し、何に注意すべきか は、OpenAI 互換に依存するときにこちらで正確にまとめています。

実践的なワークフロー: まず Sonnet で strict な json_schema を 使ってプロトタイプし、ホールドアウトのセットでバリデーターが通ることを 確認してから、同じスキーマを Flash で試す。安いモデルがあなたの検証通過率を クリアすれば、コード変更ゼロでコストを削れた — そして Brievio は正直な トークン数を報告し、失敗した 4xx/5xx の呼び出しを ゼロで課金するので、リトライや修復ターンが計測上のサプライズを隠すことは ありません。各モデルの比較は モデルページで、チャットのリクエスト/レスポンス 契約の全体は チャットドキュメントにあります。

まとめ

形がシンプルでプロンプトを自分が持っているなら json_object に、 構造を強制したいなら strict: true additionalProperties: false・完全な required 配列を 付けた json_schema に、モデルが固定レコードを生み出すのではなくアクションを選んでいるなら tool calling に手を伸ばしてください。 どれを選ぶにせよ、スキーマは平らに・enum 多めに設計し、そして必ず パース・検証・修復を行う — strict 対応はモデルファミリーによって異なり、 バリデーション層こそが「デモでは動く構造化出力」と「実トラフィックを 生き延びる構造化出力」を分けるからです。同じコード、同じ契約、本物のモデル — Claude と Gemini をまたいで、ひとつの base URL の背後で。