体系 › 推論・高速化
·無料·論文·15分で読めます

構造化出力と制約デコーディング — JSONを壊さない仕組み

「JSONで返して」とお願いする代わりに、文法的に許されないトークンを出す前に確率ゼロへ落とす — 制約デコーディングの仕組みを、logitマスクから語彙のインデックス化、function callingの裏側、そして「構文は守れても中身は守れない」限界まで1から解説する。

要点

  • 制約デコーディングは、softmaxの前に文法が許さないトークンのlogitを負の無限大に沈め、確率をちょうど0にする。
  • 文法は文字で書かれ、モデルはトークンを出すため、語彙を先にインデックス化して、マスク作成を表引きに変える。
  • JSONは入れ子があり正規言語ではなく、厳密にはスタックが要る。スキーマで深さが確定していればFSMで足りる。
  • 保証されるのは構文だけで、完走しなければ壊れ、値の妥当性は守られず、分布も真の条件付き分布とは一致しない。
  • フィールドの並び順が効き、考える欄を先に置くだけで、同じモデル・同じプロンプトでも精度が変わる。
対象textタスクinference

Efficient Guided Generation for Large Language Models


「JSONで返して」とお願いしても壊れる理由

プロンプトに「必ずJSONで答えてください」と大文字で三回書いても、数百回に数回は壊れたものが返ってきます。閉じ括弧がひとつ足りない。末尾に余計なカンマが残る。Markdownのコードフェンスで丁寧に包まれてしまう。本文の前に「はい、承知しました。」という挨拶がついてくる。

原因は、生成が左から右へ1トークンずつ進み、後戻りできないことにあります。人間なら書き終えてから括弧の対応を数え直せますが、モデルにその工程はありません。「いま開いた { に対応する } を最後に出す」という規律は、学習データの中に正しいJSONが大量にあったおかげで身についた確率的な癖であって、機構的な保証ではないのです。

そして「だいたい当たる」は、本番でいちばん厄介な性質です。1000件のバッチのうち3件だけがパースに失敗し、その3件のためにリトライとアラートと当直の対応が必要になります。

比喩: 一筆書きで迷路を抜ける

生成を、鉛筆を紙から離さずに迷路を抜ける一筆書きだと考えてください。線を引いてしまった場所には戻れません。行き止まりに入ったら、そこで詰みです。

対策は原理的に二つしかありません。ひとつは、抜けたあとで正しいかを確認し、間違っていたら最初から引き直すこと。これがバリデーションとリトライで、実装は簡単ですが、成功は運任せで、失敗するたびに全部やり直しになります。

もうひとつは、行き止まりへ向かう道を、鉛筆が入る前に壁で塞いでしまうこと。これが制約デコーディング(constrained decoding、あるいはguided generation)です。壁があれば、迷路を抜けられないルートはそもそも選べません。

直感: 出せない手を、出す前に消す

モデルは各ステップで、語彙に含まれる全トークン(10万語規模)に「スコア」を付けます。この生のスコアがlogitです。ふだんはこれをsoftmaxで確率に直し、そこから1つ引いて次のトークンにします。

制約デコーディングがやることは一行で言えます。softmaxにかける前に、いま文法が許さないトークンのスコアを負の無限大に沈める。それだけです。確率がちょうど0になったトークンは、どんな温度設定でも、どんな乱数でも選ばれません。

こうして問題の質が変わります。「壊れたJSONが出る確率をどう下げるか」ではなく、「壊れたJSONは出せない」になる。この差は、運用してみるとかなり大きいものです。

仕組み: logitマスク

文法から作られるのは、各ステップごとのマスクです。語彙と同じ長さの、0と1が並んだ札だと思ってください。

z~v=zv+log⁡mv,mv∈{0,1}\tilde{z}_v = z_v + \log m_v, \qquad m_v \in \{0, 1\}
(1)

zvz_v はトークン vv に対するモデルの生スコア、mvm_v は「文法がいま vv を許すなら1、許さないなら0」の札、z~v\tilde{z}_v が補正後のスコアです。log⁡1=0\log 1 = 0 なので許されたトークンのスコアはそのまま、log⁡0=−∞\log 0 = -\infty なので禁止されたトークンは負の無限大まで落ちます。足し算ひとつで「そのまま」と「消滅」を切り替えているわけです。

FIG 1棒の1本1本が次トークンの候補。マスクは、この図の何本かを高さゼロに落として、残りだけで100%を配り直す操作にあたる。温度スライダーで変わるのは「残った候補の中での尖り方」だけで、消えた候補は温度をいくら上げても復活しない

消えた分は、残りに配り直される

補正後のスコアは、あとは通常どおりsoftmaxに通します。

pv=exp⁡(z~v/T)∑uexp⁡(z~u/T)p_v = \frac{\exp(\tilde{z}_v / T)}{\sum_u \exp(\tilde{z}_u / T)}

TT は温度(大きいほど分布が平らになる調整つまみ)、pvp_v は vv が選ばれる確率です。e−∞=0e^{-\infty} = 0 なので禁止トークンの確率はちょうど0になり、分母から消えた分は残った候補に自動的に配り直されます。もともと確率が2%しかなかった候補でも、他が全部禁止されているなら100%になります。

この「softmaxの前に −∞-\infty を足す」という道具は、Transformerの因果マスクとまったく同じものです(Attention機構を1から理解するで扱った、未来を覗かせないための処理)。使う場所が違うだけで、発想は共通しています。

実装上の注意がひとつ。本当に -inf を代入すると、バグや文法の書き間違いで全トークンが禁止された瞬間にsoftmaxの分母が0になり、確率が nan になって静かに壊れます。多くの実装が -1e9 のような有限の大きな負数を使うのはこのためで、代わりに「許されたトークンが1つも無い」状態を明示的に検出してエラーにする必要があります。

トークンと文法は、目盛りがズレている

ここからが実装の本番です。文法はJSONの文字({、"、,)で定義されていますが、モデルが出すのはトークンであり、トークナイザが作るBPEトークンは文字の切れ目と一致しません。

実際の語彙には {" がひとつのトークンとして入っていたり、": や ", のような区切りの塊が入っていたりします。つまり「次に許される文字は " である」という文法側の情報を、「次に許されるトークンの集合」へ翻訳する工程が必要になります。

素朴にやるなら、毎ステップ語彙10万件を総当たりし、「このトークンを今の出力の末尾に繋げても文法違反にならないか」をパーサに聞きます。1トークン出すたびに10万回のパース試行。これでは制約の分だけ生成が遅くなり、速度が売り物の推論サーバーでは使い物になりません。

さらにUTF-8の事情が重なります。日本語の1文字は複数バイトで構成され、トークンの境界がバイト列の途中に来ることがあります。文字単位でオートマトンを回していると、途中で切れたバイトが「不正な文字」に見えて止まってしまう。実装が文字ではなくバイト単位で状態を進めるのはこのためです。

語彙を先にインデックス化する

この総当たりを消す方法を示したのが Outlines の論文(arXiv:2307.09702)です。アイデアは素直で、生成ループの外に計算を追い出す。

  1. 正規表現やスキーマを有限オートマトン(FSM)にコンパイルする
  2. 語彙の各トークンについて、「そのトークンを読めるFSM状態はどれか」を全部調べておく
  3. 結果を「状態 → 許されるトークン集合」の索引として保存する

生成時にやるのは、いまの状態でこの索引を引くだけです。語彙サイズに比例した走査が生成ループから消え、マスク作成が表引きになります。

コストは消えたのではなく、コンパイル時に移動しただけです。新しいスキーマの初回だけ索引の構築が走ります。だから実務ではスキーマと索引をキャッシュするのが必須で、リクエストごとにスキーマを動的に組み立てて渡すと、そのコンパイルが毎回ボトルネックとして現れます。

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)は、この機構の上に乗った薄い層です。

  1. ツールのスキーマ(input_schema / parameters)を、モデルが読める形にしてプロンプトへ入れる
  2. モデルは「ツールを呼ぶ」ことを示す特別なトークンや書式を出すよう、あらかじめ追加学習されている
  3. 引数を生成している区間だけ、そのスキーマから作った文法でマスクをかける
  4. 出てきた文字列をパースし、スキーマで再検証してからアプリへ渡す

厳密モード(strict系のフラグ)のオン・オフは、実質的に3をやるかどうかの差です。オフなら頼りは2の学習だけで、たいてい合いますが保証はありません。

厳密モードを使うときに「全プロパティを required に」「additionalProperties: false に」といった制限がつく実装があるのは、文法を作る側の都合です。省略可能なキーは「あってもなくてもよい」という分岐を生み、キーの数だけ組み合わせが増えて文法が急激に膨らみます。制限は意地悪ではなく、索引を現実的なサイズに保つための線引きです。

構文は守れても、中身は守れない

いちばん多い誤解がここです。マスクが保証するのはパースできることだけです。{"age": 999999} も {"city": "存在しない市"} も、文法的には完璧に正しいJSONです。

選択肢が有限のもの(enum)は、選択肢そのものを文法に書けるのでマスクで守れます。一方、数値の範囲(minimum / maximum)や文字列長のように正規文法で表現しにくい制約は、実装によっては黙って無視されます。受け取った後にバリデータを通す工程を、制約デコーディングを入れたからといって外してはいけません。

もうひとつ、統計的にもう少し深い問題があります。本当に欲しいのは「文法が受理する文字列の集合 LL で条件付けた分布」のはずです。

p(x∣x∈L)=p(x)∑y∈Lp(y)p(x \mid x \in L) = \frac{p(x)}{\sum_{y \in L} p(y)}
(2)

xx は出力文字列、LL は文法が受理する文字列全体、pp はモデルの確率です。言い換えれば「モデルが本来出したかった確率を、有効な出力だけに絞って、比率を保ったまま配り直したもの」。

ところが逐次マスクが実際に作るのは、各ステップで局所的に正規化した積です。

q(x)=∏tp(xt∣x<t)∑v∈A(x<t)p(v∣x<t)q(x) = \prod_t \frac{p(x_t \mid x_{<t})}{\sum_{v \in A(x_{<t})} p(v \mid x_{<t})}

A(x<t)A(x_{<t}) は「そこまでの出力に対して文法が許すトークンの集合」、xtx_t は tt 番目のトークンです。こちらは「各ステップで、許された候補だけの中での比率に直し、それを掛け合わせたもの」。

分母がステップごとに、しかも通った経路ごとに違うため、qq と p(⋅∣x∈L)p(\cdot \mid x \in L) は一般に一致しません。厳密に揃えるには「その先にどれだけ有効な完成形が残っているか」で各候補を重み付けする必要があり、それは未来を全部数え上げる作業になります。実務では諦めて qq を使いますが、制約は「モデルの考えを絞り込む」だけでなく、分布そのものを歪めるという自覚は持っておくべきです。

歪みが表面化するのは、スキーマがモデルの書き癖と喧嘩したときです。実用上いちばん効く対策はフィールドの並び順で、多くの実装はスキーマの宣言順に生成するので、生成が左から右である以上、後ろのフィールドは前のフィールドを見てから決まります。{"answer": ..., "reason": ...} は結論を先に固定させる形で、{"reason": ..., "answer": ...} は考えてから答えさせる形です。考える欄を先に置くだけで、同じモデル・同じプロンプトでも精度が変わります。

現場ではこう使う

誰がいつ使うか。 LLMをパイプラインに組み込むアプリ/バックエンドのエンジニア、抽出バッチを回すデータエンジニア、エージェント基盤の担当者。判断基準は単純で、出力を次に読むのが人間か機械かです。DBに入れる、外部APIを叩く、UIコンポーネントに流し込む — 機械が読むなら制約をかける。人が読む文章なら要りません。

触るもの。

事故になる落とし穴(現場で踏む頻度の高い順)。

  1. max_tokens で切れる。 制約下でも途中で止まれば壊れたJSONです。スキーマから最大長を見積もって余裕を取り、切れたときは「切れた」と分かるエラーにすること。パース失敗を一律に「モデルが馬鹿」として片づけると、この原因は永遠に見つかりません
  2. 空白の暴走。 JSON文法は要素の間に空白をいくらでも許すので、モデルが空白や改行を延々と出し続けるループに入ることがあります。文法側で空白を0〜1個に絞ってしまうのが確実です
  3. スキーマのコンパイルコスト。 動的に組み立てたスキーマを毎回渡すと、初回コンパイルが毎リクエスト走ります。スキーマは定数化してキャッシュに載せる
  4. 未対応キーワードの黙殺。 minimum、pattern、oneOf、再帰 $ref は実装によって無視されるかエラーになります。受け取り後の検証は外さない
  5. フィールド順。 結論を先頭に置いたスキーマは、モデルから考える余地を奪います
  6. リトライとの併用。 制約デコーディングは構文の保証、リトライとバリデーションは意味の保証。役割が違うので、片方だけでは足りません

レビューでよく問われる筋。 「JSONで返させたいなら、正規表現で後処理すればいいのでは?」— 後処理はすでに出てしまったものを直す作業です。壊れ方は無限にあり、直した結果がモデルの意図と一致する保証もありません。デコード時のマスクは、壊れた出力の確率をそもそも0にします。事後修復と事前禁止では、保証の強さが根本的に違う、というのが答えの筋です。運用面のコストや同時実行数への影響はLLMサービングの実際側の話題になります。

まとめ

参考文献

  1. Efficient Guided Generation for Large Language Models. arXiv:2307.09702論文ページ·PDF
  2. XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models. arXiv:2411.15100論文ページ·PDF

本記事は上記論文の本文にもとづいて執筆しています。数値・主張は原典を優先してください。

運営: TechAthletes · 編集方針 · 出典の付け方 · 訂正の記録

コメント

コメントにはログインが必要です