Novità Registrati gratis, 10 chiamate le offriamo noi. Fino a $1, senza carta.
Il tuo gateway LLM mente sulla cache? Verificalo in 5 minuti

Il tuo gateway LLM mente sulla cache? Verificalo in 5 minuti

Indice
  1. Quattro modi in cui un gateway può mentire sulla cache
  2. Due meccanismi di cache, un solo audit
  3. Controllo 1: la cache si attiva?
  4. Controllo 2: il costo applica lo sconto?
  5. Controllo 3: i conteggi dei token tornano?
  6. Controllo 4: lo streaming conserva i metadati?
  7. Controllo 5: il controllo negativo
  8. Come leggere il riepilogo
  9. Conclusione
  10. FAQ

Un gateway si trova tra il tuo codice e il provider del modello. Nella risposta leggi cached_tokens, vedi un numero più basso e dai per scontato che il risparmio sia reale. Ma non vedi mai la chiamata upstream. Il gateway potrebbe segnalare un cache hit e addebitare comunque l’intero costo dell’input. Potrebbe non usare affatto la cache, pur restituendo una risposta apparentemente corretta. Oppure potrebbe eliminare i metadati di utilizzo durante lo streaming, proprio sul percorso seguito dalla maggior parte del traffico di produzione, impedendoti qualsiasi verifica.

TL;DR

  • Un avviso su Hacker News ha segnalato che un gateway molto diffuso restituiva per DeepSeek V4 un numero di token in cache 2-3 volte inferiore rispetto alle chiamate dirette a DeepSeek.
  • Un unico script eseguibile effettua cinque controlli: attivazione della cache, effettiva riduzione dei costi, coerenza del conteggio dei token, conservazione dei metadati di utilizzo durante lo streaming e corretto funzionamento di un controllo negativo.
  • Nel test tramite il gateway Synthorai, deepseek-v4-flash ha registrato un cache-hit rate a caldo del 96% e una riduzione del costo per chiamata del 72.3%; claude-opus-4-8 ha raggiunto rispettivamente il 99.9% e il 90.6%.
  • cached_tokens > 0 con lo stesso costo a freddo e a caldo significa che il gateway segnala un hit senza applicarne il prezzo.

Non è un caso ipotetico. Un avviso su Hacker News ha segnalato che, passando DeepSeek V4 attraverso un gateway molto diffuso, venivano restituiti 2-3× meno token in cache rispetto a una chiamata diretta a DeepSeek; un utente ha pubblicato fatture da cui risultava che le statistiche della cache non venivano riportate affatto dal gateway. Il team del gateway ha risposto di non essere riuscito a riprodurre il problema e di aver avviato un’indagine. È proprio questo il punto. Se due parti non concordano sul funzionamento della cache, l’unico dato affidabile è una misurazione eseguita da te.

Di solito non c’è malafede. Più spesso manca una corretta conversione tra API o un percorso del codice non è ancora completo. Per la fattura, il risultato non cambia. Questo articolo presenta un unico script eseguibile che verifica entrambe le modalità di prompt caching, automatica (DeepSeek) e basata su marker (Claude), con qualsiasi gateway, incluso questo. In meno di cinque minuti stampa un riepilogo comparativo.


Quattro modi in cui un gateway può mentire sulla cache

Modalità di erroreCosa vediCosa succede davvero
Cache silenziosamente assenteUna risposta corretta, senza erroriNon viene memorizzato nulla; ogni chiamata costa il prezzo pieno
Cache di facciatacached_tokens > 0 nella risposta…ma il costo addebitato corrisponde all’intera tariffa di input
Markup crescenteUn costo apparentemente plausibileIl markup del gateway assorbe di nascosto lo sconto
Metadati assentiOutput testuale correttoI campi di utilizzo vengono rimossi, soprattutto durante lo streaming, quindi non puoi verificare nulla

I primi due casi sono i più pericolosi: dalla risposta sembra che la cache funzioni. Scopri il problema solo a fine mese.


Due meccanismi di cache, un solo audit

I provider espongono la cache in due modi e un vero gateway deve supportarli entrambi senza alterazioni:

  • Automatica (DeepSeek, GPT, Gemini, Qwen): il provider memorizza autonomamente qualsiasi prefisso abbastanza lungo. Non servono marker. Gli hit compaiono in usage.prompt_tokens_details.cached_tokens.
  • Basata su marker (Anthropic Claude): contrassegni le porzioni memorizzabili con cache_control. Gli hit compaiono come cache_read_input_tokens.

Lo script nasconde questa differenza dietro un semplice adapter Lane, quindi esegue tutti e cinque i controlli su entrambe le modalità. Il codice completo è qui: due lane e una sola funzione audit() che esegue ogni controllo.

import os, time, uuid
from openai import OpenAI
from anthropic import Anthropic

KEY  = os.environ["GATEWAY_KEY"]
oai  = OpenAI(api_key=KEY,    base_url="https://synthorai.io/v1")   # auto lane
anth = Anthropic(api_key=KEY, base_url="https://synthorai.io/")     # marker lane

class AutoLane:      # DeepSeek / GPT / Gemini / Qwen: provider caches automatically
    mode = "auto"
    def __init__(self, model): self.model = model
    def call(self, sys, q, stream=False):
        if stream:
            cached = cost = None
            s = oai.chat.completions.create(model=self.model, max_tokens=48, stream=True,
                stream_options={"include_usage": True},
                messages=[{"role":"system","content":sys},{"role":"user","content":q}])
            for ev in s:
                if ev.usage:
                    d = ev.usage.prompt_tokens_details
                    cached, cost = (d.cached_tokens if d else None), getattr(ev.usage,"cost",None)
            return {"cached": cached or 0, "cost": cost, "prompt_total": None}
        u = oai.chat.completions.create(model=self.model, max_tokens=48,
            messages=[{"role":"system","content":sys},{"role":"user","content":q}]).usage
        cached = u.prompt_tokens_details.cached_tokens if u.prompt_tokens_details else 0
        return {"cached": cached or 0, "cost": u.cost, "prompt_total": u.prompt_tokens}

class MarkerLane:    # Anthropic Claude: explicit cache_control markers
    mode = "marker"
    def __init__(self, model): self.model = model
    def call(self, sys, q, stream=False):
        block = {"type":"text","text":sys,"cache_control":{"type":"ephemeral"}}
        if stream:
            with anth.messages.stream(model=self.model, max_tokens=48, system=[block],
                    messages=[{"role":"user","content":q}]) as s:
                for _ in s.text_stream: pass
                u = s.get_final_message().usage.model_dump()
            return {"cached": u.get("cache_read_input_tokens") or 0,
                    "cost": u.get("cost"), "prompt_total": None}
        u = anth.messages.create(model=self.model, max_tokens=48, system=[block],
            messages=[{"role":"user","content":q}]).usage.model_dump()
        read, created = u.get("cache_read_input_tokens",0), u.get("cache_creation_input_tokens",0)
        return {"cached": read, "cost": u.get("cost"),
                "prompt_total": u.get("input_tokens",0) + read + created}

def audit(lane, long_prompt):
    SYS = f"[audit {uuid.uuid4().hex}]\n\n" + long_prompt    # unique => guaranteed cold start
    r = {"lane": lane.model, "mode": lane.mode}

    # CHECK 1: cache engages. Cold misses; a repeat should hit. A cache can
    # take a moment to become readable, so poll the warm read (sleep 1s between
    # attempts) before concluding "no cache".
    cold = lane.call(SYS, "Q1")
    warm = cold
    for i in range(4):
        warm = lane.call(SYS, f"warm {i}")
        if warm["cached"] > 0: break
        time.sleep(1.0)
    r["cold"], r["warm"] = cold, warm
    r["check1"] = cold["cached"] == 0 and warm["cached"] > 0

    # CHECK 2: cost reflects the discount (catches "cache theater").
    disc = (1 - warm["cost"]/cold["cost"])*100 if cold["cost"] and warm["cost"] else None
    r["discount"], r["check2"] = disc, (disc is not None and disc > 30)

    # CHECK 3: token accounting. cached fits inside the prompt total.
    r["check3"] = warm["prompt_total"] is None or warm["cached"] <= warm["prompt_total"]

    # CHECK 4: streaming preserves usage metadata (cache count AND cost).
    st = lane.call(SYS, "stream", stream=True)
    r["stream_cached"], r["stream_cost"] = st["cached"] > 0, st["cost"] is not None
    r["check4"] = r["stream_cached"] and r["stream_cost"]

    # CHECK 5: negative control. a unique prefix must always miss.
    n1 = lane.call(f"[uniq {uuid.uuid4().hex}]\n\n"+long_prompt, "x")
    n2 = lane.call(f"[uniq {uuid.uuid4().hex}]\n\n"+long_prompt, "y")
    r["check5"] = n1["cached"] == 0 and n2["cached"] == 0
    return r

# Any long, STABLE text works as the cacheable prefix: a system prompt, tool
# schemas, or a retrieved document. It only needs to clear the provider's
# minimum cacheable size (see Check 1). Load yours however you like.
LONG_SYSTEM_PROMPT = open("system_prompt.txt").read()   # ~8K+ tokens

for lane in [AutoLane("deepseek-v4-flash"), MarkerLane("claude-opus-4-8")]:
    print(audit(lane, LONG_SYSTEM_PROMPT))

Nel resto dell’articolo analizzeremo ogni controllo: le righe che lo implementano, i risultati restituiti dalle due lane e come interpretarli.


Controllo 1: la cache si attiva?

cold = lane.call(SYS, "Q1")
warm = cold
for i in range(4):                       # poll: a cache may take a beat to be readable
    warm = lane.call(SYS, f"warm {i}")
    if warm["cached"] > 0: break
    time.sleep(1.0)
r["check1"] = cold["cached"] == 0 and warm["cached"] > 0
cache a freddocache a caldorisultato
deepseek-v4-flash07,552 / 7,870 (96%)SUPERATO
claude-opus-4-8012,446 / 12,454 (99.9%)SUPERATO

Una chiamata a freddo con un prefisso univoco non deve trovare nulla in cache; ripetendo la chiamata, deve verificarsi un hit. Il falso allarme più comune è dichiarare che la cache non funziona dopo una sola chiamata a caldo, perché i dati non sono sempre leggibili immediatamente. Il ciclo riprova alcune volte, con una pausa di 1 secondo, eliminando questa instabilità. Se dopo più chiamate a caldo ottieni ancora 0 con un prompt superiore alla dimensione minima (~1,024 token per la maggior parte dei provider; DeepSeek esegue il matching con granularità più fine, pari a 64), la cache non si sta davvero attivando.


Controllo 2: il costo applica lo sconto?

disc = (1 - warm["cost"]/cold["cost"])*100 if cold["cost"] and warm["cost"] else None
r["check2"] = disc is not None and disc > 30
costo a freddocosto a caldoscontorisultato
deepseek-v4-flash$0.00107$0.0003072.3%SUPERATO
claude-opus-4-8$0.07112$0.0067290.6%SUPERATO

Questo controllo rileva la cache di facciata. Il costo della chiamata a caldo deve diminuire davvero. Il totale per chiamata di DeepSeek è sceso di ~72%: lo sconto sull’input memorizzato è maggiore, ma l’output e la parte di input non in cache riducono la percentuale complessiva. Per Claude, la lettura dalla cache costa ~90% in meno. Il segnale di errore è inequivocabile: cached_tokens > 0 con costi a freddo e a caldo identici significa che il gateway segnala un hit senza applicare lo sconto. Stai pagando il prezzo pieno per una cache che “funziona” solo sulla carta.


Controllo 3: i conteggi dei token tornano?

r["check3"] = warm["prompt_total"] is None or warm["cached"] <= warm["prompt_total"]
in cachetotale promptrisultato
deepseek-v4-flash7,5527,870SUPERATO
claude-opus-4-812,44612,454SUPERATO

cached deve essere incluso nel totale del prompt; la parte restante viene addebitata come input non in cache. In entrambi i casi i conti tornano. Se cached_tokens supera prompt_tokens, oppure la parte non in cache è poco plausibile per un prefisso stabile, il gateway sta conteggiando male: durante la conversione potrebbe eseguire nuovamente la tokenizzazione o contare due volte alcuni token.


Controllo 4: lo streaming conserva i metadati?

st = lane.call(SYS, "stream", stream=True)
r["stream_cached"], r["stream_cost"] = st["cached"] > 0, st["cost"] is not None
r["check4"] = r["stream_cached"] and r["stream_cost"]
cache nello streamcosto nello streamrisultato
deepseek-v4-flashconservatoconservatoSUPERATO
claude-opus-4-8conservatoconservatoSUPERATO

La maggior parte delle chat in produzione usa lo streaming, quindi questo è il percorso più importante. In entrambe le lane, sia il segnale di cache hit sia il costo rimangono disponibili nello stream. cached_tokens e cost arrivano nel chunk finale con i dati di utilizzo, perciò anche il percorso con più traffico resta verificabile. Bisogna fare attenzione ai gateway che eliminano i dati di utilizzo durante lo streaming: un output corretto ma privo di cached_tokens o cost ti impedisce di controllare proprio il percorso usato più spesso. (Passa stream_options={"include_usage": True} per fare in modo che il chunk con i dati di utilizzo venga emesso.)


Controllo 5: il controllo negativo

n1 = lane.call(f"[uniq {uuid.uuid4().hex}]\n\n"+long_prompt, "x")
n2 = lane.call(f"[uniq {uuid.uuid4().hex}]\n\n"+long_prompt, "y")
r["check5"] = n1["cached"] == 0 and n2["cached"] == 0
prefisso univoco Aprefisso univoco Brisultato
deepseek-v4-flashcache 0cache 0SUPERATO
claude-opus-4-8cache 0cache 0SUPERATO

Invia un prefisso univoco a ogni chiamata: non deve mai produrre un hit. Per prefissi distinti, entrambe le lane hanno restituito correttamente cached=0 al costo pieno. Un “hit” in questo caso renderebbe inaffidabile qualsiasi dato sulla cache. Un controllo negativo corretto è ciò che rende significativi i risultati positivi dei controlli 1-2.


ControlloRisultato correttoSegnale d’allarme
1. attivazione della cache0 a freddo, >0 a caldo (dopo più tentativi)0 dopo più chiamate a caldo, oltre la dimensione minima
2. applicazione dello scontocosto a caldo ≪ costo a freddocached > 0 ma costi uguali
3. conteggio dei tokencached ≤ prompt_total, conti coerentii conteggi non tornano
4. metadati nello streamingcache e costo presenti nello streamdati di utilizzo assenti nelle chiamate in streaming
5. controllo negativoun prefisso univoco produce sempre un missun prefisso distinto produce un “hit”

I due problemi che aumentano i costi senza farsi notare sono il 2 (prezzo pieno nonostante l’hit dichiarato) e l’1 (nessuna cache dietro una risposta apparentemente corretta). Esegui entrambi su ogni modello che ti viene fatturato.


Conclusione

La cache è il modo più efficace per ridurre i costi di un’applicazione LLM. Proprio per questo, il suo funzionamento va verificato, non dato per scontato. Integra i controlli 1 e 2 nella CI per ogni modello che ti viene fatturato e genera un alert se lo sconto esce dall’intervallo previsto. In questo modo rileverai subito una regressione silenziosa causata da un cambiamento nel gateway o nel provider upstream, invece di scoprirla alla fine del ciclo di fatturazione. E in qualsiasi audit, riprova la lettura a caldo prima di dichiarare che la cache non funziona.

Per capire i meccanismi alla base di questi numeri, come prefill, KV cache e TTL, parti da Come funzionano KV cache e TTL. Per esempi pratici di caching specifici per ogni provider, consulta il tutorial.


FAQ

Nel controllo 1 ottengo 0 sulla chiamata a caldo. Il gateway sta mentendo? Verifica prima tre cose. (1) Il prompt supera la dimensione minima memorizzabile dal provider (~1,024 token per la maggior parte dei provider; DeepSeek esegue il matching con granularità più fine, pari a 64 token)? (2) Hai riprovato la lettura a caldo alcune volte? La cache non è sempre leggibile già alla chiamata immediatamente successiva. (3) Il prefisso è identico byte per byte tra le chiamate, senza timestamp o ID specifici della richiesta all’inizio? Solo dopo queste tre verifiche ha senso sospettare del gateway.

Quanto mi costa concretamente una “cache di facciata”? Paghi la tariffa piena dell’input per ogni chiamata, pur credendo di pagarne solo una frazione. Su un endpoint ad alto volume con un lungo prefisso stabile, la fattura può essere parecchie volte superiore alle stime. Il controllo 2 è quello su cui configurare un alert.

Perché qui lo sconto di DeepSeek è inferiore a quello di Claude? Le due percentuali misurano grandezze diverse. Il ~90% di Claude è lo sconto sulla lettura dell’input memorizzato. Il ~72% di DeepSeek è la riduzione del totale per chiamata: l’output e la parte non in cache vengono fatturati alla tariffa piena e riducono la percentuale complessiva. Per il tuo prompt, confronta metriche omogenee.

Funziona anche con GPT, Gemini e Qwen? Sì. Usano tutti la cache automatica, quindi basta riutilizzare AutoLane cambiando il model. Solo Claude richiede MarkerLane. I cinque controlli restano gli stessi.

Conviene eseguirlo nella CI? Sì. Esegui periodicamente i controlli 1 e 2 su ogni modello che ti viene fatturato e genera un alert quando lo sconto osservato esce dall’intervallo previsto. Un audit permanente trasforma una regressione silenziosa in una notifica.

← Torna al blog