チャットモデルは「話したがり」です。一方、あなたのパイプラインが欲しいのは 「レコード」です。「このチケットについて親切に一段落で説明しますね」と {"category": "billing", "priority": "high"} のあいだの溝こそ、ほとんどの LLM 連携が静かに壊れる場所です — はぐれた Markdown のフェンス、末尾のカンマ、幻覚で生えたキー、そして下流の json.loads が午前 3 時に例外を吐く。本稿のテーマは、Claude と Gemini から正しく使える JSON を引き出すこと、ひとつの base_url の背後で同じ OpenAI 形式のリクエストを使うこと、そしてそれをデモ品質ではなく 本番品質に引き上げるバリデーション層です。
この仕事に使える道具は 3 つあります。json_object を指定した response_format、json_schema を指定した response_format、そしてネイティブの tool/function calling です。 これらは互換ではなく、誤った道具を選ぶことこそ、構造化出力の機能が 「不安定に感じる」最大の原因です。それぞれを順に、いつ手を伸ばすべきか、 スキーマをどう設計するか、返ってきたものをどう検証し修復するかまで見ていきます。
JSON モード: パース可能は保証、正しさは保証しない
最もシンプルなレバーが response_format={"type": "json_object"} です。これはモデルを構文的に正しい JSON の 出力へ制約します — 前置きの文章も、```json のフェンスも、 謝罪もなし。ただし、これがやらないことは「あなたの形」の 強制です。フィールドは依然プロンプトで説明する必要があり、モデルはキーを 落としたり、勝手に作ったり、boolean が欲しいところに文字列を入れたりし得ます。
# 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 配列(欠落キーなし)です。
# 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.cityをcityに平らにできるなら、そうして、 検証後に整形し直しましょう。 - 閉じた集合にはすべて enum を使う。
"priority": {"enum": ["low","medium","high"]}は、後処理する自由なstringよりはるかに信頼できます。enum は スキーマで最もレバレッジの効く機能です。 - フィールドは人間が付けるように命名する。
needs_human_reviewはnh_flagに勝ります。 名前が指示そのものを運ぶので、よく名付けられたフィールドほどモデルは 正確に埋めます。 - 曖昧なフィールドには
descriptionを付ける。 スキーマ内のフィールドごとに 1 行あれば、「モデルが推測を外した」ケースの 大半はプロンプトの書き直しなしで解消します。 - 省略可能性を明示する。 フィールドが欠けうるなら、
requiredから外すか、nullable なユニオンとしてモデル化すること — モデルが番兵値を発明してくれると期待しないでください。「欠落」のケースを 誰が — あなたかモデルか — 持つのかを決めましょう。 - 境界のある型で済むなら自由な数値は避ける。 1〜5 の整数評価を
[1,2,3,4,5]の enum にすると、プロンプトでの 「1 から 5 までの数値」より良い結果になります。
検証と修復: 本番に出せるようにする層
信頼性を最も大きく引き上げるのは、モデル出力を信頼できないクライアントの リクエストのように扱うことです。パースし、本物のスキーマで検証し、失敗したら エラーを差し戻して1 回だけリトライする。Pydantic モデル(あるいは zod、または使っている言語の JSON Schema 検証)は、strict モードをすり抜ける ケースまで捕まえます — そして修復ターンがその大半を直します。モデルは、 直接に指し示された間違いを訂正するのが得意だからです。
# 自分でバリデーションしていない出力は決して信用しないこと。モデルは信頼できない
# クライアントとして扱う: パース -> 自分のスキーマで検証 -> エラーを差し戻して 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 の背後で。