Salta al contenuto principale

LLM observability crisis: silent failures, perché il logging non basta

LLM observability crisis: silent failures, perché il logging non basta: LLM observability crisis: silent failures, perché il logging non basta Lunedì mattina.

Cosa ne pensi di questo articolo?

LLM observability crisis: silent failures, perché il logging non basta
AI ObservabilityFoto:Tima MiroshnichenkoPexels

In Breve

LLM observability crisis: silent failures, perché il logging non basta: LLM observability crisis: silent failures, perché il logging non basta Lunedì mattina.

LLM observability crisis: silent failures, perché il logging non basta

Lunedì mattina. Il prompt template che venerdì in staging dava 85% di success rate, in produzione misura 60%. Nessuno ha toccato il prompt, nessuno ha cambiato modello, nessuno ha modificato il retrieval. Il bill mensile di OpenAI è raddoppiato. Tre utenti enterprise lamentano risposte allucinate. Apri i log applicativi e trovi righe come INFO: agent_call completed in 2103ms: zero indicazioni utili. Benvenuto nella crisi dell'osservabilità LLM.

Il problema non sono i log: i log fanno il loro lavoro, registrano eventi. Il problema è che un sistema basato su LLM moderno non è più un'API request-response stateless con un body JSON deterministico. È una catena multi-componente non deterministica: 10-50 chiamate a modelli, retrieval, tool, parser, guardrail, ognuna con costo variabile, latenza variabile e qualità variabile. Senza tracing strutturato non hai idea di dove brucia il budget, dove latenza esplode, dove l'allucinazione nasce. Hai solo aggregazioni anonime di "successo" o "fallimento" che mentono per costruzione.

In questa serie su LLM Observability Production analizziamo perché il modello logging tradizionale è inadatto, cosa significa instrumentare correttamente un agente, quali sono le tre categorie di tool moderni (Langfuse, Phoenix, Helicone) e come strutturi cost tracking, alerting e debug trace analysis per evitare di scoprire un incident dalla fattura del mese successivo. Iniziamo con il manifesto: perché serve tracing dedicato, e perché il tuo stack APM tradizionale non basta.

Cosa Imparerai

  • Riconoscere i pattern di silent failure tipici dei sistemi LLM in produzione
  • Capire perché logging applicativo e APM tradizionale (Datadog, New Relic) sono insufficienti per LLM
  • Progettare una span hierarchy semantica per workflow multi-componente con agent + tool + retrieval
  • Calcolare il costo effettivo per richiesta sommando token input/output di ogni step
  • Definire metriche di latenza percentile (p50, p95, p99) per ogni componente, non solo end-to-end
  • Distinguere le tre categorie di osservabilità LLM: request tracing, profiling, metrics aggregation
  • Posizionare Langfuse, Phoenix e Helicone nel landscape e capire quando ciascuno serve
  • Configurare alert rule che catturano cost anomaly, latency spike e quality degradation

Prerequisiti

  • Esperienza con almeno un framework LLM (LangChain, LlamaIndex, Haystack, raw OpenAI/Ollama SDK)
  • Familiarità con Python o TypeScript per gli esempi di instrumentation
  • Concetti base di osservabilità: log strutturati, metriche, distributed tracing
  • Conoscenza superficiale di OpenTelemetry (specs, span, trace context)
  • Comprensione del costo per token nei modelli commerciali e nelle alternative self-hosted

Sezione 1 - Anatomia di un silent failure: il caso del prompt che si rompe da solo

Iniziamo con l'esempio canonico. Hai un agente RAG che risponde a domande tecniche su prodotti. L'architettura sembra banale: prendi la query, cerchi 5 documenti via vector search, costruisci un prompt e chiami il modello. Il prompt template è versionato, il codice non cambia da settimane. Eppure misuri due metriche di prodotto: "user thumbs up" cala dal 78% al 52% nell'arco di 14 giorni, e "agente non sa rispondere" sale dal 6% al 19%. Nessun deploy, nessun incidente infrastrutturale, nessun alert.

Cosa è successo? Possibilità non mutuamente esclusive: (1) il provider ha fatto rolling update del modello sotto al tuo alias gpt-4o-2024-08-06 con micro-degradazioni di stile, (2) il vector store ha indicizzato 30K documenti nuovi più rumorosi che ranking-up e abbassano la precision del retrieval, (3) gli utenti hanno iniziato a fare query più ambigue perché il marketing ha cambiato landing page, (4) la temperatura del modello viene ereditata da una env var sovrascritta in un terzo deploy che non c'entrava niente. Senza tracing semantico, devi indovinare. Con tracing semantico, vedi quale componente ha cambiato comportamento misurabile.

Il logging applicativo classico ti dice "ho fatto una richiesta, è andata bene". Non ti dice quanti token ha consumato, quale documento è stato recuperato, quanto è cambiata la distribuzione di lunghezza dei prompt input nel tempo. Per un sistema deterministico bastano i log. Per un sistema probabilistico devi misurare la distribuzione, non solo l'evento.

# Esempio di logging "tradizionale" tipico in un agente RAG
# Vedrai questo pattern in qualsiasi codebase Python che non ha ancora abbracciato il tracing
import logging
import time
from typing import Any

logger = logging.getLogger(__name__)


def rispondi_a_query(query: str, user_id: str) -> str:
    """Funzione orchestratrice di un agente RAG. Logging puro, nessun tracing."""
    inizio = time.time()
    logger.info("Inizio elaborazione", extra={"user_id": user_id, "query_len": len(query)})

    # Step 1: retrieval - completamente opaco al log
    documenti = vector_store.search(query, top_k=5)
    logger.info(f"Recuperati {len(documenti)} documenti")

    # Step 2: costruzione prompt - opaco
    prompt = costruisci_prompt(query, documenti)
    logger.info(f"Prompt lungo {len(prompt)} caratteri")

    # Step 3: chiamata LLM - solo durata, niente costo, niente token
    risposta = llm_client.complete(prompt)
    durata = time.time() - inizio

    logger.info(f"Risposta generata in {durata:.2f}s", extra={"user_id": user_id})
    return risposta

Cosa manca in questo codice? Tutto quello che conta. Non sai quanti token ha consumato la chiamata, non sai quale modello specifico ha risposto, non sai se il vector search ha restituito documenti rilevanti o spazzatura, non sai correlare la query lenta con l'utente che si è lamentato in chat support. Soprattutto: i log sono flat, mentre il workflow è gerarchico. Ricostruire la catena causale guardando 12 righe di log INFO è un esercizio di archeologia digitale.

Lo stesso workflow strumentato con OpenTelemetry diventa un trace gerarchico in cui ogni step è uno span con attributi semantici, durata, esito e relazioni parent-child. La differenza non è cosmetica: è la differenza tra leggere e ricostruire.

# Lo stesso agente, instrumentato con OpenTelemetry e convenzioni GenAI
# Ogni step diventa uno span semantico, navigabile come un albero
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode

tracer = trace.get_tracer("portfolio.agent.rag", "1.0.0")


def rispondi_a_query(query: str, user_id: str) -> str:
    """Workflow tracciato: ogni step è uno span figlio del trace root."""
    with tracer.start_as_current_span("agent.rag.respond") as span_root:
        # Attributi semantici sul root: dimensione user, dimensione query
        span_root.set_attribute("user.id", user_id)
        span_root.set_attribute("gen_ai.request.query.length", len(query))
        span_root.set_attribute("gen_ai.system", "rag.v2")

        # Span figlio 1: retrieval, isolato e misurabile
        with tracer.start_as_current_span("retrieval.vector_search") as span_ret:
            documenti = vector_store.search(query, top_k=5)
            span_ret.set_attribute("retrieval.top_k", 5)
            span_ret.set_attribute("retrieval.docs_returned", len(documenti))
            # Punteggio di similarita medio: indicatore di qualita retrieval
            score_medio = sum(d.score for d in documenti) / max(len(documenti), 1)
            span_ret.set_attribute("retrieval.score.mean", score_medio)

        # Span figlio 2: costruzione prompt, deterministica ma utile per dimensionare token
        with tracer.start_as_current_span("prompt.build") as span_p:
            prompt = costruisci_prompt(query, documenti)
            span_p.set_attribute("prompt.length_chars", len(prompt))

        # Span figlio 3: la chiamata LLM, con tutta la metadata che conta davvero
        with tracer.start_as_current_span("gen_ai.completion") as span_llm:
            span_llm.set_attribute("gen_ai.system", "ollama")
            span_llm.set_attribute("gen_ai.request.model", "llama3.1:8b")
            span_llm.set_attribute("gen_ai.request.temperature", 0.2)
            risposta = llm_client.complete(prompt)
            # Token usage: il dato chiave per il cost tracking
            span_llm.set_attribute("gen_ai.usage.input_tokens", risposta.usage.input)
            span_llm.set_attribute("gen_ai.usage.output_tokens", risposta.usage.output)
            span_llm.set_status(Status(StatusCode.OK))

        return risposta.text

Questo secondo snippet non è solo "log più dettagliati". È un cambio di paradigma. Ogni span ha un identificatore univoco, un parent, una durata, attributi tipizzati. Quando esporti il trace verso un backend (Langfuse, Phoenix, Tempo, Jaeger) ottieni una vista ad albero in cui clicchi sul retrieval e vedi quale documento è venuto fuori, clicchi sulla completion e vedi quanti token. Il logging "ti racconta", il tracing "ti mostra".

Il secondo aspetto sottovalutato è la correlazione automatica. Quando un utente con browser X chiama l'endpoint A, che internamente chiama il microservizio B, che chiama il modello LLM C, tutto il workflow eredita lo stesso trace_id via context propagation. Il trace_id passa per HTTP header W3C standard (traceparent), per messaggi di coda, per chiamate gRPC. Se domani il customer support ti dice "l'utente Mario alle 14:32 ha avuto problemi", filtri per app.user.id=mario in Langfuse e in 5 secondi hai la lista completa dei suoi trace, ognuno espandibile in albero. Provando a fare la stessa cosa coi log applicativi devi grep su 5 servizi diversi sperando che l'engineer del servizio B abbia ricordato di propagare il request_id. Spoiler: non sempre lo fa.

Sezione 2 - Logging vs tracing: matrice di capacità

Capisco la tentazione di dire "ho già log strutturati con JSON, mi basta". Non basta. Il logging è ottimo per eventi puntuali (warning, error, audit). Il tracing è progettato per workflow correlati. Le due cose sono complementari: il tracing non sostituisce i log, ma copre dimensioni che il logging non può coprire senza diventare un mostro non navigabile. Vediamo esattamente le differenze in una tabella di confronto.

CapacitàLogging strutturatoDistributed tracing
GranularitàEvento singolo (timestamp + payload)Workflow gerarchico (root + n span figli)
Correlazione richiesteManuale via correlation_idAutomatica via trace_id + span_id
Visualizzazione causaleLista cronologica piattaAlbero parent-child + waterfall timing
Token usage per stepDifficile, devi ricostruireAttributo nativo gen_ai.usage.*
Cost calculationAggregazione offline complessaSum su attributi span del trace
Latency percentile per componenteSolo end-to-endPer ogni span, isolato
Debug failure cascadeLinea per lineaVista albero, identifica span fallito + ramo
Sampling intelligentePer livello (DEBUG/INFO/WARN)Per trace, basato su priorità o errore
Storage cost a scalaBasso se aggreghi, alto se verboseMedio (tail sampling riduce volume)
Esperienza ingegneriFamiliare a tuttiCurva apprendimento iniziale ma standard

Nota la riga "cost calculation": questa è la più sottovalutata. Per calcolare quanto ti costa una singola richiesta utente devi sommare i token di ogni LLM call lungo il workflow. Con il logging tradizionale dovresti loggare token usage in ogni callsite, poi parsare i log offline per ricostruire la sequenza. Con il tracing, il backend ti dà la somma per trace nativa. È la differenza tra un report generato e una metrica vivente.

Sezione 3 - La gerarchia degli span: come pensare la struttura del trace

Una volta accettato che servono span, la domanda diventa: come li organizzi? La regola pratica è una sola: uno span per unità semantica. Una unità semantica è un'operazione che vale la pena misurare in isolamento, con un nome che ha senso per chi farà il debug fra sei mesi. "Funzione foo()" non è un'unità semantica, "retrieval da vector store" sì. "Loop iterazione 7" non è un'unità semantica, "agent step 7: chiamata tool calculator" sì.

La struttura tipica per un agente moderno ha tre livelli: il root span rappresenta la richiesta utente, gli step span rappresentano le iterazioni dell'agente o i passi della pipeline, gli operation span rappresentano le singole chiamate (LLM, tool, retrieval). Ogni livello aggiunge contesto senza nascondere il dettaglio del livello sotto.

# Struttura span hierarchy per agente con loop multi-step
# Convenzione di naming: dominio.componente.azione
# Esempio: agent.research.run -> agent.research.step -> gen_ai.completion
from contextlib import contextmanager
from opentelemetry import trace

tracer = trace.get_tracer("portfolio.agent.research")


@contextmanager
def span_step(numero_step: int, intento: str):
    """Context manager per uno step di agente con metadata standard."""
    with tracer.start_as_current_span(f"agent.research.step") as s:
        s.set_attribute("agent.step.index", numero_step)
        s.set_attribute("agent.step.intent", intento)
        yield s


def esegui_agente_research(domanda: str) -> str:
    with tracer.start_as_current_span("agent.research.run") as root:
        root.set_attribute("agent.task.type", "research")
        root.set_attribute("agent.task.input.length", len(domanda))

        contesto_corrente = domanda
        for step_idx in range(MAX_STEPS):
            # Step span: una iterazione del loop di reasoning
            with span_step(step_idx, intento="plan_or_act") as step:
                # Operation span: la singola chiamata LLM dello step
                with tracer.start_as_current_span("gen_ai.completion.plan") as plan:
                    plan.set_attribute("gen_ai.request.model", "llama3.1:8b")
                    decisione = llm_plan(contesto_corrente)
                    plan.set_attribute("gen_ai.usage.input_tokens", decisione.in_tok)
                    plan.set_attribute("gen_ai.usage.output_tokens", decisione.out_tok)

                if decisione.usa_tool:
                    # Operation span: invocazione tool, non LLM ma comunque misurato
                    with tracer.start_as_current_span("tool.invoke") as tool:
                        tool.set_attribute("tool.name", decisione.tool_name)
                        tool.set_attribute("tool.input", decisione.tool_input[:500])
                        risultato_tool = invoca_tool(decisione)
                        tool.set_attribute("tool.output.length", len(risultato_tool))
                        contesto_corrente += f"\n[TOOL]: {risultato_tool}"

                if decisione.is_finale:
                    step.set_attribute("agent.step.terminal", True)
                    return decisione.risposta_finale

        return "Limite step raggiunto"

Notare due dettagli importanti. Primo: usiamo nomi di span stabili (agent.research.step) anziché variabili (agent.research.step.7), e mettiamo il numero di step come attributo. Questo permette al backend di aggregare statistiche per "tipo di step" indipendentemente da quante iterazioni avvengono. Secondo: tracciamo anche le invocazioni dei tool, non solo le chiamate LLM. Un tool che fa una query SQL lenta o un HTTP call a un servizio esterno è altrettanto critico per la latenza end-to-end del trace.

La cardinalità degli attributi è il vero rischio nascosto. Mettere tool.input intero in un attributo è seducente per il debug, ma può esplodere lo storage del backend e violare requisiti di privacy. La regola che applico nel ml-service del portfolio è: troncare a 500-1000 caratteri per il debug, fare hash o token-count per le metriche aggregate, evitare contenuti utente potenzialmente sensibili negli attributi se non con consenso esplicito.

Un'altra dimensione critica della span hierarchy è il sampling. A scala di milioni di richieste al giorno conservare ogni singolo trace è economicamente insostenibile e operativamente inutile. Il pattern raccomandato è il tail-based sampling: il collector OpenTelemetry valuta i trace completi e applica policy che conservano sempre i trace con errore, sempre i trace sopra soglia di latenza, sempre i trace sopra soglia di costo, e una percentuale random (1-5%) del traffico nominale. Questo riduce lo storage di 95-99% mantenendo intatta la capacità di indagine post-mortem sui casi anomali. Per il portfolio il volume è ancora gestibile con head sampling al 100%, ma la mental model va impostata fin da subito.

Sezione 4 - Le tre categorie di tooling moderno: tracing, profiling, metrics

Il landscape dell'osservabilità LLM si è stratificato in tre categorie distinte di tool, ognuna con un focus specifico. Confonderle porta a comprare il prodotto sbagliato per il problema sbagliato. Distinguerle ti permette di costruire uno stack coerente.

La prima categoria è request tracing: backend persistenti che ricevono span via SDK o OTLP, li indicizzano e li mostrano come trace navigabili. Esempi: Langfuse, LangSmith, Phoenix in modalità persistente. Questa è la base operativa per chiunque abbia LLM in produzione: senza request tracing non puoi rispondere alla domanda "perché questa specifica richiesta è andata male". La seconda categoria è profiling ed experimentation: ambienti notebook-first o dashboard di esperimento per esplorare il comportamento del modello su dataset di test. Esempio principale: Phoenix di Arize in modalità Jupyter. La terza categoria è metrics aggregation e cost analytics: tool che calcolano metriche aggregate (cost per user, p99 per endpoint, error rate), tipicamente integrati con Prometheus, Grafana, o backend FinOps come Vantage. Esempi: Helicone, Langfuse dashboard, custom Grafana su OpenTelemetry metrics.

CategoriaTool rappresentativiUse case primarioQuando ti serve
Request tracing persistente Langfuse, LangSmith, Phoenix (server mode), Tempo Visualizzare singola richiesta utente come trace gerarchico, debug ex-post Sempre, dal giorno 1 in produzione
Profiling / experimentation Phoenix (notebook), Weights and Biases Prompts, OpenAI Evals Confrontare varianti di prompt o modello su dataset di test, A/B locale Fase di iterazione prompt e di valutazione modelli candidati
Metrics aggregation / FinOps Helicone, Langfuse analytics, Grafana + Prometheus, Vantage Cost per user, latency percentile, anomaly detection, alert Quando spendi piu di qualche centinaio di euro al mese o hai SLA

I tre layer non sono alternativi: si combinano. Tipicamente Langfuse o Phoenix copre tracing+analytics base, Phoenix notebook copre experimentation, Prometheus+Grafana copre alerting custom su metriche derivate dai trace. Helicone è elegante quando lavori prevalentemente con API provider commerciali ma diventa meno utile se, come nel ml-service del portfolio, usi Ollama on-prem dove non c'è una API esterna da proxare.

Sezione 5 - Langfuse SDK in pratica: instrumentation Python con callback

Langfuse è il candidato più popolare nella categoria request tracing per il mondo open source: ha SDK Python e TypeScript stabili, integra direttamente con LangChain, LlamaIndex e OpenAI client, supporta self-hosting via Docker Compose con Postgres come backend, e in versione 3.x usa ClickHouse per scala enterprise. Il pattern di base è semplice: importi il decoratore @observe() sulle funzioni che vuoi tracciare e l'SDK costruisce automaticamente la gerarchia di span basata sullo stack di chiamata.

# Instrumentation Langfuse: decoratore observe + integrazione con OpenAI client
# Il decoratore costruisce la gerarchia automaticamente
from langfuse.decorators import observe, langfuse_context
from langfuse.openai import openai  # Wrapper drop-in dell'SDK OpenAI


@observe(name="agent.rag.respond")
def rispondi_a_query(query: str, user_id: str) -> str:
    """Span root: tutto cio che chiamiamo dentro diventa figlio."""
    # Aggiungi metadata sul trace root: utile per filtrare in dashboard
    langfuse_context.update_current_trace(
        user_id=user_id,
        tags=["rag", "produzione"],
        metadata={"app_version": "2.4.1"},
    )

    documenti = retrieva_documenti(query)
    risposta = genera_risposta(query, documenti)
    return risposta


@observe(name="retrieval.vector_search")
def retrieva_documenti(query: str) -> list:
    """Span figlio: nominato esplicitamente per coerenza in dashboard."""
    risultati = vector_store.search(query, top_k=5)
    # Aggiungi output strutturato all'observation: visibile nella UI
    langfuse_context.update_current_observation(
        output={"docs_returned": len(risultati), "top_score": risultati[0].score},
        metadata={"index": "kb_prod_v3"},
    )
    return risultati


@observe(as_type="generation", name="gen_ai.completion")
def genera_risposta(query: str, documenti: list) -> str:
    """as_type=generation: Langfuse calcola costo automaticamente da modello + token."""
    prompt = costruisci_prompt(query, documenti)
    # Il wrapper langfuse.openai cattura automaticamente token usage e modello
    risposta = openai.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.2,
    )
    # Le info su modello, input_tokens, output_tokens, cost finiscono nel trace
    return risposta.choices[0].message.content

Tre cose da notare. Primo: il wrapper langfuse.openai sostituisce l'import standard e cattura automaticamente token usage senza che tu debba scriverlo. Secondo: as_type="generation" è il segnale che dice a Langfuse di calcolare costo e contare token come "operation di generazione" (vs un retrieval o un tool call). Terzo: update_current_observation ti permette di arricchire lo span con output strutturato che appare nella UI: usalo per documenti recuperati, decisioni dell'agente, output di tool, qualsiasi cosa serva al debug post-mortem.

Per Ollama o qualsiasi provider non-OpenAI il pattern cambia leggermente: usi langfuse.generation() esplicito per registrare model, input, output, usage manualmente. Nel ml-service del portfolio uso questo pattern perché Ollama non ha cost tracking nativo (zero token cost a livello di provider) e l'SDK deve sapere che il "costo" da tracciare è la GPU utilization e la latency, non gli euro.

Sezione 6 - Schema metadata strutturati: cosa mettere su ogni span

Il valore di un trace cresce esponenzialmente con la qualità degli attributi. Un trace con span privi di metadata è quasi inutile: vedi che è successo qualcosa, non sai cosa né perché. Definire uno schema standard di attributi per la tua applicazione è uno degli investimenti con ROI più alto in tutto il workflow di osservabilità. OpenTelemetry sta standardizzando le convenzioni semantiche per GenAI sotto il namespace gen_ai.*, ed è la base che ti consiglio di adottare oggi anche se le specs sono ancora work-in-progress nel 2025-2026.

// Schema TypeScript per metadata standard span LLM
// Adottabile in client SDK Node/Bun che chiamano provider LLM o orchestratori
// Riferimento: OpenTelemetry GenAI semantic conventions (work in progress)

export interface SpanLlmAttributes {
  // Identificazione richiesta
  "gen_ai.system": "openai" | "ollama" | "anthropic" | "custom";
  "gen_ai.request.model": string;             // es. "llama3.1:8b"
  "gen_ai.request.temperature"?: number;
  "gen_ai.request.top_p"?: number;
  "gen_ai.request.max_tokens"?: number;

  // Token usage: il dato per il cost tracking
  "gen_ai.usage.input_tokens"?: number;
  "gen_ai.usage.output_tokens"?: number;
  "gen_ai.usage.total_tokens"?: number;

  // Output del modello: utile per debug, attenzione a privacy
  "gen_ai.response.model"?: string;           // versione effettiva restituita
  "gen_ai.response.finish_reason"?: "stop" | "length" | "tool_calls" | "content_filter";

  // Identificazione applicativa: per filtraggio multi-tenant
  "app.user.id"?: string;
  "app.tenant.id"?: string;
  "app.feature.flag"?: string;
  "app.prompt.template_id"?: string;          // versione del prompt template usato
  "app.prompt.template_version"?: string;
}

export interface SpanRetrievalAttributes {
  "retrieval.system": "rediseach" | "pgvector" | "qdrant" | "elasticsearch";
  "retrieval.top_k": number;
  "retrieval.docs_returned": number;
  "retrieval.score.min"?: number;
  "retrieval.score.max"?: number;
  "retrieval.score.mean"?: number;
  "retrieval.query.length": number;           // lunghezza query in caratteri
  "retrieval.index.name": string;             // identifica l'indice corrente
  "retrieval.index.version"?: string;         // utile per A/B su indicizzazione
}

export interface SpanToolAttributes {
  "tool.name": string;
  "tool.version"?: string;
  "tool.input.length": number;
  "tool.output.length"?: number;
  "tool.error"?: string;                      // messaggio errore se fallito
  "tool.cached": boolean;                     // hit cache vs cold call
}

Definire questi schemi come tipi (TypeScript, Pydantic, dataclass) ha tre benefici. Primo: garantisce coerenza tra team. Tre engineer che instrumentano tre componenti diversi dell'applicazione mettono attributi con gli stessi nomi e gli stessi tipi. Secondo: abilita strumenti automatici di analisi. Una query in Langfuse o Phoenix che cerca "tutti i trace con gen_ai.usage.input_tokens > 8000" funziona solo se il nome dell'attributo è uniforme. Terzo: ti permette di evolvere lo schema in modo controllato, deprecando attributi vecchi senza rompere dashboard esistenti.

La domanda inevitabile è: dove tracci il limite tra "metti tutto" e "metti il minimo"? La regola operativa che funziona è la three layer attribute model. Layer 1, sempre presenti: identificatori (model, system, user, tenant), token counts, durata, status. Layer 2, presenti su sample: input/output troncati a 1KB, decisioni dell'agente, choice rationale. Layer 3, presenti solo in modalità debug-on-demand: full prompt, full output, intermediate reasoning. Il layer 3 si attiva via feature flag su singoli utenti o su trace ID specifici quando il customer support apre un ticket. Mantenere questi tre layer separati ti permette di scalare in modo lineare il costo dello storage senza sacrificare la profondità del debug nei casi che lo richiedono.

Sezione 7 - Cost tracking: dalla formula al cost per request

Il cost tracking è la metrica che convince il CFO ad approvare il budget di osservabilità. La formula base è semplicissima: per ogni LLM call sommi token di input moltiplicati per il prezzo di input e token di output moltiplicati per il prezzo di output. Il diavolo sta nei dettagli: serve mantenere un dizionario aggiornato dei prezzi per modello, gestire i casi di prompt caching (Anthropic, OpenAI 2024-2025), considerare l'eventuale costo di embedding per il retrieval, e per i modelli self-hosted come Ollama tradurre il "costo zero token" in costo effettivo di GPU utilization e ammortamento hardware.

# Calcolo cost per LLM call: lookup prezzo + somma input/output
# Prezzi indicativi 2025-2026, da aggiornare leggendo le pagine pricing ufficiali
# OpenAI: openai.com/pricing - Anthropic: anthropic.com/pricing

PREZZI_PER_MODELLO = {
    # Prezzi in dollari per 1M token (input, output)
    "gpt-4o":              {"input": 2.50, "output": 10.00},
    "gpt-4o-mini":         {"input": 0.15, "output": 0.60},
    "claude-3-5-sonnet":   {"input": 3.00, "output": 15.00},
    "claude-3-5-haiku":    {"input": 0.80, "output": 4.00},
    # Self-hosted: il "prezzo token" è ammortamento GPU + energia
    # Vedi piu sotto la formula alternativa per modelli on-prem
    "llama3.1:8b":         {"input": 0.00, "output": 0.00},
}


def calcola_cost_per_call(modello: str, input_tokens: int, output_tokens: int) -> float:
    """Restituisce il costo in dollari della singola LLM call.

    Per modelli self-hosted ritorna 0 ma andrebbe accoppiato con calcolo GPU/h.
    """
    if modello not in PREZZI_PER_MODELLO:
        # Fail-loud: meglio errore esplicito che cost tracking errato silenzioso
        raise ValueError(f"Modello '{modello}' non in tariffario, aggiornare PREZZI_PER_MODELLO")

    prezzi = PREZZI_PER_MODELLO[modello]
    cost_input = (input_tokens / 1_000_000) * prezzi["input"]
    cost_output = (output_tokens / 1_000_000) * prezzi["output"]
    return cost_input + cost_output


def calcola_cost_per_trace(span_list: list) -> dict:
    """Aggrega costo per trace sommando ogni span di tipo generation.

    Restituisce breakdown per modello: utile per capire dove si concentra la spesa.
    """
    breakdown = {}
    cost_totale = 0.0

    for span in span_list:
        if span.attributes.get("gen_ai.system") is None:
            continue  # Span non-LLM: skip

        modello = span.attributes.get("gen_ai.request.model")
        in_tok = span.attributes.get("gen_ai.usage.input_tokens", 0)
        out_tok = span.attributes.get("gen_ai.usage.output_tokens", 0)
        cost_step = calcola_cost_per_call(modello, in_tok, out_tok)

        breakdown[modello] = breakdown.get(modello, 0.0) + cost_step
        cost_totale += cost_step

    return {
        "cost_totale_usd": round(cost_totale, 6),
        "breakdown_per_modello": {k: round(v, 6) for k, v in breakdown.items()},
        "n_llm_calls": len([s for s in span_list if s.attributes.get("gen_ai.system")]),
    }

Esempi numerici concreti. Una richiesta utente che fa retrieval (gratis), un agent step di pianificazione con GPT-4o-mini (1500 input, 200 output) e una generazione finale con Claude 3.5 Sonnet (3000 input, 800 output) costa: planning 0.000345$, generazione finale 0.021$, totale ~2.1 cent per richiesta. Se ne servi 10000 al giorno, sono 210$ al giorno, 6300$ al mese. Capire dove sta il 90% del costo (la generation finale Claude) ti permette di valutare downgrading: la stessa pipeline con Claude 3.5 Haiku invece di Sonnet costerebbe ~6 cent al giorno per quella stessa generation, una riduzione di ~3.7x sul componente più caro. Senza tracing, questo ragionamento non si può fare.

Il prompt caching cambia ulteriormente l'equazione. Anthropic e OpenAI nel 2024-2025 hanno introdotto sconti significativi sui token di input quando il prefisso del prompt è cacheable e identico tra richieste consecutive: tipicamente 90% in meno sul costo dei cached input tokens. Per una pipeline con system prompt lungo e statico (5000 token comuni a tutte le richieste, 500 token variabili per query) lo sconto può tagliare il costo per richiesta di 4-5x. Tracciare il cache_read_tokens come attributo separato negli span permette di misurare il cache hit rate effettivo e di accorgersi quando un piccolo cambio nel template prompt invalida il cache (passando da 95% hit rate a 0% in un deploy). Senza visibilità su questo metric, la fattura raddoppia silenziosamente.

Per Ollama on-prem la formula è diversa. Nel ml-service del portfolio uso llama3.1:8b su VPS Hetzner: il "cost per token" è zero a livello provider, ma il costo reale è dato da utilizzo CPU/RAM (il VPS è CPU-only, non GPU), latenza p99 che peggiora sotto carico, e capacità throughput limitata. La metrica utile non è "$/1k token" ma "$/h della macchina" diviso per "richieste/h sostenibili". Se il VPS costa 30 EUR/mese e sostiene 5000 richieste al mese al livello qualitativo accettabile, il cost per request si calcola come 30/5000 = 0.006 EUR. Tracciare GPU/CPU utilization e latency p99 negli span diventa la proxy del costo reale.

Sezione 8 - Latency percentile: perché il p99 conta più della media

La latenza media è la metrica più ingannevole del catalogo. Se servi 1000 richieste e 990 sono veloci (200ms) e 10 sono lente (15 secondi), la media è 348ms: sembra ottimo. Il p99 è 15 secondi: significa che 1 utente su 100 sta aspettando un quarto di minuto. Per un'API conversazionale, quell'utente abbandona. Per un'API B2B, quello è il bug ticket. Le percentile (p50, p95, p99, p99.9) sono il modo onesto di parlare di performance, e devi calcolarle per ogni componente del trace, non solo end-to-end.

# Query Prometheus / PromQL per latency percentile per span LLM
# Assume che esporti metriche da OTel collector con etichette span_name, model

# p50, p95, p99 della completion LLM, per modello, ultimi 5 minuti
- expr: |
    histogram_quantile(0.50,
      sum(rate(gen_ai_completion_duration_seconds_bucket[5m]))
      by (le, gen_ai_request_model)
    )
  record: gen_ai:completion_latency_p50:5m

- expr: |
    histogram_quantile(0.95,
      sum(rate(gen_ai_completion_duration_seconds_bucket[5m]))
      by (le, gen_ai_request_model)
    )
  record: gen_ai:completion_latency_p95:5m

- expr: |
    histogram_quantile(0.99,
      sum(rate(gen_ai_completion_duration_seconds_bucket[5m]))
      by (le, gen_ai_request_model)
    )
  record: gen_ai:completion_latency_p99:5m

# Cost per minuto, per modello
- expr: |
    sum(rate(gen_ai_completion_cost_usd_total[1m]))
    by (gen_ai_request_model)
  record: gen_ai:cost_per_minute_usd:1m

# Latency end-to-end del trace root (la metrica utente-facing)
- expr: |
    histogram_quantile(0.99,
      sum(rate(agent_request_duration_seconds_bucket[5m]))
      by (le, agent_task_type)
    )
  record: agent:request_latency_p99:5m

Queste recording rule pre-calcolano percentile ogni volta che Prometheus le valuta, riducendo il costo delle query in dashboard Grafana. Il pattern di etichettatura per modello e per task type ti permette di capire se il p99 alto è generalizzato (problema infrastrutturale) o limitato a una combinazione specifica (problema applicativo: prompt troppo lungo, modello specifico, retrieval che restituisce 5000 token che gonfiano la generazione successiva).

Sezione 9 - Alerting: catturare cost anomaly e latency spike senza false positive

Le metriche servono ad alimentare alert. Gli alert sbagliati però costano più del problema: un team che riceve 30 page falsi a notte smette di rispondere. La regola operativa è: meglio un alert in meno che dieci di troppo. Per LLM in produzione i quattro alert minimi che configuro sono: (1) latency p99 sopra baseline x2 per 5 minuti consecutivi, (2) error rate sopra 5% per 1 minuto, (3) cost per richiesta sopra mean + 3 sigma sull'ultima ora, (4) token spent giornaliero per utente sopra quota mensile (cap individuale).

# Regole di alerting Prometheus per stack LLM in produzione
# Da inserire in /etc/prometheus/rules/llm-alerts.yml e ricaricare

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

      # Latenza: p99 raddoppiato vs baseline 24h, sostenuto per 5 minuti
      - alert: LlmLatencyP99Spike
        expr: |
          gen_ai:completion_latency_p99:5m
            >
          (avg_over_time(gen_ai:completion_latency_p99:5m[24h]) * 2)
        for: 5m
        labels:
          severity: warning
          team: ai-platform
        annotations:
          summary: "Latenza p99 LLM raddoppiata vs baseline 24h"
          description: "Modello {{ $labels.gen_ai_request_model }} p99 a {{ $value }}s"

      # Error rate sopra 5% per 1 minuto: critical, page immediato
      - alert: LlmErrorRateHigh
        expr: |
          (sum(rate(gen_ai_completion_errors_total[1m]))
           /
           sum(rate(gen_ai_completion_total[1m]))
          ) > 0.05
        for: 1m
        labels:
          severity: critical
          team: ai-platform
        annotations:
          summary: "Error rate LLM sopra 5%"
          runbook: "https://wiki.local/runbooks/llm-error-rate"

      # Anomalia di costo: cost per richiesta oltre 3 sigma
      - alert: LlmCostAnomaly
        expr: |
          (
            avg_over_time(gen_ai:cost_per_request_usd:5m[5m])
            >
            (
              avg_over_time(gen_ai:cost_per_request_usd:5m[1h])
              + 3 * stddev_over_time(gen_ai:cost_per_request_usd:5m[1h])
            )
          )
        for: 10m
        labels:
          severity: warning
          team: finops
        annotations:
          summary: "Cost per request oltre 3 sigma"
          description: "Costo medio {{ $value }} vs baseline statistica oraria"

      # Quota giornaliera token per utente: cap soft alert
      - alert: UserTokenBudgetExceeded
        expr: |
          sum by (app_user_id) (gen_ai_user_tokens_daily) > 100000
        for: 5m
        labels:
          severity: info
          team: ai-platform
        annotations:
          summary: "Utente {{ $labels.app_user_id }} oltre quota daily"
          action: "Verifica abuse o aumento legittimo"

Le soglie qui sopra sono punti di partenza, non verità assolute. Vanno tarate sul baseline reale della tua applicazione. Il vero lavoro inizia dopo aver schierato le regole: monitorare per 2-3 settimane il rate di alert, capire quante volte triggerano per problemi reali vs rumore, alzare/abbassare soglie. Un alert configurato male è peggio di nessun alert: addestra il team a ignorarlo, e quando finalmente trigger per un problema vero passa inosservato. Investi nel tuning quanto investi nel design iniziale.

Sezione 10 - Esperienza dal portfolio: Ollama on-prem e cost tracking "non ovvio"

Nel ml-service del portfolio federicocalo.dev uso Ollama on-prem locale installato sul VPS Hetzner come provider primario, modello llama3.1:8b. La scelta è nata per ragioni di privacy (i prompt restano sul mio VPS, non escono mai verso provider terzi) e di costo zero variabile (il VPS costa una cifra fissa al mese, 30 EUR circa, indipendentemente dal numero di token consumati). Questo cambia radicalmente cosa significa "cost tracking" per la mia applicazione.

I prezzi per token nel mio dizionario di tariffe sono zero. Ma il costo reale è tutt'altro che zero: il VPS è CPU-only (no GPU dedicata), quindi la latency p99 di una completion sui prompt più lunghi raggiunge facilmente 8-15 secondi, e il throughput sostenibile è limitato a ~1 richiesta concorrente accettabile. Le metriche che traccio negli span di Langfuse self-hosted sono dunque diverse: infrastructure.cpu_usage_pct al momento della call, infrastructure.memory_used_mb, gen_ai.completion.duration_ms, gen_ai.completion.tokens_per_second come misura di throughput effettivo. Il cost per richiesta lo derivo a posteriori dividendo il costo mensile fisso del VPS per il numero di richieste servite con qualità accettabile nel mese.

L'altra lezione importante è il graceful degradation. Quando Ollama è sotto carico o il modello sta caricando in memoria dopo un riavvio, alcune richieste vanno in timeout. Senza tracing avrei visto solo "errore generico". Con tracing ho span gen_ai.completion con status ERROR e attributo error.type=timeout: posso aggregare per capire quanto spesso accade, in che fasce orarie, su quali tipi di tool. Questo mi ha permesso di introdurre nei tool del portfolio un fallback deterministico (output statico + warning "AI temporaneamente non disponibile") invece di propagare errore 500 all'utente. Il fallback è esso stesso uno span con attributo fallback.triggered=true: quando il rate di fallback supera il 5% so che è ora di intervenire sul VPS o sul modello.

La terza considerazione operativa, meno ovvia, riguarda il warmup. Ollama in modalità default scarica il modello dalla RAM se non viene usato per qualche minuto, e la prima richiesta dopo l'idle paga 5-15 secondi di model load. Tracciando ollama.cold_start=true sugli span ho potuto distinguere chiaramente i picchi di latenza dovuti a cold start dai picchi dovuti a prompt lunghi o load reale. La mitigazione è un keepalive periodico (cron che fa una mini-richiesta ogni 4 minuti per tenere il modello in memoria), ma la decisione di introdurla è stata possibile solo perché avevo i numeri concreti per dimostrare l'impatto: il 12% delle richieste totali era cold start, con latency mediana 4 volte la baseline. Senza i numeri, sarebbe stata l'ennesima ottimizzazione "intuita" rimandata in backlog.

Sezione 11 - Errori comuni e come evitarli

Ho visto abbastanza implementazioni di osservabilità LLM da identificare otto pattern di errore ricorrenti. Li elenco perché evitarli ti risparmia mesi di rifacimento.

Errore 1: instrumentare solo le LLM call. Ti perdi tutto il resto del workflow (retrieval, tool, parsing). Quando una richiesta è lenta non sai se è colpa del modello o della query SQL del tool che impiega 4 secondi. Soluzione: instrumentare anche tool, retrieval, parser, callback.

Errore 2: nomi span variabili. Chiamare span step_7 invece di agent.step con attributo step.index=7 distrugge l'aggregazione in dashboard. Soluzione: nomi stabili semantici, dettaglio negli attributi.

Errore 3: attributi a cardinalità infinita. Mettere user prompt completo come attributo esplode il cardinality budget di Prometheus o Langfuse. Soluzione: tronca, hash, sample.

Errore 4: nessun sampling. A scala (1M richieste/giorno) memorizzare tutti i trace è costoso e poco utile. Soluzione: tail sampling intelligente che conserva trace con errore, latenza alta, costo alto, sample casuale 1-5% del resto.

Errore 5: cost tracking solo aggregato. Sapere che hai speso 800 EUR a marzo non aiuta a capire dove tagliare. Soluzione: breakdown per modello, per feature, per utente, per tool.

Errore 6: nessuna baseline per gli alert. Soglie hardcoded (es: latency > 2s) sono arbitrarie. Soluzione: alert relativi a baseline statistico (sigma, rolling average).

Errore 7: tracing in dev, non in prod. Tipico: setup elegante in development, in produzione disabilitato per "performance". Soluzione: overhead tracing reale è 1-3% di latency, sempre on con sampling.

Errore 8: nessuna correlazione utente-trace. Quando un utente segnala "ho avuto risposta sbagliata alle 14:32", non sai trovare il trace. Soluzione: app.user.id e timestamp navigabili nella dashboard, sempre.

Sezione 12 - FAQ

Posso usare il mio APM esistente (Datadog, New Relic) invece di Langfuse o Phoenix?

In parte sì: Datadog e New Relic supportano OpenTelemetry e quindi accettano span con attributi GenAI. Quello che perdi è la UI specializzata per LLM (visualizzazione prompt input, output generato, breakdown costo per modello, prompt versioning). Se hai già Datadog in azienda e vuoi minimizzare nuovi tool, esporta gli span lì come baseline e valuta se aggiungere Langfuse o Phoenix solo per i workflow LLM-critical. Non sono mutuamente esclusivi.

Quanto overhead aggiunge il tracing a una request LLM?

Misurato sul ml-service del portfolio: tra 1% e 3% di latenza aggiuntiva con OTel SDK in Python e batch span processor. L'overhead è dominato dalla serializzazione degli attributi e dall'I/O verso il backend. Diventa significativo solo se metti centinaia di attributi per span o se usi simple span processor (sincrono) invece di batch. Nessun motivo razionale per disabilitarlo in produzione.

Self-hosted Langfuse vs managed: quando vale ciascuno?

Self-hosted (Docker Compose + Postgres) ha senso se il volume è basso-medio (sotto 100K eventi/mese), se il team ha competenze DevOps di base, se hai requisiti di privacy stringenti che impediscono di mandare prompt utente a terzi. Managed ha senso se il volume cresce (oltre 1M eventi/mese, dove serve ClickHouse), se non vuoi gestire backup e upgrade del backend, se ti servono SLA enterprise. Personalmente nel portfolio uso self-hosted: VPS che ho già, volume basso, prompt che non voglio mandare fuori.

Phoenix di Arize è alternativa o complemento a Langfuse?

Complemento. Phoenix brilla in modalità notebook per esperimenti rapidi (provo 5 varianti di prompt, vedo quale vince su un dataset di test), Langfuse brilla per monitoring continuo in produzione. Molti team usano Phoenix in fase di sviluppo e Langfuse in produzione, esportando trace tra i due quando serve confrontare. Sono entrambi open source, entrambi compatibili con OpenTelemetry.

Come gestisco la privacy dei prompt utente nel tracing?

Tre strategie combinabili. Primo: non mettere il contenuto utente integrale negli attributi span, usa lunghezza, hash, primi 100 caratteri. Secondo: configura il backend per non indicizzare i campi sensibili (Langfuse permette di marcare metadata come "do not store"). Terzo: per dati altamente sensibili usa redaction lato client prima dell'export, sostituendo entità riconosciute (email, codici fiscali, IBAN) con placeholder. Self-hosting riduce ulteriormente il rischio mantenendo i dati nel tuo perimetro.

Quanti trace devo conservare e per quanto tempo?

Dipende dal regolamento e dal use case. Per debug operativo bastano 7-30 giorni di full trace. Per analisi di trend a lungo termine basta conservare metriche aggregate (cost giornaliero per modello, latency percentile, error rate) per 12-24 mesi. Tail sampling intelligente che conserva 100% degli errori e 1-5% del traffico nominale tiene lo storage gestibile anche a scale di milioni di richieste.

Posso instrumentare un sistema legacy senza modificare il codice?

In parte sì, con auto-instrumentation. OpenTelemetry Python ha plugin che catturano automaticamente chiamate HTTP, database, e per alcuni framework anche LLM (LangChain). Langfuse offre callback handler per LangChain e LlamaIndex che si attivano configurando una variabile di ambiente. La copertura è buona per i framework popolari, parziale per orchestrazione custom: per quelle parti dovrai aggiungere span manuali, ma puoi farlo gradualmente partendo dai punti più critici.

Conclusione

Il logging applicativo non è morto, ma da solo è del tutto inadeguato a operare un sistema LLM in produzione. Hai bisogno di tracing strutturato per ricostruire la catena di chiamate, di metriche aggregate per capire trend e anomalie, di alert basati su baseline statistici per intervenire prima che il problema diventi un incident. Le tre categorie di tool (request tracing con Langfuse, profiling con Phoenix, metrics aggregation con Helicone o stack Prometheus+Grafana) coprono dimensioni complementari: vanno combinate, non scelte in alternativa.

Le decisioni architetturali fondamentali sono tre. Primo: adotta OpenTelemetry come fondazione, anche se le convenzioni semantiche GenAI sono ancora work-in-progress nel 2025-2026. Secondo: progetta uno schema di attributi standard per la tua applicazione (model, tokens, costs, user, tenant) prima di iniziare a instrumentare in massa. Terzo: ricordati che la qualità del tracing dipende dall'uniformità dell'instrumentation, non dalla quantità di span. Cento span ben nominati con attributi consistenti valgono più di mille span generati a caso.

Se sei un platform engineer che sta valutando da dove partire, il consiglio operativo è: setup Langfuse self-hosted in 30 minuti su un VPS, instrumenta una sola pipeline critica con il decoratore @observe, lascia girare per due settimane, guarda i trace generati. Solo dopo aver capito cosa stai effettivamente misurando puoi prendere decisioni sensate su cost optimization, model selection e SLA. L'osservabilità non è una checkbox di compliance: è il sistema sensoriale che permette al tuo agente di restare sano nel tempo.

Prossimo episodio: entriamo nel dettaglio di Langfuse - setup self-hosted vs managed, integrazione con prompt management, evaluation pipeline e configurazione cost tracking automatico per multi-provider.

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