🎁 Nuevo Regístrate gratis, 10 llamadas de regalo. Hasta 1 $, sin tarjeta.
¿Tu gateway de LLM miente sobre la caché? Audítalo en 5 minutos

¿Tu gateway de LLM miente sobre la caché? Audítalo en 5 minutos

Contenido
  1. Cuatro formas en las que un gateway puede mentir sobre la caché
  2. Dos mecanismos de caché, una sola auditoría
  3. Comprobación 1: ¿entra en funcionamiento 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 conserva los metadatos?
  7. Comprobación 5: el control negativo
  8. Cómo interpretar el cuadro de resultados
  9. Conclusión
  10. Preguntas frecuentes

Un gateway se interpone entre tu código y el proveedor del modelo. La respuesta incluye cached_tokens, ves una cifra menor y das por hecho que el ahorro es real. Pero no puedes ver la llamada al proveedor. El gateway podría informar de un acierto de caché y aun así cobrar la tarifa completa de entrada. Podría no almacenar nada en caché y devolver una respuesta impecable. También podría eliminar los metadatos de uso durante el streaming, que suele concentrar la mayor parte del tráfico de producción, y dejarte sin forma de comprobarlo.

TL;DR

  • Un aviso publicado en Hacker News señaló que un gateway popular devolvía entre 2 y 3 veces menos tokens en caché para DeepSeek V4 que una llamada directa a DeepSeek.
  • Un único script ejecutable realiza cinco comprobaciones: la caché entra en funcionamiento, el coste realmente baja, el recuento de tokens cuadra, el streaming conserva los metadatos de uso y un control negativo se mantiene sin aciertos.
  • En la auditoría a través del gateway de Synthorai, deepseek-v4-flash obtuvo una tasa de aciertos en caliente del 96% y una reducción del coste por llamada del 72.3%; claude-opus-4-8 obtuvo un 99.9% y un 90.6%.
  • Si cached_tokens > 0 pero el coste en frío y en caliente es idéntico, el gateway informa de un acierto sin aplicar el descuento correspondiente.

No es un caso hipotético. Un aviso publicado en Hacker News señaló que enrutar DeepSeek V4 a través de un gateway popular devolvía entre 2 y 3 veces menos tokens en caché que llamar directamente a DeepSeek. Un usuario publicó facturas que mostraban que las estadísticas de caché ni siquiera se informaban a través del gateway. El equipo del gateway respondió que no podía reproducirlo y que estaba investigándolo. Ahí está el problema: si dos partes no se ponen de acuerdo sobre si tu caché funciona, la única prueba válida es una medición realizada por ti.

Normalmente no hay mala fe. Suele tratarse de una diferencia al adaptar formatos o de una ruta de código incompleta. Para tu factura, el resultado es el mismo. Este artículo presenta un único script ejecutable que audita los dos tipos de caché de prompts, la automática (DeepSeek) y la basada en marcadores (Claude), con cualquier gateway, incluido este. En menos de cinco minutos imprime un cuadro comparativo de resultados.


Cuatro formas en las que un gateway puede mentir sobre la caché

Tipo de falloLo que vesLo que ocurre en realidad
Sin caché y sin avisoUna respuesta correcta, sin erroresNo se ha almacenado nada; pagas el precio completo en cada llamada
Caché de escaparatecached_tokens > 0 en la respuesta…pero se factura la tarifa completa de entrada
Margen crecienteUna cifra de coste plausibleEl margen del gateway se come el descuento sin que se note
Metadatos ausentesUna salida de texto correctaSe eliminan los campos de uso, sobre todo en streaming, y no puedes auditarlo

Los dos primeros son los más peligrosos: la respuesta parece indicar que la caché funciona. No descubres el problema hasta final de mes.


Dos mecanismos de caché, una sola auditoría

Los proveedores exponen la caché de dos formas, y un gateway real debe trasladar ambas sin alterar nada:

  • Automática (DeepSeek, GPT, Gemini, Qwen): el proveedor almacena por su cuenta cualquier prefijo suficientemente largo. No requiere marcadores. Los aciertos aparecen en usage.prompt_tokens_details.cached_tokens.
  • Basada en marcadores (Anthropic Claude): marcas los segmentos que pueden almacenarse con cache_control. Los aciertos aparecen como cache_read_input_tokens.

El script oculta esta diferencia tras un adaptador Lane sencillo y ejecuta las cinco comprobaciones en ambos casos. Este es el script completo: dos lanes y una función audit() que realiza todas las comprobaciones.

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

A continuación se explica cada comprobación: las líneas que la implementan, los resultados de ambos lanes y cómo interpretarlos.


Comprobación 1: ¿entra en funcionamiento 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
caché en fríocaché en calienteresultado
deepseek-v4-flash07,552 / 7,870 (96%)CORRECTO
claude-opus-4-8012,446 / 12,454 (99.9%)CORRECTO

Una llamada en frío con un prefijo único no debe encontrar nada en caché; al repetirla, sí debe haber un acierto. El falso positivo más habitual consiste en declarar que «no hay caché» después de una sola llamada en caliente, porque la caché no siempre queda disponible de inmediato. El bucle reintenta varias veces con una pausa de 1 segundo, lo que evita resultados inestables. Si sigues obteniendo 0 después de varias llamadas en caliente con un prompt que supera el tamaño mínimo (~1,024 tokens para la mayoría de los proveedores; DeepSeek compara en bloques más pequeños de 64), la caché realmente no está entrando en funcionamiento.


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
coste en fríocoste en calientedescuentoresultado
deepseek-v4-flash$0.00107$0.0003072.3%CORRECTO
claude-opus-4-8$0.07112$0.0067290.6%CORRECTO

Esta comprobación detecta la caché de escaparate. El coste de la llamada en caliente debe bajar de verdad. El coste total por llamada de DeepSeek cayó aproximadamente un 72%. La entrada almacenada tiene un descuento mayor, pero la salida y la parte de entrada no almacenada reducen el porcentaje total. En Claude, la lectura de caché tiene un descuento aproximado del 90%. La señal de fallo es inequívoca: si cached_tokens > 0 pero el coste en frío y en caliente es idéntico, el gateway informa de un acierto sin aplicar el precio correspondiente. Estás pagando la tarifa completa por una caché que solo «funciona» en los informes.


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

r["check3"] = warm["prompt_total"] is None or warm["cached"] <= warm["prompt_total"]
en cachétotal del promptresultado
deepseek-v4-flash7,5527,870CORRECTO
claude-opus-4-812,44612,454CORRECTO

cached debe formar parte del total del prompt; el resto se factura como entrada sin almacenar. En ambos casos, las cifras cuadran. Si cached_tokens supera prompt_tokens, o si la parte no almacenada es inverosímilmente grande para un prefijo estable, el gateway está contabilizando mal los tokens: en algún punto de la adaptación vuelve a tokenizar o cuenta dos veces.


Comprobación 4: ¿el streaming conserva 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"]
caché en streamingcoste en streamingresultado
deepseek-v4-flashconservadaconservadoCORRECTO
claude-opus-4-8conservadaconservadoCORRECTO

La mayoría de los chats de producción usan streaming, así que esta es la ruta que más importa. En ambos lanes, tanto el indicador de acierto de caché como el coste se conservan durante el stream. cached_tokens y cost llegan en el último bloque de uso, por lo que puedes seguir auditando la ruta con mayor volumen. El problema aparece cuando un gateway elimina los datos de uso durante el streaming: si recibes correctamente los tokens de salida pero no hay cached_tokens ni cost, no tienes visibilidad sobre la ruta que más utilizas. (Pasa stream_options={"include_usage": True} para que se emita el bloque de uso.)


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 0CORRECTO
claude-opus-4-8cached 0cached 0CORRECTO

Envía un prefijo único en cada llamada; nunca debe producir un acierto. Ambos lanes informaron correctamente de cached=0 y aplicaron el coste completo a prefijos distintos. Un «acierto» en este caso convertiría el indicador de caché en un falso positivo imposible de considerar fiable. Un control negativo limpio es lo que permite confiar en los resultados positivos de las comprobaciones 1 y 2.


Cómo interpretar el cuadro de resultados

ComprobaciónResultado correctoSeñal de alerta
1. la caché entra en funcionamiento0 en frío, >0 en caliente tras varios intentos0 después de varias llamadas en caliente y por encima del tamaño mínimo
2. el coste refleja el descuentocoste en caliente ≪ coste en fríocached > 0, pero los costes son iguales
3. contabilidad de tokenscached ≤ prompt_total, las cifras cuadranlos recuentos no cuadran
4. metadatos en streamingla caché y el coste se conservan durante el streamfaltan datos de uso en las llamadas con streaming
5. control negativoun prefijo único nunca produce un aciertoun prefijo distinto produce un «acierto»

Los dos fallos que aumentan el coste sin avisar son el 2 (se cobra el precio completo pese a informar de un acierto) y el 1 (no hay caché, aunque la respuesta sea correcta). Ejecuta ambos con todos los modelos que factures.


Conclusión

La caché es una de las herramientas con mayor impacto para reducir costes en una aplicación con LLM. Precisamente por eso, no debes asumir que funciona: debes comprobarlo. Integra las comprobaciones 1 y 2 en CI para cada modelo que factures y genera una alerta si el descuento se sale del intervalo esperado. Así detectarás una regresión silenciosa el mismo día en que cambie el comportamiento del gateway o del proveedor, no al final del ciclo de facturación. Y hagas lo que hagas en tu auditoría, reintenta la lectura en caliente antes de concluir que la caché está rota.

Para entender los mecanismos que hay detrás de estas cifras, como prefill, KV cache y TTL, empieza por Cómo funcionan KV cache y TTL. Para consultar patrones de caché funcionales por proveedor, revisa el tutorial.


Preguntas frecuentes

Mi comprobación 1 muestra 0 en la llamada en caliente. ¿Está mintiendo mi gateway? Comprueba primero tres cosas. (1) ¿Supera tu prompt el tamaño mínimo que el proveedor permite almacenar en caché (~1,024 tokens para la mayoría; DeepSeek compara con una granularidad más fina de 64 tokens)? (2) ¿Has reintentado la lectura en caliente varias veces? La caché no siempre queda disponible justo en la siguiente llamada. (3) ¿El prefijo es idéntico byte a byte entre llamadas, sin marcas de tiempo ni identificadores por solicitud al principio? Solo deberías sospechar del gateway después de comprobar las tres.

¿Cuánto me cuesta en la práctica una «caché de escaparate»? Pagas la tarifa completa de entrada en cada llamada mientras crees estar pagando solo una fracción. En un endpoint con mucho tráfico y un prefijo estable grande, la factura puede multiplicar varias veces tus previsiones. Configura las alertas sobre la comprobación 2.

¿Por qué el descuento de DeepSeek es menor que el de Claude en este caso? Se están midiendo cosas distintas. El ~90% de Claude corresponde al descuento de lectura de la entrada almacenada. El ~72% de DeepSeek es la reducción total por llamada: la salida y la parte de entrada no almacenada se facturan a la tarifa completa y reducen el porcentaje final. Para tus propios prompts, compara métricas equivalentes.

¿También funciona con GPT, Gemini y Qwen? Sí. Todos usan caché automática, así que puedes reutilizar AutoLane sin cambios y especificar otro model. Solo Claude necesita MarkerLane. Las cinco comprobaciones son las mismas en ambos casos.

¿Debería ejecutarse en CI? Sí. Ejecuta las comprobaciones 1 y 2 de forma periódica con todos los modelos que factures y genera una alerta cuando el descuento observado se salga del intervalo esperado. Una auditoría permanente convierte una regresión silenciosa en una notificación.

← Volver al blog