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