構造化出力と制約デコーディング — JSONを壊さない仕組み
「JSONで返して」とお願いする代わりに、文法的に許されないトークンを出す前に確率ゼロへ落とす — 制約デコーディングの仕組みを、logitマスクから語彙のインデックス化、function callingの裏側、そして「構文は守れても中身は守れない」限界まで1から解説する。
要点
- 制約デコーディングは、softmaxの前に文法が許さないトークンのlogitを負の無限大に沈め、確率をちょうど0にする。
- 文法は文字で書かれ、モデルはトークンを出すため、語彙を先にインデックス化して、マスク作成を表引きに変える。
- JSONは入れ子があり正規言語ではなく、厳密にはスタックが要る。スキーマで深さが確定していればFSMで足りる。
- 保証されるのは構文だけで、完走しなければ壊れ、値の妥当性は守られず、分布も真の条件付き分布とは一致しない。
- フィールドの並び順が効き、考える欄を先に置くだけで、同じモデル・同じプロンプトでも精度が変わる。
Efficient Guided Generation for Large Language Models
一次資料 — この記事の根拠
この解説の公開 2026-08-27
Efficient Guided Generation for Large Language ModelsarXiv:2307.09702論文ページ·PDFXGrammar: Flexible and Efficient Structured Generation Engine for Large Language ModelsarXiv:2411.15100論文ページ·PDF
「JSONで返して」とお願いしても壊れる理由
プロンプトに「必ずJSONで答えてください」と大文字で三回書いても、数百回に数回は壊れたものが返ってきます。閉じ括弧がひとつ足りない。末尾に余計なカンマが残る。Markdownのコードフェンスで丁寧に包まれてしまう。本文の前に「はい、承知しました。」という挨拶がついてくる。
原因は、生成が左から右へ1トークンずつ進み、後戻りできないことにあります。人間なら書き終えてから括弧の対応を数え直せますが、モデルにその工程はありません。「いま開いた { に対応する } を最後に出す」という規律は、学習データの中に正しいJSONが大量にあったおかげで身についた確率的な癖であって、機構的な保証ではないのです。
そして「だいたい当たる」は、本番でいちばん厄介な性質です。1000件のバッチのうち3件だけがパースに失敗し、その3件のためにリトライとアラートと当直の対応が必要になります。
比喩: 一筆書きで迷路を抜ける
生成を、鉛筆を紙から離さずに迷路を抜ける一筆書きだと考えてください。線を引いてしまった場所には戻れません。行き止まりに入ったら、そこで詰みです。
対策は原理的に二つしかありません。ひとつは、抜けたあとで正しいかを確認し、間違っていたら最初から引き直すこと。これがバリデーションとリトライで、実装は簡単ですが、成功は運任せで、失敗するたびに全部やり直しになります。
もうひとつは、行き止まりへ向かう道を、鉛筆が入る前に壁で塞いでしまうこと。これが制約デコーディング(constrained decoding、あるいはguided generation)です。壁があれば、迷路を抜けられないルートはそもそも選べません。
直感: 出せない手を、出す前に消す
モデルは各ステップで、語彙に含まれる全トークン(10万語規模)に「スコア」を付けます。この生のスコアがlogitです。ふだんはこれをsoftmaxで確率に直し、そこから1つ引いて次のトークンにします。
制約デコーディングがやることは一行で言えます。softmaxにかける前に、いま文法が許さないトークンのスコアを負の無限大に沈める。それだけです。確率がちょうど0になったトークンは、どんな温度設定でも、どんな乱数でも選ばれません。
こうして問題の質が変わります。「壊れたJSONが出る確率をどう下げるか」ではなく、「壊れたJSONは出せない」になる。この差は、運用してみるとかなり大きいものです。
仕組み: logitマスク
文法から作られるのは、各ステップごとのマスクです。語彙と同じ長さの、0と1が並んだ札だと思ってください。
はトークン に対するモデルの生スコア、 は「文法がいま を許すなら1、許さないなら0」の札、 が補正後のスコアです。 なので許されたトークンのスコアはそのまま、 なので禁止されたトークンは負の無限大まで落ちます。足し算ひとつで「そのまま」と「消滅」を切り替えているわけです。
消えた分は、残りに配り直される
補正後のスコアは、あとは通常どおりsoftmaxに通します。
は温度(大きいほど分布が平らになる調整つまみ)、 は が選ばれる確率です。 なので禁止トークンの確率はちょうど0になり、分母から消えた分は残った候補に自動的に配り直されます。もともと確率が2%しかなかった候補でも、他が全部禁止されているなら100%になります。
この「softmaxの前に を足す」という道具は、Transformerの因果マスクとまったく同じものです(Attention機構を1から理解するで扱った、未来を覗かせないための処理)。使う場所が違うだけで、発想は共通しています。
実装上の注意がひとつ。本当に -inf を代入すると、バグや文法の書き間違いで全トークンが禁止された瞬間にsoftmaxの分母が0になり、確率が nan になって静かに壊れます。多くの実装が -1e9 のような有限の大きな負数を使うのはこのためで、代わりに「許されたトークンが1つも無い」状態を明示的に検出してエラーにする必要があります。
トークンと文法は、目盛りがズレている
ここからが実装の本番です。文法はJSONの文字({、"、,)で定義されていますが、モデルが出すのはトークンであり、トークナイザが作るBPEトークンは文字の切れ目と一致しません。
実際の語彙には {" がひとつのトークンとして入っていたり、": や ", のような区切りの塊が入っていたりします。つまり「次に許される文字は " である」という文法側の情報を、「次に許されるトークンの集合」へ翻訳する工程が必要になります。
素朴にやるなら、毎ステップ語彙10万件を総当たりし、「このトークンを今の出力の末尾に繋げても文法違反にならないか」をパーサに聞きます。1トークン出すたびに10万回のパース試行。これでは制約の分だけ生成が遅くなり、速度が売り物の推論サーバーでは使い物になりません。
さらにUTF-8の事情が重なります。日本語の1文字は複数バイトで構成され、トークンの境界がバイト列の途中に来ることがあります。文字単位でオートマトンを回していると、途中で切れたバイトが「不正な文字」に見えて止まってしまう。実装が文字ではなくバイト単位で状態を進めるのはこのためです。
語彙を先にインデックス化する
この総当たりを消す方法を示したのが Outlines の論文(arXiv:2307.09702)です。アイデアは素直で、生成ループの外に計算を追い出す。
- 正規表現やスキーマを有限オートマトン(FSM)にコンパイルする
- 語彙の各トークンについて、「そのトークンを読めるFSM状態はどれか」を全部調べておく
- 結果を「状態 → 許されるトークン集合」の索引として保存する
生成時にやるのは、いまの状態でこの索引を引くだけです。語彙サイズに比例した走査が生成ループから消え、マスク作成が表引きになります。
コストは消えたのではなく、コンパイル時に移動しただけです。新しいスキーマの初回だけ索引の構築が走ります。だから実務ではスキーマと索引をキャッシュするのが必須で、リクエストごとにスキーマを動的に組み立てて渡すと、そのコンパイルが毎回ボトルネックとして現れます。
JSONは正規言語ではない
FSMだけでは足りない場面があります。JSONは入れ子が原理的に無制限で、いま } を出してよいかどうかは「これまでにいくつ { を開いたか」に依存します。開いた数を数える仕事は、有限個の状態では覚えきれません。JSONは正規言語ではなく文脈自由言語であり、厳密に扱うにはスタックを持つオートマトン(下推しオートマトン)が要ります。
XGrammar(arXiv:2411.15100)はこの点を正面から扱った推論エンジン向けの実装で、スタックを保持しながら、語彙のトークンを「文脈に依存せず判定できるもの」と「その場で確かめるしかないもの」に切り分けて処理します。
実務的な切り分けとしては、こう覚えておくと速いです。{"name": string, "tags": string[]} のようにスキーマで入れ子の深さが確定しているなら、状態を有限に畳めるのでFSMで足ります。再帰的な $ref(木構造やコメントスレッドのような自己参照)を入れた瞬間に、本物の文脈自由文法が必要になります。
コードで書く
制約付き生成ループの骨格は、これだけです。
def constrained_generate(model, grammar, max_tokens=256):
state, out = grammar.start(), []
for _ in range(max_tokens):
logits = model.next_logits(out) # (vocab,) 生スコア
allowed = grammar.allowed_tokens(state) # 事前索引からの表引き
mask = np.full_like(logits, -np.inf)
mask[allowed] = 0.0
tok = sample(logits + mask) # softmax は sample の中
if tok == EOS:
if not grammar.is_accepting(state):
continue # 閉じ括弧が足りない間は止まらせない
return out
state = grammar.step(state, tok)
out.append(tok)
raise Truncated(out) # ← 壊れたJSONが出る唯一の経路
注目すべきは末尾の数行です。EOS(終了トークン)も文法の対象で、まだ閉じ括弧が残っている状態ではEOSをマスクして、モデルを途中で止まらせません。逆に言えば、max_tokens に当たったときだけは、途中で切れた文字列がそのまま返ります。
制約デコーディングが保証するのは「完走すれば正しい」であって、「必ず完走する」ではない。これが後述する事故の第一位です。
function callingの裏側
ツール呼び出し(function calling)は、この機構の上に乗った薄い層です。
- ツールのスキーマ(
input_schema/parameters)を、モデルが読める形にしてプロンプトへ入れる - モデルは「ツールを呼ぶ」ことを示す特別なトークンや書式を出すよう、あらかじめ追加学習されている
- 引数を生成している区間だけ、そのスキーマから作った文法でマスクをかける
- 出てきた文字列をパースし、スキーマで再検証してからアプリへ渡す
厳密モード(strict系のフラグ)のオン・オフは、実質的に3をやるかどうかの差です。オフなら頼りは2の学習だけで、たいてい合いますが保証はありません。
厳密モードを使うときに「全プロパティを required に」「additionalProperties: false に」といった制限がつく実装があるのは、文法を作る側の都合です。省略可能なキーは「あってもなくてもよい」という分岐を生み、キーの数だけ組み合わせが増えて文法が急激に膨らみます。制限は意地悪ではなく、索引を現実的なサイズに保つための線引きです。
構文は守れても、中身は守れない
いちばん多い誤解がここです。マスクが保証するのはパースできることだけです。{"age": 999999} も {"city": "存在しない市"} も、文法的には完璧に正しいJSONです。
選択肢が有限のもの(enum)は、選択肢そのものを文法に書けるのでマスクで守れます。一方、数値の範囲(minimum / maximum)や文字列長のように正規文法で表現しにくい制約は、実装によっては黙って無視されます。受け取った後にバリデータを通す工程を、制約デコーディングを入れたからといって外してはいけません。
もうひとつ、統計的にもう少し深い問題があります。本当に欲しいのは「文法が受理する文字列の集合 で条件付けた分布」のはずです。
は出力文字列、 は文法が受理する文字列全体、 はモデルの確率です。言い換えれば「モデルが本来出したかった確率を、有効な出力だけに絞って、比率を保ったまま配り直したもの」。
ところが逐次マスクが実際に作るのは、各ステップで局所的に正規化した積です。
は「そこまでの出力に対して文法が許すトークンの集合」、 は 番目のトークンです。こちらは「各ステップで、許された候補だけの中での比率に直し、それを掛け合わせたもの」。
分母がステップごとに、しかも通った経路ごとに違うため、 と は一般に一致しません。厳密に揃えるには「その先にどれだけ有効な完成形が残っているか」で各候補を重み付けする必要があり、それは未来を全部数え上げる作業になります。実務では諦めて を使いますが、制約は「モデルの考えを絞り込む」だけでなく、分布そのものを歪めるという自覚は持っておくべきです。
歪みが表面化するのは、スキーマがモデルの書き癖と喧嘩したときです。実用上いちばん効く対策はフィールドの並び順で、多くの実装はスキーマの宣言順に生成するので、生成が左から右である以上、後ろのフィールドは前のフィールドを見てから決まります。{"answer": ..., "reason": ...} は結論を先に固定させる形で、{"reason": ..., "answer": ...} は考えてから答えさせる形です。考える欄を先に置くだけで、同じモデル・同じプロンプトでも精度が変わります。
現場ではこう使う
誰がいつ使うか。 LLMをパイプラインに組み込むアプリ/バックエンドのエンジニア、抽出バッチを回すデータエンジニア、エージェント基盤の担当者。判断基準は単純で、出力を次に読むのが人間か機械かです。DBに入れる、外部APIを叩く、UIコンポーネントに流し込む — 機械が読むなら制約をかける。人が読む文章なら要りません。
触るもの。
- API側: JSON Schemaを渡す
response_formatの厳密モード、ツール定義のinput_schema/parameters - セルフホスト: vLLMの構造化出力オプション(バックエンドに outlines / xgrammar などを選べる)、llama.cppのGBNF文法(
--grammar-file、スキーマからの変換はjson_schema_to_grammar.py) - ライブラリ: Outlines、Guidance、lm-format-enforcer
- 共通で必ず見る値:
max_tokens、スキーマのキャッシュ有無
事故になる落とし穴(現場で踏む頻度の高い順)。
max_tokensで切れる。 制約下でも途中で止まれば壊れたJSONです。スキーマから最大長を見積もって余裕を取り、切れたときは「切れた」と分かるエラーにすること。パース失敗を一律に「モデルが馬鹿」として片づけると、この原因は永遠に見つかりません- 空白の暴走。 JSON文法は要素の間に空白をいくらでも許すので、モデルが空白や改行を延々と出し続けるループに入ることがあります。文法側で空白を0〜1個に絞ってしまうのが確実です
- スキーマのコンパイルコスト。 動的に組み立てたスキーマを毎回渡すと、初回コンパイルが毎リクエスト走ります。スキーマは定数化してキャッシュに載せる
- 未対応キーワードの黙殺。
minimum、pattern、oneOf、再帰$refは実装によって無視されるかエラーになります。受け取り後の検証は外さない - フィールド順。 結論を先頭に置いたスキーマは、モデルから考える余地を奪います
- リトライとの併用。 制約デコーディングは構文の保証、リトライとバリデーションは意味の保証。役割が違うので、片方だけでは足りません
レビューでよく問われる筋。 「JSONで返させたいなら、正規表現で後処理すればいいのでは?」— 後処理はすでに出てしまったものを直す作業です。壊れ方は無限にあり、直した結果がモデルの意図と一致する保証もありません。デコード時のマスクは、壊れた出力の確率をそもそも0にします。事後修復と事前禁止では、保証の強さが根本的に違う、というのが答えの筋です。運用面のコストや同時実行数への影響はLLMサービングの実際側の話題になります。
まとめ
- 制約デコーディングの実体は、softmaxの前にlogitへ を足す操作。禁止トークンの確率をちょうど0にする
- 難所は、文法が文字で書かれ、モデルがトークンを出すというズレ。語彙を事前にインデックス化して、生成ループを表引きに変える
- JSONは入れ子があるので正規言語ではない。厳密にはスタックが要り、深さが有限に畳めるならFSMで足りる
- 保証されるのは構文だけ。完走しなければ壊れるし、値の妥当性は守られず、分布も真の条件付き分布とは一致しない
コメント
コメントにはログインが必要です