Cuando lanzamos la plataforma LLM de Neosmart, nuestra factura de API era un error de redondeo. El producto todavia estaba encontrando su camino, las peticiones diarias rondaban los pocos miles, y estabamos mas preocupados por la fiabilidad y la latencia que por el coste. Seis meses despues, procesabamos 100.000 interacciones de IA cada dia y la factura de infraestructura habia pasado de algo que apenas notabamos a algo que podia determinar la viabilidad economica de todo el producto.
Esta es la historia de como controlamos ese coste — no sirviendo peor IA, sino siendo mas inteligentes respecto a lo que realmente necesitabamos computar. Las tecnicas que acabaron importando mas fueron prompt caching, cache de respuestas con Redis, model tiering y batching. Cada una ahorro una parte significativa. Juntas evitaron una crisis de costes.
Como 100k/dia te pilla desprevenido
En el lanzamiento las cuentas cuadraban. GPT-4o a aproximadamente $0,005 por 1k tokens de entrada y $0,015 por 1k tokens de salida, con una interaccion media de ~1.200 tokens de entrada y ~400 de salida: unos $0,012 por peticion. A 5.000 peticiones diarias son $60/dia, o unos $1.800/mes. Asumible.
Despues el uso crecio. Siempre crece mas rapido de lo que decia la hoja de calculo. Para cuando alcanzamos las 100k interacciones diarias, esa misma tarifa por peticion se convierte en $1.200/dia, $36.000/mes. No es catastrofico para un SaaS sano, pero aun estabamos en fase de crecimiento — y lo mas importante, la base de 100k era un suelo, no un techo. Necesitabamos reducir el coste por interaccion antes de que llegara el siguiente punto de inflexion.
Lo otro que nos pillo desprevenidos: no todas las interacciones son iguales. Una tarea de clasificacion rapida puede costar $0,002. Una sumarizacion compleja con un contexto recuperado grande puede llegar a $0,08. Cuando agregas todo en un unico numero diario, las peticiones caras de la cola dominan la factura pero son invisibles en tus dashboards hasta que instrumentas deliberadamente para detectarlas.
Las cuatro palancas
1. Prompt caching
La mayoria de los prompts LLM en produccion tienen un componente estatico grande — el system prompt, las definiciones de herramientas, el contenido recuperado de la base de conocimiento — seguido de un componente dinamico pequeno (el mensaje real del usuario). La funcionalidad de prompt caching de la API de Anthropic Claude y el equivalente de OpenAI permiten marcar el prefijo estatico como cacheable. Las peticiones posteriores que comparten ese prefijo acceden a una cache a nivel del proveedor; pagas una fraccion del coste de tokens de entrada en los cache hits.
En nuestros flujos basados en base de conocimiento, el system prompt mas el contexto recuperado promediaban ~3.000 tokens. Ese prefijo era identico en miles de peticiones por hora. Tras habilitar el caching, los cache hits en ese prefijo se situaron en ~80-90% a lo largo de cualquier hora dada. El coste efectivo de tokens de entrada para esos tokens se redujo aproximadamente un 60-70%. El impacto en la factura total fue significativo: los tokens de entrada eran nuestra mayor linea de coste, y el prompt caching atacaba la porcion mas grande y repetitiva de ellos.
La disciplina clave: estructura tus prompts de modo que el contenido estatico vaya primero. La invalidacion de cache ocurre a nivel de token — cualquier modificacion en un caracter del prefijo invalida todo lo que viene despues en la seccion cacheada. Eso significa nada de timestamps dinamicos, IDs de usuario ni variantes de tests A/B dentro del prefijo cacheado.
2. Cache de respuestas con Redis
Para prompts deterministas — clasificacion, etiquetado, extraccion estructurada a partir de plantillas fijas — la misma entrada produce de forma fiable la misma salida. No hay razon para llamar al modelo dos veces ante una peticion identica. Implementamos una cache Redis de dos niveles: clave por coincidencia exacta para cadenas de prompt identicas, y coincidencia por similitud semantica usando distancia coseno sobre embeddings para prompts casi identicos por encima de un umbral de 0,97.
Los cache hits por coincidencia exacta son gratuitos; solo pagas la llamada de embedding en la busqueda de cache, que cuesta ordenes de magnitud menos que una completion completa. La coincidencia semantica requirio mas cuidado — con un umbral de 0,95 estabamos sirviendo ocasionalmente respuestas obsoletas para preguntas sutilmente diferentes, asi que lo ajustamos al alza hasta 0,97 y anadimos un TTL de 24 horas para coincidencias semanticas frente a 7 dias para coincidencias exactas.
Los flujos de clasificacion y etiquetado que mas se beneficiaron de esto alcanzaron tasas de cache hit de ~35-45%, reduciendo sus llamadas netas al modelo en aproximadamente la misma proporcion.
3. Model tiering
No todas las tareas necesitan GPT-4o o Claude Sonnet. Categorizamos cada flujo de trabajo por complejidad y enrutamos en consecuencia. Las tareas de clasificacion rapida, deteccion de intenciones y slot-filling simple fueron a gpt-4o-mini o claude-haiku-3. El razonamiento multi-paso, la sintesis y todo lo orientado al cliente fueron a los modelos completos. La diferencia de coste entre niveles es sustancial — los modelos mini/haiku son aproximadamente 10-20x mas baratos por token que sus equivalentes completos.
Acertar con la logica de enrutamiento fue la parte mas dificil. Evaluamos cada tipo de tarea contra un conjunto de evaluacion de referencia de 200 ejemplos antes de reasignarla a un modelo mas barato. La precision en ese conjunto tenia que mantenerse dentro de 2 puntos porcentuales respecto a la linea base del modelo completo. Las tareas que no pasaban ese umbral se quedaban en el modelo caro. Aproximadamente un 40-50% de nuestro volumen de peticiones paso la evaluacion y se movio a modelos mas baratos, reduciendo el coste combinado por interaccion en aproximadamente un 30-40%.
4. Batching
Algunos flujos de trabajo no necesitan respuestas en tiempo real. Procesamiento de documentos, generacion de informes nocturnos, tareas de etiquetado masivo — estos pueden tolerar una ventana de 15 minutos. La Batch API de OpenAI y el equivalente de Anthropic ofrecen descuentos significativos (alrededor del 50%) a cambio de procesamiento asincrono. Trasladamos todos los flujos no interactivos al modo batch. Las peticiones batch representan ahora aproximadamente el 25% de nuestro volumen total de tokens, a la mitad del coste de las llamadas sincronas.
Ingenieria de prompts orientada al coste
Una vez aplicadas las palancas arquitectonicas anteriores, la siguiente mayor ganancia viene de reducir tokens sin reducir calidad.
Presupuestos de tokens. Anadimos instrucciones explicitas a la mayoria de los system prompts: "Responde en menos de 150 palabras a menos que la tarea requiera mas." Los LLM son verbosos por defecto. Una instruccion de presupuesto de tokens reduce la longitud media de salida un 20-35% con un impacto negligible en calidad para tareas estructuradas.
Compresion de system prompts. Los system prompts originales fueron escritos por ingenieros que optimizaban para la claridad y no estaban pensando en tokens. Auditamos cada prompt y eliminamos instrucciones redundantes, fusionamos orientaciones solapadas y eliminamos ejemplos que no cambiaban la calidad de la salida. El system prompt medio se redujo de ~800 tokens a ~350 tokens tras la compresion. En flujos de alto volumen, esos 450 tokens por peticion se acumulan rapidamente.
Recorte de contexto. Los pipelines de RAG a menudo meten tanto contexto recuperado como sea posible en el prompt bajo la teoria de que mas contexto equivale a mejores respuestas. A escala eso es caro. Implementamos un evaluador de relevancia sobre los fragmentos recuperados e impusimos un limite estricto de los 5 fragmentos principales por puntuacion de relevancia, independientemente de cuantos fragmentos se recuperaran. Para la mayoria de las consultas, los fragmentos del 6 al 20 tenian una contribucion marginal casi nula a la calidad de la respuesta pero un coste real en tokens. El recorte redujo el recuento medio de tokens de entrada en aproximadamente un 25% en los flujos intensivos en RAG.
Observabilidad: que instrumentar
No puedes optimizar lo que no puedes ver. Descubrimos que los dashboards de coste agregado eran casi inutiles para la toma de decisiones — nos decian que la factura era alta pero no por que. Las metricas que realmente impulsaron la accion fueron:
- Coste por peticion, por tipo de flujo. Revela que funcionalidades son caras y si los picos de coste estan aislados en un unico flujo.
- Coste por usuario. Identifica usuarios intensivos que estan consumiendo recursos desproporcionados. Util para decisiones de modelo de precios y deteccion de abuso.
- Coste por funcionalidad. La metrica mas accionable — te dice si optimizar una funcionalidad o subir su nivel de precio.
- Histogramas de distribucion de tokens. La peticion del percentil 95 suele ser 3-5x la mediana. Entender la cola es esencial para limitar costes desbocados.
- Tasa de cache hit, por tipo de cache. Una tasa de cache hit de prompt por debajo del 70% en un flujo estable significa que la estructura de tu prompt es demasiado dinamica.
Construimos un callback de coste en LangChain que etiquetaba cada llamada LLM con el nombre del flujo, el ID de usuario y el contexto de feature flags, y luego enviaba los recuentos de tokens y los IDs de modelo a una capa de agregacion ligera. Este es el nucleo:
# LangChain cost callback — tags every LLM call with workflow context
from langchain.callbacks.base import BaseCallbackHandler
from langchain_core.outputs import LLMResult
import time
# Approximate cost per 1k tokens (USD), update as pricing changes
MODEL_COST_PER_1K = {
"gpt-4o": {"input": 0.005, "output": 0.015},
"gpt-4o-mini": {"input": 0.00015, "output": 0.0006},
"claude-3-5-sonnet": {"input": 0.003, "output": 0.015},
"claude-3-haiku": {"input": 0.00025, "output": 0.00125},
}
class CostTrackingCallback(BaseCallbackHandler):
def __init__(self, workflow: str, user_id: str, metrics_client):
self.workflow = workflow
self.user_id = user_id
self.metrics = metrics_client
self._start_time = None
def on_llm_start(self, serialized, prompts, **kwargs):
self._start_time = time.monotonic()
def on_llm_end(self, response: LLMResult, **kwargs):
usage = response.llm_output.get("token_usage", {})
model = response.llm_output.get("model_name", "unknown")
rates = MODEL_COST_PER_1K.get(model, {"input": 0, "output": 0})
input_tokens = usage.get("prompt_tokens", 0)
output_tokens = usage.get("completion_tokens", 0)
cost_usd = (
input_tokens / 1000 * rates["input"] +
output_tokens / 1000 * rates["output"]
)
latency_ms = (time.monotonic() - self._start_time) * 1000
self.metrics.record({
"workflow": self.workflow,
"user_id": self.user_id,
"model": model,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"cost_usd": cost_usd,
"latency_ms": latency_ms,
"cache_hit": usage.get("cached_tokens", 0) > 0,
})
Estrategia de cache con Redis para prompts deterministas
El decorador Redis a continuacion envuelve cualquier llamada a una chain de LangChain. Calcula una clave de coincidencia exacta a partir del prompt serializado, consulta la cache y recurre al modelo solo en caso de miss. La ruta semantica basada en embeddings se activa por separado para flujos donde los prompts casi identicos son habituales.
import hashlib, json, redis
from functools import wraps
from typing import Callable, Any
r = redis.Redis.from_url("redis://localhost:6379", decode_responses=True)
def llm_cache(ttl: int = 86400, prefix: str = "llm"):
"""
Exact-match response cache for deterministic LLM calls.
ttl: seconds. Set lower for time-sensitive content.
"""
def decorator(fn: Callable) -> Callable:
@wraps(fn)
async def wrapper(*args, **kwargs) -> Any:
# Build a stable cache key from all inputs
key_data = json.dumps(
{"args": args, "kwargs": kwargs},
sort_keys=True, default=str
)
cache_key = f"{prefix}:" + hashlib.sha256(
key_data.encode()
).hexdigest()
cached = r.get(cache_key)
if cached:
# Deserialise and return without hitting the model
return json.loads(cached)
result = await fn(*args, **kwargs)
# Only cache if result looks valid (no error sentinel)
if result and not getattr(result, "error", None):
r.setex(cache_key, ttl, json.dumps(result, default=str))
return result
return wrapper
return decorator
# Usage: wrap any chain invoke
@llm_cache(ttl=604800, prefix="classify") # 7-day TTL for classification
async def classify_document(text: str, categories: list[str]) -> dict:
return await classification_chain.ainvoke({
"text": text, "categories": categories
})
Cuando NO cachear
El caching no siempre es apropiado. Tres categorias donde deliberadamente lo omitimos:
Flujos sensibles al cumplimiento normativo. En contextos regulados — asesoramiento financiero, informacion medica, resumenes legales — servir una respuesta cacheada de hace un mes conlleva responsabilidad real. El prompt puede haber sido identico pero los pesos del modelo o el contexto regulatorio pueden haber cambiado. Para estos flujos aplicamos una politica de no-cache a nivel de decorador y aceptamos el mayor coste como un requisito de negocio.
Respuestas personalizadas por usuario. Si la salida incorpora historial del usuario, preferencias o datos de perfil que pueden cambiar entre peticiones, el caching por coincidencia exacta servira la personalizacion incorrecta para el estado incorrecto. La tasa de hit en estos prompts tiende a ser baja de todos modos — el sufijo dinamico impide la deduplicacion.
Flujos en tiempo real y basados en eventos. Cualquier cosa donde la frescura es la funcionalidad — analisis de datos en vivo, resumenes en tiempo real, razonamiento sobre el estado actual — no deberia cachearse mas alla de un TTL muy corto, si es que se cachea. Usamos un TTL de 60 segundos para estos y lo tratamos como coalescencia en lugar de cache real.
Logica de enrutamiento de modelos
La funcion de enrutamiento a continuacion se ejecuta antes de cada llamada LLM. Comprueba el tipo de tarea contra una tabla de configuracion, verifica el estado de la cache y selecciona el modelo mas barato que cumple el umbral de calidad para ese tipo de tarea.
from enum import Enum
from dataclasses import dataclass
class ModelTier(str, Enum):
FAST = "fast" # gpt-4o-mini / claude-3-haiku — cheap, low latency
FULL = "full" # gpt-4o / claude-3-5-sonnet — expensive, high quality
BATCH = "batch" # async batch endpoint — 50% discount, 15 min SLA
@dataclass
class RoutingRule:
tier: ModelTier
fast_model: str
full_model: str
max_tokens: int
cacheable: bool
# Per-workflow routing config, maintained as data not code
ROUTING_TABLE: dict[str, RoutingRule] = {
"classify": RoutingRule(ModelTier.FAST, "gpt-4o-mini", "gpt-4o", 150, True),
"tag": RoutingRule(ModelTier.FAST, "claude-3-haiku", "claude-3-5-sonnet", 100, True),
"summarise": RoutingRule(ModelTier.FULL, "gpt-4o-mini", "gpt-4o", 500, False),
"extract": RoutingRule(ModelTier.FAST, "gpt-4o-mini", "gpt-4o", 300, True),
"generate": RoutingRule(ModelTier.FULL, "gpt-4o-mini", "gpt-4o", 800, False),
"report": RoutingRule(ModelTier.BATCH, "gpt-4o-mini", "gpt-4o", 2000, False),
}
def resolve_model(task: str, force_full: bool = False) -> tuple[str, RoutingRule]:
rule = ROUTING_TABLE.get(task)
if not rule:
raise ValueError(f"Unknown task type: {task}")
if force_full or rule.tier == ModelTier.FULL:
return rule.full_model, rule
return rule.fast_model, rule
Donde acabamos. Tras implementar las cuatro palancas mas los cambios de ingenieria de prompts, nuestro coste combinado por interaccion se redujo aproximadamente un 55-65% respecto al pico. Pasamos de una trayectoria que iba a ser insostenible a 500k/dia a una en la que la economia unitaria mejora con la escala — porque tanto las tasas de cache hit como los ratios de batch aumentan a medida que crece el volumen. El trabajo llevo unas seis semanas de tiempo de ingenieria distribuidas entre el equipo, y el retorno de inversion fue inmediato.
El mayor error que cometimos al principio fue tratar la factura de IA como un unico numero a optimizar. No lo es. Es un portfolio de flujos de trabajo, cada uno con su propio perfil de coste, potencial de caching y equilibrio calidad-coste. Instrumenta primero. Enruta y cachea segundo. Comprime prompts tercero. En ese orden, las ganancias se componen.