Blog

Timeout e cancelamento em cadeia de chamadas de LLM

Um pipeline de IA raramente é uma chamada só. Uma pergunta do cliente vira uma consulta de embedding, um retrieval, um rerank, uma chamada ao modelo e talvez uma validação de saída, cada etapa esperando a anterior. Cada uma dessas chamadas pode demorar, e a soma delas é o tempo que o cliente espera olhando para o indicador de digitando. O erro clássico é dar um timeout generoso para cada etapa isoladamente: trinta segundos para o retrieval, sessenta para o modelo, trinta para a validação. Cada etapa parece razoável sozinha, mas a cadeia inteira pode levar dois minutos, e o cliente desistiu no primeiro. Pior: quando ele desiste, as chamadas continuam rodando, queimando tokens e ocupando conexão para produzir uma resposta que ninguém vai ler. Este artigo mostra como tratar o tempo como um orçamento único da requisição, propagar o deadline pela cadeia, cancelar de verdade o trabalho que perdeu o prazo, distinguir o timeout que merece retry do que merece degradação e testar que a cadeia responde algo útil dentro do prazo em vez de nada depois dele.

2026-07-22 / IA Aplicada / 13 min

01

Timeout por etapa esconde o estouro da cadeia

Quando cada etapa da cadeia define o próprio timeout sem olhar para as outras, a matemática trai. Retrieval com timeout de dez segundos, rerank com cinco, chamada ao modelo com sessenta, validação com dez: nenhum número parece absurdo, e a soma é oitenta e cinco segundos de pior caso para uma requisição que o cliente esperava em cinco. O pior caso parece raro até o dia em que o provedor do modelo degrada e todas as chamadas passam a encostar no próprio limite ao mesmo tempo. Nesse dia, a cadeia inteira anda no pior caso, o p99 da requisição vira a soma dos p99 das etapas, e o atendimento trava sem que nenhuma etapa individual tenha tecnicamente falhado. O timeout por etapa responde à pergunta errada: ele limita quanto cada peça pode demorar, quando a pergunta que importa é quanto a requisição inteira pode demorar.

A inversão correta é começar pelo orçamento da requisição e derivar os limites das etapas dele. O cliente tolera oito segundos? Esse é o orçamento total, e ele é gasto conforme a cadeia anda: se o retrieval consumiu três segundos, a chamada ao modelo não tem mais os sessenta do seu timeout local, tem os cinco que sobraram. Cada etapa recebe um deadline, um instante absoluto no relógio em que a resposta precisa existir, e calcula o próprio limite como a diferença entre o deadline e o agora. Uma etapa que recebe um deadline já vencido nem executa: falha rápido ou aciona a degradação, em vez de gastar tempo produzindo um resultado que chegará tarde demais. O deadline transforma um conjunto de timeouts locais desconectados num contrato único que a cadeia inteira respeita.

AbordagemComo limitaO que quebra
Timeout por etapaCada chamada tem limite próprio e isoladoA soma dos piores casos estoura o prazo do cliente sem nenhuma etapa falhar
Timeout global sem propagaçãoUm relógio na borda derruba tudo no fimA cadeia trabalha até o fim e joga tudo fora; nada degrada no meio
Orçamento com deadline propagadoCada etapa herda o tempo que sobrouExige passar deadline e signal por toda a cadeia, inclusive nas libs

02

O orçamento de tempo da requisição

O orçamento nasce na borda, onde a requisição entra, e é definido pelo canal, não pela infra. Um atendimento por chat tolera segundos; um webhook do WhatsApp precisa de resposta HTTP rápida mesmo que o processamento continue depois; um job de análise noturno tolera minutos. O número certo vem da pergunta "quanto tempo o cliente espera antes de considerar que quebrou", e não de "quanto tempo as etapas precisam". Definido o orçamento, ele vira um deadline absoluto, o instante de agora mais o orçamento, e esse instante viaja com a requisição pela cadeia inteira. Instante absoluto, não duração: se cada etapa recebesse "você tem cinco segundos", o tempo gasto em fila entre as etapas não seria descontado de ninguém, e a cadeia estouraria o prazo com todas as etapas dentro dos seus limites.

Com o deadline na mão, cada etapa faz a mesma conta antes de trabalhar: quanto falta até o deadline, descontada uma reserva para o que ainda vem depois. Se a chamada ao modelo é a última etapa cara, ela pode usar quase tudo que sobrou; se depois dela ainda vêm a validação e a formatação, ela precisa deixar espaço para elas. Essa reserva é o equivalente temporal de deixar espaço na janela de contexto para a resposta: usar o limite bruto até a borda é garantir que a última etapa nunca cabe. E a conta expõe cedo o caso perdido: uma etapa que calcula que sobraram duzentos milissegundos para uma chamada de modelo que nunca responde nisso não tenta, ela falha imediatamente ou degrada, devolvendo o tempo restante para a cadeia fazer algo útil com ele.

// chain/deadline.js
// O orcamento nasce na borda e vira um deadline ABSOLUTO que viaja
// com a requisicao. Cada etapa calcula o proprio limite a partir dele.

export function startBudget(totalMs) {
  const deadline = Date.now() + totalMs;
  return {
    deadline,
    // Quanto sobra agora, descontando a reserva para as etapas seguintes.
    remaining(reserveMs = 0) {
      return deadline - Date.now() - reserveMs;
    },
  };
}

export async function callWithDeadline(fn, budget, { reserveMs, minMs }) {
  const timeLeft = budget.remaining(reserveMs);

  // Deadline ja vencido ou tempo insuficiente: nao tenta.
  // Falhar rapido devolve o tempo restante para a degradacao.
  if (timeLeft < minMs) {
    throw new DeadlineExceededError('sem tempo util para esta etapa');
  }

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeLeft);
  try {
    // O signal segue junto: quem estourar o prazo e CANCELADO,
    // nao apenas abandonado enquanto continua rodando.
    return await fn(controller.signal);
  } finally {
    clearTimeout(timer);
  }
}

03

Cancelar de verdade, não apenas parar de esperar

Existe uma diferença enorme entre parar de esperar uma chamada e cancelar a chamada. Um timeout implementado como corrida entre a promessa da chamada e um relógio faz o primeiro: quando o relógio ganha, o seu código segue em frente, mas a chamada continua viva por baixo, a conexão segue aberta, o provedor segue gerando tokens e cobrando por eles, e o pool de conexões segue ocupado por um trabalho cujo resultado ninguém vai ler. Em carga normal isso passa despercebido; em degradação, quando os timeouts disparam em massa, essas chamadas zumbis se acumulam, esgotam o pool e derrubam também as requisições saudáveis. O timeout sem cancelamento transforma um problema de latência num problema de capacidade.

Cancelar de verdade é propagar um sinal de aborto até a operação de rede. No ecossistema JavaScript o mecanismo é o AbortSignal: o mesmo objeto desce do handler da requisição até o fetch da chamada ao provedor, e quando alguém chama abort, a conexão HTTP é encerrada, o provedor para de gerar e o pool libera a vaga. Os SDKs dos principais provedores de LLM aceitam signal nas opções da chamada exatamente para isso. E o cancelamento tem duas origens que o mesmo sinal deve cobrir: o prazo que venceu e o cliente que desistiu. Quando o usuário fecha o chat ou a conexão cai, manter a cadeia rodando é puro desperdício, e o sinal de desconexão da borda deve abortar a cadeia inteira da mesma forma que o deadline faria. Um único signal, combinando as duas fontes, atravessa a cadeia de ponta a ponta.

Cancelamento propagado pela cadeia

  cliente          borda              cadeia de etapas
    |                |                     |
    | pergunta       |  budget = 8s        |
    |--------------->|  deadline absoluto  |
    |                |-------------------->| retrieval (signal)
    |                |                     | rerank    (signal)
    |                |                     | modelo    (signal)
    |                |                     |
    X desiste        |                     |
    |--- desconexao->| abort(signal) ----->X chamadas canceladas
    |                |                     | conexoes liberadas,
    |                |                     | provedor para de gerar
    |                |                     |
  sem signal: as chamadas viram zumbis, seguem gerando
  tokens e ocupando o pool para uma resposta que ninguem le

04

Timeout de conexão não é timeout de resposta

Tratar o tempo da chamada como um número só esconde fases com significados diferentes. Conectar ao provedor deve levar milissegundos: se a conexão não abre em dois ou três segundos, algo está errado na rede ou no provedor, e esperar mais não ajuda. Já a resposta de um modelo legitimamente leva segundos, e num streaming a fase que importa é outra: o tempo até o primeiro token, que mede se o provedor está vivo e processando, e o intervalo entre tokens, que detecta o stream que congelou no meio. Um timeout único e grande, dimensionado para a resposta completa, espera esse tempo inteiro até para descobrir que a conexão nunca abriu, desperdiçando quase todo o orçamento num caso que era diagnosticável no primeiro segundo.

  • Timeout de conexão: curto e agressivo, poucos segundos. Conexão que não abre rápido não vai abrir; falhar aqui cedo preserva orçamento para retry ou fallback.
  • Timeout até o primeiro token: o sinal de vida do streaming. Se o modelo não começou a responder em um prazo curto, tratar como falha e agir enquanto ainda há orçamento.
  • Timeout entre tokens: detecta o stream congelado. Tokens chegando, relógio zerando; silêncio prolongado no meio do stream é falha, mesmo com a conexão aberta.
  • Deadline total da etapa: o teto que vale mesmo com tudo saudável. Um stream lento que vai estourar o orçamento deve ser cortado, e o parcial recebido pode alimentar a degradação.

Separar as fases também melhora a decisão sobre o que fazer depois da falha. Uma conexão recusada em milissegundos deixou o orçamento quase intacto: dá para retentar ou acionar o fallback de provedor com folga. Um stream que congelou aos quarenta segundos consumiu quase tudo: retentar do zero é estourar o prazo com certeza, e a escolha real é entre usar o parcial recebido, degradar para uma resposta mais simples ou admitir a falha para o cliente. O mesmo erro de timeout, em fases diferentes, pede reações opostas, e só medir as fases separadamente permite escolher a certa.

05

Retry, degradação e o que fazer quando o prazo aperta

Timeout dentro de uma cadeia com orçamento muda a regra do retry. O retry clássico assume tempo de sobra: espera um backoff, tenta de novo, repete. Dentro de um orçamento, cada retry gasta o tempo das próximas etapas, e um retry que não cabe no tempo restante é pior que não retentar, porque queima o orçamento que a degradação usaria. A regra passa a ser: só retenta se o tempo restante comporta a nova tentativa inteira, com margem; senão, degrada. E degradar não é falhar, é responder menos: pular o rerank e usar o retrieval cru, trocar o modelo grande por um pequeno que responde no tempo que sobrou, responder sem a validação secundária, ou, no limite, dizer ao cliente que a resposta completa chega em instantes enquanto um caminho assíncrono termina o trabalho.

Para a degradação existir, cada etapa da cadeia precisa declarar o que acontece sem ela. Etapas obrigatórias, como a chamada principal ao modelo, não têm versão degradada, têm fallback, outro provedor ou modelo menor. Etapas opcionais, como rerank, enriquecimento e validações secundárias, declaram explicitamente o comportamento de pulo: o que a cadeia usa no lugar do resultado delas. Com isso, o estouro de prazo numa etapa opcional vira uma decisão local e barata, pula e segue, em vez de derrubar a requisição inteira. A cadeia bem desenhada sob pressão de tempo se parece com um avião soltando peso: vai abrindo mão do acessório para garantir que o essencial, uma resposta útil dentro do prazo, chega.

// chain/steps.js
// Cada etapa declara se e opcional e o que acontece quando o tempo
// nao da: retry so se couber INTEIRO no orcamento; senao, degrada.

export async function runStep(step, input, budget, signal) {
  try {
    return await callWithDeadline(
      (stepSignal) => step.run(input, stepSignal),
      budget,
      { reserveMs: step.reserveForRest, minMs: step.minUsefulMs },
    );
  } catch (err) {
    if (!isTimeoutOrDeadline(err)) throw err;

    // Retry so quando a nova tentativa INTEIRA cabe no tempo restante.
    // Retry que nao cabe queima o orcamento da degradacao.
    const canRetry =
      step.retryable && budget.remaining(step.reserveForRest) > step.expectedMs;
    if (canRetry) {
      return await step.run(input, signal);
    }

    // Etapa opcional degrada: a cadeia segue com o valor de pulo
    // (retrieval cru sem rerank, resposta sem validacao secundaria).
    if (step.optional) {
      return step.skipValue(input);
    }

    // Etapa obrigatoria sem tempo: fallback declarado ou falha honesta.
    if (step.fallback) {
      return await step.fallback(input, signal);
    }
    throw err;
  }
}

06

Testar a cadeia sob prazo apertado

O comportamento da cadeia sob timeout não se descobre em produção, se ensaia em teste, e o ingrediente principal é um provedor falso que você controla no tempo: um stub que demora o quanto o teste mandar, que congela no meio do stream, que nunca conecta, que responde no último milissegundo. Com ele, os cenários que importam viram testes determinísticos: a requisição inteira respeita o orçamento mesmo com toda etapa no pior caso; a etapa opcional lenta é pulada e a resposta sai sem ela; o cancelamento da borda aborta as chamadas em voo, visível no stub como conexão encerrada; o deadline vencido no meio da cadeia impede as etapas seguintes de sequer executarem. Cada um desses testes falha alto no CI quando alguém adiciona uma etapa que ignora o deadline ou esquece de propagar o signal.

  1. Teto do orçamento: com todas as etapas no pior caso simulado, a resposta ainda sai dentro do orçamento total; medir o tempo da requisição inteira, não o de cada etapa.
  2. Degradação por etapa: forçar timeout em cada etapa opcional, uma por vez, e verificar que a resposta sai sem ela e registra a degradação em vez de falhar.
  3. Cancelamento em voo: abortar a requisição na borda no meio da chamada ao modelo e verificar no stub que a conexão foi encerrada, não abandonada rodando.
  4. Deadline vencido a meio caminho: consumir o orçamento nas primeiras etapas e verificar que as seguintes falham rápido sem executar, em vez de trabalhar para ninguém.
  5. Zumbis sob carga: disparar muitas requisições contra um provedor degradado e medir conexões abertas; timeout sem cancelamento aparece aqui como pool esgotado.

Em produção, as métricas que contam a história são o tempo total da requisição contra o orçamento, quanto de margem sobrou em cada resposta, a taxa de degradação por etapa e a contagem de cancelamentos por origem, prazo vencido ou cliente desistiu. Margem encolhendo semana a semana avisa que a cadeia está engordando antes do primeiro estouro; degradação alta numa etapa aponta o gargalo real; cancelamentos por desistência do cliente medem o quanto o orçamento está acima da paciência real de quem espera. O timeout deixa de ser um erro esporádico nos logs e vira um orçamento visível, com saldo, gasto por etapa e extrato.

FAQ

Perguntas frequentes

Por que timeouts por etapa não bastam numa cadeia de chamadas de LLM?

Porque cada timeout limita uma peça isolada quando a pergunta que importa é quanto a requisição inteira pode demorar. Timeouts locais razoáveis, dez segundos aqui, sessenta ali, somam um pior caso de minutos, e no dia em que o provedor degrada todas as etapas encostam no próprio limite ao mesmo tempo: a cadeia anda inteira no pior caso sem que nenhuma etapa individual falhe, e o cliente desiste muito antes. A inversão correta é começar pelo orçamento da requisição, definido pela tolerância do canal, e derivar dele um deadline absoluto que viaja com a requisição: cada etapa calcula o próprio limite como o tempo que resta até o deadline, descontada a reserva para as etapas seguintes, e uma etapa que recebe deadline vencido falha rápido em vez de trabalhar para ninguém.

Qual a diferença entre dar timeout numa chamada e cancelar a chamada?

O timeout sem cancelamento apenas para de esperar: o código segue em frente, mas a chamada continua viva, a conexão aberta, o provedor gerando e cobrando tokens, o pool ocupado por um trabalho cujo resultado ninguém vai ler. Em carga normal passa despercebido; em degradação, quando os timeouts disparam em massa, essas chamadas zumbis esgotam o pool e derrubam também as requisições saudáveis, transformando latência em problema de capacidade. Cancelar de verdade é propagar um sinal de aborto, o AbortSignal no ecossistema JavaScript, do handler da borda até o fetch da chamada: quando o prazo vence ou o cliente desiste, a conexão é encerrada e o provedor para de gerar. Os SDKs dos principais provedores aceitam signal nas opções exatamente para isso.

O que a cadeia deve fazer quando o tempo não dá para tudo?

Degradar por etapa em vez de estourar no fim. Cada etapa declara se é opcional e o que a cadeia usa quando ela é pulada: sem rerank, segue o retrieval cru; sem validação secundária, a resposta sai com uma checagem a menos; sem o modelo grande, um menor responde no tempo que sobrou. O retry entra nessa conta: só vale retentar quando a nova tentativa inteira cabe no tempo restante com margem, porque um retry que não cabe queima justamente o orçamento que a degradação usaria. Etapas obrigatórias sem tempo acionam o fallback declarado ou falham honestamente. O resultado é uma cadeia que, sob pressão, abre mão do acessório para entregar o essencial: uma resposta útil dentro do prazo, em vez de uma resposta perfeita depois que o cliente já desistiu.

O tempo da requisição é um orçamento, não uma esperança

Uma cadeia de chamadas de LLM sem orçamento de tempo responde no prazo por sorte: cada etapa com seu timeout isolado, a soma estourando a paciência do cliente e as chamadas abandonadas rodando para ninguém. Tratar o tempo como orçamento, um deadline absoluto que viaja pela cadeia, um AbortSignal que cancela de verdade o trabalho vencido, fases de timeout separadas para conexão, primeiro token e stream, retry só quando cabe e degradação declarada por etapa, transforma o prazo de um acidente num contrato que a cadeia cumpre. Posso desenhar esse orçamento de tempo no seu pipeline de IA, definindo os deadlines por canal, propagando o cancelamento até os SDKs dos provedores e declarando a degradação de cada etapa, para que o seu atendimento responda algo útil dentro do prazo em vez de nada depois dele.