Cuando decides que un producto necesita un modelo propio, lo instintivo es ajustar la parte con la que interactúa el usuario. El usuario sube una foto, así que ajustas algo que reciba fotos.
Yo hice lo contrario. El adaptador que entrené para VIOFY nunca ha visto una foto del usuario y nunca la verá.
Es un modelo de texto de 3B que recibe un resumen emocional estructurado y devuelve una o dos frases. Antes de esa etapa, un modelo de visión de propósito general procesa todos los píxeles. Lo pago por token y podría cambiarlo mañana.
Esa separación fue la decisión que hizo defendible llevar un modelo propio a producción.
Las dos tareas dentro de una sola función
La función parece una sola capacidad: mira esta foto y reacciona como lo haría un amigo.
Son dos tareas que tienen muy poco en común.
Percepción. Qué aparece en la imagen, qué ánimo transmite, si es una foto del gimnasio o de un viaje, si hay algo inseguro. Todos necesitamos esto. La versión de cada producto es más o menos la misma. Los laboratorios de modelos avanzados compiten por vendérmelo a un precio cada vez menor.
Voz. Sonar como un amigo concreto, con un registro concreto, que diga This feels earned, like the work happened when nobody was watching en lugar de Great photo! The lighting really works here. La primera frase reconoce el esfuerzo; la segunda comenta la iluminación. Nadie vende esa voz concreta. Es el producto entero.
En un modelo que hace ambas cosas, la parte que no puedo comprar queda unida a la que sí puedo contratar. Mejorar la percepción implica volver a entrenar la voz. Cambiar la voz implica volver a validar la percepción. Ambas mejoran a ritmos completamente distintos, y una mejora gratis mientras duermo.
Por eso el pipeline las separa:
image
→ general vision model (rented, swappable, sees the photo)
→ structured vision JSON (validated + normalized at the seam)
→ LoRA writer on serverless GPU (mine, never sees the photo)
→ one short reaction
La imagen pasa al modelo de visión contratado, después a un JSON validado y normalizado, y de ahí al redactor LoRA en una GPU sin servidor. Ese redactor es mío, nunca ve la foto y produce una reacción breve.
Ajusta la capa que no puedes contratar. Contrata la que se está convirtiendo en un servicio estándar. Si no puedes distinguir cuál es cuál, todavía no estás listo para ajustar un modelo.
Lo que el redactor nunca recibe
La etapa de visión devuelve un objeto bastante completo: categoría y confianza, categorías secundarias, razonamiento, ánimo percibido, señales emocionales y también lighting_and_colors, subjects_and_composition, key_contextual_objects, además de varias señales de riesgo.
El redactor recibe esto:
{
"category": "fitness",
"emotional_focus": ["discipline", "consistency", "progress", "self-respect", "effort", "growth"],
"avoid_focus": ["body rating", "sexualized comments", "unsafe fitness advice", "only describing muscles"],
"vision_summary": "focused. discipline, effort, quiet determination. Training effort is the emotional center.",
"mood": "focused",
"language": "en",
"max_new_tokens": 80,
"temperature": 0.75
}
No recibe la imagen, la URL, la composición ni una lista de objetos.
Las omisiones son deliberadas, y el código lo explica donde ocurren:
/**
* Build the writer-facing summary from emotional context only.
*
* Deliberately skips `subjects_and_composition` and `key_contextual_objects`:
* feeding literal object/framing descriptions to the writer is what pushes it
* toward inventory-style output, which the product rules forbid.
*/
El resumen se construye solo con contexto emocional. Se excluyen las descripciones de sujetos, encuadre y objetos porque llevan al redactor a producir un inventario, algo que las reglas del producto prohíben.
Lo aprendí por las malas. Dale a un modelo de lenguaje una lista de lo visible y te dirá lo que es visible. Love the dumbbells and the natural window light es una frase correcta sobre la foto y un fracaso completo para el producto, que existe para responder qué transmite esto, no qué hay en la imagen.
La solución es dejar de enviarle los objetos. Una instrucción que le pide que no los describa sigue siendo una petición. Un campo ausente garantiza que esa información no se envíe.
La misma lógica actúa en sentido contrario con avoid_focus, que ni siquiera sale de un modelo. Viene de un mapa de pautas por categoría escrito a mano. Para fitness, prohíbe valorar cuerpos y dar consejos inseguros. Es una regla del producto. Esas reglas deben estar en un archivo JSON que una persona pueda editar y una prueba pueda comprobar, en vez de en unos pesos que solo cambian al volver a entrenar.
La unión entre etapas es una frontera de validación
La versión fácil de esta arquitectura conecta la salida de un modelo con la entrada de otro. La flecha del medio hace más que eso.
Entre ambas etapas hay una función cuyo trabajo es rechazar:
// Without any emotional context the writer has nothing to react to, and the
// LoRA will fall back to generic filler. Treat that as a pipeline error.
if (!visionSummary && !perceivedMood && emotionalSignals.length === 0) {
return { ok: false, reason: 'invalid_vision_json', detail: 'no_emotional_context' }
}
Sin contexto emocional, el redactor no tiene a qué reaccionar y el LoRA recurre a frases genéricas. Por eso se trata como un error del pipeline.
La función también resuelve la incertidumbre en vez de transmitirla:
export function resolveEffectiveCategory(primaryCategory, confidence, unclearCategory) {
if (!isGuidelineCategory(primaryCategory)) return FALLBACK_CATEGORY
if (unclearCategory === true) return FALLBACK_CATEGORY
if (typeof confidence === 'number' && confidence < 0.5) return FALLBACK_CATEGORY
return primaryCategory
}
Un modelo de visión que tiene un 40 % de confianza en que una foto pertenece a couples no lo sabe. Enviar couples con una confianza de 0.4 le entrega al redactor una categoría que parece segura, pero que se apoya en una apuesta. El redactor no sabe que debería matizar, porque se ajustó para expresarse con decisión. Es mejor indicar general_lifestyle de forma explícita.
Cada cadena que cruza esta frontera se recorta, se normaliza, se deduplica y se limita en longitud. El modelo de visión no tiene por qué ser malicioso: un campo entre dos modelos es precisamente donde una cadena sin límites se convierte en una superficie de inyección de prompts. «El modelo anterior probablemente no hará eso» no es una garantía de seguridad.
La frontera es simétrica. La salida se valida al volver con la misma desconfianza:
const LEAKED_PROMPT_MARKERS = ['System:', 'User:', 'Assistant:', 'You need to', 'You have to']
Si cualquiera de esos marcadores aparece en una reacción, la ejecución se rechaza como prompt_leakage y el redactor contratado atiende la petición. Esa comprobación existe porque detectó un fallo real: la ausencia de una plantilla de chat hizo que el adaptador recibiera un formato ajeno a su entrenamiento y empezara a repetir sus propias instrucciones en el texto que veía el usuario.
Hay dieciocho motivos de fallo tipados, desde disabled hasta reaction_too_long, y el cliente no ve ninguno. Van a una columna de métricas para que yo pueda distinguir algo lento de algo roto sin adivinar.
Qué permitió esa separación
Una capa de percepción intercambiable. Si aparece un modelo de visión mejor, cambio un valor de configuración. El adaptador no se ve afectado porque su contrato de entrada no cambió. Para eso está el JSON del medio.
Privacidad por diseño. Una foto no puede filtrarse desde un componente que nunca la recibió. Mi worker de GPU, la parte menos probada en producción de toda la arquitectura, no tiene acceso a las imágenes del usuario. La protección viene de esa ausencia.
Pruebas que se ejecutan en milisegundos. El módulo cliente no lee variables globales: la configuración y fetch se inyectan. Por eso puedo probar toda la ruta del redactor en una laptop sin GPU, sin el entorno de Deno y sin una clave de API. Las partes de una función de ML que más probablemente fallen son las que no necesitan un acelerador.
Un lugar para las protecciones. Las reglas de seguridad, la orientación por categoría, los límites de longitud y el enrutamiento por idioma viven en código corriente alrededor del modelo. Cuando cambia una regla, despliego código en vez de volver a entrenar.
La parte que es fácil omitir
Hay dos costes que conviene contar.
El primero: el resumen marca el techo. El adaptador solo puede ser tan bueno como el resumen que recibe. Si la percepción confunde una graduación con una foto de una salida nocturna, el redactor producirá una reacción fluida y con el tono correcto para el momento equivocado. Lo hará con seguridad, porque se entrenó para eso. Mis peores resultados son errores de categoría envueltos en buena prosa. Por eso la etapa que merece vigilancia es la llamada de visión.
El segundo, y el que omitiría si estuviera vendiendo esto: el modelo propio atiende una minoría del tráfico. El adaptador es viofy-qwen2.5-3b-lora-en, solo para inglés. VIOFY admite once idiomas. El enrutador envía los otros diez al redactor contratado y registra esa ruta como un éxito normal. Saltarse el adaptador por diseño no es un fallo y no debería contaminar esa métrica.
Pasé semanas trabajando en un modelo que actualmente escribe para uno de once idiomas.
Lo volvería a hacer por la arquitectura: como la voz se selecciona mediante enrutamiento, ampliar su cobertura requiere cambiar una configuración (RUNPOD_WRITER_LOCALES=en,es) el día que termine de entrenarse un adaptador en español. Sin cambiar código ni volver a desplegar el pipeline. Y cuando el redactor propio está desactivado, no admite el idioma, tarda demasiado o falla, un modelo general termina el trabajo sin que el usuario tenga que saber cuál le tocó.
La regla que le daría a quien empieza
Antes de ajustar un modelo, separa la función en la parte que se está convirtiendo en un servicio estándar y la parte que constituye tu producto. Contrata la primera. Entrena la segunda. Pon entre ambas un contrato validado, tipado y deliberadamente limitado. Trata cada campo que dejas fuera como una decisión de producto. La foto nunca cruza porque así está diseñado.
Un pequeño voto de confianza
¿Valió la pena leer esta nota?