SLM · Deployment
Come un checkpoint PyTorch da 530 MB diventa un file standard che qualsiasi runtime compatibile può caricare — e cosa significa deployare un modello addestrato da zero fuori da Python.
GGUF (GGML Universal File) è il formato binario introdotto da llama.cpp nel 2023
per sostituire il precedente formato proprietario GGML.
È diventato lo standard de facto per distribuire LLM quantizzati perché risolve tre problemi
che i formati precedenti (PyTorch .pt, SafeTensors, HuggingFace bin) non affrontano:
Un checkpoint PyTorch non è portabile: richiede Python, PyTorch, le stesse classi del modello definite nello stesso modulo. Un file GGUF è leggibile da qualsiasi runtime che supporti lo standard — llama.cpp, llama-server, llamafile, Ollama, LM Studio, Jan.
GGUF non è un formato agnostico: ogni file dichiara un'architettura
(general.architecture) e llama.cpp esegue il forward pass
usando il codice specifico per quell'architettura.
Scegliere quella sbagliata significa output privi di senso anche se i pesi sono corretti.
Il nostro modello ha tre proprietà architetturali che vincolano la scelta:
| Proprietà | Nostro modello | Architettura GGUF |
|---|---|---|
| Positional embedding | RoPE (Rotary) | gptneox, llama, falcon |
| Activation | GELU | gptneox (LLaMA usa SiLU) |
| Residual connection | sequenziale (pre-norm → attn → add, poi pre-norm → ffn → add) | gptneox con use_parallel_residual=false |
L'unica architettura che soddisfa tutte e tre è gptneox con
use_parallel_residual = false.
Questa flag distingue il comportamento sequenziale del nostro modello da quello parallelo
di Pythia, che calcola attenzione e FFN contemporaneamente sulla stessa norma.
I pesi nel checkpoint PyTorch hanno nomi derivati dall'implementazione Python.
GGUF usa una convenzione diversa, derivata dall'ecosistema llama.cpp.
Il converter tools/convert_to_gguf.py applica il mapping seguente:
Questa è la parte più sottile della conversione — e quella che ha causato
l'errore unordered_map::at: key not found durante il test di inferenza.
La tokenizzazione in llama.cpp non è un'operazione singola. Prima che qualsiasi ricerca nel vocabolario avvenga, il testo grezzo passa per una fase di pre-tokenizzazione:
Il carattere ▁ (U+2581) è il marcatore di spazio di SentencePiece.
Non è uno spazio ASCII — è un carattere Unicode distinto che fa parte del token.
Ogni chunk viene cercato nella mappa token_to_id. Se un chunk
non esiste nella mappa, il codice C++ lancia std::out_of_range.
Il campo tokenizer.ggml.pre nel GGUF dichiara quale strategia usare
allo stadio 1. Senza questa dichiarazione, llama.cpp v0.5.0 usa un default
che potrebbe non corrispondere al comportamento del tokenizer usato durante il training.
Il nostro tokenizer è SentencePiece in modalità BPE, addestrato su Wikipedia italiana.
LLaMA 1/2 usa lo stesso tipo di tokenizer e funziona correttamente con llama.cpp —
ma dichiara esplicitamente il suo pre-tokenizer e ha un vocabolario più grande (32k)
con byte fallback attivo: per ogni byte non rappresentato, esistono token
speciali del tipo <0x41> che garantiscono la tokenizzabilità di qualsiasi stringa.
Il nostro vocabolario da 16k potrebbe non avere copertura completa per tutti i caratteri,
oppure le opzioni di normalizzazione SPM usate durante il training (NFD, NFKC, lowercase)
potrebbero differire da quelle che llama.cpp applica con il pre-tokenizer di default.
Il risultato: alcuni chunk prodotti dallo stadio 1 non sono nella token_to_id map → crash.
llama-tokenize conferma: "Il Parlamento europeo"
→ [181: ▁Il, 6362: ▁Parlamento, 4332: ▁europeo].
Il problema non era nel tokenizer ma nella chat interface di llama-cli,
che cerca il newline token \n via unordered_map::at() senza try-catch.
Il nostro vocabolario non ha un token \n esplicito → crash.
All'avvio, llama.cpp tenta di registrare il token del newline per usarlo
nella formattazione della chat. Il codice GPT-NeoX in llama-vocab.cpp
chiama token_to_id.at("\n"). La riga relativa a LLaMA/SPM è protetta
da try-catch e produce solo un warning; il percorso di codice per gptneox potrebbe
non avere la stessa protezione — da qui il crash non gestito in llama-cli.
llama-simple fa text completion pura senza chat interface, senza template
e senza il lookup del newline token. Funziona correttamente.
# Text completion con llama-simple (installato con llama.cpp) llama-simple \ -m slm-run4.gguf \ -n 120 \ --temp 0.85 \ --top-k 40 \ --top-p 0.9 \ --repeat-penalty 1.3 \ "Il Parlamento europeo"
Output su M2 (fp32, Metal): ~200–310 token/s. Caricamento modello ~670 ms.
\n (newline) al vocabolario durante la conversione,
oppure popolare il campo tokenizer.ggml.linefeed_id nel GGUF.
Questo permetterà a llama-cli e llama-server di funzionare
senza il workaround di llama-simple.
# Verifica copertura — da eseguire prima di ogni conversione
def check_coverage(sp, test_text="Il Parlamento europeo è fondato nel"):
pieces = sp.encode_as_pieces(test_text)
vocab_set = {sp.id_to_piece(i) for i in range(sp.vocab_size())}
missing = [p for p in pieces if p not in vocab_set]
if missing:
print(f"ATTENZIONE: {len(missing)} pezzi non nel vocabolario: {missing}")
return len(missing) == 0
Il file GGUF prodotto dal converter è fp32 (217 MB). La quantizzazione riduce la precisione numerica dei pesi per guadagnare in velocità e dimensione.
gptneox dalla whitelist di llama-quantize.
Questo blocca solo la produzione del file quantizzato, non il suo utilizzo a runtime:
llama.cpp carica Q8_0, Q4_K_M e qualsiasi formato quantizzato normalmente, perché le routine
di dequantizzazione sono architecture-agnostic.
tools/quantize_q8.py produce un GGUF Q8_0 equivalente a quello
che produrrebbe llama-quantize se gptneox fosse nella whitelist.
Carica direttamente il checkpoint, applica Q8_0 su tutti i tensori matrice
(embedding incluse), e scrive il file GGUF con i metadati corretti.
# Produce slm-run4-q8_0.gguf (58 MB, da checkpoint 556 MB)
python3 tools/quantize_q8.py \
--checkpoint checkpoints/run4_best.pt \
--tokenizer checkpoints/tokenizer.model \
--output slm-run4-q8_0.gguf
# Test inferenza sul file quantizzato
llama-simple -m slm-run4-q8_0.gguf -n 60 "Il Parlamento europeo"
# Output: 418 t/s su M2 Metal (vs ~250 t/s fp32)
Il quantizzatore implementa Q8_0 in circa 20 righe di numpy:
ogni riga di un tensore matrice viene suddivisa in blocchi da 32 elementi,
ciascuno con una scala float16 condivisa e 32 valori int8
(34 byte per blocco). La codifica è identica a quella di llama.cpp.
Per Q4_K_M (~35 MB, qualità leggermente inferiore) sarà necessario o aggiungere gptneox alla whitelist di una build sorgente di llama.cpp, o estendere il quantizzatore Python ai formati K-quant (più complessi di Q8_0).
llama-server è incluso nella stessa installazione di llama.cpp e espone
un endpoint HTTP compatibile con l'API OpenAI. Questo significa che qualsiasi client
che usa OpenAI SDK può puntare al server locale senza modifiche.
# Avvio del server (fp32, porta default 8080) llama-server \ --model slm-run4.gguf \ --ctx-size 512 \ --host 0.0.0.0 \ --port 8080 # Con quantizzato (quando disponibile) llama-server \ --model slm-run4-q4_k_m.gguf \ --ctx-size 512 \ --n-gpu-layers 33 # offload su Metal (M2)
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8080/v1",
api_key="non-rilevante" # llama-server non autentica in locale
)
response = client.completions.create(
model="slm-run4",
prompt="Il Parlamento europeo",
max_tokens=80,
temperature=0.8,
top_p=0.9
)
print(response.choices[0].text)
/v1/completions con un prompt grezzo.
llamafile (Mozilla) è un singolo file eseguibile che contiene sia il modello GGUF che un runtime llama.cpp compilato per macOS/Linux/Windows. Non richiede installazione: si scarica, si rende eseguibile, si avvia.
# Creazione llamafile da GGUF (richiede zipalign dalla toolchain llamafile) cp /usr/local/bin/llamafile slm-run4.llamafile zipalign -j0 slm-run4.llamafile slm-run4-q4_k_m.gguf chmod +x slm-run4.llamafile # Avvio — apre automaticamente un browser con interfaccia web ./slm-run4.llamafile # In modalità server headless ./slm-run4.llamafile --server --nobrowser --port 8080
Il file risultante (slm-run4.llamafile) è distribuibile — chiunque
può eseguirlo su qualsiasi macchina senza Python, PyTorch o llama.cpp installati.
Per un modello da 35 MB (Q4_K_M), il llamafile pesa circa 38 MB.
train.py → checkpoints/run4_best.pt (530 MB, fp32)tools/convert_to_gguf.py → slm-run4.gguf (217 MB, fp32)python3 tools/quantize_q8.py → slm-run4-q8_0.gguf 58 MB, 418 t/s M2llama-simple -m slm-run4.gguf -n 100 --temp 0.85 --repeat-penalty 1.3 "prompt"llama-cli richiede fix aggiuntivo (newline token).
llama-server --model slm-run4-q4_k_m.gguf --port 8080/v1/completions raggiungibile da qualsiasi client OpenAI SDKgptneox.
La versione fp32 (217 MB) è scrivibile e caricabile, ma llama-quantize
restituisce "unknown model architecture".
Opzioni: downgrade a una build pre-b3000 via Homebrew, oppure implementare un
quantizzatore Q8_0 in Python (circa 20 righe con numpy).
llama-cli cerca \n via
unordered_map::at("\n") senza try-catch nel percorso gptneox.
Il nostro vocabolario BPE non ha un token newline esplicito → crash.
Workaround: usare llama-simple per text completion pura.
Fix permanente: aggiungere il token \n al vocabolario o
settare tokenizer.ggml.linefeed_id nel GGUF.
gptneox riconosciuta, 17 KV pair corretti,
147 tensori letti senza errori, use_parallel_residual = false presente,
RoPE dimension count = 64 corretto.
Il deploy via GGUF è la porta di uscita da Python verso il mondo reale. Ma l'utilità di un modello deployato dipende interamente da cosa sa fare.
Un base model come Run #4 esposto via /v1/completions
è un completatore di testo — utile per scrittura assistita, generazione
di bozze, completamento di pattern. Non è un assistente.
Il passaggio che trasforma il modello in qualcosa di utilizzabile in un contesto
applicativo è il Supervised Fine-Tuning (SFT) su un task specifico.
Con SFT, il modello impara a seguire istruzioni nella forma
[contesto] → [azione richiesta] → [output strutturato],
che è esattamente il pattern necessario per RAG, tool use e pipeline agentiche.
La scelta del task per Run #5 determina il valore del progetto — vedi Fine-tuning e Valutazione.