Salta al contenuto principale

Monitoring self-hosted LLM: GPU utilization, token latency, cost tracking real-time

Monitoring self-hosted LLM: GPU utilization, token latency, cost tracking real-time: Monitoring self-hosted LLM: GPU utilization, token latency, cost...

Cosa ne pensi di questo articolo?

Monitoring self-hosted LLM: GPU utilization, token latency, cost tracking real-time
AI ObservabilityFoto:Egor KomarovPexels

In Breve

Monitoring self-hosted LLM: GPU utilization, token latency, cost tracking real-time: Monitoring self-hosted LLM: GPU utilization, token latency, cost...

Monitoring self-hosted LLM: GPU utilization, token latency, cost tracking real-time

GPU idle al 40%. Server che brucia elettricità, 24 ore su 24, senza generare un singolo token utile. La dashboard? Muta. Nessun alert, nessuna metrica, nessuna visibilità su cosa sta succedendo dentro quella macchina che ti costa €80 al mese. Questa è la realtà di molti deployment self-hosted di LLM: si mette su Ollama o vLLM, si verifica che funzioni, e poi ci si dimentica di capire come sta funzionando.

Il monitoring di un LLM self-hosted è diverso dal monitoring di una webapp tradizionale. Non ti bastano CPU% e HTTP response time. Devi tracciare metriche specifiche per l'inferenza: Time To First Token (TTFT), inter-token latency, KV cache usage, GPU power draw, queue depth, e soprattutto il costo per token in euro reali, non in astratti "credits". In questo episodio costruiamo l'intero stack di osservabilità per un VPS Hetzner CCX23 con Ollama + Llama 3.1 8B, il setup che uso nel mio portfolio backend, e lo rendiamo applicabile anche a vLLM in produzione.

Cosa Imparerai

  • Costruire un Prometheus exporter Python custom per Ollama (nessun endpoint nativo)
  • Configurare vLLM per esporre metriche Prometheus con YAML corretto
  • Creare una Grafana dashboard con pannelli GPU, latency e queue in JSON importabile
  • Scrivere alert rules YAML per GPU idle e GPU peak con soglie calibrate
  • Implementare structured logging con structlog per ogni richiesta LLM
  • Calcolare il costo per token in euro con formula Python
  • Interrogare la query history con ElasticSearch per analisi aggregate

Prerequisiti

  • Prometheus e Grafana: conoscenza base di scraping e PromQL
  • Python 3.10+ con familiarità a prometheus_client
  • Ollama o vLLM installato (vedi EP01-EP02 di questa serie)
  • VPS con GPU o CPU per Ollama (il codice funziona in entrambi i casi)
  • Lettura consigliata: EP04 - Production safety: rate limit e circuit breaker

Sezione 1 - Perché il monitoring standard non basta per LLM

Quando monitori una REST API tradizionale, i tuoi KPI sono chiari: request rate, error rate, response time p95. Il modello RED (Rate, Errors, Duration) copre tutto. Con un LLM self-hosted, RED è necessario ma non sufficiente. Hai strati aggiuntivi di complessità che richiedono metriche specifiche.

Il primo problema è la dualità della latency. Un LLM non risponde tutto-o-niente come un'API JSON: genera token in streaming. La latency rilevante per l'utente è TTFT (Time To First Token, quante millisecondi prima di vedere il primo carattere) e inter-token latency (quanto tempo tra un token e il successivo, che determina la fluidità dello streaming). Se misuri solo l'end-to-end request latency, hai una media che maschera entrambi i problemi.

Il secondo problema è la GPU come risorsa condivisa e cara. La GPU non è come la CPU che scala orizzontalmente aggiungendo core. È una risorsa finita con vincoli di VRAM, di banda memoria e di compute. Quando la VRAM si avvicina al 100%, le performance degradano drasticamente prima di crashare. Quando la GPU utilization è bassa, stai pagando per nulla. Devi monitorare entrambi gli estremi.

Il terzo problema è il costo invisibile. Con le API cloud hai una fattura a fine mese che ti dice esattamente quanto hai speso per ogni modello. Self-hosted, il costo è diffuso nel costo del server, dell'elettricità, del tuo tempo. Se non lo calcoli attivamente, non sai se stai risparmiando davvero.

Sul mio VPS Hetzner CCX23 (8 vCPU, 32GB RAM, GPU condivisa) con Ollama e Llama 3.1 8B, i valori baseline che ho misurato sono: TTFT 450-800ms, inter-token latency 60-90ms, throughput 35-45 token/sec, GPU utilization 65-85% durante inferenza attiva. Questi numeri sono il punto di partenza per costruire alert significativi.

Sezione 2 - Prometheus exporter Python custom per Ollama

Ollama non ha un endpoint /metrics Prometheus nativo. La issue #3144 su GitHub è aperta da tempo ma non è ancora stata integrata nel core. La soluzione è costruire un exporter Python che interroga le API REST di Ollama e le metriche GPU via nvidia-smi o DCGM, poi espone tutto nel formato Prometheus.

L'architettura è semplice: un processo Python separato interroga Ollama ogni 15 secondi, raccoglie i dati, e li espone su :9400/metrics. Prometheus scrapa questo endpoint. Il codice seguente mostra il cuore dell'exporter, commentato per chiarezza.

"""
Ollama Prometheus Exporter - espone metriche LLM e GPU per Prometheus scraping.
Avvia con: python ollama_exporter.py
Porta default: 9400
Dipendenze: prometheus_client, httpx, subprocess
"""

import time
import subprocess
import json
import httpx
from prometheus_client import (
    start_http_server,
    Gauge,
    Counter,
    Histogram,
    REGISTRY,
)

# URL base di Ollama (default locale)
OLLAMA_BASE_URL = "http://localhost:11434"

# Intervallo di scraping in secondi
SCRAPE_INTERVAL = 15

# --- Definizione metriche Prometheus ---

# GPU utilization in percentuale (0-100)
gpu_utilization = Gauge(
    "ollama_gpu_utilization_percent",
    "GPU utilization percentage",
    ["gpu_index", "gpu_name"],
)

# VRAM usata in MB
gpu_memory_used_mb = Gauge(
    "ollama_gpu_memory_used_mb",
    "GPU memory used in MB",
    ["gpu_index", "gpu_name"],
)

# VRAM totale in MB
gpu_memory_total_mb = Gauge(
    "ollama_gpu_memory_total_mb",
    "GPU memory total in MB",
    ["gpu_index", "gpu_name"],
)

# Power draw in watt
gpu_power_watt = Gauge(
    "ollama_gpu_power_draw_watt",
    "GPU power draw in watts",
    ["gpu_index", "gpu_name"],
)

# Temperatura GPU in Celsius
gpu_temperature_celsius = Gauge(
    "ollama_gpu_temperature_celsius",
    "GPU temperature in Celsius",
    ["gpu_index", "gpu_name"],
)

# Numero di modelli caricati in memoria
ollama_models_loaded = Gauge(
    "ollama_models_loaded_count",
    "Number of models currently loaded in Ollama",
)

# Stato server Ollama (1=up, 0=down)
ollama_server_up = Gauge(
    "ollama_server_up",
    "Ollama server health status (1=up, 0=down)",
)

# Latency dell'ultima richiesta di health check in ms
ollama_health_latency_ms = Gauge(
    "ollama_health_check_latency_ms",
    "Latency of last Ollama health check in milliseconds",
)


def raccoglie_metriche_gpu() -> None:
    """Interroga nvidia-smi e popola le metriche GPU Prometheus."""
    try:
        # Formato CSV: index, name, utilization.gpu, memory.used, memory.total, power.draw, temperature.gpu
        risultato = subprocess.run(
            [
                "nvidia-smi",
                "--query-gpu=index,name,utilization.gpu,memory.used,memory.total,power.draw,temperature.gpu",
                "--format=csv,noheader,nounits",
            ],
            capture_output=True,
            text=True,
            timeout=10,
        )

        if risultato.returncode != 0:
            # Nessuna GPU NVIDIA disponibile (es. CPU-only o AMD)
            return

        for riga in risultato.stdout.strip().split("\n"):
            parti = [p.strip() for p in riga.split(",")]
            if len(parti) < 7:
                continue

            gpu_idx = parti[0]
            gpu_nome = parti[1]

            # Popola i Gauge con float (nvidia-smi può restituire "[N/A]")
            def safe_float(val: str, default: float = 0.0) -> float:
                try:
                    return float(val)
                except ValueError:
                    return default

            gpu_utilization.labels(gpu_index=gpu_idx, gpu_name=gpu_nome).set(
                safe_float(parti[2])
            )
            gpu_memory_used_mb.labels(gpu_index=gpu_idx, gpu_name=gpu_nome).set(
                safe_float(parti[3])
            )
            gpu_memory_total_mb.labels(gpu_index=gpu_idx, gpu_name=gpu_nome).set(
                safe_float(parti[4])
            )
            gpu_power_watt.labels(gpu_index=gpu_idx, gpu_name=gpu_nome).set(
                safe_float(parti[5])
            )
            gpu_temperature_celsius.labels(gpu_index=gpu_idx, gpu_name=gpu_nome).set(
                safe_float(parti[6])
            )

    except (FileNotFoundError, subprocess.TimeoutExpired):
        # nvidia-smi non disponibile: nessun problema, log warning
        pass


def raccoglie_metriche_ollama() -> None:
    """Interroga le API REST di Ollama e popola le metriche."""
    try:
        inizio = time.monotonic()
        risposta = httpx.get(f"{OLLAMA_BASE_URL}/api/tags", timeout=5.0)
        latenza_ms = (time.monotonic() - inizio) * 1000

        if risposta.status_code == 200:
            ollama_server_up.set(1)
            ollama_health_latency_ms.set(latenza_ms)

            dati = risposta.json()
            # /api/tags restituisce {"models": [...]}
            modelli = dati.get("models", [])
            ollama_models_loaded.set(len(modelli))
        else:
            ollama_server_up.set(0)

    except (httpx.ConnectError, httpx.TimeoutException):
        ollama_server_up.set(0)
        ollama_health_latency_ms.set(-1)


def ciclo_raccolta() -> None:
    """Loop principale: raccoglie metriche ogni SCRAPE_INTERVAL secondi."""
    while True:
        raccoglie_metriche_gpu()
        raccoglie_metriche_ollama()
        time.sleep(SCRAPE_INTERVAL)


if __name__ == "__main__":
    # Avvia il server HTTP Prometheus sulla porta 9400
    start_http_server(9400)
    print(f"Exporter avviato su :9400/metrics - scraping ogni {SCRAPE_INTERVAL}s")
    ciclo_raccolta()

L'exporter si installa come servizio systemd accanto a Ollama. Aggiungi una unit file in /etc/systemd/system/ollama-exporter.service con Restart=always e After=ollama.service per garantire che parta dopo Ollama. Il Prometheus scraper configuration punta a localhost:9400.

Sezione 3 - vLLM: configurazione endpoint Prometheus

A differenza di Ollama, vLLM espone nativamente un endpoint /metrics in formato Prometheus sull'API server. Il prefisso di tutte le metriche è vllm:. La configurazione avviene tramite argomenti di avvio o, in deployment più strutturati, tramite file YAML con lo schema OpenAI-compatible server config.

# vllm-server-config.yaml
# Configurazione vLLM con metriche Prometheus abilitate
# Avvia con: python -m vllm.entrypoints.openai.api_server --config vllm-server-config.yaml

model: meta-llama/Llama-3.1-8B-Instruct
host: 0.0.0.0
port: 8000

# Metriche Prometheus: abilita e configura intervallo raccolta
enable_metrics: true
# Abilita anche metriche MFU (GPU floating-point utilization)
# Richiede GPU NVIDIA con CUDA support
enable_mfu_metrics: true

# Lunghezza sequenza massima
max_model_len: 8192

# Numero richieste in batch simultanee (impatta throughput)
max_num_seqs: 64

# Tensor parallelism: 1 per GPU singola, 2+ per multi-GPU
tensor_parallel_size: 1

# Cache KV: percentuale GPU memory dedicata alla KV cache
# Default 0.9 - riduci a 0.7 se hai altri processi sulla GPU
gpu_memory_utilization: 0.85

# Log ogni N step del motore di inferenza
disable_log_stats: false

# Formato log: json per integrazione strutturata
# oppure 'auto' per human-readable in dev
# json_log_format: true

Con questa configurazione, vLLM espone su http://localhost:8000/metrics un set ricco di metriche. Il catalogo completo delle metriche rilevanti, con tipo e descrizione, è nella tabella seguente. Configurare Prometheus per scrapare localhost:8000/metrics è sufficiente - nessun exporter aggiuntivo serve.

Metrica Prometheus Tipo Descrizione Soglia critica
vllm:num_requests_running Gauge Richieste in esecuzione attiva > max_num_seqs * 0.9
vllm:num_requests_waiting Gauge Richieste in coda > 50 per 2min
vllm:kv_cache_usage_perc Gauge KV cache occupata (0.0–1.0) > 0.95
vllm:time_to_first_token_seconds Histogram TTFT per richiesta (p50, p95, p99) p95 > 2s
vllm:inter_token_latency_seconds Histogram Latency tra token consecutivi p95 > 0.15s
vllm:e2e_request_latency_seconds Histogram Latency end-to-end richiesta completa p99 > 30s
vllm:prompt_tokens_total Counter Token di prompt processati totali -
vllm:generation_tokens_total Counter Token generati totali -
vllm:request_success_total Counter Richieste completate con successo -
vllm:num_preemptions_total Counter Preemption (sequenze interrotte per VRAM) > 0/min costante
ollama_gpu_utilization_percent Gauge GPU utilization % (da exporter custom) <20% o >95%
ollama_gpu_memory_used_mb Gauge VRAM usata in MB > 90% totale
ollama_gpu_power_draw_watt Gauge Consumo energetico GPU in watt > TDP * 0.95
ollama_gpu_temperature_celsius Gauge Temperatura GPU in Celsius > 85°C
ollama_server_up Gauge Stato server Ollama (1=up) == 0 per 30s
ollama_health_check_latency_ms Gauge Latency health check Ollama in ms > 5000ms

Sezione 4 - Grafana dashboard: GPU, latency, queue in JSON

La dashboard Grafana deve dare risposta immediata a tre domande: la GPU sta lavorando (o è idle)? La latency è nei range SLA? C'è accumulo di coda? Il JSON seguente è un dashboard completo importabile direttamente in Grafana con Dashboard > Import > Upload JSON file. Include quattro pannelli chiave con le PromQL corrette.

{
  "title": "LLM Self-Hosted Monitor",
  "uid": "llm-selfhosted-v1",
  "timezone": "Europe/Rome",
  "panels": [
    {
      "id": 1,
      "title": "GPU Utilization %",
      "type": "gauge",
      "gridPos": { "x": 0, "y": 0, "w": 6, "h": 8 },
      "targets": [
        {
          "expr": "ollama_gpu_utilization_percent",
          "legendFormat": "GPU {{gpu_name}}"
        }
      ],
      "fieldConfig": {
        "defaults": {
          "min": 0, "max": 100, "unit": "percent",
          "thresholds": {
            "steps": [
              { "color": "yellow", "value": 0 },
              { "color": "green", "value": 20 },
              { "color": "orange", "value": 90 },
              { "color": "red", "value": 95 }
            ]
          }
        }
      }
    },
    {
      "id": 2,
      "title": "TTFT p95 (Time To First Token)",
      "type": "timeseries",
      "gridPos": { "x": 6, "y": 0, "w": 10, "h": 8 },
      "targets": [
        {
          "expr": "histogram_quantile(0.95, rate(vllm:time_to_first_token_seconds_bucket[5m]))",
          "legendFormat": "TTFT p95"
        },
        {
          "expr": "histogram_quantile(0.50, rate(vllm:time_to_first_token_seconds_bucket[5m]))",
          "legendFormat": "TTFT p50"
        }
      ],
      "fieldConfig": {
        "defaults": { "unit": "s" }
      }
    },
    {
      "id": 3,
      "title": "Queue Depth - Richieste in Attesa",
      "type": "stat",
      "gridPos": { "x": 16, "y": 0, "w": 4, "h": 4 },
      "targets": [
        {
          "expr": "vllm:num_requests_waiting",
          "legendFormat": "In coda"
        }
      ],
      "fieldConfig": {
        "defaults": {
          "thresholds": {
            "steps": [
              { "color": "green", "value": 0 },
              { "color": "yellow", "value": 10 },
              { "color": "red", "value": 50 }
            ]
          }
        }
      }
    },
    {
      "id": 4,
      "title": "Token/sec (throughput generazione)",
      "type": "timeseries",
      "gridPos": { "x": 0, "y": 8, "w": 12, "h": 8 },
      "targets": [
        {
          "expr": "rate(vllm:generation_tokens_total[1m])",
          "legendFormat": "Token/sec generati"
        },
        {
          "expr": "rate(vllm:prompt_tokens_total[1m])",
          "legendFormat": "Token/sec prompt"
        }
      ]
    }
  ]
}

In produzione aggiungi un pannello per VRAM usata vs totale (bar gauge con soglia all'85%), un pannello per GPU power draw (line chart, utile per correlare consumo energetico con carico), e uno stat panel per costo EUR accumulato nella sessione corrente (vedi Sezione 6).

Sezione 5 - Alert rules: GPU idle, picco e degradazione latency

Gli alert sono la parte del monitoring che fa davvero la differenza. Un alert ben calibrato ti sveglia quando serve - non quando la GPU è idle di notte per cinque minuti, ma quando è idle durante il picco atteso oppure quando la latency p95 ha superato la soglia SLA per tre minuti consecutivi.

Il file seguente contiene le regole Prometheus in formato YAML standard. Inseriscilo in /etc/prometheus/rules/llm_alerts.yaml e aggiungi il riferimento in prometheus.yml con rule_files: ["rules/*.yaml"].

# llm_alerts.yaml - Alert rules per LLM self-hosted
# Carica in Prometheus: rule_files: ["rules/llm_alerts.yaml"]

groups:
  - name: llm_gpu_alerts
    interval: 30s
    rules:

      # GPU completamente idle durante orario attivo
      # Indica server acceso senza traffico: spreco di risorse
      - alert: GpuIdleOrarioPicco
        expr: |
          ollama_gpu_utilization_percent < 20
          AND
          hour() >= 8 AND hour() < 22
        for: 15m
        labels:
          severity: warning
          team: ai-infra
        annotations:
          summary: "GPU idle durante orario di picco"
          description: >
            GPU {{ $labels.gpu_name }} utilization {{ $value }}% < 20%
            da 15 minuti in orario 08-22. Verifica se il traffico è calato
            o se Ollama ha smesso di servire richieste.

      # GPU in sovraccarico: rischio di VRAM esaurita e OOM
      - alert: GpuPiccoEstremo
        expr: ollama_gpu_utilization_percent > 95
        for: 3m
        labels:
          severity: critical
          team: ai-infra
        annotations:
          summary: "GPU in sovraccarico critico"
          description: >
            GPU {{ $labels.gpu_name }} utilization {{ $value }}% > 95%
            da 3 minuti. Rischio OOM e degradazione severa della latency.
            Valuta di attivare il rate limiting di emergenza (EP04).

      # VRAM quasi esaurita: precursore di OOM kill
      - alert: VramCritica
        expr: |
          (ollama_gpu_memory_used_mb / ollama_gpu_memory_total_mb) > 0.90
        for: 5m
        labels:
          severity: critical
          team: ai-infra
        annotations:
          summary: "VRAM superiore al 90%"
          description: >
            VRAM usata {{ $value | humanizePercentage }} del totale.
            Ollama o vLLM potrebbero fare OOM e crashare nelle prossime
            richieste con contesti lunghi.

      # Temperatura GPU elevata: rischio throttling hardware
      - alert: GpuTemperaturaAlta
        expr: ollama_gpu_temperature_celsius > 82
        for: 10m
        labels:
          severity: warning
          team: ai-infra
        annotations:
          summary: "Temperatura GPU elevata"
          description: >
            GPU {{ $labels.gpu_name }} a {{ $value }}°C > 82°C da 10 minuti.
            Verifica ventilazione del rack o del VPS.

  - name: llm_latency_alerts
    interval: 30s
    rules:

      # TTFT p95 sopra soglia SLA
      - alert: TtftP95SlaViolation
        expr: |
          histogram_quantile(0.95,
            rate(vllm:time_to_first_token_seconds_bucket[5m])
          ) > 2.0
        for: 3m
        labels:
          severity: warning
          team: ai-infra
        annotations:
          summary: "TTFT p95 supera 2 secondi"
          description: >
            Time To First Token al 95° percentile è {{ $value }}s > 2.0s
            negli ultimi 5 minuti. Utenti percepiscono lag notevole.

      # Coda eccessiva: sistema sotto pressione
      - alert: QueueDepthAlta
        expr: vllm:num_requests_waiting > 50
        for: 2m
        labels:
          severity: warning
          team: ai-infra
        annotations:
          summary: "Coda richieste LLM superiore a 50"
          description: >
            {{ $value }} richieste in attesa da 2 minuti. Considera di
            aumentare max_num_seqs o attivare il circuit breaker (EP04).

      # Server Ollama down
      - alert: OllamaServerDown
        expr: ollama_server_up == 0
        for: 30s
        labels:
          severity: critical
          team: ai-infra
        annotations:
          summary: "Ollama server non raggiungibile"
          description: >
            Il server Ollama non risponde da 30 secondi.
            Verifica con: systemctl status ollama.service

  - name: llm_threshold_table
    # Tabella soglie di riferimento (documentazione alert)
    # GPU idle warning:   utilization < 20% per 15min (orario 8-22)
    # GPU peak critical:  utilization > 95% per 3min
    # VRAM critical:      usage > 90% per 5min
    # GPU temp warning:   temperature > 82C per 10min
    # TTFT SLA warning:   p95 > 2.0s per 3min
    # Queue warning:      waiting > 50 per 2min
    # Ollama down:        up == 0 per 30s
    rules: []
Alert Metrica Soglia Durata Severity
GPU Idle Picco gpu_utilization_percent < 20% (ore 8-22) 15 minuti warning
GPU Peak Critico gpu_utilization_percent > 95% 3 minuti critical
VRAM Critica gpu_memory_used_mb / total > 90% 5 minuti critical
Temperatura Alta gpu_temperature_celsius > 82°C 10 minuti warning
TTFT SLA Violation time_to_first_token p95 > 2.0s 3 minuti warning
Queue Depth Alta num_requests_waiting > 50 2 minuti warning
Ollama Server Down ollama_server_up == 0 30 secondi critical
KV Cache Satura kv_cache_usage_perc > 0.95 5 minuti warning
Preemption Frequenti num_preemptions_total rate > 1/min costante 10 minuti warning
Inter-token Latency inter_token_latency_seconds p95 > 0.15s 5 minuti warning

Sezione 6 - Structured logging con structlog per ogni richiesta LLM

Le metriche Prometheus ti dicono quanto sta succedendo in aggregato. I log strutturati ti dicono cosa è successo per ogni singola richiesta. La combinazione delle due fonti è fondamentale per debug e ottimizzazione. structlog è la libreria Python di riferimento per logging strutturato in formato JSON - zero dependency, composable processors, async-safe.

Il pattern per LLM è logare ogni richiesta con: modello usato, user ID anonimizzato, numero di token in ingresso e uscita, latency TTFT e totale, costo calcolato. Questo ti permette di rispondere a domande come "chi sta consumando il 60% dei token?" o "qual è il modello con TTFT peggiore?" senza interrogare Prometheus.

"""
Structured logging per LLM inference con structlog.
Ogni richiesta viene loggata come JSON con contesto completo.
Integra nel layer middleware del tuo API server (FastAPI, Flask, etc.).
"""

import time
import hashlib
import structlog
import logging
from typing import Optional

# Configura structlog per output JSON (produzione)
# In sviluppo puoi usare ConsoleRenderer per output leggibile
structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.StackInfoRenderer(),
        # In produzione: JSONRenderer per Loki/ElasticSearch
        structlog.processors.JSONRenderer(),
        # In sviluppo: ConsoleRenderer per output colorato
        # structlog.dev.ConsoleRenderer(),
    ],
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
    context_class=dict,
    logger_factory=structlog.PrintLoggerFactory(),
)

logger = structlog.get_logger("llm.inference")


def anonimizza_user_id(user_id: str) -> str:
    """
    Hash SHA256 troncato a 12 caratteri per anonimizzare l'user ID nei log.
    Mantiene la correlabilità all'interno di una sessione senza esporre PII.
    """
    return hashlib.sha256(user_id.encode()).hexdigest()[:12]


class LLMRequestLogger:
    """
    Context manager che logga una richiesta LLM con tutte le metriche.
    Uso: with LLMRequestLogger(model="llama3.1:8b", user_id="u123") as req_log:
             risultato = chiama_ollama(prompt)
             req_log.set_risultato(tokens_prompt=50, tokens_gen=200, ttft_ms=450)
    """

    def __init__(
        self,
        model: str,
        user_id: str,
        endpoint: str = "/chat",
        cost_per_1k_tokens_eur: float = 0.0007,  # Valore calcolato (vedi Sezione 7)
    ):
        self.model = model
        self.user_id_hash = anonimizza_user_id(user_id)
        self.endpoint = endpoint
        self.cost_per_1k = cost_per_1k_tokens_eur
        self.inizio_ts = time.monotonic()
        self._tokens_prompt = 0
        self._tokens_gen = 0
        self._ttft_ms: Optional[float] = None
        self._errore: Optional[str] = None

    def set_risultato(
        self,
        tokens_prompt: int,
        tokens_gen: int,
        ttft_ms: Optional[float] = None,
    ) -> None:
        """Setta i dati di output dopo la chiamata al modello."""
        self._tokens_prompt = tokens_prompt
        self._tokens_gen = tokens_gen
        self._ttft_ms = ttft_ms

    def set_errore(self, messaggio: str) -> None:
        """Registra un errore avvenuto durante la richiesta."""
        self._errore = messaggio

    def __enter__(self) -> "LLMRequestLogger":
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
        # Calcola latency totale
        latency_totale_ms = (time.monotonic() - self.inizio_ts) * 1000

        # Calcola costo in EUR
        token_totali = self._tokens_prompt + self._tokens_gen
        costo_eur = (token_totali / 1000.0) * self.cost_per_1k

        # Se c'è stata un'eccezione non gestita, registrala
        if exc_type is not None and self._errore is None:
            self._errore = f"{exc_type.__name__}: {exc_val}"

        # Log unico per richiesta - JSON completo
        log_evento = logger.bind(
            event="llm_request_completed",
            model=self.model,
            user_id=self.user_id_hash,
            endpoint=self.endpoint,
            tokens_prompt=self._tokens_prompt,
            tokens_generated=self._tokens_gen,
            tokens_total=token_totali,
            ttft_ms=round(self._ttft_ms, 1) if self._ttft_ms else None,
            latency_total_ms=round(latency_totale_ms, 1),
            cost_eur=round(costo_eur, 8),
            success=self._errore is None,
            error=self._errore,
        )

        if self._errore:
            log_evento.error("LLM request fallita")
        else:
            log_evento.info("LLM request completata")

        # Non sopprimere eccezioni originali
        return False


# Esempio di utilizzo in un endpoint FastAPI
# ============================================
# async def chat_endpoint(request: ChatRequest, user: User = Depends(get_current_user)):
#     with LLMRequestLogger(
#         model="llama3.1:8b",
#         user_id=user.id,
#         endpoint="/chat",
#     ) as req_log:
#         risposta = await ollama_client.chat(request.messaggio)
#         req_log.set_risultato(
#             tokens_prompt=risposta.prompt_eval_count,
#             tokens_gen=risposta.eval_count,
#             ttft_ms=risposta.ttft_ms,
#         )
#         return { "response": risposta.message }

I log JSON vengono poi inviati a Loki (integrato con Grafana per search full-text) o a ElasticSearch per analisi aggregate più potenti. La sezione 8 mostra come interrogare ElasticSearch per query history avanzata.

Sezione 7 - Cost tracking per token: formula Python e baseline Hetzner

Il calcolo del costo per token in un setup self-hosted richiede di distribuire il costo fisso del server (€/ora) sul numero di token generati (token/sec). Non è complesso, ma va fatto correttamente per avere numeri confrontabili con le API cloud. Ecco la formula e il codice per calcolarla in real-time.

"""
Cost calculator per LLM self-hosted.
Formula: costo_per_token = (costo_server_eur_ora / 3600) / token_per_secondo

Baseline misurata su Hetzner CCX23 con Ollama + Llama 3.1 8B:
  - Costo server:     €0.096/ora (CCX23, 8 vCPU, 32GB RAM)
  - Throughput medio: 40 token/sec durante inferenza
  - Utilizzo GPU:     70% del tempo (non genera token il 30% del tempo)
  - Costo effettivo:  (0.096/3600) / (40 * 0.70) = €0.00000095/token
                   ≈ €0.00095 per 1000 token
                   ≈ 10-50x più economico di GPT-4 API (~€0.01-0.06/1k token)
"""

from dataclasses import dataclass
from typing import Optional


@dataclass(frozen=True)
class CostoServerConfig:
    """Configurazione immutabile del costo server."""

    costo_eur_per_ora: float        # Costo orario VPS/bare metal
    token_per_secondo_medio: float  # Throughput medio misurato
    utilizzo_gpu_fraction: float    # Frazione tempo con GPU attiva (0.0-1.0)

    def calcola_costo_per_token(self) -> float:
        """
        Restituisce il costo in EUR per singolo token generato.
        Tiene conto che il server paga anche quando è idle.
        """
        # Token effettivi per secondo considerando idle time
        token_effettivi_al_sec = self.token_per_secondo_medio * self.utilizzo_gpu_fraction
        if token_effettivi_al_sec <= 0:
            return float("inf")

        # Costo server per secondo diviso per token prodotti in quel secondo
        costo_per_sec = self.costo_eur_per_ora / 3600.0
        return costo_per_sec / token_effettivi_al_sec

    def calcola_costo_per_1k_token(self) -> float:
        """Restituisce il costo per 1000 token (metrica standard confronto API)."""
        return self.calcola_costo_per_token() * 1000.0

    def calcola_costo_richiesta(
        self,
        tokens_prompt: int,
        tokens_generati: int,
    ) -> float:
        """Calcola il costo in EUR per una singola richiesta."""
        # I token di prompt hanno costo minore (solo prefill, non generazione)
        # Approssimazione conservativa: usiamo lo stesso costo per entrambi
        totale_token = tokens_prompt + tokens_generati
        return totale_token * self.calcola_costo_per_token()

    def report_confronto_cloud(self) -> dict:
        """
        Genera un report di confronto con API cloud equivalenti.
        Dati 2025: GPT-4o-mini ~€0.0002/1k, GPT-4 ~€0.01/1k, Claude Sonnet ~€0.003/1k.
        """
        costo_1k = self.calcola_costo_per_1k_token()
        return {
            "self_hosted_eur_per_1k": round(costo_1k, 6),
            "risparmio_vs_gpt4_x": round(0.01 / costo_1k, 1) if costo_1k > 0 else 0,
            "risparmio_vs_gpt4o_mini_x": round(0.0002 / costo_1k, 1) if costo_1k > 0 else 0,
            "breakeven_richieste_giorno": _calcola_breakeven(costo_1k),
        }


def _calcola_breakeven(costo_1k_selfhosted: float, costo_1k_cloud: float = 0.0002) -> int:
    """
    Calcola quante richieste/giorno servono perché il self-hosted convenga
    rispetto all'API cloud più economica (GPT-4o-mini come riferimento 2025).
    Assumendo 500 token medi per richiesta.
    """
    costo_selfhosted_per_req = (500 / 1000.0) * costo_1k_selfhosted
    costo_cloud_per_req = (500 / 1000.0) * costo_1k_cloud
    # Il server costa sempre, quindi breakeven = costo_fisso_giorno / risparmio_per_req
    # Semplificato: quante richieste per ammortizzare il server
    return int(0.096 * 24 / max(costo_cloud_per_req - costo_selfhosted_per_req, 0.000001))


# Configurazione baseline misurata su Hetzner CCX23 + Ollama + Llama 3.1 8B
BASELINE_HETZNER_CCX23 = CostoServerConfig(
    costo_eur_per_ora=0.096,          # CCX23 a pagamento orario
    token_per_secondo_medio=40.0,     # Misurato con Llama 3.1 8B Q4
    utilizzo_gpu_fraction=0.70,       # 70% tempo attivo, 30% idle
)

if __name__ == "__main__":
    cfg = BASELINE_HETZNER_CCX23
    print(f"Costo per token:    EUR {cfg.calcola_costo_per_token():.8f}")
    print(f"Costo per 1k token: EUR {cfg.calcola_costo_per_1k_token():.6f}")

    # Richiesta tipica: 200 token prompt, 400 token risposta
    costo_req = cfg.calcola_costo_richiesta(tokens_prompt=200, tokens_generati=400)
    print(f"Costo richiesta 600 token: EUR {costo_req:.6f}")

    # Confronto cloud
    report = cfg.report_confronto_cloud()
    print(f"Risparmio vs GPT-4: {report['risparmio_vs_gpt4_x']}x")
    print(f"Risparmio vs GPT-4o-mini: {report['risparmio_vs_gpt4o_mini_x']}x")

Sul VPS Hetzner CCX23, con Ollama + Llama 3.1 8B in quantizzazione Q4_K_M, il costo calcolato è circa €0.00095 per 1000 token, 10x più economico di GPT-4o-mini e oltre 50x più economico di modelli GPT-4. Il breakeven rispetto a GPT-4o-mini si raggiunge a circa 3.000 richieste/giorno di 500 token medi - una soglia raggiungibile anche per applicazioni di medie dimensioni.

Sezione 8 - ElasticSearch per query history e analisi aggregate

I log strutturati di structlog vengono inviati a ElasticSearch tramite Logstash o Filebeat. Una volta indicizzati, puoi fare analisi che Prometheus non supporta: "chi sono i top 10 utenti per consumo token questa settimana?", "qual è il modello con latency media più alta?", "quante richieste fallite ci sono state per modello?".

"""
ElasticSearch query per analisi query history LLM.
Prerequisiti: elasticsearch-py installato, indice "llm-requests-*" creato da Logstash.
Ogni documento segue la struttura loggata da LLMRequestLogger (Sezione 6).
"""

from datetime import datetime, timedelta
from elasticsearch import Elasticsearch


# Client ElasticSearch - usa credenziali da variabili d'ambiente in produzione
es = Elasticsearch(
    hosts=["http://localhost:9200"],
    # In produzione: basic_auth=("user", os.environ["ES_PASSWORD"])
)

INDEX_PATTERN = "llm-requests-*"


def top_utenti_per_token(giorni: int = 7, top_n: int = 10) -> list[dict]:
    """
    Trova i top N utenti per consumo di token negli ultimi X giorni.
    Utile per identificare heavy user e impostare cost cap per-user.
    """
    da = datetime.utcnow() - timedelta(days=giorni)

    risposta = es.search(
        index=INDEX_PATTERN,
        body={
            "query": {
                "range": {
                    "@timestamp": {
                        "gte": da.isoformat(),
                        "format": "strict_date_optional_time"
                    }
                }
            },
            "aggs": {
                "per_utente": {
                    "terms": {
                        "field": "user_id.keyword",
                        "size": top_n,
                        "order": { "token_totali": "desc" }
                    },
                    "aggs": {
                        "token_totali": {
                            "sum": { "field": "tokens_total" }
                        },
                        "costo_totale_eur": {
                            "sum": { "field": "cost_eur" }
                        },
                        "latency_media_ms": {
                            "avg": { "field": "latency_total_ms" }
                        }
                    }
                }
            },
            "size": 0  # Solo aggregazioni, nessun documento raw
        }
    )

    risultati = []
    for bucket in risposta["aggregations"]["per_utente"]["buckets"]:
        risultati.append({
            "user_id": bucket["key"],
            "token_totali": int(bucket["token_totali"]["value"]),
            "costo_totale_eur": round(bucket["costo_totale_eur"]["value"], 4),
            "latency_media_ms": round(bucket["latency_media_ms"]["value"], 1),
            "richieste": bucket["doc_count"],
        })

    return risultati


def latency_per_modello(giorni: int = 1) -> list[dict]:
    """
    Confronta la latency media (TTFT e totale) per ogni modello usato.
    Utile per decidere quale modello promuovere a default e quale ritirare.
    """
    da = datetime.utcnow() - timedelta(days=giorni)

    risposta = es.search(
        index=INDEX_PATTERN,
        body={
            "query": {
                "bool": {
                    "must": [
                        { "range": { "@timestamp": { "gte": da.isoformat() } } },
                        { "term": { "success": True } }  # Solo richieste riuscite
                    ]
                }
            },
            "aggs": {
                "per_modello": {
                    "terms": { "field": "model.keyword", "size": 20 },
                    "aggs": {
                        "ttft_p95": {
                            "percentiles": {
                                "field": "ttft_ms",
                                "percents": [50, 95, 99]
                            }
                        },
                        "latency_totale_p95": {
                            "percentiles": {
                                "field": "latency_total_ms",
                                "percents": [50, 95]
                            }
                        },
                        "token_medi_per_req": {
                            "avg": { "field": "tokens_total" }
                        }
                    }
                }
            },
            "size": 0
        }
    )

    risultati = []
    for bucket in risposta["aggregations"]["per_modello"]["buckets"]:
        ttft = bucket["ttft_p95"]["values"]
        lat = bucket["latency_totale_p95"]["values"]
        risultati.append({
            "model": bucket["key"],
            "richieste": bucket["doc_count"],
            "ttft_p50_ms": round(ttft.get("50.0", 0), 1),
            "ttft_p95_ms": round(ttft.get("95.0", 0), 1),
            "latency_totale_p95_ms": round(lat.get("95.0", 0), 1),
            "token_medi": round(bucket["token_medi_per_req"]["value"], 0),
        })

    return sorted(risultati, key=lambda x: x["ttft_p95_ms"])


# Utilizzo rapido da CLI
if __name__ == "__main__":
    print("=== Top 10 utenti per consumo token (7 giorni) ===")
    for u in top_utenti_per_token(giorni=7, top_n=10):
        print(f"  {u['user_id']}: {u['token_totali']:,} token - EUR {u['costo_totale_eur']:.4f}")

    print("\n=== Latency per modello (ultimo giorno) ===")
    for m in latency_per_modello(giorni=1):
        print(f"  {m['model']}: TTFT p95={m['ttft_p95_ms']}ms, total p95={m['latency_totale_p95_ms']}ms")

Questi query patterns coprono l'80% dei casi d'uso analitici reali. Per setup più semplici (singolo VPS, pochi utenti), puoi sostituire ElasticSearch con PostgreSQL + query SQL equivalenti, loggando i dati direttamente in una tabella llm_requests tramite un batch writer asincrono.

Sezione 9 - Architettura completa dello stack di monitoring

Tutti i componenti descritti si integrano in uno stack coerente. La sequenza di deploy per un VPS Hetzner con Ollama è la seguente: l'exporter Python gira come servizio systemd accanto a Ollama, Prometheus e Grafana girano in Docker Compose (2GB RAM aggiuntivi totali), i log JSON vanno a Loki tramite Promtail per search full-text rapida, e a ElasticSearch tramite Logstash per analisi aggregate periodiche.

Per vLLM in produzione, il flusso è leggermente diverso: il /metrics nativo di vLLM sostituisce l'exporter Python per le metriche di inferenza, ma l'exporter Python rimane necessario per le metriche GPU hardware (nvidia-smi). Il structured logging si integra nel middleware FastAPI del tuo API gateway che siede davanti a vLLM.

Un aspetto spesso trascurato: la retention dei dati. Prometheus default è 15 giorni. Per un VPS con storage limitato, configura --storage.tsdb.retention.time=30d e un remote write verso Victoria Metrics (leggero, compressione 10x migliore) se vuoi retention più lunga. I log strutturati in ElasticSearch: considera un ILM (Index Lifecycle Management) con rollover a 7 giorni e delete a 90 giorni per controllare lo storage.

FAQ

Ollama ha un endpoint Prometheus nativo?

No, Ollama non espone un endpoint /metrics Prometheus nativo. La feature request è aperta su GitHub (issue #3144) ma non è ancora integrata. Devi costruire un exporter Python separato come mostrato in Sezione 2, oppure usare una delle librerie community come ollama-exporter su PyPI, che implementa lo stesso pattern con qualche metrica aggiuntiva.

Qual è la differenza tra TTFT e inter-token latency?

TTFT (Time To First Token) misura il tempo dal momento della richiesta fino alla generazione del primo token: determina la responsività percepita dall'utente, cioè quanto aspetta prima di vedere qualcosa apparire. Inter-token latency misura il tempo tra token consecutivi durante la generazione: determina la fluidità dello streaming. Per Ollama + Llama 3.1 8B su Hetzner CCX23, TTFT tipico è 450-800ms e inter-token latency 60-90ms. Se TTFT è alto ma inter-token è basso, il problema è il prefill (context lungo o GPU satura). Se inter-token è alto, il problema è il throughput di generazione.

Come si calcola il costo per token in un LLM self-hosted?

La formula è: costo_per_token = (costo_eur_ora / 3600) / (token_sec * utilizzo_gpu). Per Hetzner CCX23 a €0.096/ora con 40 token/sec e 70% utilizzo, il risultato è circa €0.00095 per 1000 token. Questo include solo il costo del server: aggiungi elettricità (su VPS è già inclusa), costo del tuo tempo per manutenzione, e ammortamento eventuali GPU dedicate se usi bare metal.

Quando far scattare un alert per GPU idle?

La soglia giusta dipende dal tuo pattern di traffico. Se hai traffico prevedibile (es. applicazione usata solo in orario lavorativo), imposta alert quando utilization < 20% per 15 minuti durante ore 8-22. Se il traffico è discontinuo, considera alert più morbidi (30 minuti) o dinamici basati su percentuale di utilizzo atteso per quella fascia oraria. Non mettere alert notturni per GPU idle: è normale e atteso.

vLLM espone metriche GPU nativamente?

vLLM espone metriche di inferenza tramite /metrics Prometheus: KV cache usage, numero richieste in coda, TTFT, inter-token latency, token throughput. Per metriche GPU hardware (VRAM usata, temperature, power draw), serve aggiungere NVIDIA DCGM Exporter separatamente. Con --enable-mfu-metrics puoi abilitare anche stime di GPU MFU (Model Flops Utilization), utile per benchmark di efficienza computazionale.

Come usare ElasticSearch per analizzare la query history?

Ogni richiesta LLM viene loggata come documento JSON in ElasticSearch con i campi mostrati in Sezione 6: model, user_id anonimizzato, token prompt/generati, latency, costo. Le aggregazioni in Sezione 8 ti permettono di trovare top user, confrontare latency tra modelli, e identificare pattern di utilizzo. Per setup più semplici (meno di 100k richieste/giorno), PostgreSQL con le stesse query in SQL è più semplice da gestire.

Quale stack di monitoring è consigliato per un singolo VPS con Ollama?

Stack minimo efficace per VPS singolo: exporter Python custom (sezione 2) come servizio systemd, Prometheus + Grafana in Docker Compose. Questo occupa circa 1.5-2GB RAM aggiuntivi. Aggiungi Loki + Promtail se vuoi ricerca full-text sui log. Aggiungi ElasticSearch solo se hai bisogno di analisi aggregate avanzate - su un VPS con 32GB RAM come CCX23, ElasticSearch richiede almeno 4GB heap, quindi è fattibile ma non banale.

Conclusione

Il monitoring di un LLM self-hosted non è opzionale: è la differenza tra avere un sistema che "funziona" e uno che funziona bene. Senza osservabilità, non sai se la GPU sta bruciando risorse inutilmente, se gli utenti stanno sperimentando latency inaccettabile, o se il costo effettivo per token sta erodendo il vantaggio economico rispetto alle API cloud.

Lo stack presentato - exporter Python per Ollama, metriche native vLLM, Grafana dashboard con pannelli GPU + latency + queue, alert rules calibrate, structured logging con structlog, cost calculator per token, e ElasticSearch per query history - copre tutti e tre i livelli di osservabilità: metriche aggregate (Prometheus/Grafana), log per richiesta (structlog/Loki), analisi storiche (ElasticSearch). Sul mio Hetzner CCX23 con Ollama + Llama 3.1 8B, questo stack mi ha permesso di identificare un'idle window di 6 ore notturne (e quindi valutare se ridimensionare il VPS) e di calcolare un costo effettivo di €0.00095/1k token, 10x sotto GPT-4o-mini.

Prossimo episodio: ora che sai misurare le performance, è il momento di spingerle al limite. In EP06 - Scaling self-hosted LLM: multi-GPU, multi-node, Ray cluster orchestration, vedremo come passare da un singolo nodo Ollama a un cluster Ray che gestisce automaticamente il load balancing tra GPU e nodi, con model replication e data locality per embedding store. Il salto di scala che ti permette di servire 10x più utenti con la stessa qualità di servizio.

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