A Message Batches API da Anthropic permite enviar até 100 mil requisições ao Claude de uma vez, receber os resultados em algumas horas (no máximo 24) e pagar metade do preço por token. No Laravel, o fluxo cabe em três peças: um comando que monta e envia o lote, um job agendado que consulta o status e um job que baixa o arquivo JSONL de resultados e aplica cada resposta pelo custom_id. Se a tarefa não precisa de resposta imediata, como categorizar o histórico, reprocessar documentos ou enriquecer cadastros, essa é a forma mais barata e mais estável de chamar um LLM em volume.
O que você vai ter funcionando no final
Um pipeline que pega transações sem categoria, envia tudo num lote para o Claude, acompanha o processamento sozinho pelo scheduler, grava a categoria de cada transação, registra o consumo de tokens e reenvia automaticamente o que expirou ou deu erro transitório.
Pré-requisitos:
- Laravel 11 ou 12 com PHP 8.2+.
- PostgreSQL (o exemplo usa PostgreSQL, mas nada aqui é exclusivo dele).
- Um worker de fila rodando (Redis + Horizon, ou o driver
database). - O scheduler ativo (
php artisan schedule:workem dev ou o cron em produção). - Uma chave da API da Anthropic com acesso ao modelo escolhido.
Não uso SDK: tudo vai pelo Http:: do Laravel, então você vê exatamente o JSON que trafega.
Quando usar a Batch API em vez de chamadas síncronas?
Batch API é um endpoint assíncrono: você envia uma lista de requisições, recebe um ID e volta depois para buscar os resultados. A regra que sigo é simples: se existe um humano esperando a resposta, é chamada síncrona; se não existe, é lote.
| Critério | Chamada síncrona (/v1/messages) |
Message Batches API |
|---|---|---|
| Latência | Segundos | Minutos a até 24 horas |
| Preço | Cheio | 50% de desconto em entrada e saída |
| Volume | Limitado pelo rate limit da sua conta | Até 100 mil requisições ou 256 MB por lote |
| Retry e throttling | Você implementa | A Anthropic distribui a carga |
| Streaming | Sim | Não |
| Uso típico | Agente no WhatsApp, chat, tela do usuário | Backfill, classificação noturna, relatórios |
Na Granpag, o caso clássico é categorização de extrato. Em tempo real, quando chega uma transação nova, faz sentido a chamada síncrona que descrevi em como categorizar transações do extrato com Claude no Laravel. Mas quando um cliente novo importa meses de histórico, ou quando o prompt muda e você precisa recategorizar tudo, disparar milhares de chamadas síncronas é queimar rate limit e dinheiro. Para ter uma ideia da diferença no seu volume, a calculadora de custo de IA compara o custo mensal com e sem Batch API e prompt caching.
1. Configurar a chave e o cliente HTTP
No .env:
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_BATCH_MODEL=claude-haiku-4-5
Em config/services.php:
'anthropic' => [ 'key' => env('ANTHROPIC_API_KEY'), 'version' => '2023-06-01', 'batch_model' => env('ANTHROPIC_BATCH_MODEL', 'claude-haiku-4-5'), ],
Agora o cliente, em app/Ai/AnthropicBatches.php. Ele faz quatro coisas: criar o lote, consultar, cancelar e baixar os resultados.
<?php namespace App\Ai; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; use Psr\Http\Message\StreamInterface; class AnthropicBatches { private const BASE = 'https://api.anthropic.com/v1/messages/batches'; private function http(): PendingRequest { return Http::withHeaders([ 'x-api-key' => config('services.anthropic.key'), 'anthropic-version' => config('services.anthropic.version'), ]) ->acceptJson() ->timeout(120) ->retry(3, 2000, fn ($e) => $e instanceof ConnectionException || ($e instanceof RequestException && $e->response->serverError())); } public function create(array $requests): array { return $this->http()->post(self::BASE, ['requests' => $requests])->throw()->json(); } public function retrieve(string $id): array { return $this->http()->get(self::BASE."/{$id}")->throw()->json(); } public function cancel(string $id): array { return $this->http()->post(self::BASE."/{$id}/cancel")->throw()->json(); } public function results(string $resultsUrl): StreamInterface { return $this->http()->get($resultsUrl)->throw()->toPsrResponse()->getBody(); } }
O retry só repete erro de conexão e 5xx. Um 400 de validação repetido três vezes continua sendo 400.
2. Criar as tabelas de controle do lote
Você precisa saber, a qualquer momento, quais lotes estão em voo e qual item local corresponde a cada custom_id. custom_id é o identificador que você dá a cada requisição dentro do lote; é a única forma de ligar um resultado ao registro de origem, porque os resultados não voltam na ordem de envio.
php artisan make:migration create_llm_batches_tables
public function up(): void { Schema::create('llm_batches', function (Blueprint $table) { $table->id(); $table->string('provider_batch_id')->nullable()->unique(); $table->string('purpose'); $table->string('status'); // submitting, in_progress, canceling, ended, failed $table->unsignedInteger('request_count'); $table->unsignedInteger('succeeded')->default(0); $table->unsignedInteger('errored')->default(0); $table->unsignedInteger('expired')->default(0); $table->unsignedInteger('canceled')->default(0); $table->text('results_url')->nullable(); $table->timestamp('submitted_at')->nullable(); $table->timestamp('ended_at')->nullable(); $table->timestamp('processed_at')->nullable(); $table->timestamps(); }); Schema::create('llm_batch_items', function (Blueprint $table) { $table->id(); $table->foreignId('llm_batch_id')->constrained()->cascadeOnDelete(); $table->string('custom_id', 64); $table->morphs('subject'); $table->string('status')->default('pending'); // pending, succeeded, errored, expired, canceled, invalid $table->text('error')->nullable(); $table->unsignedInteger('input_tokens')->nullable(); $table->unsignedInteger('output_tokens')->nullable(); $table->timestamps(); $table->unique(['llm_batch_id', 'custom_id']); }); }
Nos models, LlmBatch tem items(): HasMany e LlmBatchItem tem subject(): MorphTo. Em Transaction, adicione:
public function batchItems(): MorphMany { return $this->morphMany(LlmBatchItem::class, 'subject'); }
3. Montar os parâmetros de cada requisição
Cada item do lote tem o mesmo formato do corpo de /v1/messages, dentro de params. Uso tool calling forçado para garantir um JSON com formato previsível: tool calling é quando você descreve uma função com JSON Schema e o modelo responde com os argumentos dela; com tool_choice apontando para a ferramenta, ele é obrigado a usá-la.
<?php namespace App\Ai; use App\Models\Transaction; final class CategorizationPrompt { public static function params(Transaction $t, array $categories): array { return [ 'model' => config('services.anthropic.batch_model'), 'max_tokens' => 256, 'tools' => [[ 'name' => 'categorizar', 'description' => 'Registra a categoria da transação.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'categoria' => ['type' => 'string', 'enum' => $categories], 'confianca' => ['type' => 'number', 'minimum' => 0, 'maximum' => 1], ], 'required' => ['categoria', 'confianca'], ], ]], 'tool_choice' => ['type' => 'tool', 'name' => 'categorizar'], 'system' => [[ 'type' => 'text', 'text' => 'Você classifica transações de conta PJ no Brasil. Use só as categorias da ferramenta. Na dúvida, use "outros" com confiança baixa.', 'cache_control' => ['type' => 'ephemeral'], ]], 'messages' => [[ 'role' => 'user', 'content' => sprintf( "Descrição: %s\nValor: %d centavos\nDireção: %s", $t->description, $t->amount_cents, $t->direction ), ]], ]; } }
O cache_control no system marca tools + system como prefixo cacheável. Prompt caching funciona dentro de lotes, mas o acerto de cache é por melhor esforço, porque as requisições são processadas em paralelo, e o prefixo precisa ter um tamanho mínimo para ser cacheado. Se o seu prompt é curto como este, o ganho vem quase todo do desconto do lote; com instruções longas e exemplos, o cache soma bem.
4. Enviar o lote com um comando Artisan
php artisan make:command SubmitCategorizationBatch
<?php namespace App\Console\Commands; use App\Ai\AnthropicBatches; use App\Ai\CategorizationPrompt; use App\Models\{Category, LlmBatch, LlmBatchItem, Transaction}; use Illuminate\Console\Command; use Throwable; class SubmitCategorizationBatch extends Command { protected $signature = 'ai:categorize-batch {--limit=10000}'; protected $description = 'Envia transações sem categoria para a Message Batches API'; public function handle(AnthropicBatches $api): int { $transactions = Transaction::query() ->whereNull('category_id') ->whereDoesntHave('batchItems', fn ($q) => $q->whereIn('status', ['pending', 'succeeded', 'invalid'])) ->whereHas('batchItems', fn ($q) => $q->whereIn('status', ['errored', 'expired']), '<', 3) ->orderBy('id') ->limit((int) $this->option('limit')) ->get(); if ($transactions->isEmpty()) { $this->info('Nada para enviar.'); return self::SUCCESS; } $categories = Category::pluck('slug')->all(); $batch = LlmBatch::create([ 'purpose' => 'categorize_transactions', 'status' => 'submitting', 'request_count' => $transactions->count(), ]); $now = now(); $rows = []; $requests = []; foreach ($transactions as $t) { $customId = "txn-{$t->id}-b{$batch->id}"; $rows[] = [ 'llm_batch_id' => $batch->id, 'custom_id' => $customId, 'subject_type' => $t->getMorphClass(), 'subject_id' => $t->id, 'status' => 'pending', 'created_at' => $now, 'updated_at' => $now, ]; $requests[] = ['custom_id' => $customId, 'params' => CategorizationPrompt::params($t, $categories)]; } collect($rows)->chunk(1000)->each(fn ($chunk) => LlmBatchItem::insert($chunk->all())); try { $response = $api->create($requests); } catch (Throwable $e) { $batch->items()->delete(); $batch->update(['status' => 'failed']); throw $e; } $batch->update([ 'provider_batch_id' => $response['id'], 'status' => $response['processing_status'], 'submitted_at' => now(), ]); $this->info("Lote {$response['id']} enviado com {$transactions->count()} requisições."); return self::SUCCESS; } }
O JSON que vai para POST /v1/messages/batches fica assim (resumido):
{ "requests": [ { "custom_id": "txn-981-b12", "params": { "model": "claude-haiku-4-5", "max_tokens": 256, "tool_choice": {"type": "tool", "name": "categorizar"}, "messages": [{"role": "user", "content": "Descrição: PIX ENVIADO ..."}] } } ] }
A resposta traz id (algo como msgbatch_...), processing_status: "in_progress", request_counts e results_url: null.
Duas decisões aqui. Primeiro, crio o registro local antes de chamar a API: se a chamada falhar, apago os itens e as transações voltam a ser elegíveis. O caso ruim é o timeout depois de a Anthropic aceitar o lote: você fica com um lote órfão. Aceito esse risco porque o pior que acontece é pagar duas vezes por algumas classificações; num fluxo que mexe com dinheiro eu não aceitaria. Segundo, o custom_id inclui o ID do lote, então reenviar a mesma transação gera um custom_id novo e não colide com a tentativa anterior.
5. Acompanhar o status com o scheduler
Não existe webhook de conclusão de lote, então é polling. Um job único, rodando a cada cinco minutos, basta.
<?php namespace App\Jobs; use App\Ai\AnthropicBatches; use App\Models\LlmBatch; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; class PollLlmBatches implements ShouldQueue, ShouldBeUnique { use Queueable; public function handle(AnthropicBatches $api): void { LlmBatch::whereIn('status', ['in_progress', 'canceling'])->each(function (LlmBatch $batch) use ($api) { $data = $api->retrieve($batch->provider_batch_id); $counts = $data['request_counts']; $batch->update([ 'status' => $data['processing_status'], 'succeeded' => $counts['succeeded'], 'errored' => $counts['errored'], 'expired' => $counts['expired'], 'canceled' => $counts['canceled'], 'results_url' => $data['results_url'], 'ended_at' => $data['ended_at'], ]); if ($data['processing_status'] === 'ended') { ProcessLlmBatchResults::dispatch($batch->id); } }); } }
Em routes/console.php:
use App\Jobs\PollLlmBatches; use Illuminate\Support\Facades\Schedule; Schedule::job(new PollLlmBatches)->everyFiveMinutes();
Como o status vira ended no mesmo update que dispara o processamento, o lote não é selecionado de novo no próximo ciclo.
6. Baixar e aplicar os resultados do JSONL
O results_url devolve um arquivo JSONL, um objeto JSON por linha. Cada linha tem custom_id e um result cujo type é succeeded, errored, canceled ou expired. Leio linha a linha do stream para não carregar o arquivo inteiro num array.
<?php namespace App\Jobs; use App\Ai\AnthropicBatches; use App\Models\{Category, LlmBatch, LlmBatchItem}; use GuzzleHttp\Psr7\Utils; use Illuminate\Contracts\Queue\{ShouldBeUnique, ShouldQueue}; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\{DB, Validator}; use Illuminate\Validation\Rule; class ProcessLlmBatchResults implements ShouldQueue, ShouldBeUnique { use Queueable; public int $timeout = 900; public function __construct(public int $batchId) {} public function uniqueId(): string { return (string) $this->batchId; } public function handle(AnthropicBatches $api): void { $batch = LlmBatch::findOrFail($this->batchId); if ($batch->processed_at) { return; } $stream = $api->results($batch->results_url); while (! $stream->eof()) { $line = trim(Utils::readLine($stream)); if ($line === '') { continue; } $row = json_decode($line, true, flags: JSON_THROW_ON_ERROR); $item = $batch->items()->where('custom_id', $row['custom_id'])->where('status', 'pending')->first(); if (! $item) { continue; // já processado numa execução anterior } $result = $row['result']; match ($result['type']) { 'succeeded' => $this->applySucceeded($item, $result['message']), 'errored' => $this->markErrored($item, $result), default => $item->update(['status' => $result['type']]), // expired, canceled }; } $batch->update(['processed_at' => now()]); } private function applySucceeded(LlmBatchItem $item, array $message): void { $input = collect($message['content'])->firstWhere('type', 'tool_use')['input'] ?? []; $validator = Validator::make($input, [ 'categoria' => ['required', 'string', Rule::exists('categories', 'slug')], 'confianca' => ['required', 'numeric', 'between:0,1'], ]); if ($validator->fails()) { $item->update(['status' => 'invalid', 'error' => $validator->errors()->first()]); return; } DB::transaction(function () use ($item, $input, $message) { if ($input['confianca'] >= 0.7) { $item->subject->update([ 'category_id' => Category::where('slug', $input['categoria'])->value('id'), ]); } $item->update([ 'status' => 'succeeded', 'input_tokens' => $message['usage']['input_tokens'] ?? null, 'output_tokens' => $message['usage']['output_tokens'] ?? null, ]); }); } private function markErrored(LlmBatchItem $item, array $result): void { $type = data_get($result, 'error.error.type', data_get($result, 'error.type')); $item->update([ 'status' => $type === 'invalid_request_error' ? 'invalid' : 'errored', 'error' => data_get($result, 'error.error.message', json_encode($result['error'] ?? null)), ]); } }
Pontos que importam:
- Idempotência por item: o filtro
status = pendingfaz o job poder rodar de novo depois de uma queda no meio do arquivo sem aplicar nada duas vezes. - Validação mesmo com tool calling forçado: o enum do schema ajuda, mas a categoria pode ter sido removida do banco entre o envio e o resultado. O mesmo raciocínio de validar o JSON do Claude e pedir correção quando a validação falha vale aqui, só que a correção vira um item
invalidpara revisão em vez de uma nova chamada. - Confiança baixa não grava categoria: o item fica
succeeded, mas a transação continua sem categoria e vai para a fila de revisão manual. Ela não é reenviada porque já tem itemsucceeded. - Erros de validação da requisição são
invalid: reenviar uma requisição mal formada não adianta.
7. Reenviar o que expirou ou falhou
Não precisa de código novo. A query do passo 4 já considera elegível qualquer transação sem categoria, sem item pending, succeeded ou invalid, e com menos de três itens errored/expired. Basta agendar o envio:
Schedule::command('ai:categorize-batch --limit=10000')->hourly()->withoutOverlapping();
Com isso, o backfill inteiro anda sozinho: cada hora sai um lote novo com o que falta, inclusive as sobras dos lotes anteriores.
8. Testar o fluxo com Http::fake
Como tudo passa pelo Http::, dá para testar o pipeline completo sem chamar a Anthropic. A ordem das chaves importa: o padrão * casa com barras, então a rota de resultados precisa vir antes.
it('aplica a categoria vinda do lote', function () { $category = Category::create(['slug' => 'fornecedores', 'name' => 'Fornecedores']); $transaction = Transaction::factory()->create(['category_id' => null]); $batch = LlmBatch::create([ 'provider_batch_id' => 'msgbatch_test', 'purpose' => 'categorize_transactions', 'status' => 'in_progress', 'request_count' => 1, ]); $batch->items()->create([ 'custom_id' => "txn-{$transaction->id}-b{$batch->id}", 'subject_type' => $transaction->getMorphClass(), 'subject_id' => $transaction->id, ]); $jsonl = json_encode([ 'custom_id' => "txn-{$transaction->id}-b{$batch->id}", 'result' => ['type' => 'succeeded', 'message' => [ 'content' => [['type' => 'tool_use', 'name' => 'categorizar', 'input' => ['categoria' => 'fornecedores', 'confianca' => 0.92]]], 'usage' => ['input_tokens' => 310, 'output_tokens' => 40], ]], ])."\n"; Http::fake([ 'api.anthropic.com/v1/messages/batches/*/results' => Http::response($jsonl), 'api.anthropic.com/v1/messages/batches/*' => Http::response([ 'id' => 'msgbatch_test', 'processing_status' => 'ended', 'ended_at' => now()->toIso8601String(), 'request_counts' => ['processing' => 0, 'succeeded' => 1, 'errored' => 0, 'canceled' => 0, 'expired' => 0], 'results_url' => 'https://api.anthropic.com/v1/messages/batches/msgbatch_test/results', ]), ]); (new PollLlmBatches)->handle(app(AnthropicBatches::class)); expect($transaction->fresh()->category_id)->toBe($category->id) ->and($batch->fresh()->processed_at)->not->toBeNull(); });
Com a fila em sync no ambiente de teste, o dispatch do processamento roda na hora. Para gravar respostas reais e reaproveitar como fixture, o processo é o mesmo de testar código que chama o Claude com Http::fake e respostas gravadas.
Erros comuns
custom_idcom caractere inválido ou longo demais: ele aceita só letras, números,-e_, com até 64 caracteres. Um UUID com prefixo cabe; um e-mail ou algo com ponto derruba a criação do lote inteiro com 400.- Achar que o
createvalida cada requisição: a validação dosparamsde cada item é assíncrona. Ummax_tokensfaltando ou um nome de modelo errado só aparece comoerroredno resultado, horas depois. Antes de mandar 10 mil, mande os mesmosparamsuma vez pelo/v1/messagessíncrono. - Assumir a ordem dos resultados: o JSONL não vem na ordem do envio. Sempre case pelo
custom_id. custom_idduplicado no mesmo lote: precisa ser único dentro do lote. Se você gera o ID só a partir da transação e ela aparece duas vezes na query, o lote é rejeitado.- Esquecer de processar a tempo: os resultados ficam disponíveis por 29 dias depois da criação. Um job que falhou em silêncio e só foi notado depois disso significa pagar de novo.
- Usar lote em fluxo com usuário esperando: a maioria termina em menos de uma hora, mas o limite é 24 horas, e o que não terminar até lá volta como
expired. Agente no WhatsApp, tela de checkout ou qualquer coisa com SLA de segundos não é caso de lote. - Lote gigante num único job: o limite é 100 mil requisições ou 256 MB, mas lotes de alguns milhares são mais fáceis de monitorar, reprocessar e caber no
timeoutdo worker. Ajuste o--limite o$timeoutjuntos. - Polling agressivo: consultar a cada poucos segundos não acelera nada e consome rate limit da sua conta. Cinco minutos é suficiente.
Perguntas frequentes
Quanto a Batch API economiza?
O preço por token cai pela metade, tanto na entrada quanto na saída, em relação à chamada síncrona do mesmo modelo. Combinada com prompt caching em prompts longos, a economia é ainda maior.
Dá para cancelar um lote em andamento?
Sim, com POST /v1/messages/batches/{id}/cancel. O status passa para canceling e depois ended; o que já foi processado volta como succeeded e o resto como canceled, e você paga só pelo que foi processado.
Posso usar tool calling, visão e system prompt dentro do lote?
Sim. Qualquer parâmetro aceito pelo /v1/messages vai dentro de params. A exceção prática é streaming, que não existe em lote.
A OpenAI tem algo equivalente?
Tem a Batch API da OpenAI, com desconto parecido, mas o fluxo é diferente: você sobe um arquivo JSONL pela Files API e cria o lote apontando para ele. A estrutura de tabelas, polling e processamento por custom_id deste tutorial serve para os dois.
Como saber quanto cada lote custou?
Some input_tokens e output_tokens dos itens succeeded agrupando por llm_batch_id e aplique o preço do modelo com 50% de desconto. Gravar o usage por item, como no passo 6, é o que torna essa conta possível depois.
Conclusão prática
Antes de otimizar prompt ou trocar de modelo para economizar, olhe quais chamadas ao Claude no seu sistema não têm ninguém esperando a resposta. Backfill, recategorização depois de mudar o prompt, enriquecimento de cadastro e relatórios noturnos quase sempre podem virar lote. Comece por um único caso, com as duas tabelas de controle e os três componentes deste tutorial (comando de envio, polling e processamento idempotente por custom_id), rode um lote pequeno validando os params antes e só então aumente o --limit. O resto do pipeline já fica pronto para os próximos casos.