Salta al contenuto principale

Production safety LLM: rate limit, circuit breaker, cost cap, SLA guarantee

Production safety LLM: rate limit, circuit breaker, cost cap, SLA guarantee: Production safety LLM: rate limit, circuit breaker, cost cap, SLA guarantee...

Cosa ne pensi di questo articolo?

Production safety LLM: rate limit, circuit breaker, cost cap, SLA guarantee
AI InfrastructureFoto:Tara WinsteadPexels

In Breve

Production safety LLM: rate limit, circuit breaker, cost cap, SLA guarantee: Production safety LLM: rate limit, circuit breaker, cost cap, SLA guarantee...

Production safety LLM: rate limit, circuit breaker, cost cap, SLA guarantee

vLLM spinge 800 token al secondo. È una potenza enorme e, senza nessun guardrail, è anche un vettore di attacco perfetto. Un utente con uno script banale può inviare richieste in loop e consumare il tuo budget mensile di GPU in meno di sessanta secondi. Non è fantascienza: è esattamente quello che succede quando metti online un endpoint LLM senza protezioni.

In questo episodio costruiamo i quattro pilastri della production safety per un LLM self-hosted: rate limit per-user e per-model con Bucket4j in Spring Boot, circuit breaker GPU-aware che monitora l'utilizzo reale della scheda video, cost cap per sessione tramite contatore token su Redis, e SLA garantiti con alert Prometheus su p50/p95/p99. A chiudere il tutto, un kill switch manuale con fallback a provider cloud come valvola di sicurezza di ultimo livello.

Il portfolio backend di Federico usa già Bucket4j con 120 req/min sulle API generali e 6 req/min sugli endpoint AI. In questo articolo partiamo da quella base e la estendiamo sistematicamente fino a coprire tutti gli scenari di abuso e degrado prevedibili in un ambiente di produzione reale.

Cosa Imparerai

  • Configurare rate limit per-user e per-model con Bucket4j in Spring Boot
  • Implementare un circuit breaker che apre quando la GPU supera il 90% di utilizzo
  • Costruire un cost cap per sessione basato su conteggio token con Redis TTL
  • Definire target SLA p50/p95/p99 realistici e scrivere alert Prometheus
  • Scegliere tra coda e reject-fast in base al tuo caso d'uso specifico
  • Implementare un kill switch con fallback OpenAI trasparente al client
  • Testare la tenuta dell'intero stack con script di stress test in bash

Prerequisiti

  • Aver letto EP03 sulla vLLM infrastructure o avere esperienza con serving LLM
  • Familiarità con Spring Boot 3.x e/o FastAPI
  • Redis installato e accessibile (porta 6379)
  • Prometheus + Grafana attivi o in fase di configurazione
  • Esperienza con configurazione di microservizi in produzione

Sezione 1 - Rate Limit: per-user, per-endpoint e per-model

Il rate limiting in un sistema LLM ha tre dimensioni indipendenti che devi gestire separatamente. La prima è il rate per-user: quante richieste al minuto può fare un singolo utente autenticato. La seconda è il rate per-endpoint: quante richieste totali al secondo regge il tuo backend prima di saturare la coda. La terza, specifica per i sistemi multi-modello, è il rate per-model: quante richieste parallele può ricevere ciascun modello, per evitare che il modello da 70B monopolizzi la GPU impedendo le richieste verso quello da 8B usato per le risposte interattive.

Bucket4j implementa il token bucket algorithm: ogni bucket ha una capacità massima di token e si riempie a una velocità costante (refill rate). Ogni richiesta consuma uno o più token. Quando il bucket è vuoto, la richiesta viene rifiutata con 429. La libreria supporta storage distribuito su Redis, essenziale per ambienti multi-istanza, tramite il modulo bucket4j-redis.

Vediamo la configurazione completa per Spring Boot con rate limit per-user differenziato per tier e per-model:

// RateLimitConfig.java - Configurazione Bucket4j per tier utente e per modello
@Configuration
@EnableCaching
public class RateLimitConfig {

    // Capacità massima del bucket e velocità di ricarica per tier
    public static final Map<String, BandwidthDefinition> USER_TIERS = Map.of(
        "FREE",       new BandwidthDefinition(10,  Duration.ofMinutes(1)),  // 10 req/min
        "PRO",        new BandwidthDefinition(60,  Duration.ofMinutes(1)),  // 60 req/min
        "ENTERPRISE", new BandwidthDefinition(300, Duration.ofMinutes(1))   // 300 req/min
    );

    // Rate per-model: previene monopolizzazione GPU da modello pesante
    public static final Map<String, BandwidthDefinition> MODEL_RATES = Map.of(
        "llama3.1:70b", new BandwidthDefinition(5,  Duration.ofMinutes(1)), // 5 req/min globale
        "llama3.1:8b",  new BandwidthDefinition(200, Duration.ofMinutes(1)), // 200 req/min globale
        "mistral:7b",   new BandwidthDefinition(150, Duration.ofMinutes(1))
    );

    @Bean
    public ProxyManager<String> bucketProxyManager(RedissonClient redissonClient) {
        // Usa Redisson per storage distribuito: funziona anche su cluster multi-istanza
        return Bucket4jRedisson.casBasedProxyManager(redissonClient);
    }

    // Crea o recupera il bucket per uno specifico utente e tier
    public Bucket resolveBucketForUser(String userId, String tier,
                                        ProxyManager<String> proxyManager) {
        BandwidthDefinition def = USER_TIERS.getOrDefault(tier,
            USER_TIERS.get("FREE")); // fallback a FREE se tier sconosciuto

        BucketConfiguration config = BucketConfiguration.builder()
            .addLimit(Bandwidth.classic(
                def.capacity(),
                Refill.greedy(def.capacity(), def.period())
            ))
            .build();

        // Chiave Redis: "bucket:user:{userId}" - TTL 2x period per cleanup automatico
        return proxyManager.builder().build("bucket:user:" + userId, () -> config);
    }

    // Crea o recupera il bucket globale per un modello specifico
    public Bucket resolveBucketForModel(String modelName,
                                         ProxyManager<String> proxyManager) {
        BandwidthDefinition def = MODEL_RATES.getOrDefault(modelName,
            new BandwidthDefinition(50, Duration.ofMinutes(1))); // default 50 req/min

        BucketConfiguration config = BucketConfiguration.builder()
            .addLimit(Bandwidth.classic(
                def.capacity(),
                Refill.greedy(def.capacity(), def.period())
            ))
            .build();

        return proxyManager.builder().build("bucket:model:" + modelName, () -> config);
    }

    public record BandwidthDefinition(long capacity, Duration period) {}
}

Il filtro Spring che applica entrambi i rate limit in sequenza, con header informativi per il client:

// LlmRateLimitFilter.java - Filtro che applica rate limit user + model in cascata
@Component
@Order(1) // eseguito prima di qualsiasi altro filtro
public class LlmRateLimitFilter extends OncePerRequestFilter {

    private final ProxyManager<String> bucketProxyManager;
    private final RateLimitConfig rateLimitConfig;
    private final UserTierService userTierService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws IOException, ServletException {

        // Applica solo alle richieste verso l'endpoint LLM
        if (!request.getRequestURI().startsWith("/api/v1/ai/")) {
            chain.doFilter(request, response);
            return;
        }

        String userId = extractUserId(request); // da JWT
        String tier   = userTierService.getTier(userId);
        String model  = extractModelParam(request); // da query param o body

        // Controlla il bucket dell'utente
        Bucket userBucket = rateLimitConfig.resolveBucketForUser(
            userId, tier, bucketProxyManager);
        ConsumptionProbe userProbe = userBucket.tryConsumeAndReturnRemaining(1);

        if (!userProbe.isConsumed()) {
            long waitSeconds = userProbe.getNanosToWaitForRefill() / 1_000_000_000;
            response.setHeader("X-RateLimit-Tier", tier);
            response.setHeader("Retry-After", String.valueOf(waitSeconds));
            response.setStatus(429);
            response.getWriter().write(
                "{\"error\":\"rate_limit_exceeded\",\"tier\":\"" + tier +
                "\",\"retry_after_seconds\":" + waitSeconds + "}"
            );
            return;
        }

        // Se l'utente passa, controlla il bucket del modello
        if (model != null) {
            Bucket modelBucket = rateLimitConfig.resolveBucketForModel(
                model, bucketProxyManager);
            if (!modelBucket.tryConsume(1)) {
                response.setHeader("X-RateLimit-Model", model);
                response.setStatus(429);
                response.getWriter().write(
                    "{\"error\":\"model_capacity_exceeded\",\"model\":\"" + model + "\"}"
                );
                return;
            }
        }

        // Aggiunge header informativi prima di procedere
        response.setHeader("X-RateLimit-Remaining",
            String.valueOf(userProbe.getRemainingTokens()));
        chain.doFilter(request, response);
    }

    private String extractUserId(HttpServletRequest request) {
        // Estrae user ID dal JWT nell'header Authorization
        String token = request.getHeader("Authorization");
        if (token != null && token.startsWith("Bearer ")) {
            return jwtService.extractUserId(token.substring(7));
        }
        return request.getRemoteAddr(); // fallback a IP per richieste non autenticate
    }
}
Tier Rate (req/min) Token AI (req/min) Max sessioni parallele Cost cap/sessione
Free 10 6 1 5.000 token
Pro 60 30 5 50.000 token
Enterprise 300 120 25 500.000 token

Nota il caso del portfolio backend di Federico: l'endpoint /api/v1/ai/* usa un bucket separato da 6 req/min rispetto alle 120 req/min delle API generali. Questa separazione è fondamentale perché una richiesta LLM occupa la GPU per 2-5 secondi, mentre una chiamata REST normale si risolve in millisecondi. Mescolare i due nel medesimo bucket significa permettere a un utente di saturare le 120 chiamate con richieste LLM e bloccare completamente il servizio.

Sezione 2 - Circuit Breaker GPU-Aware

Il circuit breaker classico monitora l'error rate di un servizio. In un sistema LLM, però, il problema più comune non è un crash esplicito ma un degrado silenzioso: la GPU è al 92% di utilizzo, le richieste vengono accodate, la latenza sale a 15 secondi, ma nessun errore HTTP 5xx viene sollevato. Il circuit breaker per LLM deve essere GPU-aware: deve aprirsi non solo sugli errori ma anche quando le metriche hardware indicano un sistema sotto stress.

L'approccio che usiamo qui integra pybreaker in Python con un listener che raccoglie metriche nvidia-smi ogni 10 secondi. Quando la GPU utilization supera il 90% per almeno 30 secondi consecutivi, il circuit breaker passa in stato OPEN e tutte le nuove richieste ricevono un 503 immediato invece di accumularsi in coda.

# circuit_breaker.py - Circuit breaker GPU-aware per vLLM
import subprocess
import pybreaker
import asyncio
import time
from dataclasses import dataclass
from typing import Optional
import httpx

@dataclass
class GpuMetrics:
    """Snapshot delle metriche GPU correnti."""
    utilization_pct: float   # percentuale utilizzo compute
    memory_used_mb: float    # memoria VRAM usata
    temperature_c: float     # temperatura in gradi Celsius
    timestamp: float         # unix timestamp

class GpuMonitor:
    """Raccoglie metriche GPU da nvidia-smi ogni 10 secondi."""

    def __init__(self, check_interval: int = 10):
        self.check_interval = check_interval
        self.latest_metrics: Optional[GpuMetrics] = None
        self._running = False

    async def start(self):
        self._running = True
        while self._running:
            self.latest_metrics = self._collect_metrics()
            await asyncio.sleep(self.check_interval)

    def _collect_metrics(self) -> GpuMetrics:
        """Esegue nvidia-smi e parsa l'output in formato CSV."""
        result = subprocess.run([
            "nvidia-smi",
            "--query-gpu=utilization.gpu,memory.used,temperature.gpu",
            "--format=csv,noheader,nounits"
        ], capture_output=True, text=True, timeout=5)

        parts = result.stdout.strip().split(", ")
        return GpuMetrics(
            utilization_pct=float(parts[0]),
            memory_used_mb=float(parts[1]),
            temperature_c=float(parts[2]),
            timestamp=time.time()
        )

    def is_under_pressure(self, utilization_threshold: float = 90.0) -> bool:
        """True se la GPU è sopra soglia (trigger circuit breaker)."""
        if self.latest_metrics is None:
            return False  # metriche non ancora disponibili: non bloccare
        return self.latest_metrics.utilization_pct >= utilization_threshold


class LlmCircuitBreakerListener(pybreaker.CircuitBreakerListener):
    """Listener che logga le transizioni di stato del circuit breaker."""

    def state_change(self, cb, old_state, new_state):
        # Logga la transizione con contesto
        print(f"[CIRCUIT_BREAKER] {cb.name}: {old_state} -> {new_state} "
              f"at {time.strftime('%Y-%m-%dT%H:%M:%SZ')}")
        # In produzione: emetti una metrica Prometheus counter qui

    def failure(self, cb, exc):
        print(f"[CIRCUIT_BREAKER] {cb.name} failure: {exc}")


# Istanza globale del circuit breaker vLLM
vllm_breaker = pybreaker.CircuitBreaker(
    fail_max=5,          # apre dopo 5 errori consecutivi
    reset_timeout=30,    # tenta reset dopo 30 secondi in OPEN
    listeners=[LlmCircuitBreakerListener()]
)

gpu_monitor = GpuMonitor(check_interval=10)


async def call_vllm_with_breaker(
    prompt: str,
    model: str,
    vllm_base_url: str = "http://localhost:8000"
) -> dict:
    """
    Invia richiesta a vLLM con circuit breaker + GPU check combinati.
    Apre il circuito sia su errori HTTP che su GPU overcrowded.
    """
    # Controlla lo stato GPU prima ancora di chiamare il breaker
    if gpu_monitor.is_under_pressure(utilization_threshold=90.0):
        raise pybreaker.CircuitBreakerError(
            "GPU utilization >= 90%: circuit open (GPU-triggered)"
        )

    @vllm_breaker
    async def _inner_call():
        async with httpx.AsyncClient(timeout=30.0) as client:
            response = await client.post(
                f"{vllm_base_url}/v1/chat/completions",
                json={
                    "model": model,
                    "messages": [{"role": "user", "content": prompt}],
                    "max_tokens": 1024
                }
            )
            response.raise_for_status()  # 4xx/5xx triggera il breaker
            return response.json()

    return await _inner_call()

Questo approccio combina due modalità di apertura del circuito: la modalità classica basata su errori HTTP (5 fallimenti consecutivi) e la modalità proattiva basata su GPU utilization. Il risultato è un sistema che si difende anche dai degrado silenzioso, che è il caso più comune nei server LLM sotto carico pesante.

Attenzione: circuit breaker a stato condiviso

In un ambiente con più istanze del middleware (load balancer davanti a 3 repliche FastAPI), il circuit breaker deve essere in stato condiviso su Redis altrimenti ogni istanza prende decisioni indipendenti. pybreaker supporta storage custom: implementa un CircuitBreakerStorage che legge e scrive lo stato su Redis per garantire coerenza tra repliche.

Sezione 3 - Cost Cap per Sessione con Redis

Il cost cap è diverso dal rate limit: non limita il numero di richieste ma la quantità di risorse consumate. Per un LLM, la risorsa principale è il numero di token generati. Ogni token ha un costo virtuale calcolato in base al prezzo dell'hardware: un server con GPU A100 a €2/ora che genera 800 token/sec ha un costo per token di circa €0.0000007 (€2 diviso 2.880.000 token generabili in 1 ora).

Memorizzare il contatore su Redis con TTL 24h permette di resettare automaticamente il cap ogni giorno senza logica schedulata. Quando l'utente supera la soglia del suo tier, le richieste successive ricevono 429 con un messaggio che indica il reset time.

# config/cost_cap.yaml - Configurazione cost cap per tier utente
cost_cap:
  # Prezzi virtuali per token (in micro-euro, moltiplicati per 1000 per evitare float)
  # Basati su GPU A100 @2.00 EUR/ora, 800 token/sec throughput vLLM
  token_cost_micro_eur: 0.7   # 0.0000007 EUR per token = 0.7 micro-EUR

  tiers:
    FREE:
      max_tokens_per_session: 5000        # max 0.0035 EUR per sessione
      session_ttl_seconds: 86400          # reset ogni 24 ore
      hard_stop: true                     # rifiuta richieste oltre soglia

    PRO:
      max_tokens_per_session: 50000       # max 0.035 EUR per sessione
      session_ttl_seconds: 86400
      hard_stop: false                    # avvisa ma non blocca (soft cap)

    ENTERPRISE:
      max_tokens_per_session: 500000      # max 0.35 EUR per sessione
      session_ttl_seconds: 86400
      hard_stop: false
      alert_at_pct: 80                    # alert quando arriva all'80% del cap

  # Chiave Redis: "cost:user:{userId}:session:{date}"
  redis_key_pattern: "cost:user:{user_id}:session:{date}"
  redis_expire_jitter_seconds: 3600      # jitter ±1h per evitare reset sincronizzati
# cost_cap_middleware.py - Middleware FastAPI per controllo cost cap
import redis.asyncio as aioredis
from datetime import date
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
import json

class CostCapMiddleware(BaseHTTPMiddleware):
    """
    Traccia i token consumati per sessione utente e applica il cost cap.
    Aggiorna il contatore DOPO la risposta per contare token reali (non stimati).
    """

    def __init__(self, app, redis_client: aioredis.Redis, cost_config: dict):
        super().__init__(app)
        self.redis = redis_client
        self.config = cost_config

    async def dispatch(self, request: Request, call_next):
        # Applica solo agli endpoint AI
        if not request.url.path.startswith("/v1/"):
            return await call_next(request)

        user_id = request.state.user_id  # iniettato da AuthMiddleware
        tier = request.state.tier        # iniettato da AuthMiddleware
        tier_config = self.config["tiers"][tier]

        # Verifica il cap PRIMA di eseguire la richiesta
        current_tokens = await self._get_current_tokens(user_id)
        max_tokens = tier_config["max_tokens_per_session"]

        if current_tokens >= max_tokens and tier_config.get("hard_stop", True):
            reset_at = self._get_reset_time()
            raise HTTPException(
                status_code=429,
                detail={
                    "error": "session_cost_cap_exceeded",
                    "used_tokens": current_tokens,
                    "max_tokens": max_tokens,
                    "tier": tier,
                    "reset_at_utc": reset_at
                }
            )

        # Avviso soft cap per tier PRO/Enterprise
        alert_pct = tier_config.get("alert_at_pct", 100)
        if current_tokens >= (max_tokens * alert_pct / 100):
            request.state.cost_warning = True

        # Esegui la richiesta LLM
        response = await call_next(request)

        # Aggiorna contatore con token reali dalla risposta
        if hasattr(request.state, "tokens_used"):
            tokens_used = request.state.tokens_used  # iniettato da LlmResponseHandler
            await self._increment_tokens(user_id, tokens_used, tier_config)

        return response

    async def _get_current_tokens(self, user_id: str) -> int:
        key = f"cost:user:{user_id}:session:{date.today().isoformat()}"
        value = await self.redis.get(key)
        return int(value) if value else 0

    async def _increment_tokens(self, user_id: str, tokens: int, tier_config: dict):
        key = f"cost:user:{user_id}:session:{date.today().isoformat()}"
        ttl = tier_config["session_ttl_seconds"]
        # INCR atomico + EXPIRE: anche con richieste concorrenti il contatore è consistente
        pipe = self.redis.pipeline()
        pipe.incrby(key, tokens)
        pipe.expire(key, ttl)
        await pipe.execute()

    def _get_reset_time(self) -> str:
        from datetime import datetime, timedelta, timezone
        tomorrow = datetime.now(timezone.utc).replace(
            hour=0, minute=0, second=0, microsecond=0
        ) + timedelta(days=1)
        return tomorrow.isoformat()

Sezione 4 - SLA Guarantee: target e alert Prometheus

Definire un SLA senza misurarlo è un esercizio inutile. Prima di tutto devi avere obiettivi chiari su quali percentili vuoi garantire. La tabella seguente mostra target realistici per diversi scenari di deployment, basati su benchmark reali di vLLM su hardware consumer e data center:

Scenario Hardware Modello p50 TTFT p95 TTFT p99 TTFT p99 E2E (512 token)
Dev / prototipo RTX 3090 24GB Llama3.1 8B <80ms <200ms <500ms <8s
Produzione small A10G 24GB Llama3.1 8B <40ms <100ms <250ms <4s
Produzione medium A100 80GB Llama3.1 70B <120ms <300ms <800ms <15s
Hetzner VPS (Federico) no GPU dedicata Llama3.1 8B (Ollama) <200ms <800ms <2s <30s

Il TTFT (Time To First Token) è la metrica più critica per l'esperienza utente in un chatbot interattivo: rappresenta il tempo che l'utente aspetta prima di vedere la prima parola. Il p99 E2E (end-to-end per una risposta completa da 512 token) è più rilevante per task batch o di analisi. Definisci obiettivi separati per entrambi.

Gli alert Prometheus scattano quando la violazione SLA persiste per almeno 2 minuti, evitando falsi positivi da picchi momentanei durante la fase di warmup o batch bursting occasionale:

# prometheus/alerts/llm_sla.yaml - Alert rules per SLA p99 vLLM
groups:
  - name: llm_sla_alerts
    interval: 30s
    rules:

      # Alert: TTFT p99 supera 500ms per 2 minuti (scenario RTX 3090)
      - alert: LlmTtftP99High
        expr: |
          histogram_quantile(
            0.99,
            sum by (le, model) (
              rate(vllm_time_to_first_token_seconds_bucket[5m])
            )
          ) > 0.5
        for: 2m
        labels:
          severity: warning
          team: ai-infra
        annotations:
          summary: "vLLM TTFT p99 {{ $labels.model }} supera 500ms"
          description: |
            Il 99° percentile del Time To First Token per il modello
            {{ $labels.model }} è {{ $value | humanizeDuration }}.
            SLA target: 500ms. Verificare GPU utilization e coda richieste.

      # Alert: latency E2E p99 supera 8 secondi
      - alert: LlmE2eLatencyP99Critical
        expr: |
          histogram_quantile(
            0.99,
            sum by (le) (
              rate(vllm_e2e_request_latency_seconds_bucket[5m])
            )
          ) > 8.0
        for: 2m
        labels:
          severity: critical
          team: ai-infra
        annotations:
          summary: "vLLM E2E latency p99 supera 8 secondi - SLA violato"
          description: "p99={{ $value | humanizeDuration }}. Aprire circuit breaker manuale se persiste."

      # Alert: GPU utilization sopra 90% per più di 3 minuti
      - alert: GpuHighUtilization
        expr: |
          avg_over_time(
            nvidia_smi_utilization_gpu_ratio[3m]
          ) > 0.90
        for: 3m
        labels:
          severity: warning
          team: ai-infra
        annotations:
          summary: "GPU utilization media >90% da 3 minuti"
          description: "GPU={{ $labels.gpu }}, util={{ $value | humanizePercentage }}. Considerare circuit breaker."

      # Alert: coda richieste in attesa supera 50 elementi
      - alert: LlmQueueBackpressure
        expr: vllm_num_requests_waiting > 50
        for: 1m
        labels:
          severity: warning
          team: ai-infra
        annotations:
          summary: "Coda vLLM: {{ $value }} richieste in attesa"
          description: "Possibile saturazione GPU o configurazione batch subottimale."

Sezione 5 - Queue vs Reject-Fast: il Trade-off Decisivo

Quando il sistema è sovraccarico, devi scegliere tra due strategie fondamentalmente diverse: mettere le richieste in coda oppure rifiutarle immediatamente. Non esiste una risposta universalmente corretta: dipende dal caso d'uso specifico e dal tipo di esperienza che vuoi offrire agli utenti.

La strategia a coda è giusta quando il tuo utente è un processo automatico (batch processing, pipeline di analisi, webhook asincrono) che può aspettare minuti o ore senza che l'esperienza venga compromessa. Stai essenzialmente trasformando le richieste da sincrone ad asincrone: il client riceve immediatamente un job ID e fa polling per il risultato.

La strategia reject-fast è giusta quando hai SLA di latenza interattiva (chatbot, copilot, completamento in tempo reale). Un utente che aspetta 30 secondi per una risposta si è già disconnesso, quindi la coda non serve: è meglio comunicare subito l'errore e permettere al client di mostrare un messaggio di backoff.

# request_router.py - Router che sceglie queue o reject-fast per tipo richiesta
import asyncio
import redis.asyncio as aioredis
from enum import Enum
from dataclasses import dataclass
from typing import Optional
import uuid
import json

class RequestPriority(Enum):
    INTERACTIVE = "interactive"  # chatbot, copilot - usa reject-fast
    BATCH = "batch"              # analisi, summarization - usa coda

@dataclass
class QueueConfig:
    max_queue_size: int = 20        # rifiuta se coda > 20 (reject-fast oltre soglia)
    max_wait_seconds: int = 120     # TTL job in coda
    poll_interval_seconds: float = 0.5

async def route_request(
    request_payload: dict,
    priority: RequestPriority,
    redis_client: aioredis.Redis,
    queue_config: QueueConfig,
    vllm_client
) -> dict:
    """
    Smista la richiesta verso coda o reject-fast in base alla priorità.
    """
    if priority == RequestPriority.INTERACTIVE:
        # REJECT-FAST: tenta esecuzione immediata, fallisce veloce se sistema pieno
        return await _execute_reject_fast(request_payload, vllm_client)

    elif priority == RequestPriority.BATCH:
        # QUEUE: inserisce in coda Redis, restituisce job ID per polling
        return await _enqueue_request(request_payload, redis_client, queue_config)


async def _execute_reject_fast(payload: dict, vllm_client) -> dict:
    """
    Tenta l'esecuzione con timeout aggressivo (5s).
    Se il sistema non risponde, lancia immediatamente l'errore.
    """
    try:
        # timeout=5s: se vLLM non inizia a rispondere entro 5s, circuit aperto
        result = await asyncio.wait_for(
            vllm_client.generate(payload),
            timeout=5.0
        )
        return {"status": "ok", "result": result}
    except asyncio.TimeoutError:
        return {
            "status": "error",
            "error": "service_busy",
            "message": "Il modello è momentaneamente sovraccarico. Riprova tra 10 secondi.",
            "retry_after_seconds": 10
        }


async def _enqueue_request(
    payload: dict,
    redis_client: aioredis.Redis,
    config: QueueConfig
) -> dict:
    """
    Inserisce la richiesta in una coda Redis FIFO.
    Restituisce job_id per polling asincrono.
    """
    # Controlla dimensione coda prima di inserire
    queue_length = await redis_client.llen("llm:batch:queue")
    if queue_length >= config.max_queue_size:
        return {
            "status": "error",
            "error": "queue_full",
            "message": f"Coda batch piena ({queue_length}/{config.max_queue_size}). Riprova tra qualche minuto.",
            "current_queue_depth": queue_length
        }

    job_id = str(uuid.uuid4())
    job = {
        "job_id": job_id,
        "payload": payload,
        "enqueued_at": asyncio.get_event_loop().time()
    }

    # LPUSH + EXPIRE per TTL automatico
    await redis_client.lpush("llm:batch:queue", json.dumps(job))

    return {
        "status": "queued",
        "job_id": job_id,
        "estimated_wait_seconds": queue_length * 3,  # stima: 3s per job in coda
        "poll_url": f"/api/v1/ai/jobs/{job_id}/status"
    }

Sezione 6 - Kill Switch e Fallback Cloud

Il kill switch è il layer di sicurezza finale: un meccanismo manuale (o automatico come ultima risorsa) che devia tutto il traffico LLM verso un provider cloud quando l'infrastruttura self-hosted è compromessa. Il porto sicuro del team di Federico è configurato per usare Ollama su VPS come provider primario: qualsiasi fallback a provider esterni è una decisione esplicita, non automatica, per mantenere il controllo sui costi.

Il middleware sottostante implementa un flag su Redis che può essere attivato via API interna (chiamata dal team o da un alert Prometheus in modalità auto-trigger con soglie molto conservative). Quando il flag è attivo, ogni richiesta viene instradata a OpenAI con la stessa struttura di risposta, rendendo il fallback trasparente al client.

# kill_switch_middleware.py - Middleware kill switch con fallback OpenAI
import redis.asyncio as aioredis
import httpx
import os
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware

KILL_SWITCH_KEY = "llm:kill_switch:active"
OPENAI_FALLBACK_URL = "https://api.openai.com/v1/chat/completions"

class KillSwitchMiddleware(BaseHTTPMiddleware):
    """
    Controlla il flag kill switch su Redis.
    Se attivo, redirige le richieste LLM a OpenAI come fallback cloud.
    NOTA: il flag deve essere attivato esplicitamente dal team via API interna.
    OpenAI non è mai il provider primario (vedi feedback_ai_provider_ollama_only).
    """

    def __init__(self, app, redis_client: aioredis.Redis):
        super().__init__(app)
        self.redis = redis_client
        self._cache_ttl = 5     # ri-controlla Redis ogni 5 secondi (evita overhead)
        self._cached_state = False
        self._cache_ts = 0.0

    async def dispatch(self, request: Request, call_next):
        import time

        # Usa cache per evitare query Redis a ogni richiesta
        now = time.time()
        if now - self._cache_ts > self._cache_ttl:
            flag = await self.redis.get(KILL_SWITCH_KEY)
            self._cached_state = flag is not None and flag.decode() == "1"
            self._cache_ts = now

        # Se kill switch attivo e richiesta verso endpoint AI: devia a OpenAI
        if self._cached_state and request.url.path.startswith("/v1/chat/"):
            return await self._forward_to_openai(request)

        return await call_next(request)

    async def _forward_to_openai(self, request: Request) -> Response:
        """
        Inoltra la richiesta a OpenAI mantenendo la stessa struttura request/response.
        La chiave API è SOLO in variabile d'ambiente server-side, mai nel client.
        """
        openai_key = os.environ.get("OPENAI_FALLBACK_API_KEY")
        if not openai_key:
            # Nessuna chiave configurata: fallback non disponibile, restituisce 503
            return Response(
                content='{"error":"fallback_unavailable","message":"Self-hosted LLM non disponibile e nessun fallback configurato"}',
                status_code=503,
                media_type="application/json"
            )

        body = await request.body()

        async with httpx.AsyncClient(timeout=60.0) as client:
            resp = await client.post(
                OPENAI_FALLBACK_URL,
                content=body,
                headers={
                    "Authorization": f"Bearer {openai_key}",
                    "Content-Type": "application/json"
                }
            )

        # Aggiunge header per tracciare che la risposta viene dal fallback
        headers = dict(resp.headers)
        headers["X-Llm-Provider"] = "openai-fallback"
        headers["X-Kill-Switch-Active"] = "1"

        return Response(
            content=resp.content,
            status_code=resp.status_code,
            headers=headers,
            media_type="application/json"
        )


# API interna per attivare/disattivare il kill switch (richiede autenticazione admin)
# POST /internal/kill-switch/activate
# POST /internal/kill-switch/deactivate
async def activate_kill_switch(redis_client: aioredis.Redis, reason: str):
    """Attiva il kill switch e logga il motivo con timestamp."""
    import time
    await redis_client.set(KILL_SWITCH_KEY, "1")
    await redis_client.set(
        "llm:kill_switch:reason",
        f"{time.strftime('%Y-%m-%dT%H:%M:%SZ')} | {reason}"
    )

async def deactivate_kill_switch(redis_client: aioredis.Redis):
    """Disattiva il kill switch e riporta il traffico verso self-hosted."""
    await redis_client.delete(KILL_SWITCH_KEY)
    await redis_client.delete("llm:kill_switch:reason")

Sezione 7 - Stress Test: Verificare la Tenuta del Sistema

Prima di andare in produzione, devi verificare che rate limit, circuit breaker e cost cap funzionino effettivamente sotto carico reale. Lo script seguente simula 50 utenti concorrenti che inviano richieste in loop, verificando che il sistema risponda con i codici di stato corretti (200 per le richieste entro limite, 429 per quelle oltre soglia, 503 quando il circuit breaker è aperto).

#!/bin/bash
# stress_test_llm.sh - Script di stress test per production safety LLM
# Uso: ./stress_test_llm.sh <BASE_URL> <JWT_TOKEN> <CONCURRENT_USERS>

BASE_URL="${1:-http://localhost:8080}"
JWT_TOKEN="${2:-your-jwt-token-here}"
CONCURRENT_USERS="${3:-50}"
DURATION_SECONDS=60
REQUEST_INTERVAL=0.1  # 10 req/sec per utente = 500 req/sec totali

# Contatori risultati
count_200=0
count_429=0
count_503=0
count_other=0

echo "=== LLM Production Safety Stress Test ==="
echo "Target:      $BASE_URL"
echo "Concurrency: $CONCURRENT_USERS utenti"
echo "Duration:    ${DURATION_SECONDS}s"
echo "Rate:        $(echo "$CONCURRENT_USERS / $REQUEST_INTERVAL" | bc) req/sec totali"
echo ""

# Funzione per un singolo utente concorrente
run_user() {
    local user_id=$1
    local end_time=$((SECONDS + DURATION_SECONDS))
    local local_200=0 local_429=0 local_503=0

    while [ $SECONDS -lt $end_time ]; do
        response=$(curl -s -o /dev/null -w "%{http_code}" \
            -X POST "$BASE_URL/api/v1/ai/chat" \
            -H "Authorization: Bearer $JWT_TOKEN" \
            -H "Content-Type: application/json" \
            -d "{\"model\":\"llama3.1:8b\",\"message\":\"Cosa è il rate limiting?\",\"user_id\":\"test-user-$user_id\"}" \
            --max-time 10)

        case $response in
            200) ((local_200++)) ;;
            429) ((local_429++)) ;;
            503) ((local_503++)) ;;
        esac

        sleep $REQUEST_INTERVAL
    done

    # Scrive risultati in file temporaneo (thread-safe via append)
    echo "$local_200 $local_429 $local_503" >> /tmp/stress_results.$$
}

# Avvia utenti concorrenti in background
rm -f /tmp/stress_results.$$
for i in $(seq 1 $CONCURRENT_USERS); do
    run_user $i &
done

# Aspetta completamento
wait

# Aggrega risultati
while IFS=' ' read -r c200 c429 c503; do
    ((count_200 += c200))
    ((count_429 += c429))
    ((count_503 += c503))
done < /tmp/stress_results.$$

total=$((count_200 + count_429 + count_503))
echo ""
echo "=== Risultati Stress Test ==="
echo "Totale richieste:     $total"
echo "200 OK (entro limite): $count_200 ($(( count_200 * 100 / total ))%)"
echo "429 Rate Limited:      $count_429 ($(( count_429 * 100 / total ))%)"
echo "503 Circuit Open:      $count_503 ($(( count_503 * 100 / total ))%)"
echo ""
echo "Verifica attesa:"
echo "  - 429% dovrebbe essere ~60-80% con $CONCURRENT_USERS utenti e rate limit FREE=10req/min"
echo "  - 503% dovrebbe scattare se GPU utilization supera 90% durante il test"
echo "  - 200% dovrebbe essere stabile e non degradare nel tempo"

rm -f /tmp/stress_results.$$

Interpreta i risultati in questo modo: se la percentuale di 429 è nulla anche con 50 utenti concorrenti, il rate limit non sta funzionando. Se la percentuale di 503 appare immediatamente senza che la GPU sia sotto stress, il circuit breaker ha soglie troppo conservative. Il target è 429 alto (sistema che si difende), 503 basso (sistema che si apre solo in emergenza reale), e 200 stabile nel tempo (gli utenti entro soglia ricevono sempre risposta).

FAQ

Qual è la differenza tra rate limit e circuit breaker in un LLM self-hosted?

Il rate limit controlla quante richieste un singolo utente o endpoint può fare in un dato intervallo, restituendo 429 quando supera la soglia. Il circuit breaker monitora la salute del servizio LLM stesso (GPU utilization, error rate, latency) e apre il circuito rifiutando tutte le richieste quando il sistema è sotto stress, indipendentemente dal richiedente. I due pattern si complementano: rate limit protegge dalle singole richieste abusive, circuit breaker protegge l'infrastruttura dal collasso a cascata.

Come si calcola un cost cap realistico per sessione?

Calcola il costo per token dividendo il costo orario dell'hardware per il throughput in token/ora: GPU A100 a €2/ora con vLLM a 800 token/sec = €2 / (800 × 3600) = €0.00000069 per token. Moltiplica per la soglia desiderata: 50.000 token (tier Pro) = €0.035 per sessione. Questo approccio rende il cost cap indipendente dai prezzi cloud e ancorato al costo reale dell'infrastruttura.

Cosa succede se la GPU raggiunge il 95% di utilizzo?

Con GPU utilization al 95%, le nuove richieste accodate non trovano capacità computazionale libera: la latenza TTFT inizia a crescere in modo non lineare, fino a superare i timeout dei client. Il circuit breaker dovrebbe aprirsi prima, tipicamente a >85-90% sostenuto per 30 secondi, per evitare che la coda cresca indefinitamente. Le richieste in arrivo ricevono un 503 immediato invece di aspettare indefinitamente.

Queue o reject-fast: quando scegliere?

Scegli la coda quando il caso d'uso tollera latenza variabile: batch processing notturno, summarization asincrona, pipeline di analisi. Scegli reject-fast quando hai SLA di latenza stringenti (chatbot interattivo, copilot in tempo reale) perché l'utente preferisce un errore istantaneo a un'attesa di 30 secondi. In pratica molti sistemi usano entrambe: coda breve (10-20 elementi) per assorbire picchi momentanei, poi reject-fast se la coda è piena.

Come si implementa un fallback a un provider cloud senza esporre la chiave API nel frontend?

Il fallback deve sempre transitare dal backend (Spring Boot o FastAPI), mai dal frontend. Il middleware intercetta le richieste, e se il circuito è aperto, reindirizza la chiamata al provider esterno usando la chiave API conservata come variabile d'ambiente server-side. Il client riceve la stessa struttura di risposta senza sapere quale provider ha servito la richiesta. La chiave API non compare mai nel bundle JavaScript né nei log di accesso pubblici.

Quali metriche Prometheus sono essenziali per garantire SLA p99?

Le metriche critiche sono: vllm_time_to_first_token_seconds (TTFT histogram), vllm_e2e_request_latency_seconds (latency E2E), vllm_num_requests_waiting (coda), vllm_gpu_cache_usage_perc (KV cache satura), e un counter custom per token per utente per sessione. Gli alert devono scattare quando histogram_quantile(0.99) supera la soglia SLA per almeno 2 minuti, evitando falsi positivi da picchi momentanei.

Il backend Spring Boot con Bucket4j può gestire modelli AI multipli con rate diversi?

Sì. L'approccio mostrato nell'articolo usa due bucket separati: uno per-user (con capacità dipendente dal tier) e uno per-model (con capacità globale per evitare monopolizzazione GPU). Bucket4j su Redis distribuito garantisce che i contatori siano consistenti anche con più istanze Spring Boot in load balancing. La chiave Redis bucket:model:llama3.1:70b è condivisa tra tutte le istanze e usa CAS (Compare-And-Swap) per aggiornamenti atomici senza lock espliciti.

Conclusione

Un LLM self-hosted senza guardrail è una bomba a orologeria. Le quattro protezioni di questo episodio formano un sistema di difesa in profondità: il rate limit Bucket4j ferma gli abusi prima ancora che raggiungano vLLM; il circuit breaker GPU-aware si apre proattivamente quando l'hardware è sotto stress; il cost cap Redis impedisce che una singola sessione esaurisca il budget; gli alert Prometheus su p99 danno visibilità istantanea sulle violazioni SLA prima che diventino incidenti.

La scelta tra coda e reject-fast è strategica e non tecnica: dipende da chi usa il sistema e cosa si aspetta. Per un chatbot interattivo, un errore immediato è sempre preferibile a un'attesa lunga. Per una pipeline di analisi batch, la coda è essenziale. Il kill switch con fallback cloud è la rete di sicurezza finale, da attivare solo in emergenza e mai come percorso primario - la privacy e il controllo dei costi restano al centro della strategia.

Prossimo episodio: EP05 - Monitoring self-hosted LLM: GPU utilization, token latency, cost tracking real-time. Costruiremo dashboard Grafana complete, exporter Prometheus per vLLM e Ollama, e un sistema di cost tracking per utente visibile in tempo reale.

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