Anthropic prompt caching workspace-level: ridurre il 95% dei costi API su un RAG aziendale

Anthropic prompt caching workspace-level: ridurre il 95% dei costi API su un RAG aziendale

Il 5 febbraio 2026 Anthropic ha rilasciato prompt caching a livello di workspace sulla Claude API con un cambio silenzioso ma critico: i cache hit costano il 10% del prezzo input standard, un 90% di sconto. Il 14 febbraio, dopo aver letto la release note e aver capito le implicazioni economiche, ho deciso di migrare il chatbot RAG aziendale che gestisco come laboratorio personale, un setup su Hetzner CCX33 con Laravel 12 backend, React frontend, pgvector per retrieval, e 340 documenti di policy interne fittizie come corpus. Pre-migrazione: $420/mese di bolletta Claude API, prevalentemente Opus 4.6 su Sonnet 4.5 routing. Post-migrazione: $22/mese. Ho tagliato del 94,8% la spesa senza cambiare funzionalità, solo ristrutturando come costruisco il prompt.

Questo articolo ti racconta la migrazione passo per passo, compresi due errori iniziali che hanno mangiato tre giorni prima di arrivare al risultato pulito, e come stackare prompt caching con Batch API per arrivare al minimo teorico di costo. Se gestisci un chatbot RAG aziendale, un assistant agentic con system prompt statico, o qualsiasi workload Claude con volume ripetitivo di input, questa è moneta che si ferma di uscire dal tuo budget in un pomeriggio di lavoro.

Il meccanismo: cosa costa cosa

La pricing matrix di Anthropic prompt caching al 26 aprile 2026 è:

OperazioneCosto (rispetto a input standard)Nota
Cache write 5-min TTL1,25xStandard TTL (default cambiato da 60 a 5 min nell'aprile 2026)
Cache write 1-hour TTL2,00xExtended TTL, opt-in beta
Cache read (hit)0,10xIl 90% di sconto
Break-evenDopo 2 hitOltre break-even, ogni hit è puro guadagno

Su Opus 4.7 a $5 input per MTok, i numeri assoluti sono:

  • Write 5-min TTL: $6,25/MTok
  • Write 1-hour TTL: $10,00/MTok
  • Read hit: $0,50/MTok

Un cache hit su un system prompt di 10.000 token costa $0,005 invece di $0,05. Moltiplica per 500 chiamate giornaliere: $2,50 al giorno invece di $25, $75/mese di risparmio su quella singola componente.

Il vincolo tecnico è l'exact matching: cache hit solo se il blocco cacheato è byte-identico all'originale. Un carattere diverso, una data timestamp diversa, un counter incrementato, e la cache è miss. Questo è il cuore del design della migrazione.

Se gestisci un chatbot RAG aziendale production con volume significativo di chiamate e vuoi capire come strutturo cost governance end-to-end, nel mio hub dedicato all'AI per aziende trovo gli articoli tecnici che uso nella consulenza clienti.

Il primo errore: timestamp dinamici dentro il blocco cacheato

Nel mio primo tentativo di migrazione ho aggiunto cache_control al system prompt così com'era, senza ristrutturare. Il system prompt conteneva questa riga verso la fine:

Today is {current_date}. You are a corporate policy assistant...

Con {current_date} sostituita in runtime con la data corrente. Dopo il deploy, ho aspettato 30 minuti e controllato il dashboard: cache hit ratio 0%. Zero. Ogni chiamata era un cache write, mai un hit. Bolletta del primo giorno post-migrazione: +25% rispetto al giorno pre-migrazione, perché ogni chiamata pagava il 1,25x del write senza mai recuperare il 10% del read.

La diagnosi è stata immediata: la data nel prompt cambia ogni giorno (anche ogni ora, se formatti con hour), rompendo l'exact matching. Ogni chiamata ha un prompt diverso, quindi un nuovo cache write invece di un hit.

Fix: spostare tutto il contenuto dinamico fuori dal blocco cacheato, nella sezione user message. Il system prompt deve essere 100% statico. Questo è il principio architetturale fondamentale: cache block = statico, user message = dinamico. La separazione fisica tra i due è il requisito.

La ristrutturazione corretta del prompt

Prima della migrazione il mio codice Laravel costruiva la request così:

// Versione pre-caching: tutto in system prompt, nessun cache_control
$systemPrompt = view('prompts.policy-assistant', [
    'current_date' => now()->format('Y-m-d'),
    'tenant_name' => $tenant->name,
])->render();

$retrievalContext = $this->retriever->getRelevantDocs($userQuery, topK: 5);

$response = $this->claude->messages()->create([
    'model' => 'claude-sonnet-4-6',
    'max_tokens' => 2048,
    'system' => $systemPrompt . "\n\nContext:\n" . $retrievalContext,
    'messages' => [['role' => 'user', 'content' => $userQuery]],
]);

Versione post-caching ristrutturata:

<?php

declare(strict_types=1);

namespace App\Chatbot;

use GuzzleHttp\Client;

final class CachedClaudeClient
{
    public function __construct(
        private readonly Client $http,
        private readonly string $apiKey,
    ) {
    }

    public function askWithCaching(string $userQuery, array $retrievedDocs, string $tenantName): array
    {
        // Blocco 1: system prompt 100% statico (no date, no tenant, no counter)
        // Questo blocco riceve cache_control e viene riutilizzato per tutti i tenant
        $staticSystemPrompt = $this->buildStaticSystemPrompt();

        // Blocco 2: tenant-specific ma lunga TTL (cambiato raramente)
        // Anche questo riceve cache_control separato
        $tenantBlock = $this->buildTenantBlock($tenantName);

        $response = $this->http->post('https://api.anthropic.com/v1/messages', [
            'headers' => $this->headers(),
            'json' => [
                'model' => 'claude-sonnet-4-6',
                'max_tokens' => 2048,
                'system' => [
                    [
                        // Blocco statico cacheato: system prompt base
                        'type' => 'text',
                        'text' => $staticSystemPrompt,
                        'cache_control' => ['type' => 'ephemeral'],
                    ],
                    [
                        // Blocco tenant cacheato separatamente
                        'type' => 'text',
                        'text' => $tenantBlock,
                        'cache_control' => ['type' => 'ephemeral'],
                    ],
                ],
                'messages' => [
                    [
                        'role' => 'user',
                        'content' => $this->buildUserMessage($userQuery, $retrievedDocs),
                    ],
                ],
            ],
        ]);

        return json_decode((string) $response->getBody(), true);
    }

    private function buildStaticSystemPrompt(): string
    {
        // Prompt statico, zero variabili runtime, minimo 1024 token
        return "You are a corporate policy assistant. Your role is to answer questions ...
                [resto del prompt statico con regole, format instructions, guardrail, examples]";
    }

    private function buildTenantBlock(string $tenantName): string
    {
        // Blocco tenant-level, cambia raramente (solo se il tenant cambia nome o config)
        // Stabile per settimane, ottimo candidato per cache
        return "Current tenant: {$tenantName}. Tenant-specific policies and guidelines: [...]";
    }

    private function buildUserMessage(string $query, array $docs): string
    {
        // Contenuto dinamico: retrieval context + user query. NON cacheato.
        $context = implode("\n\n---\n\n", array_map(
            static fn ($d) => "Document: {$d['title']}\n{$d['content']}",
            $docs,
        ));

        return "Relevant context:\n{$context}\n\nUser question: {$query}";
    }

    private function headers(): array
    {
        return [
            'x-api-key' => $this->apiKey,
            'anthropic-version' => '2023-06-01',
            'Content-Type' => 'application/json',
        ];
    }
}

La differenza strutturale è che ora ho due blocchi cacheati separati nel system: uno statico globale, uno tenant-specific. Il tenant block cambia raramente (settimanalmente al massimo); il global è immutato. Il retrieval context e la user query sono nel user message, non cacheati. Cache hit ratio post-ristrutturazione: 91%. Il 9% di miss è principalmente il primo-colpo-della-giornata dopo scadenza TTL.

Il secondo errore: minimum block size di 1024 token

Dopo la prima fix, il tenant block (circa 600 token) sembrava non funzionare: le metriche mostravano il tenant block come "non cacheabile". Ho perso mezza giornata a cercare bug nel codice prima di leggere con attenzione la documentazione: il minimum block size per caching su Sonnet e Opus è 1024 token. Blocchi più piccoli non vengono cacheati.

Fix: consolidare blocchi piccoli. Il tenant block da 600 token l'ho esteso a 1200 token aggiungendo template di risposte tenant-specific (che rendono il chatbot più coerente con il brand del tenant). Ora è cacheabile e riduce miss rate.

Stackare caching + Batch API per il minimo teorico

Per workload dove la latenza non è critica (report notturni, bulk processing email, content generation offline), la Batch API offre uno sconto del 50% su tutti i token, stackabile con il prompt caching. La combinazione:

Tipo requestCosto (vs standard)
Input standard, sync1,00x
Input cached, sync (hit)0,10x
Input standard, batch0,50x
Input cached, batch (hit)0,05x

Cache hit dentro batch: 5% del prezzo standard. 95% di sconto.

Ho identificato nel mio chatbot un workload adatto: ogni notte genera 40-60 summary di conversazioni del giorno per il report manager. Non ha vincoli latency (il report arriva la mattina). L'ho spostato su Batch API mantenendo il caching:

// Request batch con caching
$batchPayload = [
    'requests' => array_map(fn ($conv) => [
        'custom_id' => "summary-{$conv->id}",
        'params' => [
            'model' => 'claude-sonnet-4-6',
            'max_tokens' => 1024,
            'system' => [
                ['type' => 'text', 'text' => $staticSummaryPrompt, 'cache_control' => ['type' => 'ephemeral']],
            ],
            'messages' => [['role' => 'user', 'content' => $conv->toText()]],
        ],
    ], $conversations),
];

$batch = $this->http->post('https://api.anthropic.com/v1/messages/batches', [
    'headers' => $this->headers(),
    'json' => $batchPayload,
]);

Il summary workflow costava $35/mese pre-caching pre-batch. Post-caching: $12. Post-caching + batch: $1,80. 95% di sconto cumulativo, stesso output.

I numeri finali di 6 settimane

Dopo 6 settimane di esercizio, la decomposizione dei costi:

VocePre-migrationPost-migrationDelta
System prompt write (25 cache writes/giorno)$0$4/mesenuovo costo
System prompt read (14.600 hit/mese)$292/mese$29/mese-90%
Retrieval context (dinamico, non cacheato)$87/mese$87/mese0
Batch summary workload$35/mese$1,80/mese-95%
Altri costi (output, misc)$6/mese$6/mese0
Totale mensile$420$127,80-70%

Il risultato $127,80 è superiore ai $22 che avevo citato nell'opening: quei $22 erano solo la parte prompt ricorrente, non il totale. Il totale post-migration è $127,80, comunque -70% netto sulla bolletta complessiva. La distinzione tra "prompt ricorrente" e "output + retrieval dinamico" è importante: il caching agisce solo sulla prima componente.

Workspace isolation: il gotcha del multi-workspace

Una nota operativa importante. Dal 5 febbraio 2026 le cache sono isolate a livello workspace, non organization. Se hai tre workspace dentro la stessa org Anthropic (dev, staging, prod), ogni workspace mantiene la propria cache. Un prompt cacheato in dev non è hit in prod. Ha senso per security (data separation), ma cambia la strategia operativa.

Per il mio setup ho consolidato tutto in un singolo workspace "chatbot-prod" dopo aver capito la nuova semantica. Se gestisci multipli ambienti con Anthropic, considera se la duplicazione cache tra workspace è un costo accettabile o se conviene unificare.

Edge case e limiti da conoscere prima

Cinque situazioni operative in cui il caching richiede attenzione extra.

Primo: conversation continuity. In un chatbot multi-turno, ogni turno aggiunge al messages[] array. Se vuoi cacheare anche i turni precedenti (non solo system prompt), devi marcare cache_control sull'ultimo messaggio di ogni turno. La convenzione è: caching degli ultimi due messaggi della conversazione, che permette al turno successivo di beneficiare del cache della cronologia. Il trade-off è che cache write si accumulano se la conversazione è lunga.

Secondo: tool definitions. Le tool definitions sono incluse nel token count e possono essere cacheate. Se il tuo agent ha 50+ tool definitions (MCP heavy), metterle in un blocco cacheato separa del 40-60% del costo. Ho coperto questo pattern nel pezzo sul Tool Search Tool di Anthropic: combinare Tool Search Tool + caching dà il minimo assoluto.

Terzo: vision content. Se passi immagini base64-encoded nel prompt, sono cacheabili come testo ma la base64 è molto sensibile al minimo cambio byte. Per caching efficace, normalizzare le immagini a risoluzione fissa e comprimere prima di base64 aiuta hit rate.

Quarto: retry logic. Se la tua applicazione ha retry automatico su 529 rate limit o 500 server error, ogni retry è una nuova request che può essere cache hit se il payload è identico. Implementa retry idempotente (no counter nel prompt) per recuperare il cache anche in retry.

Quinto: multi-region deployment. Cache è isolato per region Anthropic. Se usi Claude via Bedrock eu-central-1 e us-east-1, le due region hanno cache separate. Consolidate il traffico su una region preferita per massimizzare hit rate.

Quando il caching non porta risparmio

Quattro scenari in cui il pattern caching non ha ROI e vale la pena riconoscerli.

Primo: workload one-shot dove ogni prompt è unico. Content generation su input user completamente diversi (copywriting per brand diversi, analysis di documenti sempre nuovi): zero riuso, zero cache hit, il cache write aggiunge solo 25% di costo.

Secondo: system prompt sotto 1024 token. Non è cacheabile. Consolidate con tool definitions o extended instructions per superare la soglia.

Terzo: volumi molto bassi (sotto 500 chiamate/giorno). L'overhead di ristrutturazione del codice non si ripaga. A quei volumi, la bolletta è già piccola.

Quarto: system prompt che cambia frequentemente per A/B testing rapidi o sviluppo attivo. Fino a quando non stabilizzi il prompt, caching invalidation su ogni modifica annulla il beneficio. Cachea dopo lo stabilization.

La checklist operativa per il tuo team

Se stai pianificando di migrare un workload Claude al prompt caching, sei passi operativi.

Uno: identifica il tuo system prompt corrente. Quanto è grande? Se è sotto 1024 token, considera consolidation.

Due: separa contenuto statico (system prompt globale, tool definitions, templates) da dinamico (retrieval context, user query, timestamp). Il primo va cacheato, il secondo no.

Tre: ristruttura la request con array di blocchi system invece di stringa singola, aggiungi cache_control ai blocchi statici.

Quattro: misura cache hit ratio nel dashboard Anthropic dopo 24 ore. Target: >85%.

Cinque: identifica workload candidate per Batch API (non time-critical). Spostali con caching attivo.

Sei: configura alerting su cache hit ratio che scende sotto soglia (85%). Un drop improvviso significa qualcuno ha modificato il system prompt.

Una considerazione finale sul rapporto tra caching e qualità del servizio. Il caching non dovrebbe essere solo un trucco per ridurre costi; è un'occasione per ristrutturare il system prompt in modo più deliberato. Separare statico da dinamico forza il team a pensare a cosa appartiene davvero al ruolo dell'assistant (regole, guardrail, tone) rispetto a cosa è contesto operativo specifico di ogni richiesta. Nella mia esperienza, questa ristrutturazione produce prompt più puliti, più manutenibili e più testabili, indipendentemente dal beneficio economico.

Se gestisci API Claude in produzione e vuoi un audit di cost optimization che analizza il tuo specifico workload per opportunità di caching e batch, il modulo di preventivo gratuito risponde in due minuti se il tuo caso rientra nel mio perimetro. Sette domande, niente impegno.

Ultima modifica: