構造化出力と関数呼び出し
モデルにJSONを頼むと、ほぼJSONを喜んで作る。末尾のカンマ、引用符のないキー、最初の波括弧の前の親切な文章。自由形式サンプリングは常に不正トークンを出し得る。サンプラはあなたのスキーマを何も知らないから。構造化出力がこの隙間を塞ぎ、関数呼び出しは同じ機構が別の帽子をかぶったもの。
自由形式JSONが壊れる理由
生成される各トークンは分布からのサンプル。各ステップで、いくらかの確率質量——小さいが、temperature 0超では決してゼロにならない——が、形式を壊すトークンの上に載っている。開き括弧の前の閉じ括弧、配列最終要素の後のカンマ、小文字のtrueがTrueと綴られる(学習データに焼き込まれたPythonの癖)。「ほぼ常に有効」を200出力トークンに掛けると、断続的で再現不能な破壊が得られる——最悪の種類のバグ。temperature 0でさえ、出力を決定的にするだけで、スキーマ有効にはしない。
解決の梯子
| 段 | 機構 | 保証 | 場所 |
|---|---|---|---|
| プロンプトのみ(「有効なJSONで返答して」) | お願い | なし——サンプリングはまだ誤り得る | 任意のエンジン |
| JSONモード | プロンプト+JSON構文に制限 | 構文的に有効なJSON。形状は保証されない | OpenAI互換API、Ollama format: "json" |
| スキーマ制約 | スキーマ → グラマー → ロジットマスク | 出力は常にパースされスキーマに一致 | llama.cpp(json_schema / GBNF)、vLLM guided_json、Ollama structured outputs、outlines / xgrammar |
各段は柔軟性を支払い決定性を買う。消費者がコードなら常に最上段を使うこと。
グラマー制約付きデコーディング
スキーマをグラマーにコンパイルする。各デコードステップで、有効な文書を延長できない各トークンのロジットが、サンプリングの前に−∞に設定される。不正な出力はありそうにないのではない。不可能だ。llama.cppはGBNFグラマーを同梱する。10進数のグラマーは1行。
root ::= "-"? [0-9]+ ("." [0-9]+)?
同じアイデアがJSON Schemaを包む。ローカルのllama.cppサーバ(OpenAI互換エンドポイント)に対して。
import json, requests
schema = {
"type": "object",
"properties": {
"title": {"type": "string"},
"component": {"type": "string",
"enum": ["auth", "upload", "ui", "api"]},
"severity": {"type": "string",
"enum": ["low", "medium", "high"]}
},
"required": ["title", "component", "severity"],
"additionalProperties": False
}
r = requests.post("http://localhost:8080/v1/chat/completions", json={
"messages": [{"role": "user",
"content": "Extract the bug: " + report}],
"response_format": {
"type": "json_schema",
"json_schema": {"name": "bug", "strict": True,
"schema": schema}
},
"temperature": 0
})
bug = json.loads(r.json()["choices"][0]["message"]["content"])
OllamaはformatフィールドのJSON Schemaで同じことを、vLLMはguided_jsonで同じことをする。同じ機構、3つの綴り——エンジンごとの正確なフィールド名はエンジンパラメータ。
制約付き=正しい、ではない
グラマーが保証するのは形状であって真実ではない。モデルはクラッシュ報告に{"severity": "low"}を出し得るし、文字列フィールドを埋め草で膨らませ得る。マスクは自由記述フィールドを傷つけることもある。モデルが段落改行を欲しがり、グラマーが改行を禁じるとき、品質は下がる。実用ルール。
- 値空間が既知なら、自由文字列の代わりに狭いenumを。
- 消費する全フィールドに
required。additionalProperties: falseで発明を止める。 - 文字列フィールドは短く。長い文章は制約付き応答の外へ迂回。
- とはいえ、望む出力の実例を1つプロンプトに入れること——内容を操縦するのはグラマーにはできない。
関数呼び出しとツール呼び出し
ツール呼び出しは構造化出力+慣習。モデルはどの関数をどんな引数で呼ぶかを返し、あなたのコードが実行して結果を追加する。OpenAIのtools形が、ローカルエンジンが模倣する事実上の方言。
{
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"},
"unit": {"type": "string", "enum": ["C", "F"]}
},
"required": ["city"]
}
}
}]
}
モデルはメッセージ内のtool_callsで応答する。あなたは出力をrole: "tool"メッセージとして返し、ループが続く。モデルは特定のツール方言向けに学習されている——Hermes系とQwen系のテンプレートがローカルエンジンで最もよくサポートされる(--jinja付きllama.cpp、Ollama、自動ツール選択付きvLLM)。ツール学習のないモデルは、何を制約しても引数名を幻覚したもっともらしい呼び出しを出す。だから先に仕事に合うモデルを選ぶこと:モデル選択。
パースと内容の両方を検証
毎回の呼び出し後に2つの確認。1つ目、パース——json.loadsが成功する(グラマーは大抵これを保証し、JSONモードは必ずしもそうでない)。2つ目、意味。必須キーが存在、enumが範囲内、数値が妥当、文字列が空でない。失敗したら、検証エラーをプロンプトに戻して1回再試行——制約付き再試行は安く、大抵最初の跳ね返りで成功する。
このまさにそのループ——呼び出しを出し、検証し、実行し、観察する——をエージェントは一日中走らせる:エージェント。