MLX를 사용하여 Mac에서 LLM을 16초 만에 미세 조정하기 (실제 실행, 클라우드 불필요)
요약
본 기사는 Apple Silicon과 MLX 프레임워크를 활용하여 Mac 환경에서 LLM을 빠르고 효율적으로 미세 조정하는 방법을 안내합니다. LoRA와 QLoRA 기술을 사용하여 적은 메모리로 3B 모델의 가중치를 업데이트하며, 클라우드나 고성능 GPU 없이도 높은 성능을 입증했습니다.
핵심 포인트
- MLX는 Apple Silicon과 통합 메모리에 최적화된 ML 프레임워크입니다.
- QLoRA를 사용해 학습 가능한 매개변수를 최소화하여 효율성을 높였습니다.
- MacBook Pro M2 Pro에서 3B 모델 미세 조정이 단 16초 만에 가능했습니다.
- MLX LM을 통해 간편하게 LLM 실행 및 미세 조정 환경을 구축할 수 있습니다.
저는 Mac에서 언어 모델을 미세 조정했습니다. 학습은 약 16초가 걸렸고, 메모리는 약 2.5 GB를 사용했으며, 클라우드나 NVIDIA 카드가 필요하지 않았습니다. 오직 Apple의 MLX만 사용했습니다.
학습 전에는 이 모델이 길고 장황한 답변을 작성했습니다. 학습 후에는 제 예시처럼 짧고 명확하게 답변했습니다. 그리고 가장 점수가 좋았던 버전이 최악의 답변을 내놓았습니다. 이 반전이야말로 이 게시물에서 가장 유용한 부분입니다.
아래 모든 수치는 기록된 실행에서 가져온 것이며, 저는 이 비디오에서 처음부터 끝까지 보여줍니다:
테스트 장비: MacBook Pro, M2 Pro, 16 GB 통합 메모리 · macOS 26.5 · MLX 0.32.3 · MLX LM 0.32.0. 명령어는 Mac mini M5 Pro와 같은 최신 Mac에서도 동일하게 작동하며, 속도와 메모리만 달라집니다.
미세 조정이 실제로 바꾸는 것 (LoRA를 한 분 만에)
언어 모델은 다음 토큰을 반복적으로 예측합니다. 학습 과정에서는 이 추측을 정답과 비교하고, 오차(손실, loss)를 측정하며, 가중치를 작게 움직여 개선합니다.
3B 모델의 30억 개에 달하는 모든 가중치를 업데이트하려면 너무 많은 메모리가 필요할 것입니다. LoRA는 이 가중치들을 고정하고 대신 각 레이어 옆에 두 개의 작은 행렬을 학습시킵니다: 새로운 가중치는 이전 가중치와 작은 업데이트(B × A)를 더한 값입니다.
이 실행에서는 **30억 9천만 개 중 330만 개의 학습 가능한 매개변수, 즉 모델의 0.108%**만을 사용했습니다. 기본 모델이 4비트이기 때문에 이것은 QLoRA입니다.
왜 MLX를 사용하는가? PyTorch 대신?
MLX는 Apple Silicon과 통합 메모리를 위해 구축된 Apple의 머신러닝 프레임워크입니다 (CPU와 GPU가 동일한 RAM을 공유하므로 모델이 여기저기 복사되지 않습니다). MLX LM은 언어 모델을 실행하고 미세 조정하기 위한 준비된 명령어를 추가합니다.
Mac에서 PyTorch를 사용하려면 다른 백엔드(MPS)를 사용해야 합니다. CUDA 노트북을 가져와 cuda를 mps로 교체한다고 해서 학습이 될 것이라고 기대하지 마세요.
1. MLX LM 설치하기
새로운 Python 3.12 환경에서:
uv pip install
저는 **Qwen2.5-3B-Instruct**를 사용했습니다. 제 네트워크에서 Hugging Face 접속이 차단되어 ModelScope에서 공식 가중치를 다운로드하고 SHA-256 해시값을 확인했습니다. 정상적인 접근 환경에서는 mlx-community의 준비된 4비트 모델을 사용할 수 있습니다.
mlx_lm.convert --hf-path ./models/qwen2.5-3b-instruct -q --q-bits 4
--mlx-path ./models/Qwen2.5-3B-Instruct-4bit
**9초 소요. 6.2 GB가 1.6 GB로 감소했습니다** (가중치당 4.501 비트). 간단히 계산해 보면: 3B 가중치 × ~4.5 비트 ÷ 8 ≈ 1.7 GB입니다. 이것은 오직 가중치만을 의미하며, 학습에는 그 이상의 메모리가 필요합니다.
## 3. 기준선(Baseline) 확보하기
어떤 것을 학습시키기 전에, 저는 기본 모델에게 나중에 사용할 것과 동일한 질문을 던졌습니다. 이때 시스템 프롬프트와 온도는 반복 가능한 결과를 위해 동일하게 설정했습니다:
> 인증(authentication)과 인가(authorization)의 차이점을 설명해 주세요.
답변은 정확했지만 길었습니다: 번호 매기기, 굵은 글씨, **151 토큰**으로 구성되어 있었으며, **84 토큰/초** 속도와 **1.86 GB**의 최대 메모리를 사용했습니다. 이 출력을 저장해 두세요. 기준선 없이는 어떤 개선점도 증명할 수 없습니다.
## 4. 학습 데이터 생성하기 (JSONL)
파일의 각 줄은 하나의 채팅 기록입니다: 시스템 메시지, 사용자 질문, 그리고 이상적인 답변으로 구성됩니다.
{"messages": [{"role": "system", "content": "소프트웨어 개념을 한두 개의 짧고 명확한 문장으로 설명하세요."}, {"role": "user", "content": "인증과 인가의 차이점은 무엇인가요?"}, {"role": "assistant", "content": "인증은 당신이 누구인지 확인하는 것입니다. 인가는 당신이 무엇을 할 수 있도록 허용할지 결정합니다."}]}
시간을 절약해 주는 두 가지 규칙이 있습니다:
- 수동으로 작성한 특수 토큰 대신 **채팅 메시지(chat messages)**를 사용하세요. MLX는 모델 자체의 채팅 템플릿을 적용합니다.
- 유사한 예시는 같은 분할(split)에 유지해야 합니다. 그렇지 않으면 테스트 세트가 학습 데이터로 유출됩니다(data leak).
제 데모에는 20개의 예시가 있었습니다: 16개는 학습용, 2개는 검증용, 그리고 2개는 테스트를 위해 보류된 것(`train.jsonl`, `valid.jsonl`, `test.jsonl`을 `./data`에 저장). **이것은 파이프라인을 테스트하기에 충분한 양일 뿐입니다.** 실제 프로젝트에는 수백 개의 검토된 예시가 필요합니다.
## 5. LoRA 어댑터 학습시키기
mlx_lm.lora --model ./models/Qwen2.5-3B-Instruct-4bit --train --data ./data \
--adapter-path ./adapters/smoke --batch-size 1 --num-layers 8 \
--max-seq-length 512 --learning-rate 1e-5 --iters 20 \
...
- `--grad-checkpoint`는 메모리를 절약합니다.
- `--mask-prompt`는 질문이 아닌 답변으로부터만 학습한다는 의미입니다.
- 20 iterations: 이것은 테스트 목적의 실행(smoke test)입니다.
결과:
| | 시작 | 끝 |
| --- | --- | :--- |
| Training loss (학습 손실) | 4.95 | **1.70** |
| Validation loss (검증 손실) | 7.03 | **2.81** |
약 **16초**, 최대 메모리 사용량 약 **2.5 GB**이며, 10단계와 20단계에서 체크포인트가 저장됩니다.
## 6. 실제로 성능이 향상되었을까? (반전)
모델이 본 적 없는 2가지 예제에 대한 테스트 손실:
| 모델 | 테스트 손실 |
| --- | :--- |
| Base model (기본 모델) | 3.99 |
| ... |
mlx_lm.lora --model ./models/Qwen2.5-3B-Instruct-4bit --test --data ./data --adapter-path ./adapters/smoke
20단계가 명확한 승자처럼 보입니다. 그래서 제가 이 질문을 던졌습니다:
> **Step 20:** "Authentication verifies who you are." (인증은 당신이 누구인지 확인합니다.)
그게 전부였습니다. 권한(authorization)에 대해서는 빠뜨렸습니다. 짧아지도록 학습했지만, 너무 짧았습니다. 성능 저하가 더 큰 손실을 보였던 단계-10 체크포인트:
> **Step 10:** "Authentication verifies who you are. Authorization decides what you can do." (인증은 당신이 누구인지 확인합니다. 권한은 당신이 무엇을 할 수 있는지 결정합니다.)
짧고, 명확하며, 완전했습니다: **151 토큰 대신 47 토큰**입니다.
**손실 값이 낮다고 항상 더 나은 어시스턴트를 의미하지는 않습니다.** 어떤 체크포인트를 유지할지 결정하기 전에 항상 답변을 읽고 비교해야 합니다.
## 7. 어댑터를 독립형 모델로 병합(Fuse)하기
단계-10 체크포인트를 사용하려면, `0000010_adapters.safetensors`를 자체 폴더에 `adapters.safetensors`로 복사하고, 여기에 `adapter_config.json`도 함께 넣습니다 (저는 이것을 `adapters/smoke-it10`이라고 명명했습니다). 그런 다음:
mlx_lm.fuse --model ./models/Qwen2.5-3B-Instruct-4bit
--adapter-path ./adapters/smoke-it10 --save-path ./models/rainy-qwen-3b
여전히 **1.6 GB**이며, **81 토큰/초**로 실행됩니다. 한 가지 팁: 병합된 모델을 학습할 때 사용했던 시스템 프롬프트와 **동일한 시스템 프롬프트를 사용하여 테스트**해 보세요. 그렇지 않으면 전체 길이의 답변으로 되돌아갑니다.
## 8. FastAPI를 사용하여 로컬 API로 서비스하기 (그리고 한 단어 수정)
작은 FastAPI 앱이 모델과 어댑터를 한 번 로드한 후 채팅 경로(chat route)를 제공합니다:
from fastapi import FastAPI
from pydantic import BaseModel
from mlx_lm import load, generate
...
uvicorn app:app --port 8000
curl -X POST localhost:8000/chat -H 'Content-Type: application/json' -d '{"question": "What is a reverse proxy?"}'
일반 `def` 경로를 사용했을 때, 첫 번째 요청에서 다음과 같은 오류가 발생했습니다:
There is no Stream(cpu, 0) in current thread
MLX 스트림은 하나의 스레드에 속하며, FastAPI는 일반 `def` 경로를 워커 스레드(worker thread)에서 실행합니다. 경로를 **`async def`**로 만들면 모델을 로드한 스레드를 유지할 수 있습니다. 이는 단일 사용자 데모(스트리밍 없음, 인증 없음, 큐 없음)이므로 본인의 컴퓨터에서 사용하세요.
## Ollama과 GGUF는 어떨까요?
Ollama은 별도의 경로입니다. MLX 어댑터를 자동으로 사용하지 않으며, MLX LM의 내장 GGUF 내보내기는 현재 Llama, Mistral 및 Mixtral을 지원하지만 Qwen은 지원하지 않습니다. (만약 대신 Llama 모델을 미세 조정한다면, 이를 융합(fuse)하여 safetensors를 Ollama에 가져올 수 있습니다. 저는 제 Ollama 과정에서 그렇게 했습니다.)
## 실행 시 메모리 팁
- 채팅은 학습보다 적은 메모리를 필요로 합니다: 이 3B 모델의 경우 짧은 예시로 **1.9 GB를 채팅, 2.5 GB를 학습**에 사용합니다.
- 더 긴 시퀀스와 더 큰 배치(batch)는 메모리를 빠르게 증가시킵니다. 메모리가 부족하면: 배치 크기 1, `--max-seq-length`를 짧게 설정, `--num-layers` 수를 줄이세요.
- 플래그는 이전의 `--lora-layers`가 아니라 **`--num-layers`**입니다.
- 답변이 반복되나요? 모델을 탓하기 전에 데이터에 중복(duplicates)이 있는지 확인하세요.
- 실제 실행을 위해서는 그래디언트 누적(gradient accumulation)을 사용한 더 많은 반복(예: 400회 반복, 누적 4 = 100 가중치 업데이트)이 필요합니다. 먼저 자신의 속도를 측정해 보세요: 초당 0.2it에서 400회 반복은 약 33분이 걸립니다.
## 미세 조정(Fine-tuning) vs RAG vs 더 나은 프롬프트
- **변하는 문서(documents that change)**에 대한 답변은 **RAG**를 사용하세요.
- 단지 **다른 톤(different tone)**이 필요하다면, 먼저 **더 나은 시스템 프롬프트(system prompt)**를 시도해 보세요.
- 실제 예시와 측정 가능한 동작을 가지고 있을 때 **미세 조정(Fine-tune)**하세요.
작은 미세 조정만으로는 작은 모델을 더 똑똑하게 만들지 못합니다. 대신, 더 **일관성 있게(consistent)** 만듭니다.
## 모든 실험 기록 유지하기
정확한 모델, 패키지 버전, 명령어, 랜덤 시드, 데이터 분할, 모든 어댑터(초기 체크포인트 포함), 그리고 동일한 테스트 프롬프트에 대한 전/후 답변을 기록하세요. 그 기록이야말로 당신의 모델이 실제로 개선되었음을 증명하는 방법입니다.
**그렇다면, Mac에서 LLM을 미세 조정할 수 있을까요?** 네. MLX를 사용하면 3B 모델이 3GB 미만의 메모리에서 몇 초 만에 학습됩니다. 20단계 테스트로 시작하여 답변을 읽어본 다음, 규모를 키우세요.
당신 자신의 로컬 모델에게 무엇을 가르쳐 주고 싶나요? 댓글로 알려주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기