제3부 사후학습·정렬(Post-training/Alignment)
3.2 SFT — 지도 미세조정
베이스 모델에게 "다음 문단을 세 줄로 요약해줘"라고 넣어 보면, 요약 대신 잘못된 것이 나온다. 대개는 원문을 조금 더 이어 쓰거나, "그리고 다음 질문도 만들어 줘"처럼 지시문 자체를 연장해 버린다. 고장이 아니다. 베이스 모델은 웹 텍스트의 다음 토큰을 잇도록 학습됐고(2.3), 인터넷에서 "요약해줘"라는 문장 뒤에 실제 요약이 오는 경우는 생각보다 드물다 — FAQ 페이지면 다음 질문이, 시험지면 다른 문제가, 게시판이면 조롱 섞인 딴소리가 이어진다. 베이스 모델은 그 모든 "그럴듯한 이어짐"의 평균이지 "요청에 응답하는 조수"가 아니다.
지도 미세조정(Supervised Fine-Tuning, SFT) — 지시 조정(instruction tuning)이라고도 한다 — 은 이 간극을 메운다. 방법은 놀랄 만큼 단순하다. "이렇게 물으면 이렇게 답한다"를 사람(또는 더 나은 모델)이 시연한 지시-응답 쌍을 잔뜩 모아, 베이스 모델에게 그 응답을 그대로 흉내 내도록 다음 토큰 예측으로 학습시킨다. 새 손실함수도, 강화학습도 아니다. 사전학습과 글자 그대로 같은 목적함수를 쓴다. 갈라지는 것은 단 두 가지 — 무엇을 투입하느냐(데이터)와, 그중 무엇의 예측에 벌점을 매기느냐(손실 마스킹)다. 이 장은 그 두 가지가 어떻게 "이어 쓰는 엔진"을 "지시를 따르는 조수"로 바꾸는지, 그리고 그 과정에서 무엇이 조용히 망가지는지를 다룬다.
강화학습 용어로 이것은 행동 복제(behavior cloning)다 — 전문가의 시연을 지도학습으로 모방하는 것. 이 프레임을 쥐고 있으면 이 장의 거의 모든 함정이 한 문장에서 파생된다는 것을 알게 된다. 모델은 시연이 보여 준 행동을 배우지, 시연이 의도한 지식을 배우는 것이 아니다.
3.2.1 사전학습과 무엇이 같고 무엇이 다른가
베이스 모델은 코퍼스의 조건부 분포 $p_\theta(x_t \mid x_{<t})$ 를 학습했다(1.1·2.3). 이 분포는 "지시 → 도움되는 응답"이라는 좁은 모드가 아니라, 웹에 존재하는 온갖 이어짐의 혼합에 확률질량을 분산시켜 놓는다. 정렬의 첫 과제는 이 질량을 옮기는 것이다 — 없던 능력을 새로 심는 것이 아니라, 이미 그 안에 있는 "질문에 답하는" 이어짐 쪽으로 분포를 이동시킨다. SFT가 하는 일이 정확히 이것이고, 그래서 목적함수가 사전학습과 같을 수밖에 없다. 우리는 모델이 아는 새로운 방식으로 무언가를 하게 만들려는 것이 아니라, 이미 아는 것 중 특정한 것을 훨씬 자주 하게 만들려는 것이다.
사전학습과 SFT를 나란히 놓으면 무엇이 고정이고 무엇이 바뀌는지가 선명해진다.
| 사전학습(2.3) | SFT(이 장) | |
|---|---|---|
| 목적함수 | 다음 토큰 예측(인과 LM 교차엔트로피) | 동일 |
| 데이터 | 웹·책·코드 등 원시 텍스트(수조 토큰) | 지시-응답 시연 쌍(수천~수백만) |
| 포맷 | 자유 텍스트 | 채팅 템플릿(역할·특수 토큰) |
| 손실 대상 | 모든 토큰 | 응답 토큰만(프롬프트는 마스킹) |
| 규모·비용 | 수천 GPU·수주, 막대함 | 수십 GPU·수시간~수일 규모 |
| 심는 것 | 지식·언어·세계 모형 | "지시를 받으면 응답한다"는 행동·형식 |
여기서 가장 오해받는 칸이 맨 아랫줄이다. SFT는 새 지식을 주입하는 단계가 아니다. 대부분의 지식은 이미 사전학습에 들어 있고, SFT는 그 지식을 "지시에 맞춰 꺼내 쓰는 인터페이스"를 붙이는 얇은 층에 가깝다. 이 관점 — 뒤에서 표면적 정렬 가설로 다시 만난다 — 을 받아들이면 이 장의 여러 사실이 한꺼번에 설명된다. 왜 고작 수천 개 데이터로 형식이 형성되는지, 왜 SFT로 모델이 모르는 지식을 억지로 가르치면 환각이 늘 수 있는지, 왜 SFT를 오래 수행하면 사전학습 능력이 퇴화하는지가 전부 "SFT는 지식이 아니라 행동을 옮긴다"는 한 실에서 갈라진다.
3.2.2 정렬 파이프라인에서 SFT의 자리
SFT는 보통 혼자 서지 않는다. 사전학습 위에 추가되어 "지시 따르기"의 바탕을 마련하고, 그 위에 선호 최적화(RLHF·DPO)가 추가되어 "여러 그럴듯한 응답 중 사람이 더 선호하는 쪽"으로 다듬는다.
순서가 왜 이렇게 굳었는지는 선호 최적화가 무엇을 요구하는지를 보면 나온다. RLHF든 DPO든 "그럴듯한 후보 여러 개"를 놓고 우열을 가리는 방법인데, 베이스 모델은 애초에 지시에 맞는 후보를 잘 못 낸다. 요약을 시켰는데 절반이 딴소리면 "이 요약이 저 요약보다 낫다"를 물을 대상 자체가 없다. SFT가 먼저 후보 분포를 "지시 따르기" 영역으로 옮겨 놓아야 선호 학습이 딛고 설 발판이 생긴다. 그래서 대개 SFT → 선호 최적화 순서이고, 선호 단계 없이 곧장 RLHF를 시도하면 학습이 잘 되지 않는다.
거꾸로, SFT만으로 배포하는 경우도 흔하다. 선호 데이터와 보상 모델을 구축하는 비용이 만만찮고, 도메인이 좁아 "형식·톤만 맞으면 되는" 응용에서는 SFT 단독으로도 쓸 만한 조수가 나온다. 다만 미묘한 유용성·안전·톤의 최적화 — "같은 뜻이라도 이렇게 말하는 편이 낫다" 같은 순위 인식 — 은 선호 단계가 맡는 몫이라, SFT만 실행한 모델은 대체로 딱딱하거나 과하게 장황한 티가 남는다는 점은 기억할 값어치가 있다.
3.2.3 채팅 템플릿·역할·특수 토큰
SFT 데이터는 "질문 텍스트 + 답변 텍스트"를 단순히 이어 붙인 것이 아니다. 누가 말하는가(역할)와 턴이 어디서 끊기는가(경계)를 모델이 토큰 수준에서 알아보도록, 약속된 특수 토큰으로 감싼 채팅 템플릿(chat template)으로 렌더링한다. 1.2.11에서 개관한 것을 여기서는 학습이라는 각도에서 다룬다.
대화형 모델은 메시지를 보통 세 역할로 구조화한다. system은 대화 전체에 걸친 지시·페르소나·제약이다("너는 친절한 한국어 도우미다. 모르면 모른다고 답한다"). 사용자에게는 안 보이지만 어시스턴트의 행동을 규정하며, 제품에서 이 시스템 프롬프트를 설계하는 문제는 7.1에서 따로 다룬다. user는 사용자의 발화·질문·요청이고, assistant는 모델이 생성해야 하는 응답이다 — 그리고 SFT에서 손실을 적용하는 대상이 정확히 이 assistant 부분이다. 일부 포맷은 도구 호출과 그 결과를 담는 tool·function 역할을 더 두지만(6.5), 지금은 세 역할에 집중한다.
역할과 경계는 어휘에 예약된 특수 토큰으로 표시한다. 형식은 모델마다 완전히 다르다 — 아래는 널리 알려진 ChatML 계열의 형태를 보여 주려는 것이지 특정 모델의 실제 토큰이 아니다.
<|im_start|>system
너는 친절한 한국어 도우미다. 모르면 모른다고 답한다.<|im_end|>
<|im_start|>user
토큰이 뭐야?<|im_end|>
<|im_start|>assistant
토큰은 모델이 텍스트를 처리하는 최소 단위입니다. …<|im_end|>
여기서 <|im_start|>·<|im_end|>는 턴의 시작·끝을 나타내는 단일 특수 토큰이고, 그 뒤 system·user·assistant가 역할 이름이다. 다른 계열은 [INST] … [/INST], <<SYS>>, 혹은 <|start_header_id|>…<|end_header_id|>·<|eot_id|>(end-of-turn) 같은 전혀 다른 마킹을 쓴다. 공통점은 형식이 아니라 목적이다 — 모델이 "여기서 사용자 말이 끝나고 내가 답할 차례다"를 배울 수 있게 하는 것. 이 토큰들이 왜 반드시 단일 특수 토큰이어야 하는지는 1.2.11에서 본 그대로다. <|im_end|>를 일반 문자열로 넣으면 토크나이저가 이를 <, |, im, _end, |, > 같은 여러 조각으로 분할해, 모델이 "턴 종료"라는 하나의 신호 대신 의미 없는 문자 나열을 보게 된다.
특히 턴 종료 토큰(<|im_end|>·<|eot_id|> 등)이 중요하다. SFT에서 이 토큰까지 응답에 포함해 손실을 적용해야, 추론 때 모델이 "답을 마치고 멈추는 법"을 배운다. 이 토큰을 응답에서 빼먹으면 모델이 답을 끝없이 이어 쓰거나, 심하면 자기가 사용자 턴까지 지어내는 역할 누수(role leakage)가 난다 — 답을 하고 나서 "사용자: 그럼 이건요?"를 스스로 생성해 버리는 사고다.
3.2.4 왜 학습·추론의 템플릿이 정확히 같아야 하나
모델은 학습 때 본 정확한 토큰 배열에 조건화되어 응답을 생성한다. 그래서 추론 때도 학습과 완전히 동일한 템플릿으로 입력을 렌더링해야 제 성능이 난다. 이것이 1.2.15에서 예고한 "조용한 버그"의 정체이고, SFT를 처음 수행하는 팀이 가장 자주 빠지는 함정이다.
불일치가 위험한 이유는 예외를 던지지 않기 때문이다. 모델은 낯선 포맷에도 그럭저럭 뭔가를 생성하므로, 증상은 "왠지 답이 예전만 못하다"는 애매한 품질 저하로만 나타난다. 크래시가 나면 스택 트레이스라도 있지만, 이것은 조용히 몇 퍼센트씩 성능을 잠식하며 원인 추적을 어렵게 한다. 전형적 원인은 세 갈래다. 첫째, 특수 토큰 누락·오타 — <|im_end|>를 빼먹거나 앞서 말한 대로 일반 텍스트로 넣어 여러 토큰으로 분할되는 경우. 둘째, 역할 이름·순서·선행 공백의 차이 — 헤더 뒤 개행 하나, 앞 공백 하나가 분절을 바꾼다(1.2.8에서 본 "보이지 않는 경계"가 여기서 문제가 된다). 셋째, system 프롬프트의 유무·위치가 학습과 다른 경우.
해결책은 규율 하나로 요약된다. 문자열을 수작업으로 조립하지 말고 토크나이저의 템플릿 함수를 써라. Hugging Face transformers는 채팅 템플릿을 토크나이저에 Jinja로 실어 두므로, 학습·추론 양쪽에서 같은 함수로 동일한 렌더링을 얻는다.
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("some-org/some-instruct-model") # 실제 모델 id로
messages = [
{"role": "system", "content": "너는 친절한 한국어 도우미다."},
{"role": "user", "content": "토큰이 뭐야?"},
]
# 추론용: 마지막에 "이제 assistant가 말할 차례"를 여는 토큰까지 붙인다
prompt_text = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
prompt_ids = tok.apply_chat_template(messages, add_generation_prompt=True) # 토큰 ID
add_generation_prompt=True는 끝에 어시스턴트 헤더(예: <|im_start|>assistant\n)까지 붙여, 모델이 그 뒤부터 이어 생성하게 한다. 학습 때는 이 헤더 뒤에 실제 응답과 종료 토큰이 이어지고, 추론 때는 바로 그 자리를 모델이 채운다. 학습과 추론이 정확히 같은 이음매에서 만나야 하는 이유가 이것이다 — 모델이 본 "헤더 다음"의 통계와 실제로 이어 쓰는 "헤더 다음"의 문맥이 어긋나면, 모델은 익숙하지 않은 자리에서 생성을 시작하는 셈이 된다.
파인튜닝으로 새 특수 토큰을 도입할 때는 함정이 하나 더 있다. 커스텀 역할이나 도구 토큰을 추가하면 임베딩 행렬과 LM 헤드를 함께 리사이즈하고(model.resize_token_embeddings(len(tok)), 1.2.12) 새로 생긴 행을 학습시켜야 한다. 안 그러면 새 토큰 ID가 임베딩 행렬의 범위를 벗어나 인덱싱에서 실패하거나, 리사이즈만 하고 학습을 안 하면 초기화된 난수 임베딩이 붙어 그 토큰만 나오면 출력이 붕괴한다.
3.2.5 손실 마스킹 — 왜 프롬프트를 배우지 않는가
SFT의 가장 특징적인 기법이자, 사전학습과 갈라지는 두 번째 축이다. 렌더링된 대화 전체를 모델에 넣되, 손실은 assistant 응답 토큰에만 적용한다. system·user 프롬프트 토큰과 템플릿 특수 토큰(어시스턴트 헤더까지)은 손실에서 제외한다. 이유는 명료하다. 우리가 가르치려는 것은 "응답을 생성하는 능력"이지 "질문을 생성하는 능력"이 아니다.
세 가지 근거가 서로를 받친다. 첫째는 목표 불일치다. 프롬프트(사용자 질문·시스템 지시)는 추론 때 입력으로 주어지는 조건이지 모델이 만들어 낼 대상이 아니다. 그 토큰들의 예측에 손실을 적용하면 모델의 학습 신호가 "사용자가 무슨 질문을 할지 맞히기"라는 잘못된 과제로 벗어난다 — 그리고 이 과제는 원리상 거의 예측 불가능하다(사용자가 다음에 무엇을 물을지는 문맥으로 좁혀지지 않는다). 둘째는 분포 오염이다. 사용자 프롬프트는 형식·주제·말투가 제각각이라, 특히 사람이 쓴 데이터에서 그 토큰을 학습시키면 모델이 "사용자 말투"까지 복제해 응답의 톤이 변동한다. 셋째는 신호 집중이다. 한 샘플에서 응답이 프롬프트보다 짧은 경우가 많은데, 응답에만 손실을 몰아주면 학습 신호가 우리가 원하는 행동에 집중된다.
이 기법을 completion-only 손실 또는 프롬프트 마스킹이라 부른다. 손실을 프롬프트까지 적용하는 변형("full-text" 학습)도 있고 — 데이터가 매우 짧거나 프롬프트 스타일까지 학습시킬 이유가 있을 때 — 실제로 아주 짧은 응답이 많은 데이터셋에서는 프롬프트에도 약한 손실을 적용하면 학습이 안정된다는 관찰도 있다. 하지만 instruction tuning의 표준은 응답만 적용하는 쪽이고, 특별한 이유가 없으면 이 표준을 따르는 것이 맞다.
한 가지 혼동하기 쉬운 것을 명시해 두자. 손실 마스크와 어텐션 마스크는 완전히 다른 것이다. 어텐션 마스크는 "무엇을 문맥으로 볼 수 있나"를 정하고(1.4.10의 인과 마스킹), 손실 마스크는 "무엇의 예측에 벌점을 줄까"를 정한다. 프롬프트는 손실에서는 빠지지만 어텐션에서는 여전히 전부 보인다 — 응답을 생성하려면 질문을 봐야 하니 당연하다. 이 둘을 혼동해 "프롬프트를 안 보이게 한다"고 구현하면 모델이 무엇에 답하는지도 모른 채 답을 지어내는, 전혀 다른 고장으로 전이된다.
3.2.6 손실 마스킹 — 어떻게, -100으로 라벨을 가린다
구현은 의외로 간단하다. 모델은 인과 LM이라 위치 $t$ 에서 다음 토큰을 예측하고(2.3), 손실은 토큰별 교차엔트로피의 평균이다. 여기서 PyTorch CrossEntropyLoss의 ignore_index(관례상 -100)를 쓴다 — 라벨이 -100인 위치는 손실 계산에서 전부 건너뛴다. 그래서 두 텐서를 이렇게 만든다. input_ids에는 렌더링된 대화 전체(프롬프트+응답)를 담는다. 모델은 이 전부를 문맥으로 본다. labels는 input_ids와 같은 길이인데, 프롬프트 구간의 라벨을 전부 -100으로 덮고 응답 구간(+종료 토큰)만 실제 토큰 ID를 남긴다.
$$ \mathcal{L}_{\text{SFT}} = -\frac{1}{|R|}\sum_{t \in R} \log p_\theta\!\left(x_{t} \mid x_{<t}\right), \qquad R = \{\text{응답 토큰 위치들}\} $$
합이 응답 위치 집합 $R$ 에 대해서만 실행되고, 분모 $|R|$ 도 응답 토큰 수라는 점이 핵심이다. 프롬프트가 아무리 길어도 손실의 크기와 그래디언트 방향은 응답 토큰만이 정한다.
여기서 자주 놓치는 사실 하나. 손실 마스킹은 연산을 절약하지 않는다. 순전파와 역전파는 프롬프트 토큰을 포함한 전체 시퀀스에 그대로 흐르고, 마스킹은 마지막 손실 합산에서 프롬프트 위치를 빼는 것뿐이다. 그래서 긴 프롬프트는 학습 신호는 하나도 안 주면서 연산 비용은 온전히 청구한다 — 이 사실이 3.2.8의 다중 턴 토큰 회계에서 실제 비용으로 돌아온다.
경계를 어디서 자르느냐는 미묘하지만 결정적이다. 어시스턴트 헤더 토큰(<|im_start|>assistant\n)은 보통 프롬프트 쪽으로 넣어 마스킹한다 — 추론 때 이 헤더는 우리가 붙여 주지 모델이 생성하는 것이 아니기 때문이다. 응답 본문의 첫 토큰부터 손실을 켜고, 종료 토큰은 응답 쪽에 넣어 손실을 켠다(멈추는 법 학습). 헤더나 역할 이름 토큰을 실수로 응답에 포함하면, 모델이 매 응답 앞에 역할 표식을 출력하는 이상 행동을 배운다. 최신 transformers는 apply_chat_template(..., return_assistant_tokens_mask=True)로 어시스턴트 토큰 마스크를 직접 돌려주기도 하고(템플릿이 {% generation %} 블록을 지원할 때), TRL의 DataCollatorForCompletionOnlyLM 같은 유틸은 응답 시작 마커를 찾아 자동으로 마스킹한다. 경계 계산은 가능하면 이런 검증된 도구에 맡기는 편이 수작업으로 인덱스를 세는 것보다 안전하다.
3.2.7 한 샘플의 손실을 직접 계산하기
지금까지의 조각을 한 샘플로 조립해 실제 숫자를 내 보자. 원리를 드러내려 라벨을 수동으로 만드는 코드다. 실무에서는 앞 절의 유틸을 쓰는 편이 안전하지만, 무슨 일이 벌어지는지는 한 번 직접 따라가야 이해된다.
from transformers import AutoTokenizer
import torch
tok = AutoTokenizer.from_pretrained("some-org/some-instruct-model") # 실제 모델 id로
# 1) 한 학습 샘플: 프롬프트(메시지들)와 정답 응답을 분리해 둔다
messages = [
{"role": "system", "content": "너는 친절한 한국어 도우미다."},
{"role": "user", "content": "토큰이 뭐야?"},
]
answer = "토큰은 모델이 텍스트를 처리하는 최소 단위입니다."
# 2) 프롬프트만 렌더(어시스턴트 헤더까지) → 이 길이가 마스킹 경계
prompt_ids = tok.apply_chat_template(messages, add_generation_prompt=True)
# 3) 프롬프트+응답 전체를 렌더(종료 토큰이 붙도록 assistant 턴을 추가)
full = messages + [{"role": "assistant", "content": answer}]
input_ids = tok.apply_chat_template(full, add_generation_prompt=False)
# 4) 라벨: 전체를 복사한 뒤 프롬프트 구간을 -100으로 가린다
labels = list(input_ids)
for i in range(len(prompt_ids)):
labels[i] = -100 # 프롬프트(헤더 포함) → 손실 제외
input_ids = torch.tensor([input_ids]) # (1, T)
labels = torch.tensor([labels]) # (1, T)
# 5) 모델이 내부에서 로짓을 한 칸 시프트해 labels와 교차엔트로피.
# labels == -100 위치는 자동으로 건너뛴다.
out = model(input_ids=input_ids, labels=labels)
loss = out.loss # 응답 토큰에 대해서만 평균된 교차엔트로피
시프트를 확인하고 넘어가자. 인과 LM은 위치 $t$ 의 로짓으로 $t{+}1$ 토큰을 예측하므로, 손실을 계산하려면 로짓과 라벨을 한 칸 어긋나게 정렬해야 한다. Hugging Face 모델은 labels를 넘기면 내부에서 logits[:, :-1] 과 labels[:, 1:] 을 맞춰 주므로, 우리는 라벨을 직접 시프트할 필요 없이 위치만 마스킹하면 된다. 직접 손실을 구현할 때는 이 시프트를 수작업으로 맞춰야 하는데, 여기서 한 칸을 놓치면 "직전 토큰을 그대로 복사"하는 자명한 해에 모델이 수렴하며 손실만 이상하게 낮아진다.
이제 손실을 실제 숫자로 내 보자. 이 샘플이 렌더링 뒤 21개 토큰이라 하자 — 프롬프트(시스템·유저·헤더) 12개, 응답 9개(본문 8개 + 종료 토큰 1개). 교사 강요(teacher forcing) 아래, 모델이 각 응답 토큰의 정답에 매긴 확률이 아래와 같다고 두자(설명용 값).
$$p = [\,0.62,\; 0.90,\; 0.35,\; 0.80,\; 0.55,\; 0.95,\; 0.70,\; 0.88,\; 0.40\,]$$
토큰별 손실 $-\ln p$ 는
$$-\ln p = [\,0.478,\; 0.105,\; 1.050,\; 0.223,\; 0.598,\; 0.051,\; 0.357,\; 0.128,\; 0.916\,]$$
이고, 이들의 합은 $3.906$ 이다. 응답 토큰 수 $|R| = 9$ 로 나누면
$$\mathcal{L}_{\text{SFT}} = \frac{3.906}{9} \approx 0.434 \ \text{nats} \quad(\text{퍼플렉서티 } e^{0.434} \approx 1.54)$$
이 한 샘플이 주는 신호는 "이 프롬프트가 주어졌을 때 이 응답 토큰들을(그리고 멈춤을) 높은 확률로 내라"이다. 세 번째 토큰($-\ln 0.35 = 1.05$)과 마지막 종료 토큰($-\ln 0.40 = 0.92$)에서 손실이 크니, 그래디언트는 이 둘의 확률을 높이는 방향으로 가장 세게 밀 것이다.
이제 마스킹을 빼먹으면 무슨 일이 벌어지는지를 같은 숫자로 보자. 프롬프트 12개 토큰까지 손실에 넣었다고 하자. 사용자 질문은 문맥으로 좁혀지지 않아 모델이 잘 못 맞히므로, 프롬프트 토큰당 손실이 평균 $3.5$ nats(퍼플렉서티 약 33) 정도로 응답보다 훨씬 크다. 그러면 전체 손실은
$$\mathcal{L}_{\text{full}} = \frac{3.906 + 12 \times 3.5}{21} = \frac{3.906 + 42}{21} \approx 2.19 \ \text{nats}$$
가 되고, 손실 질량의 $42/45.9 \approx 91\%$ 가 우리가 신경 쓰지 않는 프롬프트 토큰에서 나온다. 그래디언트가 압도적으로 "사용자가 무슨 질문을 할지 맞히기"에 쏠린다는 뜻이다 — 예측 불가능한 과제라 배워도 소용없고, 정작 배우고 싶은 응답 신호는 9%로 묻힌다. 손실 마스킹이 왜 선택이 아니라 필수인지가 이 한 계산에 담겨 있다.
3.2.8 다중 턴 대화와 토큰 회계
실제 대화는 한 번의 질문-답변이 아니라 여러 턴이 교환된다. 다중 턴 샘플은 [system, user₁, assistant₁, user₂, assistant₂, …] 로 렌더링하고, 모든 assistant 턴에 손실을 적용한다 — user 턴들은 몇 번째든 여전히 마스킹한다.
이렇게 하면 한 대화에서 여러 개의 학습 신호가 나오고(assistant 턴마다 하나씩), 모델은 누적된 대화 문맥을 조건으로 답하는 법을 배운다 — 앞 턴을 기억해 이어가는 능력의 원천이다. 인과성 덕에 이것이 추가 비용 없이 성립한다. assistant₂를 학습할 때 모델이 보는 문맥은 앞의 sys·user₁·assistant₁·user₂뿐이고, 인과 마스킹(1.4.10)이 미래 턴을 자동으로 가린다. 한 시퀀스에 여러 턴을 담아도 각 응답은 자기 과거만 조건으로 학습된다. 다중 턴을 굳이 턴별로 분할해 여러 개의 단일 턴 샘플로 펼치는 방법도 있지만 — 각 assistant 턴을 타깃으로, 그 앞 전체를 프롬프트로 — 같은 프롬프트 접두를 여러 번 재계산하므로 연산이 중복된다. 한 시퀀스에 담아 모든 assistant 턴에 손실을 적용하는 편이 효율적이다.
여기서 3.2.6의 "마스킹은 연산을 아끼지 않는다"가 실제 비용으로 돌아온다. 4턴 대화 하나를 놓고 토큰을 세어 보자. 시스템 프롬프트 40토큰, user 턴 각 25토큰, assistant 턴 각 70토큰(+종료 토큰 1개 = 71)이라 하면, 총 토큰은
$$40 + 4\times 25 + 4\times 71 = 40 + 100 + 284 = 424 \ \text{토큰}$$
이고, 손실을 지는 것은 assistant 턴뿐이라 $4\times 71 = 284$ 토큰, 즉 전체의 $284/424 \approx 67\%$ 다. 나머지 33%는 문맥으로만 쓰이며 연산은 다 소모하고 학습 신호는 0이다. 그런데 시스템 프롬프트를 400토큰 길이의 상세 페르소나로 바꾸면 어떻게 될까.
$$400 + 100 + 284 = 784 \ \text{토큰}, \qquad \text{손실 비율} = \frac{284}{784} \approx 36\%$$
같은 학습 신호(284개 응답 토큰)를 얻는 데 순전파·역전파가 처리하는 토큰이 424개에서 784개로 거의 두 배가 됐다. 긴 시스템 프롬프트를 매 샘플에 반복하면 연산의 절반 이상이 아무것도 안 가르치는 토큰에 쓰인다는 뜻이다. 그래서 SFT 단계에서는 시스템 프롬프트를 짧게 유지하거나, 여러 짧은 대화를 한 시퀀스로 묶어(다음 절) 반복 오버헤드를 줄이는 것이 실질적인 비용 절감이 된다.
3.2.9 시퀀스 패킹과 문서 경계
SFT 샘플은 길이가 제각각(짧은 QA부터 긴 대화까지)이라, 고정 길이 배치에 단순히 채우면 패딩 토큰으로 낭비가 크다. 512 길이 배치에 평균 120토큰 샘플을 하나씩 넣으면 4분의 3이 패딩이다. 2.3.3에서 본 시퀀스 패킹 — 여러 짧은 샘플을 하나의 긴 시퀀스로 이어 붙여 컨텍스트 창을 꽉 채우는 기법 — 을 SFT에도 쓴다.
여기 함정이 있다. 단순히 이어 붙이면 인과 어텐션 때문에 뒤 샘플이 앞 샘플을 문맥으로 보게 되어(교차 오염), 모델이 "무관한 앞 대화"에 조건화되는 잘못을 배운다. 대응은 두 가지다. 하나는 문서 간 어텐션 차단(intra-document / block-diagonal masking)으로, 패킹된 시퀀스 안에서 각 샘플이 자기 경계 안만 보도록 어텐션 마스크를 블록 대각으로 준다 — 2.3.3.1의 문서 경계 오염 대응과 같은 원리이고, FlashAttention의 가변 길이(varlen) 경로가 이를 효율적으로 지원한다. 다른 하나는 샘플 사이에 종료 토큰(EOS)을 넣어 최소한 "여기서 하나가 끝났다"는 신호를 주는 것인데, 이것만으로는 어텐션 차단만큼 깨끗하지 않다(어텐션은 여전히 경계 너머를 볼 수 있다).
| 방식 | 패딩 낭비 | 교차 오염 위험 | 구현 부담 |
|---|---|---|---|
| 패딩만(패킹 안 함) | 큼 | 없음 | 낮음 |
| 순진한 패킹(경계 무시) | 없음 | 큼(뒤가 앞을 봄) | 낮음 |
| 패킹 + 문서 간 어텐션 차단 | 없음 | 없음 | 중간(varlen 커널 필요) |
패킹해도 손실 마스킹은 그대로다 — 각 샘플의 프롬프트는 마스킹하고 응답에만 손실을 적용한다. 패킹은 어디까지나 패딩을 없애는 연산 효율의 문제이고, 무엇에 손실을 적용하는가와는 독립이다. 두 마스크(손실 마스크와 블록 대각 어텐션 마스크)가 같은 패킹된 시퀀스 위에 동시에 추가된다고 생각하면 관계가 정리된다.
3.2.10 데이터가 성패를 가른다 — 품질 대 양
SFT에서 결과를 가장 크게 가르는 것은 모델도 하이퍼파라미터도 아니고 데이터셋의 구성이다. 이것은 이 분야의 반복된 경험이다. 그런데 "얼마나 많이 대 얼마나 좋게"를 두고 유명한 긴장이 있다.
한쪽 극단이 LIMA다("Less Is More for Alignment", Zhou et al. 2023). 이 연구는 엄선한 1,000개 예시만으로 SFT해도 강한 지시 따르기 성능이 난다고 보고했고, 그 배경 논리가 표면적 정렬 가설(Superficial Alignment Hypothesis)이다 — 모델의 지식·능력은 거의 전부 사전학습에서 습득되고, 정렬(SFT)은 어떤 형식·톤·하위분포로 응답할지를 고르는 얇은 층에 가깝다는 것. 이것이 사실이라면 함의가 크다. 수십만 개의 잡다한 데이터보다 잘 만든 수천 개가 낫다는 뜻이고, 3.2.1에서 "SFT는 지식 주입이 아니다"라고 한 것과 같은 이야기다.
하지만 이 주장을 "적은 데이터가 항상 낫다"로 확장하면 틀린다. LIMA의 결과는 특정 베이스 모델과 특정 평가(사람 선호 비교) 조건에서 얻어진 것이고, 이후의 관찰들은 반대 방향도 만만찮음을 보여 준다. 복잡한 추론, 코딩, 도구 사용, 다양한 언어, 안전 같은 능력은 SFT 데이터의 양과 다양성에서 실질적으로 이득을 본다 — 대규모 instruction 컬렉션이 미답 과제(unseen task)로의 일반화를 높였다는 계열의 연구가 그 반례다. 정직하게 말하면 두 관찰은 모순이 아니라 서로 다른 국면을 본 것이다. 좁은 형식·톤을 정하는 데는 소수의 고품질이 충분하고, 넓은 과제 커버리지와 미답 과제 일반화를 원하면 다양성이 필요하며 다양성은 규모를 요구한다. LIMA가 깨는 통념은 "무조건 많으면 좋다"이지, "적을수록 좋다"가 새 법칙인 것은 아니다.
그래서 "고품질"을 축으로 분해해야 실무가 된다. 정확성은 응답이 사실에 맞고 지시를 실제로 이행하는가다 — 틀린 시연은 틀린 행동을 직접 가르친다(행동 복제의 필연). 다양성은 과제 유형(요약·추론·코딩·번역·창작·거절…), 주제, 길이, 난이도가 고루 퍼졌는가다. 편중된 데이터는 편중된 조수를 만들고, 다양성은 흔히 양 자체보다 중요하다. 난이도 분포도 중요하다 — 너무 쉬운 예시만이면 얕은 형식만 배우고, 너무 어려운 예시만이면 베이스 모델이 못 따라가 "억지 정답"을 흉내 내며 환각을 배운다(3.2.12). 형식 일관성 — 응답의 구조·마크다운·말투·거절 방식이 일관돼야 모델이 안정적 형식을 배운다. 데이터셋마다 스타일이 뒤죽박죽이면 출력도 불균일해진다. 마지막으로 안전·거절 시연의 포함 — 위험 요청에 적절히 거절·완화하는 예시가 없으면 안전 행동이 학습되지 않는다(4.3·7.3).
그래서 얼마나 써야 하나. 정답은 목표에 달렸다. 좁은 도메인·특정 톤·형식만 필요하면 수백~수천 개의 잘 큐레이션된 예시로 시작해 반복 개선하는 편이 비용 대비 낫다. 범용 조수·넓은 커버리지·미답 과제 일반화가 목표면 다양성을 확보하기 위해 규모가 필요하고, 수만~수십만+ 규모의 다양한 컬렉션이 유리하다. 다만 규모를 잡음으로 채우면 역효과다 — 필터링·중복 제거·품질 채점이 규모와 함께 커져야 한다. 공통 원칙은 하나다. 다양성·정확성·형식 일관성을 먼저 확보하고, 그다음에야 규모를 키운다. 순서를 뒤집어 규모부터 키우면 잡음을 대량으로 학습하게 된다.
3.2.11 데이터는 어디서 오나 — 인간·기존·합성
SFT 시연 데이터는 크게 세 갈래에서 오고, 각각 품질·비용·라이선스가 다르다.
인간 작성은 사람 라벨러가 직접 좋은 응답을 쓰는 방식이다. 품질과 정합성이 높지만 비용이 크고 느리다. 초기 instruction 정렬(예: InstructGPT 계열의 라벨러 시연)이 이 방식이었고, 전문 도메인(의료·법률·코드)일수록 전문가 시연 비용이 급등한다. 기존 공개 데이터셋은 이미 공개된 지시 데이터를 필터링해 쓰는 방식이다 — 학술 태스크를 지시 형태로 변환한 FLAN·Natural Instructions·P3 계열, 사람이 직접 만든 Dolly, 크라우드 대화 OpenAssistant, 사용자-어시스턴트 대화 로그를 정리한 ShareGPT 계열 등이 흔히 인용된다. 빠르고 저렴하지만 품질·라이선스·중복이 제각각이라 큐레이션이 필수다.
합성 데이터는 더 강한 모델(또는 같은 모델)로 지시·응답을 생성하는 방식이다. Self-Instruct(모델이 스스로 지시를 불려 만든다), Alpaca(강한 모델의 출력을 시연으로 증류), Evol-Instruct/WizardLM(지시를 점점 복잡하게 진화) 계열이 대표다. 저렴하고 대규모로 뽑히지만 생성 모델의 편향·오류·스타일을 그대로 물려받고, 반복 생성으로 다양성이 붕괴하는 모델 붕괴(model collapse) 위험이 있다. 합성 데이터의 생성·품질 관리·붕괴 위험은 3.9에서 본격적으로 다룬다. 실무에서는 셋을 섞는다 — 합성으로 규모·다양성을 확보하고, 인간 작성·검수로 핵심 품질과 안전을 보강하고, 기존 데이터셋으로 특정 과제 커버리지를 메운다. 어느 쪽이든 마지막의 필터·중복 제거·형식 표준화 단계가 품질을 좌우한다.
여기에 기술이 아니라 법의 문제인 위험 요소가 하나 있다. 라이선스와 이용약관이다. 많은 상용 LLM의 이용약관은 "그 출력으로 경쟁 모델을 학습하는 것"을 금지하는 조항을 둔다 — Alpaca류가 "연구용"으로만 배포된 배경에도 이 문제가 있었다. 상용 서비스에 합성 데이터를 쓸 때는 생성에 쓴 모델의 약관을 반드시 확인해야 한다. 공개 데이터셋도 라이선스가 제각각이라 "공개돼 있음"이 "상업적으로 자유롭게 학습 가능"을 뜻하지 않는다(비상업 NC, 특정 귀속 BY, 원 데이터의 저작권 승계 등). 대화 로그·크롤 데이터에는 개인정보와 저작물이 섞여 있을 수 있다. 이 조건들은 시점에 따라 바뀌고 법적 해석이 갈리므로, 상용화 전에 실제 약관·라이선스 원문과 법무 검토가 필요하다(9.3). 이 절은 그 리스크가 존재한다는 경보이지 법률 자문이 아니다.
3.2.12 SFT가 조용히 망가지는 네 가지 방식
SFT는 개념이 단순한 만큼 조용히 망가지는 방식도 많다. 넷을 순서대로 보되, 전부 "모델은 시연이 보여 준 행동을 배운다"는 한 문장에서 파생된다는 점을 기억하자.
첫째는 파국적 망각(catastrophic forgetting)이다. 미세조정이 사전학습에서 얻은 능력·지식을 덮어써 잃는 현상. 좁은 SFT 데이터에 과하게 맞추면(높은 학습률·많은 에폭·좁은 분포) 모델이 SFT 분포로 쏠리며 일반 지식·다른 언어·희귀 능력이 퇴화한다. 완화책은 세 갈래다. 낮은 학습률과 적은 에폭 — SFT는 보통 1~3 에폭에 사전학습보다 훨씬 작은 학습률로 실행하며, 과하게 오래 실행하지 않는 것이 원칙이다. 데이터 혼합·리허설(rehearsal) — SFT 데이터에 일반·다양 데이터를 섞고, 때로는 사전학습 데이터 일부를 리플레이해 분포가 좁아지는 것을 막는다. 그리고 PEFT(LoRA/QLoRA, 3.8) — 베이스 가중치를 동결하고 저랭크 어댑터만 학습하면 원 모델의 표현이 보존돼 망각이 구조적으로 줄어든다. LoRA가 SFT에서 널리 쓰이는 이유 중 하나가 이것이다.
둘째는 과적합·앵무새화(parroting)다. 데이터가 적거나 에폭이 많으면 모델이 시연을 암기한다. 증상은 학습 예시와 비슷한 입력에 토씨까지 복제, 출력 다양성 붕괴(늘 같은 서두·구조), 특정 상투구("물론이죠! 기꺼이 도와드리겠습니다…")의 반복이다. 형식은 그럴듯한데 새 입력에 대한 일반화가 약하다. 완화는 데이터 다양성 확보, 검증셋으로 과적합 시점을 감시하는 조기 종료, 에폭 축소, 중복 샘플 제거다(같은 예시의 반복은 곧장 암기를 부른다).
셋째가 가장 미묘하고 중요하다. SFT가 환각을 가르칠 수 있다. 행동 복제라는 성질 때문에, 시연이 "베이스 모델이 실제로 아는 것"을 넘어서면 위험한 것을 배운다. 시연 데이터가 모델이 모르는 사실을 자신 있게 답하는 예시로 가득하면, 모델은 "이런 질문엔 이런 형식·톤으로 확신에 차서 답한다"는 표면적 패턴을 배운다 — 그 내용이 자기 지식에 있든 없든. 결과는 답처럼 생겼지만 틀린 유창한 환각의 증폭이다. SFT는 없는 지식을 넣어 주지는 못하면서 "모르는 것도 아는 척 답하는 스타일"만 가르칠 수 있다. 이것이 3.2.1·3.2.10의 "SFT는 지식 주입이 아니다"의 어두운 이면이다. 대응은 시연을 모델의 실제 능력 범위에 맞춰 큐레이션하고(너무 어려운 예시로 억지 정답을 흉내 내게 하지 않기), "모른다·불확실하다"고 답하는 시연을 데이터에 포함하는 것이다. 다만 근본적 완화는 SFT 밖에 있다 — RLHF/DPO로 정직성과 보정(calibration)을 보상하고(3.3~3.5), 사실성은 검색 결합으로 외부화한다(6.3). SFT만으로 환각을 없애려 하지 마라.
넷째는 안전 정렬의 저하다. 미세조정이 사전 정렬된 안전 행동을 붕괴시킬 수 있다. 널리 인용되는 관찰(Qi et al. 2023 계열)은, 정렬된 모델을 소량의, 심지어 무해해 보이는 데이터로 파인튜닝해도 안전 거절이 약해질 수 있다는 것이다 — 이는 특정 조건의 연구 결과이지만 방향성은 실무에서 반복 확인된다. 하물며 안전 예시가 전혀 없는 SFT 데이터로 실행하면 거절·완화 행동이 희석된다. 완화는 안전·거절 시연을 데이터에 명시적으로 포함하고(4.3·7.3), 미세조정 후 안전 회귀 평가(레드팀·안전 벤치)를 반드시 재실행하고, LoRA·낮은 학습률로 드리프트를 억제하는 것이다. 안전은 SFT 한 번으로 끝내지 말고 선호 최적화·가드레일까지 다층으로 방어해야 한다.
| 함정 | 증상 | 주 원인 | 완화 |
|---|---|---|---|
| 파국적 망각 | 일반 능력·지식 퇴화 | 높은 LR·많은 에폭·좁은 데이터 | 낮은 LR, 데이터 혼합·리허설, LoRA |
| 과적합·앵무새화 | 암기·다양성 붕괴·상투구 반복 | 적은/중복 데이터, 과다 에폭 | 다양성, 조기 종료, 중복 제거 |
| 내용 없는 형식·환각 | 유창하나 틀림, 아는 척 | 능력 밖 시연 복제 | 능력 맞춤 큐레이션, 정직성 시연, RLHF·RAG |
| 안전 저하 | 거절 약화·탈옥 취약 | 안전 데이터 부재, 드리프트 | 안전 시연 포함, 안전 회귀 평가, 다층 방어 |
3.2.13 실무 — 하이퍼파라미터·풀 파인튜닝 대 LoRA·비용·평가
원리를 넘어 실행할 때의 조정 변수는 몇 개로 압축된다. 에폭은 보통 1~3이고 그 이상은 과적합·망각 위험이 급증한다. 학습률은 사전학습보다 훨씬 작게 설정한다 — 풀 파인튜닝은 대략 $1\text{–}2\times10^{-5}$ 급, LoRA는 어댑터만 학습하므로 더 크게($1\text{–}3\times10^{-4}$ 급) 설정하는 것이 일반적이다(모두 대표적 범위이고 데이터·모델에 따라 조정한다). 웜업·코사인 스케줄은 2.3.8 그대로, 정밀도는 BF16 혼합정밀도(2.6), 유효 배치는 그래디언트 누적으로 확보한다(2.3.5). 시퀀스 길이는 컨텍스트 창에 맞춰 패킹하고 문서 간 어텐션을 차단한다(3.2.9).
풀 파인튜닝과 LoRA 사이의 선택은 3.8에서 깊이 다루지만, 개략은 여기서 파악할 수 있다. 풀 파인튜닝은 모든 가중치를 갱신해 최대 유연성을 주지만 GPU 메모리·비용이 크고 망각 위험이 높다 — 파운데이션급 정렬이나 대규모·광범위 SFT에 맞는다. LoRA/QLoRA는 저랭크 어댑터만 학습해 저비용·저메모리에 망각을 억제하고, 여러 도메인 어댑터를 교체하기 쉽다. 내 견해로는 대부분의 응용 SFT에서 LoRA가 합리적 출발점이다 — 아주 큰 분포 이동이 필요한 경우에만 표현력 부족이 문제가 되는데, 그런 경우는 생각보다 드물다.
SFT가 얼마나 저렴한지는 숫자로 확정해 두면 규모가 분명해진다. 데이터셋이 10만 샘플, 평균 512토큰이라 하면 에폭당 $10^5 \times 512 = 5.12\times10^7$ 토큰, 3에폭이면 $1.54\times10^8$ 토큰이다. 학습 FLOP은 토큰당 대략 $6N$ (순전파 $2N$ + 역전파 $4N$, $N$ 은 파라미터 수)이므로, 8B 모델에서
$$\text{FLOPs} \approx 6 \times 8\times10^9 \times 1.54\times10^8 = 7.4\times10^{18} \ \text{FLOP} \;(\approx 7.4\ \text{EFLOP})$$
이다. 지속 성능 $\sim\!4\times10^{14}$ FLOP/s(BF16, 현실적 활용도)인 GPU 한 장이면 $7.4\times10^{18} / 4\times10^{14} \approx 1.85\times10^4$ 초, 약 5시간이고, 8장이면 40분가량이다. 같은 8B를 Chinchilla 최적(약 1,600억 토큰)으로 사전학습하면 $6\times8\times10^9\times1.6\times10^{11} \approx 7.7\times10^{21}$ FLOP으로, SFT의 1,000배가 넘는다. "SFT는 사전학습에 비하면 거의 공짜"라는 말의 정확한 크기가 이것이다. 한 가지 주의 — 손실 마스킹은 이 FLOP을 줄이지 않는다(3.2.6). 순전파·역전파는 프롬프트 토큰까지 전부 처리하고, 마스킹은 손실 합산에서만 뺀다. LoRA가 아끼는 것도 주로 옵티마이저 상태와 마스터 가중치 복사본이 차지하는 메모리이지, 동결된 가중치를 통과하는 행렬곱의 FLOP 자체는 대부분 그대로 든다.
마지막으로 평가. SFT 손실이 내려간다고 모델이 좋아진 것은 아니다 — 손실은 시연 복제의 정확도일 뿐이다. 반드시 행동을 별도로 평가해야 한다. 보류셋(held-out) 지시에 대한 응답 품질을 사람이나 LLM-as-judge로 재고(4.2), 일반 벤치마크로 능력 회귀를 확인해 파국적 망각을 억제하고(4.1), 레드팀·안전 벤치로 안전 회귀를 재실행하고(4.3), 원하는 형식·거절 정책을 실제로 지키는지 본다. 벤치마크 점수만 좇는 것은 위험하다 — 지시 데이터가 벤치마크와 겹치면(오염) 점수는 오르되 실제 유용성은 오르지 않을 수 있다. 오프라인 지표와 실제 사용 품질을 함께 봐야 한다.
SFT는 여기까지다. 이 단계가 심은 것은 "지시를 따른다"는 바탕이지, "여러 그럴듯한 응답 중 사람이 더 선호하는 쪽"으로 다듬는 능력은 아니다. 시연 복제는 하나의 정답을 흉내 낼 뿐, "같은 뜻이라도 이렇게 말하는 편이 낫다"는 선호의 미묘한 순위를 담지 못하기 때문이다. 그 순위를 인간 선호에서 배우는 것이 다음 단계다 — 3.3에서 페어와이즈 선호로 보상 모델을 학습하고, 그 보상으로 정책을 높이는 RLHF의 큰 그림으로 넘어간다.