# Como usar prompt caching do Claude no Laravel para baratear o agente de IA

> Passo a passo para ativar o prompt caching da API do Claude no Laravel com cache_control, organizar o prompt e medir no usage se o cache realmente está sendo usado.

Por Lucas Roberto · IA · publicado em 2026-10-31 · 10 min de leitura
Versão web: https://lucasroberto.com/blog/como-usar-prompt-caching-do-claude-no-laravel-para-baratear-o-agente-de-ia

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](/ferramentas/calculadora-custo-ia), que compara o custo com e sem prompt caching.

## 1. Configurar a chave e o modelo no Laravel

No `.env`:

```dotenv
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-sonnet-4-5
ANTHROPIC_TIMEOUT=60
```

Em `config/services.php`:

```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](/blog/como-versionar-prompts-no-laravel-arquivos-no-repositorio-rollout-gradual-e-rollback). Um bônus é que o conteúdo fica estável por definição: só muda quando alguém faz deploy.

```text
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
<?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
<?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](/blog/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`:

```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
<?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](/blog/como-gerenciar-a-memoria-de-um-agente-de-ia-no-laravel-janela-resumo-e-fatos). 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
<?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:

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

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

---

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