A Message Batches API do Claude permite enviar milhares de requisições de uma vez e receber os resultados de forma assíncrona, com 50% de desconto sobre o preço normal. No Laravel, o caminho que funciona é guardar cada item numa tabela com um custom_id, enviar o lote por um job, consultar o status pelo scheduler e aplicar os resultados de forma idempotente. É a escolha certa para qualquer trabalho de IA que não precisa de resposta em segundos: reprocessar histórico, categorizar extratos à noite e rodar avaliações offline.
Em fintech aparece muito esse tipo de carga. Você muda o prompt de categorização e precisa reprocessar meses de transações. Você quer rodar um juiz sobre as conversas do agente de ontem. Você precisa gerar descrições para milhares de cadastros. Mandar tudo isso pela API síncrona é pagar preço cheio, brigar com rate limit e manter workers do Horizon ocupados esperando resposta de rede. A Batch API resolve as três coisas, mas cobra um preço: latência e uma arquitetura um pouco diferente.
Quando usar a Batch API em vez da chamada síncrona?
A regra é simples: se existe um humano esperando a resposta, não é batch. Se o resultado pode chegar daqui a uma hora sem ninguém perceber, provavelmente é.
| Critério | API síncrona (/v1/messages) |
Batch API (/v1/messages/batches) |
|---|---|---|
| Latência | Segundos | Geralmente menos de 1 hora, até 24 horas |
| Preço | Cheio | 50% de desconto em entrada e saída |
| Streaming | Sim | Não |
| Rate limit | Compartilhado com o tráfego em tempo real | Limites próprios, não disputa com o atendimento |
| Tamanho | Uma requisição por chamada | Até 100.000 requisições ou 256 MB por lote |
| Bom para | Chat, agente no WhatsApp, extração na hora | Reprocessamento, backfill, evals, relatórios noturnos |
O ponto do rate limit é subestimado. Se você reprocessa 50 mil transações pela API síncrona no mesmo dia em que o agente de atendimento está rodando, o backfill come a cota do atendimento. Com batch, esse problema some.
Casos em que eu não usaria batch mesmo sendo assíncrono: quando o volume é pequeno (dezenas de itens por dia, o ganho não paga a complexidade) e quando o resultado de um item define a entrada do próximo, porque o lote não tem ordem nem dependência entre requisições.
Como a Message Batches API funciona?
O fluxo tem quatro chamadas HTTP:
POST /v1/messages/batchescom um arrayrequests. Cada item tem umcustom_id(identificador seu, único dentro do lote, de 1 a 64 caracteres entre letras, números,_e-) e umparamscom exatamente o mesmo corpo que você mandaria para/v1/messages.GET /v1/messages/batches/{id}para consultar o status. O campoprocessing_statusfica emin_progressaté virarended.- Quando termina, o campo
results_urlaponta para um arquivo JSONL (um objeto JSON por linha) com o resultado de cada requisição. - Opcionalmente,
POST /v1/messages/batches/{id}/cancelpara cancelar.
Dois detalhes que mudam o desenho: os resultados não vêm na ordem do envio, então tudo é casado pelo custom_id; e cada linha tem um result.type que pode ser succeeded, errored, canceled ou expired. Um lote "terminado" não significa um lote "bem-sucedido". Os resultados ficam disponíveis por 29 dias.
Como não confio em SDK de terceiros para isso, chamo direto com o Http:: do Laravel:
// app/Services/Claude/AnthropicClient.php namespace App\Services\Claude; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; class AnthropicClient { public function http(): PendingRequest { return Http::baseUrl('https://api.anthropic.com/v1') ->withHeaders([ 'x-api-key' => config('services.anthropic.key'), 'anthropic-version' => '2023-06-01', ]) ->acceptJson() ->timeout(60); } }
Como modelar lotes e itens no banco?
O erro mais comum é tratar o lote como um job que fica dormindo até o resultado chegar. Não faça isso. O lote é estado, não é processo. Duas tabelas resolvem:
Schema::create('llm_batches', function (Blueprint $table) { $table->id(); $table->string('purpose'); // ex.: categorize_transactions $table->string('prompt_version'); $table->string('anthropic_id')->nullable()->unique(); $table->string('status')->default('pending'); // pending, submitting, in_progress, ended, processed, submit_unknown $table->string('results_url')->nullable(); $table->timestamp('submitted_at')->nullable(); $table->timestamp('ended_at')->nullable(); $table->timestamps(); }); Schema::create('llm_batch_items', function (Blueprint $table) { $table->id(); $table->foreignId('llm_batch_id')->nullable()->constrained(); $table->string('custom_id', 64)->unique(); $table->morphs('subject'); // a transação, a conversa etc. $table->json('params'); $table->string('status')->default('queued'); // queued, sent, succeeded, applied, retry, failed $table->unsignedTinyInteger('attempts')->default(0); $table->json('output')->nullable(); $table->string('error_type')->nullable(); $table->timestamps(); });
O custom_id é gerado por mim (um ULID com prefixo, por exemplo cat_01J...) e é único na tabela toda, não só no lote. Isso permite que o mesmo item seja reenviado num lote novo com outro custom_id sem ambiguidade, e que eu encontre qualquer resultado de volta só com essa string.
Guardar o params completo por item parece desperdício de espaço, mas é o que deixa o reenvio trivial e a auditoria honesta: você sabe exatamente o que foi mandado. Se você já trabalha com prompts versionados no repositório, o prompt_version no lote amarra cada resultado à versão que o gerou.
Como enviar o lote a partir de um job do Laravel?
Um job pega os itens queued, monta o lote e envia. O cuidado principal é o timeout do POST: se a requisição cair depois que a Anthropic criou o lote, um retry automático cria um segundo lote e você paga duas vezes.
class SubmitClaudeBatch implements ShouldQueue { use Queueable; public int $tries = 1; // retry cego aqui duplica custo public function __construct(public int $batchId) {} public function handle(AnthropicClient $claude): void { $batch = LlmBatch::findOrFail($this->batchId); $updated = LlmBatch::whereKey($batch->id) ->where('status', 'pending') ->update(['status' => 'submitting']); if ($updated === 0) { return; // outro worker já pegou } $items = $batch->items()->where('status', 'queued')->get(); $requests = $items->map(fn (LlmBatchItem $item) => [ 'custom_id' => $item->custom_id, 'params' => $item->params, ])->values()->all(); try { $response = $claude->http() ->timeout(120) ->post('/messages/batches', ['requests' => $requests]) ->throw(); } catch (ConnectionException $e) { // Não sabemos se o lote foi criado. Ninguém reenvia sozinho. $batch->update(['status' => 'submit_unknown']); report($e); return; } DB::transaction(function () use ($batch, $response) { $batch->update([ 'anthropic_id' => $response->json('id'), 'status' => 'in_progress', 'submitted_at' => now(), ]); $batch->items()->where('status', 'queued') ->update(['status' => 'sent', 'attempts' => DB::raw('attempts + 1')]); }); } }
O status submit_unknown é a válvula de segurança. Um lote nesse estado vai para um alerta, e alguém confere em GET /v1/messages/batches (que lista os lotes recentes) se ele existe, comparando created_at e a contagem de requisições. É chato, mas é raro, e é muito melhor do que pagar um reprocessamento inteiro duas vezes. A mesma lógica de Idempotency-Key em chamadas ao gateway de pagamento vale aqui, só que sem a chave do lado do servidor, então a proteção fica toda do seu lado.
Sobre o tamanho: o limite é 100.000 requisições ou 256 MB, mas eu prefiro lotes menores, na casa de alguns milhares. Lote menor termina antes, falha com menos estrago e deixa o progresso visível. Monto os lotes com chunkById sobre os itens queued e crio um LlmBatch por pedaço.
Como acompanhar o status sem prender um worker?
Polling (consultar o status periodicamente) pelo scheduler, não por job com sleep nem por release() em loop. Um comando agendado a cada cinco minutos olha os lotes em in_progress:
// routes/console.php Schedule::command('llm-batches:poll')->everyFiveMinutes()->withoutOverlapping();
public function handle(AnthropicClient $claude): void { LlmBatch::where('status', 'in_progress')->each(function (LlmBatch $batch) use ($claude) { $data = $claude->http() ->get("/messages/batches/{$batch->anthropic_id}") ->throw() ->json(); if ($data['processing_status'] !== 'ended') { return; } $batch->update([ 'status' => 'ended', 'results_url' => $data['results_url'], 'ended_at' => now(), ]); ProcessClaudeBatchResults::dispatch($batch->id)->onQueue('llm-batch'); }); }
O resultado é processado numa fila própria. Um arquivo com milhares de linhas pode levar alguns minutos para ser aplicado, e não quero isso concorrendo com webhooks de pagamento. É o mesmo raciocínio de separar filas no Horizon por bulkhead.
Como ler o JSONL de resultados sem estourar a memória?
O arquivo de resultados pode ter centenas de megabytes. Carregar com ->json() ou ->body() é pedir um Allowed memory size exhausted. O Http::sink() do Laravel grava a resposta direto em disco, e depois leio linha a linha:
public function handle(AnthropicClient $claude): void { $batch = LlmBatch::findOrFail($this->batchId); $path = storage_path("app/llm-batches/{$batch->anthropic_id}.jsonl"); File::ensureDirectoryExists(dirname($path)); $claude->http() ->timeout(300) ->sink($path) ->get($batch->results_url) ->throw(); $handle = fopen($path, 'r'); while (($line = fgets($handle)) !== false) { if (trim($line) === '') { continue; } $this->applyLine(json_decode($line, true, flags: JSON_THROW_ON_ERROR)); } fclose($handle); $batch->update(['status' => 'processed']); }
Cada linha tem este formato:
{"custom_id": "cat_01J9Z...", "result": {"type": "succeeded", "message": {"content": [{"type": "text", "text": "{\"categoria\": \"fornecedores\"}"}], "stop_reason": "end_turn", "usage": {"input_tokens": 412, "output_tokens": 18}}}}
Como tratar resultados com erro, cancelados e expirados?
Aqui mora a maior parte do trabalho. Cada tipo de resultado pede uma decisão diferente:
succeeded: a requisição rodou. Isso não quer dizer que a saída é válida. Valido o JSON e as regras de negócio antes de gravar, do mesmo jeito que faria na chamada síncrona. Também confiro ostop_reason: se veiomax_tokens, a resposta foi cortada e trato como falha.errored: olho o tipo do erro.invalid_request_erroré culpa minha (parâmetro errado, conteúdo grande demais) e reenviar não adianta, vai parafailed. Erros do lado do servidor vão pararetry.expired: o lote passou de 24 horas e essa requisição não foi processada. Não é cobrada. Vai pararetry.canceled: alguém cancelou. Também não é cobrada. Decido caso a caso, normalmenteretry.
private function applyLine(array $line): void { $item = LlmBatchItem::where('custom_id', $line['custom_id'])->first(); if (! $item || $item->status !== 'sent') { return; // já aplicado ou desconhecido: reprocessar o arquivo é seguro } $result = $line['result']; match ($result['type']) { 'succeeded' => $this->applySuccess($item, $result['message']), 'errored' => $this->markError($item, data_get($result, 'error.error.type', 'unknown')), 'expired', 'canceled' => $this->scheduleRetry($item, $result['type']), }; } private function markError(LlmBatchItem $item, string $type): void { $type === 'invalid_request_error' || $item->attempts >= 3 ? $item->update(['status' => 'failed', 'error_type' => $type]) : $this->scheduleRetry($item, $type); } private function scheduleRetry(LlmBatchItem $item, string $reason): void { // novo custom_id para não colidir com o lote anterior $item->update([ 'status' => 'queued', 'llm_batch_id' => null, 'custom_id' => 'cat_'.Str::ulid(), 'error_type' => $reason, ]); }
O applySuccess é onde mora a regra de negócio, e ele precisa ser idempotente. No caso de categorização de extrato, a atualização da transação só acontece se ela ainda não foi categorizada manualmente pelo usuário, e o item só vira applied dentro da mesma transação de banco. Se o job morrer no meio do arquivo, o Laravel tenta de novo, as linhas já aplicadas são ignoradas pelo status !== 'sent' e o resto segue. Para a parte de validar e corrigir a saída do modelo, uso o mesmo fluxo que descrevi em validar o JSON do Claude e pedir correção, com uma diferença: no batch, a "correção" vira um item novo no próximo lote em vez de uma segunda chamada imediata.
Dá para combinar Batch API com prompt caching?
Dá, e os descontos se somam. Num lote em que todas as requisições compartilham o mesmo system prompt longo (as regras de categorização, a lista de categorias, exemplos), marco esse bloco com cache_control exatamente como faria na API síncrona:
{ "model": "claude-haiku-4-5", "max_tokens": 256, "system": [ {"type": "text", "text": "Você categoriza transações de extrato PJ...", "cache_control": {"type": "ephemeral"}} ], "messages": [ {"role": "user", "content": "PIX RECEBIDO JOAO S 1.250,00"} ] }
A ressalva é que, no batch, o cache é melhor esforço. As requisições são processadas em paralelo e em qualquer ordem, então a taxa de acerto do cache é menor e menos previsível do que num fluxo síncrono sequencial. Ajuda manter o prefixo idêntico em todos os itens, byte a byte, e deixar tudo que varia no final. Antes de decidir entre os modos, vale simular o volume na calculadora de custo de IA, que compara o custo mensal com e sem prompt caching e Batch API.
Quais são os trade-offs de usar batch em produção?
O desconto é real, mas a conta não é só financeira:
- Latência imprevisível. A maioria dos lotes termina em menos de uma hora, mas o contrato é de até 24 horas. Qualquer fluxo que dependa do resultado precisa tolerar isso. Se o produto promete "categorizado até amanhã cedo", envio à noite com folga.
- Depuração mais lenta. Um erro no
params(modelo errado,max_tokensbaixo) só aparece quando o lote termina. Antes de mandar milhares de itens com um prompt novo, mando um lote de dez e confiro. - Mais estado para cuidar. São duas tabelas, um job, um comando agendado e um processador de resultados. Para volume pequeno, a API síncrona numa fila com rate limit é mais simples e resolve.
- Sem streaming e sem conversa. Cada requisição é independente. Fluxos multi-turno ou com tool calling em loop não cabem bem num lote, porque cada volta dependeria do resultado da anterior.
- Custo de duplicidade. Um reenvio indevido de lote inteiro custa caro. Por isso o job de envio não tem retry automático e o estado
submit_unknownexiste.
O que eu faria ao adotar a Batch API amanhã
Começaria por um único caso de uso de reprocessamento, normalmente a categorização de transações ou uma rodada de avaliação offline, e não pelo atendimento. Criaria as tabelas de lote e item com custom_id único global, enviaria lotes de alguns milhares com o job sem retry automático e acompanharia o status pelo scheduler a cada cinco minutos. Leria o JSONL com Http::sink() e fgets, nunca carregando o arquivo inteiro, e trataria succeeded, errored, expired e canceled como quatro caminhos explícitos, reenviando só o que faz sentido com um limite de tentativas. Por último, mediria: custo por item aplicado, tempo do envio até o applied e taxa de itens em failed. Se esses três números estiverem bons depois de algumas semanas, aí sim vale migrar os outros trabalhos noturnos para o mesmo trilho.