Salta al contenuto principale

Ollama setup production: quantization, memory, concurrency, cost per inference

Ollama setup production: quantization, memory, concurrency, cost per inference Hai installato Ollama, hai fatto ollama pull llama3.1:8b e il modello risponde.

Cosa ne pensi di questo articolo?

Ollama setup production: quantization, memory, concurrency, cost per inference
AI InfrastructureFoto:Sanket MishraPexels

In Breve

Ollama setup production: quantization, memory, concurrency, cost per inference Hai installato Ollama, hai fatto ollama pull llama3.1:8b e il modello risponde.

Ollama setup production: quantization, memory, concurrency, cost per inference

Hai installato Ollama, hai fatto ollama pull llama3.1:8b e il modello risponde. Ottimo. Ma in produzione questo non basta. Quella pull scarica la variante Q4_K_M di default - circa 5GB di VRAM - e tu magari non sai nemmeno cosa significa quella sigla. La variante F16 dello stesso modello pesa 16GB e va 3x più lento in tokens al secondo. La variante Q2_K pesa 2.8GB ma degrada la qualità a tal punto che le risposte diventano incoerenti. La scelta sbagliata in produzione non è un dettaglio tecnico: è la differenza tra un servizio che regge 10 utenti concorrenti e uno che crasha al quinto.

In questo episodio vediamo tutto quello che devi sapere per mettere Ollama in produzione in modo serio: come funziona la quantizzazione GGUF e quale scegliere, come pianificare la memoria (model weights + KV cache + activation), come configurare la concurrency senza affogare la GPU, come integrare Postgres con pgvector per RAG, come deployare via systemd o Docker, e - la parte che nessuno ti dice - come calcolare il costo reale per token del tuo setup self-hosted. Alla fine hai numeri concreti, non teoria.

Cosa Imparerai

  • Differenze tra Q2_K, Q4_K_M, Q5_K_S, Q8_0 e F16: VRAM, perplexity, tokens/sec
  • Come pianificare la memoria totale: pesi + KV cache + activation overhead
  • Configurare OLLAMA_NUM_PARALLEL e OLLAMA_MAX_QUEUE per il tuo carico reale
  • Integrare Ollama con Postgres + pgvector per un stack RAG completo via Docker Compose
  • Deploy systemd hardened vs Docker: quando usare quale
  • Misurare GPU/CPU con psutil e nvidia-smi via Python
  • Calcolare il costo per token del tuo VPS e confrontarlo con cloud API

Prerequisiti

  • Linux con GPU NVIDIA (o VPS con GPU, es. Hetzner CCX23 con L4)
  • Ollama installato (curl -fsSL https://ollama.com/install.sh | sh)
  • Docker e Docker Compose per la sezione RAG
  • Python 3.10+ con pip per gli script di monitoring
  • Aver letto EP01 - Ollama vs vLLM

Sezione 1 - La quantizzazione GGUF: capire le sigle prima di scegliere

GGUF (GPT-Generated Unified Format) è il formato file che llama.cpp - il motore sotto Ollama - usa per rappresentare i pesi del modello in modo compresso. Prima di GGUF esisteva GGML, poi sono arrivati i formati K-quantization (la K sta per "k-means" applicato ai cluster di pesi). La differenza tra Q4_0 e Q4_K_M non è solo il numero di bit: è l'algoritmo con cui quei bit vengono scelti. Le K-quant usano una calibration dataset per minimizzare la perdita di informazione, mentre le varianti senza K sono una semplice troncatura uniforme. Il risultato: a parità di dimensione file, una Q4_K_M è sempre migliore di una Q4_0.

La tabella seguente mostra un confronto per un modello da 8 miliardi di parametri (Llama 3.1 8B). I valori di perplexity sono delta rispetto all'originale FP16 su dataset WikiText-2 - più il delta è piccolo, meglio è. I valori TPS (tokens per second) sono misurati su GPU NVIDIA L4 24GB con batch singolo.

Quantizzazione VRAM (8B model) Delta Perplexity TPS (L4 24GB) Uso Consigliato
Q2_K ~2.8 GB +0.87 ppl ~55 TPS Solo edge offline, qualità bassa
Q4_K_M ~5.0 GB +0.05 ppl ~38 TPS Produzione standard - sweet spot
Q5_K_S ~5.7 GB +0.04 ppl ~32 TPS Produzione alta qualità, VRAM >8GB
Q8_0 ~8.5 GB +0.00 ppl ~21 TPS Validazione/debug, non per volume
F16 ~16.0 GB baseline ~12 TPS Fine-tuning base, non per serving

Nota il dato interessante: Q8_0 occupa quasi il doppio della VRAM di Q4_K_M e va quasi 2x più lento, ma il guadagno di qualità è statisticamente irrilevante (+0.00 ppl). Questo spiega perché la comunità e Ollama stesso scelgono Q4_K_M come default. Q2_K invece è molto veloce in TPS perché i pesi sono così compressi che il bandwidth di lettura dalla VRAM è minimo - ma la qualità crolla. Attenzione: i TPS variano significativamente con la batch size e il contesto, questi valori sono indicativi per batch=1.

Per scaricare tag specifici con Ollama, usa questa sintassi:

# Scarica il modello di default (Q4_K_M - raccomandato per produzione)
ollama pull llama3.1:8b

# Scarica esplicitamente Q4_K_M (stesso risultato del default)
ollama pull llama3.1:8b-instruct-q4_K_M

# Scarica Q5_K_S se hai VRAM >8GB e vuoi qualità leggermente superiore
ollama pull llama3.1:8b-instruct-q5_K_S

# Scarica Q8_0 solo per validazione/test di qualità
ollama pull llama3.1:8b-instruct-q8_0

# Verifica i modelli presenti e la loro dimensione su disco
ollama list

# Rimuovi una variante che non usi più (libera spazio disco)
ollama rm llama3.1:8b-instruct-q8_0

Nel mio portfolio (federicocalo.dev), il ml-service FastAPI gira Llama 3.1 8B in Q4_K_M sul VPS Hetzner CCX23 con GPU NVIDIA L4 24GB. Con 5GB occupati dal modello, restano 19GB disponibili per KV cache e future espansioni. Avrei potuto scegliere Q8_0, ma a parità di hardware avrei dimezzato i TPS senza alcun beneficio percepibile nelle risposte agli utenti.

Sezione 2 - Creare un Modelfile custom per produzione

Ollama usa un Modelfile per definire la configurazione di un modello: quale base usare, quale system prompt caricare, parametri come temperatura e contesto. In produzione, creare un Modelfile dedicato ti permette di standardizzare la configurazione e versioarla in git. È l'equivalente di un Dockerfile per i tuoi LLM.

# File: Modelfile-production
# Modello base: llama3.1 8B con quantizzazione Q4_K_M
FROM llama3.1:8b-instruct-q4_K_M

# Parametri di inferenza ottimizzati per produzione
# temperature 0.1: risposte più deterministiche per task tecnici
PARAMETER temperature 0.1

# num_ctx: contesto in token (4096 = buon bilanciamento VRAM/qualità)
# Aumentare a 8192 se le query sono lunghe, ma raddoppia la VRAM per KV cache
PARAMETER num_ctx 4096

# num_predict: token massimi per risposta (-1 = illimitato)
PARAMETER num_predict 1024

# top_p: nucleus sampling, valori più bassi = risposte più focalizzate
PARAMETER top_p 0.9

# repeat_penalty: penalizza ripetizioni nel testo generato
PARAMETER repeat_penalty 1.1

# System prompt per il tuo caso d'uso
SYSTEM """
Sei un assistente tecnico specializzato in sviluppo software.
Rispondi in modo conciso e preciso. Usa codice quando utile.
Comunica sempre in italiano a meno che l'utente non usi un'altra lingua.
"""

# Crea il modello con nome custom per produzione
# ollama create portfolio-llm -f Modelfile-production

Dopo aver creato il Modelfile, crei il modello con ollama create portfolio-llm -f Modelfile-production. Da quel momento puoi usare ollama run portfolio-llm o chiamare l'API con "model": "portfolio-llm". Il vantaggio rispetto a passare parametri via API ogni volta è la riproducibilità: tutti i client ottengono la stessa configurazione, nessuno può "dimenticarsi" di settare la temperatura corretta.

Sezione 3 - Memory planning: model weights, KV cache, activation overhead

L'errore più comune quando si pianifica il deploy di un LLM è pensare che la VRAM necessaria sia solo il peso del file GGUF. Non è così. La memoria totale occupata da un modello in inferenza ha tre componenti:

  1. Model weights: il file GGUF stesso, caricato in VRAM. Per Llama 3.1 8B Q4_K_M = ~5GB.
  2. KV Cache: la memoria per memorizzare le Keys e Values dell'attention durante la generazione. Cresce linearmente con il contesto e con il numero di richieste parallele. Formula: 2 * num_layers * num_heads * head_dim * context_len * batch_size * dtype_bytes
  3. Activation overhead: tensori temporanei durante il forward pass. Tipicamente 10-15% dei pesi.

Lo script Python seguente misura l'uso reale di memoria con psutil (CPU RAM) e nvidia-smi (VRAM GPU) mentre Ollama gira. Usalo per calibrare la tua configurazione prima di andare in produzione.

"""
Script di monitoraggio memoria Ollama in produzione.
Misura VRAM (nvidia-smi) e RAM (psutil) durante l'inferenza.
Richiede: pip install psutil requests
"""

import subprocess
import psutil
import time
import requests
import json
from typing import Optional


def misura_vram_nvidia() -> Optional[float]:
    """
    Legge la VRAM utilizzata tramite nvidia-smi.
    Restituisce GB utilizzati, o None se nvidia-smi non è disponibile.
    """
    try:
        result = subprocess.run(
            # Formato: vram_usata_MB,vram_totale_MB
            ["nvidia-smi", "--query-gpu=memory.used,memory.total",
             "--format=csv,noheader,nounits"],
            capture_output=True, text=True, timeout=5
        )
        if result.returncode == 0:
            # Prendi solo la prima GPU (indice 0)
            linea = result.stdout.strip().split("\n")[0]
            usata_mb, totale_mb = [int(v.strip()) for v in linea.split(",")]
            return {
                "vram_usata_gb": round(usata_mb / 1024, 2),
                "vram_totale_gb": round(totale_mb / 1024, 2),
                "vram_uso_percentuale": round(usata_mb / totale_mb * 100, 1)
            }
    except (subprocess.TimeoutExpired, FileNotFoundError):
        return None
    return None


def misura_ram_sistema() -> dict:
    """
    Misura la RAM di sistema con psutil.
    """
    mem = psutil.virtual_memory()
    return {
        "ram_totale_gb": round(mem.total / (1024 ** 3), 2),
        "ram_usata_gb": round(mem.used / (1024 ** 3), 2),
        "ram_disponibile_gb": round(mem.available / (1024 ** 3), 2),
        "ram_uso_percentuale": mem.percent
    }


def benchmark_inferenza_ollama(
    model: str = "llama3.1:8b",
    prompt: str = "Spiega in 3 frasi cosa è il machine learning.",
    num_chiamate: int = 3
) -> dict:
    """
    Esegue N chiamate all'API Ollama e misura:
    - Tempo totale per risposta
    - Token generati
    - Token al secondo
    - VRAM prima e dopo
    """
    url = "http://localhost:11434/api/generate"
    risultati = []

    for i in range(num_chiamate):
        # Misura VRAM prima della chiamata
        vram_prima = misura_vram_nvidia()

        payload = {
            "model": model,
            "prompt": prompt,
            "stream": False  # Attendi risposta completa per misurare
        }

        tempo_inizio = time.time()
        try:
            resp = requests.post(url, json=payload, timeout=120)
            resp.raise_for_status()
            dati = resp.json()
        except requests.RequestException as e:
            print(f"Errore chiamata {i+1}: {e}")
            continue

        tempo_totale = time.time() - tempo_inizio

        # Misura VRAM dopo la chiamata
        vram_dopo = misura_vram_nvidia()

        # Ollama restituisce eval_count (token generati) e eval_duration (ns)
        token_generati = dati.get("eval_count", 0)
        durata_ns = dati.get("eval_duration", 1)
        tps_ollama = round(token_generati / (durata_ns / 1e9), 2)

        risultati.append({
            "chiamata": i + 1,
            "tempo_totale_sec": round(tempo_totale, 3),
            "token_generati": token_generati,
            "tps_misurato": tps_ollama,
            "vram_prima": vram_prima,
            "vram_dopo": vram_dopo
        })

        print(f"Chiamata {i+1}: {token_generati} token in {tempo_totale:.2f}s = {tps_ollama} TPS")
        time.sleep(1)  # Pausa tra chiamate per evitare throttling

    return {
        "modello": model,
        "num_chiamate": num_chiamate,
        "ram_sistema": misura_ram_sistema(),
        "risultati": risultati,
        "tps_medio": round(
            sum(r["tps_misurato"] for r in risultati) / len(risultati), 2
        ) if risultati else 0
    }


if __name__ == "__main__":
    print("=== Benchmark Ollama - Memoria e Performance ===\n")
    stats = benchmark_inferenza_ollama(
        model="llama3.1:8b",
        num_chiamate=3
    )
    print("\n--- Riepilogo ---")
    print(f"RAM sistema: {stats['ram_sistema']['ram_usata_gb']}GB / {stats['ram_sistema']['ram_totale_gb']}GB")
    print(f"TPS medio: {stats['tps_medio']}")
    print(json.dumps(stats, indent=2, ensure_ascii=False))

Il campo chiave da osservare è eval_duration nella risposta Ollama: è in nanosecondi e misura solo il tempo di generazione token, escluso il prefill (elaborazione del prompt). Per misurare il tempo totale incluso il prefill, usa la differenza tra il tuo timestamp di richiesta e risposta. Nei miei test su Hetzner CCX23 con L4, il prefill di un prompt da 200 token impiega circa 80ms - non trascurabile per latenze sotto i 200ms.

Sezione 4 - Concurrency: OLLAMA_NUM_PARALLEL e OLLAMA_MAX_QUEUE

Ollama, a differenza di vLLM, non è stato progettato originariamente per gestire centinaia di richieste concorrenti. Il suo motore (llama.cpp) può tuttavia gestire parallelismo limitato con le variabili d'ambiente corrette. Capire questi parametri è fondamentale perché il default può essere il tuo collo di bottiglia senza che te ne accorga.

OLLAMA_NUM_PARALLEL controlla quante richieste simultanee un singolo modello può processare. Il default è auto: Ollama sceglie 1 o 4 in base alla memoria disponibile. Ogni slot aggiuntivo richiede memoria proporzionale al contesto (OLLAMA_CONTEXT_LENGTH * 2 bytes per slot) per il KV cache. Con Llama 3.1 8B Q4_K_M, contesto 4096 e 8 slot paralleli, il KV cache occupa circa 2GB aggiuntivi di VRAM.

OLLAMA_MAX_QUEUE è il numero massimo di richieste in attesa prima che Ollama risponda con HTTP 503. Il default è 512 - altissimo. In produzione con SLA strict, abbassa questo valore a 20-50 per avere fail-fast immediato invece di accumulare latenza.

Di seguito lo snippet per configurare il servizio systemd di Ollama con tutti i parametri di produzione:

# File: /etc/systemd/system/ollama.service.d/override.conf
# Crea con: sudo systemctl edit ollama.service
# Poi: sudo systemctl daemon-reload && sudo systemctl restart ollama

[Service]
# Directory dove sono salvati i modelli (default: ~/.ollama/models)
Environment="OLLAMA_MODELS=/opt/ollama/models"

# Porta e host di ascolto (0.0.0.0 espone a rete locale/VPN, attenzione sicurezza)
Environment="OLLAMA_HOST=127.0.0.1:11434"

# Numero di richieste parallele per modello
# Formula: OLLAMA_NUM_PARALLEL <= (VRAM_disponibile - model_size_GB) / (ctx * 2 / 1024^3)
# Es: (19GB - 5GB) / (4096 * 2 / 1e9) = 14 / 0.008 = max 14, ma usa max 8 per sicurezza
Environment="OLLAMA_NUM_PARALLEL=8"

# Coda massima: abbassa per fail-fast (503 immediato invece di attesa lunga)
Environment="OLLAMA_MAX_QUEUE=50"

# Modelli massimi caricati simultaneamente (default: 3 per GPU)
Environment="OLLAMA_MAX_LOADED_MODELS=2"

# Tempo di keep-alive modello in memoria (0 = unload subito, -1 = mai unload)
# In produzione: 10m è ragionevole per bilanciare cold start e memoria
Environment="OLLAMA_KEEP_ALIVE=10m"

# Flash attention: riduce uso VRAM ~15% senza perdita qualità
Environment="OLLAMA_FLASH_ATTENTION=1"

# KV cache quantization: riduce VRAM KV cache ulteriormente (sperimentale in v0.4+)
# Environment="OLLAMA_KV_CACHE_TYPE=q8_0"

# Log level: info per produzione, debug per troubleshooting
Environment="OLLAMA_DEBUG=0"

# Limite thread CPU (utile se condividi il server con altri processi)
# Environment="OLLAMA_NUM_THREAD=8"

Dopo aver modificato l'override, ricarica systemd e riavvia: sudo systemctl daemon-reload && sudo systemctl restart ollama. Verifica i log con journalctl -u ollama -f per controllare che le variabili siano state caricate correttamente.

Il benchmark Python seguente misura il comportamento reale di Ollama sotto carico concorrente con asyncio, per verificare che il tuo OLLAMA_NUM_PARALLEL sia configurato correttamente:

"""
Benchmark concorrenza Ollama con asyncio.
Simula N utenti simultanei e misura latenza e throughput reali.
Richiede: pip install aiohttp
"""

import asyncio
import aiohttp
import time
import statistics
from dataclasses import dataclass, field
from typing import List


@dataclass
class RisultatoBenchmark:
    """Risultato di una singola richiesta al benchmark."""
    utente_id: int
    latenza_ms: float
    token_generati: int
    tps: float
    successo: bool
    errore: str = ""


async def richiesta_singola(
    sessione: aiohttp.ClientSession,
    utente_id: int,
    model: str,
    prompt: str
) -> RisultatoBenchmark:
    """Esegue una singola richiesta a Ollama in modo asincrono."""
    url = "http://localhost:11434/api/generate"
    payload = {
        "model": model,
        "prompt": prompt,
        "stream": False
    }

    tempo_inizio = time.monotonic()
    try:
        async with sessione.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp:
            if resp.status != 200:
                errore = f"HTTP {resp.status}"
                return RisultatoBenchmark(
                    utente_id=utente_id, latenza_ms=0,
                    token_generati=0, tps=0, successo=False, errore=errore
                )
            dati = await resp.json()
    except asyncio.TimeoutError:
        return RisultatoBenchmark(
            utente_id=utente_id, latenza_ms=0,
            token_generati=0, tps=0, successo=False, errore="Timeout"
        )
    except aiohttp.ClientError as e:
        return RisultatoBenchmark(
            utente_id=utente_id, latenza_ms=0,
            token_generati=0, tps=0, successo=False, errore=str(e)
        )

    latenza_ms = (time.monotonic() - tempo_inizio) * 1000
    token_generati = dati.get("eval_count", 0)
    durata_ns = dati.get("eval_duration", 1)
    tps = round(token_generati / (durata_ns / 1e9), 2)

    return RisultatoBenchmark(
        utente_id=utente_id,
        latenza_ms=round(latenza_ms, 1),
        token_generati=token_generati,
        tps=tps,
        successo=True
    )


async def benchmark_concorrenza(
    utenti_simultanei: int = 8,
    model: str = "llama3.1:8b",
    prompt: str = "Elenca 5 vantaggi del Python per data science."
) -> None:
    """
    Lancia N richieste simultanee e stampa statistiche di latenza.
    """
    print(f"\n=== Benchmark: {utenti_simultanei} utenti simultanei ===")
    print(f"Modello: {model}")

    async with aiohttp.ClientSession() as sessione:
        tempo_inizio = time.monotonic()

        # Lancia tutte le richieste in parallelo
        task_list = [
            richiesta_singola(sessione, i, model, prompt)
            for i in range(utenti_simultanei)
        ]
        risultati: List[RisultatoBenchmark] = await asyncio.gather(*task_list)

        tempo_totale = time.monotonic() - tempo_inizio

    # Calcola statistiche solo sulle richieste riuscite
    successi = [r for r in risultati if r.successo]
    fallimenti = [r for r in risultati if not r.successo]

    if successi:
        latenze = [r.latenza_ms for r in successi]
        print(f"\nRichieste riuscite: {len(successi)}/{utenti_simultanei}")
        print(f"Richieste fallite:  {len(fallimenti)}/{utenti_simultanei}")
        print(f"Tempo totale:       {tempo_totale:.2f}s")
        print(f"Latenza p50:        {statistics.median(latenze):.0f}ms")
        print(f"Latenza p95:        {sorted(latenze)[int(len(latenze)*0.95)]:.0f}ms")
        print(f"Latenza max:        {max(latenze):.0f}ms")
        print(f"TPS medio:          {statistics.mean([r.tps for r in successi]):.1f}")

    for f in fallimenti:
        print(f"  Utente {f.utente_id} fallito: {f.errore}")


if __name__ == "__main__":
    # Testa prima con 4 utenti, poi aumenta gradualmente
    for n_utenti in [1, 4, 8, 16]:
        asyncio.run(benchmark_concorrenza(utenti_simultanei=n_utenti))
        time.sleep(3)  # Lascia raffreddare tra i test

Questo benchmark ti mostra l'effetto reale di OLLAMA_NUM_PARALLEL. Se lo hai a 4 e lanci 8 richieste simultanee, vedrai che le prime 4 terminano velocemente e le ultime 4 aspettano in coda. Con OLLAMA_NUM_PARALLEL=8, tutte e 8 partono insieme ma ognuna va leggermente più lenta a causa dello scheduling. Il compromesso dipende dal tuo caso: latenza singola bassa (usa NUM_PARALLEL basso) o throughput totale alto (usa NUM_PARALLEL alto).

Sezione 5 - RAG con Docker Compose: Ollama + Postgres + pgvector

Il pattern RAG (Retrieval-Augmented Generation) su stack self-hosted combina Ollama per l'inferenza, un modello di embedding (anche via Ollama) per vettorizzare i documenti, e Postgres con estensione pgvector come vector store. È lo stesso stack usato nel ml-service del mio portfolio: non serve Redis Stack né servizi cloud, tutto gira su un unico VPS.

# File: docker-compose.yml
# Stack RAG completo: Ollama + Postgres + pgvector
# Avvio: docker compose up -d
# Verifica: docker compose ps

version: "3.9"

services:
  # ----------------------------------------------------------------
  # Ollama: server LLM con GPU passthrough
  # ----------------------------------------------------------------
  ollama:
    image: ollama/ollama:latest
    container_name: ollama-prod
    restart: unless-stopped
    ports:
      - "127.0.0.1:11434:11434"  # Esponi solo su localhost, no Internet
    volumes:
      # Persistenza modelli: evita re-download a ogni riavvio
      - ollama_models:/root/.ollama
    environment:
      # Configurazione concorrenza production
      OLLAMA_NUM_PARALLEL: "4"
      OLLAMA_MAX_QUEUE: "50"
      OLLAMA_KEEP_ALIVE: "10m"
      OLLAMA_FLASH_ATTENTION: "1"
    deploy:
      resources:
        reservations:
          devices:
            # Passthrough GPU NVIDIA - richiede NVIDIA Container Toolkit
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s  # Attendi avvio Ollama prima di controllare

  # ----------------------------------------------------------------
  # Postgres con pgvector: vector store per RAG
  # ----------------------------------------------------------------
  postgres:
    image: pgvector/pgvector:pg16  # Immagine ufficiale con pgvector preinstallato
    container_name: postgres-rag
    restart: unless-stopped
    ports:
      - "127.0.0.1:5432:5432"
    environment:
      POSTGRES_DB: rag_db
      POSTGRES_USER: rag_user
      # In produzione: usa Docker secrets o variabili da .env (non committare password)
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cambia_questa_password}
      # Ottimizzazioni Postgres per workload vettoriale
      POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init-db.sql:/docker-entrypoint-initdb.d/init.sql:ro
    command: >
      postgres
        -c shared_preload_libraries=vector
        -c max_connections=100
        -c shared_buffers=256MB
        -c work_mem=64MB
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U rag_user -d rag_db"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ----------------------------------------------------------------
  # Il tuo servizio applicativo (es. FastAPI ml-service)
  # ----------------------------------------------------------------
  ml-service:
    build:
      context: ./ml-service
      dockerfile: Dockerfile
    container_name: ml-service
    restart: unless-stopped
    ports:
      - "127.0.0.1:8000:8000"
    environment:
      OLLAMA_BASE_URL: "http://ollama:11434"  # Nome servizio Docker come hostname
      DATABASE_URL: "postgresql://rag_user:${POSTGRES_PASSWORD}@postgres:5432/rag_db"
    depends_on:
      ollama:
        condition: service_healthy
      postgres:
        condition: service_healthy

volumes:
  ollama_models:
    driver: local
  postgres_data:
    driver: local

Crea anche il file init-db.sql per inizializzare le estensioni pgvector al primo avvio:

# File: init-db.sql - eseguito automaticamente al primo avvio Postgres
# Abilita pgvector per la ricerca semantica
CREATE EXTENSION IF NOT EXISTS vector;

# Crea la tabella per i chunk di documenti vettorializzati
# dim=384 per modelli all-MiniLM-L6-v2, dim=1536 per modelli OpenAI-compatibili
CREATE TABLE IF NOT EXISTS document_chunks (
    id BIGSERIAL PRIMARY KEY,
    content TEXT NOT NULL,
    embedding vector(384) NOT NULL,
    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW()
);

# Indice HNSW per ricerca approssimata veloce (cosine distance)
# m=16: numero connessioni per nodo (più alto = più accurato ma più lento da costruire)
# ef_construction=64: dimensione della lista candidati durante costruzione
CREATE INDEX IF NOT EXISTS idx_chunks_embedding_hnsw
    ON document_chunks
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

Sezione 6 - Docker vs systemd: quando usare quale

La scelta tra Docker e systemd per deployare Ollama non è banale. Dipende dall'architettura del tuo server, dal tuo livello di familiarità con i due sistemi e dai requisiti operativi. Ecco un confronto onesto basato sull'esperienza diretta su VPS Hetzner:

Aspetto systemd Docker / Docker Compose
Overhead performance Zero: accesso diretto a GPU, nessun layer virtuale Piccolo: ~1-3% per network/volume, GPU passthrough via NVIDIA CTK richiede setup
Isolamento Processo nativo, isolamento parziale (user dedicato) Container isolato: filesystem, network, PID separati
GPU setup Funziona out-of-the-box se driver NVIDIA installati Richiede NVIDIA Container Toolkit + --gpus all
Orchestrazione multi-servizio Manuale: più .service, systemd target Docker Compose: un file gestisce tutto (Ollama + Postgres + App)
Update modelli Semplice: ollama pull + nessun riavvio Semplice: docker exec ollama ollama pull ...
Logging journald integrato, rotazione automatica Docker logs + necessità di log driver (fluentd, splunk, etc.)
Restart automatico systemd Restart=always nativo restart: unless-stopped in Docker Compose
Consigliato per VPS dedicato, Ollama è il servizio principale, massime performance Stack multi-componente (RAG), sviluppo locale, riproducibilità

Nel mio portfolio, Ollama gira su systemd sul VPS Hetzner perché è il provider AI primario dell'intera applicazione - ogni millisecondo conta. Il ml-service FastAPI invece gira separatamente (anch'esso systemd) e chiama Ollama su localhost. Per lo sviluppo locale uso Docker Compose perché mi permette di avere Ollama + Postgres + pgvector con un unico docker compose up.

Sezione 7 - Costo per inference: il calcolo che nessuno fa

Quando scegli self-hosted vs cloud API, il costo reale per token è il numero che conta. Ma quasi nessuno lo calcola correttamente perché mescola costi fissi (server sempre acceso) con costi variabili (token generati). Vediamo come farlo in modo preciso.

Il costo per token self-hosted dipende da tre variabili: costo orario del server, tokens al secondo che il modello genera, e l'utilizzo effettivo (se il server è idle l'80% del tempo, il costo reale per token è più alto). Questo script calcola entrambe le varianti:

#!/usr/bin/env bash
# Script: costo_per_token.sh
# Calcola il costo reale per token Ollama self-hosted vs cloud API
# Uso: ./costo_per_token.sh [costo_ora_eur] [tps] [utilizzo_percentuale]
# Es:  ./costo_per_token.sh 0.90 35 0.30

set -euo pipefail

# Parametri (con default per Hetzner CCX23 GPU L4 + Llama 3.1 8B Q4_K_M)
COSTO_ORA_EUR="${1:-0.90}"       # Hetzner CCX23 GPU: ~€0.90/ora
TPS="${2:-35}"                    # Tokens al secondo misurati
UTILIZZO="${3:-0.30}"             # 30% di utilizzo medio (realistico per portfolio)

# Costi cloud di riferimento (maggio 2026, prezzi indicativi)
COSTO_GPT4_OUTPUT="0.030"         # GPT-4o: $0.030 per 1K token output
COSTO_CLAUDE_OUTPUT="0.015"       # Claude 3.5 Sonnet: $0.015 per 1K token output
COSTO_GEMINI_OUTPUT="0.010"       # Gemini 1.5 Pro: $0.010 per 1K token output

echo "========================================================"
echo "  Calcolatore Costo per Token - Self-Hosted Ollama"
echo "========================================================"
echo ""
echo "Configurazione:"
echo "  Server: €${COSTO_ORA_EUR}/ora (Hetzner CCX23 GPU L4)"
echo "  Modello: Llama 3.1 8B Q4_K_M @ ${TPS} TPS"
echo "  Utilizzo stimato: $(echo "$UTILIZZO * 100" | bc)%"
echo ""

# Calcolo costo per token con utilizzo pieno (lower bound)
# Costo per secondo = costo_ora / 3600
# Costo per token = costo_per_secondo / TPS
COSTO_TOKEN_FULL=$(echo "scale=9; $COSTO_ORA_EUR / 3600 / $TPS" | bc)
COSTO_1K_TOKEN_FULL=$(echo "scale=6; $COSTO_TOKEN_FULL * 1000" | bc)

# Calcolo costo reale con utilizzo parziale (realistic cost)
# Il costo fisso del server si spalma sui token generati effettivamente
# ore_al_giorno_attive = 24 * utilizzo
# token_al_giorno = TPS * 3600 * ore_attive
TOKEN_ORA_EFFETTIVI=$(echo "scale=0; $TPS * 3600 * $UTILIZZO" | bc)
COSTO_TOKEN_REALE=$(echo "scale=9; $COSTO_ORA_EUR / $TOKEN_ORA_EFFETTIVI" | bc)
COSTO_1K_TOKEN_REALE=$(echo "scale=6; $COSTO_TOKEN_REALE * 1000" | bc)

echo "Costo per 1000 token output:"
echo "  Self-hosted (100% utilizzo):  €${COSTO_1K_TOKEN_FULL}"
echo "  Self-hosted (${UTILIZZO}x utilizzo): €${COSTO_1K_TOKEN_REALE}"
echo ""
echo "Confronto Cloud API (prezzi indicativi maggio 2026):"
echo "  GPT-4o output:     \$${COSTO_GPT4_OUTPUT} / 1K token"
echo "  Claude 3.5 Sonnet: \$${COSTO_CLAUDE_OUTPUT} / 1K token"
echo "  Gemini 1.5 Pro:    \$${COSTO_GEMINI_OUTPUT} / 1K token"
echo ""

# Calcola break-even: quanti token al giorno giustificano il server
# Break-even = quando costo_self_hosted_giorno == costo_cloud_giorno
# costo_server_giorno = costo_ora * 24
COSTO_SERVER_GIORNO=$(echo "scale=4; $COSTO_ORA_EUR * 24" | bc)
# token_breakeven_vs_gpt4 = costo_giorno_server / (costo_gpt4_per_token)
TOKEN_BREAKEVEN=$(echo "scale=0; $COSTO_SERVER_GIORNO / ($COSTO_GPT4_OUTPUT / 1000)" | bc)

echo "Break-even vs GPT-4o:"
echo "  Costo server/giorno: €${COSTO_SERVER_GIORNO}"
echo "  Token/giorno per pareggiare: ${TOKEN_BREAKEVEN}"
echo "  Equivale a: $(echo "scale=0; $TOKEN_BREAKEVEN / $TPS / 3600" | bc) ore di inferenza continua/giorno"
echo ""
echo "Conclusione: il self-hosted conviene se generi >${TOKEN_BREAKEVEN} token/giorno."

Eseguendo questo script con i parametri del mio VPS - €0.90/ora per Hetzner CCX23 con L4, 35 TPS per Llama 3.1 8B Q4_K_M, utilizzo al 30% - otteniamo un costo di circa €0.007 per 1000 token output. GPT-4o costa $0.030 per 1000 token output: il self-hosted è più di 4x più economico. Il break-even è a circa 720.000 token al giorno - un volume realistico per un'applicazione con 50-100 utenti attivi.

Nota dal portfolio Federico: Il ml-service su Hetzner CCX23 con GPU L4 serve il portfolio federicocalo.dev con funzionalità di chat, raccomandazioni articoli e analisi codice. Con Llama 3.1 8B Q4_K_M si ottengono latenze p50 di circa 400ms per risposte da 100 token - accettabile per use case non real-time. Per chat a bassa latenza percepita, abilitare lo streaming ("stream": true nell'API Ollama) che permette di mostrare i token mentre vengono generati.

FAQ

Qual è la differenza tra Q4_K_M e Q4_0 in Ollama?

Q4_0 è la variante legacy: troncatura uniforme a 4 bit. Q4_K_M usa K-quantization con calibration dataset: divide i pesi in gruppi (blocks) e applica quantizzazione non-uniforme che minimizza l'errore di ricostruzione. A parità di dimensione file, Q4_K_M è sempre più accurata di Q4_0. Quando vedi "K" nella sigla, è sempre la variante migliore. Usa sempre K-quant quando disponibili.

Posso usare Ollama senza GPU?

Sì. Ollama usa llama.cpp che supporta inferenza CPU-only. Le performance sono però drasticamente inferiori: con Llama 3.1 8B Q4_K_M su CPU (es. Intel Xeon con 16 core) aspettati 2-5 TPS invece di 35-50 TPS su GPU L4. Per uso personale/sviluppo è accettabile; per produzione con SLA è necessaria una GPU. In caso di CPU-only, considera modelli più piccoli come Llama 3.2 3B o Phi-3.5 mini.

Come faccio a sapere quanta VRAM sta usando Ollama in tempo reale?

Usa nvidia-smi -l 1 per un refresh ogni secondo della VRAM. Alternativamente, Ollama espone metriche via API: curl http://localhost:11434/api/ps mostra i modelli caricati e la VRAM usata da ciascuno. Per monitoring continuo, usa nvtop (sudo apt install nvtop) che mostra VRAM, utilizzo GPU e processi in un'interfaccia TUI simile a htop.

OLLAMA_NUM_PARALLEL=8 rallenta le singole richieste?

Sì, ma di poco. Ogni slot parallelo compete per la stessa GPU, quindi i TPS per singola richiesta scendono leggermente. In pratica, con 8 slot paralleli su L4 24GB e Llama 3.1 8B Q4_K_M, i TPS per singola richiesta scendono da ~35 a ~28. Il throughput totale (tutti gli slot sommati) sale però da 35 a ~224 TPS. Questo è esattamente il compromesso da fare: latenza singola vs throughput totale.

Il Modelfile viene perso se aggiorno Ollama?

No. Il Modelfile è incorporato nel modello custom che hai creato con ollama create. I modelli custom sopravvivono agli aggiornamenti di Ollama perché sono salvati in ~/.ollama/models/ (o OLLAMA_MODELS se configurato). Quello che puoi perdere è il Modelfile sorgente se non lo versionati in git - per questo ti consiglio sempre di tenerlo in repo.

Docker Compose con Ollama funziona su ARM (Apple M1/M2, Raspberry Pi)?

Sì. Ollama ha immagini Docker multi-arch che supportano linux/arm64 e linux/amd64. Su Apple Silicon, Ollama usa il Metal GPU backend invece di CUDA, con performance simili a una GPU dedicata di fascia media. Su Raspberry Pi 5 (8GB RAM, niente GPU) funziona solo CPU-only ed è limitato a modelli piccoli (Phi-3.5 mini, Qwen 2.5 1.5B). Vedi anche EP07 della serie per le alternative più leggere.

Come aggiorno un modello Ollama senza downtime?

Ollama non supporta hot-swap nativo. La procedura safe è: 1) ollama pull modello:nuova-versione (scarica in background senza interrompere il modello attivo), 2) verifica che il pull sia completo con ollama list, 3) aggiorna il Modelfile per puntare alla nuova versione, 4) ollama create con il nuovo Modelfile, 5) esegui un test rapido, 6) rimuovi la versione vecchia con ollama rm. Il downtime è solo il tempo del riavvio di Ollama al punto 4, tipicamente <10 secondi.

Conclusione

La quantizzazione non è una scorciatoia: è ingegneria. Scegliere Q4_K_M invece di F16 per Llama 3.1 8B significa passare da 16GB a 5GB di VRAM, triplicare i TPS, e perdere solo 0.05 punti di perplexity - una differenza invisibile negli output reali. Questa scelta, da sola, può determinare se il tuo VPS regge il carico o va in swap. Il memory planning - pesi + KV cache + activation overhead - ti evita la sorpresa di un OOM alle 3 di notte. E il calcolo del costo per token ti dà il numero per giustificare l'infrastruttura self-hosted al tuo CTO o al tuo cliente.

La concurrency con OLLAMA_NUM_PARALLEL richiede calibrazione: non esiste il valore universalmente corretto. Misura il tuo carico reale con il benchmark asyncio, osserva la VRAM durante il picco, e trova il punto di equilibrio tra latenza p95 e throughput totale. In produzione, abbassa sempre OLLAMA_MAX_QUEUE (50 è un buon punto di partenza) per avere fail-fast invece di coda infinita che accumula latenza.

Prossimo episodio: Nel EP03 - vLLM infrastructure deep-dive esploriamo PagedAttention, tensor parallelism e batching dinamico: la differenza tra 41 TPS di Ollama e 793 TPS di vLLM spiegata meccanicamente, con configurazione passo-passo per multi-GPU.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti