LLMの出力をJSON Schemaで固定する — パース失敗のリトライを設計から消す

LLM の応答を後続処理に流すとき、いちばん頻繁に壊れるのは中身の正しさではなく 形式 だ。「JSON だけを返してください」とプロンプトに書いても、前後に説明文が付く、コードフェンスで囲まれる、末尾カンマが入る、といった形で崩れる。結果、こういうコードを書くことになる。

1
2
3
4
5
6
7
8
9
10
11
12
// よくある防御的パース
function looseParse(text) {
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
const body = fenced ? fenced[1] : text;
const start = body.indexOf("{");
const end = body.lastIndexOf("}");
try {
return JSON.parse(body.slice(start, end + 1));
} catch {
return null; // ここで null が返ったらリトライ…
}
}

このコードには終わりが無い。新しい壊れ方を見つけるたびに分岐が増え、リトライ回数とタイムアウトの調整が始まる。

解き方:形式をモデル側で強制する

現在の主要な LLM API には、応答をスキーマに従わせる機能がある。Claude API の場合は output_config.format に JSON Schema を渡す形だ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"title": {"type": "string"},
"severity": {"type": "string", "enum": ["low", "medium", "high"]},
"tags": {"type": "array", "items": {"type": "string"}},
},
"required": ["title", "severity"],
"additionalProperties": False,
},
}
}

こうすると応答テキストはスキーマを満たす JSON になる。上の looseParse は丸ごと消え、JSON.parse を素で呼べる。ツール呼び出し側の引数を固めたい場合は、ツール定義に strict: true を付ける同種の仕組みがある。

効くのは「リトライが消える」ことより「分岐が消える」こと

速度やコストの改善として語られがちだが、実務で効くのはコードの形だ。パースが必ず成功する前提に立てると、次の3つがまとめて不要になる。

  • パース失敗時のリトライループと、その最大回数の設定
  • リトライしても直らなかったときのフォールバック経路
  • 「壊れた出力」をログに残して後で調べる運用

自動実行されるバッチ処理では、この3つが障害対応コストのかなりの部分を占める。分岐が消えれば、監視すべき失敗モードが減る。

スキーマ側にはそれなりの制約がある

万能ではない。Claude API の構造化出力でサポートされるのは、基本型(object / array / string / integer / number / boolean / null)、enumconstanyOfallOf$ref / $def / definitions、文字列フォーマット(date-time / date / email / uri / uuid など)、そして配列の minItems(値は 0 か 1 のみ)まで。

一方で次はサポートされない。

  • 再帰的なスキーマ
  • minimum / maximum などの数値制約
  • minLength / maxLength などの文字列制約
  • 複雑な配列制約
  • additionalPropertiesfalse 以外を指定すること
  • 外部 $ref

複雑さの上限もある。1リクエストあたりの strict ツールは20個まで、オプションパラメータの総数は24個まで、union 型を使うパラメータは16個までだ。

つまり 「形は保証されるが、値の範囲は保証されない」。年齢が 0〜150 の整数であることや、文字列長が 200 文字以内であることは、スキーマではなく自分のコードで検証する必要がある。ここを取り違えると、パースは通るのに値が異常、という別種のバグに移動するだけになる。

1
2
3
4
// 形式はAPI側、値の妥当性は自分で
const data = JSON.parse(response.content[0].text); // 失敗しない前提に立てる
if (!["low", "medium", "high"].includes(data.severity)) throw new Error("unexpected severity");
if (data.tags && data.tags.length > 20) data.tags = data.tags.slice(0, 20);

運用上の注意点

いくつか把握しておくと事故が減る点がある。

  • 初回リクエストは遅くなる。 スキーマをグラマーにコンパイルする時間が乗る。コンパイル結果は24時間キャッシュされるが、スキーマ構造やツールセットを変えるとキャッシュは無効化される。スキーマを動的に組み立てる実装は、この恩恵を捨てることになる。
  • スキーマ外の応答が返る場合がある。 安全上の理由でモデルが拒否した場合(stop_reason: "refusal")はスキーマに従わない拒否メッセージが返り、トークン上限に達した場合(stop_reason: "max_tokens")は出力が途中で切れる。stop_reason の確認は依然として必要だ。
  • enum の大文字小文字は保証されない。 比較は大文字小文字を区別せずに行う。
  • プロパティの順序は必須が先。 出力順に依存する処理は書かない。

SDK のネイティブ定義を使う

スキーマをコードと JSON の2箇所で持つと必ずずれる。各 SDK は言語ネイティブの型からスキーマを生成する経路を持っているので、そちらに寄せるほうが保守しやすい。Python なら Pydantic モデルと client.messages.parse()、TypeScript なら Zod スキーマと zodOutputFormat()、Go なら構造体からのリフレクション、といった具合だ。

構造化出力の詳しい設定は Claude チュートリアル 日本語版 にもまとめている。

自動化パイプラインで LLM を使うなら、プロンプトで形式をお願いする段階から早めに卒業したほうがいい。形式を保証する層と、値を検証する層を分けておくと、後から増える要件はほぼ後者に閉じる。


LLMの出力をJSON Schemaで固定する — パース失敗のリトライを設計から消す
https://blog.hashito.biz/2026/07/19/json-schema-structured-outputs-llm-parse-retry/
著者
hashito
作成日
2026年7月19日
著作権