🎁 Nouveau Inscription gratuite, 10 appels offerts. Jusqu'à 1 $, sans carte.
Votre gateway LLM ment-il sur le cache ? Audit en 5 minutes

Votre gateway LLM ment-il sur le cache ? Audit en 5 minutes

Sommaire
  1. Quatre façons pour un gateway de mentir sur le cache
  2. Deux mécanismes de cache, un seul audit
  3. Contrôle 1 : le cache s’active-t-il ?
  4. Contrôle 2 : le coût tient-il compte de la remise ?
  5. Contrôle 3 : le décompte des tokens est-il cohérent ?
  6. Contrôle 4 : le streaming conserve-t-il les métadonnées ?
  7. Contrôle 5 : le contrôle négatif
  8. Interpréter le tableau de résultats
  9. Conclusion
  10. 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 > 0 alors 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éfaillanceCe que vous voyezCe qui se passe réellement
Absence de cache invisibleUne réponse normale, sans erreurRien n’a été mis en cache ; chaque appel est facturé au plein tarif
Cache de façadecached_tokens > 0 dans la réponse…mais le coût facturé correspond au plein tarif des tokens d’entrée
Marge qui absorbe la remiseUn coût qui paraît plausibleLa marge du gateway absorbe discrètement la remise
Métadonnées absentesUne sortie texte normaleLes 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 sous cache_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 à froidcache à chaudrésultat
deepseek-v4-flash07,552 / 7,870 (96%)RÉUSSI
claude-opus-4-8012,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 à froidcoût à chaudremiserésultat
deepseek-v4-flash$0.00107$0.0003072.3%RÉUSSI
claude-opus-4-8$0.07112$0.0067290.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 cachetotal du promptrésultat
deepseek-v4-flash7,5527,870RÉUSSI
claude-opus-4-812,44612,454RÉ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 streamingcoût en streamingrésultat
deepseek-v4-flashconservéconservéRÉUSSI
claude-opus-4-8conservé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 Apréfixe unique Brésultat
deepseek-v4-flashcache 0cache 0RÉUSSI
claude-opus-4-8cache 0cache 0RÉ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ôleRésultat normalSignal d’alerte
1. activation du cache0 à froid, >0 à chaud après plusieurs tentativestoujours 0 après plusieurs appels à chaud au-dessus de la taille minimale
2. remise appliquée au coûtcoût à chaud ≪ coût à froidcached > 0, mais coûts identiques
3. comptabilisation des tokenscached ≤ prompt_total, comptes cohérentsles totaux ne correspondent pas
4. métadonnées en streamingcache et coût conservés dans le streamdonnées d’usage absentes des appels en streaming
5. contrôle négatifun préfixe unique produit toujours un missun 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.

← Retour au blog