목차
생성 모델 미세 튜닝하기
분류 모델은 정해진 레이블 중 하나를 고른다. 생성 모델은 앞에 나온 토큰들을 보고 다음 토큰을 반복해서 예측한다. 이 모델을 미세 튜닝하면 특정 형식의 답변, 말투, 작업 절차를 더 일관되게 따르도록 만들 수 있다. 여기서는 고객지원 답변을 결론 → 확인 방법 → 추가 안내 순서로 쓰도록 지도 미세 튜닝(SFT)하는 예를 살펴본다.
먼저 목적을 분명히 해야 한다. 자주 바뀌는 정책이나 최신 가격을 외우게 하는 것이 목적이면 모델 가중치를 고치는 것보다 문서를 검색해 답변에 넣는 RAG가 적합할 수 있다. “문서를 보고 일정한 형식으로 답하기”처럼 출력 행동을 바꾸고 싶다면 미세 튜닝을 검토한다. 정책 검색과 출력 형식 학습은 함께 사용할 수도 있다.
예를 들어 고객센터의 배송비 기준이 자주 바뀌는데 답변의 순서와 문체만 일정하게 만들고 싶다고 해 보자. 정책 수치를 학습 데이터의 정답 문장에 박아 넣으면 나중에 기준이 바뀐 뒤에도 이전 수치를 답할 위험이 있다. 대신 최신 정책을 입력 근거에 넣고, 모델은 그 근거를 짧고 일관되게 설명하는 동작을 배우도록 한다. 미세 튜닝으로 무엇을 바꾸고 무엇을 외부 정보로 남길지 구분하는 것이 데이터 설계의 첫 단계다.
flowchart TD
U[사용자 질문] --> C{어떤 정보가 필요한가?}
C -- 최신 정책·개별 주문 정보 --> R[검색 또는 업무 API로 근거 확보]
C -- 고정된 답변 형식 --> M[미세 튜닝된 생성 모델]
R --> M
M --> V[근거·형식·안전 기준 검사]
V --> A[최종 답변]
그림의 검색 단계는 필요할 때만 사용한다. 모델이 답변 형식을 익혔더라도 최신 정책을 스스로 알 수는 없으므로, 정책에 관한 질문에는 별도로 확인한 근거를 전달한다.
다음 토큰 예측과 지도 미세 튜닝
언어 모델은 입력 토큰 이 주어졌을 때 다음 토큰 의 확률을 계산한다. 지도 미세 튜닝에서는 좋은 질문·답변 예제를 이어 붙여 정답 답변 토큰의 확률을 높인다.
실무에서는 질문과 시스템 지시문까지 전부 손실에 넣을지, 답변 토큰에만 손실을 줄지를 명시해야 한다. 답변 형식을 학습시키는 목적이라면 후자를 많이 쓴다. SFTTrainer의 prompt-completion 데이터와 completion_only_loss가 이를 지원한다. 라이브러리 버전에 따라 기본값과 지원 데이터 형식이 달라질 수 있으므로 실행 환경의 문서를 확인한다.
손실을 받는 토큰을 구분해 보면 이해하기 쉽다. 입력이 질문: 영수증은 어디서 받나요?이고 정답이 결론: 마이페이지에서...라면, 질문은 모델에 문맥으로 제공하지만 이 예시의 학습 목표에서는 정답 답변 부분만 예측 대상으로 삼는다. 반대로 전체 시퀀스에 손실을 주면 질문 문구를 복원하는 데도 학습 용량을 쓴다. 어떤 방식이든 토크나이저가 역할 구분과 답변 종료를 정확히 표현해야 한다. 마스킹된 토큰에 실제 손실이 적용되지 않는지 몇 건을 출력해 확인하면, 설정만 맞춘 척하고 다른 부분을 학습하는 실수를 줄일 수 있다.
데이터 한 건의 구조
아래는 고객지원용 가상 예제다. 실제 고객 정보나 주문번호를 그대로 학습 데이터에 넣지 않는다. 모델에 최신 정책을 기억시키지 않기 위해 정책 문구는 입력의 근거로 주고, 답변은 그 근거에서만 작성하도록 만든다.
{
"prompt": [
{"role": "system", "content": "제공된 근거만 사용해 결론, 확인 방법, 추가 안내 순서로 답하세요. 근거가 없으면 확인이 필요하다고 말하세요."},
{"role": "user", "content": "질문: 결제 영수증은 어디서 받나요?\n근거: 영수증은 마이페이지 > 주문 내역 > 영수증에서 내려받을 수 있습니다."}
],
"completion": [
{"role": "assistant", "content": "결론: 마이페이지에서 영수증을 내려받을 수 있습니다.\n확인 방법: 주문 내역에서 해당 주문을 선택하고 영수증 메뉴를 여세요.\n추가 안내: 메뉴가 보이지 않는 경우에는 추가 확인이 필요합니다."}
]
}
좋은 데이터는 답변의 길이뿐 아니라 모를 때의 행동도 보여준다. 근거가 없는 질문에 그럴듯한 정책을 지어내는 답변을 정답으로 넣으면, 미세 튜닝이 오히려 환각을 강화한다. 거절·추가 확인·사람에게 이관하는 예제도 업무 규칙에 맞게 포함한다.
예제 한 건을 검수하는 순서
- 질문의 의도를 읽는다. “영수증은 어디서 받나요?”는 메뉴 위치를 묻는다. 결제 취소 방법을 답하면 문장은 친절해도 정답이 아니다.
- 근거의 범위를 표시한다. 위 예시의 근거에는 영수증 메뉴의 경로만 있다. 초안에
주문 상태를 확인해 주세요를 넣고 싶어도 그 절차는 근거에 없다. 근거만 사용하라는 규칙을 지키려면 추가 자료로 뒷받침하거나 답변에서 제거해야 한다. - 답변의 각 문장이 근거에 의해 뒷받침되는지 확인한다. 결론, 확인 방법, 추가 안내 형식이 있어도 사실이 틀리면 좋은 학습 예제가 아니다.
- 동일한 정책 문서의 변형이 학습과 테스트에 섞이지 않는지 확인한다. 질문만 달리 쓴 거의 같은 사례가 두 세트에 걸쳐 있으면 일반화 성능이 과대평가된다.
위 가상 예시의 추가 안내는 근거에 없는 절차를 만들어 내지 않고 확인이 필요하다고 말한다. 실제 데이터라면 누가 확인할지, 언제 사람에게 연결할지도 업무 절차에 맞춰 정해야 한다.
대화 형식은 모델마다 다르다. 역할 이름, 구분 토큰, 종료 토큰을 수작업으로 추측해 이어 붙이지 말고 해당 모델의 chat_template을 사용한다. 학습과 추론의 템플릿이 달라지면 같은 대화도 다른 토큰열이 된다. Transformers의 채팅 템플릿 문서에서 형식과 주의점을 확인할 수 있다.
데이터가 많아도 같은 답변을 수백 번 복사하면 모델이 특정 문구만 암기하기 쉽다. 메뉴 경로 안내, 근거 없음, 서로 충돌하는 근거, 불완전한 주문 정보 등 실제 입력의 유형별로 예제를 모은다. 평가 세트에도 각 유형을 넣고, 학습 데이터의 한 답변을 단어만 바꾼 문장은 피한다. 실서비스 대화를 사용한다면 개인식별정보를 제거하고 재학습이 허용된 데이터만 사용한다.
전체 가중치와 LoRA
전체 미세 튜닝은 모델의 모든 가중치를 업데이트한다. 큰 모델에서는 가중치뿐 아니라 최적화 상태와 활성값도 메모리를 차지한다. LoRA는 원래 가중치를 고정하고 일부 층에 작은 저랭크 행렬을 추가해 학습한다. 어댑터만 저장·교체하기 쉬운 장점이 있지만, 데이터 품질이나 평가가 불필요해지는 것은 아니다. PEFT의 LoRA 설명을 참고한다.
flowchart LR
B[기본 생성 모델: 가중치 고정] --> O[출력]
I[질문과 근거] --> B
I --> L[작은 LoRA 행렬: 학습]
L --> O
O --> T[정답 답변과 비교]
T --> L
그림에서 기본 모델의 가중치는 그대로 두고 LoRA 행렬만 업데이트한다. 추론할 때에는 기본 모델과 그 모델에 맞는 어댑터를 함께 로드한다.
다음 코드는 train.jsonl, validation.jsonl에 위의 prompt·completion 구조가 들어 있다고 가정한 학습 뼈대다. 한 건만으로는 훈련도 평가도 의미가 없다. 예시 모델과 구체적인 설정은 설치한 trl, transformers, peft 버전 및 GPU 메모리에 맞춰 확인해야 한다.
from datasets import load_dataset
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer
data = load_dataset("json", data_files={
"train": "train.jsonl",
"validation": "validation.jsonl",
})
args = SFTConfig(
output_dir="models/support-answer-adapter",
max_length=1024,
completion_only_loss=True,
eval_strategy="epoch",
save_strategy="epoch",
num_train_epochs=2,
per_device_train_batch_size=2,
gradient_accumulation_steps=8,
)
adapter = LoraConfig(
r=8,
lora_alpha=16,
lora_dropout=0.05,
target_modules=["q_proj", "v_proj"],
task_type="CAUSAL_LM",
)
trainer = SFTTrainer(
model="Qwen/Qwen2.5-0.5B-Instruct",
args=args,
train_dataset=data["train"],
eval_dataset=data["validation"],
peft_config=adapter,
)
trainer.train()
trainer.save_model("models/support-answer-adapter/final")
load_dataset은 준비한 파일을 훈련과 검증으로 읽는다. 두 파일 사이에 같은 고객 문의나 같은 정책 문구의 복사본이 없는지 먼저 확인한다. SFTConfig의 max_length는 토큰화한 한 예제의 최대 길이이므로, 길이가 긴 근거에서 답에 필요한 끝부분이 잘리지 않는지 검사한다. gradient_accumulation_steps=8은 작은 장치 배치를 여러 번 누적해 가중치를 갱신하는 설정이다. 이 값은 속도와 메모리 요구량에 영향을 주며 데이터 품질 문제를 해결하지 않는다.
LoraConfig의 r은 업데이트 행렬의 차원을 조절하고, target_modules는 어느 층에 LoRA를 넣을지 지정한다. 예시의 q_proj, v_proj는 이 모델 계열에서 사용하는 이름을 가정한 값이다. 다른 모델로 바꾸면 층 이름을 확인해야 한다. lora_dropout은 학습 중 LoRA 경로에 적용되는 드롭아웃이다. 마지막 save_model은 어댑터 중심의 결과물을 저장하므로 기본 모델과 토크나이저의 정확한 버전을 함께 기록한다. PEFT의 모델 저장·로드 안내가 배포 시의 조합을 설명한다.
max_length=1024를 넘는 대화는 잘릴 수 있다. 근거의 뒷부분에 답이 있는 사례가 많이 잘린다면 모델은 올바른 답을 볼 기회가 없다. 데이터의 토큰 길이 분포를 먼저 확인하고, 필요한 정보를 짧고 명확한 근거로 제공해야 한다. 어댑터를 배포할 때에는 어떤 기본 모델과 토크나이저에 맞춰 학습했는지도 함께 기록한다.
저장한 어댑터로 생성할 때
다음은 학습에 사용한 기본 모델과 어댑터를 함께 불러온다는 점을 보여 주는 추론 예제다. 실제 학습과 추론을 실행한 결과는 아니다. 메시지 형식은 앞의 학습 예제와 같게 유지한다.
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
base_name = "Qwen/Qwen2.5-0.5B-Instruct"
adapter_path = "models/support-answer-adapter/final"
tokenizer = AutoTokenizer.from_pretrained(base_name)
base_model = AutoModelForCausalLM.from_pretrained(base_name)
model = PeftModel.from_pretrained(base_model, adapter_path)
model.eval()
messages = [
{"role": "system", "content": "제공된 근거만 사용해 결론, 확인 방법, 추가 안내 순서로 답하세요."},
{"role": "user", "content": "질문: 영수증은 어디서 받나요?\n근거: 영수증은 마이페이지 > 주문 내역 > 영수증에서 내려받을 수 있습니다."},
]
inputs = tokenizer.apply_chat_template(
messages, tokenize=True, add_generation_prompt=True, return_tensors="pt"
).to(model.device)
outputs = model.generate(inputs, max_new_tokens=120, do_sample=False)
answer_tokens = outputs[0][inputs.shape[-1]:]
print(tokenizer.decode(answer_tokens, skip_special_tokens=True))
apply_chat_template은 역할 구분을 해당 모델의 토큰 형식으로 변환한다. add_generation_prompt=True는 이제 assistant가 답할 차례임을 표시한다. generate는 입력 뒤에 새 토큰을 이어 붙이므로 inputs.shape[-1]부터 잘라야 질문까지 함께 출력하는 혼동을 피할 수 있다. do_sample=False로 생성의 무작위성을 줄여 두 모델을 비교하기 쉽지만, 정답이 보장되지는 않는다. 어댑터 로드 방법은 PEFT 공식 Quicktour를 따른다.
평가: 손실보다 실제 답변
검증 손실이 낮아져도 운영 질문에 정확히 답한다는 보장은 없다. 훈련에 없던 질문을 모아 기존 모델과 미세 튜닝 모델에 같은 근거와 같은 생성 설정을 주고 비교한다. 답변은 문장 단위로 읽어야 한다.
비교 질문을 한 건 구체적으로 보자. 근거에 “주문 후 영수증은 마이페이지에서 다운로드할 수 있다”만 있을 때, 답변이 “영수증은 마이페이지에서 받을 수 있다”라면 근거 충실도가 높다. 여기에 “결제 후 24시간이 지나야 한다”를 덧붙였다면 그 부분은 근거에 없으므로 실패다. 반대로 형식을 완벽히 지켰어도 메뉴 경로를 잘못 안내하면 업무 목표를 만족하지 못한다. 형식 준수와 사실성은 별도 점수로 매긴다.
| 평가 축 | 확인 질문 | 실패 예 |
|---|---|---|
| 근거 충실도 | 답변의 각 사실이 제공된 근거에 있는가? | 근거에 없는 환불 기한을 제시 |
| 형식 준수 | 결론·확인 방법·추가 안내를 지키는가? | 장황한 서론만 쓰고 절차 누락 |
| 불확실성 처리 | 근거가 없을 때 확인을 요청하는가? | 존재하지 않는 메뉴를 만들어 안내 |
| 일반화 | 표현과 순서가 달라져도 답하는가? | 학습 문장과 같은 문구에만 반응 |
| 회귀 | 기존에 잘하던 일반 질문도 처리하는가? | 미세 튜닝한 형식을 모든 답에 강요 |
정책 문서가 바뀐 뒤의 질문, 근거에 답이 없는 질문, 서로 충돌하는 두 근거를 주는 질문을 테스트 세트에 포함한다. 고객 또는 대화 단위로 분할해 동일한 답변 템플릿의 문장 복사본이 훈련과 테스트 양쪽에 섞이지 않게 한다. 모델을 선택한 뒤에는 손대지 않은 테스트 세트로 마지막 평가를 한다.
사람이 평가한다면 각 축의 판정 기준과 예시를 미리 정해 두고, 두 사람이 일부 답변을 독립적으로 읽어 판단 차이를 확인한다. 자동 채점만 쓸 때는 채점 모델도 근거를 놓치거나 자신의 선호 문체에 높은 점수를 줄 수 있다. 특히 사실성은 원문 근거와 대조한다.
기준선보다 좋아진 항목뿐 아니라 새로 나빠진 항목도 기록한다. 예를 들어 근거 문서가 없는 질문에서 확인 요청을 더 잘하게 되었지만, 이전에 잘 답하던 일반 질문까지 모두 “확인이 필요합니다”로 끝낸다면 회귀가 생긴 것이다. 배포 후에는 실제 수정·이관 사례를 모아 평가 세트를 갱신하되, 그 사례를 다시 학습에 넣은 뒤 같은 평가 점수로 개선을 증명하지 않는다.
자주 생기는 문제
- 훈련과 추론의 템플릿이 다름: 학습에 사용한 역할 구분과 추론 프롬프트를 일치시킨다.
- 답변이 중간에 끊김: 데이터 잘림, 종료 토큰, 생성 최대 길이를 함께 확인한다.
- 훈련 예제를 그대로 반복함: 중복 데이터를 줄이고 표현이 다른 질문으로 평가한다.
- 최신 정보를 틀림: 가중치에 넣은 지식을 갱신하려 애쓰기보다 검색된 근거 또는 업무 API를 연결한다.
- 한 형식을 모든 상황에 강요함: 정형 답변이 필요한 업무와 자유 대화 평가를 분리한다.
정리
생성 모델 미세 튜닝의 출발점은 모델 크기가 아니라 원하는 답변 행동을 구체적인 예제로 정의하는 것이다. 입력 근거, 일관된 대화 템플릿, 답변 토큰의 학습 범위, 독립적인 평가 세트가 맞물려야 결과를 믿을 수 있다. 최신 사실은 검색으로 공급하고, 미세 튜닝은 그 사실을 어떻게 사용해 답할지 가르치는 데 집중한다.