なぜ「構造化出力」が実務の要になるのか
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スコアの再現が目的ではなく、小型モデルでもタスク特化のファインチューニングでより大きなモデルに迫れることを示すのが目的です。

事前準備: 学習は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%とほぼ一致します。この値を同一サービングスタック比較のベースラインとします。

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.0field_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チューニング | Δ |
|---|---|---|---|
| Overall | 22.6% | 29.7% | +7.1 |
| JSON | 18.0% | 31.9% | +13.9 |
| YAML | 27.2% | 27.5% | +0.3 |
| Wrapper key | 28.5% | 29.7% | +1.2 |
| Bare list | 16.6% | 29.7% | +13.1 |
性能向上はまさに学習目標に沿って現れました。JSON pass rateは14ポイント近く上昇し、YAMLはほぼ変化なし。bare listは13ポイント上昇しており、augmentation戦略が効いたことが分かります。
Qwen3.5-2Bが33.15%であることを踏まえると、モデルサイズ6倍の差を100ステップの学習でほぼ追いついた計算になります。

実務適用のアドバイス
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ステップに伸ばしてどこまで到達するか検証する価値があります
併せて読みたい記事
- AIコーディングエージェントの新たな攻撃ベクトル、AGENTS.md間接インジェクション攻撃の完全分析 — エージェントパイプラインにLLMを組み込む際に必ず知っておくべきセキュリティ課題
- オープンソースの持続可能性、Metaの10年間のスポンサーシップから学ぶ教訓 — TRLやllama.cppのようなオープンソースエコシステムがどう維持されているかについてのインサイト