この記事のゴール
LLM に「JSON で返して」と頼んでも、本番では必ず崩れます。前置きの一言、```json のフェンス、末尾のカンマ、途中で切れた中括弧——。Qwen3-8B-AWQ 実践ガイドでは response_format: json_object と Zod 検証に軽く触れました。本稿はそこを徹底します。
狙いは二重の守りです。
- 生成段階で“不正なJSONを作れなくする”(vLLM の構造化出力 / guided decoding)。
- アプリ境界で“それでも信用せず検証する”(Zod)。
そして、両者を 1つの Zod スキーマで駆動し、スキーマの二重管理をなくします(DRY)。これが、思考するLLMの出力を型安全なデータとしてアプリに流し込む、世界最高峰の作法です。
信頼性の開示:本稿の vLLM 構造化出力 API・思考モードとの併用は、vLLM 公式(Structured Outputs)・Qwen3-8B-AWQ モデルカードに基づきます。vLLM はバージョンで API 名が変わるため、お使いの版で必ず確認してください(後述)。
30秒の結論
| 層 | 何をする | 道具 | 守る失敗 |
|---|---|---|---|
| 生成制約 | 不正なJSONを生成不能にする | vLLM 構造化出力(xgrammar) | 文法崩れ・前置き・フェンス |
| 境界検証 | 出力を信用せず型に通す | Zod parse | 打ち切り・拒否・版差・型ズレ |
| 真実源 | スキーマを1つに | Zod → JSON Schema | スキーマの二重管理 |
本質:構造化出力(制約)は「ほぼ正しいJSON」を作るが、100%ではない(打ち切り・拒否・実装差)。だから境界の Zod 検証は省けません。制約は“事故を減らす”、検証は“事故を止める”。両方やる。
なぜ json_object だけでは足りないのか
response_format: {type: "json_object"} は「JSONとして妥当な文字列」を促すだけで、スキーマ(どんなキー・型か)は強制しません。{"foo": 1} も妥当なJSONです。本番で欲しいのは「このスキーマに従ったJSON」。それを保証するのが guided decoding(構造化出力)——デコード時にスキーマが許すトークンしか出させない仕組みです。
サーバ:構造化出力+思考パースを有効化
vLLM は構造化出力を標準搭載(既定バックエンドは xgrammar)。思考モデルと併用するときは、思考は自由・最終回答だけ制約が原則です。
# Qwen3-8B-AWQ:思考パース+構造化出力。最終回答にだけスキーマ制約がかかる
vllm serve Qwen/Qwen3-8B-AWQ \
--reasoning-parser qwen3 \
--max-model-len 32768 \
--port 8000
# 思考の途中まで制約をかけたい高度な場合のみ:
# --structured-outputs-config.enable_in_reasoning=True(既定はオフ=思考は自由)
🔧 バージョン差に注意:vLLM は構造化出力の引数名を更新しています。OpenAI互換の
response_format: json_schemaが最も移植性が高いので、本稿はこれを主役にします。vLLMネイティブ指定は、新しい版ではextra_body={"structured_outputs": {"json": schema}}、古い版ではextra_body={"guided_json": schema}です。お使いの vLLM のドキュメントで確認してください。
クライアント:1つのZodスキーマを真実源にする
ここが設計の核心です。Zod スキーマを1つだけ書き、そこから「vLLM に渡す JSON Schema(制約用)」と「応答を検証する parser」の両方を導きます。スキーマを二重に書かない=ズレない(DRY)。
// lib/structured.ts — 1つのZodスキーマで「制約」も「検証」も賄う
import OpenAI from "openai";
import { z } from "zod";
const client = new OpenAI({ baseURL: process.env.QWEN_BASE_URL, apiKey: "internal", timeout: 60_000 });
/** 抽出したい構造(これが唯一の真実源)。説明はモデルへのヒントにもなる。 */
export const Invoice = z.object({
vendor: z.string().min(1).describe("請求元の会社名"),
total: z.number().nonnegative().describe("税込合計(円)"),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("支払期日 YYYY-MM-DD"),
currency: z.enum(["JPY", "USD", "EUR"]),
});
export type Invoice = z.infer<typeof Invoice>;
// Zod 4: z.toJSONSchema(Invoice) が標準。Zod 3 なら zod-to-json-schema を使う。
const invoiceJsonSchema = z.toJSONSchema(Invoice);
/** 非構造のテキストから請求書情報を“型”として取り出す。 */
export async function extractInvoice(text: string): Promise<Invoice> {
const resp = await client.chat.completions.create({
model: "Qwen/Qwen3-8B-AWQ",
messages: [
{ role: "system", content: "請求書テキストから指定スキーマのJSONだけを返す。推測で埋めない。" },
{ role: "user", content: text },
],
temperature: 0.7, top_p: 0.8, // 抽出は非思考モードで十分(速い・安い)
// ① 生成制約:このスキーマに従うJSONしか生成させない(OpenAI互換・移植性◎)
response_format: {
type: "json_schema",
json_schema: { name: "Invoice", schema: invoiceJsonSchema, strict: true },
},
presence_penalty: 1.5, // 量子化モデルの繰り返し対策(OpenAI互換の標準パラメータ)
// vLLM拡張はトップレベルで送る。Node SDKは未知キーも本文へ転送し、spreadなので型エラーも出ない。
// ※ Python SDK の extra_body は TS SDK には存在しないので使わない。
...{ top_k: 20, chat_template_kwargs: { enable_thinking: false } },
});
const raw = resp.choices[0]?.message.content ?? "";
// ② 境界検証:制約しても信用しない。版差・打ち切りはここで止める。
return Invoice.parse(JSON.parse(raw));
}
同じ Invoice から、z.toJSONSchema が vLLM への制約スキーマを生成し、Invoice.parse が応答を検証します。スキーマを足せば両方が自動で追従——これが ETC(Easy To Change)です。
それでも壊れる時:修復ループ(defense in depth)
構造化出力は事故を減らすが、ゼロにはしません。max_tokens 不足で途中で切れる、安全上の理由で拒否される、実装差で稀に崩れる——。そこで、Zod の parse 失敗を検知して一度だけ修復を試みる、回復性のある関数にします。
// lib/structured-safe.ts — 検証失敗を“正常系”として吸収する(最大1回リトライ)
import { z } from "zod";
export type Parsed<T> = { ok: true; data: T } | { ok: false; error: string };
/** schema に通るまで最大2回。2回目は失敗内容をモデルに渡して自己修復させる。 */
export async function generateValidated<T>(
schema: z.ZodType<T>,
run: (repairHint?: string) => Promise<string>,
): Promise<Parsed<T>> {
for (let attempt = 0; attempt < 2; attempt++) {
const raw = await run(attempt === 0 ? undefined : "前回の出力はスキーマ不一致。キーと型を厳密に直して再出力。");
const result = schema.safeParse(safeJson(raw));
if (result.success) return { ok: true, data: result.data };
// 失敗はログへ(PIIは載せない。スキーマ名・エラー要約・attemptだけ)
logValidationFailure({ attempt, issues: result.error.issues.length });
}
return { ok: false, error: "schema validation failed after retry" };
}
const safeJson = (s: string): unknown => {
try { return JSON.parse(s); } catch { return null; } // null は確実に parse 失敗 → 上で握る
};
declare function logValidationFailure(meta: { attempt: number; issues: number }): void;
safeParse で例外を制御フローにしない、失敗をメタデータだけログする(可観測性・PII非出力)、修復は有限回で打ち切る——回復性・可観測性・KISS が同居します。
思考モードと構造化出力の併用
Qwen3 の強みは「考えてから答える」。構造化出力と併用するときの鉄則は 「思考は自由、最終回答だけスキーマ制約」。
- サーバは
--reasoning-parser qwen3で<think>…</think>を本文と分離。スキーマ制約は分離後の最終回答にかかる(思考の文章まで JSON に強制すると推論が壊れる)。 - アプリは
content(最終JSON)だけをZod.parse。reasoning_content(思考過程)は検証対象に含めず、監査・評価ログへ。
const msg = resp.choices[0]?.message;
const answer = Invoice.parse(JSON.parse(msg?.content ?? "{}")); // 検証するのは content だけ
auditLog({ reasoning: (msg as { reasoning_content?: string })?.reasoning_content }); // 思考は監査用
💡 使い分け:抽出・分類・整形のような型が主役のタスクは非思考+構造化出力が最速・最安。複雑な判断を含む構造化(例:根拠付きのリスク判定)だけ思考モード+構造化出力にする。難度でモードを振るのは原価設計の本丸です。
ハマりどころ & ベストプラクティス
- 🔴 構造化出力でも境界検証を省かない。打ち切り・拒否で壊れ得る。
json_object単体は論外、json_schemaでも Zod は通す。 - 🟠
max_tokensを絞りすぎない。JSONが長いと途中で切れて不正になる。スキーマの最大サイズを見込む。 - 🟠 スキーマは1つに(DRY)。Zod → JSON Schema を自動生成し、手書きの二重定義を作らない。ズレは必ず事故になる。
- 🟠
describe()をヒントに使う。各フィールドの説明はモデルの埋め方を安定させる。ただし推測で埋めさせない指示を system に明記。 - 🟢 enum / union は構造化出力の得意分野。「positive | negative」のような選択は
z.enumで確実に。曖昧な自由文字列を避ける。 - 🟢 バージョン差は
response_format: json_schemaで吸収。vLLM ネイティブの引数名(structured_outputs/guided_json)は版に依存する。
よくある質問(FAQ)
Q. json_object と json_schema、どっち?
A. 常に json_schema。json_object は「妥当なJSON」までしか保証せず、キーや型は自由。スキーマ準拠が欲しいなら json_schema(guided decoding)一択です。
Q. 構造化出力を使えば Zod 検証は不要? A. いいえ。制約は事故を減らすだけで、打ち切り・拒否・実装差では崩れます。境界の Zod 検証は必須。制約=予防、検証=停止。両方やります。
Q. 思考モードでも構造化出力できる?
A. できます。--reasoning-parser qwen3 と併用し、スキーマ制約は最終回答にだけかけます。content を検証、reasoning_content は監査用に分離します。
Q. xgrammar / outlines などバックエンドは選ぶべき?
A. 既定の xgrammar で多くの場合十分です。特殊な文法(正規表現・CFG)が要る時だけ --structured-outputs-config.backend で切り替えます。まずは既定で計測を。
Q. Pydantic(Python)でも同じ設計?
A. はい。Model.model_json_schema() を response_format.json_schema に渡し、応答を Model.model_validate_json() で検証。1モデルを真実源にする思想は同じです(Pydantic v2 の境界検証)。
まとめ
“思考するLLM”の出力を本番データにするには、制約と検証の二重の守りを、1つのスキーマで駆動します。
- 生成制約:vLLM の構造化出力(
response_format: json_schema)で不正JSONを生成不能に。 - 境界検証:それでも信用せず Zod
parse。打ち切り・拒否・版差を止める。 - 真実源を1つに:Zod → JSON Schema を自動生成(DRY・ETC)。
- 思考は自由・回答だけ制約:
contentを検証、reasoning_contentは監査へ。 - 修復ループ+可観測性で回復性を担保(有限回・メタデータのみログ)。
自前LLMの構造化出力を、スキーマ設計・guided decoding・境界検証・修復まで含めて型安全に作り込みます。AI基盤の実績をご覧のうえご相談ください。一人 × 生成AIで、速く・安く・安全に。
出典・公式リソース
- vLLM 公式(Structured Outputs) — guided decoding / response_format / backends
- Qwen3-8B-AWQ モデルカード — 思考モードとサンプリング
- Zod 公式 — スキーマ・
toJSONSchema・safeParse - xgrammar — vLLM 既定の構造化出力エンジン
※ vLLM の構造化出力 API は版で変わります。実装前に一次情報とお使いの版で必ず確認してください。