Mi IA local se optimizó a sí misma: la arquitectura que nació de darle las llaves al modelo

Mi IA local se optimizó a sí misma: la arquitectura que nació de darle las llaves al modelo

Llevaba un tiempo con Ollama en el homelab, probando, aprendiendo, haciendo que las cosas funcionaran. Monté mi primer despliegue, estudié cómo encajaban las piezas y empecé a sentir que podía ir un paso más allá. Pero llegó un momento en el que me planté una pregunta incómoda: ¿esto escalaría si más de una persona lo usaba a la vez? La respuesta honesta era no. Tras investigar a fondo las alternativas, quedé claro que las herramientas que estaba usando no eran las más adecuadas para ese propósito. Y en lugar de seguir parcheando, decidí replantearlo todo desde cero.

La primera decisión fue el cambio más importante: adiós Ollama, hola vLLM. No es que Ollama sea malo —es fantástico para desarrollo y prototipos— pero cuando el objetivo es ofrecer servicios a múltiples usuarios concurrentes, vLLM es otra liga. Su motor de inferencia y su gestión de la memoria están pensados para soportar peticiones simultáneas en tiempo real, que es justo lo que necesitaba.

El primer despliegue con vLLM funcionó. Pero no rápido. Me topé con un TTFB (Time To First Byte) que no me convencía. Es decir, el tiempo que pasaba desde que lanzaba una petición al modelo hasta que recibía la primera respuesta era demasiado alto. Parte del problema venía de un cambio de modelo que hice en paralelo: pasé de modelos menos exigentes como un Gemma 3 (27B) o un Mistral (19B) a un Qwen 3.6 (27B), con más capacidades pero también con un peso mucho mayor. Fue ese salto el que empezó a darme los primeros problemas de rendimiento.

Tenía un despliegue funcional, pero más lento de lo que me gustaría. Y entonces se me ocurrió algo que al principio sonó absurdo: aún siendo lento, ¿por qué no aprovechar el propio modelo para que me ayudara a mejorarlo?

Antes de llegar a ese punto, ya me había metido a investigar por mi cuenta las opciones de configuración de vLLM. Hay muchísimas, y poco a poco fui entendiendo cuáles podrían servir para optimizar el despliegue. Pero al final, el acierto fue pasarle al modelo la combinación perfecta: por un lado, la configuración del despliegue que tenía, y por otro, el log que vLLM genera al levantarse. Por suerte, ese log es muy claro y permite ver de un vistazo la memoria VRAM disponible para el KV Cache, la memoria que se usa para calcular los Cuda Graphs, y qué opciones elige vLLM por defecto al cargar ese modelo en concreto.

El resultado fue excelente. El modelo no solo me sugirió qué parámetros podrían venirme bien, sino que me dio los valores concretos que debía usar en función de lo que quisiera priorizar: ¿prefería dar soporte a más usuarios concurrentes o ampliar el contexto de cada conversación? Me lo desglosó y me explicó los parámetros para cada escenario.

Pero no se quedó ahí. Al pasarle también el YAML completo de despliegue que estaba usando sobre K3S, entendió perfectamente la infraestructura: dos GPUs de 16 GB de VRAM cada una, sin enlace NVLink entre ellas, por lo que toda la comunicación viajaba por PCIe, que es un cuello de botella notable. A partir de ahí me propuso optimizaciones específicas para esa limitación hardware, como ampliar la memoria compartida (SHM) del Pod para que la comunicación entre GPUs fuera más fluida. Al final del artículo dejo el YAML que uso actualmente por si queréis echarle un vistazo. Aunque como resumen las opciones que uso actualmente son:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
vllm serve $PRELOAD_MODEL \
    --served-model-name $NEW_MODEL \
    --tensor-parallel-size 2 \
    --gpu-memory-utilization 0.98 \
    --host 0.0.0.0 --port 8000 \
    --enable-force-include-usage \
    --enable-auto-tool-choice \
    --reasoning-parser qwen3 \
    --max-model-len 98304 \
    --kv-cache-dtype fp8 \
    --tool-call-parser qwen3_coder \
    --max-num-seqs 2 \
    --enable-chunked-prefill \
    --max-num-batched-tokens 2048 \
    --enable-prefix-caching \
    --compilation-config '{"cudagraph_capture_sizes":[1,2],"compile_sizes":[1,2]}'

Las mejoras, sin embargo, no terminan con vLLM. Añadí dos capas adicionales a la arquitectura para cubrir dos necesidades que me quedaban sin resolver.

Arquitectura Leria.gal

La primera es un servicio de embedding dedicado, para mejorar la comprensión del modelo a la hora de analizar frases y contextos largos. Tras revisar los embeddings disponibles con mayores capacidades y contextos, me decanté por el qwen3-embedding (8B), que desplegé en otro worker del cluster con su propia GPU. No hay mucho que contar sobre esta parte: simplemente funciona de forma muy eficiente y la diferencia en la calidad de los resultados se nota desde el primer momento.

La segunda capa es un servicio de análisis de PDF, para que los documentos se procesen y limpien antes de enviarlos al modelo. Elegí Apache Tika, desplegado también sobre el cluster con varias réplicas para ganar velocidad en caso de subidas concurrentes. De este componente solo echo de menos que no disponga de OCR integrado para el análisis de imágenes y su transformación a texto. Es algo que tengo pendiente de investigar; de hecho, ya he probado el propio modelo para esta tarea y funciona bastante bien, así que puede que la solución esté más cerca de lo que pienso.

A continuación, el YAML de despliegue que estoy utilizando. Un apunte: el bloqueo de red se hace de esta forma porque hasta que pueda redesplegar el cluster completo no puedo cambiar el plugin de red de K3S:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: vllm-qwen3-pv
  namespace: ai
spec:
  capacity:
    storage: 50Gi
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-storage
  hostPath:
    path: /mnt/ssd2/vllm/qwen3/
    type: DirectoryOrCreate
  volumeMode: Filesystem
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - baremetal-k3s-pro-02 
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: vllm-qwen3-pvc
  namespace: ai
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: local-storage
  resources:
    requests:
      storage: 50Gi 
  volumeName: vllm-qwen3-pv    
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-qwen3
  namespace: ai
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      name: vllm-qwen3
  template:
    metadata:
      labels:
        name: vllm-qwen3
    spec:
      runtimeClassName: nvidia
      nodeSelector:
        nvidia.com/gpu.present: "true"
      volumes:
      - name: cache-volume
        persistentVolumeClaim:
          claimName: vllm-qwen3-pvc
      # vLLM necesita memoria compartida del host para la inferencia en paralelo (Tensor Parallelism).
      - name: shm
        emptyDir:
          medium: Memory
          sizeLimit: "48Gi"
      containers:
      - name: vllm-qwen3
        image: vllm/vllm-openai:v0.20.1
        securityContext:
          capabilities:
            add: ["NET_ADMIN", "NET_RAW"]
        command:
        - /bin/sh
        - -c
        - |
          # Si HF_HUB_OFFLINE es "1", bloquear acceso a internet
          if [ "$HF_HUB_OFFLINE" = "1" ]; then
            echo "Aplicando bloqueo de internet..."
            apt update && apt install -y iptables iproute2
            # Reset de reglas por si acaso
            iptables -F
            iptables -X
            # Permitir tráfico local (127.0.0.1)
            iptables -A OUTPUT -o lo -j ACCEPT
            # PERMITIR tráfico hacia el cluster (Otros pods y servicios)
            iptables -A OUTPUT -d $POD_CIDR -j ACCEPT
            iptables -A OUTPUT -d $SERVICE_CIDR -j ACCEPT
            # BLOQUEO TOTAL de cualquier otra salida (Internet)
            iptables -P OUTPUT DROP
            echo "Bloqueo de internet aplicado"
          fi
          vllm serve $PRELOAD_MODEL \
            --served-model-name $NEW_MODEL \
            --tensor-parallel-size 2 \
            --gpu-memory-utilization 0.98 \
            --host 0.0.0.0 --port 8000 \
            --enable-force-include-usage \
            --enable-auto-tool-choice \
            --reasoning-parser qwen3 \
            --max-model-len 98304 \
            --kv-cache-dtype fp8 \
            --tool-call-parser qwen3_coder \
            --max-num-seqs 2 \
            --enable-chunked-prefill \
            --max-num-batched-tokens 2048 \
            --enable-prefix-caching \
            --compilation-config '{"cudagraph_capture_sizes":[1,2],"compile_sizes":[1,2]}' 
        env:
        - name: HUGGING_FACE_HUB_TOKEN
          valueFrom:
            secretKeyRef:
              name: vllm-secret
              key: HUGGING_FACE_HUB_TOKEN
        - name: PRELOAD_MODEL
          value: "cyankiwi/Qwen3.6-27B-AWQ-INT4"
        - name: NEW_MODEL
          value: "leria:redacta"
        # Bloqueao de salida a internet
        - name: "POD_CIDR"
          value: "10.42.0.0/16"
        - name: "SERVICE_CIDR"
          value: "10.43.0.0/16"
        # Eliminar esta varaible de entorno (HF_HUB_OFFLINE) 
        # si en algun momento se necesita 
        # volver a descargar el modelo
        - name: HF_HUB_OFFLINE
          value: "1"
        # Esto bloquea la salida a internet de las librerias
        # si se necesita volver a descargar el modelo
        # habria que comentarlas: 
        # TRANSFORMERS_OFFLINE, VLLM_NO_USAGE_STATS
        - name: TRANSFORMERS_OFFLINE
          value: "1"
        - name: VLLM_NO_USAGE_STATS
          value: "1"
        - name: VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS
          value: "1"
        - name: OMP_NUM_THREADS
          value: "28"
        - name: PYTHONWARNINGS
          value: "ignore::UserWarning"
        resources:
          requests:
            nvidia.com/gpu: "2"
            cpu: "20"
            memory: "64Gi"
          limits:
            nvidia.com/gpu: "2"
            cpu: "48"
            memory: "200Gi"
        ports:
        - containerPort: 8000
        volumeMounts:
        - mountPath: /root/.cache/huggingface
          name: cache-volume
        - name: shm
          mountPath: /dev/shm
---
apiVersion: v1
kind: Service
metadata:
  name: vllm-qwen3
  namespace: ai
spec:
  type: ClusterIP
  selector:
    name: vllm-qwen3
  ports:
  - protocol: TCP
    port: 8000
    targetPort: 8000