Salta al contenuto principale

Hybrid cloud-fallback strategy: self-hosted primario, OpenAI/Anthropic failover

Hybrid cloud-fallback strategy: self-hosted primario, OpenAI/Anthropic failover GPU morta alle 2:17 AM di un giovedi. Quaranta utenti attivi sulla piattaforma.

Cosa ne pensi di questo articolo?

Hybrid cloud-fallback strategy: self-hosted primario, OpenAI/Anthropic failover
AI InfrastructureFoto:Google DeepMindPexels

In Breve

Hybrid cloud-fallback strategy: self-hosted primario, OpenAI/Anthropic failover GPU morta alle 2:17 AM di un giovedi. Quaranta utenti attivi sulla piattaforma.

Hybrid cloud-fallback strategy: self-hosted primario, OpenAI/Anthropic failover

GPU morta alle 2:17 AM di un giovedi. Quaranta utenti attivi sulla piattaforma. Il processo Ollama crasha per un OOM su un modello da 13B caricato per errore da un deploy notturno automatico. I log mostrano 503 a cascata. Il fallback a OpenAI gpt-4o-mini si attiva in 800 millisecondi, salva la notte, risponde a tutte le chiamate. Ma quella notte costa €200 di API cloud perché nessuno aveva configurato un cap di spesa.

Questo episodio - l'ultimo della Serie 22 - affronta l'architettura che evita entrambi i problemi: il downtime senza fallback e la bolletta cloud senza limiti. Il principio fondamentale e semplice: self-hosted e il provider primario, cloud e il sistema di emergenza, non il contrario. L'obiettivo numerico che ho adottato nel portfolio di Federico Calò: meno del 5% delle chiamate LLM deve raggiungere OpenAI. Il restante 95% rimane su Ollama on-premise sul VPS Hetzner.

Cosa Imparerai

  • Come scrivere un health sentinel Python che monitora Ollama con retry esponenziale
  • Come configurare Resilience4j circuit breaker in Spring Boot per chiamate LLM
  • La routing logic Python che decide self-hosted vs cloud in tempo reale
  • Come costruire un billing audit middleware che traccia ogni token per provider
  • Differenza pratica tra warm fallback e cold fallback con numeri reali
  • Come calcolare il ROI della strategia ibrida per tre livelli di traffico
  • Come inviare un alert Telegram su attivazione fallback critico

Prerequisiti

  • Episodio 1 della serie: architettura Ollama vs vLLM
  • Python intermedio (async, dataclasses, httpx)
  • Concetto base di circuit breaker pattern (vedi Episodio 4)
  • Spring Boot base per la sezione Resilience4j (opzionale)
  • Chiave API OpenAI o Anthropic (anche solo per test)

Sezione 1 - Health Check: il Sentinel che Decide Tutto

Ogni decisione di routing nasce da un dato semplice: Ollama sta rispondendo o no? Non si tratta solo di un ping TCP. Un health check robusto per un LLM verifica tre livelli: la raggiungibilita del processo (porta 11434 aperta), la disponibilita del modello specifico (il modello richiesto e effettivamente caricato in memoria), e la latenza di inferenza (un token di test risponde entro la soglia configurata).

Il sentinel che uso nel ml-service del portfolio controlla tutti e tre i livelli ogni 30 secondi e mantiene uno stato interno con una sliding window degli ultimi 10 check. Se almeno 3 degli ultimi 5 check falliscono, il sentinel dichiara Ollama degraded e il router smette di inviare traffico al provider locale. Dopo 5 minuti di stato degraded continuativo, scatta il fallback cloud.

"""
health_sentinel.py - Monitora la salute di Ollama con sliding window.
Usato dal router per decidere se mandare traffico a self-hosted o cloud.
"""

import asyncio
import time
import httpx
from dataclasses import dataclass, field
from collections import deque
from typing import Literal

# Configurazione soglie
OLLAMA_BASE_URL = "http://localhost:11434"
HEALTH_CHECK_INTERVAL_SEC = 30
HEALTH_WINDOW_SIZE = 5        # ultimi N check considerati
FAIL_THRESHOLD = 3             # quanti fail nella window prima di dichiarare degraded
DEGRADED_TO_FALLBACK_SEC = 300 # 5 minuti degraded -> attiva fallback cloud
INFERENCE_TIMEOUT_SEC = 8.0    # timeout per il token di test

HealthStatus = Literal["healthy", "degraded", "fallback"]


@dataclass
class SentinelState:
    status: HealthStatus = "healthy"
    degraded_since: float | None = None
    last_check_ts: float = 0.0
    # True = successo, False = fallimento
    window: deque = field(default_factory=lambda: deque(maxlen=HEALTH_WINDOW_SIZE))


_state = SentinelState()


async def _check_once(model: str = "llama3.1:8b") -> bool:
    """Esegue un singolo health check a tre livelli su Ollama."""
    async with httpx.AsyncClient(timeout=INFERENCE_TIMEOUT_SEC) as client:
        try:
            # Livello 1: processo raggiungibile
            r = await client.get(f"{OLLAMA_BASE_URL}/api/tags")
            if r.status_code != 200:
                return False

            # Livello 2: modello presente tra i tag caricati
            tags = r.json().get("models", [])
            model_names = [m["name"] for m in tags]
            if not any(model in name for name in model_names):
                return False

            # Livello 3: inferenza risponde entro il timeout
            payload = {
                "model": model,
                "prompt": "ping",
                "stream": False,
                "options": {"num_predict": 1}  # genera solo 1 token
            }
            r2 = await client.post(f"{OLLAMA_BASE_URL}/api/generate", json=payload)
            return r2.status_code == 200

        except (httpx.ConnectError, httpx.TimeoutException):
            return False


async def run_sentinel(model: str = "llama3.1:8b") -> None:
    """Loop continuo del sentinel. Avviare come task asyncio al boot del servizio."""
    global _state
    while True:
        success = await _check_once(model)
        _state.window.append(success)
        _state.last_check_ts = time.monotonic()

        fail_count = sum(1 for ok in _state.window if not ok)
        now = time.monotonic()

        if fail_count >= FAIL_THRESHOLD:
            # Marca degraded se non lo era gia
            if _state.status == "healthy":
                _state.status = "degraded"
                _state.degraded_since = now
            # Controlla se e ora di passare a fallback
            elif _state.status == "degraded":
                degraded_duration = now - (_state.degraded_since or now)
                if degraded_duration >= DEGRADED_TO_FALLBACK_SEC:
                    _state.status = "fallback"
        else:
            # Ripristino: torna healthy solo se la window e pulita
            if _state.status != "healthy":
                _state.status = "healthy"
                _state.degraded_since = None

        await asyncio.sleep(HEALTH_CHECK_INTERVAL_SEC)


def get_current_status() -> HealthStatus:
    """Chiamato dal router per conoscere lo stato attuale di Ollama."""
    return _state.status

Il punto critico e la gestione del ripristino. Quando Ollama torna operativo, il sentinel non deve far rientrare il traffico immediatamente: aspetta che la window sia completamente pulita (tutti e 5 i check recenti siano successo). Questo evita il fenomeno del flapping - il continuo switch avanti/indietro tra provider - che e piu costoso del downtime stesso.

Sezione 2 - Circuit Breaker Resilience4j per il Backend Java

Se il backend e in Spring Boot, il circuit breaker non vive nel sentinel Python ma direttamente nel layer di chiamata al servizio ML. Resilience4j e la libreria di riferimento per questo pattern in ambiente Java/Spring. La configurazione chiave per un LLM e diversa da quella standard per microservizi HTTP: le chiamate sono lente (2-8 secondi), poco frequenti (decine al minuto), e il failure e spesso un timeout, non un 500.

Nel backend Spring Boot del portfolio, il circuit breaker avvolge la chiamata a ml-service FastAPI (che a sua volta chiama Ollama). Se ml-service non risponde entro 10 secondi o restituisce errore per il 30% delle chiamate in 60 secondi, il circuito si apre e il fallback cloud viene invocato direttamente dal backend Java senza passare per il layer Python.

# application.yml - Configurazione Resilience4j per LLM call
resilience4j:
  circuitbreaker:
    instances:
      ollamaService:
        # Usa sliding window temporale, non count-based
        # Motivo: chiamate LLM sono lente e poco frequenti
        slidingWindowType: TIME_BASED
        slidingWindowSize: 60              # finestra di 60 secondi
        failureRateThreshold: 30           # 30% failure in 60s -> OPEN
        slowCallRateThreshold: 50          # 50% slow (>8s) -> OPEN
        slowCallDurationThreshold: 8000    # 8000ms = chiamata lenta
        waitDurationInOpenState: 300s      # 5 min in OPEN prima di half-open
        permittedNumberOfCallsInHalfOpenState: 3
        minimumNumberOfCalls: 5            # almeno 5 call per calcolare failure rate
        recordExceptions:
          - java.net.ConnectException
          - java.util.concurrent.TimeoutException
          - org.springframework.web.client.ResourceAccessException
// OllamaGateway.java - Circuit breaker con fallback cloud inline
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

@Service
public class OllamaGateway {

    private final RestClient mlServiceClient;
    private final CloudLlmClient cloudClient;   // wrapper OpenAI/Anthropic

    public OllamaGateway(RestClient mlServiceClient, CloudLlmClient cloudClient) {
        this.mlServiceClient = mlServiceClient;
        this.cloudClient = cloudClient;
    }

    /**
     * Chiama Ollama via ml-service. Se il circuito e aperto,
     * Resilience4j invoca automaticamente ollamaFallback().
     */
    @CircuitBreaker(name = "ollamaService", fallbackMethod = "ollamaFallback")
    public String generateCompletion(String prompt, String model) {
        return mlServiceClient.post()
            .uri("/internal/ai/chat")
            .body(new ChatRequest(prompt, model))
            .retrieve()
            .body(String.class);
    }

    /**
     * Fallback: stessa firma + Throwable. Chiamato quando circuito e OPEN.
     * Usa gpt-4o-mini come modello cloud di emergenza.
     */
    private String ollamaFallback(String prompt, String model, Throwable cause) {
        // Loga per audit trail
        log.warn("Circuit breaker OPEN - routing to cloud. Causa: {}", cause.getMessage());
        return cloudClient.generateWithGptMini(prompt);
    }
}

La fallback method deve avere la stessa firma del metodo originale piu un parametro Throwable alla fine: e un vincolo Resilience4j. Il log nella fallback e fondamentale per il billing audit che vedremo nella sezione 5.

Sezione 3 - Routing Logic: decidere Self-Hosted vs Cloud

Il router e il componente centrale della strategia ibrida. Riceve ogni richiesta LLM e decide, in tempo reale, quale provider usare. La decisione non e solo binaria (Ollama si/no): considera anche il tipo di task, il budget cloud residuo, e la priorita della richiesta.

Nel ml-service FastAPI del portfolio, il router implementa tre livelli di decision making: primo lo stato del sentinel (healthy/degraded/fallback), secondo il budget cloud consumato nel mese (se supera il 90% del cap, accetta solo richieste con flag priority=high), terzo il tipo di task (task di ricerca RAG rimangono sempre su self-hosted anche se degraded, perché il modello embedding non e fallback-able su cloud con la stessa semantica).

"""
router.py - Routing logic self-hosted vs cloud con cost-awareness.
"""

from enum import Enum
from dataclasses import dataclass
from health_sentinel import get_current_status
from billing_tracker import get_monthly_cloud_spend, CLOUD_BUDGET_CAP_EUR

class Provider(Enum):
    SELF_HOSTED = "ollama"
    CLOUD = "openai_gpt4o_mini"

class TaskType(Enum):
    CHAT = "chat"
    RAG_EMBEDDING = "rag_embedding"  # mai fallback: semantica dipende dal modello
    SUMMARIZATION = "summarization"
    CODE_REVIEW = "code_review"

# Task che NON possono andare su cloud per ragioni di coerenza semantica
NO_CLOUD_TASKS = {TaskType.RAG_EMBEDDING}

# Soglia budget cloud oltre cui accettiamo solo richieste high-priority
BUDGET_WARNING_PCT = 0.85


@dataclass
class RoutingDecision:
    provider: Provider
    reason: str
    estimated_cost_eur: float


def decide_provider(
    task: TaskType,
    priority: str = "normal",   # "normal" | "high"
    force_cloud: bool = False    # override manuale (solo admin)
) -> RoutingDecision:
    """
    Decide quale provider usare per la richiesta.
    Restituisce la decisione con la motivazione per l'audit trail.
    """
    # Override manuale (kill switch admin)
    if force_cloud:
        return RoutingDecision(
            provider=Provider.CLOUD,
            reason="manual_override",
            estimated_cost_eur=0.0003  # stima media gpt-4o-mini per request
        )

    # Task che non possono usare il cloud
    if task in NO_CLOUD_TASKS:
        return RoutingDecision(
            provider=Provider.SELF_HOSTED,
            reason="task_no_cloud_compatible",
            estimated_cost_eur=0.0
        )

    sentinel_status = get_current_status()

    # Self-hosted healthy: sempre locale
    if sentinel_status == "healthy":
        return RoutingDecision(
            provider=Provider.SELF_HOSTED,
            reason="sentinel_healthy",
            estimated_cost_eur=0.0
        )

    # Sentinel degraded ma non ancora in fallback:
    # usa self-hosted per task non critici, cloud per high-priority
    if sentinel_status == "degraded":
        if priority == "high":
            return RoutingDecision(
                provider=Provider.CLOUD,
                reason="sentinel_degraded_high_priority",
                estimated_cost_eur=0.0003
            )
        return RoutingDecision(
            provider=Provider.SELF_HOSTED,
            reason="sentinel_degraded_normal_priority_keep_local",
            estimated_cost_eur=0.0
        )

    # Sentinel in fallback: vai su cloud, ma controlla il budget
    monthly_spend = get_monthly_cloud_spend()
    budget_pct = monthly_spend / CLOUD_BUDGET_CAP_EUR

    if budget_pct >= 1.0:
        # Budget esaurito: fallisci gracefully invece di sforare
        raise BudgetExhaustedError(
            f"Budget cloud mensile esaurito ({monthly_spend:.2f} EUR). "
            "Attesa ripristino Ollama o aumento cap."
        )

    if budget_pct >= BUDGET_WARNING_PCT and priority != "high":
        # Budget quasi esaurito: accetta solo high-priority
        raise BudgetWarningError(
            f"Budget cloud all' {budget_pct*100:.0f}%. Solo richieste high-priority accettate."
        )

    return RoutingDecision(
        provider=Provider.CLOUD,
        reason="sentinel_fallback",
        estimated_cost_eur=0.0003
    )


class BudgetExhaustedError(Exception):
    pass

class BudgetWarningError(Exception):
    pass

La separazione tra degraded e fallback e deliberata: durante il periodo degraded, Ollama risponde ancora, ma con errori intermittenti. Mandare tutto sul cloud in quel momento e prematuro e costoso. Solo quando il sentinel dichiara lo stato fallback dopo 5 minuti continuativi di problemi, il routing diventa cloud-first per le chiamate normali.

Sezione 4 - Warm vs Cold Fallback: Trade-off Latenza, Costo, Setup

La differenza tra warm e cold fallback e fondamentale per chi ha SLA di produzione da rispettare. Un warm fallback mantiene una quota minima di traffico costante verso il provider cloud anche quando Ollama e healthy: questo mantiene le connessioni aperte, i token di autenticazione freschi, e la latenza di attivazione del fallback sotto i 500ms. Un cold fallback non invia nulla al cloud finche Ollama non guasta: la prima richiesta di fallback deve aprire la connessione, autenticarsi, e attraversare eventuali cold path del provider, aggiungendo 5-15 secondi.

Dimensione Warm Fallback Cold Fallback
Latenza attivazione <500 ms 5-15 secondi
Costo fisso mensile €15-30 (traffico minimo costante) €0 (pay-per-use puro)
Costo evento fallback Invariato (già attivo) Spike: +20% prime 30s per throttling iniziale
SLA garantibile p99 <2s durante failover p99 fino a 20s durante failover
Complessità setup Alta (keepalive scheduler, quota mgmt) Bassa (solo API key)
Rischio flapping Basso (connessione sempre aperta) Alto (ogni switch riapre connessione)
Caso d'uso ideale SaaS production, SLA <2s, enterprise Portfolio personale, startup early-stage
"""
warm_fallback.py - Implementazione warm fallback con keepalive scheduler.
Invia una richiesta minima ogni 3 minuti al provider cloud per mantenere
la connessione calda anche quando Ollama e healthy.
"""

import asyncio
import time
import httpx
from health_sentinel import get_current_status

# Intervallo keepalive in secondi (3 minuti)
KEEPALIVE_INTERVAL_SEC = 180
# Testo minimo per il keepalive (1 token output, costo ~€0.0001)
KEEPALIVE_PROMPT = "respond with: ok"
OPENAI_API_KEY = "sk-..."    # da variabile d'ambiente
OPENAI_MODEL = "gpt-4o-mini"

_last_keepalive_ts: float = 0.0
_keepalive_active: bool = False


async def _send_keepalive() -> None:
    """Invia una richiesta minima a OpenAI per mantenere la connessione calda."""
    global _last_keepalive_ts
    headers = {"Authorization": f"Bearer {OPENAI_API_KEY}"}
    payload = {
        "model": OPENAI_MODEL,
        "messages": [{"role": "user", "content": KEEPALIVE_PROMPT}],
        "max_tokens": 5
    }
    async with httpx.AsyncClient(timeout=10.0) as client:
        try:
            r = await client.post(
                "https://api.openai.com/v1/chat/completions",
                headers=headers,
                json=payload
            )
            if r.status_code == 200:
                _last_keepalive_ts = time.monotonic()
        except httpx.RequestError:
            pass  # keepalive fallito: non critico, il cold path fa da backup


async def run_warm_keepalive() -> None:
    """
    Loop keepalive: invia ping a OpenAI ogni KEEPALIVE_INTERVAL_SEC.
    Si avvia solo se warm_fallback e abilitato nella config.
    Non invia keepalive se Ollama e gia in stato fallback
    (in quel caso il traffico reale mantiene calda la connessione).
    """
    global _keepalive_active
    _keepalive_active = True
    while _keepalive_active:
        status = get_current_status()
        # Se Ollama e in fallback, il traffico reale mantiene calda la connessione
        if status != "fallback":
            await _send_keepalive()
        await asyncio.sleep(KEEPALIVE_INTERVAL_SEC)

Nel portfolio Federico Calò, dove il traffico LLM e moderato (decine di richieste al giorno, non centinaia), ho optato per il cold fallback con una modifica: il cold path si pre-autentica all'avvio del servizio e mantiene il client HTTP configurato in memoria, riducendo la latenza di attivazione da 15 a circa 2-3 secondi. La soluzione warm completa (con keepalive scheduler) vale per deployment con SLA inferiori a 2 secondi garantiti.

Sezione 5 - Billing Audit Middleware: tracciare ogni Token per Provider

Il rischio piu sottovalutato nella strategia ibrida non e il downtime di Ollama: e la bolletta cloud inaspettata a fine mese. Per evitarlo, ogni singola chiamata LLM deve essere registrata con tre informazioni: il provider usato, i token consumati, e il costo stimato in euro. Il middleware di billing audit si inserisce nel layer di chiamata, trasparente rispetto al business logic sovrastante.

"""
billing_tracker.py - Middleware che traccia token e costo per provider.
Usa Redis per contatori in-memory veloci + PostgreSQL per audit trail persistente.
"""

import time
import redis.asyncio as redis
from dataclasses import dataclass

# Costi OpenAI gpt-4o-mini (USD per token, convertiti in EUR a 0.93)
# Aggiornati a maggio 2026
COST_PER_INPUT_TOKEN_EUR = 0.15 / 1_000_000 * 0.93   # $0.15/1M input
COST_PER_OUTPUT_TOKEN_EUR = 0.60 / 1_000_000 * 0.93  # $0.60/1M output

# Costo Ollama: zero per token, ma include quota di infrastructure cost
# Calcolata come: costo VPS mensile / token medi mensili
INFRA_COST_PER_TOKEN_EUR = 0.000_001  # ~€0.001 per 1000 token

# Budget mensile cloud (hard cap in EUR)
CLOUD_BUDGET_CAP_EUR = 50.0

# Chiavi Redis
REDIS_KEY_CLOUD_SPEND = "billing:cloud:monthly_spend_eur"
REDIS_KEY_CLOUD_CALLS = "billing:cloud:monthly_calls"
REDIS_KEY_LOCAL_CALLS = "billing:local:monthly_calls"

_redis_client: redis.Redis | None = None


@dataclass
class CallRecord:
    provider: str           # "ollama" | "openai_gpt4o_mini"
    model: str
    input_tokens: int
    output_tokens: int
    latency_ms: float
    reason: str             # dal RoutingDecision
    cost_eur: float
    timestamp: float


async def record_llm_call(record: CallRecord) -> None:
    """
    Registra una chiamata LLM su Redis (per monitoraggio real-time)
    e accoda per persistenza su PostgreSQL.
    """
    r = await _get_redis()

    if record.provider == "ollama":
        await r.incr(REDIS_KEY_LOCAL_CALLS)
    else:
        # Aggiorna spesa cloud in tempo reale
        await r.incrbyfloat(REDIS_KEY_CLOUD_SPEND, record.cost_eur)
        await r.incr(REDIS_KEY_CLOUD_CALLS)

    # Salva evento per audit (struttura semplificata, in prod usa Postgres)
    audit_key = f"billing:audit:{int(record.timestamp)}"
    await r.hset(audit_key, mapping={
        "provider": record.provider,
        "model": record.model,
        "input_tokens": record.input_tokens,
        "output_tokens": record.output_tokens,
        "latency_ms": record.latency_ms,
        "reason": record.reason,
        "cost_eur": f"{record.cost_eur:.6f}"
    })
    await r.expire(audit_key, 86400 * 30)  # 30 giorni di retention


async def get_monthly_cloud_spend() -> float:
    """Legge la spesa cloud cumulativa del mese corrente da Redis."""
    r = await _get_redis()
    val = await r.get(REDIS_KEY_CLOUD_SPEND)
    return float(val or 0.0)


async def compute_call_cost(
    provider: str,
    input_tokens: int,
    output_tokens: int
) -> float:
    """Calcola il costo in EUR per una singola chiamata."""
    if provider == "ollama":
        # Costo infrastruttura: trascurabile ma tracciato per ROI analysis
        return (input_tokens + output_tokens) * INFRA_COST_PER_TOKEN_EUR
    else:
        return (input_tokens * COST_PER_INPUT_TOKEN_EUR +
                output_tokens * COST_PER_OUTPUT_TOKEN_EUR)


async def _get_redis() -> redis.Redis:
    global _redis_client
    if _redis_client is None:
        _redis_client = redis.Redis(host="localhost", port=6379, decode_responses=True)
    return _redis_client

I contatori Redis si resettano il primo di ogni mese tramite un cron job sul VPS. Il punto critico e CLOUD_BUDGET_CAP_EUR = 50.0: questa variabile e anche quella che il router legge per bloccare le richieste cloud quando il budget e esaurito. Con gpt-4o-mini a $0.15/1M token input e $0.60/1M token output, €50 mese corrispondono a circa 250-300 milioni di token cloud totali - abbondanti per un fallback che deve restare sotto il 5% del traffico.

Sezione 6 - ROI Calculator: quando Conviene la Strategia Ibrida

La domanda che ogni responsabile AI si fa prima di investire in hardware self-hosted e questa: quando il costo del VPS (o del server dedicato) e inferiore al costo equivalente su cloud? La risposta dipende dal volume di traffico e dalla distribuzione dei token.

Scenario Token/mese Solo cloud (gpt-4o-mini) Solo self-hosted (VPS €40/mo) Ibrido (95% local + 5% cloud) Risparmio mensile ibrido
Small (portfolio/blog) 5M €2.80 €40.00 €40.14 -€37.34 (self-hosted non conviene)
Medium (startup prodotto) 100M €55.80 €40.00 €42.79 +€13.01 (breakeven superato)
Enterprise (piattaforma SaaS) 1B €558.00 €120.00 (server dedicato) €147.90 +€410.10 (risparmio 73%)
"""
roi_calculator.py - Calcola ROI della strategia ibrida self-hosted + cloud.
Utile per giustificare l'investimento in hardware o per scegliere la tier VPS.
"""

from dataclasses import dataclass


@dataclass
class RoiInput:
    monthly_tokens: int          # token totali processati al mese
    avg_input_ratio: float       # frazione di input vs output (es. 0.7 = 70% input)
    infra_cost_eur: float        # costo mensile VPS/server self-hosted in EUR
    cloud_fallback_pct: float    # percentuale token che vanno su cloud (es. 0.05 = 5%)


@dataclass
class RoiResult:
    cost_all_cloud_eur: float
    cost_all_self_hosted_eur: float
    cost_hybrid_eur: float
    monthly_saving_eur: float
    break_even_tokens: int
    payback_months: float        # mesi per ammortizzare hardware one-off


# Prezzi gpt-4o-mini (maggio 2026, EUR)
GPT4O_MINI_INPUT_EUR_PER_TOKEN = 0.15 / 1_000_000 * 0.93
GPT4O_MINI_OUTPUT_EUR_PER_TOKEN = 0.60 / 1_000_000 * 0.93
# Costo infrastruttura per token locale (VPS amortizzato)
LOCAL_INFRA_EUR_PER_TOKEN = 0.000_001


def calculate_roi(inp: RoiInput) -> RoiResult:
    """Calcola ROI comparativo tra le tre strategie di deployment."""
    input_tokens = inp.monthly_tokens * inp.avg_input_ratio
    output_tokens = inp.monthly_tokens * (1 - inp.avg_input_ratio)

    # Scenario 1: tutto su cloud
    cost_cloud = (input_tokens * GPT4O_MINI_INPUT_EUR_PER_TOKEN +
                  output_tokens * GPT4O_MINI_OUTPUT_EUR_PER_TOKEN)

    # Scenario 2: tutto su self-hosted
    cost_self_hosted = inp.infra_cost_eur  # fisso mensile

    # Scenario 3: ibrido
    cloud_tokens = inp.monthly_tokens * inp.cloud_fallback_pct
    cloud_input = cloud_tokens * inp.avg_input_ratio
    cloud_output = cloud_tokens * (1 - inp.avg_input_ratio)
    cost_hybrid_cloud_part = (cloud_input * GPT4O_MINI_INPUT_EUR_PER_TOKEN +
                               cloud_output * GPT4O_MINI_OUTPUT_EUR_PER_TOKEN)
    cost_hybrid = inp.infra_cost_eur + cost_hybrid_cloud_part

    saving = cost_cloud - cost_hybrid

    # Break-even: quanti token al mese per cui self-hosted == cloud
    # infra_cost = tokens * cloud_price_per_token
    avg_cloud_price = (inp.avg_input_ratio * GPT4O_MINI_INPUT_EUR_PER_TOKEN +
                       (1 - inp.avg_input_ratio) * GPT4O_MINI_OUTPUT_EUR_PER_TOKEN)
    break_even = int(inp.infra_cost_eur / avg_cloud_price)

    return RoiResult(
        cost_all_cloud_eur=cost_cloud,
        cost_all_self_hosted_eur=cost_self_hosted,
        cost_hybrid_eur=cost_hybrid,
        monthly_saving_eur=saving,
        break_even_tokens=break_even,
        payback_months=0.0  # hardware one-off non incluso in questo esempio
    )


# --- Esempio di utilizzo ---
if __name__ == "__main__":
    # Scenario portfolio Federico: VPS CCX13 €40/mese, ~5M token/mese
    portfolio_scenario = RoiInput(
        monthly_tokens=5_000_000,
        avg_input_ratio=0.7,
        infra_cost_eur=40.0,
        cloud_fallback_pct=0.05
    )
    result = calculate_roi(portfolio_scenario)
    print(f"Costo tutto-cloud: €{result.cost_all_cloud_eur:.2f}/mese")
    print(f"Costo self-hosted: €{result.cost_all_self_hosted_eur:.2f}/mese")
    print(f"Costo ibrido:      €{result.cost_hybrid_eur:.2f}/mese")
    print(f"Risparmio mensile: €{result.monthly_saving_eur:.2f}")
    print(f"Break-even:        {result.break_even_tokens:,} token/mese")

Il risultato per il portfolio Federico e chiaro: con 5 milioni di token mensili, il self-hosted costa piu del cloud perche il VPS costa €40/mese fissi mentre gpt-4o-mini costerebbe solo €2.80 per quel volume. La soglia di break-even, con il VPS CCX13 Hetzner a €40/mese, e circa 76 milioni di token mensili. Il self-hosted nel portfolio di Federico si giustifica per altri motivi: privacy dei dati, zero dipendenza da API esterne, e il fatto che il VPS serve gia frontend Angular + backend Spring Boot - l'LLM e un servizio aggiuntivo su infrastruttura esistente.

Sezione 7 - Alert Telegram su Fallback Critico

Il fallback cloud che si attiva silenziosamente e un problema: potresti scoprire il guasto di Ollama solo guardando la bolletta del mese successivo. Un alert Telegram immediato risolve questo: al momento dell'attivazione del fallback critico, un messaggio arriva al tuo telefono con il contesto completo del problema.

"""
telegram_alert.py - Notifica Telegram su attivazione fallback critico.
Si integra con il sentinel: chiamato quando lo stato passa a "fallback".
"""

import httpx
import time
from health_sentinel import HealthStatus

# Configurazione bot Telegram (da variabili d'ambiente in produzione)
TELEGRAM_BOT_TOKEN = "..."  # da env var TELEGRAM_BOT_TOKEN
TELEGRAM_CHAT_ID = "..."    # da env var TELEGRAM_CHAT_ID
TELEGRAM_API_URL = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"

# Anti-spam: non inviare piu di 1 alert ogni 10 minuti per lo stesso evento
_last_alert_ts: float = 0.0
ALERT_COOLDOWN_SEC = 600


async def send_fallback_alert(
    previous_status: HealthStatus,
    new_status: HealthStatus,
    fail_count: int,
    degraded_since: float | None,
    monthly_cloud_spend_eur: float
) -> None:
    """
    Invia alert Telegram quando il sentinel entra in stato 'fallback'.
    Include contesto diagnostico e spesa cloud corrente per valutare urgenza.
    """
    global _last_alert_ts

    if new_status != "fallback":
        return  # invia solo per transizione a fallback critico

    now = time.monotonic()
    if now - _last_alert_ts < ALERT_COOLDOWN_SEC:
        return  # anti-spam

    degraded_minutes = 0
    if degraded_since:
        degraded_minutes = int((now - degraded_since) / 60)

    message = (
        "ALLERTA: Ollama in stato FALLBACK\n\n"
        f"Stato precedente: {previous_status}\n"
        f"Fail negli ultimi check: {fail_count}/5\n"
        f"Degraded da: {degraded_minutes} minuti\n"
        f"Spesa cloud mese corrente: EUR {monthly_cloud_spend_eur:.2f}\n\n"
        "Azione: il traffico LLM e ora su OpenAI gpt-4o-mini.\n"
        "Controlla processo Ollama sul VPS: systemctl status portfolio-ml\n"
        "Restart: sudo systemctl restart portfolio-ml"
    )

    payload = {
        "chat_id": TELEGRAM_CHAT_ID,
        "text": message,
        "parse_mode": "Markdown"
    }

    async with httpx.AsyncClient(timeout=10.0) as client:
        try:
            await client.post(TELEGRAM_API_URL, json=payload)
            _last_alert_ts = now
        except httpx.RequestError:
            # L'alert non e critico per il servizio: logga e vai avanti
            pass


async def send_recovery_alert(monthly_cloud_spend_eur: float) -> None:
    """Notifica il ritorno di Ollama in stato healthy con riepilogo spesa cloud."""
    message = (
        "OK: Ollama ripristinato, stato HEALTHY\n\n"
        f"Spesa cloud accumulata durante il fallback: EUR {monthly_cloud_spend_eur:.2f}\n"
        "Il traffico LLM e tornato su self-hosted."
    )
    payload = {"chat_id": TELEGRAM_CHAT_ID, "text": message}
    async with httpx.AsyncClient(timeout=10.0) as client:
        try:
            await client.post(TELEGRAM_API_URL, json=payload)
        except httpx.RequestError:
            pass

Il cooldown di 10 minuti e essenziale: senza di esso, un loop del sentinel che cicla velocemente potrebbe inviare decine di alert in pochi minuti. L'alert di recovery e altrettanto importante: ti dice quando Ollama e tornato operativo e quanto e costato il periodo di fallback.

Sezione 8 - Mettere Tutto Insieme: Architettura Completa

Ecco come i componenti descritti si integrano nel ml-service FastAPI del portfolio di Federico Calò. Al boot del servizio, tre task asyncio si avviano in parallelo: il sentinel loop (controllo salute ogni 30s), il keepalive warm (opzionale, ogni 3 min se abilitato), e il worker principale che serve le richieste HTTP.

Ogni richiesta a /internal/ai/chat attraversa questo percorso in ordine: il middleware di billing che apre il record, il router che decide il provider, la chiamata al provider scelto, il completamento del record con token e costo, e infine la risposta al client. Se il provider cloud risponde con un errore, NON si fa retry automatico su self-hosted (Ollama e gia in stato fallback per definizione): si restituisce l'errore cloud al client con HTTP 503 e messaggio esplicativo.

# systemd drop-in per garantire riavvio automatico di Ollama
# /etc/systemd/system/portfolio-ml.service.d/restart.conf

[Service]
Restart=on-failure
RestartSec=10s
# Limite: non piu di 5 restart in 5 minuti
StartLimitBurst=5
StartLimitIntervalSec=300

Il file di override systemd e il complemento infrastrutturale del circuit breaker applicativo: se Ollama crasha per un OOM o un errore di runtime, systemd lo riavvia entro 10 secondi. Il sentinel nel frattempo marca lo stato come degraded/fallback, il cloud serve le richieste, e al riavvio di Ollama il sentinel riprende i check e - se trovano il processo healthy - riporta il routing su self-hosted automaticamente.

FAQ

Quando conviene attivare il fallback cloud da self-hosted?

Attiva il fallback quando l'health check di Ollama fallisce per piu di 5 minuti consecutivi, il tasso di errore supera il 10% in una finestra di 60 secondi, oppure la latenza p95 supera i 5 secondi. Queste soglie evitano falsi positivi da spike temporanei. Per sistemi con SLA piu stringenti (p99 sotto 500ms garantito), abbassa le soglie e usa warm fallback.

Warm fallback e cold fallback: qual e la differenza pratica?

Il warm fallback mantiene un quota minima pre-allocata sul provider cloud (es. 10 req/min costanti) per garantire attivazione in meno di 500ms. Il cold fallback attiva la chiave API solo all'evento di guasto, con 5-15 secondi di latenza aggiuntiva per autenticazione e routing. Warm costa di piu (€15-30/mese fissi), ma e indispensabile per SLA produzione.

Come evitare che il fallback cloud costi piu del previsto?

Imposta un hard cap mensile sulla chiave API cloud (es. $50/mese su OpenAI dashboard) e un alert a $30. Nel billing tracker, conta i token in tempo reale e smetti di inviare richieste cloud al raggiungimento della soglia, ritornando un errore graceful all'utente. Il cap nel dashboard OpenAI e il safety net finale - il billing tracker nel codice e la prima linea di difesa.

Il circuit breaker deve vivere nel codice applicativo o in un proxy?

Dipende dall'architettura. In un monolite Spring Boot, Resilience4j inline e piu semplice e sufficiente. In microservizi con piu istanze, un API gateway con circuit breaker centralizzato (Nginx+Lua, Envoy, o Kong) e preferibile perche condivide lo stato tra istanze - altrimenti ogni istanza ha il suo contatore indipendente e il circuito non si apre mai in modo coerente.

Come si imposta la finestra temporale del circuit breaker per gli LLM?

Per LLM usa una sliding window temporale (time-based) da 60 secondi invece di count-based, perche le chiamate LLM sono lente e poco frequenti. Se usi count-based con soglia 5 failure, potresti aspettare 10 minuti prima di accumulare 5 failure. Con time-based, la finestra scorre in tempo reale indipendentemente dal volume. Soglia consigliata: 30% failure in 60s.

Come misuro che il 95% delle chiamate rimane su self-hosted?

Nel billing audit middleware, due contatori Redis (calls_self_hosted, calls_cloud) vengono incrementati ad ogni richiesta. Un job ogni 5 minuti calcola la percentuale cloud e la scrive su Prometheus come metrica llm_cloud_ratio. Se supera il 5% configurato, un alert Grafana e il messaggio Telegram entrano in azione. Il target 5% e una soglia di allarme, non un limite tecnico: il sistema non blocca le richieste cloud se necessario, ma ti avvisa che qualcosa non va nel setup.

Il fallback deve usare lo stesso modello del self-hosted?

Non necessariamente. Nel portfolio uso Llama 3.1 8B su Ollama come primario e gpt-4o-mini come fallback. Sono modelli con output diversi, ma per task informativi (RAG, summarization, classificazione) la differenza qualitativa e accettabile. Per task critici (generazione di contenuti regolati, codice safety-critical, decisioni finanziarie) e meglio fallire esplicitamente con un messaggio all'utente piuttosto che rispondere con un modello diverso da quello calibrato per quel caso d'uso specifico.

Conclusione - Fine della Serie 22

Siamo arrivati alla fine del percorso. La strategia ibrida cloud-fallback e il sigillo architetturale di una infrastruttura LLM self-hosted matura: non basta far girare Ollama su un VPS, bisogna pianificare il momento in cui quel VPS non risponde. Il circuit breaker, il sentinel, il routing cost-aware, il billing audit e l'alert Telegram non sono opzionali in produzione - sono la differenza tra un sistema resiliente e una fonte di ansia notturna.

Riepilogo della Serie 22 in 10 episodi:

  • EP01 - Ollama vs vLLM: decision tree basato su TPS, latenza e concorrenza. La scelta sbagliata costa 19x.
  • EP02 - Quantization e memory planning su Ollama: GGUF 4-bit, concurrency limits, costo per token.
  • EP03 - vLLM PagedAttention e tensor parallelism: come servire 100 richieste concorrenti senza Head-of-Line blocking.
  • EP04 - Production safety: rate limit, circuit breaker, cost cap per sessione, graceful degradation.
  • EP05 - Monitoring con Prometheus: GPU utilization, token latency percentile, cost tracking real-time su Grafana.
  • EP06 - Scaling multi-GPU e multi-node con Ray cluster: load balancing e data locality.
  • EP07 - Landscape alternative 2026: LM Studio, SGLang, text-generation-webui, Llamafile con matrice comparativa.
  • EP08 - Fine-tuning on-premise con LoRA e QLoRA su GPU consumer: workflow end-to-end, costo reale.
  • EP09 - Model Context Protocol (MCP): wrapper per connettere tool a Ollama come se fosse Claude API.
  • EP10 - Hybrid cloud-fallback: sentinel, circuit breaker, warm/cold fallback, billing audit, ROI calculator.

Se la Serie 22 ti ha dato gli strumenti per mettere in produzione un LLM self-hosted, le prossime serie ti aiutano a renderlo ancora piu preciso, economico e osservabile:

  • Serie 23 - RAG Evaluation: come misurare la qualita del retrieval-augmented generation con metriche quantitative (faithfulness, context recall, answer relevancy) e strumenti come RAGAS.
  • Serie 24 - SLM su Edge: Small Language Models su dispositivi embedded, Raspberry Pi e smartphone - quando il VPS e ancora troppo potente per il tuo caso d'uso.
  • Serie 27 - Observability LLM: tracing distribuito, log strutturati e dashboard per ogni chiamata LLM in produzione, dall'ingresso della richiesta all'ultimo token generato.
  • Serie 28 - LoRA e PEFT: approfondimento teorico e pratico su Parameter-Efficient Fine-Tuning oltre il QLoRA - DoRA, IA3, adapters, prefix tuning.
  • Serie 33 - FinOps AI: ottimizzazione dei costi AI a livello organizzativo, showback, chargeback per team, e governance del budget LLM.

Il codice completo della strategia ibrida - sentinel, router, billing tracker, alert Telegram - e disponibile come progetto template nel repository del portfolio. Puoi adattarlo al tuo stack in poche ore: e progettato per funzionare sia su FastAPI standalone che come modulo integrabile in un backend Spring Boot via HTTP gateway.

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