Gestire i limiti di frequenza e i nuovi tentativi nelle API degli LLM
Alle 09:12 di un martedì, la nostra pipeline di riepilogo sembrava funzionare bene: 96 worker attivi, latenza mediana del modello di circa 2.8 secondi, nessun aumento del tasso di errori 5xx. Poi un cliente ha caricato un arretrato di 38,000 ticket di assistenza.
Nel giro di quattro minuti, la pipeline era «fuori servizio» senza che alcun server lo fosse. Stavamo raggiungendo contemporaneamente due limiti diversi:
requests_per_minute: troppe chiamate APItokens_per_minute: un volume eccessivo di prompt e risposte
La logica ingenua dei tentativi ha peggiorato la situazione. Ogni worker riceveva 429, attendeva un secondo e riprovava insieme agli altri. Questo generava un'ondata di tentativi, che produceva ulteriori 429, generando un'altra ondata. Il sistema aveva accidentalmente creato un attacco denial-of-service sincronizzato contro la propria quota.
Gestire i limiti di frequenza degli LLM non significa semplicemente «aggiungere un backoff esponenziale». In produzione, bisogna considerare token, code, concorrenza, tentativi, idempotenza, fornitori di riserva e comportamento della fatturazione come un unico sistema.
I due limiti che contano: RPM e TPM
La maggior parte delle API LLM applica una combinazione di:
- RPM: richieste al minuto
- TPM: token al minuto
- RPD / TPD: richieste o token al giorno
- Limiti delle richieste simultanee: numero massimo di chiamate in corso
- Limiti specifici per modello: limiti più alti per i modelli più piccoli, più bassi per i modelli di frontiera
Il punto importante: RPM e TPM causano problemi diversi.
Se invii molti prompt brevi, raggiungi prima il limite RPM. Se invii meno prompt ma di grandi dimensioni, raggiungi prima il limite TPM.
Esempio:
Limit:
- 600 requests/minute
- 300,000 tokens/minute
Workload A:
- 1,000 short classification calls/minute
- 200 tokens each
- 200,000 tokens/minute total
Result:
- TPM is fine
- RPM fails
Workload B:
- 100 long document analysis calls/minute
- 5,000 tokens each
- 500,000 tokens/minute total
Result:
- RPM is fine
- TPM fails
Un'insidia comune: gli sviluppatori limitano la frequenza delle richieste, ma non i token. Questo funziona nell'ambiente di staging, dove i prompt sono brevi. Poi gli utenti in produzione incollano PDF, cronologie di chat, log o contratti legali, e il budget di token si esaurisce.
In pratica, considero ogni chiamata LLM come una prenotazione di due risorse:
cost = {
requests: 1,
tokens: estimated_prompt_tokens + max_output_tokens
}
Anche se il modello restituisce meno token di output rispetto a max_output_tokens, riservare il limite superiore evita di superare la capacità disponibile.
Cosa succede davvero quando ignori il limite TPM
Supponiamo di avere:
TPM limit: 120,000
Average request: 4,000 input tokens + 1,000 max output tokens
Reserved cost: 5,000 tokens
La capacità massima effettiva non è «il maggior numero possibile di worker». È:
120,000 / 5,000 = 24 requests per minute
Questo equivale in media a una richiesta ogni 2.5 secondi.
Se esegui 100 worker, tutte le richieste del primo gruppo potrebbero partire correttamente se il fornitore consente picchi di traffico. Poi l'account raggiunge un limite invalicabile. Ogni richiesta successiva riceve 429, e i tentativi si accumulano.
La soluzione non consiste semplicemente nel ridurre il numero di worker. Serve uno scheduler che tenga conto delle prenotazioni dei token.
Un limitatore di frequenza minimale che tiene conto dei token
Per un singolo processo, di solito basta un token bucket. La capacità viene ripristinata nel tempo e ogni richiesta deve acquisire sia uno slot per la richiesta sia uno slot per i token.
Ecco un esempio semplificato in Python:
import asyncio
import time
from dataclasses import dataclass
@dataclass
class Bucket:
capacity: float
refill_per_second: float
tokens: float
updated_at: float
def refill(self):
now = time.monotonic()
elapsed = now - self.updated_at
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_per_second)
self.updated_at = now
async def acquire(self, amount: float):
while True:
self.refill()
if self.tokens >= amount:
self.tokens -= amount
return
missing = amount - self.tokens
sleep_for = missing / self.refill_per_second
await asyncio.sleep(min(sleep_for, 1.0))
class LLMRateLimiter:
def __init__(self, rpm: int, tpm: int):
now = time.monotonic()
self.request_bucket = Bucket(
capacity=rpm,
refill_per_second=rpm / 60,
tokens=rpm,
updated_at=now,
)
self.token_bucket = Bucket(
capacity=tpm,
refill_per_second=tpm / 60,
tokens=tpm,
updated_at=now,
)
self.lock = asyncio.Lock()
async def acquire(self, estimated_tokens: int):
async with self.lock:
await self.request_bucket.acquire(1)
await self.token_bucket.acquire(estimated_tokens)
Utilizzo:
estimated_tokens = prompt_tokens + max_output_tokens
await limiter.acquire(estimated_tokens)
response = await client.chat.completions.create(
model="gpt-5.5",
messages=messages,
max_tokens=max_output_tokens,
)
Questo esempio è volutamente semplice. In un sistema distribuito, usa Redis, i lock consultivi di Postgres o una coda con invio centralizzato. Il principio fondamentale è lo stesso: limita sia il numero di richieste sia il budget di token prima di chiamare l'API.
Tentativi: il backoff è necessario ma non sufficiente
I tentativi sono utili in caso di:
- Transitorie
429risposte - Timeout di rete
- Temporanei
500,502,503, oppure504errori - Sovraccarico lato fornitore
- Reset delle connessioni
I tentativi sono pericolosi in caso di:
- Errori di convalida
- Errori relativi alla lunghezza del contesto
- Errori di autenticazione
- Rifiuti dovuti alle politiche di sicurezza
- Prompt errati che falliscono in modo deterministico
- Chiamate a strumenti non idempotenti
La politica dei tentativi dovrebbe prima classificare gli errori.
| Tipo di errore | Riprovare? | Azione tipica |
|---|---|---|
429 rate_limit_exceeded | Sì | Applicare il backoff, rispettare gli header dei tentativi, ridurre la concorrenza |
500/502/503/504 | Sì | Applicare il backoff con jitter e un numero limitato di tentativi |
| Timeout prima del corpo della risposta | Forse | Riprovare con una chiave di idempotenza, se supportata |
| Lunghezza del contesto superata | No | Comprimere, suddividere in blocchi o instradare verso un modello con contesto più lungo |
| Chiave API non valida | No | Disabilitare la chiave, avvisare l'operatore |
| Rifiuto per motivi di contenuto o sicurezza | No | Restituire un errore controllato o riscrivere la richiesta |
| Effetto collaterale dello strumento già eseguito | No/Forse | Richiede una progettazione che garantisca l'idempotenza |
La forma di base del backoff:
import random
def retry_delay(attempt: int, base=0.5, cap=30.0):
exponential = min(cap, base * (2 ** attempt))
return random.uniform(0, exponential)
Quel random.uniform(0, exponential) è il jitter. Non ometterlo.
Senza jitter:
100 workers fail
100 workers sleep 1s
100 workers retry
100 workers fail
100 workers sleep 2s
100 workers retry
Con jitter:
100 workers fail
workers retry across a spread of 0-1s
then 0-2s
then 0-4s
Il secondo schema lascia al provider e alla tua coda il margine necessario per riprendersi.
Rispetta gli header di retry quando sono presenti
Molte API includono un'indicazione come:
Retry-After: 12
oppure header di reset specifici del provider. Se l'API indica un intervallo di attesa preciso, usalo come limite minimo:
delay = max(header_retry_after_seconds, retry_delay(attempt))
await asyncio.sleep(delay)
In pratica, imposto anche un limite al tempo totale dedicato ai tentativi. Una richiesta di chat rivolta all'utente non dovrebbe continuare a riprovare silenziosamente per due minuti mentre l'utente fissa un indicatore di caricamento. Un processo batch in background può attendere molto più a lungo.
Esempio di criterio:
{
"interactive_chat": {
"max_attempts": 3,
"max_total_retry_seconds": 8
},
"batch_summarization": {
"max_attempts": 8,
"max_total_retry_seconds": 300
}
}Una coda è meglio di un assalto simultaneo dei worker
Se il lavoro da svolgere supera la quota disponibile, i worker non dovrebbero chiamare tutti direttamente l'API LLM. Inserisci una coda prima dell'API.
Un’architettura pratica:
App/API
-> Job queue
-> Rate-aware dispatcher
-> Provider client
-> Result store
Il dispatcher gestisce:
- conteggio delle RPM
- conteggio dei TPM
- selezione del modello
- criterio di retry
- fallback su un altro provider
- limiti di concorrenza
- priorità della coda
Questo rende visibile il sovraccarico. Invece di migliaia di attività che falliscono con 429, vedi aumentare la lunghezza della coda e il tempo di attesa stimato.
Nei sistemi basati su Redis, preferisco separare i job «pronti per l'esecuzione» da quelli con «retry differito»:
llm:ready
llm:delayed
llm:dead_letter
Un job fallito non torna immediatamente in ready. Gli viene assegnato un timestamp per il tentativo successivo. Un piccolo scheduler riporta nella coda dei job pronti quelli per cui è arrivato il momento di riprovare.
{
"job_id": "sum_7f13",
"model": "claude-sonnet-4.6",
"estimated_tokens": 6200,
"attempt": 2,
"not_before": "2026-02-03T09:14:22Z"
}
Quel not_before impedisce le tempeste di retry.
Il controllo della concorrenza non equivale alla limitazione della frequenza
I limiti di concorrenza pongono un tetto al lavoro in corso. I limiti di frequenza pongono un tetto al lavoro svolto in un intervallo di tempo.
Servono entrambi.
Immagina che ogni chiamata richieda 20 secondi e che il tuo limite RPM sia 60. Un limitatore basato solo sulle RPM consente una richiesta al secondo. Dopo 20 secondi, hai circa 20 chiamate simultanee. Se la latenza sale a 90 secondi, lo stesso limite RPM genera 90 chiamate simultanee. Questo potrebbe superare i limiti del provider o delle risorse locali.
Usa un semaforo per la concorrenza:
provider_concurrency = asyncio.Semaphore(20)
async def call_model(job):
async with provider_concurrency:
await limiter.acquire(job.estimated_tokens)
return await send_request(job)
Un'insidia comune: se acquisisci il semaforo troppo presto, i worker restano inattivi occupando preziosi slot di concorrenza mentre attendono i token del limitatore di frequenza. Di solito, conviene prima acquisire la capacità consentita dal limite di frequenza, poi acquisire uno slot di concorrenza immediatamente prima della chiamata di rete. Se l'attesa del limitatore può essere lunga, evita di occupare le risorse dei worker durante quell'attesa.
Degradazione controllata: concentra la qualità dove conta
Quando i limiti di frequenza si fanno sentire, hai altre opzioni oltre al fallimento.
Per esempio, un assistente per il supporto clienti potrebbe ridurre il livello di servizio in questo modo:
| Livello di pressione | Strategia | Impatto sull'utente |
|---|---|---|
| Normale | Usa il modello più potente tra quelli configurati | Qualità migliore |
| Lieve pressione sui TPM | Riduci max_tokens, accorcia il contesto recuperato | Risposte leggermente più brevi |
| Pressione moderata | Instrada le attività semplici verso un modello più piccolo | Minimo se la classificazione o l'instradamento sono semplici |
| Pressione elevata | Metti in coda i job non urgenti | Lavoro in background ritardato |
| Pressione estrema | Restituisci una risposta dalla cache o basata su un modello predefinito | Qualità inferiore, ma servizio disponibile |
Esempi concreti:
- Instrada l'assegnazione di tag ai ticket verso Haiku 4.5, Qwen o una variante piccola di Llama invece di usare un modello di frontiera.
- Usa Sonnet 4.6 per il ragionamento ordinario e riserva Opus 4.8 o GPT-5.5 ai percorsi di escalation.
- Sposta l'analisi con contesto lungo su Fable 5 o Gemini 3 solo quando il prompt richiede davvero quella finestra di contesto.
- Per i job batch, sospendi i tenant a bassa priorità prima di incidere sul traffico interattivo.
Il compromesso è la complessità. Ogni percorso di fallback richiede test, osservabilità e accettazione da parte del team di prodotto. In alcuni ambiti, una risposta peggiore può essere più dannosa di una risposta ritardata.
Fallback con più chiavi e più provider
L'instradamento con più chiavi e più provider può migliorare la resilienza, ma è facile implementarlo male.
Esistono tre schemi distinti:
| Schema | Cosa risolve | Rischio |
|---|---|---|
| Più chiavi, stesso provider/account | Isolamento operativo | Può violare i termini del provider se usato per aggirare i limiti |
| Più account/regioni | Isolamento dei guasti | Maggiore complessità nella fatturazione e nella governance |
| Più provider/modelli | Disponibilità e flessibilità dei costi | Variabilità dell'output, incompatibilità dei prompt |
Non usare più chiavi per aggirare i limiti di nascosto. Usale per separazioni legittime: tenant, ambienti, prodotti o ambiti di fatturazione.
Un criterio di instradamento chiaro potrebbe essere questo:
{
"task": "support_ticket_summary",
"primary": {
"provider": "anthropic",
"model": "claude-sonnet-4.6"
},
"fallbacks": [
{
"provider": "openai",
"model": "gpt-5.5",
"when": ["provider_5xx", "rate_limit_after_retry_budget"]
},
{
"provider": "google",
"model": "gemini-3",
"when": ["context_length_needed", "primary_unavailable"]
}
],
"degrade_to": {
"provider": "open_model_cluster",
"model": "qwen",
"when": ["batch_low_priority"]
}
}
Se usi un aggregatore o una piattaforma come AI Prime Tech per accedere a più modelli, valgono comunque le stesse regole di ingegneria: tieni traccia esplicitamente dell'idempotenza, dei budget, delle condizioni di fallback e delle regole di fatturazione finale.
Evitare la doppia fatturazione durante il fallback
La parte difficile del fallback è sapere se la prima richiesta è stata completata.
Considera questa sequenza:
Client sends request to Provider A
Provider A processes it successfully
Network connection drops before client receives response
Client retries with Provider B
Both providers bill
Two completions may exist
Non puoi risolvere completamente questo problema dal lato client, a meno che il provider non supporti le chiavi di idempotenza o la consultazione dello stato delle richieste. Puoi però limitare i danni.
Usa le chiavi di idempotenza quando disponibili
Genera una chiave stabile per ogni operazione logica:
import hashlib
import json
def idempotency_key(task_name, tenant_id, input_payload):
canonical = json.dumps(input_payload, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(canonical.encode()).hexdigest()
return f"{tenant_id}:{task_name}:{digest}"
Memorizza l'operazione prima dell'invio:
{
"operation_id": "acme:summarize:9d2a...",
"status": "in_flight",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"created_at": "2026-02-03T09:12:01Z"
}
Poi aggiornala dopo il completamento:
{
"operation_id": "acme:summarize:9d2a...",
"status": "completed",
"provider_request_id": "req_abc123",
"usage": {
"input_tokens": 4180,
"output_tokens": 612
}
}
Prima di riprovare altrove, controlla la tabella delle operazioni. Se un altro worker l'ha già completata, restituisci il risultato memorizzato.
Distingui i tipi di timeout
Non tutti i timeout sono uguali:
- Timeout di connessione: probabilmente la richiesta non ha mai raggiunto il provider
- Timeout di lettura prima degli header: esito incerto
- Timeout durante lo streaming dei token: quasi certamente il provider ha elaborato la richiesta
- Annullamento da parte del client: dipende dal fatto che l'annullamento sia stato propagato
Nei casi incerti, preferisco un criterio di retry prudente:
interactive:
- retry connect timeout
- do not cross-provider retry after partial stream
- show recoverable error if status unknown
batch:
- retry after reconciliation delay
- prefer same provider with idempotency key
- fallback only after retry budget expires
Gestisci lo streaming con attenzione
Lo streaming migliora la latenza percepita, ma complica i tentativi successivi. Una volta inviato un output parziale all'utente, riprovare con un altro modello può produrre una continuazione diversa.
Per le chat in streaming, di solito considero la «ricezione del primo token» come il punto oltre il quale non è più possibile riprovare in modo trasparente. Da quel momento, gli errori diventano visibili:
"The response was interrupted. Retry?"
È meno elegante di un ripristino silenzioso, ma evita di unire output incompatibili provenienti da modelli diversi.
Osservabilità: le metriche davvero utili
Serve più del solo tasso di successo.
Monitora almeno:
llm_requests_total{provider,model,status}
llm_tokens_reserved_total{provider,model}
llm_tokens_used_total{provider,model}
llm_retries_total{provider,model,error_type}
llm_queue_depth{priority,task_type}
llm_queue_wait_seconds{priority,task_type}
llm_rate_limit_hits_total{provider,model,limit_type}
llm_fallbacks_total{from_provider,to_provider,reason}
llm_duplicate_suppressed_total{task_type}
Durante gli incidenti reali, il pannello più utile della dashboard spesso non è quello della latenza. È:
queue_wait_seconds p50 / p95
rate_limit_hits by provider and model
tokens_reserved vs tokens_used
fallback rate by reason
Se tokens_reserved è costantemente molto superiore a tokens_used, le tue prenotazioni sono troppo conservative. Se è troppo vicino all'utilizzo effettivo o inferiore, stai allocando più capacità di quella disponibile e raggiungerai inaspettatamente il limite TPM.
Un wrapper pratico per i tentativi
Ecco uno schema compatto per riprovare solo in caso di errori per cui è opportuno farlo:
import asyncio
import random
import time
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
class LLMError(Exception):
def __init__(self, status, message="", retry_after=None):
self.status = status
self.retry_after = retry_after
super().__init__(message)
def jittered_backoff(attempt, base=0.5, cap=20):
return random.uniform(0, min(cap, base * (2 ** attempt)))
async def call_with_retries(fn, max_attempts=4, max_elapsed=20):
started = time.monotonic()
for attempt in range(max_attempts):
try:
return await fn()
except LLMError as error:
elapsed = time.monotonic() - started
if error.status not in RETRYABLE_STATUS:
raise
if attempt == max_attempts - 1 or elapsed >= max_elapsed:
raise
delay = jittered_backoff(attempt)
if error.retry_after is not None:
delay = max(delay, error.retry_after)
remaining = max_elapsed - elapsed
await asyncio.sleep(min(delay, remaining))
Il wrapper è volutamente banale. La complessità dovrebbe risiedere nella classificazione, nella gestione delle code, nell'idempotenza e nell'instradamento, non in un ciclo magico che continua a riprovare all'infinito.
Indicazioni pratiche
- Modella i limiti di frequenza sia in termini di RPM sia di TPM. Una limitazione che tiene conto dei token evita sorprese dovute a prompt di grandi dimensioni.
- Usa backoff esponenziale con jitter, e rispetta gli header per i nuovi tentativi quando il provider li fornisce.
- Inserisci una coda e un dispatcher tra la tua applicazione e i provider di LLM per carichi di lavoro batch o ad alto volume.
- Controllo la concorrenza separatamente dai limiti di frequenza; altrimenti i picchi di latenza possono creare troppe chiamate in corso.
- Design degradazione controllata in modo intenzionale: modelli più piccoli, contesto più breve, attività in background accodate o risposte memorizzate nella cache.
- Tratta fallback tra più provider come una funzionalità di affidabilità, non come un'autorizzazione a inviare indiscriminatamente richieste duplicate.
- Usa chiavi di idempotenza e registri delle operazioni per ridurre gli addebiti doppi ed eliminare le risposte generate duplicate.
- Sii trasparente con gli utenti quando una risposta in streaming fallisce dopo che sono già stati emessi dei token.
- Crea dashboard incentrate su attesa in coda, prenotazione dei token, raggiungimento dei limiti di frequenza, nuovi tentativi e motivi del fallback.
- Mantieni semplice la logica dei nuovi tentativi; il vero lavoro in produzione consiste nel definire i budget, pianificare e sapere quando non riprovare.
Un'unica chiave API per Claude Opus 5.5, Sonnet 5, Haiku 4.5 e Fable 5.1, oltre a GPT-6 modelli. Pagamento a consumo, senza abbonamento.
Ottieni la tua chiave API →