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

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

- 公開日: 2026-06-25
- 著者: 友田 陽大
- タグ: Qwen, vLLM, TypeScript, Zod, 型安全, 構造化出力, 生成AI
- URL: https://tomodahinata.com/blog/qwen3-structured-output-json-vllm-guided-decoding-zod

## 要点

- LLMの『それっぽいJSON』は本番で壊れる。vLLMの構造化出力(guided decoding)で文法的に不正なJSONを“生成できなく”し、さらにZodで境界検証する——制約と検証の二重の守りが要点
- 1つのZodスキーマを真実源に：z.toJSONSchemaでvLLMへ渡す制約スキーマを生成し、同じスキーマで応答をparse。スキーマの二重管理をなくす(DRY)
- APIはOpenAI互換の response_format: json_schema が最も移植性が高い。vLLMネイティブは extra_body.structured_outputs（旧 guided_json）。バックエンドは既定で xgrammar
- 思考モードと併用するときは『思考は自由・最終回答だけ制約』。--reasoning-parser qwen3 と構造化出力を併用し、reasoning_content は検証対象に含めない
- 制約しても境界検証は省かない：打ち切り(truncation)・拒否・バージョン差で壊れ得る。Zodのparse失敗を検知して修復ループ＋可観測性でカバーする

---

## この記事のゴール

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

狙いは**二重の守り**です。

1. **生成段階で“不正なJSONを作れなくする”**（vLLM の構造化出力 / guided decoding）。
2. **アプリ境界で“それでも信用せず検証する”**（Zod）。

そして、両者を **1つの Zod スキーマ**で駆動し、スキーマの二重管理をなくします（DRY）。これが、思考するLLMの出力を**型安全なデータ**としてアプリに流し込む、世界最高峰の作法です。

> **信頼性の開示**：本稿の vLLM 構造化出力 API・思考モードとの併用は、[vLLM 公式（Structured Outputs）](https://docs.vllm.ai/en/latest/features/structured_outputs.html)・[Qwen3-8B-AWQ モデルカード](https://huggingface.co/Qwen/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**）。思考モデルと併用するときは、**思考は自由・最終回答だけ制約**が原則です。

```bash
# 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）。

```ts
// 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` 失敗を検知して一度だけ修復**を試みる、回復性のある関数にします。

```ts
// 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` で**例外を制御フローにしない**、失敗を**メタデータだけログ**する（[可観測性](/blog/opentelemetry-observability-production-tracing-metrics-logs)・PII非出力）、修復は**有限回**で打ち切る——回復性・可観測性・KISS が同居します。

---

## 思考モードと構造化出力の併用

Qwen3 の強みは「考えてから答える」。構造化出力と併用するときの鉄則は **「思考は自由、最終回答だけスキーマ制約」**。

- サーバは `--reasoning-parser qwen3` で `<think>…</think>` を**本文と分離**。スキーマ制約は**分離後の最終回答**にかかる（思考の文章まで JSON に強制すると推論が壊れる）。
- アプリは `content`（最終JSON）だけを `Zod.parse`。`reasoning_content`（思考過程）は**検証対象に含めず**、監査・評価ログへ。

```ts
const msg = resp.choices[0]?.message;
const answer = Invoice.parse(JSON.parse(msg?.content ?? "{}")); // 検証するのは content だけ
auditLog({ reasoning: (msg as { reasoning_content?: string })?.reasoning_content }); // 思考は監査用
```

> 💡 **使い分け**：抽出・分類・整形のような**型が主役**のタスクは**非思考＋構造化出力**が最速・最安。**複雑な判断を含む構造化**（例：根拠付きのリスク判定）だけ**思考モード＋構造化出力**にする。難度でモードを振るのは[原価設計の本丸](/blog/llama-inference-cost-optimization-self-host-vs-api#コスト削減レバー効果の大きい順)です。

---

## ハマりどころ & ベストプラクティス

- 🔴 **構造化出力でも境界検証を省かない**。打ち切り・拒否で壊れ得る。`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 の境界検証](/blog/pydantic-v2-production-validation-type-safety)）。

---

## まとめ

“思考する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基盤の[実績](/case-studies/ai-video-localization-lipsync)をご覧のうえご相談ください。**一人 × 生成AI**で、速く・安く・安全に。

### 出典・公式リソース

- [vLLM 公式（Structured Outputs）](https://docs.vllm.ai/en/latest/features/structured_outputs.html) — guided decoding / response_format / backends
- [Qwen3-8B-AWQ モデルカード](https://huggingface.co/Qwen/Qwen3-8B-AWQ) — 思考モードとサンプリング
- [Zod 公式](https://zod.dev/) — スキーマ・`toJSONSchema`・`safeParse`
- [xgrammar](https://github.com/mlc-ai/xgrammar) — vLLM 既定の構造化出力エンジン

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