AI DAILY / 2026-09-07
用 100 步 GRPO 微调 3.5 亿参数模型以提升结构化输出
Fine-tuning a 350M Model for Better Structured Outputs in 100 GRPO Steps
全文中文翻译 · AI 生成,仅供学习交流
100 步 GRPO 微调 350M 模型,让结构化输出更靠谱
结构化输出是 LLM(Large Language Model)在现实中最常见的任务之一。但大多数基准测试都把它塞进更宽泛的推理或抽取分数里,不会单独拿出来衡量。
一个模型能不能稳定地以所要求的格式和结构返回有效、可解析的输出,也就是 schema 合规(schema compliance),往往决定了它到底能不能被接进下游系统。
先说一句,本文跑的训练流程,和 IFStruct 博客里训练 RL(Reinforcement Learning)模型的那一套不是一回事。这份 notebook 目的不是复现 IFStruct 基准分数,而是想说明给小模型做任务专属微调(task-specific fine-tuning)能有多大提升,甚至能追平比它大得多的模型。
前置条件
这份指南分两块,跑在两个地方。
微调(Fine-tuning)跑在 GPU 上,配套 notebook 是按免费版 Colab 或 Kaggle GPU 的配置设计的。
评估(Evaluation)可以在 MacBook 上本地跑。我这里用的是一台带 Apple M5 Max、36 GB 统一内存的 MacBook Pro。模型通过 llama.cpp 起一个兼容 OpenAI 的 server,IFStruct 评估器就跟它通信。
Python 工具链用 uv 来管,模型服务用 llama.cpp。按 Liquid AI 的 llama.cpp 部署文档,用 Homebrew 装上 llama.cpp,再验证一下 llama-server 能用。
brew install llama.cpp
llama-server --version对 LFM2.5-350M(基础模型)进行 IFStruct 评估
开始之前,先在 IFStruct 上跑一下 LFM2.5-350M,看看能不能复现报告里的 21.1%。
IFStruct 这个基准专门测 LLM 输出的有效性,以及对 schema 的遵循程度。代码在 Liquid4All/ifstruct 开源,公开的基准数据集放在 Hugging Face 的 LiquidAI/ifstruct-v1.0 上。
git clone https://github.com/Liquid4All/ifstruct.git为了对比评估,我们在 MacBook 上用 llama.cpp 本地起服务,模型用 BF16(BFloat16)的 GGUF(LiquidAI/LFM2.5-350M-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,让 llama.cpp 在可用时把全部层卸载到 GPU
- -np 4,并行处理四个请求
- -c 32768,prompt 上下文大小
服务起来之后,就可以用 2000 条样本跑完整基准了。
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============================================================
Model: LiquidAI/LFM2.5-350M
============================================================
Overall: 452/2000 passed (22.6%)
Average latency: 1453ms
By format:
JSON: 180/1000 passed (18.0%)
YAML: 272/1000 passed (27.2%)
By top-level structure:
Wrapper key 288/1011 passed (28.5%)
Bare list 164/989 passed (16.6%)
By entity type:
test__camera_review 6/83 passed (7.2%)
test__clinical_trial 20/104 passed (19.2%)
test__conference_schedule 7/87 passed (8.0%)
test__escaping__bug_report_batch 24/89 passed (27.0%)
test__escaping__config_snippet_audit 15/85 passed (17.6%)
test__escaping__customer_email_thread 5/73 passed (6.8%)
test__escaping__dialogue_sample 14/95 passed (14.7%)
test__escaping__interview_transcript_segment 21/80 passed (26.2%)
test__escaping__log_parser_examples 21/72 passed (29.2%)
test__escaping__pr_discussion 22/87 passed (25.3%)
test__escaping__repro_steps_batch 16/73 passed (21.9%)
test__escaping__screenplay_scene 16/92 passed (17.4%)
test__escaping__short_story_chapter 15/84 passed (17.9%)
test__escaping__support_ticket_batch 27/73 passed (37.0%)
test__escaping__terminal_session_notes 20/70 passed (28.6%)
test__event_ticket_booking 49/107 passed (45.8%)
test__gpu_review 6/94 passed (6.4%)
test__invoice 28/86 passed (32.6%)
test__job_posting 25/85 passed (29.4%)
test__real_estate_listing 31/82 passed (37.8%)
test__recipe 3/70 passed (4.3%)
test__rental_car_booking 27/79 passed (34.2%)
test__scientific_experiment 13/69 passed (18.8%)
test__travel_itinerary 21/81 passed (25.9%)
Common errors:
7228x required field missing
738x wrong item count
540x type mismatch
317x Unclosed code block
190x extraneous field 'notes'
181x extraneous field 'path'
175x extraneous field 'constraints'
170x extraneous field 'type'
170x missing code block
100x expected bare list, got wrapperIFStruct 发布博客里报的是 21.1%。我们本地的 llama.cpp/BF16 环境测出来 22.6%,跟博客里的 21.1% 差不多。我们拿这个本地结果作为同一套服务栈对比的基线。
基于 TRL 的结构化输出 GRPO 微调
完整能跑的流程都在配套 notebook 里。这一节只挑关键的部分讲。
训练数据
数据集用 nvidia/Nemotron-RL-instruction_following-structured_outputs,每条 prompt 都配了一个目标 JSON Schema 和期望字段数。训练用了大约 500 条样本。
Nemotron 的数据分布和 IFStruct 评估对不上,我们对 prompt 做了一些增广,堵两个口子。
40% 的样本在末尾追加「把输出放到围栏代码块(fenced code block)里」的指令,让模型学会听格式指令,而不是不管什么场景都吐裸 JSON。
另外不重叠的 20% 改成顶层数组任务,把 schema 包在一个带必需条目数量的 array 里,专门训练裸列表(bare list)输出和条目数合规。
模型与 LoRA
加载 LiquidAI/LFM2.5-350M,挂一个 LoRA(Low-Rank Adaptation)适配器上去。LFM2.5 用的是注意力加卷积的混合架构,所以 target_modules 要挑 LFM 专属的模块名。
lora_config = LoraConfig(
r=,
lora_alpha=,
bias="none",
task_type="CAUSAL_LM",
target_modules=[
"q_proj",
"k_proj",
"v_proj",
"out_proj",
"in_proj",
"w1",
"w2",
"w3",
],
)大概会训练 600 万参数,占模型的 1.66%。
奖励函数
接着定义三个奖励函数,都在 [0, 1] 区间上,每条完成都按它提取出的结构对不对来打分。
- json_format_reward,输出能不能解析出来,并且是不是要求的形式。要求的形式(围栏或裸)满分 1.0,形式不对但能解析 0.2,解析不了 0.0。
- field_count_reward,对象的顶层字段数量对不对。完全匹配 1.0,差多少线性衰减。
- schema_validation_reward,输出能不能过那一行的 JSON Schema。它会数每一条约束违反,再看必需键的覆盖率够不够,够就给部分分。
三者按 reward_weights=[1.0, 0.5, 2.0] 加权求和。
训练
训练 100 步,每个 prompt 组采 8 个生成。配置是按免费版 16 GB GPU 算的。
from trl import GRPOConfig
training_args = GRPOConfig(
output_dir="./outputs/lfm25-350m-nemotron-schema-grpo",
learning_rate=5e-5,
max_steps=,
warmup_steps=,
num_generations=,
# completions sampled per prompt group
per_device_train_batch_size=,
gradient_accumulation_steps=,
# 4 prompt groups per optimizer step
steps_per_generation=,
max_completion_length=,
# room for nested JSON
mask_truncated_completions=False,
temperature=1.1,
# hotter sampling keeps groups varied
beta=0.01,
# KL penalty toward the reference model
reward_weights=[1.0, 0.5, 2.0],
# json_format, field_count, schema_validation
logging_steps=,
save_steps=,
)跑起来之后看 notebook,三个奖励分量都在往上爬。KL 距离过了 warmup 之后从零开始抬升。截断完成的占比基本一直贴着零。
合并并保存模型
最后把 LoRA 适配器合并回基础权重,存成一个自包含的 checkpoint,随时可以转 GGUF 上服务。
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)对 GRPO 微调后的 LFM2.5-350M 进行 IFStruct 评估
GRPO 微调完之后,再跑一遍 IFStruct 评估。这一步要把合并好的 checkpoint 转成 BF16 GGUF。转换脚本 llama.cpp 源码里就带了,所以仓库 clone 一次,把 gguf 包装上就行。
git clone --depth 1 https://github.com/ggml-org/llama.cpp
pip install ./llama.cpp/gguf-py
mkdir -p models
python llama.cpp/convert_hf_to_gguf.py \
PATH_TO_YOUR_MERGED_MODEL \
--outfile ./models/lfm25-350m-grpo-bf16.gguf \
--outtype bf16然后用下面这条命令把合并好的模型起服务。
llama-server \
-m ./models/lfm25-350m-grpo-bf16.gguf \
--alias lfm25-350m-grpo-structured-output \
-c 32768 \
-np 4 \
-ngl 99 \
--host 127.0.0.1 \
--port 8081接着用微调后的模型再完整跑一次 IFStruct。
uv run ifstruct-eval \
--model lfm25-350m-grpo-structured-output \
--base-url http://localhost:8081/v1 \
--api-key dummy \
--dataset data/test.jsonl \
--results-file results/lfm25-350m-grpo.json \
--n-threads 4 \
--max-tokens 2048 \
-v============================================================
Model: lfm25-350m-grpo-structured-output
============================================================
Overall: 594/2000 passed (29.7%)
Average latency: 1518ms
By format:
JSON: 319/1000 passed (31.9%)
YAML: 275/1000 passed (27.5%)
By top-level structure:
Wrapper key 300/1011 passed (29.7%)
Bare list 294/989 passed (29.7%)
By entity type:
test__camera_review 5/83 passed (6.0%)
test__clinical_trial 31/104 passed (29.8%)
test__conference_schedule 11/87 passed (12.6%)
test__escaping__bug_report_batch 32/89 passed (36.0%)
test__escaping__config_snippet_audit 24/85 passed (28.2%)
test__escaping__customer_email_thread 9/73 passed (12.3%)
test__escaping__dialogue_sample 17/95 passed (17.9%)
test__escaping__interview_transcript_segment 13/80 passed (16.2%)
test__escaping__log_parser_examples 33/72 passed (45.8%)
test__escaping__pr_discussion 26/87 passed (29.9%)
test__escaping__repro_steps_batch 23/73 passed (31.5%)
test__escaping__screenplay_scene 34/92 passed (37.0%)
test__escaping__short_story_chapter 24/84 passed (28.6%)
test__escaping__support_ticket_batch 36/73 passed (49.3%)
test__escaping__terminal_session_notes 23/70 passed (32.9%)
test__event_ticket_booking 62/107 passed (57.9%)
test__gpu_review 7/94 passed (7.4%)
test__invoice 36/86 passed (41.9%)
test__job_posting 33/85 passed (38.8%)
test__real_estate_listing 32/82 passed (39.0%)
test__recipe 7/70 passed (10.0%)
test__rental_car_booking 37/79 passed (46.8%)
test__scientific_experiment 14/69 passed (20.3%)
test__travel_itinerary 25/81 passed (30.9%)
Common errors:
7331x required field missing
890x wrong item count
555x type mismatch
102x expected bare list, got wrapper
62x extraneous field 'metadata.tone'
55x 6 is greater than maximum 5
49x extraneous field 'speaker_labels'
47x extraneous field 'tone'
44x 'cups' not in allowed values ['mg', 'g', 'kg', 'oz', 'lb', 'ml', 'l', 'cl', 'dl'
44x extraneous field 'notes'在相同的服务栈上对两次运行进行对比。
| 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 通过率涨了近 14 个百分点(18.0% → 31.9%),YAML 几乎没动。虽然还比不上 Qwen3.5-2B 的 33.15%,但哪怕只是轻量的任务专属微调,也能让小模型逼近大模型。
结论
仅用约 500 条样本、100 步的简短 GRPO 训练,就能把一个 3.5 亿参数的小模型在 IFStruct 上的分数从 22.6% 拉到 29.7%。要点是,廉价、任务专属的奖励信号能让小模型在……