# Prompt caching do Claude no Laravel: como estruturar o prompt do agente para pagar menos

> Como usar cache_control da API do Claude num agente de IA em Laravel: onde pôr os breakpoints, o que quebra o cache sem você perceber e como medir a taxa de acerto.

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

Prompt caching é o recurso da API do Claude que guarda o processamento de um prefixo do prompt (tools, system e começo da conversa) e cobra cerca de 10% do preço normal de input quando a próxima chamada repete esse prefixo exatamente igual. Num agente de IA em Laravel, o ganho depende menos do `cache_control` em si e mais de como você ordena o conteúdo. O que muda pouco vai no começo, o que muda a cada turno vai no fim, e nada dinâmico pode vazar para dentro do prefixo cacheado.

Aprendi isso montando agentes de atendimento no WhatsApp, do tipo que consulta agenda no Horalis ou responde dúvidas sobre cobranças. O padrão se repete: um system prompt grande com regras de negócio, uma dúzia de tools, o contexto do estabelecimento e uma conversa que cresce a cada mensagem. Sem cache, você paga por esses milhares de tokens fixos em toda chamada, e o loop de tool calling faz várias chamadas por mensagem do cliente.

## Como funciona o prompt caching da API do Claude?

A API monta o prompt sempre na mesma ordem: `tools`, depois `system`, depois `messages`. Você marca um bloco com `cache_control: {"type": "ephemeral"}`. Esse bloco vira um breakpoint, ou seja, um ponto de corte que diz à API para cachear tudo do início até ali.

Algumas regras práticas:

1. O cache é por prefixo exato. Um caractere diferente antes do breakpoint e você paga uma escrita nova.
2. Você pode ter até 4 breakpoints por requisição.
3. O TTL padrão é de 5 minutos e é renovado a cada leitura. Existe a opção de 1 hora (`"ttl": "1h"`), com escrita mais cara.
4. Há um tamanho mínimo de prefixo para cachear, que varia por modelo (1024 tokens nos Sonnet, mais nos Haiku). Abaixo disso a API simplesmente não cacheia e não reclama.
5. O cache é isolado por organização na Anthropic, então outro cliente da API nunca lê o seu prefixo.

A cobrança, em relação ao preço base de input do modelo:

| Tipo de token | Multiplicador |
|---|---|
| Input normal (depois do último breakpoint) | 1x |
| Escrita de cache, TTL 5 min | 1,25x |
| Escrita de cache, TTL 1 h | 2x |
| Leitura de cache | 0,1x |

A conta do break-even é simples. Com TTL de 5 minutos, uma escrita mais uma leitura custa 1,35x, contra 2x sem cache. Um único acerto já paga. Com TTL de 1 hora, escrita mais uma leitura dá 2,1x, então você precisa de pelo menos dois acertos dentro da hora. Para simular isso com o seu volume, a [calculadora de custo de IA](/ferramentas/calculadora-custo-ia) compara os modelos com e sem prompt caching.

## Onde colocar os breakpoints num agente de IA?

O erro mais comum é tratar breakpoint como "cachear esta seção". Ele é um marcador de prefixo. Você não precisa de um por seção, e sim de um em cada ponto onde a frequência de mudança muda.

Num agente multi-tenant, as camadas costumam ser estas:

1. **Tools + instruções base**: iguais para todos os clientes, mudam só no deploy.
2. **Contexto do tenant**: serviços, horários e políticas do estabelecimento. Muda raramente e é igual em todas as conversas daquele tenant.
3. **Histórico da conversa**: cresce a cada turno.

Isso dá três breakpoints. Um no fim das instruções base, que já cobre as tools porque elas vêm antes no prefixo. Outro no fim do contexto do tenant. E um na última mensagem. Pôr um breakpoint separado na última tool seria desperdício, já que tools e instruções base mudam juntas.

Em Laravel, chamando a API direto com o `Http::`:

```php
final class AgendaAgentClient
{
    public function __construct(
        private PromptRepository $prompts,
        private ToolRegistry $registry,
    ) {}

    public function send(Tenant $tenant, array $history): array
    {
        $payload = [
            'model' => config('services.anthropic.model'),
            'max_tokens' => 1024,
            'tools' => $this->tools(),
            'system' => [
                [
                    'type' => 'text',
                    'text' => $this->prompts->base(),
                    'cache_control' => ['type' => 'ephemeral'],
                ],
                [
                    'type' => 'text',
                    'text' => $this->prompts->tenantContext($tenant, today()),
                    'cache_control' => ['type' => 'ephemeral'],
                ],
            ],
            'messages' => $this->withCacheOnLastMessage($history),
        ];

        $response = Http::withHeaders([
            'x-api-key' => config('services.anthropic.key'),
            'anthropic-version' => '2023-06-01',
        ])
            ->timeout(60)
            ->retry(2, 500)
            ->post('https://api.anthropic.com/v1/messages', $payload)
            ->throw();

        $this->recordUsage($tenant, $response->json('usage', []));

        return $response->json();
    }
}
```

O breakpoint da conversa vai no último bloco da última mensagem. Se o conteúdo for string, é preciso convertê-lo em array de blocos, porque `cache_control` só existe em bloco:

```php
private function withCacheOnLastMessage(array $messages): array
{
    $last = array_key_last($messages);
    $content = $messages[$last]['content'];

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

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

    return $messages;
}
```

Isso funciona bem no loop de tool calling. Quando a última mensagem é um `tool_result`, o breakpoint cai nele. Na próxima iteração, todo o histórico anterior é lido do cache. A API também procura acertos em posições anteriores ao breakpoint, dentro de uma janela de cerca de 20 blocos. Por isso mover o breakpoint para a nova última mensagem a cada turno continua aproveitando o prefixo da chamada anterior.

## O que quebra o prompt caching sem você perceber?

O cache raramente falha de forma visível. A resposta chega certa, só que mais cara. Os vilões que mais encontro:

| Causa | Por que quebra | Correção |
|---|---|---|
| Data e hora no system prompt | O prefixo muda a cada minuto | Granularidade de dia no system; hora exata na mensagem do usuário |
| Tools em ordem não determinística | Tools são o início do prefixo, e qualquer troca invalida tudo | Ordenar por nome antes de serializar |
| Tools diferentes por plano ou tenant | O prefixo global vira um por tenant | Mesmo conjunto para todos; checar permissão na execução |
| Nome do cliente nas instruções base | O primeiro breakpoint deixa de ser compartilhado | Mover para o bloco do tenant ou para a conversa |
| Janela deslizante de memória | Cortar a mensagem mais antiga muda o prefixo inteiro da conversa | Cortar em lotes, não um turno por vez |
| Deploy de prompt novo | Prefixo novo, escrita em todos os tenants | Esperado; só não faça rollout que alterna versões por requisição |

Vale detalhar três delas.

**Tools determinísticas.** Se o registry monta as tools a partir de classes descobertas pelo container, a ordem pode variar entre workers ou deploys. Ordene sempre:

```php
private function tools(): array
{
    return collect($this->registry->all())
        ->sortBy(fn (Tool $tool) => $tool->name())
        ->map(fn (Tool $tool) => $tool->definition())
        ->values()
        ->all();
}
```

**Tools por plano.** É tentador esconder a tool `emitir_segunda_via` de quem não tem o plano. Mas as tools ficam no topo do prefixo, então cada combinação de tools vira um cache separado. Prefiro mandar o mesmo conjunto para todos e, na execução, devolver um `tool_result` com `is_error: true` explicando que o recurso não está disponível no plano. O modelo lida bem com isso e o prefixo global continua compartilhado.

**Memória e janela de contexto.** Se você limita o histórico às últimas N mensagens e remove uma a cada turno, a primeira mensagem do array muda sempre, e o cache da conversa nunca acerta. A estratégia que funciona é deixar a conversa crescer até um teto e, ao atingi-lo, resumir ou cortar um bloco grande de uma vez. Você paga uma escrita no corte e volta a acertar nos turnos seguintes. Detalhei as estratégias de janela, resumo e fatos 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), e o prompt caching é mais um motivo para preferir cortes em lote.

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

O objeto `usage` da resposta separa os tokens em três campos de input:

- `cache_read_input_tokens`: lidos do cache, cobrados a 0,1x.
- `cache_creation_input_tokens`: escritos no cache nesta chamada, cobrados a 1,25x ou 2x.
- `input_tokens`: o que veio depois do último breakpoint, cobrado a 1x.

Registre os três por chamada:

```php
private function recordUsage(Tenant $tenant, array $usage): void
{
    $read = $usage['cache_read_input_tokens'] ?? 0;
    $write = $usage['cache_creation_input_tokens'] ?? 0;
    $input = $usage['input_tokens'] ?? 0;

    Log::info('claude.usage', [
        'tenant_id' => $tenant->id,
        'input' => $input,
        'cache_write' => $write,
        'cache_read' => $read,
        'output' => $usage['output_tokens'] ?? 0,
        'hit_ratio' => round($read / max(1, $read + $write + $input), 3),
    ]);
}
```

A métrica que importa é a taxa de acerto: leitura sobre o total de input. Num agente com prompt base grande e conversa de vários turnos, ela deve ficar alta. Se aparecer `cache_write` alto em todas as chamadas, algum item da tabela acima está mexendo no prefixo. Se `cache_write` e `cache_read` vierem zerados, o prefixo provavelmente está abaixo do mínimo do modelo. Se você já contabiliza tokens por cliente, como em [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), lembre de precificar cada campo com seu multiplicador. Somar tudo como input normal superestima o custo de quem tem bom acerto.

## Como garantir em teste que o prefixo continua estável?

Como a quebra de cache é silenciosa, transformo a regra em teste. Com `Http::fake`, faço duas chamadas com conversas diferentes para o mesmo tenant e confiro que `tools` e `system` saíram idênticos:

```php
it('mantém o prefixo cacheável estável entre chamadas', function () {
    Http::fake([
        'api.anthropic.com/*' => Http::response([
            'content' => [['type' => 'text', 'text' => 'ok']],
            'stop_reason' => 'end_turn',
            'usage' => ['input_tokens' => 10, 'output_tokens' => 2],
        ]),
    ]);

    $tenant = Tenant::factory()->create();
    $client = app(AgendaAgentClient::class);

    $client->send($tenant, [['role' => 'user', 'content' => 'oi']]);
    $this->travel(3)->minutes();
    $client->send($tenant, [['role' => 'user', 'content' => 'quero marcar amanhã']]);

    $prefixes = Http::recorded()->map(
        fn (array $pair) => md5(json_encode([$pair[0]['tools'], $pair[0]['system']]))
    );

    expect($prefixes->unique())->toHaveCount(1);
});
```

O `travel` pega justamente o caso de alguém colocar `now()->format('H:i')` no system prompt. Esse teste já barrou mais de uma regressão desse tipo em revisão.

## Quando o prompt caching não compensa?

O cache não ajuda em todos os casos:

1. **Tráfego esparso com TTL de 5 minutos.** Um tenant que recebe uma mensagem a cada meia hora paga 1,25x na escrita e nunca lê. O prefixo global, compartilhado por todos os tenants, quase sempre está quente; o do tenant pequeno, não. Avalie se o bloco do tenant merece breakpoint próprio ou se é melhor deixá-lo como input normal.
2. **Rajadas em paralelo.** O cache só fica disponível depois que a primeira resposta começa a ser gerada. Se você dispara 50 jobs simultâneos com o mesmo prefixo, como numa classificação em massa, vários deles escrevem o mesmo cache. Dá para mandar uma chamada na frente para aquecer e só então liberar o resto. Para volume offline, a Batch API costuma ser a ferramenta certa.
3. **TTL de 1 hora sem volume.** Só vale quando você tem certeza de pelo menos duas leituras por hora no mesmo prefixo e o intervalo entre elas passa de 5 minutos, como um agente interno usado ao longo do expediente.
4. **Rollout de prompt alternando versões.** Se metade das requisições usa a versão A e metade a B, você mantém dois caches. É aceitável durante um rollout gradual, como descrevi em [versionar prompts no Laravel](/blog/como-versionar-prompts-no-laravel-arquivos-no-repositorio-rollout-gradual-e-rollback). Só não sorteie a versão por requisição: fixe por tenant ou por conversa.

## Conclusão: o que fazer amanhã no seu agente

Se você já tem um agente em produção, o caminho com melhor retorno é este:

1. Comece a logar `cache_read_input_tokens` e `cache_creation_input_tokens` antes de mudar qualquer coisa. Sem esses números, você não sabe se a mudança ajudou.
2. Reordene o prompt por frequência de mudança: tools e instruções base, depois o contexto do tenant, depois a conversa. Coloque um breakpoint no fim de cada camada e outro na última mensagem.
3. Varra o prefixo atrás de conteúdo dinâmico: hora, nome do cliente, tools condicionais e ordem não determinística. Tire tudo isso do prefixo ou leve para depois do último breakpoint.
4. Troque a janela deslizante de um turno por cortes em lote.
5. Escreva o teste de prefixo estável para que ninguém desfaça isso sem querer daqui a três meses.

O `cache_control` é uma linha de código. O trabalho de verdade é disciplina de ordenação, e ela continua rendendo enquanto o agente estiver no ar.

---

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