¿Tu gateway de LLM miente sobre la caché? Una auditoría en 5 min
Contenido
- Cuatro maneras en que un gateway puede mentir sobre la caché
- Dos mecanismos de caché, una auditoría
- Comprobación 1: ¿se activa la caché?
- Comprobación 2: ¿el coste refleja el descuento?
- Comprobación 3: ¿cuadran los recuentos de tokens?
- Comprobación 4: ¿el streaming preserva los metadatos?
- Comprobación 5: el control negativo
- Cómo leer tu cuadro de mando
- Cierre
- 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 fallo | Lo que ves | Lo que realmente ocurre |
|---|---|---|
| No-caché silencioso | Una respuesta limpia, sin error | No 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 recargo | Una cifra de coste plausible | El recargo del gateway se come silenciosamente el descuento |
| Apagón de metadatos | Salida de texto limpia | Campos 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 comocache_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 cached | warm cached | resultado | |
|---|---|---|---|
deepseek-v4-flash | 0 | 7,552 / 7,870 (96%) | PASS |
claude-opus-4-8 | 0 | 12,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 cost | warm cost | descuento | resultado | |
|---|---|---|---|---|
deepseek-v4-flash | $0.00107 | $0.00030 | 72.3% | PASS |
claude-opus-4-8 | $0.07112 | $0.00672 | 90.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"]
| cached | prompt total | resultado | |
|---|---|---|---|
deepseek-v4-flash | 7,552 | 7,870 | PASS |
claude-opus-4-8 | 12,446 | 12,454 | PASS |
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 cached | stream cost | resultado | |
|---|---|---|---|
deepseek-v4-flash | preservado | preservado | PASS |
claude-opus-4-8 | preservado | preservado | PASS |
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 A | prefijo único B | resultado | |
|---|---|---|---|
deepseek-v4-flash | cached 0 | cached 0 | PASS |
claude-opus-4-8 | cached 0 | cached 0 | PASS |
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ón | Resultado sano | Señal de alarma |
|---|---|---|
| 1. la caché se activa | 0 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 descuento | coste en caliente ≪ coste en frío | cached > 0 pero costes iguales |
| 3. contabilidad de tokens | cached ≤ prompt_total, cuadra | los recuentos no cuadran |
| 4. metadatos en streaming | caché + coste sobreviven al stream | uso ausente en las llamadas con streaming |
| 5. control negativo | un prefijo único siempre falla | un 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.