¿Tu gateway de LLM miente sobre la caché? Una auditoría en 5 min

Contenido
  1. Cuatro maneras en que un gateway puede mentir sobre la caché
  2. Dos mecanismos de caché, una auditoría
  3. Comprobación 1: ¿se activa la caché?
  4. Comprobación 2: ¿el coste refleja el descuento?
  5. Comprobación 3: ¿cuadran los recuentos de tokens?
  6. Comprobación 4: ¿el streaming preserva los metadatos?
  7. Comprobación 5: el control negativo
  8. Cómo leer tu cuadro de mando
  9. Cierre
  10. Preguntas frecuentes

Un gateway se sitúa entre tu código y el proveedor del modelo. Lees cached_tokens de la respuesta, ves un número más pequeño y confías en que los dólares ahorrados son reales. Pero nunca ves la llamada upstream. El gateway podría informar un acierto de caché y aun así facturar la tarifa de entrada completa. Podría no cachear nada en absoluto detrás de una respuesta perfectamente limpia. Podría eliminar los metadatos de uso en el streaming, la ruta por la que circula la mayor parte de tu tráfico de producción, de modo que no puedas comprobarlo de ninguna manera.

Esto no es hipotético. Un PSA en Hacker News informó que enrutar DeepSeek V4 a través de un gateway popular devolvía de 2 a 3× menos tokens cacheados que llamar a DeepSeek directamente; un comentarista publicó facturas que mostraban que las estadísticas de caché no se informaban en absoluto a través del gateway. El equipo del gateway respondió que no podía reproducirlo y que estaba investigando. Ese desacuerdo es justamente el punto. Cuando dos partes no pueden ponerse de acuerdo sobre si tu caché funciona, el único árbitro es una medición que hayas hecho tú mismo.

Normalmente no se trata de malicia. Es una brecha de traducción o una ruta de código inacabada. El efecto en tu factura es el mismo en ambos casos. Esta publicación es un único script ejecutable que audita ambos estilos de caché de prompts, automático (DeepSeek) y basado en marcadores (Claude), contra cualquier gateway, incluido este. Imprime un cuadro de mando comparativo en menos de cinco minutos.


Cuatro maneras en que un gateway puede mentir sobre la caché

Modo de falloLo que vesLo que realmente ocurre
No-caché silenciosoUna respuesta limpia, sin errorNo se cacheó nada; pagas el precio completo en cada llamada
Teatro de cachécached_tokens > 0 en la respuesta…pero el coste facturado es la tarifa de entrada completa
Deriva del recargoUna cifra de coste plausibleEl recargo del gateway se come silenciosamente el descuento
Apagón de metadatosSalida de texto limpiaCampos de uso eliminados (sobre todo en streaming), no puedes auditarlo

Los peligrosos son los dos primeros: la respuesta parece que la caché funciona. Te enteras a final de mes.


Dos mecanismos de caché, una auditoría

Los proveedores exponen la caché de dos formas, y un gateway de verdad tiene que pasar ambas con fidelidad:

  • Automático (DeepSeek, GPT, Gemini, Qwen): el proveedor cachea por su cuenta cualquier prefijo suficientemente largo. Sin marcadores. Los aciertos aparecen en usage.prompt_tokens_details.cached_tokens.
  • Basado en marcadores (Anthropic Claude): etiquetas los tramos cacheables con cache_control. Los aciertos aparecen como cache_read_input_tokens.

El script oculta esa diferencia tras un fino adaptador Lane y luego ejecuta las cinco comprobaciones contra ambos. Aquí está todo: dos carriles (lanes) y un audit() que realiza cada comprobación.

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))

El resto de la publicación recorre cada comprobación: las líneas que la implementan, lo que devolvieron ambos carriles y cómo leer el resultado.


Comprobación 1: ¿se activa la caché?

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

Una llamada en frío sobre un prefijo único no debe cachear nada; una repetición debe acertar. La falsa alarma más común con diferencia es declarar «sin caché» tras una sola llamada en caliente, porque las cachés no siempre se vuelven legibles al instante. El bucle sondea unas cuantas veces con una pausa de un segundo, lo que elimina la inestabilidad. Si sigues obteniendo 0 tras varias llamadas en caliente sobre un prompt por encima del umbral de tamaño (~1.024 tokens en la mayoría de proveedores; DeepSeek se ajusta a una granularidad más fina de 64), la caché realmente no se está activando.


Comprobación 2: ¿el coste refleja el descuento?

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

Esta es la comprobación que destapa el teatro de caché. El coste de la llamada en caliente debe bajar realmente. El total por llamada de DeepSeek cayó ~72 % (la entrada cacheada se descuenta de forma más pronunciada; la salida y el resto no cacheado diluyen la cifra titular). El read cacheado de Claude tiene un ~90 % de descuento. La señal de fallo es inconfundible: cached_tokens > 0 con un coste en frío y en caliente idéntico significa que el gateway informa un acierto que no está tarifando. Estás pagando el precio completo por una caché que «funciona» sobre el papel.


Comprobación 3: ¿cuadran los recuentos de tokens?

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

cached tiene que caber dentro del total del prompt, con el resto facturado como entrada no cacheada. Ambos cuadran. Si cached_tokens supera prompt_tokens, o el resto no cacheado es inverosímilmente grande para un prefijo estable, el gateway está contabilizando mal: retokeniza o cuenta por duplicado en algún punto de la traducción.


Comprobación 4: ¿el streaming preserva los metadatos?

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 costresultado
deepseek-v4-flashpreservadopreservadoPASS
claude-opus-4-8preservadopreservadoPASS

La mayoría de los chats de producción usan streaming, así que esta es la ruta que más importa. En ambos carriles tanto la señal de acierto de caché como el coste sobreviven al stream. cached_tokens y cost llegan en el chunk final de uso, de modo que tu ruta de mayor volumen sigue siendo auditable. El modo de fallo que hay que vigilar es un gateway que descarta el uso en el streaming: una salida de tokens limpia sin cached_tokens ni cost significa que vuelas a ciegas por la ruta que más utilizas. (Pasa stream_options={"include_usage": True} para que el chunk de uso se emita siquiera.)


Comprobación 5: el control 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
prefijo único Aprefijo único Bresultado
deepseek-v4-flashcached 0cached 0PASS
claude-opus-4-8cached 0cached 0PASS

Envía un prefijo único en cada llamada; nunca debe acertar. Ambos carriles informaron correctamente cached=0 a precio completo para prefijos distintos. Un «acierto» aquí convertiría el reporte de caché en un falso positivo en el que nunca podrías confiar. El control negativo limpio es lo que hace que los resultados positivos de las comprobaciones 1 y 2 sean significativos en primer lugar.


Cómo leer tu cuadro de mando

ComprobaciónResultado sanoSeñal de alarma
1. la caché se activa0 en frío, >0 en caliente (tras sondeo)0 tras varias llamadas en caliente, por encima del umbral de tamaño
2. el coste refleja el descuentocoste en caliente ≪ coste en fríocached > 0 pero costes iguales
3. contabilidad de tokenscached ≤ prompt_total, cuadralos recuentos no cuadran
4. metadatos en streamingcaché + coste sobreviven al streamuso ausente en las llamadas con streaming
5. control negativoun prefijo único siempre fallaun prefijo distinto «acierta»

Las dos que cuestan dinero en silencio son la 2 (precio completo por un acierto informado) y la 1 (sin caché detrás de una respuesta limpia). Ejecuta ambas en cada modelo que factures.


Cierre

La caché es la palanca de coste de mayor apalancamiento en una app de LLM, que es justamente por lo que «la caché funciona» merece una prueba, no una suposición. Conecta la comprobación 1 + la comprobación 2 en la CI contra cada modelo que factures, alerta si el descuento deriva por debajo de tu banda esperada, y cazarás una regresión silenciosa el día en que un gateway o un proveedor upstream cambie de comportamiento, en lugar de al final del ciclo de facturación. Y haga lo que haga tu auditoría, sondea la lectura en caliente antes de dar por roto un caché.

Para la mecánica detrás de estos números (prefill, caché KV, TTL) empieza por Cómo funcionan la caché KV y el TTL. Para patrones de caché funcionales por proveedor, consulta el tutorial.


Preguntas frecuentes

Mi comprobación 1 muestra 0 en la llamada en caliente. ¿Está mintiendo mi gateway? Comprueba primero tres cosas. (1) ¿Tu prompt supera el tamaño mínimo cacheable del proveedor (~1.024 tokens en la mayoría; DeepSeek se ajusta a una granularidad más fina de 64 tokens)? (2) ¿Sondeaste la lectura en caliente varias veces? Las cachés no siempre se vuelven legibles en la llamada inmediatamente siguiente. (3) ¿El prefijo es idéntico byte a byte entre llamadas, sin marcas de tiempo ni IDs por petición al principio? Solo después de los tres deberías sospechar del gateway.

¿Qué me cuesta en la práctica el «teatro de caché»? Pagas la tarifa de entrada completa en cada llamada mientras crees que pagas una fracción. En un endpoint de alto volumen con un gran prefijo estable, eso significa que tu factura es varias veces lo que habías modelado. La comprobación 2 es la que conviene alertar.

¿Por qué el descuento de DeepSeek es aquí menor que el de Claude? Se están midiendo cosas distintas. El ~90 % de Claude es el descuento de read sobre la entrada cacheada. El ~72 % de DeepSeek es la reducción del total por llamada, donde la salida y el resto no cacheado se facturan a tarifa completa y diluyen la cifra titular. Compara lo comparable para la forma de tu propio prompt.

¿Funciona también para GPT, Gemini, Qwen? Sí. Todos son automáticos, así que usan AutoLane sin cambios con un model distinto. Solo Claude necesita el MarkerLane. Las mismas cinco comprobaciones en cualquier caso.

¿Debería vivir esto en la CI? Sí. Ejecuta la comprobación 1 + la comprobación 2 contra cada modelo que factures, de forma programada, y alerta cuando el descuento observado derive fuera de tu banda esperada. Una auditoría permanente convierte una regresión silenciosa en una notificación.

← Volver al blog