O prompt caching do Claude permite que a API reaproveite um prefixo do prompt já processado, como o system prompt, a lista de tools e o histórico da conversa. A leitura desse prefixo custa uma fração do preço normal de entrada e responde mais rápido. No Laravel, basta marcar os blocos certos com cache_control, manter o começo do prompt idêntico entre chamadas e conferir no usage da resposta se o cache foi lido ou escrito.
No final deste tutorial você vai ter um cliente HTTP para a API do Claude que aplica cache em três camadas: tools, system prompt e histórico da conversa. Também vai ter um log de uso que mostra a taxa de acerto e um comando Artisan para validar tudo no terminal. É o tipo de ajuste que faço em qualquer agente de IA que atende WhatsApp, porque esse cenário tem exatamente o perfil que o cache adora: um prompt grande e fixo, repetido em cada mensagem do cliente.
Pré-requisitos:
- Laravel 10, 11 ou 12 com PHP 8.2+.
- Uma chave da API da Anthropic (
ANTHROPIC_API_KEY). - Nenhum pacote extra. Vou chamar a API por HTTP com o
Http::do Laravel, assim você vê exatamente o JSON enviado. - Um prompt que valha a pena cachear. Existe um tamanho mínimo de prefixo para o cache funcionar, na casa de 1024 tokens em vários modelos e maior em outros. Confira o valor do seu modelo na documentação da Anthropic.
Como funciona o prompt caching do Claude?
A API processa o prompt sempre na mesma ordem: tools, depois system, depois messages. Quando você coloca "cache_control": {"type": "ephemeral"} em um bloco, está criando um breakpoint, ou seja, um ponto de corte. Tudo do início do prompt até ele vira uma entrada de cache. Na próxima chamada, se o prefixo for byte a byte igual, a API lê do cache em vez de reprocessar.
As regras que importam na prática:
| Item | Comportamento |
|---|---|
| Escrita no cache (TTL 5 min) | Custa 1,25x o preço normal de entrada |
| Escrita no cache (TTL 1 h) | Custa 2x o preço normal de entrada |
| Leitura do cache | Custa 0,1x o preço normal de entrada |
| Renovação | Cada leitura renova o TTL |
| Breakpoints | No máximo 4 por requisição |
| Invalidação | Qualquer mudança antes do breakpoint invalida o cache dali em diante |
A conta fecha rápido. Uma escrita custa 25% a mais, e cada leitura seguinte economiza 90%. Se o prefixo for reaproveitado pelo menos uma vez dentro do TTL, você já sai no lucro. Para simular com o volume do seu produto, use a calculadora de custo de IA, que compara o custo com e sem prompt caching.
1. Configurar a chave e o modelo no Laravel
No .env:
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_MODEL=claude-sonnet-4-5 ANTHROPIC_TIMEOUT=60
Em config/services.php:
'anthropic' => [ 'key' => env('ANTHROPIC_API_KEY'), 'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-5'), 'version' => '2023-06-01', 'timeout' => (int) env('ANTHROPIC_TIMEOUT', 60), ],
O cache é por modelo. Se você troca de modelo no meio do caminho, o cache começa do zero. Isso vale também para quem roteia entre modelos: cada um tem o próprio cache.
2. Organizar o prompt: o que é fixo vem primeiro
Esse é o passo que mais pesa no resultado, e não tem código de API nele. O cache só funciona sobre um prefixo idêntico. Então o prompt precisa ser montado do mais estável para o mais volátil:
- Tools: definições das ferramentas do agente, que quase nunca mudam.
- System estático: instruções, regras de negócio, políticas, exemplos.
- Breakpoint.
- System dinâmico: data de hoje, nome do cliente, dados do estabelecimento.
- Histórico da conversa, com um breakpoint móvel na última mensagem.
O erro clássico é começar o system prompt com algo como Hoje é 14/03/2025 10:32. Você está atendendo Maria.... Isso muda a cada chamada e invalida tudo o que vem depois. Mova essas linhas para um segundo bloco de system, depois do breakpoint.
Para o texto estático, prefiro arquivo versionado no repositório, como descrevi em como versionar prompts no Laravel. Um bônus é que o conteúdo fica estável por definição: só muda quando alguém faz deploy.
resources/prompts/atendimento/system.md
3. Criar o montador de prompt com cache_control
Essa classe monta tools, system e messages já com os breakpoints no lugar certo.
<?php namespace App\Ai; class CachedPrompt { private const CACHE = ['type' => 'ephemeral']; /** * Ordena as tools por nome (ordem determinística) e marca a última. */ public static function tools(array $tools): array { if ($tools === []) { return []; } usort($tools, fn ($a, $b) => strcmp($a['name'], $b['name'])); $last = array_key_last($tools); $tools[$last]['cache_control'] = self::CACHE; return $tools; } /** * Bloco estático com breakpoint + bloco dinâmico sem cache. */ public static function system(string $static, ?string $dynamic = null): array { $blocks = [[ 'type' => 'text', 'text' => $static, 'cache_control' => self::CACHE, ]]; if ($dynamic !== null && $dynamic !== '') { $blocks[] = ['type' => 'text', 'text' => $dynamic]; } return $blocks; } /** * Breakpoint móvel no último bloco da última mensagem. */ public static function messages(array $messages): array { $i = array_key_last($messages); if ($i === null) { return $messages; } $content = $messages[$i]['content']; if (is_string($content)) { $content = [['type' => 'text', 'text' => $content]]; } $j = array_key_last($content); $content[$j]['cache_control'] = self::CACHE; $messages[$i]['content'] = $content; return $messages; } }
Aqui uso três breakpoints: última tool, system estático e última mensagem. Fica dentro do limite de quatro. Dois detalhes importam:
- Ordenar as tools parece bobagem, mas se a lista vem de um
collect()->filter()ou de um registro montado em ordem variável, o JSON muda e o cache nunca bate. - O breakpoint da conversa precisa ser móvel. Monte o histórico sempre a partir do banco, sem marcações salvas, e marque só a última mensagem. Assim a API acha o maior prefixo já cacheado da chamada anterior e cacheia o novo trecho. Se você persistir o histórico com
cache_controle só ir acrescentando, os breakpoints se acumulam e a requisição passa de quatro.
4. Criar o cliente da API do Claude
<?php namespace App\Ai; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class ClaudeClient { public function send(array $tools, array $system, array $messages, int $maxTokens = 1024): array { $payload = [ 'model' => config('services.anthropic.model'), 'max_tokens' => $maxTokens, 'system' => $system, 'messages' => $messages, ]; if ($tools !== []) { $payload['tools'] = $tools; } $response = Http::withHeaders([ 'x-api-key' => config('services.anthropic.key'), 'anthropic-version' => config('services.anthropic.version'), ]) ->timeout(config('services.anthropic.timeout')) ->retry(2, 1000) ->post('https://api.anthropic.com/v1/messages', $payload) ->throw() ->json(); $this->logUsage($response['usage'] ?? []); return $response; } private function logUsage(array $usage): void { $input = $usage['input_tokens'] ?? 0; $write = $usage['cache_creation_input_tokens'] ?? 0; $read = $usage['cache_read_input_tokens'] ?? 0; $total = $input + $write + $read; Log::channel('ai')->info('claude.usage', [ 'model' => config('services.anthropic.model'), 'input_tokens' => $input, 'cache_write' => $write, 'cache_read' => $read, 'output_tokens' => $usage['output_tokens'] ?? 0, 'hit_ratio' => $total > 0 ? round($read / $total, 3) : 0, ]); } }
Repare na conta do hit_ratio. Com cache ativo, input_tokens traz só os tokens depois do último breakpoint lido ou escrito, não o total. O total de entrada é a soma dos três campos. Quem já mede custo por tenant precisa ajustar a fórmula: cada campo tem preço diferente. Detalhei a contabilidade por cliente em como medir tokens e limitar o custo de IA por cliente no Laravel, e lá basta aplicar os multiplicadores de 1,25x (ou 2x) e 0,1x a esses campos.
Crie o canal ai em config/logging.php:
'ai' => [ 'driver' => 'daily', 'path' => storage_path('logs/ai.log'), 'days' => 14, ],
5. Usar no agente de atendimento
Um exemplo de serviço que responde uma mensagem do WhatsApp:
<?php namespace App\Ai; use App\Models\Conversation; class AtendimentoAgent { public function __construct(private ClaudeClient $claude) {} public function reply(Conversation $conversation): array { $static = file_get_contents(resource_path('prompts/atendimento/system.md')); $dynamic = sprintf( "Data/hora atual: %s\nEstabelecimento: %s\nCliente: %s", now('America/Sao_Paulo')->format('d/m/Y H:i'), $conversation->business->name, $conversation->customer_name, ); $history = $conversation->messages() ->orderBy('id') ->get() ->map(fn ($m) => ['role' => $m->role, 'content' => $m->content]) ->all(); return $this->claude->send( tools: CachedPrompt::tools(config('ai.tools.atendimento')), system: CachedPrompt::system($static, $dynamic), messages: CachedPrompt::messages($history), ); } }
O bloco dinâmico fica depois do breakpoint do system. Então a data mudando a cada minuto não estraga o cache de tools e instruções. Ela só impede o reaproveitamento do histórico, que vem depois dela, entre minutos diferentes.
Se o histórico estiver crescendo demais, o cache ajuda no custo, mas não resolve a janela de contexto. Para isso, combine com resumo e fatos, como mostrei em como gerenciar a memória de um agente de IA no Laravel. Um cuidado: cada vez que o resumo é regenerado, o prefixo muda. Regenere em blocos, não a cada mensagem.
No loop de tool use, o ganho aparece ainda mais. Cada iteração reenvia tools, system e todo o histórico, agora acrescido do tool_use e do tool_result. Com o breakpoint móvel, cada volta do loop lê do cache quase tudo o que a anterior escreveu.
6. Testar com um comando Artisan
<?php namespace App\Console\Commands; use App\Ai\CachedPrompt; use App\Ai\ClaudeClient; use Illuminate\Console\Command; class AiCacheCheck extends Command { protected $signature = 'ai:cache-check'; protected $description = 'Envia o mesmo prompt duas vezes e mostra o uso de cache'; public function handle(ClaudeClient $claude): int { $static = file_get_contents(resource_path('prompts/atendimento/system.md')); $messages = [['role' => 'user', 'content' => 'Quais horários vocês têm amanhã?']]; foreach ([1, 2] as $round) { $response = $claude->send( tools: [], system: CachedPrompt::system($static), messages: CachedPrompt::messages($messages), maxTokens: 50, ); $u = $response['usage']; $this->line(sprintf( 'Rodada %d: input=%d write=%d read=%d', $round, $u['input_tokens'], $u['cache_creation_input_tokens'] ?? 0, $u['cache_read_input_tokens'] ?? 0, )); } return self::SUCCESS; } }
Rode:
php artisan ai:cache-check
O esperado é a primeira rodada com write maior que zero e read zerado, e a segunda com read alto e write zerado. Se as duas mostrarem write=0 read=0, o prefixo está abaixo do mínimo de tokens do modelo. Veja a seção de erros abaixo.
7. Quando usar o TTL de 1 hora?
O TTL padrão é de 5 minutos, renovado a cada leitura. Ele serve bem para conversa ativa: o cliente responde em segundos ou poucos minutos. Para tráfego espaçado, como um agente interno consultado algumas vezes por hora, o cache expira entre chamadas e você paga a escrita toda vez. Nesse caso, use o TTL de 1 hora no bloco estável:
{ "type": "text", "text": "...instruções fixas...", "cache_control": {"type": "ephemeral", "ttl": "1h"} }
A escrita de 1 hora custa 2x a entrada, contra 1,25x do padrão. Ela compensa quando o intervalo entre chamadas passa de 5 minutos, mas o mesmo prefixo ainda é usado algumas vezes na hora. Se misturar TTLs na mesma requisição, os blocos de 1 hora precisam vir antes dos de 5 minutos. Na dúvida sobre a disponibilidade no seu modelo ou conta, confira a documentação de prompt caching da Anthropic antes de subir.
Erros comuns
cache_creation_input_tokenssempre zero. O prefixo até o breakpoint está abaixo do mínimo do modelo. A API não dá erro, só ignora o cache. Junte mais conteúdo estável antes do breakpoint, como exemplos e políticas, ou aceite que aquele prompt é pequeno demais para valer a pena.- Escreve no cache toda vez e nunca lê. Tem algo mudando no prefixo: timestamp no system, ID de request, ordem das tools,
json_encodede um array associativo montado em ordem diferente, ou espaço em branco gerado por template Blade. Compare dois payloads consecutivos comdiff. Normalmente o culpado aparece na hora. - Erro 400 por excesso de breakpoints. Você persistiu
cache_controlno histórico e está reenviando. Salve as mensagens limpas e aplique o breakpoint só no momento de montar a requisição. - Chamadas paralelas sem hit. A entrada do cache só fica disponível depois que a primeira resposta começa a ser gerada. Se você dispara 20 jobs ao mesmo tempo com o mesmo prefixo, todos podem pagar a escrita. Faça uma chamada de aquecimento, espere a resposta e só depois libere o resto na fila.
- Cache do histórico some ao mudar
tool_choiceou incluir imagens. Alterartool_choiceou adicionar e remover imagens entre chamadas invalida o cache das mensagens. Mantenha esses parâmetros estáveis dentro de uma conversa. - Custo calculado errado no painel interno. Somar só
input_tokenssubestima o consumo, e cobrar tudo pelo preço cheio superestima. Use os três campos, cada um com seu multiplicador.
Perguntas frequentes
Preciso de algum header beta para usar prompt caching?
Não. Hoje o prompt caching funciona na Messages API com o header anthropic-version: 2023-06-01 e o campo cache_control nos blocos. Se a documentação indicar header extra para algum recurso específico, como um TTL novo, siga o que estiver lá.
O cache é compartilhado entre meus clientes?
O cache é isolado por organização da API. Dentro dela, qualquer requisição com o mesmo prefixo reaproveita a entrada. Por isso, deixar dados de cliente fora do prefixo cacheado tem dupla vantagem: aumenta o acerto e mantém o trecho compartilhado livre de dado pessoal.
Prompt caching funciona junto com a Batch API?
Sim, os descontos se somam. Mas os itens de um batch são processados em ordem e momento que você não controla, então o acerto de cache costuma ser menor do que no tráfego síncrono. O TTL de 1 hora ajuda nesse cenário.
O cache muda a resposta do modelo?
Não. O cache guarda o processamento do prefixo, não a resposta. A saída é gerada normalmente a cada chamada, então não há risco de devolver a resposta de outro usuário. Isso é bem diferente de um cache de respostas.
Vale cachear um system prompt pequeno?
Se ele está abaixo do mínimo do modelo, não tem como. Se está pouco acima e as chamadas são espaçadas, a escrita a 1,25x pode custar mais do que economiza. O cache brilha com prefixos de milhares de tokens reusados várias vezes por minuto, que é exatamente o caso de um agente com muitas tools e uma conversa longa.
Por onde começar amanhã
Antes de mexer em qualquer código, adicione o log de cache_creation_input_tokens e cache_read_input_tokens que já existe no usage. Depois, mova tudo o que é dinâmico para depois do primeiro breakpoint e ordene as tools. Em agentes com muitas tools e histórico longo, isso costuma bastar para o hit_ratio subir e a fatura cair visivelmente. O TTL de 1 hora e o aquecimento de cache antes de rajadas ficam para quando os números mostrarem que ainda tem desperdício.