· 7 min · API

Gestire i limiti di frequenza e i nuovi tentativi nelle API degli LLM

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 API
  • tokens_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 429 risposte
  • Timeout di rete
  • Temporanei 500, 502, 503, oppure 504 errori
  • 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 erroreRiprovare?Azione tipica
429 rate_limit_exceededSìApplicare il backoff, rispettare gli header dei tentativi, ridurre la concorrenza
500/502/503/504SìApplicare il backoff con jitter e un numero limitato di tentativi
Timeout prima del corpo della rispostaForseRiprovare con una chiave di idempotenza, se supportata
Lunghezza del contesto superataNoComprimere, suddividere in blocchi o instradare verso un modello con contesto più lungo
Chiave API non validaNoDisabilitare la chiave, avvisare l'operatore
Rifiuto per motivi di contenuto o sicurezzaNoRestituire un errore controllato o riscrivere la richiesta
Effetto collaterale dello strumento già eseguitoNo/ForseRichiede 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 pressioneStrategiaImpatto sull'utente
NormaleUsa il modello più potente tra quelli configuratiQualità migliore
Lieve pressione sui TPMRiduci max_tokens, accorcia il contesto recuperatoRisposte leggermente più brevi
Pressione moderataInstrada le attività semplici verso un modello più piccoloMinimo se la classificazione o l'instradamento sono semplici
Pressione elevataMetti in coda i job non urgentiLavoro in background ritardato
Pressione estremaRestituisci una risposta dalla cache o basata su un modello predefinitoQualità 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:

SchemaCosa risolveRischio
Più chiavi, stesso provider/accountIsolamento operativoPuò violare i termini del provider se usato per aggirare i limiti
Più account/regioniIsolamento dei guastiMaggiore complessità nella fatturazione e nella governance
Più provider/modelliDisponibilità e flessibilità dei costiVariabilità 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.
A

AI Prime Tech pubblica questo blog. Gli articoli sono redatti con l'assistenza dell'AI; verifica nella documentazione ufficiale i dettagli specifici della versione, come gli ID dei modelli, i prezzi e i limiti, prima di farvi affidamento.

Ottieni accesso all'API Claude a un costo inferiore

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 →
AI Prime Tech è un gateway API indipendente di terze parti. Claude™ e Anthropic® sono marchi di Anthropic, PBC. Non si intende suggerire alcuna affiliazione o approvazione.