エージェントから信頼できるJSONを得る(Schema、制約付きデコード、リトライ)
昨年構築したパイプラインが午前2時に落ちた。モデルがJSONをおしゃべりな前置きで包んでしまったからだ。「もちろん!お求めのデータはこちらです:」に続いてコードブロック。レスポンスボディが { で始まることを期待していたパーサーが例外を投げた。リトライは同じ親切なモデルに当たり、同じ親切な前置きを受け取り、また例外を投げた。3回のリトライ、3回の失敗、1人の人間が起こされた。
腹立たしいことに、コードブロックの中のJSONは完璧だった。モデルは難しい部分—抽出—を正しくこなしていた。ただ、先に挨拶せずにはいられなかっただけ。プロンプトに力を注いで、コントラクトには何もしていなかった。
これが一つの逸話で示す問題のすべてだ。モデルは通常、求めるデータを生成できる。失敗は継ぎ目にある。前置き、末尾のカンマ、リネームされたフィールド、作り上げたenum値、トークン上限を上げ忘れてオブジェクトの途中で切れたレスポンス。どれも推論の失敗ではない。コントラクトの失敗であり、それに対抗するのはメカニズムであって、より良いプロンプトではない。
短くまとめると:JSONを頼まず、モデルに生成させるよう制約する。プロバイダーのschema制約付き構造化出力モード(今やOpenAI、Anthropic、Googleで標準)を使い、自分が所有するschemaに対して結果を検証し、それでも漏れるケースのために修復リトライループを持つ。以下は最弱から最強への段階と、私が実際にどこまで登るかだ。
段階:最弱から最強へ
大まかに5つの段があり、ほとんどの人は思っているより一段低いところにいる。
第1段:prompt-and-pray。 「JSONのみで返答してください、他のテキストなし」と書いて、祈る。これはデモで十分頻繁に動くので、危険なほど油断させる。前置き、コードブロック、末尾の散文、そして表現欲旺盛な日のモデルで失敗する。本番には出さないこと。
第2段:JSONモード。 出力が構文的に有効なJSONであることを保証するプロバイダーフラグ—パースはできる。出力が自分の形状に一致することは保証しない。OpenAIのドキュメントはこれが古くて弱い選択肢だとはっきり言っている。「Structured OutputsはJSONモードの進化版です。どちらも有効なJSONの生成を保証しますが、schema遵守を保証するのはStructured Outputsだけです」(OpenAI structured outputs guide)。JSONモードは前置き問題を止める。フィールドの欠落やhallucinatedなenumには何もしない。
第3段:schema制約付き構造化出力。 プロバイダーにJSON Schemaを渡し、出力が準拠するよう生成を制約する—すべての必須フィールドが存在し、すべての型が正しく、すべてのenum値がリストから取られる。データ抽出で住むべき段はここだ。
第4段:ツール/関数呼び出し。 モデルがパラメーターをJSON Schemaとして持つ関数への呼び出しを発する。メカニカルには第3段と同じ制約で、レスポンスボディではなくツール引数に適用される。モデルがどのアクションをとるかを選んでいるとき—単にフォームを埋めるのではなく—これが必要だ。
第5段:自分で実行する制約付き/文法ベースのデコード。 オープンウェイトモデルではデコーダーを制御できるので、各ステップでトークンlogitsをマスクして文法を破るトークンを禁止できる。これは最強の保証—モデルは文字通り無効なトークンを発せない—そして最も手間がかかる。
ほとんどの本番コードはホスト型プロバイダーを使って第3段か第4段にいるべきだ。それぞれがどう見えるかを示そう。
第3段:schema制約付き構造化出力
3つの主要プロバイダーが今やこれを提供しており、形状は一度学べば十分なほど似ている。
OpenAIは response_format を json_schema と strict: true で使う。strictフラグが「お願い」を「保証」に変えるものだ。その要件は具体的でつまずきやすい:すべてのオブジェクトで additionalProperties が false でなければならず、すべてのプロパティが required 配列に現れなければならない。オプションフィールドは省略でなく null との和集合で表現される(OpenAI structured outputs guide)。
AnthropicはClaude APIに同じ機能を追加した—今は一般提供、ベータヘッダー(structured-outputs-2025-11-13)はもう不要。JSONレスポンスボディには output_config.format を渡し、ツール定義に strict: true を設定して保証付きの有効なツール入力を得る。schemaのサブセットはOpenAIと同じ形:各オブジェクトで additionalProperties: false、各プロパティが required に列挙(Anthropic structured outputs docs)。
GoogleのGeminiはgeneration configを通して行う:responseMimeType を application/json に設定し、schemaを responseSchema として渡す。SDKではJSON Schemaを手書きする代わりにPythonでPydanticまたはTypeScriptでZodでschemaを定義できる(Gemini structured output docs)。
OpenAIを使うTypeScriptの形はこうだ。JSON Schemaを手で管理するとschemaと型が乖離するので、Zodでschemaを定義してコンバートする。
import OpenAI from "openai";
import * as z from "zod";
const Ticket = z.object({
priority: z.enum(["low", "medium", "high", "urgent"]),
category: z.enum(["billing", "bug", "feature", "other"]),
summary: z.string().max(280),
needs_human: z.boolean(),
});
const client = new OpenAI();
const res = await client.chat.completions.create({
model: "gpt-4o-2024-08-06",
messages: [
{ role: "system", content: "Classify the support ticket." },
{ role: "user", content: ticketText },
],
response_format: {
type: "json_schema",
json_schema: {
name: "ticket",
strict: true,
schema: z.toJSONSchema(Ticket),
},
},
});
const parsed = Ticket.parse(JSON.parse(res.choices[0].message.content));
z.toJSONSchema() はZod 4に組み込まれており、デフォルトでDraft 2020-12を対象にする(Zod JSON Schema docs)。priority と category フィールドがenumであることに注目—strictモードがオンなら、モデルは "critical" を返せない。そのトークンが許可セットにないからだ。この一つのプロパティがバグの一クラス全体を排除する。
制約が守ってくれないこと:形状を保証するが、意味は保証しない。制約されたモデルは誤って読んだチケットに堂々と本物のカテゴリーを付ける。だからやはり最後に Ticket.parse() を実行する—型とワイヤーフォーマットのschemaドリフトに対する防御として、また時々JSON Schemaで表現できないセマンティックチェックを追加するから。
第4段:ツール呼び出し、そしていつ選ぶか
ツール呼び出しは同じ制約が別のターゲットに向けられたものだ。「このレスポンスボディを埋めろ」ではなく「ツールを呼び出すなら、引数はそのschemaに一致しなければならない」だ。Anthropicのstrict tool useはその保証を明示する:strict: true で「Claudeのツール入力はschemaと完全に一致する」(Anthropic structured outputs docs)。
普通の構造化レスポンスよりツールを選ぶ理由は選択だ。モデルが search_orders、issue_refund、escalate の中から選ぶとき、それぞれ独自の引数形状を持つ場合、関数呼び出しが必要だ—どのツールかというdiscriminated union、そのツールへのvalidated args、これがまさにエージェントループのプリミティブだ。1回の呼び出しから1つの固定形状だけが必要なら、構造化レスポンスボディの方がシンプルでツール呼び出しの儀式を省ける。
ループの実用的なTypeScript風スケッチ:
// model returns tool_calls; each has a name + JSON arguments
for (const call of message.tool_calls ?? []) {
const tool = tools[call.function.name];
if (!tool) throw new Error(`hallucinated tool: ${call.function.name}`);
// strict mode guarantees this parses to the tool's shape,
// but validate anyway — providers differ, and you own the contract
const args = tool.schema.parse(JSON.parse(call.function.arguments));
const result = await tool.run(args);
// feed result back into the conversation, continue the loop
}
strictモードでも hallucinated tool ガードを残しておく。どのツールが存在するかのセットとツールごとの有効な引数のセットはスタックの異なる部分で適用されており、すべてのプロバイダーで両方が完璧だと仮定したくないからだ。
第5段:自分で実行する制約付きデコード
オープンウェイトモデルを実行するとデコーダーを所有することになり、トークンレベルで文法を施行できる:各ステップで、部分的な出力をschemaに対してunparsableにするトークンをマスクアウトする。無効なトークンはメニューに乗らないので、モデルは無効なJSONを生成できない。
ほとんどの人が始めるライブラリはOutlinesで、regex、JSON Schema、EBNFでの完全な文脈自由文法をサポートする。これは本物の機能であり本物のコストがある:初期の有限状態機械アプローチは複雑なschemaのコンパイルに長時間かかることがあり、JSON-schemaベンチマークではコンパイルタイムアウトが重いschemaでのコンプライアンス率を引き下げたことが分かった。ほとんどの高スループットサービングスタックが移行したバックエンドはXGrammar—vLLMやSGLangなどのエンジンでのデフォルト構造化生成バックエンドで、トークンあたりのオーバーヘッドが非常に低い(XGrammar)。セルフホスティングしていて構造化出力がホットパスにあるなら、エコシステムはそこへ向かった。
これに手を伸ばす前に知っておくべき注意点:ハードな文法制約は推論と悪く相互作用することがある。構造を強制しすぎると、散文で考えさせて後で構造化するのと比べて、モデルの実際の回答品質が低下するという発表済みの研究がある—いわゆる制約付きデコードのalignment tax(arXiv)。実用的な読み:出力を制約し、タスクが難しいならモデルに先に考える余地を与える。モデルにJSONで考えるよう強制しないこと。
失敗モードと本当の防御策
制約は形状を扱う。以下は扱わないものであり、夜中に呼び出される原因だ。
切り捨て。 最も一般的なもの。モデルがオブジェクトの途中で出力トークン上限に達し、どんな制約も救えない半分のJSONドキュメントを得る—止まるまでは有効だった。防御:schemaの最悪ケースに対して max_tokens を余裕を持って設定し、パース失敗時に盲目的にリトライする前にレスポンスが長さのせいで止まったかチェックする。同じ上限で切り捨てをリトライするのはただお金を燃やすことだ。
Schemaドリフト。 コードの期待する形状と送ったschemaが同期外れになる—たいてい誰かがJSON Schemaを手動で編集したから。解決策は手書きしないこと:パースに使う同じZodかPydanticの型からJSON Schemaを導出して、真実のソースが一つになるようにする。Instructorはまさにこれを活用し、クライアントをラップしてPydanticモデルがschemaを定義し、レスポンスを検証し、失敗時にリトライする—すべて一つのオブジェクト内で。
Hallucinated enum値。 prompt-and-prayのモデルはenumが low|medium|high のときに "critical" を発明する。第3段でのstrict構造化出力はこれを完全に排除する—無効なトークンは到達不能だ。制約モードでなければ、バリデートするパーサーがそれをダウンストリームの驚きでなくキャッチ可能なエラーにする。
過剰ネストとサイレントリネーム。 name を求めたのに customer_name の下に正しいデータを返したり、すべてを余分な { "result": ... } 層に包んだりする。additionalProperties: false と網羅的な required リストがモデルがフィールドを追加やリネームするのを禁止するもの;OpenAIとAnthropicがstrictモードでその2つの制約を必須にしているのはまさにこのためだ。
修復ループがすべてをつなぐ。バリデーションが失敗したとき、同じ呼び出しをリトライするだけでなく—エラーをフィードバックする:
import instructor
from pydantic import BaseModel
client = instructor.from_provider("openai/gpt-4o")
class Ticket(BaseModel):
priority: str
summary: str
# on a validation error, Instructor sends the Pydantic error message
# back to the model and asks it to fix the specific field — then re-validates
ticket = client.chat.completions.create(
response_model=Ticket,
max_retries=2,
messages=[{"role": "user", "content": ticket_text}],
)
モデルに特定のバリデーションエラー(「priorityはlow、medium、highのいずれかでなければなりません」)を見せることは盲目的なリトライよりはるかに効果的だ—何を直すか正確に伝えたから。しかしリトライを上限で制限する—2回で十分。バリデーションを3回連続失敗するモデルは通常、もっとリトライしても解決しない理由で失敗しており、gracefulにデグレードしたい:型付きエラーを返す、デフォルトにフォールバックする、人間にルーティングする。毒入り入力で永遠にループするのが午前2時の通知の原因だ。
ユースケース別に実際に選ぶもの
ホスト型モデルに対するデータ抽出や分類—一般的なケース—プロバイダーのstrict schema制約モードを使い、ZodかPydanticからschemaを導出し、同じ型で結果を検証する。これは第3段にバリデートするパーサーを加えたもので、ほとんどの人が作っているものをカバーする。
アクションを選ぶエージェントには、strict tool callingを使う。どのツールかとvalidated argsの形状が求めるもので、strictフラグが引数バリデーションのギャップを閉じる。認識できないツール名へのガードを持っておく。
ホットパスのセルフホスト型オープンウェイトモデルには、サービングエンジン経由で文法制約付きデコードを使う—XGrammarクラスのバックエンドは今や十分速く、手作りする理由はほとんどない。タスクが難しければモデルにまず散文で推論させ、それから構造化部分を制約する。
そして段がどこであれ:自分が所有するschemaで境界でバリデートし、gracefulなデグレードを伴う有限の修復ループを持つ。制約はモデルがゴミを出すのを止める。バリデーターは自分の仮定がゴミになるのを止める。プロンプトを信頼するのをやめてコントラクトを適用し始めてから、パイプラインも睡眠も改善した。
