Votre passerelle LLM ment-elle sur le cache ? Un audit en 5 min

Sommaire
  1. Quatre façons pour une passerelle de mentir sur le cache
  2. Deux mécanismes de cache, un seul audit
  3. Vérification 1 : le cache s’enclenche-t-il ?
  4. Vérification 2 : le coût reflète-t-il la réduction ?
  5. Vérification 3 : les décomptes de tokens sont-ils cohérents ?
  6. Vérification 4 : le streaming préserve-t-il les métadonnées ?
  7. Vérification 5 : le contrôle négatif
  8. Lire votre tableau de bord
  9. Conclusion
  10. FAQ

Une passerelle se place entre votre code et le fournisseur du modèle. Vous récupérez cached_tokens dans la réponse, vous voyez un nombre plus petit, et vous faites confiance au fait que les dollars économisés sont réels. Mais vous ne voyez jamais l’appel en amont. La passerelle pourrait signaler un hit de cache tout en facturant le plein tarif d’entrée. Elle pourrait ne rien mettre en cache du tout derrière une réponse parfaitement propre. Elle pourrait supprimer les métadonnées d’usage en streaming, le chemin par lequel passe la majeure partie de votre trafic de production, de sorte que vous ne puissiez rien vérifier dans un sens ou dans l’autre.

Ce n’est pas hypothétique. Un PSA sur Hacker News a rapporté que faire passer DeepSeek V4 par une passerelle populaire renvoyait 2 à 3× moins de tokens mis en cache qu’en appelant DeepSeek directement ; un commentateur a publié des factures montrant que les statistiques de cache n’étaient tout simplement pas remontées via la passerelle. L’équipe de la passerelle a répondu qu’elle ne parvenait pas à reproduire le problème et qu’elle enquêtait. Ce désaccord est précisément le sujet. Lorsque deux parties ne peuvent pas s’accorder sur le fonctionnement de votre cache, le seul arbitre est une mesure que vous avez faite vous-même.

En général, ce n’est pas de la malveillance. C’est un écart de traduction ou un chemin de code inachevé. L’effet sur votre facture est le même dans les deux cas. Cet article propose un seul script exécutable qui audite les deux styles de mise en cache des prompts, automatique (DeepSeek) et à marqueurs (Claude), face à n’importe quelle passerelle, y compris celle-ci. Il affiche un tableau de bord comparatif en moins de cinq minutes.


Quatre façons pour une passerelle de mentir sur le cache

Mode de défaillanceCe que vous voyezCe qui se passe réellement
Non-cache silencieuxUne réponse propre, aucune erreurRien n’a été mis en cache ; vous payez le plein tarif à chaque appel
Théâtre de cachecached_tokens > 0 dans la réponse…mais le coût facturé est le plein tarif d’entrée
Dérive de margeUn chiffre de coût plausibleLa marge de la passerelle grignote discrètement la réduction
Black-out de métadonnéesSortie texte propreChamps d’usage supprimés (surtout en streaming), vous ne pouvez pas auditer

Les dangereux sont les deux premiers : la réponse donne l’impression que le cache fonctionne. Vous le découvrez à la fin du mois.


Deux mécanismes de cache, un seul audit

Les fournisseurs exposent le cache sous deux formes, et une vraie passerelle doit transmettre les deux fidèlement :

  • Automatique (DeepSeek, GPT, Gemini, Qwen) : le fournisseur met en cache tout préfixe suffisamment long de lui-même. Aucun marqueur. Les hits apparaissent dans usage.prompt_tokens_details.cached_tokens.
  • À marqueurs (Anthropic Claude) : vous taguez les portions cachables avec cache_control. Les hits apparaissent sous cache_read_input_tokens.

Le script masque cette différence derrière un fin adaptateur Lane, puis exécute les cinq vérifications sur les deux. Voici l’ensemble : deux voies (lanes) et un audit() qui effectue chaque vérification.

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 parcourt chaque vérification : les lignes qui l’implémentent, ce que les deux voies ont renvoyé, et comment lire le résultat.


Vérification 1 : le cache s’enclenche-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
cold cachedwarm cachedrésultat
deepseek-v4-flash07,552 / 7,870 (96%)PASS
claude-opus-4-8012,446 / 12,454 (99.9%)PASS

Un appel à froid sur un préfixe unique ne doit rien mettre en cache ; une répétition doit faire un hit. La fausse alerte la plus courante est de déclarer « pas de cache » après un seul appel à chaud, car les caches ne deviennent pas toujours lisibles instantanément. La boucle effectue quelques sondages avec une pause d’une seconde, ce qui élimine l’instabilité. Si vous obtenez toujours 0 après plusieurs appels à chaud sur un prompt au-dessus du plancher de taille (~1 024 tokens pour la plupart des fournisseurs ; DeepSeek s’aligne sur une granularité plus fine de 64), le cache ne s’enclenche réellement pas.


Vérification 2 : le coût reflète-t-il la réduction ?

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 costréductionrésultat
deepseek-v4-flash$0.00107$0.0003072.3%PASS
claude-opus-4-8$0.07112$0.0067290.6%PASS

C’est la vérification qui démasque le théâtre de cache. Le coût de l’appel à chaud doit réellement baisser. Le total par appel de DeepSeek a chuté d’environ 72 % (l’entrée mise en cache est réduite plus fortement ; la sortie et le reste non mis en cache diluent le chiffre affiché). La lecture mise en cache de Claude est réduite d’environ 90 %. Le signal de défaillance est sans équivoque : cached_tokens > 0 avec un coût à froid et à chaud identique signifie que la passerelle signale un hit qu’elle ne tarife pas. Vous payez le plein tarif pour un cache qui « fonctionne » sur le papier.


Vérification 3 : les décomptes de tokens sont-ils cohérents ?

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

cached doit tenir dans le total du prompt, le reste étant facturé comme entrée non mise en cache. Les deux se réconcilient. Si cached_tokens dépasse prompt_tokens, ou si le reste non mis en cache est invraisemblablement grand pour un préfixe stable, la passerelle compte mal : retokenisation ou double comptage quelque part dans la traduction.


Vérification 4 : le streaming préserve-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"]
stream cachedstream costrésultat
deepseek-v4-flashpréservépréservéPASS
claude-opus-4-8préservépréservéPASS

La plupart des chats de production utilisent le streaming, c’est donc le chemin qui compte le plus. Sur les deux voies, le signal de hit de cache et le coût survivent tous deux au stream. cached_tokens et cost arrivent dans le dernier chunk d’usage, de sorte que votre chemin le plus volumineux reste auditable. Le mode de défaillance à surveiller est une passerelle qui supprime l’usage en streaming : une sortie de tokens propre sans cached_tokens ni cost signifie que vous naviguez à l’aveugle sur le chemin que vous utilisez le plus. (Passez stream_options={"include_usage": True} pour que le chunk d’usage soit émis.)


Vérification 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-flashcached 0cached 0PASS
claude-opus-4-8cached 0cached 0PASS

Envoyez un préfixe unique à chaque appel ; il ne doit jamais faire de hit. Les deux voies ont correctement signalé cached=0 au plein tarif pour des préfixes distincts. Un « hit » ici ferait du reporting de cache un faux positif auquel vous ne pourriez jamais vous fier. Le contrôle négatif propre est ce qui rend les résultats positifs des vérifications 1 et 2 significatifs en premier lieu.


Lire votre tableau de bord

VérificationRésultat sainSignal d’alarme
1. le cache s’enclenche0 à froid, >0 à chaud (après sondage)0 après plusieurs appels à chaud, au-dessus du plancher de taille
2. le coût reflète la réductioncoût à chaud ≪ coût à froidcached > 0 mais coûts égaux
3. décompte des tokenscached ≤ prompt_total, se réconcilieles décomptes ne collent pas
4. métadonnées en streamingcache + coût survivent au streamusage manquant sur les appels en streaming
5. contrôle négatifun préfixe unique manque toujoursun préfixe distinct fait un « hit »

Les deux qui coûtent de l’argent silencieusement sont la 2 (plein tarif pour un hit signalé) et la 1 (aucun cache derrière une réponse propre). Exécutez les deux sur chaque modèle que vous facturez.


Conclusion

Le cache est le levier de coût le plus puissant d’une application LLM, ce qui est précisément pourquoi « le cache fonctionne » mérite un test, pas une supposition. Branchez la vérification 1 + la vérification 2 dans la CI face à chaque modèle que vous facturez, alertez si la réduction dérive en dessous de votre fourchette attendue, et vous détecterez une régression silencieuse le jour où une passerelle ou un fournisseur en amont change de comportement, au lieu de la découvrir à la fin du cycle de facturation. Et quoi que fasse votre audit, sondez la lecture à chaud avant de déclarer un cache cassé.

Pour les mécanismes derrière ces chiffres (prefill, cache KV, TTL) commencez par Comment fonctionnent le cache KV et le TTL. Pour des patterns de cache fonctionnels par fournisseur, voir le tutoriel.


FAQ

Ma vérification 1 affiche 0 à l’appel à chaud. Ma passerelle ment-elle ? Vérifiez d’abord trois choses. (1) Votre prompt dépasse-t-il la taille minimale cachable du fournisseur (~1 024 tokens pour la plupart ; DeepSeek s’aligne sur une granularité plus fine de 64 tokens) ? (2) Avez-vous sondé la lecture à chaud plusieurs fois ? Les caches ne deviennent pas toujours lisibles dès l’appel suivant. (3) Le préfixe est-il identique octet pour octet entre les appels, sans horodatages ni identifiants par requête en tête ? Ce n’est qu’après ces trois points que vous devriez suspecter la passerelle.

Que me coûte le « théâtre de cache » en pratique ? Vous payez le plein tarif d’entrée à chaque appel en croyant n’en payer qu’une fraction. Sur un endpoint à fort volume avec un grand préfixe stable, c’est votre facture qui devient plusieurs fois supérieure à ce que vous aviez modélisé. La vérification 2 est celle sur laquelle alerter.

Pourquoi la réduction de DeepSeek est-elle ici plus faible que celle de Claude ? On mesure des choses différentes. Les ~90 % de Claude correspondent à la réduction de lecture sur l’entrée mise en cache. Les ~72 % de DeepSeek correspondent à la réduction du total par appel, où la sortie et le reste non mis en cache sont facturés au plein tarif et diluent le chiffre affiché. Comparez ce qui est comparable pour votre propre forme de prompt.

Cela fonctionne-t-il aussi pour GPT, Gemini, Qwen ? Oui. Ils sont tous automatiques, donc ils utilisent AutoLane tel quel avec un model différent. Seul Claude a besoin de MarkerLane. Les mêmes cinq vérifications dans tous les cas.

Cela devrait-il vivre dans la CI ? Oui. Exécutez la vérification 1 + la vérification 2 face à chaque modèle que vous facturez, de manière planifiée, et alertez lorsque la réduction observée dérive hors de votre fourchette attendue. Un audit permanent transforme une régression silencieuse en notification.

← Retour au blog