Fine-tuning self-hosted: LoRA, QLoRA workflow end-to-end su GPU consumer
Llama 3.1 8B è un modello straordinariamente capace, ma parla con un tono generico. Non conosce il tuo dominio, non scrive nel tuo stile, non risponde con il vocabolario specifico del tuo settore. Full fine-tuning per personalizzarlo costerebbe settimane di compute su hardware enterprise. Ma esiste un'alternativa praticabile su una GPU da €800 che hai già in casa: LoRA e QLoRA - tecniche che allenano meno dello 0.5% dei parametri del modello producendo risultati che per la maggior parte dei casi d'uso sono indistinguibili da un full fine-tuning.
Ho fatto questa esperienza concretamente. Ho preso i 1000 articoli tecnici del mio portfolio su federicocalo.dev, li ho convertiti in formato Alpaca, e ho fine-tunato Llama 3.1 8B su una RTX 3090 in circa 6 ore. Costo totale: ~€15 tra elettricità e costo ammortizzato della GPU. Risultato: un modello che genera bozze di articoli nel mio stile, usa il mio vocabolario tecnico, e cita correttamente le tecnologie che effettivamente uso. In questo episodio ti mostro il workflow completo - dal dataset raw al modello servito su Ollama - con ogni riga di codice necessaria per replicarlo.
Il contesto tecnico: questo articolo è l'EP08 della serie Self-Hosted LLM Stack. Nei precedenti episodi abbiamo costruito l'infrastruttura di serving. Ora aggiungiamo il layer di customizzazione. Se stai partendo da zero, ti consiglio di leggere almeno EP01 (Ollama vs vLLM) e EP02 (setup Ollama production) prima di continuare.
Cosa Imparerai
- La matematica di LoRA e QLoRA: perché funzionano e quando falliscono
- Come preparare un dataset in formato Alpaca da contenuti esistenti
- Come configurare e lanciare un training QLoRA con Unsloth su Llama 3.1 8B
- Come fare hyperparameter sweep con W&B per trovare il learning rate ottimale
- Come fare il merge del LoRA adapter nel base model e convertire in GGUF
- Come caricare il modello fine-tuned su Ollama via Modelfile personalizzato
- Come calcolare il costo reale di fine-tuning su GPU consumer vs cloud
Prerequisiti
- Python 3.10+ con PyTorch 2.x installato
- GPU NVIDIA con almeno 12GB VRAM (RTX 3060 limite inferiore, RTX 3090 ideale)
- CUDA 12.x e driver NVIDIA >= 535
- Conoscenza base di Transformer (attention, token, layer)
- Ollama installato e funzionante (vedi EP01-EP02 di questa serie)
Sezione 1 - LoRA vs Full Fine-tuning vs QLoRA: la matematica che conta
Prima di scrivere una riga di codice, è essenziale capire cosa succede matematicamente con LoRA. In un Transformer, ogni layer attention ha matrici di peso W di dimensione d×d, dove d è tipicamente 4096 per un modello 8B. Full fine-tuning aggiorna tutti questi pesi: per Llama 3.1 8B significa aggiornare ~8 miliardi di parametri. In termini di VRAM, il modello FP16 pesa ~16GB, i gradienti altri ~16GB, optimizer states (Adam) altri ~32GB: servono ~64GB solo per il training, più i batch di input. Serve un cluster A100 e settimane di addestramento.
LoRA (Low-Rank Adaptation) fa un'osservazione brillante: le modifiche di peso durante il fine-tuning tendono a avere basso rango intrinseco. Invece di aggiornare W direttamente, introduce due matrici piccole A (d×r) e B (r×d) dove r è il rango, tipicamente 8-64, molto più piccolo di d. L'aggiornamento del peso diventa W' = W + B·A. Per r=16 e d=4096, invece di 4096×4096 = 16.7M parametri per layer, alleni solo 2×4096×16 = 131K parametri. In totale per tutti i layer: circa 40-80M parametri trainabili contro 8B - una riduzione del 99%.
QLoRA estende LoRA aggiungendo quantizzazione NF4 (NormalFloat4) al modello base via BitsAndBytes. Il modello base viene caricato in 4-bit invece di 16-bit, dimezzando la VRAM necessaria per il modello da ~16GB a ~5GB. I LoRA adapters rimangono in BF16 per precisione di training. La differenza pratica: Llama 3.1 8B QLoRA su RTX 3090 richiede 10-12GB di VRAM con gradient checkpointing, lasciando 12-14GB liberi per batch size ragionevoli.
| Approccio | VRAM necessaria | Parametri trainabili | Training speed (rel.) | Accuracy vs full | Costo indicativo (8B, 1000 esempi) |
|---|---|---|---|---|---|
| Full Fine-tuning | 64+ GB | 8B (100%) | 1x (baseline) | 100% | ~€80-150 cloud A100 |
| LoRA (r=16) | ~18-20 GB | ~50M (0.6%) | 3-5x più veloce | 90-95% | ~€5-10 cloud RTX 3090 |
| QLoRA (4-bit + r=16) | ~10-12 GB | ~50M (0.6%) | 2-3x più veloce | 88-93% | ~€2-5 cloud / €0.50 consumer |
| QLoRA + Unsloth | ~7-9 GB | ~50M (0.6%) | 5-7x più veloce | 88-93% | ~€1-3 cloud / €0.30 consumer |
La colonna "Accuracy vs full" merita una nota: il gap 7-12% non è uniforme. Per task di adattamento stilistico (scrivi come Federico Calò), knowledge injection (conosci le tecnologie X, Y, Z), o domain adaptation (rispondi come un esperto di fiscalità italiana), LoRA/QLoRA raggiunge praticamente la parità con full fine-tuning. Il gap è più visibile per task che richiedono modifiche profonde al ragionamento del modello.
Sezione 2 - Preparazione Dataset in Formato Alpaca
Il dataset è il 70% del risultato finale. Puoi avere il training setup più ottimizzato del mondo, ma con dati rumorosi il modello imparerà comportamenti sbagliati. La regola pratica 2025-2026 che ogni paper di fine-tuning ha confermato: 500 esempi di qualità alta battono 5000 esempi rumorosi per la quasi totalità dei task di adattamento.
Il formato Alpaca è diventato lo standard de-facto per instruction tuning perché è semplice e universalmente supportato. Ogni esempio è un JSON con tre campi: instruction (cosa deve fare il modello), input (contesto opzionale), output (risposta attesa). Per il mio caso d'uso - fine-tuning su articoli del portfolio - ho usato questo schema: instruction = "Scrivi un articolo tecnico su [TOPIC] nel formato del blog federicocalo.dev", input = titolo + abstract + punti chiave, output = l'articolo completo.
// dataset/portfolio_articles.jsonl
// Ogni riga è un JSON separato (formato JSONL)
// Esempio di entry per fine-tuning stile articoli portfolio
{
"instruction": "Scrivi un articolo tecnico per il blog federicocalo.dev su: Monitoring self-hosted LLM con Prometheus e Grafana. Il tono deve essere diretto, con numeri concreti, esempi di codice commentati in italiano. Struttura: intro hook + sezioni H3 + snippet + conclusione con CTA.",
"input": "Topic: GPU monitoring, cost tracking, alert threshold\nSerie: Self-Hosted LLM Stack EP05\nLivello: intermedio\nLunghezza target: 2000-2500 parole",
"output": "Avere un server GPU che brucia €200/mese di elettricità senza che tu lo sappia è un problema reale...\n\n[articolo completo]"
}
{
"instruction": "Scrivi una sezione tecnica su QLoRA BitsAndBytes 4-bit per un articolo sul fine-tuning self-hosted.",
"input": "Pubblico: ML engineer con conoscenza base di PyTorch\nFocus: configurazione BitsAndBytesConfig, trade-off precisione/memoria",
"output": "QLoRA carica il modello base in NormalFloat4 (NF4), un tipo a 4 bit ottimizzato per pesi di reti neurali distribuiti normalmente..."
}
Lo script di conversione che ho usato per trasformare gli articoli HTML del portfolio in JSONL Alpaca:
# scripts/prepare_dataset.py
# Converte articoli HTML del portfolio in formato Alpaca JSONL
# Richiede: pip install beautifulsoup4 langdetect tqdm
import json
import re
from pathlib import Path
from bs4 import BeautifulSoup
from tqdm import tqdm
def html_to_text(html_content: str) -> str:
"""Estrae testo pulito da HTML articolo, rimuove codice."""
soup = BeautifulSoup(html_content, "html.parser")
# Rimuove blocchi di codice per evitare training su snippet specifici
for code_block in soup.find_all(["pre", "code"]):
code_block.decompose()
return soup.get_text(separator="\n", strip=True)
def build_alpaca_entry(article_json: dict, article_html: str) -> dict:
"""Costruisce una entry Alpaca da metadati + contenuto."""
text = html_to_text(article_html)
# Limita a 2000 parole per evitare context overflow durante training
words = text.split()
if len(words) > 2000:
text = " ".join(words[:2000]) + "\n[continua...]"
instruction = (
f"Scrivi un articolo tecnico per federicocalo.dev su: {article_json['title']}. "
f"Categoria: {article_json['category']}. "
f"Tono: diretto, tecnico, numeri concreti, esempi pratici. "
f"Scrivi in italiano, usa 'tu' diretto al lettore."
)
input_context = (
f"Tag: {', '.join(article_json.get('tags', [])[:5])}\n"
f"Reading time: {article_json.get('readingTime', 10)} minuti\n"
f"Keywords: {', '.join(article_json.get('shortKeywords', []))}"
)
return {
"instruction": instruction,
"input": input_context,
"output": text
}
def prepare_dataset(articles_dir: Path, output_file: Path, min_words: int = 500):
"""
Scansiona la directory articoli e costruisce il dataset JSONL.
Filtra articoli troppo corti o senza contenuto HTML.
"""
entries = []
article_dirs = [d for d in articles_dir.iterdir() if d.is_dir()]
for article_dir in tqdm(article_dirs, desc="Processing articles"):
json_path = article_dir / "it.json"
html_path = article_dir / "it.html"
if not json_path.exists() or not html_path.exists():
continue
try:
with open(json_path, "r", encoding="utf-8") as f:
metadata = json.load(f)
with open(html_path, "r", encoding="utf-8") as f:
html_content = f.read()
# Filtra articoli troppo corti
word_count = metadata.get("wordCount", 0)
if word_count < min_words:
continue
entry = build_alpaca_entry(metadata, html_content)
entries.append(entry)
except (json.JSONDecodeError, Exception) as e:
print(f"Errore in {article_dir.name}: {e}")
continue
# Shuffle per evitare bias sequenziale
import random
random.seed(42)
random.shuffle(entries)
# Split train/validation 90/10
split_idx = int(len(entries) * 0.9)
train_entries = entries[:split_idx]
val_entries = entries[split_idx:]
# Salva in JSONL
with open(output_file, "w", encoding="utf-8") as f:
for entry in train_entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
val_file = output_file.with_stem(output_file.stem + "_val")
with open(val_file, "w", encoding="utf-8") as f:
for entry in val_entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
print(f"Dataset pronto: {len(train_entries)} train, {len(val_entries)} validation")
return train_entries, val_entries
if __name__ == "__main__":
articles_dir = Path("frontend/public/content/articles")
output_file = Path("dataset/portfolio_train.jsonl")
output_file.parent.mkdir(exist_ok=True)
prepare_dataset(articles_dir, output_file, min_words=500)
Dopo la preparazione, ho ottenuto 923 entry di training e 103 di validation dai 1026 articoli IT del portfolio. Le 103 escluse erano troppo corte (<500 parole) o articoli con problemi di parsing HTML.
Sezione 3 - QLoRA Training con Unsloth
Unsloth è un framework di fine-tuning che riscrive i kernel CUDA critici in Triton, ottenendo 2-2.7x di speedup e 60-74% di risparmio VRAM rispetto a HuggingFace Trainer standard con Flash Attention 2. La cosa importante: non fa approssimazioni - i risultati sono numericamente identici. Il benchmark ufficiale su Llama 3.1 8B mostra 2.1x speedup su RTX 3090.
| Configurazione | VRAM peak (8B, bs=4) | Token/sec training | Tempo per 1000 step | Note |
|---|---|---|---|---|
| HF Trainer + FA2 (FP16) | ~20 GB | ~850 tok/s | ~35 min | Richiede RTX 3090 piena |
| HF Trainer + QLoRA 4-bit | ~13 GB | ~620 tok/s | ~48 min | Funziona su RTX 4080 16GB |
| Unsloth + QLoRA 4-bit | ~8 GB | ~1300 tok/s | ~23 min | Funziona su RTX 3060 12GB |
| Unsloth + LoRA BF16 | ~14 GB | ~1700 tok/s | ~18 min | Più veloce se hai la VRAM |
Installazione dipendenze - è importante usare le versioni compatibili:
# Installa Unsloth con dipendenze compatibili
# Richiede Python 3.10-3.11, CUDA 12.x
pip install "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git"
pip install --no-deps "xformers==0.0.27" trl peft accelerate bitsandbytes
pip install wandb # per hyperparameter tracking
# Verifica installazione
python -c "import unsloth; print(unsloth.__version__)"
Lo script di training QLoRA completo con Unsloth:
# train_qlora.py
# Fine-tuning QLoRA di Llama 3.1 8B con Unsloth
# Testato su: RTX 3090 24GB, CUDA 12.2, Unsloth 2025.x
import torch
import wandb
from datasets import load_dataset
from trl import SFTTrainer
from transformers import TrainingArguments, DataCollatorForSeq2Seq
from unsloth import FastLanguageModel
from unsloth.chat_templates import get_chat_template
# ── Configurazione modello ───────────────────────────────────────────────────
MAX_SEQ_LENGTH = 2048 # Llama 3.1 supporta 128K, ma 2048 è ottimale per articoli
LORA_RANK = 16 # r=16: buon bilanciamento tra qualità e velocità
LORA_ALPHA = 16 # alpha/r = 1.0 è la convenzione standard
LORA_DROPOUT = 0.0 # Unsloth raccomanda 0.0 per i suoi kernel ottimizzati
TARGET_MODULES = [ # Layer dove applicare LoRA (tutti i linear per r=16)
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
]
# ── Carica modello base in 4-bit (QLoRA) ────────────────────────────────────
model, tokenizer = FastLanguageModel.from_pretrained(
model_name="unsloth/Meta-Llama-3.1-8B-Instruct",
max_seq_length=MAX_SEQ_LENGTH,
dtype=None, # Autodetect: BF16 su Ampere+, FP16 su Turing
load_in_4bit=True, # QLoRA: quantizzazione NF4
)
# ── Aggiunge LoRA adapters al modello quantizzato ───────────────────────────
model = FastLanguageModel.get_peft_model(
model,
r=LORA_RANK,
target_modules=TARGET_MODULES,
lora_alpha=LORA_ALPHA,
lora_dropout=LORA_DROPOUT,
bias="none", # Nessun bias trainabile: risparmia memoria
use_gradient_checkpointing="unsloth", # 30% meno VRAM, ~5% più lento
random_state=42,
use_rslora=False, # rsLoRA: utile solo per r>64
loftq_config=None,
)
# Stampa statistiche parametri trainabili
model.print_trainable_parameters()
# Output atteso: trainable params: 41,943,040 || all params: 8,072,204,288
# trainable%: 0.5196
# ── Formato prompt Alpaca-style ──────────────────────────────────────────────
ALPACA_PROMPT = """Di seguito è riportata un'istruzione che descrive un'operazione, \
insieme a un contesto che fornisce ulteriori informazioni. \
Scrivi una risposta che completa adeguatamente la richiesta.
### Istruzione:
{}
### Contesto:
{}
### Risposta:
{}"""
EOS_TOKEN = tokenizer.eos_token
def format_alpaca_prompt(examples: dict) -> dict:
"""Formatta batch di esempi nel template Alpaca con EOS token."""
instructions = examples["instruction"]
inputs = examples["input"]
outputs = examples["output"]
texts = []
for instruction, context, output in zip(instructions, inputs, outputs):
text = ALPACA_PROMPT.format(instruction, context, output) + EOS_TOKEN
texts.append(text)
return {"text": texts}
# ── Carica e processa dataset ────────────────────────────────────────────────
train_dataset = load_dataset(
"json", data_files="dataset/portfolio_train.jsonl", split="train"
)
val_dataset = load_dataset(
"json", data_files="dataset/portfolio_train_val.jsonl", split="train"
)
train_dataset = train_dataset.map(format_alpaca_prompt, batched=True)
val_dataset = val_dataset.map(format_alpaca_prompt, batched=True)
print(f"Train: {len(train_dataset)} esempi | Val: {len(val_dataset)} esempi")
# ── Inizializza W&B per tracking ─────────────────────────────────────────────
wandb.init(
project="portfolio-llama-finetuning",
name="qlora-llama31-8b-run1",
config={
"model": "Llama-3.1-8B-Instruct",
"lora_rank": LORA_RANK,
"lora_alpha": LORA_ALPHA,
"max_seq_length": MAX_SEQ_LENGTH,
"dataset_size": len(train_dataset),
}
)
# ── Argomenti di training ─────────────────────────────────────────────────────
training_args = TrainingArguments(
output_dir="./checkpoints/llama31-portfolio",
num_train_epochs=3,
per_device_train_batch_size=4, # RTX 3090 24GB: batch 4 è ottimale con Unsloth
per_device_eval_batch_size=2,
gradient_accumulation_steps=4, # Effective batch size = 4*4 = 16
warmup_steps=50,
learning_rate=2e-4, # Standard per QLoRA r=16
fp16=not torch.cuda.is_bf16_supported(),
bf16=torch.cuda.is_bf16_supported(),
logging_steps=10,
eval_strategy="steps",
eval_steps=100,
save_strategy="steps",
save_steps=200,
save_total_limit=3,
load_best_model_at_end=True,
metric_for_best_model="eval_loss",
greater_is_better=False,
report_to="wandb",
optim="adamw_8bit", # Optimizer 8-bit: salva ~6GB VRAM
weight_decay=0.01,
lr_scheduler_type="cosine",
seed=42,
)
# ── SFTTrainer (Supervised Fine-tuning Trainer da TRL) ───────────────────────
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=train_dataset,
eval_dataset=val_dataset,
dataset_text_field="text",
max_seq_length=MAX_SEQ_LENGTH,
data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, padding=True),
dataset_num_proc=4,
packing=False, # Packing accelera ma può degradare qualità su articoli lunghi
args=training_args,
)
# ── Avvia training ────────────────────────────────────────────────────────────
print("Inizio training QLoRA...")
trainer_stats = trainer.train()
print(f"Training completato in {trainer_stats.metrics['train_runtime']:.0f}s")
print(f"Loss finale: {trainer_stats.metrics['train_loss']:.4f}")
# Salva il modello (solo adapter LoRA, ~80MB)
model.save_pretrained("./output/portfolio-lora-adapter")
tokenizer.save_pretrained("./output/portfolio-lora-adapter")
wandb.finish()
Sezione 4 - Hyperparameter Sweep con W&B
Il learning rate è il parametro che influenza di più la qualità del fine-tuning. Troppo alto: il modello "dimentica" ciò che sapeva prima (catastrophic forgetting), la validation loss diverge dopo poche epoch. Troppo basso: il modello non impara abbastanza dal tuo dominio, rimane troppo simile al base model. Il range tipico per QLoRA r=16 è 1e-5 a 5e-4, con il sweet spot attorno a 1e-4 – 3e-4.
Con W&B Sweeps puoi automatizzare la ricerca del learning rate ottimale con una ricerca a griglia o bayesiana:
# sweep_config.py
# Configurazione W&B Sweep per hyperparameter search su LoRA fine-tuning
# Esegui con: wandb sweep sweep_config.py && wandb agent SWEEP_ID
import wandb
sweep_configuration = {
"method": "bayes", # Ottimizzazione bayesiana: più efficiente di grid search
"metric": {
"name": "eval/loss",
"goal": "minimize"
},
"parameters": {
"learning_rate": {
# Distribuzione log-uniforme: campiona in modo bilanciato su scala log
"distribution": "log_uniform_values",
"min": 1e-5,
"max": 5e-4
},
"lora_rank": {
"values": [8, 16, 32] # r=32 per più capacità, r=8 per più velocità
},
"num_train_epochs": {
"values": [2, 3, 5]
},
"per_device_train_batch_size": {
"values": [2, 4, 8]
},
"warmup_ratio": {
"values": [0.03, 0.05, 0.1]
}
},
"early_terminate": {
"type": "hyperband",
"min_iter": 100, # Termina run che non migliorano dopo 100 step
"eta": 3
}
}
def train_with_sweep():
"""Funzione chiamata da wandb agent per ogni run dello sweep."""
with wandb.init() as run:
config = wandb.config
# Aggiorna TrainingArguments con parametri dello sweep
training_args = TrainingArguments(
output_dir=f"./checkpoints/sweep-{run.id}",
learning_rate=config.learning_rate,
num_train_epochs=config.num_train_epochs,
per_device_train_batch_size=config.per_device_train_batch_size,
warmup_ratio=config.warmup_ratio,
# ... altri args fissi come sopra
report_to="wandb",
)
# Ricrea model e trainer con nuova config
# (omesso per brevità, vedi train_qlora.py sopra)
if __name__ == "__main__":
sweep_id = wandb.sweep(sweep_configuration, project="portfolio-llama-finetuning")
print(f"Sweep ID: {sweep_id}")
print(f"Avvia agent con: wandb agent {sweep_id}")
# wandb.agent(sweep_id, function=train_with_sweep, count=20)
Dai miei esperimenti sul dataset portfolio: learning rate 2e-4 con r=16 e 3 epoch ha dato il miglior bilanciamento tra adattamento allo stile e retention delle capacità generali. Con r=32 il modello si adattava leggermente meglio allo stile ma perdeva più capacità di ragionamento generale.
Sezione 5 - Merge Adapter + Conversione GGUF
Dopo il training hai due opzioni: usare l'adapter separato (LoRA adapter + base model caricati insieme a runtime) oppure fare il merge permanente in un modello unificato. Il merge è consigliato per deployment production perché elimina l'overhead di de-quantizzazione a runtime e produce un modello GGUF standard che Ollama gestisce nativamente.
# merge_and_export.py
# Merge del LoRA adapter nel modello base e conversione in GGUF
# Richiede: pip install llama-cpp-python (per la conversione GGUF)
from unsloth import FastLanguageModel
# ── Carica il modello base + adapter ─────────────────────────────────────────
model, tokenizer = FastLanguageModel.from_pretrained(
model_name="unsloth/Meta-Llama-3.1-8B-Instruct",
max_seq_length=2048,
dtype=None,
load_in_4bit=True,
)
# Ricarica adapter salvato durante il training
model.load_adapter("./output/portfolio-lora-adapter")
# ── Merge e salvataggio in FP16 ───────────────────────────────────────────────
# Il merge fonde i pesi LoRA (A·B) nel modello base: W_merged = W + B·A
# Risultato: modello standard senza dipendenza da PEFT
print("Merge LoRA adapter nel modello base...")
model.save_pretrained_merged(
"./output/portfolio-merged-fp16",
tokenizer,
save_method="merged_16bit", # FP16: qualità massima, ~16GB
)
# ── Alternativa: salva direttamente in GGUF quantizzato ──────────────────────
# Unsloth supporta la conversione GGUF diretta senza passaggi intermedi
print("Conversione in GGUF Q4_K_M...")
model.save_pretrained_gguf(
"./output/portfolio-gguf",
tokenizer,
quantization_method="q4_k_m", # Q4_K_M: bilanciamento qualità/dimensione
)
# Output: ./output/portfolio-gguf/portfolio-llama31-8b-q4km.gguf (~5GB)
# Metodi di quantizzazione disponibili:
# q4_k_m → ~5GB, qualità ottimale per la dimensione (raccomandato)
# q5_k_m → ~6.5GB, leggermente migliore
# q8_0 → ~9GB, quasi-FP16 per inference critica
# f16 → ~16GB, nessuna perdita di qualità
print("Export completato.")
Nota sul formato GGUF
GGUF (GPT-Generated Unified Format) è il formato binario usato da llama.cpp e Ollama. Contiene sia i pesi del modello che i metadati del tokenizer in un singolo file. La quantizzazione Q4_K_M usa una strategia "mixed": layer critici in 5-6 bit, altri in 4 bit, producendo qualità superiore rispetto a Q4_0 puro con dimensione simile.
Sezione 6 - Deploy su Ollama con Modelfile Custom
Con il file GGUF pronto, il deployment su Ollama richiede solo un Modelfile e un comando. Ollama supporta due approcci: usare il GGUF come modello standalone (merge permanente) oppure usare l'istruzione ADAPTER per caricare l'adapter sopra il base model senza merge. Per il portfolio ho scelto il merge permanente: inferenza più veloce, gestione più semplice, nessuna dipendenza sull'alignment tra adapter e base model.
# Modelfile per il modello fine-tuned portfolio
# Salvato come: Modelfile.portfolio
# Usa il GGUF con merge permanente
FROM ./output/portfolio-gguf/portfolio-llama31-8b-q4km.gguf
# Alternativa: adapter separato (Ollama 0.5+)
# FROM llama3.1:8b
# ADAPTER ./output/portfolio-lora-adapter-gguf/adapter.gguf
# Parametri di sampling per stile articoli tecnici
PARAMETER temperature 0.7
PARAMETER top_p 0.9
PARAMETER top_k 40
PARAMETER repeat_penalty 1.1
PARAMETER num_ctx 4096
# System prompt personalizzato per stile Federico Calò
SYSTEM """Sei un assistente tecnico specializzato nella generazione di articoli per il blog federicocalo.dev.
Scrivi in italiano tecnico, diretto e accessibile. Usa 'tu' diretto al lettore.
Includi numeri concreti, esempi di codice commentati in italiano, e data/benchmark verificati.
Non usare emoji. Non fare marketing-speak. Sii specifico e pratico."""
# Importa il modello in Ollama
# ollama create portfolio-llama31 -f Modelfile.portfolio
# Comandi per importare, testare e gestire il modello custom in Ollama
# Importa il modello (richiede ~5GB di spazio libero per Q4_K_M)
ollama create portfolio-llama31 -f Modelfile.portfolio
# Verifica importazione
ollama list
# portfolio-llama31 abc123def456 5.2 GB 2 hours ago
# Test rapido in chat
ollama run portfolio-llama31 \
"Scrivi l'introduzione di un articolo su vLLM vs Ollama per un blog tecnico italiano."
# Test via API (stessa interfaccia di Ollama standard)
curl -s http://localhost:11434/api/generate \
-H "Content-Type: application/json" \
-d '{
"model": "portfolio-llama31",
"prompt": "Scrivi una sezione su QLoRA nel tuo stile.",
"stream": false,
"options": {"temperature": 0.7, "num_predict": 500}
}' | python3 -c "import sys,json; print(json.load(sys.stdin)['response'])"
# Rimozione se necessario
# ollama rm portfolio-llama31
Dopo il deploy, la latenza su RTX 3090 con il GGUF Q4_K_M è stata di circa 42 token/secondo in single-stream - identica al Llama 3.1 8B base, perché il merge rende il modello strutturalmente identico. Il modello risponde immediatamente con lo stile e il vocabolario del portfolio: usa "tu" diretto, cita numeri concreti, struttura le risposte con H3 e bullet points. La differenza rispetto al modello base è netta su qualsiasi prompt relativo al dominio degli articoli.
FAQ
Quanta VRAM serve per fare fine-tuning QLoRA di Llama 3.1 8B?
Con QLoRA (4-bit NF4 + LoRA adapters) e Unsloth, Llama 3.1 8B richiede circa 7-9 GB di VRAM con batch size 2 e gradient checkpointing. Su RTX 3090 (24GB) puoi usare batch size 4-8 comodamente. Su RTX 4080 (16GB) batch size 2-4. Su RTX 3060 (12GB) è possibile con batch size 1-2. HF Trainer standard senza Unsloth richiede circa 13GB per lo stesso training.
Qual è la differenza tra LoRA e QLoRA?
LoRA allena solo matrici di basso rango aggiunte ai layer attention, riducendo parametri trainabili a ~0.5% del totale. Il modello base rimane in FP16/BF16. QLoRA estende LoRA caricando il modello base in 4-bit NF4 via BitsAndBytes, dimezzando ulteriormente la VRAM necessaria. La differenza pratica: QLoRA rende possibile il training su GPU da 12GB invece di 20GB, al costo di un training circa 10-15% più lento per la de-quantizzazione intermedia.
Unsloth è sicuro da usare in produzione o ha problemi di accuracy?
Unsloth non fa approssimazioni matematiche: usa kernel CUDA scritti in Triton che producono risultati numericamente identici a PyTorch standard. La degradazione di accuracy rispetto a QLoRA standard è 0%. Il vantaggio è 2-2.7x di speedup e 60-74% meno VRAM. Una nota critica del 2025 riguardava configurazioni mal impostate (gradient norm zero) dipendenti dall'utente, non dal framework. Con le configurazioni mostrate in questo articolo il training è corretto.
Quanti esempi servono per un fine-tuning efficace?
La regola empirica 2025-2026 confermata da più paper: qualità batte quantità. 500-1000 esempi altamente curati superano spesso 5000-10000 esempi rumorosi per task di adattamento stilistico e dominio. Per instruction following generalista servono 3000-5000 esempi. Per adattamento a uno stile specifico (es. articoli blog) bastano 500-1000 esempi ben formattati. La diversità degli esempi è più importante della quantità pura.
Posso caricare il LoRA adapter direttamente su Ollama senza fare il merge?
Sì. Ollama supporta l'istruzione ADAPTER nel Modelfile, che carica l'adapter GGUF sopra il modello base senza merge permanente. Il vantaggio: puoi avere più adapter diversi e switchare senza duplicare il modello base (~5GB GGUF Q4). Lo svantaggio: leggero overhead di caricamento e necessità che il base model nel Modelfile corrisponda esattamente a quello usato per il training dell'adapter.
Come valuto se il fine-tuning ha funzionato?
Valutazione multi-livello: (1) la loss curve sul training set deve scendere regolarmente; (2) la validation loss deve scendere in parallelo - se diverge è overfitting; (3) test qualitativo manuale su 20-30 prompt del dominio target; (4) per task di generazione, ROUGE/BLEU su hold-out set. Per task conversazionali, l'LLM-as-judge (usa un modello più grande per valutare output) è diventato lo standard nel 2025.
Quanto costa fare fine-tuning su cloud vs GPU consumer propria?
Su cloud (RunPod/Vast.ai): RTX 3090 24GB = $0.35-0.50/ora. Per 6 ore di training = $2-3. Su GPU consumer propria: costo elettricità ~350W × 6h = 2.1 kWh = circa €0.50-0.80 in Italia. Il costo hardware ammortizzato dipende dall'utilizzo: se fai 10+ run al mese, una RTX 3090 usata (~€800-1000) si ripaga in 6-12 mesi rispetto al cloud. Per il fine-tuning sul portfolio (1000 articoli, ~6 ore), il costo totale ammortizzato è stato circa €15.
Conclusione
Il workflow completo è: prepara il dataset in formato Alpaca JSONL - 500-1000 esempi curati bastano per adattamento stilistico - poi lancia QLoRA con Unsloth su una GPU consumer da 12GB+, fai l'hyperparameter sweep con W&B per trovare il learning rate ottimale, esegui il merge dell'adapter nel base model, converti in GGUF Q4_K_M, e carica su Ollama con un Modelfile personalizzato. L'intero processo ha richiesto circa 6 ore di training su RTX 3090 e ha prodotto un modello che genera bozze di articoli nel mio stile con una qualità che prima avrei ottenuto solo da GPT-4 con molti esempi in contesto.
La differenza tra il modello base e il modello fine-tunato non è solo stilistica. Il modello fine-tunato conosce il vocabolario tecnico specifico del portfolio, sa come strutturare un articolo con H2/H3 e info-box, usa "tu" diretto invece del formale "Lei", e cita tecnologie (Ollama, vLLM, Unsloth, BitsAndBytes) nel contesto corretto. Questo è il valore di 1000 esempi curati: non stai insegnando nuova conoscenza al modello, stai allineando il suo output con il tuo specifico stile di comunicazione.
Prossimo episodio: EP09 - Model Context Protocol (MCP): connettere strumenti a Ollama come a Claude API. Implementeremo un wrapper MCP che permette a Ollama di usare tool esterni (filesystem, database, API) con lo stesso protocollo standard usato da Claude. Se hai completato il fine-tuning di questo episodio, il prossimo passo naturale è dare al tuo modello personalizzato la capacità di interagire con i tuoi sistemi.

Commenti
Caricamento commenti...
Accedi per commentare