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

Il tuo gateway LLM mente sulla cache? Un audit in 5 minuti

Indice
  1. Quattro modi in cui un gateway può mentire sulla cache
  2. Due meccanismi di cache, un solo audit
  3. Verifica 1: la cache si attiva davvero?
  4. Verifica 2: il costo riflette lo sconto?
  5. Verifica 3: i conteggi dei token tornano?
  6. Verifica 4: lo streaming preserva i metadati?
  7. Check 5: il controllo negativo
  8. Come leggere la tua scorecard
  9. Chiusura
  10. FAQ

Un gateway sta tra il tuo codice e il provider del modello. Leggi cached_tokens dalla risposta, vedi un numero più piccolo e ti fidi che i soldi risparmiati siano reali. Ma la chiamata a monte non la vedi mai. Il gateway potrebbe segnalare un cache hit e fatturarti comunque la tariffa di input piena. Potrebbe non fare cache per niente, nascondendolo dietro una risposta perfettamente pulita. Potrebbe eliminare i metadati di usage nello streaming, cioè il percorso su cui gira gran parte del tuo traffico in produzione, lasciandoti senza modo di verificare.

TL;DR

  • Una PSA su Hacker News ha segnalato che un popolare gateway restituiva 2-3 volte meno token in cache per DeepSeek V4 rispetto alla chiamata diretta a DeepSeek.
  • Uno script eseguibile esegue cinque controlli: la cache si attiva, il costo effettivamente diminuisce, i conteggi dei token tornano, lo streaming preserva i metadati di utilizzo e un controllo negativo rimane freddo.
  • Verificato tramite il gateway Synthorai, deepseek-v4-flash ha mostrato un tasso di hit su cache calda del 96% e un calo dei costi per chiamata del 72,3%; claude-opus-4-8 ha mostrato 99,9% e 90,6%.
  • cached_tokens > 0 con costo identico a freddo e a caldo significa che un gateway sta segnalando un hit che non tariffa.

Non è un’ipotesi teorica. Un PSA su Hacker News segnalava che instradare DeepSeek V4 attraverso un gateway molto usato restituiva 2–3× cached token in meno rispetto a chiamare DeepSeek direttamente; un commento riportava fatture in cui le statistiche di caching non venivano proprio riportate dal gateway. Il team del gateway ha risposto che non riusciva a riprodurre il problema e stava indagando. È proprio qui il punto. Quando due parti non riescono a mettersi d’accordo se la tua cache funzioni o no, l’unico arbitro è una misurazione che hai fatto tu.

Di solito non è malafede. È uno scarto di traduzione tra formati o un percorso di codice lasciato a metà. Sulla tua fattura l’effetto è lo stesso in entrambi i casi. Questo post è un unico script eseguibile che controlla entrambi gli stili di prompt caching, automatico (DeepSeek) e basato su marker (Claude), contro qualsiasi gateway, questo incluso. Stampa una pagella affiancata in meno di cinque minuti.


Quattro modi in cui un gateway può mentire sulla cache

Modalità di guastoCosa vediCosa succede davvero
No-cache silenziosoRisposta pulita, nessun erroreNon è stato messo nulla in cache; paghi il prezzo pieno a ogni chiamata
Teatro della cachecached_tokens > 0 nella risposta…ma il costo fatturato è la tariffa di input piena
Markup striscianteUn costo plausibileIl markup del gateway si mangia lo sconto senza farsi notare
Blackout dei metadatiOutput di testo pulitoI campi di usage vengono eliminati (soprattutto in streaming), quindi non puoi verificarli

I due pericolosi sono i primi: la risposta sembra indicare che la cache funzioni. Te ne accorgi a fine mese.


Due meccanismi di cache, un solo audit

I provider espongono il caching in due forme, e un gateway serio deve inoltrarle entrambe fedelmente:

  • Automatica (DeepSeek, GPT, Gemini, Qwen): il provider mette in cache da solo qualsiasi prefisso abbastanza lungo. Nessun marker. Gli hit compaiono in usage.prompt_tokens_details.cached_tokens.
  • Basata su marker (Anthropic Claude): sei tu a marcare le porzioni cacheabili con cache_control. Gli hit compaiono come cache_read_input_tokens.

Lo script nasconde questa differenza dietro un sottile adapter Lane, poi esegue tutti e cinque i controlli su entrambe. Ecco il tutto: due lane e un unico 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))

Il resto del post ripercorre ogni controllo: le righe che lo implementano, cosa hanno restituito entrambe le lane e come leggere il risultato.


Verifica 1: la cache si attiva davvero?

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
cold cachedwarm cachedrisultato
deepseek-v4-flash07,552 / 7,870 (96%)PASS
claude-opus-4-8012,446 / 12,454 (99.9%)PASS

Una chiamata a freddo su un prefisso univoco non deve mettere nulla in cache; una ripetizione deve fare hit. Il falso allarme più comune è dichiarare “nessuna cache” dopo una sola chiamata a caldo, perché le cache non sempre diventano leggibili all’istante. Il loop fa polling qualche volta con una pausa di 1 secondo, e questo elimina la flakiness. Se dopo diverse chiamate a caldo ottieni ancora 0 su un prompt sopra la soglia minima di dimensione (~1.024 token per la maggior parte dei provider; DeepSeek allinea a una granularità più fine di 64), allora la cache non si sta davvero attivando.


Verifica 2: il costo riflette 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
cold costwarm costscontorisultato
deepseek-v4-flash$0.00107$0.0003072.3%PASS
claude-opus-4-8$0.07112$0.0067290.6%PASS

Questa è la verifica che smaschera il teatrino della cache. Il costo della chiamata a caldo deve scendere per davvero. Il totale per chiamata di DeepSeek è calato del ~72% (l’input in cache è scontato più pesantemente; l’output e la parte rimanente non in cache diluiscono il dato di testa). La lettura in cache di Claude sconta il ~90%. Il segnale di fallimento è inequivocabile: cached_tokens > 0 con costo a freddo e a caldo identici significa che il gateway riporta un hit che non sta prezzando. Stai pagando a tariffa piena una cache che “funziona” solo sulla carta.


Verifica 3: i conteggi dei token tornano?

r["check3"] = warm["prompt_total"] is None or warm["cached"] <= warm["prompt_total"]
cachedprompt totalrisultato
deepseek-v4-flash7,5527,870PASS
claude-opus-4-812,44612,454PASS

cached deve rientrare nel totale del prompt, con la parte rimanente fatturata come input non in cache. Entrambi tornano. Se cached_tokens supera prompt_tokens, o se la parte non in cache è implausibilmente grande per un prefisso stabile, il gateway sta contabilizzando male: da qualche parte nella traduzione ri-tokenizza o conta due volte.


Verifica 4: lo streaming preserva 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"]
stream cachedstream costrisultato
deepseek-v4-flashpreservatopreservatoPASS
claude-opus-4-8preservatopreservatoPASS

La maggior parte delle chat in produzione usa lo streaming, quindi questo è il percorso che conta di più. Su entrambe le lane sopravvivono sia il segnale di cache hit sia il costo. cached_tokens e cost arrivano nel chunk finale di usage, così il tuo percorso a più alto volume resta verificabile. Il caso da tenere d’occhio è un gateway che perde i dati di usage in streaming: un output di token pulito ma senza cached_tokenscost significa che stai andando alla cieca proprio sul percorso che usi di più. (Passa stream_options={"include_usage": True} perché il chunk di usage venga emesso.)


Check 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
unique-prefix Aunique-prefix Brisultato
deepseek-v4-flashcached 0cached 0PASS
claude-opus-4-8cached 0cached 0PASS

Invia un prefisso univoco a ogni chiamata: non deve mai fare hit. Entrambe le lane hanno riportato correttamente cached=0 a costo pieno per prefissi diversi. Un “hit” qui renderebbe il report della cache un falso positivo di cui non ti potresti mai fidare. È proprio il controllo negativo pulito a dare senso ai risultati positivi dei Check 1–2.


Come leggere la tua scorecard

CheckRisultato sanoSegnale d’allarme
1. la cache si attiva0 a freddo, >0 a caldo (dopo il polling)0 dopo diverse chiamate a caldo, sopra la soglia minima di dimensione
2. il costo riflette lo scontocosto a caldo ≪ costo a freddocached > 0 ma costi uguali
3. conteggio dei tokencached ≤ prompt_total, tornai conteggi non tornano
4. metadati in streamingcache + costo sopravvivono allo streamusage assente sulle chiamate in streaming
5. controllo negativoil prefisso univoco fa sempre missun prefisso diverso fa “hit”

I due che ti costano soldi in silenzio sono il 2 (prezzo pieno per un hit riportato) e l’1 (nessun caching dietro una risposta pulita). Esegui entrambi su ogni modello che ti viene fatturato.


Chiusura

Il caching è la leva sui costi con il rapporto più alto in un’app LLM, ed è esattamente per questo che “la cache funziona” merita un test, non un’assunzione. Collega Check 1 + Check 2 nella CI per ogni modello che ti viene fatturato, imposta un alert se lo sconto scende sotto la banda che ti aspetti, e coglierai una regressione silenziosa il giorno stesso in cui un gateway o un provider a monte cambia comportamento, invece che a fine ciclo di fatturazione. E qualunque cosa faccia il tuo audit, fai il polling della lettura a caldo prima di dichiarare rotta una cache.

Per la meccanica dietro questi numeri (prefill, KV cache, TTL) parti da Come funzionano KV Cache e TTL. Per pattern di caching funzionanti per ciascun provider, vedi il tutorial.


FAQ

Il mio Check 1 mostra 0 sulla chiamata a caldo. Il mio gateway sta mentendo? Controlla prima tre cose. (1) Il tuo prompt supera la dimensione minima cacheable del provider (~1.024 token per la maggior parte; DeepSeek si allinea con una granularità più fine di 64 token)? (2) Hai fatto il polling della lettura a caldo un paio di volte? Le cache non sempre diventano leggibili proprio alla chiamata successiva. (3) Il prefisso è identico byte per byte tra le chiamate, senza timestamp o ID per-richiesta in testa? Solo dopo aver verificato tutte e tre dovresti sospettare del gateway.

Quanto mi costa in pratica il “cache theater”? Paghi la tariffa piena di input a ogni chiamata mentre credi di pagarne una frazione. Su un endpoint ad alto volume con un prefisso stabile e grande, significa una bolletta diverse volte più alta di quanto avevi modellato. Il Check 2 è quello su cui mettere un alert.

Perché qui lo sconto di DeepSeek è più basso di quello di Claude? Si stanno misurando cose diverse. Il ~90% di Claude è lo sconto in lettura sull’input cached. Il ~72% di DeepSeek è la riduzione totale per chiamata, dove l’output e la parte non cached vengono fatturati a tariffa piena e diluiscono il valore in evidenza. Confronta grandezze omogenee per la forma del tuo prompt.

Funziona anche per GPT, Gemini, Qwen? Sì. Sono tutti automatici, quindi usano l’AutoLane invariata con un model diverso. Solo Claude richiede la MarkerLane. Gli stessi cinque check in entrambi i casi.

Dovrebbe stare nella CI? Sì. Esegui Check 1 + Check 2 per ogni modello che ti viene fatturato, in modo schedulato, e imposta un alert quando lo sconto osservato esce dalla banda che ti aspetti. Un audit permanente trasforma una regressione silenziosa in una notifica.

← Torna al blog