El modelo ajustado es la parte más interesante de mi pipeline y la que menos confianza me inspira.
Se ejecuta en una GPU bajo demanda que no administro, detrás de una cola que no puedo inspeccionar, dentro de un contenedor que construí yo, y sirve unos pesos que entrené con 400 ejemplos. Cualquier otro componente de la arquitectura lleva más horas en producción que los minutos de vida de mi adaptador.
Por eso no puede sostener el producto por sí solo. Detrás hay un modelo general que puede atender la siguiente petición sin desplegar nada, sin cambiar código y sin que el usuario tenga que saber cuál respondió.
Esa era la condición para que resultara razonable publicar el modelo propio.
Ya escribí sobre la estructura general de los sistemas de IA fiables. Esta es la implementación concreta, incluida la parte que hice mal al principio.
La versión ingenua falla en la métrica
Escribir una alternativa es fácil. Pruebas el modelo bueno con try, capturas el fallo con catch y llamas al otro. Veinte minutos.
Después añades un contador, porque una alternativa sin supervisión solo tarda más en estar rota, y el contador se vuelve inútil de inmediato.
La etapa del redactor puede terminar de cuatro formas, y solo una representa un problema:
| Situación | Proveedor registrado | Cuenta como fallback |
|---|---|---|
| El LoRA funciona | runpod | no |
RUNPOD_WRITER_ENABLED=false | openrouter | no |
Idioma fuera de RUNPOD_WRITER_LOCALES | openrouter | no |
| El LoRA falla o devuelve una salida inválida | openrouter_fallback | sí |
Las filas dos y tres son el sistema funcionando como se diseñó. El adaptador solo admite inglés, así que cada petición en español se envía al redactor general a propósito. Es un resultado correcto. Si lo cuento como fallback, la tasa se queda cerca del 90 % de forma permanente. La cifra que debería avisarme de que el modelo propio está fallando no me dice nada, porque ya marca un 90 % en un día sin problemas.
Por eso la distinción está en el código y no en un filtro del panel que alguien tiene que recordar:
// Being switched off or skipped by design is not a failure, so those stay
// plain 'openrouter'. Only a genuine RunPod problem counts as a fallback.
const isRealFailure =
failureReason !== null &&
failureReason !== 'disabled' &&
failureReason !== 'locale_unsupported'
Se usan tres valores de proveedor: runpod, openrouter y openrouter_fallback. El del medio es la ruta prevista por diseño y se registra como un éxito.
Una alerta que aprendiste a ignorar cuesta atención cada vez que se dispara y no aporta nada. Gran parte del trabajo de supervisión consiste en decidir qué no cuenta.
El segundo redactor debe cumplir el mismo estándar
No había previsto una ruta alternativa que redujera la calidad en silencio.
Si compruebo la seguridad y la longitud del redactor propio, pero no las del general, cada fallo del modelo propio se convierte en una respuesta sin revisar. La alternativa estaría convirtiendo un fallo visible en una pérdida de calidad silenciosa, precisamente lo que el diseño intenta evitar.
Por eso la salida del LoRA pasa por el mismo control de calidad que ya superaba el redactor general. Cualquiera de estas señales envía la petición al otro modelo:
const DISQUALIFYING_QUALITY_FLAGS = [
'unsafe_language',
'too_technical',
'mentions_literal_objects',
'too_long',
]
mentions_literal_objects es una regla del producto. La función existe para decir qué transmite una foto. Una reacción que enumera lo que aparece en la imagen ha fallado, aunque la frase esté bien escrita. Si un modelo produce eso, su salida está fuera de especificación y debe sustituirse antes de llegar al usuario.
Una señal se excluye a propósito. El comentario explica por qué, para que nadie vuelva a añadirla:
/**
* `too_generic` is excluded: it is a soft style signal that the OpenRouter path
* retries rather than hard-fails on, and treating it as fatal here would inflate
* the fallback rate without a safety benefit.
*/
too_generic es una señal de estilo que la ruta de OpenRouter intenta resolver con un reintento. Tratarla aquí como un fallo definitivo inflaría la tasa de fallback sin mejorar la seguridad.
Es el mismo criterio que en la clasificación de proveedores, aplicado una capa más abajo. Que una señal merezca registrarse no significa que deba hacer fallar la petición.
Dieciocho formas de fallar sin trasladarle el problema al usuario
El cliente del redactor propio nunca lanza una excepción. Todas las rutas devuelven un resultado tipado:
export type RunpodFailureReason =
| 'disabled' | 'missing_api_key' | 'missing_endpoint_id'
| 'invalid_vision_json' | 'timeout' | 'network_error'
| 'unauthorized' | 'rate_limited' | 'server_error'
| 'service_unavailable' | 'http_error' | 'malformed_json'
| 'job_failed' | 'job_not_completed' | 'missing_output'
| 'empty_reaction' | 'prompt_leakage' | 'reaction_too_long'
Dos detalles de esa lista.
Distingue mis fallos de los de la plataforma. missing_api_key es un error de despliegue. rate_limited es un problema de capacidad. prompt_leakage es el propio modelo comportándose mal. Reducirlos todos a error me dejaría leyendo registros sin procesar justo cuando necesito una respuesta rápida.
Nada de eso llega al cliente. El motivo se guarda en una columna de la base de datos y en una línea de registro estructurada. La aplicación móvil recibe siempre una respuesta con la misma estructura, la produzca quien la produzca. El usuario no necesita mi clasificación de problemas de infraestructura. Un formato que cambiara según el modelo obligaría a cada cliente a conocer la ruta alternativa.
El resultado tipado también evita que esa alternativa se omita por accidente. Una excepción lanzada varios niveles más abajo puede escapar de un bloque try que alguien reorganizó. Un tipo unión devuelto exige que quien lo llama lo gestione: el compilador lo comprueba.
El interruptor de emergencia es un valor de configuración
Para desactivar toda la ruta del modelo propio:
supabase secrets set RUNPOD_WRITER_ENABLED=false
Sin volver a desplegar ni compilar. La siguiente invocación lee el valor nuevo y todas las peticiones pasan al redactor general.
Si volver atrás exige desplegar, entre el problema y la solución hay una compilación, una cola y un arranque en frío. Y lo necesitaré en el peor momento. Cualquier interruptor que quiera usar durante un incidente debe estar disponible sin publicar código.
El mismo principio se aplica al enrutamiento por idioma. Los idiomas admitidos se leen de la configuración:
export function runpodSupportsLocale(locale: string, supportedLocales = ['en']): boolean {
return supportedLocales.includes(writerLanguage(locale))
}
Cuando termine de entrenarse un adaptador en español, ampliar el modelo propio a un segundo idioma será cambiar RUNPOD_WRITER_LOCALES=en,es. Si la comprobación siguiera siendo if (locale === 'en'), como la escribí al principio porque era más corta y clara, ese entrenamiento requeriría además un cambio de código, una revisión y un despliegue para llegar a producción. La versión configurable complica un poco el código y mejora el sistema.
El coste real
Dos redactores implican dos formatos de prompt, dos validadores de salida, dos conjuntos de pruebas y la obligación permanente de mantener operativa la ruta general, incluso cuando pasa largos periodos atendiendo casi nada. La mayor parte del tiempo es trabajo adicional sin uso visible.
También es lo que me permite dormir después de desplegar un modelo que entrené yo.
Hay un fallo que la alternativa no puede detectar: solo se activa cuando la salida es incorrecta de una forma detectable. Vacía, con instrucciones filtradas, insegura, demasiado larga o fuera de especificación. Una reacción fluida, bien formada y pertinente, pero peor que la que habría producido el modelo general, supera todas las comprobaciones y se registra como un éxito de runpod.
La instrumentación puede demostrar que el redactor propio está vivo. No puede demostrar que sea mejor. Eso necesita un conjunto de evaluación y comparaciones puntuadas por personas. Hasta que lo haga, lo que puedo afirmar es que tengo una forma fiable de servir un modelo cuya superioridad no he medido.
Es raro publicar eso sobre tu propio trabajo, pero es su estado real.
Un pequeño voto de confianza
¿Valió la pena leer esta nota?