最初に結論を述べます。LLMの構造化出力(JSON)で「制約付きデコード(guided / constrained decoding)を使えば安全」というのは誤解です。制約付きデコードが保証するのは『構文的に妥当なJSON』であって、『意味的に正しい値』ではありません。 スキーマに合致していても、値が業務的に間違っている——税率が存在しない値、合計が明細と合わない、必須の根拠が空——という出力は、平然と生成されます。制約付きデコードを入れると、構文エラーはほぼ消えますが、失敗は消えるのではなく、形を変えて『妥当だが誤った出力』や『拒否・退化』へ移るのです。本番では、構文と意味を分けて検証し、修復リトライとフォールバックで固める設計が必要です。
本記事は、私が生成AIの構造化出力(考査の観点別リスク判定など)を本番運用した経験をもとに、構造化出力を実用品質にする設計を、実装付きで解説します。vLLMでの構造化出力の実装方法そのものはQwen3の構造化出力(guided decoding × Zod)を参照してください。
1. 「妥当なJSON」と「正しい値」は別物
まず、この区別を腹落ちさせてください。構造化出力には2つの正しさがあります。
| 構文(Syntax) | 意味(Semantics) | |
|---|---|---|
| 何を保証するか | JSONの形・型・必須フィールドが正しい | 値が業務的に正しい |
| 誰が保証するか | 制約付きデコード(guided decoding)が保証できる | 誰も自動では保証しない——自分で検証する |
| 失敗の例 | {"total": "abc"}(数値であるべき所が文字列) | {"total": 9999}(型は正しいが、明細と合わない) |
制約付きデコードは、モデルの出力をスキーマ(JSON Schema / 文法)に沿うよう生成段階で拘束します。これにより、「型が違う」「閉じ括弧がない」といった構文エラーは劇的に減ります。報告では、プロンプトのみのJSON生成が数%〜十数%失敗するのに対し、制約付きデコードは構文失敗をごくわずか(0.1%未満とも)に抑えるとされます。
しかし——ここが本質です——構文が保証されても、意味は保証されません。「税率は0/8/10%のいずれか」「合計は明細の積み上げと一致する」「根拠フィールドは空でない」といった業務ルールは、スキーマの型だけでは表現しきれない。制約付きデコードを入れて安心していると、「形は完璧だが中身が間違っている」出力を、検証なしで下流に流してしまいます。
2. 失敗は消えるのではなく、形を変える
制約付きデコードを導入すると、失敗のモードが移動します。これを理解しておかないと、「構文エラーが消えたから安全になった」と誤認します。
| 制約付きデコード導入で… | 失敗の移動先 |
|---|---|
| 構文エラー(壊れたJSON)は ほぼ消える | → スキーマに合致するが値が誤っている(最も危険:気づけない) |
| → 拒否・退化(過度な制約でモデルが空・定型・無意味な出力を返す) | |
| → 品質低下(文法に縛られ、本来出せたはずの良い回答が削られる) |
特に厄介なのが2番目の「拒否・退化」です。スキーマで強く縛りすぎると、モデルが本来持っている推論力を発揮できず、「とりあえずスキーマを満たす無難な値」を返すことがあります。制約は『構文の保証』と引き換えに『意味の質』を犠牲にしうる——このトレードオフを設計で扱う必要があります。
3. 本番設計①:構文と意味を分けて検証する
解は明快です。構文(スキーマ)と意味(業務ルール)を、2層で検証する。TypeScript なら Zod の superRefine で業務ルールを表現できます。
import { z } from "zod";
// 第1層(構文): 形・型・必須。制約付きデコードが守ってくれる領域。
const InvoiceLineSchema = z
.object({
description: z.string().min(1),
quantity: z.number().int().positive(),
unitPriceJpy: z.number().int().nonnegative(),
})
.strict(); // 余計なキーを許さない(プロンプトインジェクション由来の混入を弾く)
// 第2層(意味): 業務ルール。制約付きデコードでは守られない——ここを自分で検証する。
const InvoiceSchema = z
.object({
lines: z.array(InvoiceLineSchema).min(1),
taxRate: z.number(),
totalJpy: z.number().int(),
})
.strict()
.superRefine((inv, ctx) => {
// ルール1: 税率は 0 / 8% / 10% のいずれか(型は number だが、値は限定)
if (![0, 0.08, 0.1].includes(inv.taxRate)) {
ctx.addIssue({ code: "custom", message: "tax rate must be 0, 0.08, or 0.1", path: ["taxRate"] });
}
// ルール2: 合計は明細の積み上げ+税と一致する(LLMの算術を信用しない)
const subtotal = inv.lines.reduce((s, l) => s + l.quantity * l.unitPriceJpy, 0);
const expected = Math.round(subtotal * (1 + inv.taxRate));
if (inv.totalJpy !== expected) {
ctx.addIssue({
code: "custom",
message: `total mismatch: got ${inv.totalJpy}, expected ${expected}`,
path: ["totalJpy"],
});
}
});
export type Invoice = z.infer<typeof InvoiceSchema>;
.strict() で余計なキーを弾き、superRefine で「型では表現できない業務ルール」を検証します。LLMに算術や業務判断をさせて、その結果をそのまま信用しない——これは決済で金額をサーバ側で再計算するのと同じ思想です。LLMは「判断の候補」を出すが、「正しさ」はコードで確定させる。
4. 本番設計②:修復リトライとフォールバック
検証に失敗したとき、ただエラーにするのではなく、失敗理由をLLMにフィードバックして修復リトライし、それでも駄目ならフォールバックで安全側に倒す。この回復性が本番品質を分けます。
type Outcome<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly reason: "exhausted" };
interface ExtractDeps<T> {
/** LLM呼び出し(制約付きデコード)。前回の失敗理由を修復ヒントとして渡せる。 */
readonly call: (repairHint?: string) => Promise<unknown>;
readonly schema: z.ZodType<T>;
readonly maxAttempts: number;
/** 失敗を構造化ログへ。どのフィールドがなぜ落ちたかを観測可能にする。 */
readonly onFailure: (e: { attempt: number; issues: string }) => void;
}
/**
* 構文+意味を検証し、失敗時は理由をフィードバックして修復リトライ。
* 枯渇したら Outcome.ok=false を返し、呼び出し側が安全側(人手確認・既定値)に倒す。
* 例外を制御フローに使わず、結果を型で表す(テスト容易性・回復性)。
*/
export async function extractStructured<T>(deps: ExtractDeps<T>): Promise<Outcome<T>> {
let hint: string | undefined;
for (let attempt = 1; attempt <= deps.maxAttempts; attempt++) {
const raw = await deps.call(hint);
const parsed = deps.schema.safeParse(raw);
if (parsed.success) return { ok: true, value: parsed.data };
// 失敗フィールドと理由を集約し、次の試行へ具体的な修復指示として渡す
hint = parsed.error.issues
.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
.join("; ");
deps.onFailure({ attempt, issues: hint });
}
return { ok: false, reason: "exhausted" };
}
この設計の要点は3つです。
- 例外を制御フローに使わない——成否を
Outcome<T>型で表し、呼び出し側が「枯渇時にどうするか」を必ず扱うよう型で強制する。 - 修復リトライ——「どのフィールドがなぜ駄目か」をLLMに返すことで、闇雲な再試行ではなく、的を絞った修復をさせる。
- フォールバック——リトライが枯渇したら、人手確認に回す・安全な既定値を使う・処理を縮退させる、と安全側に倒す。AI動画パイプラインでも、TTS失敗率が閾値を超えたら処理を中断し、無音挿入でグレースフルに退化させる定量ゲートを設けています。
5. 本番設計③:失敗を観測可能にする
最後に、失敗を測れなければ改善できません。「どのフィールドが、どの頻度で、どんな理由で検証に落ちたか」を構造化ログで観測可能にします。これにより、
- スキーマや業務ルールが厳しすぎる(モデルが満たせない)箇所が見える。
- プロンプトを改善すべき箇所が特定できる。
- 「制約を強めたら拒否・退化が増えた」というトレードオフを数字で判断できる。
私が放送局向けに構築した生成AIの考査支援では、グラウンディング由来の引用を出力に付与し、判定の根拠を追跡可能にしました。構造化出力に「根拠」を含めて検証・記録することで、誤った判定の原因究明と、品質の継続的な改善が回ります。構造化出力の信頼性は、スキーマだけでなく、この観測と改善のループまで含めて初めて担保されます。
よくある質問(FAQ)
Q. 制約付きデコード(guided decoding)を使えば、JSON出力は安全ですか?
構文的には安全になりますが、意味的には安全になりません。制約付きデコードは「JSONとして妥当な形」を保証しますが、「値が業務的に正しいか」は保証しません。税率が存在しない値、合計が明細と合わない、といったスキーマに合致する誤った出力は生成されます。スキーマ検証に加えて、業務ルールの検証が必須です。
Q. プロンプトで「JSONで返して」と指示するだけでは駄目ですか?
本番では不十分です。プロンプトのみのJSON生成は数%〜十数%の頻度で構文的に失敗すると報告されています。制約付きデコードで構文失敗はほぼ消えますが、今度は「妥当だが誤った値」や「拒否・退化」へ失敗が移ります。いずれにせよ、出力を受け取った後に構文+意味の2層で検証する設計が必要です。
Q. 業務ルールの検証は、どう実装しますか?
スキーマの型だけでは表現できない制約(値の範囲、フィールド間の整合性、算術の一致など)を、検証ロジックとして実装します。TypeScriptならZodのsuperRefine、PythonならPydanticのバリデータが使えます。重要なのは、LLMにさせた算術や判断の結果をそのまま信用せず、コードで再計算・再検証して正しさを確定させることです。
Q. 検証に失敗したら、どうすべきですか?
ただエラーにせず、失敗理由(どのフィールドがなぜ駄目か)をLLMにフィードバックして修復リトライします。それでも枯渇したら、人手確認に回す・安全な既定値を使う・処理を縮退させる、と安全側に倒すフォールバックを用意します。成否を例外ではなく型(Result/Outcome)で表し、呼び出し側が枯渇時の扱いを必ず実装するよう強制すると堅牢です。
Q. 制約を強くすれば、品質は上がりますか?
必ずしも上がりません。スキーマで強く縛りすぎると、モデルが推論力を発揮できず「とりあえずスキーマを満たす無難な値」を返す(拒否・退化)ことがあります。制約は「構文の保証」と引き換えに「意味の質」を犠牲にしうるトレードオフです。失敗モードを観測可能にし、制約の強さと出力品質のバランスを数字で調整してください。
まとめ:構文と意味を分け、検証・修復・観測で固める
LLMの構造化出力を本番品質にするために、押さえるべきは次の通りです。
- 制約付きデコードが保証するのは構文であって意味ではない——失敗は消えず、形を変える。
- 構文(スキーマ)と意味(業務ルール)を2層で検証する——Zodの
refineで業務制約を表現。 - LLMの算術・判断を信用せず、コードで再確定する——金額のサーバ側再計算と同じ思想。
- 失敗時は修復リトライ+フォールバック——理由をフィードバックし、枯渇したら安全側に倒す。
- 失敗を構造化ログで観測可能にする——測れなければ改善できない。
「LLMにJSONを出させているが、たまに変な値が来る」「構造化出力を本番で安定させたい」——その信頼性は、構文と意味の分離、修復リトライ、観測のループで担保できます。型安全な境界設計で、生成AIを本番運用品質に引き上げる実装をお引き受けします。