Como lidar com limites de taxa e novas tentativas em APIs de LLM
Às 09:12 de uma terça-feira, nosso pipeline de sumarização parecia saudável: 96 workers ativos, latência mediana do modelo em torno de 2.8 segundos, sem aumento na taxa de erros 5xx. Então, um cliente enviou um acúmulo de 38,000 tickets de suporte.
Em quatro minutos, o pipeline estava “fora do ar” sem que nenhum servidor estivesse fora do ar. Estávamos atingindo dois limites diferentes ao mesmo tempo:
requests_per_minute: chamadas de API demaistokens_per_minute: volume excessivo de tokens de prompt e de conclusão
A lógica ingênua de novas tentativas piorou a situação. Cada worker recebeu 429, aguardou um segundo e tentou novamente ao mesmo tempo que os demais. Isso criou uma onda de novas tentativas, que gerou mais 429, o que criou outra onda. O sistema havia criado acidentalmente um ataque sincronizado de negação de serviço contra sua própria cota.
Lidar com limites de taxa de LLM não se resume a “adicionar backoff exponencial”. Em produção, você precisa considerar tokens, filas, concorrência, novas tentativas, idempotência, provedores alternativos e comportamento de cobrança como um único sistema.
Os dois limites que importam: RPM e TPM
A maioria das APIs de LLM aplica alguma combinação de:
- RPM: requisições por minuto
- TPM: tokens por minuto
- RPD / TPD: requisições ou tokens por dia
- Limites de requisições simultâneas: número máximo de chamadas em andamento
- Limites específicos por modelo: limites maiores para modelos menores, limites menores para modelos de ponta
O ponto importante: RPM e TPM causam falhas de formas diferentes.
Se você enviar muitos prompts pequenos, atingirá primeiro o limite de RPM. Se enviar menos prompts, mas maiores, atingirá primeiro o limite de TPM.
Exemplo:
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
Uma armadilha comum: os desenvolvedores limitam a taxa de requisições, mas não a de tokens. Isso funciona no ambiente de homologação, onde os prompts são curtos. Então, os usuários em produção colam PDFs, históricos de conversa, logs ou contratos jurídicos, e o orçamento de tokens evapora.
Na prática, trato cada chamada de LLM como uma reserva de dois recursos:
cost = {
requests: 1,
tokens: estimated_prompt_tokens + max_output_tokens
}
Mesmo que o modelo retorne menos tokens de saída do que max_output_tokens, reservar o limite superior evita alocar mais recursos do que os disponíveis.
O que realmente acontece quando você ignora o TPM
Suponha que você tenha:
TPM limit: 120,000
Average request: 4,000 input tokens + 1,000 max output tokens
Reserved cost: 5,000 tokens
Sua vazão máxima real não é “o maior número possível de workers”. É:
120,000 / 5,000 = 24 requests per minute
Isso equivale, em média, a uma requisição a cada 2.5 segundos.
Se você executar 100 workers, todas as chamadas do primeiro lote poderão começar com sucesso se o provedor permitir picos de tráfego. Depois, a conta atinge um limite rígido. Cada requisição subsequente recebe 429, e as novas tentativas se acumulam.
A solução não é apenas reduzir o número de workers. Você precisa de um escalonador que entenda as reservas de tokens.
Um limitador de taxa mínimo que considera tokens
Para um único processo, um token bucket geralmente é suficiente. Você repõe a capacidade ao longo do tempo e exige que cada requisição obtenha tanto uma reserva de requisição quanto uma reserva de tokens.
Veja um exemplo simplificado em 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)
Uso:
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,
)
Isso é deliberadamente simples. Em um sistema distribuído, use Redis, locks consultivos do Postgres ou uma fila com despacho centralizado. O princípio importante é o mesmo: limite tanto a quantidade de requisições quanto o orçamento de tokens antes de chamar a API.
Novas tentativas: backoff é necessário, mas não suficiente
Novas tentativas são úteis para:
- Respostas transitórias
429respostas - Timeouts de rede
- Erros temporários
500,502,503, ou504erros - Sobrecarga no provedor
- Redefinições de conexão
Novas tentativas são perigosas para:
- Erros de validação
- Erros de tamanho do contexto
- Falhas de autenticação
- Rejeições por políticas de segurança
- Prompts inválidos que falham de forma determinística
- Chamadas de ferramentas não idempotentes
A política de novas tentativas deve classificar os erros primeiro.
| Tipo de erro | Tentar novamente? | Ação típica |
|---|---|---|
429 rate_limit_exceeded | Sim | Aplicar backoff, respeitar os cabeçalhos de nova tentativa e reduzir a concorrência |
500/502/503/504 | Sim | Aplicar backoff com jitter e limitar o número de tentativas |
| Timeout antes do corpo da resposta | Talvez | Tentar novamente com uma chave de idempotência, se houver suporte |
| Limite de tamanho do contexto excedido | Não | Comprimir, dividir em partes ou encaminhar para um modelo com contexto maior |
| Chave de API inválida | Não | Desativar a chave e alertar o operador |
| Rejeição por conteúdo ou segurança | Não | Retornar uma falha controlada ou reformular a requisição |
| Efeito colateral da ferramenta já executado | Não/Talvez | Exige um projeto que contemple idempotência |
A forma básica do 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)
Isso random.uniform(0, exponential) é jitter. Não o ignore.
Sem jitter:
100 workers fail
100 workers sleep 1s
100 workers retry
100 workers fail
100 workers sleep 2s
100 workers retry
Com jitter:
100 workers fail
workers retry across a spread of 0-1s
then 0-2s
then 0-4s
O segundo padrão dá ao provedor e à sua própria fila espaço para se recuperarem.
Respeite os cabeçalhos de repetição quando estiverem presentes
Muitas APIs incluem uma indicação como:
Retry-After: 12
ou cabeçalhos de redefinição específicos do provedor. Se a API informar um tempo de espera concreto, use-o como mínimo:
delay = max(header_retry_after_seconds, retry_delay(attempt))
await asyncio.sleep(delay)
Na prática, também limito o tempo total de novas tentativas. Uma solicitação de chat voltada ao usuário não deve continuar tentando silenciosamente por dois minutos enquanto ele fica olhando para um indicador de carregamento. Um processamento em lote em segundo plano pode esperar muito mais.
Exemplo de política:
{
"interactive_chat": {
"max_attempts": 3,
"max_total_retry_seconds": 8
},
"batch_summarization": {
"max_attempts": 8,
"max_total_retry_seconds": 300
}
}Usar filas é melhor do que uma avalanche de workers
Se você tem mais trabalho do que a cota permite, os workers não devem todos chamar diretamente a API de LLM. Coloque uma fila antes dela.
Uma arquitetura prática:
App/API
-> Job queue
-> Rate-aware dispatcher
-> Provider client
-> Result store
O despachante é responsável por:
- controle de RPM
- controle de TPM
- seleção de modelo
- política de novas tentativas
- alternância para um provedor de contingência
- limites de concorrência
- prioridade da fila
Isso torna a sobrecarga visível. Em vez de milhares de tarefas falhando com 429, você vê o tamanho da fila crescer e o tempo estimado de espera aumentar.
Em sistemas baseados em Redis, gosto de separar tarefas “prontas para executar” de tarefas com “nova tentativa adiada”:
llm:ready
llm:delayed
llm:dead_letter
Uma tarefa que falhou não retorna imediatamente para ready. Ela recebe um registro de data e hora para a nova tentativa agendada. Um pequeno agendador move as tarefas cujo horário chegou de volta para a fila de tarefas prontas.
{
"job_id": "sum_7f13",
"model": "claude-sonnet-4.6",
"estimated_tokens": 6200,
"attempt": 2,
"not_before": "2026-02-03T09:14:22Z"
}
Isso not_before campo evita tempestades de novas tentativas.
Controle de concorrência não é o mesmo que limitação de taxa
Os limites de concorrência restringem o trabalho em andamento. Os limites de taxa restringem o trabalho por janela de tempo.
Você precisa de ambos.
Imagine que cada chamada leve 20 segundos e seu RPM seja 60. Um limitador baseado apenas em RPM permite uma solicitação por segundo. Após 20 segundos, você tem cerca de 20 chamadas simultâneas. Se a latência subir para 90 segundos, o mesmo RPM gera 90 chamadas simultâneas. Isso pode exceder os limites do provedor ou dos recursos locais.
Use um semáforo para controlar a concorrência:
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)
Uma armadilha comum: adquirir o semáforo cedo demais faz com que seus workers fiquem ociosos, ocupando vagas escassas de concorrência enquanto esperam por tokens de taxa. Em geral, primeiro adquira capacidade de taxa e depois adquira uma vaga de concorrência imediatamente antes da chamada de rede. Se a espera pelo limitador puder ser longa, evite ocupar recursos dos workers durante esse período.
Degradação controlada: invista em qualidade onde ela importa
Quando os limites de taxa começam a afetar o sistema, você tem outras opções além de falhar.
Por exemplo, um assistente de suporte ao cliente poderia reduzir sua capacidade assim:
| Nível de pressão | Estratégia | Impacto para o usuário |
|---|---|---|
| Normal | Usar o modelo mais capaz configurado | Melhor qualidade |
| Pressão leve sobre TPM | Reduzir max_tokens, enxugar o contexto recuperado | Respostas um pouco mais curtas |
| Pressão moderada | Encaminhar tarefas simples para um modelo menor | Mínimo se a classificação ou o roteamento forem fáceis |
| Pressão alta | Colocar tarefas não urgentes na fila | Trabalho em segundo plano adiado |
| Pressão severa | Retornar uma resposta em cache ou padronizada | Qualidade inferior, mas disponível |
Exemplos concretos:
- Encaminhe a categorização de chamados para Haiku 4.5, Qwen ou uma variante pequena de Llama em vez de usar um modelo de ponta.
- Use Sonnet 4.6 para raciocínio habitual e reserve Opus 4.8 ou GPT-5.5 para os casos que exigem escalonamento.
- Transfira a análise de contextos longos para Fable 5 ou Gemini 3 apenas quando o prompt realmente precisar dessa janela de contexto.
- Para processamentos em lote, pause os clientes de baixa prioridade antes de afetar o tráfego interativo.
A contrapartida é a complexidade. Cada caminho de contingência precisa de testes, observabilidade e aceitação pela equipe de produto. Em alguns domínios, uma resposta pior pode causar mais danos do que uma resposta atrasada.
Contingência com múltiplas chaves e múltiplos provedores
O roteamento com múltiplas chaves e múltiplos provedores pode melhorar a resiliência, mas é fácil implementá-lo mal.
Há três padrões distintos:
| Padrão | O que resolve | Risco |
|---|---|---|
| Múltiplas chaves, mesmo provedor e conta | Isolamento operacional | Pode violar os termos do provedor se for usado para contornar limites |
| Múltiplas contas ou regiões | Isolamento de falhas | Maior complexidade de faturamento e governança |
| Múltiplos provedores ou modelos | Disponibilidade e flexibilidade de custos | Variação nas saídas, incompatibilidade de prompts |
Não use múltiplas chaves para burlar limites. Use-as para uma separação legítima: clientes, ambientes, produtos ou limites de faturamento.
Uma política clara de roteamento poderia ser assim:
{
"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 você usa um agregador ou uma plataforma como AI Prime Tech para acessar múltiplos modelos, as mesmas regras de engenharia continuam valendo: acompanhe explicitamente a idempotência, os orçamentos, as condições de contingência e a semântica do faturamento final.
Como evitar cobrança duplicada na alternância de contingência
A parte difícil da alternância de contingência é saber se a primeira solicitação foi concluída.
Considere esta sequência:
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
Você não pode resolver isso completamente do lado do cliente, a menos que o provedor ofereça suporte a chaves de idempotência ou à consulta de solicitações. Mas pode reduzir os danos.
Use chaves de idempotência quando disponíveis
Gere uma chave estável para cada operação lógica:
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}"
Armazene a operação antes do envio:
{
"operation_id": "acme:summarize:9d2a...",
"status": "in_flight",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"created_at": "2026-02-03T09:12:01Z"
}
Depois, atualize-a após a conclusão:
{
"operation_id": "acme:summarize:9d2a...",
"status": "completed",
"provider_request_id": "req_abc123",
"usage": {
"input_tokens": 4180,
"output_tokens": 612
}
}
Antes de tentar novamente em outro provedor, consulte a tabela de operações. Se outro worker já a tiver concluído, retorne o resultado armazenado.
Diferencie os tipos de timeout
Nem todos os timeouts são iguais:
- Timeout de conexão: a solicitação provavelmente nunca chegou ao provedor
- Timeout de leitura antes dos cabeçalhos: incerto
- Timeout durante a transmissão de tokens: o provedor quase certamente a processou
- Cancelamento pelo cliente: depende de o cancelamento ter sido propagado
Para casos incertos, prefiro uma política conservadora de novas tentativas:
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
Faça streaming com cuidado
O streaming melhora a latência percebida, mas complica as novas tentativas. Depois que você envia uma saída parcial ao usuário, tentar novamente com outro modelo pode produzir uma continuação diferente.
Em chats com streaming, geralmente considero o “primeiro token recebido” como o ponto a partir do qual não há mais novas tentativas transparentes. Depois disso, as falhas se tornam visíveis:
"The response was interrupted. Retry?"
Isso é menos elegante do que uma recuperação silenciosa, mas evita juntar saídas incompatíveis de modelos diferentes.
Observabilidade: as métricas que realmente ajudam
Você precisa de mais do que a taxa de sucesso.
Acompanhe pelo menos:
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 incidentes reais, o painel mais útil do dashboard muitas vezes não é o de latência. É:
queue_wait_seconds p50 / p95
rate_limit_hits by provider and model
tokens_reserved vs tokens_used
fallback rate by reason
Se tokens_reserved for consistentemente muito maior que tokens_used, suas reservas são conservadoras demais. Se estiver muito próximo ou abaixo do uso real, você estará alocando além da capacidade e atingirá o limite de TPM inesperadamente.
Um wrapper prático para novas tentativas
Aqui está um padrão compacto para repetir apenas as chamadas com falhas elegíveis:
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))
O wrapper é intencionalmente simples. A sofisticação deve estar na classificação, nas filas, na idempotência e no roteamento — não em um loop mágico de novas tentativas que insiste para sempre.
Recomendações Práticas
- Modele os limites de taxa considerando tanto RPM quanto TPM. O controle de taxa baseado em tokens evita surpresas com prompts grandes.
- Use backoff exponencial com aleatoriedade, e respeite os cabeçalhos de nova tentativa quando o provedor os fornecer.
- Coloque uma fila e um despachante entre sua aplicação e os provedores de LLM para cargas de trabalho em lote ou de alto volume.
- Controle concorrência separadamente dos limites de taxa; caso contrário, picos de latência podem criar chamadas em andamento em excesso.
- Design degradação controlada de forma intencional: modelos menores, contexto mais curto, trabalho em segundo plano enfileirado ou respostas em cache.
- Trate fallback entre vários provedores como um recurso de confiabilidade, não como uma licença para disparar solicitações duplicadas indiscriminadamente.
- Use chaves de idempotência e registros de operações para reduzir a cobrança duplicada e impedir gerações de resposta duplicadas.
- Seja transparente com os usuários quando uma resposta em streaming falhar após tokens já terem sido emitidos.
- Crie painéis com foco em tempo de espera na fila, reserva de tokens, ocorrências de limites de taxa, novas tentativas e motivos de fallback.
- Mantenha a lógica de novas tentativas simples; o verdadeiro trabalho em produção é definir orçamentos, agendar a execução e saber quando não tentar novamente.
Uma única chave de API para Claude Opus 5.5, Sonnet 5, Haiku 4.5 e Fable 5.1, além de GPT-6 modelos. Pague conforme o uso, sem assinatura.
Obtenha Sua Chave de API →