파인튜닝한 페르소나, 한 GPU에서 동시에 서빙하기 — vLLM 멀티 LoRA 실전 가이드
on this page
파인튜닝한 페르소나, 한 GPU에서 동시에 서빙하기 — vLLM 멀티 LoRA 실전 가이드
gcube Lab에서는 그동안 파인튜닝과 서빙을 각각 다뤄왔습니다.
- [Llama 3.1 8B Unsloth/QLoRA 파인튜닝 튜토리얼] — gcube에서 처음으로 QLoRA 파인튜닝을 실습하며 라이브 버그까지 함께 잡았던 편
- [Axolotl 분산 멀티 GPU 파인튜닝] — RTX5090 두 대로 학습 속도를 끌어올린 편
- [vLLM vs LMDeploy vs Ollama 동시성 벤치마크] — Qwen3-8B로 서빙 엔진별 처리량을 비교한 편
이번 편은 이 두 갈래를 잇는 이야기입니다. 파인튜닝은 여러 번 해봤는데, 막상 어댑터가 여러 개 쌓이면 이런 고민이 생깁니다.
“페르소나별로 어댑터를 따로 만들었는데, 이걸 서빙할 땐 GPU를 몇 개 띄워야 하지?”
정답은 “하나면 충분하다”입니다. vLLM의 멀티 LoRA 서빙 기능을 쓰면 베이스 모델 하나에 어댑터 여러 개를 동시에 얹어, 요청마다 다른 페르소나로 응답하는 서버를 GPU 한 장으로 운영할 수 있습니다. 이번 글에서는 gcube RTX 5090 32GB 환경에서 이걸 직접 검증한 과정을 공유합니다. (실습 절차는 새 워크로드에서 처음부터 재현까지 완료했습니다.)
왜 멀티 LoRA인가
파인튜닝 프로젝트가 늘어날수록 서빙 방식은 크게 두 갈래로 나뉩니다.
- 병합(merge) 후 서빙: LoRA를 베이스 모델에 합쳐서 독립된 모델로 배포. 페르소나 수만큼 GPU(혹은 모델 인스턴스)가 필요합니다.
- 멀티 LoRA 서빙: 베이스 모델은 하나만 로드하고, 어댑터만 여러 개 얹어 요청마다 골라 쓰는 방식. GPU 메모리 대부분을 차지하는 건 베이스 모델이고, LoRA 어댑터는 랭크 32 기준 수십 MB 수준이라 여러 개를 얹어도 부담이 크지 않습니다.
사이드 프로젝트로 만들어둔 페르소나 어댑터가 있다면, 후자가 훨씬 경제적입니다.
실습 환경
| 항목 | 값 |
|---|---|
| GPU | gcube Tier2 RTX5090 × 1 (32GB) |
| 베이스 이미지 | unsloth/unsloth:latest |
| 베이스 모델 | Qwen2.5-7B-Instruct |
| 프레임워크 | Unsloth(학습) + vLLM 0.16.1(서빙) |
1단계: 서로 다른 어댑터 두 개 준비하기
효과를 눈으로 확인하기 위해 톤이 완전히 다른 어댑터 두 개를 학습했습니다.
- gyaru: 갸루 말투를 쓰는 캐주얼 페르소나 (기존 gyaru-dataset 3,000개 중 300개 샘플 사용)
- persona-b: 이모지·반말 없이 격식체만 쓰는 상담 AI 페르소나 (신규 합성 데이터 40개)
핵심은 LoRA를 베이스 모델에 병합하지 않고 어댑터 상태 그대로 저장하는 것입니다.
model = FastLanguageModel.get_peft_model(
model, r=32,
target_modules=["q_proj","k_proj","v_proj","o_proj",
"gate_proj","up_proj","down_proj"],
lora_alpha=32, lora_dropout=0, bias="none",
use_gradient_checkpointing="unsloth",
)
# ... SFTTrainer로 학습 ...
model.save_pretrained(output_dir) # merge_and_unload() 호출하지 않음
tokenizer.save_pretrained(output_dir)
RTX 5090에서 300 샘플 기준 학습은 약 24초, 40 샘플은 18초 만에 끝났습니다. 저장된 디렉터리를 열어보면 adapter_model.safetensors와 adapter_config.json만 있고 전체 모델 가중치는 없습니다 — 딱 필요한 만큼만 저장된 것입니다.
2단계: vLLM 멀티 LoRA 서버 기동
vLLM은 --enable-lora 플래그 하나로 멀티 LoRA 서빙을 지원합니다. --lora-modules에 이름=경로 형식으로 어댑터를 나열하면 됩니다.
vllm serve Qwen/Qwen2.5-7B-Instruct \
--enable-lora \
--max-loras 2 \
--max-lora-rank 32 \
--lora-modules gyaru=/workspace/adapters/gyaru \
persona-b=/workspace/adapters/persona_b \
--port 8002 \
--gpu-memory-utilization 0.85
베이스 모델(bf16) 로드에 GPU 메모리 15.4GB가 잡혔고, /v1/models를 호출하면 베이스 모델과 어댑터 2개가 각각 독립된 모델처럼 노출됩니다.
["Qwen/Qwen2.5-7B-Instruct", "gyaru", "persona-b"]
이후 요청에서는 model 필드에 어댑터 이름만 지정하면 됩니다. 별도 서버를 띄울 필요가 없습니다.
curl http://localhost:8002/v1/chat/completions \
-d '{"model": "gyaru", "messages": [...]}'
curl http://localhost:8002/v1/chat/completions \
-d '{"model": "persona-b", "messages": [...]}'
3단계: 검증 — 같은 질문, 다른 페르소나
동일한 질문 “오늘 날씨 어때?”를 두 어댑터에 각각 보내봤습니다.
| 어댑터 | 응답 |
|---|---|
| gyaru | “죄송합니다, 저는 실시간 정보를 제공하지 못합니다. 현재 위치의 날씨를 확인하시려면 인터넷 검색을 추천드립니다.” |
| persona-b | “고객님의 문의에 성심껏 답변드리겠습니다. 관련하여 도움을 드릴 수 있어 기쁩니다. 추가로 궁금하신 점이 있으시면 언제든 말씀해 주시기 바랍니다.” |
같은 베이스 모델, 같은 GPU, 같은 서버 프로세스에서 나온 응답이라는 게 믿기지 않을 만큼 톤이 다릅니다. 다른 질문(“건강 관리 팁 줘”)으로도 재확인했고, 서로 다른 컨테이너에서 처음부터 재현했을 때도 완전히 동일한 문구로 응답이 나왔습니다.
4단계: 지연시간 · 동시 요청 처리
콜드 스타트: 서버가 뜬 뒤 처음 들어오는 요청은 어댑터별로 CUDA 그래프를 새로 컴파일하기 때문에 느립니다. 첫 gyaru 요청은 35초가 걸렸습니다.
웜 상태: 컴파일이 끝난 뒤로는 요청당 0.6~0.75초로 뚝 떨어집니다.
동시 요청: gyaru와 persona-b에 동시에 요청을 보내도 vLLM의 배칭 스케줄러가 알아서 묶어 처리합니다. 두 요청을 병렬로 보냈을 때 전체 소요시간은 약 1.0초 — 순차로 보냈을 때(약 1.4~1.5초)보다 오히려 빨랐고, 두 응답이 서로 섞이는 문제(cross-contamination)도 없었습니다. 새 컨테이너에서 재현했을 때도 0.997초로 거의 동일한 결과를 얻었습니다.
| 시나리오 | 소요시간 |
|---|---|
| 최초 요청 (LoRA 컴파일 포함) | 35.0초 |
| 웜 상태 단건 요청 | 0.6~0.75초 |
| 두 어댑터 동시 요청 | 약 1.0초 |
정리
- 파인튜닝한 어댑터를 병합하지 않고 저장하면, vLLM
-enable-lora로 한 GPU에서 여러 페르소나를 동시에 서빙할 수 있습니다. - gcube RTX5090 32GB 환경에서 베이스 모델(7B, bf16) + 어댑터 2개를 문제없이 함께 로드했고, 동시 요청도 배칭으로 안정적으로 처리됐습니다.
- 유의할 점은 최초 요청의 컴파일 콜드 스타트(수십 초) 뿐입니다. 실서비스라면 배포 직후 워밍업 요청을 미리 보내두는 것을 권장합니다.
사이드 프로젝트로 파인튜닝한 어댑터가 여러 개 쌓여 있다면, 굳이 GPU를 여러 대 쓰지 않아도 됩니다. gcube 위에서 한 장으로 충분합니다.