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:

  1. Laravel 10, 11 ou 12 com PHP 8.2+.
  2. Uma chave da API da Anthropic (ANTHROPIC_API_KEY).
  3. Nenhum pacote extra. Vou chamar a API por HTTP com o Http:: do Laravel, assim você vê exatamente o JSON enviado.
  4. 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:

  1. Tools: definições das ferramentas do agente, que quase nunca mudam.
  2. System estático: instruções, regras de negócio, políticas, exemplos.
  3. Breakpoint.
  4. System dinâmico: data de hoje, nome do cliente, dados do estabelecimento.
  5. 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_control e 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

  1. cache_creation_input_tokens sempre 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.
  2. Escreve no cache toda vez e nunca lê. Tem algo mudando no prefixo: timestamp no system, ID de request, ordem das tools, json_encode de um array associativo montado em ordem diferente, ou espaço em branco gerado por template Blade. Compare dois payloads consecutivos com diff. Normalmente o culpado aparece na hora.
  3. Erro 400 por excesso de breakpoints. Você persistiu cache_control no histórico e está reenviando. Salve as mensagens limpas e aplique o breakpoint só no momento de montar a requisição.
  4. 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.
  5. Cache do histórico some ao mudar tool_choice ou incluir imagens. Alterar tool_choice ou adicionar e remover imagens entre chamadas invalida o cache das mensagens. Mantenha esses parâmetros estáveis dentro de uma conversa.
  6. Custo calculado errado no painel interno. Somar só input_tokens subestima 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.