localaiforum
ログイン
ガイド › モデルの活用

構造化出力と関数呼び出し

モデルに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回再試行——制約付き再試行は安く、大抵最初の跳ね返りで成功する。

このまさにそのループ——呼び出しを出し、検証し、実行し、観察する——をエージェントは一日中走らせる:エージェント。

関連: エージェント · プロンプト · サンプリング · RAG