이 LoRA는 GGUF / llama.cpp / Ollama 경로로 배포할 수 없습니다. 겉보기에는 동작할 것 같습니다 — convert_hf_to_gguf.py도 오류 없이 실행되고, GGUF 파일도 정상적으로 로드되며, 서버도 시작됩니다. 그러나 출력은 <think>\n\n</think>\n\n?adgeadgeadge... (토큰 ID 58659로의 퇴화 어트랙터) 가 됩니다. F16, Q5_K_M, Q4_K_M, pre-permute 적용/미적용, linear_attn LoRA 0으로 비우기 — 모든 조합에서 같은 증상을 확인했습니다.
원인 (그냥 동작만 시키고 싶다면 건너뛰셔도 됩니다):
이 어댑터는 linear_attn.in_proj_qkv|in_proj_z|in_proj_a|in_proj_b|out_proj (Qwen3.5 하이브리드 아키텍처 내 Gated DeltaNet 투영 — 전체 레이어의 75 %)를 학습 대상으로 합니다. convert_hf_to_gguf.py:_reorder_v_heads 는 ggml CUDA 커널이 효율적으로 repeat-브로드캐스트할 수 있도록 해당 텐서들의 V-row 레이아웃을 순열 변환합니다. 베이스 모델 자체는 특정 이항 연산 패턴 하에서 이 순열에 불변이지만, LoRA 델타는 그렇지 않습니다. 순열 후 델타가 헤드 위치를 어긋난 채 적용되어 토큰 붕괴가 발생합니다. merge → GGUF 경로는 모두 이 구조적 결함을 그대로 물려받습니다.
제대로 동작하는 배포 경로는 unsloth.FastVisionModel + peft.PeftModel.from_pretrained (런타임 LoRA, 병합 없음, GGUF 변환 없음) 입니다. 아래의 레시피가 표준 설정입니다.
빠른 시작 (FP16, 약 20 GB VRAM)
python
1from unsloth import FastVisionModel
2from peft import PeftModel
3from PIL import Image
45# 1. Unsloth로 베이스 로드 (transformers.AutoModelForImageTextToText 가 아닙니다 —6# 학습 시점의 linear_attn forward 경로와 일치시키려면 Unsloth의 monkey-patch가7# 반드시 적용되어야 합니다).8model, tokenizer = FastVisionModel.from_pretrained(9"unsloth/Qwen3.5-9B",10 load_in_4bit=False,# 4-bit 사용 시 정확도 손실 없이 약 8.7 GB VRAM11)1213# 2. LoRA를 런타임 훅으로 부착. model.merge_and_unload() 는 호출하면 안 됩니다.14# 병합 시 linear_attn 델타가 조용히 손상되어 출력이 "adgeadge"로 떨어집니다.15model = PeftModel.from_pretrained(model,"pfox1995/pest-detector-final")1617# 3. 매우 중요 — 내부 모드를 추론용으로 전환합니다. 이 호출이 없으면18# 다른 모든 설정이 옳더라도 출력이 망가집니다.19FastVisionModel.for_inference(model)20model.eval()2122# 4. 추론23image = Image.open("pest.jpg").convert("RGB")24image = letterbox(image,512)# 회색 패딩 letterbox 512×512 — 아래 참조2526messages =[27{"role":"system","content":[{"type":"text","text": SYSTEM_MSG}]},28{"role":"user","content":[29{"type":"image","image": image},30{"type":"text","text":"이 사진에 있는 해충의 이름을 알려주세요."},31]},32]33text = tokenizer.apply_chat_template(messages, add_generation_prompt=True)34inputs = tokenizer(image, text, add_special_tokens=False, return_tensors="pt").to("cuda")3536with__import__("torch").inference_mode():37 out = model.generate(38**inputs,39 max_new_tokens=10,# 16 이상이면 안 됨 — 모델은 "<클래스>\n"을 출력하므로 더 길게 강제하면 쓰레기40 use_cache=True,41 stop_strings=["\n"],# 모델의 자연스러운 정지 신호42 tokenizer=tokenizer.tokenizer,# stop_strings를 위해 반드시 필요43)4445prediction = tokenizer.decode(46 out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True47).strip()48print(prediction)# PEST_CLASSES 중 하나 (한국어)
전체 실행 가능한 스크립트(이미지 전처리, 배치 평가, 메트릭 포함)는 inference.py 를 참고하세요.
RunPod 컨테이너가 재시작되면 컨테이너 디스크가 초기화되어 pip 패키지가 사라집니다 (/workspace 볼륨은 유지됨). 이 상황을 한 번에 복구해주는 스크립트:
bash
1# /workspace 에 이 저장소를 받아두었다고 가정2bash /workspace/restart_server.sh
스크립트가 자동으로 처리하는 단계:
의존성 스택 점검 (unsloth, peft, fastapi, bitsandbytes, flash-linear-attention) → 없으면 설치
causal_conv1d 점검 → 없으면 사전 빌드된 wheel 설치 (소스 빌드는 9개 GPU 아키텍처 컴파일 때문에 메모리 부족으로 실패함)
기존 pest tmux 세션 종료 후 새로 시작
/health 가 200을 반환할 때까지 대기 (약 90~100초)
공개 프록시 URL 출력 — RunPod의 $RUNPOD_POD_ID 환경변수에서 자동으로 추론
환경변수로 동작 변경 가능:
bash
1LOAD_IN_4BIT=false bash /workspace/restart_server.sh # FP16, 약 19.5 GB VRAM, 추론 속도 약 2배2PORT=9000bash /workspace/restart_server.sh # 다른 포트로3ADAPTER=... bash /workspace/restart_server.sh # 다른 어댑터로4PUBLIC_URL=... bash /workspace/restart_server.sh # 자동 감지 결과 덮어쓰기
RunPod 에서 8080 포트를 외부에 노출하려면:
RunPod 대시보드 → 해당 Pod 선택 → Edit Pod → HTTP Ports 에서 8080 추가
저장 → 자동으로 https://<POD_ID>-8080.proxy.runpod.net/ 가 활성화됨
첫 번째 요청은 약 12초 (Triton JIT 컴파일), 이후는 정상 상태 약 2~3초/이미지
⚠ Edit Pod 는 내부적으로 컨테이너를 재시작합니다. 컨테이너 디스크가 초기화되므로 다시 restart_server.sh 를 실행해 주세요. 볼륨(/workspace) 의 파일과 모델 캐시는 유지됩니다. SSH 의 publicPort 도 바뀌므로 GraphQL 로 새 포트를 다시 조회해야 합니다.
출력을 망가뜨리는 9가지 함정 (각각 직접 부딪힌 것들)
출력이 이상하면 아래 항목을 순서대로 점검하세요. 모든 항목이 실제로 adgeadge 또는 <think>...assistant<think>... 반복을 일으킨 사례입니다.
1. 잘못된 로더
unsloth.FastVisionModel.from_pretrained 를 사용해야 합니다. transformers.AutoModelForImageTextToText.from_pretrained 는 안 됩니다. 같은 가중치를 로드하지만 후자는 학습 시점에 사용된 Unsloth의 Gated DeltaNet 패치를 적용하지 않습니다.
2. LoRA 병합
model.merge_and_unload() 또는 model.save_pretrained_merged(...) 를 호출하지 마세요. PEFT의 병합은 이 아키텍처의 linear_attn 모듈에 대해 조용히 잘못된 가중치를 생성합니다. PeftModel.from_pretrained 를 통해 LoRA를 런타임 훅으로 유지하세요.
3. FastVisionModel.for_inference(model) 누락
이 호출이 Unsloth의 내부 캐시/추론 모드를 전환합니다. 빠뜨리면 첫 토큰은 정상이다가 이어지는 토큰이 adge 어트랙터로 빠질 수 있습니다.
4. max_new_tokens 가 너무 큼
모델은 <클래스>\n (예: 검거세미밤나방\n) 을 출력하고 자연스럽게 멈춥니다. 그러나 16 토큰 이상을 요구하면 \n 을 지나서 계속 생성하다 adge 어트랙터에 빠집니다. 클래스명 출력에는 max_new_tokens=10 을 사용하세요.min_new_tokens 는 설정하지 마세요(EOS 이후로 강제 생성하게 됩니다).
5. stop_strings=["\n"] 누락
max_new_tokens=10 이라도 모델은 <클래스>\nassistant\n<think> 를 출력할 수 있습니다. model.generate 에 stop_strings=["\n"] 와 함께 tokenizer=tokenizer.tokenizer 도 전달해야 합니다. tokenizer= 인자가 없으면 stop_strings 가 동작하지 않습니다.
6. enable_thinking 호출 방식 오류
챗 템플릿이 enable_thinking 변수에 따라 분기됩니다. 학습은 thinking 비활성 모드로 진행됐습니다. tokenizer.apply_chat_template(...) 에 enable_thinking=False 를 직접 키워드 인자로 전달하세요. chat_template_kwargs={"enable_thinking": False} 로 감싸면 transformers ≥ 5.0 의 VLM 프로세서에서 조용히 무시됩니다.
7. 잘못된 시스템 프롬프트
학습 시 사용한 시스템 메시지를 정확히 그대로 사용하세요(아래 SYSTEM_MSG 참조). 19개 클래스를 나열한 더 긴 버전을 시도해 봤는데 출력에 편향이 생겼습니다.
8. 잘못된 이미지 크기
모델은 회색(RGB 128, 128, 128) 패딩으로 letterbox 처리된 512×512 이미지로 학습되었습니다. 다른 해상도나 패딩 색상을 쓰면 정확도가 떨어집니다.
9. 빠른 linear-attn 경로용 의존성 누락
pip install flash-linear-attention causal-conv1d 로 transformers 가 Gated DeltaNet 의 빠른 Triton 커널을 사용할 수 있게 하세요. 없으면 torch 폴백 경로를 타게 되는데, FP16 누산 순서가 달라서 적응된 가중치에서는 출력이 표류할 수 있습니다. (torch 2.8 + cu128 용 사전 빌드된 wheel 이 있습니다.)
시스템 프롬프트 및 상수 (학습 시점과 동일)
python
1SYSTEM_MSG =(2"당신은 작물 해충 식별 전문가입니다. "3"사진을 보고 해충의 이름만 한국어로 답하세요. "4'해충이 없으면 "정상"이라고만 답하세요. '5"부가 설명 없이 이름만 출력하세요."6)7USER_PROMPT ="이 사진에 있는 해충의 이름을 알려주세요."89PEST_CLASSES =[10"검거세미밤나방","꽃노랑총채벌레","담배가루이","담배거세미나방",11"담배나방","도둑나방","먹노린재","목화바둑명나방","무잎벌",12"배추좀나방","배추흰나비","벼룩잎벌레","비단노린재","썩덩나무노린재",13"알락수염노린재","정상","큰28점박이무당벌레","톱다리개미허리노린재",14"파밤나방",15]
19개 클래스는 18종의 해충 + 정상 으로 구성됩니다. 학습 데이터 분포는 클래스 간 대체로 균형을 이룹니다.
torch==2.8.0+cu128
transformers>=5.2,<6.0 # 5.5.0 검증됨
peft==0.19.1 # adapter_config.json 의 버전과 반드시 일치
unsloth==2026.4.8 # 더 이전 버전은 cu128-torch280 extra 를 사용
xformers>=0.0.32 # FA2 폴백으로 충분
Pillow>=10.0
flash-linear-attention # Gated DeltaNet 빠른 경로
causal-conv1d # fla 와 함께 사용
causal-conv1d 가 사용 중인 CUDA 버전에서 빌드 실패하면 Unsloth 가 broken 으로 표시하고 계속 진행합니다 — 정확도는 영향 없고 처리량만 약간 떨어집니다.
bnb 4-bit (NF4) 로 VRAM 줄이기
베이스를 4-bit 로 로드하려면 한 줄만 바꾸면 됩니다:
python
1model, tokenizer = FastVisionModel.from_pretrained(2"unsloth/Qwen3.5-9B",3 load_in_4bit=True,# 이전 값: False4)5# 나머지 설정은 모두 동일
측정 결과:
VRAM: 19.5 GB → 8.7 GB (55 % 감소)
디스크: ~18 GB → ~5 GB
정확도: 비트 단위 동일 — 10샘플 프로브에서 8/10 로 일치 (틀린 2개는 두 설정 모두에서 동일한 어려운 사례 담배가루이 → 정상 혼동)
이 LoRA 에서 작동하는 유일한 양자화 방식인데, LoRA 가 양자화된 가중치에 굽히지 않고 PEFT 훅을 통해 런타임에서 유지되기 때문입니다.
load_in_8bit=True (LLM.int8) 도 동작하지만 이 작업에서는 NF4 대비 품질 이득 없이 VRAM 약 13 GB 를 사용합니다.
동작하지 않는 경로들 (시도하지 마세요)
model.save_pretrained_gguf(...) — 정확도 0 %
model.save_pretrained_merged(...) 후 convert_hf_to_gguf.py — 0 %
변환 전 _reorder_v_heads 미리 적용 — 5.3 % (붕괴)
병합 → GGUF 전에 linear_attn LoRA 를 0으로 — 35.1 % (적응 손실)
병합된 모델에 AutoAWQ — 같은 병합 버그
peft.merge_and_unload() 후 병합된 디렉토리에 transformers.AutoModelForImageTextToText.from_pretrained — adgeadge
병합된 모델로 vLLM — 위와 동일
패턴은 명확합니다: linear_attn 의 LoRA 델타를 FastVisionModel 런타임 경로 외의 방식으로 건드리는 모든 시도가 쓰레기 출력을 만듭니다. 구조적 결함은 convert_hf_to_gguf.py:_reorder_v_heads 에 있고, 상위 이슈는 llama.cpp#21125 입니다.
알려진 분류 오류
아래는 배포 버그가 아닌 실제 모델의 분류 오류 입니다 — 모델이 올바른 한국어 클래스명을 출력하지만 정답이 아닌 경우입니다:
정답 → 예측
비율 (57샘플 벤치)
이유
도둑나방 → 검거세미밤나방
2/3
둘 다 어두운 색 나방, 시각적으로 유사
비단노린재 → 목화바둑명나방
2/3
작고 얼룩무늬가 있는 곤충
담배가루이 → 정상
2/3
작은 흰 해충, 놓치기 쉬움
알락수염노린재 → 정상
1/3
위와 유사
벼룩잎벌레 → 목화바둑명나방 / 배추좀나방
1/3 씩
작고 어두운 곤충
19개 클래스 중 14개는 벤치에서 100 % (3/3) 적중합니다. 약한 클래스를 개선하려면 해당 종의 샘플을 추가하거나 더 변별력 있는 augmentation 으로 재학습하세요.