Ejecutar un ajuste fino de LoRA localmente: verificar, fusionar y evitar fallos silenciosos
Demuestre que un adaptador LoRA realmente mejoró un modelo pequeño, fusione y sirva dicho modelo a través de una API local compatible con OpenAI, y detecte los fallos que generan resultados incorrectos pero presentados como correctos.
Al finalizar un entrenamiento de LoRA, se obtiene un pequeño archivo de adaptador, de unos 11 MB en este caso, y poco más. Un archivo no constituye un resultado real: hasta que el modelo afinado sea evaluado contra la misma línea de base y esté disponible para ser llamado por una aplicación, solo queda esperanza. Esta guía toma un adaptador para la triaje de tickets de soporte de un modelo con 2 mil millones de parámetros, lo mide, lo fusiona en pesos independientes, lo hace accesible a través de un endpoint local compatible con OpenAI, y explica los modos de fallo que generan respuestas plausibles, bien formadas y erróneas sin mostrar ningún mensaje de error.
Todo lo mostrado se ejecuta desde el repositorio finetune-demo, que incluye el adaptador ya entrenado, por lo que puede seguir el proceso sin tener que entrenar nada usted mismo. El resultado clave: en esta tarea, el modelo pasó de no tener ninguna respuesta completamente válida entre 40 a obtener 40 respuestas correctas de 40.
Midiendo el adaptador frente a la línea de base
La única medida justa es aquella que modifica exactamente una variable. La evaluación utiliza el mismo script, los mismos 40 tickets reservados y la misma temperatura que la ejecución de referencia con el modelo no entrenado; la única adición es la bandera --adapter que apunta a los pesos entrenados. --no-think desactiva el modo de razonamiento del modelo para que responda directamente.
python evaluate.py - model mlx-community/Qwen3.5–2B-MLX-4bit \
--adapter adapters/triage-2b --limit 40 --no-think
===== mlx-community/Qwen3.5–2B-MLX-4bit (adapter: adapters/triage-2b) =====
examples : 40
usable : 40/40 (100%) returned parseable JSON
fully valid : 40/40 (100%) <- the headline
median latency: 0.33s
errors by rule:
La sección errors by rule está vacía, y ese es precisamente el objetivo: cada una de las 40 respuestas cumplió con todas las reglas de validación. La latencia media fue de 0.33 segundos por ticket.
Al compararlo con la versión de referencia, el cambio es evidente. El modelo no entrenado ya generaba JSON legible en cada ocasión, pero nunca utilizó el vocabulario requerido para categoría, prioridad o etiquetas:
| | Before | After |
|-------------------------|-----------|-----------|
| Returned parseable JSON | 40/40 | 40/40 |
| **Fully valid** | **0/40** | **40/40** |
| `category` errors | 40 | 0 |
| `priority` errors | 40 | 0 |
| `tags` errors | 40 | 0 |
| `needs_human` errors | 8 | 0 |
La misma muestra utilizada para demostrar el punto de referencia muestra por qué. Antes del entrenamiento, el modelo inventó etiquetas como "IT Support" y etiquetas en mayúsculas iniciales; posteriormente utilizó los valores en minúsculas del esquema estándar:
TICKET : The password reset email never arrives, I have checked spam.
BEFORE : {"category": "IT Support", "priority": "High", "needs_human": true,
"tags": ["Password Reset","Email Delivery","Account Access","Spam Filter"]}
AFTER : {"category": "account", "priority": "medium", "needs_human": true,
"tags": ["password", "email_change"]}
Hay una segunda ventaja en ese ejemplo. El modelo base utilizó 63 tokens de completación para su respuesta, mientras que el modelo ajustado utilizó 29, menos de la mitad. Los tokens de salida afectan tanto el tiempo de respuesta como el costo de procesamiento en un endpoint con alto tráfico, por lo que reducirlos a la mitad representa un ahorro significativo, no un error de redondeo.
Probándolo con su propio texto
Dado que el adaptador se incluye con el repositorio, el script try_it.py funciona inmediatamente después de clonarlo. Al pasar --compare se cargan tanto el modelo base como el adaptado, para que pueda ver la diferencia en un texto que haya escrito usted mismo:
.venv/bin/python try_it.py \
--compare "I was charged twice for my Pro plan and nobody has replied in a week"
TICKET "I was charged twice for my Pro plan and nobody has replied in a week"
before { "category": "Billing & Support", "priority": "High", "needs_human": true,
"tags": ["Duplicate Charge","Account Inquiry","Support Ticket","Pro Plan"] }
INVALID -> category, priority, tags (0.42s)
after {"category":"billing","priority":"medium","needs_human":true,
"tags":["double_charge","email_change"]}
VALID (0.24s)
La respuesta base falla en la validación en tres campos; la respuesta adaptada pasa la validación y además es más rápida. Elimine --compare para obtener solo la respuesta ajustada, o omita el texto del ticket para conseguir un prompt interactivo.
Tres formas de ejecutar el modelo
Puede mantener el adaptador por separado, fusionarlo con los pesos base o convertirlo a otro formato. La fusión es la opción más fiable para su uso en producción.
Fusión del adaptador
LoRA representa la actualización de pesos como un producto de rango bajo BA que se suma a los pesos congelados W en cada paso de procesamiento. La fusión realiza la suma W + BA una sola vez y guarda pesos normales, lo que le permite contar con un único directorio de modelo autónomo:
python -m mlx_lm fuse \
--model mlx-community/Qwen3.5-2B-MLX-4bit \
--adapter-path adapters/triage-2b \
--save-path fused/triage-2b
Esto tomó 3.6 segundos y generó 1.0 GB de salida. El siguiente paso no es opcional: evaluar el modelo fusionado antes de confiar en él.
fused/triage-2b fully valid: 40/40 (100%) median latency 0.26s
adapter fully valid: 40/40 (100%) median latency 0.33s
La calidad es idéntica, y el modelo fusionado es significativamente más rápido, ya que han desaparecido las multiplicaciones matriciales adicionales por capa. La razón para volver a evaluarlo es que la fusión implica operaciones aritméticas, y los errores en estas operaciones ocurren de forma silenciosa. Un modelo fusionado defectuoso sigue generando un directorio con archivos que parecen razonables, pero que luego producen resultados sin sentido. Solo una evaluación puede distinguir entre los dos.
Servirlo
mlx_lm server expone el modelo fusionado a través de HTTP. La opción --chat-template-args desactiva el proceso de razonamiento a nivel del servidor, lo cual es importante por las razones que se explican a continuación:
python -m mlx_lm server --model fused/triage-2b --port 8082 \
--chat-template-args '{"enable_thinking":false}'
Una solicitud simple con curl dirigida al endpoint de completado de chat confirma que el modelo responde en el formato entrenado. La temperatura es cero para obtener una salida determinista, y se utiliza el mismo prompt del sistema que durante el entrenamiento:
curl -s -X POST http://127.0.0.1:8082/v1/chat/completions \
-H 'Content-Type: application/json' -d '{
"messages":[
{"role":"system","content":"You are a support triage engine. Reply with one JSON object and nothing else, with keys: category, priority, needs_human, tags."},
{"role":"user","content":"Production is down for all our users. The app crashes every time I open the dashboard screen."}],
"max_tokens":120,"temperature":0}'
{"category": "bug", "priority": "urgent", "needs_human": false,
"tags": ["crash", "desktop"]}
La respuesta utilizó 29 tokens de completado. Dado que el endpoint es compatible con OpenAI, el código existente escrito para la API de OpenAI puede usarlo simplemente cambiando la URL base.
Llamada al endpoint desde el código de la aplicación
La integración se realiza en una sola función utilizando únicamente la biblioteca estándar de Python. La versión en client_example.py del repositorio importa el prompt del sistema y las herramientas de validación desde un módulo schema compartido, envía la solicitud y se niega a devolver cualquier dato que no pueda validar:
import json, urllib.request
from schema import SYSTEM_PROMPT, validate, extract_json
ENDPOINT = "http://127.0.0.1:8082/v1/chat/completions"
def triage(ticket_text, timeout=60):
payload = {
"messages": [
{"role": "system", "content": SYSTEM_PROMPT}, # MUST match training
{"role": "user", "content": ticket_text},
],
"max_tokens": 160, "temperature": 0,
}
req = urllib.request.Request(ENDPOINT, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
body = json.load(r)
msg = body["choices"][0]["message"]
content = msg.get("content")
if not content: # thinking left no answer
raise RuntimeError(f"no content; finish_reason={body['choices'][0]['finish_reason']}")
record = extract_json(content)
errs = validate(record) if record is not None else ["unparseable"]
if errs: # never trust it blindly
raise ValueError(f"invalid record: {errs} -> {content!r}")
return record
Al ejecutarlo con dos tickets, devuelve diccionarios limpios:
I was charged twice for my Pro subscription this month.
-> {'category': 'billing', 'priority': 'medium', 'needs_human': True,
'tags': ['double_charge', 'invoice']}
Production is down for all our users, the dashboard crashes on load.
-> {'category': 'bug', 'priority': 'urgent', 'needs_human': False,
'tags': ['crash', 'desktop']}
Tres detalles en esa función están allí intencionadamente, y cada uno sirve para evitar un fallo descrito en la sección siguiente:
SYSTEM_PROMPTproviene de una importación y no de una copia. Incluso una diferencia de un solo carácter con respecto a los datos de entrenamiento hace que el modelo salga de su distribución típica.- La verificación del
contentvacío. Si el modelo utiliza todo su tiempo de procesamiento en razonamientos, no queda respuesta alguna que analizar. validate()se ejecuta en cada respuesta. Un modelo ajustado con precisión tiende a funcionar bien, pero no es una garantía. Una puntuación perfecta en un conjunto de pruebas no dice nada seguro sobre la siguiente solicitud, por lo que se debe decidir en el código qué sucede cuando un registro falla.
Tres fallos que nunca generan error
Ninguno de los siguientes lanza excepciones. Cada uno devuelve una respuesta incorrecta, pero clara y bien estructurada.
La bandera del adaptador que se ignora en silencio
La solución obvia es omitir el proceso de fusión y pasar directamente el adaptador al servidor:
python -m mlx_lm server --model <base> --adapter-path adapters/triage-2b
Con mlx-lm 0.31.3, la versión utilizada aquí, esto sirvió al modelo base. No hubo advertencia, ni línea en los registros, ni error. El endpoint comenzó a funcionar normalmente y respondió con "category": "Production", "priority": "Critical" y un conjunto de cuatro etiquetas en mayúsculas: el comportamiento del modelo sin entrenar, sin cambios. Al no haber un valor de referencia con el que comparar, la conclusión lógica habría sido que el ajuste fino falló. Las versiones posteriores pueden comportarse de manera diferente, así que es mejor verificar en lugar de asumir.
Una forma rápida de detectar esto toma segundos: envía una solicitud cuya respuesta correcta ya conoces. Una respuesta en el vocabulario propio del adaptador indica que está activo; una respuesta similar al modelo base significa que no lo está. La ruta fusionada, mencionada anteriormente, evita completamente la pregunta.
Razonamiento que consume todo el presupuesto
Muchos modelos pequeños recientes razonan antes de responder. Solicita JSON con un límite de 120 tokens mientras el razonamiento está habilitado, y la respuesta puede verse así:
{
"choices":
[
{
"finish_reason":"length",
"message":{
"role": "assistant",
"reasoning":"Thinking Process:\n\n1. **Analyze the Request:** ..."
}
}
]
}
**No existe el campo content**. Cada token se utilizó para razonar, la generación se detuvo con finish_reason: "length" a mitad del proceso de razonamiento, y un cliente que lee response.choices[0].message.content puede enfrentarse a un KeyError o, peor aún, obtener una cadena vacía que interpreta como una respuesta válida y vacía.
Desactive el proceso de razonamiento en el servidor con --chat-template-args '{"enable_thinking":false}', o por solicitud con "chat_template_kwargs": {"enable_thinking": false}. Con el razonamiento desactivado, la misma solicitud se completa en 29 tokens.
Un prompt de sistema diferente al utilizado durante el entrenamiento
El entrenamiento enseñó al adaptador a responder bajo exactamente un prompt de sistema. Al cambiar ese prompt, la solicitud queda fuera de lo que el modelo ha visto, y la mayor parte del comportamiento aprendido desaparece. Aquí está el mismo modelo afinado a la que se le da un prompt genérico pidiéndole a un asistente útil que categorice la solicitud:
This is a **Critical Production Incident** (or a **Major Service Level Incident**).
Here is the breakdown of why this categorization applies:
* **Severity Level: Critical / P0**
* **Impact:** Total system outage affecting all users.
El resultado es un ensayo en Markdown sin ningún JSON. El modelo no está dañado; se le hizo una pregunta para la cual nunca fue entrenado. Mantenga una única definición del prompt, compartida por el generador de datos y el cliente, e impórtela en todas partes.
Otras dos trampas: la lista de modelos y su propio sistema de integración
GET /v1/models muestra todos los modelos en la caché local, no el que está cargado actualmente. Considérelo como un listado de caché y no como una prueba de estado: puede indicarle que el servidor está activo, pero no qué pesos están respondiendo.
También revise el mecanismo de evaluación antes de culpar a los pesos. En este proyecto, el evaluador decidió si desactivar la capacidad de razonamiento buscando "qwen" en el nombre del modelo. Eso funcionó para mlx-community/Qwen3.5-2B-MLX-4bit, pero la copia fusionada se encuentra en fused/triage-2b, por lo que la capacidad de razonamiento permaneció activa sin que nadie se diera cuenta, y el modelo fusionado obtuvo un 82% en lugar del 100%. Los pesos estaban bien; el error fue del evaluador. Cuando la puntuación disminuye de forma inesperada, sospeche primero del mecanismo de evaluación, y nunca base el comportamiento en el nombre de un archivo.
Lo que no demuestra el 40/40
La puntuación perfecta existe, pero sea preciso respecto a su alcance: abarca los casos no utilizados creados por el mismo generador que produjo el conjunto de entrenamiento. El modelo sí generaliza, pero solo a nuevos ejemplos sintéticos de ese tipo.
Un puñado de tickets realistas y desordenados cuentan una historia diferente. Se probaron seis. Cuatro superaron la validación estructural, pero varios de ellos seguían siendo incorrectos con total certeza:
- Una queja escrita en mayúsculas que indicaba que los pedidos no podían enviarse y que todo estaba roto terminó en
accounten lugar debug. - Una nota de agradecimiento que elogiaba una corrección en el panel de control fue enviada a
feature_request, ya que el esquema no ofrece la opción “no es un ticket” y el modelo debe elegir una. - Una solicitud de eliminación según el GDPR se convirtió en
how_toconneeds_human: false, desviando un plazo legal de una persona.
El último caso es un defecto en los datos, no en el modelo. En el conjunto de datos generado, needs_human está completamente determinado por category:
account {True: 125} billing {True: 137}
bug {False: 153} how_to {False: 115} feature_request {False: 110}
Por lo tanto, el modelo aprendió una tabla de consulta de cinco filas en lugar de tomar una decisión, y ninguna cantidad de entrenamiento puede corregir una etiqueta que nunca fue independiente. Solo se descubre esto mediante pruebas fuera de la distribución original, así que considere una puntuación reservada como el mínimo que puede reclamar y no como el máximo. Para uso en producción, etiquete unos cientos de tickets reales, permita que needs_human varíe independientemente de la categoría, e introduzca una etiqueta de “ninguna acción”.
Más allá de los tickets de soporte
Nada en esta pipeline es específico de los tickets. Funciona en cualquier lugar donde haya texto no estructurado y un conjunto fijo de etiquetas:
- Currículos en categorías de antigüedad, años de experiencia y habilidades.
- Facturas en categorías de proveedor, moneda y líneas de artículo.
- Líneas de registro en categorías de servicio, gravedad y tipo de incidente.
Solo dos archivos necesitan modificarse: schema.py, que contiene los valores permitidos, el mensaje de solicitud y la función validate(), y make_data.py, que genera los ejemplos. Por lo tanto, todos los comandos mostrados aquí seguirán funcionando sin cambios.
Antes de ajustar el próximo modelo
Pruebe primero la decodificación restringida. Las gramáticas GBNF en llama.cpp o bibliotecas como xgrammar obligan a que la salida generada se ajuste a un esquema, lo que impide obtener resultados estructuralmente defectuosos independientemente de si el modelo fue ajustado o no. Aplicar únicamente una gramática habría logrado una validez del esquema del 100% aquí sin necesidad de entrenamiento. Sin embargo, el ajuste fino sigue siendo necesario: una gramática puede imponer la estructura pero no el significado, y es el entrenamiento el que enseña al modelo la categoría correcta, además de reducir a la mitad el número de tokens. Pero si su único problema es JSON mal formado, recurra a una gramática antes de realizar un entrenamiento.
Cuenta el costo por solicitud, no por ejecución de entrenamiento. La ejecución de entrenamiento dura aproximadamente cinco minutos, una sola vez. El gasto en tokens se repite con cada llamada mientras el servicio esté activo, por lo que reducir los tokens de 63 a 29 representa un ahorro que sigue aumentando. Si lo comparas con una API alojada, el análisis en fine-tune or call the API detalla las cifras para un pipeline similar.
Considera a GGUF como la tercera opción, más frágil. La conversión a GGUF permite que el modelo sea portable a llama.cpp u Ollama, pero las herramientas pueden terminar sin problemas y dejarte con pesos que generan resultados inútiles. Crea una muestra de salida después de cada paso de conversión; la existencia de un archivo GGUF no demuestra nada sobre si funciona correctamente.
Puntos clave
- El número que da significado a cada resultado posterior es la línea de referencia. Mida antes del entrenamiento y vuelva a medir después de cada transformación, como la fusión o conversión.
- Los pesos fusionados fueron tan precisos como el adaptador y más rápidos de servir; la ruta sin fusionar
--adapter-pathsirvió silenciosamente el modelo base de la versión probada. - Guarde cada respuesta en código: importe el prompt de entrenamiento exacto, verifique si falta el
contenty valide el registro. - Una puntuación perfecta en datos reservados solo cubre información como el conjunto de entrenamiento. Pruebe con entradas reales complejas y corrija las fugas de etiquetas en los datos en lugar de esperar que el entrenamiento las resuelva.
Lecturas relacionadas
- Diagnóstico de problemas en la salida de LLM: Cuándo usar prompt, obtener datos o realizar fine-tuning — Un enfoque basado en los síntomas para decidir si una función de IA deficiente necesita un mejor prompt, una capa de recuperación de información o fine-tuning, y por qué entrenar un modelo con datos factuales puede tener efectos negativos.
- Fine-tunar o usar la API? Costos de un pipeline de extracción de documentos — Un modelo de costos aplicado a un pipeline de auditoría de documentos muestra por qué la redirección al modelo es más económica que el fine-tuning, y cuándo la precisión del esquema o la residencia de datos en la UE justifican poseer un modelo propio.