Semantic caching: reduce costo y latencia

Por: Artiko
bifrostsemantic-cachevector-storeredisqdrant

Semantic caching: reduce costo y latencia

En el capítulo 7 hicimos que Bifrost fuera resiliente: reintenta, conmuta a otro proveedor y reparte la carga para que ninguna petición se quede sin respuesta. Pero la petición más resiliente, más barata y más rápida es la que nunca llega al proveedor. Si dos usuarios preguntan “¿cómo reseteo mi contraseña?” y “olvidé mi clave, ¿qué hago?”, el LLM va a generar prácticamente la misma respuesta dos veces, cobrándote dos veces y haciéndote esperar dos veces. El semantic caching existe precisamente para evitar eso.

En este capítulo vas a entender por qué cachear por significado (y no solo por texto exacto) es una de las palancas de ahorro más grandes en un gateway de IA, cómo funciona internamente el plugin semantic_cache, cómo conectarlo a un vector store (Redis o Qdrant), y cuándo NO deberías cachear para no devolver datos obsoletos o peligrosos.

¿Qué es el semantic caching?

Un caché tradicional funciona por exact-match: usa el texto exacto de la petición como clave. Si el siguiente request cambia una sola coma, una mayúscula o el orden de una palabra, el caché falla (cache miss) y vuelves a pagar la inferencia. Para lenguaje natural esto es desastroso, porque los humanos casi nunca formulamos la misma idea con las mismas palabras.

El semantic caching cachea por similitud semántica. En lugar de comparar caracteres, calcula el embedding de la petición (un vector que representa su significado) y lo compara contra los embeddings de peticiones anteriores. Si encuentra una suficientemente parecida, devuelve la respuesta ya almacenada sin llamar al modelo.

flowchart LR
    A["¿cómo reseteo mi contraseña?"] --> E1["[0.12, -0.88, 0.34, ...]"]
    B["olvidé mi clave, ¿qué hago?"] --> E2["[0.13, -0.86, 0.35, ...]"]
    E1 -. "similitud coseno 0.96" .-> E2
    E2 --> H["Cache HIT: misma respuesta"]

Las dos frases tienen palabras distintas pero su distancia vectorial es mínima, así que comparten respuesta. Las ganancias son directas:

  • Costo: un cache hit no consume tokens de generación del proveedor. Solo pagas, como mucho, un embedding barato (text-embedding-3-small cuesta una fracción de lo que cuesta un gpt-4o).
  • Latencia: recuperar un vector y devolver la respuesta almacenada toma milisegundos, frente a los segundos que tarda una generación completa con streaming.
  • Resiliencia: si el proveedor está caído o saturado, un hit del caché responde igual, complementando los fallbacks del capítulo anterior.

Bifrost implementa esto con el plugin semantic_cache, que en realidad ofrece dos caminos de lookup complementarios:

  1. Direct (hash) matching: replay determinista por exact-match. Normaliza la petición y la hashea. Es gratis (no requiere embeddings) y siempre devuelve la respuesta idéntica.
  2. Semantic (similarity) matching: lookup por embeddings para peticiones semánticamente similares aunque la redacción difiera.

El flujo prioriza el lookup directo antes de caer al semántico: si hay coincidencia exacta, no se gasta ni siquiera el embedding.

Cómo funciona por dentro

Cada petición entrante pasa por el plugin antes de llegar al proveedor. La lógica es la siguiente:

sequenceDiagram
    participant C as Cliente
    participant B as Bifrost (semantic_cache)
    participant V as Vector Store
    participant P as Proveedor LLM

    C->>B: POST /chat/completions (+ x-bf-cache-key)
    B->>B: Normaliza y hashea la peticion
    B->>V: Lookup directo (hash)
    alt Hit directo
        V-->>B: Respuesta cacheada
        B-->>C: Respuesta (hit_type: direct)
    else Miss directo
        B->>P: Genera embedding de la peticion
        P-->>B: vector [1536 dims]
        B->>V: GetNearest(vector, threshold)
        alt Similitud >= threshold
            V-->>B: Respuesta cacheada
            B-->>C: Respuesta (hit_type: semantic)
        else Miss semantico
            B->>P: Inferencia completa
            P-->>B: Respuesta nueva
            B-->>C: Respuesta
            B--)V: Escribe respuesta (async)
        end
    end

Tres detalles importantes que la documentación deja claros:

  • La cache key es obligatoria. El caché solo se activa si la petición trae el header x-bf-cache-key o si defines un default_cache_key en la config. Sin clave de partición, Bifrost no cachea nada. Esto te permite aislar caché por sesión, por tenant o por usuario.
  • Las escrituras son asíncronas. Cuando hay un miss y se genera una respuesta nueva, escribirla en el vector store ocurre en segundo plano: la primera petición nunca se bloquea esperando a que se guarde el caché.
  • Las entradas persisten entre reinicios, porque viven en el vector store y no en memoria del proceso de Bifrost.

Cada respuesta incluye metadatos de depuración en response.ExtraFields.CacheDebug, útiles para verificar que el caché funciona:

{
  "cache_hit": true,
  "cache_id": "550e8500-e29b-41d4-a725-446655440001",
  "hit_type": "direct",
  "threshold": 0.8,
  "similarity": 0.95,
  "provider_used": "openai",
  "model_used": "text-embedding-3-small",
  "input_tokens": 100
}

El campo hit_type te dice si el acierto vino del camino direct o semantic, y similarity muestra el coseno real cuando fue semántico.

Diagnóstico de prompt cache de Anthropic

Un cache hit del propio Bifrost no es la única forma de caché en juego. Anthropic tiene su prompt caching nativo, que abarata los tokens de entrada reutilizando prefijos de prompt entre requests. El problema clásico es que un cache miss ahí es silencioso: pagas el prompt completo sin saber por qué no se reutilizó nada.

Para eso Bifrost expone un esquema de diagnóstico de prompt cache: los objetos CacheDiagnostics y CacheMissReason viajan en la respuesta y te dicen, cuando hubo un miss, por qué el prompt no se cacheó (orden de bloques inestable, prefijo que cambió, marcador de cache mal colocado, etc.). En vez de adivinar, lees el motivo del miss directamente en CacheDiagnostics y ajustas tu prompt para que el prefijo quede estable y vuelva a cachearse.

Este diagnóstico se apoya en que el prefijo enviado al proveedor sea determinista entre requests. Un caso real que rompía el prompt caching era el orden no determinista de las herramientas MCP inyectadas: si las tools llegaban en distinto orden en cada petición, el prefijo cambiaba y Anthropic lo trataba como nuevo. Ese bug ya está corregido (el orden de tools MCP ahora es estable, lo veremos en el capítulo 10), pero CacheMissReason sigue siendo tu herramienta para detectar cualquier otra fuente de inestabilidad del prefijo.

Tipos de petición soportados

El caché no se limita a chat. Cubre chat completions, text completions, la Responses API (incluyendo WebSocket), embeddings, transcriptions, speech e image generation, incluyendo sus variantes en streaming.

Configuración del plugin semantic_cache

La configuración vive en el array plugins de tu config.json (revisamos su estructura general en el capítulo 3). Esta es la configuración completa con todos los campos:

{
  "plugins": [
    {
      "enabled": true,
      "name": "semantic_cache",
      "config": {
        "provider": "openai",
        "embedding_model": "text-embedding-3-small",
        "dimension": 1536,
        "ttl": "5m",
        "threshold": 0.8,
        "conversation_history_threshold": 3,
        "exclude_system_prompt": false,
        "cache_by_model": true,
        "cache_by_provider": true,
        "vector_store_namespace": "BifrostSemanticCachePlugin",
        "default_cache_key": ""
      }
    }
  ]
}

Cada campo y su rol:

CampoTipoDefaultPara qué sirve
providerstringProveedor del modelo de embeddings. Se omite en modo direct-only.
embedding_modelstringModelo de embeddings (p. ej. text-embedding-3-small).
dimensionintegerTamaño del vector. Usa 1 para modo direct-only.
ttlduración / segundos5mExpiración de cada entrada. Acepta "30s", "5m", "1h" o un número en segundos.
thresholdnúmero 0–10.8Similitud coseno mínima para considerar un hit semántico.
conversation_history_thresholdinteger3No cachea conversaciones con más mensajes que este umbral.
exclude_system_promptbooleanfalseExcluye los mensajes de sistema al generar la cache key.
cache_by_modelbooleantrueIncluye el nombre del modelo en la cache key.
cache_by_providerbooleantrueIncluye el nombre del proveedor en la cache key.
vector_store_namespacestringBifrostSemanticCachePluginNombre del bucket/índice donde se guardan los vectores.
default_cache_keystring""Clave de fallback cuando la petición no trae header de cache key.

El umbral de similitud (threshold)

Es el parámetro más importante de afinar. Va de 0 a 1 y representa la similitud coseno mínima para devolver un hit:

  • Más alto (p. ej. 0.95): solo cachea peticiones casi idénticas en significado. Menos hits, pero prácticamente cero riesgo de devolver una respuesta equivocada. Empieza siempre conservador.
  • Más bajo (p. ej. 0.75): agrupa peticiones más dispares bajo la misma respuesta. Más ahorro, pero riesgo de devolver una respuesta que no encaja del todo con la pregunta.

El default de 0.8 es un punto medio razonable. Sube o baja según midas la tasa de aciertos y la calidad percibida.

El modelo de embeddings

provider + embedding_model definen cómo se vectoriza cada petición. El dimension debe coincidir con el modelo: text-embedding-3-small produce vectores de 1536 dimensiones. Un detalle crítico de operación:

Cambiar dimension, provider o embedding_model contra un namespace existente exige un namespace nuevo o limpiar el índice manualmente. Vectores de distinta dimensión o modelo no son comparables entre sí.

Modo direct-only (sin embeddings)

Si solo quieres exact-match (caché determinista, gratis, sin coste de embeddings), pon dimension: 1 y omite provider y embedding_model:

{
  "plugins": [
    {
      "name": "semantic_cache",
      "enabled": true,
      "config": {
        "dimension": 1,
        "ttl": "5m",
        "cache_by_model": true,
        "cache_by_provider": true
      }
    }
  ]
}

Es ideal cuando tus peticiones se repiten literalmente (por ejemplo prompts generados por código, plantillas fijas) y no quieres pagar ni un embedding por miss.

Control por petición: headers

Más allá de la config global, puedes ajustar el comportamiento del caché request a request con headers. Esto es clave para una partición correcta:

HeaderValorEfecto
x-bf-cache-keystringPartición del caché para esta petición. Obligatorio para que se cachee.
x-bf-cache-ttlduración / segundosSobrescribe el TTL para esta petición.
x-bf-cache-thresholdfloat 0–1Sobrescribe el umbral de similitud.
x-bf-cache-typedirect o semanticRestringe a un solo camino de lookup.
x-bf-cache-no-storetrueLee del caché pero no escribe (modo read-only).

Un ejemplo de petición que usa una clave por sesión, un TTL corto y solo lookup directo:

curl -X POST http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "x-bf-cache-key: session-123" \
  -H "x-bf-cache-ttl: 30s" \
  -H "x-bf-cache-type: direct" \
  -d '{
    "model": "openai/gpt-4o-mini",
    "messages": [{"role": "user", "content": "¿como reseteo mi contraseña?"}]
  }'

Gestión del caché vía API

Bifrost expone endpoints para administrar el plugin y purgar entradas. Habilitar el plugin en caliente:

curl -X POST http://localhost:8080/api/plugins \
  -H "Content-Type: application/json" \
  -d '{
    "name": "semantic_cache",
    "enabled": true,
    "config": {
      "provider": "openai",
      "embedding_model": "text-embedding-3-small",
      "dimension": 1536,
      "ttl": "5m",
      "threshold": 0.8
    }
  }'

Actualizar la configuración del plugin:

curl -X PUT http://localhost:8080/api/plugins/semantic_cache \
  -H "Content-Type: application/json" \
  -d '{"enabled": true, "config": {"ttl": "10m"}}'

Invalidar una entrada concreta por su cache_id:

curl -X DELETE http://localhost:8080/api/cache/clear/550e8500-e29b-41d4-a725-446655440001

Limpiar todas las entradas asociadas a una cache key (útil al cerrar una sesión o invalidar un tenant):

curl -X DELETE http://localhost:8080/api/cache/clear-by-key/support-session-456

El vector store dentro del framework de Bifrost

El caché semántico necesita un sitio donde guardar y buscar vectores. Bifrost no reinventa cada base de datos: define un componente unificado llamado VectorStore dentro de su paquete framework, que ofrece una interfaz común sobre varios backends. Así, el plugin semantic_cache trabaja siempre contra la misma API independientemente de si por debajo hay Redis o Qdrant.

flowchart TD
    SC["Plugin semantic_cache"] --> VS["VectorStore (framework)"]
    VS --> R["Redis / Valkey"]
    VS --> Q["Qdrant"]
    VS --> P["Pinecone"]
    VS --> W["Weaviate"]

La interfaz expone operaciones de alto nivel para gestionar namespaces y vectores. Las principales que documenta Bifrost:

  • CreateNamespace(ctx, name, dimensions, properties): crea una colección con su esquema.
  • Add(ctx, namespace, id, embedding, metadata): guarda un vector con metadatos.
  • GetNearest(ctx, namespace, queryEmbedding, filters, fields, threshold, limit): búsqueda por similitud.
  • GetChunk(ctx, namespace, id): recupera un item concreto.
  • GetAll(ctx, namespace, filters, fields, cursor, limit): recuperación por lotes con paginación.
  • Delete(ctx, namespace, id): elimina un vector.

Los embeddings se almacenan como arrays de float32 (1536 dimensiones en el ejemplo) y los metadatos como map[string]interface{} junto al vector. El vector_store_namespace de tu plugin corresponde al namespace/colección donde Bifrost organiza las entradas de caché.

Vector stores soportados y cómo elegir

Bifrost soporta cuatro backends. Así los describe la documentación:

BackendCaracterísticaCuándo elegirlo
Redis / ValkeyVector store in-memory de alto rendimientoEmpezar rápido, baja latencia, ya usas Redis. Necesita módulo de búsqueda (FT.*).
QdrantMotor de búsqueda en Rust con filtrado avanzadoFiltrado complejo, escalar a muchos vectores, opción cloud gestionada.
PineconeBase gestionada con opciones serverlessNo querer operar infraestructura, escalado automático.
WeaviateBase lista para producción con soporte gRPCCasos avanzados de búsqueda y producción.

Para arrancar y aprender, Redis/Valkey es la opción más simple: un contenedor y a funcionar. Cuando necesites filtrado más potente o escalar a millones de vectores, Qdrant es la siguiente parada natural. Veamos ambos.

Opción A: Redis / Valkey

Redis necesita un módulo de búsqueda que soporte comandos FT.* (creación de índices y búsqueda vectorial). Por eso no sirve un Redis pelado: usa Redis Stack o el bundle de Valkey, que ya lo incluyen.

# Redis Stack (incluye RediSearch)
docker run -d --name redis-stack -p 6379:6379 redis/redis-stack:latest

# Alternativa: bundle de Valkey
docker run -d --name valkey-bundle -p 6379:6379 valkey/valkey-bundle:9.0.0

Configuración local mínima en config.json:

{
  "vector_store": {
    "enabled": true,
    "type": "redis",
    "config": {
      "addr": "localhost:6379"
    }
  }
}

Configuración completa con pool de conexiones y timeouts, útil para producción:

{
  "vector_store": {
    "enabled": true,
    "type": "redis",
    "config": {
      "addr": "localhost:6379",
      "username": "",
      "password": "",
      "db": 0,
      "use_tls": false,
      "insecure_skip_verify": false,
      "cluster_mode": false,
      "pool_size": 10,
      "max_active_conns": 10,
      "min_idle_conns": 5,
      "max_idle_conns": 10,
      "dial_timeout": "5s",
      "read_timeout": "3s",
      "write_timeout": "3s",
      "context_timeout": "10s"
    }
  }
}

Para Redis gestionado en la nube con TLS, activa use_tls y aporta credenciales (y opcionalmente el certificado de la CA):

{
  "vector_store": {
    "enabled": true,
    "type": "redis",
    "config": {
      "addr": "your-redis-host:port",
      "username": "your-username",
      "password": "your-password",
      "db": 0,
      "use_tls": true,
      "ca_cert_pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
      "cluster_mode": false,
      "context_timeout": "10s"
    }
  }
}

Si despliegas un cluster de Redis, activa cluster_mode: true y deja db en 0 (los clusters solo usan la base 0):

{
  "vector_store": {
    "enabled": true,
    "type": "redis",
    "config": {
      "addr": "your-cluster-endpoint:6379",
      "username": "your-username",
      "password": "your-password",
      "use_tls": true,
      "cluster_mode": true,
      "context_timeout": "10s"
    }
  }
}

Opción B: Qdrant

Qdrant es un motor de búsqueda vectorial escrito en Rust con filtrado avanzado. Bifrost se conecta siempre por gRPC (puerto 6334), no por REST (6333). Levantarlo en local:

docker run -d \
  --name qdrant \
  -p 6333:6333 \
  -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  qdrant/qdrant:latest

Configuración local en config.json (fíjate en el puerto gRPC 6334):

{
  "vector_store": {
    "enabled": true,
    "type": "qdrant",
    "config": {
      "host": "localhost",
      "port": 6334
    }
  }
}

Para Qdrant Cloud necesitas api_key y use_tls: true:

{
  "vector_store": {
    "enabled": true,
    "type": "qdrant",
    "config": {
      "host": "your-qdrant-cluster.cloud.qdrant.io",
      "port": 6334,
      "api_key": "your-qdrant-api-key",
      "use_tls": true
    }
  }
}

Campos de configuración de Qdrant:

CampoTipoDefaultNotas
hoststringrequeridoHostname del servidor. Soporta sintaxis env.VAR_NAME.
portinteger6334Puerto gRPC. Bifrost siempre usa la interfaz gRPC.
api_keystringopcionalSoporta sintaxis env.VAR_NAME.
use_tlsbooleanfalseRequerido para Qdrant Cloud.
max_recv_msg_size_mbinteger64Súbelo para payloads grandes (p. ej. respuestas en base64).

Tanto host como api_key admiten la sintaxis env.VAR_NAME para leer el valor de una variable de entorno en vez de incrustarlo en el config.json, evitando secretos en disco (mismo patrón de seguridad que vimos en el capítulo 4).

Buenas prácticas: cuándo NO cachear

El caché es potente, pero servir una respuesta vieja en el momento equivocado es peor que pagar una inferencia. Reglas para decidir qué dejar fuera:

  • Respuestas con tools / function calling. Una llamada a herramienta depende del estado del mundo en ese instante (consultar un pedido, crear un ticket, ejecutar una acción). Cachear la respuesta de un tool call devuelve datos congelados o, peor, omite una acción que debía ejecutarse. Para estas peticiones usa x-bf-cache-no-store: true o no envíes cache key.
  • Datos en tiempo real. Precios, clima, stock, saldos, noticias: cualquier respuesta cuya validez caduca en segundos. Si aun así quieres algo de caché, fuerza un TTL muy corto con x-bf-cache-ttl: 10s.
  • Respuestas personalizadas o con PII. Si la respuesta depende de la identidad del usuario, asegúrate de particionar con una x-bf-cache-key por usuario/tenant para no filtrar la respuesta de uno a otro. Nunca uses una clave global compartida para contenido sensible.
  • Conversaciones largas. El conversation_history_threshold (default 3) ya evita cachear hilos largos, donde cada turno depende fuertemente del contexto previo y la probabilidad de reúso es mínima.
  • Salidas creativas con alta temperatura. Si el valor del producto es la variedad (brainstorming, generación creativa), cachear arruina la experiencia devolviendo siempre lo mismo.

La estrategia práctica: activa el caché por defecto solo donde haya reúso real y respuestas estables (FAQs, soporte, clasificación, traducciones de frases recurrentes), y mantenlo apagado o read-only para el resto. Empieza con un threshold alto, mide la tasa de aciertos y la calidad con el campo CacheDebug, y baja el umbral solo cuando estés seguro.

Resumen

  • El semantic caching reutiliza respuestas por similitud de significado (embeddings) en vez de por texto exacto, ahorrando tokens de generación (costo) y devolviendo respuestas en milisegundos (latencia).
  • El plugin semantic_cache combina dos caminos: direct (hash exact-match, gratis) y semantic (búsqueda por embeddings sobre un umbral de similitud), priorizando siempre el directo.
  • Se configura en el array plugins del config.json; los campos clave son threshold (similitud), ttl (expiración), provider + embedding_model + dimension (vectorización) y vector_store_namespace.
  • La cache key es obligatoria (x-bf-cache-key o default_cache_key); además controlas TTL, umbral, tipo y modo read-only por petición con headers x-bf-cache-*.
  • Más allá del caché propio, los objetos CacheDiagnostics / CacheMissReason exponen por qué el prompt caching de Anthropic tuvo un miss, para estabilizar el prefijo (por ejemplo, el orden determinista de tools MCP del capítulo 10).
  • El VectorStore del framework unifica cuatro backends: Redis/Valkey (rápido de arrancar, requiere módulo FT.*), Qdrant (gRPC en puerto 6334, filtrado avanzado), Pinecone y Weaviate.
  • No caches respuestas con tools, datos en tiempo real, salidas personalizadas con PII sin particionar, ni conversaciones largas.

Con un gateway que además de resiliente ahorra dinero, el siguiente paso es controlar quién consume y cuánto: claves virtuales, presupuestos y límites.

Siguiente: Governance: virtual keys, presupuestos y límites