23. Lab: MoE 풀 파이프라인 (SFT → DPO → RM → PPO)
난이도: 최고 | 예상 시간: RTX 5090 1장 기준 24-48시간 | 경로: SFT → DPO → RM → PPO (전체)
이 과제는 2025-2026 최신 LLM이 갖추는 핵심 능력을 단일 모델에 부여하는 풀 파이프라인 실전입니다. DeepSeek-V3/R1의 MoE + reasoning, GPT o-시리즈의 extended thinking, Claude의 structured tool use가 공통적으로 지향하는 모델 특성을 4단계 훈련으로 구현합니다.
1. 과제 목표
최종 모델 프로필: 한국어/영어 이중 언어 + MoE expert specialization + 구조적 추론 + 코드 생성
| 능력 | 목표 수준 | 관련 최신 모델 |
|---|---|---|
| 구조적 추론 (thinking) | <think> 태그로 사고 과정 출력 |
GPT o1/o3, DeepSeek-R1 |
| 코드 생성 | 함수 구현 + 설명 | Claude, GPT-4o |
| 수학 풀이 | 단계별 수식 유도 | DeepSeek-V3, Qwen-2.5-Math |
| 한국어 대화 | 자연스러운 한국어 응답 | 기존 KoAlpaca 데이터 |
| MoE specialization | expert별 도메인 특화 | DeepSeek-V3, Mixtral |
| 선호 정렬 | 정확하고 안전한 응답 선호 | RLHF 전체 경로 (DPO → RM → PPO) |
2. 전체 파이프라인
Phase 1: 데이터 수집 + 변환 (1-2시간)
│
Phase 2: SFT — 멀티도메인 기초 (6-8시간)
│ ├── 한국어 대화 (KoAlpaca)
│ ├── 수학 (GSM8K + MATH)
│ ├── 코딩 (CodeAlpaca + CodeFeedback)
│ └── Thinking 패턴 (규칙 변환 + LLM 생성)
│
Phase 3: DPO — 멀티도메인 선호 정렬 (4-6시간)
│ ├── 수학 정답/오답 선호
│ ├── Thinking/Non-thinking 선호
│ └── 한국어 응답 품질 선호
│
Phase 4: RM — Reward Model 학습 (3-4시간)
│
Phase 5: PPO — RLHF 정책 최적화 (4-6시간)
│
Phase 6: Bench — 원본 vs 최종 모델 비교
3. 데이터 수집 가이드
공통 데이터 수집 가이드: 19_data_collection.md에서 모든 데이터셋(SFT/DPO, 한국어/영어, 수학/코딩)의 수집 스크립트와 크기 조절 방법을 참조하세요. 아래는 이 과제에 특화된 혼합 가이드입니다.
3.1 SFT 데이터 — 4개 도메인 혼합
| 도메인 | 데이터셋 | 규모 | 출처 |
|---|---|---|---|
| 한국어 대화 | KoAlpaca | 10K | data/sft_10k_raw.jsonl (보유) |
| 수학 | GSM8K | 8.5K | HuggingFace gsm8k |
| 수학 (고급) | MATH | 7.5K | HuggingFace hendrycks/competition_math |
| 코딩 | CodeAlpaca | 10K | HuggingFace sahil2801/CodeAlpaca-20k |
| Thinking 패턴 | 위 데이터 변환 | 5-10K | 규칙/LLM 생성 |
총 SFT 데이터: ~40K rows (혼합 후 셔플)
# ── 3.1.1 한국어: 이미 보유
cp data/sft_10k_raw.jsonl data/pipeline/ko_sft.jsonl
# ── 3.1.2 수학 (GSM8K)
python -c "
from datasets import load_dataset
import json
ds = load_dataset('gsm8k', 'main', split='train')
with open('data/pipeline/math_gsm8k_sft.jsonl', 'w') as f:
for row in ds:
prompt = f'Solve the following math problem step by step.\n\nProblem: {row[\"question\"]}'
response = row['answer']
f.write(json.dumps({'prompt': prompt, 'response': response}, ensure_ascii=False) + '\n')
print(f'GSM8K: {len(ds)} rows')
"
# ── 3.1.3 수학 고급 (MATH)
python -c "
from datasets import load_dataset
import json
ds = load_dataset('hendrycks/competition_math', split='train')
with open('data/pipeline/math_competition_sft.jsonl', 'w') as f:
count = 0
for row in ds:
prompt = f'Solve this math problem. Show your work.\n\n{row[\"problem\"]}'
response = row['solution']
if prompt and response:
f.write(json.dumps({'prompt': prompt, 'response': response}, ensure_ascii=False) + '\n')
count += 1
print(f'MATH: {count} rows')
"
# ── 3.1.4 코딩
python -c "
from datasets import load_dataset
import json
ds = load_dataset('sahil2801/CodeAlpaca-20k', split='train')
with open('data/pipeline/code_sft.jsonl', 'w') as f:
count = 0
for row in ds:
prompt = row.get('instruction', '')
inp = row.get('input', '')
if inp:
prompt = f'{prompt}\n\nInput: {inp}'
response = row.get('output', '')
if prompt and response:
f.write(json.dumps({'prompt': prompt, 'response': response}, ensure_ascii=False) + '\n')
count += 1
if count >= 10000:
break
print(f'Code: {count} rows')
"
# ── 3.1.5 Thinking 패턴 변환
python -c "
import json
# GSM8K의 step-by-step을 <think> 형식으로
with open('data/pipeline/math_gsm8k_sft.jsonl') as f_in, \
open('data/pipeline/thinking_sft.jsonl', 'w') as f_out:
count = 0
for line in f_in:
row = json.loads(line)
answer = row['response']
parts = answer.rsplit('####', 1)
if len(parts) == 2:
reasoning = parts[0].strip()
final = parts[1].strip()
response = f'<think>\nLet me work through this step by step.\n{reasoning}\n</think>\nThe answer is {final}.'
else:
response = f'<think>\n{answer}\n</think>\n{answer}'
f_out.write(json.dumps({
'prompt': row['prompt'].replace('step by step', 'step by step. Show your reasoning in <think> tags'),
'response': response,
}, ensure_ascii=False) + '\n')
count += 1
print(f'Thinking: {count} rows')
"
# ── 3.1.6 병합 + 셔플
cat data/pipeline/ko_sft.jsonl \
data/pipeline/math_gsm8k_sft.jsonl \
data/pipeline/math_competition_sft.jsonl \
data/pipeline/code_sft.jsonl \
data/pipeline/thinking_sft.jsonl \
| shuf --random-source=<(yes 42) > data/pipeline/all_sft_raw.jsonl
wc -l data/pipeline/all_sft_raw.jsonl
# 예상: ~40K rows
3.2 DPO 데이터 — 3가지 선호 축
mkdir -p data/pipeline
# ── 수학 정답/오답 선호
python -c "
from datasets import load_dataset
import json, random
random.seed(42)
ds = load_dataset('gsm8k', 'main', split='train')
with open('data/pipeline/math_dpo.jsonl', 'w') as f:
for row in ds:
prompt = f'Solve: {row[\"question\"]}'
chosen = row['answer']
parts = chosen.rsplit('####', 1)
if len(parts) == 2:
wrong = random.randint(1, 9999)
rejected = parts[0] + f'#### {wrong}'
else:
rejected = 'I am not sure about this problem.'
f.write(json.dumps({
'prompt': prompt, 'chosen': chosen, 'rejected': rejected
}, ensure_ascii=False) + '\n')
print(f'Math DPO: {len(ds)} rows')
"
# ── 한국어 선호 (이미 보유)
cp data/dpo_10k_raw.jsonl data/pipeline/ko_dpo.jsonl
# ── Thinking 선호 (thinking 있음 > 없음)
python -c "
import json
with open('data/pipeline/thinking_sft.jsonl') as f_think, \
open('data/pipeline/math_gsm8k_sft.jsonl') as f_plain, \
open('data/pipeline/thinking_dpo.jsonl', 'w') as f_out:
think_data = {json.loads(l)['prompt']: json.loads(l)['response'] for l in f_think}
count = 0
for line in f_plain:
row = json.loads(line)
# thinking 버전이 있는지 찾기
for tp, tr in think_data.items():
if row['prompt'][:50] in tp:
f_out.write(json.dumps({
'prompt': tp,
'chosen': tr,
'rejected': row['response'],
}, ensure_ascii=False) + '\n')
count += 1
break
if count >= 5000:
break
print(f'Thinking DPO: {count} rows')
"
# ── 병합
cat data/pipeline/math_dpo.jsonl \
data/pipeline/ko_dpo.jsonl \
data/pipeline/thinking_dpo.jsonl \
| shuf --random-source=<(yes 42) > data/pipeline/all_dpo_raw.jsonl
wc -l data/pipeline/all_dpo_raw.jsonl
3.3 RM/PPO 데이터
RM은 DPO 데이터를 재사용하고, PPO는 프롬프트만 필요합니다.
# RM: DPO 데이터 그대로 사용
cp data/pipeline/all_dpo_raw.jsonl data/pipeline/rm_raw.jsonl
# PPO: 프롬프트만 추출
python -c "
import json
seen = set()
with open('data/pipeline/all_sft_raw.jsonl') as f_in, \
open('data/pipeline/ppo_prompts_raw.jsonl', 'w') as f_out:
count = 0
for line in f_in:
row = json.loads(line)
p = row['prompt'][:200]
if p not in seen:
seen.add(p)
f_out.write(json.dumps({'prompt': row['prompt']}, ensure_ascii=False) + '\n')
count += 1
if count >= 5000:
break
print(f'PPO prompts: {count}')
"
4. Phase 2: MoE SFT — 멀티도메인 기초
전략: moe_expert_lora (4 experts) — expert별 도메인 specialization 기대
eulerforge train \
--preset configs/presets/qwen3.5_0.8b_moe_expert_lora_sft.yml \
--set data.format=raw \
--set data.task=sft \
--set data.path=data/pipeline/all_sft_raw.jsonl \
--set data.max_length=1024 \
--set training.max_train_steps=15000 \
--set training.batch_size=2 \
--set training.grad_accum_steps=8 \
--set training.log_steps=100 \
--set training.save_steps=3000 \
--output-dir outputs/pipeline/01_sft
예상 시간: ~6-8시간 (RTX 5090, ~40K data, 15K micro-steps)
3-Phase 스케줄: - Phase 0 (step 0-2000): router 워밍업 — expert routing 패턴 학습 - Phase 1 (step 2000-): LoRA 훈련 — 도메인별 expert specialization
확인 포인트:
- metrics.jsonl에서 loss가 step 5000까지 가파르게 감소
- step 10000 이후 2.0 이하로 수렴
- MoE routing stats (advanced metrics)로 expert 활용 균형 확인
5. Phase 3: 선호 정렬 — DPO 또는 ORPO
옵션 A: DPO (reference 모델 사용)
eulerforge train \
--preset configs/presets/qwen3.5_0.8b_moe_expert_lora_dpo.yml \
--set model_name=outputs/pipeline/01_sft/final \
--set data.format=raw \
--set data.task=prompted_preference \
--set data.path=data/pipeline/all_dpo_raw.jsonl \
--set data.max_length=1024 \
--set training.max_train_steps=6000 \
--set training.batch_size=2 \
--set training.grad_accum_steps=8 \
--output-dir outputs/pipeline/02_dpo
옵션 B: ORPO (DPO 대체 — 단일 GPU 권장)
DPO 대비 장점: 메모리 50% 절약 (1× forward), Handoff 호환, SFT+선호 결합 DPO 대비 단점: reference 비교 없으므로 선호 경계가 약간 덜 명확할 수 있음
eulerforge train \
--preset configs/presets/qwen3.5_0.8b_dense_lora_orpo.yml \
--set model_name=outputs/pipeline/01_sft/final \
--set injection.strategy=moe_expert_lora \
--set injection.num_experts=4 --set injection.top_k=2 \
--set data.format=raw \
--set data.task=preference \
--set data.path=data/pipeline/all_dpo_raw.jsonl \
--set data.max_length=1024 \
--set training.max_train_steps=6000 \
--output-dir outputs/pipeline/02_orpo
예상 시간: ~4-6시간 (DPO), ~3-4시간 (ORPO)
DPO 확인 포인트:
- Phase 0 (router): reward_margin=0, loss=0.6931 (정상 — LoRA freeze 시 policy=reference)
- Phase 1 (LoRA): reward_margin 점차 양수, accuracy > 0.5
ORPO 확인 포인트:
- Phase 0: sft_loss 감소 시작 (ORPO는 SFT loss도 포함하므로 router phase에서도 유효)
- Phase 1: orpo_loss 감소 + log_odds_ratio 양수 증가
6. Phase 4: RM — Reward Model 학습
eulerforge train \
--preset configs/presets/qwen3.5_0.8b_dense_lora_rm.yml \
--set model_name=outputs/pipeline/01_sft/final \
--set data.format=raw \
--set data.task=preference \
--set data.path=data/pipeline/rm_raw.jsonl \
--set data.max_length=1024 \
--set training.max_train_steps=6000 \
--output-dir outputs/pipeline/03_rm
예상 시간: ~3-4시간
참고: RM은
dense_lora전략 권장 — MoE의 routing 불안정이 reward 예측에 부정적 영향을 줄 수 있음.
확인 포인트:
- RM accuracy > 0.65 (chosen이 rejected보다 높은 점수를 받는 비율)
- reward_head.pt가 final/에 저장되는지
7. Phase 5: PPO — RLHF 정책 최적화
eulerforge train \
--preset configs/presets/qwen3.5_0.8b_dense_lora_ppo.yml \
--set model_name=outputs/pipeline/02_dpo/final \
--set training.reward_model.checkpoint_path=outputs/pipeline/03_rm/final \
--set data.format=raw \
--set data.task=sft \
--set data.path=data/pipeline/ppo_prompts_raw.jsonl \
--set data.max_length=512 \
--set training.max_train_steps=3000 \
--output-dir outputs/pipeline/04_ppo
예상 시간: ~4-6시간
확인 포인트:
- reward_mean이 점차 증가
- KL divergence가 너무 크지 않은지 (과도한 reward hacking 방지)
8. Phase 6: 벤치마크 — 4단계 비교
8.1 수학 능력 비교
# configs/bench/pipeline_math.yml
bench:
task: sft
data_path: data/pipeline/math_gsm8k_sft.jsonl
sample:
k: 20
seed: 42
generation:
max_new_tokens: 512
temperature: 0.1
models:
target:
model_dir: "outputs/pipeline/04_ppo/final"
device: "cuda:0"
baseline:
enabled: true
provider: ollama
model: "qwen3.5:0.8b"
judge:
enabled: true
provider: ollama
model: "gpt-oss:20b"
mode: pointwise
output:
out_dir: outputs/bench_pipeline_math
save_jsonl: true
print_examples: true
eulerforge bench --preset configs/bench/pipeline_math.yml
8.2 한국어 대화 비교
eulerforge bench --preset configs/bench/pipeline_math.yml \
--set bench.data_path=data/sft_1k_bench_raw.jsonl \
--set bench.generation.temperature=0.7 \
--set bench.generation.max_new_tokens=400 \
--set bench.output.out_dir=outputs/bench_pipeline_ko
8.3 단계별 비교 (SFT → DPO → PPO 진화 추적)
# SFT 모델
eulerforge bench --preset configs/bench/pipeline_math.yml \
--set bench.models.target.model_dir=outputs/pipeline/01_sft/final \
--set bench.output.out_dir=outputs/bench_pipeline_sft
# DPO 모델
eulerforge bench --preset configs/bench/pipeline_math.yml \
--set bench.models.target.model_dir=outputs/pipeline/02_dpo/final \
--set bench.output.out_dir=outputs/bench_pipeline_dpo
# PPO 모델 (최종)
eulerforge bench --preset configs/bench/pipeline_math.yml \
--set bench.models.target.model_dir=outputs/pipeline/04_ppo/final \
--set bench.output.out_dir=outputs/bench_pipeline_ppo
9. 기대 결과 분석
9.1 단계별 능력 진화
| 단계 | 수학 정확도 | Thinking 비율 | 한국어 품질 | 선호 정렬 |
|---|---|---|---|---|
| 원본 (Qwen3.5-0.8B) | 낮음 | 0% | 기본 | 없음 |
| SFT | 중 | ~30% | 좋음 | 없음 |
| DPO | 중-상 | ~60% | 좋음 | 초기 |
| PPO | 상 | ~70%+ | 좋음 | 강화 |
9.2 MoE Expert Specialization 확인
--metrics-level advanced로 훈련하면 expert routing 통계를 확인할 수 있습니다:
[MoE Routing] Layer 5: expert_0=32%, expert_1=28%, expert_2=22%, expert_3=18%
이상적으로는: - Expert 0: 수학/추론 (숫자가 많은 입력에 높은 비율) - Expert 1: 코딩 (코드 토큰에 높은 비율) - Expert 2: 한국어 (한국어 토큰에 높은 비율) - Expert 3: 일반/영어
실제로 이 수준의 specialization이 자연스럽게 발생하는지는 데이터 혼합 비율과 훈련 기간에 따라 달라집니다. 이것을 관찰하고 분석하는 것 자체가 이 과제의 핵심 학습 포인트입니다.
10. 고급 변형 실험
10.1 LoRA Handoff
SFT 단계에서 LoRA Handoff를 적용하면, 후반부에 LoRA가 base FFN으로 지식 이전됩니다:
training:
lora_handoff:
expert_lora:
start_step: 8000
duration_steps: 5000
end_scale: 0.0
curve: cosine
end_action: freeze
base_ffn_ramp:
start_step: 8000
end_step: 13000
start_multiplier: 1.0
end_multiplier: 3.0
10.2 전략 비교: dense_lora vs moe_expert_lora
동일 데이터에서 전략만 바꿔 비교:
- dense_lora: 단순 LoRA, 모든 파라미터가 하나의 전문가
- moe_expert_lora: 4 experts, 도메인별 specialization 가능
10.3 데이터 비율 실험
도메인 혼합 비율을 바꿔 최적 비율 탐색: - 수학 heavy: 수학 50% + 나머지 50% - 코딩 heavy: 코딩 50% + 나머지 50% - 균형: 각 25%
11. 체크포인트 체인 요약
data/pipeline/
├── all_sft_raw.jsonl # ~40K (4도메인 혼합)
├── all_dpo_raw.jsonl # ~23K (3가지 선호)
├── rm_raw.jsonl # = all_dpo_raw.jsonl
└── ppo_prompts_raw.jsonl # ~5K (프롬프트만)
outputs/pipeline/
├── 01_sft/final/ # MoE SFT 완료 모델
├── 02_dpo/final/ # MoE DPO 정렬 모델
├── 03_rm/final/ # Reward Model + reward_head.pt
│ └── reward_head.pt
├── 04_ppo/final/ # 최종 RLHF 모델 ★
└── bench_pipeline_*/ # 각 단계별 벤치마크 결과
12. 이 과제에서 배우는 것
| 학습 포인트 | 내용 |
|---|---|
| 데이터 엔지니어링 | 다중 도메인 데이터 수집/변환/혼합/셔플 |
| 훈련 순서의 의미 | SFT → DPO → RM → PPO 각 단계가 모델에 미치는 변화 |
| MoE 동작 이해 | expert routing, specialization, load balance |
| Phase scheduling | router warmup → LoRA → full unfreeze 순서의 이유 |
| 선호 정렬의 한계 | DPO만으로 충분한지, PPO가 추가하는 가치 |
| 벤치마크 해석 | pointwise 스코어의 의미와 한계 |
| 최신 트렌드 실습 | Thinking 패턴, MoE specialization, progressive alignment |
관련 문서
- 18_training_pipeline.md — 훈련 순서 개요
- 03_moe_expert_lora.md — MoE Expert LoRA 상세
- 05_dpo_training.md — DPO 상세
- 07_rm_training.md — Reward Model 상세
- 08_ppo_training.md — PPO 상세
- 14_lora_handoff.md — LoRA Handoff 상세
- 21_lab_thinking_model.md — Thinking 모델 과제