Instruction Tuning·Chat Template·Loss Mask: 프롬프트 토큰에 loss를 걸면 모델이 무엇을 배우나
SFT(지도 미세조정)는 사전학습된 모델에게 "지시를 받으면 응답한다"는 행동을 가르치는 단계다. 오늘은 대화를 하나의 토큰 열로 바꾸는 chat template과, 응답 토큰에서만 오차(loss)를 계산하는 loss mask를 직접 만든다. 그리고 프롬프트에도 loss를 걸었을 때와 아닐 때를 비교해 본다.
By 송민성
오늘의 목표
SFT(Supervised Fine-Tuning, 지시와 정답 응답 쌍으로 모델을 추가 학습하는 것)가 무엇인지, 사전학습(pretraining)과 무엇이 다른지 설명할 수 있게 된다. 대화 데이터를 모델이 읽는 토큰 열로 바꾸는 chat template 렌더러와, 응답 토큰에만 loss를 계산하게 하는 마스크 함수를 직접 구현한다.
개념과 직관
사전학습 vs SFT
사전학습된 모델(base model)은 "앞의 토큰들을 보고 다음 토큰을 맞히는" 훈련만 받았다. 인터넷 글을 엄청나게 읽은 사람이라고 생각하면 된다. 아는 건 많지만 "질문을 받으면 답한다"는 행동 규칙은 없다. 그래서 "대한민국의 수도는?"이라고 넣으면 답 대신 "대한민국의 수도는? 일본의 수도는? ..." 처럼 비슷한 문제를 이어 쓸 수도 있다.
SFT는 이 모델에게 (지시, 응답) 쌍을 보여주며 "이런 입력엔 이런 출력"이라는 습관을 들이는 단계다. 놀라운 점은 학습 방식 자체는 똑같다는 것이다. 여전히 다음 토큰 예측이다. 달라지는 건 두 가지다.
- 데이터의 모양: 일반 글 대신 지시-응답 형태의 대화 데이터를 쓴다.
- loss를 계산하는 범위: 응답 부분만 채점한다(아래 loss mask).
Chat template: 대화를 한 줄의 글로 바꾸기
모델은 "사용자", "어시스턴트" 같은 역할을 모른다. 오직 토큰 열만 본다. 그래서 역할 구분을 특수 토큰(special token, 구분용으로 약속해 둔 특별한 토큰)이나 표시 문자열로 글 안에 심어야 한다. 이 규칙이 chat template이다. 예를 들어 이런 식이다.
<|user|>
대한민국의 수도는?<|end|>
<|assistant|>
서울입니다.<|eos|>모델마다 템플릿 형식이 다르다. 학습할 때 쓴 템플릿과 추론할 때 쓰는 템플릿이 다르면 성능이 눈에 띄게 떨어질 수 있다. 시험지 양식이 달라지는 것과 비슷하다.
Loss mask: 응답만 채점한다
시험 비유를 들어보자. 문제지(프롬프트)와 답안(응답)이 한 장에 인쇄돼 있다. 채점자는 답안만 채점해야 한다. 문제지 글자까지 "잘 베꼈나"를 채점할 이유가 없다. 우리가 원하는 건 "프롬프트가 주어졌을 때 좋은 응답을 내는 능력"이기 때문이다.
수식으로 쓰면 이렇다. 토큰 위치 t마다 loss_t = -log P(정답 토큰 t | 앞의 토큰들)이다. 평균을 낼 때 마스크 m_t(응답이면 1, 프롬프트면 0)를 곱한다.
loss = sum(m_t * loss_t) / sum(m_t)PyTorch의 CrossEntropyLoss는 정답 라벨이 -100인 위치를 자동으로 무시한다(ignore_index=-100이 기본값). 그래서 실무에서는 "프롬프트 위치의 label을 -100으로 바꾸는" 방식으로 마스크를 구현하는 경우가 많다.
Padding, Packing, EOS
- 패딩(padding): 배치 안의 문장 길이를 맞추려고 뒤에 채우는 빈 토큰. 패딩 위치도 loss에서 빼야 한다(label -100).
- 패킹(packing): 짧은 예제 여러 개를 이어 붙여 한 시퀀스로 만드는 것. 패딩 낭비는 줄지만, 예제 경계를 잘못 다루면 서로 영향을 줄 수 있다. 오늘은 개념만 알아두고 구현은 하지 않는다.
- EOS(End Of Sequence, 문장 끝을 알리는 토큰): 응답 끝에 붙인다. 이 토큰이 loss에 들어가야 모델이 "여기서 멈춰야 한다"를 배운다. 응답은 마스크 1, EOS도 마스크 1이다.
코드로 직접 해보기
라이브러리 없이 글자 단위 토크나이저로 원리만 구현한다. 실제 토크나이저를 쓰더라도 구조는 같다.
1단계: chat template 렌더러
def render(messages):
"""messages: [{"role": "user"|"assistant", "content": str}, ...]
반환: (역할, 텍스트 조각) 리스트"""
parts = []
for m in messages:
header = f"<|{m['role']}|>\n"
if m["role"] == "user":
parts.append(("prompt", header + m["content"] + "<|end|>\n"))
else:
# 헤더는 프롬프트 쪽, 본문과 EOS는 응답 쪽
parts.append(("prompt", header))
parts.append(("response", m["content"] + "<|eos|>"))
return partsassistant의 헤더(<|assistant|>\n)는 프롬프트 쪽으로 분류했다. 추론할 때는 이 헤더까지 우리가 넣어주고 모델이 그 뒤를 이어 쓰기 때문이다.
2단계: 토큰화와 라벨 만들기
SPECIALS = ["<|user|>", "<|assistant|>", "<|end|>", "<|eos|>"]
def tokenize(text):
"""특수 토큰은 하나로, 나머지는 글자 단위로 쪼갠다."""
tokens, i = [], 0
while i < len(text):
for sp in SPECIALS:
if text.startswith(sp, i):
tokens.append(sp)
i += len(sp)
break
else:
tokens.append(text[i])
i += 1
return tokens
def build_example(messages, vocab):
input_ids, labels = [], []
for kind, text in render(messages):
ids = [vocab.setdefault(t, len(vocab)) for t in tokenize(text)]
input_ids += ids
if kind == "response":
labels += ids # 응답: 정답으로 사용
else:
labels += [-100] * len(ids) # 프롬프트: 채점 제외
return input_ids, labels3단계: 마스크 함수와 확인
def loss_mask(labels):
return [0 if y == -100 else 1 for y in labels]
vocab = {}
msgs = [
{"role": "user", "content": "수도는?"},
{"role": "assistant", "content": "서울"},
]
ids, labels = build_example(msgs, vocab)
inv = {v: k for k, v in vocab.items()}
for i, y in zip(ids, labels):
print(f"{inv[i]!r:15} label={y}")
print("응답 토큰 수:", sum(loss_mask(labels)), "/ 전체:", len(ids))출력에서 서, 울, <|eos|> 세 토큰만 label이 -100이 아니어야 한다.
4단계: 다음 토큰 예측에 맞게 한 칸 밀기
언어 모델은 위치 t의 출력으로 위치 t+1의 토큰을 맞힌다. 그래서 loss를 계산할 때는 입력과 라벨을 한 칸 어긋나게 쓴다.
import torch
import torch.nn.functional as F
def sft_loss(logits, labels):
# logits: (B, T, V), labels: (B, T)
shift_logits = logits[:, :-1, :]
shift_labels = labels[:, 1:]
return F.cross_entropy(
shift_logits.reshape(-1, shift_logits.size(-1)),
shift_labels.reshape(-1),
ignore_index=-100,
)HuggingFace 모델에 labels=를 넘기면 내부에서 이 shift를 해 준다. 그래서 직접 한 칸 밀어서 넘기면 두 번 밀리는 실수가 생긴다. 이 점은 아래 막히는 지점에서 다시 다룬다.
오늘의 실험
질문: 프롬프트에도 loss를 걸면 결과가 어떻게 달라지는가?
설정: 같은 소형 모델(예: 작은 GPT 계열 모델이나 앞서 만든 미니 모델), 같은 데이터(지시-응답 수백~수천 개), 같은 하이퍼파라미터로 두 번 학습한다. 바꾸는 것은 라벨뿐이다.
- A: 응답만 loss (프롬프트 라벨 = -100)
- B: 전체 loss (프롬프트 라벨도 그대로 사용)
관찰할 것
- 학습 loss 곡선. 단, A와 B의 loss는 계산 대상이 달라서 숫자를 직접 비교하면 안 된다. 같은 검증 세트의 응답 부분 loss를 따로 계산해 비교한다.
- 같은 프롬프트 5~10개에 대한 생성 결과를 눈으로 비교한다. 지시를 잘 따르는지, 프롬프트를 되풀이하지 않는지, 멈춰야 할 곳에서 멈추는지 본다.
- 프롬프트가 길고 응답이 짧은 데이터에서 차이가 더 두드러지는지 확인한다. 이 경우 B는 loss의 대부분이 프롬프트 토큰에서 나온다.
기록 방법: 표 하나를 만든다.
| 실험 | 응답 검증 loss | 생성 샘플 품질(1~5) | 메모 |
|---|---|---|---|
| A 응답만 | |||
| B 전체 |
결과가 예상과 달라도 그대로 적는다. 데이터가 작으면 차이가 거의 없을 수도 있다. 그럴 때는 "차이가 작았다. 이유 가설: 데이터 양, 프롬프트/응답 길이 비율"처럼 가설까지 남겨 두는 것이 연구 기록이다.
왜 이걸 배우나 (LLM / Post-training 연결)
SFT는 Post-training의 첫 단계이고, 이후 선호 데이터로 모델을 다듬는 단계들도 같은 chat template과 "어디에 loss를 걸 것인가"라는 질문 위에서 진행된다. LLM Post-training 업무에서는 데이터 포맷과 마스킹 버그가 모델 품질 저하의 흔한 원인이라, 이 부분을 직접 구현해 본 경험이 디버깅 속도를 크게 좌우한다. 이어지는 LoRA/QLoRA 실습에서도 이 데이터 파이프라인을 그대로 쓴다.
초보자가 막히는 지점
- 템플릿 불일치: 학습과 추론에서 구분 토큰이나 줄바꿈이 한 글자라도 다르면 품질이 흔들린다. 렌더 결과를 반드시
print로 눈으로 확인한다. - EOS 누락: 응답 끝에 EOS가 없으면 모델이 생성을 멈추지 못하고 계속 말한다. 라벨에 EOS가 포함됐는지 확인한다.
- shift 중복: HuggingFace 모델에
labels를 넘기면 내부에서 이미 한 칸 밀어 계산한다. 직접 또 밀면 한 칸 어긋난 정답을 학습한다. 직접 loss를 짤 때만 밀어 준다. - 패딩과 EOS 혼동: 패딩 토큰으로 EOS를 재사용하는 모델이 많은데, 이때 패딩 위치 label을 -100으로 바꾸면서 진짜 EOS까지 지워 버리기 쉽다. "위치 기준"으로 마스킹하고 "토큰 값 기준"으로 하지 않는다.
- 토큰 경계 어긋남: 실제 토크나이저는 프롬프트와 응답을 따로 토큰화한 결과와 합쳐서 토큰화한 결과가 다를 수 있다. 경계에서 병합이 일어나기 때문이다. 조각별로 토큰화해서 이어 붙이거나, offset 정보로 경계를 확인한다.
- 마스크가 전부 0: 응답이 잘려서(max length 초과) 응답 토큰이 하나도 안 남은 예제는 loss가
nan이 될 수 있다. 응답 토큰 수를 세어 확인한다. - "응답만 학습하면 항상 더 좋다"는 오해: 일반적으로 널리 쓰이는 방식이지만 절대 규칙은 아니다. 그래서 오늘 실험으로 직접 확인한다.
알고리즘 (병행 1시간)
LeetCode Medium 2문제(그래프)를 푼다. 예를 들어 Number of Islands, Course Schedule처럼 BFS/DFS나 위상 정렬(topological sort)을 쓰는 문제가 적당하다. 문제를 직접 고를 때는 "그래프를 만들거나 격자를 그래프로 보는 문제"인지 확인한다.
풀이 후 아래 세 가지를 기록한다.
- 접근: 노드와 간선을 무엇으로 정의했는지, BFS/DFS 중 무엇을 왜 골랐는지, 방문 처리를 어디서 했는지
- 시간·공간 복잡도: 노드 수 V, 간선 수 E 기준으로 직접 유도해서 적는다.
- 틀린 이유: 방문 체크 누락, 사이클 처리 실수, 인덱스 범위 오류 등 실제로 틀렸던 지점과 다음에 막을 방법