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