Monitoring di LLM in produzione: osservabilità su qualità, costi e anomalie nelle pipeline AI
Ho aggiunto l'intero layer di osservabilità alla mia pipeline personale di automazione AI nel gennaio 2026, dopo tre piccoli incidenti che non sarei riuscito a diagnosticare senza dati: una chiamata notturna che ha bruciato 40 dollari di API in 20 minuti (retry loop su un errore fatale non classificato), un modello Claude che ha cominciato a rispondere male dopo una modifica a un system prompt (accuracy scesa dal 88% al 61% senza che io me ne accorgessi per tre giorni), un picco di latenza P99 che ha fatto scadere il timeout del client per il 8% delle sessioni in due ore. Ogni incidente era facilmente diagnosticabile dopo con i dati giusti; nessuno era rilevabile prima senza l'osservabilità. Lo stack che descrivo in questo articolo gira su un Hetzner CCX23 separato dal resto (4 vCPU AMD EPYC, 16 GB RAM, 80 GB NVMe, Debian 12) per mantenere l'osservabilità operativa anche quando il resto dello stack è in difficoltà - regola d'oro DevOps applicata all'AI realm. L'infrastruttura include Prometheus 2.55, Grafana 11.3, Loki 3.3 per log aggregation, Tempo 2.6 per tracing distribuito, un piccolo servizio Go che fa da exporter custom per le metriche AI, e un container Claude Haiku dedicato a fare LLM-as-judge sulle evaluation di qualità. Il costo mensile di questo setup è 13 euro di VPS Hetzner + circa 5 dollari di Haiku per l'evaluation continua - quasi niente rispetto al valore di sapere quando qualcosa si rompe mentre si rompe invece che nel follow-up del cliente sei ore dopo. Questo articolo è il tutorial walkthrough di come l'ho costruito, pezzo per pezzo, con codice e configurazioni reali.
Cosa devi davvero monitorare su un LLM in produzione?
La risposta breve è che su una pipeline AI classica ti servono cinque famiglie di metriche, non una. Primo: costo per chiamata e costo cumulato - in dollari e in token, per modello e per feature. Secondo: latenza - P50, P95, P99, separati per modello e per endpoint. Terzo: tasso di errore - errori transitori, errori fatali, classificazione per codice di risposta HTTP. Quarto: qualità semantica dell'output - misurata con un evaluator automatico, tipicamente LLM-as-judge. Quinto: drift comportamentale - quando la distribuzione delle risposte cambia rispetto a una baseline storica. Senza anche una di queste cinque, sei cieco su una classe di problemi.
Il monitoring che copre solo le prime tre è il monitoring classico - quello che Prometheus fa da sempre per applicazioni web. Aggiungere le ultime due (qualità semantica + drift) è la specificità AI che le pipeline tradizionali di osservabilità non affrontano. Il mio articolo precedente su budget AI per PMI con 8 strategie di ottimizzazione copre la parte di governance dei costi; qui ci concentriamo sull'aspetto qualità e anomalia, che è il vero valore aggiunto del monitoring AI rispetto al monitoring di una qualunque altra applicazione.
Se vuoi vedere come progetto pipeline AI dove il monitoring è parte integrante del design - non un'aggiunta post-incident - nel mio hub sull'automazione AI per aziende trovo articoli su governance dei costi, retry policy, cost tracking, tutti con filo conduttore di observability come prerequisito, non come nice to have.
Step 1: instrumentazione lato applicazione
Ogni chiamata LLM che la pipeline fa passa da un wrapper che traccia cinque cose in una tabella llm_calls su PostgreSQL: request_id, feature (stringa che categorizza l'operazione), model, input_tokens, output_tokens, cached_tokens, cost_usd, duration_ms, status_code, user_id se applicabile, timestamp. Il wrapper è una thin-layer sopra l'SDK Anthropic PHP nel mio caso, ma il pattern è identico per Python o Node.
<?php
declare(strict_types=1);
namespace App\Llm\Monitoring;
use Illuminate\Support\Facades\DB;
final class MonitoredAnthropicClient
{
public function call(string $feature, array $params, ?int $userId = null): array
{
$requestId = (string) \Ramsey\Uuid\Uuid::uuid7();
$startedAt = hrtime(true);
try {
$response = $this->anthropic->messages()->create($params);
$durationMs = (int) ((hrtime(true) - $startedAt) / 1e6);
$cost = $this->costCalculator->compute($params['model'], $response->usage);
DB::table('llm_calls')->insert([
'request_id' => $requestId,
'feature' => $feature,
'user_id' => $userId,
'model' => $params['model'],
'input_tokens' => $response->usage->inputTokens,
'output_tokens' => $response->usage->outputTokens,
'cached_tokens' => $response->usage->cacheReadInputTokens ?? 0,
'cost_usd' => $cost,
'duration_ms' => $durationMs,
'status_code' => 200,
'created_at' => now(),
]);
return $response;
} catch (\Throwable $e) {
$durationMs = (int) ((hrtime(true) - $startedAt) / 1e6);
DB::table('llm_calls')->insert([
'request_id' => $requestId,
'feature' => $feature,
'user_id' => $userId,
'model' => $params['model'] ?? 'unknown',
'duration_ms' => $durationMs,
'status_code' => $this->extractHttpStatus($e),
'error_class' => get_class($e),
'error_message' => substr($e->getMessage(), 0, 500),
'created_at' => now(),
]);
throw $e;
}
}
}Questa tabella è il source of truth di tutta l'osservabilità - ogni dashboard, ogni alert, ogni analisi post-incidente pesca da qui. Il request_id in UUID v7 ha timestamp incluso, permette correlazione con i log applicativi e con i trace distribuiti (OpenTelemetry se usato). Il campo feature è la chiave di aggregazione fondamentale: permette di chiedere "quanto è costata la feature X questo mese?" con un singolo SELECT SUM(cost_usd) WHERE feature = 'email-classify'.
Step 2: exporter Prometheus custom
Prometheus vuole metriche scraperate via endpoint HTTP in formato testuale. Scrivere un exporter custom che espone aggregazioni della tabella llm_calls permette di avere in Grafana le metriche AI accanto alle metriche DevOps classiche. Il mio exporter è in Go 1.23 - 180 righe totali, efficiente anche su 50k+ righe di ledger.
package main
import (
"database/sql"
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
_ "github.com/jackc/pgx/v5/stdlib"
)
var (
llmCallsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "llm_calls_total", Help: "Total LLM API calls"},
[]string{"feature", "model", "status"},
)
llmCostUsdTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "llm_cost_usd_total", Help: "Total cost in USD"},
[]string{"feature", "model"},
)
llmDurationSeconds = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "llm_duration_seconds", Help: "Call duration",
Buckets: []float64{0.1, 0.5, 1, 2, 5, 10, 20, 40, 80},
},
[]string{"feature", "model"},
)
)
func main() {
db, _ := sql.Open("pgx", os.Getenv("DATABASE_URL"))
prometheus.MustRegister(llmCallsTotal, llmCostUsdTotal, llmDurationSeconds)
go pollMetrics(db)
http.Handle("/metrics", promhttp.Handler())
http.ListenAndServe(":9200", nil)
}
func pollMetrics(db *sql.DB) {
for {
time.Sleep(15 * time.Second)
rows, _ := db.Query(`
SELECT feature, model, status_code, count(*), sum(cost_usd), avg(duration_ms/1000.0)
FROM llm_calls
WHERE created_at > now() - interval '30 seconds'
GROUP BY feature, model, status_code`)
for rows.Next() {
var feature, model string
var statusCode int
var count, cost, avgDuration float64
rows.Scan(&feature, &model, &statusCode, &count, &cost, &avgDuration)
status := "ok"
if statusCode >= 400 {
status = "error"
}
llmCallsTotal.WithLabelValues(feature, model, status).Add(count)
llmCostUsdTotal.WithLabelValues(feature, model).Add(cost)
llmDurationSeconds.WithLabelValues(feature, model).Observe(avgDuration)
}
rows.Close()
}
}Il polling ogni 15 secondi con finestra 30 secondi è un compromesso: sotto si perde granularità, sopra si aggiunge rumore. Prometheus scraperà :9200/metrics ogni 15 secondi nella config, e le metriche saranno disponibili in Grafana con 30-45 secondi di latenza rispetto all'evento reale - accettabile per tutti i casi tranne alert hard real-time.
Step 3: dashboard Grafana
Grafana dashboard che uso ha otto pannelli distribuiti in tre righe. Riga uno: cost overview - totale oggi, totale mese, top 5 feature per cost. Riga due: performance - P50/P95/P99 latency per feature, error rate per feature, throughput per feature. Riga tre: anomaly - drift di output length (media token output per feature nel tempo), drift di cost per call (anomalie rispetto alla media storica), tasso di fallback attivati.
Le query PromQL chiave sono tre, e le riporto perché sono quelle che un developer tipicamente deve scrivere e sbaglia la prima volta:
# Cost mese corrente per feature
sum by (feature) (
increase(llm_cost_usd_total[30d])
)
# Latency P95 per feature, ultimi 15 min
histogram_quantile(0.95,
sum by (feature, le) (rate(llm_duration_seconds_bucket[15m]))
)
# Error rate per feature (ultimo 5 min)
sum by (feature) (rate(llm_calls_total{status="error"}[5m]))
/
sum by (feature) (rate(llm_calls_total[5m]))Il dashboard Grafana espone tutto via URL pubblica interna - i developer possono guardarlo, i manager possono guardarlo. Trasparenza sul costo AI è una delle leve di adoption più potenti che ho visto nel 2026: quando chi usa il sistema vede in tempo reale cosa costa, le abitudini cambiano verso pattern più economici senza bisogno di policy forzate.
Step 4: evaluation della qualità con LLM-as-judge
Questo è il pezzo più caratteristicamente AI del monitoring. Le chiamate LLM tracciate fin qui hanno metriche di sistema (latenza, costo, errore), ma non di qualità semantica dell'output. Un modello che risponde in 500 ms con 0% di errori HTTP può comunque restituire risposte sbagliate per drift del prompt, cambio di versione del modello o degradazione del fine-tuning. Il LLM-as-judge è un secondo LLM che valuta in modo automatico un campione delle risposte del primo.
Nel mio setup, un worker periodico estrae il 2% delle chiamate del giorno in campionamento random, e per ciascuna chiede a Claude Haiku di valutare la qualità della risposta rispetto alla domanda con quattro criteri: factual correctness, completeness, relevance, style appropriateness. Il prompt del judge è strutturato e chiede un punteggio da 1 a 5 per ogni criterio più un breve reasoning.
JUDGE_PROMPT = """Valuta la risposta seguente rispetto alla domanda fornita.
Usa quattro criteri, ognuno con punteggio 1-5:
- correctness: la risposta è fattualmente corretta?
- completeness: la risposta copre tutta la domanda?
- relevance: la risposta resta in tema?
- style: il tono è appropriato per il contesto?
Emetti JSON strutturato:
{
"correctness": <1-5>,
"completeness": <1-5>,
"relevance": <1-5>,
"style": <1-5>,
"reasoning": "<50-150 parole>"
}
DOMANDA: {question}
RISPOSTA: {answer}
"""
def judge_sample(question: str, answer: str) -> dict:
response = haiku_client.messages.create(
model="claude-haiku-4-5",
max_tokens=500,
messages=[{"role": "user", "content": JUDGE_PROMPT.format(question=question, answer=answer)}],
)
return parse_judge_verdict(response)I punteggi medi giornalieri per feature vanno in una tabella llm_quality_scores e da lì in Grafana come serie temporali. Quando il punteggio medio di una feature scende sotto una soglia (tipicamente 3,5 su 5) per più di 24 ore, scatta un alert. Questo è il meccanismo che mi ha fatto scoprire il drift del system prompt dell'incidente 2: la risposta rimaneva formalmente corretta ma il judge notava che era meno complete rispetto al baseline storico.
Il costo del judge sul 2% del volume è trascurabile: 0,001 dollari per valutazione, 2% di 1.000 chiamate al giorno = 20 valutazioni = 0,02 dollari al giorno, circa 70 centesimi al mese. Per il valore che restituisce in rilevazione precoce di drift, è uno dei migliori investimenti del mio stack.
Step 5: alerting su anomalie e kill switch automatico
Gli alert che uso sono quattro, con severity graduata come ho descritto in precedenti articoli della serie. Budget warning: il costo giornaliero di una feature supera del 50% la media dei 7 giorni precedenti - Slack al canale #llm-observability, non urgente. Quality drop: il punteggio medio del judge scende sotto 3,5 per una feature - Slack con @here, da guardare oggi. Error spike: tasso di errore supera il 5% per 10 minuti consecutivi - Slack con @here, urgente. Cost explosion: il costo orario supera 3x la media oraria - PagerDuty, notte inclusa.
Il PagerDuty sul cost explosion è il kill switch ultimo. Quando scatta, un webhook automatico sposta l'applicazione in modalità degraded - tutte le chiamate LLM non-critical sono sospese, solo le critical passano. Questa logica è la stessa del circuit breaker che ho descritto per la containerizzazione LLM self-hosted: meglio un servizio degradato per 15 minuti mentre l'operatore indaga che una bolletta API triplicata a fine mese.
Step 6: log aggregation con Loki per contesto
Prometheus fa le metriche, Loki fa i log. Quando un alert scatta, devo potere navigare dal pannello Grafana al log applicativo della singola chiamata problematica. Il wrapper PHP sopra scrive ogni chiamata anche a syslog con request_id nel trace id del log. Promtail raccoglie il syslog e lo spedisce a Loki. Un data link in Grafana permette di cliccare su una chiamata anomala nel pannello e aprire il log Loki filtrato per quel request_id.
Questa correlazione metric-log-trace è il observability three pillars classico applicato al contesto AI. La specificità è che il log include il prompt esatto e la risposta esatta, non solo lo stato HTTP - e nel debugging post-incidente questo è ciò che conta davvero. "Perché Claude ha risposto male?" è una domanda a cui puoi rispondere solo se hai salvato quale prompt hai mandato e quale risposta ha dato. Senza, sei alla mercé delle hypothesis.
Step 7: drift detection sulla distribuzione delle risposte
L'ultimo pezzo è statistical drift detection. Ogni settimana, un job calcola la distribuzione di alcune metriche derivate sulle risposte (lunghezza media output, tempo medio al primo token, entropia delle categorie per feature di classificazione) e le confronta con la settimana precedente e con il mese precedente. Se la distribuzione si sposta significativamente (test statistico Kolmogorov-Smirnov con p-value sotto 0,01), scatta un alert "drift detected".
Lo scopo è rilevare cambiamenti silenziosi: un nuovo modello Anthropic deployato che genera output leggermente più lunghi; una modifica al prompt di retrieval che fa variare le categorie; un cambiamento nella distribuzione degli input utenti (nuovo use pattern). Senza drift detection, questi cambi sono invisibili per settimane, e quando emergono è tardi.
Quando questo stack è sproporzionato
Se hai una pipeline con meno di 100 chiamate LLM al giorno, tutto questo stack è over-engineering - un log a file e un grep settimanale sono sufficienti. Se il tuo setup usa solo un vendor API cloud (es. solo Anthropic) e nessun self-hosting, le dashboard native di Anthropic Console danno una fetta di queste informazioni senza costo. Se il tuo team non ha competenze DevOps operative, mantenere Prometheus+Grafana+Loki+Tempo aggiunge un maintenance burden che può superare il valore delle metriche - valuta Datadog o altro servizio managed.
Lo stack Prometheus+Grafana+Loki+LLM-as-judge+drift detection si giustifica quando hai contemporaneamente: pipeline con 1.000+ chiamate giornaliere, più di uno use case attivo (dove cost per feature conta), deploy su infrastruttura propria (dove Datadog costerebbe più di quanto risparmia), team con DevOps background (dove l'operational cost è basso), requisito di SLA interno che richiede rilevare incident in minuti invece di giorni. In quel punto, l'investimento si ripaga rapidamente.
La differenza fra un team che sa "funziona" e uno che sa "funziona e costa X e risponde in Y ms e la qualità è Z" non è di strumenti - entrambi possono avere accesso a Prometheus. È di disciplina: chi misura prima di deployare, chi ha fatto evaluation per stabilire un baseline, chi controlla il dashboard la mattina prima del commit. Il monitoring AI nel 2026 smette di essere best practice opzionale e diventa prerequisito operativo per qualsiasi pipeline che tocca dati reali o utenti reali. Le PMI italiane che portano in produzione modelli senza visibilità strutturata sulla qualità dei propri output sono esattamente quelle che, quando un incidente di hallucination colpisce un cliente, non hanno né dati per capire cosa è successo né strumenti per prevenirlo. Non è una questione di tecnica avanzata: è la stessa disciplina che ha portato il DevOps dagli anni 2010 a essere norma industriale - il monitoring AI è il prossimo passo logico della stessa evoluzione.
Se stai portando (o hai già portato) un LLM in produzione nel tuo business e vuoi capire se la tua osservabilità è sufficiente o se stai accumulando debito tecnico invisibile, il modulo di preventivo gratuito ti dà una prima lettura in 7 domande, 2 minuti. Ti dico se il tuo progetto rientra nelle cose che so fare bene e, se il caso richiede un profilo diverso, te lo dico e ti indico una direzione utile quando posso.