メインコンテンツへスキップ
友田 陽大
量子化LLM・セルフホスト
Qwen
vLLM
TypeScript
Zod
型安全
構造化出力
生成AI

Qwen3-8B-AWQ で型安全な構造化出力:vLLM guided decoding × Zod

自前LLMのJSON出力を“崩れない”ものにする実践ガイド。vLLMの構造化出力(guided decoding / response_format json_schema)で文法的に不正なJSONを生成不能にし、さらにZodで境界検証する二重の守り。1つのZodスキーマを真実源にvLLMへの制約とアプリの検証を両立し、思考モードとの併用や修復ループまで実コードで。

公開日
読了時間
10分
著者
友田 陽大
シェア

この記事のゴール

LLM に「JSON で返して」と頼んでも、本番では必ず崩れます。前置きの一言、```json のフェンス、末尾のカンマ、途中で切れた中括弧——。Qwen3-8B-AWQ 実践ガイドでは response_format: json_object と Zod 検証に軽く触れました。本稿はそこを徹底します。

狙いは二重の守りです。

  1. 生成段階で“不正なJSONを作れなくする”(vLLM の構造化出力 / guided decoding)。
  2. アプリ境界で“それでも信用せず検証する”(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.parsereasoning_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_objectjson_schema、どっち? A. 常に json_schemajson_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つのスキーマで駆動します。

  1. 生成制約:vLLM の構造化出力(response_format: json_schema)で不正JSONを生成不能に。
  2. 境界検証:それでも信用せず Zod parse。打ち切り・拒否・版差を止める。
  3. 真実源を1つに:Zod → JSON Schema を自動生成(DRY・ETC)。
  4. 思考は自由・回答だけ制約content を検証、reasoning_content は監査へ。
  5. 修復ループ+可観測性で回復性を担保(有限回・メタデータのみログ)。

自前LLMの構造化出力を、スキーマ設計・guided decoding・境界検証・修復まで含めて型安全に作り込みます。AI基盤の実績をご覧のうえご相談ください。一人 × 生成AIで、速く・安く・安全に。

出典・公式リソース

※ vLLM の構造化出力 API は版で変わります。実装前に一次情報とお使いの版で必ず確認してください。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

AI動画ローカライズ・リップシンク基盤

ケーススタディを見る