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:
- O prompt de sistema, com regras de negócio, tom de voz e exemplos.
- As definições de tools (funções que o modelo pode chamar), cada uma com seu JSON Schema.
- O histórico da conversa inteiro, incluindo resultados de tools.
- 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, 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:
- A ordem do prefixo é fixa:
tools, depoissystem, depoismessages. Um breakpoint no fim dosystemcobre as tools também. - O prefixo precisa ser idêntico byte a byte. Qualquer caractere diferente antes do breakpoint gera cache miss.
- 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.
- O TTL padrão é de 5 minutos, renovado a cada hit. Existe opção de TTL de 1 hora, com custo de escrita maior.
- 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.
- Você pode usar até 4 breakpoints por requisição.
A requisição fica assim:
{ "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:
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. Aqui o foco é o formato do payload.
O código é simples. O que quebra o cache é o conteúdo. Os erros que mais vejo:
- 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.
- Tools em ordem instável. Se a lista de tools vem de uma query sem
orderByou de um array montado condicionalmente, a ordem muda e o prefixo muda junto. Ordene explicitamente. - 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
systemem dois blocos: regras gerais primeiro (iguais para todos) com um breakpoint, dados do estabelecimento depois com outro. - Trocar de modelo no meio da conversa. Cache é por modelo. Roteamento dinâmico entre modelos joga o cache fora.
- Serialização diferente do mesmo conteúdo. Um float formatado de outro jeito ou um campo opcional que às vezes vem
nulle à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:
$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.
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:
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:
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();:
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:
- A ordem dos resultados não é garantida. Use sempre o
custom_idpara casar resposta e registro. Ele aceita letras, números, hífen e underscore, até 64 caracteres. - Resultados com tipo
erroredouexpirednão são cobrados. Limpar ollm_batch_iddevolve a transação para o próximo lote, sem lógica de retry separada. - Valide o que o modelo devolveu. O
Categoria::tryFromgarante que só valores do enum entram no banco. O resto vai para revisão humana em vez de virar dado sujo. - O
whereNull('category')no update torna o processamento idempotente: se o job rodar duas vezes sobre o mesmo resultado, nada muda. - 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.
- 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.
- 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_tokensde 50 é suficiente e funciona como trava contra o modelo resolver explicar o raciocínio. - 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.
- Estime antes de mudar o prompt. O endpoint
POST /v1/messages/count_tokensrecebe 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
- Grave os quatro campos de
usagepor feature e por modelo desde o primeiro dia. - Coloque
cache_controlno fim do prefixo estável (tools e regras gerais) em toda feature com prompt longo. - Tire data, hora e qualquer dado volátil de antes do breakpoint.
- Ordene tools e normalize os dados que entram no prompt.
- Mova para a Batch API tudo que não tem usuário esperando.
- Escolha o modelo por feature com base em evals, não por hábito.
- Limite
max_tokense 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.