Che cosa decidi davvero quando servi un modello su un VPS europeo
Servire un modello di linguaggio su un tuo server in Europa significa prendere tre decisioni, non una: quanta memoria serve (e quindi quale macchina), come impedire che l'endpoint sia raggiungibile da chiunque, e come accettare che su una CPU la generazione sia lenta. Ollama, il programma che usiamo come esempio, rende facile l'installazione; non rende facili queste tre scelte, e sono quelle in cui una PMI sbaglia.
La risposta breve, prima del dettaglio: un modello da 7-8 miliardi di parametri, quantizzato, entra in 8-16 GB di RAM, ma su una VPS senza GPU produce poche decine di token al secondo nel caso migliore e pochi token al secondo nel caso tipico. Funziona per un reparto che interroga i propri documenti, non per un servizio con decine di utenti contemporanei. L'articolo ti dà i calcoli per verificarlo sul tuo caso, e dichiara dove i numeri sono ipotesi da misurare.
Un paragone per fissare l'idea: scegliere la macchina è come scegliere il furgone per un trasloco. Il volume del carico (i pesi del modello) decide se entra; la lunghezza dell'ultimo scatolone che apri (il contesto) decide se resta posto per altro; la velocità del furgone (la banda di memoria) decide quanto ci metti. Un furgone più piccolo non è più lento perché è peggiore, ma perché porta meno roba per viaggio.
Quando ha senso farlo in proprio
Le ragioni che reggono, per una PMI, sono poche e vanno dette con i loro costi. Le altre, come «costa zero in API», valgono solo dopo aver contato il resto (lo faremo nell'articolo sui costi).
- Dati che non vuoi mandare a un terzo: contratti, cartelle cliniche, verbali. Se i testi restano sul tuo server, il fornitore del modello non li vede mai.
- Volume stabile e prevedibile: un uso regolare ammortizza un canone fisso meglio di un uso a picchi.
- Indipendenza da un singolo fornitore: i pesi aperti si possono spostare su un'altra macchina o un altro fornitore europeo.
- Esperimenti senza costo marginale: provare dieci prompt in più non genera una fattura.
Nessuna di queste ragioni dice «è più veloce» o «è più capace»: un modello piccolo in proprio è quasi sempre più lento e meno capace di un modello grande in cloud. Se la qualità della risposta conta più della riservatezza, il confronto va fatto con dati tuoi: l'articolo su come scegliere fra modello piccolo e grande mostra come, e quello su quando il cloud europeo vince elenca i casi in cui conviene non farlo.
Quanta memoria serve: pesi, cache e margine
La memoria occupata da un modello in esecuzione ha tre parti. I pesi sono il file del modello: dipendono dal numero di parametri e dalla quantizzazione. La cache K/V è la memoria che l'attenzione usa per non rileggere l'intero testo a ogni token: cresce linearmente col contesto e con il numero di richieste elaborate in parallelo. Il margine copre buffer di calcolo, sistema operativo e tutto il resto.
Per i pesi non conviene calcolare a mente: la libreria di Ollama pubblica la dimensione del file di ciascun modello. Dividendo quella dimensione per il numero di parametri (dalla scheda del modello) si ottengono i bit per peso effettivi, che risultano vicini a 4,9: la quantizzazione predefinita è a circa 4-5 bit, non a 4 esatti. La cache K/V si calcola invece dall'architettura: due matrici (K e V) per strato, per ciascuna delle teste KV, per la dimensione della testa, per due byte in virgola mobile a 16 bit. Lo fa il programma che segue.
"""Dimensionamento della memoria per servire un modello in locale.
Tutti i dati di ingresso vengono da fonti pubbliche (vedi l'articolo):
- parametri e architettura: config.json e API di Hugging Face;
- dimensione del file quantizzato: pagina del modello nella libreria Ollama.
Le ipotesi sono marcate IPOTESI.
"""
GIB = 1024 ** 3
MODELLI = {
# nome: (parametri, strati, teste KV, dim. testa, GB del tag predefinito in libreria)
"llama3.1:8b": (8_030_261_248, 32, 8, 128, 4.9),
"mistral:7b": (7_248_023_552, 32, 8, 128, 4.4),
"qwen2.5:7b": (7_615_616_512, 28, 4, 128, 4.7),
}
BYTE_KV = 2 # IPOTESI: cache K/V in f16, il valore predefinito di Ollama
OVERHEAD_GIB = 1.0 # IPOTESI: buffer di calcolo e runtime, da misurare sul proprio carico
RISERVA_OS_GIB = 1.5 # IPOTESI: sistema operativo, nginx, il resto
RAM_GB = {"CCX13": 8, "CCX23": 16, "CCX33": 32} # pagina Hetzner General Purpose
def kv_per_token(strati, teste_kv, dim_testa):
# 2 = una matrice K e una V per ogni strato
return 2 * strati * teste_kv * dim_testa * BYTE_KV
print("bit per peso impliciti nel file scaricato (GB decimali, ipotesi)")
for nome, (par, st, kv, hd, gb) in MODELLI.items():
print(f" {nome:12s} {gb * 1e9 * 8 / par:5.2f} bit/peso")
print("\nKV cache per token e per 8192 token di contesto")
for nome, (par, st, kv, hd, gb) in MODELLI.items():
b = kv_per_token(st, kv, hd)
print(f" {nome:12s} {b // 1024:4d} KiB/token {b * 8192 / GIB:5.2f} GiB a 8192")
print("\nMemoria totale (pesi + KV + overhead) e verdetto per taglia di RAM")
for contesto, parallele in [(4096, 1), (8192, 1), (8192, 4)]:
print(f"contesto {contesto}, richieste parallele {parallele}")
for nome, (par, st, kv, hd, gb) in MODELLI.items():
pesi = gb * 1e9 / GIB
cache = kv_per_token(st, kv, hd) * contesto * parallele / GIB
tot = pesi + cache + OVERHEAD_GIB
esito = []
for srv, ram in RAM_GB.items():
ok = tot + RISERVA_OS_GIB <= ram * 1e9 / GIB
esito.append(f"{srv}:{'si' if ok else 'NO'}")
print(f" {nome:12s} {tot:5.2f} GiB " + " ".join(esito))
# Output:
# bit per peso impliciti nel file scaricato (GB decimali, ipotesi)
# llama3.1:8b 4.88 bit/peso
# mistral:7b 4.86 bit/peso
# qwen2.5:7b 4.94 bit/peso
#
# KV cache per token e per 8192 token di contesto
# llama3.1:8b 128 KiB/token 1.00 GiB a 8192
# mistral:7b 128 KiB/token 1.00 GiB a 8192
# qwen2.5:7b 56 KiB/token 0.44 GiB a 8192
#
# Memoria totale (pesi + KV + overhead) e verdetto per taglia di RAM
# contesto 4096, richieste parallele 1
# llama3.1:8b 6.06 GiB CCX13:NO CCX23:si CCX33:si
# mistral:7b 5.60 GiB CCX13:si CCX23:si CCX33:si
# qwen2.5:7b 5.60 GiB CCX13:si CCX23:si CCX33:si
# contesto 8192, richieste parallele 1
# llama3.1:8b 6.56 GiB CCX13:NO CCX23:si CCX33:si
# mistral:7b 6.10 GiB CCX13:NO CCX23:si CCX33:si
# qwen2.5:7b 5.81 GiB CCX13:si CCX23:si CCX33:si
# contesto 8192, richieste parallele 4
# llama3.1:8b 9.56 GiB CCX13:NO CCX23:si CCX33:si
# mistral:7b 9.10 GiB CCX13:NO CCX23:si CCX33:si
# qwen2.5:7b 7.13 GiB CCX13:NO CCX23:si CCX33:si
Tre cose si leggono nell'uscita. La prima: Llama 3.1 8B e Mistral 7B pagano 128 KiB di cache per token, Qwen 2.5 7B solo 56 KiB, perché ha 4 teste KV invece di 8 e meno strati; a 8192 token la differenza è mezzo gibibyte, trascurabile in un caso e decisiva nell'altro. La seconda: con 4 richieste in parallelo la memoria sale di circa 3 GiB per Llama, perché la cache si moltiplica, e non il file dei pesi. La terza: i 7,5 GiB utili di una macchina da 8 GB sono giusti per Mistral a 4096 di contesto e non bastano per Llama con la stessa configurazione, secondo le ipotesi dichiarate in testa al programma (1 GiB di overhead, 1,5 GiB per il sistema). Sono ipotesi tue da misurare: guarda la memoria del processo con il tuo carico e correggi le due costanti.
Il programma non conosce due fatti. Se il contesto richiesto dall'applicazione supera quello che il modello supporta, la richiesta viene troncata o rifiutata; e se la RAM non basta, il sistema operativo comincia a usare lo swap su disco, con un rallentamento che si nota subito. In entrambi i casi la prima cosa da cambiare è la dimensione del modello o il contesto, non la macchina.
Quanto è veloce: il tetto imposto dalla banda di memoria
Per produrre ogni token un modello denso rilegge, in sostanza, tutti i suoi pesi. Su una CPU il collo di bottiglia è quindi la banda di memoria, non il numero di core: se la banda è B gigabyte al secondo e i pesi pesano P gigabyte, la velocità non supera B/P token al secondo. È un tetto, non una previsione, perché il calcolo vero costa altro tempo, ma ti dice subito che cosa è impossibile.
La banda di una macchina virtuale condivisa non è un dato di listino: dipende dal processore e da chi altro usa l'host. Per questo il programma la lascia come ipotesi e mostra tre valori; la misura vera la ottieni con una richiesta all'API di Ollama, i cui campi di risposta eval_count e eval_duration (nanosecondi) sono documentati e danno direttamente i token al secondo.
"""Limite teorico di velocita' e lettura delle metriche di Ollama.
Per generare un token un modello denso legge (circa) tutti i suoi pesi dalla
memoria. Se la banda di memoria e' B e i pesi pesano P byte, la velocita'
non puo' superare B / P token al secondo. E' un tetto, non una previsione.
"""
PESI_GB = {"llama3.1:8b": 4.9, "mistral:7b": 4.4, "qwen2.5:3b": 1.9}
BANDE_GB_S = [20, 40, 80] # IPOTESI: la banda reale di una VM va misurata
def tetto(banda_gb_s, pesi_gb):
return banda_gb_s / pesi_gb
print("tetto teorico in token/s (banda ipotizzata -> modello)")
for nome, p in PESI_GB.items():
celle = " ".join(f"{b} GB/s: {tetto(b, p):5.1f}" for b in BANDE_GB_S)
print(f" {nome:12s} {celle}")
RISPOSTA = 300 # token di una risposta di media lunghezza (IPOTESI)
print(f"\ntempo minimo per {RISPOSTA} token, llama3.1:8b")
for b in BANDE_GB_S:
print(f" {b} GB/s -> {RISPOSTA / tetto(b, PESI_GB['llama3.1:8b']):5.1f} s")
def token_al_secondo(risposta):
"""Campi documentati nell'API di Ollama: eval_count (token generati) e
eval_duration (nanosecondi spesi a generarli)."""
return risposta["eval_count"] / (risposta["eval_duration"] / 1e9)
# Risposta di ESEMPIO costruita a mano, non misurata: serve a mostrare la formula.
esempio = {"eval_count": 120, "eval_duration": 15_000_000_000}
print(f"\nesempio illustrativo: {token_al_secondo(esempio):.1f} token/s")
# Output:
# tetto teorico in token/s (banda ipotizzata -> modello)
# llama3.1:8b 20 GB/s: 4.1 40 GB/s: 8.2 80 GB/s: 16.3
# mistral:7b 20 GB/s: 4.5 40 GB/s: 9.1 80 GB/s: 18.2
# qwen2.5:3b 20 GB/s: 10.5 40 GB/s: 21.1 80 GB/s: 42.1
#
# tempo minimo per 300 token, llama3.1:8b
# 20 GB/s -> 73.5 s
# 40 GB/s -> 36.8 s
# 80 GB/s -> 18.4 s
#
# esempio illustrativo: 8.0 token/s
Con una banda di 40 GB/s, Llama 3.1 8B non può superare circa 8 token al secondo, e una risposta di 300 token richiede almeno 37 secondi. Un modello da 3 miliardi di parametri (qui Qwen 2.5 3B, 1,9 GB) raddoppia o triplica il tetto, al prezzo di una minore capacità. L'esempio finale usa una risposta costruita a mano per mostrare la formula: i numeri veri li produce la tua macchina. Su CPU, la lentezza non è un difetto di configurazione: è la fisica del problema, e l'unico modo di aggirarla è un modello più piccolo, una GPU o la coda di lavori.
Quale server: i prezzi che contano, con la data
I prezzi dei server cambiano, e quelli che circolano nei vecchi articoli sono spesso superati. La pagina «General Purpose» di Hetzner, consultata il 2026-11-13, riporta per la sede di Norimberga (NBG1) i valori della tabella, tutti al mese e IVA esclusa. La stessa pagina indica prezzi «da» più bassi per altre sedi e, accanto ad alcune configurazioni, l'avviso «currently unavailable»: la disponibilità si controlla nella console, non nell'articolo.
| Istanza | vCPU dedicate | RAM | Disco | NBG1, €/mese IVA esclusa |
|---|---|---|---|---|
| CCX13 | 2 | 8 GB | 80 GB NVMe | 43,49 |
| CCX23 | 4 | 16 GB | 160 GB NVMe | 86,49 |
| CCX33 | 8 | 32 GB | 240 GB NVMe | 138,99 |
| CCX43 | 16 | 64 GB | 360 GB NVMe | 276,49 |
Il confronto col programma di memoria è immediato: per un modello da 7-8 miliardi di parametri la macchina da 8 GB è al limite, quella da 16 GB è la prima comoda, quella da 32 GB lascia spazio a due modelli o a più richieste in parallelo. Le istanze CCX hanno vCPU dedicate, quindi la velocità è più regolare di una VM condivisa, ma il prezzo mensile è di un ordine di grandezza superiore a quello di un piccolo VPS da sito web: ricontrolla sempre la pagina prima di fare conti, e usa l'articolo sui costi del self-hosting per decidere se la spesa si ripaga.
Installare Ollama e configurarlo per restare in memoria
L'installazione su Linux, secondo la documentazione ufficiale, è un comando: curl -fsSL https://ollama.com/install.sh | sh. Eseguire uno script scaricato direttamente in una shell è una scelta di fiducia verso chi lo pubblica; se la tua policy lo vieta, la stessa pagina descrive l'installazione manuale con il pacchetto compresso e un'unità systemd. In ogni caso il servizio resta in ascolto su 127.0.0.1:11434, e l'indirizzo cambia solo se imposti OLLAMA_HOST.
Le impostazioni che contano si danno con un file di override di systemd. Il file seguente le mostra una per una; i valori predefiniti sono quelli della FAQ ufficiale di Ollama, consultata il 2026-11-13.
# /etc/systemd/system/ollama.service.d/override.conf
# Si crea con: sudo systemctl edit ollama.service
[Service]
# Lascia OLLAMA_HOST al valore predefinito (127.0.0.1:11434): niente porta aperta.
# Quanto resta in memoria un modello dopo l'ultima richiesta. Il valore predefinito
# e' 5 minuti; -1 lo tiene caricato per sempre.
Environment="OLLAMA_KEEP_ALIVE=30m"
# Richieste elaborate insieme da ciascun modello. Predefinito: 1.
# La RAM cresce di OLLAMA_NUM_PARALLEL x OLLAMA_CONTEXT_LENGTH (vedi memoria.py).
Environment="OLLAMA_NUM_PARALLEL=2"
# Modelli caricati insieme, se entrano in RAM. Predefinito su CPU: 3.
Environment="OLLAMA_MAX_LOADED_MODELS=1"
# Richieste in coda prima del rifiuto con 503. Predefinito: 512.
Environment="OLLAMA_MAX_QUEUE=32"
Ogni riga ha un motivo. OLLAMA_KEEP_ALIVE decide se il modello resta in RAM fra due richieste: a 5 minuti (il predefinito) la prima domanda dopo una pausa paga il ricaricamento dei pesi dal disco, a -1 la memoria resta occupata anche se nessuno la usa. OLLAMA_NUM_PARALLEL moltiplica la cache come mostra il programma sopra: raddoppiarlo senza rifare i conti è il modo più rapido di finire in swap. OLLAMA_MAX_QUEUE limita la coda: oltre quel limite il server risponde 503, che per l'applicazione è un messaggio chiaro invece di un'attesa infinita. Questo file è uno schema da adattare, non eseguito: i nomi delle variabili vengono dalla FAQ, i valori sono scelte tue.
Proteggere l'endpoint: la porta aperta è il rischio vero
Nella FAQ e nella pagina dell'API locale di Ollama che ho letto non compare nessun meccanismo di autenticazione: chi raggiunge la porta può chiedere al modello qualsiasi cosa e, con le funzioni di gestione, scaricare o cancellare modelli. Non è una dichiarazione sull'intero prodotto, è ciò che dice quella documentazione, ed è sufficiente per una regola pratica: l'endpoint non va mai esposto a Internet senza qualcosa davanti.
La soluzione più semplice è non esporlo affatto: se l'applicazione gira sullo stesso server, chiama 127.0.0.1. Se serve raggiungerlo da un'altra macchina, la documentazione stessa propone un reverse proxy come nginx. Lo schema che segue aggiunge tre cose: una chiave in intestazione verificata da nginx, un limite di richieste e il buffering spento per le risposte a pezzi.
# /etc/nginx/conf.d/ollama.conf (schema da adattare, non eseguito)
# Serve solo se l'applicazione che chiama il modello sta su un'altra macchina.
# Se sta sullo stesso server, chiama http://127.0.0.1:11434 e questo file non serve.
# Una riga per chiave, fuori dal repository, permessi 640: "Bearer <chiave>" 1;
map $http_authorization $chiave_valida {
default 0;
include /etc/nginx/ollama-chiavi.map;
}
limit_req_zone $binary_remote_addr zone=ollama:10m rate=30r/m;
server {
listen 443 ssl;
server_name ai.example.eu; # il tuo dominio
ssl_certificate /etc/letsencrypt/live/ai.example.eu/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ai.example.eu/privkey.pem;
location / {
if ($chiave_valida = 0) { return 401; }
limit_req zone=ollama burst=5;
proxy_pass http://127.0.0.1:11434;
proxy_buffering off; # le risposte arrivano a pezzi
proxy_read_timeout 300s; # una generazione lenta non e' un guasto
}
}
La mappa legge le chiavi valide da un file separato, così il segreto non finisce nel repository né nel testo della configurazione. limit_req protegge la macchina da un client impazzito, non da un attaccante determinato. proxy_buffering off serve perché una risposta generata token per token deve arrivare al client man mano, e proxy_read_timeout a 300 secondi evita di scambiare una generazione lenta per un guasto. Lo schema non è stato eseguito (nginx non è installato sulla macchina dove ho scritto questo articolo): le direttive vanno controllate con nginx -t sul tuo server, e il certificato TLS lo ottieni con lo strumento che preferisci.
Il firewall completa il quadro: tutto chiuso tranne SSH e HTTPS, e una verifica dall'esterno che la porta 11434 non risponda.
# Schema da adattare, non eseguito. Aprire solo cio' che serve.
sudo ufw default deny incoming
sudo ufw allow 22/tcp
sudo ufw allow 443/tcp
sudo ufw enable
# Dopo: dall'esterno la porta 11434 deve risultare chiusa.
# Da un'altra macchina: nc -vz <ip-del-server> 11434 -> connessione rifiutata o timeout
La riga finale è la più importante: una porta che «dovrebbe» essere chiusa si controlla con un tentativo di connessione da un'altra rete, non rileggendo la configurazione. Se usi Docker, la sua documentazione avverte che ufw e le regole create da Docker sono incompatibili: una porta pubblicata da un container può aggirare il firewall, quindi anche in quel caso la verifica da fuori è l'unica prova.
Che cosa va storto, in ordine di frequenza
- Il processo viene terminato dal sistema per memoria: il modello più la cache superano la RAM. Si vede in
journalctl -u ollama, il comando indicato dalla guida di diagnosi ufficiale. - La prima richiesta dopo una pausa è lentissima: il modello è stato scaricato dalla memoria per
OLLAMA_KEEP_ALIVEtroppo breve. - Le richieste si accumulano e poi falliscono con 503: la coda ha raggiunto
OLLAMA_MAX_QUEUE; il carico supera la capacità della macchina. - La risposta si interrompe a metà nel browser: il proxy ha il buffering attivo o un timeout troppo corto.
- L'endpoint risponde a chiunque: manca la chiave o la porta è aperta; è il guasto più grave e più silenzioso.
Gli ultimi due sono errori di configurazione che non producono messaggi: nessun log ti avvisa che un estraneo sta usando la tua CPU. Il primo e il secondo, invece, si vedono subito e si correggono con i numeri dei programmi sopra. L'ordine conta: prima chiudi la porta, poi misuri la memoria, poi ottimizzi la velocità.
Domande frequenti
Una VPS senza GPU basta per un assistente aziendale?
Dipende dal numero di utenti contemporanei. Per poche persone che interrogano documenti interni e accettano risposte in decine di secondi sì; per un servizio con molte richieste simultanee no, perché la velocità è limitata dalla banda di memoria e le richieste si mettono in coda.
Come misuro la velocità reale della mia macchina?
Invia una richiesta a /api/generate senza streaming e dividi eval_count per eval_duration espresso in secondi. Ripeti più volte, a macchina scarica e sotto carico, e riporta un intervallo.
Tenere i dati in Europa basta per il GDPR?
No: la residenza è una parte del quadro. Restano base giuridica, minimizzazione, sicurezza e contratti con i fornitori. L'articolo su privacy by design li passa uno per uno.
Che cosa sai fare adesso
Sai stimare, con le formule dell'architettura, se un modello entra nella macchina che stai guardando e con quanti utenti in parallelo; sai quale tetto di velocità impone la memoria e come misurare la velocità vera; sai configurare Ollama perché resti in RAM senza saturarla e perché non esponga una porta senza protezione. Il passo successivo è decidere che cosa farci girare: il pezzo sul recupero dei documenti costruisce la parte che dà al modello le tue informazioni, mentre il confronto fra famiglie di modelli ti aiuta a sceglierlo. Il tema della capacità sotto carico è ripreso in scaling da 1 a 50 utenti.
Nella stessa serie trovi anche il riepilogo con la scheda di decisione.
Fonti
- Hetzner, Cloud General Purpose - istanze CCX13-CCX43, vCPU, RAM, disco e prezzi mensili in sede NBG1 IVA esclusa, consultata il 2026-11-13
- Ollama, FAQ (docs/faq.mdx nel repository del progetto) - indirizzo di ascolto predefinito, OLLAMA_KEEP_ALIVE, OLLAMA_NUM_PARALLEL, OLLAMA_MAX_LOADED_MODELS, OLLAMA_MAX_QUEUE e l'esempio con nginx, consultata il 2026-11-13
- Ollama, installazione su Linux - comando di installazione e installazione manuale, consultata il 2026-11-13
- Ollama, riferimento dell'API - i campi eval_count ed eval_duration della risposta, consultata il 2026-11-13
- Ollama, libreria: llama3.1 - dimensione del file del tag predefinito (4,9 GB); le pagine mistral e qwen2.5 della stessa libreria per 4,4 e 4,7 GB, consultate il 2026-11-13
- config.json di Llama 3.1 8B (copia di NousResearch) - strati, teste KV e dimensione dell'architettura; il repository ufficiale di Meta richiede l'accettazione della licenza, per questo ho letto la copia, consultata il 2026-11-13
- config.json di Mistral 7B v0.3 e di Qwen2.5-7B-Instruct - architettura per la cache K/V, consultate il 2026-11-13
- Ollama, guida alla diagnosi (troubleshooting) - il comando
journalctl -u ollamaper leggere i log del servizio, consultata il 2026-11-13 - Docker, Packet filtering and firewalls - la sezione «Docker and ufw» sull'incompatibilità fra le regole di Docker e quelle di ufw, consultata il 2026-11-13
I due programmi sono stati eseguiti con Python 3.14.4 (python3 -I), due volte, con uscita identica; le ipotesi sono dichiarate nel codice. Configurazioni di systemd, nginx e firewall sono schemi da adattare: non eseguiti, nessun server avviato. Il numero di parametri viene dall'API dei modelli di Hugging Face.

Commenti
Caricamento commenti...
Accedi per commentare