02IA7 min

Ninguna capa de abstracción me ha ahorrado una migración de modelo

Seis cambios de modelo de IA en año y medio, dos intentos de taparlo con OpenRouter y LangChain, y la conclusión incómoda de que lo que se rompe no es la llamada sino lo que el modelo acepta.

Por Salva Castells

Imagen de portada: Tim Green, CC BY 2.0.
En este post

La última vez fue con un reranker. Tengo un buscador semántico de anime que saca veinticinco candidatos por embeddings y luego le pide a un LLM que los ordene. Desde el primer día la llamada llevaba temperature: 0, porque si reordenas resultados quieres que el mismo input dé el mismo orden, y eso es lo primero que se te ocurre. Cambié el modelo por uno de razonamiento, hice deploy, y la llamada empezó a fallar. El modelo nuevo no ignora temperature: la rechaza con un error.

Lo gracioso es lo que vino después. Borrar la línea habría "funcionado", pero me habría quedado con un reranker sin ninguna palanca de control, así que la sustituí por reasoning_effort, la saqué a una constante con low por defecto y me acordé (tarde, cuando ya estaba cerrando el portátil) de que el mismo modelo se llamaba desde otro sitio: un script de ingesta que sintetiza premisas de franquicia. Tres ficheros, una variable de entorno nueva y un documento que actualizar. Por un parámetro.

Llevo seis migraciones de modelo en año y medio entre varios proyectos, y todas empezaron pareciendo cambiar una cadena de texto.

Lo que se rompe, más o menos por orden de gravedad

Si las pongo en fila, se reparten en cuatro tipos.

El más tonto es el del parámetro que desaparece, como el temperature de arriba. Lo que me costó entender es que no tiene traducción: reasoning_effort no es "la temperatura de los modelos de razonamiento", es otra palanca que controla otra cosa. No migras un valor, decides uno nuevo sin tener ni idea de si low se parece en algo al 0 que tenías.

Luego está la forma del payload. Pasé un parser de visión de Chat Completions a la Responses API y pensé que era cambiar el nombre del método. messages pasa a input, {"type": "text"} pasa a input_text, y image_url deja de ser un objeto {url, detail} para ser una cadena con detail al lado, al mismo nivel. Nada de eso es difícil, pero todo es distinto.

En esa misma migración cambió la forma de la respuesta. response.choices[0].message.content pasó a response.output_text, y el contador de tokens dejó de ser un número: ahora vienen input_tokens y output_tokens por separado. Como el precio de entrada y salida es distinto, no podía sumarlos y seguir, así que la función pasó de devolver -> str a -> tuple[str, int, int] y tuve que acumularlos a mano al recoger los futures que corrían en paralelo. Un cambio de SDK que acaba tocando la firma de tus funciones.

Y el cuarto, el que me quita el sueño: la calidad cambia y no te enteras. El modelo responde, el JSON valida contra el esquema, los tests pasan, y los resultados son peores. Los tres primeros te explotan en el primer deploy. Este te lo comes durante semanas, hasta que alguien dice "oye, ¿la búsqueda no iba mejor antes?".

Dos veces intenté taparlo con una capa

La reacción lógica es meter algo por encima que te aísle del proveedor. Lo hice dos veces y en las dos me salió regular.

En un SaaS de transcripción tenía un lib/claude.ts con un processWithClaude(text, prompt) que llamaba directo al SDK de Anthropic. Lo borré entero y lo cambié por un AIService singleton contra OpenRouter, con modelName y temperature como parámetros que viajaban desde el FormData del endpoint. Eso sí fue una mejora real: cambiar de modelo pasó a ser mandar otra cadena en la petición.

En una app de chat con proyectos y contextos la capa fue LangChain, con @langchain/anthropic y @langchain/google-genai, para poder montar un desplegable de modelos agrupado por proveedor. Ahí descubrí que la capa también se mueve. Uno de los primeros commits del proyecto se llama, literalmente, "update dependencies for @langchain/anthropic and zod to latest versions". Actualizar a latest una abstracción que envuelve APIs que cambian cada pocos meses es cambiar un problema que entiendes por uno que no.

Lo que hacen estas capas es normalizar la forma de la llamada, y eso está bien. Lo que no pueden normalizar es qué acepta cada modelo. Si el modelo A acepta temperature y el B no, la capa te deja pasarlo en los dos casos y el error te llega igual, solo que desde dentro de una librería y con el stack trace tres niveles más abajo de donde tú escribiste la llamada.

Ya sé lo que dirá alguien: "para eso LiteLLM tiene drop_params". Lo tiene, y descarta en silencio los parámetros que el modelo no soporta. A mí eso me parece peor. Mi temperature: 0 estaba ahí porque quería un orden reproducible; si la librería se lo come sin avisar, el reranker sigue funcionando, deja de ser determinista y nadie lo sabe. Prefiero el error.

Donde más tiempo perdí fue en mi propio código

El día que cambié un generador de horarios de google/gemini-3-flash-preview a deepseek/deepseek-v3.2, el diff tocó app/api/generate/route.ts, app/api/adjust-schedule/route.ts y app/api/verify-checks/route.ts. El mismo literal, copiado tres veces dentro de tres JSON.stringify. Eso no es culpa de ningún proveedor. Cuando el nombre del modelo está incrustado donde haces la llamada, migrar es un buscar y reemplazar y cruzar los dedos para no dejarte uno.

En un servicio de análisis de documentos intenté arreglarlo de una vez centralizando modelos y cálculo de coste en un fichero de constantes. Me salieron tres refactors en tres semanas, uno de ellos con la errata en el mensaje del commit ("reafactor constants"), que da una idea de cómo iba la tarde. No cuajaba porque estaba metiendo en la misma estructura dos cosas que cambian a ritmos distintos: qué modelo uso para qué tarea, y cuánto cuesta cada modelo. Lo primero es una decisión mía; lo segundo es una tabla que cambia cuando el proveedor quiere.

Lo que hago ahora (y el agujero que sigue ahí)

El modelo nunca va como literal en el punto de llamada. Siempre una constante que se lee del entorno con un valor por defecto, del estilo RERANK_MODEL = process.env.RERANK_MODEL || "gpt-5.4-mini". Cambiar de modelo en producción no debería pedir un deploy.

Los parámetros que dependen del modelo van con el modelo, no sueltos por las firmas de las funciones:

type ModelParams =
  | { temperature: number }
  | { reasoning_effort: "low" | "medium" | "high" };

type ModelConfig = {
  id: string;
  params: ModelParams;
};

export const RERANK: ModelConfig = {
  id: process.env.RERANK_MODEL || "gpt-5.4-mini",
  params: { reasoning_effort: "low" },
};

Y aquí es donde tengo que admitir que no lo tengo resuelto. El id puede venir del entorno, pero params está en el código. Si mañana alguien pone en RERANK_MODEL un modelo que no acepta reasoning_effort, vuelvo a estar en la casilla de salida. He pensado en meter todo el objeto en una variable de entorno como JSON, o en un mapa de capacidades por modelo, y ninguna de las dos me convence lo suficiente como para haberla hecho. De momento el tipo al menos obliga a quien añade un modelo a pensar qué palanca usa, que ya es algo.

Lo otro que sí hago siempre es desconfiar de la respuesta en el borde. Cuando añadí timeout a las llamadas de OpenAI, en el mismo commit metí esto en el batch de embeddings:

if (embeddings.length !== input.length) {
  throw new Error(
    `Embedding provider returned ${embeddings.length} vectors for ${input.length} inputs`
  );
}

Parece una tontería. Lo que evita es un desalineamiento silencioso entre vectores y textos, que es de los peores fallos que puedes tener en un buscador: la base se llena de embeddings asignados al anime equivocado y los resultados son raros de una forma que no sabes explicar. Con la comprobación, falla en el momento. El AbortSignal.timeout(30_000) del mismo commit hace lo mismo con las llamadas que se quedan colgadas.

Para el cuarto tipo de rotura, el de la calidad, no tengo una respuesta buena. Lo mínimo sensato es guardarse un puñado de consultas con el resultado que consideras correcto y repasarlas cada vez que cambias de modelo, aunque sea a ojo. Lo digo sabiendo que lo hago menos de lo que debería.

Si me preguntas qué doy por estable de un proveedor de LLM, la lista es corta: que hay un endpoint HTTP, que acepta JSON y que devuelve JSON. El nombre del modelo, los parámetros, la forma del mensaje, dónde viene el contador de tokens, el precio y que el modelo siga existiendo dentro de seis meses, todo eso lo trato como configuración que va a cambiar. Casi siempre antes de lo que me gustaría.