Votre gateway LLM ment-il sur le cache ? Audit en 5 minutes
Sommaire
- Quatre façons pour un gateway de mentir sur le cache
- Deux mécanismes de cache, un seul audit
- Contrôle 1 : le cache s’active-t-il ?
- Contrôle 2 : le coût tient-il compte de la remise ?
- Contrôle 3 : le décompte des tokens est-il cohérent ?
- Contrôle 4 : le streaming conserve-t-il les métadonnées ?
- Contrôle 5 : le contrôle négatif
- Interpréter le tableau de résultats
- Conclusion
- FAQ
Un gateway se place entre votre code et le fournisseur du modèle. La réponse contient cached_tokens, la valeur est plus faible, et vous partez du principe que l’économie annoncée est réelle. Mais vous ne voyez jamais l’appel upstream. Le gateway peut signaler un cache hit tout en facturant les tokens d’entrée au plein tarif. Il peut aussi ne rien mettre en cache derrière une réponse parfaitement normale. Sur les requêtes en streaming, qui représentent souvent l’essentiel du trafic en production, il peut supprimer les métadonnées d’usage et vous empêcher de vérifier quoi que ce soit.
TL;DR
- Une alerte publiée sur Hacker News signalait qu’un gateway populaire renvoyait 2 à 3 fois moins de tokens en cache pour DeepSeek V4 qu’un appel direct à DeepSeek.
- Un seul script exécutable effectue cinq contrôles : activation du cache, baisse réelle du coût, cohérence du décompte des tokens, conservation des métadonnées d’usage en streaming et absence de hit sur le contrôle négatif.
- Lors de l’audit via le gateway Synthorai, deepseek-v4-flash a affiché un taux de cache hit à chaud de 96% et une baisse du coût par appel de 72.3% ; claude-opus-4-8 a obtenu respectivement 99.9% et 90.6%.
- Si
cached_tokens > 0alors que le coût à froid et à chaud est identique, le gateway signale un hit sans appliquer le tarif correspondant.
Ce cas est bien réel. Une alerte publiée sur Hacker News indiquait qu’en passant par un gateway populaire, DeepSeek V4 renvoyait 2 à 3 fois moins de tokens en cache qu’avec un appel direct à DeepSeek. Un commentaire montrait même des factures où les statistiques de cache n’étaient pas du tout remontées par le gateway. L’équipe du gateway a répondu qu’elle ne parvenait pas à reproduire le problème et qu’elle enquêtait. Ce désaccord résume le problème. Quand deux parties ne s’accordent pas sur le fonctionnement de votre cache, seule une mesure effectuée par vos soins permet de trancher.
Il ne s’agit généralement pas de malveillance, mais d’un écart de conversion entre API ou d’un chemin de code incomplet. Sur la facture, le résultat reste le même. Cet article propose un seul script exécutable pour auditer les deux formes de prompt caching sur n’importe quel gateway, y compris celui-ci : le cache automatique de DeepSeek et le cache par marqueurs de Claude. En moins de cinq minutes, il affiche un tableau comparatif.
Quatre façons pour un gateway de mentir sur le cache
| Mode de défaillance | Ce que vous voyez | Ce qui se passe réellement |
|---|---|---|
| Absence de cache invisible | Une réponse normale, sans erreur | Rien n’a été mis en cache ; chaque appel est facturé au plein tarif |
| Cache de façade | cached_tokens > 0 dans la réponse | …mais le coût facturé correspond au plein tarif des tokens d’entrée |
| Marge qui absorbe la remise | Un coût qui paraît plausible | La marge du gateway absorbe discrètement la remise |
| Métadonnées absentes | Une sortie texte normale | Les champs d’usage sont supprimés, surtout en streaming, ce qui rend l’audit impossible |
Les deux premiers cas sont les plus dangereux : la réponse donne l’impression que le cache fonctionne. Vous ne découvrez le problème qu’à la fin du mois.
Deux mécanismes de cache, un seul audit
Les fournisseurs proposent deux formes de cache, et un vrai gateway doit transmettre fidèlement les deux :
- Automatique (DeepSeek, GPT, Gemini, Qwen) : le fournisseur met lui-même en cache tout préfixe suffisamment long. Aucun marqueur n’est nécessaire. Les hits apparaissent dans
usage.prompt_tokens_details.cached_tokens. - Par marqueurs (Anthropic Claude) : vous marquez les segments à mettre en cache avec
cache_control. Les hits apparaissent souscache_read_input_tokens.
Le script masque cette différence derrière un petit adaptateur Lane, puis exécute les cinq contrôles sur les deux mécanismes. Voici le script complet : deux lanes et une fonction audit() qui réalise tous les contrôles.
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))
La suite de l’article détaille chaque contrôle : les lignes qui l’implémentent, les résultats des deux lanes et la façon de les interpréter.
Contrôle 1 : le cache s’active-t-il ?
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 à froid | cache à chaud | résultat | |
|---|---|---|---|
deepseek-v4-flash | 0 | 7,552 / 7,870 (96%) | RÉUSSI |
claude-opus-4-8 | 0 | 12,446 / 12,454 (99.9%) | RÉUSSI |
Un appel à froid avec un préfixe unique ne doit rien lire dans le cache ; un appel répété doit produire un hit. La fausse alerte la plus courante consiste à conclure à l’absence de cache après un seul appel à chaud, car le cache n’est pas toujours lisible immédiatement. La boucle réessaie plusieurs fois avec une pause de 1 seconde, ce qui élimine cette instabilité. Si vous obtenez toujours 0 après plusieurs appels à chaud sur un prompt dépassant la taille minimale, environ 1,024 tokens chez la plupart des fournisseurs avec une correspondance plus fine par blocs de 64 chez DeepSeek, le cache ne s’active réellement pas.
Contrôle 2 : le coût tient-il compte de la remise ?
disc = (1 - warm["cost"]/cold["cost"])*100 if cold["cost"] and warm["cost"] else None
r["check2"] = disc is not None and disc > 30
| coût à froid | coût à chaud | remise | résultat | |
|---|---|---|---|---|
deepseek-v4-flash | $0.00107 | $0.00030 | 72.3% | RÉUSSI |
claude-opus-4-8 | $0.07112 | $0.00672 | 90.6% | RÉUSSI |
Ce contrôle détecte le cache de façade. Le coût de l’appel à chaud doit réellement baisser. Le coût total par appel de DeepSeek a diminué d’environ 72% : les tokens d’entrée en cache bénéficient d’une remise plus importante, mais les tokens de sortie et le reste des tokens d’entrée hors cache réduisent la baisse globale. Chez Claude, la lecture du cache bénéficie d’une remise d’environ 90%. Le signal d’échec est sans ambiguïté : si cached_tokens > 0 alors que le coût à froid et à chaud est identique, le gateway signale un hit sans appliquer le tarif correspondant. Vous payez le plein tarif pour un cache qui ne « fonctionne » que dans les statistiques.
Contrôle 3 : le décompte des tokens est-il cohérent ?
r["check3"] = warm["prompt_total"] is None or warm["cached"] <= warm["prompt_total"]
| en cache | total du prompt | résultat | |
|---|---|---|---|
deepseek-v4-flash | 7,552 | 7,870 | RÉUSSI |
claude-opus-4-8 | 12,446 | 12,454 | RÉUSSI |
La valeur cached doit être comprise dans le total du prompt, le reste étant facturé comme tokens d’entrée hors cache. Les comptes sont cohérents dans les deux cas. Si cached_tokens dépasse prompt_tokens, ou si le reste hors cache est anormalement élevé pour un préfixe stable, le gateway comptabilise mal les tokens : il retokenise ou effectue un double comptage quelque part lors de la conversion entre API.
Contrôle 4 : le streaming conserve-t-il les métadonnées ?
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 en streaming | coût en streaming | résultat | |
|---|---|---|---|
deepseek-v4-flash | conservé | conservé | RÉUSSI |
claude-opus-4-8 | conservé | conservé | RÉUSSI |
La plupart des chats en production utilisent le streaming. C’est donc le chemin le plus important. Sur les deux lanes, le signal de cache hit et le coût sont conservés dans le stream. cached_tokens et cost arrivent dans le dernier chunk d’usage, ce qui permet d’auditer le chemin qui concentre le plus de trafic. Un gateway qui supprime les données d’usage en streaming présente un risque majeur : si les tokens arrivent normalement, mais sans cached_tokens ni cost, vous n’avez aucune visibilité sur votre principal chemin d’exécution. (Passez stream_options={"include_usage": True} pour que le chunk d’usage soit émis.)
Contrôle 5 : le contrôle négatif
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
| préfixe unique A | préfixe unique B | résultat | |
|---|---|---|---|
deepseek-v4-flash | cache 0 | cache 0 | RÉUSSI |
claude-opus-4-8 | cache 0 | cache 0 | RÉUSSI |
Envoyez un préfixe unique à chaque appel : il ne doit jamais produire de hit. Pour des préfixes distincts, les deux lanes ont correctement renvoyé cached=0 au plein tarif. Un « hit » dans ce test indiquerait un faux positif et rendrait les données de cache inutilisables. C’est ce contrôle négatif propre qui donne du sens aux résultats positifs des contrôles 1 et 2.
Interpréter le tableau de résultats
| Contrôle | Résultat normal | Signal d’alerte |
|---|---|---|
| 1. activation du cache | 0 à froid, >0 à chaud après plusieurs tentatives | toujours 0 après plusieurs appels à chaud au-dessus de la taille minimale |
| 2. remise appliquée au coût | coût à chaud ≪ coût à froid | cached > 0, mais coûts identiques |
| 3. comptabilisation des tokens | cached ≤ prompt_total, comptes cohérents | les totaux ne correspondent pas |
| 4. métadonnées en streaming | cache et coût conservés dans le stream | données d’usage absentes des appels en streaming |
| 5. contrôle négatif | un préfixe unique produit toujours un miss | un préfixe distinct produit un « hit » |
Les contrôles 2 et 1 sont ceux qui augmentent la facture sans signal visible : plein tarif malgré un hit annoncé pour le premier, et absence de cache derrière une réponse normale pour le second. Exécutez-les pour chaque modèle facturé.
Conclusion
Dans une application LLM, le cache est l’un des moyens les plus efficaces de réduire les coûts. Son bon fonctionnement doit donc être testé, pas supposé. Intégrez les contrôles 1 et 2 à la CI pour chaque modèle facturé. Déclenchez une alerte si la remise sort de la plage attendue. Vous détecterez ainsi une régression silencieuse le jour même où un gateway ou un fournisseur upstream change de comportement, au lieu de la découvrir à la fin du cycle de facturation. Dans tous les cas, réessayez la lecture à chaud avant de conclure qu’un cache est défaillant.
Pour comprendre les mécanismes derrière ces chiffres, notamment le prefill, le KV cache et les TTL, commencez par Comprendre le KV cache et les TTL. Pour des implémentations de cache par fournisseur, consultez le tutoriel.
FAQ
Mon contrôle 1 renvoie 0 sur l’appel à chaud. Mon gateway ment-il ?
Vérifiez d’abord trois points. (1) Votre prompt dépasse-t-il la taille minimale requise par le fournisseur pour le cache, soit environ 1,024 tokens dans la plupart des cas et une granularité plus fine de 64 tokens chez DeepSeek ? (2) Avez-vous réessayé la lecture à chaud plusieurs fois ? Le cache n’est pas toujours lisible dès l’appel suivant. (3) Le préfixe est-il strictement identique au niveau des octets entre les appels, sans timestamp ni identifiant propre à la requête au début ? Ne soupçonnez le gateway qu’après avoir vérifié ces trois points.
Combien me coûte concrètement un « cache de façade » ? Vous payez le plein tarif des tokens d’entrée à chaque appel tout en pensant n’en payer qu’une fraction. Sur un endpoint très sollicité avec un long préfixe stable, la facture peut atteindre plusieurs fois le montant prévu par votre modèle de coûts. C’est le contrôle 2 qui doit déclencher une alerte.
Pourquoi la remise de DeepSeek est-elle inférieure à celle de Claude dans cet exemple ? Les métriques ne portent pas sur la même chose. Les quelque 90% de Claude correspondent à la remise sur la lecture des tokens d’entrée en cache. Les quelque 72% de DeepSeek représentent la baisse du coût total par appel : les tokens de sortie et le reste hors cache restent facturés au plein tarif, ce qui réduit la baisse globale. Pour vos propres prompts, comparez des métriques équivalentes.
Cela fonctionne-t-il aussi avec GPT, Gemini et Qwen ?
Oui. Tous utilisent le cache automatique ; vous pouvez donc conserver AutoLane tel quel et changer uniquement le model. Seul Claude nécessite MarkerLane. Les cinq contrôles restent identiques.
Faut-il intégrer cet audit à la CI ? Oui. Planifiez les contrôles 1 et 2 pour chaque modèle facturé et déclenchez une alerte lorsque la remise observée sort de la plage attendue. Un audit permanent transforme une régression silencieuse en notification.