Skip to content

Batch inference: o mesmo modelo, pela metade do preço, e a produção nem percebe

· 8 min de leitura

tags: llms, recruiting, architecture

Migrar milhares de candidatos por um modelo de linguagem custa o que custa. Ou a metade. Na Selenios cortamos até 50% da conta de tokens toda vez que processamos dados em bulk, com o mesmo modelo, os mesmos prompts e os mesmos resultados. A técnica se chama batch inference, está na tabela de preços de todos os grandes provedores, e a maioria dos times com quem eu converso ainda não usa para o único tipo de carga em que é dinheiro de graça. Isto é o que ela é, por que custa a metade, o que ela cobra em troca, e o checklist que escrevemos depois de aprender do jeito difícil.

O que é batch inference

Em tempo real, cada request espera sua resposta. Em batch não existe conversa. Você escreve um arquivo com um objeto JSON por linha, milhares de prompts, e sobe para um bucket. Você diz ao provedor "processa isso quando der". Algum tempo depois aparece outro arquivo com todas as respostas. Nenhum servidor seu esperando em sockets, nenhuma lógica de retry, nenhuma fila para você operar.

No Amazon Bedrock, que é onde rodamos, o input é um arquivo JSONL no S3 em que cada linha carrega um identificador e o mesmo body que você mandaria para o InvokeModel:

{"recordId":"a1f3c9e2","modelInput":{ …the InvokeModel body for cv 1… }}
{"recordId":"9c02b7d4","modelInput":{ …the InvokeModel body for cv 2… }}
…
{"recordId":"0d41f8a7","modelInput":{ …the InvokeModel body for cv N… }}

Depois, uma única chamada de API cria o job:

import { BedrockClient, CreateModelInvocationJobCommand } from '@aws-sdk/client-bedrock';

const { jobArn } = await bedrock.send(new CreateModelInvocationJobCommand({
  jobName: `cv-parse-${runId}-${shard}`,
  modelId,
  roleArn,
  inputDataConfig: { s3InputDataConfig: { s3Uri: `s3://${bucket}/input/${runId}/${shard}.jsonl` } },
  outputDataConfig: { s3OutputDataConfig: { s3Uri: `s3://${bucket}/output/${runId}/` } },
}));

A partir daí você faz polling do status do job e, quando ele termina, lê os arquivos de output, casa cada linha com seu recordId e segue o seu pipeline como se as chamadas tivessem sido feitas uma a uma.

Por que custa a metade

Porque você está vendendo ao provedor a única coisa que sobra para ele: capacidade ociosa. As frotas de inferência são dimensionadas para o pico de tráfego interativo e, fora do pico, as GPUs ficam parcialmente vazias. Quando você abre mão de latência, eles recebem trabalho que podem encaixar nesses buracos, e o preço do token cai 50%.

Não é promoção nem desconto negociado. É o preço de tabela do modo batch no Amazon Bedrock, Anthropic, OpenAI e Gemini, os quatro pela metade da tarifa on-demand. Mesmo modelo, mesmos pesos, mesmo output. A única coisa pela qual você paga menos é a promessa de uma resposta imediata.

Um caso real: uma migração com anos de histórico

Recentemente migramos um cliente que vinha de outro sistema de recrutamento. Isso significa dezenas de milhares de CVs diferentes e, junto com eles, as vagas, as notas dos recrutadores, as movimentações entre etapas do pipeline e as avaliações de anos de histórico. Cada CV passou por um modelo de linguagem no Bedrock para extrair sua estrutura no formato que o nosso produto espera.

A conta on-demand teria sido um número. A conta em batch foi exatamente a metade, com o mesmo modelo e os mesmos resultados. Não vou colocar valores absolutos aqui, e não importa: o ponto é a proporção, e ela se mantém em qualquer escala. Leve isso para milhares de candidatos com todo o histórico e a conta continua a mesma. Metade.

Existe uma segunda economia que não tem nada a ver com batch e que você deveria fazer de qualquer jeito: antes de mandar qualquer coisa, fazemos hash de cada arquivo. Num sistema de recrutamento, o mesmo CV aparece anexado a muitas candidaturas. Um candidato que se candidatou a cinco vagas tem cinco cópias do mesmo PDF. Com um MD5 do arquivo como chave, o mesmo CV é parseado uma única vez e o resultado é replicado para cada candidatura que o referencia. O token mais barato é aquele que você nunca manda.

A vantagem de que ninguém fala: batch não compete com a produção

O preço é a manchete, mas não é o melhor motivo. O tráfego on-demand sai de um pool compartilhado e, quando esse pool enche, o provedor responde com um 503 e uma mensagem de "insufficient capacity". Já passamos por isso. Agora imagine que, além do seu tráfego normal, você dispara milhares de chamadas de um script de migração pelo mesmo canal, com o mesmo modelo e a mesma conta. Quem paga são seus usuários, em latência e em erros, nas telas que eles realmente usam.

Batch é outra fila, com outra cota. A migração roda de madrugada e a plataforma nem percebe. A própria orientação do Bedrock para erros de capacidade no on-demand diz a mesma coisa: mova as cargas offline para batch. Esse foi o argumento que me convenceu mais do que o desconto. Pagar metade é ótimo. Trabalho em massa que nunca encosta na experiência de quem usa seu produto é o ganho de verdade.

O que ele cobra em troca

Nada é de graça, então aqui vai a conta, com os detalhes do Bedrock porque é onde rodamos:

  1. Latência. Minutos ou horas, com o compromisso de terminar em até 24 horas. Você não escolhe quando.
  2. Menos features. No batch do Bedrock não há tool calling nem structured output. Você pede JSON no prompt e faz o parse você mesmo, com a mesma validação que usaria para qualquer output de modelo não confiável.
  3. Sem caminho de OCR em vários modelos. Se parte do seu input são imagens escaneadas, elas precisam de uma rota on-demand própria.
  4. A ordem do output não é garantida. A linha 1 do input não é a linha 1 do output. O recordId é a única coisa que liga uma à outra.
  5. Um mínimo por job. Por padrão, 100 registros. Com menos que isso, o job é rejeitado.

Se o seu caso de uso é interativo, nada disso funciona. Se ninguém está esperando a resposta, tudo isso é aceitável.

O checklist que aprendemos do jeito difícil

Nada disso está no guia de getting started. Tudo nos custou algo na primeira vez.

  • Deduplique antes de mandar. Faça hash do arquivo, não da candidatura. Um parse por CV, não um por candidatura.
  • Respeite as cotas. No mínimo 100 registros por job, 1 GB por arquivo de input, 5 GB por job. Nós fazemos shards de 50.000 registros.
  • Junte o último shard se ele ficar curto. 50.050 registros divididos ingenuamente em shards de 50.000 geram um shard final de 50, e esse job é rejeitado. O fix são três linhas, e é o tipo de bug que você só encontra com dados reais:
    function shard<T>(items: T[], size = 50_000, min = 100): T[][] {
      const shards: T[][] = [];
      for (let i = 0; i < items.length; i += size) shards.push(items.slice(i, i + size));
      const last = shards.at(-1);
      if (shards.length > 1 && last && last.length < min) shards.at(-2)!.push(...shards.pop()!);
      return shards;
    }
    O shard combinado fica um pouco acima do tamanho-alvo, então mantenha o alvo bem abaixo dos limites reais.
  • Persista o estado de cada job assim que ele for aceito. Um job é cobrado a partir do segundo em que o provedor o aceita. Se o seu script cair depois de criar o job mas antes de salvar o ARN, e você relançar, paga duas vezes pelo mesmo trabalho. Grave o ARN do job e o shard que ele cobre em armazenamento durável antes de qualquer outra coisa, e faça o launcher pular os shards que já têm um job.
  • Use identificadores curtos e únicos. O output volta fora de ordem, então cada linha precisa de um recordId que mapeie de volta para os seus dados sem ambiguidade. Um hash curto funciona melhor que uma chave composta longa que você vai ter que parsear depois.
  • Valide com o mesmo parser que você usa no on-demand. O import não deveria conseguir distinguir por qual caminho um resultado veio. Se o output do batch precisa de um parser próprio, agora você tem duas fontes de verdade e uma delas vai divergir.
  • Apague todos os arquivos do bucket quando terminar. Inputs e outputs. São CVs, ou seja, dados pessoais, e nada deveria ficar no S3 depois que o import for verificado. Faça a limpeza ser parte do job, não uma nota num runbook.

Quem suporta

Os quatro grandes provedores oferecem com 50% de desconto, com limites diferentes. Nesta semana:

ProvedorComoLimites que vale conhecer
Amazon BedrockJSONL no S3, CreateModelInvocationJob1 GB por arquivo, 5 GB por job, mínimo de 100 registros por job, sem tool calling nem structured output. Disponível para Claude, Nova, Llama, Mistral, DeepSeek, Qwen e outros.
AnthropicMessage Batches APIAté 100.000 requests ou 256 MB por batch. A maioria dos batches termina em menos de uma hora. Resultados disponíveis por 29 dias.
OpenAIBatch API, arquivo JSONL50.000 requests e 200 MB por arquivo, janela de 24 horas, rate limits separados da API síncrona.
GeminiBatch Mode, inline ou arquivoArquivos de até 2 GB, meta de 24 horas, o context caching funciona dentro do batch.

Se você já usa o AI SDK, existe um caminho mais simples para batches pequenos. O AI Gateway da Vercel suporta batch via experimental_startBatch, experimental_getBatchStatus e experimental_getBatchResults, sem S3 envolvido: os requests vão inline, até 1.000 por batch e 4,5 MB, e o mesmo código funciona com modelos da OpenAI e da Anthropic.

import {
  experimental_startBatch as startBatch,
  experimental_getBatchStatus as getBatchStatus,
  experimental_getBatchResults as getBatchResults,
} from 'ai';

const batch = await startBatch({
  requests: cvs.map((cv) => ({
    id: cv.hash,
    type: 'text',
    model: modelId, // any OpenAI or Anthropic model on the gateway
    prompt: buildParsePrompt(cv.text),
  })),
});
await saveBatchReference(batch); // { version, id, provider }: persist it before anything else

// later, from a cron or a worker
const { status } = await getBatchStatus({ batch });
if (status === 'completed') {
  for await (const item of getBatchResults({ batch })) {
    if (item.status === 'succeeded') await importParsedCv(item.id, parseCv(item.text));
  }
}

Mesmo formato da versão com Bedrock, sem precisar cuidar de um bucket, de uma role e da limpeza. É ideal para batches pequenos e médios. Para dezenas de milhares de CVs, o arquivo no S3 continua sendo a ferramenta certa.

A regra

A decisão inteira cabe numa pergunta: tem alguém esperando por esta resposta? Se sim, on-demand. Se não, batch. Migrações, re-scoring em massa, enriquecimento de dados, evals, classificação de registros históricos: nenhum deles tem um humano do outro lado olhando um spinner, e todos estão pagando o dobro se rodam on-demand.

Mesmo modelo, metade do preço, e seus usuários nem percebem. E, sinceramente, a metade do preço é o de menos. O que importa é que o trabalho em massa nunca encoste na experiência de quem usa o seu produto.