結論

LLM の構造化出力が守られるのは、JSON Schema や文法を有限オートマトンに変換し、生成の1歩ごとに「文法上つながらないトークン」の確率を0にしてから次を選ぶからです(制約付きデコーディング)。numpy で自作した小さな言語モデルでは、制約なしで JSON Schema に合ったのは1,000件中689件でしたが、制約付きでは1,000件すべてが合い、時間の増加は1割余りでした。

この記事でわかること

  • 制約なし・検証して再試行・JSONモード相当・スキーマ制約の4方式の成功率・再試行回数・速度の実測
  • JSON Schema → 正規表現 → 有限オートマトン → トークンのマスク、の作り方(コードつき)
  • 確率を絞ると出力の分布が変わる例と、トークン境界と文法がずれる問題の実測

2026年11月4日更新|Python 3.13.18・numpy 2.5.3(OpenBLAS 0.3.34)・CPU 2コアで実行確認

この記事で実際に動かしたもの:numpy だけで書いた文字単位の言語モデル(32.7万パラメータ、150秒学習)と BPE トークンの言語モデル(38.2万パラメータ、95秒学習)、自作の正規表現→NFA→DFA コンパイラ、汎用 JSON のプッシュダウン・オートマトン、マスク付きサンプラーです。

LLM に「JSON で答えて」と頼むと、たいていは JSON が返りますが、閉じかっこが足りない、キー名が少し違う、決めた値以外が入る、といった崩れがときどき起きます。各社の API にある Structured Outputs(構造化出力)は、この崩れを「生成のしかた」の側で起きないようにする機能です。この記事では、その中身である制約付きデコーディング(constrained decoding)を小さなモデルで一から作り、何が保証され、何が保証されないのかを数字で確かめます。

flowchart TD
    S[JSON Schema] --> R[正規表現]
    R --> D[DFA<br>有限オートマトン]
    D --> T[状態 x トークン<br>の遷移表]
    M[言語モデル] --> L[次のトークンの確率]
    T --> K[許されないトークンを0に]
    L --> K
    K --> P[残りを正規化して1つ選ぶ]
    P -->|状態を進める| T

よく調べられることの答えを先にまとめます。

知りたいこと答え詳しい節
構造化出力の仕組みスキーマを文法(オートマトン)にし、1トークンごとに文法に合わない候補の確率を0にして選ぶ構造化出力の仕組み
constrained decoding(制約付きデコーディング)とはモデルの出力確率に「許される候補だけ残すマスク」をかけてからサンプリングする方式マスクして選ぶ実装
JSON Schema を LLM の出力に守らせる方法スキーマを正規表現に変換し、DFA にコンパイルして状態ごとの許可トークン表を作るJSON Schemaを有限オートマトンにする
制約なしでどれくらい壊れるか手元の小さなモデルでは温度1.0でスキーマ適合68.9%、温度0.7で92.2%制約なしで壊れるJSON
検証して作り直すのと何が違うか再試行は最大5回で99.7%。平均1.44回の生成が要る。制約付きは1回で100%検証して作り直す方式
JSONモードとスキーマ制約の違いJSONモードは「JSONとして読める」まで。キー名・値の候補・長さは守られない(手元で78.5%)JSONモードとスキーマ制約の違い
文法で制約すると出力は変わるか変わる。maxLength 12 の例で、ちょうど12文字の題名が20.0%から91.5%に増えた確率を絞ると分布が変わる
トークン境界の問題とは文法の区切りとトークンの区切りがずれること。1文字目だけで判定すると4.6%が壊れたトークン境界と文法のずれ

確認環境:Ubuntu 24.04.5 LTS(Intel Xeon 2.10GHz・2コア、ほかの作業と同時に動いている共有のマシン)の上で、Python 3.13.18 と numpy 2.5.3(OpenBLAS 0.3.34、OPENBLAS_NUM_THREADS=1)だけを使いました。PyTorch などの深層学習ライブラリ、Outlines・XGrammar などの制約付き生成ライブラリは使っていません。学習データは、RJC の技術記事の見出し(h2・h3)3,988個を題名にした JSON 風の行23,928行(2,323,727文字)を自分で作りました。言語モデルは直前24トークンを見る小さな多層パーセプトロン(文字単位 327,334パラメータ・150秒、BPE 200併合 381,934パラメータ・95秒)です。学習済みの公開モデルの重みや LLM の API には接続していないため、生成された文章の中身の質については主張しません。測ったのは「JSON として読めるか」「スキーマに合うか」「何回作り直したか」「時間」「確率の変化」です。時間は共有のマシンのため1〜2割ぶれます。各社 API の仕様は Anthropic・OpenAI・Google の公式ドキュメントで、手法の根拠は Willard と Louf(2023)、Park ほか(NeurIPS 2024)、Dong ほか(XGrammar)、LMSYS の技術ブログで確かめました。図は mermaid で描画を確かめています(確認日 2026年11月4日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「構造化出力の仕組み|制約付きデコーディングを自作して実測」

構造化出力の仕組み(制約付きデコーディングとは)

この節では、構造化出力を守る方法が大きく3つあることと、制約付きデコーディングがそのどこに当たるかが分かります。

LLM は、次のトークンの確率分布を出し、そこから1つ選んで文に足す、を繰り返して文章を作ります。出力の形を守らせる方法は、この繰り返しの「前」「後」「途中」のどこで手を打つかで分けられます。

1前:プロンプトで頼む

「次の形の JSON だけを返す」と指示し、例を見せる。モデルが従えば形は合うが、従わなかったときの手当てが無い。

2後:検証して作り直す

出力を json.loads と JSON Schema の検証にかけ、合わなければもう一度生成する。仕組みは単純だが、失敗率の分だけ時間と費用が増える。

3途中:候補を絞って選ぶ

1トークンごとに、文法上つながらない候補の確率を0にしてから選ぶ。これが制約付きデコーディング。文法に合わない出力は、途中で打ち切られない限り作られない。

制約付きデコーディングで大事なのは、モデルの重みには手を入れず、確率分布の「選び方」だけを変える点です。モデルが出した確率のうち、文法が許すトークンだけを残し、残りの合計が1になるように割り直してから選びます。どのトークンが許されるかは、スキーマから作った有限オートマトン(DFA)の「今の状態」で決まります。

Willard と Louf(2023)は、生成を有限状態機械の状態遷移として扱い、状態ごとに許される語彙のトークンを前もって索引にしておけば、正規表現や文脈自由文法の制約を小さな追加の手間でかけられることを示しました。この記事の実装も同じ考え方で、状態×トークンの遷移表を最初に1回だけ作ります。

実験の作り方(numpyの文字単位モデルとJSONデータ)

この節では、実験に使ったデータ・スキーマ・モデルの作りが分かります。

学習データは、RJC の技術記事247本の見出しを題名にした、次の形の JSON の行です。id は乱数、level は3つの値から、tags は題名の語から決めました。同じ見出しを6回、id・level・tags を変えて使っています。

{"id": 7643, "title": "スケルトンスクリーンを使うべきでない場面(判断表)", "level": "上級", "tags": ["network"], "draft": false}
{"id": 7616, "title": "splitChunks で npm のパッケージを分ける", "level": "初級", "tags": ["network"], "draft": false}
files 247 titles 3988 chars 835
lines 23928 chars 2323727

守らせたいスキーマは次のとおりです。title は1〜32文字、level は3つの値のどれか、tags は6つの値から1〜3個、余計なキーは許しません。

{
  "type": "object",
  "properties": {
    "id":    {"type": "integer", "minimum": 0, "maximum": 9999},
    "title": {"type": "string", "minLength": 1, "maxLength": 32},
    "level": {"enum": ["初級", "中級", "上級"]},
    "tags":  {"type": "array", "minItems": 1, "maxItems": 3,
              "items": {"enum": ["python", "linux", "ai", "web", "db", "network"]}},
    "draft": {"type": "boolean"}
  },
  "required": ["id", "title", "level", "tags", "draft"],
  "additionalProperties": false
}

モデルは、直前の24文字の埋め込み(16次元)を並べて、256個の隠れ層(tanh)から次の文字の確率を出す多層パーセプトロンです。Transformer ではありませんが、「次のトークンの確率分布を出す」という点で LLM と同じ形をしているので、制約付きデコーディングの仕組みを確かめられます。逆伝播は手で書き、Adam で学習させました。

V 838
(500, 23.5, 1.16, 1.141)
(2500, 80.3, 0.775, 0.873)
(5000, 149.8, 0.795, 0.787)
(5005, 150.0, None, 0.788)     # (ステップ, 経過秒, 学習損失, 検証損失 nats/文字)
327,334文字単位モデルのパラメータ数語彙838文字・文脈24文字
150秒学習時間(1スレッド)5,005ステップ・バッチ256
0.788検証損失(nats/文字)学習を止めた時点でまだ下がっていた

わざと小さく、学習も短くしてあります。大きな LLM より崩れやすいモデルの方が、制約の効き目が数字ではっきり見えるからです。実際の LLM では、制約なしの失敗率はこれよりずっと低くなるのがふつうです。

制約なしの生成で壊れるJSON(エラー原文)

この節では、制約をかけずに生成したときに、JSON がどう壊れるかが原文で分かります。

行頭から1文字ずつ、温度1.0でモデルの確率どおりに選び、改行(終わり)が出たら止めます。1,000件作って json.loads と上のスキーマで判定しました。

none T=1.0  parse 839 / 1000   schema 689 / 1000
reasons:
  parse: Expecting ',' delimiter                         92
  tags enum                                               50
  parse: Expecting ':' delimiter                          29
  parse: Expecting value                                  23
  parse: Expecting property name enclosed in double quotes 17
  title length 33                                         12
  title length 34                                          9
  keys: level,livel                                        4

none T=0.7  parse 968 / 1000   schema 922 / 1000

実際に出てきた壊れ方の原文です(json.JSONDecodeError のメッセージと出力)。

Expecting ',' delimiter: line 1 column 86 (char 85)
{"id": 2831, "title": "Lockit とは(意味が使われれるか)", "level": "初級", "tags": ["neaw", "dytol": "中級", ...

Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
{チid": 1071, "title": "nettass で確率で渡す", "level": "初級", "tags": ["linux"], "draft": false}

Expecting value: line 1 column 8 (char 7)
{"id": , "tittt": "中級", "level": "上級", "tags": ["db"], "draft": false}

JSON としては読めるがスキーマに合わない例
{"id": 7014, "titli": "gitの文字を直ら半順番を標準(実測)", "level": "初級", "tags": ["web"], "draft": false}
{"id": 4228, "title": "判断表", "level": "上級", "nags": ["network"], "draft": true}

壊れ方は2種類に分かれます。1つは構文の崩れ(区切りのカンマやコロンが無い、キーの引用符が別の文字になる)、もう1つはJSON として読めるがスキーマに合わないもの(キー名の1文字違い、tags に決めた値以外、題名が33文字以上)です。温度を0.7に下げると適合率は92.2%まで上がりますが、0にはなりません。確率で1文字ずつ選ぶ以上、低い確率の誤りは必ずどこかで引かれます。

生成後に検証して作り直す方式(再試行回数の実測)

この節では、「検証して合わなければ作り直す」方式で、何回の生成が要るかが分かります。

制約なしの出力を5,000件ためておき、1件の依頼ごとに先頭から順に取り出して、スキーマに合うまで最大 R 回まで試す、という再試行をまねました。5,000件のうちスキーマに合ったのは3,375件(67.5%)です。

最大の試行回数 R1,000依頼の成功数平均の生成回数失敗した依頼
1(再試行なし)6951.000305
28991.303101
39661.39834
59971.4443
上限なし3,375件すべて1.4810
attempts hist [(1, 2293), (2, 726), (3, 233), (4, 84), (5, 27), (6, 5), (7, 4), (8, 1)]  mean 1.481

成功率 p の生成を繰り返すと、成功までの回数は平均 1/p 回になります(幾何分布)。p=0.675 なら1.48回で、実測の1.481回と一致しました。再試行の費用は「失敗率」で決まり、モデルの失敗率が5%なら平均1.05回、30%なら1.43回です。また上限を決めないと、まれに8回かかる依頼が出ます。

この方式の利点は、どの API でも、どんなスキーマでも使えることと、成功した出力の分布が「モデルの分布をスキーマに合うものだけに絞ったもの」と一致することです(棄却サンプリング)。後の節で、この性質が制約付きデコーディングとの大きな違いになります。

JSON Schemaを正規表現と有限オートマトンにする(コンパイル)

この節では、JSON Schema を文字ごとの状態遷移表に変換する手順と、その大きさが分かります。

1

スキーマを正規表現にする

キーの順序と区切りの空白を固定し、型ごとに部品の正規表現をつなぐ。integer は (0|[1-9][0-9]{0,3})、maxLength 32 の文字列は "[^"\\]{1,32}"、enum は選択 (初級|中級|上級)、maxItems 3 の配列は T(, T){0,2}。

Outlines と同じ考え方

2

正規表現を NFA にする

Thompson の構成法で、文字の集合を辺に持つ非決定性有限オートマトンを作る。繰り返し {0,3} は部品を3回並べて展開する。

NFA 434状態

3

文字を同値類にまとめる

838文字のうち、どの辺に対しても同じ振る舞いをする文字は1つの類にする。題名に使う「" と \ 以外の文字」などがまとまり、36類になった。

36類

4

部分集合構成で DFA にする

NFA の状態の集合を DFA の1状態にし、類ごとの遷移を求める。最後に類を文字へ戻して、状態×文字の表にする。

DFA 195状態・0.14秒

5

トークンへ持ち上げる

トークンが複数の文字なら、DFA をその文字の分だけたどった先を表に入れる。途中で行き止まりになるトークンは −1(禁止)。受理状態では終わりのトークンを許す。

状態×語彙の int32 表

実際に変換した正規表現と、コンパイルの結果です。

\{"id": (0|[1-9][0-9]{0,3}), "title": "[^"\\]{1,32}", "level": "(初級|中級|上級)", "tags": \["(python|linux|ai|web|db|network)"(, "(python|linux|ai|web|db|network)"){0,2}\], "draft": (true|false)\}

DFA states 195 nfa 434 classes 36 compile 0.14 lift 0.027 T bytes 653640
allowed per state: min 1 max 837   states with 1 allowed: 152 of 195

小さな例として (true|false) の DFA を出すと、次のように1文字ごとの分かれ道になります。状態0では t か f しか許されず、t を選ぶと状態2で r しか許されない、という形です。

true|false DFA states 10
0 {'f': 1, 't': 2}
1 {'a': 3}
2 {'r': 4}
3 {'l': 5}
4 {'u': 6}
5 {'s': 7}
6 {'e': 8}
7 {'e': 9}
8 {} accept
9 {} accept

正規表現の部分を自作したコードの中心は、Thompson の構成法と部分集合構成です(抜粋)。

def build(node, nfa, labels):            # Thompson の構成法
    k = node[0]
    if k == 'set':                       # 1文字(文字の集合)
        a, b = nfa.new(), nfa.new()
        labels.append((node[1], node[2])); nfa.edge[a] = (len(labels) - 1, b)
        return a, b
    if k == 'cat':                       # 連接
        a = nfa.new(); cur = a
        for x in node[1]:
            p, q = build(x, nfa, labels); nfa.eps[cur].append(p); cur = q
        return a, cur
    if k == 'alt':                       # 選択 |
        a, b = nfa.new(), nfa.new()
        for x in node[1]:
            p, q = build(x, nfa, labels); nfa.eps[a].append(p); nfa.eps[q].append(b)
        return a, b
    ...                                  # 'rep':{lo,hi} は部品を並べて展開

# 部分集合構成(文字の同値類ごとに遷移を求める)
start = closure([nfa_start]); ids = {start: 0}; queue = [start]
while queue:
    D = queue.pop(0); row = np.full(n_classes, -1, np.int32)
    for c in range(n_classes):
        nxt = [nfa.edge[u][1] for u in D if nfa.edge[u] and sig[c][nfa.edge[u][0]]]
        if nxt:
            N = closure(nxt)
            if N not in ids: ids[N] = len(ids); queue.append(N)
            row[c] = ids[N]
    table.append(row); accept.append(nfa_accept in D)

変換で固定したものに注意が要ります。JSON Schema はキーの順序や空白を決めませんが、正規表現にするときに1つの並べ方へ固定しています。出力は1通りの書き方に寄りますが、スキーマ上は正しい JSON です。

マスクして選ぶ:制約付きデコーディングの実装と速くする工夫

この節では、遷移表を使ったマスク付きサンプリングのコードと、マスクにかかる時間、ジャンプフォワードでモデルの呼び出しを減らす方法が分かります。

サンプリングの1歩は「モデルで確率を出す → 今の状態で許されるトークン以外を0にする → 割り直して1つ選ぶ → 状態を進める」です。状態はバッチの行ごとに整数1つなので、表を引くだけでマスクが作れます。

st = np.zeros(n, np.int64)                    # 各行の DFA の状態(0 = 開始)
for step in range(maxlen):
    act = np.where(~done)[0]
    M = (T[st[act]] != -1)                     # [行, 語彙] 許されるトークン
    z = model.logits(ctx[act]) / temp
    p = np.exp(z - z.max(1, keepdims=True))
    kept.extend((p * M).sum(1) / p.sum(1))     # マスクで残った確率の割合(記録用)
    p *= M; p /= p.sum(1, keepdims=True)       # 禁止を0にして割り直す
    c = (p.cumsum(1) < rng.random((len(act), 1))).sum(1)
    for j, i in enumerate(act):
        if c[j] == EOS: done[i] = True; continue
        out[i].append(c[j]); st[i] = T[st[i], c[j]]   # 状態を進める

手元の表の引き方と、モデル本体の計算の時間を比べました(500行のバッチ1歩)。

dfa mask lookup per step (500 rows): 0.426 ms
model forward per step (500 rows): 7.283 ms

マスクを作る時間はモデル本体の約6%でした。遷移表を前もって作っておけば、1歩ごとの手間は「表の1行を読む」だけだからです。XGrammar の論文(Dong ほか)は、語彙を「前もって判定できるトークン」と「実行時にスタックを見ないと判定できないトークン」に分け、前者を前計算し、文法の処理を GPU の計算と重ねることで、エンドツーエンドのサービングで構造化生成の追加の手間をほぼ0にできたと報告しています。

ジャンプフォワードは、許されるトークンが1つしかない状態ではモデルを呼ばずにそのトークンを置く工夫です。キー名や区切りのように決まった文字が続く部分で効きます。手元の DFA では195状態のうち152状態が「許されるのが1文字だけ」でした。

97.9回1件あたりのモデル呼び出し(制約のみ)ほぼ文字数と同じ
30.5回ジャンプフォワードあり69%減
2.48ms1件あたりの時間(ジャンプあり)制約のみは5.90ms

LMSYS の技術ブログ(SGLang の圧縮 FSM)は、文法上の続きが1通りしかない区間をまとめて先に埋め、1歩で複数トークンを確定させる方法で、バッチ1の遅延を最大2倍短く、スループットを最大2.5倍にしたと報告しています。同じ記事は、強制した文字列をトークンに直すときの切り方がモデルの確率を変える点にも触れており、これは後のトークン境界の節で実測します。

表を前もって作る方式には弱点もあります。DFA の状態数は正規表現によって爆発することがあり、たとえば (a|b)*a(a|b){n} は n を増やすと状態数が2のべき乗で増えました。

(a|b)*a(a|b){4}   NFA 47   DFA 33     0.00s
(a|b)*a(a|b){8}   NFA 79   DFA 513    0.03s
(a|b)*a(a|b){12}  NFA 111  DFA 8193   0.73s

maxLength 12   NFA 394  DFA 175  compile 0.095s  table 573KB
maxLength 32   NFA 434  DFA 195  compile 0.103s  table 638KB
maxLength 64   NFA 498  DFA 227  compile 0.145s  table 743KB
maxLength 128  NFA 626  DFA 291  compile 0.158s  table 953KB
maxLength 256  NFA 882  DFA 419  compile 0.251s  table 1372KB

今回の JSON Schema では、maxLength を大きくしても状態数は文字数分だけ増える程度でした。表は「状態数×語彙数」なので、語彙が10万を超える実際の LLM では、状態数が少なくても表が大きくなります。各社の API が初回のスキーマで遅延が出ると書き、コンパイル結果をキャッシュしているのはこのためです(後の節)。

4方式の比較(成功率・再試行・速度の実測)

この節では、制約なし・検証して再試行・JSONモード相当・スキーマ制約を同じモデルで比べた結果が分かります。

方式(温度1.0、1,000件)JSONとして読めるスキーマに合う1件の時間(3回の中央値)モデル呼び出し/件
制約なし8396895.18ms98.0
検証して再試行(上限5回)―997約7.5ms(5.18ms×1.444回)約141
JSONモード相当(汎用JSON文法)9927856.72ms99.2
スキーマ制約(DFA)1,0001,0005.90ms97.9
スキーマ制約+ジャンプフォワード1,0001,0002.48ms30.5
none T=0.7     parse  968  schema  922
json T=0.7     parse 1000  schema  942
dfa  T=0.7     parse 1000  schema 1000
dfa+jump T=0.7 parse 1000  schema 1000  calls/record 29.8

読み取れることは次の3つです。

  • スキーマ制約は温度によらず1,000件すべてが合った。1回の生成で済み、時間は制約なしの約1.1倍。
  • 再試行は最後には合うが、平均1.44倍の生成が要る。失敗率の高いスキーマやモデルほど、費用と遅延のぶれが大きくなる。
  • JSONモード相当は「読める」をほぼ守るが、スキーマ適合は78.5%にとどまる。理由は次の節で見ます。

「必ず JSON になる」の受け取り方

やってはいけないJSON モードを使っているから、キー名や値の種類も合っていると考えて、検証せずに後段へ渡す
→
代わりにスキーマを渡す方式を使い、それでも受け取った側で JSON Schema(または Pydantic)で検証する

手元の JSON モード相当では、読める992件のうち207件がスキーマ違反だった

JSONモードとスキーマ制約の違い(プッシュダウン・オートマトンで再現)

この節では、「JSON として読める」ことだけを守る制約がどう作られ、なぜスキーマ違反が残るのかが分かります。

汎用の JSON は、オブジェクトや配列をいくらでも入れ子にできるので、有限オートマトン(正規表現)では表せません。開いたかっこの種類を覚えておくスタックが要り、これをプッシュダウン・オートマトンと呼びます。手元では、状態を「今の位置の種類(値を待つ・キーの文字列の中・コロンを待つ・値の後 など)とスタック」の組にして、1文字ずつ進める関数を書きました。

def jfeed(st, c):                      # st = (mode, stack, extra)
    mode, stk, x = st
    if mode == 'STR':                  # 文字列の中
        if c == '"': return ('COL', stk, None) if x == 'key' else after(stk)
        return None if c == '\n' else st
    if mode in ('VAL', 'VAL0', 'VALS'):  # 値を待っている
        if c == '{': return ('KEY0', stk + ('{',), None)   # スタックに積む
        if c == '[': return ('VAL0', stk + ('[',), None)
        if c == '"': return ('STR', stk, 'val')
        if c in 'tfn': return ('LIT', stk, {'t': 'rue', 'f': 'alse', 'n': 'ull'}[c])
        ...
    if mode == 'AFT':                  # 値の後:, か閉じかっこ
        top = stk[-1]
        if c == ',': return ('KEY', stk, None) if top == '{' else ('VAL', stk, None)
        if c == '}' and top == '{': return after(stk[:-1])          # スタックから下ろす
        if c == ']' and top == '[': return after(stk[:-1])
        return None

許されるトークンの集合は「状態」だけで決まるので、状態ごとに結果を覚えておけば(キャッシュ)、同じ状態が来たときは表を引くのと同じ速さになります。それでもスタックの中身の組み合わせは前もって列挙できないため、DFA の表を最初に作る方式より1件あたり1割余り遅くなりました(6.72ms と 5.90ms)。

JSONモード相当で守られたこと

かっこ・引用符・カンマ・コロンの並び

true・false・null・数値の書き方

最上位がオブジェクトであること

守られなかったこと(1,000件中)

tags に決めた値以外:45件

題名が33文字以上:35件以上

キー名の1文字違い(livel 4件・lags 3件 など)

長さの上限で途中で切れた文字列:5件

最後の「途中で切れた」は大事な点です。JSON モード相当でも、生成の長さの上限(今回は160文字)に達すると、文字列の途中で終わって Unterminated string starting at になりました。スキーマ制約でも同じで、上限を短くして試すと次のようになりました。

dfa maxlen 80   schema-valid 21 / 1000
dfa maxlen 100  schema-valid 644 / 1000
dfa maxlen 120  schema-valid 1000 / 1000

制約付きデコーディングが保証するのは「文法に合わない文字を選ばない」ことで、「文法を最後まで書き終える」ことではありません。Anthropic の公式ドキュメントも、stop_reason: "max_tokens" のときは出力が不完全でスキーマに合わない可能性があると書いています。

確率を絞ると出力の分布が変わる(maxLengthの実験)

この節では、制約付きデコーディングがモデルの「本来の答え方」をどう変えてしまうかが、数字で分かります。

題名の maxLength を32から12に厳しくしたスキーマで、2つの方法を比べました。学習データの題名は4〜32文字で、12文字以下は3,988個中505個(12.7%)しかありません。

  • 棄却(検証して捨てる):制約なしの5,000件から、maxLength 12 に合うものだけを残す。残るのは「モデルが自然に12文字以下で書き終えた題名」で、モデルの分布をスキーマで条件づけたものになる。
  • マスク(制約付きデコーディング):題名が12文字に達した時点で、閉じの引用符以外をすべて禁止する。
方法件数ちょうど12文字の割合題名の平均文字数
棄却(モデルの条件付き分布)416(5,000件中)20.0%8.92
マスク(制約付きデコーディング)1,00091.5%11.60
examples mask len12: ['Le・キログ・既定と長い', 'スマンドモード(Roup', 'Ad の入れを通える(ソ', ...]
examples reject    : ['実行の扱い', '端bolを解除する', 'マッシュロルカルの削除', ...]
p(quote) at forced position: median 0.0002  mean 0.0268  n 915  share<1% 80.8%

マスクした方は、題名の9割以上が12文字ちょうどで、かっこの途中「(ソ」「(Roup」のように切れています。モデルは長い題名を書くつもりで進み、12文字目で急に打ち切られたからです。打ち切られた位置で、モデルが閉じの引用符に付けていた確率は中央値0.02%でした。モデルにとってはほぼ起きない選択が、マスクで100%に引き上げられています。

1つの例で見ると、{"id": 120, "title": "Pythonの仮想環境を(題名12文字)の次に、モデルは「使」21.4%、「削」16.1%、「消」5.8%と続けるつもりでした。閉じの引用符は0.0015%で、838文字中410位です。

Park ほか(Grammar-Aligned Decoding、NeurIPS 2024)は、文法による制約付きデコーディングが LLM の分布をゆがめ、文法には合うがモデルが付ける尤度と合わない出力になり、質が下がることがあると指摘し、ゆがみを減らす ASAp という方法を示しています。手元の結果も同じ現象です。実務では次の3つが効きます。

  • モデルが自然に守れる範囲の制約にする(長さの上限は余裕を持たせ、プロンプトでも短く書くよう頼む)
  • enum の候補名やキー名を、モデルが書きそうな言葉にする
  • スキーマで縛る前に、プロンプトだけの出力がどのくらい合っているかを測っておく(合う率が低いほど、マスクが分布を大きく曲げている)

トークン境界と文法のずれ(BPEモデルで実測)

この節では、トークンが複数の文字からなるときに起きる「文法の区切りとトークンの区切りのずれ」と、その影響の大きさが分かります。

実際の LLM のトークンは1文字ではなく、BPE などで作った文字のまとまりです。同じ学習データで BPE を200回併合した語彙(1,038トークン)を作り、2つ目のモデルを学習させました。併合の上位には、文法の区切りをまたぐトークンが並びました。

[' "', '":', ', "', '": ', '", "', ... '"]', '{"id', '["', 'e}', ... '": ["', '"], "draft": ', ...]

encode('{"id": 1234, "title": "Pythonの仮想環境を作る", "level": "初級", ...')
['{"id": 1', '2', '3', '4, "title": "', 'Python', 'の', '仮', '想', '環', '境', 'を', '作',
 'る", "level": "初級", "tags": ["', 'python"], "draft": false}']

る", "level": "初級", "tags": [" は、題名の最後の1文字・閉じの引用符・次のキー・level の値・tags の開きまでが1トークンです。このトークンが許されるかは、1文字目の「る」だけでは決まらず、DFA を全部の文字の分たどらないと分かりません。そこで3通りのマスクを比べました。

マスクの作り方(1,000件)スキーマに合うトークン/件マスクで残った確率1件の時間
制約なし85425.11.0002.01ms
全部の文字をたどる(正しい)1,00024.80.9881.58ms
1文字目だけで判定(誤り)95424.20.9961.79ms
1文字のトークンだけ許す1,000110.00.2288.15ms
lift to 1038 tokens: 0.042 s; T (195, 1038) 809640 bytes; allowed per state mean 163.2
firstchar bad examples:
  {"id": 2646, "title": "5.nso:1009, "title": "
  {"id": 53800

1文字目だけで判定すると、題名の中で「9, "title": "」のようなトークンが「9 は題名に使える」として通り、46件(4.6%)が途中で文法から外れました。id が5桁になった例も同じで、「0」は許されても、それに続く文字までは見ていません。

逆に「1文字のトークンだけ許す」と、文法は必ず守れますが、モデルが本来使うトークンの大半を禁止することになります。1歩あたりモデルの確率の77%が捨てられ、76%の歩で半分以上が捨てられました。トークン数は4.4倍、時間は5倍になり、題名は平均109文字の出力に伸びて意味の無い文字列が続きました。

もう1つの形が、プロンプトや強制した文字列の切り方です。同じ文字列 {"id": 1234, "title": " を、BPE の正規の切り方で入れた場合と、1文字ずつ入れた場合で、その後の本物の続き(学習データの800行)にモデルが付ける損失を測りました。

"title": " canonical prefix: 0.676 nats/char   char-split prefix: 0.885 nats/char  (n=800 records)
"tags": [" canonical prefix: 0.152 nats/char   char-split prefix: 0.434 nats/char  (n=800 records)

文字列は同じでも、切り方が学習のときと違うだけで、続きの予測が31%〜2.9倍悪くなりました。制約エンジンが決まった文字を1文字ずつ強制したり、プロンプトの最後がトークンの途中で終わったりすると、この状態になります。対策として知られているのは、強制した文字列を足した後に全体をトークン化し直すこと(LMSYS の記事では約4%の追加の手間と報告)や、プロンプトの最後のトークンを戻して制約付きで生成し直すトークンヒーリングと呼ばれる方法です。

BPE のモデルでは、ジャンプフォワードの効果はほとんどありませんでした(呼び出し24.5→23.5回)。キー名や区切りがすでに長いトークンにまとまっているうえ、同じ文字列を作れるトークンが複数あるので「許されるのが1つだけ」の状態が少ないからです。

各社APIのStructured Outputs・JSONモード(公式ドキュメントの範囲)

この節では、主要な API の構造化出力の指定方法と、公式に書かれている制限・注意点が分かります(2026年11月4日時点、各社の公式ドキュメントで確かめた範囲だけを書きます)。

APIスキーマで縛る指定公式に書かれている仕組み・注意
Anthropic(Claude Messages API)output_config.format(type: json_schema)、ツールの strict: trueスキーマを文法にコンパイルして出力を制約する(constrained decoding)。初回のスキーマはコンパイルの遅延があり、コンパイル結果は最後の使用から24時間キャッシュされる
OpenAIResponses API の text.format、Chat Completions の response_format(type: json_schema、strict: true)Structured Outputs は JSON モードの発展形で、JSON モードは有効な JSON を、Structured Outputs はスキーマへの準拠を保証すると説明。安全上の拒否はプログラムで判別できる
Google(Gemini API)response_format に mime_type: application/json と schema(公式の例は interactions.create)JSON Schema の一部に対応。minLength・maxLength などは制約付きデコーディングで強制されず無視される。とても大きい・深いスキーマは 400 INVALID_ARGUMENT になることがある

Anthropic の公式ドキュメントで確かめた主な制限は次のとおりです。

  • 再帰的なスキーマ、外部の $ref、minimum・maximum・multipleOf、minLength・maxLength、0か1以外の minItems には対応しない。使うと 400 エラーになり、SDK の補助関数は一部の制約を説明文に移してから送る
  • 文法が大きすぎると「Schema is too complex for compilation」の 400 エラーになり、コンパイルには180秒の時間切れがある
  • stop_reason: "refusal"(拒否)と stop_reason: "max_tokens"(上限)のときは、出力がスキーマに合わないことがある
  • 文字列の enum・const の大文字小文字は保証されない。大文字小文字だけが違う値を並べない

OpenAI の公式ガイドでは、Structured Outputs を JSON モードより優先して使うよう勧め、gpt-4-turbo 以前のモデルでは JSON モードを使うこともあると書いています。Google の公式ドキュメントは、出力は構文として正しい JSON でも、値はアプリケーション側で検証するよう勧めています。

3社とも、ドキュメントの言い方は違っても「構文は生成の側で守るが、値の正しさや打ち切りは受け取る側で確かめる」という前提で書かれています。手元の実験で見た、上限での打ち切りと、制約が分布を変える点は、どの API でも受け取る側の検証で拾う必要があります。

よくあるエラー/つまずき(構造化出力・制約付きデコーディング)

この節では、構造化出力でよく出会うエラーや症状と、その原因・直し方が分かります。

症状・原文原因直し方
Expecting ',' delimiter(json.JSONDecodeError)制約なしの生成で区切りが抜けたスキーマを渡す方式にする。使えなければ検証して再試行
Unterminated string starting at生成の長さの上限で文字列の途中で切れた上限を上げる。stop_reason / finish_reason を見て再試行
JSON は読めるがキー名が1文字違う(livel など)JSON モードは構文しか守らないスキーマで required と additionalProperties: false を指定
文字列がちょうど上限の長さで不自然に切れるmaxLength でマスクされ、閉じの引用符を強制された上限に余裕を持たせ、プロンプトでも長さを頼む
Schema is too complex for compilation(400)文法が大きすぎる(Anthropic)任意項目・union を減らす、スキーマを分ける
400 INVALID_ARGUMENT(大きいスキーマ)深い入れ子・多い enum・長い名前(Gemini)入れ子の配列の上限を下げる、名前を短くする
制約したのに出力が壊れている(自作)複数文字のトークンを1文字目だけで判定したDFA をトークンの全部の文字でたどって表を作る
初回だけ応答が遅いスキーマの文法のコンパイル同じスキーマを使い回し、キャッシュに乗せる

JSON の読み書きでのエラーそのものは、Python の json モジュールの原文を見ると早く分かります。関連記事「LLMの構造化出力とJSON|4方式の違いとPydantic検証の実行結果」で扱っています。この記事は方式の中身(なぜ守られるか)を、関連記事は方式の選び方と Pydantic での検証の書き方を受け持っています。

判断表(構造化出力の方式の選び方)

この節では、状況ごとにどの方式を選ぶとよいかが分かります。

状況向く方式理由(手元の実測から)
API がスキーマ指定に対応し、スキーマもその範囲に収まるスキーマ制約+受け取り側の検証1回の生成で1,000件すべて合った
スキーマに API が対応しない制約(数値の範囲・文字数)があるスキーマ制約+検証して再試行構文は制約で、値の範囲は検証で拾う
モデルがもともとほぼ守れる(失敗率が数%)検証して再試行でもよい平均回数は 1/成功率。95%なら1.05回
長さや候補を強く絞りたい制約は緩めにし、プロンプトと検証で絞るmaxLength 12 で91.5%が上限ちょうどで切れた
自前の推論サーバーで制約をかけるトークン単位の遷移表を前計算するエンジンマスクの時間はモデル本体の約6%
入れ子や再帰のある形文脈自由文法(スタック)に対応した方式正規表現(DFA)では入れ子を表せない
出力を関数の引数として使うツール呼び出しの strict 指定回答そのものより引数の形が大事な場面

ツール呼び出しで引数の形を守らせる仕組みは、関連記事「Function Calling(tool use)の仕組みとPython実装」で扱っています。

よくある質問(構造化出力の仕組み)

この節では、構造化出力と制約付きデコーディングについてよく聞かれる疑問への答えが分かります。

Q. 構造化出力を使えば、中身も正しくなりますか。

なりません。守られるのは形(構文・キー・型・候補)です。手元の出力も、スキーマには合っていても題名の文章はでたらめでした。値の正しさは受け取る側で確かめます。

Q. 制約付きデコーディングで遅くなりますか。

遷移表を前もって作れば、1歩の追加はマスクを引く分だけです。手元ではモデル本体の約6%で、ジャンプフォワードを使うと逆に速くなりました(5.90ms→2.48ms)。初回だけ文法のコンパイルの時間がかかります。

Q. 温度を0にすれば制約は要りませんか。

崩れは減りますが、0にはなりません。手元では温度0.7でもスキーマ適合は92.2%でした。貪欲法でも、モデルが最も確からしいと考える文字が誤りなら、そのまま誤ります。温度の意味は関連記事「temperatureとは?top_p・max_tokensの意味と設定目安表」で扱っています。

Q. JSON モードと Structured Outputs はどちらを使えばよいですか。

スキーマを渡せるなら、スキーマを渡す方です。JSON モードは「読める JSON」までで、手元の再現ではスキーマ適合が78.5%でした。OpenAI の公式ガイドも Structured Outputs を優先するよう勧めています。

Q. なぜ正規表現(有限オートマトン)で JSON Schema を表せるのですか。

入れ子の深さが決まっていて再帰が無いスキーマなら、取りうる文字列の並びが正規言語に収まるからです。再帰や任意の深さの入れ子があるとスタックが要り、文脈自由文法とプッシュダウン・オートマトンを使います。正規表現の記号そのものは関連記事「正規表現 早見表|記号一覧・言語別の違い・実用パターン(実行結果つき)」で扱っています。

Q. 制約付きデコーディングで出力の質が下がることはありますか。

あります。モデルが書くつもりだった続きを禁止すると、残りの候補へ確率が割り振られ、モデル本来の分布から外れます。手元の maxLength 12 の例では、閉じの引用符の確率が中央値0.02%のところで強制され、題名の9割が途中で切れました。

Q. 検証して再試行する方式の利点は何ですか。

どのモデル・API・スキーマでも使えることと、成功した出力がモデルの分布をスキーマで条件づけたものに一致することです。費用は平均 1/成功率 倍になります。

Q. トークン境界の問題は API を使う側にも関係しますか。

API の内部で処理されるため、直接の実装は要りません。ただ、プロンプトの最後の書き方(途中で切れた語や余計な空白)がトークンの切り方を変え、出力に影響することがあります。トークンの数え方は関連記事「トークンとは|生成AIの数え方・日本語の文字数目安と節約方法」で扱っています。

Q. キーの順番はどうなりますか。

正規表現に変換する方式では、変換のときに決めた順番で出ます。手元の実装もスキーマに書いた順に固定しました。OpenAI の公式ガイドのコード例には、スキーマのプロパティの順序を保つという注記があります。

Q. 推論の過程(理由)をスキーマの項目に入れてもよいですか。

入れられますが、Anthropic の公式ドキュメントは、段階的な推論を求める項目が拒否(reasoning_extraction)のきっかけになることがあると書いています。項目の並びは、結論の前に根拠を置くかどうかで出力が変わりうるので、自分の入力で測って決めるのが確実です。

まとめ

この節では、記事全体の要点が分かります。

  1. 構造化出力は、スキーマから作った文法(オートマトン)で、1トークンごとに許されない候補の確率を0にして守られる。手元のモデルでは制約なし68.9%が、制約付きで1,000件すべてスキーマに合った。
  2. JSON Schema は正規表現→NFA→DFA→状態×トークンの表に変換できる。今回のスキーマは DFA 195状態・0.14秒で、マスクの時間はモデル本体の約6%、ジャンプフォワードで呼び出しは69%減った。
  3. 検証して再試行は確実だが平均 1/成功率 回の生成が要り、JSON モード相当はスキーマ適合78.5%にとどまった。長さの上限で打ち切られると、どの方式でも壊れる。
  4. 確率を絞ると出力の分布が変わる。maxLength 12 では、モデルの条件付き分布で20.0%だった「ちょうど12文字」が、マスクで91.5%になった。
  5. トークン境界と文法のずれは、全部の文字で判定し、正規の切り方で戻して防ぐ。1文字目だけの判定は4.6%を壊し、1文字ずつの入力は続きの損失を最大2.9倍にした。

参考・出典

この節では、この記事で根拠にした論文・公式ドキュメント・技術ブログが分かります(確認日 2026年11月4日)。

  • Brandon T. Willard, Rémi Louf「Efficient Guided Generation for Large Language Models」2023:https://arxiv.org/abs/2307.09702
  • Kanghee Park ほか「Grammar-Aligned Decoding」NeurIPS 2024:https://arxiv.org/abs/2405.21047
  • Yixin Dong ほか「XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models」:https://arxiv.org/abs/2411.15100
  • LMSYS「Fast JSON Decoding for Local LLMs with Compressed Finite State Machine」2024:https://lmsys.org/blog/2024-02-05-compressed-fsm
  • Anthropic 公式ドキュメント「Structured outputs」:https://platform.claude.com/docs/en/build-with-claude/structured-outputs
  • OpenAI 公式ドキュメント「Structured Outputs」:https://developers.openai.com/api/docs/guides/structured-outputs
  • Google 公式ドキュメント「Structured output(Gemini API)」:https://ai.google.dev/gemini-api/docs/structured-output

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「構造化出力の仕組み|制約付きデコーディングを自作して実測」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

成長のヒミツ、 ぜんぶ丸わかり