# Prompt caching e Batch API do Claude no Laravel: como cortar o custo de LLM em produção

> Como usar prompt caching e a Batch API da Anthropic no Laravel para pagar menos por token: estrutura do prompt, métrica de cache hit, batches noturnos e escolha de modelo.

Por Lucas Roberto · IA · publicado em 2026-10-09 · 10 min de leitura
Versão web: https://lucasroberto.com/blog/prompt-caching-e-batch-api-do-claude-no-laravel-como-cortar-o-custo-de-llm-em-producao

Os dois maiores cortes de custo numa integração com o Claude, sem mexer na qualidade da resposta, são o **prompt caching** e a **Batch API**. O prompt caching cobra uma fração do preço normal pelos tokens de entrada que se repetem entre chamadas. A Batch API dá 50% de desconto para tarefas que podem esperar até 24 horas. O primeiro serve para agentes e chats com prompt de sistema longo, o segundo para processamento em lote como classificação e enriquecimento de dados. Os dois se combinam, e no Laravel a implementação cabe em poucas classes chamando a API por HTTP.

O que segue é o que aprendi colocando LLM em produção em produtos que rodam o dia inteiro, como o agente de agendamento do Horalis no WhatsApp e tarefas de back-office de uma fintech como a Granpag. Uso a API da Anthropic direto pelo `Http::` do Laravel, sem SDK, porque fica explícito o que está sendo enviado e cobrado.

## O que mais pesa na fatura de um LLM em produção?

Token é a unidade de texto que o modelo processa e pela qual você paga, separada em tokens de entrada (o que você envia) e de saída (o que o modelo gera). Em quase todo caso real, a entrada domina a conta, e o motivo é estrutural: a API é *stateless*. A cada turno de conversa você reenvia tudo, ou seja:

1. O prompt de sistema, com regras de negócio, tom de voz e exemplos.
2. As definições de tools (funções que o modelo pode chamar), cada uma com seu JSON Schema.
3. O histórico da conversa inteiro, incluindo resultados de tools.
4. A mensagem nova do usuário, que costuma ser a menor parte.

No agente que descrevi em [como criar um agente de IA com tool calling do Claude para agendamentos no WhatsApp](/blog/como-criar-um-agente-de-ia-com-tool-calling-do-claude-no-laravel-para-agendamentos-no-whatsapp), o cliente manda "pode ser amanhã às 15h?" e eu reenvio milhares de tokens de instrução e tools que são idênticos aos do turno anterior. Pagar preço cheio por isso é desperdício puro.

O segundo padrão é o volume sem urgência: classificar descrições de transações, gerar resumo de atendimentos do dia, normalizar cadastros importados. Ninguém está esperando a resposta em tempo real, mas é comum ver isso implementado com chamadas síncronas, uma por registro, a preço cheio.

## Como funciona o prompt caching do Claude?

Prompt caching é o mecanismo em que a Anthropic guarda o processamento de um **prefixo** do seu prompt e reaproveita esse trabalho nas próximas chamadas que começarem exatamente igual. Você marca até onde vai o prefixo com um `cache_control` em um bloco de conteúdo, e tudo antes desse ponto vira candidato a cache.

As regras que importam na prática:

1. **A ordem do prefixo é fixa:** `tools`, depois `system`, depois `messages`. Um breakpoint no fim do `system` cobre as tools também.
2. **O prefixo precisa ser idêntico byte a byte.** Qualquer caractere diferente antes do breakpoint gera cache miss.
3. **Escrever no cache custa mais que entrada normal** (25% a mais no TTL padrão) e **ler do cache custa 10% do preço de entrada**. O cache se paga a partir da segunda leitura.
4. **O TTL padrão é de 5 minutos**, renovado a cada hit. Existe opção de TTL de 1 hora, com custo de escrita maior.
5. **Há um tamanho mínimo de prefixo** para ser cacheado, que varia por modelo (na faixa de 1024 a 4096 tokens). Abaixo disso a requisição funciona, só não cacheia.
6. Você pode usar **até 4 breakpoints** por requisição.

A requisição fica assim:

```json
{
  "model": "claude-sonnet-4-5",
  "max_tokens": 1024,
  "tools": [
    { "name": "buscar_horarios", "description": "...", "input_schema": { "type": "object", "properties": {} } },
    { "name": "criar_agendamento", "description": "...", "input_schema": { "type": "object", "properties": {} } }
  ],
  "system": [
    {
      "type": "text",
      "text": "Você é o assistente de agendamento do estabelecimento...",
      "cache_control": { "type": "ephemeral" }
    }
  ],
  "messages": [
    { "role": "user", "content": "pode ser amanhã às 15h?" }
  ]
}
```

## Como montar o prompt no Laravel para o cache realmente bater?

Centralizo a chamada numa classe que sempre coloca o breakpoint no último bloco do `system`. Quem monta o prompt não precisa lembrar disso:

```php
class ClaudeClient
{
    public function message(string $feature, string $model, array $system, array $tools, array $messages, int $maxTokens = 1024): array
    {
        $system[array_key_last($system)]['cache_control'] = ['type' => 'ephemeral'];

        $response = $this->http()
            ->timeout(60)
            ->post('https://api.anthropic.com/v1/messages', [
                'model' => $model,
                'max_tokens' => $maxTokens,
                'tools' => $tools,
                'system' => $system,
                'messages' => $messages,
            ])
            ->throw()
            ->json();

        LlmUsage::record($feature, $model, $response['usage']);

        return $response;
    }

    private function http()
    {
        return Http::withHeaders([
            'x-api-key' => config('services.anthropic.key'),
            'anthropic-version' => '2023-06-01',
        ]);
    }
}
```

Timeouts, retries e idempotência dessa chamada ficam numa camada acima, rodando em fila, como detalhei em [como chamar LLMs no Laravel com filas, retries e timeouts sem duplicar custo](/blog/como-chamar-llms-no-laravel-com-filas-retries-e-timeouts-sem-duplicar-custo). Aqui o foco é o formato do payload.

O código é simples. O que quebra o cache é o conteúdo. Os erros que mais vejo:

1. **Data e hora no prompt de sistema.** Um "Agora são 14:32 de terça-feira" no topo invalida o cache a cada minuto. Mova qualquer dado volátil para depois do breakpoint, por exemplo como um bloco extra na última mensagem do usuário.
2. **Tools em ordem instável.** Se a lista de tools vem de uma query sem `orderBy` ou de um array montado condicionalmente, a ordem muda e o prefixo muda junto. Ordene explicitamente.
3. **Dados do cliente misturados às regras gerais.** No Horalis, cada estabelecimento tem seu nome, serviços e horário de funcionamento. Isso pode ficar no prefixo, mas aí o cache é por estabelecimento e só compensa se ele tiver conversas dentro da janela de 5 minutos. A saída é dividir o `system` em dois blocos: regras gerais primeiro (iguais para todos) com um breakpoint, dados do estabelecimento depois com outro.
4. **Trocar de modelo no meio da conversa.** Cache é por modelo. Roteamento dinâmico entre modelos joga o cache fora.
5. **Serialização diferente do mesmo conteúdo.** Um float formatado de outro jeito ou um campo opcional que às vezes vem `null` e às vezes não vem muda o JSON. Normalize antes de montar o prompt.

Para o histórico da conversa, dá para usar um segundo breakpoint no último bloco da última mensagem. Na chamada seguinte, o prefixo até ali já está em cache e você só paga preço cheio pelo turno novo:

```php
$last = array_key_last($messages);

if (is_string($messages[$last]['content'])) {
    $messages[$last]['content'] = [['type' => 'text', 'text' => $messages[$last]['content']]];
}

$blocks = &$messages[$last]['content'];
$blocks[array_key_last($blocks)]['cache_control'] = ['type' => 'ephemeral'];
```

Isso vale também quando a última mensagem é um `tool_result`: o breakpoint vai no último bloco, seja ele qual for.

## Como saber se o prompt caching está funcionando?

Não confie que está funcionando, meça. A resposta da API traz em `usage` quatro campos que você deve gravar:

| Campo | O que significa |
|---|---|
| `input_tokens` | Tokens de entrada cobrados a preço cheio (depois do último breakpoint) |
| `cache_creation_input_tokens` | Tokens escritos no cache nesta chamada |
| `cache_read_input_tokens` | Tokens lidos do cache, cobrados a 10% |
| `output_tokens` | Tokens gerados pelo modelo |

Atenção: `input_tokens` **não** é o total de entrada. O total é a soma dos três primeiros. Já vi dashboard de custo errado por ignorar isso.

```php
class LlmUsage extends Model
{
    protected $guarded = [];

    public static function record(string $feature, string $model, array $usage): void
    {
        static::create([
            'feature' => $feature,
            'model' => $model,
            'input_tokens' => $usage['input_tokens'],
            'cache_write_tokens' => $usage['cache_creation_input_tokens'] ?? 0,
            'cache_read_tokens' => $usage['cache_read_input_tokens'] ?? 0,
            'output_tokens' => $usage['output_tokens'],
        ]);
    }
}
```

Com isso, uma query no PostgreSQL mostra a taxa de acerto por funcionalidade:

```sql
SELECT feature,
       SUM(cache_read_tokens)::numeric
         / NULLIF(SUM(input_tokens + cache_write_tokens + cache_read_tokens), 0) AS hit_ratio
FROM llm_usages
WHERE created_at > now() - interval '1 day'
GROUP BY feature;
```

Se uma feature com prompt longo e tráfego constante aparece com taxa de acerto baixa, quase sempre é um dos cinco erros da seção anterior. Se `cache_write_tokens` aparece em toda chamada, alguma coisa antes do breakpoint está mudando a cada requisição.

## Quando usar a Batch API em vez de chamadas síncronas?

A Batch API da Anthropic (Message Batches) recebe um lote de requisições de uma vez, processa de forma assíncrona e cobra metade do preço. A maioria dos lotes termina bem antes, mas o contrato é de até 24 horas. Cada lote aceita até 100.000 requisições ou 256 MB.

| Critério | Chamada síncrona | Batch API |
|---|---|---|
| Alguém espera a resposta? | Sim (chat, WhatsApp, tela) | Não |
| Latência aceitável | Segundos | Minutos a horas |
| Volume | Baixo e contínuo | Alto e agrupável |
| Preço | Cheio | 50% de desconto |
| Exemplos | Agente de atendimento | Classificar transações, resumir o dia, enriquecer cadastros |

O desconto do batch se soma ao do prompt caching. Num lote em que todas as requisições compartilham as mesmas instruções, vale marcar o `system` com `cache_control` também. Os hits de cache em batch não são garantidos, mas quando acontecem o desconto empilha.

## Como enviar e processar um batch do Claude no Laravel?

O fluxo tem três partes: submeter o lote, consultar o status periodicamente e processar os resultados de forma idempotente. O exemplo classifica descrições de transações em categorias, uma tarefa típica de back-office em fintech.

Submissão:

```php
class SubmeterBatchDeCategorias implements ShouldQueue
{
    use Queueable;

    public function handle(): void
    {
        $transacoes = Transaction::whereNull('category')
            ->whereNull('llm_batch_id')
            ->orderBy('id')
            ->limit(5000)
            ->get();

        if ($transacoes->isEmpty()) {
            return;
        }

        $requests = $transacoes->map(fn ($t) => [
            'custom_id' => 'txn_'.$t->id,
            'params' => [
                'model' => config('services.anthropic.batch_model'),
                'max_tokens' => 50,
                'system' => [[
                    'type' => 'text',
                    'text' => view('prompts.categorizar-transacao')->render(),
                    'cache_control' => ['type' => 'ephemeral'],
                ]],
                'messages' => [['role' => 'user', 'content' => $t->description]],
            ],
        ])->values()->all();

        $batch = Http::withHeaders([
                'x-api-key' => config('services.anthropic.key'),
                'anthropic-version' => '2023-06-01',
            ])
            ->post('https://api.anthropic.com/v1/messages/batches', ['requests' => $requests])
            ->throw()
            ->json();

        $registro = LlmBatch::create(['external_id' => $batch['id'], 'status' => $batch['processing_status']]);

        Transaction::whereIn('id', $transacoes->pluck('id'))->update(['llm_batch_id' => $registro->id]);
    }
}
```

A coluna `llm_batch_id` impede que a próxima submissão pegue as mesmas transações enquanto o lote anterior ainda está processando. Sem ela, você paga duas vezes pelo mesmo trabalho.

Consulta e processamento, agendados no `routes/console.php` com `Schedule::job(new ProcessarBatchesLlm)->everyFiveMinutes()->withoutOverlapping();`:

```php
public function handle(): void
{
    foreach (LlmBatch::where('status', '!=', 'ended')->get() as $registro) {
        $batch = $this->http()
            ->get('https://api.anthropic.com/v1/messages/batches/'.$registro->external_id)
            ->throw()
            ->json();

        if ($batch['processing_status'] !== 'ended') {
            continue;
        }

        $jsonl = $this->http()->get($batch['results_url'])->throw()->body();

        foreach (explode("\n", trim($jsonl)) as $linha) {
            $row = json_decode($linha, true);
            $id = (int) str_replace('txn_', '', $row['custom_id']);

            if ($row['result']['type'] !== 'succeeded') {
                Transaction::whereKey($id)->update(['llm_batch_id' => null]);
                continue;
            }

            $categoria = Categoria::tryFrom(trim($row['result']['message']['content'][0]['text']));

            Transaction::whereKey($id)->whereNull('category')->update([
                'category' => $categoria?->value,
                'category_needs_review' => $categoria === null,
            ]);
        }

        $registro->update(['status' => 'ended']);
    }
}
```

Alguns detalhes que fazem diferença:

1. **A ordem dos resultados não é garantida.** Use sempre o `custom_id` para casar resposta e registro. Ele aceita letras, números, hífen e underscore, até 64 caracteres.
2. **Resultados com tipo `errored` ou `expired` não são cobrados.** Limpar o `llm_batch_id` devolve a transação para o próximo lote, sem lógica de retry separada.
3. **Valide o que o modelo devolveu.** O `Categoria::tryFrom` garante que só valores do enum entram no banco. O resto vai para revisão humana em vez de virar dado sujo.
4. **O `whereNull('category')` no update** torna o processamento idempotente: se o job rodar duas vezes sobre o mesmo resultado, nada muda.
5. **Para lotes grandes**, baixe o JSONL para disco com `->sink($caminho)` e leia linha a linha, em vez de carregar o corpo inteiro na memória.

## Como escolher o modelo e limitar tokens para gastar menos?

Caching e batch reduzem o preço por token. A escolha de modelo e o limite de saída reduzem a quantidade e o preço base.

1. **Use o menor modelo que passa nos seus evals.** Classificação em categoria fechada, extração simples e roteamento de intenção costumam funcionar bem com o Haiku. Agentes com várias tools e decisões encadeadas pedem Sonnet. Deixe o modelo em config por feature, nunca hardcoded.
2. **Tokens de saída custam várias vezes mais que os de entrada.** Peça respostas curtas e em formato fechado. Na classificação acima, `max_tokens` de 50 é suficiente e funciona como trava contra o modelo resolver explicar o raciocínio.
3. **Corte o histórico.** Em conversa de WhatsApp, as mensagens de dias atrás raramente ajudam. Manter só os últimos turnos e um resumo do estado (agendamento em andamento, serviço escolhido) reduz entrada sem perder contexto. O ponto de corte deve ser estável para não invalidar o cache a cada turno.
4. **Estime antes de mudar o prompt.** O endpoint `POST /v1/messages/count_tokens` recebe o mesmo payload da chamada real e devolve a contagem sem gerar resposta. Uso para comparar versões de prompt antes de subir.

## Checklist para reduzir custo de LLM no Laravel

1. Grave os quatro campos de `usage` por feature e por modelo desde o primeiro dia.
2. Coloque `cache_control` no fim do prefixo estável (tools e regras gerais) em toda feature com prompt longo.
3. Tire data, hora e qualquer dado volátil de antes do breakpoint.
4. Ordene tools e normalize os dados que entram no prompt.
5. Mova para a Batch API tudo que não tem usuário esperando.
6. Escolha o modelo por feature com base em evals, não por hábito.
7. Limite `max_tokens` e peça saídas fechadas.

## Conclusão

Se você tem um agente ou chat em produção, comece pelo prompt caching. É uma mudança de poucas linhas na classe que chama a API e o efeito aparece na fatura do mês seguinte, desde que você grave o `usage` para provar. Depois, levante a lista de jobs que chamam LLM sem ninguém esperando a resposta: cada um deles é candidato à Batch API e a pagar metade. E deixe a troca de modelo por último, porque é a única das três que mexe na qualidade e exige evals para fazer com segurança.

---

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