# Batch API do Claude no Laravel: como processar milhares de chamadas pela metade do preço

> Como usar a Message Batches API do Claude no Laravel para reprocessar milhares de itens com 50% de desconto: envio, polling, leitura do JSONL, reenvio e idempotência.

Por Lucas Roberto · IA · publicado em 2026-10-30 · 9 min de leitura
Versão web: https://lucasroberto.com/blog/batch-api-do-claude-no-laravel-como-processar-milhares-de-chamadas-pela-metade-do-preco

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:

1. `POST /v1/messages/batches` com um array `requests`. Cada item tem um `custom_id` (identificador seu, único dentro do lote, de 1 a 64 caracteres entre letras, números, `_` e `-`) e um `params` com exatamente o mesmo corpo que você mandaria para `/v1/messages`.
2. `GET /v1/messages/batches/{id}` para consultar o status. O campo `processing_status` fica em `in_progress` até virar `ended`.
3. Quando termina, o campo `results_url` aponta para um arquivo JSONL (um objeto JSON por linha) com o resultado de cada requisição.
4. Opcionalmente, `POST /v1/messages/batches/{id}/cancel` para 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:

```php
// 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:

```php
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](/blog/como-versionar-prompts-no-laravel-arquivos-no-repositorio-rollout-gradual-e-rollback), 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.

```php
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](/blog/timeout-ao-chamar-o-gateway-de-pagamento-como-evitar-cobranca-duplicada-com-idempotency-key-no-laravel) 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`:

```php
// routes/console.php
Schedule::command('llm-batches:poll')->everyFiveMinutes()->withoutOverlapping();
```

```php
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](/blog/bulkhead-de-filas-no-laravel-horizon-como-impedir-que-um-job-lento-atrase-os-webhooks).

## 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:

```php
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:

```json
{"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:

1. **`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 o `stop_reason`: se veio `max_tokens`, a resposta foi cortada e trato como falha.
2. **`errored`**: olho o tipo do erro. `invalid_request_error` é culpa minha (parâmetro errado, conteúdo grande demais) e reenviar não adianta, vai para `failed`. Erros do lado do servidor vão para `retry`.
3. **`expired`**: o lote passou de 24 horas e essa requisição não foi processada. Não é cobrada. Vai para `retry`.
4. **`canceled`**: alguém cancelou. Também não é cobrada. Decido caso a caso, normalmente `retry`.

```php
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](/blog/como-validar-o-json-do-claude-no-laravel-e-pedir-correcao-quando-a-validacao-falha), 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:

```json
{
  "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](/ferramentas/calculadora-custo-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:

1. **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.
2. **Depuração mais lenta.** Um erro no `params` (modelo errado, `max_tokens` baixo) só aparece quando o lote termina. Antes de mandar milhares de itens com um prompt novo, mando um lote de dez e confiro.
3. **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.
4. **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.
5. **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_unknown` existe.

## 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.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
