StudioForge: un servidor LLM solo-GPU que reemplazó a LM Studio en mi equipo y que mis agentes manejan a distancia
- Categoría
- IA y LLM Local
- Publicado
- 23 agosto 2026
- Por
- Jacob Lloyd — escrito con ayuda de IA, después del proyecto
- Tiempo de lectura
- 61 min de lectura
En palabras simples: Es un programa que se ejecuta en el ordenador que tiene las tarjetas gráficas. Las aplicaciones le piden una respuesta de un modelo de IA, y él calcula en qué tarjetas cabe ese modelo, lo inicia, lo mantiene caliente mientras está en uso y lo apaga cuando queda en silencio. La regla que nunca rompe es que un modelo cabe entero en las tarjetas gráficas o se rechaza: no va a ejecutar silenciosamente la mitad en el procesador lento y dejarte preguntándote por qué todo iba a paso de tortuga. La pega: necesita tarjetas NVIDIA, está mejor probado en Windows y todavía no le he elegido licencia.
StudioForge es el servidor de modelos local que escribí para reemplazar a LM Studio en mi equipo con GPU: un supervisor de llama.cpp con una API compatible con OpenAI en el puerto 1234 — el puerto de LM Studio, a propósito —, un panel de control en el navegador, un sidecar de recuperación que responde cuando el proceso principal no lo hace y un plano de gestión publicado por MCP para que un agente en otra máquina pueda manejarlo sin abrir una shell.
Existe por un registro de incidentes que dejó de tener gracia. Un 200 que no demostraba nada: LM Studio responde a rutas sin enrutar con un estado de éxito y un cuerpo de error, y su propio log dice Returning 200 anyway. /v1/models lista todo lo descargado en lugar de lo cargado, así que «¿qué está residente de verdad?» no tenía respuesta. Modelos de razonamiento que desbordaban una ventana de 8.192 tokens, porque el contexto que yo ponía en la configuración del cliente no era el contexto que usaba la carga: en LM Studio la ventana se fija en el momento de cargar, y nadie me lo decía. Y el caro: un modelo que no cabía se recortaba en lugar de rechazarse — el comportamiento documentado es que «reducirá automáticamente el tamaño de la descarga a GPU … y el resto en la RAM del sistema», lo que cambia velocidad de GPU por velocidad de RAM de sistema y, en cualquier caso, informa éxito.
Si leíste el artículo sobre dsh, ya conoces este servidor: el bloque de proveedor llamado «StudioForge (GPU rig)» es esto. Es a lo que recurre la pila de ocho agentes por la red para el trabajo pesado, con lo que habla DisPatch y lo que OpenClaw Email pide para un modelo de chat y un modelo de embeddings. Mi artículo sobre OpenClaw nombró el problema — «minutos de tiempo de carga de modelos, malabarismos de VRAM entre servicios» — y lo que sigue es la respuesta, con cada regla acompañada de la medición que la hizo necesaria.
tl;dr
- Qué es: un servidor LLM solo-GPU compatible con OpenAI sobre
llama-serverde llama.cpp, medido en la buildb10425(CUDA 13.3). Una puerta de enlace en1234, un panel de control en8080, un vigilante en1235y un proceso hijo por cada modelo cargado en18100–18200. - Qué hace: carga un modelo en su primer uso, planificando su contexto, tipo de caché KV, colocación en GPU y número de slots según la VRAM libre en ese instante; lo deja en reposo cuando no se usa; mantiene residentes los modelos fijados; cede tarjetas enteras a un modelo si se le pide; y publica 29 herramientas por MCP — 19 de gestión, 10 de recuperación.
- Lo que nunca hace: volcar a la CPU (un modelo cabe entero en la VRAM o se rechaza con las cifras), llamar a casa (las únicas llamadas salientes son Hugging Face para los modelos, GitHub para la build fijada de
llama-servery su comprobación de actualizaciones, la comprobación opcional de versiones de StudioForge y las URLs de imagen que nombre una petición) ni ejecutar inferencia por MCP: el plano de control no tiene herramienta de finalización y lo dice en mayúsculas. - Qué necesitas: GPU NVIDIA con un driver de la serie 580 o superior, Python 3.12+, uv y una carpeta de GGUF. ¿Nunca has ejecutado un modelo en local? Empieza aquí.
- Con qué terminas: una URL base que todos los clientes OpenAI de tu red usan sin cambios, un panel que dice qué ocupa cada gigabyte de cada tarjeta y un asistente capaz de decir «carga el 27B a 128k en las dos 5090» y que ocurra.
- La parte honesta: Windows es la plataforma de referencia, solo NVIDIA y todavía no hay archivo de licencia — lee primero Consíguelo.
Qué hace, en una imagen
Lo que no es:
- No es un modelo. Ejecuta los GGUF que tienes y trae más a la misma carpeta y estructura que usa LM Studio.
- No es un motor de inferencia. llama.cpp hace las cuentas; esto decide qué proceso se ejecuta con qué flags y en qué tarjetas.
- No es una app de chat. Hay una pestaña Chat, y existe para demostrar que la ruta real de petición funciona.
- No es un clúster. Una máquina, sus propias tarjetas. El backend RPC de llama.cpp existe y no está conectado.
- No es un servidor de inferencia en CPU. No hay ningún valor de
--n-gpu-layersdistinto de999en todo el código. - No es una segunda superficie de API. No hay
/api/generatede Ollama, no hay API de KoboldCpp — la superficie de OpenAI, un espejo/api/v0al estilo de LM Studio y el REST de gestión/api, y eso es todo.
Qué corre dónde
| Pieza | Dónde | Para qué sirve |
|---|---|---|
| Puerta de enlace | el host GPU, un proceso, 1234 | /v1, /mcp (19 herramientas), /api. Contiene el registro, el planificador y el supervisor. |
| Panel de control | el mismo proceso, un segundo uvicorn, 8080 | Dashboard, Setup, Models, Download, Chat, Server, Logs. |
| Vigilante | un proceso aparte, 1235 | Su propio servidor MCP, 10 herramientas de recuperación. Sobrevive a lo que supervisa. |
Hijos llama-server | 18100–18200, solo loopback | Uno por modelo cargado; un fallo se lleva un modelo, nunca la puerta de enlace. |
Acompañante sfctl | la máquina del agente | Un cliente HTTP puro — Python 3.11+, sin CUDA, sin dependencia del servidor. También es el puente MCP por stdio. |
| Biblioteca GGUF | models.dir, donde ya está | Indexada en su sitio; nada se copia. LM Studio sigue usando la misma carpeta. |
Lo que dice la tabla:
- Solo el host GPU instala algo. Un cliente necesita una URL base; un agente necesita un pequeño paquete de Python, y solo para las herramientas de gestión.
- Los hijos son invisibles desde fuera. Se enlazan a
127.0.0.1y a nada más, así que la puerta de enlace es la única superficie pública — que es lo que convierte una sola API key en una frontera real.
Cómo fluye una petición
Todo lo que un cliente puede equivocar se comprueba antes del primer byte, porque una petición incorrecta debería recibir un 4xx real (un 404 para un id de modelo que no existe) con un cuerpo JSON en lugar de un marco de error enterrado dentro de un stream SSE 200: los clientes manejan lo primero y suelen manejar mal lo segundo. Esto es lo que dice GET /health en mi equipo:
{"status": "ok", "version": "1.26-08-23", "uptime_s": 12690.6,
"loaded_models": ["ggml-org/SmolVLM-256M-Instruct-GGUF/SmolVLM-256M-Instruct-Q8_0"],
"busy": {"active_requests": 0, "busy_models": [], "loading": [], "testing": null},
"draining": false, "instance": "primary",
"boot": {"phase": "ready", "ready": true, "elapsed_s": 0.2, "error": null},
"engine": {"ok": true, "tag": "b10425", "variant": "cuda", "smoke_tested": true},
"gpu_count": 4, "models_indexed": 34, "can_serve": true}
can_servees la respuesta al problema del 200-que-no-demuestra-nada. Es false mientras se ejecuta el primer escaneo de la biblioteca, mientrasstatussigue enokporque el proceso está vivo, que es lo que pregunta un sondeo de liveness.GET /health?deep=trueejecuta una finalización real de 8 tokens (una llamada de embeddings para los modelos de embeddings) contra cada modelo cargado — y sin nada cargado respondeno_models_loadeden lugar de pasar, porque una sonda que no puede fallar es peor que no tener sonda.- El alias
local-modelse resuelve.local-model,default,autoycurrentapuntan todos amodels.default_model. Los clientes de LM Studio recurren a esa cadena literal, así que devolverle un 404 los rompe sin motivo. - Un modelo frío no parece un cuelgue. El stream abierto lleva
: loading <model id> (5s)cada cinco segundos, y luego: prefilling <model id> (Ns)hasta el primer token real — líneas de comentario SSE que todo parser ignora. Un socket en silencio durante tanto tiempo dispara un timeout de lectura, y un cliente que reintenta apila más prefill sobre un lote saturado. - Una carga cada vez, en toda la máquina. Una vez, dos modelos fríos se planificaron a la vez sobre las mismas tarjetas; uno murió con
CUDA error: out of memory, su reintento desalojó al otro modelo nuevo, y la petición de ese cliente golpeó un hijo muerto. - Un
ttla nivel de petición mueve el temporizador de reposo y nada más.ttl: 0es la forma en el cable de «fijado» en todo esto, así que se ignora en lugar de respetarse — un cliente que enviaba{"ttl": 60}solía desfijar lo que su dueño había fijado.
La verdad sobre la VRAM: el planificador
Esta es la parte que nadie más hace, así que aquí va en detalle y no con adjetivos. core/planner.py son 3.188 líneas que responden una pregunta antes de que arranque un modelo: dada la VRAM realmente libre en cada tarjeta ahora mismo, ¿cuál es la mejor ventana, calidad de caché y número de slots que este modelo puede tener — y si la respuesta es ninguna, qué debería decirte?
La escalera, y lo que se niega a ceder
Los peldaños dibujados ahí son ilustrativos, no los valores por defecto: el mecanismo es exacto, las cifras son un ejemplo. Lo que incluye config.example.yaml es target_ctx: 1048576 como objetivo y default_ctx: 8192 como suelo, con el objetivo limitado primero a la ventana entrenada del modelo — aunque en una primera ejecución tune_for_hardware escribe default_ctx: 16384 cuando la tarjeta más pequeña tiene 24 GiB o más, y 8192 a partir de 12 GiB; mi equipo usa un suelo de 128000. Pasar de una ventana entrenada requiere escalado RoPE y degrada la calidad, así que nunca se ofrece un peldaño por encima. Un ctx_size explícito es una escalera de un solo peldaño.
Dos pasadas, la segunda solo cuando la primera falló en todas partes. La razón es una carga a las 12:03 una tarde: 79.832 MB libres, otros 19.423 MB recuperables de un modelo en reposo. Con ese presupuesto, 262144 con caché q4_0 cabía en 96.004 MB, y 65536 con f16 completo cabía en 95.236 MB. Lo que cargó fue 8192/f16 en 89.860 MB — el modelo pagó el precio completo del desalojo y se quedó con la ventana más pequeña de la escalera.
La KV no es un solo número por modelo
Casi todas las calculadoras de VRAM online calculan la KV como capas × cabezas × dimensión de cabeza × 2 × bytes × contexto. Correcto para una Llama, y muy equivocado para dos familias que la gente sí ejecuta: Gemma 3 y 4 intercalan cinco capas de ventana deslizante por cada completa, a la mitad de la dimensión de cabeza, y Qwen3.5, 3.6 y 3.8 declaran full_attention_interval = 4, así que hay caché KV en cada cuarta capa y el resto son capas recurrentes Gated-DeltaNet con un estado fijo por secuencia.
El coste de equivocarse: una Gemma-4 31B a la que se le pedían 262.144 tokens se estimaba en 480 GiB de KV y se limitaba a 65.536, mientras el log de calibración llevaba semanas registrando predicted_mb=95615 actual_mb=40037. Tras el arreglo de la geometría, lo predicho y lo real aterrizan en 38 GiB en dos 5090 con n_ctx=262144 — un desbloqueo de 4× que se llevó consigo a toda la flota Gemma-4. Cargar la KV a cada capa de una Qwen3.5 era el mismo bug con otro sombrero: un sobrecargo directo de 4×.
Dos detalles que merecen copiarse. El recuento de celdas de ventana deslizante debe reflejar exactamente a llama.cpp; un multiplicador plano de 1,25× era erróneo en la dirección peligrosa, 3,6× por debajo a cuatro slots. Y attention_kind se deriva de la geometría de capas en lugar de general.architecture — donde no se puede derivar informa unknown, que significa «desconfía de cada cifra de KV aquí», nunca «supón el caso barato».
La calidad de la caché se elige dentro de cada peldaño en lugar de cambiarse por una ventana más ancha: f16/f16 → q8_0/q8_0 → q8_0 K + q4_0 V, con el q4_0 simétrico fuera de todas las rutas automáticas. No es cuestión de gusto: con una caché q4_0 de K, Qwen2.5-7B reproduce solo el 11,7% de los tokens que produce su yo en f16, mientras que un par q8_0/q8_0 emparejado queda en una divergencia KL de 0,0018.
Cuatro tarjetas, dos generaciones
El equipo son dos RTX 5090 y dos RTX 3090 — tarjetas nominalmente de 32 GB y 24 GB, que el panel cuenta como 31,84 GiB y 24,0 GiB, 111,7 GiB en total — con driver 610.88 y CUDA driver 13.3. Primero una sola tarjeta, siempre — un modelo dividido en PCIe sin NVLink es notablemente más lento — y el planificador lo cambia solo cuando la colocación en una tarjeta queda limitada a un slot, la división al menos lo duplica, cada tarjeta añadida es al menos igual de capaz y dejaste el número de slots en automático. Esa última condición no es cortesía: una división corre al ritmo de su miembro más lento.
| Colocación (1,5B Q4_K_M, dos 3090, ctx 8k) | Generación | Procesado de prompt |
|---|---|---|
| Una 3090 | 352,5 tok/s | 2803,6 tok/s |
Dos, -sm layer | 344,4 tok/s | 2722,5 tok/s |
Dos, -sm tensor | 294,3 tok/s | 1182,0 tok/s |
Dos, -sm row | falla: error loading model: device CUDA2 does not support split buffers | |
Lo que dice la tabla:
- Una tarjeta ganó a dos en ambos ejes. La división por capas cuesta un 2% de generación; la división por tensores cuesta un 17% de generación y el 58% del procesado de prompt — así que el modo tensor es opt-in, y solo una medición puede elegirlo.
-sm rowestá muerto en CUDA. El parser lo acepta y la carga falla después, así que se bloquea antes de lanzar el hijo y no después.
Dos detalles de colocación solo aparecen cuando mides por tarjeta. La capa de salida se carga al último dispositivo, porque los cuantizadores mantienen los tensores de embedding y salida en Q6_K u Q8_0 incluso dentro de un archivo Q4: un 27B planificado como --device CUDA1,CUDA0 --tensor-split 0.5079,0.4921 aterrizó con 15,52 GiB en CUDA0 — el último dispositivo, el que recibió menos en la división — frente a los 14,48 de CUDA1. Y llama.cpp abre un contexto CUDA en cada dispositivo visible — ~0,22 GiB en una 3090, 0,43 GiB en una 5090 — que es por lo que la columna de colocación tiene un suelo de 512 MiB.
Cuántas conversaciones vale una colocación
El --ctx-size de llama.cpp es el presupuesto total de KV compartido entre slots, no la ventana por slot — un flag muy malinterpretado, y el README de upstream no lo aclara. Una carga con --ctx-size 4096 y sin --parallel informa total_slots: 4: 1.024 tokens por conversación. StudioForge arranca con ctx_per_slot × parallel. Y luego: cuántos slots merece la pena tener, que es donde dejé de fiarme de mi propia aritmética.
| Concurrentes | Por stream | Agregado | p50 | p95 | Lote logrado |
|---|---|---|---|---|---|
| 1 | 302,8 tok/s | 302,8 tok/s | 0,41 s | 0,41 s | 1,00 |
| 2 | 225,3 tok/s | 425,3 tok/s | 0,46 s | 0,49 s | 1,84 |
| 4 | 134,5 tok/s | 436,0 tok/s | 0,83 s | 1,00 s | 3,46 |
| 8 | 83,3 tok/s | 576,9 tok/s | 1,57 s | 1,77 s | 6,03 |
Qwen2.5-1.5B-Instruct-Q4_K_M, una RTX 3090, 8.192 tokens por slot, KV f16, ocho slots lanzados, prompts de 512 tokens, 192 tokens generados cada uno.
Lo que dice la tabla:
- El estimador dijo 8. La medición dijo 2. A cuatro slots cada stream cae al 44% de la velocidad en solitario, por debajo de un suelo del 65%; la regla toma el mayor de 1/2/4/8 que supere ese suelo ganando aún un 15% de agregado sobre el nivel inferior.
- El agregado nunca deja de subir — ocho slots mueven 1,9× los tokens que mueve uno — mientras una sola conversación se desploma al 27%. Una regla que maximice el agregado elegiría 8, y cada usuario experimentaría un modelo tres veces más lento de lo que la tarjeta puede ejecutarlo.
- El batching es real, no una cola. El lote logrado subiendo 1,00 → 1,84 → 3,46 → 6,03 demuestra pasos de decodificación compartidos; reproducido en tres ejecuciones con un margen del 2%.
Una segunda ejecución en dos 3090 a 32.768 por slot fue de 301,7 → 230,5 por stream y volvió a responder 2. De paso, el catálogo había predicho 308,0 tok/s para esa colocación y la ejecución midió 301,7 — un 2% de desviación, mejor de lo que esperaba. Las filas ahora llevan max_parallel (cuántos caben) junto a recommended_parallel (cuántos merece la pena ejecutar).
Dos flags que medí antes de fiarme
La decodificación especulativa es una victoria de un solo stream. Qwen3.8-27B Q5_K_S con cabeza MTP, una 3090, cuatro prompts distintos de 256 tokens, caché de prompt apagada: sin especulación 37,75 tok/s; draft-mtp a profundidad 3 dio 50,70 tok/s, +34,3%, con 0,528 de aceptación. La profundidad 4 baja a 47,48, porque la aceptación cae a 0,446 y cada token extra rechazado se verificó para nada. ngram-mod logró +0,4% y no emitió ningún borrador.
Y ahí viene la trampa: el mismo prompt tres veces midió +751% en ese 27B. Repite un prompt y estarás midiendo la caché de prompt llamándolo decodificación especulativa. Por encima de cuatro slots, auto ahora devuelve none y dice por qué — «la especulación es una victoria de un solo stream y perjudica a un lote saturado» — después de que una ejecución cargara un 27B con --parallel 8 y auto siguiera eligiendo draft-mtp, viendo la cabeza MTP y no el número de slots.
El micro-lote compra prefill a cambio de VRAM. El mismo 1,5B, un prompt de 5.166 tokens: -ub 512 (el valor por defecto del motor) dio 15.232 tok/s con 1492 MiB; -ub 1024, 17.307 tok/s (+13,6%) con 1562 MiB; -ub 2048, 18.061 tok/s (+18,6%) con 1702 MiB. Estuvo apagado mucho tiempo porque el buffer de cómputo crece con -ub, el planificador no lo modelaba, y un buffer no modelado convierte un ajuste en un out-of-memory. Ahora el planificador lo cobra, redondeado hacia arriba para que peque de rechazar, y sube el micro-lote automáticamente solo por encima de cuatro slots.
El rechazo, con las cifras
Cada lanzamiento pasa --fit off y --n-gpu-layers 999, y el segundo es una constante, no un ajuste. Eso importa más que antes: la build fijada b10425 incluye -fit, --fit [on|off] — «si ajustar los argumentos sin definir para que quepan en la memoria del dispositivo» — con on por defecto, junto a --n-gpu-layers auto; ambos llegaron a upstream en el PR #16653 en diciembre de 2025. Ese par es exactamente una ruta silenciosa de descarga parcial: un valor por defecto razonable para un servidor de propósito general, y el comportamiento exacto que este proyecto existe para rechazar. Cuando nada cabe, la respuesta es HTTP 507 con la aritmética en el cuerpo, recortada aquí:
HTTP 507 {"error": {"code": "insufficient_vram", "type": "server_error", "message":
"Cannot load 'lmstudio-community/gemma-4-31B-it-QAT-GGUF/gemma-4-31B-it-QAT-Q4_0'
entirely in VRAM: needs 29.09 GiB, 20.90 GiB usable. largest single GPU offers
20.90 GiB usable (headroom 10% reserved). Suggestions: set KV cache type to q8_0
(roughly halves KV cache VRAM for a small quality cost); VRAM is held by other
processes: 5.87 GiB held by python.exe (pid 45072) on CUDA1; 0.83 GiB held by
dwm.exe (pid 2468) on CUDA0; …; clear the per-model device override so the
planner can use other GPUs",
"studioforge": {
"required_bytes": 31235974510, "available_bytes": 22438368871,
"per_gpu_free": {"3": 22438368871},
"max_ctx_that_fits": null, "max_parallel_that_fits": null,
"suggestions": ["set KV cache type to q8_0 (roughly halves KV cache VRAM
for a small quality cost)",
"VRAM is held by other processes: …",
"clear the per-model device override so the planner can use
other GPUs"],
"notes": ["wanted up to 262144 tokens of context but not even the 128000 floor
fits in the VRAM available right now",
"device placement forced by per-model device_override"],
"estimate_mb": {"weights_bytes": 16818.2, "kv_bytes": 8575.0,
"compute_bytes": 2438.6, "mmproj_bytes": 1145.1,
"mmproj_compute_bytes": 512.0, "cuda_context_bytes": 300.0,
…, "total": 29788.9},
"vram_holders": [ … one entry per process, per card … ],
"busy_models": [], "retry_after_s": null }}}
Ese es un rechazo real, capturado mientras escribía esto: el 31B pidió cargar en una RTX 3090. La prosa nombra el déficit y los ocupantes; error.studioforge lleva el mismo fallo como datos — los bytes necesarios y disponibles, la VRAM libre por tarjeta, la estimación desglosada en pesos, KV, buffer de cómputo, modelo de proyección y contexto CUDA, cada proceso que retiene VRAM, y notes diciendo en qué peldaño de la escalera estaba parado cuando se rindió. Cuando una ventana más pequeña sí cabría, max_ctx_that_fits la nombra, calculada sobre la geometría por capas para que la oferta sea una que la siguiente carga acepte; aquí ni el suelo cabía, así que es null en lugar de un número que fallaría. Un rechazo que no es por un modelo ocupado no lleva retry_after_s, porque «inténtalo más tarde» es un mal consejo cuando nada va a cambiar.
Fijados, TTL, reservas y el reequilibrador
Un fijado es un estado deseado, no una exención. Antes significaba TTL cero, exclusión de toda escalera de desalojo y un calentamiento al arrancar — y no la cuarta cosa: un modelo fijado nunca se recargaba, así que un hijo que entrara en bucle de fallos más allá de su max_restarts por modelo se quedaba en state="failed" sin retener nada. Ahora un reconciliador cabalga el barrido de 15 segundos, retrocediendo de 60 s a un techo de 900 s. Lo único que supera a un fijado es una persona: una descarga explícita marca el id suprimido y se queda abajo.
Una reserva es una tarjeta que pertenece a un modelo. Una tarjeta reservada está ausente de la vista de GPU de cualquier otro modelo — no queda la última del ranking, no es una opción — y el dueño se ve forzado exactamente a esas tarjetas, dimensionadas por el estimador, en el modo de división que su propio benchmark midió más rápido allí. Una tarjeta ya reservada es un conflicto 409, nunca una toma de control. Una reserva sin modelo mantiene tarjetas para algo fuera del servidor: reserve_gpus(devices=[3], reason="ComfyUI render") es por lo que mi generación de imágenes y mis modelos de lenguaje dejaron de pelearse.
El reequilibrador arregla la buena decisión de ayer. A las 13:42 un 27B se planificó en las tarjetas [1, 3] — una división entre niveles que compartía la GPU1 — porque un 31B retenía [1, 0, 2] y una app de generación de imágenes retenía 7,5 GiB de la GPU2. A las 13:53 el 31B se encogió a [0, 1]; desde entonces [2, 3] quedó libre y estrictamente mejor, y el 27B se quedó donde estaba. Así que ahora un modelo en reposo se mueve cuando un plan sin desalojo con sus ajustes actuales exactos lo saca de toda tarjeta compartida. Los tokens por segundo estimados nunca justifican un movimiento.
Mira una vez por minuto, y solo cuando el mundo ha cambiado: solo en una máquina tranquila, solo para un modelo en reposo cinco minutos, un movimiento por modelo cada 30 minutos — porque una reubicación es una recarga y una recarga tira la caché de prompt, y en la carga de conversaciones largas de este equipo esa caché era el 93% de un prompt de 98k tokens. El desalojo tiene además tres reglas duras — nunca un modelo fijado, nunca uno a mitad de petición, nunca una instancia cargando — y una tarjeta reservada no está en la vista del planificador para empezar. Una carga just-in-time nunca puede poner force.
Cuando las cosas se rompen
La VRAM muere con el proceso que la tomó. En Windows los hijos viven en un job object anónimo creado con JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, así que el kernel mata a todos los miembros cuando se cierra el último handle — acabe como acabe el padre. Anónimo, porque un job con nombre se compartiría con cualquiera que adivinara el nombre. Linux recibe un shim PR_SET_PDEATHSIG, que cubre un kill -9 de la puerta de enlace; es un esfuerzo razonable más que una garantía del kernel, así que el barrido de arranque y reclaim_orphan_engines atrapan lo que se cuela.
Que es por lo que hay un barrido de arranque. El 18 de agosto de 2026, ~10 GiB en la GPU0 y ~15,6 GiB en la GPU1 estaban no disponibles con «todo parado». Los ocupantes eran tres hijos llama-server.exe de un python -m pytest tests -q que un agente de programación había lanzado y que desde entonces había terminado. Ahora cada ocupante se clasifica — ours, child-of-live-process, orphan, other-instance, foreign — y solo se mata orphan, seguro por construcción porque nada más lanza binarios de nuestro árbol de motores.
Nombrar quién retiene qué llevó dos intentos. NVML informa cero de memoria usada por proceso en Windows, así que los tamaños vienen del contador que lee la columna «Dedicated GPU memory» del Administrador de tareas — pero eso es un total por proceso entre adaptadores, así que la columna de dispositivo estaba mal: un proceso informado en CUDA0,1,2,3 en realidad retenía 15,52 GiB en la CUDA0 y nada en las 3090. El arreglo une el LUID del adaptador con una dirección de bus PCI, y una trampa se gana el párrafo: el número de bus en el busId de NVML es hexadecimal. "00000000:42:00.0" es el bus 66, no el 42 — una respuesta equivocada y segura de sí misma, indistinguible de una correcta.
Una descarga se verifica, no se anuncia. El supervisor vuelve a comprobar el pid con una protección de tiempo de creación contra la reutilización de pid, escala a un superviviente a un tree-kill forzado y registra la VRAM antes y después; un superviviente devuelve un 500 pidiéndote que lo mates manualmente. Una descarga que informa éxito mientras el proceso sigue residente es la mentira más cara que este sistema puede contar, porque cada carga posterior se planifica entonces contra una VRAM que no está libre.
Los códigos de salida son vocabulario. 2 es un error de configuración que nombra la clave. 3 es un conflicto de puerto, y la bandeja nunca relanza sobre el puerto en conflicto — espera a que el ocupante responda a /health como servidor StudioForge y se engancha a él en su lugar. 75 es «reinicio solicitado»: el servidor drena, fija el código y se apaga con elegancia — 1,0 s de la petición a la salida, medido — y la bandeja relanza sin gastar un intento de fallo. Esa distinción existe porque un reinicio por GUI produjo una vez dos servidores compitiendo por el 1234, y tres fallos contados después la bandeja se quedaba en Fallido — mira la carpeta de logs junto a un servidor sano que ya no podía parar.
Cuando la puerta de enlace está atascada en lugar de muerta, hablas con el vigilante: un proceso aparte siempre encendido en 1235, construido con argparse y logging de stdlib, así que arranca incluso cuando config.yaml es lo que está roto. Sus diez herramientas son health, get_config, set_config, restart_server, kill_model, nuke_all_models, reclaim_orphan_engines, tail_logs, gpu_status y rollback_update. Reimplementa la regla de huérfanos localmente en lugar de importar el módulo que la posee — el proceso de recuperación no debe importar la pila que repara.
El panel de control
El panel en 8080 es un segundo servidor uvicorn dentro del mismo proceso, que comparte el grafo de objetos de la puerta de enlace por referencia — no tiene ninguna URL absoluta, que es lo que lo hace funcionar igual por HTTP plano en una VPN mesh y detrás de un front end HTTPS.
9.7 tok/s es la aritmética de esta pestaña — fragmentos en streaming sobre el reloj de pared desde el momento en que salió la petición, prefill incluido — no el tiempo de generación del motor, que son los 58,77 tok/s de la tarjeta del modelo. Un chat exitoso aquí es evidencia de que un cliente funcionará, no una ruta simulada que puede derivar.
sfctl por fuera, el PIN de emparejamiento nunca tiene que aparecer en un archivo de configuración.
Cuatro líneas más abajo en el mismo buffer están las que leo cuando una carga me sorprende — la línea de comandos que construyó, el proceso que respondió, y el planificador corrigiendo su propia tarea (timestamps y nombres de logger recortados):
model_spawn argv='C:\Users\<you>\…\engines\b10425\llama-server.exe
--model E:\LLM\Models\…\gemma-4-31B-it-QAT-Q4_0.gguf --host 127.0.0.1 --port 18100
--n-gpu-layers 999 --ctx-size 262144 --parallel 1 --device CUDA1,CUDA0
--tensor-split 0.5368,0.4632 --split-mode layer --cache-type-k q8_0 --cache-type-v q8_0
--flash-attn on --fit off --cache-reuse 256 --cache-ram 32603 --reasoning-format deepseek
--mmproj …' port=18100 source=jit:/v1/chat/completions
model_ready pid=34732 port=18100 source=jit:/v1/chat/completions
load observation actual_mb=37046 predicted_mb=33031 ratio=1.122 ctx=262144
devices=[1, 0] per_device_mb={'0': 18908, '1': 18138}
[warning] a device holds more than its planned share devices=[1, 0]
overruns={'CUDA0': {'planned_mb': 15886, 'actual_mb': 18908}}
detail='llama.cpp places the output layer on the last device of the list; the planner
now charges it there, so a persistent overrun means the charge is too small for this model'
Eso es el planificador pillándose a sí mismo por 3 GB en una tarjeta, 4 GB en el par, y diciendo qué tarjeta y por qué — la capa de salida aterrizó en el último dispositivo, exactamente donde la cobra, y el cobro aun así se quedó corto.
Usarlo como backend de arnés
Seis clientes de mi red hablan con esto y solo uno sabe que es StudioForge. Esa es la gracia. Cinco están dibujados abajo; OpenClaw Email es el sexto.
Cualquier cliente OpenAI
Dos variables de entorno. server.api_key es null de fábrica, así que vale cualquier cadena no vacía — la mayoría de clientes OpenAI se niegan a arrancar con una vacía.
export OPENAI_BASE_URL=http://my-gpu-rig:1234/v1
export OPENAI_API_KEY=not-required # any non-empty string while server.api_key is unset
curl http://my-gpu-rig:1234/v1/chat/completions -H "Content-Type: application/json" \
-d '{"model": "<id from /v1/models>", "messages": [{"role": "user", "content": "hello"}]}'
from openai import OpenAI
client = OpenAI(base_url="http://my-gpu-rig:1234/v1", api_key="none") # any non-empty string, until you set one
print(client.models.list()) # every downloaded model; naming an unloaded one loads it on demand
GET /v1/models lista todo lo descargado, al estilo de LM Studio, añadiendo state y — cuando está residente — ctx_per_slot, max_parallel y parallel_limited_by, porque un solo tamaño de contexto es ambiguo cuando un modelo corre más de un slot. Los ids hacen round-trip: el id completo publisher/repo/file, un nombre de archivo a secas, o publisher/name, sin distinguir mayúsculas. DisPatch no necesitó más que una URL base nueva; OpenClaw Email es el cliente más exigente, que quiere un modelo de chat y un modelo de embeddings y llama a /v1/models al arrancar para preguntar qué se está sirviendo de verdad.
OpenClaw, en otra máquina
La máquina del agente instala un pequeño wheel: sfctl, que apunta a Python 3.11 en lugar del 3.12 del servidor porque la máquina que corre el agente suele ir por detrás del equipo, y que a propósito no depende del paquete del servidor — sin CUDA, sin planificador, sin registro.
sfctl servers add rig http://my-gpu-rig:1234 --api-key <PIN> --use
openclaw mcp add studioforge --command sfctl --arg mcp
O a mano — el detalle que le cuesta una tarde a la gente. La clave de OpenClaw es mcp.servers, anidada bajo mcp: el mapa plano mcpServers vale para Claude Code, Cline y LibreChat, y no es una clave que conozca el esquema de OpenClaw. La inferencia es un camino aparte, bajo models.providers — fíjate en baseUrl, con la rl en minúscula:
// ~/.openclaw/openclaw.json
{ "mcp": { "servers": {
"studioforge": { "command": "sfctl", "args": ["mcp"] } } },
"models": { "providers": {
"studioforge": {
"baseUrl": "http://my-gpu-rig:1234/v1",
"apiKey": "not-required",
"api": "openai-completions",
"models": [ { "id": "<id from /v1/models>", "name": "Rig 27B", "contextWindow": 131072 } ] } } } }
Lo que recibe el agente es una lista fusionada de 29 herramientas: las 19 de la puerta de enlace más las 10 del vigilante, tres renombradas como recovery_* — get_config y set_config porque chocan con herramientas de la puerta de enlace, health por simetría. restart_server conserva su nombre a secas, porque es el nombre que el mensaje de error de una herramienta de gestión muerta le dice al agente que llame. Cuando el servidor principal está caído, el puente sigue anunciando las 19 herramientas de gestión con una nota añadida — un agente que no puede ver load_model no sabe que la capacidad existe. El bucle que ejecuta:
list_models(limit=N)— el catálogo, la descarga más reciente primero. Lee la fila recomendada.load_model(**row["load_args"])— pásalo tal cual; un agente que ha elegido una fila ya ha terminado de elegir.load_recommended(model_id, ctx_size=N)cuando lo que sabes es el contexto que necesitas — la única ruta de carga que rechaza en lugar de encogerse.- Inferencia por HTTP, no por MCP. Nombrar un modelo no cargado lo carga, con los valores por defecto del planificador en lugar de la fila que estabas leyendo.
model_options(model_id)cuando la fila recomendada no basta: cada nivel de contexto, con velocidades.search_models→repo_details→download_modelpara conseguir algo nuevo.pin_modelpara el modelo que siempre debe responder;reserve_gpus/release_gpuspara tarjetas propias.server_statusyconnection_info— qué está residente, quién retiene VRAM, cada dirección en la que responde.
Y el párrafo del que más orgulloso estoy, servido a cada cliente al conectar:
INFERENCE IS NOT HERE. This server exposes no chat/completion/generation tool
by design. To actually run a prompt, use the OpenAI-compatible HTTP API on the
gateway port (POST /v1/chat/completions, /v1/embeddings; GET /v1/models).
Naming an unloaded model in a request just-in-time loads it, so you usually do
not need load_model at all -- reach for it only to pre-warm a model or to load
one with non-default context/quantization settings.
dsh, Claude Code y cinco one-liners
dsh (DeepSeek Harness) cambia de modelo editando un archivo YAML, recargado en caliente para la siguiente petición. El bloque de proveedor es camelCase — baseURL, apiKeyEnv:
# ~/.dsh/settings.yaml
llm-pi-ai:
providers:
gpu-rig:
displayName: StudioForge (GPU rig)
apiKeyEnv: STUDIOFORGE_PLACEHOLDER_KEY # a reference, not a value
api: openai-completions
baseURL: http://my-gpu-rig:1234/v1
defaultContextWindow: 131072
compat:
supportsDeveloperRole: false # many local servers reject role: "developer"
maxTokensField: max_tokens
models:
- id: <id from /v1/models>
apiKeyEnv es una referencia, nunca un literal — y un servidor local sin clave sigue necesitando que se referencie alguna credencial, porque el cliente compatible con OpenAI insiste en un bearer token. Claude Code, en cambio, no puede enrutar aquí su propia inferencia en absoluto: su referencia de protocolo de puerta de enlace lista Anthropic Messages, Bedrock y Vertex, y ninguno es /v1/chat/completions. Así que StudioForge es su herramienta, no su cerebro — claude mcp add studioforge -- sfctl mcp (el -- es obligatorio) le entrega las 29.
bench-llm produjo las cifras del equipo que la gente me cita — Gemma 4 26B-A4B (QAT, Q4) a 229,0 tok/s, 110 ms hasta el primer token, medidas a través de LM Studio en su momento — y es un cliente OpenAI normal, así que una URL base es todo lo que necesita; pero ejecuta pkill -f llama-server entre benchmarks, lo que mata a todos los backends de StudioForge de la máquina. El resto son una línea cada uno: Open WebUI, OPENAI_API_BASE_URL; LibreChat, un endpoint custom con baseURL y models.fetch: true; aider, OPENAI_API_BASE y luego --model openai/<id>; Continue, provider: openai más apiBase. La clave de la URL base se escribe distinta en cada uno de ellos, que es la fuente más fiable de tardes perdidas en este ecosistema.
De qué elige un agente
El catálogo convierte la elección de modelo en una consulta en lugar de una suposición: ordenado con la descarga más reciente primero, una fila por nivel de contexto, cada una con fits contra la VRAM libre en vivo, devices, tipos de KV, max_parallel, recommended_parallel, una confidence, una columna if_gpus_idle y load_args. Una fila es una llamada real al plan, así que no puede prometer lo que una carga rechazaría — y if_gpus_idle es la diferencia entre un agente que se rinde y un agente que llama a unload_model.
repo_details es la que merece nombrarse: lee una cabecera GGUF de forma remota, por peticiones HTTP Range — 2–15 MB, sobre todo los arrays de cadenas con prefijo de longitud del tokenizador, cacheados en disco — en lugar de descargar 20 GB para averiguar si cabe, y una CDN que responde a una petición con rango con un 200 y el cuerpo entero se detecta y se rechaza. Lo que vuelve es una matriz context_fit del mismo planificador que usa una carga real:
| Quant | 1× RTX 5090 | 2× RTX 5090 | Las cuatro tarjetas |
|---|---|---|---|
| BF16 (51,8 GiB) | — los pesos solos no caben | 32k en q8_0 | 256k |
| Q8_0 (27,9 GiB) | — | 256k | 256k |
| Q5_K_M (19,3 GiB) | 128k en q8_0 | 256k | 256k |
| IQ2_M (10,5 GiB) | 256k | 256k | 256k |
De la guía de OpenClaw del repo, calculada en este equipo, para unsloth/Qwen3.8-27B-GGUF. max_ctx es la ventana más grande con una caché f16 de calidad completa; una cifra q8_0 aparece solo donde llega más lejos.
Lo que dice la tabla: la cuantización decide la ventana, no el número de tarjetas — de BF16 a Q5_K_M, «no cabe en absoluto» se convierte en 128k en una tarjeta — y es la respuesta del propio planificador, así que donde un nivel solo se alcanza cuantizando la caché la matriz dice «en q8_0» en lugar de contarlo como victoria. Para cifras reales el guion son tres pasos: un benchmark de colocación (cada modo de GPU bajo su propia reserva, throughput de los tiempos del propio llama-server), luego benchmark_parallel sobre el ganador, y luego reserve_gpus para fijarlo. Nunca hagas benchmark de un modelo con el que alguien está a mitad de conversación.
Drop-in para LM Studio
La compatibilidad era la restricción de diseño, y el repo lista lo que se tomó prestado para que nadie tenga que adivinarlo: el puerto 1234; /v1/models listando lo descargado en lugar de lo cargado; la carga just-in-time; el TTL de reposo; el ttl por petición; la estructura publisher/repo/ usada en su sitio, así que no hay paso de importación y ambos programas comparten una biblioteca; el espejo /api/v0/models; y el deep link lmstudio://open_from_hf. Migrar un cliente es un cambio de host, no un cambio de host y puerto.
| LM Studio 0.4.21 | StudioForge 1.26-08-23 | |
|---|---|---|
| Motor | builds propias de llama.cpp más MLX en Apple | llama-server de upstream, una build fijada (b10425, CUDA 13.3), probada antes de activarla |
| Rutas sin enrutar | 200 con un cuerpo de error — su log dice Returning 200 anyway | 404 con un sobre JSON, y JSON en cada estado |
| Errores | prosa sin estructurar que los clientes parsean con regex | un error.code estable, diagnósticos bajo error.studioforge |
| Configuración de carga | context_length ignorado en una de las dos rutas de carga; repetition_penalty ignorado en silencio | una sola ruta de carga, cada campo respetado, valores efectivos devueltos; alias de sampler aceptados |
| Cuando no cabe | «reducirá automáticamente el tamaño de la descarga a GPU … y el resto en la RAM del sistema» — un volcado silencioso a CPU, por diseño | 507 insufficient_vram con bytes necesarios y disponibles, libre por GPU, el mayor contexto que cabría, sugerencias ordenadas |
| Multi-GPU | prioridad o división uniforme, conmutadores por GPU, tensor parallel desde 0.4.15 | un planificador que dimensiona contexto, tipo de KV y slots por colocación, inclinando las fracciones de división para la capa de salida |
| TTL de reposo | 60 minutos; el auto-evict mantiene como mucho 1 modelo cargado JIT | 1.800 s de fábrica (15 min en mi equipo), barrido cada 15 s; tantos como quepan, con fijados y reservas decidiendo quién se queda |
| Gestión remota | /api/v1 load/unload/download; LM Link en preview, que se monetizará, descubrimiento vía el hub de LM Studio | /api REST, sfctl, 29 herramientas MCP; el alcance es tu LAN o tu propia VPN mesh |
| MCP | solo host — consume servidores MCP | un servidor MCP para su propia gestión, más uno en el vigilante |
| Fuente | cerrada; gratuita para uso personal y empresarial interno | fuente en el zip, y sin licencia elegida aún |
Comprobado contra el changelog, los docs y el bug tracker del propio LM Studio el 2026-08-23, versión 0.4.21 (publicada el 12 de agosto de 2026). La fila 2 está en su bug tracker público; las filas 3–4 son lo que mi propio cliente tuvo que esquivar en la API 0.3.x. No he vuelto a probar ninguna contra la 0.4.21, así que léelas como «documentado en algún momento», no como «roto hoy».
Lo que dice la tabla: es una mejor app de escritorio de lo que esto será jamás — GUI pulida, MLX en Apple Silicon, un endpoint compatible con Anthropic, un compañero móvil, un equipo publicando cada dos semanas — y la división es filosófica más que de funciones. La postura por defecto de LM Studio es el mejor esfuerzo: haz que corra de alguna manera. La mía es rechazar-y-explicar. Donde un agente decide qué se carga, el mejor esfuerzo es el valor por defecto equivocado, porque nada aguas abajo puede distinguir lo rápido de lo lento sin medir.
Cómo se compara
| Servidor · motor · licencia | Hot-swap / TTL de reposo | Colocación multi-GPU | Rechaza el volcado a CPU | Gestión remota / MCP |
|---|---|---|---|---|
| StudioForge 1.26-08-23 llama.cpp, una build fijada · licencia: ninguna aún | ✅ JIT · TTL de 1.800 s, barrido de 15 s | planificada por modelo, tarjetas mixtas, fijados + reservas | ✅ -ngl 999 + --fit off, 507 con cifras | ✅ REST + 29 herramientas MCP |
| LM Studio 0.4.21 llama.cpp propio + MLX · cerrada | ✅ JIT · 60 min, auto-evict a 1 | división por prioridad/uniforme, tensor parallel | ❌ reduce la descarga, el resto en RAM | REST; MCP solo como host |
| Ollama 0.32.15 llama.cpp/GGML; MLX en Apple · MIT | ✅ keep_alive 5 min · 3 residentes por GPU | reparto automático entre tarjetas | ❌ vuelca, muestra % de CPU en ollama ps | /api/* rico; sin servidor MCP |
| llama-server router (b105xx, agosto de 2026) es llama.cpp · MIT | ✅ un hijo por modelo · --sleep-idle-seconds, LRU por recuento | -sm / -ts / -dev manuales | ❌ --fit on encoge tu plan | /models/load|unload |
| llama-swap v251 un proxy que lanza a otros · MIT | ✅ todo el producto · ttl por modelo/grupo | ❌ lo que diga tu cmd | n/d — solo proxy | /ui + rutas de upstream |
| vLLM 0.27.1 propio (PagedAttention) · Apache-2.0 | ❌ un modelo por proceso | tensor / pipeline / expert parallel | parcial — sin ruta de descarga por capas | solo LoRA, «local dev» |
| KoboldCpp 1.119 fork de llama.cpp + imagen/audio · AGPL-3.0 | ✅ --admin + --routermode | --tensor_split manual | ❌ vuelca | /api/admin/*; solo cliente MCP |
| TextGen (ex-oobabooga) 4.9 5 loaders incl. ExLlamaV3, TRT-LLM · AGPL-3.0 | ✅ cambio sin reiniciar · ¿TTL? | --tensor-split manual | ❌ vuelca | /v1/internal/model/*; cliente MCP |
| TabbyAPI (rolling) solo ExLlamaV3 — sin GGUF · AGPL-3.0 | ✅ admin + carga inline · ¿TTL? | gpu_split_auto activado por defecto | ✅ en efecto — ExLlama no tiene ruta de CPU | /v1/model/load con admin-key |
| Jan 0.8.4 modo router de llama.cpp · Apache-2.0 | ✅ vía el router · ¿TTL? | heredada de llama.cpp | ❌ vuelca | /v1/orchestrations; cliente MCP |
| LocalAI 4.9.0 más de 60 backends como imágenes de contenedor · MIT | ✅ bajo demanda · WATCHDOG_IDLE_TIMEOUT | «automatic GPU model fitting» | ❌ «No GPU required» | REST + UI; solo cliente MCP |
| GPUStack 2.2.3 vLLM, SGLang, MindIE, VoxBox · Apache-2.0 | parcial — despliegues de clúster | Spread/Binpack automático, multi-nodo | ? | API completa de gestión de clúster |
Comprobado desde fuentes primarias el 2026-08-23. Un signo de interrogación significa desconocido, no «no». Los workers de GPUStack son solo Linux; vLLM no tiene soporte nativo de Windows (solo WSL o forks).
Lo que dice la tabla:
- La carga JIT y el TTL de reposo no son novedosos y no lo estoy reivindicando. LM Studio, Ollama, llama-swap y LocalAI hacen ambas cosas, y los flags
swap/exclusive/persistentpor grupo de llama-swap son un motor de políticas genuinamente elegante. - Tres cosas de ahí son raras: negarse a correr antes que volcar a CPU (solo TabbyAPI se acerca, y solo porque ExLlama no tiene ruta de CPU); un planificador que dimensiona contexto y slots a las tarjetas que encontró de verdad; y la gestión publicada como herramientas MCP, que nada más de la categoría hace que yo sepa.
- Lo más cercano en upstream es el router del propio llama.cpp, una pelea justa: multi-modelo con aislamiento de procesos, gratis, en el binario que ya tienes. Lo que le falta es desalojo por memoria en lugar de por recuento, fijados, reservas y un rechazo con cifras; sí tiene un sueño de reposo (
--sleep-idle-seconds), aunque un sondeo de/metricslo despierta.
Varias buenas ideas están tomadas prestadas, y el repo dice cuáles. El Modelfile de Ollama se convirtió en modelos virtuales, así que dos personas sobre una misma base comparten un solo llama-server, y su keep_alive se convirtió en el ttl por petición. La superficie de ajustes en tres niveles de TextGen se adoptó directamente, «extra flags» en bruto incluidos, con los flags validados contra el --help del propio motor fijado al guardar. La filosofía de un solo artefacto de KoboldCpp es por lo que los motores viven en directorios versionados.
Instalación
Windows, la plataforma de referencia, en cuatro pasos: instala Git, Python 3.12+, uv y un driver NVIDIA actual; clona el repo o descomprime la descarga; haz doble clic en launchers\Update StudioForge.bat, que pese a su nombre es el paso de primera ejecución — construye el virtualenv, instala la release de llama.cpp más nueva que tenga build para tu driver, la prueba y se fija a ella (b10425 es la build sobre la que se midió este artículo, no la que obtendrás tú); y luego launchers\Start StudioForge.bat, o launchers\StudioForge Tray.bat si lo quieres en el área de notificación, y el panel se abre en http://127.0.0.1:8080 en su pestaña Setup.
Linux, cuatro líneas — más cmake y un CUDA toolkit cuyo nvcc coincida con tu driver, porque upstream no publica ningún archivo CUDA de Linux en ningún tag y el motor se compila desde fuente una vez por versión:
git clone https://github.com/LaserLloyd/StudioForge.git && cd StudioForge
uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python -e ".[dev]"
.venv/bin/studioforge serve --open # first run builds the engine
Para una máquina headless, deploy/ contiene dos unidades systemd de usuario — unidades de usuario a propósito, porque el proceso debe correr como el usuario que inició sesión y que posee la biblioteca de modelos, el venv y los nodos de dispositivo GPU. El vigilante a propósito no está BindsTo= la puerta de enlace y usa Restart=always: existe para estar arriba cuando la puerta de enlace no lo está. Y luego sudo loginctl enable-linger "$USER", el mismo patrón que el artículo sobre ComfyUI headless. La primera ejecución se abre en Setup, donde Detect LM Studio library sondea primero el downloadsFolder en ~/.lmstudio/settings.json.
| Servicio | Puerto por defecto | Clave de configuración |
|---|---|---|
Puerta de enlace — /v1, /api, /mcp | 1234 | server.port |
| Panel de control web | 8080 | gui.port |
| Vigilante de recuperación | 1235 | watchdog.port |
Hijos llama-server (solo loopback) | 18100–18200 | gateway.child_port_start / _end |
Lo que dice la tabla: el puerto 1234 significa «el servidor de modelos local» en mis dos máquinas y no son lo mismo — la máquina del agente corre el suyo en 127.0.0.1:1234, loopback, mientras el equipo sirve my-gpu-rig:1234 por la VPN mesh — y solo tres puertos son alcanzables jamás, con la validación de configuración rechazando una colisión entre cualquier puerto de servicio y el rango de hijos en el momento de cargar.
La regla del directorio de datos es SF_DATA_DIR primero, luego la carpeta de un archivo --config, y luego <repo>/data en un checkout — el orden completo, y por qué data_dir nunca se escribe de vuelta en config.yaml, está en docs/SETUP.md. Una instancia posee un directorio de datos, impuesto por un lock exclusivo del sistema operativo; una segunda instancia es de solo lectura.
Seguridad, con honestidad
Una regla debajo de todo: las lecturas, la inferencia y la residencia siguen abiertas; cambiar la máquina, no. Con server.api_key sin fijar, una petición mutante a una ruta que cambia la máquina se acepta solo desde un llamador en esta máquina, o con el PIN MCP enviado como X-MCP-Pin o como bearer token — cualquier otra cosa recibe 403 remote_admin_requires_credential. El conjunto protegido es configuración, reinicios, motores, actualizaciones, recuperación de VRAM, descargas, reservas, borrados y las dos escrituras por modelo que sobreviven a la instancia. El problema que arregló era mío: cualquiera en la LAN podía hacer PATCH /api/config, fijar server.api_key él mismo y dejarme fuera — mientras la herramienta MCP set_config, la misma capacidad en el mismo proceso, exigía el PIN.
- El PIN protege solo MCP. Es un código de emparejamiento que lees del banner de arranque, limitado a las herramientas de gestión. No es una API key.
server.api_keyes la credencial real y esnullpor defecto. Fíjala y cubre/v1,/api,/mcpy el vigilante; el PIN sigue funcionando en los dos endpoints MCP junto a ella.- El bind de fábrica es
0.0.0.0en los tres listeners. La fila Network exposure de la pestaña Setup se pone ámbar y obligatoria en cuanto cualquier listener se expone sin clave — comprobando los tres, porqueserver.hosten loopback congui.hosten0.0.0.0antes leía verde mientras el panel estaba de par en par. - Una petición de navegador cross-origin no es «esta máquina», ni en loopback. Con
cors_origins: ["*"], cualquier página que visites podría preflight dePATCH /api/configen127.0.0.1:1234y llegar pareciendo local — así que la comparación de origen incluye el puerto, yOrigin: nullcuenta como foráneo. CORS gobierna lo que una página puede leer, nunca en quién confía el servidor. El websocket del panel tiene una versión solo-host de la misma puerta, porque el panel se alcanza por el puerto desde el que se sirvió. - Un navegador remoto en una instalación sin clave recibe lecturas e inferencia, 403 en los cambios de la máquina, y el PIN retenido — de lo contrario, cualquier cosa en la LAN podría leer el PIN de un endpoint abierto y usarlo. El PIN era teatro justo cuando importaba.
- Las imágenes se obtienen bajo una protección SSRF que bloquea loopback, link-local, privado, ULA y espacio CGNAT — el rango
100.64/10, donde vive cada peer de VPN mesh — y resuelve una vez, conectando a la dirección verificada con elHosty el SNI originales. - Nada sale de la máquina sin pedirlo. Las únicas llamadas salientes son Hugging Face para los modelos, GitHub para la build fijada de
llama-servery su comprobación de actualizaciones, la comprobación opcional de versiones de StudioForge, y las URLs de imagen que nombre una petición; el self-update informa «no configurado» sin una llamada de red hasta que ponesupdate.repo, y un test unitario lo fija.
Dos límites declarados en lugar de ocultos: una comprobación de dirección de peer confía en lo que esté en loopback, que detrás de un reverse proxy es el proxy, así que pon el proxy detrás de server.api_key; y no hay autenticación más allá de una clave compartida — sin cuentas, sin limitación de velocidad. La regla de la casa de el artículo sobre OpenClaw sigue valiendo: loopback más un proxy con autenticación, o una VPN mesh, nunca el internet crudo.
La parte honesta
Escrito con claridad para que puedas decidir antes de instalarlo:
- Windows es la plataforma de referencia — es contra lo que se construyeron la bandeja, la protección de VRAM por job object y los contadores de GPU por proceso. Linux está soportado y CI ejecuta ambos, y está menos probado en combate; la ruta de compilación desde fuente tiene su construcción de comandos probada pero nunca se ha ejercitado de punta a punta aquí. macOS no está soportado: no hay CUDA.
- Solo NVIDIA. El planificador lee NVML, el motor es una build CUDA, y la afinidad de cuantización se expresa en capacidades de cómputo.
- La estimación de VRAM es una estimación. Los pesos aterrizan dentro del 2% del tamaño del archivo y la KV es exacta desde la geometría por capas, pero el buffer de cómputo es una fracción calibrada, afinada una vez al arrancar, limitada a 0,03–0,15 y mantenida solo en memoria — así que una mala calibración se deshace con un reinicio. Dos historiales de calibración en esta máquina se contaminaron y ahora se ignoran por completo.
- La división multi-GPU es proporcional, no medida. No modela el ancho de banda del interconectado, y mezclar generaciones corre al ritmo de la tarjeta más lenta.
- Las estimaciones de concurrencia son aritmética. El estimador supone que los slots están a medio llenar y reduce los modelos MoE por una mitad plana, y
--ctx-checkpointsno se modela en absoluto. Ambos errores apuntan hacia menos slots, que es la dirección segura — pero ejecuta el benchmark en paralelo antes de fiarte de 8. - Las estimaciones de velocidad usan cifras nominales del fabricante — 5090 a 1792 GB/s y 209 TFLOPS fp16, 3090 a 936 y 71, ninguna medida aquí. Hay exactamente dos anclas de calibración, ambas en este equipo, ambas a un slot: una 31B densa midió 39,4 tok/s frente a una estimación de 36,1, y una 122B MoE midió 37,3 frente a 47,4. Nada está validado a cuatro u ocho slots, ni en un modelo denso por encima de 31B.
- Reservar una GPU para otro programa solo limita a mi planificador. Nada lo impone contra el otro programa, y nada evita que tome la memoria primero.
- Una instancia por directorio de datos, y el lock cubre el directorio de datos en lugar de la biblioteca de modelos — dos instancias con directorios de datos distintos sobre una biblioteca siguen siendo dos escritores.
- No se ha elegido licencia. A propósito no hay archivo
LICENSE, ypyproject.tomllo dice en un comentario — Consíguelo tiene lo que eso significa en la práctica. - No hay auditoría de seguridad de terceros. Las afirmaciones de arriba describen lo que hace el código; yo escribí ambas cosas. Lee la fuente — por eso es una descarga y no un servicio.
2.503 tests unitarios pasan y 17 se saltan, en 327 segundos en esta máquina; CI ejecuta la misma suite en Windows y Ubuntu con la sonda de GPU forzada a un backend nulo. Una segunda suite carga pesos reales en GPU reales y está deseleccionada por defecto y protegida detrás de una variable de entorno — tirantes y cinturón, tras el incidente de huérfanos de arriba. Sin hacer, o sin activar: el A/B de micro-lote a 8 slots, los presets de sampler con nombre y mypy en CI; el self-update de la app está escrito pero sigue apagado hasta que pongas update.repo tú mismo.
Trampas
La lista honesta — cosas que de verdad mordieron, en orden aproximado del tiempo que costaron:
- Primero hay que cerrar LM Studio en el 1234. Ambos no pueden ocupar el puerto; el preflight nombra al ocupante en lugar de imprimir un traceback de bind, y
server.portlo mueve. La biblioteca sí se puede compartir. - Dos flags de llama.cpp no significan lo que parecen.
--ctx-sizees el presupuesto entre todos los slots, no por slot;--fitpor defecto está on en upstream, junto a--n-gpu-layers auto— ambos arriba, bajo el planificador. - Los modelos de razonamiento devuelven una respuesta vacía bajo
--reasoning-format auto. Mismo prompt, solo cambió el flag:content0 caracteres yreasoning_content316 bajoauto;content323 bajonone.reasoning_contentno está en el esquema de OpenAI, así que un cliente estándar lee una cadena vacía y concluye que el modelo no dijo nada. Yo ejecuto el 31B endeepseekporque mis clientes leen ese campo; todo lo demás recibenone. - Las prereleases
vX.Y.Zde llama.cpp no llevan asset CUDA de Windows. Una etiquetadav0.1.2quedó por encima de dos buildsbNNNNnormales sin ningún archivo precompilado, así que la pestaña Server ofrecía una actualización detrás de un botón que solo podía fallar. Ahora los tags se filtran a^b\d+$. - Nunca hagas tree-kill de la raíz de la bandeja con una pestaña del navegador abierta en el panel. Bajo un stub de lanzador de venv, el vigilante es el nieto del servidor, y un reinicio una vez terminó con el servidor muerto, el vigilante muerto y nada lanzado. Usa el menú de la bandeja, el panel, o
sfctl recover --restart. pkill -f llama-serverdesde otra herramienta mata tus backends. bench-llm hace exactamente eso entre ejecuciones — arriba, bajo los clientes del arnés.- El PIN MCP no es una API key, y un servidor sin clave sigue necesitando una credencial de relleno en algunos clientes — arriba, bajo dsh.
- La clave MCP de OpenClaw es
mcp.servers, anidada bajomcp. El mapa planomcpServersno es una clave que conozca su esquema — arriba, bajo OpenClaw. - Dos atajos de medición te mentirán. Repetir un prompt en un benchmark especulativo cronometra la caché de prompt (+751% frente a +0,4%), y una fórmula de KV de capas × cabezas × contexto se equivoca en 4× en una Qwen3.5 — ambos arriba, bajo el planificador.
/propstambién informaspeculative.types: "none"mientras está redactando, así que leetimings.draft_nde una finalización real. - Los modelos de visión no obtienen beneficio de caché de prompt. llama.cpp desactiva por sí mismo la reutilización de caché para los modelos multimodales, y cada imagen se presupuesta en 1.024 tokens salvo que los metadatos del mmproj digan otra cosa — suficiente para que una ventana de 8k sea sobre todo imágenes.
Consíguelo
El zip de abajo es todo el conjunto: el árbol de código fuente etiquetado, los tests, los docs, los lanzadores, las unidades systemd, más una carpeta dist/ con ambos wheels y el sdist para que puedas instalar sin un paso de compilación. studioforge-2026-08.zip — release v1.26-08-23, 4.238.728 bytes (4,04 MiB), SHA-256:
84f4f828b5c75206236890f28e8651c96146a7bb39c14e21b13a0922a13f7d3f studioforge-2026-08.zip
226 entradas bajo un único directorio de nivel superior, construido con git archive desde el tag anotado v1.26-08-23 en el commit 0610446, así que solo puede contener archivos rastreados — sin config.yaml, sin data/, sin sobrescrituras locales. El código fuente también está en github.com/LaserLloyd/StudioForge. Necesita Python 3.12+, uv, un driver NVIDIA de la serie 580 en adelante y una carpeta de GGUF. Licencia: aún no elegida, así que formalmente todos los derechos están reservados — en la práctica, trátalo como el resto de las descargas de este sitio: gratis para uso personal, y si lo quieres comercialmente, pregúntame.
Si algo se rompe, escríbeme — la dirección está en la página Acerca de — y envíame la forma del fallo en lugar de tu configuración: el error.code, las cifras de un 507, las últimas veinte líneas de logs/models/<model>.log. Nunca el PIN ni la clave. Y si construyes el planificador de bin-packing conjunto, los presets de sampler con nombre, o una ruta AMD que funcione de verdad antes que yo, preferiré fusionar la tuya a escribir la mía.
Dónde me deja esto
Lo que no esperaba era cuánto de esto resultó ser medición más que código. El planificador es aritmética que cualquiera podría escribir; lo que lo hizo fiable fue leer la propia geometría de KV de llama.cpp en lugar de una fórmula, y luego medir el codo de slots en 2 cuando el estimador decía 8. Casi cada decisión de ese registro empezó como un número que contradecía una creencia. Si ya ejecutas LM Studio en 1234, todo el experimento es un clon, un archivo batch, y apuntar models.dir a la carpeta que ya tienes — y si no se gana su puesto en una tarde, tu configuración anterior queda intacta.
Relacionado: DeepSeek Harness (dsh) (donde aparece por primera vez este servidor, como un bloque de proveedor sin explicar), bench-llm (de donde salen las cifras de tokens por segundo del equipo, y la herramienta que matará tus backends), Mi configuración de OpenClaw (el artículo que nombró el problema que esto resuelve), y DisPatch (la app de chat que hay delante).
Descargas
Gratis para uso personal. Si te ahorra una tarde, el botón del café está aquí cerca.