왜 '구조화 출력'이 실무의 승부처일까?
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, 평가는 맥북에서
이 가이드는 두 파트로 나뉘어요.
- 파인튜닝: GPU 필요 (무료 Colab/Kaggle GPU 기준으로 사이징됨)
- 평가: 맥북에서 로컬로 돌림. 여기서는 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% 수준입니다.
3가지 보상 함수 ([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%p 가까이 올랐고, YAML은 거의 그대로예요. bare list는 13%p 상승해서 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회로 압도적 1위입니다. 필수 필드 준수를 더 강하게 밀어붙이는 보상 설계가 다음 개선 포인트예요.test__recipe같은 도메인은 4.3% → 10.0%로 여전히 낮습니다. 학습 데이터에 요리 레시피 스키마가 거의 없어서 생기는 커버리지 문제예요.
다음 단계 학습 방향
- 보상 함수 확장: 필수 필드 준수에 별도 보상을 추가하거나, 필드별 가중치를 스키마에서 동적으로 뽑아내는 방식
- 데이터 커버리지: Nemotron 데이터에 없는 도메인(레시피, 카메라 리뷰 등)을 합성 데이터로 보강
- 더 긴 학습: 100스텝은 어디까지나 무료 GPU 기준입니다. 500~1000스텝으로 늘리면 어디까지 가는지 확인해볼 만해요
함께 보면 좋은 글
- AI 코딩 에이전트의 새로운 공격 벡터, AGENTS.md 간접 주입 공격 완벽 분석 — 에이전트 파이프라인에 LLM을 붙일 때 반드시 알아야 할 보안 이슈
- 오픈소스의 지속 가능성, 메타의 10년 후원에서 배우는 교훈 — TRL, llama.cpp 같은 오픈소스 생태계가 어떻게 유지되는지에 대한 인사이트