Salta al contenuto principale

Tassonomia dei failure AI: 12 modi in cui il tuo LLM può rompersi

Tassonomia dei failure AI: 12 modi in cui il tuo LLM può rompersi Hai messo in produzione il tuo servizio AI. Funziona. Per tre settimane tutto bene.

Cosa ne pensi di questo articolo?

Tassonomia dei failure AI: 12 modi in cui il tuo LLM può rompersi
AI EngineeringFoto:Sanket MishraPexels

In Breve

Tassonomia dei failure AI: 12 modi in cui il tuo LLM può rompersi Hai messo in produzione il tuo servizio AI. Funziona. Per tre settimane tutto bene.

Tassonomia dei failure AI: 12 modi in cui il tuo LLM può rompersi

Hai messo in produzione il tuo servizio AI. Funziona. Per tre settimane tutto bene. Poi, alle 2:47 di notte, arriva la prima notifica: il modello non risponde. Il tuo on-call apre il dashboard e trova un errore che non ha mai visto prima: non è un 500 generico, non è un problema di rete. È qualcosa di più subdolo: il contesto inviato al modello è troppo lungo, il modello ha semplicemente rifiutato la richiesta senza tornare nemmeno un codice HTTP leggibile.

Il problema dei servizi AI in produzione è che i loro failure mode sono radicalmente diversi da quelli dei servizi tradizionali. Un database va giù: lo vedi subito, hai un errore di connessione pulito, fai failover. Un LLM può rompersi in dodici modi diversi, molti dei quali silenziosi, parziali, o statisticamente distribuiti su una percentuale di richieste - abbastanza da non triggerare un alert, abbastanza da danneggiare l'esperienza utente per ore prima che tu te ne accorga. Questo articolo classifica tutti e 12, con il fingerprint di detection e la mitigation immediata per ciascuno.

Nel mio ml-service (FastAPI + Ollama on-prem) ho misurato la distribuzione reale di questi failure su un arco di tre mesi: il failure mode più frequente non è l'errore API, è il timeout silenzioso - Ollama che supera i 30 secondi il 3-8% delle volte sotto carico. Il secondo è l'output malformato: JSON con virgolette non bilanciate, markdown annidato dentro il JSON, testo prima della struttura attesa. Il terzo è la refusal - il modello che si rifiuta di rispondere per policy interne. Tutti e tre richiedono strategie di fallback diverse.

Cosa Imparerai

  • I 12 failure mode LLM con frequenza reale, fingerprint e blast radius
  • Come implementare un timeout handler asyncio con fallback su risposta cached
  • Come costruire un parser JSON robusto che gestisce output malformati
  • Come applicare exponential backoff con jitter per rate limit e errori transitori
  • Come implementare un circuit breaker in Python puro senza librerie esterne
  • La severity matrix per prioritizzare i failure in base al rischio reale
  • La strategia di fallback del ml-service: Ollama timeout 30s → cache Redis

Prerequisiti

  • Python async/await: devi saper leggere e scrivere codice asincrono
  • Esperienza con almeno un LLM provider (Ollama, API esterne equivalenti)
  • Familiarità con pattern retry, error handling ed exception hierarchy
  • Comprensione base di Redis o qualsiasi sistema di cache key-value

Sezione 1 - La tassonomia: enum Python come fonte di verità

Il primo passo per gestire i failure è nominarli in modo consistente in tutto il codebase. Se ogni team chiama lo stesso problema in modo diverso - "timeout", "slow response", "model hung" - non puoi aggregare metriche, non puoi costruire alert coerenti, non puoi fare post-mortem significativi. L'enum Python serve esattamente a questo: è il dizionario condiviso dei failure mode. Ogni eccezione, ogni log, ogni metrica Prometheus deve usare questi nomi.

from enum import Enum, auto
from dataclasses import dataclass
from typing import Optional


class LLMFailureMode(Enum):
    """
    Tassonomia dei 12 failure mode LLM in produzione.
    Ogni valore è un caso distinto che richiede mitigation separata.
    """
    # Errori infrastrutturali - il provider non risponde
    HARD_API_ERROR = "hard_api_error"          # HTTP 500, 503: provider down
    RATE_LIMIT = "rate_limit"                  # HTTP 429: quota esaurita temporaneamente
    TIMEOUT = "timeout"                        # Nessuna risposta entro il limite configurato
    REGION_OUTAGE = "region_outage"            # Provider down per area geografica

    # Errori di input - il prompt non è accettabile
    CONTEXT_OVERFLOW = "context_overflow"      # Token count supera la finestra massima
    QUOTA_EXHAUSTED = "quota_exhausted"        # Budget mensile/giornaliero consumato

    # Errori di output - il modello risponde ma male
    MALFORMED_OUTPUT = "malformed_output"      # JSON rotto, markdown inatteso, troncato
    REFUSAL = "refusal"                        # Il modello si rifiuta per policy interna
    HALLUCINATION = "hallucination"            # Output plausibile ma fattualmente errato

    # Errori sistemici - problemi di ciclo di vita del modello
    TOOL_ERROR = "tool_error"                  # Tool call che il modello non esegue correttamente
    MODEL_DEPRECATION = "model_deprecation"    # Il modello richiesto non esiste più
    COST_SPIKE = "cost_spike"                  # Costo per singola richiesta anomalo (token explosion)


@dataclass(frozen=True)
class FailureEvent:
    """Rappresentazione immutabile di un failure catturato."""
    mode: LLMFailureMode
    provider: str           # es. "ollama", "external_api"
    model: str              # es. "llama3.1:8b"
    latency_ms: int         # Latenza al momento del failure (0 se non applicabile)
    raw_error: Optional[str] = None
    token_count: Optional[int] = None
    request_id: Optional[str] = None

L'uso di frozen=True nel dataclass non è casuale: un evento di failure è immutabile per definizione. Non vuoi che nessun layer del codice lo modifichi dopo la creazione. Il request_id è fondamentale per correlare il failure con la trace del gateway e il log del modello - senza di esso, il debug in produzione diventa un'ipotesi.

Sezione 2 - I 12 failure mode: frequenza, detection e blast radius

La tabella seguente sintetizza ogni failure mode con la frequenza osservata (range tipico in produzione con provider on-prem e cloud), il segnale di detection primario e il blast radius - ovvero quante richieste vengono impattate quando il failure si manifesta. Un failure con blast radius "tutti" è incomparabilmente più pericoloso di uno con blast radius "singola richiesta", anche se la frequenza è simile.

# Failure Mode Frequenza tipica Detection primaria Blast Radius Mitigation immediata
1 Hard API Error (500/503) 0.1–2% HTTP status code ≥ 500 Singola richiesta Retry immediato (1x), poi fallback provider
2 Rate Limit (429) 0.5–5% (cloud) HTTP 429 + Retry-After header Tutte le richieste dello stesso tier Exponential backoff + queue locale
3 Timeout 3–8% (on-prem picco) asyncio.TimeoutError dopo soglia Singola richiesta (ma può cascadare) Cache Redis → risposta statica
4 Context Overflow 1–3% Errore esplicito "context length exceeded" Singola richiesta Truncation + sliding window
5 Malformed Output 2–6% JSONDecodeError / schema validation fail Singola richiesta Re-prompt con schema esplicito
6 Refusal 1–4% Pattern su testo risposta ("I cannot", "Mi dispiace") Singola richiesta Prompt rewriting + escalation
7 Hallucination 5–15% Cross-check RAG sources + confidence score Singola risposta (danno reputazionale) Disclaimer + human review queue
8 Tool Error 2–8% Tool call con argomenti invalidi / schema errato Singola sessione agente Retry con tool schema semplificato
9 Model Deprecation Evento raro ma pianificabile HTTP 404 "model not found" o changelog provider Tutti gli utenti del modello Feature flag rollover a modello successore
10 Region Outage 0.01–0.1% Timeout sistematico da una singola regione Tutti gli utenti di quella regione Geo-aware failover a regione secondaria
11 Quota Exhausted Mensile/giornaliero HTTP 429 con body "quota exceeded" (diverso da rate limit) Tutti gli utenti fino a reset Budget alert preventivo + fallback on-prem
12 Cost Spike Raro ma costoso Token count anomalo (10x rispetto alla media) Budget mensile Max token cap + alert Prometheus

Una nota sulla hallucination: il 5–15% di frequenza sembra alto, ma considera che "hallucination" in questo contesto include qualsiasi risposta fattualmente non supportata dai documenti di contesto - non solo le invenzioni clamorose. In sistemi RAG con documenti tecnici, la frequenza reale di risposte non groundate si attesta tipicamente tra il 7% e il 12% senza mitigation attiva.

Sezione 3 - Timeout handler: Ollama 30s → cache Redis

Il timeout è il failure mode più insidioso nell'on-prem perché non produce un errore esplicito: produce silenzio. Il processo Python aspetta, il thread è bloccato, l'utente vede un caricamento infinito. La soluzione è asyncio.wait_for con una soglia configurabile, e un fallback su risposta cached identifica con l'hash del prompt normalizzato.

Nel mio ml-service questa strategia gestisce il 3-8% delle call Ollama durante i picchi: la cache Redis con TTL di 2 ore copre la maggior parte delle query frequenti (articoli popolari, query di ricerca ripetute), mentre le query nuove ricevono una risposta di degradation esplicita con un invito a riprovare.

import asyncio
import hashlib
import json
import logging
from typing import Optional, Any
from dataclasses import dataclass

import redis.asyncio as redis

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class TimeoutFallbackConfig:
    """Configurazione immutabile per il timeout handler."""
    timeout_seconds: float = 30.0
    cache_ttl_seconds: int = 7200   # 2 ore
    cache_key_prefix: str = "llm_cache"


class OllamaTimeoutHandler:
    """
    Gestisce timeout di Ollama con fallback su risposta cached.
    Strategia: call Ollama → timeout 30s → cerca cache Redis → risposta statica.
    """

    def __init__(self, redis_client: redis.Redis, config: TimeoutFallbackConfig):
        self._redis = redis_client
        self._config = config

    def _build_cache_key(self, prompt: str, model: str) -> str:
        """Costruisce una chiave cache deterministica da prompt + modello."""
        # Normalizza il prompt: rimuovi spazi multipli e lowercase
        normalized = " ".join(prompt.lower().split())
        content = f"{model}:{normalized}"
        digest = hashlib.sha256(content.encode()).hexdigest()
        return f"{self._config.cache_key_prefix}:{digest}"

    async def call_with_fallback(
        self,
        ollama_coroutine,
        prompt: str,
        model: str,
        static_fallback: str = "Il servizio AI è temporaneamente occupato. Riprova tra qualche minuto."
    ) -> dict[str, Any]:
        """
        Esegue la call Ollama con timeout e fallback a cascata.
        Restituisce sempre una risposta, mai un'eccezione non gestita.
        """
        cache_key = self._build_cache_key(prompt, model)

        try:
            # Tenta la call Ollama con timeout configurato
            result = await asyncio.wait_for(
                ollama_coroutine,
                timeout=self._config.timeout_seconds
            )
            # Salva nella cache per future call identiche
            await self._redis.setex(
                cache_key,
                self._config.cache_ttl_seconds,
                json.dumps(result)
            )
            return {"response": result, "source": "ollama", "cached": False}

        except asyncio.TimeoutError:
            logger.warning(
                "ollama_timeout",
                extra={"model": model, "timeout_s": self._config.timeout_seconds}
            )
            return await self._serve_from_cache_or_static(
                cache_key, static_fallback, reason="timeout"
            )

        except Exception as exc:
            logger.error("ollama_unexpected_error", extra={"error": str(exc)})
            return await self._serve_from_cache_or_static(
                cache_key, static_fallback, reason="error"
            )

    async def _serve_from_cache_or_static(
        self, cache_key: str, static_fallback: str, reason: str
    ) -> dict[str, Any]:
        """Cerca in cache Redis; se assente, restituisce risposta statica."""
        cached = await self._redis.get(cache_key)
        if cached:
            logger.info("serving_cached_response", extra={"reason": reason})
            return {
                "response": json.loads(cached),
                "source": "cache",
                "cached": True,
                "cache_reason": reason,
                "disclaimer": "Risposta dalla cache. Aggiornamento AI disponibile a breve."
            }
        # Nessuna cache disponibile: risposta di degradation
        return {
            "response": static_fallback,
            "source": "static_fallback",
            "cached": False,
            "cache_reason": reason
        }

La chiave del design è che il metodo call_with_fallback non lancia mai eccezioni verso il caller: gestisce internamente tutti i casi e restituisce sempre un dizionario con il campo source che indica la provenienza della risposta. Il chiamante può quindi decidere autonomamente se mostrare o meno il disclaimer all'utente.

Sezione 4 - Hallucination detector: tre segnali combinati

La hallucination è l'unico failure mode che non produce un errore tecnico: il sistema funziona perfettamente, restituisce una risposta ben formata, e quella risposta è sbagliata. Non c'è un'eccezione da catturare. Devi costruire un layer di detection esplicito basato su segnali semantici.

import re
from dataclasses import dataclass
from typing import Optional


# Pattern linguistici che indicano alta confidenza del modello
# (spesso associati a hallucination: il modello mente con sicurezza)
OVERCONFIDENCE_PATTERNS = [
    r"\b(certamente|assolutamente|senza dubbio|è un fatto che|come tutti sanno)\b",
    r"\b(il \d{1,4} [a-z]+ \d{4})\b",   # date specifiche inventate
    r"\b(fonte:|secondo [A-Z][a-z]+ [A-Z][a-z]+)\b",  # citazioni di persone/fonti
]


@dataclass(frozen=True)
class HallucinationSignal:
    """Segnali di detection hallucination con score aggregato."""
    confidence_score_low: bool       # Segnale 1: confidence < 0.6 (se disponibile)
    not_grounded_in_sources: bool    # Segnale 2: nessun documento RAG supporta la risposta
    overconfidence_detected: bool    # Segnale 3: pattern linguistici di falsa certezza
    composite_risk: str              # "low" | "medium" | "high"


def detect_hallucination_risk(
    response_text: str,
    rag_sources: list[str],
    confidence_score: Optional[float] = None,
    grounding_threshold: float = 0.3
) -> HallucinationSignal:
    """
    Valuta il rischio di hallucination su tre segnali combinati.
    Non è infallibile: riduce i falsi negativi combinando più euristiche.
    """
    # Segnale 1: confidence score bassa (solo se il modello lo espone)
    confidence_low = (
        confidence_score is not None and confidence_score < 0.6
    )

    # Segnale 2: verifica grounding - almeno il 30% delle frasi deve avere
    # un termine chiave presente in almeno un documento RAG
    sentences = [s.strip() for s in response_text.split(".") if len(s.strip()) > 20]
    grounded_count = 0
    for sentence in sentences:
        words = set(sentence.lower().split())
        for source in rag_sources:
            source_words = set(source.lower().split())
            # Grounded se più del 20% delle parole della frase sono nel documento
            overlap = len(words & source_words) / max(len(words), 1)
            if overlap > 0.2:
                grounded_count += 1
                break

    grounding_ratio = grounded_count / max(len(sentences), 1)
    not_grounded = grounding_ratio < grounding_threshold

    # Segnale 3: pattern linguistici di overconfidence
    overconfident = any(
        re.search(pattern, response_text, re.IGNORECASE)
        for pattern in OVERCONFIDENCE_PATTERNS
    )

    # Score composito: 2+ segnali attivi = alto rischio
    active_signals = sum([confidence_low, not_grounded, overconfident])
    if active_signals >= 2:
        risk = "high"
    elif active_signals == 1:
        risk = "medium"
    else:
        risk = "low"

    return HallucinationSignal(
        confidence_score_low=confidence_low,
        not_grounded_in_sources=not_grounded,
        overconfidence_detected=overconfident,
        composite_risk=risk
    )

Sezione 5 - JSON malformato: parser con fallback a tre livelli

I modelli LLM tendono a restituire JSON sporco in due pattern ricorrenti: aggiungono testo introduttivo ("Ecco il JSON richiesto: {…}") o includono blocchi markdown (```json) che rompono il parsing diretto. Un parser robusto deve gestire entrambi i casi prima di dichiarare fallimento.

import json
import re
import logging
from typing import Any, Optional


logger = logging.getLogger(__name__)

# Pattern per estrarre JSON da output con testo circostante
JSON_EXTRACTION_PATTERNS = [
    r"```json\s*([\s\S]*?)\s*```",  # Blocco markdown ```json ... ```
    r"```\s*([\s\S]*?)\s*```",      # Blocco markdown generico
    r"(\{[\s\S]*\})",               # JSON object standalone
    r"(\[[\s\S]*\])",               # JSON array standalone
]


def parse_llm_json(
    raw_output: str,
    expected_schema: Optional[dict] = None,
    default_value: Any = None
) -> tuple[Any, bool]:
    """
    Parser JSON con fallback a tre livelli per output LLM malformati.
    Restituisce (valore_parsato, successo: bool).

    Livello 1: json.loads diretto
    Livello 2: estrazione regex del JSON dall'output
    Livello 3: default_value con log dell'errore
    """
    raw_stripped = raw_output.strip()

    # Livello 1: parsing diretto
    try:
        result = json.loads(raw_stripped)
        return result, True
    except json.JSONDecodeError:
        logger.debug("json_parse_level1_failed", extra={"preview": raw_stripped[:100]})

    # Livello 2: estrazione regex
    for pattern in JSON_EXTRACTION_PATTERNS:
        match = re.search(pattern, raw_stripped, re.DOTALL)
        if match:
            candidate = match.group(1).strip()
            try:
                result = json.loads(candidate)
                logger.info("json_parse_level2_success", extra={"pattern": pattern})
                return result, True
            except json.JSONDecodeError:
                continue  # Prova il pattern successivo

    # Livello 3: fallback al valore di default
    logger.error(
        "json_parse_all_levels_failed",
        extra={
            "raw_preview": raw_stripped[:200],
            "raw_length": len(raw_stripped)
        }
    )
    return default_value, False


def parse_llm_json_with_schema_retry(
    raw_output: str,
    schema_description: str,
    llm_caller_fn,
    max_retries: int = 1
) -> tuple[Any, bool]:
    """
    Se il parsing fallisce, ri-chiede al modello un output conforme allo schema.
    Usare solo quando il costo del re-prompt è accettabile.
    """
    result, success = parse_llm_json(raw_output)
    if success:
        return result, True

    if max_retries <= 0:
        return None, False

    # Re-prompt con schema esplicito
    repair_prompt = (
        f"Il tuo output precedente non era JSON valido. "
        f"Rispondi SOLO con JSON valido conforme a questo schema:\n{schema_description}\n"
        f"Output precedente (da correggere):\n{raw_output[:500]}"
    )
    repaired_output = llm_caller_fn(repair_prompt)
    return parse_llm_json_with_schema_retry(
        repaired_output, schema_description, llm_caller_fn, max_retries - 1
    )

Sezione 6 - Rate limit handler con backoff esponenziale e jitter

Il rate limit (HTTP 429) è inevitabile quando usi provider cloud con tier limitati. La risposta naive - fare retry immediato - è la risposta sbagliata: aggrava il problema perché tutti i client riprovano simultaneamente. Il backoff esponenziale con jitter distribuisce i retry nel tempo, riducendo la pressione sul provider.

import asyncio
import random
import logging
from dataclasses import dataclass
from typing import TypeVar, Callable, Awaitable

logger = logging.getLogger(__name__)

T = TypeVar("T")


@dataclass(frozen=True)
class BackoffConfig:
    """Configurazione immutabile per exponential backoff."""
    max_retries: int = 5
    base_delay_seconds: float = 1.0
    max_delay_seconds: float = 60.0
    jitter_factor: float = 0.25   # ±25% di variazione casuale


def compute_backoff_delay(attempt: int, config: BackoffConfig) -> float:
    """
    Calcola il delay con exponential backoff e jitter.
    Formula: min(base * 2^attempt, max) * (1 ± jitter)
    """
    exponential = config.base_delay_seconds * (2 ** attempt)
    capped = min(exponential, config.max_delay_seconds)
    jitter = capped * config.jitter_factor * (2 * random.random() - 1)
    return max(0.1, capped + jitter)  # Mai sotto 100ms


async def with_rate_limit_retry(
    fn: Callable[[], Awaitable[T]],
    config: BackoffConfig,
    rate_limit_status: int = 429
) -> T:
    """
    Esegue fn con retry automatico in caso di rate limit.
    Usa il header Retry-After se presente, altrimenti backoff esponenziale.
    """
    last_exception = None

    for attempt in range(config.max_retries):
        try:
            return await fn()

        except Exception as exc:
            last_exception = exc
            # Determina se è un rate limit (basato su messaggio o codice HTTP)
            is_rate_limit = "429" in str(exc) or "rate limit" in str(exc).lower()

            if not is_rate_limit or attempt == config.max_retries - 1:
                raise  # Non è un rate limit o abbiamo esaurito i retry

            delay = compute_backoff_delay(attempt, config)
            logger.warning(
                "rate_limit_backoff",
                extra={
                    "attempt": attempt + 1,
                    "max_retries": config.max_retries,
                    "delay_seconds": round(delay, 2)
                }
            )
            await asyncio.sleep(delay)

    raise last_exception

Sezione 7 - Context overflow: truncation con sliding window

Il context overflow è l'unico failure mode che puoi prevenire al 100% lato client, prima che la richiesta arrivi al modello. Hai sempre il controllo del prompt: se è troppo lungo, devi truncarlo. La domanda è come truncare senza perdere le informazioni critiche.

from dataclasses import dataclass
from typing import Optional
import re


@dataclass(frozen=True)
class ContextWindowConfig:
    """Configurazione limiti contesto per modello."""
    max_tokens: int               # Finestra massima del modello
    safety_margin: float = 0.85   # Usa solo l'85%: lascia spazio per la risposta
    chars_per_token: float = 3.5  # Stima euristica: 1 token ≈ 3.5 caratteri IT


def estimate_token_count(text: str, chars_per_token: float = 3.5) -> int:
    """Stima approssimativa dei token senza tokenizer esterno."""
    return int(len(text) / chars_per_token)


def truncate_context_sliding_window(
    system_prompt: str,
    conversation_history: list[dict],
    user_query: str,
    config: ContextWindowConfig
) -> tuple[list[dict], bool]:
    """
    Trunca la history di conversazione con sliding window.
    Strategia: mantieni sempre system_prompt e user_query intatti;
    rimuovi i messaggi più vecchi fino a rientrare nel limite.

    Restituisce (history_truncata, was_truncated: bool).
    """
    max_chars = int(config.max_tokens * config.chars_per_token * config.safety_margin)
    fixed_chars = len(system_prompt) + len(user_query) + 100  # Buffer sicurezza

    available_chars = max_chars - fixed_chars
    if available_chars <= 0:
        # System prompt + query già occupano tutto: history vuota
        return [], True

    # Sliding window: parti dall'ultimo messaggio e aggiungi all'indietro
    truncated_history = []
    chars_used = 0
    was_truncated = False

    for message in reversed(conversation_history):
        msg_chars = len(message.get("content", ""))
        if chars_used + msg_chars > available_chars:
            was_truncated = True
            break  # I messaggi più vecchi vengono scartati
        truncated_history.insert(0, message)
        chars_used += msg_chars

    return truncated_history, was_truncated

Sezione 8 - Circuit breaker in Python puro

Il circuit breaker è il pattern che impedisce al tuo sistema di continuare a chiamare un provider in difficoltà, aggravando la situazione. Dopo un numero configurabile di fallimenti consecutivi, il circuito si "apre" e le richieste vengono reindirizzate al fallback senza nemmeno tentare la call al provider. Dopo un cooldown, entra in stato "half-open": tenta una singola call di verifica e decide se tornare in "closed" o riaprire.

import time
import asyncio
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, Awaitable, TypeVar

logger = logging.getLogger(__name__)

T = TypeVar("T")


class CircuitState(Enum):
    CLOSED = "closed"       # Normale: le call passano
    OPEN = "open"           # Failure: le call vengono bloccate
    HALF_OPEN = "half_open" # Verifica: una singola call di prova


@dataclass
class CircuitBreakerState:
    """Stato mutabile del circuit breaker (intentionally mutable per tracking)."""
    state: CircuitState = CircuitState.CLOSED
    failure_count: int = 0
    last_failure_time: float = 0.0
    success_count_half_open: int = 0


@dataclass(frozen=True)
class CircuitBreakerConfig:
    """Configurazione immutabile del circuit breaker."""
    failure_threshold: int = 5      # Apre dopo N fallimenti consecutivi
    cooldown_seconds: float = 60.0  # Attende prima di passare a HALF_OPEN
    half_open_success: int = 2      # Successi in HALF_OPEN per tornare a CLOSED


class LLMCircuitBreaker:
    """
    Circuit breaker per call LLM. Previene cascade failure su provider instabile.
    """

    def __init__(self, name: str, config: CircuitBreakerConfig):
        self._name = name
        self._config = config
        self._state = CircuitBreakerState()

    @property
    def is_open(self) -> bool:
        return self._state.state == CircuitState.OPEN

    def _check_and_transition(self) -> CircuitState:
        """Valuta se transitare da OPEN a HALF_OPEN dopo il cooldown."""
        if self._state.state == CircuitState.OPEN:
            elapsed = time.monotonic() - self._state.last_failure_time
            if elapsed >= self._config.cooldown_seconds:
                self._state.state = CircuitState.HALF_OPEN
                self._state.success_count_half_open = 0
                logger.info("circuit_half_open", extra={"name": self._name})
        return self._state.state

    def _on_success(self) -> None:
        if self._state.state == CircuitState.HALF_OPEN:
            self._state.success_count_half_open += 1
            if self._state.success_count_half_open >= self._config.half_open_success:
                self._state.state = CircuitState.CLOSED
                self._state.failure_count = 0
                logger.info("circuit_closed", extra={"name": self._name})
        elif self._state.state == CircuitState.CLOSED:
            self._state.failure_count = 0  # Reset su ogni successo

    def _on_failure(self) -> None:
        self._state.failure_count += 1
        self._state.last_failure_time = time.monotonic()
        if self._state.state == CircuitState.HALF_OPEN:
            # Un fallimento in HALF_OPEN ri-apre il circuito
            self._state.state = CircuitState.OPEN
            logger.warning("circuit_reopened", extra={"name": self._name})
        elif self._state.failure_count >= self._config.failure_threshold:
            self._state.state = CircuitState.OPEN
            logger.error(
                "circuit_opened",
                extra={"name": self._name, "failures": self._state.failure_count}
            )

    async def call(
        self,
        fn: Callable[[], Awaitable[T]],
        fallback: Callable[[], Awaitable[T]]
    ) -> T:
        """
        Esegue fn se il circuito è chiuso/half-open, altrimenti chiama fallback.
        """
        current_state = self._check_and_transition()

        if current_state == CircuitState.OPEN:
            logger.debug("circuit_open_fallback", extra={"name": self._name})
            return await fallback()

        try:
            result = await fn()
            self._on_success()
            return result
        except Exception as exc:
            self._on_failure()
            logger.warning(
                "circuit_breaker_failure",
                extra={"name": self._name, "error": str(exc)}
            )
            return await fallback()

Sezione 9 - La severity matrix per prioritizzare i failure

Non tutti i failure mode meritano la stessa urgenza di risposta. La severity matrix combina due dimensioni: l'impatto sull'utente (da "nessuno" a "risposta errata con conseguenze") e la velocità di propagazione (da "singola richiesta" a "tutti gli utenti istantaneamente"). Il prodotto delle due dimensioni determina la priorità di incident response.

Failure Mode Impatto Utente Propagazione Severity SLA risposta Owner
Hallucination (non rilevata) Critico - risposta errata presentata come vera Singola ma reputazionale P1 15 minuti AI Engineering + Legal
Region Outage Alto - servizio irraggiungibile Tutti gli utenti regione P1 15 minuti DevOps + AI Engineering
Quota Exhausted Alto - servizio degradato per tutti Tutti fino a reset P1 30 minuti AI Engineering + Finance
Model Deprecation Alto - 404 su tutte le call Tutti gli utenti P2 2 ore (pianificabile) AI Engineering
Rate Limit (sistematico) Medio - latenza aumentata Tier utente specifico P2 2 ore AI Engineering
Cost Spike Basso per utente - alto per budget Budget mensile P2 4 ore Finance + AI Engineering
Timeout (isolato) Medio - attesa + fallback Singola richiesta P3 Next business day AI Engineering
Malformed Output Medio - errore parsing Singola richiesta P3 Next business day AI Engineering
Refusal Basso - utente non ottiene risposta Singola richiesta P3 Sprint successivo AI Engineering
Hard API Error (transient) Basso - retry automatico Singola richiesta P4 Monitoraggio AI Engineering
Context Overflow Basso - richiesta rifiutata Singola richiesta P4 Monitoraggio AI Engineering
Tool Error Medio - agente bloccato Singola sessione P3 Next business day AI Engineering

La hallucination non rilevata occupa la posizione P1 non perché sia frequente, ma perché il suo impatto è asimmetrico: una sola risposta errata presentata con sicurezza può causare danni reputazionali o legali che superano di gran lunga il costo tecnico di qualsiasi altro failure mode. Questo giustifica l'investimento in detection attiva anche su sistemi con budget limitato.

FAQ

Quali sono i failure mode più frequenti di un LLM in produzione?

I più frequenti sono rate limit (HTTP 429), timeout e malformed output. In produzione con Ollama on-prem, il timeout supera il 30s nel 3–8% delle call durante picchi di carico, mentre il rate limit colpisce quasi esclusivamente servizi cloud con tier free o pay-per-use. Il malformed output è sottostimato perché spesso non viene loggato correttamente: se non hai un parser esplicito che distingue i livelli di fallback, i casi di JSON rotto vengono inghiottiti da un generico "errore di parsing".

Come si detecta una risposta hallucinated da un LLM?

La detection automatica si basa su tre segnali: confidence score sotto soglia (se il modello lo espone), cross-check con RAG sources (la risposta non è supportata da nessun documento recuperato), e pattern detection su affermazioni di certezza assoluta su fatti verificabili. Nessun metodo è infallibile: combinare almeno due segnali riduce i falsi negativi. In sistemi critici, aggiungi una human review queue per le risposte classificate ad alto rischio.

Qual è la differenza tra timeout e context overflow?

Il timeout avviene quando il modello non restituisce risposta entro il limite configurato (es. 30s): può essere causato da carico, contesto lungo o infrastruttura. Il context overflow avviene quando il numero di token del prompt supera la finestra massima del modello (es. 128k token per llama3.1): il modello rifiuta la richiesta con errore esplicito, non con un timeout. Il contesto lungo può causare entrambi: se sei vicino al limite, il modello potrebbe rispondere lentamente (→ timeout) oppure rifiutare direttamente (→ overflow). La prevenzione del context overflow elimina anche una classe di timeout.

Come implementare un fallback su risposta cached quando Ollama è in timeout?

La strategia è: wrappa la call Ollama con asyncio.wait_for(timeout=30), intercetta asyncio.TimeoutError, cerca in Redis una risposta cached per la stessa query (hash SHA256 del prompt normalizzato), restituisci la cache con un disclaimer visibile all'utente. Se la cache non esiste, restituisci una risposta statica di degradation. Salva sempre nella cache dopo una call riuscita con TTL configurabile (2 ore è un buon default per contenuti semi-statici).

Il circuit breaker serve anche con un provider on-prem come Ollama?

Sì, anzi è ancora più importante: Ollama on-prem non ha SLA garantiti, può saturarsi con richieste concorrenti, e un loop di retry senza circuit breaker può aggravare la situazione. Il circuit breaker permette al sistema di "respirare" aprendo il circuito dopo N errori consecutivi e riprovando solo dopo un cooldown. Con Ollama, un threshold di 5 errori e un cooldown di 60 secondi è un punto di partenza ragionevole: calibra poi su dati reali del tuo carico.

Cosa fare quando il modello restituisce JSON malformato?

Implementa un parser con fallback a tre livelli: (1) json.loads diretto, (2) estrazione regex del JSON dall'output (i modelli spesso aggiungono testo prima o dopo), (3) valore di default tipizzato con log del raw output. Se tutti e tre falliscono, hai due opzioni: re-prompt con schema esplicito (costoso ma accurato) oppure restituire il default con un flag che indica parsing fallito. Logga sempre il raw output dei fallimenti: dopo 50 esempi avrai un pattern chiaro su come il tuo modello specifico rompe il JSON e potrai ottimizzare l'estrazione regex.

Come si gestisce la model deprecation senza downtime?

La model deprecation va trattata come un evento pianificato, non un'emergenza. Strategie: monitora i changelog del provider (Ollama pubblica release notes per ogni modello), testa il modello successore in shadow mode (ricevi dual-response senza mostrarla all'utente), automatizza il rollover con feature flag o variabile d'ambiente. Per Ollama on-prem, il pinning esplicito del tag modello in configurazione (es. llama3.1:8b-instruct-q4_K_M invece di llama3.1) evita aggiornamenti accidentali durante i pull.

Conclusione

I 12 failure mode che abbiamo classificato non sono casi rari: sono la norma in qualsiasi servizio AI in produzione con volume non banale. La differenza tra un sistema fragile e uno resiliente non è l'assenza di questi failure, ma la capacità di riconoscerli istantaneamente, contenerli prima che si propaghino e servire comunque una risposta utile all'utente - anche se degradata.

Il pattern che ho implementato nel ml-service di questo portfolio (timeout 30s → cache Redis → risposta statica) gestisce il caso più frequente con tre righe di logica reale. L'enum LLMFailureMode garantisce che ogni componente del sistema parli la stessa lingua quando un failure viene loggato, alertato o incluso in un post-mortem. Il circuit breaker impedisce che un Ollama momentaneamente saturo diventi un failure sistemico.

Prossimo episodio: passiamo dall'identificazione dei failure alla loro prevenzione strutturale. Nel prossimo articolo della serie esploreremo il circuit breaker LLM con Resilience4j e Python puro a confronto: quando usare una libreria battle-tested, quando implementare da zero, e come configurare il half-open probing per un recovery automatico sicuro.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

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