なぜ「構造化出力」が実務の要になるのか

LLMを実サービスに組み込む際、最初にぶつかる壁は 「このモデルは指定したJSONスキーマを守って出力してくれるのか?」 という問題です。どれだけ推論性能が高くても、フィールドが一つ欠けたり、型が一つ違うだけで下流のパイプラインはパースエラーで停止します。

ところが、多くのベンチマークはこの能力をreasoningやextractionのスコアに丸め込んでしまい、独立して測定していません。そこで schema compliance(スキーマ準拠率) を正面から測定するIFStructのようなベンチマークが実務的に価値を持ちます。

本記事では、350Mパラメータの超小型モデル(LFM2.5-350M)を TRLのGRPOでわずか100ステップ 学習させ、IFStructスコアを22.6% → 29.7%まで引き上げた過程をまとめます。学習データは約500件、LoRAパラメータはモデル全体の1.66%程度です。

なお、本記事で扱うパイプラインは、IFStructブログで使用されたRLモデル学習パイプラインとは別物です。IFStructスコアの再現が目的ではなく、小型モデルでもタスク特化のファインチューニングでより大きなモデルに迫れることを示すのが目的です。

Developer reviewing LLM structured output JSON schema on laptop screen during GRPO fine-tuning Coding Session Visual

事前準備: 学習はGPU、評価はMacBookで

本ガイドは二つのパートに分かれます。

  • ファインチューニング: GPU必須(無料のColab/Kaggle GPUを想定したサイジング)
  • 評価: MacBook上でローカル実行。ここではM5 Max + 36GBユニファイドメモリ環境で llama.cpp を使い、OpenAI互換サーバーを立ててIFStruct評価器から接続します。

llama.cppのインストール

brew install llama.cpp
llama-server --version

ベースモデルのサービング(BF16 GGUF)

llama-server \
  -hf LiquidAI/LFM2.5-350M-GGUF:BF16 \
  -c 32768 \
  -np 4 \
  -ngl 99 \
  --alias LiquidAI/LFM2.5-350M \
  --host 127.0.0.1 \
  --port 8080
  • --alias: IFStructがOpenAI互換エンドポイントに送るモデル名
  • -ngl 99: 可能な限り全レイヤーをGPUへオフロード
  • -np 4: 4リクエスト並列処理
  • -c 32768: プロンプトコンテキストサイズ

ベースライン評価の実行

uv run ifstruct-eval \
  --model LiquidAI/LFM2.5-350M \
  --base-url http://localhost:8080/v1 \
  --api-key dummy \
  --dataset data/test.jsonl \
  --results-file results/lfm2.5-350m-llamacpp-base.json \
  --n-threads 4 \
  --max-tokens 2048 \
  -v

結果は 2000サンプル中452件通過(22.6%)。公式ブログで報告されている21.1%とほぼ一致します。この値を同一サービングスタック比較のベースラインとします。

Local llama.cpp server terminal running IFStruct benchmark evaluation on Apple Silicon Mac IT Technology Image

GRPOファインチューニング: 報酬関数の設計が8割

学習データ

nvidia/Nemotron-RL-instruction_following-structured_outputs を使用し、約500サンプルのみを使います。IFStruct評価分布とのギャップを埋めるため、二種類のaugmentationを適用します。

  • 40%: 「fenced code block内に出力を入れよ」という指示文を追加 → raw JSONのみを返す癖を矯正
  • 20%(disjoint): top-level arrayタスクへ変換 → bare list出力とitem count準拠を学習

LoRA設定(LFMハイブリッド構造に注意)

LFM2.5はattention/convolutionハイブリッドアーキテクチャのため、LFM固有のモジュール名をターゲットにする必要があります。

from peft import LoraConfig

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    bias="none",
    task_type="CAUSAL_LM",
    target_modules=[
        "q_proj", "k_proj", "v_proj", "out_proj", "in_proj",
        "w1", "w2", "w3",
    ],
)

この設定で学習されるパラメータは約6M、モデル全体の 1.66% 程度です。

三つの報酬関数([0, 1]スケール)

  • json_format_reward: パース可能かつ要求された形式(fenced vs raw)か? 要求形式なら1.0、パース可能だが形式違いは0.2、パース不可は0.0
  • field_count_reward: top-levelフィールド数が期待値と一致するか? 完全一致で1.0、不一致は線形減衰
  • schema_validation_reward: JSON Schema検証を通過するか? 制約違反ごとに減点、required-keyカバレッジで部分点をゲーティング

重み付き和は reward_weights=[1.0, 0.5, 2.0] — スキーマ検証に最大の重み を置いたのがポイントです。

GRPO学習設定

from trl import GRPOConfig

training_args = GRPOConfig(
    output_dir="./outputs/lfm25-350m-nemotron-schema-grpo",
    learning_rate=5e-5,
    max_steps=100,
    warmup_steps=10,
    num_generations=8,           # プロンプトグループごとにサンプリングするcompletion数
    per_device_train_batch_size=4,
    gradient_accumulation_steps=8,  # オプティマイザステップあたり4プロンプトグループ
    steps_per_generation=2,
    max_completion_length=1024,  # ネストJSONを収めるための余裕
    mask_truncated_completions=False,
    temperature=1.1,             # グループの多様性を保つため高温サンプリング
    beta=0.01,                   # 参照モデルに対するKLペナルティ
    reward_weights=[1.0, 0.5, 2.0],  # json_format, field_count, schema_validation
    logging_steps=1,
    save_steps=100,
)

学習ログを見ると、三つの報酬コンポーネントがすべて上昇し、warmup後に参照モデルとのKLが0から離れ、truncated completionの割合はほぼ0に保たれます。

LoRAのマージと保存

MERGED_DIR = f"{training_args.output_dir}-merged"
merged_model = trainer.model.merge_and_unload()
merged_model.save_pretrained(MERGED_DIR)
tokenizer.save_pretrained(MERGED_DIR)

結果: どこがどれだけ伸びたか

マージ済みモデルをBF16 GGUFへ変換し、同一スタックで再評価します。

python llama.cpp/convert_hf_to_gguf.py \
  PATH_TO_YOUR_MERGED_MODEL \
  --outfile ./models/lfm25-350m-grpo-bf16.gguf \
  --outtype bf16
IFStructグループベースGRPOチューニングΔ
Overall22.6%29.7%+7.1
JSON18.0%31.9%+13.9
YAML27.2%27.5%+0.3
Wrapper key28.5%29.7%+1.2
Bare list16.6%29.7%+13.1

性能向上はまさに学習目標に沿って現れました。JSON pass rateは14ポイント近く上昇し、YAMLはほぼ変化なし。bare listは13ポイント上昇しており、augmentation戦略が効いたことが分かります。

Qwen3.5-2Bが33.15%であることを踏まえると、モデルサイズ6倍の差を100ステップの学習でほぼ追いついた計算になります。

Comparison chart of base vs GRPO-tuned 350M model pass rates across JSON and YAML formats Programming Illustration

実務適用のアドバイス

1. 報酬関数の重み配分が勝負どころです。 schema_validation に2.0を置いたことがJSON pass rate上昇の鍵でした。皆さんのドメインで最も重要な制約(フィールド数、型、enum値)に重みを集中させてください。

2. augmentationは評価分布を見て設計してください。 Nemotronデータにfenced code block指示文を追加したのは、IFStructがそのフォーマットを要求するためです。学習データと評価データの分布ギャップを先に把握することが優先です。

3. ローカルサービングスタックを統一してください。 ベースとチューニング済みモデルを同一の llama.cpp BF16 GGUFでサービングしなければ公正な比較になりません。公式ブログの数値との差はサービングスタックの違いに過ぎません。

注意事項

  • YAMLはこのパイプラインでは伸びませんでした。 報酬関数がJSONスキーマ中心のため、これは避けられない結果です。YAMLも伸ばすには別途報酬設計が必要です。
  • required field missing エラーが依然として7331回 で圧倒的首位です。必須フィールド準拠をより強く押し出す報酬設計が次の改善ポイントです。
  • test__recipe のようなドメインは4.3% → 10.0%と依然低いままです。 学習データにレシピスキーマがほとんど含まれていないカバレッジ問題です。

次のステップ学習の方向性

  • 報酬関数の拡張: 必須フィールド準拠に別途報酬を追加する、あるいはスキーマからフィールド別重みを動的に抽出する方式
  • データカバレッジ: Nemotronデータにないドメイン(レシピ、カメラレビュー等)を合成データで補強
  • より長い学習: 100ステップはあくまで無料GPU基準です。500〜1000ステップに伸ばしてどこまで到達するか検証する価値があります

併せて読みたい記事


根拠資料: GRPO with TRL on IFStruct (Hugging Face Blog)

本コンテンツは、信頼性の高い情報源をもとにAIツールを活用して作成され、編集者によるレビューを経て公開されています。専門家によるアドバイスの代替となるものではありません。