20 KiB
Fine-tuning LoRA de Qwen3.6-35B-A3B para MCPs y skills de Claude Code
Contexto
El usuario sirve RedHatAI/Qwen3.6-35B-A3B-NVFP4 en producción vía vLLM en su DGX Spark, y quiere un LoRA que le enseñe al modelo a:
- Usar correctamente el MCP de Penpot (diseño) y los otros 4 MCPs conectados (gitea, github-personal, docmost, atlassian).
- Seguir sus "skills" de Claude Code (en español) sin saltarse pasos ni reglas — su queja principal es que el modelo olvida reglas/pasos al ejecutar tareas largas.
Se investigó a fondo antes de diseñar: se leyó el chat_template.jinja real del modelo (7764 bytes), se hizo SSH a spark para verificar hardware/disco/contenedores en vivo, se documentaron a fondo las 5 tools del Penpot MCP (arquitectura, schemas, reglas no-obvias), se inventariaron los 5 skills reales y los 5 MCP servers, y se lanzaron dos diseños independientes (uno priorizando un framework turnkey, otro priorizando control total con transformers+peft+trl) que convergieron en casi todos los puntos técnicos. Este plan sintetiza ambos.
Resultado esperado: un adapter LoRA entrenado, mergeado y re-cuantizado a NVFP4, que reemplaza el checkpoint actual en ~/models/ en spark como drop-in — sin tocar el docker-compose.yaml de producción.
Hallazgos que determinan el diseño
- El modelo NO es un MoE clásico.
config.jsondeQwen/Qwen3.6-35B-A3Bdeclaramodel_type: "qwen3_5_moe",architectures: ["Qwen3_5MoeForConditionalGeneration"]— ya soportado entransformers≥5.2 (verificado, no requieretrust_remote_code). De 40 capas: 30 sonlinear_attention(Gated DeltaNet) y solo 10 sonfull_attention(full_attention_interval: 4). Además tiene 256 expertos ruteados (top-8) + 1 shared expert siempre-activo, y un vision tower (SigLIP, ~27 bloques). - Los expertos ruteados son
nn.Parameter3D, nonn.Linear. PEFT/bitsandbytes no los puede envolver como LoRA/QLoRA estándar. Son ~32B de los ~35B params totales (91%). - GB10 (SM121) no tiene kernels de linear attention (
causal_conv1d/flasin build sm121 según la doc de HF) → Gated DeltaNet corre en el fallback lento de PyTorch. Es el cuello de botella de velocidad y hay que aceptarlo (vale la pena un experimento acotado de 20 min probandoflash-linear-attentionpor si el build sí funciona vía Triton JIT, ya que es sospechoso que una librería sin extensiones CUDA "no tenga build" — pero no bloqueante). - Existe precedente casi idéntico: kreuzhofer/dgx-spark-unsloth-qwen3.5-training hace LoRA BF16 de Qwen3.5-35B-A3B (misma arquitectura) en un solo DGX Spark, y NVIDIA publica un playbook oficial de Unsloth para DGX Spark.
- Existe la receta exacta de cuantización NVFP4 usada por RedHatAI (
recipe.yamldel repo):QuantizationModifier(scheme="NVFP4", ignore=["re:.*lm_head","re:visual.*","re:model.visual.*","re:.*mlp.gate$","re:.*embed_tokens$","re:.*shared_expert_gate$","re:.*linear_attn.*"]). Nota clave: las capas Gated DeltaNet (linear_attn) quedan en BF16 en producción — si el LoRA se aplica ahí, el merge no pierde precisión de cuantización. - El chat template usa formato XML tipo Hermes para tool calls (
<tool_call><function=X><parameter=Y>valor</parameter></function></tool_call>, NO JSON), conrole: "tool"como rol de primera clase que el template convierte a<tool_response>, yreasoning_contentcomo campo separado (no meter<think>a mano encontent). Conpreserve_thinking=true(como en producción), todos los turnos assistant previos preservan su<think>al re-serializar. - Los transcripts reales de Claude Code (
~/.claude/projects/*/*.jsonl) están casi vacíos de uso de MCPs (10 archivos, 735 líneas, 0 de 216tool_usesonmcp__*) — no sirven como fuente principal. En cambio~/.claude/plans/*.md(28 archivos, ~200KB) sí tiene español real del usuario y es útil para estilo/plantillas de reporte. - Discrepancia sin resolver entre las dos investigaciones: no coinciden en si las proyecciones de Gated DeltaNet están fusionadas (
in_proj_qkvz,in_proj_ba, por analogía con Qwen3-Next) o separadas (in_proj_qkv,in_proj_z,in_proj_a,in_proj_b, según lectura directa del índice de tensores real). Esto se resuelve empíricamente en la Fase 0 inspeccionando el checkpoint real descargado — no se asume ninguna de las dos. - Hallazgo de seguridad, fuera del alcance de este plan pero a resolver antes/en paralelo: el mismo token (
<<SPARK_PASSWORD_1>>) está reusado como Bearer de gitea, github-personal y penpot MCP, y parece ser la misma password de spark. Rotar esos tokens y dejar de reusar la password de spark como bearer HTTP.
Decisiones de diseño
- Framework: stack primario
transformers+peft+trl(control total, sin dependencia de que un framework de alto nivel ya soporte esta arquitectura tan nueva). Probarunslothcomo acelerador opcional (puede dar 1.5-2x velocidad) una vez que el pipeline base funcione — no bloqueante, con fallback documentado al stack puro si falla la carga. - Memoria de entrenamiento: LoRA BF16 puro, no QLoRA. Los expertos ruteados (91% de los params) no son cuantizables por bnb, así que QLoRA ahorraría ~4-5% de memoria a cambio de fidelidad numérica y una dependencia con bugs abiertos. No vale la pena.
- Módulos objetivo de LoRA: atención completa (
q/k/v/o_proj, 10 capas) + proyecciones de Gated DeltaNet (30 capas, nombres exactos a confirmar en Fase 0) +shared_expert.{gate,up,down}_proj(40 capas — el único FFN que ve el 100% de los tokens). Excluidos: router (mlp.gate), expertos ruteados, vision tower. Esto cubre 40/40 capas para aprender formato/comportamiento sin tocar conocimiento factual ni arriesgar colapso de expertos. r=32, alpha=64, dropout=0.05. - Chat template / masking: construir el dataset con
messages+tool_callsestructurados (formato TRL/OpenAI,argumentscomo dict) y dejar queapply_chat_templategenere el XML — nunca escribirlo a mano. Verificar primero si TRL reconoce nativamente el chat template de Qwen3.6 y aplica el masking de loss víaassistant_only_loss=True; si no, fallback a copiar el.jinjacon{% generation %}...{% endgeneration %}manual alrededor de la salida assistant. - Ubicación de archivos: todo el código (pipeline de dataset, scripts de training/merge/cuantización, docker-compose del entorno de training, configs, dataset generado) vive en
~/projects/ai-projects/qwen3-6-lora/(=/mnt/docker-nas/projects/ai-projects/qwen3-6-lora/visto desde spark, mismo storage NFS) — una subcarpeta nueva dentro deai-projects, noai-projectsen sí. Esa subcarpeta es la raíz de un repo git nuevo creado al arrancar la Fase 0 (git initdentro deqwen3-6-lora/, con.gitignoreexcluyendo binarios de modelo,data/raw/sin sanitizar, y cualquier checkpoint). El dataset final ya sanitizado (data/train.jsonl,data/eval.jsonl) y los schemas de MCP sí se versionan — son el activo más valioso del proyecto y lo que justifica tener historia de git. Los binarios del modelo (checkpoint base BF16 ~72GB, merge, cuantizado) se mantienen en disco local rápido de spark (no en el NFS ni en git — no tiene sentido versionar cientos de GB de pesos) en un directorio nuevo dedicado; solo el checkpoint NVFP4 final se copia a~/models/en spark (requisito deldocker-compose.yamlde vLLM). Publicar el repo a gitea/github (con la skillaleleba-pr) queda como paso posterior, ya en fase de ejecución, no parte de este plan. - Ventana operativa: entrenar localmente en Spark, aceptando parar vLLM ~1-2 noches (confirmado con el usuario). No se renta GPU en la nube.
- Alcance: solo texto por ahora (confirmado con el usuario) — el vision tower queda congelado; un bucket con capturas de Penpot puede agregarse después sin rehacer el pipeline.
Estructura de directorios
~/projects/ai-projects/qwen3-6-lora/ # repo git nuevo (git init en Fase 0), subcarpeta de ai-projects
├── .gitignore # excluye data/raw/, out/*.safetensors, cualquier checkpoint
├── docker-compose.yml # contenedor de training (NGC pytorch, con volúmenes persistentes)
├── scripts/
│ ├── 00_verify_hardware.py # smoke tests: flash_attn, sdpa, fla (opcional), sm121
│ ├── 01_inspect_modules.py # resuelve la discrepancia de nombres in_proj_* contra el checkpoint real
│ ├── 02_dump_mcp_schemas.py # tools/list contra los 5 MCP servers -> data/schemas/*.json
│ ├── 03_build_replay.py # genera ~700 ejemplos de replay contra el vLLM de producción (antes de pararlo)
│ ├── 04_sanitize.py # scrubbing de secretos (gitleaks/detect-secrets + regex) sobre fuentes crudas
│ ├── 05_build_dataset.py # arma los buckets A-K -> data/train.jsonl, data/eval.jsonl
│ ├── 06_validate_dataset.py # render con apply_chat_template, assert de máscara de loss, filtro de longitud
│ ├── 10_train.py # LoRA con transformers+peft+trl (+ unsloth opcional)
│ ├── 20_merge_lora.py # merge streaming shard-a-shard, preserva tensores MTP
│ ├── 21_quantize_nvfp4.py # llm-compressor, receta clonada de RedHatAI, reinjerta MTP
│ └── 30_eval_suite.py # las 4 puertas de evaluación (parser real, checklists de skills, etc.)
├── data/
│ ├── schemas/ # {penpot,gitea,github-personal,docmost,atlassian}.json
│ ├── raw/ # fuentes sanitizadas (skills, agents, plans, replay)
│ ├── train.jsonl / eval.jsonl
│ └── chat_template_train.jinja # solo si hace falta el fallback manual de masking
└── out/ # adapters, logs de eval (NO los checkpoints de modelo completo)
En spark (fuera del NFS, disco local rápido — confirmar ruta exacta en Fase 0, p.ej. /home/aleleba/ft-models/):
Qwen--Qwen3.6-35B-A3B/ # checkpoint base BF16 descargado de HF (~72GB)
Qwen3.6-35B-A3B-mcp-bf16/ # merge del LoRA (~72GB)
Qwen3.6-35B-A3B-mcp-NVFP4/ # resultado final, se copia a ~/models/ para servir
Fases de implementación
Fase 0 — Infraestructura y verificación (sin tocar producción)
- Crear la subcarpeta
~/projects/ai-projects/qwen3-6-lora/y hacergit initahí dentro (no enai-projectsdirectamente), con el.gitignorede arriba, y un primer commit con la estructura base de carpetas. Todo el código/scripts/dataset sanitizado de las fases siguientes se commitea a medida que se produce. - El contenedor
jupyter-pytque ya está corriendo en spark (imagennvcr.io/nvidia/pytorch:25.12-py3) no tiene volumen persistente — no lo parches in-place. Crear un servicio nuevo (docker-compose.ymlde arriba) con bind mounts a~/projects/ai-projectsy al directorio local de modelos, misma imagen base (traeflash_attnynvidia-modeloptpreinstalados). - Instalar
transformers>=5.2(verificar versión exacta que declare soporte deqwen3_5_moe),peft,trl,accelerate,datasets,bitsandbytes(solo paraadamw_8bit), conPIP_CONSTRAINTfijando la versión detorchde la imagen NGC (--no-depsen transformers) para no romper el build ARM64/Blackwell. scripts/00_verify_hardware.py: probarflash_attnreal (forward simple), y si falla usarattn_implementation="sdpa". Probar (opcional, 20 min) siflash-linear-attentioncompila y da backward en SM121 — usarlo si funciona, descartarlo si no.- Descargar el checkpoint base:
hf download Qwen/Qwen3.6-35B-A3B(~72GB). Verificar quechat_template.jinjapese igual (7764 bytes) que el de producción — confirma que son el mismo template. scripts/01_inspect_modules.py(resuelve la discrepancia): cargar el modelo condevice_map="meta"e imprimir todos losnn.Linear/nn.Parameterde una capalinear_attention(p.ej. layer 0) y unafull_attention(p.ej. layer 3). Esto define los nombres reales a usar entarget_modules— no proceder a la Fase 3 sin este dato confirmado.
Fase 1 — Extracción de schemas y datos de replay (vLLM sigue corriendo)
scripts/02_dump_mcp_schemas.py: conectar a los 5 MCP servers (mcp__penpot__*,gitea,github-personal,docmost,atlassian) y volcartools/listreal a JSON. Esto confirma también si el deployment remoto de Penpot tiene filesystem deshabilitado (siimport_imageno aparece, oexport_shapepierdefilePath, hay que entrenar solo sobre las tools que sí existen — no inventar).scripts/03_build_replay.py: generar ~700 ejemplos de "replay" (anti-forgetting) pidiéndole al modelo en producción actual (antes de pararlo) que responda prompts genéricos en español/código/razonamiento conpreserve_thinking=true. Esto ancla el replay a la distribución real del modelo base, mejor que mezclar un corpus externo.
Fase 2 — Construcción del dataset
scripts/04_sanitize.py: gate de scrubbing de secretos sobre las 5 SKILL.md, los 7 agents, los~/.claude/plans/*.md, y cualquier transcript usado — regex +gitleaks/detect-secrets, sustituyendo (no borrando) tokens/passwords/IPs/emails por placeholders estables. El build debe fallar si sobrevive algún patrón conocido.scripts/05_build_dataset.py: construir ~2500-3000 ejemplos en buckets: Penpot MCP (~300, cubriendo cada regla no-obvia documentada:insertChildnoappendChild, orden invertido dechildrenen flex, re-fijargrowTypetrasresize(),storageentre llamadas, no loguear lo que se retorna, leer"Tool execution failed: ..."como texto y autocorregirse), los otros 4 MCPs (~800 combinados), adherencia a skills — el bucket más importante para la queja principal del usuario, con checklist explícito de reglas en el<think>inicial, auto-corrección a mitad de trayectoria, fidelidad de la plantilla de "Reporte final", y escenarios que tientan a violar cada reglaNUNCA/SIEMPRE/OBLIGATORIOreal de los 5 skills (~450), delegación a subagentes (~180), negativos/no-tool (~200), manejo de errores (~150), y el replay de la Fase 1 (~700, ~25% del total). Dejar 1 de las 5 skills completamente fuera del training como held-out de generalización.scripts/06_validate_dataset.py: renderizar cada ejemplo conapply_chat_template(tools=..., tokenize=False)y verificar que no lanza excepción, que el texto contiene<function=...>bien formado, y que la máscara deassistant_masks(conreturn_assistant_tokens_mask=True) no está vacía y no cubre system/tool/user. Filtrar (no truncar) ejemplos que excedan la longitud máxima (8192, con ventana deslizante para skills largas).
Fase 3 — Entrenamiento
- Configurar
LoraConfigcon lostarget_modulesconfirmados en la Fase 0 (attention + GDN + shared_expert),r=32, alpha=64, dropout=0.05. Verificar conmodel.print_trainable_parameters()(~0.11% esperado) y un conteo por módulo antes de arrancar cualquier run largo. docker compose stop vllm(nodown, conserva volúmenes). Dry run de 20 pasos: confirmar memoria pico <90GB, loss bajando. Luego run completo (2 épocas, LR 1e-4 cosine, gradient checkpointing,save_steps=50). Mitigar el riesgo de OOM al cargar el checkpoint (mmap + tensores CUDA compitiendo por la misma pool unificada) conPYTORCH_CUDA_ALLOC_CONF=expandable_segments:Truey purgando page cache antes de cargar.docker compose start vllmen cuanto termine el training — producción vuelve, el adapter queda en disco.
Fase 4 — Merge y evaluación
scripts/20_merge_lora.py: merge shard-a-shard (streaming, pico de memoria ~2GB en vez de cargar el modelo completo dos veces) del adapter sobre el checkpoint BF16. Preservar los tensoresmtp.*(quefrom_pretraineddescarta al cargar) copiándolos del checkpoint original — si se pierden,--speculative-configde producción no arranca.- Evaluación en 4 puertas, sin pasar a la siguiente sin cerrar la anterior: (1) eval-loss offline por bucket, especialmente el bucket de replay separado del resto; (2) validez de tool-calls usando el parser real de producción (
qwen3_coder/qwen3de vLLM, no una regex propia) contra ~200 prompts held-out; (3) checklists de adherencia a reglas por skill (baseline medido con el modelo actual antes de comparar) + test de no-activación (frases cercanas a triggers que no deben disparar la skill); (4) prueba en vivo sirviendo el BF16 mergeado directo (no--enable-lora— hay incompatibilidades de nombres de módulos entre PEFT y vLLM para esta arquitectura, documentadas como riesgo abierto) contra los 5 MCP reales y las 5 skills de punta a punta. - Puerta de decisión: si no mejora sobre el baseline en la puerta 3, volver a la Fase 2 antes de gastar tiempo en cuantizar.
Fase 5 — Re-cuantización y despliegue
scripts/21_quantize_nvfp4.py: clonar exactamente la receta de RedHatAI (llm-compressor, mismoignorelist) sobre el modelo mergeado, conmoe_calibrate_all_experts=True(obligatorio, si no la mayoría de los 256 expertos quedan sin calibrar) e idealmente calibrando con una muestra del propio dataset de fine-tuning en vez de un corpus genérico.- Reinjertar tensores MTP en el checkpoint cuantizado. Copiar
chat_template.jinjaoriginal (no el de training). - Mover el checkpoint actual a
.bak(35GB, sobra espacio) antes de reemplazar — rollback = revertir el nombre ydocker compose up -d. Copiar el nuevo checkpoint a~/models/en spark. - Arrancar vLLM con la config de producción completa y sin modificar, incluido
--speculative-config. Si no arranca, el problema son los tensores MTP. Si arranca, correr el smoke test de las 5 skills contra los MCPs reales una vez más.
Riesgos principales
| Riesgo | Mitigación |
|---|---|
Copiar target_modules mal (nombres fusionados vs separados) → adapter vacío, run de horas perdido |
Fase 0 verifica empíricamente antes de configurar LoRA; assert de print_trainable_parameters() antes de cada run |
| Masking de loss mal hecho (entrenar sobre system/tool_response) | Validación obligatoria de la máscara sobre 20 ejemplos antes de arrancar (Fase 2, paso 10) |
| Cuelgue del host por OOM en memoria unificada (no da error limpio) | expandable_segments:True, purga de page cache, considerar systemd-run --scope -p MemoryMax |
Pérdida de tensores MTP en el merge → rompe --speculative-config en producción |
Merge streaming los preserva por diseño; verificación explícita antes de desplegar |
--enable-lora no soporta bien esta arquitectura en vLLM (issue abierto) |
No usarlo como validación principal; servir el BF16 mergeado directo para evaluar |
| Fuga de secretos al dataset (tokens/passwords reales en skills/plans/transcripts) | Gate de sanitización que falla el build si sobrevive algún patrón; rotar los tokens reusados de una vez |
| Overfitting a 5 skills / tool-call spam | Skill held-out, ~25% replay auto-destilado, LR conservador, medir no-activación |
| Entrenamiento lento (fallback PyTorch de Gated DeltaNet) | Aceptar 10-15h como run nocturno; experimento opcional con flash-linear-attention |
Verificación end-to-end
- Fase 0 completa cuando
01_inspect_modules.pyconfirma los nombres reales y00_verify_hardware.pycorre sin excepciones. - Fase 2 completa cuando
06_validate_dataset.pypasa sobre el 100% del dataset (render limpio + máscara no vacía) y el gate de secretos no encuentra nada. - Fase 3 completa cuando el dry-run de 20 pasos no OOMea y el loss baja de forma consistente en el run completo.
- Fase 4: comparar explícitamente las 4 puertas contra el baseline del modelo actual (medido antes de entrenar) — la mejora debe ser medible, no asumida. La prueba real de éxito es servir el BF16 mergeado y ejecutar de punta a punta al menos una tarea real por cada uno de los 5 MCPs y las 5 skills contra el propio Claude Code apuntando a ese endpoint.
- Fase 5:
docker compose up -dde producción arranca sin errores con la config completa (incluido MTP/speculative decoding) sirviendo el checkpoint nuevo, con el.bakdisponible para rollback inmediato.