9장. 모델 평가와 활용, QLoRA
어댑터 불러오기, 끄고 켜기, 병합하기
카메라와 교체 렌즈를 떠올려 봅니다. 몸체(기반 모델)는 하나이고, 인물용 렌즈, 풍경용 렌즈를 상황에 맞게 끼웠다 뺐다 합니다. 렌즈를 빼면 원래 카메라 그대로입니다. 그런데 늘 같은 렌즈만 쓴다면 렌즈를 몸체에 붙여 버리는 편이 가볍고 빠릅니다. 대신 다시는 뗄 수 없습니다. 어댑터를 붙여 쓰기(PeftModel.from_pretrained)와 병합하기(merge_and_unload())가 이 두 방식입니다.
그림의 첫 상자는 새로 불러온 기반 모델에 저장해 둔 어댑터를 붙이는 일입니다. 둘째 상자의 disable_adapter() 는 with 블록 안에서만 어댑터를 끕니다. 셋째 상자의 병합은 LoRA 의 변화량 B×A 를 원래 가중치 W 에 더해 새 W 를 만듭니다. 병합하면 LoRA 층이 사라져 일반 Qwen 모델(Qwen2ForCausalLM)이 됩니다.
붙여 쓰기와 병합 견주기
| 항목 | 어댑터를 붙여 쓰기 | 병합하기 |
|---|---|---|
| 함수 | PeftModel.from_pretrained(base, ADAPTER_DIR) | loaded.merge_and_unload() |
| 저장 크기 | 어댑터 33.6 MB(기반 모델은 따로) | 병합 모델 942 MB |
| 끄고 켜기 | disable_adapter() 로 가능 | 불가능 |
| 업무별 교체 | 렌즈만 바꿔 끼운다 | 업무마다 모델 한 벌 |
| 속도 | LoRA 경로 계산이 조금 더 든다 | 일반 모델과 같다 |
병합 크기 손으로 계산하기
- 어댑터: LoRA 파라미터 8,798,208개 × 4바이트(FP32 로 저장) = 35,192,832바이트 ÷ 1024² ≈ 33.6 MB
- 병합 모델: 전체 파라미터 494,032,768개 × 2바이트(BF16) = 988,065,536바이트 ÷ 1024² ≈ 942 MB
- 942 ÷ 33.6 ≈ 28, 병합 모델이 어댑터보다 약 28배 큽니다.
짧은 예시
import numpy as np
# 병합이 왜 "같은 답"을 내는지 4×4 장난감 행렬로 확인한다(값은 설명용)
rng = np.random.default_rng(0)
W = rng.normal(size=(4, 4)) # 동결된 원래 가중치(Qwen 에서는 896×896)
A = rng.normal(size=(1, 4)) # LoRA 행렬 A: r=1 줄
B = rng.normal(size=(4, 1)) # LoRA 행렬 B: r=1 열
scale = 2.0 # lora_alpha / r = 32 / 16 = 2
x = rng.normal(size=4) # 입력 하나
# 붙여 쓰기: 원래 경로와 LoRA 경로를 따로 계산해 더한다
y_adapter = W @ x + scale * (B @ (A @ x))
# 병합: B×A 를 W 에 먼저 더해 새 W 하나로 만든다
W_merged = W + scale * (B @ A)
y_merged = W_merged @ x
print(np.allclose(y_adapter, y_merged)) # 결과: True → 두 방식의 출력이 같다
# 저장 크기 손계산: 파라미터 수 × 바이트 수 ÷ 1024²
adapter_mb = 8_798_208 * 4 / 1024**2
merged_mb = 494_032_768 * 2 / 1024**2
print(f"{adapter_mb:.1f} MB, {merged_mb:,.0f} MB, {merged_mb / adapter_mb:.0f}배")
# 결과: 33.6 MB, 942 MB, 28배이 코드가 뜻하는 것
렌즈를 끼운 채 찍은 사진과 렌즈를 몸체에 붙여 찍은 사진이 똑같다는 것을 숫자로 확인했습니다. 행렬곱은 나눠서 더해도, 먼저 더하고 곱해도 결과가 같기 때문입니다. 여기서 몸체가 W, 렌즈가 B @ A, 붙여 버린 몸체가 W_merged 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
W @ x + scale * (B @ (A @ x)) | 두 경로를 따로 계산해 더한다 | 렌즈를 끼우고 찍기 |
W + scale * (B @ A) | 변화량을 가중치에 미리 더한다 | 렌즈를 몸체에 코팅 |
np.allclose(...) | 두 결과가 같은지 본다 | 두 사진 비교 |
8_798_208 * 4 / 1024**2 | 어댑터 크기를 MB 로 | 렌즈 무게 |
실습 3의 실제 출력도 "어댑터 33.6 MB vs 병합 모델 942 MB"입니다. 손계산과 같습니다.
주의할 점
- 병합한 모델에서는 어댑터를 다시 떼어 낼 수 없습니다. 어댑터 파일(
outputs/persona-lora-adapter)은 지우지 말고 따로 보관합니다. - 실습 3의 병합 모델 답("날짜를 기다리는 게 좋겠군. ...")이 어댑터를 붙였을 때의 답("날씨 걱정은 네 일요일 밥 먹는다.")과 조금 다릅니다. BF16 으로 더하며 생기는 아주 작은 반올림 차이가 greedy 생성에서 다른 토큰을 고르게 만들 수 있기 때문입니다.
- 어댑터는 어떤 기반 모델에 붙는지 기억합니다(
adapter_config.json의base_model_name_or_path). 다른 기반 모델에 붙이면 안 됩니다.