Files
qwen3-6-lora/data/raw/sanitized/plans/te-comento-en-mi-nifty-dragon.md

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:

  1. Usar correctamente el MCP de Penpot (diseño) y los otros 4 MCPs conectados (gitea, github-personal, docmost, atlassian).
  2. 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.json de Qwen/Qwen3.6-35B-A3B declara model_type: "qwen3_5_moe", architectures: ["Qwen3_5MoeForConditionalGeneration"] — ya soportado en transformers ≥5.2 (verificado, no requiere trust_remote_code). De 40 capas: 30 son linear_attention (Gated DeltaNet) y solo 10 son full_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.Parameter 3D, no nn.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/fla sin 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 probando flash-linear-attention por 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.yaml del 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), con role: "tool" como rol de primera clase que el template convierte a <tool_response>, y reasoning_content como campo separado (no meter <think> a mano en content). Con preserve_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 216 tool_use son mcp__*) — 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

  1. Framework: stack primario transformers + peft + trl (control total, sin dependencia de que un framework de alto nivel ya soporte esta arquitectura tan nueva). Probar unsloth como 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.
  2. 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.
  3. 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.
  4. Chat template / masking: construir el dataset con messages + tool_calls estructurados (formato TRL/OpenAI, arguments como dict) y dejar que apply_chat_template genere 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ía assistant_only_loss=True; si no, fallback a copiar el .jinja con {% generation %}...{% endgeneration %} manual alrededor de la salida assistant.
  5. 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 de ai-projects, no ai-projects en sí. Esa subcarpeta es la raíz de un repo git nuevo creado al arrancar la Fase 0 (git init dentro de qwen3-6-lora/, con .gitignore excluyendo 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 del docker-compose.yaml de vLLM). Publicar el repo a gitea/github (con la skill aleleba-pr) queda como paso posterior, ya en fase de ejecución, no parte de este plan.
  6. Ventana operativa: entrenar localmente en Spark, aceptando parar vLLM ~1-2 noches (confirmado con el usuario). No se renta GPU en la nube.
  7. 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)

  1. Crear la subcarpeta ~/projects/ai-projects/qwen3-6-lora/ y hacer git init ahí dentro (no en ai-projects directamente), con el .gitignore de 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.
  2. El contenedor jupyter-pyt que ya está corriendo en spark (imagen nvcr.io/nvidia/pytorch:25.12-py3) no tiene volumen persistente — no lo parches in-place. Crear un servicio nuevo (docker-compose.yml de arriba) con bind mounts a ~/projects/ai-projects y al directorio local de modelos, misma imagen base (trae flash_attn y nvidia-modelopt preinstalados).
  3. Instalar transformers>=5.2 (verificar versión exacta que declare soporte de qwen3_5_moe), peft, trl, accelerate, datasets, bitsandbytes (solo para adamw_8bit), con PIP_CONSTRAINT fijando la versión de torch de la imagen NGC (--no-deps en transformers) para no romper el build ARM64/Blackwell.
  4. scripts/00_verify_hardware.py: probar flash_attn real (forward simple), y si falla usar attn_implementation="sdpa". Probar (opcional, 20 min) si flash-linear-attention compila y da backward en SM121 — usarlo si funciona, descartarlo si no.
  5. Descargar el checkpoint base: hf download Qwen/Qwen3.6-35B-A3B (~72GB). Verificar que chat_template.jinja pese igual (7764 bytes) que el de producción — confirma que son el mismo template.
  6. scripts/01_inspect_modules.py (resuelve la discrepancia): cargar el modelo con device_map="meta" e imprimir todos los nn.Linear/nn.Parameter de una capa linear_attention (p.ej. layer 0) y una full_attention (p.ej. layer 3). Esto define los nombres reales a usar en target_modules — no proceder a la Fase 3 sin este dato confirmado.

Fase 1 — Extracción de schemas y datos de replay (vLLM sigue corriendo)

  1. scripts/02_dump_mcp_schemas.py: conectar a los 5 MCP servers (mcp__penpot__*, gitea, github-personal, docmost, atlassian) y volcar tools/list real a JSON. Esto confirma también si el deployment remoto de Penpot tiene filesystem deshabilitado (si import_image no aparece, o export_shape pierde filePath, hay que entrenar solo sobre las tools que sí existen — no inventar).
  2. 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 con preserve_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

  1. 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.
  2. scripts/05_build_dataset.py: construir ~2500-3000 ejemplos en buckets: Penpot MCP (~300, cubriendo cada regla no-obvia documentada: insertChild no appendChild, orden invertido de children en flex, re-fijar growType tras resize(), storage entre 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 regla NUNCA/SIEMPRE/OBLIGATORIO real 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.
  3. scripts/06_validate_dataset.py: renderizar cada ejemplo con apply_chat_template(tools=..., tokenize=False) y verificar que no lanza excepción, que el texto contiene <function=...> bien formado, y que la máscara de assistant_masks (con return_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

  1. Configurar LoraConfig con los target_modules confirmados en la Fase 0 (attention + GDN + shared_expert), r=32, alpha=64, dropout=0.05. Verificar con model.print_trainable_parameters() (~0.11% esperado) y un conteo por módulo antes de arrancar cualquier run largo.
  2. docker compose stop vllm (no down, 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) con PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True y purgando page cache antes de cargar.
  3. docker compose start vllm en cuanto termine el training — producción vuelve, el adapter queda en disco.

Fase 4 — Merge y evaluación

  1. 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 tensores mtp.* (que from_pretrained descarta al cargar) copiándolos del checkpoint original — si se pierden, --speculative-config de producción no arranca.
  2. 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/qwen3 de 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.
  3. 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

  1. scripts/21_quantize_nvfp4.py: clonar exactamente la receta de RedHatAI (llm-compressor, mismo ignore list) sobre el modelo mergeado, con moe_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.
  2. Reinjertar tensores MTP en el checkpoint cuantizado. Copiar chat_template.jinja original (no el de training).
  3. Mover el checkpoint actual a .bak (35GB, sobra espacio) antes de reemplazar — rollback = revertir el nombre y docker compose up -d. Copiar el nuevo checkpoint a ~/models/ en spark.
  4. 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

  1. Fase 0 completa cuando 01_inspect_modules.py confirma los nombres reales y 00_verify_hardware.py corre sin excepciones.
  2. Fase 2 completa cuando 06_validate_dataset.py pasa sobre el 100% del dataset (render limpio + máscara no vacía) y el gate de secretos no encuentra nada.
  3. Fase 3 completa cuando el dry-run de 20 pasos no OOMea y el loss baja de forma consistente en el run completo.
  4. 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.
  5. Fase 5: docker compose up -d de producción arranca sin errores con la config completa (incluido MTP/speculative decoding) sirviendo el checkpoint nuevo, con el .bak disponible para rollback inmediato.