Blog

Testes de carga em sistema com LLM: simular o provedor sem pagar por ele

Você quer saber se o sistema aguenta a campanha de sexta-feira, e o teste de carga esbarra numa parede de custo: cada requisição do teste chama o provedor de verdade, e simular dez mil conversas custa o preço de dez mil conversas. Pior, o teste bate no rate limit da sua conta muito antes de bater no limite da sua aplicação, então você mede o teto do provedor em vez de medir o seu sistema. A saída não é testar com carga menor e extrapolar, porque os problemas que o teste de carga existe para encontrar aparecem justamente na região que a extrapolação não alcança: a fila que cresce, o pool de conexões que esgota, o timeout que só dispara quando o provedor fica lento. A saída é substituir o provedor por um dublê que se comporta como ele, incluindo nos momentos ruins. Este artigo trata disso: o que exatamente precisa ser simulado, por que a latência constante é o erro que invalida o teste inteiro, como reproduzir streaming, erro e rate limit com fidelidade, o que medir na sua fronteira em vez de na do provedor e quando o dublê deixa de servir e você precisa gastar dinheiro de verdade.

2026-08-04 / IA Aplicada / 14 min

01

O que você está medindo quando chama o provedor de verdade

O primeiro problema de rodar teste de carga contra a API real não é o custo, é a atribuição. Quando você dispara mil requisições por minuto e observa a latência subir, existem pelo menos três explicações possíveis e o teste não separa nenhuma delas: o seu sistema está saturado, a sua cota no provedor está estourando, ou o provedor está com um dia ruim para todo mundo. As três produzem o mesmo gráfico. Como você não controla duas dessas variáveis e nem consegue observá-las direito, o resultado é um teste que não é reproduzível: rodar de novo na terça dá um número diferente de rodar na quinta, e nenhuma das duas execuções diz se a mudança que você fez no código melhorou ou piorou alguma coisa.

O segundo problema é que o rate limit do provedor funciona como um teto artificial que esconde o comportamento que você queria observar. Se a sua cota é de trezentas requisições por minuto e você quer testar o sistema a mil, o provedor devolve erro de limite nas setecentas excedentes, e o teste passa a exercitar o seu caminho de tratamento de erro em vez do seu caminho de sucesso sob carga. É um teste válido, mas é outro teste. A pergunta original, se a aplicação aguenta mil requisições por minuto de trabalho real, continua sem resposta, e o único jeito honesto de respondê-la é remover o teto: substituir o provedor por algo que aceita a carga toda e devolve respostas com o mesmo formato e o mesmo perfil de tempo.

AbordagemO que ela mede de verdadeCustoReprodutível
Chamar a API real com carga totalO rate limit da sua conta, misturado com o seu sistemaAlto e proporcional à cargaNão: depende do dia do provedor
Carga reduzida e extrapolarO regime linear, justamente onde não há problemaBaixoSim, mas responde a pergunta errada
Dublê com latência constanteO sistema num cenário que nunca aconteceZeroSim, e enganosamente otimista
Dublê com perfil de latência e falha realistaO seu sistema, isolado, na carga que você escolheuZeroSim, e comparável entre execuções

02

A latência constante é o erro que invalida tudo

O dublê ingênuo devolve uma resposta fixa depois de esperar um tempo fixo, e é isso que transforma o teste de carga num teatro. A latência de um LLM não é uma constante, é uma distribuição de cauda longa: a maioria das respostas chega perto da mediana e uma fração pequena demora várias vezes mais. Essa cauda é o que faz o sistema quebrar. Com latência constante, a sua fila nunca acumula, porque cada worker libera no mesmo ritmo previsível; com latência de cauda longa, alguns workers ficam presos em respostas lentas enquanto a fila cresce atrás deles, e é exatamente aí que o pool esgota, o timeout dispara e o backpressure precisa entrar em ação. Um teste com latência constante passa com folga num sistema que vai cair na primeira sexta-feira movimentada.

A latência de um LLM também não é independente do pedido, e isso importa mais do que parece. O tempo até o primeiro token depende do tamanho do prompt, e o tempo total depende principalmente de quantos tokens a resposta tem, porque a geração é sequencial. Um dublê que ignora isso e sorteia um tempo aleatório qualquer perde a correlação que produz os piores casos reais: as requisições com mais contexto tendem a ser as que geram respostas mais longas, então o pico de carga tende a coincidir com o pico de duração, e não a se cancelar. Modelar tempo até o primeiro token em função do tamanho do prompt e depois um tempo por token na geração reproduz essa correlação sem nenhuma sofisticação estatística.

// test/fake-llm/latency.js
// A latencia de um LLM tem duas fases com causas diferentes:
// TTFT cresce com o prompt (prefill), e a geracao e sequencial
// por token. Modelar isso reproduz a correlacao que quebra o sistema.

export function sampleLatency(request, profile, rng) {
  const promptTokens = estimateTokens(request.messages);
  const outputTokens = sampleOutputTokens(profile.outputTokens, rng);

  // Prefill: cresce com o tamanho do prompt, com um piso de rede.
  const ttft =
    profile.networkFloorMs +
    profile.prefillMsPerKToken * (promptTokens / 1000) +
    lognormalJitter(profile.ttftJitter, rng);

  // Geracao: sequencial, entao o total cresce com os tokens de saida.
  const perToken = profile.msPerOutputToken * lognormal(profile.tokenJitter, rng);

  // A cauda longa e o que quebra o sistema. Sem ela o teste passa
  // com folga e a producao cai na primeira sexta movimentada.
  const isSlowTail = rng() < profile.slowTailRate;
  const tailFactor = isSlowTail ? profile.slowTailFactor : 1;

  return {
    ttftMs: ttft * tailFactor,
    totalMs: (ttft + perToken * outputTokens) * tailFactor,
    outputTokens,
  };
}

De onde tirar os números do perfil é a parte que costuma travar o time, e a resposta é mais simples do que parece: da produção, se você já tem tráfego, ou de uma amostra pequena e barata contra a API real, se ainda não tem. Bastam algumas centenas de chamadas com prompts representativos para estimar o piso de rede, o custo por mil tokens de prompt, o tempo por token de saída e a fração de respostas na cauda. Isso custa alguns centavos e vale para milhões de requisições simuladas. O perfil deve ser um arquivo versionado no repositório, com data de coleta, porque ele envelhece: o provedor muda de infraestrutura, você troca de modelo, e um perfil de seis meses atrás descreve um sistema que não existe mais.

03

Simular o comportamento ruim, não só o bom

Um dublê que só responde com sucesso testa metade do sistema. Sob carga, o provedor real erra de maneiras específicas, e cada uma delas exercita um caminho diferente do seu código: o erro de limite de taxa, que vem com uma indicação de quanto esperar e deveria alimentar o seu backoff; o erro transitório de servidor, que justifica retentativa; o erro de contexto excedido, que nunca deve ser retentado porque vai falhar igual; e o pior de todos, a resposta que começa a transmitir e morre no meio, deixando o seu cliente com um texto truncado e uma conexão pendurada. Se o dublê não produz esses casos, o teste de carga mede o desempenho de um sistema que só existe em condições ideais.

A injeção de falha precisa ser determinística para o teste servir de comparação entre execuções. Falha aleatória com semente fixa dá as duas coisas ao mesmo tempo: a distribuição parece realista e a sequência é idêntica em toda execução, então quando você muda o código e o resultado muda, a diferença é sua e não do acaso. Vale expor a semente e a taxa de cada tipo de falha como configuração do cenário, porque assim o mesmo dublê serve para o teste de carga em regime normal, para o teste de resiliência com quinze por cento de erro e para o exercício de modo degradado com o provedor totalmente fora.

Modo de falhaO que deve exercitar no seu sistemaSinal de que passou
Erro de limite de taxa com tempo de esperaBackoff que respeita o valor indicado, sem retentar na horaA vazão cai e se recupera, sem tempestade de retentativa
Erro transitório de servidorRetentativa com teto de tentativas e jitterLatência sobe no percentil alto, taxa de sucesso se mantém
Contexto excedidoFalha imediata sem retentativa, com resposta útil ao usuárioZero retentativas nesse caminho, erro tratado na borda
Stream que morre no meioDetecção de resposta truncada e limpeza da conexãoNenhuma conexão pendurada, resposta parcial não vira final
Provedor totalmente foraDisjuntor abre e o modo degradado assumeFila não cresce sem limite, cliente recebe resposta útil
Provedor lento, sem erroTimeout com deadline e cancelamento propagadoTrabalho vencido é cancelado, não continua rodando para ninguém

04

Streaming muda a forma do teste

Se a sua aplicação usa streaming, o dublê que devolve a resposta inteira de uma vez apaga a característica mais importante do sistema sob carga: a conexão fica aberta durante toda a geração. Numa API tradicional, a requisição ocupa um slot por alguns milissegundos; com streaming, ela ocupa por vários segundos, e o número de conexões simultâneas passa a ser o recurso escasso em vez da taxa de requisições. Um sistema que aguenta mil requisições por minuto sem streaming pode esgotar file descriptors muito antes disso com streaming, e o dublê precisa emitir os eventos ao longo do tempo, no ritmo de tokens do perfil, para que esse custo apareça no teste.

// test/fake-llm/stream.js
// O dublê emite eventos ao longo do tempo, no ritmo de tokens do
// perfil. Sem isso, a conexao nao fica aberta e o teste nao mede
// o recurso que realmente escasseia com streaming: conexao simultanea.

export async function streamResponse(res, request, profile, rng) {
  const { ttftMs, totalMs, outputTokens } = sampleLatency(request, profile, rng);
  const perTokenMs = (totalMs - ttftMs) / Math.max(outputTokens, 1);

  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    Connection: 'keep-alive',
  });

  await sleep(ttftMs);

  // Falha no meio do stream: o caso que o dublê ingenuo nunca produz
  // e que deixa conexao pendurada e resposta truncada em producao.
  const dropAt = rng() < profile.midStreamDropRate
    ? Math.floor(rng() * outputTokens)
    : -1;

  for (let i = 0; i < outputTokens; i += 1) {
    if (i === dropAt) {
      res.destroy(); // encerra sem evento de fim, de proposito
      return { outcome: 'mid_stream_drop', tokensSent: i };
    }

    res.write(`data: ${JSON.stringify({ delta: nextToken(rng) })}\n\n`);
    await sleep(perTokenMs);
  }

  res.write('data: [DONE]\n\n');
  res.end();
  return { outcome: 'complete', tokensSent: outputTokens };
}

Onde inserir o dublê também é uma decisão de fidelidade. Substituir o cliente do provedor por um objeto falso dentro do processo é o mais fácil e o menos fiel, porque remove serialização, rede, pool de conexões e o próprio custo de manter a conexão aberta, que é justamente o que você quer medir. Subir o dublê como um servidor de verdade e apontar a variável de ambiente da URL base para ele mantém todo o caminho intacto e troca apenas quem responde do outro lado. O segundo custa alguns minutos a mais de infraestrutura de teste e é o único que responde a pergunta que motivou o teste.

05

O que medir, e do lado certo da fronteira

Com o provedor substituído, as métricas que importam deixam de ser as dele e passam a ser as suas. A latência da chamada ao dublê é conhecida por construção, você mesmo a definiu, então medi-la não informa nada. O que informa é o que acontece entre a borda da sua aplicação e essa chamada: quanto tempo a requisição passou na fila antes de virar chamada, quantos workers estavam ocupados, quantas conexões simultâneas o processo sustentou, quanta memória a fila consumiu no pico e quantas requisições foram recusadas na entrada porque o sistema aplicou o freio. É essa a diferença entre um teste de carga que produz um número e um que produz uma decisão.

  1. Tempo de espera na fila separado do tempo de chamada, porque a soma esconde qual dos dois está estourando o orçamento de tempo do cliente.
  2. Profundidade máxima da fila e memória no pico, que dizem quanto tempo o sistema aguenta um pico antes de estourar em vez de degradar.
  3. Conexões simultâneas sustentadas, que com streaming costumam esgotar antes da CPU e antes da taxa de requisições.
  4. Taxa de recusa na borda e o código devolvido, para confirmar que o freio está atuando na entrada e não deixando a fila crescer sem limite.
  5. Latência no percentil alto ponta a ponta, não a média, porque a média some com a cauda que é exatamente o que quebra a experiência.
  6. Trabalho cancelado corretamente quando o cliente desiste, medido como chamadas abortadas sobre desistências, que deveria ser praticamente um.
Teste de carga com dublê de provedor: onde medir

  gerador de carga (perfil de trafego real, nao rajada uniforme)
     |
     v
  [ borda da aplicacao ] <-- MEDIR: taxa de recusa, codigo devolvido
     |
     v
  [ fila ] <----------------- MEDIR: tempo de espera, profundidade, memoria
     |
     v
  [ pool de workers ] <------ MEDIR: ocupacao, conexoes simultaneas
     |
     v
  ================= FRONTEIRA =================
     |
     v
  [ dublê do provedor ]   <-- NAO medir: a latencia daqui voce definiu
     |
     +-- TTFT em funcao do tamanho do prompt
     +-- tempo por token na geracao (streaming real, evento a evento)
     +-- cauda longa com fracao configurada
     +-- falhas deterministicas por semente:
           limite de taxa / transitorio / contexto excedido
           stream que morre no meio / provedor fora / provedor lento

  Resultado util: "a fila estoura em X req/min com Y de memoria"
  Resultado inutil: "a latencia media foi Z" (voce mesmo escolheu Z)

O gerador de carga também merece cuidado, porque a rajada uniforme é irreal e otimista. Tráfego de atendimento chega em ondas, com correlação entre chegadas, e o mesmo volume total distribuído em ondas produz picos de fila que a distribuição uniforme nunca alcança. Vale ainda modelar o comportamento do cliente que desiste: uma fração das conversas é abandonada antes da resposta chegar, e se o seu sistema não cancela o trabalho correspondente, ele continua queimando worker e token para ninguém, o que sob carga é uma forma silenciosa de amplificar a sobrecarga.

06

Onde o dublê deixa de servir

O dublê responde perguntas sobre o seu sistema e não responde nenhuma pergunta sobre o provedor. Se o que você precisa saber é qual a cota real da sua conta, como o provedor se comporta quando você passa dela, se a latência dele piora nos horários de pico globais ou se o modelo novo é mais lento que o antigo, nenhuma simulação substitui uma medição contra a API real. A diferença é que essas perguntas se respondem com um teste pequeno e barato, de algumas centenas de chamadas, e não com um teste de carga de dez mil. A divisão saudável é usar a API real para calibrar o perfil e validar suposições, e usar o dublê para tudo que envolve volume.

Também vale ter uma execução periódica de fumaça contra a API real, pequena e barata, com dois objetivos. O primeiro é detectar deriva do perfil: se a latência medida na amostra afastou do que o dublê simula, o seu teste de carga está descrevendo um provedor que não existe mais e precisa ser recalibrado. O segundo é pegar mudança de contrato, porque o dublê responde no formato que você programou nele, e se o provedor mudar um campo ou a semântica de um erro, o seu teste continuará passando alegremente enquanto a produção quebra. Um dublê bem calibrado é uma ferramenta poderosa, e é também uma foto do provedor num instante do passado.

  • Calibração do perfil: precisa de API real, com amostra pequena, e deve ser refeita quando o modelo ou o provedor mudar.
  • Descoberta de cota e comportamento no limite: API real, porque é uma propriedade da sua conta e não do seu código.
  • Qualidade de resposta e regressão de prompt: fora do escopo do teste de carga, é avaliação, e o dublê não tem opinião sobre conteúdo.
  • Capacidade, fila, memória, conexões e freio na borda: dublê, porque é o seu sistema que está sendo medido e o volume é caro.
  • Contrato da API e formato de erro: teste de contrato contra a API real, periódico, para o dublê não virar ficção.

FAQ

Perguntas frequentes

Por que não rodar o teste de carga direto contra a API do provedor?

Por dois motivos, e o custo é o menos importante deles. O primeiro é atribuição: quando a latência sobe, o teste não separa se o seu sistema saturou, se a sua cota estourou ou se o provedor está com um dia ruim, e você só controla uma dessas variáveis. O segundo é que o rate limit da sua conta vira um teto artificial: se a cota é de trezentas requisições por minuto e você quer testar mil, o provedor recusa as excedentes e o teste passa a exercitar o caminho de erro em vez do caminho de sucesso sob carga, deixando a pergunta original sem resposta. Substituir o provedor por um dublê remove o teto e torna o teste reproduzível entre execuções, que é o que permite comparar antes e depois de uma mudança.

Um dublê com latência fixa não é suficiente?

Não, e esse é o erro que invalida o teste inteiro. A latência de um LLM é uma distribuição de cauda longa, e é a cauda que quebra o sistema: com tempo constante cada worker libera no mesmo ritmo e a fila nunca acumula, enquanto com cauda longa alguns workers ficam presos em respostas lentas e a fila cresce atrás deles, que é exatamente quando o pool esgota e o timeout dispara. Além disso a latência não é independente do pedido: o tempo até o primeiro token cresce com o tamanho do prompt e o tempo total cresce com os tokens gerados, então prompts maiores tendem a gerar respostas mais longas e os picos se somam em vez de se cancelar. Modelar essas duas fases mais uma fração de cauda já reproduz o comportamento que importa.

Como calibrar o perfil de latência sem gastar muito?

Com uma amostra pequena contra a API real, de algumas centenas de chamadas com prompts representativos do seu tráfego, ou direto da sua produção se você já tem volume. Isso basta para estimar o piso de rede, o custo por mil tokens de prompt, o tempo por token de saída e a fração de respostas na cauda, custa alguns centavos e vale para milhões de requisições simuladas depois. O perfil deve ficar versionado no repositório com a data de coleta, porque envelhece: o provedor muda de infraestrutura e você troca de modelo. Vale manter uma execução periódica de fumaça contra a API real para detectar quando o perfil derivou e para pegar mudança de contrato, já que o dublê responde no formato que você programou nele.

Teste de carga bom mede o seu sistema, não a conta do provedor

Chamar a API real sob carga mistura três variáveis que você não consegue separar e cobra caro para produzir um número que não se repete na semana seguinte. Substituir o provedor por um dublê que modela as duas fases da latência, reproduz a cauda longa, emite streaming evento a evento e injeta falha determinística por semente devolve ao teste a propriedade que ele precisa ter: isolar o seu sistema e ser comparável entre execuções. Com isso, as métricas que interessam passam a ser tempo de fila, profundidade, memória no pico, conexões simultâneas e taxa de recusa na borda, que são as que dizem em qual volume o sistema degrada e em qual ele quebra. Posso montar esse harness no seu projeto, calibrando o perfil com uma amostra barata da sua própria produção, modelando os modos de falha que importam e desenhando o cenário de carga que reproduz a sua sexta-feira movimentada, para você saber o limite antes de encontrá-lo ao vivo.