Il tuo gateway LLM mente sulla cache? Un audit in 5 minuti
Indice
- Quattro modi in cui un gateway può mentire sulla cache
- Due meccanismi di cache, un solo audit
- Verifica 1: la cache si attiva davvero?
- Verifica 2: il costo riflette lo sconto?
- Verifica 3: i conteggi dei token tornano?
- Verifica 4: lo streaming preserva i metadati?
- Check 5: il controllo negativo
- Come leggere la tua scorecard
- Chiusura
- 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 > 0con 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 guasto | Cosa vedi | Cosa succede davvero |
|---|---|---|
| No-cache silenzioso | Risposta pulita, nessun errore | Non è stato messo nulla in cache; paghi il prezzo pieno a ogni chiamata |
| Teatro della cache | cached_tokens > 0 nella risposta | …ma il costo fatturato è la tariffa di input piena |
| Markup strisciante | Un costo plausibile | Il markup del gateway si mangia lo sconto senza farsi notare |
| Blackout dei metadati | Output di testo pulito | I 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 comecache_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 cached | warm cached | risultato | |
|---|---|---|---|
deepseek-v4-flash | 0 | 7,552 / 7,870 (96%) | PASS |
claude-opus-4-8 | 0 | 12,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 cost | warm cost | sconto | risultato | |
|---|---|---|---|---|
deepseek-v4-flash | $0.00107 | $0.00030 | 72.3% | PASS |
claude-opus-4-8 | $0.07112 | $0.00672 | 90.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"]
| cached | prompt total | risultato | |
|---|---|---|---|
deepseek-v4-flash | 7,552 | 7,870 | PASS |
claude-opus-4-8 | 12,446 | 12,454 | PASS |
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 cached | stream cost | risultato | |
|---|---|---|---|
deepseek-v4-flash | preservato | preservato | PASS |
claude-opus-4-8 | preservato | preservato | PASS |
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_tokens né cost 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 A | unique-prefix B | risultato | |
|---|---|---|---|
deepseek-v4-flash | cached 0 | cached 0 | PASS |
claude-opus-4-8 | cached 0 | cached 0 | PASS |
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
| Check | Risultato sano | Segnale d’allarme |
|---|---|---|
| 1. la cache si attiva | 0 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 sconto | costo a caldo ≪ costo a freddo | cached > 0 ma costi uguali |
| 3. conteggio dei token | cached ≤ prompt_total, torna | i conteggi non tornano |
| 4. metadati in streaming | cache + costo sopravvivono allo stream | usage assente sulle chiamate in streaming |
| 5. controllo negativo | il prefisso univoco fa sempre miss | un 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.