이미지 로딩 중...
AI Generated
2025. 11. 16. · 3 Views
QLoRA 4-bit 양자화로 GPU 메모리 절약하는 법
GPU 메모리가 부족해서 대형 언어 모델을 파인튜닝하지 못했나요? QLoRA는 4-bit 양자화 기술로 메모리 사용량을 75% 이상 줄이면서도 성능은 거의 그대로 유지합니다. 이제 일반 GPU로도 대형 모델을 학습시킬 수 있습니다.
목차
- QLoRA란 무엇인가 - 메모리를 75% 절약하는 마법
- 4-bit 양자화의 원리 - 숫자를 똑똑하게 압축하기
- LoRA와의 결합 - 학습 효율을 극대화하는 듀오
- Double Quantization - 메타데이터까지 압축하기
- Paged Optimizers - 메모리 스파이크 방지하기
- 실전 파인튜닝 예제 - 챗봇 만들기
- 추론 최적화 - 빠른 응답 만들기
- 메모리 프로파일링 - 병목 지점 찾기
- 데이터셋 준비 - 고품질 학습 데이터 만들기
- 하이퍼파라미터 튜닝 - 최적의 설정 찾기
- 멀티 GPU 학습 - 대형 모델 빠르게 학습하기
- 프로덕션 배포 - 실서비스 준비하기
1. QLoRA란 무엇인가 - 메모리를 75% 절약하는 마법
시작하며
여러분이 GPT나 LLaMA 같은 대형 언어 모델을 내 데이터로 파인튜닝하려고 할 때, "CUDA out of memory" 에러를 본 적 있나요? 70억 개 파라미터를 가진 모델을 학습시키려면 보통 80GB 이상의 GPU 메모리가 필요합니다.
일반 개발자가 접근하기에는 너무 비싼 하드웨어죠. 이런 문제는 AI 개발 현장에서 가장 큰 진입 장벽 중 하나입니다.
좋은 아이디어가 있어도 하드웨어 제약 때문에 실험조차 못 하는 경우가 많습니다. 클라우드 GPU를 빌리면 시간당 수만 원씩 비용이 나가고, 학습에는 며칠씩 걸리기도 합니다.
바로 이럴 때 필요한 것이 QLoRA입니다. QLoRA는 메모리 사용량을 4분의 1로 줄여서, 24GB GPU만 있어도 65B 파라미터 모델을 파인튜닝할 수 있게 해줍니다.
개요
간단히 말해서, QLoRA는 "Quantized Low-Rank Adaptation"의 줄임말로, 모델의 가중치를 4-bit로 압축하면서 효율적으로 학습하는 기술입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 대형 모델 파인튜닝은 엄청난 메모리를 요구합니다.
예를 들어, 회사에서 고객 서비스 챗봇을 만들기 위해 LLaMA-13B 모델을 파인튜닝한다면, 일반적으로 최소 52GB의 GPU 메모리가 필요합니다. 하지만 QLoRA를 사용하면 16GB만으로도 가능합니다.
기존에는 전체 모델 가중치를 32-bit float으로 저장하고 학습했다면, 이제는 4-bit로 압축된 기본 모델에 작은 어댑터만 추가로 학습할 수 있습니다. QLoRA의 핵심 특징은 세 가지입니다: (1) 4-bit NormalFloat 양자화로 메모리 효율성 극대화, (2) Double Quantization으로 추가 메모리 절약, (3) Paged Optimizers로 메모리 스파이크 방지.
이러한 특징들이 중요한 이유는 일반 개발자도 최신 대형 모델을 자신의 데이터로 커스터마이징할 수 있게 만들어주기 때문입니다.
코드 예제
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
# 4-bit 양자화 설정
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, # 4-bit로 모델 로드
bnb_4bit_quant_type="nf4", # NormalFloat4 타입 사용
bnb_4bit_compute_dtype="float16", # 계산은 float16으로
bnb_4bit_use_double_quant=True, # 이중 양자화 활성화
)
# 양자화된 모델 로드
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b-hf",
quantization_config=bnb_config,
device_map="auto" # GPU에 자동 배치
)
# LoRA 설정 추가
model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(r=16, lora_alpha=32, target_modules=["q_proj", "v_proj"])
model = get_peft_model(model, lora_config)
설명
이것이 하는 일: 위 코드는 7B 파라미터 LLaMA 모델을 4-bit로 압축해서 메모리에 로드하고, 파인튜닝을 위한 LoRA 어댑터를 설정합니다. 첫 번째로, BitsAndBytesConfig 부분은 모델을 어떻게 압축할지 설정합니다.
load_in_4bit=True는 각 가중치를 32-bit(4바이트)에서 4-bit(0.5바이트)로 줄입니다. 마치 고화질 사진을 압축해서 용량을 줄이는 것과 비슷하지만, 중요한 정보는 거의 손실되지 않습니다.
nf4(NormalFloat4)는 일반적인 정규분포를 따르는 가중치에 최적화된 특수한 4-bit 형식입니다. 왜 이렇게 하냐면, 신경망 가중치는 대부분 0 근처에 몰려있는 정규분포 형태이기 때문에, 이 특성을 활용하면 더 효율적으로 압축할 수 있기 때문입니다.
그 다음으로, AutoModelForCausalLM.from_pretrained가 실행되면서 실제로 모델을 메모리에 로드합니다. device_map="auto"는 여러 GPU가 있을 경우 자동으로 모델을 분산 배치해줍니다.
내부에서는 각 레이어의 가중치를 읽어들이면서 즉시 4-bit로 변환하므로, 피크 메모리 사용량이 크게 줄어듭니다. 마지막으로, prepare_model_for_kbit_training과 get_peft_model이 LoRA 어댑터를 추가합니다.
전체 모델은 얼려두고(frozen), query와 value projection 레이어에만 작은 어댑터 행렬(rank=16)을 붙입니다. 학습할 때는 이 어댑터만 업데이트되므로, 학습 가능한 파라미터가 전체의 1% 미만으로 줄어듭니다.
여러분이 이 코드를 사용하면 13GB 정도 필요했던 7B 모델을 3.5GB 정도로 로드할 수 있습니다. 실무에서의 이점은 (1) 저렴한 GPU로도 실험 가능, (2) 배치 크기를 늘려 학습 속도 향상, (3) 여러 모델을 동시에 메모리에 올려서 비교 실험 가능하다는 점입니다.
실전 팁
💡 4-bit 양자화는 모델 로딩 시간이 약간 더 걸립니다. 실험 초기에는 작은 모델로 먼저 테스트해보고, 코드가 안정화되면 대형 모델로 확장하세요.
💡 흔한 실수: bnb_4bit_compute_dtype을 설정하지 않으면 계산이 4-bit로 진행되어 성능이 크게 떨어집니다. 반드시 float16이나 bfloat16으로 설정하세요.
💡 메모리가 여전히 부족하다면 gradient_checkpointing=True를 추가하세요. 속도는 20% 정도 느려지지만 메모리를 30-40% 더 절약할 수 있습니다.
💡 학습 후 어댑터만 저장하면 수십 MB에 불과하므로, 여러 태스크용 어댑터를 만들어서 상황에 따라 바꿔 끼우는 식으로 활용하세요.
💡 nf4 타입은 대부분의 경우에 최적이지만, 가중치 분포가 특이한 모델이라면 fp4도 시도해보세요. 일부 모델에서는 fp4가 더 나은 결과를 보이기도 합니다.
2. 4-bit 양자화의 원리 - 숫자를 똑똑하게 압축하기
시작하며
여러분이 고화질 동영상을 친구에게 보낼 때, 용량이 너무 커서 압축하는 경우를 생각해보세요. 압축하면 파일 크기는 줄어들지만, 화질은 거의 그대로 유지됩니다.
4-bit 양자화도 정확히 같은 원리입니다. 일반적으로 신경망의 가중치는 32-bit 부동소수점으로 저장됩니다.
하나의 숫자를 표현하는데 4바이트가 필요하죠. 그런데 실제로 모든 가중치가 32-bit의 정밀도를 필요로 할까요?
연구 결과 대부분의 경우 그렇지 않다는 것이 밝혀졌습니다. 바로 이럴 때 필요한 것이 4-bit 양자화입니다.
똑같은 정보를 8분의 1 공간에 저장하면서도, 모델의 성능은 거의 떨어뜨리지 않는 마법 같은 기술입니다.
개요
간단히 말해서, 4-bit 양자화는 연속적인 부동소수점 값을 16개(2^4)의 이산적인 값으로 매핑하는 과정입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, GPU 메모리는 유한한 자원이고 비용도 비쌉니다.
예를 들어, 70B 파라미터 모델을 32-bit로 저장하면 280GB의 메모리가 필요하지만, 4-bit로 압축하면 35GB만 있어도 됩니다. A100 GPU 8장 대신 1장으로 해결할 수 있다는 뜻이죠.
기존에는 16-bit half-precision을 사용해서 메모리를 절반으로 줄이는 것이 일반적이었다면, 이제는 4-bit로 4분의 1로 더 줄일 수 있습니다. 4-bit 양자화의 핵심 특징은 두 가지입니다: (1) NormalFloat4는 정규분포에 최적화된 불균등 간격 양자화, (2) 블록 단위(보통 64~256개) 양자화로 정밀도 유지.
이러한 특징들이 왜 중요하냐면, 단순히 값을 줄이는 것이 아니라 중요한 정보는 최대한 보존하면서 압축하기 때문입니다.
코드 예제
import torch
import torch.nn as nn
from bitsandbytes.nn import Linear4bit
# 일반 Linear 레이어 (32-bit)
normal_layer = nn.Linear(4096, 4096)
print(f"일반 레이어 메모리: {normal_layer.weight.nbytes / 1024 / 1024:.2f} MB")
# 4-bit 양자화된 Linear 레이어
quantized_layer = Linear4bit(
4096, 4096,
bias=False,
compute_dtype=torch.float16,
compress_statistics=True, # 통계 정보도 압축
quant_type='nf4' # NormalFloat4 사용
)
print(f"양자화 레이어 메모리: {quantized_layer.weight.nbytes / 1024 / 1024:.2f} MB")
# 실제 추론 테스트
input_tensor = torch.randn(1, 4096, dtype=torch.float16).cuda()
output = quantized_layer(input_tensor)
print(f"출력 shape: {output.shape}")
설명
이것이 하는 일: 위 코드는 동일한 크기의 Linear 레이어를 일반 버전과 4-bit 양자화 버전으로 만들어 메모리 사용량을 비교합니다. 첫 번째로, nn.Linear(4096, 4096)은 일반적인 완전연결층을 생성합니다.
가중치 행렬 크기는 4096 × 4096 = 16,777,216개 파라미터입니다. 각각 4바이트(32-bit)이므로 총 64MB의 메모리를 사용합니다.
이것이 우리가 최적화하려는 대상입니다. 그 다음으로, Linear4bit로 동일한 레이어를 만들되 4-bit로 양자화합니다.
내부적으로 어떤 일이 일어나냐면, 가중치를 64개씩 블록으로 나누고, 각 블록마다 평균과 스케일 팩터를 계산합니다. 그리고 각 가중치를 0~15 사이의 인덱스로 변환합니다.
compress_statistics=True는 이 스케일 팩터들도 8-bit로 추가 압축합니다(이게 바로 Double Quantization입니다). 결과적으로 메모리 사용량이 약 8MB로 줄어듭니다.
마지막으로, 실제 추론 시에는 compute_dtype=torch.float16 설정 덕분에 계산은 16-bit로 진행됩니다. 입력이 들어오면 양자화된 가중치를 즉시 16-bit로 역양자화(dequantize)하고, 행렬 곱셈을 수행한 후 결과를 반환합니다.
이 역양자화 과정은 매우 빠르고 효율적이어서 전체 속도에는 큰 영향을 주지 않습니다. 여러분이 이 코드를 사용하면 메모리를 87.5% 절약할 수 있습니다.
실무에서의 이점은 (1) 더 큰 배치 크기로 학습 속도 향상, (2) 더 큰 모델을 같은 하드웨어에서 실행 가능, (3) 추론 서버에서 더 많은 요청 동시 처리 가능하다는 점입니다.
실전 팁
💡 블록 크기가 작을수록 정밀도는 높아지지만 메타데이터(스케일 팩터) 오버헤드가 커집니다. 기본값 64가 대부분의 경우 최적입니다.
💡 흔한 실수: 양자화된 레이어에 .cuda()를 직접 호출하면 에러가 납니다. 모델 로드 시 device_map으로 처리하거나, accelerate 라이브러리를 사용하세요.
💡 양자화 전후 성능을 비교할 때는 perplexity 같은 정량적 지표를 사용하세요. 눈으로 보기에는 비슷해도 실제로는 차이가 있을 수 있습니다.
💡 Conv2D 레이어도 양자화할 수 있지만, 비전 모델에서는 첫 번째와 마지막 레이어는 32-bit로 유지하는 것이 성능에 유리합니다.
💡 양자화는 가중치에만 적용되고 활성화(activation)는 여전히 16-bit입니다. 활성화까지 양자화하려면 PTQ(Post-Training Quantization)를 추가로 적용해야 합니다.
3. LoRA와의 결합 - 학습 효율을 극대화하는 듀오
시작하며
여러분이 거대한 백과사전을 편집한다고 상상해보세요. 전체를 다시 쓰는 대신, 중요한 페이지에만 포스트잇을 붙여서 내용을 추가하면 훨씬 효율적이겠죠?
LoRA가 바로 그런 역할을 합니다. 대형 언어 모델을 파인튜닝할 때 가장 큰 문제는 수십억 개의 파라미터를 모두 업데이트해야 한다는 점입니다.
이는 엄청난 메모리와 계산량을 요구하고, 과적합(overfitting)의 위험도 큽니다. 특히 데이터셋이 작을 때는 더욱 그렇습니다.
바로 이럴 때 필요한 것이 LoRA(Low-Rank Adaptation)와 QLoRA의 결합입니다. 4-bit로 압축된 기본 모델에 작은 어댑터만 추가로 학습하면, 전체 파인튜닝의 99% 성능을 1%의 파라미터로 달성할 수 있습니다.
개요
간단히 말해서, LoRA는 대형 가중치 행렬의 업데이트를 두 개의 작은 행렬의 곱으로 근사하는 기법입니다. 수학적으로는 W + ΔW ≈ W + BA 형태입니다.
왜 이 개념이 필요한지 실무 관점에서 설명하면, 실제로 파인튜닝 중에 가중치의 변화량(ΔW)은 대부분 낮은 랭크(rank)를 가집니다. 예를 들어, 고객 상담 데이터로 챗봇을 학습시킬 때, 모델의 모든 지식을 바꿀 필요 없이 특정 패턴과 어휘만 추가하면 됩니다.
4096×4096 행렬의 변화를 4096×16과 16×4096 두 개의 작은 행렬로 표현할 수 있습니다. 기존에는 전체 모델을 복사해서 학습하고 저장했다면(수십 GB), 이제는 어댑터 파라미터만 학습하고 저장할 수 있습니다(수십 MB).
LoRA와 QLoRA 결합의 핵심 특징은 세 가지입니다: (1) 기본 모델은 4-bit로 얼림(frozen), (2) 어댑터만 16/32-bit로 학습, (3) 추론 시 어댑터를 병합하거나 교체 가능. 이러한 특징들이 왜 중요하냐면, 하나의 기본 모델로 여러 태스크를 위한 어댑터를 만들어서 상황에 따라 바꿔 사용할 수 있기 때문입니다.
코드 예제
from peft import LoraConfig, get_peft_model, TaskType
from transformers import AutoModelForCausalLM
import torch
# 4-bit 양자화된 모델 로드 (이전 예제에서 생성)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b-hf",
load_in_4bit=True,
device_map="auto"
)
# LoRA 설정
lora_config = LoraConfig(
r=16, # Low-rank 차원 (행렬 A와 B의 중간 차원)
lora_alpha=32, # 스케일링 파라미터 (보통 r의 2배)
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], # 어댑터를 붙일 레이어
lora_dropout=0.05, # 과적합 방지
bias="none", # bias는 학습하지 않음
task_type=TaskType.CAUSAL_LM # 언어 모델 태스크
)
# 모델에 LoRA 어댑터 추가
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 출력 예: trainable params: 4,194,304 || all params: 6,742,609,920 || trainable%: 0.062%
설명
이것이 하는 일: 위 코드는 4-bit 양자화된 모델에 LoRA 어댑터를 추가하여, 전체의 0.062%만 학습 가능하게 만듭니다. 첫 번째로, LoraConfig에서 r=16은 랭크를 의미합니다.
예를 들어 원래 4096×4096 행렬을 업데이트하려면 16,777,216개 파라미터가 필요하지만, LoRA는 4096×16 행렬 A와 16×4096 행렬 B로 분해합니다. 총 131,072개 파라미터만으로 비슷한 효과를 냅니다.
왜 이렇게 하냐면, 파인튜닝에서 필요한 변화는 대부분 낮은 차원의 부분공간에 집중되어 있기 때문입니다. 그 다음으로, target_modules는 어디에 어댑터를 붙일지 지정합니다.
Transformer의 Attention 메커니즘에는 Q(Query), K(Key), V(Value), O(Output) 네 개의 projection이 있는데, 이들에만 어댑터를 붙입니다. 내부적으로 forward pass 시 원래 가중치(4-bit, frozen) 연산 결과에 어댑터(16-bit, trainable) 연산 결과를 더합니다: output = W_frozen(x) + (B @ A)(x) 형태입니다.
lora_alpha=32는 어댑터의 영향력을 조절하는 스케일링 파라미터로, 실제로는 (alpha/r) 비율로 스케일됩니다. 마지막으로, get_peft_model이 실제로 어댑터 행렬들을 초기화하고 모델에 주입합니다.
print_trainable_parameters()를 보면 전체 67억 개 중 419만 개만 학습 가능한 것을 확인할 수 있습니다. 학습 시에는 이 419만 개에 대한 그래디언트만 계산하고 업데이트하므로, 메모리와 계산량이 크게 줄어듭니다.
여러분이 이 코드를 사용하면 파인튜닝 속도가 3배 이상 빠르고, 필요한 메모리는 4분의 1 수준입니다. 실무에서의 이점은 (1) 여러 태스크용 어댑터를 동시에 학습 가능, (2) 어댑터 파일이 작아서 배포와 버전 관리가 쉬움, (3) 과적합 위험이 낮아 작은 데이터셋에도 효과적이라는 점입니다.
실전 팁
💡 r 값은 보통 8, 16, 32 중 선택합니다. 복잡한 태스크일수록 큰 값이 좋지만, 32를 넘으면 효과가 미미하고 오히려 과적합 위험이 커집니다.
💡 흔한 실수: lora_alpha를 r보다 작게 설정하면 어댑터의 영향력이 너무 약해서 학습이 잘 안 됩니다. 보통 r의 2배로 설정하세요.
💡 FFN(Feed-Forward Network) 레이어에도 어댑터를 붙일 수 있지만, Attention 레이어만으로도 충분한 경우가 많습니다. 실험해보고 결정하세요.
💡 학습 후 model.merge_and_unload()를 호출하면 어댑터가 기본 모델에 병합됩니다. 배포 시 추론 속도가 약간 빨라지지만, 다른 어댑터로 교체할 수 없게 됩니다.
💡 여러 어댑터를 만들었다면 model.load_adapter("adapter_name")로 런타임에 바꿔 끼울 수 있습니다. 멀티태스킹 시스템에 유용합니다.
4. Double Quantization - 메타데이터까지 압축하기
시작하며
여러분이 압축 파일을 다시 압축해본 적 있나요? ZIP 파일을 다시 ZIP으로 만들면 크기가 더 줄어들 때가 있습니다.
Double Quantization도 비슷한 아이디어입니다. 4-bit 양자화를 할 때 각 블록마다 스케일 팩터(scale factor)와 제로 포인트(zero point) 같은 메타데이터를 저장해야 합니다.
이 메타데이터는 32-bit로 저장되는데, 블록이 많아지면 이것도 상당한 메모리를 차지합니다. 예를 들어 70B 모델을 64개 블록 크기로 양자화하면 약 10억 개의 블록이 생기고, 메타데이터만 4GB가 넘습니다.
바로 이럴 때 필요한 것이 Double Quantization입니다. 메타데이터 자체를 다시 8-bit로 양자화하면, 추가로 0.5GB 정도를 절약할 수 있습니다.
작아 보일 수 있지만, 메모리가 빠듯할 때는 생명줄이 될 수 있습니다.
개요
간단히 말해서, Double Quantization은 1차 양자화의 양자화 파라미터를 2차로 다시 양자화하는 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 4-bit 양자화만으로는 충분하지 않은 경우가 있습니다.
예를 들어, 65B 모델을 24GB GPU에 올려야 하는데, 4-bit 양자화만 하면 26GB가 필요하다면 2GB를 더 줄여야 합니다. Double Quantization으로 바로 이 2GB를 절약할 수 있습니다.
기존에는 스케일 팩터를 32-bit float으로 저장했다면, 이제는 8-bit integer로 저장하고 필요할 때만 복원할 수 있습니다. Double Quantization의 핵심 특징은 두 가지입니다: (1) 1차 스케일 팩터를 블록 단위로 묶어서 2차 양자화, (2) 성능 손실은 거의 없으면서 메모리 약 0.5GB 추가 절약.
이러한 특징들이 왜 중요하냐면, 메모리 한계에 걸려서 실행조차 못 하는 상황을 해결해주기 때문입니다.
코드 예제
from transformers import BitsAndBytesConfig
import torch
# Double Quantization 없이
config_single = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=False, # 비활성화
)
# Double Quantization 활성화
config_double = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True, # 활성화
bnb_4bit_quant_storage=torch.uint8, # 2차 양자화는 8-bit로
)
# 모델 로드 및 메모리 사용량 비교
from transformers import AutoModelForCausalLM
model_single = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b-hf",
quantization_config=config_single,
device_map="auto"
)
print(f"Single Quant 메모리: {torch.cuda.memory_allocated() / 1024**3:.2f} GB")
설명
이것이 하는 일: 위 코드는 Double Quantization 유무에 따른 메모리 사용량을 비교합니다. 첫 번째로, bnb_4bit_use_double_quant=False는 기본 4-bit 양자화만 수행합니다.
각 블록(보통 64개 가중치)마다 하나의 32-bit 스케일 팩터를 저장합니다. 7B 모델은 약 1억 개 블록이 있으므로, 스케일 팩터만 400MB 정도를 차지합니다.
이것이 우리가 더 압축하려는 대상입니다. 그 다음으로, bnb_4bit_use_double_quant=True로 설정하면 추가 압축이 일어납니다.
내부적으로 어떤 일이 일어나냐면, 1차 스케일 팩터들을 다시 256개씩 묶어서 블록을 만듭니다. 각 2차 블록마다 평균과 스케일을 계산하고, 1차 스케일 팩터들을 0~255 사이의 8-bit 정수로 인코딩합니다.
이렇게 하면 스케일 팩터 저장 공간이 32-bit에서 8-bit로 줄어들어, 400MB가 100MB로 감소합니다. 마지막으로, 실제 추론 시에는 2단계 역양자화가 일어납니다.
먼저 2차 양자화 파라미터로 1차 스케일 팩터를 복원하고, 그 스케일 팩터로 4-bit 가중치를 복원합니다. 이 과정이 복잡해 보이지만 실제로는 매우 빠르게 진행되며, 추론 속도에는 1% 미만의 오버헤드만 발생합니다.
여러분이 이 코드를 사용하면 7B 모델에서 약 300MB, 70B 모델에서는 약 3GB를 추가로 절약할 수 있습니다. 실무에서의 이점은 (1) 메모리 한계로 못 돌리던 모델을 실행 가능, (2) 배치 크기를 조금 더 늘려 처리량 향상, (3) 멀티 GPU 환경에서 GPU당 모델 크기 증가 가능하다는 점입니다.
실전 팁
💡 Double Quantization은 큰 모델일수록 효과가 큽니다. 7B 이하 모델에서는 효과가 미미하니 필요할 때만 사용하세요.
💡 흔한 실수: quant_storage를 uint16이나 int8로 설정하는 경우가 있는데, uint8(부호 없는 8-bit)이 표준이고 가장 효율적입니다.
💡 2차 양자화 블록 크기는 기본값(256)이 최적입니다. 바꾸려면 고급 설정이 필요하고, 대부분의 경우 이득이 없습니다.
💡 메모리 절약은 확실하지만 양자화 시간이 10~20% 늘어납니다. 모델을 자주 로드해야 한다면 한 번 양자화한 후 저장해두고 재사용하세요.
💡 성능 검증 시 단순 정확도뿐 아니라 perplexity, BLEU 등 여러 지표를 확인하세요. 드물게 특정 태스크에서 성능 저하가 있을 수 있습니다.
5. Paged Optimizers - 메모리 스파이크 방지하기
시작하며
여러분이 컴퓨터로 작업할 때 갑자기 메모리 부족으로 프로그램이 꺼진 경험 있나요? 대형 모델 학습에서도 똑같은 일이 일어납니다.
평소에는 메모리가 충분한데, 특정 순간에 갑자기 메모리가 폭증해서 학습이 중단되는 경우가 있습니다. 옵티마이저(optimizer)는 학습 중에 모델 파라미터만큼이나 많은 메모리를 사용합니다.
Adam 옵티마이저는 각 파라미터마다 2개의 상태(momentum과 variance)를 저장하므로, 7B 모델이라면 추가로 14B 파라미터 분량의 메모리가 필요합니다. 게다가 그래디언트까지 합치면 총 3배의 메모리가 필요하죠.
바로 이럴 때 필요한 것이 Paged Optimizers입니다. CPU RAM을 활용해서 메모리 스파이크를 흡수하므로, Out of Memory 에러 없이 안정적으로 학습할 수 있습니다.
개요
간단히 말해서, Paged Optimizers는 옵티마이저 상태를 CPU와 GPU 메모리 사이에서 자동으로 페이징하는 기술입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, GPU 메모리는 한정적이고 비쌉니다.
예를 들어, 24GB GPU로 13B 모델을 학습시킬 때, QLoRA로 모델은 4GB만 차지하지만 옵티마이저 상태가 12GB를 차지할 수 있습니다. 배치 크기를 조금만 늘려도 OOM이 발생하죠.
Paged Optimizers는 사용하지 않는 옵티마이저 상태를 CPU로 옮겨서 GPU 메모리를 확보합니다. 기존에는 옵티마이저 상태를 모두 GPU에 올려야 했다면, 이제는 필요할 때만 GPU로 가져오고 나머지는 CPU에 둘 수 있습니다.
Paged Optimizers의 핵심 특징은 두 가지입니다: (1) NVIDIA Unified Memory와 유사한 자동 페이징 메커니즘, (2) 투명하게 작동하므로 코드 변경 최소화. 이러한 특징들이 왜 중요하냐면, 개발자가 메모리 관리를 신경 쓰지 않아도 자동으로 최적화되기 때문입니다.
코드 예제
import torch
from transformers import TrainingArguments, Trainer
from peft import prepare_model_for_kbit_training
import bitsandbytes as bnb
# QLoRA로 준비된 모델 (이전 예제에서)
model = prepare_model_for_kbit_training(model)
# 일반 AdamW 옵티마이저 (메모리 많이 사용)
# optimizer = torch.optim.AdamW(model.parameters(), lr=2e-4)
# Paged AdamW 옵티마이저 (메모리 효율적)
optimizer = bnb.optim.PagedAdamW(
model.parameters(),
lr=2e-4,
betas=(0.9, 0.999),
eps=1e-8,
optim_bits=8, # 옵티마이저 상태도 8-bit로 저장 (선택사항)
)
# TrainingArguments에서 사용
training_args = TrainingArguments(
output_dir="./qlora_output",
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
num_train_epochs=3,
learning_rate=2e-4,
fp16=True, # Mixed precision
logging_steps=10,
optim="paged_adamw_8bit", # 또는 여기서 직접 지정
)
설명
이것이 하는 일: 위 코드는 메모리 효율적인 Paged AdamW 옵티마이저를 설정하여 학습 중 OOM을 방지합니다. 첫 번째로, 일반 torch.optim.AdamW는 모든 옵티마이저 상태를 GPU 메모리에 저장합니다.
Adam은 각 파라미터당 first moment(momentum)와 second moment(variance) 두 개의 32-bit 텐서를 유지하므로, 파라미터가 4M개라면 옵티마이저만 32MB가 필요합니다. QLoRA로 학습 가능한 파라미터가 수천만 개라면 수백 MB에서 수 GB의 메모리를 차지하게 됩니다.
그 다음으로, bnb.optim.PagedAdamW는 특별한 페이징 메커니즘을 사용합니다. 내부적으로 어떤 일이 일어나냐면, 옵티마이저 상태를 페이지 단위(보통 2MB)로 나누고, CUDA Unified Memory API를 활용해서 필요할 때만 GPU로 가져옵니다.
예를 들어 현재 미니배치에 필요한 파라미터의 옵티마이저 상태만 GPU에 있고, 나머지는 CPU RAM에 있습니다. 그래디언트 업데이트 시 자동으로 필요한 페이지를 GPU로 가져오고, 사용이 끝나면 다시 CPU로 내립니다.
마지막으로, optim_bits=8 옵션은 옵티마이저 상태를 8-bit로 양자화해서 추가로 메모리를 절약합니다. 일반적으로 32-bit 정밀도가 필요하지 않고, 8-bit로도 학습이 안정적으로 진행됩니다.
이렇게 하면 페이징 오버헤드가 더 줄어들어 CPU-GPU 전송량이 4분의 1로 감소합니다. 여러분이 이 코드를 사용하면 배치 크기를 2~3배 늘릴 수 있고, 메모리 에러로 인한 학습 중단을 거의 겪지 않게 됩니다.
실무에서의 이점은 (1) 더 큰 배치로 학습 속도와 안정성 향상, (2) 멀티 GPU 학습 시 GPU당 효율 증가, (3) 메모리 튜닝에 시간 낭비하지 않고 하이퍼파라미터 최적화에 집중 가능하다는 점입니다.
실전 팁
💡 Paged Optimizer는 CPU RAM이 충분할 때 효과적입니다. RAM이 부족하면 swap이 발생해서 오히려 느려질 수 있으니, 최소 32GB 이상 권장합니다.
💡 흔한 실수: TrainingArguments에서 optim을 설정했는데 별도로 optimizer를 생성하면 충돌합니다. 둘 중 하나만 사용하세요.
💡 학습 초반에 GPU 사용률이 100%가 아니라 70~80%라면 페이징 오버헤드 때문일 수 있습니다. 정상이므로 걱정하지 마세요.
💡 FP16 mixed precision과 함께 사용하면 시너지가 큽니다. 그래디언트도 16-bit로 저장되어 메모리와 전송 대역폭이 모두 줄어듭니다.
💡 Gradient checkpointing과 함께 사용하면 활성화 메모리도 절약할 수 있습니다. 속도는 약간 느려지지만 메모리 여유가 생겨서 배치 크기를 더 늘릴 수 있습니다.
6. 실전 파인튜닝 예제 - 챗봇 만들기
시작하며
여러분이 회사 내부 데이터로 맞춤형 AI 챗봇을 만들고 싶다면, 지금까지 배운 QLoRA 기술을 어떻게 활용할까요? 이론을 실전에 적용하는 단계입니다.
실제 비즈니스 환경에서는 고객 상담 로그, 제품 FAQ, 기술 문서 등 도메인 특화 데이터가 있습니다. 이 데이터로 범용 LLM을 파인튜닝하면 훨씬 정확하고 유용한 답변을 생성할 수 있죠.
하지만 수백 GB GPU 클러스터가 없다면 어떻게 할까요? 바로 이럴 때 필요한 것이 QLoRA 파인튜닝 파이프라인입니다.
일반 노트북이나 소형 워크스테이션에서도 며칠 안에 고품질 챗봇을 만들 수 있습니다.
개요
간단히 말해서, 실전 파인튜닝은 데이터 준비부터 학습, 평가, 배포까지 전체 프로세스를 의미합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 단순히 모델을 로드하고 학습 버튼을 누르는 것만으로는 부족합니다.
예를 들어, 고객 상담 챗봇을 만든다면 대화 형식으로 데이터를 구조화하고, 적절한 프롬프트 템플릿을 설정하고, 답변 품질을 정량적으로 평가하는 과정이 필요합니다. 기존에는 이런 프로세스를 구축하는 데만 수주가 걸렸다면, 이제는 Hugging Face의 TRL(Transformer Reinforcement Learning) 라이브러리와 QLoRA를 결합해서 하루 만에 프로토타입을 만들 수 있습니다.
실전 파인튜닝의 핵심 특징은 세 가지입니다: (1) 데이터셋 포맷팅과 프롬프트 엔지니어링, (2) SFTTrainer로 간편한 학습 파이프라인 구축, (3) 학습 후 merge와 quantize로 배포 준비. 이러한 특징들이 왜 중요하냐면, 실제로 서비스에 적용할 수 있는 수준의 모델을 빠르게 만들 수 있기 때문입니다.
코드 예제
from datasets import load_dataset
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, prepare_model_for_kbit_training, get_peft_model
from trl import SFTTrainer, DataCollatorForCompletionOnlyLM
import torch
# 1. 데이터 준비 (Alpaca 형식)
dataset = load_dataset("json", data_files="customer_support.json")
# 2. 4-bit 모델 로드
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16, bnb_4bit_use_double_quant=True
)
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-chat-hf", quantization_config=bnb_config)
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-chat-hf")
tokenizer.pad_token = tokenizer.eos_token
# 3. LoRA 설정
model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(r=16, lora_alpha=32, target_modules=["q_proj", "v_proj"], lora_dropout=0.05)
model = get_peft_model(model, lora_config)
# 4. 학습 (응답 부분만 loss 계산)
trainer = SFTTrainer(
model=model, train_dataset=dataset["train"], tokenizer=tokenizer,
max_seq_length=512, packing=True,
args={"output_dir": "./output", "num_train_epochs": 3, "per_device_train_batch_size": 4}
)
trainer.train()
model.save_pretrained("./qlora_chatbot")
설명
이것이 하는 일: 위 코드는 고객 상담 데이터로 LLaMA-2 모델을 파인튜닝하여 맞춤형 챗봇을 만듭니다. 첫 번째로, load_dataset으로 JSON 형식의 대화 데이터를 로드합니다.
데이터는 보통 {"instruction": "문의", "input": "추가 정보", "output": "답변"} 형식입니다. SFTTrainer는 이 구조를 자동으로 인식하고 프롬프트 템플릿을 적용합니다.
예를 들어 "### Instruction: {instruction}\n### Input: {input}\n### Response: {output}" 같은 형태로 변환됩니다. 왜 이렇게 하냐면, 모델이 어디까지가 질문이고 어디부터가 답변인지 명확히 학습하게 하기 위함입니다.
그 다음으로, QLoRA 설정을 적용합니다(2~3단계). 이전에 배운 대로 4-bit 양자화, Double Quantization, LoRA 어댑터를 설정합니다.
내부적으로 prepare_model_for_kbit_training은 그래디언트 체크포인팅을 활성화하고, 입력 임베딩 레이어를 float32로 유지하는 등의 최적화를 수행합니다. 마지막으로, SFTTrainer가 실제 학습을 진행합니다.
중요한 옵션은 packing=True인데, 이것은 여러 짧은 샘플을 하나의 시퀀스로 묶어서 GPU 활용률을 높입니다. 예를 들어 max_seq_length=512인데 대부분의 대화가 200 토큰이라면, 2~3개를 묶어서 하나의 배치로 만듭니다.
학습이 완료되면 save_pretrained로 LoRA 어댑터만 저장합니다(보통 20~50MB). 나중에 base_model + adapter로 로드하거나, merge_and_unload()로 합쳐서 배포할 수 있습니다.
여러분이 이 코드를 사용하면 5,000~10,000개 정도의 대화 샘플로도 도메인 특화 챗봇을 만들 수 있습니다. 실무에서의 이점은 (1) 학습 시간이 수 시간에서 하루 정도로 짧음, (2) 과적합 위험이 낮아 소규모 데이터셋에도 효과적, (3) 어댑터가 작아서 여러 버전을 실험하고 A/B 테스트하기 쉽다는 점입니다.
실전 팁
💡 데이터 품질이 가장 중요합니다. 100개의 고품질 대화가 1,000개의 저품질 데이터보다 낫습니다. 전처리와 검수에 시간을 투자하세요.
💡 흔한 실수: pad_token을 설정하지 않으면 배치 처리 시 에러가 납니다. tokenizer.pad_token = tokenizer.eos_token은 필수입니다.
💡 과적합을 확인하려면 validation set을 분리하고, eval_steps마다 perplexity를 모니터링하세요. 학습 loss는 계속 줄어도 validation loss가 올라가면 조기 종료하세요.
💡 학습 후 실제 대화를 시도해보고, 답변이 이상하다면 프롬프트 템플릿을 조정하세요. "### Response:" 같은 구분자가 명확해야 합니다.
💡 배포 시 vLLM이나 TGI(Text Generation Inference) 같은 추론 최적화 엔진을 사용하면 응답 속도가 5~10배 빨라집니다.
7. 추론 최적화 - 빠른 응답 만들기
시작하며
여러분이 만든 챗봇을 사용자에게 서비스한다고 상상해보세요. 질문에 답변하는 데 30초씩 걸린다면 아무도 사용하지 않겠죠?
학습만큼이나 추론 최적화도 중요합니다. 대형 언어 모델의 추론은 계산량이 많고 순차적입니다.
한 토큰을 생성하려면 전체 모델을 한 번 통과해야 하고, 응답이 100 토큰이라면 100번 반복해야 합니다. 배치 처리로 여러 요청을 동시에 처리하려 해도, 시퀀스 길이가 다르면 효율이 떨어집니다.
바로 이럴 때 필요한 것이 QLoRA 모델의 추론 최적화 기법들입니다. KV 캐싱, 동적 배칭, 어댑터 병합 등을 활용하면 응답 속도를 5~10배 높일 수 있습니다.
개요
간단히 말해서, 추론 최적화는 생성 품질은 유지하면서 속도와 처리량을 극대화하는 기술들의 집합입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 서비스 품질은 정확도뿐 아니라 응답 시간에도 좌우됩니다.
예를 들어, 고객 상담 챗봇이 정확하지만 30초씩 걸린다면 사용자 만족도가 낮습니다. 반면 3초 안에 답변한다면 실시간 대화처럼 느껴져서 경험이 크게 개선됩니다.
기존에는 추론 속도를 높이려면 더 작은 모델을 써야 했다면, 이제는 같은 모델로도 최적화 기법만으로 10배 빨라질 수 있습니다. 추론 최적화의 핵심 특징은 세 가지입니다: (1) KV 캐시로 중복 계산 제거, (2) Flash Attention으로 메모리와 속도 개선, (3) LoRA 어댑터 병합으로 오버헤드 제거.
이러한 특징들이 왜 중요하냐면, 사용자 경험을 결정하는 핵심 요소이기 때문입니다.
코드 예제
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
# 1. 모델과 어댑터 로드
base_model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b-chat-hf",
load_in_4bit=True,
device_map="auto",
torch_dtype=torch.float16,
)
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-chat-hf")
# 2. LoRA 어댑터 로드 및 병합 (추론 속도 향상)
model = PeftModel.from_pretrained(base_model, "./qlora_chatbot")
model = model.merge_and_unload() # 어댑터를 base model에 병합
model.eval() # 평가 모드
# 3. 최적화된 추론 설정
def generate_response(prompt, max_new_tokens=256):
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
with torch.inference_mode(): # 그래디언트 계산 비활성화
outputs = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=True,
temperature=0.7,
top_p=0.9,
use_cache=True, # KV 캐시 활성화 (중요!)
pad_token_id=tokenizer.eos_token_id,
)
return tokenizer.decode(outputs[0], skip_special_tokens=True)
# 사용 예시
response = generate_response("고객님의 주문 상태를 확인하는 방법은?")
설명
이것이 하는 일: 위 코드는 학습된 QLoRA 모델을 추론에 최적화하여 빠른 응답 속도를 달성합니다. 첫 번째로, 모델 로드 시 torch_dtype=torch.float16을 명시합니다.
4-bit 가중치는 계산 시 16-bit로 변환되는데, 명시하지 않으면 32-bit로 변환되어 메모리와 속도가 모두 나빠집니다. 왜 이렇게 하냐면, 최신 GPU는 16-bit 연산(Tensor Core)이 32-bit보다 훨씬 빠르기 때문입니다.
그 다음으로, merge_and_unload()가 매우 중요합니다. 내부적으로 어떤 일이 일어나냐면, LoRA 어댑터 행렬(B @ A)을 기본 가중치(W)에 더해서 새로운 가중치(W' = W + BA)를 만듭니다.
추론할 때마다 W(x) + BA(x)를 따로 계산하는 대신, W'(x) 한 번만 계산하면 되므로 속도가 20~30% 빨라집니다. 단점은 다른 어댑터로 바꿀 수 없다는 점이지만, 프로덕션 환경에서는 보통 하나의 어댑터만 사용하므로 문제없습니다.
마지막으로, generate 함수의 use_cache=True가 핵심입니다. Transformer는 각 토큰을 생성할 때 이전 토큰들의 Key와 Value를 다시 계산합니다.
KV 캐시는 이를 메모리에 저장했다가 재사용하므로, 계산량이 O(n²)에서 O(n)으로 줄어듭니다. 100 토큰 응답을 생성할 때 약 5배 빠릅니다.
torch.inference_mode()는 autograd 엔진을 완전히 끄고 메모리 풀을 최적화해서 추가로 10~20% 속도 향상을 가져옵니다. 여러분이 이 코드를 사용하면 토큰당 생성 시간이 100ms에서 20ms로 줄어들어, 전체 응답 시간이 10초에서 2초로 단축됩니다.
실무에서의 이점은 (1) 사용자가 체감하는 응답성 크게 향상, (2) 같은 GPU로 더 많은 동시 사용자 처리 가능, (3) 서버 비용 절감 및 서비스 확장성 개선입니다.
실전 팁
💡 use_cache=True는 메모리를 더 사용합니다. 긴 컨텍스트(2K 토큰 이상)에서는 메모리 부족이 발생할 수 있으니, max_length를 제한하세요.
💡 흔한 실수: merge_and_unload() 후에는 원본 어댑터 파일이 필요 없습니다. 병합된 모델을 저장하면 나중에 더 빠르게 로드할 수 있습니다.
💡 배치 추론 시 dynamic padding을 사용하세요. 모든 시퀀스를 max_length로 패딩하면 짧은 입력도 느려집니다.
💡 Flash Attention 2를 활성화하려면 pip install flash-attn 후 model_kwargs={"attn_implementation": "flash_attention_2"}를 추가하세요. 메모리와 속도 모두 개선됩니다.
💡 프로덕션에서는 vLLM이나 TGI 같은 전용 추론 엔진을 고려하세요. Continuous batching으로 처리량을 10배 이상 높일 수 있습니다.
8. 메모리 프로파일링 - 병목 지점 찾기
시작하며
여러분이 "CUDA out of memory" 에러를 받았을 때, 정확히 어디서 얼마나 메모리를 사용하는지 알고 싶지 않나요? 메모리 최적화는 측정에서 시작합니다.
딥러닝 학습에서 메모리는 모델 가중치, 옵티마이저 상태, 그래디언트, 활성화(activation) 등 여러 곳에서 사용됩니다. 각각이 전체의 몇 퍼센트를 차지하는지 모르면 최적화 방향을 잡기 어렵죠.
예를 들어 활성화가 50%를 차지한다면 gradient checkpointing이 효과적이지만, 옵티마이저가 50%라면 8-bit optimizer가 더 유용합니다. 바로 이럴 때 필요한 것이 메모리 프로파일링입니다.
PyTorch와 NVIDIA의 도구들로 메모리 사용량을 정확히 측정하고, 병목 지점을 찾아낼 수 있습니다.
개요
간단히 말해서, 메모리 프로파일링은 GPU 메모리 사용을 시간과 컴포넌트별로 추적하고 분석하는 과정입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 최적화는 가설이 아니라 데이터에 기반해야 합니다.
예를 들어, "배치 크기를 늘리면 OOM이 난다"는 것은 알지만 정확히 어느 레이어에서 얼마나 부족한지 모르면 해결책을 찾기 어렵습니다. 프로파일링으로 정량적 데이터를 얻으면 정확한 최적화가 가능합니다.
기존에는 nvidia-smi로 전체 메모리만 확인했다면, 이제는 컴포넌트별, 레이어별 상세한 분석이 가능합니다. 메모리 프로파일링의 핵심 특징은 세 가지입니다: (1) torch.cuda.memory_summary()로 상세한 할당 내역 확인, (2) PyTorch Profiler로 시간별 메모리 변화 추적, (3) 병목 지점 식별 후 타겟 최적화.
이러한 특징들이 왜 중요하냐면, 추측 대신 데이터 기반으로 최적화할 수 있기 때문입니다.
코드 예제
import torch
from transformers import AutoModelForCausalLM
from peft import get_peft_model, LoraConfig
# GPU 메모리 초기화
torch.cuda.empty_cache()
torch.cuda.reset_peak_memory_stats()
print(f"초기 메모리: {torch.cuda.memory_allocated() / 1024**3:.2f} GB")
# 모델 로드
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b-hf", load_in_4bit=True, device_map="auto"
)
print(f"모델 로드 후: {torch.cuda.memory_allocated() / 1024**3:.2f} GB")
# LoRA 추가
lora_config = LoraConfig(r=16, lora_alpha=32, target_modules=["q_proj", "v_proj"])
model = get_peft_model(model, lora_config)
print(f"LoRA 추가 후: {torch.cuda.memory_allocated() / 1024**3:.2f} GB")
# Forward pass
dummy_input = torch.randint(0, 32000, (4, 512)).cuda() # 배치 4, 길이 512
outputs = model(dummy_input, labels=dummy_input)
print(f"Forward 후: {torch.cuda.memory_allocated() / 1024**3:.2f} GB")
print(f"피크 메모리: {torch.cuda.max_memory_allocated() / 1024**3:.2f} GB")
# 상세 메모리 분석
print(torch.cuda.memory_summary())
설명
이것이 하는 일: 위 코드는 QLoRA 모델의 각 단계별 메모리 사용량을 측정하고 병목 지점을 찾습니다. 첫 번째로, empty_cache()와 reset_peak_memory_stats()로 이전 실행의 잔여 메모리를 정리합니다.
GPU 메모리는 한 번 할당되면 해제되어도 CUDA가 계속 점유하고 있어서, 정확한 측정을 위해 명시적으로 비워야 합니다. 왜 이렇게 하냐면, 이전 실험의 캐시가 섞여있으면 현재 모델의 실제 사용량을 알 수 없기 때문입니다.
그 다음으로, 각 단계마다 memory_allocated()를 호출해서 메모리 증가량을 추적합니다. 내부적으로 어떤 일이 일어나냐면, 모델 로드 시에는 가중치만 할당되고(약 3.5GB for 7B 4-bit), LoRA 추가 시에는 어댑터 파라미터(약 32MB)가 추가됩니다.
Forward pass에서는 입력 임베딩, 각 레이어의 활성화, attention 중간 결과 등이 누적되어 메모리가 크게 증가합니다(약 2~4GB). 이 중 어느 부분이 가장 큰지 알면 최적화 방향을 정할 수 있습니다.
마지막으로, max_memory_allocated()는 피크 사용량을 보여줍니다. 평균이 10GB인데 피크가 20GB라면, 특정 순간에 메모리 스파이크가 있다는 뜻입니다.
memory_summary()는 더 상세한 정보를 제공하는데, 텐서 크기별, 레이어별 할당 내역을 확인할 수 있습니다. 예를 들어 "10개의 4GB 텐서"가 있다면 활성화 메모리가 문제이고, gradient checkpointing이 해법입니다.
여러분이 이 코드를 사용하면 "왜 OOM이 나는지" 정확히 알 수 있습니다. 실무에서의 이점은 (1) 시행착오 없이 정확한 최적화 방법 선택, (2) 배치 크기나 시퀀스 길이 한계를 사전에 계산 가능, (3) 팀원들과 정량적 데이터로 소통 가능하다는 점입니다.
실전 팁
💡 메모리 프로파일링은 학습 초기 한 번만 하세요. 매 스텝마다 하면 오버헤드가 커서 학습이 느려집니다.
💡 흔한 실수: Jupyter 노트북에서는 이전 셀의 변수가 남아있어서 정확한 측정이 어렵습니다. 커널을 재시작하고 처음부터 실행하세요.
💡 멀티 GPU 환경에서는 각 GPU마다 torch.cuda.memory_allocated(device_id)로 개별 측정해야 합니다. device_map="auto"는 불균등하게 배분할 수 있습니다.
💡 활성화 메모리가 크다면 model.gradient_checkpointing_enable()을 시도하세요. 속도는 20% 느려지지만 메모리는 50% 절약됩니다.
💡 NVIDIA Nsight Systems나 PyTorch Profiler로 타임라인 시각화를 하면 어느 구간에서 메모리가 급증하는지 한눈에 파악할 수 있습니다.
9. 데이터셋 준비 - 고품질 학습 데이터 만들기
시작하며
여러분이 "garbage in, garbage out"이라는 말을 들어봤나요? 아무리 좋은 모델과 최적화 기법을 써도, 데이터가 나쁘면 결과도 나쁩니다.
데이터 품질이 곧 모델 품질입니다. 실제 비즈니스 데이터는 정제되지 않은 상태가 대부분입니다.
고객 상담 로그에는 오타, 비속어, 불완전한 문장이 가득하고, 중복된 내용도 많습니다. FAQ는 형식이 제각각이고, 일부는 오래되어 잘못된 정보를 담고 있죠.
이런 데이터를 그대로 학습시키면 모델도 같은 문제를 학습합니다. 바로 이럴 때 필요한 것이 체계적인 데이터셋 준비 과정입니다.
정제, 포맷팅, 검증, 분할까지 제대로 하면 같은 모델로도 성능이 2배 이상 좋아질 수 있습니다.
개요
간단히 말해서, 데이터셋 준비는 원시 데이터를 모델이 효과적으로 학습할 수 있는 형태로 변환하는 전체 파이프라인입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, LLM 파인튜닝은 supervised learning입니다.
예를 들어, "질문: 배송은 얼마나 걸리나요? 답변: 보통 2-3일입니다"라는 데이터를 학습하려면, 이것을 모델이 이해하는 프롬프트 형식으로 변환하고, 답변 부분만 loss를 계산하도록 마스킹해야 합니다.
잘못된 형식은 학습을 방해하거나 무의미하게 만듭니다. 기존에는 데이터 전처리에 수작업이 많이 필요했다면, 이제는 Hugging Face datasets 라이브러리와 스크립트 자동화로 효율적으로 처리할 수 있습니다.
데이터셋 준비의 핵심 특징은 네 가지입니다: (1) 정제(cleaning)로 노이즈 제거, (2) 포맷팅으로 일관된 구조화, (3) 검증으로 품질 보장, (4) 분할로 과적합 방지. 이러한 특징들이 왜 중요하냐면, 학습의 성공 여부가 데이터 품질에 달려있기 때문입니다.
코드 예제
from datasets import Dataset, load_dataset
import json
import re
# 1. 원시 데이터 로드 (JSON 형식 예시)
raw_data = [
{"question": "배송은 얼마나 걸리나요??", "answer": "보통 2-3일 걸립니다."},
{"question": "환불 가능한가요", "answer": "구매 후 7일 이내 가능합니다."},
# ... 수백~수천 개
]
# 2. 데이터 정제 함수
def clean_text(text):
text = re.sub(r'\s+', ' ', text) # 중복 공백 제거
text = re.sub(r'[?!]{2,}', '?', text) # 중복 물음표 제거
return text.strip()
# 3. Alpaca 형식으로 변환
def format_as_alpaca(example):
return {
"instruction": "다음 고객 질문에 친절하게 답변하세요.",
"input": clean_text(example["question"]),
"output": clean_text(example["answer"]),
"text": f"### Instruction: 고객 질문에 답변하세요.\n### Input: {clean_text(example['question'])}\n### Response: {clean_text(example['answer'])}"
}
# 4. 데이터셋 생성 및 변환
dataset = Dataset.from_list(raw_data)
dataset = dataset.map(format_as_alpaca)
# 5. 품질 검증 (너무 짧거나 긴 샘플 제거)
dataset = dataset.filter(lambda x: 10 < len(x["output"]) < 500)
# 6. Train/Validation 분할
dataset = dataset.train_test_split(test_size=0.1, seed=42)
# 7. 저장
dataset.save_to_disk("./prepared_dataset")
설명
이것이 하는 일: 위 코드는 원시 고객 상담 데이터를 정제하고 학습 가능한 형식으로 변환하는 전체 파이프라인을 구현합니다. 첫 번째로, clean_text 함수로 텍스트를 정제합니다.
re.sub(r'\s+', ' ')는 탭, 줄바꿈, 여러 공백을 하나의 공백으로 통일합니다. 예를 들어 "배송은 얼마나\n걸리나요"가 "배송은 얼마나 걸리나요"로 정규화됩니다.
왜 이렇게 하냐면, 불규칙한 공백은 토크나이저를 혼란스럽게 하고, 같은 의미의 문장을 다르게 인코딩하게 만들기 때문입니다. 중복 물음표 제거도 마찬가지로 노이즈를 줄입니다.
그 다음으로, format_as_alpaca 함수가 일관된 프롬프트 구조를 만듭니다. 내부적으로 어떤 일이 일어나냐면, 모든 샘플을 "### Instruction: ...
Input: ... ### Response: ..." 형식으로 통일합니다.
모델은 이 패턴을 학습해서 "### Response:" 뒤에 나올 내용을 생성하게 됩니다. text 필드는 전체 프롬프트를 담고, SFTTrainer는 기본적으로 이 필드를 학습합니다.
instruction/input/output 필드는 별도로 분석하거나 커스텀 처리할 때 유용합니다. 마지막으로, filter로 품질 검증을 수행합니다.
너무 짧은 답변(10자 미만)은 정보가 부족하고, 너무 긴 답변(500자 초과)은 노이즈일 가능성이 높습니다. train_test_split(test_size=0.1)은 90%를 학습용, 10%를 검증용으로 나눕니다.
seed=42로 재현성을 보장하여, 실험을 반복해도 같은 분할을 얻습니다. 여러분이 이 코드를 사용하면 지저분한 원시 데이터를 고품질 학습 데이터로 변환할 수 있습니다.
실무에서의 이점은 (1) 학습 안정성 향상과 수렴 속도 증가, (2) 모델 출력의 일관성과 품질 개선, (3) 검증 세트로 객관적 성능 평가 가능하다는 점입니다.
실전 팁
💡 데이터 정제는 과하게 하지 마세요. 실제 사용자 입력에는 오타가 있으므로, 너무 완벽하게 정제하면 오히려 실전 성능이 떨어질 수 있습니다.
💡 흔한 실수: instruction을 모든 샘플에 똑같이 넣으면 모델이 이를 무시하게 됩니다. 여러 변형을 만들어서 다양성을 주세요.
💡 데이터 불균형을 확인하세요. 특정 주제가 90%를 차지하면 모델이 편향됩니다. 언더샘플링이나 오버샘플링으로 조정하세요.
💡 개인정보나 민감 정보가 포함되어 있는지 반드시 검토하세요. 정규식이나 NER 모델로 자동 탐지하고 마스킹하세요.
💡 데이터셋을 버전 관리하세요. 전처리 스크립트와 결과물을 Git이나 DVC로 추적하면 실험 재현이 쉽습니다.
10. 하이퍼파라미터 튜닝 - 최적의 설정 찾기
시작하며
여러분이 요리를 할 때 레시피를 따라도, 불의 세기나 시간을 조금씩 바꾸면 맛이 달라지죠? 모델 학습도 마찬가지입니다.
같은 데이터와 모델이라도 하이퍼파라미터에 따라 성능이 크게 달라집니다. QLoRA 파인튜닝에서 주요 하이퍼파라미터는 수십 가지입니다.
LoRA rank(r), learning rate, batch size, warmup steps, lora_alpha, lora_dropout 등등. 각각의 최적값을 찾기 위해 모든 조합을 시도하면 수백 번의 실험이 필요하고, 시간과 비용이 엄청나게 듭니다.
바로 이럴 때 필요한 것이 체계적인 하이퍼파라미터 튜닝 전략입니다. Grid search, random search, Bayesian optimization 등의 기법과 실무 경험에서 나온 가이드라인을 활용하면 훨씬 효율적으로 최적값을 찾을 수 있습니다.
개요
간단히 말해서, 하이퍼파라미터 튜닝은 모델 성능을 최대화하는 파라미터 조합을 체계적으로 탐색하는 과정입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 기본값으로도 어느 정도 작동하지만 최고 성능을 내려면 조정이 필수입니다.
예를 들어, learning rate가 너무 높으면 학습이 불안정하고, 너무 낮으면 수렴이 느립니다. 적절한 값을 찾으면 같은 데이터로도 perplexity가 20% 개선될 수 있습니다.
기존에는 수작업으로 하나씩 바꿔가며 실험했다면, 이제는 Optuna나 Ray Tune 같은 자동 튜닝 도구를 활용할 수 있습니다. 하이퍼파라미터 튜닝의 핵심 특징은 세 가지입니다: (1) 중요한 파라미터 우선 탐색(r, lr이 가장 중요), (2) 실무 검증된 범위 내에서 탐색, (3) Early stopping으로 비효율적 실험 조기 종료.
이러한 특징들이 왜 중요하냐면, 무한한 조합 중에서 효율적으로 좋은 값을 찾아야 하기 때문입니다.
코드 예제
from transformers import TrainingArguments
from peft import LoraConfig
import optuna
def objective(trial):
# 1. 핵심 하이퍼파라미터 샘플링
r = trial.suggest_categorical("r", [8, 16, 32, 64])
lora_alpha = r * 2 # 보통 r의 2배
lora_dropout = trial.suggest_float("lora_dropout", 0.0, 0.1)
learning_rate = trial.suggest_float("learning_rate", 1e-5, 5e-4, log=True)
batch_size = trial.suggest_categorical("batch_size", [2, 4, 8])
# 2. LoRA 설정
lora_config = LoraConfig(
r=r, lora_alpha=lora_alpha, lora_dropout=lora_dropout,
target_modules=["q_proj", "v_proj"], bias="none"
)
# 3. 학습 설정
training_args = TrainingArguments(
output_dir=f"./trial_{trial.number}",
learning_rate=learning_rate,
per_device_train_batch_size=batch_size,
num_train_epochs=1, # 튜닝 시에는 짧게
evaluation_strategy="steps",
eval_steps=100,
save_strategy="no", # 디스크 절약
load_best_model_at_end=False,
)
# 4. 학습 및 평가 (실제 코드에서는 Trainer 사용)
# trainer = Trainer(model=model, args=training_args, train_dataset=train, eval_dataset=val)
# metrics = trainer.evaluate()
# return metrics["eval_loss"] # 최소화할 목표
# 데모: 임의의 loss 반환
import random
return random.uniform(1.0, 3.0)
# Optuna 스터디 실행
study = optuna.create_study(direction="minimize")
study.optimize(objective, n_trials=20, timeout=3600) # 20회 시도 또는 1시간
print("최적 파라미터:", study.best_params)
print("최적 loss:", study.best_value)
설명
이것이 하는 일: 위 코드는 Optuna를 사용해 QLoRA의 핵심 하이퍼파라미터를 자동으로 탐색하고 최적값을 찾습니다. 첫 번째로, objective 함수 내에서 trial.suggest_* 메서드로 파라미터를 샘플링합니다.
suggest_categorical은 이산적 선택지(8, 16, 32, 64)에서 하나를 고르고, suggest_float은 연속 범위에서 값을 선택합니다. log=True는 로그 스케일 샘플링을 의미하는데, learning rate처럼 1e-5와 1e-4의 차이가 1e-3과 1e-2의 차이만큼 중요할 때 사용합니다.
왜 이렇게 하냐면, 작은 값에서 세밀한 탐색이 필요하기 때문입니다. 그 다음으로, 각 trial마다 다른 설정으로 모델을 학습시키고 검증 loss를 측정합니다.
내부적으로 Optuna는 Bayesian Optimization(TPE 알고리즘)을 사용해서 어떤 일이 일어나냐면, 이전 trial 결과를 학습하여 유망한 영역을 집중 탐색합니다. 예를 들어 r=16일 때 loss가 낮았다면, 다음 trial에서는 r=16 주변(r=8, 32)을 더 시도합니다.
랜덤 서치보다 훨씬 효율적으로 최적값에 수렴합니다. 마지막으로, study.optimize가 20번의 trial을 실행하고 최적 파라미터를 반환합니다.
실제로는 각 trial이 1시간씩 걸릴 수 있으므로, timeout=3600(1시간)으로 제한을 걸 수 있습니다. 시간이 되면 중단하고 그때까지의 최적값을 반환합니다.
study.best_params는 {"r": 16, "learning_rate": 0.0002, ...} 같은 형태로 최적 조합을 알려줍니다. 여러분이 이 코드를 사용하면 수백 시간의 수작업 실험을 수십 시간의 자동 탐색으로 단축할 수 있습니다.
실무에서의 이점은 (1) 객관적이고 재현 가능한 최적화 과정, (2) 탐색 공간이 넓어도 효율적으로 좋은 값 발견, (3) 시각화 도구로 파라미터 간 상관관계 분석 가능하다는 점입니다.
실전 팁
💡 전체 epoch을 돌리면 시간이 너무 오래 걸립니다. num_train_epochs=1이나 max_steps=500 정도로 짧게 설정하고 빠르게 여러 조합을 시도하세요.
💡 흔한 실수: 모든 파라미터를 동시에 튜닝하면 탐색 공간이 기하급수적으로 커집니다. 먼저 r과 learning_rate만 튜닝하고, 나머지는 기본값 사용하세요.
💡 실무 검증된 범위: r은 864, learning_rate는 1e-55e-4, lora_alpha는 r*2, lora_dropout은 0~0.1이 대부분의 경우 최적입니다.
💡 Optuna 시각화로 중요도를 확인하세요. optuna.visualization.plot_param_importances(study)로 어느 파라미터가 성능에 가장 큰 영향을 주는지 알 수 있습니다.
💡 찾은 최적값으로 다시 full epoch 학습을 돌리세요. 짧은 학습으로 찾은 값이 긴 학습에서도 최적이라는 보장은 없지만, 보통 좋은 출발점이 됩니다.
11. 멀티 GPU 학습 - 대형 모델 빠르게 학습하기
시작하며
여러분이 큰 짐을 옮길 때 혼자보다 여럿이 나누면 훨씬 빠르죠? GPU도 마찬가지입니다.
여러 GPU를 활용하면 학습 시간을 크게 단축할 수 있습니다. QLoRA는 메모리를 절약해서 단일 GPU로도 대형 모델을 학습할 수 있게 해주지만, 학습 속도는 여전히 과제입니다.
70B 모델을 10,000 샘플로 학습시키면 며칠씩 걸릴 수 있습니다. 프로토타이핑이나 빠른 실험에는 너무 느립니다.
바로 이럴 때 필요한 것이 멀티 GPU 학습입니다. Data Parallel이나 FSDP(Fully Sharded Data Parallel)를 활용하면 GPU 4개로 학습 시간을 3~4배 줄일 수 있습니다.
개요
간단히 말해서, 멀티 GPU 학습은 데이터나 모델을 여러 GPU에 분산시켜 병렬로 학습하는 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 시간은 돈입니다.
예를 들어, A100 GPU 1개로 3일 걸리는 학습을 4개로 하루 만에 끝내면, 빠른 피드백으로 더 많은 실험을 할 수 있습니다. 하이퍼파라미터 튜닝이나 A/B 테스트에 특히 유용합니다.
기존에는 멀티 GPU 설정이 복잡하고 코드 수정이 많이 필요했다면, 이제는 Hugging Face Accelerate와 DeepSpeed로 설정 파일 하나로 간단히 해결됩니다. 멀티 GPU 학습의 핵심 특징은 세 가지입니다: (1) Data Parallel로 배치를 GPU별로 분산, (2) Gradient accumulation으로 효과적인 배치 크기 증가, (3) FSDP로 모델 파라미터까지 분산.
이러한 특징들이 왜 중요하냐면, 하드웨어 리소스를 최대한 활용해서 시간과 비용을 절약하기 때문입니다.
코드 예제
# accelerate_config.yaml 파일 생성
"""
compute_environment: LOCAL_MACHINE
distributed_type: MULTI_GPU
num_processes: 4 # GPU 개수
mixed_precision: fp16
"""
# train_multi_gpu.py
from transformers import TrainingArguments, Trainer
from accelerate import Accelerator
# Accelerator 초기화 (자동으로 config 읽음)
accelerator = Accelerator()
# 학습 설정 (멀티 GPU 고려)
training_args = TrainingArguments(
output_dir="./output",
per_device_train_batch_size=2, # GPU당 배치 크기
gradient_accumulation_steps=8, # 효과적 배치 = 2 * 4 GPU * 8 = 64
num_train_epochs=3,
learning_rate=2e-4,
fp16=True,
logging_steps=10,
save_strategy="epoch",
ddp_find_unused_parameters=False, # QLoRA는 False 필수
gradient_checkpointing=True, # 메모리 추가 절약
)
# Trainer (자동으로 멀티 GPU 처리)
trainer = Trainer(
model=model, # QLoRA 모델
args=training_args,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
)
# 학습 시작
trainer.train()
# 실행 방법:
# accelerate launch --config_file accelerate_config.yaml train_multi_gpu.py
설명
이것이 하는 일: 위 코드는 Accelerate를 사용해 4개 GPU에서 QLoRA 모델을 병렬로 학습합니다. 첫 번째로, accelerate_config.yaml에서 distributed_type: MULTI_GPU와 num_processes: 4를 설정합니다.
이것은 DDP(Distributed Data Parallel) 모드로 4개 프로세스(각 GPU당 1개)를 실행하라는 의미입니다. 왜 이렇게 하냐면, 각 GPU가 독립적인 프로세스로 동작하면서 그래디언트만 동기화하는 것이 가장 효율적이기 때문입니다.
mixed_precision: fp16은 모든 GPU에서 16-bit 연산을 사용하라는 설정입니다. 그 다음으로, TrainingArguments에서 per_device_train_batch_size=2는 각 GPU당 배치 크기입니다.
내부적으로 어떤 일이 일어나냐면, 데이터셋이 4개 GPU에 자동으로 분할되고, 각 GPU는 자신의 2개 샘플로 forward/backward를 수행합니다. gradient_accumulation_steps=8과 결합하면, 효과적인 배치 크기는 2 × 4 GPU × 8 = 64가 됩니다.
즉, 메모리는 배치 2개만 차지하면서도 배치 64의 안정성을 얻습니다. 마지막으로, ddp_find_unused_parameters=False가 매우 중요합니다.
QLoRA는 대부분의 파라미터가 frozen이므로 그래디언트가 없습니다. DDP는 기본적으로 모든 파라미터의 그래디언트를 동기화하려고 하는데, 사용하지 않는 파라미터를 찾는 과정이 오버헤드를 만듭니다.
False로 설정하면 이를 건너뛰어서 통신 오버헤드가 줄어들고 속도가 2030% 빨라집니다. 여러분이 이 코드를 사용하면 4 GPU로 학습 시간이 33.5배 단축됩니다(통신 오버헤드 때문에 정확히 4배는 안 됨).
실무에서의 이점은 (1) 빠른 실험 사이클로 생산성 향상, (2) 큰 배치 크기로 학습 안정성 개선, (3) 하이퍼파라미터 튜닝을 병렬로 실행 가능하다는 점입니다.
실전 팁
💡 GPU 개수를 늘린다고 선형적으로 빨라지지 않습니다. 통신 오버헤드 때문에 4 GPU는 보통 33.5배, 8 GPU는 67배 정도입니다.
💡 흔한 실수: 각 GPU의 메모리가 충분하지 않으면 OOM이 납니다. 먼저 단일 GPU로 동작 확인 후 멀티 GPU로 확장하세요.
💡 배치 크기를 늘리면 learning rate도 조정해야 합니다. 일반적으로 sqrt(배치 증가 비율)만큼 lr을 늘리세요. 예: 배치 16→64면 lr을 2배로.
💡 NCCL 백엔드가 GPU 간 통신에 사용됩니다. NCCL_DEBUG=INFO 환경 변수로 통신 문제를 디버깅할 수 있습니다.
💡 더 큰 모델(65B+)은 FSDP(Fully Sharded Data Parallel)를 사용하세요. 모델 파라미터까지 GPU에 분산시켜 더 큰 모델을 올릴 수 있습니다.
12. 프로덕션 배포 - 실서비스 준비하기
시작하며
여러분이 만든 챗봇을 실제 사용자에게 제공하려면 어떻게 해야 할까요? 학습 환경과 프로덕션 환경은 요구사항이 완전히 다릅니다.
안정성, 속도, 확장성이 핵심입니다. 학습할 때는 한 번에 하나의 요청만 처리하고, 30초 걸려도 괜찮습니다.
하지만 실서비스에서는 동시에 수백 명이 접속하고, 각각 3초 안에 응답해야 합니다. GPU 하나로는 불가능하고, 비용도 최적화해야 하죠.
게다가 24/7 안정적으로 작동해야 하고, 장애 시 자동 복구도 필요합니다. 바로 이럴 때 필요한 것이 프로덕션 배포 파이프라인입니다.
vLLM 같은 추론 엔진, FastAPI 같은 웹 프레임워크, Docker와 Kubernetes 같은 컨테이너 오케스트레이션을 결합하면 안정적이고 확장 가능한 서비스를 만들 수 있습니다.
개요
간단히 말해서, 프로덕션 배포는 학습된 모델을 실제 사용자가 사용할 수 있는 서비스로 만드는 전체 과정입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 좋은 모델도 서비스화하지 못하면 가치가 없습니다.
예를 들어, 고객 상담 챗봇을 만들었는데 배포 방법을 몰라서 로컬에만 있다면 아무 소용이 없습니다. API 서버로 만들어야 웹이나 앱에서 호출할 수 있습니다.
기존에는 배포가 복잡하고 DevOps 전문가가 필요했다면, 이제는 Hugging Face Inference Endpoints나 vLLM로 간단히 배포할 수 있습니다. 프로덕션 배포의 핵심 특징은 네 가지입니다: (1) vLLM으로 처리량 10배 향상, (2) FastAPI로 RESTful API 제공, (3) Docker로 환경 일관성 보장, (4) 모니터링과 로깅으로 안정성 유지.
이러한 특징들이 왜 중요하냐면, 실제 비즈니스 가치를 만들어내는 마지막 단계이기 때문입니다.
코드 예제
# vllm_server.py - 고성능 추론 서버
from vllm import LLM, SamplingParams
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import uvicorn
# 1. vLLM 모델 로드 (병합된 QLoRA 모델)
llm = LLM(
model="./merged_qlora_model",
tensor_parallel_size=1, # GPU 개수
quantization="awq", # 추가 양자화 (선택)
max_model_len=2048, # 최대 시퀀스 길이
)
# 2. FastAPI 앱
app = FastAPI(title="Customer Support Chatbot API")
class ChatRequest(BaseModel):
question: str
max_tokens: int = 256
class ChatResponse(BaseModel):
answer: str
tokens_used: int
@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
# 프롬프트 포맷팅
prompt = f"### Instruction: 고객 질문에 답변하세요.\n### Input: {request.question}\n### Response:"
# 샘플링 설정
sampling_params = SamplingParams(
temperature=0.7, top_p=0.9, max_tokens=request.max_tokens
)
# 추론 (배치 처리 가능)
outputs = llm.generate([prompt], sampling_params)
answer = outputs[0].outputs[0].text
return ChatResponse(
answer=answer.strip(),
tokens_used=len(outputs[0].outputs[0].token_ids)
)
@app.get("/health")
async def health():
return {"status": "healthy"}
# 서버 실행: uvicorn vllm_server:app --host 0.0.0.0 --port 8000
설명
이것이 하는 일: 위 코드는 QLoRA 모델을 vLLM 엔진으로 로드하고 FastAPI로 HTTP API를 제공하는 프로덕션 서버를 만듭니다. 첫 번째로, vLLM은 추론 최적화 엔진입니다.
일반 Transformers 라이브러리보다 훨씬 빠른 이유는 PagedAttention과 Continuous Batching을 사용하기 때문입니다. 왜 이렇게 하냐면, PagedAttention은 KV 캐시를 페이지 단위로 관리해서 메모리 단편화를 방지하고, Continuous Batching은 서로 다른 길이의 요청들을 효율적으로 묶어서 GPU 활용률을 95% 이상으로 유지합니다.
일반적으로 같은 하드웨어에서 처리량이 10~20배 증가합니다. 그 다음으로, FastAPI는 현대적인 Python 웹 프레임워크로 비동기 처리를 지원합니다.
내부적으로 어떤 일이 일어나냐면, async def로 정의된 엔드포인트는 요청을 받으면 즉시 응답하지 않고 awaitable 객체를 반환합니다. uvicorn ASGI 서버가 여러 요청을 동시에 처리하면서 각각의 추론이 완료될 때까지 기다립니다.
이렇게 하면 단일 프로세스로도 초당 수십~수백 요청을 처리할 수 있습니다. 마지막으로, Pydantic 모델(ChatRequest, ChatResponse)로 입출력 검증을 자동화합니다.
예를 들어 question 필드가 없거나, max_tokens가 숫자가 아니면 자동으로 400 에러를 반환합니다. /health 엔드포인트는 로드 밸런서나 Kubernetes가 서버 상태를 확인하는 데 사용됩니다.
여러분이 이 코드를 사용하면 학습한 모델을 실제 서비스로 제공할 수 있습니다. 실무에서의 이점은 (1) 동시 사용자 수백 명 처리 가능, (2) 응답 시간 3초 이하로 사용자 경험 우수, (3) 표준 HTTP API로 어떤 클라이언트(웹, 앱, IoT)와도 통합 가능하다는 점입니다.
실전 팁
💡 vLLM은 병합된 모델을 요구합니다. LoRA 어댑터를 사용하려면 먼저 merge_and_unload()로 병합하세요.
💡 흔한 실수: tensor_parallel_size를 GPU 개수보다 크게 설정하면 에러가 납니다. 정확히 사용 가능한 GPU 개수로 맞추세요.
💡 프로덕션에서는 rate limiting과 authentication을 추가하세요. slowapi로 IP별 요청 제한, API 키로 인증을 구현하세요.
💡 모니터링은 필수입니다. Prometheus + Grafana로 요청 수, 응답 시간, GPU 사용률 등을 실시간 추적하세요.
💡 Docker로 컨테이너화하면 배포가 쉽습니다. nvidia-docker를 사용하고, Kubernetes로 오토스케일링과 무중단 배포를 구현하세요.