Un clasificador es sobre todo fontanería
En esta página
En Flowstate llevamos un tiempo dándole vueltas a un problema que parece trivial hasta que lo intentas: enviar cada petición entrante al modelo más barato que de verdad pueda resolverla. Para enrutar un prompt primero tienes que averiguar qué quiere, y los prompts llegan como puro caos humano. Erratas. Un volcado de código de 400 líneas con la petición real enterrada en la última. “oye puedes mirar esto”. De todo eso tienes que sacar una intención limpia, y tienes que hacerlo en bastante menos de un milisegundo, dentro del proceso, en una CPU, gratis. Un juicio semántico en una memoria más pequeña que un JPEG, mientras todos los artículos del tema juran y perjuran que necesitas un rack de H100.
Hay un manual estándar en la industria para esto. Preparas un dataset de oro de entre 200 y 500 prompts etiquetados por humanos. Validas un LLM profesor contra él y no sigues si el F1 baja de 0,90. Destilas el profesor en un modelo alumno pequeño: SetFit, una variante de BERT, algo con embeddings. Dibujas matrices de confusión. Mides la latencia p99. Haces un despliegue en sombra de 48 horas antes de dejarlo tocar una sola petición real. Es un manual precioso, de los que escriben investigadores con computación infinita y sin un busca de guardia. También es una forma estupenda de sobrediseñarte hasta quedar arrinconado.
El trabajo real era acotado: etiquetar cada prompt entrante con un tipo de tarea (parent → child, como engineering → fix_bug o data → spreadsheet_edit) para que el proxy lo enrute a un sitio razonable en lugar de mandarlo todo al modelo más caro del menú. Solo CPU, sin GPU en el camino caliente, muchísimas peticiones. Lo que hace que entrenar tu propio clasificador sea sensato y no una locura es que Flowstate ya tiene una montaña de peticiones reales. Solo que sin etiquetar. Para eso sirve, y es el único buen uso, un LLM profesor barato. No como portero que se lleva una comisión de cada petición. Lo apuntas una vez a la pila, dejas que lo etiquete todo y lo tiras. Usas lo caro exactamente una vez.
La paradoja del peaje
La objeción obvia, la que me tuvo mirando el techo: olvídate del clasificador local. Llama a un modelo barato y rápido (Flash, Haiku, lo que haya al fondo de la tabla de precios), deja que él etiquete el prompt y acaba antes de comer. Además sería más preciso que cualquier cosa que yo pudiera entrenar.
Pero esa es justo la trampa de la que toda la arquitectura quiere escapar. El sentido de etiquetar un prompt antes de que llegue a un modelo es gastar menos: mandar lo fácil a un sitio barato y pagar precios de modelo de frontera solo cuando no queda otra. Si el propio router hace una llamada de pago a una API en cada petición, has montado un peaje delante de tu peaje. Pagas un billete de autobús solo para preguntarle al conductor si es el autobús correcto. Has quemado el ahorro antes de ganarlo, has pegado una ida y vuelta de red a cada petición y has mandado una copia de cada prompt a los logs de otro. Un clasificador que corre en el proceso, en una CPU que ya tienes, es gratis por petición. No barato, gratis, para siempre una vez entrenado. Así que un modelo torpe del 92% que no cuesta nada puede valer más que uno del 99% que te cobra por token. Aquí quieres un portero rápido y torpe, no un filósofo lento y caro.
La pega es la palabra “entrenado”. Un modelo así tiene hambre. Quiere muchos más ejemplos etiquetados de los que yo etiquetaría a mano por gusto. Así que el profesor se gana el sueldo exactamente una vez: etiqueta la pila sin conexión y el clasificador funciona gratis para siempre.
Antes de discutir nada de esto, hice lo que suelo hacer: monté un banco de pruebas pequeño y medí.
Una confesión primero
Todavía no he apuntado el profesor a la pila real, así que para esta primera pasada generé un sustituto. Unos miles de prompts sintéticos en dieciocho tipos de tarea, con ambigüedad deliberada para que el clasificador tuviera algo en lo que fallar de verdad. Prompts como “mira el endpoint roto”, que sinceramente es fix_bug o debug y no lo distingues por el texto. Unos 2800 para entrenar, 600 para probar.
Esto importa, así que lo digo bien alto: los datos sintéticos etiquetados por lo mismo que los generó son una partida trucada. Te dicen si el pipeline funciona y cómo se clasifican los enfoques entre sí. No te dicen la precisión en el mundo real. Y halagan sin que se note a los modelos simples, porque el texto de plantilla deja huellas léxicas que una bolsa de palabras se traga enteras. Guárdatelo en el bolsillo. Volveré a ello.
Con eso clavado en la puerta, vamos a los números.
El modelo es la decisión aburrida
Cinco enfoques, del más barato al más sofisticado. Una bolsa de palabras TF-IDF normal hacia regresión logística. Lo mismo con n-gramas de caracteres y una SVM. Un vectorizador por hashing hacia un clasificador SGD. Y el que el manual quiere que construyas: embeddings de frases (un transformer BGE cuantizado) hacia una cabeza lineal.
| Enfoque | Prec. hoja | Prec. padre | p95 (1 petición) | p95 (bajo carga) | Tamaño |
|---|---|---|---|---|---|
| TF-IDF + logreg | 0,923 | 1,000 | 0,18 ms | 0,17 ms | 336 KB |
| TF-IDF + char + SVM | 0,926 | 1,000 | 1,4 ms | 29,6 ms | 3,2 MB |
| Hashing (2²⁰) + SGD | 0,926 | 1,000 | 20,7 ms | 42,4 ms | 147 MB |
| Hashing (2¹⁸) + SGD | 0,924 | 1,000 | 4,1 ms | 6,8 ms | 37 MB |
| Embeddings + logreg | 0,918 | 0,997 | 6,0 ms | 20,8 ms | 131 MB |
La columna de precisión es la aburrida. Todos los enfoques quedan a menos de un punto de los demás, agrupados en torno al 92%. El transformer de 131MB, el que escribirías un documento de diseño para justificar, quedó último y fue el único que no acertó del todo la etiqueta padre, la gruesa. La bolsa de palabras de 336KB, una idea más vieja que la mayoría de los frameworks de tu package.json, empató y salió en un tercio de milisegundo.
La latencia es la columna que no es plana. Dos órdenes de magnitud entre arriba y abajo. El único eje en el que estos enfoques de verdad se diferencian es el operativo, y ahí gana la opción más tonta de calle.
La columna del padre esconde un resultado más discreto: un 1,000 plano para casi todos. Toda la confusión vive dentro de un dominio: viz confundido con data_analysis, market_research con web_research. Nada confunde una hoja de cálculo con un informe de errores. Si el proxy solo necesita el dominio grueso, lo más barato de la lista ya lo había resuelto y yo podría haberme ido a casa.
Así que si el modelo apenas importa, ¿qué importa?
La fontanería
Tres cosas me ocuparon mucho más que el modelo, y ninguna sale en ninguna guía.
Trampa uno: el vectorizador por hashing se zampa 147 megabytes sin avisar
El modelo de hashing con 2²⁰ características marcó 20,7ms y 147MB de memoria residente, para una tarea con dieciocho clases. Es una cuarta parte de segundo de latencia y un vídeo pequeño de RAM para decidir si alguien escribió “arregla esto” o “por qué esto está roto”.
La causa es sosa y totalmente autoinfligida: un espacio de 2²⁰ características por dieciocho clases es una matriz de coeficientes densa del tamaño de un álbum de fotos de vacaciones, y cada predicción le pasa la mano por todo. Baja el hash a 2¹⁸ y va cinco veces más rápido y ocupa cuatro veces menos con la misma precisión. El valor por defecto es la trampa, y nadie te avisa de que está cargada.
Trampa dos: el espejismo del “en mi máquina funciona”
La SVM de n-gramas de caracteres se veía preciosa en solitario: 1,4ms por petición, la mejor precisión de la tabla por un pelo. Casi la subo al repositorio y me voy a por un café. Luego le apunté ocho workers concurrentes y su p95 se hundió hasta 29,6 milisegundos. Un desplome de veinte veces en cuanto tuvo compañía.
Dos motivos, ambos invisibles para un benchmark de una sola petición. Para sacar probabilidades de una SVM la calibras, y eso entrena y ejecuta sin avisar tres submodelos por predicción. Y todo ese trabajo extra de CPU por petición se amontona contra el bloqueo global de Python, así que las peticiones hacen cola educadamente dentro del edificio en llamas en lugar de salir volando. La bolsa de palabras, que casi no hace trabajo por petición, ni se enteró de la carga. 0,17ms con ocho workers, lo mismo que sola. Ganar la mediana está bien. Mantener la cabeza fría cuando todo arde es lo que te importa a las 3 de la madrugada.
El pegamento: truncar es la diferencia entre el 17% y el 83%
Este me hizo sentir idiota. Le lancé una prueba de estrés al modelo ganador: un bloque de 400 líneas de Java con una instrucción clave pegada en la última, “ahora arregla el bug que hace que el total salga mal”. La forma real de un prompt en una herramienta de programación.
Sacó un 16,7%. Perdió la cabeza y etiquetó casi todo como refactor, porque para una bolsa de palabras una pared de código es un refactor, y la única instrucción humana del final se ahogó en el ruido.
La solución no fue un modelo más sofisticado ni una matriz de embeddings. Fueron cuatro líneas que tiran todo menos los últimos 200 caracteres y clasifican eso.
Del dieciséis por ciento al ochenta y tres, solo cambiando lo que el modelo podía ver y no qué modelo era. (Quedarte con el final gana cuando la instrucción va al final. Principio más final es la apuesta general más segura, por si la petición está arriba.) El modelo estaba bien desde el principio. La fontanería, no.
El otro pegamento: enseñarle a decir “no lo sé”
Un etiquetador que se equivoca con aplomo es peor que uno que se abstiene. Por suerte, el modelo ya sabía cuándo estaba adivinando. Sus respuestas erróneas en prompts ambiguos de una palabra (“debug”, ”?”) venían con una confianza mínima. Así que barrí un umbral de confianza: por debajo, enruta a un cajón de sastre en lugar de adivinar.
Es un intercambio limpio. Si dejas el umbral en torno a 0,7, sigues respondiendo el 90% de los prompts, la precisión de los respondidos sube al 95,5% y cazas el 90% de la basura realmente fuera de alcance. Dónde lo pongas es una decisión de producto (cuántas veces estás dispuesto a encogerte de hombros frente a cuántas a equivocarte) y, otra vez, nada que ver con el modelo.
Dónde está trucado y dónde no
Vuelvo a la confesión. Un lector avispado ya está tecleando: tus datos son sintéticos y léxicamente ordenados, claro que ganó la bolsa de palabras. Los embeddings se ganan el sueldo con prompts reales y desordenados, que no probaste. Ese lector tiene razón, y yo aceptaría su apuesta. Con tráfico real, con erratas, ideas a medias, tres idiomas en una frase, la misma intención dicha de cien maneras, espero por completo que los embeddings se pongan por delante. El torneo de modelos, en concreto, es la parte de esto en la que menos deberías confiar.
La prueba honesta está ahí mismo: apuntar el profesor a la pila de Flowstate, etiquetarla una vez y repetir el torneo con prompts reales. Ahí sabrías si el 92% era que los datos fueron amables o que el enfoque es sólido. Si llego a hacerlo, será su propio post.
La fontanería, en cambio, no se fija en qué datos le echas. El vectorizador por hashing se come 147MB igualmente. La SVM calibrada se hunde bajo concurrencia con cualquier entrada. El truncado decide si el modelo llega a ver la instrucción. El umbral de confianza es una propiedad del despliegue, no del dataset. Esos hallazgos sobreviven intactos a la salvedad, y fueron la mayor parte del trabajo.
Lo que aprendí de verdad
- Mide antes de diseñar la arquitectura. Podría haber pasado una semana discutiendo SetFit contra BERT. Una tarde con un script me dijo que el modelo era la variable menos interesante del sistema.
- Los benchmarks de una sola petición mienten. La SVM parecía la mejor en solitario y la peor bajo carga. Si tu proxy atiende tráfico concurrente, haz el benchmark con tráfico concurrente.
- Los valores por defecto son trampas. Un espacio de hash de 2²⁰ para dieciocho clases son 147MB de nada. Entiende qué hace el mando antes de dejarlo donde lo encontraste.
- Truncar es un modelo. Elegir qué enseñarle al clasificador movió la precisión más que cualquier decisión de arquitectura: del 17% al 83% con cuatro líneas.
- Déjalo abstenerse. Un umbral de confianza convierte “a veces se equivoca con aplomo” en “casi siempre acierta y de vez en cuando es sincero”. Es un mando que merece la pena tener.
- La referencia aburrida es lo que hay que superar, no lo que hay que saltarse. Empieza con 336KB y cara de póquer. Echa mano del transformer de 131MB cuando tengas datos reales que demuestren que lo necesitas, y ni un commit antes.
El manual sofisticado no estaba mal, exactamente. Optimizaba la única decisión que no importaba y callaba sobre las cuatro que sí. Toda la industria te venderá un clúster de H100 para leer un prompt. Lo que de verdad cambió las cosas fueron cuatro líneas de cortar cadenas. Un clasificador es sobre todo fontanería, y la fontanería no sale bien en las fotos, que probablemente es la razón de que nadie escriba la guía.
Todo el banco de pruebas son unas 600 líneas de Python. Es código del trabajo, así que no lo publico, pero todo lo que importa está en este post: los cinco enfoques, la prueba de concurrencia, la solución del truncado, el barrido del umbral. Dada la salvedad, buscar agujeros es justo lo que quiero, así que apunta al método.