
Ottobre 2024. Apro il dashboard interno di AI Multisite alle 7:14 e vedo che la coda di generazione articoli è cresciuta del 30% rispetto alla notte precedente. Non è un picco di traffico, è una redazione internazionale che ha schedulato un batch notturno e ha saturato la quota di un singolo provider LLM. Il router fa quello che deve fare: sposta il carico su un secondo backend, registra il costo per tenant_id, manda avanti la coda. Nessuno chiama, nessuno apre un ticket. È in queste mattine che capisci che il routing multi-LLM in produzione non è un esercizio di architettura su carta, è una serie di compromessi che si pagano in latenza, costo e quota. Vedi anche gli altri post tecnici di questa rubrica: Production.
Un’unica interfaccia, quattro implementazioni (e mezzo)
Il punto di partenza è banale e necessario: tutto il codice applicativo parla a una sola astrazione, mai direttamente all’SDK del singolo provider. In Laravel/PHP la forma è un’interfaccia con un metodo principale e un piccolo set di tipi di payload condivisi:
interface LlmProvider
{
public function generate(LlmRequest $req): LlmResponse;
public function capabilities(): LlmCapabilities;
public function estimateCost(LlmRequest $req): Money;
}
Le implementazioni dietro sono cinque, ognuna con i suoi capricci:
- OpenAI (gpt-4o, gpt-4o-mini, modelli o-series): function calling solido, vision, JSON mode, ma temperature non parametrizzabile sui modelli reasoning.
- Anthropic (Claude Sonnet, Claude Opus): finestra di contesto generosa, output narrativo di qualità, Message Batches API per workload non interattivi.
- Google Gemini (2.0, 2.5): multi-modale nativo, ottimo prezzo sui token in input, idiosincrasie sul JSON strutturato.
- xAI (grok): disponibile come secondo backend per use-case specifici, qui non centrale.
- Llama via Ollama, self-hosted: zero cost per token (paghiamo VM e GPU), latenza prevedibile, qualità inferiore sui task complessi ma accettabile su normalizzazione, classificazione, embedding di basso livello.
Sopra le cinque implementazioni vive un wrapper unico (in AI Multisite l’abbiamo costruito sopra Prism PHP) che gestisce retry con backoff, timeout per provider, normalizzazione delle risposte e logging strutturato. Il chiamante a monte vede solo LlmProvider, mai i client SDK direttamente.
Il router: come si sceglie il provider per ogni richiesta
Il router è una funzione pura: prende una LlmRequest (tenant, tipo di task, parametri, capability richieste) e ritorna un ordine di provider preferiti. Non sceglie a caso, sceglie su quattro assi che vivono insieme:
Costo per token in/out. Il prezzo dei provider commerciali varia di un fattore non trascurabile sui token in input e su quelli in output, e i due non vanno sempre nella stessa direzione (un provider può essere economico in input e caro in output, un altro il contrario). Per ogni request stimiamo un costo atteso (lunghezza prompt + max output), poi ordiniamo. Per i task ad altissimo volume e bassa criticità il vincitore stabile è Ollama: zero costo marginale per token, anche se mediamente più lento sul per-richiesta.
Latenza p99. Su task interattivi (assistenza in tempo reale al giornalista, suggerimento bozza) misuriamo il p99 e teniamo solo i provider che stanno in millisecondi a doppia cifra (decine, non centinaia) sulla prima risposta utile. I batch notturni invece non guardano la latenza, guardano il costo: lì il batch endpoint di Anthropic vince spesso, perché paghi meno token a fronte di SLA non interattive.
Capability. Function calling, vision, JSON mode, finestra di contesto, multi-modal, structured output. Non tutti i provider coprono tutte le capability allo stesso livello; il router elimina dalla rosa chi non può servire la richiesta. Esempio reale: un articolo che richiede analisi di un’immagine in input + output JSON con uno schema preciso restringe la rosa a OpenAI e Gemini.
Quota e budget per tenant. Ogni tenant_id ha un suo budget mensile e una sua corsia di quota. Se un tenant ha consumato il 90% del budget mensile in metà mese, il router downgrada automaticamente verso modelli più economici (gpt-4o-mini al posto di gpt-4o, o Ollama per i task batch). Trasparente per il giornalista, visibile nel dashboard amministratore.
In codice, il router è poco più di una pipeline:
$candidates = $this->allProviders()
->filter(fn($p) => $p->capabilities()->satisfies($req->required))
->reject(fn($p) => $this->budget->isExhaustedFor($req->tenantId, $p))
->sortBy(fn($p) => $this->scoreFor($p, $req)); // costo + latenza + preferenza tenant
return $candidates->values()->all();
Il punteggio è una somma pesata: i pesi cambiano per tipo di task. Generazione creativa pesa di più la qualità (Claude in cima); estrazione strutturata pesa il JSON mode (gpt-4o-mini compete bene); normalizzazione bulk pesa solo il costo (Ollama vince).
Pattern: temperature-aware routing
Una scelta che torna utile sui prodotti editoriali: routare in base alla temperatura semantica del task, non solo al modello.
- Task creativi (riformulazione di un titolo, suggerimento di un attacco di articolo, riscrittura culturale per un mercato diverso): temperatura alta, voce primaria a Claude, secondaria gpt-4o.
- Task deterministici (estrazione di entità, classificazione di tag, structured JSON): temperatura bassa, voce primaria a gpt-4o-mini per costo, secondaria Gemini.
- Task batch non urgenti (re-stilometria di un archivio): batch endpoint Anthropic in coda notturna, fallback Ollama per tenant senza budget batch.
Nei modelli reasoning (o-series, Claude con extended thinking) la temperatura non è un parametro sensato: il wrapper la disabilita silenziosamente quando il modello attivo non la accetta, evitando l’errore “unsupported parameter” lato API.
La cache semantica davanti
Senza una cache davanti al router, in produzione il routing multi-LLM è un sistema che brucia denaro. La cache che usiamo non è una cache esatta sulla stringa di prompt, è semantica:
- La request viene normalizzata (whitespace, case, parametri non rilevanti).
- Per il task corrente si calcola un embedding con un modello economico (testo piccolo, costo trascurabile).
- Si cerca in Redis (con un indice vettoriale) la risposta cached più vicina dentro una soglia di similarità per quel tenant e per quel task.
- Se hit sopra soglia: si serve la cached. Se miss: si chiama il provider, si memorizza la risposta indicizzata sul nuovo embedding.
La parte importante è la chiave di partizionamento. La cache è scoped per (tenant_id, task_type, locale): due tenant diversi non condividono mai la cache, anche su richieste identiche, e una redazione italiana non vede mai il risultato di una redazione polacca per via dello stesso titolo. Stessa logica delle Eloquent Global Scope tenant-aware: la cache eredita il TenantContext, niente cross-tenant leak.
L’hit rate cambia molto per task: sui titoli e sui tag arriva facilmente sopra la metà delle request, sulle generazioni complete è marginale. Vale comunque la pena, perché abbatte costo e latenza dove fa più male: nei task ad alto volume e bassa varianza.
Cost tracking per tenant: la base del pricing
Ogni chiamata a un LlmProvider viene loggata con: tenant_id, provider, model, task_type, token in/out, costo stimato in euro, latenza, esito. Lo schema è denormalizzato di proposito (writes alti, query analitiche su finestre temporali ampie) e finisce su ClickHouse via materialized view, dove le query di rollup mensile per tenant girano in centinaia di millisecondi anche su anni di storico.
Cosa ne facciamo:
- Pricing. Il piano Enterprise include una quota mensile di token, oltre la quale scatta overage. Senza tracking per tenant, niente fatturazione corretta.
- Quota / soft-limit. Quando un tenant si avvicina al limite, il router smette di proporre i provider premium: il giornalista continua a lavorare, ma su modelli più economici.
- Audit. Su cliente Enterprise serve poter dire “questo articolo ha consumato N token, è stato generato dal modello X, il costo è stato Y, la latenza è stata Z”. Il log strutturato è il backbone di quella conversazione.
Fallback: come reagisce il router quando un provider muore
Il provider primario non risponde. Le ragioni in ordine di frequenza reale: quota superata sul nostro account (rate limit), errore 5xx temporaneo del provider, timeout sopra soglia, capability degradata su un modello specifico, account temporaneamente sospeso.
La logica di fallback è esplicita, non implicita:
- Si prova il provider primario con un retry rapido (1 attempt, 100ms backoff) per assorbire i transient.
- Se il retry fallisce o la latenza supera lo SLA del task, si passa al secondario nella lista del router.
- Se anche il secondario fallisce, si passa al tertiary (di solito Ollama self-hosted, perché non condivide il single-point-of-failure col cloud commerciale).
- Ogni step è loggato come
fallback_reason, in modo che il dashboard SRE veda non solo che è successo, ma perché si è scelto il fallback.
Punto importante: il fallback non è “best effort silenzioso”. Sopra una soglia di fallback rate per provider (anomalia rispetto al baseline storico), parte un alert. Una volta abbiamo notato, da quella soglia, che la qualità di output di un provider era cambiata in modo non annunciato; abbiamo dirottato il traffico al fallback finché la situazione non è tornata stabile.
Batch ottimizzato: Message Batches per spostare l’interactive in offline
I task interattivi vivono di latenza. I task batch vivono di costo. Riconoscere quali generazioni possono spostarsi in coda batch è una delle ottimizzazioni più sottostimate del routing multi-LLM.
Esempi reali di task batch nella nostra piattaforma:
- Re-stilometria notturna di archivi storici dopo un cambio del modello stilometrico.
- Generazione di varianti culturali (italiano → polacco → ucraino) per articoli evergreen senza data di scadenza.
- Recompute di tag e categorie su finestre di 30 giorni di articoli pubblicati.
Per questi flussi, le Message Batches API di Anthropic abbattono il costo per token rispetto al synchronous endpoint, in cambio di una SLA non interattiva (ore, non secondi). Il router le riconosce come una “lane” diversa: i task marcati come batch=true saltano la coda interactive, finiscono in una queue Horizon dedicata, e il dispatcher batch raggruppa per provider e invia in finestre di 100/500 messaggi alla volta.
Il guadagno è doppio: paghi meno per token, e non saturi la quota interactive del tenant in orari di picco (quando la redazione sta lavorando dal vivo).
Cosa ho imparato a costruire (e non costruire) sul routing multi-LLM in produzione
Tre cose, in ordine di importanza tecnica.
Prima. Costruisci l’interfaccia LlmProvider prima del secondo provider, non dopo. Aggiungere il primo è facile, aggiungere il quinto su una codebase che ha già usato l’SDK di OpenAI in venti punti diversi è un refactor doloroso. La regola è la stessa di Sandi Metz su the wrong abstraction: meglio una piccola duplicazione iniziale che un’astrazione fragile costruita troppo presto, ma quando il secondo backend entra, l’astrazione la metti subito.
Seconda. Il routing che decide solo per costo è il modo più rapido per degradare la qualità del prodotto senza accorgersene. Costo, latenza, capability e quota tenant vivono insieme. Esci con un router monodimensionale (solo costo, solo latenza) e finisci a fare A/B test sulla qualità percepita per recuperare quello che hai perso.
Terza. Il cost tracking non è una nice-to-have. È la spina dorsale del pricing, della quota, dell’audit verso i clienti Enterprise e del debugging quando una generazione costa dieci volte la media. Va costruito al primo giorno, anche se ti sembra premature optimization. Senza, dopo sei mesi non sai più chi consuma cosa.
Il routing multi-LLM in produzione lo abbiamo iniziato semplice, una interfaccia e due implementazioni. Oggi sono cinque backend, una cache semantica davanti, un router multi-criterio, un cost tracking che arriva fino al singolo articolo. Niente di tutto questo era nel design iniziale: ognuno di questi pezzi è arrivato dopo aver visto il sistema sotto carico reale, in mattine come quella delle 7:14 in cui la coda cresce e la macchina, da sola, sceglie di spostare il lavoro altrove.
