はじめに:パース可能性は祈りではなく、正しさの属性である

この2年のあいだ、大規模言語モデルを本番システムへ組み込んだほぼすべてのチームが、ある深夜に同種の障害にぶつかっています。モデルが JSON を返すべき場面で、いったん JSON にきわめて似た散文を返す。あるいは tool_calls の arguments フィールドに、末尾カンマのついた、括弧の閉じていない、あるいは中国語の引用符で囲まれたオブジェクトを押し込む。あるいはもっと発見しにくい形で、JSON 自体は完全に合法なのに、temperature の値が文字列の外にはみ出している、列挙値の大文字小文字が合わない、必須フィールドがいつの間にか消えている——しかもその爆発は、下流のどこかの非同期キューの中で、あなたのバリデーション関数を介してようやく起きます。

この種の障害に共通する点は何度強調しても足りません。「モデルが十分に賢くない」という問題ではなく、「インターフェース契約が強制されていない」という問題だからです。プロンプトに「JSON だけを出力して、説明文は書かない」と書くのは要求であって、仕組みではありません。要求は破られうるもの、仕組みは破られません。信頼性をモデルの「従順さ」に委ねるというのは、本質的に決定的なエンジニアリング問題を、確率的なモデルの振舞いという問題に置き換えているだけです。しかも後者はテストもできず、夜中にアラームが鳴っても原因を迅速に特定することもできません。

2026 年の現在、この問題にはすでに相当に成熟した答えの群れがあります。その核心はひとつだけです。サンプリングが終わったあとで出力が合法かどうかを調べるのではなく、各サンプリングステップごとに、不正な token を候補分布から物理的に消し去るのです。これが制約付きデコード(constrained decoding / grammar-constrained decoding)です。「テキストを生成する」という行為は、「言語モデルと形式言語の共通部分という制約のもとで貪欲サンプリングを行う」へと変わり、パース可能性は確率の問題から、エンジニアリング可能な決定性の問題になりました。

ただし制約付きデコードは銀の弾ではなく、明確な代償があり、扱えるシーンも限られます。よって、この記事では三層構成のアプローチを説明します。

  1. token ごとのマスク——デコード時に文法を強制し、FSM や文法(GBNF / 正規表現 / JSON Schema)によって不正な token を遮断する。これが第一層であり、最も信頼できる層です。
  2. サンプリング後の補正——parse-repair + 構造化バリデーション + 有限リトライ。生成が終わったあとに救済する層であり、文法制約では扱えないセマンティックな問題をカバーします。
  3. フォールバック解析——二層とも救えなかったときに、寛容な誤り許容パーサで最後に残った有用情報を絞り出し、信頼度(confidence)も正直に一緒に返す。こうすることで上流がリトライするか降格させるかを判断できるようになります。

三層は択一ではなく、安いものから高いものへ、確定的なものから不確定なものへと続く劣化チェーンです。制約を優先し、バリデーションで受け止め、フォールバックで解析する。この記事の順序はこのチェーンの順序そのものです。まずなぜプロンプトだけでは信頼できないのかを明確に説明し、其次に1層ずつ降りて、最後にすべてのコストを計算して帳尻を合わせます——各層がそれぞれいくらのレイテンシを必要とし、どれだけの失敗を救い戻せるのか、そして 2026 年の具体的な技術スタックの中で、それぞれを誰が実装すべきなのか。

もしあなたが「モデルの出力をそのまま下流へ食わせたい」というシーンを扱っているなら——ツール呼び出し、構造化抽出、SQL とコード生成、設定生成、Agent のアクション空間——、この記事はあなたのためのものです。ただ「JSON をちょっと見やすくしたい」だけなら、最後まで読む必要はない可能性が高いです。

一、第一層の現場:なぜ「JSON を出力せよ」というプロンプトは信頼できないのか

まずツールに手を出す前に、ツールを使わない場合に何が起きるのかを確かめます。この手順を飛ばすと、選定の議論は空中楼閣になります。実際、多くのチームは一度つまずいた後に「制約付きデコードは重すぎる、few-shot を足せば足りる」と結論づけ、別のチームは「エンジン側の制約が必須だ」と結論づけます。この2つの結論はどちらも正しくありうるのではあるが、それはあなたの許容できる失敗率と失敗時のコスト次第です。

1.1 典型的な4つの失敗形態

実際に本番環境で観測された失敗サンプルを分類すると、おおよそこの4種類です。ここで重要なのは、これらが互いに独立だということです。つまりあなたがプロンプトに対して行う最適化は通常、うち1種類にしか効きません。「プロンプトを何種類か書き足す」ことの収穫逓減が速いのはこのためです。

失敗形態具体的な現れ方プロンプトで緩和できるか制約付きデコードで根治できるか
包みの汚染出力の先頭に「はい、お求めの JSON です:」のような一文が付く、または末尾に説明が続く。markdown のコードフェンスで包まれている部分的には。明示的な指示 + few-shot で対応できる。開始トークンと終了トークンを制約することで
文法の破損末尾カンマ、シングルクォート、中国語の引用符、エスケープされていない文字列内の改行、括弧の不一致、生成の途中で max_tokens により切り詰められるほぼできない。これは tokenizer とサンプリングの確率的な振舞いできる。マスキングが不正な文字列を直接遮断する
スキーマの逸脱JSON は合法だが、列挙値が "Search" なのに schema は "search" を要求している。数字が "18" という文字列になっている。日付の書式が統一されていない部分的には。プロンプト内で schema を繰り返すことで制約に明示的にコンパイルされた部分しかカバーできない
内容のハルシネーションJSON は合法でフィールドも揃っているが、値が でっち上げられている。ツール名がツール一覧にない、引数の個数が合っていないできないできない。これはモデルの知識 / 推論の問題

4行目こそ、この記事全体で最も はっきりさせておきたい認知の境界線です。制約付きデコードが治せるのは上の3行のうち文法に関わる部分だけで、「内容が正しいか」に対してはまったく無力です。制約付きデコードを使えばツール名が必ずあなたが与えた候補集合から選ばれることを保証できますが、モデルが正しいツールを選んだことを保証することはできません。これは確率的な品質問題であり、リランキング、ツール説明の最適化、few-shot 対照の追加、もしくは、そもそもモデルの変更といった領域の問題です。

1.2 temperature=0 でも救えない理由

非常に典型的な誤解があります。「モデルが不安定なら、貪欲デコード(temperature=0、top_p=1)にすれば完全に確定する」。この推論には穴が2つあります。

1つ目の穴は、貪欲法はサンプリングのランダム性を消すだけで、分布の中の低確率経路を消してはいない、という点です。モデルが「ある誤った token が 0.3% の確率で存在する」と判断している位置では、貪欲法は毎回その最大の token を選びます——そして、その最大値がまさに誤った token であれば、貪欲法は誤りを決定的に、安定して出してしまいます。多くの「時々パースが失敗する」という本番の問題はオフライン評価で再現できず、一度再現すると 100% 再現します。制約付きデコードの価値はまさにここにあります。0.3% の経路を分布の水準そのもので削除するのであり、単に運が当たらないことを祈るのではありません。

2つ目の穴はより根本的で、貪欲デコードは「モデルが何と言いたいか」を変えずに、「言いたかった語彙の中からどう選ぶか」だけを変える、という点です。コンテキストに目標形式と矛盾するシグナルが多数ある場合——たとえば、few-shot の例を見た時点でモデルが括弧を出力する傾向が失われている場合——貪欲デコードは逸脱を加速するだけ。加えて現実的な要因が2つ重なっています。バッチ処理によって数値 kernel が完全に決定論的ではなくなる(同じリクエストでも batch の組み合わせ方によって logit に微細な差が生じ、top-1 が近いと結果が反転する)こと、そして、モデルのバージョンや量子化水準が動くたびに勝敗が変わりうることです。この2点を合わせると、「温度だけで十分に安定している」という判断は、本番環境の長い時間軸では成り立ちません。

1.3 プロンプト側でできることと、その天井

公平に言えば、プロンプトエンジニアリングは構造化出力においてなお価値があります。ただしそれは単独で正しさを保証するものではないという点が限度です。有効なやり方は通常、次のようなものです。システムプロンプトにフィールド名の一覧ではなく完全な schema を書く。境界ケースを覆う2〜3個の few-shot 例を提示する(特に列挙値、数値、null の書き方)。「markdown のコードブロックを出力しない」と明示的に宣言する。抽出対象の原文を明快なデリミタで囲む。そして生成の直後に「直前の回答が合法な JSON でなければ、まず修復過程を出力せよ」という自己検査の指示を付ける——最後の1項は実践上、驚くほど有効です。モデルは自分の出力に対して強い局所注意を持っているため、自己検査はしばしば同じ文脈の中で破損した JSON を元に戻せてしまいます。

もっとも天井ははっきりしています。これらの手段はいずれも「意図」の層で作用する一方、失敗は「文字」の層で起こります。意図は連続的で、自然言語で記述でき、モデルが実際に学習しているものです。文字は離散的であり、組合せが爆発し、外部の仕組みで保証する必要があります。だから 2026 年の共识となったやり方は、意図と仕組みを分けることなのです。プロンプトはモデルに schema に従って値を埋めたいと思わせる役割、制約付きデコードは出力が必ず文法を解析できることを保証する役割を担当します。両者は競争関係ではなく、補完関係です。

  • 実用的な判断基準:もし「出力がパース失敗する」という再現可能な自動テストケースを書き上げられないなら、失敗モードを本当に理解しているわけではありませんし、どの案が問題を解決したかも判断できません。制約付きデコードに投資する価値が大きいのは、很大程度上、失敗モードが確率的で再現困難なものから、構造上ありえないものへと変わるからです——そして後者だけが CI に書き込めるようになります。

この判断基準を持ったまま、層を一つ下げていきます。デコード時に手を動かすなら、まず「デコード時に何ができるのか」を把握しておく必要があります。

二、token ごとのマスク:「合法」を毎ステップ計算できるものにする

制約付きデコードの考え方は、どこか直感に反するほど素朴です。モデルが最後に出すのは一続きのテキストにすぎません。ならば、token を1つずつ出力する機械だとみなして、各ステップで「この次の文字は、現在の文脈において合法か?」と一度ずつ問えばいい。この判定が十分速く、十分正確であれば、不正な出力が組み立てられることは決してありません。

2.1 確率分布から許可集合へ

具体的には、通常のサンプリングとは、logits ベクトル l を取得し、softmax を通し、temperature と top-p でサンプリングすることです。制約付きサンプリングとは、l を取得し、まず許可 token 集合 A を計算して、A に含まれない位置の logit を -inf に押し下げる、そして通常どおりサンプリングすることです。擬似コードで書くとこういうことです。

import math

NEG_INF = -math.inf

def constrained_sample(logits, allowed_token_ids, temperature=0.0, top_p=1.0):
    # 1) 掩码:非法 token 直接物理性出局
    masked = [logits[t] if t in allowed_token_ids else NEG_INF
              for t in range(len(logits))]

    # 2) 正常采样流程,只在剩下的合法 token 里做
    if temperature <= 0:
        return max(range(len(masked)), key=lambda i: masked[i])

    scaled = [x / temperature if x != NEG_INF else NEG_INF
              for x in masked]
    probs = softmax(scaled)
    return top_p_sample(probs, top_p)

このコードの魔法はすべてステップ1にあります。あとのサンプリングロジックはいつもどおりです。つまりあなたは依然としてサンプリングでき、温度を持ち、多様性を持てます。ただ、その多様性が「合法な出力の範囲内」に制限されるだけです。初めて触る人がよく間違えるのは、制約付きデコードは出力を堅苦しくするという思い込みです。実際にはそうではありません。削られるのは不可能であって、可能性ではありません。本当に堅苦しくなるのは制約が厳しすぎる場合で、これは 2.5 節と次章で述べます。

上の書き方には明らかなパフォーマンス上の問題があります。各 decode step で Python の中で語彙全体を毎回走査する必要があるからです。通常、語彙は数万から十几万の規模なので、step ごとの Python ループがボトルネックになります。よって実務での実装はもっと速くなければなりません。

2.2 高速化:状態キャッシュ、ビットマスク、ベクトル化

よく使われる最適化の手法をひととおり並べておきます。ここは「本番に乗せられるかどうか」を分ける分岐点になることがあります。

  • 状態キャッシュ。制約の状態は離散的かつ有限です。同じ文法状態について許可集合は一度計算すればよく、あとは表引きです。現代の実装はたいてい「まず token の語彙を一度走査し、各 token の文字列表現にスコアを振り、集合をビット集合(bitset)で表現する」という形なので、「交集合を取る」操作は複数の整数のビット演算へと退行します。
  • マスキングを kernel に組み込む。より良い方法は、logits_bias の疎テンソル(系列長 × 語彙数、値は -inf)を直接構築し、追加の入力テンソルとして attention kernel に渡すことです。これで GPU が加算をまとめて一度に行い、CPU のループには一切入りません。SGLang と vLLM の guided decoding はどちらもこの道を通っています。-inf は安全のためには極小の負数(例: -1e9)に変換する必要があり、そうしないと softmax で NaN が出ます。
  • 決定性とキャッシュ。キャッシュを有効にすると CPU オーバーヘッドは大幅に下がりますが、各 step の CPU 作業量が一定でなくなるため、スループットには微小なゆらぎが出ます。これはオフラインのバッチ生成ではどうでもよい一方、SLA のあるオンラインサービスでは負荷試験で観察する必要があります。

この3つを並べてみると、意外にも重要な結論が得られます。制約付きデコードの主なコストはアルゴリズム計算量ではなく、「マスキングをどう構築し、どう渡すか」にあります。同じ FSM 制約であっても、Python で token ごとに走査する実装と、疎テンソルを直接 kernel に食わせる実装とでは、スループットが10倍も違うことがあります。アルゴリズムそのものを選ぶより、エンジンと実装方式を選ぶことのほうが重要です。

2.3 FSM:状態はすなわちスタック

最も基本的で、最も制御しやすい実装は有限状態機械(FSM)です。そのすべての状態とは、まだ閉じられていない記号のスタックと、文字列の中にいるかどうかのフラグ——この2つを合わせても10個の変数にもなりません。遷移関数は文字単位で入力を消費します。開き括弧に遭遇したら対応する閉じ記号をスタックに押し、閉じ記号に遭遇したら pop して一致を検査し、クォートに遭遇したら文字列フラグを反転してエスケープを処理します。そして「現在どの token が許されるか」という問いには、この状態がそのまま答えます。スタックが空でなければ、スタックの先頭と対になる閉じ記号・カンマ・あるいは内容を埋め続けられる開始記号だけを許します。スタックが空なら、完全な JSON 値の開始記号だけを許します。

この状態機械は文法的な正しさの完全な保証です。実装上はひとつ強調すべき詳細があります。token が合法かどうかを判定するとき、最初の文字だけを見ていてはいけません。その token の完全な文字列表現を調べなければなりません。語彙内の1つの token が複数の文字を含む可能性があるからです(たとえば、ある token の表現が 0, であって 0 と , の組み合わせではない、という場合)。先頭文字だけを見ると、合法な token を誤って殺し、同時に真に不正な token を見逃します。これは自前実装で最もよくある bug の出どころです。

ただし、この状態機械は schema をまったく知らないことに注意してください。{"foo": {"bar": [1, 2, "baz", null]}} は、括弧が対応していれば書いてしまいます。「ここは配列でなければならない、要素はすべて文字列でなければならない」を理解させるには、その上にもう一層積み重ねる必要があります——それがまさに第6章のスキーマコンパイルです。そして意味を理解するうえでさらに厄介な問題があります。属性依存(property dependency)です——"type": "error" と書いたなら "message" フィールドが必須で、"value" は許されない。これは文脈依存の文法で、「現在の状態 + スタック」だけでは表現できず、パス依存の制約表で解決する必要があります。

2.4 文法誘導と GBNF:llama.cpp の路線

FSM は正規言語と有限文脈言語しか表現できません。一方、JSON Schema には文脈依存(任意の深さのネストしたオブジェクトと配列)が大量に含まれています。FSM で書くと苦しくなるので、文法誘導(grammar-constrained decoding)の汎用フレームワークが生まれました。文脈自由文法を「終端記号 + 前缀木(トライ木)」にコンパイルし、各ステップで文法の状態から許可集合を計算するというものです。

llama.cpp が選んだ文法記述言語は GBNF(llama.cpp が拡張した BNF)で、文法は非常に素朴なもので、生成規則の組み合わせにすぎません。

# GBNF 示例:一个被约束死的工具调用

root   ::= "{" ws "\"tool\":" ws tool "," ws "\"arguments\":" ws args ws "}"

tool   ::= "\"search\"" | "\"calculator\"" | "\"weather\""

args   ::= "{" ws "\"query\":" ws string ws "}"

string ::= "\"" char* "\""
char   ::= [^"\\\x7F\x00-\x1F] | "\\" (["\\bfnrt] | "u" [0-9a-fA-F]{4})
digit  ::= [0-9]
number ::= "-"? digit+ ("." digit+)?
ws     ::= " "?

設計上の要点がいくつかあります。実用的な文法を書き上げられるかどうかに直接響くので、押さえておいてください。

  • GBNF 自体には実行可能セマンティクスがなく、生成規則しかありません。したがって「ここに "tool": が出なければならない」は表現できますが、「ここの文字列長は 200 文字を超えてはならない」のような計数や数値比較を要する条件は表現できません。このようなセマンティクスは上位のバリデーションに委ねるしかなく、あるいは「参照 + 規則置換」という間接層で無理に組み立てるしかありません。
  • 再帰の深さ。文法は再帰を許可しますが、grammar の状態機械は「あと何段ネストできるか」を判定できなければなりません。さもないと、合法な深いネスト JSON が状態空間をふくらませます。実務では、最大ネスト深さを制限するか、n ステップごとにマスクを再生成するかのどちらかを採ります。
  • 状態キャッシュの正しさ。実装で最も出やすい bug は、マスクは「次の状態」ではなく「現在の状態」で計算しなければならないというものです。取り違えると影響が非常に潜伏します。制約が効いているように見えながら、たまに不正な token を漏らし、しかも極めて再現しにくいのです。

Outlines はより徹底的な道を取ります。まず Python の型ヒント、正規表現、そして一批 の事前定義済み構造を、インデックス化された有限状態自動機にコンパイルし、次に DFA の辺テーブルを各ステップで引きます。Outlines の強みは「一度コンパイルしてキャッシュで再利用」であり、同じ schema を複数リクエスト間で同じ DFA を使い回せば、実行時の追加オーバーヘッドはほぼゼロになります。XGrammar も同じ考え方ですが、Outlines の初期版で弱かった部分を1つ補っています。すなわち文脈を考慮したバイト単位の制約であり、1つの token に複数文字が含まれる場合(temperature が tokenizer によって複数の token に分割されうる場合)を正しく扱えるということです。

  • ここで非常に見落とされがちな落とし穴があります。制約の粒度は token であり、文法の粒度は文字です。ある token のテキスト表現が文法の境界をまたぐ場合(たとえば 1 つの token が 0, であって 0 と , の組み合わせではない場合)、素朴な実装は制約不足(不正な文字が漏れる)か過剰制約(合法な文字が誤殺される)のどちらかになります。XGrammar はこれを明示的に「バイト単位」あるいは「文字単位」のアラインメント問題と呼び、主要な特性として掲げています。自分で制約付きデコードを書くなら避けられないエンジニアリング上のポイントです。

2.5 制約付きデコードの代償:具体的にどれだけ速度が落ちるのか

制約付きデコードにはコストがあります。しかもコストの高さは実装に強く依存し、一概に論じられません。いくつかの分解で見ていきましょう。

  • (1)マスク構築の CPU コスト

各 decode step で、エンジンはそのステップの許可 token 集合を生成する必要があります。「毎回文法を走査し直す」実装(FSM を文字ごとに辿って一つずつ照合する)なら、コストは O(語彙サイズ × token の平均文字数) になります。「事前コンパイル + 状態キャッシュ + 表引き」なら、コストは O(1) に近づきます。前者は語彙の小さいモデルなら辛うじて許容範囲ですが、十万規模の語彙では全体のスループットを明らかに低下させます。幸い、キャッシュのミスは新たな文法状態ごとにしか発生せず、同じ schema の下では頻出経路上の状態数は有限です——これこそ、複雑な schema は使い込むほど逆に「速くなる」理由です。キャッシュが温まってくるからです。

  • (2)logits の後処理を kernel に載せるコスト

マスクは最終的に1回の -inf 加法になります。「{-inf の稠密テンソルを生成してから加算する」なら、各 step で長さ十几万の VRAM を1回書き換えることになります——これは数十マイクロ秒のオーダーであり、通常の1ステップの GPU 計算(数ミリ秒)に対しては通常無視できます。ただし、このわずかなコストを節約するために制約ロジックを主 kernel に押し込むと、コードは著しく複雑化します。実用的な折衷案は、マスクを GPU テンソルとして構築する(真偽値による GPU インデックス、あるいは -inf の稠密テンソルを使う)ことで CPU ループを避けることですが、主 attention kernel の変更のためにやってはいけない、ということです。

  • (3)出力品質への予期しない影響

これが最も見落とされやすい部分です。制約付きデコードはモデルの実際の分布を変えてしまいます。遮断された token の確率が高い場合、それは確率質量を合法な token へ再配分することになり、出力は非制約時よりも「确定的」になります。極端な場合、制約が厳しすぎる文法はモデルが「ごく少数の選択可能な経路」の中で格闘させ、結果として出力品質(書式の正しさではなく)がむしろ低下します。「制約は厳しいほどよい」は誤りであり、制約の目的は不可能を排除することであって、唯一の答えを指定することではないのです。過剰制約の典型的な症状は、出力の書式が完璧である一方、内容が痩せたものになり、繰り返す、あるいは列挙の最初の項目をいつも選んでしまう、というものです。

  • (4)マルチターンの対話における追加の複雑さ

ストリーミングな対話は単一ターンの生成より複雑です。制約付きデコードとストリーミングはほぼ天敵です。制約の許可集合は「これまでに生成された完全な接頭辞」に依存する一方、ストリーミングデコードでは、最初の token を受け取った時点ではユーザーがどれだけ待つか、その先に何が続くのかが分かりません。ここから非常に実務的な設計上の判断が生まれます。ストリーミングのシーンでは、ある一定長に達してから制約を適用するか、あるいはストリーミング段階では制約せず、最終的に連結した文字列に対して解析補正だけを行うか です。だからこそ、多くの API の「JSON mode + stream」は実は最も軽量な括弧バランスしか実装しておらず、完全な制約は非ストリーミングの経路で処理しています。

この4点を合わせると、制約付きデコードの本当のコスト像はこうなります。CPU 側には1ステップごとのマスク処理が1回増え、GPU 側にはテンソルの加算が1回増えますが、どちらも「1ステップ数ミリ秒」という桁の中で薄まってしまいます。その一方で、20% から 60% にわたるスループットの低下を同時に招きます(実装のキャッシュヒット率と制約の複雑度に依存)。さらに、ストリーミングや投機的デコード(speculative decoding)などの最適化と組み合わせる場合には追加の適応が必要です。投機的デコードは特に厄介です——draft モデルは制約の存在を知らず、不正な token を提案してくるので、target モデルが検証の段階でさらに絞り込む必要があります。

「どう強制するか」は以上で終わりました。次節はもっと具体的な問題に答えます。あなたが持っている制約は手書きの文法ではなく JSON Schema です。これをデコーダが使える形にどう変換するのか。

三、制約付きデコードは実際に何をしているのか

ここまでの2章は「どうやるか」でした。この節では、多くの人が飛ばしてもやがて必ず突き当たる、認知的側面を1つ補います。token をマスクしたあと、モデルの確率分布はいったいどう変わったのか。これを理解しないと、制約付きデコードがなぜ時々出力の質を悪化させるのかを説明できず、自分の制約が厳しすぎるかも判断できません。

3.1 これはフィルタリングではなく条件化です

もっともよく使われる直感的なモデルは「フィルタリング」です。不正な token を選び出して捨て、残ったものを元の確率どおりサンプリングする。この直感はある状況では正しいですが、数学的には誤りであり、その差は重要です。

正確な言い方をすると、モデルが現在の接頭辞 c のもとでの token 分布を P(t | c) とし、制約が定める合法 token 集合を A(c) とすると、制約付きデコードが実際にサンプリングするのは、合法集合の上で再正規化された条件分布です。

# 无约束:P(t | c)
# 约束后:P'(t | c) = P(t | c) / Z(c)   当 t ∈ A(c),其中 Z(c) = Σ_{t' ∈ A(c)} P(t' | c)
#           P'(t | c) = 0               当 t ∉ A(c)

# Z(c) 就是被剪掉的概率质量总和:所有非法 token 的概率加起来是多少
clipped_mass = 1.0 - Z(c)

この Z(c)(すなわち clipped_mass)は直接測定でき、しかも非常に有用な量です。モデルが「不正なことを言いたい」多少次衡量しているかを教えてくれます。clipped_mass が長期的にゼロに近いなら、モデルはもともと合法なことを言いたがっていた、その制約は飾りです。逆に長期的に高い(30% 以上など)なら、あなたの制約はモデルの強い傾向と激しく衝突しており、そのとき制約は「誤りを避ける」のではなく、「言いたくないことを言わせる」ことになります。

後者が品質問題のもとです。制約が temperature を 0、1、2 の3つの値のいずれかでなければならないとし、モデルが 0.7 を出力したかったのに、マスクがそれを 2 に強制的に押しやるため、モデルの内部状態は実はずっとこの制約と戦っています。結果として、フィールド値は合法でも、なぜかいつも最後の1つを選んだり、別のフィールドで冗長さや繰り返し発生したりします。これは bug ではなく、条件化のあとに必然的に生じるものです。

  • clipped_mass を監視ダッシュボードに入れてください。出力内容を実際にパース解析せずに「制約が厳しすぎる」ことをオンラインで見つけられる唯一の指標です——そして出力品質の低下こそ、本番環境で自動的に検出するのが最も難しい種類の問題です。

3.2 制約の強度のスペクトル

「制約」という言葉を分解すると、そこにはまるで別物のような範囲のものがまとめて含まれていることがわかります。自分がどの層にいるかを把握しておくことで、無駄な努力をかなり省けます。

階層何を制約するか実現手段信頼性拡張性
リテラル制約ある位置が特定の文字列と厳密に一致しなければならない唯一の token までマスクする、あるいはテンプレート連結で直接書く最も高い最も良い
字句制約整数の数字表現、日付の文字集合、識別子の文字集合文字集合マスク / 正規表現高い良い
文法制約括弧の対応、キーと値の交互、型の構造、列挙の値FSM / GBNF / JSON Schema のコンパイル高い中程度。schema が複雑になると劣化する
セマンティック制約フィールド間の整合性、属性依存、値の範囲、参照の完全性サンプリング後のバリデーションでしか行えないバリデータ次第悪い。ルールが爆発する
意図制約出力の内容が実際に質問に答えているかプロンプト、few-shot、推論、モデルの変更低い制御不能

この表にはきわめて実用的な帰結があります。制約付きデコードの費用対効果は階層が上がるほど下がり、カバー範囲は階層が上がるほど広がります。したがって正しい投資順序はこうです。まずリテラル制約を最大限に使い切る(const で済むなら string を使わない)。次に、コンパイルできる構文に翻訳できるセマンティックなキーワードをすべてコンパイルする。残ったコンパイルできないものはすべてバリデータに任せる。最後にようやくプロンプトを打磨します。多くのチームが最初の2ステップを飛ばして最初からプロンプトを叩き始めるのは、最もコストの高い方法でも最も簡単な問題を解いていることになります。

3.3 反直觉な結論:制約は常に出力を「狭く」するわけではない

初めて触る多くは、制約付きデコードは出力の多様性を減らすので、決定的な出力には制約付きデコードを使い、創造的なタスクには通常のサンプリングを使うのが得当だと考えます。この理解は逆です。少なくとも2つの意味で逆です。

第1に、制約が減らすのは不可能経路であって、可能経路ではありません。schema が6通りの値を許すなら、制約下でも分布は6通りすべてを覆う。比率が再配分されるだけです。実際に多様性を決めるのは schema がどれだけ具体的かです。1つのフィールドに200個の列挙値を持つ schema は、自由な文字列よりはるかに「狭い」。これは制約付きデコードを使うかどうかとは無関係です。

第2に、構造化生成のタスクでは、制約を加えると内容が豊かくなることがあります。これは再現可能な現象です。モデルが自由に生成しているとき、「合法な JSON を出力しなければならない」という書式の負担が注意力を分散させ、内容に使える予算が減ります。一方、書式がデコーダによって保証されると、モデルはすべての注意力をフィールドの値に集中できます。外面から見ると、制約を加えた後は、必須フィールドがより完全込まれ、空文字列が少なく、途中で終わることが減ります。理由は、「すべての必須フィールドを埋めなければならない」というものが確率的の要求から構造的事実へと変わったからです——モデルには「あるフィールドを忘れる」という選択肢がもう存在しません。

帰結として、抽出、分類、要約を構造化フィールドに落とすといったタスクでは、制約付きデコードの主な利益はしばしば「解析率」ではなく「フィールド完全率」です。この指標は解析率よりも注視する価値があります。下流の品質に実際に影響するのはこちらだからです。しかもこれは非制約時代には完全に観測不可能でした。

四、制約付きデコードと他の推論最適化の相互作用

実際の本番環境には「制約付きデコードだけを使う」というエンジンは存在しません。皆がバッチ処理、プレフィックスキャッシュ、投機的デコードを同時に使っていて、これらは制約付きデコードとの間に実際の結合と衝突を抱えています。落とし穴にはまる人は、たいてい負荷試験の段階で初めて気づくからです。

4.1 連続バッチ処理との結合

連続バッチ処理の大前提は「同じ step 内のリクエストの形は、互いに異なっていてもよい」ことです。制約付きデコードが入り込むと制約がもう1層増えます。同じ step 内のリクエストがまったく異なる制約を持ったりするのです。実装には2つの選択肢があります。

  • 制約ごとにグループ化する——制約が同じリクエストを同じ batch に入れ、マスクを1回計算して batch 全体で再利用する。代償として、リクエストはキューの中で同じ仲間の到着を待つことになり、テールレイテンシが悪化する。利点はマスクのオーバーヘッドが薄まることです。
  • リクエストごとのマスク——各リクエストが自分のマスクを持って batch に入る。実装にはマスクがバッチ形状のテンソルとして表現できることが必要です(これが 2.2 節で食べた「疎テンソルを kernel に食わせる」ことです)。主流エンジンが採用している選択で、バッチの柔軟性を犠牲にしないからです。

どちらを選ぶかは制約の多様性で決まります。会社全体で固定の schema が1つだけなら、第1の方法(さらに激的には schema レベルのバッチ処理)でも大きな利益が得られます。ユーザーごとに schema が違うなら、第2の方法が唯一の選択肢です。これは「制約付きデコードは低並行時にほぼスループットを失わないが、高並行では CPU を食う」という現象を直接説明します——高並行とは、まさにリクエストの制約が最も分散し、キャッシュヒット率が最も低くなるタイミングだからです。

4.2 プレフィックスキャッシュとの結合

プレフィックスキャッシュは、複数のリクエストが完全に同一のプレフィックス KV を共有することを要求します。制約付きデコードは prefill 段階を変えません(制約は生成段階にしか作用しません)ので、プレフィックスキャッシュのヒット率は理論上は影響を受けません。ただし間接的な影響が1つあります。制約によって出力の長さがより予測しやすくなるのです。文法が必須フィールドの長さを区間として決めるからです。もともと1つのリクエストが50 token を出力,也可能なら 500 token を出力していたものが、今は一定の範囲に収まります。これによりリクエストのライフサイクルが整い、batch の断片化が間接的に減り、全体のスループットにはプラスの影響となります。

ただし注意すべき罠が1つあります。schema がリクエストごとに違うなら、プレフィックスキャッシュは期待できません。「いまのユーザーは X、今日の日付は Y」のように動的内容を system prompt に書いてしまう人が大多数です。これ自体はキャッシュを破壊しますが制約とは無関係です。ただし「制約を有効にした途端にキャッシュヒット率が落ちた」と調査しているときは、原因を取り違えないように。

4.3 投機的デコードとの結合(最も問題が起きやすい箇所)

投機的デコードは、小さなモデルで高速に候補 token をいくつか提案させ、大きなモデルがまとめて検証します。問題は、draft モデルが通常は制約の存在を知らないことです。自分の好みで k 個の token を提案し、その中には合法でないものが含まれる可能性があります。検証段階では各位置に対応するマスクを適用する必要があり、通過する最長長は、最初に不正な token が出現した位置で決まります。

これは直感に反する性能の崩壊を招きます。draft モデルが制約形式に鈍感で、提案した k 個の token が平均して2個目で不正になるなら、受け入れ長さは常に1となり、投機的デコードは「1ステップあたり1回の forward しか節約できない」場合に退行させ、しかも draft と検証の2回分のコストを追加で支払うことになります。純粋な赤字です。

対処方法は3つあり、推奨順で並べると次の通りです。

  1. draft モデルにも制約を持たせる(同じ grammar を draft に渡す)。受け入れ率が最も高く、実装上は draft と target が同じ制約エンジンを使うことを要求します。
  2. 制約は target の検証時にのみ適用する(主流のやり方)。受け入れ率は落ちますが、draft は通常とても軽いので、全体のスループットは正になる可能性があります。本番化の前に受け入れ長の分布を必ず実測し、accepted_len が k より有意に小さくないかを確認してください。
  3. 長い構造化出力では投機的デコードを切って止める。出力がほぼ確定するよう制約されている場合、draft は役に立ちません。ただし schema が緩い(フィールドが自由テキストなど)場合、投機的デコードの利益はなお残ります。判断基準は出力の総長さではなく、schema の厳しさです。

4.4 小規模モデルとの組み合わせ

見落とされがちな事実が1つあります。モデルが弱いほど、制約付きデコードの利益は大きくなります。7B モデルの書式遵守率は 70B モデルより明確に低いことが多く、このベースラインの上では、プロンプトで得られる利益は極めて小さい一方、制約付きデコードは書式の成功率を「運次第」からほぼ 100% まで直接引き上げます。言い換えれば、制約付きデコードによって「小さいモデルを使う」という決定が再び実行可能になります——制約がなければ、書式が不安定なために大きいモデルを強いられるからです。

ただし小規模モデルにはエンジニアリング上の独自の制約があります。実装レベルの guided decoding サポートは、パラメータ数の大きいモデルで先に実装されることが多い。また、量子化した小規模モデルは、ごく狭い候補集合の中で希に分布の退化を起こします(clipped_mass が高いときに特に)。したがって、アーキテクチャが「小規模モデル + 制約付きデコード」なら、必ずその「切り落とされた確率質量」を監視してください。量子化モデルでは、この値は期待より高くなることがよくあります。

五、構造化編集とパッチ形式

ここまで議論してきたのは「ゼロから構造化されたオブジェクトを生成する」ことでした。しかし 2026 年の制約付きデコードにとって最も価値のあるもう一つの戦場は、構造化編集です。ファイルやオブジェクト全体を書き直すのではなく、モデルにパッチ(patch)の形式で既存の内容を変更させましょう。JSON 生成よりずっと利益が大きいので、この方向は単独で扱う価値があります。

5.1 なぜ編集形式はことに制約を必要とするのか

ありふれたタスクを考えます。モデルにファイル内のバグを直させましょう。モデルに修正後のファイル全体を出力させると3つの問題があります。長くなる(高く遅く)、変更しないつもりのない場所を壊しやすい(編集精度が悪い)、検証できない(diff で何が変わったか推測するしかない)。

そのため、より早期の実践ではモデルに構造化パッチを出力させました。パッチ形式の文法はきわめて単純で、通常は「いくつかの検索・置換の組」で構成されます。

// search/replace 编辑格式:模型只需输出变更部分
<<<<<<< SEARCH
def calculate_total(items):
    return sum(i.price for i in items)
=======
def calculate_total(items):
    return sum(i.price * i.quantity for i in items)
>>>>>>> REPLACE

この形式には3つの特に優れた性質があり、制約付きデコードの価値が JSON のシーンよりもずっと大きくなります。

  • 文法が極めて単純——区切りマークは SEARCH と REPLACE の2つしかありません。制約の書き方は JSON Schema よりも単純で、10数行の正規表現で足り、何のコンパイルも必要ありません。
  • 検証可能——検索部分は原文に逐語で現れなければなりません。これはパッチを適用する前に厳密に検証できます。検索部分が一致しなければ、モデルは存在しないコードをハルシネートしたということであり、その時点で直ちに失敗させたりモデルにリ試行させたりすればよい。ユーザーが発見するまで待つ必要はありません。
  • 数えられる——出力の長さは変更量に比例し、ファイルサイズに比例しません。コスト改善は桁違いになります。

制約の与え方すら JSON より単純です。形式全体が区切りマーク2つで足り、制約規則は「まず SEARCH マークを出力し、任意の行を、等号マークの行まで出力する。次に任意の行を、REPLACE マークの行まで出力する」とまとめられます。正規表現で書けば、マーク行はリテラル、マーク間の内容は「改行以外の任意の文字」の繰り返しです。この規則の正規表現の複雑度はほぼゼロで、コンパイルコストは無視できます。編集系のタスクほど制約付きデコードに適している理由がここにあります。

ここに現れる汎用パターンを注意してください。文法制約は「パッチがどんな形か」を担当し、適用前バリデーションは「パッチがちゃんと当たるか」を担当します。この2つは分離しなければなりません。文法制約は原理的に「このテキストがファイル中に存在しなければならない」という条件を表現できないからです。それには外部の状態を見る必要があります。

5.2 より攻めた形式:制約付き diff

標準的な unified diff 形式をそのまま制約すれば、利益はもっと大きくなりますが実装は難しくなります。複雑な行プレフィックスの文法(context、added、removed、hunk ヘッダ、さらに行番号フィールド)を持っているうえ、hunk 間は厳密な順序を要求するからです。まさに「文脈自由文法」が価値を発揮するところです。hunk の文法を生成規則として書き、生成の過程でマスクを段階的に絞り込んでいく——ある hunk が終了したら、以降の hunk の行番号はより大きくなければならない、という条件は文法の状態の中で表現できます。

ツールごとの做法には明白な違いがあり、それぞれの特徴を知っておく価値があります。

形式のスタイル代表的な做法利点難点
検索・置換の組初期のファイル編集パッチ文法が極めて単純で、制約コストが低いコンテキストが重複すると一意に指定できない
制約付き unified diffhunk 単位の構造化 diff表現力が強く、変更量を自然に計算できる行番号フィールドを厳密に制約する必要があり、実装が複雑
行単位の編集命令行番号に基づく追加・削除・置換の命令構造化編集のモデル(挿入・削除・置換)と自然に契合する行番号は先行する編集によって漂うため、管理が必要
アンカー位置付け行番号ではなくアンカー文字列で位置付けする先行する編集に対してロバストアンカーが一意である必要があり、検証コストが上がる

選定における実用的な判断が1つ。「編集対象がファイル内に何度も現れる」ことがよくある場合(たとえば似ている行が多数ある場合)、行番号系の方式はオフセットの追加管理が必要になり、アンカー方式は唯一性検証の追加実装が必要になります。この2つの追加コストも勘定に入れて、書式そのものの簡潔さだけを比較してはいけません。|format の簡潔さだけを比較してはいけません。

5.3 構造化編集における三重検証

生成のシーンよりも編集のシーンでは、より厳しい検証が要ります。誤ったパッチはコードを壊しますから。三重検証とは、書式(制約付きデコードが保証)、適用可能性(検索部分がヒットすること、置換部分が唯一であるかあるいは意図が明示されていること)、セマンティクス(変更後にコードが動くか、テストを通るか)です。前2つは構造的で安価で、第3初めてが実際に実行を要します。

編集シーンにのみ成立する判断が1つあります。適用可能性検証の失敗率のほうは、書式失敗率よりも問題をよく物語ります。制約付きデコードの後には書式失敗率はすでにほぼゼロになっているはずだからです。適用可能性検証が頻繁に失敗するのを見たら、モデルのコード理解に問題がある(存在しない断片をハルシネートした)ということなので、このときにリトライ回数を増やしても無駄で、モデルの選択や文脈の組み立てに戻るべきです。

六、JSON Schema をデコード制約にコンパイルする

ほとんどのチームの schema の出どころは手書きの GBNF ではなく、既存の JSON Schema 文書です。OpenAPI の定義かもしれないし、データベース構造かもしれないし、単なるインターフェース文書かもしれません。この節では、Schema をデコーダが食える文法にコンパイルする方法と、コンパイルの過程で実際に踏む落とし穴を説明します。

6.1 コンパイルは何をしているのか

コンパイル処理を1文でまとめると、こうなります。宣言的な型制約を、文脈自由の生成規則の集合へと翻訳する。流れは以下の通りです。

  1. 前処理——Schema をパースし、$ref を解決し、$defs をインライン化し、ネストした参照を展開する。この段階で、後続の規則が無限再帰しないかが決まります。
  2. ノードを走査して規則を生成——各ノードについて、それがどの型(オブジェクト/配列/文字列/数値/真偽値/ユニオン)かを判定し、1本の文法規則を生成します。名前はパスの正規化した形にします。たとえば root.properties.user.properties.age です。ノードをまたいで参照される型(たとえば「このフィールドと上のあのフィールドは同じ型」)は、重複して展開するのではなく独立した規則として生成します。こうすると文法が小さくなり速くなります。
  3. 組み合わせと重複除去——子ノードが生成した規則を親ノードに連結し、どの規則からも参照されていない孤立した規則(デッドコードの除去)を削除します。このステップは性能への影響が結構大きいです。文法に規則が1本増えるたび、コンパイル時間と DFA の状態数が線形に増えるからです。
  4. 実行構造へのコンパイル——GBNF はプレーンテキストなので、最後に llama.cpp の grammar 構造に変換する必要があります。Outlines では DFA の辺テーブルへの変換、XGrammar では圧縮されたバイト単位のジャンプテーブルへの変換になります。

6.2 最小限の実用コンパイラ

以下の実装はキャッシュ、$ref の展開、循環検出などのエンジニアリング上の細部を省いていますが、完全なディスパッチのロジックは残しています——そしてそれこそがコンパイラの核心的な難所です。各 Schema キーワードは「文法規則にコンパイルできる」か「コンパイルできないので捨てなければならない」かのどちらかで、この判断はキーワードごとに明示的に書き下す必要があります。本番実装では、これらの「捨てた」項目は通常、警告リストにまとめて呼び出し元へ返されます。

import re
from typing import Any, Dict, List

# 每种基础类型对应的 GBNF 片段(为可读性省略了空白容忍规则 ws?,
# 真实文法需在分隔符后插入它,否则模型无法在逗号后加空格)
PRIM = {
    "boolean": '"true" | "false"',
    "string":  r'"(\\.|[^"\\])*"',
    "integer": r'-?(0|[1-9][0-9]*)',
    "number":  r'-?(0|[1-9][0-9]*)(\.[0-9]+)?',
    "null":    '"null"',
}


class SchemaCompiler:
    """JSON Schema -> GBNF。核心是 _body 的关键字分派:
    能编译的生成产生式,编不了的记入 discarded,交给上层校验兜底。"""

    def __init__(self):
        self.rules: List[str] = []
        self.discarded: List[str] = []

    def compile(self, schema: Dict[str, Any], name: str = "root") -> str:
        self.rules.append(f"{name} ::= {self._body(schema, name)}")
        return self._finish()

    def _emit(self, name: str, body: str) -> str:
        self.rules.append(f"{name} ::= {body}")
        return name

    def _body(self, s: Dict[str, Any], name: str) -> str:
        # $ref:单独成规则以便跨节点复用,避免就地展开导致语法成倍膨胀
        if "$ref" in s:
            return s["$ref"].split("/")[-1]

        # const / enum:字面量枚举,性价比最高的约束
        if "const" in s:
            return lit(s["const"])
        if "enum" in s:
            return " | ".join(lit(v) for v in s["enum"])

        # anyOf / oneOf:编译成选择分支
        for key in ("anyOf", "oneOf"):
            if key in s:
                alts = [self._emit(f"{name}-{key}-{i}",
                                   self._body(sub, f"{name}-{key}-{i}"))
                        for i, sub in enumerate(s[key])]
                return " | ".join(alts)

        typ = s.get("type")
        if typ == "object":
            return self._object(s, name)
        if typ == "array":
            items = s.get("items", {})
            r = self._emit(f"{name}-items", self._body(items, f"{name}-items"))
            return f'[{r} ("," {r})*]'
        if typ in PRIM:
            return PRIM[typ]

        # 编不了的关键字:显式记录,不要默默丢掉
        for kw in ("pattern", "format", "minLength", "maxLength", "minimum",
                   "maximum", "multipleOf", "uniqueItems", "patternProperties",
                   "if", "then", "not", "dependentSchemas"):
            if kw in s:
                self.discarded.append(f"{name}.{kw}")
        return self._any(name)

    def _object(self, s: Dict[str, Any], name: str) -> str:
        props: Dict[str, Any] = s.get("properties", {})
        if not props:
            return self._any(name)
        required = set(s.get("required", []))

        def member(pname: str) -> str:
            sub = self._body(props[pname], f"{name}-{pname}")
            return f'{lit(pname)} ":" {self._emit(f"{name}-{pname}", sub)}'

        # 先必填后可选:JSON 键顺序无语义,强制这个顺序可让语法显著简单
        ordered = [p for p in props if p in required] + \
                  [p for p in props if p not in required]
        seq = ""
        for i, p in enumerate(ordered):
            frag = member(p)
            # 第一个之后的所有成员都要以逗号开头;可选成员整体再套一层
            frag = f'{", " if i else ""}{frag}'
            seq += f' ({frag})?' if p not in required else frag
        return '{' + seq + '}'     # additionalProperties:false 编不出来,靠上层校验

    def _any(self, name: str) -> str:
        """类型未指定时的放行规则:任何 JSON 值都接受。"""
        return self._emit(f"{name}-any",
                          r'"(\\.|[^"\\])*" | -?[0-9]+ | true | false | null '
                          r'| \{[^{}]*\} | \[[^\[\]]*\]')

    def _finish(self) -> str:
        """死代码消除:只保留从 root 可达的规则。"""
        names = {r.split("::=")[0].strip() for r in self.rules}
        reachable, frontier = set(), [self.rules[0].split("::=")[0].strip()]
        while frontier:
            n = frontier.pop()
            if n in reachable:
                continue
            reachable.add(n)
            body = next(r for r in self.rules
                        if r.split("::=")[0].strip() == n).split("::=", 1)[1]
            for tok in re.findall(r"[A-Za-z][\w\-\.]*", body):
                if tok in names and tok not in reachable:
                    reachable.add(tok)
                    frontier.append(tok)
        return "\n".join(r for r in self.rules
                         if r.split("::=")[0].strip() in reachable)


def lit(v: Any) -> str:
    """Python 值 -> GBNF 字符串字面量。"""
    if isinstance(v, bool):
        return '"true"' if v else '"false"'
    if v is None:
        return '"null"'
    return '"' + str(v).replace('\\', '\\\\').replace('"', '\\"') + '"'

このコードには別に議論すべき設計上の判断がいくつかあります。「動く」から「使える」への分岐線です。

  • (1)enum と const は費用対効果が最も高い制約です

これらはリテラルの列挙を直接生成するので、デコーダが正確に強制できると同時に、状態爆発のリスクもほとんどありません。schema の中に出現するフィールドの多くが列挙型(ステータスコード、カテゴリ、アクション種別)であれば、制約付きデコードはこの部分をほぼ完璧に処理でき、コストは極めて低いままです。

  • (2)$ref はノード横断で再利用し、就地展開してはいけません

典型的な反例はこうです。アドレスの schema に street、city、zip があり、ユーザーの schema にもあります。各参照をその場で展開すると、生成される GBNF は何倍にも膨張します。正しいやり方は、参照される型に正規の名前を与えること。1回だけ展開し、ほかの場所では名前を直接参照します。そうすれば「アドレス」型は文法の中に定義を1つしか持たず、コンパイル時間も下がります。

  • (3)再帰的な schema は本当に難題です

JSON Schema の古典的な再帰定義を考えます。{"type":"object","properties":{"children":{"type":"array","items":{"$ref":"#"}}}} は、任意の深さのツリーを表します。これをコンパイルするには node ::= "{" ... "children"... "[" node* "]" ... "}" という規則を生成する必要があります——GBNF 自体はこの再帰をサポートしており、llama.cpp も扱えます。ただし、再帰の深さは文法の階層では制限できません。病的なプロンプトがモデルを無限のネストへ誘い込む可能性があります。生成後にコードでネスト深さを制限するか(JSON を読むときに明示的に数える)、schema のコンパイル段階で再帰を有限層まで展開するか——これは明確なトレードオフの箇所で、私は前者を好みます(再帰は残す。ただしコード側に硬い上限を設ける)。

  • (4)additionalProperties: false はコンパイルできません

これは正直に伝えるべき最も重要な制限です。GBNF は許されるすべてのキーを列挙できますが、「これらのキー以外に他のキーを許さない」という全称否定は表現できません——正規表現の負の先行アサーションを使うか、生成後にバリデーションするかのどちらかです。llama.cpp のやり方は (?!\s*[}\]]) のようなガードを足すものですが、厳密にはこれはヒューリスティックにすぎません。本当に信頼できる additionalProperties: false のセマンティクスは上位のバリデーションでしか担保できません。この点は後の三層アーキテクチャでもう一度登場します。

6.3 コンパイル時に最も見るべき3つの数値

実装が終わることは使えることではありません。本番化の前に少なくともこの3つの量を測定してください。それらは実行時に「一度コンパイルして、半時間かかる」ことが起きるかどうかを決めます。

  • コンパイル時間——schema から GBNF への変換時間。深いネストや大きな anyOf はこれを大きく押し上げます。コンパイルが遅くなって秒単位に達したら、キャッシュ(schema 文字列のハッシュ → 文法テキスト)を実装すべきです。
  • 文法規則の本数と規則の総長さ——この2つが DFA の状態数とキャッシュのコールドスタート遅延を直接決めます。規則が1千本を超える文法なら、最初のリクエストに数百ミリ秒のコールドスタートコストが発生する前提となります。
  • マスク構築の P99——平均値ではありません。制約付きデコードの所要時間の分布は通常極端に長い裾を持ちます。頻出する状態はキャッシュを通りマイクロ秒級、新しい状態は文法全体を走査する必要があり数ミリ秒になりうる。負荷試験で平均値だけを見ると問題を深刻に過小評価してしまいます。
  • 異なるエンジンのスキーマコンパイル経路の比較

同じ XGrammar が異なるエンジンの経路を提供しており、挙動の違いは注目に値します。下の表は実際のテストで観察した定性的な比較です(バージョンの違いで細部は変わりますが、ここでは安定した挙動パターンだけを述べます)。

経路制約が有効になるタイミングスキーマ対応度マルチターン / ストリーミング適用シーン
vLLM(バックエンド切替可)エンジン内、token ごとのマスク中上で、バックエンドに依存対応汎用的な本番サービス
SGLangエンジン内、token ごとのマスク高い対応高並行、構造化出力が必要なサービス
llama.cpp(GBNF)サンプリング層、token ごと文法の表現力に依存特別な処理が必要エッジ配備、CPU/GPU 推論
OutlinesDFA の表引き、一度コンパイル中高で、型ヒントに親切ラップが必要Python 側の組み込み、構造が固定されている
XGrammarバイト単位マスク、一度コンパイル高く、最多くのキーワードをカバー対応カバレッジと速度の両方を両立させたい
guidance-aitoken 単位の制約 + 逆解析高い(正規表現/選択/キャプチャ)ストリーミングに親切複雑な制御フロー、ストリーミング
JSONFormer / lm-format-enforcerローカル制約(token 単位/木のパス)実装次第限定的既存の呼び出しを素早く手直しする

これらのプロジェクトについて得られる経験則が1つ。「対応しているキーワードの数」で選ぶのではなく、「あなたの schema がどういう形か、あなたのトラフィック形態がどうか」で選んでください。schema が単純なオブジェクトといくつかの列挙だけなら、どの実装でも足ります。深い再帰と大きな anyOf があるなら、「一度コンパイル + デッドコード除去 + ノード横断再利用」の3点が実装されているかどうかのほうが、対応しているキーワードの数より重要です。そもそも捨てられたキーワードは上位のバリデーションでしか担保できないからです。

ここまでで「出力は必ず文法を満たす」という課題は完全に解決しました。しかしあなたのシーンに「出力は業務制約にも従わなければならない」要件があるなら——たとえば「status が rejected のときは rejected_reason が必須」——やはりサンプリングの後へ戻る必要があります。記事の後半で扱うのはこの救済経路と、前述のすべての見落としをどう救うかです。

七、サンプリング後の補正:parse-repair、構造化バリデーション、有限リトライ

token ごとの制約があっても 100% の正しさを期待してはいけません。理由は3系統あります。

  • 制約ではコンパイルできないセマンティック制約——前節で述べましたが、additionalProperties: false、dependentSchemas、pattern といったキーワードは GBNF では表現できないため、モデルがそれらに違反した内容を出力する可能性があります。
  • max_tokens による切り詰め——schema が長い配列を許しているのに かなり小さな max_tokens を与えた場合、出力は途中で切れます。これは非常に見落とされやすい失敗源です。モデルは問題なく動いていたのであり、あなたの予算が足りなかっただけです。
  • 制約そのものの誤り——schema と制約が同期していない(schema を変えたのに文法を再生成し忘れている)、あるいは文法にバグがある。这类の問題はコンパイル時には検出できず、エンドツーエンドのテストでしか見つけられません。

したがって第三層の「サンプリング後の補正」は任意のパッチではなく、受け網です。「パース可能」を 99.5% から 100% へ引き上げる層です。

7.1 parse-repair:文法の破損を直す

まず思いつく手段は、モデルの出力を耐障害性のある JSON パーサに渡すことです。json_repair(Python)や jsonrepair(JS)がこれにあたります。その手順は複数の段階に分かれます。

  1. 切り出し——最初の { または [ を見つけ、それより前のすべてを切り捨てます(「はい、求める JSON です:」のような汚染に対する標準的な対策です)。対称に、最後の閉じ記号から先をすべて切り捨てます。
  2. 閉じ記号の補完——スタックの順序に従って、欠けている } / ] を補います。
  3. 句読点の修正——末尾カンマを取り除き、シングルクォートや中国語の引用符をダブルクォートに置き換え、Python 風の True/False/None を JSON のリテラルに置き換えます。
  4. エスケープの補完——文字列内の素の改行、エスケープされていない引用符、バックスラッシュまで含めて、まとめてエスケープを補います。
  5. コメントと余剰テキストの除去——一部の実装は // や /* */ のコメントも剥がします。

この修復でかなり高い割合の破損した出力を救い戻すことができ、コストも極めて低い(純 CPU、マイクロ秒からミリ秒のオーダー)。ただし2つの限界を明確にしておく必要があります。

限界1つめ:意味を変える可能性があります。破損した JSON を「直す」ことと「正しく直す」ことは別のことです。たとえばモデルが {"a": "he said "hi" to me"} を出力した場合、修復器は内側の引用符をエスケープするか(a = 'he said "hi" to me' となり、たまたま正解)、あるいは後ろの部分を切り捨てます(a = 'he said ' となり、静かに誤りになります)。暗黙の誤りは明示的な失敗よりはるかに危険です——したがって修復後は必ずバリデーションし、「修復したら信頼する」としてはいけません。

限界2つめ:切り詰められた意味的内容は救えません。max_tokens により配列の途中で切られた出力に対して、修復器は「合法に見えても配列が短い」オブジェクトを返してきます。文法的には正しく、業務的には誤りです。これは「必須フィールドと配列の長さを確認する」ことでしか検出できません。つまり次の層の責務です。

7.2 構造化バリデーション:スキーマの外のセマンティック検査

バリデーションは2層に分かれる。どちらも書く必要があります。

第1層:JSON Schema バリデーション。このステップは構造、型、必須フィールド、列挙の値、数値範囲を検証します。jsonschema(Python)、ajv(JS)、zod(TS)のどれでも構いません。ポイントはバリデータ制約のコンパイルに用いたのと同じ schema を必ず使うということです。手で GBNF を1つ書き、さらに Pydantic モデルを1つ保守しているなら、この2つは必ずドリフトします。そしてドリフトの方向は通常「バリデーションが制約より厳しい」になります——これは問題ではなく、制約が保守的だという証拠です。危険なのは逆方向です。

jsonschema は既定では format のバリデーション(日時、メール、UUID)を行いません。FormatChecker を明示的に有効にしてください。また type: integer は JSON において 1.0 を通してしまいます(数値的には整数だからです)。厳密に区別したい場合は、自分で検査を追加してください。

第2層:業務のセマンティック検証。この層は schema では表現できないので手書きが必要です。ビジネスロジックと事業システムの手前に実際に立ち塞がるのも、この層です。

  • フィールド間の整合性:end_date > start_date、total == a + b。
  • 属性依存:status == "rejected" のときは reason が必須。
  • 参照の完全性:生成された user_id はデータベース上に本当に存在するか?
  • 業務 enum の時系列の妥当性:状態機械が draft から直接 shipped へ飛んではいけない。

バリデーション結果を、モデルに見せるエラー情報として構成することが、この層で最も価値のある設計です。多くのチームはバリデーションを書いてすぐ例外を投げてしまい、次のリトライの機会を無駄にしています——正しいやり方は、すべてのバリデーションエラーを集め、冒頭数件だけを取り出し、それぞれを「フィールドパス + 失敗の理由」の形で書いて、次のラウンドの文脈に添えることです。たとえば金額が一致しない場合、返すべき内容は「"/total: 各項の合計と一致しない」のような明確な1行であり、「出力が不正です」という言葉の羅列ではありません。

エラー情報に JSON Pointer(/reason、/end_date)を含めるという小さな細部に注意してください。モデルは「どのフィールドが間違っていたか」に対する感度こそ「全体を書き直す」よりはるかに高く、失敗した出力全体ではなくエラーパスのみ戻すようにすれば、リトライの成功率が明確に上がり、prefill の token コストも節約できます。

7.3 有限リトライ:これはループではなくアーキテクチャです

「成功するまでリトライする」は罠です。本番環境での失敗モードはきわめて明確です。いずれか下流の上流サービスが、モデルには直せないエラーを返し続け、あなたのサービスはそこで無限リトライし、token 予算を焼き尽くし、遅延を SLA の外へ押し出します。したがってリトライには硬い境界が必要であり、その境界はライブラリの内側でこっそり決めるのではなく、呼び出し側が明示的に指定すべきです。

明示的に設定しなければならないものが3つあります。

  • 最大試行回数——最初の1回を含めます。超えたらエラーを投げ、呼び出し側に判断を委ねます。
  • token 予算の総量——回数より重要な硬い制約です。回数が多くても毎回長いテキストを生成すれば、やはり予算を焼き切ります。
  • エラーフィードバックの形——リトライ時に直前の失敗出力を含めるか。含めればモデルには対照界の先があります(が、prompt は長くなる)。含めなければ prompt は短い(が、モデルは間違えた場所を知らない)。実務上、エラーの要約を含めるが、失敗出力の全文は含めないというものが最良の折衷案です。
import json
import logging
from dataclasses import dataclass, field

logger = logging.getLogger(__name__)


@dataclass
class GenPolicy:
    max_attempts: int = 3
    token_budget: int = 4000        # 跨所有尝试累计
    backoff_base_s: float = 0.5     # 指数退避,缓解服务端瞬时过载
    include_prev_error: bool = True


@dataclass
class Attempt:
    ok: bool
    obj: object = None
    error: str = ""
    used_tokens: int = 0


def generate_valid(generate_fn, schema, policy: GenPolicy, ctx: str = "") -> object:
    """
    generate_fn(prompt: str, max_tokens: int) -> (raw_text: str, tokens_used: int)
    抛出的所有异常都视为失败,进入下一轮重试。
    """
    prev_error = ""
    spent = 0

    for attempt in range(1, policy.max_attempts + 1):
        remaining = policy.token_budget - spent
        if remaining <= 0:
            raise RuntimeError(f"token budget exhausted after {attempt-1} attempts")

        prompt = build_prompt(schema, ctx, prev_error if policy.include_prev_error else "")
        try:
            raw, used = generate_fn(prompt, max_tokens=min(1024, remaining))
            spent += used

            ok, obj, err = parse_with_repair(raw)
            if ok:
                ok2, err2 = validate_and_describe(obj, schema)
                if ok2:
                    logger.info("valid on attempt %d, %d tokens", attempt, spent)
                    return obj
                err = err2                       # 结构合法但语义不合法

            prev_error = err
            logger.warning("attempt %d failed: %s", attempt, err[:200])

        except Exception as e:                   # 网络、限流、解析异常都归到这里
            prev_error = f"generation error: {e}"
            logger.warning("attempt %d raised: %s", attempt, e)

        if attempt < policy.max_attempts:
            sleep(policy.backoff_base_s * (2 ** (attempt - 1)))

    raise ValueError(f"failed after {policy.max_attempts} attempts, {spent} tokens: {prev_error[:300]}")

見落とされがちな細部が2つあります。リトライの遅延は累積する(3回リトライ+バックオフでは、遅い応答のもとでは簡単に 10 秒を超える)ので、エンドツーエンドの遅延に SLA があるなら、リトライ回数は遅延予算から逆算すべきです。勘で「3」と決めてはいけません。リトライは下流の負荷を増幅します。成功率 80% の API に3回リトライを足すと実効成功率は 99.2% になりますが、下流が受け取るリクエスト量は 25% 増えます——このコストは事前に下流と整合させておく必要があります。

  • 経験則:1回の生成の成功率が 95% を超えていれば、リトライはほぼ常に割に合います。60% を下回っているなら、3回リトライしても救い戻せません。やるべきなのは、制約を変える、プロンプトを変える、あるいはエラーをそのままユーザーに見せることです——正直なエラーは、見かけ上は正しくて実際は誤ったデータよりはるかに役に立ちます。

7.4 parse-repair の「副作用」に注意

最後に、実際に踏んだ罠を1つ。修復器はあなたのエラー率グラフを不恰好にします。破損した出力が修復されたことで「成功」と見なされるためです。監視上はパース失敗率が低く見えます。しかし業務バリデーションの失敗率は上がっています——修復された出力の多くは、文法は正しいのに意味が誤っているからです。第1層だけを監視していればすべて正常に見えますが、実際には上下流が静かに書き換えられたデータを受け取っています。

したがって計测は層ごとに分けなければなりません。パース失敗率(1回目でパースに成功した割合)、修復率(repair が必要だった割合)、セマンティックバリデーション失敗率——3つとも見てください。修復率が急に上がること、制約層に問題がある(たとえば max_tokens が小さくされたなど)ことを意味することがよくあります。これはとても良い早期警戒のシグナルになります。

ここまで書くと、単独の技術はほぼ説明が終わりました。残る中核の問題は、これらを本番に載せられるアーキテクチャへまとめる方法です——次節の内容です。

八、ツール呼び出しシーンの特殊な点

ツール呼び出し(function calling / tool use)は制約付きデコードの典型的な展開シーンですが、制約の形状が通常の JSON 生成とは3点重要な違いがあり、個別に扱う価値があります。

8.1 外側は列挙型で、内側がようやく schema です

1回のツール呼び出しの出力形状はおおよそこうです。外側に1つのオブジェクトがあり、その中にツール名のフィールドと引数のフィールドがあり、引数のフィールドの値はまた別の構造です。こうやって1層ずつ入れ子になり、しかも各層の値集合が異なる構造を文法として書く場合、規則がどの程度の規模になるかは想像できます。

これは、制約をツール名ごとにディスパッチする必要があることを意味します。まず tool に列挙制約を付け、search_docs に確定した段階で、arguments の制約をそのツールの引数 schema に切り替える。単なる「1つの大きな文法ですべてのツールをカバーする」のではなく、接頭辞に応じて動的に絞り込まれる文法です——幸い、token ごとのフレームワークはこれを自然にサポートしています。"tool": "search_ という数 token が出た時点で、マスクは docs だけを残せます。

この機能は見た目以上の価値があります。ツール選択の誤りがデコード時に解消されるからです。制約がなければ、モデルはとっくに廃止したツールを呼ぶ可能性があり、search_docs を camelCase に綴り違えることもあります。列挙制約があれば、そのような出力そのものが存在しません。

8.2 複数ツールの並列呼び出しでは、より厳しい制約が必要です

1回の応答で複数の呼び出しを返す(tool_calls 配列)ことをサポートするシーンなら、文法は「配列内の各要素の tool フィールドがそれぞれ制約される」ことを表現しなければなりません。やり方は「名前 + 対応する引数」を全体を1つの分岐に展開することです。名前を先に選び、引数を後で選ぶ、という順序にはしません。

「名前 + 対応する引数」を1つの分岐としてひとまとめにするのは正しい判断ですが、代償は、ツール数が2倍になると文法の規模が線形に膨らむことです——10 ツールなら10の分岐で、各分岐が完全な引数 schema を持ち、コンパイル時間と状態テーブルのサイズをやり直す必要があります。ツールが二三十を超えるあたりから、この段階の制約の費用対効果は下がり始めます。より良い戦略は「2段階」に変えることです。第1段階ではツール名だけを制約する(小さな列挙文法で、コストは極めて低い)。第2段階でツールが確定してから引数に制約をかける(2回目の独立リクエストを使ってもよい)。

8.3 サーバー API レイヤーはすでに何をしてくれているか

2026 年の現在、主なモデルサービスはほぼすべてサーバー側の構造化出力機能を提供しており|エンジンに自分で接続しなくても制約付きデコードの利益の一部を享受できます。実際には各社のカバレッジの差が大きいので、この階層を知っておく価値があります。

  • JSON mode(最も弱い):出力が1つの JSON 値であることを保証するだけで、どの schema にも適合することを保証しません。典型的な実装は「{ を見つけたら制約を始め、対応する } に来たら終わる」であり、サンプリングロジックを少し変えるだけの場合もあります。解決できるのは包みの汚染と文法の破損で、スキーマの逸脱は解決できません。
  • Structured outputs / json_schema(比較的強い):schema を受け取りサーバー側で文法レベルの制約を行います。OpenAI の structured outputs の導入後に業界標準となった做法であり、最も使う価値のある水準です。 既知の制約は通常、schema のサブセットしかサポートしないこと(minLength/maximum/pattern のように文法にコンパイルできないキーワードは非対応で、additionalProperties: false の厳密な版も非対応)、という点は、ドキュメントで事前に確認してください。
  • 厳格な関数呼び出し:structured outputs の上に、ツール名と引数 schema の結び付けを1層足したものです。本質的に 8.1 節で説明した連動制約そのものです。

したがって務実的な選択の順序はこうです。まずサーバー側の機能を使う(最もコストが低く、運用も最も楽)。それがニーズを満たさない部分だけを確認したら(たとえば必要な schema キーワードをコンパイルできない、あるいはストリーミングが必要)、自前構築を検討します。自前構築とは、エンジンのバージョン紐付け、キャッシュのプリウォーム、失敗モードのbug切り分けという長期的なコストを自分で背負うことを意味します。これは小さな話ではありません。

8.4 見落とされがちな安全境界

制約付きデコードには、ツール呼び出しの中で見落とされながらも重要な役割があります。「引数インジェクション」のリスクの一部を解消できるのです。悪意ある入力が delete_user を呼ぶようモデルを誘導するシーンを考えてください——列挙制約によって tool は常にあなたの与えた値しか取れませんが、あなたの合法なツールの中に悪意ある path 引数をモデルが埋めることまでは防げません。文法的に合法 ≠ 意味的に安全、ということです。

したがってツール呼び出しの最後の関門は常にこれです。引数のホワイトリスト検証 + 権限チェック + 高リスク操作に対する人間確認あるいは冪等キー。制約付きデコードによって「すべての戻り値をパースする」から「意味の検証だけをする」へと降格できる、これは大きな利益です——ただし「降格」を「やらなくてよい」と誤解してはいけません。

九、三層の受け網を組み合わせた実践

さて、三層を組み立てましょう。これが記事全体で最も実用的な部分です。そのまま写せる骨格を示します。

9.1 意思決定表:どの層を使うか

シーン第1層 制約第2層 バリデーション+リトライ第3層 フォールバック解析理由
ツール/関数呼び出し必須必須(意味的な安全)記録のみ、返さない降格された引数は危険な副作用を起こしうるため、決して静かに実行してはいけない
SQL 生成必須(テーブル名・列名の列挙 + AST の解析)必須(EXPLAIN 検証、読み取り専用チェック)人手による調査のためだけ誤った SQL は破壊的で、かつ文法的に合法な危険な SQL は非常に多い
構造化情報抽出推奨/td>必須返してよい(confidence を付け、低信頼度なら人手上へ)降格出力は業務に無害であり、信頼度を明示するだけでよい
ストリーミング UI レンダリング困難(2.5 を参照)困難(出力が未完了)ほぼこれ頼りストリーミングと制約は本質的に衝突し、接頭辞レベルの耐エラーレンダリングしかできない
JSON モードの対話オンでもオフでもよい任意必須失敗のコストが低く、寛容なパーサ1つで足りる
オフラインの一括処理必須必須オフ可(失敗時の再実行に変える)遅延の圧力がないので、成功するまで安心してリトライできる

この表の実質的な意味はこうです。第3層は「常に有効」ではなく、失敗のコストで決まります。コストの低いシーンでは有効にし、コストの高いシーンでは、失敗させるよりも降格を選びます。この判断はあらゆる技術的細部よりも重要です。

9.2 完全な骨格

import json, logging
from dataclasses import dataclass

logger = logging.getLogger(__name__)


@dataclass
class StructuredResult:
    value: object            # 解析后的值
    confidence: float        # 0.0-1.0
    layer_used: int          # 1=约束直接通过 2=校验修复 3=降级
    errors: list             # 收集到的所有问题
    degraded: bool           # 调用方据此决定是否信任


def generate_structured(generate_fn, schema, constraints, *, max_attempts=2,
                        token_budget=4000, allow_degrade=True) -> StructuredResult:
    """
    三层组合:约束解码 → 校验重试 → 降级解析
    generate_fn(prompt, max_tokens, gbnf_grammar) -> (raw_text, tokens)
    """
    gbnf = constraints.compile(schema)          # 第0步:schema → 语法(应带缓存)
    errors = []

    for attempt in range(1, max_attempts + 1):
        remaining = token_budget
        if remaining <= 0:
            break

        # ---------- 第1层:约束解码 ----------
        raw, used = generate_fn(
            build_prompt(schema, ctx=errors if attempt > 1 else ""),
            max_tokens=min(1024, remaining),
            gbnf_grammar=gbnf,                   # ← 约束在这里生效
        )

        # ---------- 第2层:解析 + 修复 + 校验 ----------
        ok, obj, err = parse_with_repair(raw)
        if ok:
            valid, err = validate_and_describe(obj, schema)
            if valid:
                return StructuredResult(obj, 1.0, 1, errors, False)
            # 结构对但语义错 → 记录错误,进入重试
        errors.append(err)

    # ---------- 第3层:降级解析 ----------
    if not allow_degrade:
        raise ValueError(f"all {max_attempts} attempts failed: {errors}")

    best = salvage(raw)                          # 从最后一次输出里榨出可用字段
    if best is None:
        raise ValueError(f"degraded parse also failed: {errors}")

    return StructuredResult(best, 0.4, 3, errors, True)


def salvage(raw: str):
    """
    降级解析:用宽松的提取器把能拿的字段拿出来。
    例:正则抓 key/value 对,容忍括号不闭合。
    永远要标 degraded=True,让上游决定是否信任。
    """
    pairs = re.findall(r'"(\w+)"\s*:\s*"([^"]*)"', raw)     # 先抓字符串字段
    nums  = re.findall(r'"(\w+)"\s*:\s*(-?[\d.]+)', raw)    # 再抓数字字段
    if not pairs and not nums:
        return None
    out = {}
    for k, v in pairs:
        out[k] = v
    for k, v in nums:
        try:
            out[k] = float(v) if "." in v else int(v)
        except ValueError:
            pass
    return out

このコードの設計上の要点を別に強調しておきます。これらが本番での信頼性を決めるからです。

  • constraints.compile(schema) はループの外に置く——文コンパイルは1回だけで、全リトライが共有します。リトライのコストを抑える要の鍵です。
  • 制約とバリデーションは重複ではなく補完の関係です。制約は「構造が必ず正しい」を保証し、バリデーションは「構造が正しく内容が妥当」を保証します。前者だけでは、モデルは必須フィールドが欠けた合法 JSON を安定して出し続ける可能性があります。後者だけでは、失敗のたびに1回の完全な生成を浪費します。
  • 降格結果には必ず印を付ける——degraded=True と confidence=0.4 は契約の一部です。呼び出し側がこの印を無視して降格データを使えるなら、第3層は有害です。「使えそうに見える」データによって1回の本当の失敗を覆い隠してしまうからです。
  • 降格解析は保守的に——確実に取れるフィールド(正規表現がマッチする key-value)だけを取り出し、欠けた値を「推測」しようとしてはいけません。salvage が返すオブジェクトは決して元の意図の完全な復元ではなく、best-effort の近似です。

9.3 制約キャッシュ:必ずやっておく小さな最適化

schema が固定されているなら(よくあることで、例えば固定のツール呼び出しプロトコル)、compile(schema) の結果をそのままキャッシュできます。本番では、schema が数種類のバリアントしかないケースが多く見られます(たとえばユーザーロールで3種類に分ける場合)。そこで、schema のハッシュをキーにした LRU キャッシュを使えばよいです。キャッシュの利益は複合的です。コンパイル時間を省けるだけでなく、エンジン側のマスク状態キャッシュも温かいまま保たれ(毎リクエストがコールドパスを辿らずに済む)からです。

実務から得た教訓が1つ。schema に動的内容を押し込むと(たとえばユーザー ID を const に書いて per-user のリテラル制約にする)、キャッシュのヒット率は 100% ゼロになり、コンパイルコストが全リクエストに帰着します。したがって制約の動的な部分は、可能な限り「schema 期の特殊化」ではなく「生成期の注入」にしてください——後者は、見栄えはよいのですが、実質的には毎リクエスト再コンパイルになります。

十、レイテンシの代償:このコストを正確に計算する

すべての技術判断は、最後には1つの数値に落脚します。各層がそれぞれ P99 レイテンシをどれだけ増やしたか。以下は典型的な構成での見積もり方法です(具体的な数値はハードウェア、バッチ、モデル規模に強く依存するため、必ず自分で必ず自分で実測してください)。

10.1 コストの分解

コスト項目発生タイミングオーダーの目安最適化可能か
Schema → GBNF のコンパイル毎リクエスト(またはキャッシュミス時)小スキーマならサブミリ秒、大スキーマなら数十ミリ秒可能であり、かつすべき
マスク状態の初回構築新しい文法状態ごと、コールドスタート時マイクロ秒からミリ秒、長尾キャッシュする。平均値ではなく P99 を見る
毎ステップのマスク適用各 decode stepCPU 側はマイクロ秒級、GPU 側は加算GPU テンソルを使い CPU ループを避ける
追加の prefillリトライ時エラー反復の長さに比例エラーの要約だけを戻す
リトライ全体失敗時倍数のオーダー(1回 = N 倍)遅延予算から回数を逆算する
バリデーション(parse + schema + セマンティクス)各生成の後サブミリ秒から数ミリ秒通常はボトルネックにならない
フォールバック解析失敗経路のみマイクロ秒級無視できる

桁(オーダー)を変えうるのはリトライとマスク適用の2項目だけで、残りは小数点以下のノイズです。この認識は重要です。最適化の優先順位を決めるからです。バリデーションを 2ms から 0.5ms に短縮するために労を使うより、リトライ成功率を 80% から 95% へ引き上げるほうが直接には効きます——後者は P99 を「2倍のレイテンシ」から「1倍」に直接戻してくれるからです。

10.2 リトライによるレイテンシの増幅効果

1回の生成のレイテンシを T、成功率を p、最大試行回数を N、バックオフの総コストを B とします。すると次の通りです。

# 期望尝试次数(直到成功或耗尽)
E[attempts] = (1 - p^N) / (1 - p)      # p < 1

# 期望端到端延迟(含退避,不含限流等待)
E[latency] ≈ E[attempts] * T + B

# P99 延迟:取决于尾部,通常由最坏路径决定
# P99 ≈ N * T + B(当 p^N 不够高时)

具体的な数字を挙げてみます。T = 800ms、p = 0.92、N = 3。期待試行回数は約 2.62、期待レイテンシは約 2.1 秒です——一方、1回で成功すれば 0.8 秒です。つまり、失敗率を 8% から 0.05% まで下げるには、平均レイテンシ 2.6 倍という代償を払っていることになります。

この 2.6 倍はリトライ戦略の本質的なコストで、逃れることはできません。唯一の最適化の方向はp を上げること(より良い制約、より長い max_tokens、より明確なプロンプト)です。N を大きくすることではありません。N を 3 から 5 に上げても、期待試行回数は 2.62 から 2.75 に変わるだけで(ほとんど変わりません。失敗はすみやかに「永遠に失敗する」ことに収束するからです)、P99 レイテンシは 67% も増えます。

  • リトライの予算を決める正しい順序はこうです。まず許容できる P99(たとえば 3 秒)を計算し、N = (P99 - B) / T から逆算して N = 2 か 3 を得ます。次に p を 0.85 から 0.95 へ引き上げる方法を考えます(これは通常、制約と max_tokens の修正によって実現でき、丸ごと1回分のリトライを節約できます)。最後に N を増やすかどうかを考えます。勘で N=5 と決めるのがもっともよくある誤った判断です。

10.3 スループットから見た視点:制約付きデコードはエンジンを実際にどれだけ遅くするか

レイテンシは単一リクエストの視点、スループットは複数リクエストの視点で、両者の結論は異なりえます。制約付きデコードがスループットに与える影響は、主に次の点から来ます。

  • 毎ステップの CPU マスク処理——高並行では、すべてのリクエストのマスク処理が同じ(あるいはごく少数の)CPU コアを直列に占用し、ボトルネックになりがちです。これは制約付きデコードで最も一般的なスケール性のボトルネックであり、実際に負荷をかけたときにだけ露見します。
  • 状態キャッシュのヒット率——トラフィックの中で schema が分散していれば、キャッシュは絶えず叩き出され、ヒット率は下がり、コストは上がります。制約付きデコードが「トラフィックが増えるほど遅くなる」という現象をもたらす唯一の工程です。
  • KV cache の再利用——これは見落とされがちなプラスの影響です。強制された構造化出力は出力をより短くより予測可能にし、その結果リクエスト間の KV 再利用率が高くなり、KV キャッシュが効きやすい条件ではスループットが逆に上がります。

負荷試験で QPS の曲線だけを見ると、誤解を招く結論になります。3段階に分けて測定することを推奨します。低並行(CPU マスクのオーバーヘッドを検証)、中並行(キャッシュヒット率を検証)、高並行(スケール性を検証)。実務でよくあるのは、低並行では制約付きデコードのスループット損失がほとんどない(オーバーヘッドが GPU 計算に埋もれる)、高並行では CPU 側がきつくなる——というパターンがよくあります。その時点でさらに並行が必要なら、第一選択はマスクロジックを GPU に移すか状態共有を行うことです。カードを足すことではありません。

10.4 ストリーミングシーンの特殊な計算

ストリーミングな制約付きデコードのレイテンシ像はまったく違います。TTFT(首 token 遅延)はストリーミングでは非ストリーミングより遅くなります。制約の初期状態のマスクを先に1回計算する必要があるためです。一方、TPOT(出力 token あたりの遅延)はむしろ速くなる可能性があります。token の間にネットワークとレンダリングの間隙があり、マスクの CPU コストがその間隙に隠されるからです。

したがってストリーミングのシーンでは、判断基準は TTFT ではなく「ユーザーが感じる滑らかさ」であるべきです。TPOT が数十ミリ秒以内に安定していれば、ユーザーは制約の存在を感じません。これは逆直觉ですが非常に実用的な結論です。

十一、よくある落とし穴のチェックリスト

これらの落とし穴を個別に挙げておきます。多くはエラーにならず、「静かに機能していない」だけだからです。

  • マスクが誤った状態を使った。「次の状態」ではなく「現在の状態」で許可集合を計算している。症状は不正出力が散発的で、極めて再現しにくい。対策:step() の前に許可集合を取り、step() の後に取る。
  • tokenizer が文字境界をまたぐ。token のテキスト表現が文法の境界をまたぐことがあります(1つの token のなかに关键字の末尾と値の先頭が同居している)。必ず token の完全な文字列で判定し、先頭文字だけを見てはいけません。そうしないと制約不足か誤殺のいずれかになります。これが XGrammar が「バイト単位のアライメント」を強調する理由です。
  • schema と制約が同期していない。schema を変えたのにキャッシュ済みの文書を更新し忘れた。対策:schema のハッシュをキャッシュ key の一部にし、schema 変更時に明示的にキャッシュを無効化する。
  • max_tokens が小さすぎる。schema は長い配列を許すが予算が足りず、出力が常に切り詰められる。すると「なぜ repair でも直らないのか」と訝りになります。対策:schema から下界を見積もる(最大限に何 token 入るかを数える)、少なくとも2倍の余裕を持たせる。
  • repair の後にバリデーションしない。これがいちばん危険です。直った JSON は文法的には正しいが意味的に誤り、データが静かに破損します。対策:repair の後は必ず schema バリデーション + 業務バリデーションを通す。
  • 降格結果が本物として扱われる。呼び出し側が degraded 印を無視する。対策:降格データを独立したフィールドに置く、あるいはそもそも返さない——「降格データを使う」ことを、呼び出し側が能動的に行う必要のある明示的な判断に変える。
  • 再帰的な schema に深さの上限がない。モデル(あるいは悪意ある入力)が深いネストを誘導し、スタックオーバーフローや極端に長い出力を生じる。対策:パース時に最大深度を設け、超過したら捨てる。
  • 層を分けた監視がない。「最終失敗率」だけを監視しており、パース失敗・repair 成功・セマンティック失敗の層構造が見えず、問題が起きたときにどの層が壊れたかを特定できない。

十二、本当に有効であることをどう証明するか:評価の方法論

制約付きデコードは、しばしばこのジレンマに陥ります。投入した直後は「なんとなく安定した」のような感覚を感じるのですが、実際にどれだけ安定したかを誰も言えません。理由は、この改善の性質にあります——改善されるのは低頻度で散発的で再現しにくい失敗であり、計測がない状態では成功と非成功の差が日常のノイズに埋もれてしまうからです。この節では本当に定量化できる方法を示します。

12.1 必ず分けるべき3つの指標

最も重要な認識はこうです。「出力が正しいか」は複合命題であり、3層に分けて見る必要があります。しかしほとんどのチームは第1層しか見ず、後続の問題が完全に不可視になっています。

指標定義制約付きデコードの影響なぜ単独で見る必要があるのか
書式の一発合格率修復なしで直接パースでき、かつ schema に適合する割合大幅に向上する。これが主な利益第1層の健康度を反映できる唯一の指標
修復後の合格率parse-repair 後に通過する割合小幅に向上する高すぎると第1層が失効しており、モデルが受け網に依存している
意味的有効率構造検証を通過したうえで、内容も業務制約を満たす割合通常は小幅に向上(フィールドがより完全になる)これが業務が本当に気にしている指標

この3つの指標を合わせて見れば、完全な物語を読めます。たとえば書式の一発合格率 99%、修復後合格率 99.5%、意味的有効率 70% ならば、制約層は正常に働きしていて、業務問題は別の場所にあることがわかります。一方、書式の一発合格率 60%、修復後合格率 99% であれば、これは明確な警告です。第1層がほとんど機能しておらず、あなたの「安定」はすべて repair で買ったものであり、しかも repair で買った安定の一部は静かな意味的エラーだということです。

12.2 制約カバレッジのテスト

これは最もスキップされやすいものの、価値が最も高いテストです。考え方はとても単純です。schema のすべてのキーワードについて「制約が有効なら、モデルは絶対にこれを違反できない」というテストケースを書き、実際にそれが侵犯されないことを検証します。

例を挙げましょう。あなたの schema に "age": {"type": "integer"} があるとします。対応するテストケースはこうです。強い誘導プロンプト("age フィールドに 25.5 歳を出力してください")を構成して出力を検査します。制約が有効なら、モデルが小数を出力することはありません——26 を出力するか、文字列を出力するか、あるいはこのフィールドを捨てるかのいずれかであって、25.5 にはなりません。このテストを走らせて初めて、制約が本当につながっているかどうかが分かります。

同じ考え方に基づく他のテストケース:

  • 列挙の全数テスト:schema に5つの値を持つ enum がある。5つのそれぞれについて、「6番目の値」(enum にない)を強く示唆するプロンプトを構成し、出力が5つの値に制限されているかを確認する。
  • 必須フィールドのテスト:schema に10個の required フィールドがある。20回実行し、毎回10フィールドすべてが存在するかを確認する。
  • 再帰深さのテスト:schema が再帰的なら、深いネストを誘導するプロンプトを使い、出力の深さが制御されているかを確認する。

この一連のテストの本当の価値は、制約が失効した瞬間に警報を鳴らせることです。本番環境で解析失敗率が緩やかに上昇するのを待ってから気づくのではなく。CI の一部に組み込むのは、この方法論の中で ROI が最も高いエンジニアリングアクションです。

12.3 逆方向の検証:意図的に制約を壊して出力が変わるかを見る

「制約が有効なとき ought to beこうなる」に加え、「制約が有効でないとき ought to beこうなる」もテストすべきです——これはより厳しい検査です。「動いているように見えるが、実際には動いていない」実装バグを発見できるからです。

やり方はこうです。schema を1つ取り、ある enum の値を1つ減らします(たとえば5つを4つに変える)。そして同じ誘導プロンプトを流します。

  • 出力が実際に変わった(以前は出せた5番目の値が今 出ない)なら、マスクが本当にその位置に作用していると分かります。
  • 出力がまったく変わらなかったなら、制約がまったく効いていない——これは「制約が比較的緩い」ではなく、明らかなバグです。

このテストが捉えられるのは、もっとも隠蔽性の高い種類のバグです。マスクが誤った状態を使っていたもの(第2版の 8.1 節でこの落とし穴に触れました)。この種のバグの症状は「制約はたいてい動くが、たまに不正な token を漏らす」というもので、通常のテストではまず発見できません。しかし逆方向の検証なら安定して引き出せます。

12.4 リグレッションセットとスナップショット

2つを資産として固定すべきです。

実際のプロンプトのリグレッション集。本番から数百件の実リクエストをサンプリングし(匿名化に注意)、テストセットとして固定します。これが測るのは「あなたの実トラフィックでの」効果であり、人為的なテストケースでの効果ではありません。実トラフィックにおける schema の分布、長さの分布、誘導の強さの分布は、人為的なテストケースとは著しく違うことがよくあります。

制約のスナップショット。schema → grammar のコンパイル結果をスナップショットテストにします。schema が不変なら grammar のテキストは完全に同一でなければならず、schema が変われば grammar は期待どおりに変化しなければなりません。このテストの価値は、コンパイルロジックが誤って変更された瞬間に失敗する点にあります。コンパイラは数百行の純関数であり、「ついでにリファクタリング」で最も壊れやすいものですし、この種の破損は機能テストでは、某些の辺境にあるスキーマが黙って制約を失う程度にしか現れないことがあります。スナップショットなら最初に気づけます。

12.5 必ず避けたい評価の誤り

最後に、見た目は無害だが実際は有害なやり方を1つ。「書式合格率」を唯一の成功指標として成果を報告することです。この指標はベースラインでは低く、制約を追加すると大幅に上がるので、圧倒的な勝利のように見えます。しかしこの報告は2つの事実を隠します。第1に、あなたのリトライ機構が本質的な書式の失敗をずっと覆い隠している可能性があります。第2に、業務側の意味的有効率はまったく変わらないか、むしろ低下している可能性(制約が厳しすぎる場合)があります。

誠実な報告では3つの指標を同時に示し、意味的有効率の改善がどこから来たのか(通常は必須フィールド完全率の改善です)を明確にすべきです。「構造化出力の品質が顕著に改善した」と曖昧に書くのではなく。

十三、本番運用:キャッシュ・分離・段階リリースと可観測性

制約付きデコーディングを本番に載せると、それは長期保守を要するコンポーネントになります。ここで挙げる対応を初日にやっておかないと、あとからずっとコストを払わなければなりません。

13.1 キャッシュキーの設計:tokenizer のバージョンは必ず含める

grammar のテキスト自体は tokenizer に依存しませんが、コンパイル結果(DFA テーブルやマスクのインデックス)は tokenizer と強く結びついています。マスクは token インデックス単位で計算されるからです。ほとんどのエンジンの guided decoding バックエンドも、まさに token インデックスでテーブルを参照します。したがってキャッシュキーには、少なくとも schema 内容のハッシュ、エンジンのバージョン、tokenizer のバージョンを含める必要があります。

tokenizer のバージョンという項目は、とくに強調しておきたいです。モデルサービスにモデルのルーティングを敷いている場合、同じ schema へのリクエストが別のモデル(つまり別の tokenizer)へルーティングされることがあります。その場合、キャッシュはモデルの次元で分離しておかないと、「A モデルのマスクのインデックスが B モデルで使われる」という、出力が完全に混乱してしまう問題が発生します。この種のバグはランダムで説明のつかないフォーマットエラーとして現れ、原因の特定が極めて困難です。対処は簡単で、モデルの識別子をキャッシュキーに入れるだけです。

13.2 マルチテナント分離とキャッシュの揺らぎ

サービスがマルチテナントであれば、テナントごとに schema が異なるため、キャッシュには現実的な問題が生じます。テナントごとのリクエストが交互に届くことで制約状態のキャッシュが上書きされ続けて、ヒット率がきわめて低くなり、マスク構築コストが上昇します。前述の通り、これが「トラフィックが増えるほど遅くなる」現象の唯一の原因です。

緩和方法は 3 つあり、侵入性の低い順に並べると次のようになります。

  • キャッシュ容量の拡大——最も単純で、たいていの場合はこれで足ります。ヒット率を観測し、安定するまで容量を増やします。
  • テナント別のプール分け——大手顧客には専用のキャッシュを用意します。顧客数は多くないがトラフィックが特定の顧客に集中している場合に有効です。
  • schema ごとの頻度による階級分け——高頻な schema のコンパイル結果を常駐させる(絶対に追い出さない)、低頻なものは LRU に回します。これは最も効果的な方法です。キャッシュによる効果の分布自体が極端に偏っているからです。

13.3 段階リリースとロールバック

制約付きデコーディングの段階リリースは、通常のプロンプト変更よりも微妙です。フォーマットと内容の分布を同時に変えてしまうからです。妥当な設計は次のようになります。

  1. シャドーモード——本番トラフィックで「旧経路」と「新経路」を同時に動かします(新経路は結果を記録するだけで返しません)。3 つの指標を比較します。フォーマット成功率、フィールドの完全率、そして下流タスクの最終的な正答率です。このステップで「フォーマット成功率は上がったのに下流が悪化した」といった隠れた問題を検出できます。
  2. 少量のトラフィックを切り替え——5% のトラフィックを切り替え、レイテンシ P99、エラー率、そして上記の 3 つの品質指標を観察します。
  3. 全面展開——ただし「プロンプト + repair」にワンクリックで戻せるスイッチは残しておきます。この経路は常に利用可能なので、ロールバックのために再度リリースする必要はありません。

シャドーモードのステップを飛ばす人がいます。理由は「計算量が 2 倍になる」からです。しかしこれは、ユーザーに影響を与えることなく「制約によって内容が悪化した」ことを発見できる唯一の手段です。そしてこの種の問題は、まさに本番環境で後から見つけるのが最も難しいものなのです。下流のデータの品質が緩やかに劣化していくという形で現れ、たいてい数週間後に初めて誰かが気づくからです。

13.4 可観測性のフィールド一覧

これらのフィールドを構造化ログやメトリクスとして残すことが、このシステムを保守可能にする土台になります。

フィールド意味健全な範囲異常が示すこと
schema_compile_msコンパイルにかかる時間キャッシュヒット時はほぼ 0 に近い高い場合はキャッシュが失効している、または schema が大きすぎる
grammar_cache_hit_rategrammar キャッシュのヒット率高いほどよい低下はトラフィックの schema が分散しはじめたことを示す
mask_p50 / mask_p99マスク構築のコストP99 が P50 よりかなり大きいのは正常両方とも高い場合は状態キャッシュが機能していない
clipped_mass_avg切り捨てられた平均確率質量低いほどよい高い場合は制約が厳しすぎず、出力品質が落ちる
format_first_passフォーマットの一発合格率1 に近い状態が望ましい低下は制約層が機能していないことを示す
repair_rate修復が必要な割合低いほどよい上昇は初期の警告シグナル
semantic_pass_rateセマンティクスの有効率業務に依存する低下は内容品質に問題があることを示す
attempts_histogram試行回数の分布上限に集中していれば成功率が不足しているリトライを増やすのではなく、制約を調整すべき

この表の中で優先的に整備すべきなのは、最後の 2 つと clipped_mass_avg です。前の 2 つは第 1 層と第 2 層の健全に直接対応し、clipped_mass_avg は「制約が厳しすぎる」ことを事前に検知できる唯一のシグナルだからです。解析の失敗率だけを監視するシステムは、まさに制約付きデコーディングが持ち込む最大のリスクを見逃します。厳しすぎる制約は解析の失敗を招くのではなく、内容が悪化するだけだからです。

十四、セマンティックな制約をデコード期に移す

ここまで繰り返し触れてきた境界があります。セマンティックな制約(フィールド間の整合性、属性の依存関係、値の範囲、参照の完全性)は、文脈や外部の状態を見る必要があるため、サンプリング後の検証でしか担保できません。この境界は 2026 年。現在まさに突破されつつあり、最先端がどこにあるのか、そして追いつくべきなのかを知っておく価値があります。

14.1 逆向きの制約:正規表現で生成を駆動する

従来の制約付きデコーディングは「順方向」です。grammar を与えて前進させます。それに対して guidance-ai のようなライブラリは別の考え方を示しています。1 つの正規表現と一組のキャプチャグループを制約として使い、それにマッチする前文がどうあるべきかを逆算するのです。たとえばモデルの回答に特定のパターンに合うメールアドレスを含めたいなら、「JSON の email フィールド」を制約するのではなく、その正規表現自体を制約にします。デコーダーが各ステップで「生成を続けて正規表現にマッチできるか」を判定するようにします。

この機能は文脈自由文法の表現範囲を超えています。接尾辞に基づいた先読みの判断ができるからです。これは FSM や GBNF では実現できません(どちらも先頭だけを見ており、目標を見ません)。代償は実装の複雑さがはるかに高くなることです。毎ステップ「現在の接尾辞と任意の継続の組み合わせでなお正規表現にマッチしうるか」を判定する必要があり、正規表現がバックトラックを含む場合は決定可能性の問題であり、一般の場合は決定不能なので、実装は保守的な近似をせざるを得ません。

14.2 状態フィードバック型の制約:データベースの候補をマスクに注入する

2 つめの方向は実際の業務課題に近いものです。「このフィールドの値は、データベースのこのテーブルにいま存在する値から来なければならない」という条件をデコード期に移します。status フィールドに入る前に一度データベースを引いて、合法な状態値の集合を、その位置で許可される token の集合に注入します。

この方法は非常に美しいのですが、工程上 3 つの硬い問題があり、どれも中断させるほどの重さです。

  • 外部レイテンシが decode ループに入り込む。デコーディングは本来純粋な計算なのに、いまは数 token 生成するたびにデータベースを待つことになります。対策はプリフェッチ(prefill 段階で、それともさらに早く、必要な候補をすべて取得しておく)ですが、これは「モデルがどの分岐を辿るか」を予知できることを要求します。anyOf を含む schema では、常に可能とは限りません。
  • キャッシュの一貫性。生成のあいだに候補集合が変わっている可能性があります(誰かが設定を変えた)。この窓口を受け入れるか、バージョン付きの token を導入するかですが、後者は複雑さを大幅に増やします。
  • 複数ターンの状態。対話のシーンでは、前のターンで制約したフィールドを、このターンでも制約するのか。schema は変わったのか。状態機械はターンをまたいで維持する必要があります。

したがって私の判断は次の通りです。「値の範囲が固定された小さな列挙型から来る」ような場合は schema の enum で十分であり、状態フィードバックは不要です。状態フィードバック型の制約が本当に代替できないのは、「値の空間が大きすぎて列挙できないが外部システムからは引ける」場合です。たとえば「このユーザー ID が私たちのテナント一覧に存在しなければならない」といった場合です。ただしこの場合でも、サンプリング後の検証もたいてい同じくらい有効です(一度クエリして ID の存在を照合するコストは、クエリを decode ループに組み込むよりずっと小さい)。

14.3 実務的な線引き

状態フィードバック型の制約を導入すべきかどうか判断するとき、私は 3 つの問いを使います。候補集合が本当に列挙できないのか(そうなら導入する価値がない)。サンプリング後の検証のコストが受け容忍できないほどか(1 回のクエリが数ミリ秒ならまったく許容できる)。制約ミスの后果が、検証で受け止められないほど深刻か(ツール呼び出しの高リスクな引数は該当するが、他の大部分は該当しない)。

3 つのうち 2 つが「否」なら、実施しないでください。この判断基準の価値は、「全部をデコード期に移す」という知的な魅力、滑らかな理屈を備えた誘惑に抵抗できるようにする点にあります。制約付きデコーディングで最も実用的な形態は、いまでも素朴で、構文とリテラルだけを扱うバージョンであり、セマンティクスと状態は検証層に残すものです。

これは実質的に記事全体の縮図でもあります。技術選定における最適解は、能力最強の層ではなく、「十分に使える、かつコストが最も低い」層だということです。これを理解すれば、残りはすべてディテールです。

十五、なぜ他の方法を使わないのか

制約付きデコーディングを使うことに決める前に、妥当な案はまず「なぜもっと安い方法を使わないのか」に答えるべきです。この節では主な代替経路を並べて比較します。これらは排他的ではなく、それぞれの適用境界ははっきりしています。

15.1 正規表現による後処理:安いが、黙ってデータを落とす

もっともふつうの代替手法はこうです。モデルに自由に出力させ、後から正規表現で抽出します。テキストから JSON を引っ張り出し、引っ張り出せなければ寛容なパーサを使い、無理なら重要そうなフィールドをいくつか取るというやつです。コストはきわめて低く、エンジン側の改造も一切不要なので、多くのチームの 1 番目がこれでした。

このルートを致命的にする問題は、データが確実に黙って失われることです。正規表現は想定した形態しか扱えません。モデルの出力が想定と大きくずれていると、正規表現は「妥当に見えるが内容が誤った」結果を出そうとします。たとえば、途中で切れた JSON から値域の不完全な日時フィールドを引っ張り出してきます。より危険なのは、抽出失敗と抽出成功だが内容が誤った場合が、戻り値の上では同じ姿に見えることです(どちらも辞書を返します)。下流では区別できません。

よって判断基準は明快です。フィールドが 1 つ欠けた、または誤っただけで業務上の損失につながるなら正規表現による後処理は使えません。テキストを人が閲覧するためのタグに変換するだけなら、正規表現で十分です。

15.2 few-shotによる強制約:改善するのは確率であり、保証ではない

もう一つよくあるのは、多数の例でモデルにフォーマットを「覚えさせる」方法です。様々な境界ケースを覆う 20 個の JSON サンプルを与えることです。実際にフォーマット遵循率は明確に改善し、その価値は主に 2 点にあります。モデルに命名と値の入れ方を教えることです(schema の説明より例は具体的で、具体的に書かずに済みます)。そして、モデルの出力分布を期待と一直線に揃えることです。

ただし、それができない 2 つのことも同様に重要です。必ず合法であること(few-shotでも失敗は起きる。特に予期しない組み合わせでは)、必ず完全であること(few-shotでは「すべての必須フィールドが現れる」という要件を覆いにくく、 例がちょうどすべてそろっている場合を除きます)。

妥当な分担はこうです。few-shotは「中身をどう埋めるか」を、制約は「構造が必ず正しいこと」を担う。それらは代替関係ではありません。解析失敗率が問題の対象のときに「例を増やして解決しよう」としているチームを見かけたら、それはシグナルです。構造の問題を内容の問題として誤った解法で扱っているのです。

15.3 ファインチューニング:解いていないのは別の問題

ファインチューニングは確かにモデルがフォーマットに従うことを改善します。学界でも産業界でも検証はされています。しかしこれを「出力が合法であることを保証する」手段として使うことには、構造的な問題がいくつかあります。

  • 保証にはなっていない。ファインチューニング後もモデルは誤ります。ただ誤りが少なくなるだけです。失敗率を数パーセントからほぼゼロまで落とすには、ルールやリトライといった工学的手段が必要です。そしてその工学的手段自体でほぼゼロにできるので、ファインチューニングは不要です。
  • 保守のコストがある。schema を変えれば再学習、あるいは少なくとも再学習が必要ですが、これは制約付きデコーディングの「schema を 1 行直すだけ」と鮮明な差があります。
  • ドリフトのコストがある。基盤モデルの更新、指示追従能力の変化、学習データの鮮度はすべて、フォーマット遵循率を予測不能に振らせます。しかも帰因できません。
  • 制約と矛盾はしませんが、補い合う関係でもありません。制約付きデコーディングは構造を保証し、ファインチューニングは内容品質を改善します。ボトルネックが構造ならファインチューニングを使うべきではなく、内容品質(たとえば分類の理由の書き方がまずい)ならファインチューニングが正しい投資先です。

よくある誤判断が「ファインチューニングがあるのに、なぜまだ制約が必要なのか」というものです。答えは、ファインチューニングが解いているのは大概率であり、制約が解いているのは必然だからです。「出力がそのままデータベースに入る」ようなシーンでは、「必然」しか足りません。

15.4 手法の比較

手法構造の保証内容品質変更コスト失敗の見えやすさ向いている場面
純粋なプロンプトなし普通極めて低い悪いプロトタイプ、コストが小さいシーン
正規表現の後処理なし(しかも黙って)普通低い悪いタグ抽出、人による閲覧
few-shot弱い(確率を改善)よい低い普通制約との併用
ファインチューニング弱い(それでも確率)よい高い普通内容品質がボトルネックの場合
制約付きデコーディング強い(構造的)やや高い(フィールドがより揃う)中よい硬い保証が必要なシーン
制約 + 検証 + リトライ強い + セマンティクスよい中やや高いよい本番システムの既定の選択

最後の列の表現に注意してください。「本番システムの既定の選択」は組み合わせを指しており、単一のものではありません。制約付きデコーディング単体なら構造は保証できますが、セマンティクスを受け止められません。検証単体ならセマンティクスは受け止められますが、生成を 1 回無駄にします。本番で行う価値があるのは、これらを積み重ねて、さらに監視データによってどの層に投資を続けるべきかを決めることです。

十六、5 つのシナリオを 1 つずつ分解する

抽象的な方法論は、すべてのシナリオが同じように見えてしまうという錯覚を生みがちです。この節では典型的なシナリオを 5 つ選び、同じ 3 層アーキテクチャが異なる制約のもとでどう落地するかを、重点的に見ていきます。

16.1 請求書からの抽出

schema が非常に固定的(いくつかの金額フィールド、日付、請求書番号)ですが、入力は OCR で読み取った汚いテキストです。schema がきわめて確定的なので、これは制約付きデコーディングにもっとも適したシナリオです。

重要な判断をすると、金額フィールドには値域の制約があります(負数にしてはいけない、妥当な範囲を超えないこと)。ただしこの種の制約は構文では書けません。GBNF は「これが数値である」ことは制約できますが、「この数値は 100 万未満である」ことは制約できません。したがって検証層が業務ルールを自分で書く必要があります。金額が 0 より大きいこと、合計が各明細の和であること、日付が妥当な区間にあること。それに、これらのルールは高頻度の失敗点なので、リトライのときに具体的なエラー(「total が sum of items と一致しない」)をモデルに返してやる必要があります。

見落とされがちな点がもう 1 つあります。OCR のテキストの中の数値が O08.00(英字の O が数字の 0 に擬態している)になっているかもしれません。制約付きデコーディングは、モデルが O08.00 をそのまま string フィールドに書き写すことを妨げません。schema が string と言っているなら合法だからです。この場合は上流での入力の洗浄か、検証器に拒否させてリトライするしかありません。

16.2 分類と理由

モデルに分類結果と一段の理由を出させる。これは制約付きデコーディングの特殊な使い方です。制約は構造の保証に使いますが、推論そのものは制約されません。2 つの方法があります。

  • まず自由に推論してから出力を制約する——1 回の生成で「分析……最終的な分類は X」を作り、制約付きデコーディングで構造化された結果を出力します。品質はよくなりますが、生成が 1 回増えるか出力が長くなります。
  • 1 回の生成で、構造を混ぜる——緩い構文(キーと値のペア)を使い、解析テキストを reasoning フィールドに、分類を label フィールドに入れ、label は enum で制約します。コストは低いのですが、モデルが reasoning を先に書いてから label を書くことを保証しなければなりません。これはフィールドの順序の制約で実現します(構文の中で reasoning が label より前に現れることを規定する)。

2 つめの方法における「フィールドの順序の制約」は実用的なテクニックであり、多くの人は知りません。制約は値の範囲を制限するだけでなく、順序も制限できる、そして順序はモデルの思考過程に実質的な影響を及ぼします。まず結論を書いてから理由を書くと、理由が結論の正当化に変わってしまいます。まず理由を書いてから結論を書くと、理由のほうが本当の意味での推論になる可能性が高くなります。

16.3 SQL の生成

失敗の代償が最も高いシナリオなので、3 層すべてを有効にします。しかも第 3 層はほぼ閉じます(黙ったまま間違っているクエリを実行するくらいなら、実行しないほうを選びます)。

制約の分業が興味深いのはここです。テーブル名とカラム名は列挙の制約で(ここがいちばん価値があります。幻覚のテーブル名のほとんどを取り除けます)、キーワードの並びは grammar で(SELECT ... FROM ... WHERE の順序)、そして意味的な正しさは完全に検証に任せます(EXPLAIN で実行計画を見る、読み取り専用のチェック、結果行数の上限)。

現実的な制約を前もって考えておく必要があります。テーブル名の列挙が非常に長くなる可能性があります。500 のテーブルを持つ DB で、すべてのテーブル名を grammar に書き込むと、構文がコンパイル困難なまでに膨れ上がります。この場合は 2 段階にします。第 1 段階で少量の token を使ってテーブルを選ばせ(まず検索で候補を十数個まで絞り込んでもよい)、第 2 段階で選択されたテーブルに基づいて完全な制約を行います。この「まず候補を絞ってから強く制約する」パターンは、候補空間が大きいあらゆるシナリオで使えます。

16.4 設定ファイルとワークフロー生成

YAML やワークフロー定義のようなものを生成するとき、制約付きデコーディングの価値には特殊な出所があります。インデントと句読点だからです。YAML のインデントの階層が正しさの中核であり、これは極めて間違いやすい——特にモデルが入れ子の深いところにいるときに。制約にインデントを保証させるのは、モデルに「インデントに注意させる」よりもはるかに信頼できます。

もう 1 つの要点は、この種の出力はたいてい人間が読んで編集するものなので、合法性のほかに可読性も考える必要があるということです。制約付きデコーディングはここで逆効果になる可能性があります。厳しすぎる制約(決まったキー順序の強制、コメントの禁止、行内空白の禁止)は、生成された設定を長く読みにくいものになってしまいます。よって制約の粒度は「人間が手直しするか」で決めるべきです。人間が手直しするなら、フォーマット以外の表現の自由(キー順序、追加のキーを認めるか)は制約しないでください。そうしないと、人間の編集と次回のモデル生成が互いに衝突します。

16.5 複数ターンの会話における構造化された状態

もし構造化された出力が終点ではなく、次のターンのコンテキストに入るなら(アシスタントが抽出した構造化されたスロットを会話履歴に書き戻すなど)、追加の要件があります。制約された出力は長い間コンテキストに残るため、後続のターンにとって親切でなければなりません。

これは、順序と可読性が合法性より重要だということです。JSON が 1 行数千文字の圧縮形式なら大量の token を消費し、モデルも特定のフィールドを参照しにくくなります。やり方は、制約が許す範囲で出力を最適化することです。フィールドの順序を安定させる(schema と一致させる)、インデントを制御する(制約の中で改行を禁じるのではなく許可する)、冗長な任意フィールドを避ける。したがって「制約付きデコーディング + 出力の美しさ」は実際に存在する工程的な問題であり、生成後に軽い整形の層を 1 枚加える価値があります。

十七、中文と多言語シナリオ特有の課題

中文の環境で構造化生成を動かしているチームは、英語の資料には載らない具体的な問題にぶつかります。独立した節を起こす価値があります。

17.1 中文字符の引用符が最も一般的な障害源です

モデルが JSON を出力するとき、String の値を中文の引用符で包む("名称":"北京")のは、極めて一般的な失敗モードです。訓練データでは中文の文脈で引用符がもともと全角のことが多いからです。この種の破損は厳格なパーサには受け入れられませんが、寛容なパーサなら修復できます。

さらに進めると、制約の層で根本的に解決できます。schema が二重引用符を使うことを規定しているなら、文字集合の制約の中で全角の引用符を明確に禁止します。GBNF の文字クラスはこのような精密な除外をサポートしており、代償は少し複雑になる文字集合の定義だけです。schema が文字列の中に引用符を含むことを許しているなら、enum で値を囲い込むことを検討できます。これによって全角の引用符と列挙の逸脱という 2 つの問題が同時に解けます。

17.2 中文字フィールドのキー名

中文字のキー名そのものに問題はありません(JSON は完全にサポートしています)。ただし 2 点の注意が必要です。1 つ目は全角のコロン(: ではなく :)が構文を壊すことです。これは文字集合の制約で治せます。2 つ目はキー名のなかの空白です。モデルが出力後に空白を追加した場合(" ユーザー名")、検証は失敗します。制約ならキー名を正確に 1 つのリテラルに固定でき、このリスクを完全に排除できます。

ここには一般的な法則が表れています。キー名は制約のコストパフォーマンスが最も高い場所です。キー名は有限で、列挙可能で、完全に決まっているため、const で固定してもコストはほぼゼロです。それでいて、1 種類の故障(キー名の綴り、全角と半角、余分な空白、順序の揺れ)を丸ごと取り除けます。

17.3 中文の長さバジェット

max_tokens の見積もりに、中文シナリオには落とし穴があります。同じ漢字でも tokenizer によっては 1 token かもしれないし 2 token かもしれない,しかも、中文のテキストの token 密度は通常、同等の情報量を持つ英語より低い(中文はより圧縮されている)。したがって「文字数」から token 数を推測すると系統的な偏りが出ます。

確実な方法は 1 つしかありません。実際の tokenizer で実際のサンプルを計測し、出力長さの P99 を取り、安全係数を掛けます。「文字数 × ある係数」で見積もってはいけません。前述した「切り捨て」の故障は中文シナリオでも同様に一般的で、しかも中文の情報密度が高いぶん、切り捨てが構造のより後ろの位置で起こり、repair で救える確率はむしろ低くなります。

17.4 出力言語の制約

業務で「フィールドの値は中文でなければならない」ならなら、これは意味的な制約であり構文的な制約ではありません。構文上は "北京" も "Beijing" も合法な文字列です。できるのは次のことです。

  • プロンプトの中で言語の要求を明示する(これはプロンプトが本当に効くシナリオです)。
  • 検証層で言語検出を行い、合わなければリトライする(単純で有効)。
  • GBNF の文字クラスで「CJK 文字だけを許可する」(最強ですが、数字や英語の固有名詞との混在を除外してしまうので、実際にはほとんど使えません)。

私の推奨は 2 つめです。言語検出とリトライのコストはきわめて低く、カバー率も高いのに対し、文字クラス制約の実用性は低いのです。実際のテキストにはほぼ常に数字、英語略語、句読点を混在させる必要があるからです。制約の得られるものは、構造のレベルのものにはるか劣ります。

十八、制約付きデコーディングによるデータ合成

制約付きデコーディングには見落とされがちな用途があります。高品質な訓練・評価データを生成するための道具として最適だからです。形式を保証できるので、モデル(またはルール)は内容に集中できます。

18.1 形式は制約が保証し、品質はサンプリングが制御する

従来のデータ合成は「モデルに形式で出力させる → スクリプトで解析する → 解析に失敗した样本は捨てる」という流れでした。ここには暗黙の効率の損失があります。大量の計算力が捨てられるサンプルに使われているからです。制約付きデコーディングを使うとこの比率がほぼゼロになり、同じ計算力でもっと多くの使えるサンプルが得られます。データツールとしての価値として、これがもっとも直接的です。

18.2 境界例を能動的にカバーする

制約付きデコーディングは「必要に応じて境界サンプルを生成する」ことを可能にします。「すべての任意フィールドが欠けている」サンプルがほしいなら、schema の任意フィールドを一時的に required に変えればよいだけです。「深いネスト」のサンプルがほしいなら、深い木構造を生成します。モデルに自由に生成させても、この種のサンプルを指向的に得ることは難しいです。

さらに価値のある使い方があります。schema の制約空間そのものを使って評価セットを定義することです。列挙の直積を走査して、異なるパラメータの組み合わせを覆うサンプル群を生成すれば、モデルがどの組み合わせで誤りやすいかが分かります。特に、2 つのフィールドの組み合わせに隠れた論理関係があるときには効きます。

18.3 必ず警戒すべき分布シフト

制約によって生成されたテキストには系統的な欠陥があります。その分布は実際の出力より狭いのです。制約が形式を保証すると同時に、モデルが形式面にもつ偶発性を取り除いてしまうため、生成されたサンプルが特定の次元で過度に整いすぎます(たとえば列挙フィールドの分布が偏る可能性があります。制約下の条件付けは各値の確率を変えるからです)。

このデータでファインチューニングすると、モデルはこの偏りを引き継ぎます。したがって実践での正しい做法は、制約で生成したデータを「正のサンプル」と境界サンプルとして使いつつ、実際の無制約出力を必ず混ぜることです(形式が壊れていても構いません)。さもないと、モデルは汚い入力に対するロバスト性を失います。これはコードの「happy path だけでテストしてはいけない」という原則とまったく同じです。

十九、よくあるアンチパターン

最後に、実際のプロジェクトで繰り返し現れて、いずれもアーキテクチャの問題を指す 5 つのアンチパターンをまとめます。它们は第 11 章の「落とし穴」とは異なります。落とし穴は実装詳細のバグですが、この 5 つは設計レベルの誤った選択です。

  • JSON を正規表現で直すのではなく、制約を追加する。症状として補修ロジックが次第に複雑になり、正規表現が延び続けます。判断基準はこうです。正規表現が 20 行を超えていて、しかも頻繁に書き換えているなら、それは制約の仕事を代わりにやっています。修正する場所を変えてください。
  • 完全な schema をプロンプトに詰め込むが、制約は加えない。症状としてプロンプトが長くなり続け(token コストが上昇する)にもかかわらず、フォーマット遵循率は改善しません。schema がプロンプトの中で担うのはフィールドの意味をモデルに伝えることであり、形式の保証は制約に任せてください。両者を混ぜると二重投資になります。
  • 上限なしのリトライ。症状として「成功率 99.9%」という言い方がされる一方で、失敗 0.1% のときの遅延がいくらかを言える人はいません。判断基準はこうです。リトライ回数は遅延バジェットから逆算できる必要があり、頭で決めた数値であってはいけません。
  • 高リスクな操作でフォールバック解析を有効にする。症状として「可用性のため」という理由で SQL やツール呼び出しにフォールバックを入れます。判断基準はこうです。フォールバックは、その失敗自体の代償が失敗の代償より低い場合にのみ使います。引数を埋めた状態で実行してしまうのは、直接エラーを出すよりずっと危険です。
  • 柔軟性のために schema を自由形式の文字列の集まりとして設計する。症状として「さまざまな場合に対応するため」に type: string を使い、制約が「JSON 文字列であること」だけに退化します。ほとんど効かなくなっています。制約の収益の上限が schema の質で決まることを示す最良の例です。制約付きデコーディングがいかに強くても、自由形式の文字列の中身を強制することはできません。

この 5 つのうち最後の 1 つは、別に掘り下げたい点です。schema の 80% のフィールドが string なら、制約付きデコーディングを使うか使わないかで品質への影響は微々たるものです。制約しているのは最も外側の括弧だけだからです。ポイントは、schema をより具体的に設計することから得られます。string ではなく enum を使う、1 つの JSON 文字列に押し込まず入れ子のオブジェクトを使う、モデルに判断させない代わりに必須フィールドを使う。制約付きデコーディングの能力の上限は、あなたが設計した schema の情報量の上限に等しいのです。

このことを踏まえると、技術投資の正しい順序もわかります。まず schema をよく設計する(コストが最も低く、効果が最も大きい)。次に制約付きデコーディングで構造を保証する。最後に検証でセマンティクスを守る。逆の順序——先に制約付きデコーディングを入れてから、schema が緩すぎて制約すべき対象がないことに気づく——が、最もしばしば浪费时间になるパターンです。

二十、制約システムの正しさの検証

制約付きデコーディングの実装には、ある危険な状態が生じがちです。動いているように見え、形式の成功率も悪くないのに、特定の状態でのマスクが誤っている。这类のバグはシステムを直ちにクラッシュさせません。失敗率が少し高くなるだけなので、原因の特定が非常に困難です。この節では、この種の問題を能動的に発見できる検証方法を示します。

20.1 不変条件テスト:「ありえない」状態とは何か

最も効果的なのは不変条件を定義して、大規模なランダム生成に対してそれを検査することです。制約システムには非常に有用な不変条件がいくつかあります。

  • 形式の不変条件:許可された token 列をすべて完全に連結した結果は、必ず合法な JSON(あるいは目標の構文)でなければならない。これがもっとも中心的な項目で、テストの中で毎回生成結果に直接アサートできます。
  • 単調性の不変条件:すでに検証を通った出力が、何らかの合法な token を追加した後も「完成可能」な状態にあるべきである。つまり「その後どう書いても閉じられない」というデッドロック状態が起きないということです。
  • 到達可能性の不変条件:構文で宣言されたすべてのルールは root から到達可能でなければならない。あるルールが決して使われないなら、schema と構文が同期していないということです(ルールが余分に生成されたか、参照が漏れているかのどちらか)。

2 つめの「デッドロック状態」は特に単独で説明すべきです。制約構文は、完成できない状態へ入ることを許してしまうことがあります。たとえばオブジェクトが必須フィールドを定義しているのに、構文のどこかの分岐の書き方が、その分岐に入った後に合法な続きが何も残らないようにしてしまう場合です。このとき、モデルは途中まで生成し、マスクがすべてを禁じて出力が突然止まります。現れ方は「出力が唐突に短い」というものです。这类のバグは人手によるテストでは極めて発見しづらいのですが、ランダムな fuzzing なら捕まえることができます。数万件のサンプルをランダムに生成し、各サンプルが完全か、通常どおり終端できるかを検査します。

20.2 プロパティテスト:schema をアサーションの供給源にする

もっと価値のあるテストの種類は、期待値を手書きするのではなく、schema から直接アサーションを導出することです。すべての生成結果に対して自動的に、次を確認します。

  1. 出力が合法な JSON である。
  2. 出力が JSON Schema の検証を通る(この段階で schema がテストのアサーションの供給源になります)。
  3. すべての required フィールドが存在し、null ではない。
  4. すべての enum フィールドの値が列挙の範囲内である。
  5. _at または _date で終わるすべてのフィールドが日付として解析できる。

3 つめと 4 めはとくに価値があります。制約層が完全に正しい場合でも失敗しうるからです。それこそが価値であり、テストしているのは「構文が正しいか」ではなく「schema のセマンティクスが本当に表現されているか」です。構文だけをテストするテストスイートは、「必須フィールドが制約層によって誤って任意にされてしまった」この種の問題を見逃します。

20.3 差分テスト:制約版と自由版を比べる

3 つめのテストとして、差分テストが役に立つ。同じ入力の束について、それぞれ制約付きデコーディングと自由生成(制約は施加しないが、プロンプトの要求は残す)で実行し、両者の挙動の差を比べます。

3 種類の差に注目します。それぞれに明確な意味があります。

  • 制約版が非法で、自由版が合法——これは正常です(制約の働きによるものです)。ただし比率が高すぎるなら、制約とモデルの傾向が深刻に衝突しています。
  • 制約版は合法だが、内容が自由版と大きく違う——人が抽出して確認する価値があります。差が広範囲にわたって見られるなら、制約が(形式を抑えるだけでなく)実質的に内容の選択を変えているということで、それがまさに望む効果かもしれませんし、制約が厳しすぎるということです。
  • フィールドの完全率の差——これが最も見るべき指標です。制約版の必須フィールドの完全率が明らかに高いなら、制約の核心的な価値が表れつつあるということです。

差分テストは計算力を 1 割だけ多く使いますが、ベースラインを与えてくれます。ベースラインがなければ、「制約は本当に効いているのか、採算が合うのか」に答えられません。多くのプロジェクトの制約付きデコーディングは「入れたらよくなった気がする」にとどまっており、この比較をした人はいません。

二十一、コンパイル成果物のライフサイクル

コンパイル成果物(GBNF のテキスト、DFA テーブル、マスクのインデックス)は、状態を持ち、メモリを消費し、エンジンのバージョンにも結びついています。このあたりの運用上の細部はあまり書かれていませんが、マルチテナントサービスが長期安定して動くかどうかを左右します。

21.1 三つの状態、それぞれに異なるライフサイクル

状態内容メモリの規模失効する条件存放場所
構文のテキストGBNF 文字列 / JSON数十 KBschema の変更ディスクキャッシュ + メモリ LRU
コンパイル済み構造ルールテーブル、DFA の状態テーブル数百 KB から数 MBschema の変更、エンジンのバージョン変更エンジンのプロセスメモリ
マスクの状態キャッシュ各構文の状態に対応する許可 token 集合数十 MB に達することもあるほぼ失効せず、LRU の追い出しのみエンジンのプロセスメモリ、リクエスト間で共有できる

この 3 層を分けて把握しておくことは重要です。失効のロジックとメモリの性質がまったく異なるからです。ある典型的な事故はこうです。schema を変更したが、コンパイル済み構造のキャッシュのキーに schema のバージョンが含まれておらず、新しい構文と古い構文が混在してしまう。症状としては、出力に新旧どちらのルールも許す断片が現れます。この種の問題はサービスの再起動で「解決」されるので、環境が不安定だと誤認されやすいものです。

21.2 プレウォーム:最初のリクエストを遅くしないために

コンパイル済み構造は遅延ロードなので、サービスを立ち上げた直後の数リクエストは明らかに遅くなります(現にコンパイルし、現に状態を構築する必要があるからです)。トラフィックのピーク時なら影響はありません。しかし、冷起動やトラフィックが小さいとき、スケールアウトした直後では、これはユーザーが感じ取れる遅延になります。

対処は簡単です。起動時に、事前定義されたホットな schema の集合に対して一度プレウォームを行ないます。ごく短い出力を 1 つ生成して、よく使われる構文経路上の状態をすべて一度通り、キャッシュを温めておくのです。コストは起動時に数十ミリ秒増えるだけで、効果はコールドスタートの長尾を完全になくせます。やるかどうかを判断する基準はこうです。P99 のレイテンシ曲線がサービス起動直後の数分間だけ明らかに高ければ、それが証拠です。

21.3 複数モデルと複数エンジンの併存

サービスが複数のモデル(tokenizer が異なる)や複数の推論バックエンドを同時に提供する場合、コンパイル成果物を共有できません。第 13 章で tokenizer のバージョンが必ずキャッシュキーに入れておく必要があると述べましたが、ここではより細かい点を補足します。エンジンによっては、同じ schema から生成される構文が異なることがあります(あるキーワードに対応しているかどうかなど)。したがってキャッシュキーには「エンジン + バージョン + 特定のコンパイルオプションを有効にしているか」を含める必要があります。

実際に踏みがちな落とし穴が 1 つあります。キャッシュキーに schema のハッシュしか使っていない状態で、段階リリース中に一部のトラフィックが新しいエンジンに切り替えられたら、古いエンジンの構文テキストが新しいエンジンのマスクロジックに使われてしまい、挙動が異常になります。段階リリースではキャッシュをエンジンのインスタンスごとに分離する必要があり、最も確実な方法は、エンジンインスタンスごとに専用のキャッシュ名前空間を持つことです。

二十二、コストの積み上げ

制約付きデコーディングは初期投資と継続的なオーバーヘッドの組み合わせです。多くのチームは意思決定のときに片方しか計算していません。この節では 3 つのコストを明確に整理します。

22.1 初期コスト

構文キャッシュのコールドスタートとプレウォームが主な初期コストですが、その規模は通常数十ミリ秒から数百ミリ秒なので無視できます。本当の初期コストは schema 設計への投資です。緩んだ schema を、実効的に制約できる形に書き換えることは、プロジェクト全体で工作量最大の部分になることが多く、しかもこの投資は決して「無料」に償却されることはありません。

22.2 継続コスト:レイテンシとスループット

レイテンシ側のコストは第 10 章ですでに算出済みです。スループット側のコストはマスク構築が消費する CPU で、高並行的时候会「CPU がボトルネックになる」という形で現れます。緩和策を効果対コストの順に並べると次のようになります。

  1. 状態キャッシュのヒットを保証する——これが最大の効果です。キャッシュがヒットすればマスクはほとんどコストゼロになります。
  2. マスクを GPU のテンソルとして実装する——効果はその次いで、エンジンがサポートしているかどうかによります。
  3. CPU コアを増やす——もっとも直接的でもっとも荒っぽい方法です。マスクが純 CPU で動いているなら、コードを書くよりコアを増やのほうが早く効くことがよくあります。

22.3 効果の側:解析率だけを計算しない

制約付きデコーディングの効果は過小評価されがちです。主な効果が「リトライが少なくなった」ことではないからです。実際には次のものがあります。

  • フィールドの完全率が上がることによる、下流のデータ品質の改善(リトライ回数では測れません)。
  • 下流コードの単純化です。下流は寛容な解析ロジックを書く必要がなく、「壊れている可能性のある」入力を扱う必要もなく、テストケースで異常系を構成する必要もなくなります。
  • 失敗の可観測性です。失敗が「偶発的で再現しにくい」状態から「数えられ、告警でき、原因を追える」状態になります。この効果はチーム協業という点では性能の側面よりも大きいです。

意思決定のときにもっとも起こりやすい誤りは、制約付きデコーディングを「リトライを減らすもの」としてしか ROI を計算しないことです。正しい計算方法は、下流の寛容な解析コードの保守コスト、リトライが消費する token コスト、そして不正データが入ったあとの調査コストを、すべて効果の側に含めることです。このように計算すると帳尻は通常プラスになり、しかも余裕は想像より大きいです。

逆に、下流がもともと完全な寛容ロジックを書くことを要求されているシナリオ(そもそも形式が重要ではない場合)なら、制約付きデコーディングの ROI は確かに低くなります。これは、制約付きデコーディングを導入しないべき正当な理由の、数少ないものです。

二十三、検索や長いコンテキストとの組み合わせ

構造化された生成が単独で存在するケースはまれです。通常は検索や、長い文書の処理とセットで現れます。この 2 つの組み合わせには、注意点がいくつかあります。

23.1 schema から逆算して検索を駆動する

逆向きの考え方が価値があります。まず schema を決め、その schema で何を検索すべきかを導くのです。schema に「請求書番号」というフィールドがあるなら、検索では請求書番号を探すべきです。schema に「違約金条項」フィールドがあるなら、検索ではその部分に集中して探すべきです。schema 自体が「私たちが何に関心があるか」の明示的な宣言になるからです。「まず大量のテキストを検索してからモデルに探させる」よりもずっと正確です。

さらに進めると、もし schema がデータベース構造や API 定義から来ているなら、それは最も権威のある「要件リスト」になります。検索戦略を監査するために使えます。各フィールドに対応する検索元があるかを点検すれば、「このフィールドはモデル、そもそも埋められない」類の schema 設計上の欠陥が見つかることがよくあります。

23.2 長い文書からの構造化抽出

長い文書から構造化された情報を抽出するとき、制約付きデコーディングには特別な役割があります。「抽出」と「要約」を分離できるからです。まずモデルに自由形式で段落ごとにメモを取らせ(この段階は制約しません。ノートが自由な思考に役立つからです)、最後にノートに対して強い制約の抽出を 1 回行います(形式は厳密、コストは低い)。

これは「1 回の呼び出しで文書全体に強い制約の抽出を行う」よりもよいです。強い制約は長い生成のあいだずっと形式的な圧力をかけ続け、その圧力がモデルの内容への注意力を圧迫してしまうからです。2 段階に分けることで、第 1 段でモデルの思考品質を保ち、第 2 段で出力の可用性を保証できます。token コストはわずかに高くなる可能性があります(呼び出しが 1 回増える)が、抽出品質は通常よいです。とくに文書が長いときにこそ それでわるまわ。

23.3 検索結果そのものに制約をかけるべきか

schema に「引用元の情報」フィールドが含まれる場合(たとえば、抽出された値ごとに原文の出所を付けることを要求する場合)、そのフィールドは実際に存在するソースの集合、つまり検索結果の id リストに制約できる必要があります。これは第 14 章で説明した「状態フィードバック型の制約」の典型的なシーンです。

しかし、私はまず状態フィードバックを入れず、検証で代用することを勧めます。モデルに引用 id を出させ、その id が検索結果に含まれているかをコードで確認するのです。確認は 1 回で O(1) の操作です。検索結果をデコードのマスクに注入すると、第 14 章で説明した複雑さとレイテンシの問題がすべて生じます。「モデルが出典の id をでっち上げる」割合が受け入れられないほど高いとわかるまで、やる価値はありません。

二十四、ゼロから始めるロードマップ

もしあなたのチームがまだ制約付きデコーディングを使っていないなら、この節では段階的な道筋を示します。核心の原則はこうです。各ステップが独立した価値を生み出すようにし、最終形のために中間状態に過剰投資をしないことです。

フェーズ 1:まず検証を入れる(初日にできる)

制約はまったく加えず、3 つのことだけを行います。生の出力を記録する、parse-repair を回す、schema の検証を回す、そして 3 つの失敗率をメトリクスとして報告する。このステップの産物はデータです。自分たちの実際の形式成功率がどのくらいで、失敗がどんな形をしているかが分かります。

このステップはよく飛ばされます。理由は「検証しても問題を直せない」からです。しかしこのデータなしでは、以降のすべての意思決定が盲目です。制約を入れるべきかどうか、制約をどれだけ強くすべきか、リトライに価値があるかどうかを判断できません。メトリクスのコストは 1 日です。やらないことの代償は、数週間の誤った判断です。

フェーズ 2:max_tokens と schema を直す(第 1 週)

失敗サンプルを解析すると、相当数の失敗が実際には制約の問題ではなく、予算と schema の問題だとわかります。max_tokens が小さすぎて切り捨てが起きている、schema がモデルには埋められないフィールドを要求している(たとえば、あいまいな記述から正確な数字を要求している)、などです。

この 2 つを直すだけで失敗率は大きく下がることが多く、それなら一分钱も計算力を消費しません。このステップの産物は、実際の失敗原因の分布であり、以降の投資をどこに向けるかを決めます。

フェーズ 3:サーバサイドの構造化出力を入れる(第 2〜3 週)

なこ供給者が schema レベルの構造化出力をサポートしているなら、それを有効にしてください。このステップのコストはもっとも低く(数行のパラメータを変えるだけ)、効果は形式成功率の大幅な向上です。同時に schema を詰めます。これは効果が最大のステップです。schema が具体的に細かいほど、強制できる内容も増えます。

このステップで重点的に検証すべきなのは、サーバサイドが実際にどの schema キーワードをサポートしているかです。これは一度の調査(ドキュメントと実測)として扱ってください。どの制約が実際に効いていて、どの制約が検証に任せられているのかを知る必要があります。もしサーバサイドが必要な機能をサポートしていないとわかったなら、それがフェーズ 4 の動機になります。

フェーズ 4:独自の制約を自前実装(必要に応じて)

次のうち少なくとも 1 つが成り立つときに限って、独自実装に投資する価値があります。(1)サーバサイドが必要な制約機能をサポートしていない。(2)候補集合を制御したい(たとえば、選択可能な値を実時のクエリに限定したい)。(3)ストリーミングな制約が必要だ。(4)スループットを最適化するために制約の実装を制御したい。

独自の選択肢は前の章ですでに述べました(エンジン内蔵のバックエンド、XGrammar、Outlines、guidance のいずれか)。選定で重要な問いは 1 つだけです。あなたの schema がどういう形をしているか、制約をどれだけ厳しくしたいかです。緩んだ schema なら何でもいけます。密な schema 才有必要上専用エンジン。

フェーズ 5:監視と回帰を補う(継続)

第 12 章と第 13 章に従って、3 つの失敗率、コンパイル所要時間、キャッシュヒット率、切り捨てられた確率質量を整備し、制約のカバレッジテストを CI に入れてください。新機能はありませんが、これまでの投資を煙たき;- 維持するための仕組みです。

4 つのフェーズの順序には、直感に反する点が 1 つあります。フェーズ 3(サーバサイドの構造化出力)の効果は、通常はフェーズ 4(独自実装)よりはるかに大きくなります。多くのチームがサーバサイドの案を飛ばして最初から自前実装し、エンジン統合のコストを全部支払ったのに、対応する対価を得られていません。自前実装でしか満たせない明確な要件がある場合を除き、まずサーバサイドを使いましょう。

もう 1 つの要点は、フェーズ 2(max_tokens と schema の修正)が広く過小評価されていることです。新しいコンポーネントは何も要りませんが、多くの失敗の大部分を解決できることがよくあります。多くの「形式の問題」が、実際には「あなたはモデルに情報のないものを出力させているだけ」だからです。ここをじっくりやれば、以降の方策の効果を正しく見積もれます。

二十五、複雑な schema における 5 つの難点

ここまでの章で扱った schema は、その大半がフラットなオブジェクトでした。しかし実際のプロジェクトの schema は、そう厄介なものではありません。この章では、よく出てくる 5 つの難点を取り上げ、それぞれがコンパイル期と実行期のどちらで表面化するのか、どう扱えばよいのかを説明します。

25.1 判別フィールドと多態オブジェクト

もっとも厄介な構造は、同じフィールドが型ごとに異なる下位構造を持つタイプです。典型的な形は判別フィールドつきの union です。オブジェクトの中に型のフィールドがあり、その値は列挙型のいずれかひとつで、それぞれのプロパティ集合がまったく違います。ログ、イベント、プロトコルのメッセージがこの部類に入ります。

この種の schema をコンパイルすること自体は、難しいことではありません。組み合わせ爆発のほうが厄介です。3 つの型がそれぞれ 10 個の子フィールドを持つ場合、コンパイラが各分岐の文法をすべて展開すると、文法サイズはおおよそ 1 分岐の 3 倍になります。型が 10 種に増えたなら 10 倍です。文法サイズはコンパイル時間と状態キャッシュのサイズを直接左右するので、型の数が多いときは警戒が必要です。

対処は 3 通りあります。1 つめは文法レイヤーでの共通前置部分の共有です。判別フィールドを先に出し、続くフィールドを判別値ごとに分岐させます。これで共通部分の文法は 1 度しか書かずに済み、サイズをかなり削れます。2 つめは、同時に使える型の数を制限することです。業務上、すべての型が同時に現れることは少ないなら、schema を場面ごとに小さなバージョンに分けて、毎回 2〜3 種だけを使うのが現実的です。3 つめは、制約を諦めて検証だけにすることです。型の数が本当に多すぎる場合は、判別フィールドだけを列挙で拘束します。ここが効果と代償の比でもっともよい部分です。残りのフィールドは検証側に任せます。

どちらを選ぶか迷ったなら 3 つめを選びます。判別フィールドの誤り率は、他のフィールドとは比較にならないほど高いからです。モデルは分岐内部の構造を体系的に壊すのではなく、見かけ上それっぽい型を選びます。制約を判別フィールドに集中投入し、残りを検証でカバーするのが、費用対効果のもっともよい配分になります。

25.2 条件付きフィールド:本当の難点は文法ではなく意味にある

条件付きフィールドとは、あるフィールドの存在や値が別のフィールドに依存しているものです。もっともよく使われるのは 2 パターンです。ひとつは存在依存で、あるフィールドが存在するとき、別のフィールドも同時に存在しなければなりません。もうひとつは値依存で、あるフィールドが特定の値を取ると、他のフィールドの許容範囲や存在そのものが変化します。

文法レイヤーでは条件付きフィールドを部分的に表現できます。依存関係が「フィールド 2 はフィールド 1 が特定の値のときだけ許される」という形なら、その分岐を文法の特定の文脈に限定するだけで足ります。難点は、コンパイラがパスに依存した状態を保持しなければならないことです。いま何段目にいるのか、このパス上の条件付きフィールドの値は何か、という状態です。これは単純なスタック型の状態機械ではどうにも扱えません。

そのため実際の実装では、条件付きフィールドはほぼ次のどちらかになります。条件を有限個の具体的な分岐へコンパイルする方法と、検証レイヤーへ委ねる方法です。前者は条件を展開することになり、組み合わせが増えると爆発します。推奨は後者で、しかも条件は明示的な検証ルールとして書くべきです。条件そのものが業務ルールの翻訳そのものなので、コードに書いたほうが文法に変換するより保守しやすいためです。

ここには見落とされがちな恩恵があります。条件を検証側に書き込むと、この部分の業務ルールが、各呼び出し元に散らばっていた状態から 1 か所に集まる状態に変わります。多くのチームではフィールド依存のルールが別サービスの別所に散らばっていて、ルールを変えるたびに全体を検索が必要になります。1 つの検証モジュールにまとめれば、変更の影響範囲が一目でわかるようになります。

25.3 union 型と「どれかひとつ」

union 型は文法上の実装がいちばん簡単です。生成規則を縦棒で繋ぐだけです。本当の問題は、何を検証するのかが曖昧になることです。モデルが文字列の分岐を出力し、自分のコードはオブジェクトの分岐を期待していたなら、型エラーとフィールド不足のどちらを報告すべきでしょうか。複数の分岐が正当なら、戻り値の型は下流に対して不安定になります。

より厄介なのは下流での扱いやすさです。戻り値の型が不安定なインターフェースは、型システムの負担をすべての呼び出し元に押し付けます。制約付きデコーディングが保証できるのは、出力がいずれかの分岐に従うことだけです。あなたが望む分岐に従うことは保証しません。したがって、業務要件がどちらの分岐を使うべきかを前から知っているなら、正しい做法は schema の時点で確定させることです。union を残してモデルに選ばせるべきではありません。union 型は、分岐が実行時にしか決められない場面にだけ使ってください。

これは見落とされがちな設計原則です。schema の中の union は、確定した問題を確率の問題に置き換えています。モデルが分岐を取り違える確率はゼロではないものの、その可能性を schema のレイヤーそのもので消せます。消せるなら消してください。

25.4 再帰構造の深さと広さ

再帰構造は文法ではとても表現しやすく、自己参照するルールを 1 本書けばよいだけです。ツリー状の組織図、ネストしたコメント、ディレクトリ構成などがこれにあたります。問題は実際の生成時に出てきます。モデルはあなたの深さの上限を知らないため、非常に深い入れ子を生成することがあります。下流のパーサに再帰深度の制限があるなら、制限を超えた出力は下流でそのまま例外になります。

対策は 3 つあり、推奨順に並べると次の通りです。1 つめは、生成後に深さを検査し、閾値を超えたら失敗とみなしてリトライするか縮退させる方法です。もっとも直接的で、まずこれを選びます。2 つめは、コンパイル段階で再帰を有限層だけ展開する方法です。文法では 5 層までの入れ子しか許可しません。代償は柔軟性が狭まることです。3 つめはプロンプトで深さ制限を説明する方法ですが、これはほぼ効きません。単なる要求にしかすぎないからです。

深さに加えて、広さの問題もあります。再帰構造内の配列に長さを制限しないと、モデルは数千要素の配列を生成することがあり、解析でも描画でも危険になります。同様に、文法レイヤーでは最大 100 要素のような制約を表現しづらく、それには計数が必要です。より現実的なのは、検証レイヤーで個数を制限するか、解析時に切り詰めて記録することです。

ついでに言うと、再帰構造にはもう 1 つ隠れたリスクがあります。構文状態キャッシュとの相性です。深さが違えば状態も異なるため、深い入れ子は大量の state を生やし、キャッシュヒット率が落ちます。深い構造を日常的に扱う業務であれば、マスク構築の所要時間の分布を単独で観測しておくとよいでしょう。

25.5 巨大な列挙

最後の難点は、列挙そのものが大きい場合です。データベースから取ってきた数千の製品名、数百のタグがその例です。このような列挙を制約で表現すると、巨大な縦棒区切りのリストになります。文法サイズが非常に大きくなり、値を 1 つ追加するたびに state も 1 つ増えます。

もっと悪いのはユーザー体験です。ある値が学習データに一度も現れたことがないと、その値を候補集合に強制しても、モデルはそもそも書き方を知りません。その値に慣れていないため、近いけど誤った値を書きます。これはまさに、制約が解決するはずの問題です。

巨大な列挙に対しては、2 段階に分けることを提案します。まず通常の生成を 1 回行って、モデルに意図を言葉で表現させます。たとえばこれは乳幼児用の粉ミルク製品である、という説明を出させます。次にコードでその意図を、具体的な正当な値へ写像します。最後にモデルを、縮小した候補集合の中で選ばせます。「値を生成する」と「候補から選ぶ」という 2 つの作業を分離したほうが、モデルに数千の値から 1 つ選ばせるより信頼できます。

このパターンが効く範囲は広いです。ラベルの選択、エンティティリンク、分類へのラベル付けなど、候補集合が大きすぎてモデルにすべて見せられない場面ではすべて使えます。本質は、列挙を retrieval で置き換えることです。

二十六、構造化出力のストリーミングのルート

前に触れたとおり、ストリーミングと制約は相性が悪い。この章では実際に使えるルートを整理します。多くのプロダクトの構造化出力はストリーミングであり、この領域のベストプラクティスはまだかなり未熟だからです。

26.1 3 本の基本ルート

1 本目は擬似ストリーミングです。サーバ側で完全な出力を生成してから、結果をスライスしてフロントに段階的に送ります。実装がもっとも容易で、もっとも安定した案です。実態は、非ストリーミング生成にチャンク送信を足しただけだからです。ユーザー体験も実はそこまで悪くないものです。構造化結果を消費するのはたいていプログラムで、フロントは受け取って 나서初めて描画するため、チャンク送信の必要性がもともと限定的です。

2 本目は、真のストリーミングに prefix repair を組み合わせる方式です。生成しながら生成済みの部分をフロントに送り、フロント側がエラーに寛容なパーサで不完全な prefix を補修して、最善を尽くした中間状態を得ます。利点は最初の描画に早く内容が現れることで、代償はフロント側の解析ロジックが任意の切り詰めに対応しなければならず、しかも中間状態がちらつくことです。

3 本目は、拘束されたチャンク送信です。制約付きデコーディングで生成しつつ、構文の境界ごと(あるフィールドが完全に終わったときなど)に完全な単位を切り出して送ります。もっとも優雅なルートですが、実装はもっとも複雑です。制約エンジンが、いま安全に分割できる位置を公開できることが必要で、この能力はほとんどエンジンが提供していません。

26.2 過小評価されがちなフロントエンドの問題

構造化出力をフロント側でチャンクごとに描画するなら、フィールドの順序が描画体験に影響します。制約はフィールドの順序を強制できます。これは実用的なテクニックだと、前の章で話しました。順序は schema の宣言順ではなく、ユーザーがもっとも気にしているものを先にという基準で並べます。この小さな変更だけで、ストリーミングでの体感速度が大幅に向上します。

もう 1 つのフロント側の問題は、欠けたフィールドのプレースホルダです。制約は必填フィールドの必ず出現を保証するので、フロントは、まだ届いていないフィールドの分岐を処理する必要がありません。これは制約付きデコーディングがフロントに与える直接の恩恵です。 逆に、フロント側にすでにフィールドが欠けるかもしれないという互換コードが大量に書かれているなら、制約付きデコーディングの価値はバックエンドの信頼性の向上だけではありません。フロントのコードも大幅に簡略化できる点にも及びます。この恩恵は評価のときに繰り返し見落とされますが、実際に実在します。

逆に、フロント側にすでにフィールドが欠けるかもしれないという互換コードが大量に書かれているなら、制約付きデコーディングの価値はバックエンドの信頼性の向上だけではありません。フロントのコードも大幅に簡略化できる点にも及びます。この恩恵は評価のときに繰り返し見落とされますが、実際に実在します。

26.3 ストリーミングでのタイムアウトと縮退

ストリーミングには現実的な問題がもう 1 つあります。制約によって最初の token が遅くなるからです。初期マスクの計算が先に必要になり、生成の途中である状態がコールドスタートを必要とすると、出力に目立った停止が出ます。ユーザーからは、固まっているように見えます。

対策は複数あります。よく使う構文状態を warm up しておく。ごく短い合成入力で、ホットなパスを一度なぞるだけです。ストリーミングでは制約をゆるめる方法もあります。括弧の対応と基本的な構造だけを保証し、完全な schema 制約は行いません。ストリーミングの prefix はそもそも完全には検証できないからです。加えて、停滞アラームを設定します。一定時間新しい token が出ていなければ、縮退かアラームを発動します。

最後の 1 つは特に強調する価値があります。ストリーミングでは、非ストリーミングよりも縮退パースの方が価値があります。ユーザーはすでに内容の一部を見ているので、全部やり直す体験は最悪だからです。このとき受信済みの部分を寛容な 방식으로パースして表示し、生成中という表示を添えるだけなら、直接エラーを出すよりはるかにましです。これが第 11 章の、ストリーミング UI レンダリングという行を判断の根拠にしているものです。

二十七、多候補の生成と選択

制約付きデコーディングには、まだ過小評価されている使い方があります。複数の候補を生成してもっともよいものを選ぶという運用が、実現可能かつ安価になるからです。理由は単純です。各候補が必ず正しい構造になるので、解析失敗に時間を費やす必要がありません。すべての計算資源を、候補の品質を高めることに使えるのです。

27.1 組み合わせられる 3 つの仕組み

1 つめは、サンプリングで複数の候補を出すことです。制約のもとで異なる出力をいくつかサンプリングします。それらは構造的には等価で、違いは内容に現れます。この操作自体は通常の多様性サンプリングと変わりませんが、得られるものは解析ではなく、構造の検証だけで絞り込めることです。

2 つめは、検証器とルールで採点することです。正当な候補が複数得られたら、追加のルールで並べ替えられます。フィールドの完全性、業務ルールに適合しているか、スタイル上の好みに合っているかなどです。本質的には、ルールで再順位づけをする軽量な方法であり、モデルの学習は不要です。

3 つめは、自己一致による投票です。候補どうしで同じ値になることが多いフィールドがあれば、そのフィールドはおそらく正しいはずです。多数決で最終結果を決めれば、多くの抽出タスクでは単発の生成より正確になります。

27.2 いつ多候補を使う価値があるか

多候補は無料ではなく、コストは線形に増えます。候補を 3 つ生成する token コストは単発の 3 倍です。制約と検証のオーバーヘッドも同じ比率で膨らみます。よって使うかどうかは、3 つの判断で決まります。

  • 単発の成功率は、すでに十分高いか。単発で 9 割以上の意味的妥当率が得られるなら、多候補による利得は小さい。残った誤りは、たいていモデル能力の問題であって、偶発性の問題ではありません。
  • 誤りのコストは大きいか。自動実行される経路なら、3 倍のコストをかけて正答率を上げるのは通常划算な取引です。一方、複数人をまとめて人手で確認する経路では、划算合わないことがあります。
  • 多候補より安い手段はないか。誤りが 1 つか 2 つのフィールドに集中しているなら、まず 1 回リトライすれば解決することが多いです。多候補は全体を拡げる办法です。

よく選ばれるのは 2 個から 3 個の候補です。最初の候補に数回のリトライを加える組み合わせは、たいていの場面をカバーでき、コストも抑えられます。候補が 5 個を超えると、限界的な利得は急速に落ち、レイテンシは許容できなくなります。

27.3 多候補とリトライの違い

リトライと多候補は混同されがちですが、解決する問題が違っています。リトライは失敗したときに起動する补救で、失敗シグナル(検証が通らないこと)に依存します。したがって追加のコストは、最初の 1 回が本当に失敗したときだけ発生します。しかも通常はエラー情報をモデルに返すので、効果はよりよくなります。

多候補は、無条件に複数版を生成するものです。最初が正しい場合でも 3 倍のコストを払い、得られるものは「選ぶ」機会を明示的に作れることです。よって推奨する順序は、まずリトライをととのえることです。安価で、しかもフィードバック情報があります。そのあとで多候補を考えます。高価ですが、並べ替えができるからです。多くのチームが順序を逆にしています。多候補を先に置き、検証器を用意しなかったために、2 倍のコストを無駄にするだけです。

27.4 制約が「選ぶ」を測定可能にする

多候補が成立するかは、どの候補がよいかを言えるかどうかで決まります。採点の根拠がなければ、多候補は計算資源の無駄にすぎません。ここに制約付きデコーディングの間接的な貢献があります。候補どうしの差分を内容だけに絞ってくれるので、業務ルールで比較できます。事前に構造の正規化や書式の揃えをしておく必要もなくなります。比較の実装は単純です。フィールドの値にルールを当てればよいだけです。

この利点は、候補に自由テキストのフィールドが含まれるときにもっとも明確になります。制約がない場合、2 つの候補が片方はダブルクォート、もう片方はシングルクォートを使う、空白の有無が違う、といったことが起こります。比較ルールは、まずこれらのノイズを処理する必要があります。制約がある場合は、そのような差分は存在しません。

二十八、「プロンプト+修復」からの制約への移行

いまあなたのシステムが「プロンプトで JSON を出力させ、失敗したらリトライ」という状態だとします。この章では移行の進め方と、移行のなかでもっともよく起きる問題を取り上げます。完全な移行は通常 4 つのステップに分かれます。それぞれを独立に検証でき、独立に戻せます。

28.1 第 1 ステップ:まず観測をそろえてから、挙動を変える

移行の第 1 ステップは制約を入れることではありません。現在の失敗率を測ることです。具体的には、書式の一発通過率がいくつか、修復後の通過率がいくつか、意味的有効率がいくつか、そしてこの 3 つの関係はどうかを知る必要があります。修復後の通過率が一発通過率よりはるかに高いなら、いま依存しているのは実際には修復器だということで、移行の利得はもっと大きくなります。

同時に、失敗サンプルを集めます。とくに修復器でも直せないサンプルです。それらは通常 2 つの問題を指しています。max_tokens が小さすぎて切り詰めが起きているか、あるいは schema がモデルに提供できない情報を要求しているかです。この 2 つの修正コストは制約の導入よりずっと低いので、先にやるべきです。

28.2 第 2 ステップ:schema を締める

第 2 ステップは、schema を記述的なものから実行可能なものへ変えることです。列挙で表せるフィールドは、すべて文字列から列挙型へ変えます。長さを決められる文字列にはすべて制限を付けます。散らばった任意フィールドは、明確な必須か任意かへ変えます。1 つの文字列に複数の概念を押し込んだ設計は、分けられたフィールドへ直します。す。

このステップの利得は二重です。制約で強制できる割合が上がり、モデル自身の出力品質も直接的に上がります。モデルが入れるものを推測しなくなるからです。このステップを飛ばして直接制約を入れるチームも少なくありません。結果として制約で強制できる部分が少なくなり、制約付きデコーディングは役に立たないという誤った結論に至ります。

28.3 第 3 ステップ:制約を入れ、旧経路は残しておく

第 3 ステップで制約を入れます。ただし旧経路は必ず残し、スイッチで切り替えられるようにします。理由は第 24 章で述べました。新経路は書式と内容の分布を同時に変えるため、問題が起きたときに素早く切り戻せること、そして 2 本の経路の出力を比較できることが必要だからです。

このステップでもっとも起こりやすい間違いは、一度に変える変数が多すぎることです。schema を変え、モデルを替え、制約を入れ、プロンプトも調整した。結果として指標が動いても、どれが効いたのか特定できません。正しい做法は一度に変える変数を 1 つだけにして、変更のたびに十分長い時間を観察することです。

28.4 第 4 ステップ:検証器を補う

最後のステップは検証器の補充です。制約は構造しか扱わないので、意味は必ず検証に任せます。このステップは往々にして後回しにされます。しかし 3 層構造が本当に成立した印がこれです。制約層が構造を保証し、検証層が意味を保証し、両者が連携して初めて失敗の大半をカバーできます。

検証器を補うときの推奨順序はこうです。まず業務ルールをすべて明示的な検証として書きます。いったん有効化しなくても、違反率を記録するだけで十分です。その違反率をしばらく観察し、頻度の高い違反に自動拒否とリトライを追加します。観測してから拒否するようにすることで、誤った巻き添えを避けられます。

28.5 移行中にもっともよくある 3 つの問題

1 つめは、schema と制約の同期の取り忘れです。schema を変えたのに制約を再生成し忘れると、制約は古い schema のまま強制してしまいます。モデルは埋めるべきフィールドを埋めたのにマスクで止められる、といった症状として現れます。防御手段は schema のハッシュをキャッシュキーに入れ、起動時にいま使っている制約のバージョンを表示することです。

2 つめは、max_tokens を合わせていないことです。schema を締めたあとの出力長はふつう変わります。新しいフィールドが増え、列挙の値が長くなるからです。古い予算をそのまま使うと、新しい切り詰めが生まれます。防御手段は、出力長の統計を監視に入れることです。

3 つめは、2 本の経路の意味の不一致です。旧経路と新経路は、同じ入力に対して違う内容を返すことがあります。下流が旧経路の挙動に依存しているなら、切り替えのタイミングで想定外の結果になります。防御手段は、段階リリースの期間に 2 本を並走させることです。書式の通過率だけでなく、内容の差分を見てください。

二十九、失敗モードの手引き

この章では、ここまでで扱った問題を一冊の手引きにまとめます。条項ごとに、症状、原因、切り分けの方法を示します。読むための材料ではなく、原因を探る手引きとして使ってください。

29.1 症状は「書式は完全に正しいのに、内容が明らかに違う」

もっとも誤診されやすい部類です。制約付きデコーディングは構造を保証し、業務ルールは意味を保証します。前者だけをやると、書式は完璧で内容の誤った出力が出てきます。切り分けの方法は、意味的有効率という指標を見ることです。これが明らかに一発通過率より低いなら、制約層は正常に働いています。問題は業務ルールか、モデル能力の側にある可能性が高いです。実際によく遭遇するのは、次の 3 つです。1 つめは max_tokens が小さすぎることです。出力がたまたま構造として完結する位置で切られていた、という状態です。2 つめは schema がモデルの手に入らない情報を要求していることです。あいまいな説明から正確な数値を取り出すような場合です。3 つめは、制約が強すぎて、モデルをあり得ない値に追い込んでいることです。

29.2 症状は「出力が、ある位置で突然終わる」

この症状はほとんどいつもデッドロック状態です。制約が完了できない状態に入り、マスクが以降のすべての token を禁じて、出力が強制的に終わります。原因は文法の欠陥です。たとえば、ある分岐によって特定の必填フィールドを満たせなくなる、といった具合です。切り分けの方法は、出力終了時の制約の状態スタックを出力することです。通常は、何が欠けているかがそのまま読み取れます。対処は、その分岐のルールを足すことです。あるいは生成後に必填フィールドがそろっているかを検査する方法もあります。

もう 1 つの可能性は、モデルが停止を選んだ場合です。実装が「モデルが自ら終端記号を出せる」機能を備えている一方で、文法が未完成の状態での終了を許していると、この症状が出ます。したがって終端記号の合法性は、現在の状態と紐づけられていなければなりません。

29.3 症状は「たまに(毎回ではない)不正な文字が出る」

たまに現れる不正な文字は、ほとんどいつもマスクの状態計算が間違っているからです。もっともよくある原因は、マスクが現在の状態ではなく次の状態を使ってしまうこと(あるいはその逆)です。あるいは状態が複数のリクエストの間で誤って共有されていることです。切り分けの方法はアサーションを足すことです。マスクの適用後に、許可された token をすべてつないだ文字列が依然として完了可能かを検査します。これは安価で、かつ有効なオンライン検出です。対処は状態とリクエストを紐づけて、状態機械のオブジェクトが再利用されないようにすることです。

もう 1 つの可能性は、tokenizer の文字境界をまたぐ問題です。ある token の文字列の形が、文法の境界をまたいでいます。切り分けの方法は、実際の生成サンプルを逆向きに分割して、境界をまたぐ token が存在するかを確認することです。

29.4 症状は「サービスを再起動すると治る」

再起動で解決するなら、キャッシュ汚染を疑います。制約の文法、コンパイル済み構造、あるいはマスク状態のキャッシュに古い項目が残っているということです。よくある原因はキャッシュキーが不完全なことで、schema のバージョン、エンジンのバージョン、モデルの識別子のいずれかが抜けています。対処は、これらの次元をすべてキャッシュキーに入れることです。あわせて、デプロイ時にはエンジン内のプロセス単位のキャッシュを明示的に消します。再起動で治る問題はほぼこの系統です。したがってとても有用な診断シグナルです。再起動が効くなら、コードの論理を追うのではなく、まずキャッシュを調べます。

29.5 症状は「高並行のときにスループットのほうが落ちる」

制約付きデコーディングで唯一の、逆方向に働く性質です。原因は、高並行のときにマスク構築に使う CPU がボトルネックになること、あるいはマルチテナントの schema が分散して状態キャッシュのヒット率が落ちること、この 2 つです。切り分けの方法は、並行度を低・中・高の 3 段階でそれぞれ負荷試験し、スループットの曲線がどこで折れ曲がるかを観測することです。あわせてキャッシュヒット率も見ます。対処は費用対効果の順に並べます。まずキャッシュを拡張して、ヒット率を観察します。ヒット率はすでに高いのに CPU がまだきついなら、マスクを GPU テンソルの形に変更します。CPU コアを増やすのは最後の手段です。

29.6 症状は「リトライの上限まで使っても失敗する」

この症状は、リトライ戦略が、リトライでは解けない問題を覆い隠していることを意味します。3 回ともリトライが失敗したなら、問題はほぼ確実にランダムなものではありません。リトライが効くのは、エラーに確率的要素があるためです。同じ入力でも、生成結果は毎回少しずつ違います。エラーが系統的なもの、たとえば schema とモデル能力が合っていない場合なら、何万回リトライしても成功しません。対処は根本原因に戻ることです。max_tokens が足りていないか、schema が不可能なことを求めていないか、制約が強すぎないかを確認します。この場面でリトライ回数を増やすのは、単純にコストを燃やすだけです。

29.7 症状は「本番でたまに書式エラーが出るが、再現できない」

失敗率が低く、再現も難しいなら、もっとも有効な方法はログの追加ではありません。まず観測できる形に落とすことです。失敗した生の出力をまるごと記録します。機微情報には注意してください。1 日に数十回しか出なくても、2 週間あれば特定のための十分なサンプルがたまります。この段階を先に踏むのは必須です。サンプルがないままの推測は、盲目そのものだからです。サンプルをためるときは、文脈も記録してください。モデルのバージョン、schema のバージョン、エンジンのバージョンが分からなければ、サンプル同士を比べられません。

29.8 汎用的な切り分け手順

制約に関する問題に出会ったら、次の順番で調べることを提案します。まず制約が本当に効いているかを確認します。逆向きの検証法で、意図的に制約を緩めて出力が変わるかを見ます。次に、制約の作用範囲が正しいかを確認します。schema と生成された文法をフィールドごとに突き合わせ、漏れているフィールドがないかを見ます。それから状態管理が正しいかを確認します。とくに並行な場面でも、状態が誤って共有されていないかを見ます。いちばん最後に、モデルのほうを疑います。この順番の背後にある判断はこうです。制約システムの問題はだいたい決定的で、再現可能で、ソースレビューでも見つけられます。モデルの問題は確率的で、いちばん特定しにくい。したがって、安い候補を先に潰すのです。

三十、制約とプロンプトの責務の境界

これまでの章では、プロンプトは意図、制約は構造、検証は意味を担う、ということを何度も述べてきました。ただ、この分担は具体的な意思決定のときに、いつも明快に適用できるとは限りません。この章ではそれを、そのままたどれる決定木に変え、「これはどの層に置くべきなのか」という実務上の疑問に答えます。

30.1 最初の問い:失敗したら何が起きるのか

分層の 1 つめの判断基準は技術的な性質ではなく、失敗の結果です。同じく「モデルが値を選んだ」でも、画面にラベルが 1 つ出ないだけならプロンプトの領域です。プロンプトを直すのが最も安くなります。結果として実行してはいけないデータベース操作につながるなら、制約と検証の領域で、工程的に強制する必要があります。この判断が重要なのは、よくある無駄な投資を避けられるからです。プロンプトを細かく調整して、モデルに値をもっと正確に選ぼうと時間をかけることです。しかし正答率の上限は、しばしばプロンプトでは決まりません。モデルの情報が足りず正しい選択ができないなら、どう書いても推測しかできません。この場合は表現の工夫ではなく、何がないのかを問うべきです。

30.2 2 つめの問い:正確に表現できるかどうか

2 つめの判断基準は表現可能性です。ある要求を「値は、この有限集合のいずれかでなければならない」と書ける、あるいは「このフィールドは存在しなければならない」と書けるなら、それは制約に入れるべきです。制約なら百分之百に保証できるからです。一方、语气や簡潔さ、理由の提示といった要求は、構文では表現できません。構文にはその表現の手段がないからです。この区分には重要な帰結があります。プロンプトの中で構造的な要求を何度も強調していると気づいたら、それはすぐ制約に入れるべきです。何度も強調しているということ自体が、その要求が重要だという意味だからです。重要な構造的な要求を、確率的な手段に預けるのは組み合わせとして間違っています。プロンプトに構造的な要求が 1 つ増えるたびに、モデルが内容要求に割く注意が削られます。

30.3 3 つめの問い:破ったときの補足のコスト

3 つめの判断基準は補足のコストです。同じエラーでも、自動検出できて低收入で補足できるなら、たとえば 1 回リトライすればよいなら、検証層に置けます。補足のコストが高いなら、たとえばすでに外部への副作用が発生しているなら、制約で先に止める必要があります。この判断は、縮退方針のスイッチを直接決めます。補足のコストが低い失敗だけが縮退パースを許されます。コストが高いものは、リトライを要求するか、直接失敗させます。これが第 9 章の決定表の核心的な根拠です。あの表は場面の対照表に見えますが、実はこの判断原則を具体的にしたものです。

30.4 そのまま使える 3 問のチェックリスト

上の 3 つの問いをまとめると、「これはどこに置くべきか」と迷うとき、次のように順に聞けばよいです。

  1. 失敗の結果はどれくらい重いですか。軽くはないならプロンプトに残し、いくつかの試行で解決します。
  2. それは値や存在性の制約として正確に表現できますか。できるなら制約層へ移します。いちばん信頼できる置き場所です。
  3. 補足のコストは高いですか。高くはないなら検証層に任せ、高ければ制約で先に止める必要があります。

3 つとも答えれば、だいたい答えが出ます。そして結論は「1 つ上の層へ移す」になることが多く、つまりプロンプトから制約へ、制約からハードコードへ、という移動です。経験的には、成熟した構造化出力のシステムでは、プロンプトには意図でしか表せないものだけが残るはずです。语气や文体、推論のしかた、フィールドの意味の説明などです。構造的な要求はすべて、すでに制約と検証に沈んでいるべきです。

三十一、構造化出力とセキュリティ

制約付きデコーディングは、純粋な書式の問題として扱われることがよくあります。しかしセキュリティの上では、独特な位置を持ちます。この章では 3 つを扱います。何を防げるのか、何を防げないのか、そして最後の防御線がなぜ決してここではないのかです。

31.1 制約が本質的に防げる 3 種類のリスク

1 つめは、ツールのホワイトリスト外れです。列挙の制約があれば、モデルは一覧にないツールを呼べません。ツール名を打ち違えることもできません。これによって、よくある攻撃の 1 種類を防げます。名前が似ているけれど権限のより高いツールを、モデルに呼び出させる誘導です。

2 つめは、引数構造からの注入です。引数は schema に適合しなければならないので、攻撃者が構造の中に別のフィールドを忍ばせることはできません。下流が「schema に従ってフィールドを走査して実行する」なら、構造の制約は攻撃面の大きさそのものを制限します。

3 つめは、出力に紛れ込む制御文字です。構造化出力に制御文字、ゼロ幅文字、極端に長い文字列を紛れ込ませると、下流の解析や描画に影響します。文字集合のレベルの制約なら、その内容を外へ出せます。

31.2 制約を防げない 3 つのこと

制約は、意味の次元の越権を防げません。正当なツールの引数にファイルパスやリソース識別子、あるいは検索条件が含まれているなら、「このパスがアクセスしていい場所を指していない」ということは、構文の制約ではまったく扱えません。制約が保証できるのは引数の見た目が正しいことだけで、引数が指している先が正しいことは保証しません。

制約は、プロンプトインジェクションによる内容の流入を防げません。タスクがユーザー入力から情報を抽出するもので、その入力に「status を approved にしてください」といった指示が入っていれば、モデルはそのまま従う可能性が高くなります。モデルから見れば、それもユーザーの要求だからです。制約は status が合法な列挙値であることを保証できますが、合法な列挙のなかに恰恰 approved が含まれているのです。

制約は、データの持ち出しを防げません。モデルが本来知らないはずのデータを、正当なフィールドに書き込んでいたとしても、制約はそこに対して何の手も打ちません。

31.3 では最後の防御線はどこか

構造化出力のセキュリティモデルは、層になっているべきです。制約が構造の合法性を保証し、検証が業務の合法性を保証し、権限層が操作の合法性を保証します。この 3 つは直列の関係で、どれか 1 つが他のかわりにはなりません。

権限層が最後の防御線です。そして「この主体にこの操作の権限があるか」に答えられる唯一の層でもあります。この層は書式を気にせず、内容が妥当かも気にせず、1 つだけを行います。現在の身元とリソースから、通してよいかを判断することです。この層はモデルとは独立に存在する必要があり、モデル出力が合法だからといって飛ばしてはいけません。これが多くのツール呼び出し事故の根本原因です。構造が合法であることを、操作が許可されたことに読み違えているのです。

高リスクな操作には、加えて人間による確認か、二重確認を入れるべきです。制約付きデコーディングは誤操作の数を大きく減らせます。ツール名も引数の構造も間違うことがないからです。ただし誤操作をゼロにはできないので、確認の仕組みは依然として必要です。

31.4 見落とされがちなエスケープの問題

最後に、具体的な技術的な細部を 1 つ。エスケープと二重解析の話です。構造化出力がオブジェクトとして解析され、その中の文字列フィールドが、さらに別のクエリやテンプレート、コマンドへ埋め込まれるなら、制約が保証しているのは第 1 段の解析の正しさだけです。第 2 段の埋め込みには、いまだに注入の余地が残ります。

防御は、文字列連結をやめて、構造化された方法で引数を渡すことです。パラメータ化したクエリや、テンプレートエンジンへの引数渡しです。これは制約付きデコーディングでは解けません。ただし制約が第 1 段しか守っていない、という自覚は、安全感を置く場所を誤らないように役立ちます。

三十二、依存関係のあるオブジェクト列の生成

これまで議論してきたのは、単一オブジェクトの生成でした。しかし多くの現実的なタスクは、オブジェクトの列を生成します。しかも列の中の要素どうしに依存関係があります。後ろの要素は前の要素を参照しなければならない、あるいは後ろの値の範囲は前の結果で決まる、ということです。これは制約付きデコーディングの応用編であり、もっとも間違いやすい場面の 1 つです。

32.1 列生成の 2 通りのやり方

1 つめは、列全体をまとめて生成することです。構文では配列のルールで要素のルールを包みます。要素どうしは、生成済みのフィールドを参照できます。よい点は、1 回の呼び出しで済み、順序も自然になることです。悪い点は、列が長いと後ろの要素が前の内容を覚えていにくく、参照が間違いやすいことです。

2 つめは、複数回に分けて、毎回 1 要素だけ生成する方法です。そのたびに、ここまでに生成した結果を文脈として渡します。モデルが生成すべきは次の要素だけです。よい点は、各段階の文脈が明確で、参照が正確になることです。悪い点は、呼び出し回数が増え、レイテンシが積み上がることです。さらに、進捗を追うための状態機械を保守する必要があります。

選び方は単純です。列の長さが固定で短ければ、たとえば 5 個以下ならまとめて生成します。長さが決まらないか長いなら、分けて生成します。分けるときの追加の利点は、各段階を独立に検証とリトライできることです。失敗の影響範囲は、1 段階の中に収まります。

32.2 順序制約:依存関係を実行可能にする

どちらの方式でも、ポイントは依存関係を表現することです。やり方は 2 つあります。1 つは構文のレベルで順序を強制することです。たとえば参照フィールドは定義された後にしか現れない、と構文で定めるようなものです。もう 1 つは、検証のレベルで参照の完全性を調べることです。

構文レベルの強制は、構造的な順序に向いています。先に宣言してから参照する、というような言語レベルの規則です。一方、参照先がちゃんと存在して型も合っている、という意味の制約は、検証に渡すしかありません。構文は、データベースに何があるかを所知できないからです。

ここに見落とされがちな利益があります。順序を強制すると、モデル出力の正しさが大きく上がります。モデルが参照されるオブジェクトを先に生成しなければならないなら、まだ定義されていないものを指して名前を決めてしまうことがありません。逆に、構文が順序を許すと、モデルは参照を先に書いてから定義をあとで補う傾向があり、そこで合わないことがよく起こります。

32.3 分け生成の状態管理

分けた生成を選ぶなら、状態管理を丁寧に設計する必要があります。少なくとも 3 つのことを扱わなければなりません。進捗の記録、どれを生成済みで次はどれを生成するか。一貫性の検査、新しく生成した要素が既存の要素と衝突しないか。失敗からの復帰。ある段階で失敗したら、全部を最初からやり直すのか、その段階だけリトライするのか、ということです。

失敗からの復帰の設計は特に重要です。推奨するやり方は、完了済みの部分を残して、現在の段階だけリトライすることです。リトライのときは、前回の失敗理由も一緒に渡します。第 10 章で話したリトライの考え方を、そのまま 1 段階ずつに適用していることになります。列全体を最初からやり直すと、コストもレイテンシも制御が利かなくなります。

もう 1 つの実践上の提案は、列に上限を設けることです。モデルは予期より長い列を生成することがあります。とくにタスクの説明があいまいなときはそうです。したがって明示的な最大数を用意して、超えたら止めて記録します。そのまま生成させ続けるのでなく、そこで止めるのです。

32.4 よくある失敗モード

列生成でもっともよく起きる失敗は重複です。モデルがある要素を生成している段階で、すでに出した内容を繰り返し始めます。とくに要素のテンプレートが似ているときです。似た設定項目を 10 個作る場合などを想像してください。制約付きデコーディングはあまり助けになりません。重複した要素は文法上まったく合法だからです。

防御は検証層での重複検出で、重複したら拒否してリトライします。より良いのは、生成の前にこの傾向を断つことです。文脈の中で、次の要素は前の N 個と違っていなければならない、と明示します。分けた生成なら、既存の要素の要約を並べて渡します。モデルは繰り返すなという明確な指示に、想像より高い割合で従います。

三十三、マルチモーダル入力での構造化抽出

ここまでの議論は、入力が純テキストであることを前提としてきました。入力が画像や音声、スキャン文書でも制約付きデコーディングは使えます。ただし新しい問題が出てくるので、別にまとめておきます。

33.1 抽出と識別:2 つのタスクの混ぜもの

画像から構造化情報を抽出する作業、たとえば請求書の写真から金額と日付を取り出すのは、実質的に 2 つのタスクが混ざっています。識別、つまり画像に何があるかを見ることと、抽出つまり識別した内容をフィールドに整えることです。制約付きデコーディングが制限しているのは後ろのタスクだけです。

この区別は重要です。失敗の原因を切り分ける手がかりになるからです。抽出結果が間違っているとき、モデルが画像を読み取れなかったのか、読み取れたのにフィールドを間違えたのかを判断する必要があります。この 2 つの対処法はまったく違います。前者なら画像の前処理を改善するか、より強い視覚モデルに替える必要があります。後者なら制約と検証を改善します。

実用的な切り分け手順が 1 つあります。まずモデルに自由に見えたものを説明させ、その後で構造化抽出を行います。自由の説明の中にそのフィールドがなければ、識別の問題です。説明にはあるのに抽出で落ちたなら、抽出の問題です。この 2 段構成は切り分けに使えるだけでなく、抽出の品質も上げることがあります。第 2 段で説明文を文脈として使えるためです。

33.2 画像に見えないものを制約で埋めることはできない

ここで明確にしておくべき制限があります。制約が保証できるのはフィールドの存在であって、フィールドに内容があることではありません。ぼけた写真から請求番号を抽出するとき、制約はモデルにこのフィールドを埋めます。しかし schema が必須としているので、モデルは推測で値を埋める可能性があります。

これはかなり隠れた失敗を生みます。制約が逆に幻覚をつくってしまいます。制約がなければ、モデルは空の値を出したり、読み取れないと明示したりすることがありました。それは正直な応答です。必須制約を加えると、値は 1 つ入れなければならず、モデルは作り始めるのです。

これは制約付きデコーディングがエラーを悪化させる唯一の場面です。実装の問題ではなく設計の問題です。対策は schema に読み取れない場合の出口を用意することです。null を許す、不確定という専用の列挙値を使う、空文字列を許す、といった手段です。制約は不確実性を消せませんが、不確実性に合法的な表現形式を与えられます。この原則は、汚いデータや欠損データを扱うときにもそのまま使えます。

33.3 音声と文字起こし場面の特有性

音声を書き起こして構造化情報を抽出する作業、たとえば客服の録音から受付情報を取り出す場合は、状況は似ていますがもっと極端です。識別の誤り率が高く、しかも誤りは同音字の誤りが多く、人名やプロジェクト名、数字を聞き間違える形になります。

ここで制約が効くのは主に数字の形式です。電話番号の長さ、受付番号の文字集合、金額の小数点以下の桁数、これらは強制でき、実際に誤りの相当部分を消せます。一方で人名やプロジェクト名のような内容は、制約はほとんど助けになりません。ある会社の全員の名前を列挙するのは、現実的でも理的でもないからです。

この手の内容には、エンティティリンクのほうが有効です。まず説明を生成し、候補集から照合します。モデルに直接名前を書かせるのではなく、候補集から選ばせるのです。これは第 25 章の巨大な列挙の扱いと同じ考え方です。列挙を retrieval で置き換えるということです。

33.4 マルチモーダル場面のコスト特徴

最後の実務上の注意点を 1 つ。マルチモーダルのリクエストは、入力コストが純テキストよりずっと高く、失敗したときのリトライの代償が放大されます。1 回のリトライで画像を処理し直すことになるなら、第 10 章で示した、試行回数の期待値に単回コストを掛けるという式は、マルチモーダルでは現実から大きく離れてしまいます。

したがってマルチモーダルの戦略は、純テキストの場合と変えるべきです。より重い制約と検証を中心に置くことです。単回コストが高いため、リトライでは解けないからです。リトライへの依存は減らします。そして 2 段構成のフローを優先してください。第 1 段の結果を第 2 段で再利用できるためです。複数の抽出タスクが同じ画像を何度も処理する必要もなくなります。

三十四、制約出力を状態機械に導く

エージェントの場面では、構造化出力は終点ではなく、次のロジックの入力になることがよくあります。この章では 1 つの具体的なつなぎ目を扱います。制約された出力をどう安全に状態機械へ渡すか、そして抽出だけの場面と比べて何が違うかです。

34.1 状態遷移の 4 つの出どころ

エージェントの次の状態は通常 4 つの場所から来ます。そしてリスクの差は非常に大きいです。モデルが出力する動作のリスクが最も高いです。確率的なモデルが生成しているからです。ツールの実行結果は比較的制御できます。環境の受動的なイベントは信頼できます。タイムアウトと縮退は、明示的に設計しなければなりません。

制約付きデコーディングが改善できるのは 1 つめです。モデルが出力する動作が構造的に合法であることを保証します。実在するツールを呼ばなければならないとか、引数が schema に適合する必要があるとかです。しかし動作が許可されていることは保証しません。合法な動作でも業務規則に違反していることがあるからです。権限のない状態で特定のツールを呼ぶ場合です。したがって状態機械には権限判断の層が必要です。構造が合法だという理由だけで実行してはいけません。

34.2 状態機械の保護的な設計

制約出力を状態機械につなぐとき、強調しておきたい保護的な設計が 4 つあります。

1 つめは、既定で拒否することです。認識できない動作に遭遇したとき、状態機械は明示的なエラー状態に入るべきです。現状のまま留まったり、既定の分岐に入ったりしてはいけません。現状のままにするとループの中で無限リトライになり、既定の分岐は、気が付かないうちに予期しない経路へ進ませてしまいます。

2 つめは、遷移を明示的に列挙することです。どの未一致の動作もこの辺へ流す、という書き方は避けてください。将来追加する動作が自動的に 1 本の辺へ分類されてしまいます。明示的な列挙なら、新しい動作を追加するときは必ず明示的に扱わなければなりません。これは意図的な摩擦です。

3 つめは、不可逆な動作に追加の確認を要することです。制約は動作の合法性を保証しますが、実行のタイミングが適切であるかどうかは保証しません。したがって状態機械には、保留中という中間状態を持たせます。リスクの高い動作はまずこの状態に入り、外部の確認を得てから実行します。

4 つめは、状態機械の遷移を冪等にすることです。 ある動作がタイムアウトでリトライされたとしても、2 度実行されてはいけません。制約はこの点は保証できませんが、状態機械は実行済みの動作を記録することで保証できます。

34.3 ループ検出

エージェントの場面でいちばんよくある重大な問題はループです。モデルが同じ動作を繰り返し出し、結果が毎回同じなので、無限ループに陥ります。制約付きデコーディングはこの問題にほとんど助けになりません。繰り返しの動作は文法上まったく合法だからです。

防御手段は 3 つの層があります。もっとも軽いのは動作の重複除去です。同じ、またはよく似た動作が連続したら中断します。もっとも重いのは状態検出です。状態の軌跡を追跡し、以前訪れた状態に戻ったらループと判定して中断します。中間の層は進捗検出です。各動作が必ず観測可能な進展をもたらすことを要求します。たとえば新しい情報が加わる、状態が変わる、といったことです。連続する数ステップで進展がなければ中断します。

このうち状態検出がいちばん効果的です。事前に、どんな繰り返しが悪いのかを推測しておく必要がないからです。実装には訪問済みの状態の集合を使えばよいです。現在の状態が集合に入っていれば古い状態に戻ったということで、経路は必ず輪になっていると分かります。

34.4 ループ検出と制約の連携

注目すべきことに、制約付きデコーディングは状態空間を小さくし、そのためループ検出が容易になります。出力が有限の列挙に制約されていれば、状態軌跡の数を制御できます。状態が重複したかどうかでループを判断すれば、非常に正確です。逆に、出力が自由テキストなら状態空間はほとんど無限で、状態を基盤にした検出はすべて機能しなくなります。

これは制約付きデコーディングの働きとしてはあまり挙がらない間接的な利得です。システムを、オープンループから有限状態機械という分析可能なシステムへ変えるからです。自由出力ではプロンプトと祈りでしか避けられなかった問題が、強い制約の下では形式的に検出できる問題になります。

三十五、エンジンとフレームワークの選定対照

第 6 章では大まかな対照表を示しました。この章ではそれを細分化し、選定時に実際に使える判断軸を示します。注意点を 1 つ。フレームワークの進化は速く、具体的な機能の対応は、お使いの版の文書と実測を基準にしてください。ここでは機能の一覧ではなく、判断の枠組みを示しています。

35.1 4 つの類型

1 つめは、モデル提供者のインターフェース機能です。制約の実装はサーバー側で行い、schema を渡すだけです。利点は運用の負担がゼロであること、モデルバージョンと強く結びついていること、レイテンシが最適であることです。欠点は制約の細部が不透明であること、対応できる schema キーワードが限られること、カスタマイズできないことです。ほとんどのチームはここから始めるべきです。

2 つめは、推論エンジン内蔵の guided decoding です。エンジンがサンプリングループの中で実装します。一般的なバックエンドにも実装ごとに差があり、カバレッジも違います。利点は性能が制御でき、エンジンのバッチ処理やキャッシュ最適化と組み合わせやすいことです。欠点はバックエンドを自分で選んで設定すること、そしてアップグレード時に互換性に注意が必要なことです。

3 つめは、独立した制約ライブラリです。制約のコンパイルとマスク生成をライブラリとしてまとめ、logits ハンドラのような接口でエンジンに組み込みます。利点は柔軟で再利用できること、エンジンを変えずに済むことです。欠点はエンジンとの統合を自分で扱わなければならないことで、性能は通常エンジン内蔵の方針に劣ります。

4 つめは、より攻めた生成フレームワークです。制約だけでなく生成プロセス全体の制御流も扱います。ストリーミングや逆方向の制約、制約と生成ロジックを混ぜて組み立てることに応えます。利点は機能が強く、複雑な生成条件を表現できることです。欠点は学習コストが高く、既存の推論スタックとの統合が前の 3 つほど直接ではないことです。

35.2 選定の 6 つの判断軸

1 つめは、schema のカバレッジです。対応しているキーワードがいくつあるかではなく、自分の schema で実際に使っているキーワードが何個カバーされるかを見ます。やり方は実際的です。schema を取り出して、キーワードを 1 つずつ突き合わせます。結果は 3 通りあり、それぞれ対処が変わります。コンパイルできるものは制約に渡します。コンパイルできないが重要なものは、schema を書き換えてコンパイルできる形にする方法を考えます。条件依存を union 型で置き換えるようなものです。コンパイルできず書き換えもできないものは、検証に渡します。

2 つめは、性能の特徴です。重要なのは平均速度ではなく、分散の末端です。制約構築のレイテンシ分布、キャッシュのヒット率、そして高並行での拡張性を見ます。トラフィックのピークが高いなら、この項目の重みを上げるべきです。

3 つめは、既存のスタックとの統合費用です。すでに特定の推論エンジンとサービスフレームワークを使っているなら、コンポーネントを 1 つ足せるかどうかが、能力の最大性より重要なことが多いです。生成プロセス全体を書き直す必要のあるものは、能力が強くても採用されにくいです。

4 つめは、デバッグしやすさです。制約システムに問題が出たとき、調べきれるかどうかで決まります。状態を見せているか、文法を出力できるか、明確なエラー情報があるかに依存します。ブラックボックスの制約実装は本番では危険です。失敗したときに取りの仕掛けがないからです。

5 つめは、キャッシュと状態管理の制御可能性です。キャッシュキーを制御できるか、事前暖機ができるか、ヒット率を見られるか、これらはマルチテナントの場面で重要な要素です。

6 つめは、メンテナンスの活発さとバージョン安定性です。この分野の変化は速く、活発でない実装は、半年後にはエンジンのアップグレードを妨げることがあります。選ぶときは直近のコミット頻度和リリースのペースを見るべきです。

35.3 実用的な推奨パス

どれを選べばよいか分からないなら、次の順番で試します。まず提供者のインターフェース機能を全体の動線に乗せて、この段階で schema を締め、検証を揃え、監視を構築します。このステップの価値は、ほぼゼロの費用で多くの問題を解決できることです。

そのあとでデータを見ます。書式の問題がすでに少なければ、自前の制約は不要です。意味の問題が際立って多い場合です。書式の問題が残っているなら、通常は提供者が必要な schema 機能をサポートしていないからです。その場合はエンジン内蔵の方式へアップグレードします。

三十六、可検証生成と自己点検

ここまでの内容は、出力が合法かどうかを扱うものばかりでした。この章では一歩進んだ問題を扱います。出力の正しさを、受け入れ可能であるだけでなく検証可能にする方法です。金融、医療、コンプライアンスの分野で特に重要です。

36.1 3 種類の検証可能性

1 つめは、構文としての検証可能性です。出力が決定的に解析できることですが、これは制約付きデコーディングが保証します。特点是検証コストが極めて低く、結論が確定的ですが、保証できる情報量は限られます。

2 つめは、制約としての検証可能性です。出力が明示的な業務規則の組を満たすことですが、これは検証器が保証します。特点是規則を監査できることです。各規則を業務上の規定までさかのぼれるので。ただし規則そのものを間違える可能性はあります。

3 つめは、根拠としての検証可能性です。出力のすべての値を、その根拠までさかのぼれることです。たとえば原文のどの段落か、どのデータソースかを指せます。これには追加の設計が要ります。通常は schema の中で各値に引用フィールドを添えるようにし、アプリケーションの段階で引用の有効性を検査します。

3 つめは多くの場面で、いちばん価値があります。モデルを信じることが、引用の確認に変わるからです。モデルの誤り率は必ずしも低くありません。しかし各主張に根拠が付いていれば、人間やプログラムは根拠を抽出検査するだけですみます。審査のコストは大幅に下がります。

36.2 モデルに自己点検させる

コストは低く、効果もある手法が 1 つあります。生成のあとに、モデル自身に一度チェックさせることです。生成のあとに短いチェックを追加し、schema と照らし合わせて自分の出力に問題がないか判断させ、問題があれば修正させます。

この方法が効くのは、前に触れた性質に依存しています。モデルが自分でいま生成した内容には、強い局所的な注意を向けます。そのため自己点検は、本番の生成では見落としていた問題を見つけてくれることが多いのです。フィールドの抜けや、値が列挙に入っていない、この種の問題に特に効きます。

ただし 2 つの罠に注意が必要です。1 つは、モデルが誤った出力を自信満々に認めてしまうことです。自己点検は検証器の代わりにはならず、補足としてしか使えません。もう 1 つは、自己点検がレイテンシを大きく増やすことです。誤りの代償が十分に高い場合にだけ使う価値があります。

36.3 検証器で自己点検を置き換える

自己点検は信頼できないので、決定的な検証ができる場所でモデルに自己点検させてはいけません。この項目は実際のプロジェクトで何度も破られています。代償は、レイテンシが 2 倍になっても信頼性は上がらないというものです。

判断の基準は単純です。その問題に対して決定的な規則を書けるなら、規則を書きなさい。書けない場合だけ、モデルに判断させてもかまいません。構造化出力の領域では、ほとんどの制約が前者の側に入ります。したがって自己点検が使える場面は、実際にはかなり限られます。

36.4 検証性がアーキテクチャに与える逆向きの制約

不那么 obvious な結論が 1 つあります。何を検証したいかをはっきりさせると、schema の設計が逆に決まります。出力の値をさかのぼれるようにしたいなら、schema には引用フィールドを置くべきです。フィールド間の整合性を検証したいなら、関連するフィールドは散らばせるのではなく、隣り合わせて置くべきです。人間が素早く審査できるようにしたいなら、フィールドの順序とまとめ方は審査の論理に合わせて組織すべきです。

これは、検証性の条件を schema 設計の段階で入れるべきだということを意味します。問題が起きてから補うのではなくです。後から構造化出力に検証を足すのは、たいてい苦しいことになります。元の根拠が手元にないうえ、すでに動いている出力に足りないフィールドを後から足すこともできません。

三十七、可観測シグナルとしての構造化データ

この章では、見落とされがちな使い方を 1 つ扱います。構造化出力を可観測性のシグナルとして使うというものです。価値は内容そのものにあるのではなく、構造が信頼できるからです。そのためにこれらのデータを安定的に採取、集約、通知できます。

37.1 構造化ログの利点

従来のやり方は、モデルの出力から正規表現で主要フィールドを抜き出して集計することでした。ここには問題が 2 つあります。抽出が失敗することがあること、正規表現が一致しないことです。そして、抜けたフィールドの構造が不安定なことです。あるときあったり、 なかったりで、集約のときに大量のデータが捨てられます。

37.2 構造化できる運用指標

ログが構造化されていれば、従来は人手でしか判断できなかった多くの指標を計算できます。たとえば意図の分布が急変したことから異常なトラフィックを見つけたり、ある種類の出力の比率が急上昇したことからモデルの挙動ドリフトを見つけたり、出力フィールドの平均長の変化から入力分布の変化を見つけたりできます。

この種の指標に共通するのは、測っているものがモデルが何と言っているかではなく、モデルが何を考えているか、という点です。だからこそ、ユーザーの文句が出るずっと前からシグナルを出せます。これは非構造化ログでは絶対にできないことです。自由テキストは安定して集約できないからです。

37.3 可観測性を制約の原動力に

ここにとても良い正のフィードバックがあります。これらの指標を取りたければ、安定した構造が要ります。安定した構造があれば採取は簡単になり、その結果、任意の出力ではなく強い制約にする方向に傾きます。最終的には可観測性と信頼性が一緒に上がります。

逆も成り立ちます。可観測性が任意の出力フィールドに頼っているなら、モデルが出力をサボったときに指標は静かに歪みます。そして、今日は異常がないと思い込んでしまいます。可観測性が依存するフィールドは、必須かつ強い制約でなければなりません。そうでないと観測そのものに盲点ができます。

37.4 これらのシグナルを採る費用

最後に費用に触れておきます。構造化ログは生成のたびに追加の出力が必要なことを意味します。出力長とレイテンシが増えます。判断基準は、そのシグナルを採る価値があるかです。1 週間早く重大な障害を 1 回発見できるなら、毎日数ミリ秒余分に費やすのは、採算が合うことです。誰も見ない、面白いだけの指標なら、その価値はありません。

実用的なやり方は、価値のある 1 つ 2 つのシグナルから始めることです。つまり意図の分布とエラー種類の分布です。まず、それが本当に消費されることを確認します。対応するダッシュボードやアラートがあることです。それを確かめてから、段階的に広げていきます。あり得るフィールドを全部必須にするのは、よくある無駄です。

三十八、推論予算と書式制約のトレードオフ

推論できるモデルは、構造化出力に新しい変数を 1 つ加えます。モデルがまず予算の一部を使って考え、そのあと出力を生成することです。この 2 つの予算の関係が、制約付きデコーディングの効果とコストに直接影響するので、この章で分けて考えます。

38.1 推論段と出力段の制約は分かれている

重要なのは次の点です。推論のプロセスは通常、構造的な制約を受けません。制約されるのは最終的な出力だけです。ここからとても良い性質が生まれます。推論は自由に展開して、複雑な判断をThought out考え切ったうえで、最終的な出力は厳密な構造として残るからです。

これは、出力全体を JSON に強制するより、有利です。後者ではモデルが常に書式の重荷を抱えて考え、思考の品質が落ちます。推論と制約の組み合わせは、考えることと伝えることを完全に分け、それぞれに最も適した方法で処理することになります。

38.2 推論予算が書式の誤りで消費される場合

ただし無駄もあります。モデルの推論予算が固定されていて、多くの token を書式の考慮に費やしている場合です。あるフィールドを埋めるべきかどうかを何度も確認したりする場合です。その予算は本来、業務判断に使われていません。制約付きデコーディングの間接的な利得の 1 つが、書式に関する考えを丸ごと省けることです。書式について考える必要がなくなったからです。

これをどう検証するか。制約の有無で推論段の長さを比べるのが手法です。制約があるときの推論が明らかに短ければ、それまでは書式で悩んでいたことになります。長さがほぼ同じなら、モデルは最初から書式で悩んでいなかったということで、制約の主な利得は token の節約ではなく信頼性にあります。

この比較には価値があります。信頼性を上げるか、費用を下げるかのトレードオフを判断する材料になるからです。推論の長さが明らかに変わらないなら、制約の費用は純粋にマスクの処理だけなので、費用的には小さいです。明らかに短くなるなら、制約は推論費用まで下げてくれるので、利得はさらに大きくなります。

38.3 推論結果と構造化フィールドの一致性

よくある問題は、推論で得た結論と、最終的にフィールドへ入れた値が食い違うことです。推論では 3 センチくらいだと言ったのに、フィールドには 5 と入れてしまうようなものです。原因は、2 つの段階の間に強制される対応関係がないことです。

対策は 2 つあります。1 つは推論の中でフィールド名を必ず引用させることです。つまり、考える段階でも各フィールドの値を明示させ、その後で埋めます。もう 1 つは検証層で一致性を調べることです。ただしこれは難しいです。推論段は自由テキストで、それとフィールドを意味的にそろえること自体が、1 回のモデル呼び出しになるからです。

1 つめの方が実用です。実装は、提示の構造を調整して、推論の段階で最終的なフィールドの順に、1 つずつ理由とともに出させることです。この方法の追加の利点は、推論の品質も上がることです。モデルが各フィールドに理由を出すことを強制されるので、まだ考えきれていないフィールドが露出するからです。

三十九、モデル選定時の制約サポート度評価

モデルの世代交代は速く、選定時に文脈長と汎用能力だけを見ると、構造化出力にとって決定的に重要な軸を落としがちです。制約にどれだけ従うかです。この章で、操作できる評価方法を示します。

39.1 刈られた確率質量でモデル同士を比較する

前に述べました。制約は、モデルが言おうとしている内容のうち合法でない部分を刈ります。その刈られる割合が、刈られた確率質量です。この量はモデルによって大きく異なり、モデルが書式に対してどのような内在的な傾向を持つかを直接反映します。

評価方法は簡単です。同じ schema、同じプロンプトの組を、各モデルで走らせ、刈られた確率質量の平均値を記録します。値が低いほど、モデルは書式に従う意愿が高く、制約が分布にもたらす摂動が小さく、出力品質を損なう量も小さくなります。値が高いモデルは、構造化の課題で逆に値が高いモデルは、制約と常に戦っているため、構造化の課題では逆に劣る可能性があります。

この指標は、書式への適合率より価値があります。書式への適合率は制約の有無で比較できますが、刈られた確率質量は成功率に依存しない、連続的な品質のシグナルを与えるからです。

39.2 必須フィールドの埋める完全度

2 つめの評価軸は、必須フィールドの埋める完全度です。制約がフィールドは必ず現れることを保証前提下、そのうち空文字列や意味のないプレースホルダになったものがどれくらいあるかを数えます。この指標が測るのは、モデルが本当に埋めようとしているのか、ただ位置を残されただけなのか、です。

この指標でのモデル間の差は、書式の適合率より大きくなることが多いです。書式の適合率は制約があればどちらもほぼ満点になり、この指標なら差が露出するからです。実用的な経験則として、あるモデルの空値率が明らかに高いなら、そのモデルには一部のフィールドについて,本来入る情報源がないということです。このとき制約の方式を変えるより、schema を直すか文脈を補うのが効きます。

39.3 安定性:同じ入力での複数生成の差

3 つめの軸は安定性です。入力とパラメータを固定して何回か生成し直し、結果の差の程度を統計します。同じモデルでも 2 回の出力が大きく違うなら、平均品質が何ほど良くても本番では厄介です。問題を再現できないからです。

このテストでは temperature の変数を特に制御してください。高い temperature で試すと、不安定性がより露出しやすくなります。多くのモデルは低い temperature では順調に動きますが、本番でマルチテナントの混合負荷によって安定したバッチ条件を保てなくなった途端、結果が変わります。制約付きデコーディングは構造の安定性は上げますが、内容の安定性は上げません。だからこの軸は別に測る必要があります。

四十、チーム協業とコードの組織

制約付きデコーディングは、最後にはチームの複数人で一緒に保守するシステムになります。この章では組織レベルの約束をいくつか扱います。その価値は、具体的な技術の問題を解決することではなく、長期的な混乱を避けることにあります。

40.1 schema は単一の事実の源であるべき

もっともよくある技術的負債は、schema が 3 つあることです。プロンプト生成用、制約のコンパイル用、実行時の検証用に 1 つずつ。この 3 つを手作業で同期しているなら、遅かれ早かれずれます。

対策は、すべてを同じ源から生成することです。その源はインターフェース定義のファイルでも型の定義でもかまいません。ただし 1 つでなければなりません。生成するときに 3 つを一緒に作り、同じコミットの中で更新するようにします。そうするとレビューで制約と検証が同期しているかが、一目で分かります。

40.2 制約に関する約束は書き残す

いくつかはチームの文書に明記しておく価値があります。どれも、書かなければ全員が踏む種類のものです。キャッシュキーに必ず含めるべき次元、エンジンバージョン、モデル識別子、schema バージョン、です。縮退結果に必ず付けるマーク、リトライ回数を誰が決めるか、ここでは呼び出し側がレイテンシ予算に応じて指定するのであって、ライブラリの既定値にしない、ということです。

これらの約束は文書がなければ、人が入れ替わった後に失われ、bug の形で再び現れる可能性が高いです。書く手間は 30 分で、節約されるのは今後の数え切れない原因追跡です。

40.3 階層的なエラーの扱い方

組織レベルの提案をもう 1 つ。書式の失敗と意味の失敗を区別し、それぞれ別の処理経路に対応させることです。書式の失敗はリトライすべきです。制約があれば起きないはずなので、起きたならバグがあります。意味の失敗は、業務上の縮退か、人educt回すかです。

この 2 つを一緒に処理すると、問題の切り分けが難しくなります。今回の失敗が制約層のバグなのか、それとも業務ロジックの通常の拒否なのか、分からなくなるからです。この項目は障害対応のときに価値が大きいです。

40.4 捨てられた schema キーワードを定期的に振り返る

最後に小さな仕組みを提案します。コンパイルの過程で捨てられた schema キーワードを定期的に確認するというものです。第 6 章でも述べましたが、コンパイラはコンパイルできないキーワードを記録しておきます。この一覧の変化は schema の進化の方向を示し、新しい業務規則を制約にコンパイルできるかもしれないことも思い出させます。

この仕組みには別の用途もあります。schema 設計の問題を見つけるためです。同じキーワード、たとえば値の範囲制限が、捨てられた一覧に繰り返し現れるなら、それは重要で、かつ繰り返し破られている規則ということです。制約にするために手間をかける価値があります。たとえば、表現できる schema の書き方を別の形にするとかです。

四十一、本番前のチェックリスト

順番に 1 回流してください。どの項目も 10 分あれば検証できます。

  1. 失敗をオフラインで再現できますか。出会った失敗サンプルをテストセットとして保存してください。20 条だけでも構いません。このデータが無いと、以降のすべての最適化は手探りになります。
  2. 制約は実際に効いていますか。わざと誤った制約を書きます。たとえば enum から 1 つ値を落としてみます。その漏らした値をモデルが出せるかを見ます。出せるなら、制約がまったく機能していないということです。制約があると思っているのに実際には制約がない、という最もよくある状態を捕まえられます。
  3. マスクの適用は正しい位置ですか。マスクの前で 1 回、後で 1 回出力して、非合法な token の logit が本当に -inf になっているか、合法な token が誤って殺されていないかを確認します。
  4. tokenizer の境界テスト。実際の出力の断片を数件取って、逆向きに tokenize します。構文の境界をまたぐ token がないかを見ます。あれば、あなたの判定方法にバグがあります。
  5. max_tokens の妥当性。合法なサンプルを 100 個生成し、token 長の分布を統計して、P99 を取って 2 倍したものを予算の下限にします。
  6. 3 つの失敗率をそれぞれ計測すること。parse の失敗率、repair の率、意味検証の失敗率を、個別に見て、個別の通知を設定します。
  7. リトライ回数はレイテンシ予算から逆算します。勘で決めるのでありません。先に P99 の SLA を決めてから回数を計算します。
  8. 縮退経路にスイッチがあること。縮退を 1 クリックで無効にできるようにします。問題を見つけたときに素早く止血できるためです。
  9. schema のコンパイルはキャッシュすること。さらにキャッシュ key には schema の内容ハッシュを含めます。コンパイル時間とキャッシュヒット率を監視します。
  10. 負荷試験は 3 段階で。低、中、高の並行度でそれぞれスループットと P99 を測り、CPU 上のマスクがボトルネックになっていないかを確認します。

四十二、まとめ

最初に挙げた判断基準に戻ります。出力の解析可能にすることは、本質的に決定的な技術問題です。決定的な仕組みで解くべきもので、確率的なプロンプトで祈るものではありません。3 層の受け皿の分担はこうです。

第 1 層の制約付きデコーディングは、構造上、間違えようがないことを担当します。解析可能率を 95% で再現不可から、100% で CI で守れる状態に上げます。代償は各ステップのマスクの処理と、スループットへの影響 20% から 60% です。意味は直せませんが、意味は元々この層の担当ではありません。

第 2 層の検証と有限のリトライは、内容が妥当であることを担当します。制約では表せない意味の制約、依存関係や範囲やフィールド間の整合性を受け止めます。代償は失敗時の数倍のレイテンシです。ここは最も暴走しやすい層で、リトライ回数は多いほどよいのではなく、レイテンシ予算から逆算すべきです。

第 3 層の縮退パースは、失敗しても全部失わないことを担当します。低コストな場面でのみ有効で、必ず縮退マークを付けます。ハードな失敗を、確信度のある部分結果に変えるのが価値ですが、リスクも最も高い層です。呼び出し側がマークを無視すれば、失敗は静かに成功に偽装されるからです。

1 つだけ覚えておくとしたら、まず第 1 層を入れることです。予算は成功率を_limits 上げる側に使うべきで、リトライ回数を増やす側ではないからです。p を 0.85 から 0.95 に上げることでリトライ 1 回分丸ごと省けます。レイテンシは半分になる 반면、N を 3 から 5 に上げても得られるのは成功率 0.01 の向上で、P99 は 67% 増えます。この一連の考え方の中で最も逆説的で、最も価値のある結論です。

最後に前沿に触れておきます。制約付きデコーディングの次の段階は、意味の制約もデコード期へ移すことだと考えられます。たとえばデコーダが、このフィールドの値を取るという時点でデータベースを逆引きして、合法な値の集合をマスクへ動的に注入することです。この種の外部の状態をフィードバックする制約は、いくつかのフレームワークに形が出ています。しかし外部のレイテンシ、キャッシュの一貫性、複数ターンの状態という複雑さは、純粋な構文の制約よりはるかに高いです。モデル出力が業務で許される値の空間へ厳密に収まらなければならない仕事をしているなら、この方向は見ておく価値があります。