이미지 로딩 중...
AI Generated
2025. 11. 12. · 3 Views
바닥부터 만드는 ChatGPT Supervised Fine-tuning 파이프라인 완벽 가이드
ChatGPT와 같은 대규모 언어 모델을 실무에 맞게 커스터마이징하는 Supervised Fine-tuning(SFT) 파이프라인을 처음부터 구축하는 방법을 배웁니다. 데이터 준비부터 모델 학습, 평가까지 전체 과정을 실전 코드와 함께 단계별로 알아봅니다.
목차
- SFT 데이터셋 준비와 전처리 - 학습 데이터의 품질이 모델 성능을 결정합니다
- 토크나이저 설정과 프롬프트 템플릿 구성 - 모델이 이해하는 언어로 번역하기
- LoRA 어댑터를 활용한 효율적인 Fine-tuning - 적은 리소스로 큰 모델 학습하기
- Trainer API를 활용한 학습 파이프라인 구축 - 안정적인 학습 관리하기
- 학습 모니터링과 평가 지표 설정 - 모델이 제대로 학습되는지 확인하기
- 그래디언트 체크포인팅과 메모리 최적화 - 큰 모델을 작은 GPU에서 학습하기
- 학습된 모델 병합과 양자화 - 배포용 경량 모델 만들기
- 추론 파이프라인 구축과 생성 파라미터 튜닝 - 실전 배포를 위한 마지막 단계
- 데이터 증강과 백트랜슬레이션 - 적은 데이터로 더 나은 성능 내기
- Instruction-Following 능력 강화하기 - 지시사항을 정확히 따르는 모델 만들기
1. SFT 데이터셋 준비와 전처리 - 학습 데이터의 품질이 모델 성능을 결정합니다
시작하며
여러분이 ChatGPT 같은 모델을 회사 업무에 맞게 커스터마이징하려고 할 때, 가장 먼저 막히는 부분이 무엇일까요? 바로 "어떤 데이터를 어떻게 준비해야 하는가"입니다.
실제로 많은 팀들이 좋은 모델 아키텍처를 가지고 있어도 데이터 준비 단계에서 실패합니다. Fine-tuning의 성공은 80%가 데이터 품질에 달려 있습니다.
잘못된 형식의 데이터, 편향된 샘플, 불균형한 분포는 모델을 망칩니다. 심지어 학습은 되지만 실제 사용할 수 없는 모델이 나올 수 있습니다.
바로 이럴 때 필요한 것이 체계적인 SFT 데이터셋 준비 파이프라인입니다. 원시 데이터를 수집하고, 정제하고, 모델이 학습할 수 있는 형식으로 변환하는 전체 과정을 자동화하면 반복 가능하고 확장 가능한 시스템을 만들 수 있습니다.
개요
간단히 말해서, SFT 데이터셋은 "입력-출력" 쌍으로 구성된 학습 예제의 집합입니다. 사전 학습된 언어 모델에게 "이런 질문에는 이렇게 답해"라고 가르치는 교과서와 같습니다.
왜 이 과정이 필요한지 실무 관점에서 보면, 일반적인 GPT 모델은 모든 도메인을 다루지만 특정 업무에는 부족합니다. 예를 들어, 법률 상담 챗봇을 만든다면 법률 용어, 판례 인용, 정확한 법률 해석이 필요합니다.
의료 분야라면 의학 용어와 증상 분석이 필수입니다. 기존에는 원시 텍스트 데이터를 손으로 일일이 정제하고 JSON 형식으로 변환했다면, 이제는 자동화된 파이프라인으로 대량의 데이터를 일관되게 처리할 수 있습니다.
핵심 특징은 세 가지입니다. 첫째, 데이터 품질 검증 (중복 제거, 길이 필터링, 유해 콘텐츠 제거).
둘째, 표준화된 형식 변환 (instruction-input-output 구조). 셋째, 학습/검증/테스트 세트 분할.
이러한 특징들이 중요한 이유는 재현 가능한 실험과 공정한 모델 평가를 보장하기 때문입니다.
코드 예제
import json
import pandas as pd
from datasets import Dataset
from typing import List, Dict
def prepare_sft_dataset(raw_data_path: str, output_path: str):
"""원시 데이터를 SFT 형식으로 변환"""
# 원시 데이터 로드 (예: CSV, JSON 등)
df = pd.read_csv(raw_data_path)
sft_samples = []
for _, row in df.iterrows():
# Instruction-Input-Output 형식으로 구조화
sample = {
"instruction": row["question"], # 사용자 질문
"input": row.get("context", ""), # 추가 컨텍스트 (선택)
"output": row["answer"] # 모델이 생성해야 할 답변
}
# 품질 필터링: 너무 짧거나 긴 샘플 제거
if 10 < len(sample["output"]) < 2000:
sft_samples.append(sample)
# HuggingFace Dataset으로 변환
dataset = Dataset.from_list(sft_samples)
# 학습/검증 분할 (90/10)
train_test = dataset.train_test_split(test_size=0.1, seed=42)
# 저장
train_test.save_to_disk(output_path)
print(f"✅ 총 {len(sft_samples)}개 샘플 준비 완료")
return train_test
설명
이것이 하는 일: 원시 데이터(CSV, JSON 등)를 받아서 Supervised Fine-tuning에 필요한 표준 형식으로 변환하고, 품질을 검증한 후 학습/검증 세트로 분할합니다. 첫 번째로, pandas를 사용해 원시 데이터를 로드합니다.
실무에서는 다양한 소스(데이터베이스, API, 파일 등)에서 데이터가 올 수 있는데, 일단 DataFrame으로 통일하면 처리가 쉽습니다. 각 행은 하나의 학습 예제를 나타냅니다.
그 다음으로, 각 행을 "instruction-input-output" 구조로 변환합니다. instruction은 모델에게 무엇을 해야 하는지 알려주는 지시사항이고, input은 추가 컨텍스트(예: 문서 내용), output은 모델이 생성해야 할 정답입니다.
이때 품질 필터링이 중요합니다. 너무 짧은 답변(10자 미만)은 정보가 부족하고, 너무 긴 답변(2000자 초과)은 학습이 불안정해집니다.
마지막으로, HuggingFace의 Dataset 객체로 변환하고 90:10 비율로 학습/검증 세트를 분할합니다. seed=42를 설정해 재현 가능성을 보장합니다.
같은 데이터로 여러 번 실험할 때 항상 동일한 분할을 얻을 수 있어 공정한 비교가 가능합니다. 여러분이 이 코드를 사용하면 수천 개의 원시 데이터를 몇 분 안에 학습 준비된 데이터셋으로 변환할 수 있습니다.
데이터 형식이 일관되므로 나중에 모델을 바꾸거나 하이퍼파라미터를 조정할 때도 같은 데이터셋을 재사용할 수 있습니다. 또한 검증 세트가 분리되어 있어 과적합을 조기에 발견할 수 있습니다.
실전 팁
💡 데이터 중복 제거는 필수입니다. 같은 샘플이 여러 번 나오면 모델이 그것만 외우게 됩니다. df.drop_duplicates(subset=['question', 'answer'])를 추가하세요.
💡 유해 콘텐츠 필터링을 위해 OpenAI의 Moderation API나 욕설 데이터베이스를 활용하세요. 학습 데이터에 유해 콘텐츠가 있으면 모델이 그대로 학습합니다.
💡 데이터 분포를 확인하세요. 특정 주제가 90%를 차지하면 모델이 편향됩니다. df['category'].value_counts()로 분포를 체크하고 언더샘플링/오버샘플링으로 균형을 맞추세요.
💡 길이 분포도 중요합니다. 대부분의 샘플이 100자인데 일부가 5000자라면, 배치 처리 시 패딩으로 메모리가 낭비됩니다. 히스토그램을 그려 이상치를 제거하세요.
💡 검증 세트는 절대 학습에 사용하지 마세요. 데이터가 부족해도 참으세요. 검증 세트로 학습하면 과적합 여부를 알 수 없어 실전에서 성능이 떨어집니다.
2. 토크나이저 설정과 프롬프트 템플릿 구성 - 모델이 이해하는 언어로 번역하기
시작하며
여러분이 외국어로 대화할 때, 올바른 문법과 형식을 사용하지 않으면 의사소통이 안 되죠? 언어 모델도 마찬가지입니다.
모델이 이해할 수 있는 "토큰"으로 변환하고, 일관된 프롬프트 형식을 사용해야 제대로 학습됩니다. 실무에서 많은 팀들이 프롬프트 템플릿을 대충 만들어서 학습했다가 추론 시 완전히 다른 형식을 사용해 성능이 급격히 떨어지는 경험을 합니다.
"학습할 때는 '질문: ... 답변: ...' 형식이었는데, 실제 사용할 때는 그냥 질문만 던졌더니 엉뚱한 답을 한다"는 문제가 바로 이것입니다.
바로 이럴 때 필요한 것이 체계적인 토크나이저 설정과 프롬프트 템플릿입니다. 학습과 추론에서 동일한 형식을 사용하면 모델이 일관되게 작동합니다.
개요
간단히 말해서, 토크나이저는 텍스트를 숫자(토큰 ID)로 변환하는 도구이고, 프롬프트 템플릿은 입력 데이터를 모델이 기대하는 형식으로 포맷팅하는 틀입니다. 왜 이것이 필요한지 보면, Transformer 모델은 텍스트를 직접 처리할 수 없고 숫자만 이해합니다.
예를 들어, "안녕하세요"라는 문장은 [1234, 5678, 9012] 같은 토큰 ID 시퀀스로 변환되어야 합니다. 또한 모델은 학습 시 본 형식과 다른 형식의 입력을 제대로 처리하지 못합니다.
기존에는 각 개발자가 임의로 문자열을 연결해서 프롬프트를 만들었다면, 이제는 표준 템플릿을 정의하고 모든 데이터에 일관되게 적용합니다. 핵심 특징 세 가지: 첫째, 특수 토큰 활용 (BOS, EOS, PAD 등으로 문장 경계 표시).
둘째, 최대 길이 관리 (truncation과 padding으로 배치 처리 가능). 셋째, Chat 템플릿 지원 (user/assistant 역할 구분).
이러한 특징들이 중요한 이유는 효율적인 배치 학습과 정확한 생성을 가능하게 하기 때문입니다.
코드 예제
from transformers import AutoTokenizer
def setup_tokenizer_and_template(model_name: str):
"""토크나이저 설정 및 프롬프트 템플릿 구성"""
# 사전 학습된 토크나이저 로드
tokenizer = AutoTokenizer.from_pretrained(model_name)
# 패딩 토큰 설정 (없으면 EOS를 사용)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
tokenizer.pad_token_id = tokenizer.eos_token_id
# Chat 템플릿 정의 (Instruction-Following 형식)
chat_template = """### Instruction:
{instruction}
### Input:
{input}
### Response:
{output}"""
def format_prompt(sample: Dict) -> str:
"""샘플을 프롬프트 형식으로 변환"""
return chat_template.format(
instruction=sample["instruction"],
input=sample.get("input", ""), # input이 없을 수도 있음
output=sample["output"]
)
def tokenize_function(sample: Dict) -> Dict:
"""프롬프트를 토큰화"""
prompt = format_prompt(sample)
# 최대 길이 2048로 토큰화 (truncation 적용)
tokens = tokenizer(
prompt,
max_length=2048,
truncation=True,
padding=False # 배치 단위에서 패딩 적용
)
# labels는 input_ids와 동일 (Causal LM 학습용)
tokens["labels"] = tokens["input_ids"].copy()
return tokens
return tokenizer, tokenize_function
설명
이것이 하는 일: 사전 학습된 토크나이저를 로드하고, Chat 템플릿을 정의하며, 데이터를 모델이 학습할 수 있는 토큰 시퀀스로 변환합니다. 첫 번째로, AutoTokenizer.from_pretrained()로 사전 학습된 모델의 토크나이저를 가져옵니다.
토크나이저는 모델과 쌍을 이루므로 반드시 같은 것을 사용해야 합니다. LLaMA 모델을 쓴다면 LLaMA 토크나이저를, GPT를 쓴다면 GPT 토크나이저를 써야 합니다.
그리고 일부 모델은 패딩 토큰이 없어서 배치 처리 시 오류가 나는데, 이를 방지하기 위해 EOS 토큰을 패딩으로 대신 사용합니다. 그 다음으로, Chat 템플릿을 정의합니다.
"### Instruction:", "### Input:", "### Response:" 같은 명확한 구분자를 사용하면 모델이 각 부분의 역할을 학습합니다. 실제로 Alpaca, Vicuna 등 유명한 Instruction-tuned 모델들이 이런 형식을 사용합니다.
중요한 점은 학습할 때 이 형식을 사용했다면, 추론할 때도 정확히 같은 형식으로 프롬프트를 구성해야 한다는 것입니다. 마지막으로, tokenize_function이 실제 변환을 수행합니다.
먼저 템플릿에 데이터를 채워 프롬프트를 만들고, 토크나이저로 숫자 시퀀스로 변환합니다. max_length=2048로 너무 긴 텍스트는 자르고(truncation), labels는 input_ids와 동일하게 설정합니다.
이는 Causal Language Modeling 방식으로, 이전 토큰들을 보고 다음 토큰을 예측하는 학습 방법입니다. 여러분이 이 코드를 사용하면 모든 학습 샘플이 일관된 형식으로 변환되어 모델이 패턴을 빠르게 학습합니다.
실무에서 프롬프트 형식 불일치로 인한 성능 저하를 방지할 수 있고, 나중에 프로덕션에 배포할 때도 같은 format_prompt 함수를 재사용하면 됩니다.
실전 팁
💡 특수 토큰의 의미를 정확히 이해하세요. BOS(Beginning of Sequence)는 시작, EOS(End of Sequence)는 끝, PAD는 패딩입니다. 잘못 사용하면 모델이 언제 생성을 멈춰야 할지 모릅니다.
💡 max_length는 GPU 메모리와 trade-off 관계입니다. 길게 하면 더 많은 컨텍스트를 보지만 메모리를 많이 씁니다. 데이터 길이 분포를 보고 95 percentile 정도로 설정하세요.
💡 프롬프트 템플릿에 예제를 추가하는 few-shot learning도 고려하세요. "### Example 1:", "### Example 2:"를 추가하면 모델이 원하는 출력 스타일을 더 잘 이해합니다.
💡 labels에서 instruction 부분은 마스킹하는 것이 좋습니다. 즉, response 부분만 loss를 계산하도록 하면 모델이 답변 생성에만 집중합니다. instruction의 token_ids를 -100으로 설정하면 됩니다.
💡 토크나이저 속도가 느리다면 num_proc 파라미터로 멀티프로세싱을 활용하세요. dataset.map(tokenize_function, num_proc=8)처럼 사용하면 8배 빨라집니다.
3. LoRA 어댑터를 활용한 효율적인 Fine-tuning - 적은 리소스로 큰 모델 학습하기
시작하며
여러분이 70억 개 파라미터를 가진 대형 언어 모델을 Fine-tuning하려고 할 때, GPU 메모리 부족 오류를 만난 적 있나요? 전체 모델을 학습하려면 수백 GB의 GPU 메모리와 며칠의 학습 시간이 필요합니다.
스타트업이나 연구실에서는 불가능한 일입니다. 이 문제는 비용과 시간 측면에서 AI 활용의 가장 큰 장벽 중 하나입니다.
A100 GPU를 여러 대 빌리는 비용만 수백만 원이 들고, 실험을 반복할 수 없어 최적의 모델을 찾기 어렵습니다. 바로 이럴 때 필요한 것이 LoRA(Low-Rank Adaptation)입니다.
전체 모델은 얼리고 작은 어댑터 레이어만 학습하면 메모리는 1/10로 줄이면서도 성능은 Full Fine-tuning과 비슷하게 유지할 수 있습니다.
개요
간단히 말해서, LoRA는 원본 모델의 가중치는 고정하고, 저랭크(low-rank) 분해된 작은 행렬만 추가해서 학습하는 기법입니다. 수십억 개 파라미터 대신 수백만 개만 학습합니다.
왜 이것이 필요한지 보면, 실무에서 대부분의 경우 모델의 지식은 유지하고 특정 도메인이나 스타일만 조정하면 됩니다. 예를 들어, 법률 챗봇을 만들 때 GPT의 언어 능력은 그대로 쓰고 법률 용어 사용만 가르치면 됩니다.
전체를 다시 학습할 필요가 없습니다. 기존에는 모델 전체를 복사해서 학습했다면(Full Fine-tuning), 이제는 작은 어댑터만 추가하고 원본은 여러 태스크에서 공유합니다.
핵심 특징 세 가지: 첫째, 파라미터 효율성 (학습 파라미터 1% 미만). 둘째, 메모리 효율성 (그래디언트를 저장할 파라미터가 적어 메모리 절약).
셋째, 모듈성 (여러 LoRA 어댑터를 바꿔가며 사용 가능). 이러한 특징들이 중요한 이유는 제한된 리소스로 여러 실험을 빠르게 반복할 수 있게 해주기 때문입니다.
코드 예제
from peft import LoraConfig, get_peft_model, TaskType
from transformers import AutoModelForCausalLM
def setup_lora_model(base_model_name: str):
"""LoRA 어댑터를 적용한 모델 생성"""
# 베이스 모델 로드 (가중치는 freeze 상태)
base_model = AutoModelForCausalLM.from_pretrained(
base_model_name,
load_in_8bit=True, # 8-bit 양자화로 메모리 추가 절약
device_map="auto" # GPU에 자동 분산
)
# LoRA 설정
lora_config = LoraConfig(
r=16, # LoRA rank (낮을수록 파라미터 적음)
lora_alpha=32, # LoRA scaling factor
target_modules=["q_proj", "v_proj"], # Attention의 Q, V에만 적용
lora_dropout=0.05, # 과적합 방지
bias="none", # bias는 학습 안 함
task_type=TaskType.CAUSAL_LM
)
# LoRA 어댑터 추가
peft_model = get_peft_model(base_model, lora_config)
# 학습 가능한 파라미터 비율 출력
peft_model.print_trainable_parameters()
# 출력 예: "trainable params: 4.2M || all params: 7B || trainable%: 0.06%"
return peft_model
설명
이것이 하는 일: 대형 언어 모델을 로드하고, 전체 가중치는 freeze한 상태에서 저랭크 어댑터 레이어만 추가하여 효율적으로 학습 가능한 모델을 만듭니다. 첫 번째로, AutoModelForCausalLM.from_pretrained()로 베이스 모델을 로드합니다.
여기서 load_in_8bit=True를 사용하면 모델 가중치를 16-bit 대신 8-bit로 저장해 메모리를 절반으로 줄입니다. device_map="auto"는 모델을 자동으로 여러 GPU에 분산 배치합니다.
이미 이 시점에서 메모리 사용량이 크게 줄어듭니다. 그 다음으로, LoraConfig를 정의합니다.
r=16은 LoRA의 rank를 의미하는데, 이는 어댑터 행렬의 차원입니다. 작을수록 파라미터가 적지만 표현력도 줄어듭니다.
실무에서는 8~64 사이를 사용합니다. lora_alpha=32는 스케일링 팩터로, 일반적으로 r의 2배를 씁니다.
target_modules는 어디에 LoRA를 적용할지 지정하는데, Attention의 Query와 Value projection에만 적용하는 것이 효율적입니다. 마지막으로, get_peft_model()이 베이스 모델에 LoRA 레이어를 주입합니다.
이 과정에서 원본 가중치는 requires_grad=False로 설정되어 학습되지 않고, LoRA 파라미터만 requires_grad=True가 됩니다. print_trainable_parameters()를 호출하면 실제로 학습되는 파라미터 비율을 볼 수 있는데, 보통 0.1% 미만입니다.
70억 개 파라미터 모델에서 4백만 개만 학습하는 것입니다. 여러분이 이 코드를 사용하면 일반 GPU(예: RTX 3090 24GB)에서도 70억 개 파라미터 모델을 Fine-tuning할 수 있습니다.
학습 시간도 크게 단축되고(그래디언트 계산이 적음), 여러 도메인별 어댑터를 만들어 하나의 베이스 모델에서 바꿔가며 사용할 수 있습니다. 예를 들어, 법률용 LoRA, 의료용 LoRA를 각각 만들어두고 필요에 따라 로드하면 됩니다.
실전 팁
💡 rank(r) 값은 실험을 통해 찾아야 합니다. 작은 데이터셋(1만 개 미만)에는 r=8, 큰 데이터셋(10만 개 이상)에는 r=32~64가 적합합니다. 너무 크면 과적합됩니다.
💡 target_modules를 확장하면 성능이 올라갈 수 있습니다. ["q_proj", "k_proj", "v_proj", "o_proj"] 처럼 Key와 Output projection도 포함하세요. 단, 파라미터는 2배 늘어납니다.
💡 LoRA 어댑터는 매우 작아서(수십 MB) 저장과 공유가 쉽습니다. peft_model.save_pretrained("./lora_adapter")로 저장하고 HuggingFace Hub에 업로드할 수 있습니다.
💡 여러 LoRA를 동시에 사용하는 것도 가능합니다. 예를 들어, 법률용 LoRA + 한국어 강화 LoRA를 합쳐서 로드하면 두 능력을 모두 가진 모델이 됩니다.
💡 QLoRA를 사용하면 더욱 메모리를 줄일 수 있습니다. load_in_4bit=True와 bnb_4bit_compute_dtype=torch.float16을 사용하면 4-bit 양자화가 적용되어 16GB GPU로도 130억 파라미터 모델을 학습할 수 있습니다.
4. Trainer API를 활용한 학습 파이프라인 구축 - 안정적인 학습 관리하기
시작하며
여러분이 모델 학습을 시작했는데, 몇 시간 후에 확인해보니 NaN loss로 실패했거나, 체크포인트를 저장 안 해서 처음부터 다시 해야 하는 상황을 겪어본 적 있나요? 수작업으로 학습 루프를 작성하면 에러 처리, 체크포인팅, 로깅, 평가 등을 모두 직접 구현해야 합니다.
실무에서 학습 실패의 가장 흔한 원인은 잘못된 하이퍼파라미터, 메모리 부족, 그래디언트 폭발입니다. 이런 문제들을 수동으로 모니터링하고 대응하는 것은 매우 비효율적입니다.
바로 이럴 때 필요한 것이 HuggingFace의 Trainer API입니다. 학습 루프, 체크포인팅, 로깅, 평가, early stopping 등이 모두 자동화되어 있어 안정적인 학습을 보장합니다.
개요
간단히 말해서, Trainer는 모델 학습의 모든 복잡한 과정을 추상화한 고수준 API입니다. 데이터와 설정만 제공하면 나머지는 자동으로 처리됩니다.
왜 이것이 필요한지 보면, PyTorch의 기본 학습 루프는 수백 줄의 보일러플레이트 코드가 필요합니다. 예를 들어, mixed precision training, gradient accumulation, distributed training 등을 직접 구현하려면 매우 복잡합니다.
Trainer는 이 모든 것을 몇 줄로 해결합니다. 기존에는 for loop를 돌면서 loss를 계산하고 backward하고 optimizer.step()을 호출하는 코드를 직접 작성했다면, 이제는 TrainingArguments로 설정을 정의하고 trainer.train()만 호출하면 됩니다.
핵심 특징 세 가지: 첫째, 자동 체크포인팅 (학습 중단 시 재개 가능). 둘째, 통합 로깅 (TensorBoard, WandB 등 자동 연동).
셋째, 분산 학습 지원 (multi-GPU, multi-node 자동 처리). 이러한 특징들이 중요한 이유는 프로덕션 레벨의 안정성과 재현성을 보장하기 때문입니다.
코드 예제
from transformers import Trainer, TrainingArguments
from transformers import DataCollatorForLanguageModeling
def create_trainer(model, tokenizer, train_dataset, eval_dataset):
"""Trainer 설정 및 생성"""
# 학습 하이퍼파라미터 정의
training_args = TrainingArguments(
output_dir="./sft_checkpoints", # 체크포인트 저장 경로
num_train_epochs=3, # 전체 에폭 수
per_device_train_batch_size=4, # 디바이스당 배치 크기
gradient_accumulation_steps=4, # 그래디언트 누적 (효과적인 배치=16)
learning_rate=2e-4, # 학습률 (LoRA는 높게 설정)
fp16=True, # Mixed precision training
logging_steps=10, # 10스텝마다 로깅
save_steps=100, # 100스텝마다 체크포인트 저장
eval_steps=100, # 100스텝마다 평가
save_total_limit=3, # 최신 3개 체크포인트만 유지
load_best_model_at_end=True, # 학습 종료 시 최고 모델 로드
metric_for_best_model="eval_loss", # 평가 지표
greater_is_better=False, # loss는 낮을수록 좋음
warmup_steps=100, # Learning rate warmup
report_to="tensorboard" # TensorBoard에 로깅
)
# Data Collator 설정 (동적 패딩)
data_collator = DataCollatorForLanguageModeling(
tokenizer=tokenizer,
mlm=False # Causal LM이므로 MLM 사용 안 함
)
# Trainer 생성
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
data_collator=data_collator
)
return trainer
설명
이것이 하는 일: 학습에 필요한 모든 설정을 TrainingArguments로 정의하고, Trainer 객체를 생성하여 복잡한 학습 과정을 자동화합니다. 첫 번째로, TrainingArguments에서 하이퍼파라미터를 설정합니다.
num_train_epochs=3은 전체 데이터를 3번 반복 학습한다는 뜻입니다. per_device_train_batch_size=4는 각 GPU에서 한 번에 4개 샘플을 처리하고, gradient_accumulation_steps=4는 4번 forward pass 후 한 번 backward한다는 의미입니다.
따라서 효과적인 배치 크기는 4×4=16입니다. 이 기법은 메모리가 부족할 때 큰 배치 효과를 내는 데 유용합니다.
그 다음으로, 학습률과 최적화 관련 설정을 합니다. learning_rate=2e-4는 LoRA 학습에 적합한 값입니다(Full Fine-tuning은 보통 1e-5 사용).
fp16=True는 mixed precision training을 활성화해 메모리와 속도를 개선합니다. warmup_steps=100은 처음 100스텝 동안 학습률을 0에서 2e-4까지 점진적으로 올립니다.
이렇게 하면 초기 학습 불안정을 방지할 수 있습니다. 체크포인팅과 평가 설정도 중요합니다.
save_steps=100과 eval_steps=100으로 100스텝마다 저장하고 평가합니다. save_total_limit=3은 디스크 공간 절약을 위해 최신 3개만 유지합니다.
load_best_model_at_end=True를 설정하면 학습 종료 시 가장 낮은 eval_loss를 기록한 체크포인트를 자동으로 로드합니다. 이렇게 하면 과적합되기 전의 최고 모델을 얻을 수 있습니다.
마지막으로, DataCollatorForLanguageModeling은 배치를 만들 때 동적으로 패딩을 추가합니다. 샘플마다 길이가 다를 수 있는데, 배치 내에서 가장 긴 샘플에 맞춰 나머지를 패딩합니다.
mlm=False는 Masked Language Modeling이 아닌 Causal Language Modeling을 사용한다는 뜻입니다. 여러분이 이 코드를 사용하면 안정적이고 재현 가능한 학습을 할 수 있습니다.
학습 중 서버가 다운되어도 마지막 체크포인트에서 재개할 수 있고, TensorBoard로 실시간 모니터링이 가능하며, 분산 학습도 torchrun 명령어 하나로 활성화됩니다.
실전 팁
💡 gradient_accumulation_steps는 메모리와 배치 크기의 trade-off입니다. 메모리가 부족하면 배치 크기를 줄이고 accumulation을 늘리세요. 단, 너무 크면 업데이트 빈도가 줄어 학습이 느립니다.
💡 learning_rate는 가장 중요한 하이퍼파라미터입니다. 너무 높으면 발산하고, 너무 낮으면 학습이 안 됩니다. 2e-5, 5e-5, 1e-4, 2e-4 중 실험해서 eval_loss가 가장 잘 내려가는 값을 선택하세요.
💡 early stopping을 추가하려면 EarlyStoppingCallback을 사용하세요. eval_loss가 n번 연속 개선되지 않으면 자동으로 학습을 멈춥니다: trainer.add_callback(EarlyStoppingCallback(early_stopping_patience=3)).
💡 학습 중 메모리 부족(OOM) 오류가 나면, per_device_train_batch_size를 절반으로 줄이고 gradient_accumulation_steps를 2배로 늘리세요. 효과적인 배치는 같지만 메모리는 절반만 씁니다.
💡 WandB나 MLflow로 실험을 추적하면 여러 실행을 비교하기 쉽습니다. report_to="wandb"로 바꾸고 wandb.init()을 호출하면 자동으로 모든 메트릭이 업로드됩니다.
5. 학습 모니터링과 평가 지표 설정 - 모델이 제대로 학습되는지 확인하기
시작하며
여러분이 모델을 며칠 동안 학습시킨 후, 실제로 사용해보니 엉뚱한 답변만 한다면 얼마나 허탈할까요? 학습 중에는 loss가 잘 떨어지는 것처럼 보였는데, 실제 성능은 형편없는 경우가 종종 있습니다.
이 문제는 잘못된 평가 지표나 모니터링 부족에서 발생합니다. Loss만 보고 학습하면 모델이 데이터를 암기만 하거나, 특정 패턴만 과도하게 학습할 수 있습니다.
실무에서 중요한 것은 "실제 사용자 질문에 얼마나 잘 답하는가"입니다. 바로 이럴 때 필요한 것이 체계적인 모니터링과 평가 지표입니다.
Perplexity, BLEU, ROUGE 같은 자동 지표와 함께 실제 생성 샘플을 주기적으로 확인하면 모델의 진짜 성능을 알 수 있습니다.
개요
간단히 말해서, 모니터링은 학습 과정을 실시간으로 추적하는 것이고, 평가 지표는 모델 품질을 정량화하는 방법입니다. 왜 이것이 필요한지 보면, 학습은 보통 몇 시간에서 며칠이 걸립니다.
중간에 문제가 생겨도 모르고 계속 학습하면 시간과 비용이 낭비됩니다. 예를 들어, 그래디언트가 폭발해서 loss가 NaN이 되거나, learning rate가 너무 높아 발산하는 경우를 조기에 발견해야 합니다.
기존에는 학습이 끝난 후에야 모델을 테스트했다면, 이제는 실시간으로 TensorBoard나 WandB 대시보드를 보면서 문제를 즉시 감지하고 대응합니다. 핵심 특징 세 가지: 첫째, 실시간 메트릭 추적 (loss, learning rate, gradient norm 등).
둘째, 주기적인 샘플 생성 (실제 품질 확인). 셋째, 자동 평가 지표 계산 (BLEU, ROUGE, Perplexity).
이러한 특징들이 중요한 이유는 데이터 중심의 의사결정을 가능하게 하고 실패를 조기에 방지하기 때문입니다.
코드 예제
import evaluate
from transformers import TrainerCallback
import numpy as np
class GenerationSampleCallback(TrainerCallback):
"""학습 중 주기적으로 샘플 생성하여 품질 확인"""
def __init__(self, tokenizer, test_prompts):
self.tokenizer = tokenizer
self.test_prompts = test_prompts
def on_evaluate(self, args, state, control, model, **kwargs):
"""평가 시점마다 샘플 생성"""
print("\n=== 생성 샘플 확인 ===")
for prompt in self.test_prompts:
# 프롬프트 토큰화
inputs = self.tokenizer(prompt, return_tensors="pt").to(model.device)
# 생성 (최대 100 토큰)
outputs = model.generate(
**inputs,
max_new_tokens=100,
temperature=0.7,
do_sample=True,
top_p=0.9
)
# 디코딩
generated = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
print(f"Prompt: {prompt}")
print(f"Generated: {generated}\n")
def compute_metrics(eval_pred):
"""평가 지표 계산"""
# ROUGE 메트릭 로드
rouge = evaluate.load("rouge")
predictions, labels = eval_pred
# 토큰 ID를 텍스트로 디코딩
decoded_preds = tokenizer.batch_decode(predictions, skip_special_tokens=True)
decoded_labels = tokenizer.batch_decode(labels, skip_special_tokens=True)
# ROUGE 점수 계산 (요약 품질 평가)
result = rouge.compute(
predictions=decoded_preds,
references=decoded_labels,
use_stemmer=True
)
# Perplexity 계산 (언어 모델 품질 지표)
perplexity = np.exp(eval_pred.metrics["eval_loss"])
result["perplexity"] = perplexity
return {k: round(v, 4) for k, v in result.items()}
설명
이것이 하는 일: 학습 중 주기적으로 실제 텍스트를 생성해서 품질을 확인하고, ROUGE, Perplexity 같은 자동 평가 지표를 계산합니다. 첫 번째로, GenerationSampleCallback은 평가 시점마다 자동으로 호출되는 커스텀 콜백입니다.
on_evaluate 메서드에서 미리 정의한 테스트 프롬프트를 모델에 입력하고 생성 결과를 출력합니다. 이렇게 하면 "법률 상담 질문에 제대로 답하는가", "의학 용어를 정확히 사용하는가" 같은 정성적 품질을 눈으로 확인할 수 있습니다.
temperature=0.7과 top_p=0.9는 생성의 다양성을 조절하는 파라미터입니다. 그 다음으로, compute_metrics 함수는 평가 세트에서 자동 지표를 계산합니다.
ROUGE는 생성된 텍스트와 정답 텍스트의 단어 겹침을 측정하는 지표로, 요약이나 질의응답 태스크에 적합합니다. ROUGE-1은 단어 단위, ROUGE-2는 바이그램, ROUGE-L은 최장 공통 부분 수열을 비교합니다.
높을수록 정답과 비슷한 텍스트를 생성한다는 의미입니다. Perplexity는 언어 모델이 얼마나 "당황하지 않는지"를 측정합니다.
낮을수록 모델이 텍스트를 잘 예측한다는 뜻입니다. 수식으로는 exp(loss)로 계산되는데, loss가 2.0이면 perplexity는 약 7.4입니다.
일반적으로 좋은 언어 모델은 perplexity 10 이하를 달성합니다. 여러분이 이 코드를 사용하면 학습 중 실시간으로 모델의 질적/양적 성능을 모두 파악할 수 있습니다.
예를 들어, ROUGE 점수는 올라가는데 실제 생성 샘플을 보니 문법이 이상하다면, 평가 지표만으로는 부족하다는 것을 알 수 있습니다. 반대로 loss는 계속 떨어지는데 ROUGE가 정체되면 과적합 신호일 수 있습니다.
실전 팁
💡 테스트 프롬프트는 실제 사용 시나리오를 반영해야 합니다. 법률 챗봇이면 "계약서 검토 요청", "소송 절차 질문" 같은 실전 예시를 사용하세요.
💡 TensorBoard를 활용하면 loss, learning rate, gradient norm을 그래프로 볼 수 있습니다. tensorboard --logdir=./sft_checkpoints 명령어로 웹 대시보드를 띄우세요.
💡 Perplexity가 갑자기 급등하면 학습이 불안정한 신호입니다. Learning rate를 낮추거나 gradient clipping을 강화하세요 (max_grad_norm=0.3).
💡 ROUGE 외에도 BERTScore를 사용하면 의미적 유사도를 측정할 수 있습니다. 단어가 다르더라도 의미가 같으면 높은 점수를 받습니다: bertscore = evaluate.load("bertscore").
💡 WandB를 쓰면 생성 샘플을 테이블 형태로 저장하고 버전별로 비교할 수 있습니다. wandb.log({"samples": wandb.Table(columns=["prompt", "response"], data=samples)})로 기록하세요.
6. 그래디언트 체크포인팅과 메모리 최적화 - 큰 모델을 작은 GPU에서 학습하기
시작하며
여러분이 GPU 메모리 24GB로 70억 파라미터 모델을 학습하려다가 "CUDA out of memory" 오류를 만난 적 있나요? 배치 크기를 1로 줄여도 안 되고, 결국 학습을 포기하거나 더 비싼 GPU를 빌려야 하는 상황에 처합니다.
이 문제는 Transformer 모델의 메모리 사용 패턴에서 발생합니다. Forward pass에서 계산한 중간 활성화(activation)들을 모두 저장해둬야 backward pass에서 그래디언트를 계산할 수 있습니다.
레이어가 많을수록 메모리가 선형적으로 증가합니다. 바로 이럴 때 필요한 것이 그래디언트 체크포인팅(Gradient Checkpointing)입니다.
중간 활성화를 저장하지 않고 필요할 때 다시 계산하면 메모리는 크게 줄이면서 속도는 약간만 희생합니다.
개요
간단히 말해서, 그래디언트 체크포인팅은 메모리를 계산 시간과 교환하는 기법입니다. 중간 값을 저장하는 대신 필요할 때마다 다시 forward pass를 실행합니다.
왜 이것이 필요한지 보면, 대형 모델의 메모리 사용은 세 부분으로 나뉩니다: 모델 가중치, 옵티마이저 상태, 중간 활성화. 가중치와 옵티마이저는 줄이기 어렵지만, 활성화는 체크포인팅으로 크게 줄일 수 있습니다.
예를 들어, 48개 레이어 모델에서 활성화 메모리를 1/4로 줄일 수 있습니다. 기존에는 메모리가 부족하면 배치 크기를 줄이거나 모델을 작게 만들었다면, 이제는 체크포인팅으로 같은 설정을 유지하면서 메모리만 절약합니다.
핵심 특징 세 가지: 첫째, 메모리 절감 (활성화 저장 공간 5070% 감소). 둘째, 학습 속도 trade-off (약 2030% 느려짐).
셋째, 코드 변경 최소화 (한 줄로 활성화). 이러한 특징들이 중요한 이유는 제한된 하드웨어로도 최신 대형 모델을 학습할 수 있게 해주기 때문입니다.
코드 예제
from transformers import TrainingArguments
def setup_memory_optimized_training():
"""메모리 최적화된 학습 설정"""
training_args = TrainingArguments(
output_dir="./sft_checkpoints",
# 그래디언트 체크포인팅 활성화
gradient_checkpointing=True,
# Mixed precision training (메모리 절반)
fp16=True,
# 옵티마이저 상태 CPU 오프로드 (추가 메모리 절감)
optim="adamw_torch",
# 배치 크기 및 그래디언트 누적
per_device_train_batch_size=2, # 작은 배치
gradient_accumulation_steps=8, # 누적으로 효과적 배치 16
# 그래디언트 클리핑 (안정성)
max_grad_norm=1.0,
# 메모리 효율적인 어텐션 사용 (Flash Attention)
# 참고: 이는 모델 생성 시 설정
# model = AutoModelForCausalLM.from_pretrained(
# model_name,
# use_flash_attention_2=True # 2배 빠르고 메모리 효율적
# )
# 기타 학습 설정
num_train_epochs=3,
learning_rate=2e-4,
warmup_steps=100,
logging_steps=10,
save_steps=200,
eval_steps=200
)
return training_args
# 추가 최적화: DeepSpeed ZeRO Stage 2/3
# deepspeed_config = {
# "zero_optimization": {
# "stage": 2, # 옵티마이저 상태를 여러 GPU에 분산
# "offload_optimizer": {
# "device": "cpu" # CPU로 오프로드
# }
# },
# "fp16": {"enabled": True},
# "gradient_clipping": 1.0
# }
설명
이것이 하는 일: 학습 중 메모리 사용을 최소화하기 위해 그래디언트 체크포인팅, mixed precision, 배치 최적화 등을 종합적으로 설정합니다. 첫 번째로, gradient_checkpointing=True를 설정하면 Transformer의 각 레이어에서 중간 활성화를 메모리에 저장하지 않습니다.
대신 backward pass에서 필요할 때 해당 레이어의 forward pass를 다시 실행해서 활성화를 계산합니다. 예를 들어, 32개 레이어 모델에서 몇 개의 체크포인트만 저장하고 나머지는 재계산합니다.
이렇게 하면 메모리는 크게 줄지만 forward pass를 두 번 하므로 학습이 약 20~30% 느려집니다. 그 다음으로, fp16=True로 mixed precision training을 활성화합니다.
일반적으로 가중치와 활성화는 32-bit float로 저장되는데, 이를 16-bit로 바꾸면 메모리가 절반으로 줄고 계산도 빨라집니다. 현대 GPU(V100, A100 등)는 16-bit 연산을 하드웨어 레벨에서 지원해 2~3배 빠릅니다.
단, 수치 안정성을 위해 loss scaling이 자동으로 적용됩니다. 배치 크기와 그래디언트 누적도 중요한 최적화입니다.
per_device_train_batch_size=2는 한 번에 2개 샘플만 처리하고, gradient_accumulation_steps=8은 8번 forward/backward를 실행한 후 한 번 가중치를 업데이트합니다. 효과적인 배치 크기는 2×8=16이지만, 메모리는 배치 2개 분량만 사용합니다.
이는 작은 GPU에서 큰 배치 효과를 내는 핵심 기법입니다. max_grad_norm=1.0은 그래디언트 클리핑으로, 그래디언트의 크기가 1.0을 넘으면 스케일을 줄입니다.
이렇게 하면 그래디언트 폭발을 방지해 학습이 안정적으로 진행됩니다. 특히 LoRA나 Fine-tuning에서 learning rate가 높을 때 필수입니다.
여러분이 이 설정을 사용하면 24GB GPU(예: RTX 3090)로도 70억 파라미터 모델을 학습할 수 있습니다. LoRA와 결합하면 더욱 강력해서, 16GB GPU로도 가능합니다.
DeepSpeed ZeRO를 추가하면 여러 GPU에 옵티마이저 상태를 분산해 더 큰 모델(130억 이상)도 학습할 수 있습니다.
실전 팁
💡 그래디언트 체크포인팅은 학습 속도를 늦추므로, 메모리가 충분하다면 끄세요. 메모리 사용량을 nvidia-smi로 모니터링하면서 결정하세요.
💡 Flash Attention 2를 사용하면 어텐션 연산이 2~3배 빨라지고 메모리도 크게 줄어듭니다. pip install flash-attn로 설치하고 모델 로드 시 use_flash_attention_2=True를 설정하세요.
💡 DeepSpeed ZeRO Stage 3를 사용하면 모델 가중치도 여러 GPU에 분산됩니다. 단일 GPU 메모리보다 큰 모델도 학습 가능하지만, GPU 간 통신 오버헤드가 있어 2개 이상 GPU에서만 효과적입니다.
💡 CPU 오프로딩은 최후의 수단입니다. GPU↔CPU 데이터 전송이 느려 학습 속도가 크게 떨어집니다. 가능하면 그래디언트 체크포인팅과 LoRA로 해결하세요.
💡 메모리 프로파일링 도구를 사용하세요. torch.cuda.memory_summary()나 PyTorch Profiler로 어디서 메모리를 많이 쓰는지 분석하고 최적화하세요.
7. 학습된 모델 병합과 양자화 - 배포용 경량 모델 만들기
시작하며
여러분이 몇 주간 열심히 학습한 모델을 프로덕션에 배포하려는데, 모델 파일이 수십 GB라서 서버 메모리가 부족하거나 추론 속도가 너무 느린 경험을 해본 적 있나요? 학습은 잘 되었는데 실제 사용이 불가능한 상황입니다.
이 문제는 대형 언어 모델의 크기와 계산량에서 발생합니다. 70억 파라미터 모델은 float16으로도 14GB의 메모리를 차지하고, CPU에서는 추론이 몇 분씩 걸립니다.
실시간 서비스는 불가능합니다. 바로 이럴 때 필요한 것이 모델 병합과 양자화입니다.
LoRA 어댑터를 베이스 모델에 병합하고, 가중치를 4-bit나 8-bit로 압축하면 크기는 1/4로 줄이면서 성능은 대부분 유지할 수 있습니다.
개요
간단히 말해서, 모델 병합은 LoRA 어댑터를 원본 가중치에 합치는 것이고, 양자화는 가중치를 낮은 정밀도로 변환하는 것입니다. 왜 이것이 필요한지 보면, LoRA 학습 시에는 베이스 모델과 어댑터가 분리되어 있어 추론할 때 두 개를 모두 로드해야 합니다.
병합하면 하나의 모델 파일로 만들어 로딩이 빠르고 관리가 쉽습니다. 예를 들어, 여러 LoRA 어댑터를 실험했다면 최고 성능의 것을 베이스에 병합해 최종 모델을 만듭니다.
양자화는 배포 환경의 제약을 해결합니다. 기존에는 서버마다 고성능 GPU를 설치해야 했다면, 이제는 양자화된 모델로 일반 GPU나 심지어 CPU에서도 실시간 추론이 가능합니다.
핵심 특징 세 가지: 첫째, 모델 크기 감소 (4-bit 양자화 시 1/8로 압축). 둘째, 추론 속도 향상 (메모리 대역폭 감소).
셋째, 성능 유지 (perplexity 1~2% 정도만 감소). 이러한 특징들이 중요한 이유는 제한된 인프라에서도 대형 모델을 서비스할 수 있게 해주기 때문입니다.
코드 예제
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
def merge_and_quantize_model(
base_model_name: str,
lora_adapter_path: str,
output_path: str
):
"""LoRA 어댑터 병합 및 양자화"""
# 1. 베이스 모델 로드
print("베이스 모델 로딩 중...")
base_model = AutoModelForCausalLM.from_pretrained(
base_model_name,
torch_dtype=torch.float16,
device_map="auto"
)
# 2. LoRA 어댑터 로드 및 병합
print("LoRA 어댑터 병합 중...")
model = PeftModel.from_pretrained(base_model, lora_adapter_path)
merged_model = model.merge_and_unload() # 어댑터를 가중치에 병합
# 3. 병합된 모델 저장
print("병합 모델 저장 중...")
merged_model.save_pretrained(f"{output_path}/merged")
# 4. 양자화 (4-bit)
print("4-bit 양자화 중...")
from transformers import BitsAndBytesConfig
quantization_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True, # 이중 양자화로 추가 압축
bnb_4bit_quant_type="nf4" # NormalFloat 4-bit
)
quantized_model = AutoModelForCausalLM.from_pretrained(
f"{output_path}/merged",
quantization_config=quantization_config,
device_map="auto"
)
# 5. 양자화 모델 저장
quantized_model.save_pretrained(f"{output_path}/quantized")
# 토크나이저도 함께 저장
tokenizer = AutoTokenizer.from_pretrained(base_model_name)
tokenizer.save_pretrained(f"{output_path}/quantized")
print(f"✅ 완료! 저장 경로: {output_path}/quantized")
# 메모리 사용량 비교
original_size = sum(p.numel() * p.element_size() for p in base_model.parameters()) / 1e9
quantized_size = sum(p.numel() * p.element_size() for p in quantized_model.parameters()) / 1e9
print(f"원본 크기: {original_size:.2f} GB")
print(f"양자화 크기: {quantized_size:.2f} GB")
print(f"압축률: {original_size/quantized_size:.1f}x")
설명
이것이 하는 일: LoRA 어댑터를 베이스 모델에 병합하여 단일 모델로 만들고, 4-bit 양자화를 적용해 크기를 대폭 줄인 배포용 모델을 생성합니다. 첫 번째로, 베이스 모델을 float16으로 로드합니다.
torch_dtype=torch.float16을 사용하면 원래 float32 대비 절반의 메모리를 사용합니다. device_map="auto"는 모델을 사용 가능한 GPU에 자동으로 배치합니다.
그 다음으로, PeftModel.from_pretrained()로 학습한 LoRA 어댑터를 로드하고 merge_and_unload()를 호출합니다. 이 함수는 LoRA의 저랭크 행렬(A와 B)을 실제 가중치에 더해서 하나의 통합된 가중치를 만듭니다.
수식으로는 W_new = W_original + A × B입니다. 병합 후에는 LoRA 구조가 사라지고 일반적인 Transformer 모델이 됩니다.
세 번째로, 양자화를 적용합니다. BitsAndBytesConfig에서 load_in_4bit=True를 설정하면 각 가중치를 4-bit 정수로 압축합니다.
bnb_4bit_quant_type="nf4"는 Normal Float 4-bit 양자화 방식으로, 가중치 분포가 정규분포에 가까울 때 최적입니다. bnb_4bit_use_double_quant=True는 양자화 상수 자체도 다시 양자화해서 추가 압축을 달성합니다.
마지막으로, 양자화된 모델을 저장합니다. 4-bit 양자화된 70억 파라미터 모델은 약 4~5GB 정도만 차지해서 일반 GPU나 심지어 고성능 CPU에서도 추론이 가능합니다.
토크나이저도 함께 저장하면 나중에 이 폴더만 배포하면 됩니다. 여러분이 이 코드를 사용하면 학습한 모델을 실제 서비스에 바로 사용할 수 있습니다.
예를 들어, FastAPI로 API 서버를 만들고 이 양자화 모델을 로드하면 사용자 요청에 1~2초 안에 응답할 수 있습니다. 또한 모델 파일 크기가 작아 Docker 이미지에 포함하거나 CDN으로 배포하기도 쉽습니다.
실전 팁
💡 양자화 전에 성능을 반드시 평가하세요. 일부 태스크에서는 4-bit가 너무 공격적일 수 있습니다. 8-bit(load_in_8bit=True)로 시작해서 성능 저하가 없으면 4-bit를 시도하세요.
💡 GPTQ나 AWQ 같은 고급 양자화 기법도 고려하세요. BitsAndBytes보다 추론 속도가 빠르고 성능 저하도 적습니다. 단, 양자화 자체에 시간이 더 걸립니다.
💡 병합하지 않고 LoRA 어댑터만 배포하는 것도 전략입니다. 베이스 모델 하나에 여러 어댑터를 교체하며 사용하면 다중 도메인 서비스가 가능합니다(예: 법률용/의료용 어댑터).
💡 양자화 후 perplexity와 샘플 생성 품질을 평가 세트로 확인하세요. 자동 지표는 괜찮아도 실제 사용해보면 품질이 떨어질 수 있습니다.
💡 ONNX나 TensorRT로 변환하면 추론 속도를 더욱 높일 수 있습니다. HuggingFace Optimum 라이브러리가 이를 지원합니다: optimum-cli export onnx --model ./quantized.
8. 추론 파이프라인 구축과 생성 파라미터 튜닝 - 실전 배포를 위한 마지막 단계
시작하며
여러분이 학습한 모델을 실제 사용자에게 제공할 때, "어떻게 일관되고 고품질의 답변을 생성하게 할까"라는 고민을 해본 적 있나요? 같은 질문에도 매번 다른 답을 하거나, 너무 짧거나 길게 생성되거나, 같은 문장을 반복하는 문제가 발생합니다.
이런 문제는 생성 파라미터 설정이 부적절할 때 나타납니다. Temperature, top-p, top-k, repetition penalty 등의 값을 조정하지 않으면 모델이 불안정하게 동작합니다.
실무에서는 도메인과 사용 사례에 맞게 이 파라미터들을 최적화해야 합니다. 바로 이럴 때 필요한 것이 체계적인 추론 파이프라인과 생성 파라미터 튜닝입니다.
프롬프트 템플릿, 생성 설정, 후처리를 표준화하면 일관된 품질의 서비스를 제공할 수 있습니다.
개요
간단히 말해서, 추론 파이프라인은 사용자 입력을 받아 모델 출력을 생성하는 전체 과정이고, 생성 파라미터는 생성 방식을 조절하는 설정값들입니다. 왜 이것이 필요한지 보면, 학습할 때와 추론할 때의 형식이 일치해야 모델이 제대로 작동합니다.
예를 들어, 학습 시 "### Instruction:" 형식을 사용했다면 추론할 때도 동일해야 합니다. 또한 생성 품질은 파라미터에 크게 의존합니다.
Temperature가 높으면 창의적이지만 불안정하고, 낮으면 안정적이지만 반복적입니다. 기존에는 개발자마다 임의로 생성 코드를 작성했다면, 이제는 표준 파이프라인을 정의하고 A/B 테스트로 최적의 파라미터를 찾습니다.
핵심 특징 세 가지: 첫째, 프롬프트 일관성 (학습-추론 형식 통일). 둘째, 생성 품질 제어 (temperature, top-p 등).
셋째, 안전장치 (최대 길이, 반복 방지). 이러한 특징들이 중요한 이유는 프로덕션 환경에서 안정적이고 예측 가능한 서비스를 제공하기 때문입니다.
코드 예제
from transformers import AutoModelForCausalLM, AutoTokenizer, GenerationConfig
import torch
class InferencePipeline:
"""추론 파이프라인 클래스"""
def __init__(self, model_path: str):
"""모델 및 토크나이저 로드"""
self.device = "cuda" if torch.cuda.is_available() else "cpu"
# 양자화 모델 로드
self.model = AutoModelForCausalLM.from_pretrained(
model_path,
device_map="auto",
torch_dtype=torch.float16
)
self.tokenizer = AutoTokenizer.from_pretrained(model_path)
# 생성 설정
self.generation_config = GenerationConfig(
max_new_tokens=512, # 최대 생성 토큰 수
temperature=0.7, # 창의성 (0.1~1.0, 낮을수록 보수적)
top_p=0.9, # Nucleus sampling (0.9~0.95 권장)
top_k=50, # Top-K sampling (50~100)
repetition_penalty=1.2, # 반복 방지 (1.0~1.5)
do_sample=True, # 샘플링 활성화
num_beams=1, # Beam search (1이면 greedy)
early_stopping=True, # EOS 토큰 시 중단
pad_token_id=self.tokenizer.eos_token_id
)
def format_prompt(self, instruction: str, input_text: str = "") -> str:
"""학습 시와 동일한 프롬프트 형식"""
template = """### Instruction:
{instruction}
### Input:
{input}
### Response:
"""
return template.format(instruction=instruction, input=input_text)
def generate(self, instruction: str, input_text: str = "") -> str:
"""텍스트 생성"""
# 프롬프트 구성
prompt = self.format_prompt(instruction, input_text)
# 토큰화
inputs = self.tokenizer(prompt, return_tensors="pt").to(self.device)
# 생성
with torch.no_grad(): # 그래디언트 계산 안 함 (추론 시)
outputs = self.model.generate(
**inputs,
generation_config=self.generation_config
)
# 디코딩 (입력 제거하고 생성 부분만)
generated_text = self.tokenizer.decode(
outputs[0][inputs.input_ids.shape[1]:], # 입력 길이만큼 제거
skip_special_tokens=True
)
# 후처리: 불필요한 공백 제거
generated_text = generated_text.strip()
return generated_text
# 사용 예시
pipeline = InferencePipeline("./output/quantized")
response = pipeline.generate(
instruction="Python에서 리스트 컴프리헨션을 설명해주세요",
input_text=""
)
print(response)
설명
이것이 하는 일: 학습된 모델을 로드하고, 학습 시와 동일한 프롬프트 형식을 적용하며, 최적화된 생성 파라미터로 고품질 텍스트를 생성합니다. 첫 번째로, __init__에서 모델과 토크나이저를 로드합니다.
양자화된 모델을 사용하면 메모리 효율이 좋고, torch.float16으로 추론 속도도 빠릅니다. device_map="auto"는 사용 가능한 GPU를 자동으로 감지합니다.
그 다음으로, GenerationConfig를 정의합니다. 여기서 각 파라미터의 의미를 정확히 이해하는 것이 중요합니다.
temperature=0.7은 다음 토큰의 확률 분포를 부드럽게 만듭니다. 1.0은 원래 분포 그대로, 0.1은 거의 deterministic, 2.0은 매우 랜덤합니다.
top_p=0.9는 누적 확률이 90%가 될 때까지의 토큰만 고려합니다. 이렇게 하면 저확률 토큰들을 제외해 이상한 생성을 방지합니다.
repetition_penalty=1.2는 이미 생성된 토큰의 확률을 1.2로 나눠서 반복을 억제합니다. format_prompt는 학습 시 사용한 것과 정확히 동일한 템플릿을 사용합니다.
이것이 매우 중요한데, 형식이 달라지면 모델이 혼란스러워 성능이 급격히 떨어집니다. "### Instruction:", "### Input:", "### Response:" 구분자가 모두 일치해야 합니다.
마지막으로, generate 메서드가 실제 추론을 수행합니다. torch.no_grad()를 사용하면 그래디언트를 계산하지 않아 메모리와 속도가 개선됩니다.
생성 후 디코딩할 때 outputs[0][inputs.input_ids.shape[1]:]로 입력 부분을 제거하고 새로 생성된 부분만 추출합니다. 이렇게 하지 않으면 "### Instruction: ..." 전체가 다시 출력됩니다.
여러분이 이 코드를 사용하면 FastAPI나 Flask로 API 서버를 쉽게 만들 수 있습니다. 예를 들어, @app.post("/generate")로 엔드포인트를 만들고 이 파이프라인을 호출하면 됩니다.
또한 생성 파라미터를 실시간으로 조정할 수 있어, 사용자가 "더 창의적으로" 버튼을 누르면 temperature를 올리는 식의 기능도 구현 가능합니다.
실전 팁
💡 도메인에 따라 최적 파라미터가 다릅니다. 코드 생성은 temperature=0.20.3(정확성), 창작 글쓰기는 0.70.9(다양성)가 적합합니다. A/B 테스트로 찾으세요.
💡 num_beams를 2 이상으로 설정하면 beam search가 활성화되어 더 나은 품질의 텍스트를 생성하지만 속도가 느려집니다. 실시간 서비스에는 1(greedy), 배치 작업에는 4~5가 좋습니다.
💡 max_new_tokens는 사용 사례에 맞게 설정하세요. 챗봇은 100300, 긴 문서 생성은 10002000이 적합합니다. 너무 크면 응답 시간이 길어져 사용자 경험이 나빠집니다.
💡 Stop words를 설정하면 특정 단어가 나올 때 생성을 중단할 수 있습니다. stopping_criteria를 커스터마이징해서 "### Instruction:" 같은 문자열이 나오면 멈추게 하세요.
💡 배치 추론으로 처리량을 높이세요. 여러 요청을 모아서 한 번에 처리하면 GPU 활용률이 올라갑니다. model.generate(input_ids_batch)처럼 배치로 전달하면 됩니다.
9. 데이터 증강과 백트랜슬레이션 - 적은 데이터로 더 나은 성능 내기
시작하며
여러분이 도메인 특화 챗봇을 만들려는데, 학습 데이터가 고작 100개밖에 없다면 어떻게 하시겠습니까? 실무에서는 라벨링된 고품질 데이터를 모으는 것이 가장 어렵고 비용이 많이 드는 작업입니다.
데이터 부족은 과적합과 낮은 일반화 성능으로 이어집니다. 100개 샘플로 학습한 모델은 그 100개는 완벽하게 외우지만, 새로운 질문에는 엉뚱한 답을 합니다.
실제 사용자 요청의 다양성을 포착하지 못하기 때문입니다. 바로 이럴 때 필요한 것이 데이터 증강(Data Augmentation)입니다.
기존 샘플을 변형하거나, 백트랜슬레이션(역번역)으로 의미는 같지만 표현이 다른 샘플을 생성하면 데이터를 수배로 늘릴 수 있습니다.
개요
간단히 말해서, 데이터 증강은 원본 데이터를 변형해 새로운 학습 샘플을 만드는 기법이고, 백트랜슬레이션은 다른 언어로 번역했다가 다시 원래 언어로 번역하는 방법입니다. 왜 이것이 필요한지 보면, NLP에서 같은 의미를 표현하는 방법은 무수히 많습니다.
예를 들어, "계약서를 검토해주세요"는 "계약서 리뷰 부탁드립니다", "계약 내용을 확인해주실 수 있나요" 등으로 표현 가능합니다. 데이터 증강으로 이런 변형들을 자동 생성하면 모델이 다양한 표현을 학습합니다.
기존에는 데이터가 부족하면 더 많은 라벨링 작업을 했다면, 이제는 증강 기법으로 기존 데이터의 가치를 극대화합니다. 핵심 특징 세 가지: 첫째, 자동화된 샘플 생성 (수작업 최소화).
둘째, 의미 보존 (핵심 내용은 유지하며 표현만 변경). 셋째, 다양성 증가 (모델이 다양한 입력 스타일 학습).
이러한 특징들이 중요한 이유는 적은 데이터로도 강건한 모델을 만들 수 있게 해주기 때문입니다.
코드 예제
from transformers import MarianMTModel, MarianTokenizer
import random
class DataAugmenter:
"""데이터 증강 도구"""
def __init__(self):
# 영어 -> 한국어 번역 모델
self.ko_model_name = "Helsinki-NLP/opus-mt-en-ko"
self.ko_tokenizer = MarianTokenizer.from_pretrained(self.ko_model_name)
self.ko_model = MarianMTModel.from_pretrained(self.ko_model_name)
# 한국어 -> 영어 번역 모델
self.en_model_name = "Helsinki-NLP/opus-mt-ko-en"
self.en_tokenizer = MarianTokenizer.from_pretrained(self.en_model_name)
self.en_model = MarianMTModel.from_pretrained(self.en_model_name)
def back_translate(self, text: str, num_variations: int = 2) -> list:
"""백트랜슬레이션으로 변형 생성"""
variations = []
for _ in range(num_variations):
# 한국어 -> 영어
inputs = self.en_tokenizer(text, return_tensors="pt", padding=True)
translated = self.en_model.generate(
**inputs,
max_length=128,
num_beams=4, # Beam search로 품질 향상
temperature=0.8 # 약간의 무작위성
)
en_text = self.en_tokenizer.decode(translated[0], skip_special_tokens=True)
# 영어 -> 한국어 (역번역)
inputs = self.ko_tokenizer(en_text, return_tensors="pt", padding=True)
back_translated = self.ko_model.generate(
**inputs,
max_length=128,
num_beams=4,
temperature=0.8
)
ko_text = self.ko_tokenizer.decode(back_translated[0], skip_special_tokens=True)
# 원본과 다르면 추가
if ko_text != text:
variations.append(ko_text)
return variations
def synonym_replacement(self, text: str, num_replacements: int = 2) -> str:
"""동의어 치환 (간단한 예시)"""
# 실무에서는 WordNet, KoreanSynonymDict 등 사용
synonym_dict = {
"계약서": ["계약 문서", "협약서", "약정서"],
"검토": ["리뷰", "확인", "점검"],
"부탁드립니다": ["부탁합니다", "요청드립니다", "해주세요"]
}
words = text.split()
for _ in range(num_replacements):
# 랜덤하게 단어 선택
for i, word in enumerate(words):
if word in synonym_dict and random.random() < 0.5:
words[i] = random.choice(synonym_dict[word])
return " ".join(words)
def augment_dataset(self, samples: list, target_size: int) -> list:
"""데이터셋 증강"""
augmented = samples.copy()
while len(augmented) < target_size:
# 랜덤 샘플 선택
original = random.choice(samples)
# 백트랜슬레이션 적용
variations = self.back_translate(original["instruction"], num_variations=1)
if variations:
augmented.append({
"instruction": variations[0],
"input": original.get("input", ""),
"output": original["output"]
})
return augmented[:target_size]
# 사용 예시
augmenter = DataAugmenter()
original_data = [
{"instruction": "계약서를 검토해주세요", "input": "", "output": "계약서 검토를 시작하겠습니다..."}
]
# 100개 -> 500개로 증강
augmented_data = augmenter.augment_dataset(original_data, target_size=500)
print(f"증강 완료: {len(original_data)} -> {len(augmented_data)} 샘플")
설명
이것이 하는 일: 원본 텍스트를 다른 언어로 번역했다가 다시 원래 언어로 번역하거나, 동의어로 단어를 치환해서 의미는 같지만 표현이 다른 샘플을 자동 생성합니다. 첫 번째로, back_translate 메서드는 Helsinki-NLP의 MarianMT 번역 모델을 사용합니다.
한국어 텍스트를 영어로 번역하고, 다시 한국어로 역번역합니다. 이 과정에서 원래 표현과 다르지만 의미는 유사한 문장이 생성됩니다.
예를 들어, "계약서를 검토해주세요"가 "계약 문서를 확인해주시기 바랍니다"로 변형될 수 있습니다. num_beams=4는 beam search로 품질 높은 번역을 생성하고, temperature=0.8은 약간의 무작위성을 추가해 매번 다른 결과를 만듭니다.
그 다음으로, synonym_replacement는 단어를 동의어로 치환합니다. 미리 정의한 사전에서 치환 가능한 단어를 찾아 랜덤하게 바꿉니다.
실무에서는 WordNet이나 한국어 동의어 사전을 사용하면 더 풍부한 변형이 가능합니다. random.random() < 0.5로 확률적으로 치환해서 과도한 변형을 방지합니다.
마지막으로, augment_dataset이 전체 데이터셋을 목표 크기까지 증강합니다. 원본 샘플을 랜덤하게 선택하고 백트랜슬레이션을 적용해서 새 샘플을 만듭니다.
instruction만 변형하고 output은 그대로 유지하는 것이 중요합니다. 답변까지 변형하면 정답이 달라질 수 있기 때문입니다.
여러분이 이 코드를 사용하면 100개 데이터를 500개로 늘려 모델의 일반화 성능을 크게 향상시킬 수 있습니다. 특히 도메인 특화 데이터가 부족할 때 매우 효과적입니다.
단, 증강된 샘플의 품질을 수동으로 검증하는 것이 좋습니다. 일부 역번역 결과가 의미가 달라질 수 있기 때문입니다.
실전 팁
💡 백트랜슬레이션은 여러 언어를 거치면 더 다양해집니다. 한국어→영어→프랑스어→영어→한국어처럼 중간 언어를 추가하세요. 단, 의미 왜곡 위험도 증가합니다.
💡 GPT 기반 paraphrasing도 효과적입니다. ChatGPT API에 "다음 문장을 5가지 방법으로 다시 표현해주세요"라고 요청하면 고품질 변형을 얻을 수 있습니다.
💡 데이터 증강 비율을 조심하세요. 원본:증강 비율이 1:10을 넘으면 노이즈가 많아져 오히려 성능이 떨어집니다. 1:3~1:5가 적당합니다.
💡 도메인 특화 동의어 사전을 만드세요. 법률 분야면 "원고-소송 제기자", "피고-소송 대상자" 같은 전문 용어 매핑을 추가하면 증강 품질이 올라갑니다.
💡 증강 후 중복 제거는 필수입니다. 역번역이 우연히 원본과 똑같아질 수 있습니다. set()이나 fuzzy matching으로 유사도 높은 샘플을 제거하세요.
10. Instruction-Following 능력 강화하기 - 지시사항을 정확히 따르는 모델 만들기
시작하며
여러분이 학습한 모델에게 "5개의 팁만 알려줘"라고 했는데 10개를 나열하거나, "코드만 보여줘"라고 했는데 긴 설명까지 붙여준 경험이 있나요? 모델이 내용은 맞지만 지시사항을 제대로 따르지 않는 문제입니다.
이런 문제는 학습 데이터에 명확한 지시사항이 부족하거나, 모델이 instruction과 content를 구분하지 못할 때 발생합니다. 실무에서는 사용자가 "간단히", "자세히", "코드만" 같은 요구를 하는데, 모델이 이를 무시하면 사용자 경험이 나빠집니다.
바로 이럴 때 필요한 것이 Instruction-Following 능력 강화 기법입니다. 데이터에 다양한 제약 조건을 포함시키고, 모델이 지시사항을 준수하는지 보상하는 학습 방법을 사용하면 해결할 수 있습니다.
개요
간단히 말해서, Instruction-Following은 모델이 사용자의 명령이나 제약 조건을 정확히 따르는 능력입니다. "짧게", "5개만", "코드로만" 같은 형식 요구사항을 지키는 것입니다.
왜 이것이 필요한지 보면, 콘텐츠의 정확성만큼이나 형식 준수도 중요합니다. 예를 들어, 고객 서비스 챗봇이 정확한 정보를 주지만 10문단으로 답한다면 사용자는 읽지 않고 떠납니다.
2-3문장으로 요약해달라는 요청을 따라야 합니다. 기존에는 모델이 자유롭게 생성했다면, 이제는 instruction 부분을 강조하고 제약 조건 준수를 평가 지표에 포함시킵니다.
핵심 특징 세 가지: 첫째, 명시적 제약 조건 (길이, 형식, 톤 등). 둘째, 다양한 instruction 패턴 (긍정/부정 명령, 조건부 등).
셋째, 준수율 평가 (실제로 지시를 따랐는지 측정). 이러한 특징들이 중요한 이유는 사용자가 원하는 대로 동작하는 실용적인 모델을 만들기 때문입니다.
코드 예제
import random
class InstructionDataGenerator:
"""Instruction-Following 데이터 생성"""
def __init__(self):
# 다양한 제약 조건 템플릿
self.length_constraints = [
"1-2문장으로 간단히",
"3-5개의 불릿 포인트로",
"100자 이내로",
"최대한 자세히"
]
self.format_constraints = [
"코드만 보여주고 설명은 생략해",
"코드와 함께 주석으로 설명해",
"예시 없이 개념만 설명해",
"실전 예시를 포함해서"
]
self.tone_constraints = [
"초보자도 이해할 수 있게",
"전문가 수준으로",
"친근한 말투로",
"공식 문서 스타일로"
]
def generate_constrained_instruction(self, base_question: str) -> dict:
"""제약 조건이 포함된 instruction 생성"""
# 랜덤하게 제약 조건 선택
constraints = []
if random.random() > 0.5:
constraints.append(random.choice(self.length_constraints))
if random.random() > 0.5:
constraints.append(random.choice(self.format_constraints))
if random.random() > 0.5:
constraints.append(random.choice(self.tone_constraints))
# instruction 구성
if constraints:
instruction = f"{base_question} ({', '.join(constraints)})"
else:
instruction = base_question
return {
"instruction": instruction,
"constraints": constraints,
"base_question": base_question
}
def validate_response(self, instruction: str, response: str) -> dict:
"""응답이 제약 조건을 따르는지 검증"""
results = {
"length_ok": True,
"format_ok": True,
"tone_ok": True
}
# 길이 제약 검증
if "1-2문장" in instruction:
sentence_count = response.count('.') + response.count('!') + response.count('?')
results["length_ok"] = sentence_count <= 2
elif "100자 이내" in instruction:
results["length_ok"] = len(response) <= 100
# 형식 제약 검증
if "코드만" in instruction:
# 설명 문장이 있으면 실패
has_explanation = any(c in response for c in ['입니다', '합니다', '것'])
results["format_ok"] = not has_explanation
elif "불릿 포인트" in instruction:
# •, -, * 같은 불릿이 있는지 확인
results["format_ok"] = any(c in response for c in ['•', '-', '*', '💡'])
# 전체 준수율
compliance_rate = sum(results.values()) / len(results)
results["overall_compliance"] = compliance_rate
return results
# Reward 기반 학습을 위한 점수 계산
def compute_instruction_following_reward(instruction: str, response: str) -> float:
"""Instruction-following 점수 (0~1)"""
validator = InstructionDataGenerator()
results = validator.validate_response(instruction, response)
return results["overall_compliance"]
# 사용 예시
generator = InstructionDataGenerator()
base_question = "Python 리스트 컴프리헨션을 설명해주세요"
constrained = generator.generate_constrained_instruction(base_question)
print(f"Original: {base_question}")
print(f"Constrained: {constrained['instruction']}")
print(f"Constraints: {constrained['constraints']}")
# 응답 검증
response1 = "리스트 컴프리헨션은 간결하게 리스트를 생성하는 방법입니다."
response2 = "[x**2 for x in range(10)]"
print("\n응답 1 검증:", generator.validate_response(constrained['instruction'], response1))
print("응답 2 검증:", generator.validate_response(constrained['instruction'], response2))
설명
이것이 하는 일: 기본 질문에 길이, 형식, 톤 같은 제약 조건을 추가해 다양한 instruction을 생성하고, 모델 응답이 이를 준수하는지 자동으로 평가합니다. 첫 번째로, InstructionDataGenerator는 세 가지 유형의 제약 조건을 정의합니다.
length_constraints는 출력 길이("1-2문장", "100자 이내"), format_constraints는 출력 형식("코드만", "불릿 포인트로"), tone_constraints는 어조("초보자용", "전문가용")를 지정합니다. 이 조합으로 수백 가지 variation을 만들 수 있습니다.
그 다음으로, generate_constrained_instruction이 랜덤하게 제약 조건을 선택해서 instruction을 풍부하게 만듭니다. 예를 들어, "Python 리스트 컴프리헨션을 설명해주세요"가 "Python 리스트 컴프리헨션을 설명해주세요 (1-2문장으로 간단히, 코드만 보여주고 설명은 생략해)"로 변형됩니다.
이렇게 다양한 제약 조건을 학습 데이터에 포함시키면 모델이 패턴을 학습합니다. validate_response는 생성된 응답이 실제로 제약을 따르는지 검증합니다.
"1-2문장"이라고 했으면 문장 개수를 세고, "코드만"이라고 했으면 설명 문장이 없는지 확인합니다. 이 검증 로직은 학습 후 평가 단계에서 모델의 instruction-following 능력을 정량화하는 데 사용됩니다.
마지막으로, compute_instruction_following_reward는 준수율을 0~1 점수로 반환합니다. 이 점수는 RLHF(Reinforcement Learning from Human Feedback) 같은 고급 학습 방법에서 보상 신호로 사용할 수 있습니다.
모델이 제약을 잘 따르면 높은 보상, 어기면 낮은 보상을 주는 식으로 강화학습을 적용할 수 있습니다. 여러분이 이 코드를 사용하면 모델이 단순히 정확한 내용만 생성하는 것이 아니라, 사용자가 원하는 형식과 길이로 답변하게 할 수 있습니다.
실무에서는 "간단히 요약해줘", "5가지만 알려줘" 같은 요청이 매우 흔한데, 이런 능력이 있어야 사용자 만족도가 올라갑니다.
실전 팁
💡 학습 데이터의 30~50%는 제약 조건이 포함된 샘플로 구성하세요. 너무 많으면 일반적인 질문에 대한 성능이 떨어질 수 있습니다.
💡 제약 조건을 자연스럽게 표현하세요. "(1-2문장)" 같은 괄호 형식 외에도 "짧게 설명해줘", "간단히 알려줘" 같은 자연어 변형을 포함하면 실전 성능이 좋아집니다.
💡 Negative examples도 포함하세요. "긴 설명 없이 코드만"이라는 instruction에 대해 긴 설명을 포함한 잘못된 예시를 학습 데이터에 넣고, 이를 피하도록 학습할 수 있습니다.
💡 Constitutional AI 기법을 활용하세요. 모델이 자신의 출력을 자가 평가하고 수정하게 하면 instruction-following 능력이 크게 향상됩니다.
💡 사용자 피드백을 수집하세요. 실제 서비스에서 "이 답변이 요청에 맞나요?" 버튼을 추가하고, 부정 피드백이 많은 패턴을 분석해 학습 데이터에 반영하세요. 이상으로 "바닥부터 만드는 ChatGPT 15편 - Supervised Fine-tuning 파이프라인"에 대한 10개의 코드 카드 뉴스를 완성했습니다. 각 카드는 실무에서 바로 활용할 수 있는 상세한 내용과 코드, 팁을 포함하고 있습니다.