지난 글에서 수조 개 토큰으로 베이스 모델을 학습하는 사전학습을 다뤘다. 사전학습을 마친 모델은 텍스트를 이어 쓰는 데는 뛰어나지만 질문에 답하지는 않는다. 「파이썬으로 피보나치 수열을 구현해줘」를 넣으면 같은 문장을 몇 번 더 이어 쓰거나 비슷한 질문 목록을 만들어 낸다. 틀린 동작이 아니라 학습한 그대로의 동작이다. 인터넷 문서에서 저 문장 뒤에 가장 자주 오는 것이 답이 아니라 또 다른 질문이기 때문이다.
이 간극을 메우는 첫 단계가 SFT, 곧 지시와 응답을 짝지은 데이터로 하는 지도 파인튜닝이다. 이 글은 SFT 한 단계만 다룬다. 선호 데이터로 한 번 더 손보는 RLHF와 DPO는 별도의 문제라 다음 글로 넘긴다.
정렬 갭
이어 쓰기와 답하기
베이스 모델의 학습 목표는 「다음 토큰 예측」 하나다. 우리가 원하는 것은 「유용하고 안전하고 정직한 응답」이다. 이 둘 사이의 거리를 정렬 갭이라 부른다.
갭의 정체는 능력이 아니라 형식이다. 베이스 모델은 이미 피보나치 수열을 구현할 줄 안다. 사전학습 데이터에 그 코드가 수만 번 나왔기 때문이다. 다만 「질문 다음에는 답이 온다」는 대화 형식을 모를 뿐이다. 그래서 SFT가 가르치는 것은 지식이 아니라 형식이고, 데이터가 수만 건 규모로도 충분한 이유가 여기 있다.
SFT가 채우는 것
SFT 데이터 한 건은 지시와 이상적인 응답 한 쌍이다. 학습은 사전학습과 똑같이 다음 토큰을 예측하는 방식이고, 달라지는 것은 데이터의 모양뿐이다. 일반 문서 대신 대화 형식의 텍스트를 넣고, 응답 부분에만 손실을 건다.
이 과정에서 모델이 배우는 것은 셋 정도다. 대화 형식에서 자기 차례가 어디인지, 어느 정도 길이로 답하는 것이 적절한지, 그리고 어떤 요청에는 답하지 않는지다. 셋 다 형식에 관한 학습이라 몇 에폭 안에 자리 잡는다. 반대로 SFT로 가르치기 어려운 것도 같은 이유로 정해진다. 데이터에 없던 사실을 넣는 일, 추론 능력 자체를 끌어올리는 일은 이 단계의 몫이 아니다. 새 사실을 SFT로 밀어 넣으면 모델이 그 문장을 외우기는 하지만 근거 없이 자신 있게 말하는 습관까지 함께 배운다는 관찰이 있어, 지식을 넣는 일은 검색으로 붙이거나 사전학습 단계에서 다루는 쪽이 낫다.
다음 단계와의 경계
SFT만으로 안 되는 것도 분명하다. SFT는 「이 응답이 정답이다」라고 가르칠 뿐 「이 응답이 저 응답보다 낫다」를 가르치지 못한다. 두 응답이 모두 맞는데 하나가 더 친절하거나 더 간결한 상황을 다루려면 비교 신호가 필요하고, 그것이 선호 데이터로 하는 다음 단계의 일이다.
실무 순서로 보면 SFT가 먼저이고 대체 관계가 아니다. 선호 학습은 기준이 될 모델을 필요로 하는데 그 기준이 SFT를 마친 모델이기 때문이다. 그리고 SFT를 제대로 하지 않은 채 선호 학습으로 넘어가면, 비교할 두 응답의 품질이 모두 낮아 어느 쪽을 고르든 배울 것이 적어진다. 이 단계에서 아낀 비용이 다음 단계에서 그대로 청구되는 구조다.
채팅 템플릿
템플릿의 구성
채팅 템플릿은 역할과 내용의 목록을 하나의 문자열로 펴는 규칙이다. 시스템·사용자·어시스턴트를 구분하는 특수 토큰과 각 차례의 끝을 알리는 토큰으로 이루어진다. 모델마다 이 규칙이 다르고, 같은 계열이라도 버전이 바뀌면 달라진다.
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B-Instruct")
messages = [
{"role": "system", "content": "간결하고 정확하게 답하세요."},
{"role": "user", "content": "파이썬으로 피보나치 수열을 구현해줘."},
]
prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
add_generation_prompt가 하는 일을 짚어 둘 만하다. 참으로 두면 마지막에 어시스턴트 차례를 여는 토큰까지 붙여 준다. 추론에서는 이것이 있어야 모델이 「이제 내가 쓸 차례」로 인식하고, 학습에서는 응답 토큰이 뒤에 이어 붙으므로 따로 붙일 필요가 없다. 학습 스크립트와 추론 스크립트에서 이 인자를 다르게 줘야 한다는 점이 자주 놓치는 자리다.
학습과 추론의 어긋남
템플릿이 학습과 추론에서 다르면 모델은 자기가 배운 적 없는 형식을 마주한다. 증상이 특징적이라 알아보기 쉽다.
| 증상 | 흔한 원인 |
|---|---|
| 응답 앞에 역할 표시가 그대로 나옴 | 추론에서 생성 프롬프트를 안 붙임 |
| 답한 뒤 사용자 차례까지 이어 씀 | 학습 데이터에 종료 토큰이 안 들어감 |
| 시스템 지시를 무시함 | 학습 때와 다른 자리에 시스템 메시지를 둠 |
| 첫 문장부터 엉뚱함 | 계열이 다른 템플릿을 갖다 씀 |
두 번째 줄이 특히 자주 나온다. 응답 끝에 종료 토큰을 붙이지 않고 학습하면 모델은 멈추는 법을 배우지 못하고, 서비스에서는 최대 길이까지 계속 쓰다가 잘린다. 대화 한 건을 토큰으로 만든 뒤 마지막 몇 개를 직접 찍어 보는 것이 가장 확실한 확인이다. 네 번째 줄은 계열이 다른 모델의 데이터를 그대로 가져다 쓸 때 나온다. 공개 데이터셋 중에는 특정 모델의 템플릿이 문자열로 박힌 것이 있어, 그것을 다른 모델에 그대로 먹이면 모르는 특수 토큰이 일반 문자로 쪼개져 들어간다. 데이터를 받으면 먼저 한 건을 펴서 어떤 템플릿으로 만들어졌는지 확인하고, 필요하면 역할 목록으로 되돌린 뒤 자기 템플릿으로 다시 펴야 한다.
특수 토큰 처리
사용자 입력에 역할 구분 토큰과 같은 문자열이 들어 있으면 어떻게 될까. 토크나이저가 그것을 특수 토큰으로 인식해 버리면 사용자가 시스템 메시지를 위조할 수 있다. 「이전 지시는 무시하라」를 시스템 역할로 끼워 넣는 식이다.
방어는 간단하다. 사용자 입력을 토큰화할 때 특수 토큰을 해석하지 않도록 설정하면 그 문자열은 그냥 일반 문자로 쪼개진다. 프레임워크가 기본으로 그렇게 두는 경우가 많지만 직접 문자열을 이어 붙여 프롬프트를 만드는 코드에서는 이 보호가 사라지므로, 템플릿 적용은 되도록 토크나이저에 맡기는 편이 안전하다.
손실 마스킹
응답에만 손실을 거는 이유
학습에서 모든 토큰에 손실을 걸면 모델은 사용자 질문을 생성하는 법도 함께 배운다. 배치의 절반이 프롬프트인 데이터에서는 학습 신호의 절반이 「사용자처럼 질문 쓰기」에 쓰이는 셈이다. 그래서 프롬프트 구간의 라벨을 손실 계산에서 제외한다.
제외한다고 해서 그 토큰이 사라지는 것은 아니다. 프롬프트는 입력으로 그대로 들어가 응답 토큰의 문맥이 되고, 다만 그 자리의 예측이 맞았는지를 따지지 않을 뿐이다. 「보기는 하되 채점하지 않는다」가 정확한 표현이다. 마스킹을 안 걸어도 학습이 되기는 한다는 점이 이 문제를 조용하게 만든다. 손실은 정상적으로 떨어지고 모델도 어느 정도 답을 하게 되는데, 같은 데이터에서 얻을 수 있는 것보다 덜 얻는다. 데이터가 넉넉하면 차이가 잘 안 보이고 1,000건 규모에서는 크게 벌어진다.
라벨 만들기
구현은 라벨 배열에서 프롬프트 구간을 무시 값으로 채우는 것이다. 파이토치의 교차 엔트로피 손실이 기본으로 무시하는 값이 −100이라 관행처럼 이 수를 쓴다.
def build_labels(tokenizer, prompt_text, response_text):
prompt_ids = tokenizer(prompt_text, add_special_tokens=False).input_ids
response_ids = tokenizer(response_text, add_special_tokens=False).input_ids
input_ids = prompt_ids + response_ids + [tokenizer.eos_token_id]
labels = [-100] * len(prompt_ids) + response_ids + [tokenizer.eos_token_id]
return {"input_ids": input_ids, "labels": labels}
경계를 문자열에서 찾으면 위험하다. 템플릿을 적용한 전체 문자열에서 응답 시작 위치를 문자 단위로 찾아 자르면, 토큰 경계와 문자 경계가 어긋나 응답 첫 토큰이 잘려 나가는 일이 생긴다. 프롬프트와 응답을 각각 토큰화해 길이로 자르는 편이 안전하다. 토크나이저에 특수 토큰을 자동으로 붙이는 옵션이 켜져 있으면 길이가 한둘 어긋나는 것도 같은 부류의 사고다. 프롬프트와 응답을 따로 토큰화할 때는 그 옵션을 꺼 두고, 필요한 특수 토큰은 직접 붙인다.
여러 차례가 있는 대화
대화가 여러 차례 오간 데이터에서는 어시스턴트 차례가 여러 번 나온다. 이때 선택지가 둘이다. 마지막 응답에만 손실을 걸거나, 모든 어시스턴트 차례에 손실을 걸거나다.
뒤쪽이 데이터를 더 효율적으로 쓴다. 차례가 다섯 번 오간 대화 한 건에서 학습 신호를 다섯 번 얻으므로 같은 데이터에서 몇 배를 뽑아낼 수 있다. 다만 앞쪽 차례의 응답 품질이 낮으면 그것까지 학습하게 되므로, 사람이 마지막 응답만 고쳐 쓴 데이터에서는 마지막 차례만 쓰는 편이 낫다. 데이터가 어떻게 만들어졌는지를 알아야 정할 수 있는 값이다. 라이브러리의 기본값에 맡기지 말고 한 번 확인할 만한 자리이기도 하다. 프레임워크마다 기본 동작이 다르고, 여러 차례 대화를 한 줄짜리 문자열로 이어 붙인 뒤 통째로 학습하는 구성이 기본인 경우도 있다.
데이터의 규모와 품질
LIMA와 InstructGPT
숫자 두 개를 나란히 놓으면 이 단계의 성격이 드러난다. InstructGPT는 사람이 쓴 SFT 데이터 약 1만 3천 건을 썼고, LIMA는 엄선한 1,000건만으로 상당한 수준의 지시 따르기를 얻었다고 보고했다.
두 수가 모두 작다는 점이 핵심이다. 사전학습이 수조 토큰인 것과 견주면 백만 분의 일 규모다. SFT가 지식이 아니라 형식을 가르치는 단계라는 앞의 설명과 맞아떨어진다. 그래서 데이터를 열 배 늘리는 것보다 품질이 낮은 것을 걷어내는 쪽이 더 자주 이긴다. 다만 1,000건으로 충분하다는 말을 그대로 가져가면 곤란하다. LIMA가 쓴 1,000건은 각 항목을 사람이 직접 고르고 다듬은 것이고, 그 정도 밀도를 유지하는 비용이 만 건을 기계로 찍어 내는 비용보다 크다. 적은 데이터로 되는 것과 적은 노력으로 되는 것은 다른 말이다.
다양성의 두 축
다양성은 막연한 말이라 두 축으로 나눠 재는 것이 낫다. 하나는 작업 분포이고 다른 하나는 길이 분포다.
작업 분포는 코딩·글쓰기·요약·수학·추출·대화 같은 갈래가 고르게 들어 있는가다. 한쪽으로 쏠린 데이터로 학습하면 다른 갈래에서 성능이 떨어지고, 심하면 모든 질문에 코드로 답하는 모델이 나온다. 길이 분포는 더 조용히 망가지는 축이다. 응답이 전부 길고 자세한 데이터로 학습하면 「1 더하기 1은?」에도 세 문단으로 답하는 모델이 된다. 한 줄이면 되는 질문의 데이터를 일부러 섞어 두어야 한다.
합성 데이터와 검수
사람이 쓴 데이터만으로 채우기는 비싸서, 강한 모델로 초안을 만들고 사람이 검수하는 방식이 표준이 됐다. 검수에서 실제로 걸러야 하는 것은 대체로 넷이다.
- 사실이 틀린 응답 — 특히 숫자와 고유명사
- 실행되지 않는 코드 — 돌려 보는 것이 유일한 확인이다
- 지시와 어긋난 응답 — 요약을 요청했는데 설명을 한 경우
- 중복 — 표현만 다른 같은 내용이 쌓이면 그 패턴이 과하게 학습된다
중복은 도구로 걸러진다. 지시 문장끼리 유사도를 재 임계 위를 묶어 대표 하나만 남기는 방식이면 충분하다. 사람 손이 가야 하는 것은 앞의 셋이고, 그중 코드 검증은 자동화가 가능하다. 검수 비용을 줄이려면 전수로 보지 말고 표본으로 불량률을 먼저 재는 편이 낫다. 백 건을 뽑아 봤더니 불량이 3퍼센트면 그대로 쓰고, 20퍼센트면 생성 프롬프트를 고쳐 다시 만드는 것이 검수보다 싸다. 그 판단을 먼저 하지 않고 전부 읽기 시작하면 며칠이 사라진다.
라이선스
합성 데이터에는 법적인 제약이 붙는다. 상용 모델의 출력으로 경쟁 모델을 학습하는 것을 금지하는 이용 약관이 흔하다. 연구용으로 공개된 데이터셋 중에도 같은 제약을 물려받은 것이 있어, 데이터셋 카드의 라이선스 항목을 확인하지 않고 가져다 쓰면 학습을 마친 뒤에 문제가 드러난다.
확인해야 할 것은 세 가지다. 출력을 만든 모델의 약관, 데이터셋 자체의 라이선스, 그리고 원문이 있는 경우 그 원문의 저작권이다. 학습 전에 데이터 출처를 표로 정리해 두면 나중에 되짚는 비용이 훨씬 싸다.
학습 설정
LoRA로 줄이기
전체 파라미터를 갱신하려면 모델 크기의 몇 배에 해당하는 메모리가 필요하다. LoRA는 원래 가중치를 얼리고 그 옆에 작은 행렬 두 개를 붙여 그 둘만 학습하는 방법이다. 갱신하는 파라미터가 1퍼센트 아래로 떨어지면서 메모리가 크게 줄고, SFT처럼 형식을 가르치는 작업에서는 전체 파인튜닝과 품질 차이가 크지 않다는 보고가 많다.
from peft import LoraConfig, get_peft_model, TaskType
from trl import SFTTrainer, SFTConfig
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=16,
lora_alpha=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
lora_dropout=0.05,
bias="none",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
sft_config = SFTConfig(
output_dir="./sft-output",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=8,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.05,
max_seq_length=2048,
bf16=True,
)
trainer = SFTTrainer(model=model, args=sft_config, train_dataset=dataset)
trainer.train()
값을 고르는 기준
랭크는 16에서 64 사이가 흔하다. 형식을 가르치는 작업이면 낮은 쪽으로 충분하고, 도메인 어휘 자체를 새로 익혀야 하면 올린다. 적용 대상은 어텐션 투영 행렬만 잡는 것이 기본이지만, FFN까지 포함하면 표현력이 늘어나는 대신 학습 파라미터가 몇 배가 된다.
학습률은 전체 파인튜닝보다 한두 자릿수 높게 잡는다. 얼린 가중치를 건드리지 않고 작은 행렬만 움직이므로 보폭을 크게 줘도 무너지지 않기 때문이다. 에폭은 두셋이 보통이고, 데이터가 1,000건 규모로 작으면 더 돌려도 되지만 그만큼 과적합을 봐야 한다. 실질 배치 크기도 함께 정해야 하는 값이다. 장치당 배치에 누적 스텝을 곱한 것이 실질 배치이고, 이 값이 작으면 손실이 요동쳐 언제 멈출지 판단하기 어려워진다. 메모리가 모자라 장치당 배치를 줄여야 한다면 누적 스텝을 그만큼 올려 실질 배치를 유지한다.
과적합 징후
SFT의 과적합은 손실 곡선보다 출력에서 먼저 보인다. 학습 데이터에 있던 문장이 그대로 나오거나, 응답 구조가 데이터의 특정 형식에 딱 붙어 질문이 달라져도 같은 틀로 답하는 식이다. 보류 세트의 손실이 오르기 시작하는 지점보다 이런 증상이 먼저 나타나는 경우가 많으므로, 에폭마다 같은 질문 몇 개를 넣어 응답을 눈으로 비교해 두면 시점을 잡기 쉽다.
SFT가 남기는 부작용
장황함
SFT를 거친 모델은 대체로 말이 길어진다. 데이터를 만들 때 자세한 응답이 좋은 응답으로 보이기 때문이고, 사람 평가자도 긴 응답에 후한 점수를 주는 경향이 있어 이 편향이 데이터에 스며든다.
장황함은 비용에 직접 들어온다. 응답 토큰이 두 배면 단가도 지연도 두 배다. 데이터의 길이 분포를 손보는 것이 가장 직접적인 대응이고, 시스템 프롬프트로 누르는 것은 그다음이다. 평가에서도 이 편향을 염두에 둬야 한다. 응답이 길어져 점수가 올랐는데 내용은 그대로인 경우가 있어, 승률과 함께 평균 응답 길이를 기록해 두지 않으면 개선과 팽창을 구분할 수 없다.
정형 문구
같은 도입부와 같은 마무리가 반복되는 현상도 흔하다. 「물론입니다」로 시작하고 「도움이 되셨길 바랍니다」로 끝나는 식이다. 데이터에 그 패턴이 많으면 모델이 그것을 응답의 필수 요소로 배운다.
걸러 내는 방법은 단순하다. 데이터의 응답 첫 문장과 마지막 문장을 모아 빈도를 세면 상위 몇 개가 바로 보인다. 같은 문장이 전체의 몇 퍼센트를 넘으면 그만큼 덜어 낸다. 완전히 없애는 것이 목표는 아니다. 일관된 말투는 제품의 성격이기도 해서, 문제는 반복 자체가 아니라 반복이 내용과 무관하게 붙는 것이다.
거절 과잉
안전한 응답을 가르치려고 거절 예시를 넣다 보면 모델이 거절 쪽으로 기운다. 「폭발물 만드는 법」을 거절하도록 배운 모델이 「화산 폭발의 원리」까지 거절하는 식이다. 표면적인 낱말만 보고 판단하게 되어 벌어지는 일이다.
대응은 경계에 있는 예시를 일부러 넣는 것이다. 거절해야 하는 요청과 비슷해 보이지만 답해야 하는 요청을 짝으로 만들어 넣으면, 낱말이 아니라 의도를 보고 가르는 쪽으로 학습이 기운다. 거절 데이터만 늘리는 것은 대개 이 문제를 키운다. 얼마나 기울었는지를 재려면 답해야 하는 민감 주변 질문을 모은 세트가 따로 있어야 한다. 거절률을 안전 지표로만 보면 높을수록 좋아 보이지만, 그 세트에서의 거절률은 낮을수록 좋은 값이라 두 숫자를 나란히 봐야 판단이 선다.
끝났다는 판단
보류 세트
학습에 쓰지 않은 지시 묶음을 따로 떼어 두고 매 체크포인트에서 응답을 받는다. 중요한 것은 이 묶음이 학습 데이터와 같은 곳에서 나오면 안 된다는 점이다. 같은 생성 절차로 만든 데이터를 나눠 쓰면 분포가 같아서 잘 나오는 것이 당연하고, 실제 사용자 질문에서는 그대로 무너진다.
가능하면 실제 서비스에서 들어온 질문을 모아 쓰는 것이 낫다. 초기에 그런 로그가 없다면 팀에서 손으로 몇십 건을 써 두는 것만으로도 합성 세트보다 낫다. 세트를 한 번 정했으면 그대로 두고 오래 쓴다. 중간에 항목을 바꾸면 이전 체크포인트와 비교할 수 없게 되고, 그 순간부터 「좋아졌다」는 말의 근거가 사라진다.
LLM-as-Judge의 한계
응답 둘을 강한 모델에 보여 주고 나은 쪽을 고르게 하는 방식이 표준 도구가 됐다. 사람 평가와 상관이 높고 값이 싸다는 것이 이유인데, 알려진 편향이 있어 그대로 믿으면 안 된다.
- 위치 편향 — 앞에 놓인 응답을 더 자주 고른다. 순서를 뒤집어 두 번 물어 일치할 때만 채택하는 식으로 누른다
- 길이 편향 — 긴 응답을 더 자주 고른다. 길이를 맞춰 비교하거나 길이를 함께 기록해 둔다
- 자기 편향 — 같은 계열 모델의 출력을 더 높게 본다. 평가 모델을 학습에 쓴 모델과 다른 계열로 고른다
승률을 읽는 법
기준 모델 대비 승률로 보고하는 것이 관행이다. 숫자 하나로 요약되어 편하지만, 이 값만 보면 앞의 편향이 그대로 녹아 들어온다. 최소한 작업 갈래별로 나눠 보는 것이 좋다. 전체 승률은 올랐는데 코딩만 떨어진 경우가 흔하고, 그 사실은 평균에서 드러나지 않는다.
그리고 승률이 올랐다는 것이 곧 잘 쓰인다는 뜻은 아니다. SFT가 끝났다고 판단하는 실질적인 기준은 형식이 자리 잡았는가에 가깝다. 지시한 형식대로 답하는가, 멈춰야 할 자리에서 멈추는가, 모르는 것을 모른다고 하는가다. 그 이상의 미세한 선호는 다음 단계의 몫이다.
다음 글에서는 SFT를 마친 모델에 비교 신호를 더하는 선호 정렬로 넘어간다.
읽어주셔서 감사합니다. 😊

