한국어 · English
로컬 Ollama 모델을 LoRA로 파인튜닝해 다시 Ollama에 등록하는 end-to-end 샘플. 합성 학습 데이터 생성기가 포함돼 있어 외부 데이터 없이 바로 돌려볼 수 있다.
두 파이프라인이 들어 있다:
| 폴더 | 대상 | 태스크 | 데이터 |
|---|---|---|---|
scripts-vlm/ |
qwen3-vl:8b (8.8B, 멀티모달) |
영수증 이미지 → 구조화 JSON | data/ |
scripts-llm/ |
qwen3.8:latest (27.3B) |
고객 문의 텍스트 → 라우팅 JSON | data-llm/ |
두 폴더는 같은 5단계 구조(00 데이터 → 01 학습 → 02 검증 → 03 병합 →
04 GGUF/등록 → 05 Ollama 검증)를 공유한다. 아래 1~5장은 scripts-vlm
기준이고, scripts-llm은 뒤의 전용 장에서 다룬다.
Ollama에는 파인튜닝 기능이 없다. GGUF 추론 엔진이다. 또한 로컬의
qwen3-vl:8b는 이미 Q4_K_M으로 양자화된 산출물이라 그 파일 자체를 학습시킬 수
없다(4bit 정수 가중치에는 gradient를 흘릴 수 없다).
그래서 실제 경로는 이렇다:
Qwen/Qwen3-VL-8B-Instruct (HF, bf16) ← 학습 시작점. ollama의 qwen3-vl:8b와 같은 원본
│ LoRA 학습 (transformers + peft)
▼
out/lora-receipt/ (어댑터, 수십 MB)
│ 병합
▼
out/merged/ (fp16 full weight, 약 17GB)
│ llama.cpp: convert_hf_to_gguf.py (+ --mmproj)
▼
언어모델 GGUF + 비전 프로젝터 GGUF → 양자화(Q4_K_M)
│ ollama create -f Modelfile
▼
ollama run qwen3vl-receipt
ollama show qwen3-vl:8b → qwen3vl / 8.8B / ctx 262144 / Q4_K_M / vision·tools·thinking.
그리고 ollama show --modelfile qwen3-vl:8b 를 보면 RENDERER qwen3-vl-thinking,
PARSER qwen3-vl-thinking 이다. 즉 이 태그는 Instruct가 아니라 Thinking 변종이다.
/api/chat에 "think": false를 보내도 추론이 꺼지지 않고 thinking 필드로 분리될
뿐이어서, num_predict가 작으면 답(content)이 빈 문자열로 끊긴다(실측: 추론 2000~5000자).
이 저장소의 학습 기본값은 Qwen/Qwen3-VL-8B-Instruct 다. 구조화 JSON 추출에는
추론 블록이 방해만 되고, 라벨에 thinking 형식을 흉내낼 필요도 없어 훨씬 단순하다.
전체 병합 경로(4단계)는 모델을 통째로 교체하므로 로컬 태그와 달라도 문제없다.
단, 경량 경로(4b, 기존 qwen3-vl:8b 위에 어댑터만 얹기)를 쓸 거면 베이스가
반드시 일치해야 한다 → --model Qwen/Qwen3-VL-8B-Thinking 으로 학습하고
BASE_HF_MODEL도 같이 맞춘다. 이때는 라벨도 Thinking 템플릿에 맞춰야 한다.
python scripts-vlm/05_test_ollama.py --model qwen3-vl:8b --limit 3 결과:
| 항목 | 정확도 | 관찰 |
|---|---|---|
store / date / total |
2/3 (67%) | 끝까지 생성만 되면 대체로 맞힌다 |
items (단가) |
0/3 (0%) | price에 **단가 대신 줄 합계(qty×price)**를 넣는다 |
| exact match | 0/3 (0%) | — |
| — | — | 3건 중 1건은 추론이 길어 done_reason=length로 답이 잘렸다 |
즉 베이스 모델은 "읽기"는 되는데 우리 스키마의 price 정의를 모른다. 이런
형식·정의 고정이 LoRA가 가장 잘 해결하는 종류의 문제다. 반대로 OCR 자체를 못 읽는
문제라면 파인튜닝보다 해상도/전처리를 먼저 보는 게 맞다.
이 맥은 Apple M4 / 통합메모리 24GB다.
| 대상 | 환경 | 판정 |
|---|---|---|
| qwen3-vl 8.8B | CUDA 24GB+ (A10/3090/4090) | 권장. --load-4bit QLoRA로 편하게 돌아간다 |
| qwen3-vl 8.8B | M4 24GB, scripts-vlm/01_train_lora.py |
스모크 테스트용. bf16 가중치만 17.6GB, 스왑이 걸려 매우 느리다 |
| qwen3-vl 8.8B | M4 24GB, MLX 4bit LoRA | 실사용 가능. 아래 "Apple Silicon 대안" 참고 |
| qwen3.8 27.3B | M4 24GB | 불가. bf16 가중치만 약 55GB |
| qwen3.8 27.3B | CUDA 48GB+ QLoRA | 여기서 돌려야 한다 |
즉 이 맥에서는 파이프라인 검증까지만 로컬로 하고, 본 학습은 GPU 인스턴스나
MLX로 돌리는 편을 권한다. scripts-llm 쪽은 --model Qwen/Qwen3-1.7B 같은 작은
모델로 파이프라인을 먼저 검증한 뒤 타깃 모델로 바꾸는 흐름을 기본으로 잡았다.
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txtCUDA에서 QLoRA를 쓸 거면 pip install bitsandbytes 를 추가한다(macOS에서는 설치하지 말 것).
python scripts-vlm/00_make_dataset.py --n-train 80 --n-valid 12data/images/*.png(합성 영수증)와 data/train.jsonl / data/valid.jsonl이 생긴다.
JSONL 한 줄 형식:
{"images": ["data/images/train_000.png"],
"messages": [{"role": "user", "content": "이 영수증 이미지를 읽고 ... JSON만 출력해라 ..."},
{"role": "assistant", "content": "{\"store\":\"청과마을 분당점\",\"date\":\"2026-01-01\",...}"}]}자기 데이터로 바꿀 때는 이 스키마만 맞추면 나머지 스크립트는 그대로 쓸 수 있다.
00_make_dataset.py를 변환기로 교체하면 된다. 실무에서 중요한 두 가지:
- 지시문(
INSTRUCTION)을 학습·추론에서 문자 단위로 동일하게 유지한다. 프롬프트가 달라지면 학습 효과가 상당 부분 날아간다. Modelfile의SYSTEM을 길게 쓰면 이게 깨지므로 짧게 두거나 비운다. - 라벨은 공백 없는 compact JSON으로 고정한다. 형식이 흔들리면 모델도 흔들린다.
실제 데이터 규모는 최소 수백~수천 샘플을 보자. 80개는 파이프라인 확인용이다.
# CUDA (권장)
python scripts-vlm/01_train_lora.py --load-4bit --epochs 3 --grad-accum 8 --grad-ckpt
# macOS 스모크 테스트 (파이프라인 검증만; 4샘플 1에폭)
PYTORCH_ENABLE_MPS_FALLBACK=1 python scripts-vlm/01_train_lora.py \
--max-samples 4 --epochs 1 --max-image-side 448 --grad-ckpt스크립트가 하는 일 중 눈여겨볼 부분:
- 라벨 마스킹: 프롬프트 구간은
-100으로 덮고 assistant 응답에만 loss를 건다. 프롬프트 길이를 잴 때 같은 이미지로 한 번 더 인코딩한다 — Qwen-VL은 이미지 그리드 크기에 따라image_pad토큰 수가 달라지므로 텍스트만으로 세면 어긋난다. - 비전 타워 동결: LoRA target은
q/k/v/o_proj,gate/up/down_proj뿐이다. Qwen3-VL 비전 타워는qkv/proj/linear_fc1/linear_fc2이름을 쓰므로 여기에 걸리지 않는다. 학습 시작 시vision tower touched: 0이 찍히는지 확인하자. 소량 데이터에서 비전 쪽을 같이 흔들면 모델이 쉽게 망가진다. - 이미지 해상도 상한(
--max-image-side,--max-pixels): VL 학습에서 OOM의 주범은 시퀀스 길이가 아니라 이미지 토큰 수다. 여기서 먼저 줄인다.min_pixels하한도 같이 낮춰 뒀다 — 흔히 쓰는256*28*28(=200704)을 그대로 두면--max-image-side 448로 줄인 이미지(약 168k 픽셀)를 프로세서가 되레 업스케일해서 절감이 사라진다. - 자체 검증 장치: 첫 샘플에서 학습 대상 구간을 실제로 디코딩해 정답 문자열로
시작하는지 확인한다(
[mask] 검증 통과로그). 채팅 템플릿이 바뀌어 마스킹 경계가 밀리는 사고를 학습 시작 직후에 잡는다.
결과: out/lora-receipt/ (어댑터 + 프로세서 설정).
GGUF 변환은 오래 걸리므로 먼저 학습이 먹었는지 본다.
python scripts-vlm/02_infer_test.py --no-adapter # 학습 전 baseline
python scripts-vlm/02_infer_test.py --adapter out/lora-receipt # 학습 후필드별(store/date/total/items) 일치율이 찍힌다. baseline보다 올라가지 않으면
GGUF로 넘어가지 말고 데이터/에폭/LR을 손보는 게 맞다.
python scripts-vlm/03_merge_lora.py --adapter out/lora-receipt --output out/merged
./scripts-vlm/04_export_gguf.sh out/merged qwen3vl-receipt Q4_K_M04_export_gguf.sh는 llama.cpp를 vendor/에 clone하고 llama-quantize를 빌드한 뒤:
convert_hf_to_gguf.py→ 언어모델 f16 GGUFconvert_hf_to_gguf.py --mmproj→ 비전 프로젝터 GGUFllama-quantize→ Q4_K_M- Modelfile 작성 후
ollama create
생성되는 Modelfile:
FROM ./qwen3vl-receipt-Q4_K_M.gguf
FROM ./qwen3vl-receipt-mmproj-f16.gguf
SYSTEM """너는 영수증 이미지에서 구조화된 JSON을 추출하는 도구다. JSON만 출력한다."""
PARAMETER temperature 0
VL 모델은 프로젝터 GGUF를 빼먹으면 이미지 입력이 죽는다. 두 FROM이 모두 있어야
한다. 디스크는 병합본 17GB + f16 GGUF 17GB + 양자화본 6GB로 40GB 이상 필요하다.
Qwen3-VL의 GGUF 변환(특히
--mmproj)은 llama.cpp 최신 빌드가 필요하다. 스크립트가--depth 1로 최신을 clone하지만, 변환기가qwen3vl아키텍처에서 실패하면 llama.cpp 이슈를 확인하고vendor/llama.cpp를git pull해서 다시 시도하자. 변환 스크립트 의존성은pip install -r vendor/llama.cpp/requirements.txt.
병합/재양자화 없이 어댑터만 GGUF로 바꿔 기존 qwen3-vl:8b 위에 얹는 방법도 있다.
./scripts-vlm/04b_export_adapter_only.sh out/lora-receipt qwen3vl-receipt-lora수십 MB로 끝나고 베이스 다운로드도 재활용된다. 대신 어댑터 GGUF 지원은 버전에 민감하고(VL 아키텍처는 특히), 양자화된 베이스 위에 얹히므로 품질이 조금 떨어진다. 실패하면 위의 전체 병합 경로를 쓰면 된다.
python scripts-vlm/05_test_ollama.py --model qwen3vl-receipt
python scripts-vlm/05_test_ollama.py --model qwen3-vl:8b # 파인튜닝 전과 비교/api/chat에 base64 이미지를 넣어 필드별 일치율과 exact match를 센다. 표준
라이브러리만 쓴다. done_reason=length로 답이 잘리면 경고를 찍으므로, 그때는
--num-predict를 올리면 된다(Thinking 변종 베이스를 테스트할 때 자주 걸린다).
ollama show qwen3.8:latest 실측:
architecture qwen35 parameters 27.3B quantization Q4_K_M (17GB)
capabilities completion, vision, tools, thinking
Projector clip, 460.73M params RENDERER qwen3.8 / PARSER qwen3.5
vision 능력과 CLIP 프로젝터(460M)가 붙어 있는 멀티모달 모델이고, 크기는 27.3B다. 그래서 두 가지를 감안해야 한다:
- 이 경로는 "언어 타워만 텍스트 데이터로 튜닝"하는 것이다. 비전 타워는
건드리지 않으므로, 이미지 능력을 유지하려면 내보낼 때 mmproj를 함께 넣어야
한다(→
scripts-vlm/04_export_gguf.sh사용).scripts-llm/04_export_gguf.sh는 텍스트 전용 산출물만 만든다. - 27.3B는 이 맥(24GB)에서 학습 불가다. bf16 가중치만 약 55GB다. CUDA 48GB급 에서 QLoRA로 돌려야 한다.
또 architecture qwen35 는 Qwen3.5 계열인데, 이에 대응하는 HF 원본 repo 이름을
이 저장소에서 확정하지 못했다. 그래서 --model 을 인자로 뺐고 기본값은 검증하기
쉬운 Qwen/Qwen3-1.7B 로 뒀다. 실제 타깃으로 돌릴 때는 HF에서 해당 모델의 repo id를
확인해 --model 로 넘겨야 한다. 파이프라인 자체는 모델에 무관하게 동작한다.
고객 지원 티켓 본문 → 라우팅 JSON:
{"messages": [
{"role": "user", "content": "다음 고객 문의를 읽고 ... JSON만 출력해라 ...\n\n문의:\n문의드립니다. 주문번호 872246. 카드로 결제했는데 두 번 청구됐습니다. 피해가 계속 커지고 있습니다. 결제 취소 부탁드립니다."},
{"role": "assistant", "content": "{\"category\":\"결제\",\"priority\":\"P1\",\"team\":\"billing\",\"refund_requested\":true}"}]}이미지가 없으므로 images 키가 없다. 생성기는 라벨이 본문에서 실제로 추론
가능하도록 규칙 기반으로 만든다(심각도 표현 → priority, 증상 문장 → category,
환불 문구 → refund_requested). 라벨이 본문과 무관한 난수면 모델은 아무것도 배우지
못하는데 loss는 떨어지는 것처럼 보인다 — 합성 데이터의 가장 흔한 함정이다.
train/valid 본문 중복(데이터 누수)도 생성 시 차단한다.
python scripts-llm/00_make_dataset.py --n-train 300 --n-valid 40실측 분포: category 5종 53~65개씩, priority P1/P2/P3 92/107/101, 환불 요청 114/300, train↔valid 중복 0건.
# 파이프라인 검증 (작은 모델로 빠르게)
python scripts-llm/01_train_lora.py --model Qwen/Qwen3-1.7B --epochs 1
# 실제 타깃 (CUDA 48GB+ 권장)
python scripts-llm/01_train_lora.py --model <qwen3.5-27b HF repo> --load-4bit --grad-ckpt
python scripts-llm/02_infer_test.py --no-adapter # baseline
python scripts-llm/02_infer_test.py --adapter out/lora-ticket
python scripts-llm/03_merge_lora.py --output out/merged-llm
./scripts-llm/04_export_gguf.sh out/merged-llm qwen-ticket Q4_K_M
python scripts-llm/05_test_ollama.py --model qwen-ticket
python scripts-llm/05_test_ollama.py --model qwen3.8:latest # 파인튜닝 전과 비교scripts-vlm 대비 다른 점만 정리하면:
- 프로세서 대신 토크나이저만 쓴다. 이미지 픽셀 상한 같은 고민이 없다.
enable_thinking=False: Qwen3 계열 채팅 템플릿은 이 인자를 받는다. 구조화 JSON 출력에 추론 블록은 방해만 되므로 학습·추론 양쪽에서 끈다. 템플릿이 이 인자를 모르면TypeError가 나므로 빼고 재시도하게 해뒀다.--max-len초과 잘림 카운트: 답이 잘리면 형식 학습이 망가지는데 조용히 진행되기 쉽다. 학습 끝에 잘린 샘플 수를 경고로 찍는다.- 멀티모달 베이스를 쓸 경우
[lora] vision/projector touched: 0로그로 비전 타워에 LoRA가 안 붙었는지 확인한다. 03_merge_lora.py는AutoModelForCausalLM으로 로드하므로 멀티모달 베이스에서는 비전 타워가 빠질 수 있다. 이미지 능력을 유지해야 하면scripts-vlm/03_merge_lora.py(AutoModelForImageTextToText)로 병합하자.
24GB 맥에서 8B를 실제로 학습시키려면 4bit 양자화 모델에 LoRA를 붙이는 MLX 경로가 현실적이다.
pip install mlx-vlm
python -m mlx_vlm.lora --help # 버전별로 플래그가 달라 먼저 확인할 것데이터는 이 저장소의 data/train.jsonl과 같은 {"images": [...], "messages": [...]}
형식을 그대로 쓴다. 다만 mlx-vlm의 LoRA CLI는 릴리스마다 인자가 바뀌므로, 정확한
플래그는 --help로 확인하고 쓰는 편이 안전하다. 학습된 MLX 어댑터는 MLX 런타임에서
바로 추론할 수 있지만 Ollama(GGUF)로는 직접 넘어가지 않는다. Ollama에 올리는 것이
목표라면 학습은 transformers/peft 경로로 하고, GPU 인스턴스를 빌리는 쪽이 단순하다.
| 파일 | 역할 |
|---|---|
scripts-vlm/00_make_dataset.py |
합성 영수증 이미지 + JSONL 라벨 생성 |
scripts-vlm/01_train_lora.py |
LoRA 학습 (라벨 마스킹, 비전 동결, QLoRA 옵션) |
scripts-vlm/02_infer_test.py |
학습 전/후 필드 일치율 비교 |
scripts-vlm/03_merge_lora.py |
어댑터를 fp16 베이스에 병합 |
scripts-vlm/04_export_gguf.sh |
GGUF 변환(+mmproj) → 양자화 → ollama create |
scripts-vlm/04b_export_adapter_only.sh |
어댑터만 GGUF로 만들어 기존 베이스에 얹기 |
scripts-vlm/05_test_ollama.py |
Ollama REST로 최종 검증 |
| 파일 | 역할 |
|---|---|
scripts-llm/00_make_dataset.py |
합성 고객 티켓 + 라우팅 JSON 라벨 생성 (텍스트) |
scripts-llm/01_train_lora.py |
텍스트 SFT LoRA (라벨 마스킹, enable_thinking=False) |
scripts-llm/02_infer_test.py |
학습 전/후 필드 일치율 비교 |
scripts-llm/03_merge_lora.py |
어댑터 병합 (CausalLM) |
scripts-llm/04_export_gguf.sh |
GGUF 변환 → 양자화 → ollama create (mmproj 없음) |
scripts-llm/05_test_ollama.py |
Ollama REST로 최종 검증 |
- loss가 0 또는 nan — 라벨이 전부
-100이 된 경우다. 채팅 템플릿이 바뀌어 프롬프트 길이 계산이 전체 길이와 같아졌을 수 있다.labels != -100의 개수를 찍어보자. - MPS OOM / 스왑 폭주 —
--max-image-side 448 --max-pixels 200704 --grad-ckpt,--batch-size 1. 그래도 안 되면 MLX 경로나 CUDA로 간다. vision tower touched가 0이 아님 — transformers 버전이 바뀌어 모듈 이름이 달라진 것이다.target_modules를 언어모델 경로 정규식으로 좁혀야 한다.- Ollama에서 이미지를 못 받음 — Modelfile에 mmproj
FROM이 빠졌다. - 출력이 JSON이 아니고 잡말이 섞임 — Modelfile
SYSTEM이 학습 시 프롬프트와 충돌하는 경우가 많다.SYSTEM을 비우고 학습 때 쓴INSTRUCTION을 그대로 넣어보자.