Routing de modelos con OpenRouter y DeepSeek para minimizar costos sin perder calidad
Cómo enruté modelos en producción con OpenRouter usando DeepSeek para la mayor parte del tráfico y modelos premium solo donde importa, recortando costos de LLM sin degradar la calidad.
El routing de modelos consiste en enviar cada petición al modelo más barato que aún resuelve la tarea con la calidad que necesitas, en lugar de mandar todo a un único modelo caro. En producción lo implementé con OpenRouter como capa única de acceso: DeepSeek para la mayor parte del tráfico (clasificación, extracción, resúmenes) y un modelo premium como Claude solo para los casos donde la calidad es crítica. Es la forma más directa de reducir costos de IA sin que el usuario note diferencia, porque la mayoría de las peticiones reales no necesitan el modelo más caro.
Resumen para perezosos
- Manda la mayor parte del tráfico (clasificar, extraer, resumir) a un modelo barato como DeepSeek y reserva el premium (Claude) solo para lo difícil.
- Valida la salida del barato y escala al premium solo si falla. Limpia el JSON (fences, comas colgantes) antes de rendirte, para no escalar de más.
- Mídelo todo por modelo y feature. Con volumen y tareas de dificultad desigual, el ahorro ronda el 80-90%.
En este artículo:
- Fundamentos — Qué es y por qué reduce costos · Cuánto se puede ahorrar
- Implementación — DeepSeek vs Claude por tarea · Configurar OpenRouter · Decidir el modelo · Validación y fallback
- Operación — Observabilidad · Prompt caching · Limitaciones · Cuándo NO usar routing
Qué es el routing de modelos y por qué reduce costos
La idea de fondo es simple: no todas las peticiones a un LLM tienen la misma dificultad. Clasificar un texto en una de tres categorías no requiere el mismo modelo que redactar una respuesta legal matizada. Cuando mandas todo al modelo top, pagas precio premium por tareas que un modelo más barato resuelve igual de bien. Ahí es donde se va el dinero: no en el volumen, sino en usar capacidad cara para trabajo barato.
El routing rompe esa uniformidad. Defines reglas o una señal de complejidad, y según eso eliges el modelo destino. La diferencia de precio entre niveles de modelo es de varios órdenes de magnitud por millón de tokens, así que mover incluso una fracción del tráfico al nivel barato cambia la factura por completo. Por eso el routing es, en la práctica, una de las formas más efectivas de ahorrar tokens y bajar el gasto de un proyecto con IA.
Cuánto dinero se puede ahorrar
Empiezo por lo que casi todo el mundo quiere saber antes de leer el resto: cuánto se ahorra.
Un ejemplo con precios públicos de OpenRouter (junio de 2026): DeepSeek V4 Flash cuesta unos 0.09 USD por millón de tokens de entrada y 0.18 de salida, mientras que un modelo premium como Claude Opus ronda los 5 USD de entrada y 25 de salida. Para una carga de 50 millones de tokens de entrada y 10 de salida al mes:
- Todo al premium: del orden de 500 USD mensuales.
- 90% al barato, 10% al premium: alrededor de 55 USD mensuales.
Costo mensual (ejemplo: 50M tokens in / 10M out)
Todo premium ████████████████████████████ ~500 USD
Con routing ███ ~55 USD
↓ ~89% menos
Es cerca de un 9x de reducción solo por separar las tareas fáciles de las difíciles. Los números exactos dependen de tu mezcla de tráfico, pero el orden de magnitud es ese: cuando el modelo caro pasa de resolver el 100% a resolver el 10%, la factura cae casi en proporción.
En uno de mis proyectos con este patrón de tráfico, la factura mensual bajó aproximadamente de 430 a 60 USD tras introducir el routing, con cerca del 90% de las peticiones resueltas por el modelo barato. Son cifras redondeadas y dependen del proyecto, pero el orden de magnitud se sostiene siempre que tengas volumen y una mezcla de tareas de dificultad desigual.
DeepSeek vs Claude: qué tareas aguanta el modelo barato
La promesa de “sin perder calidad” solo se sostiene si sabes qué tareas puede asumir el modelo barato y cuáles no. Esta es la parte que de verdad decide si el routing funciona: elegir el modelo LLM adecuado para cada tipo de tarea, no el más capaz para todas.
Mi regla, basada en pruebas sobre tráfico real, quedó así:
| Tipo de tarea | Calidad DeepSeek vs Claude | ¿Necesita premium? |
|---|---|---|
| Clasificación | Equivalente | No |
| Extracción de campos | Equivalente | No |
| Resúmenes | ~98% del premium | Rara vez |
| Redacción de cara al usuario | ~90% | Sí |
| Razonamiento multipaso | ~70% | Sí |
Son cifras aproximadas de mis pruebas sobre tráfico real, no un benchmark formal, pero el patrón es claro: donde la salida sigue un formato predecible y se puede validar (clasificar, extraer, resumir), el modelo barato rinde muy cerca del premium. Donde hace falta razonamiento abierto, seguimiento estricto de instrucciones largas o redacción matizada, el premium sí marca diferencia. Por eso DeepSeek no es “el modelo”, es el modelo por defecto, y existe una ruta de escape hacia algo más capaz.
Cómo configurar OpenRouter
OpenRouter expone decenas de modelos de distintos proveedores detrás de una sola API compatible con el SDK de OpenAI. Cambiar de modelo es cambiar un string, que es justo lo que necesita el routing. La configuración es mínima: cambias baseURL y la API key.
// lib/openrouter.ts
import OpenAI from "openai";
// OpenRouter habla el mismo protocolo que OpenAI, solo cambia el endpoint.
export const client = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: process.env.OPENROUTER_API_KEY,
// HTTP-Referer y X-Title son opcionales; OpenRouter los usa para atribución.
defaultHeaders: {
"HTTP-Referer": "https://ramonchancay.me",
"X-Title": "Personal Site",
},
});
Los IDs de modelo siguen el formato proveedor/modelo, por ejemplo deepseek/deepseek-v4-flash o anthropic/claude-opus-4.8. Con esto ya tienes acceso a todos los modelos; lo interesante no es la conexión, sino la lógica que decide a cuál va cada petición.
Cómo decidir qué modelo usa cada petición
Aquí está el centro del asunto. Hay dos enfoques y conviene entender el trade-off antes de elegir.
El primero es routing por reglas: clasificas la tarea por su tipo y mapeas cada tipo a un modelo. Es determinista, depurable y gratis de ejecutar porque no hay una llamada extra. El precio que pagas es mantenimiento: las reglas las defines a mano y hay que ajustarlas cuando aparecen casos nuevos.
El segundo es routing por complejidad estimada: un modelo pequeño y barato evalúa la petición y decide si necesita el modelo grande. Es más flexible, pero añade latencia y una llamada extra, y mete un punto de fallo más. Empecé por reglas porque la mayoría de mis tareas caían en categorías claras, y la previsibilidad valía más que la flexibilidad.
El flujo por reglas es directo:
Usuario
│
▼
Tipo de tarea
│
├── classify ──────► DeepSeek
├── extract ───────► DeepSeek
├── summarize ─────► DeepSeek
├── draft ─────────► Claude
└── reason ────────► Claude
│
▼
respuesta
Que en código es una sola tabla de routing:
// lib/model-router.ts
// Niveles de modelo: el barato resuelve el grueso, el premium es la excepción.
const MODELS = {
cheap: "deepseek/deepseek-v4-flash",
premium: "anthropic/claude-opus-4.8",
} as const;
type TaskType = "classify" | "extract" | "summarize" | "draft" | "reason";
// El mapeo explícito es la "política de routing": fácil de leer y de auditar.
const ROUTING: Record<TaskType, keyof typeof MODELS> = {
classify: "cheap",
extract: "cheap",
summarize: "cheap",
draft: "premium", // redacción de cara al usuario: aquí sí pago calidad
reason: "premium", // razonamiento multipaso: el barato se queda corto
};
export function pickModel(task: TaskType): string {
return MODELS[ROUTING[task]];
}
La primera versión no era así: tenía el routing repartido en varios if por el código. Funcionaba, pero cuando la factura subía era imposible saber por qué una petición había acabado en el modelo caro. Después de unas semanas lo moví a esta tabla, y la diferencia no fue el rendimiento sino la depurabilidad: la política de routing pasó a ser un objeto que puedes leer de un vistazo. Cuando alguien pregunta “¿por qué este flujo cuesta tanto?”, la respuesta está en una sola tabla, no enterrada en condicionales repartidos por el código.
Con pickModel resolviendo el destino, la llamada en sí es la de siempre del SDK de OpenAI: le pasas el model que devuelve el router y, para tareas estructuradas, una temperatura baja para que la salida sea estable. Vale la pena centralizar esa llamada en una sola función y que devuelva, junto al texto, el model que se usó. Ese dato parece un detalle, pero es lo que después te deja medir qué porcentaje del tráfico fue a cada nivel. Sin él, optimizar costos es a ciegas.
La tabla de routing cubre el caso por defecto, pero conviene dejar una salida manual: una opción fast (o un tier explícito) en la propia función de llamada, para que quien la invoca pueda forzar el modelo cuando sabe algo que la tabla no. Por ejemplo, una clasificación normalmente barata que en cierto flujo necesita más capacidad. La regla decide por defecto; el flag es el escape para los casos que la regla no ve.
Cómo evitar perder calidad: validación y fallback
El riesgo del modelo barato no es que sea malo, es que falla de forma distinta. A veces devuelve un JSON mal formado, a veces ignora una instrucción de formato. La defensa que me funcionó es validar la salida y, si no pasa, reintentar con el modelo premium. Así el costo extra solo aparece en los casos que de verdad lo necesitan.
Petición
│
▼
DeepSeek
│
¿salida válida?
│
├── Sí ──► devolver (barato)
│
└── No ──► Claude ──► devolver (premium)
En código, ese flujo es:
// lib/extract-with-fallback.ts
import { client } from "./openrouter";
const CHEAP = "deepseek/deepseek-v4-flash";
const PREMIUM = "anthropic/claude-opus-4.8";
// Parseo tolerante: limpia los quirks típicos de salida de un LLM antes de
// rendirse. Muchos "fallos" del modelo barato son solo formato, no calidad.
function parseJSON<T>(text: string): T {
// Quita fences de markdown: ```json ... ```
const fence = text.match(/```(?:json)?\s*\n([\s\S]*?)\n\s*```/i);
if (fence) text = fence[1];
text = text.trim();
// Quita comas colgantes antes de } o ]
text = text.replace(/,\s*([}\]])/g, "$1");
return JSON.parse(text) as T;
}
// Validamos que la salida sea el JSON que esperamos antes de confiar en ella.
function isValid(raw: string): boolean {
try {
const data = parseJSON<{ category?: unknown; tags?: unknown }>(raw);
return typeof data.category === "string" && Array.isArray(data.tags);
} catch {
return false;
}
}
export async function extract(prompt: string) {
// Primer intento con el modelo barato.
const first = await client.chat.completions.create({
model: CHEAP,
messages: [{ role: "user", content: prompt }],
temperature: 0.2,
response_format: { type: "json_object" },
});
const cheapText = first.choices[0]?.message?.content ?? "";
if (isValid(cheapText)) return { text: cheapText, model: CHEAP };
// Solo si el barato falla, escalamos al premium. El costo extra es la excepción.
const second = await client.chat.completions.create({
model: PREMIUM,
messages: [{ role: "user", content: prompt }],
temperature: 0.2,
response_format: { type: "json_object" },
});
return { text: second.choices[0]?.message?.content ?? "", model: PREMIUM };
}
Este patrón —intentar barato, validar, escalar si hace falta— es el que mejor equilibrio me dio. La clave es que la validación sea barata y objetiva: parsear JSON, comprobar campos obligatorios, verificar longitud. Si tu validación requiere otra llamada a un LLM, pierdes parte del ahorro.
El parseJSON tolerante de arriba importa más de lo que parece. Al principio escalaba muchísimo al premium porque usaba JSON.parse directamente, y di por hecho que el modelo barato no daba la talla. No era eso: buena parte de esos fallbacks no eran errores de calidad sino de formato. El modelo barato envolvía la respuesta en un bloque ```json o dejaba una coma colgante, y un JSON.parse a secas lo daba por fallido. El día que añadí el parseo tolerante, una buena parte de los fallbacks desaparecieron sin tocar el modelo ni el prompt. Antes de culpar al modelo barato, asegúrate de no estar escalando por una llave de más.
Un detalle que cuesta dinero si lo pasas por alto: response_format: { type: "json_object" } obliga al modelo a responder en JSON, pero no lo protege del truncamiento. Si el prompt es largo y el modelo alcanza su límite de max_tokens, el JSON se corta a la mitad y llega mal formado. Tu validador lo atrapa y escala al premium, que es lo correcto, pero ese fallback es innecesario: no falló la calidad del modelo barato, se quedó sin espacio para terminar. Por eso conviene fijar un max_tokens generoso en el modelo barato, holgado frente al tamaño máximo de salida que esperas. Es la diferencia entre escalar al premium porque de verdad hacía falta y escalar porque cortaste la respuesta tú mismo.
Structured Outputs reduce los fallbacks
Si el modelo soporta Structured Outputs (salida forzada contra un JSON Schema), úsalo en lugar de validar a mano. La diferencia frente a json_object es que no solo garantiza JSON válido, sino que garantiza tu esquema: los campos obligatorios, los tipos y los enums. Eso elimina buena parte del código de validación y, sobre todo, baja la tasa de fallbacks, porque el modelo barato deja de fallar por desviarse del formato. La validación manual sigue siendo útil como red de seguridad, pero pasa a cubrir solo los errores semánticos, no los de forma.
Trade-offs que conviene tener presentes
- Latencia del fallback: cuando una petición escala al premium, el usuario espera dos llamadas en vez de una. Si la latencia importa en ese flujo, vale la pena medir el percentil 95, no solo el promedio.
- Costo de la validación: si validar es complejo, el ahorro se erosiona. Mantén la validación en código, no en otra llamada al modelo.
- Deriva de calidad silenciosa: el modelo barato puede degradar sin disparar tu validación si esta es laxa. Conviene revisar muestras reales de vez en cuando, no confiar solo en que el JSON parsea.
- Dependencia de un proveedor intermediario: OpenRouter es un punto único en la ruta. A cambio de la comodidad de una sola API, aceptas que su disponibilidad es parte de la tuya.
Enruta por el tipo de respuesta, no solo por la dificultad
La dificultad de la tarea no es la única señal. La forma de la respuesta también decide el tier y cómo haces la llamada. En la práctica acabé con tres patrones distintos:
- JSON estructurado y corto (clasificar, extraer, decidir el propio routing): tier barato, llamada one-shot, parseo tolerante. Es donde DeepSeek brilla: salida acotada, validable y de alto volumen.
- Conversación / chat: tier barato pero con respuesta en streaming, porque el usuario espera ver el texto aparecer. La calidad exigida es media y el volumen alto, así que el modelo barato con streaming es el punto dulce.
- Generación larga de cara al usuario (informes de varios miles de tokens): tier premium con streaming. Aquí la calidad es el producto, el costo por petición es alto pero el volumen es bajo, así que pagar premium se justifica.
Es el mismo router, pero el tier depende de cuatro cosas: dificultad, formato, si es de cara al usuario y volumen. Una llamada estructurada corta y un informe de varios miles de tokens no pueden ir al mismo modelo solo porque pertenecen a la misma parte del producto. Separarlos por patrón de respuesta fue tan importante como separarlos por dificultad.
Observabilidad: sin métricas, el routing es fe
El error más común que veo es implementar routing y luego no saber si realmente ahorra. Optimizar sin medir es adivinar. Por cada petición conviene registrar, como mínimo:
- modelo usado (barato o premium)
- tipo de tarea / endpoint
- tokens de entrada y salida
- costo estimado de la llamada
- duración (para vigilar la latencia del fallback)
- si hubo fallback y por qué
- usuario o tenant, si necesitas atribuir gasto
En la práctica calculo el costo de cada llamada a partir de un mapa de precios por modelo y los tokens que devuelve la respuesta. Tener los precios en una tabla, en vez de un número mágico, hace que añadir un modelo nuevo sea una línea más:
// lib/llm-cost.ts
// Precio por millón de tokens (USD). Source: páginas de pricing de cada proveedor.
const MODEL_COSTS: Record<string, { input: number; output: number }> = {
"deepseek/deepseek-v4-flash": { input: 0.09, output: 0.18 },
"anthropic/claude-opus-4.8": { input: 5.0, output: 25.0 },
};
const PER_MILLION = 1_000_000;
export function calculateCost(model: string, inputTokens: number, outputTokens: number): number {
const costs = MODEL_COSTS[model];
if (!costs) return 0; // modelo desconocido: regístralo y revisa el mapa
return (inputTokens / PER_MILLION) * costs.input + (outputTokens / PER_MILLION) * costs.output;
}
El registro lo hago fire-and-forget: la métrica no debe bloquear ni romper la respuesta al usuario. Si el insert falla, lo logueo y sigo, nunca tumbo la petición por un problema de telemetría:
// lib/track-usage.ts
import { calculateCost } from "./llm-cost";
import { db } from "./db"; // tu cliente de base de datos (Supabase, Postgres, etc.)
// "feature" etiqueta de qué parte del producto vino la llamada, para desglosar
// el gasto por funcionalidad y no solo por modelo.
export function trackUsage(params: {
model: string;
feature: string;
inputTokens: number;
outputTokens: number;
fellBack: boolean;
}) {
const cost = calculateCost(params.model, params.inputTokens, params.outputTokens);
// Fire-and-forget: no await en el camino crítico de la respuesta.
void db.from("llm_usage").insert({ ...params, cost }).catch((err) => {
console.error("track usage error", err);
});
}
Con esos campos puedes construir un dashboard que responda las preguntas que importan: qué porcentaje del tráfico resuelve el modelo barato, qué tipos de tarea escalan más al premium, y cuánto cuesta cada feature. La métrica que más me sirvió fue el porcentaje de tráfico resuelto por el modelo barato. Si baja, algo cambió: o las peticiones se volvieron más difíciles, o tu validación se volvió demasiado estricta y está escalando de más.
Un complemento útil es guardar también un trail de auditoría de los mensajes (system, user, assistant) truncados a unos cientos de caracteres. No para leerlos todos, sino para poder reconstruir por qué una petición concreta escaló al premium cuando algo se ve raro en el dashboard.
El routing no es la única palanca: prompt caching
El routing decide qué modelo. El prompt caching decide cuánto pagas por el contexto que repites. Si tu system prompt es grande (instrucciones, ejemplos, esquema de salida) y se repite en cada petición, cachearlo abarata el input de forma brutal: el tramo cacheado se cobra a una fracción del precio normal. En flujos con system prompt estable y muchas peticiones, el caching mueve la factura tanto como el routing.
Esto tiene una consecuencia de arquitectura que conviene entender antes de añadir proveedores: el caching suele ser específico de cada proveedor. Si fragmentas demasiado entre proveedores distintos para ahorrar unos centavos por token, puedes perder el caching y terminar pagando más. Por eso conviene medir el costo efectivo con caché por modelo, no el precio de lista. A veces el modelo “más caro” con system prompt cacheado sale más barato que el “barato” sin caché. OpenRouter pasa el caching para los modelos que lo soportan, así que vale la pena verificar cuáles de tu mezcla lo aprovechan antes de decidir el routing solo por precio nominal.
En uno de mis proyectos el dashboard mostró que alrededor del 90% del tráfico se resolvía con el modelo barato y que solo un 6% aproximado de las peticiones escalaba al premium por validación fallida. Cifras redondeadas, pero la forma de la curva es lo que importa: el grueso se resuelve barato y el premium es la excepción.
Limitaciones y casos donde el routing se complica
El routing no es gratis en complejidad, y hay aristas que conviene tener claras antes de adoptarlo:
- Context windows distintos: cada modelo tiene su límite. Un prompt que cabe en el premium puede no caber en el barato (o al revés). Si enrutas dinámicamente, valida que el prompt entra en el modelo destino.
- Prompts muy largos: a más contexto, más se nota la diferencia de capacidad entre modelos, y el barato tiende a perder hilo antes. Los prompts largos suelen ser candidatos a premium.
- Tool calling y function calling: no todos los modelos manejan herramientas igual de bien, y el formato de las llamadas puede variar. Si tu flujo depende de tools, prueba cada modelo por separado antes de enrutarle tráfico.
- Visión e imágenes: si la tarea incluye imágenes, el conjunto de modelos válidos se reduce y el routing por tipo de tarea tiene que tenerlo en cuenta.
- Mismo prompt, distinta respuesta: dos modelos responden distinto al mismo prompt. Un prompt afinado para Claude puede rendir peor en DeepSeek sin ajustes. No asumas que el prompt es portable.
Ninguna de estas es razón para descartar el routing, pero sí para no tratarlo como un interruptor mágico. Cada modelo que añades a la mezcla es un modelo más que probar y mantener.
Cuándo NO usar routing de modelos
El routing añade complejidad, y no siempre compensa. Yo no lo metería si:
- Haces menos de unas 100 llamadas al día: el ahorro son unos pocos dólares al mes y no paga el código extra que hay que mantener.
- Casi todas tus llamadas son complejas: si todo necesita razonamiento profundo, no hay tráfico “fácil” que mover al modelo barato y el premium se justifica para casi todo.
- Tu aplicación necesita siempre la máxima calidad: en dominios donde un error sale caro (legal, médico, financiero), la diferencia de precio importa menos que el riesgo de degradar.
- La simplicidad pesa más que ahorrar unos dólares: un solo modelo es más fácil de razonar, depurar y mantener. A veces esa simplicidad vale más que la factura.
El routing paga cuando tienes dos cosas a la vez: volumen y una mezcla de tareas de dificultad desigual. Si te falta alguna, probablemente todavía no lo necesitas.
Preguntas frecuentes
¿Vale la pena el routing de modelos para un proyecto pequeño?
Depende del volumen. Si haces pocas peticiones al día, la diferencia en la factura es marginal y la complejidad añadida no compensa. El routing empieza a pagar cuando el tráfico es suficiente para que la diferencia de precio entre modelos sea visible en la factura mensual. Por debajo de eso, usar un solo modelo es más simple y razonable.
¿DeepSeek es lo bastante bueno para producción?
Para tareas estructuradas y validables —clasificación, extracción, resúmenes— en mi experiencia rinde muy cerca de modelos mucho más caros. Donde se queda corto es en razonamiento abierto de varios pasos y en seguimiento estricto de instrucciones largas. La estrategia que funciona no es “DeepSeek para todo”, sino “DeepSeek por defecto con escape al premium cuando la validación falla”.
¿OpenRouter añade mucha latencia frente a llamar al proveedor directo?
Añade un salto de red porque actúa de intermediario, así que hay algo de latencia extra. En la práctica fue pequeña comparada con el tiempo de generación del propio modelo. Si la latencia es crítica en un flujo concreto, conviene medirla en tu entorno antes de decidir.
¿Qué pasa si OpenRouter o el modelo barato fallan?
OpenRouter permite especificar modelos de respaldo de forma nativa: pasas un array en el parámetro model (por ejemplo ["deepseek/deepseek-v4-flash", "anthropic/claude-opus-4.8"]) y si el primero no responde, intenta el siguiente. Conviene tenerlo, pero hay una distinción sutil que importa: el fallback nativo solo se dispara ante fallos de infraestructura, es decir cuando el servidor del modelo se cae o devuelve un error 500. No mira el contenido de la respuesta.
El fallback en código que mostré antes cubre el otro caso, que en la práctica es el más frecuente: la petición sí respondió con un 200, pero el contenido es semánticamente incorrecto o el JSON está mal construido. Ahí el respaldo nativo no actúa porque para OpenRouter la llamada fue un éxito. Por eso uso los dos niveles: el array nativo me protege de que un proveedor esté caído, y mi validación me protege de que el modelo barato responda con basura bien formateada como error.
¿Routing por reglas o por complejidad estimada?
Empieza por reglas. Es determinista, no añade latencia ni una llamada extra, y es trivial de depurar. El routing por complejidad estimada —usar un modelo pequeño para decidir— solo compensa cuando tus tareas no caen en categorías claras y necesitas flexibilidad. En la mayoría de los casos reales, una tabla de routing explícita cubre la gran mayoría del tráfico.
Conclusión
El mayor error que veo es asumir que existe un único modelo ideal para todas las tareas. En la práctica, distintos problemas requieren distintos niveles de capacidad. Separar las tareas sencillas de las complejas mediante routing fue una de las optimizaciones con mayor impacto en costo que implementé, y además dejó la arquitectura preparada para incorporar nuevos modelos sin cambiar la lógica de negocio. Empieza por reglas, valida la salida del modelo barato, mide todo, y deja que los números te digan cuánto puede asumir DeepSeek antes de escalar a Claude.