¿Tu gateway de LLM miente sobre la caché? Audítalo en 5 minutos
Contenido
- Cuatro formas en las que un gateway puede mentir sobre la caché
- Dos mecanismos de caché, una sola auditoría
- Comprobación 1: ¿entra en funcionamiento la caché?
- Comprobación 2: ¿el coste refleja el descuento?
- Comprobación 3: ¿cuadran los recuentos de tokens?
- Comprobación 4: ¿el streaming conserva los metadatos?
- Comprobación 5: el control negativo
- Cómo interpretar el cuadro de resultados
- Conclusión
- 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 > 0pero 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 fallo | Lo que ves | Lo que ocurre en realidad |
|---|---|---|
| Sin caché y sin aviso | Una respuesta correcta, sin errores | No se ha almacenado nada; pagas el precio completo en cada llamada |
| Caché de escaparate | cached_tokens > 0 en la respuesta | …pero se factura la tarifa completa de entrada |
| Margen creciente | Una cifra de coste plausible | El margen del gateway se come el descuento sin que se note |
| Metadatos ausentes | Una salida de texto correcta | Se 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 comocache_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ío | caché en caliente | resultado | |
|---|---|---|---|
deepseek-v4-flash | 0 | 7,552 / 7,870 (96%) | CORRECTO |
claude-opus-4-8 | 0 | 12,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ío | coste en caliente | descuento | resultado | |
|---|---|---|---|---|
deepseek-v4-flash | $0.00107 | $0.00030 | 72.3% | CORRECTO |
claude-opus-4-8 | $0.07112 | $0.00672 | 90.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 prompt | resultado | |
|---|---|---|---|
deepseek-v4-flash | 7,552 | 7,870 | CORRECTO |
claude-opus-4-8 | 12,446 | 12,454 | CORRECTO |
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 streaming | coste en streaming | resultado | |
|---|---|---|---|
deepseek-v4-flash | conservada | conservado | CORRECTO |
claude-opus-4-8 | conservada | conservado | CORRECTO |
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 A | prefijo único B | resultado | |
|---|---|---|---|
deepseek-v4-flash | cached 0 | cached 0 | CORRECTO |
claude-opus-4-8 | cached 0 | cached 0 | CORRECTO |
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ón | Resultado correcto | Señal de alerta |
|---|---|---|
| 1. la caché entra en funcionamiento | 0 en frío, >0 en caliente tras varios intentos | 0 después de varias llamadas en caliente y por encima del tamaño mínimo |
| 2. el coste refleja el descuento | coste en caliente ≪ coste en frío | cached > 0, pero los costes son iguales |
| 3. contabilidad de tokens | cached ≤ prompt_total, las cifras cuadran | los recuentos no cuadran |
| 4. metadatos en streaming | la caché y el coste se conservan durante el stream | faltan datos de uso en las llamadas con streaming |
| 5. control negativo | un prefijo único nunca produce un acierto | un 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.