StudioForge : un serveur LLM uniquement GPU qui a remplacé LM Studio sur mon rig, piloté à distance par mes agents
- Catégorie
- IA et LLM locaux
- Publié
- 23 août 2026
- Par
- Jacob Lloyd — rédigé avec l'aide de l'IA, une fois le projet terminé
- Temps de lecture
- 63 min de lecture
En clair : C'est un programme qui tourne sur l'ordinateur équipé des cartes graphiques. Les applications lui demandent une réponse d'un modèle d'IA, et il détermine sur quelles cartes ce modèle tient, le démarre, le garde au chaud tant qu'il est utilisé, puis l'éteint quand il ne sert plus. La règle qu'il ne transgresse jamais : un modèle tient entièrement sur les cartes graphiques ou il est refusé — il ne va pas exécuter silencieusement la moitié sur le processeur lent et vous laisser vous demander pourquoi tout rampe. Le hic : il faut des cartes NVIDIA, il est surtout testé sous Windows, et je n'ai pas encore choisi de licence.
StudioForge est le serveur de modèles local que j'ai écrit pour remplacer LM Studio sur mon rig GPU : un superviseur llama.cpp avec une API compatible OpenAI sur le port 1234 — le port de LM Studio, exprès — un panneau de contrôle navigateur, un sidecar de récupération qui répond quand le processus principal ne le peut pas, et un plan de gestion publié via MCP pour qu'un agent sur une autre machine puisse le piloter sans shell.
Il existe à cause d'un journal d'incidents qui a cessé d'être drôle. Un 200 qui ne prouvait rien : LM Studio répond aux chemins non routés avec un statut de succès et un corps d'erreur, son propre journal affichant Returning 200 anyway. /v1/models listant tout ce qui est téléchargé plutôt que chargé, si bien que « qu'est-ce qui est réellement résident ? » n'avait pas de réponse. Des modèles de raisonnement débordant d'une fenêtre de 8 192 tokens, parce que le contexte que je réglais dans la config du client n'était pas le contexte utilisé par le chargement — dans LM Studio, la fenêtre est figée au moment du chargement, et rien ne me le disait. Et le plus coûteux : un modèle qui ne tenait pas était tronqué au lieu d'être refusé — le comportement documenté est qu'il « réduira automatiquement la taille de l'offload GPU … et le reste dans la RAM système », ce qui troque la vitesse GPU contre la vitesse de la RAM système et signale un succès dans les deux cas.
Si vous avez lu l'article sur dsh, vous avez déjà rencontré ce serveur : le bloc fournisseur intitulé « StudioForge (GPU rig) » est celui-ci. C'est ce que la pile à huit agents va chercher à travers le réseau pour le gros travail, ce à quoi DisPatch parle, et ce à quoi OpenClaw Email demande un modèle de chat et un modèle d'embedding. Mon article sur OpenClaw nommait le problème — « des minutes de chargement de modèle, de la VRAM jonglée entre les services » — et ce qui suit en est la réponse, chaque règle portant la mesure qui l'a imposée.
En bref
- Ce que c'est : un serveur LLM uniquement GPU et compatible OpenAI, construit sur
llama-serverde llama.cpp, mesuré sur le buildb10425(CUDA 13.3). Une passerelle sur1234, un panneau de contrôle sur8080, un chien de garde sur1235, un enfant par modèle chargé sur18100–18200. - Ce qu'il fait : charge un modèle à la première utilisation, en planifiant son contexte, son type de cache KV, son placement GPU et son nombre de slots en fonction de la VRAM libre à cet instant ; le met en veille ; garde les modèles épinglés résidents ; attribue des cartes entières à un modèle sur demande ; et publie 29 outils via MCP — 19 de gestion, 10 de récupération.
- Ce qu'il ne fait jamais : déverser sur le CPU (un modèle tient entièrement en VRAM ou est refusé avec les chiffres), téléphoner à la maison (les seuls appels sortants sont Hugging Face pour les modèles, GitHub pour le build épinglé de
llama-serveret sa vérification de mise à jour, la vérification optionnelle de version de StudioForge, et les URL d'images nommées par une requête), ni exécuter l'inférence via MCP — le plan de contrôle n'a aucun outil de complétion et le dit en majuscules. - Ce qu'il vous faut : des GPU NVIDIA avec un pilote série 580 ou plus récent, Python 3.12+, uv, et un dossier de GGUF. Vous n'avez jamais fait tourner de modèle en local ? Commencez plutôt ici.
- Ce que vous obtenez au final : une URL de base que chaque client OpenAI de votre réseau utilise inchangée, un panneau qui nomme ce qui détient chaque gigaoctet sur chaque carte, et un assistant capable de dire « charge le 27B à 128k sur les deux 5090 » et de le voir se réaliser.
- Le point honnête : Windows est la plateforme de référence, NVIDIA uniquement, et il n'y a pas encore de fichier de licence — lisez d'abord Se le procurer.
Ce qu'il fait, en une image
Ce qu'il n'est pas :
- Pas un modèle. Il exécute les GGUF que vous avez, et en télécharge d'autres dans le même dossier et la même disposition que LM Studio.
- Pas un moteur d'inférence. llama.cpp fait le calcul ; lui décide quel processus tourne avec quels drapeaux sur quelles cartes.
- Pas une application de chat. Il y a un onglet Chat, et il existe pour prouver que le vrai chemin de requête fonctionne.
- Pas un cluster. Une machine, ses propres cartes. Le backend RPC de llama.cpp existe et n'est pas câblé.
- Pas un serveur d'inférence CPU. Il n'existe aucune valeur de
--n-gpu-layersautre que999dans tout le code. - Pas une seconde surface d'API. Pas de
/api/generateà la Ollama, pas d'API KoboldCpp — la surface OpenAI, un miroir/api/v0façon LM Studio et le REST de gestion/api, et c'est tout.
Qu'est-ce qui tourne où
| Élément | Où | À quoi il sert |
|---|---|---|
| Passerelle | l'hôte GPU, un processus, 1234 | /v1, /mcp (19 outils), /api. Contient le registre, le planificateur et le superviseur. |
| Panneau de contrôle | même processus, second uvicorn, 8080 | Tableau de bord, Configuration, Modèles, Téléchargement, Chat, Serveur, Journaux. |
| Chien de garde | un processus séparé, 1235 | Son propre serveur MCP, 10 outils de récupération. Il survit à ce qu'il supervise. |
Enfants llama-server | 18100–18200, loopback uniquement | Un par modèle chargé ; un plantage fait tomber un modèle, jamais la passerelle. |
Compagnon sfctl | la machine de l'agent | Un pur client HTTP — Python 3.11+, pas de CUDA, pas de dépendance serveur. Aussi le pont stdio MCP. |
| Bibliothèque GGUF | models.dir, là où elle est déjà | Indexée sur place ; rien n'est copié. LM Studio continue d'utiliser le même dossier. |
Ce que dit le tableau :
- Seul l'hôte GPU installe quoi que ce soit. Un client n'a besoin que d'une URL de base ; un agent a besoin d'un petit paquet Python, et uniquement pour les outils de gestion.
- Les enfants sont invisibles de l'extérieur. Ils se lient à
127.0.0.1et à rien d'autre, si bien que la passerelle est la seule surface publique — c'est ce qui fait d'une clé API une vraie frontière.
Comment circule une requête
Tout ce qu'un client peut mal faire est vérifié avant le premier octet, car une mauvaise requête doit recevoir un vrai 4xx (un 404 pour un id de modèle inexistant) avec un corps JSON plutôt qu'une trame d'erreur enfouie dans un flux SSE 200 — les clients gèrent le premier et ratent régulièrement le second. Ce que dit GET /health sur mon rig :
{"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_serveest la réponse au problème du 200-qui-ne-prouve-rien. Il est faux pendant que la première analyse de bibliothèque s'exécute, alors questatusresteokparce que le processus est vivant et que c'est ce que demande un sondeur de vivacité.GET /health?deep=trueexécute une vraie complétion de 8 tokens (un appel d'embeddings pour les modèles d'embeddings) sur chaque modèle chargé — et sans rien de chargé, il répondno_models_loadedplutôt que de réussir, car une sonde qui ne peut pas échouer est pire que pas de sonde du tout.- L'alias
local-modelse résout.local-model,default,autoetcurrentpointent tous versmodels.default_model. Les clients LM Studio retombent sur cette chaîne littérale, donc lui renvoyer un 404 les casse pour rien. - Un modèle froid ne ressemble pas à un blocage. Le flux ouvert transporte
: loading <model id> (5s)toutes les cinq secondes, puis: prefilling <model id> (Ns)jusqu'au premier vrai token — des lignes de commentaire SSE que tout analyseur ignore. Une socket silencieuse aussi longtemps déclenche un délai de lecture, et un client qui réessaie empile plus de prefill sur un batch saturé. - Un chargement à la fois, sur toute la machine. Deux modèles froids ont un jour été planifiés simultanément sur les mêmes cartes ; l'un est mort avec
CUDA error: out of memory, sa nouvelle tentative a évincé l'autre nouveau modèle, et la requête de ce client a frappé un enfant mort. - Un
ttlau niveau de la requête ne fait que déplacer la minuterie d'inactivité, rien d'autre.ttl: 0est la forme filaire d'« épinglé » partout ici, donc il est ignoré plutôt qu'honoré — un client envoyant{"ttl": 60}désépinglait autrefois ce que son propriétaire avait épinglé.
La vérité sur la VRAM : le planificateur
C'est la partie que personne d'autre ne fait, alors la voici en détail plutôt qu'en adjectifs. core/planner.py fait 3 188 lignes et répond à une question avant qu'un modèle ne démarre : étant donnée la VRAM réellement libre sur chaque carte à cet instant, quelle est la meilleure fenêtre, la meilleure qualité de cache et le meilleur nombre de slots que ce modèle peut avoir — et si la réponse est « aucun », que dois-je vous dire ?
L'échelle, et ce qu'il refuse de marchander
Les barreaux dessinés là sont illustratifs, pas les valeurs par défaut livrées : le mécanisme est exact, les chiffres sont un exemple. Ce que config.example.yaml livre est target_ctx: 1048576 comme objectif et default_ctx: 8192 comme plancher, l'objectif étant d'abord plafonné à la fenêtre d'entraînement du modèle — bien qu'au premier lancement tune_for_hardware écrive default_ctx: 16384 quand la plus petite carte fait 24 Gio ou plus, et 8192 à partir de 12 Gio ; mon rig tourne avec un plancher de 128000. Dépasser une fenêtre d'entraînement exige un scaling RoPE et dégrade la qualité, donc un palier au-dessus n'est jamais proposé. Un ctx_size explicite est une échelle à un seul barreau.
Deux passes, la seconde seulement quand la première a échoué partout. La raison est un chargement à 12 h 03 un après-midi : 79 832 Mo libres, plus 19 423 Mo récupérables d'un modèle inactif. Avec ce budget, 262144 sur un cache q4_0 tenait à 96 004 Mo, et 65536 en f16 complet tenait à 95 236 Mo. Ce qui s'est chargé était 8192/f16 à 89 860 Mo — le modèle a payé le prix complet de l'éviction et obtenu la plus petite fenêtre de l'échelle.
Le KV n'est pas un seul chiffre par modèle
Presque tous les calculateurs de VRAM en ligne calculent le KV comme couches × têtes × dim-tête × 2 × octets × contexte. Exact pour un Llama, franchement faux pour deux familles que les gens exécutent vraiment : Gemma 3 et 4 entrelacent cinq couches à fenêtre glissante par couche complète à la moitié de la dimension de tête, et Qwen3.5, 3.6 et 3.8 déclarent full_attention_interval = 4, donc un cache KV existe sur chaque quatrième couche et le reste sont des couches récurrentes Gated-DeltaNet avec un état fixe par séquence.
Le coût de se tromper là-dessus : un Gemma-4 31B demandé pour 262 144 tokens était estimé à 480 Gio de KV et plafonné à 65 536, alors que le journal de calibration enregistrait predicted_mb=95615 actual_mb=40037 depuis des semaines. Après la correction de géométrie, prédit et réel tombent tous deux à 38 Gio sur deux 5090 à n_ctx=262144 — un déblocage de 4× qui a emporté toute la flotte Gemma-4 avec lui. Imputer le KV à chaque couche d'un Qwen3.5 était le même bug sous un autre chapeau : une surfacturation nette de 4×.
Deux détails qui valent la peine d'être empruntés. Le compte de cellules à fenêtre glissante doit refléter exactement llama.cpp ; un multiplicateur plat de 1,25× était faux dans la direction dangereuse, 3,6× en dessous à quatre slots. Et attention_kind est dérivé de la géométrie des couches plutôt que de general.architecture — là où il ne peut pas être dérivé, il rapporte unknown, ce qui signifie « méfiez-vous de tout chiffre KV ici », jamais « supposez le cas bon marché ».
La qualité de cache est choisie à l'intérieur de chaque barreau plutôt qu'échangée contre une fenêtre plus large : f16/f16 → q8_0/q8_0 → q8_0 K + q4_0 V, le q4_0 symétrique ayant disparu de tout chemin automatique. Pas une question de goût — avec un cache K en q4_0, Qwen2.5-7B ne reproduit que 11,7 % des tokens que son homologue f16 produit, tandis qu'une paire assortie q8_0/q8_0 se tient à une divergence KL de 0,0018.
Quatre cartes, deux générations
Le rig est fait de deux RTX 5090 et de deux RTX 3090 — des cartes nominalement de 32 Go et 24 Go, que le panneau compte comme 31,84 Gio et 24,0 Gio, soit 111,7 Gio au total — sous pilote 610.88, pilote CUDA 13.3. Une seule carte d'abord, toujours — un modèle réparti sur PCIe sans NVLink est nettement plus lent — et le planificateur ne passe outre que lorsque le placement sur une seule carte est affamé à un slot, que la répartition au moins le double, que chaque carte ajoutée est au moins aussi capable, et que vous avez laissé le nombre de slots sur auto. Cette dernière condition n'est pas de la politesse : une répartition tourne au rythme de son membre le plus lent.
| Placement (1.5B Q4_K_M, deux 3090, ctx 8k) | Génération | Traitement du prompt |
|---|---|---|
| Une 3090 | 352.5 tok/s | 2803.6 tok/s |
Deux, -sm layer | 344.4 tok/s | 2722.5 tok/s |
Deux, -sm tensor | 294.3 tok/s | 1182.0 tok/s |
Deux, -sm row | échoue : error loading model: device CUDA2 does not support split buffers | |
Ce que dit le tableau :
- Une carte a battu deux cartes sur les deux axes. La répartition par couches coûte environ 2 % de génération ; la répartition par tenseurs coûte 17 % de génération et 58 % de traitement du prompt — donc le mode tenseur est en opt-in, et seule une mesure peut le choisir.
-sm rowest mort sur CUDA. L'analyseur l'accepte puis le chargement échoue, donc il est bloqué avant le lancement de l'enfant plutôt qu'après.
Deux détails de placement n'apparaissent que lorsqu'on mesure carte par carte. La couche de sortie est imputée au dernier périphérique, parce que les quantifieurs gardent les tenseurs d'embedding et de sortie en Q6_K ou Q8_0 même dans un fichier Q4 : un 27B planifié en --device CUDA1,CUDA0 --tensor-split 0.5079,0.4921 a atterri à 15,52 Gio sur CUDA0 — le dernier périphérique, celui auquel la répartition donnait le moins — contre 14,48 sur CUDA1. Et llama.cpp ouvre un contexte CUDA sur chaque périphérique visible — environ 0,22 Gio sur une 3090, 0,43 Gio sur une 5090 — c'est pourquoi la colonne de placement a un plancher de 512 Mio.
Combien de conversations vaut un placement
Le --ctx-size de llama.cpp est le budget KV total partagé entre les slots, pas la fenêtre par slot — un drapeau largement mal lu, que le README en amont n'explique pas. Un chargement avec --ctx-size 4096 et sans --parallel rapporte total_slots: 4 : 1 024 tokens par conversation. StudioForge lance avec ctx_per_slot × parallel. Ensuite : combien de slots valent la peine d'être ouverts, et c'est là que j'ai cessé de faire confiance à ma propre arithmétique.
| Concurrent | Par flux | Agrégé | p50 | p95 | Batch atteint |
|---|---|---|---|---|---|
| 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, une RTX 3090, 8 192 tokens par slot, KV f16, huit slots lancés, prompts de 512 tokens, 192 tokens générés chacun.
Ce que dit le tableau :
- L'estimateur disait 8. La mesure disait 2. À quatre slots, chaque flux tombe à 44 % de sa vitesse en solo, sous un plancher de 65 % ; la règle prend le plus grand de 1/2/4/8 qui franchit ce plancher tout en gagnant encore 15 % d'agrégat par rapport au niveau inférieur.
- L'agrégat ne cesse de grimper — huit slots déplacent 1,9× les tokens d'un seul — tandis qu'une seule conversation s'effondre à 27 %. Une règle maximisant l'agrégat choisirait 8, et chaque utilisateur subirait un modèle trois fois plus lent que ce que la carte peut faire.
- Le batching est réel, pas de la mise en file. Le batch atteint grimpant de 1,00 → 1,84 → 3,46 → 6,03 prouve des étapes de décodage partagées ; reproduit sur trois exécutions à 2 % près.
Une seconde exécution sur deux 3090 à 32 768 par slot a donné 301,7 → 230,5 par flux et a répondu 2 à nouveau. Au passage, le catalogue avait prédit 308,0 tok/s pour ce placement et l'exécution a mesuré 301,7 — 2 % d'écart, mieux que ce que j'espérais. Les lignes portent désormais max_parallel (combien tiennent) à côté de recommended_parallel (combien valent la peine d'être exécutés).
Deux drapeaux que j'ai mesurés avant de leur faire confiance
Le décodage spéculatif est une victoire en flux unique. Qwen3.8-27B Q5_K_S avec une tête MTP, une 3090, quatre prompts distincts de 256 tokens, cache de prompt désactivé : sans spéculation 37,75 tok/s ; draft-mtp à profondeur 3 a donné 50,70 tok/s, +34,3 %, à 0,528 d'acceptation. La profondeur 4 redescend à 47,48, parce que l'acceptation tombe à 0,446 et que chaque token rejeté supplémentaire a été vérifié pour rien. ngram-mod a obtenu +0,4 % et n'a émis aucun draft du tout.
Ce qui mène au piège : le même prompt trois fois a mesuré +751 % sur ce 27B. Répétez un prompt et vous mesurez le cache de prompt en l'appelant du drafting. Au-dessus de quatre slots, auto renvoie désormais none et dit pourquoi — « la spéculation est une victoire en flux unique et nuit à un batch saturé » — après qu'une exécution a chargé un 27B avec --parallel 8 et que auto a quand même choisi draft-mtp, voyant la tête MTP mais pas le nombre de slots.
Le micro-batch achète du prefill contre de la VRAM. Même 1.5B, un prompt de 5 166 tokens : -ub 512 (le défaut du moteur) a donné 15 232 tok/s à 1 492 Mio ; -ub 1024, 17 307 tok/s (+13,6 %) à 1 562 Mio ; -ub 2048, 18 061 tok/s (+18,6 %) à 1 702 Mio. Il est resté désactivé longtemps parce que le tampon de calcul grandit avec -ub, que le planificateur ne le modélisait pas, et qu'un tampon non modélisé transforme un « ça tient » en mémoire épuisée. Le planificateur le facture désormais, arrondi vers le haut pour pencher du côté du refus, et n'augmente le micro-batch automatiquement qu'au-dessus de quatre slots.
Le refus, avec les chiffres
Chaque lancement passe --fit off et --n-gpu-layers 999, et le second est une constante, pas un réglage. Cela compte plus qu'avant : le build épinglé b10425 livre -fit, --fit [on|off] — « s'il faut ajuster les arguments non définis pour tenir dans la mémoire du périphérique » — activé par défaut, aux côtés de --n-gpu-layers auto ; les deux sont arrivés en amont dans la PR #16653 en décembre 2025. Cette paire est précisément un chemin silencieux d'offload partiel : un défaut raisonnable pour un serveur généraliste, et exactement le comportement que ce projet existe pour refuser. Quand rien ne tient, la réponse est HTTP 507 avec l'arithmétique dans le corps, tronquée ici :
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 }}}
C'est un vrai refus, capturé pendant que j'écrivais ceci : le 31B a demandé à se charger sur une seule RTX 3090. La prose nomme le manque et les détenteurs ; error.studioforge transporte le même échec sous forme de données — les octets requis et disponibles, la VRAM libre par carte, l'estimation décomposée en poids, KV, tampon de calcul, modèle de projection et contexte CUDA, chaque processus détenant de la VRAM, et des notes indiquant sur quel barreau de l'échelle il se tenait quand il a abandonné. Quand une fenêtre plus petite tiendrait, max_ctx_that_fits la nomme, calculée sur la géométrie par couches pour que l'offre soit une que le prochain chargement accepte ; ici même le plancher ne tenait pas, donc c'est null plutôt qu'un chiffre qui échouerait. Un refus qui ne concerne pas un modèle occupé ne porte pas de retry_after_s, car « réessayez plus tard » est un mauvais conseil quand rien ne changera.
Épinglages, TTL, baux et rééquilibreur
Un épinglage est un état souhaité, pas une exemption. Autrefois, cela signifiait TTL zéro, exclusion de toute échelle d'éviction et un préchauffage au démarrage — et pas la quatrième chose : un modèle épinglé n'était jamais rechargé, donc un enfant en boucle de plantage dépassant son max_restarts par modèle restait à state="failed" sans rien détenir. Un réconciliateur chevauche désormais le balayage de 15 secondes, avec un backoff de 60 s jusqu'à un plafond de 900 s. La seule chose qui bat un épinglage est une personne : un déchargement explicite marque l'id comme supprimé et il reste éteint.
Un bail est une carte appartenant à un modèle. Une carte louée est absente de la vue GPU de tout autre modèle — pas classée dernière, pas une option — et le propriétaire est forcé sur exactement ces cartes, dimensionnées par l'estimateur, dans le mode de répartition que son propre benchmark a mesuré le plus rapide là. Une carte déjà louée est un conflit 409, jamais une prise de contrôle. Un bail sans modèle retient des cartes pour quelque chose hors du serveur : reserve_gpus(devices=[3], reason="ComfyUI render") est la raison pour laquelle ma génération d'images et mes modèles de langage ont cessé de se battre.
Le rééquilibreur corrige la bonne décision d'hier. À 13 h 42, un 27B a été planifié sur les cartes [1, 3] — une répartition inter-niveaux partageant le GPU1 — parce qu'un 31B détenait [1, 0, 2] et qu'une application de génération d'images détenait 7,5 Gio du GPU2. À 13 h 53, le 31B s'est réduit à [0, 1] ; dès lors [2, 3] restait libre et strictement meilleur, et le 27B n'a pas bougé. Un modèle inactif est donc désormais déplacé quand un plan sans éviction à ses réglages actuels exacts le fait atterrir hors de toute carte partagée. Les tokens par seconde estimés ne justifient jamais un déplacement.
Il regarde une fois par minute, et seulement quand le monde a changé : seulement sur une machine calme, seulement pour un modèle inactif depuis cinq minutes, un déplacement par modèle toutes les 30 minutes — car une relocalisation est un rechargement et un rechargement fait tomber le cache de prompt, et sur la charge de travail à longues conversations de ce rig, ce cache représentait 93 % d'un prompt de 98k tokens. L'éviction a en outre trois règles dures — jamais un modèle épinglé, jamais un en pleine requête, jamais une instance en cours de chargement — et une carte louée n'est déjà pas dans la vue du planificateur. Un chargement juste-à-temps ne peut jamais fixer force.
Quand ça casse
La VRAM meurt avec le processus qui l'a prise. Sous Windows, les enfants vivent dans un objet job anonyme créé avec JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, donc le noyau tue chaque membre quand le dernier handle se ferme — quelle que soit la façon dont le parent se termine. Anonyme, parce qu'un job nommé serait partagé avec tout ce qui devine le nom. Linux reçoit un shim PR_SET_PDEATHSIG, qui couvre un kill -9 de la passerelle ; c'est un effort au mieux plutôt qu'une garantie du noyau, donc le balayage de démarrage et reclaim_orphan_engines rattrapent ce qui passe à travers.
C'est pourquoi il existe un balayage de démarrage. Le 18 août 2026, environ 10 Gio sur le GPU0 et environ 15,6 Gio sur le GPU1 étaient indisponibles avec « tout arrêté ». Les détenteurs étaient trois enfants llama-server.exe d'une exécution python -m pytest tests -q qu'un agent de codage avait lancée et qui s'était terminée depuis. Chaque détenteur est désormais classifié — ours, child-of-live-process, orphan, other-instance, foreign — et seul orphan est jamais tué, sûr par construction car rien d'autre ne lance des binaires depuis notre arbre engines.
Nommer qui détient quoi a demandé deux essais. NVML rapporte zéro de mémoire utilisée par processus sous Windows, donc les tailles viennent du compteur que lit la colonne « Mémoire GPU dédiée » du Gestionnaire des tâches — mais c'est un total par processus à travers les adaptateurs, donc la colonne périphérique était fausse : un processus signalé sur CUDA0,1,2,3 détenait en réalité 15,52 Gio sur CUDA0 et rien sur les 3090. Le correctif joint le LUID de l'adaptateur à une adresse de bus PCI, et un piège mérite le paragraphe : le numéro de bus dans le busId de NVML est hexadécimal. "00000000:42:00.0" est le bus 66, pas 42 — une mauvaise réponse assurée, indiscernable d'une bonne.
Un déchargement est vérifié, pas annoncé. Le superviseur revérifie le pid avec une garde de temps de création contre la réutilisation du pid, fait monter un survivant vers un tree-kill forcé, et enregistre la VRAM avant et après ; un survivant lève un 500 vous disant de le tuer manuellement. Un déchargement qui rapporte un succès alors que le processus reste résident est le mensonge le plus coûteux que ce système puisse raconter, car chaque chargement ultérieur est alors planifié contre une VRAM qui n'est pas libre.
Les codes de sortie sont un vocabulaire. 2 est une erreur de config nommant la clé. 3 est un conflit de port, et la zone de notification ne relance jamais sur le port en conflit — elle attend que le détenteur réponde à /health comme un serveur StudioForge et s'y attache à la place. 75 est « redémarrage demandé » : le serveur se draine, fixe le code et s'arrête proprement — 1,0 s de la demande à la sortie, mesuré — et la zone de notification relance sans dépenser de tentative de plantage. Cette distinction existe parce qu'un redémarrage depuis l'interface a un jour produit deux serveurs se disputant le 1234, et trois plantages comptés plus tard la zone de notification restait sur Crashé — voir le dossier logs à côté d'un serveur sain qu'elle ne pouvait plus arrêter.
Quand la passerelle est figée plutôt que morte, vous parlez au chien de garde : un processus séparé toujours actif sur 1235, construit à partir d'argparse et de la journalisation stdlib, donc il démarre même quand config.yaml est la chose cassée. Ses dix outils sont health, get_config, set_config, restart_server, kill_model, nuke_all_models, reclaim_orphan_engines, tail_logs, gpu_status et rollback_update. Il réimplémente la règle des orphelins localement plutôt que d'importer le module qui la détient — le processus de récupération ne doit pas importer la pile qu'il répare.
Le panneau de contrôle
Le panneau sur 8080 est un second serveur uvicorn dans le même processus, partageant le graphe d'objets de la passerelle par référence — il n'y a aucune URL absolue dedans, ce qui le fait fonctionner à l'identique en HTTP simple sur un VPN maillé et derrière un frontal HTTPS.
9,7 tok/s est la propre arithmétique de cet onglet — des morceaux streamés rapportés à l'horloge murale depuis l'instant où la requête est partie, prefill inclus — pas le temps de génération du moteur, qui est le 58,77 tok/s sur la carte du modèle. Un chat réussi ici est la preuve qu'un client fonctionnera, pas un chemin factice qui peut dériver.
sfctl en sous-processus, le PIN d'appairage n'a jamais à apparaître dans un fichier de config.
Quatre lignes plus bas dans le même tampon se trouvent celles que je lis quand un chargement me surprend — la ligne de commande qu'il a construite, le processus qui a répondu, et le planificateur qui corrige son propre devoir (horodatages et noms de journaux tronqués) :
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'
C'est le planificateur se prenant en flagrant délit de 3 Go d'écart sur une carte, 4 Go sur la paire, et disant quelle carte et pourquoi — la couche de sortie a atterri sur le dernier périphérique, exactement là où il la facture, et la facture était encore trop courte.
L'utiliser comme backend de harnais
Six clients sur mon réseau parlent à cette chose et un seul d'entre eux sait que c'est StudioForge. C'est tout l'intérêt. Cinq d'entre eux sont dessinés ci-dessous ; OpenClaw Email est le sixième.
N'importe quel client OpenAI
Deux variables d'environnement. server.api_key est null par défaut, donc n'importe quelle chaîne non vide fonctionne — la plupart des clients OpenAI refusent de démarrer avec une clé vide.
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 liste tout ce qui est téléchargé, à la manière de LM Studio, en ajoutant state et — quand il est résident — ctx_per_slot, max_parallel et parallel_limited_by, car une longueur de contexte seule est ambiguë dès qu'un modèle tourne sur plus d'un slot. Les ids font l'aller-retour : l'id complet publisher/repo/file, un simple nom de fichier, ou publisher/name, sans distinction de casse. DisPatch n'a eu besoin que d'une nouvelle URL de base ; OpenClaw Email est le client le plus exigeant, voulant un modèle de chat et un modèle d'embedding et appelant /v1/models au démarrage pour demander ce qui est réellement servi.
OpenClaw, sur une autre machine
La machine de l'agent installe un petit wheel : sfctl, qui cible Python 3.11 plutôt que le 3.12 du serveur parce que la machine exécutant l'agent est souvent en retard sur le rig, et qui ne dépend délibérément pas du paquet serveur — pas de CUDA, pas de planificateur, pas de registre.
sfctl servers add rig http://my-gpu-rig:1234 --api-key <PIN> --use
openclaw mcp add studioforge --command sfctl --arg mcp
Ou à la main — le détail qui coûte un après-midi aux gens. La clé d'OpenClaw est mcp.servers, imbriquée sous mcp : la map plate mcpServers est correcte pour Claude Code, Cline et LibreChat, et n'est pas une clé que le schéma d'OpenClaw connaît. L'inférence est un chemin séparé, sous models.providers — notez baseUrl, avec un rl en minuscules :
// ~/.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 } ] } } } }
Ce que l'agent obtient est une liste d'outils fusionnée de 29 : les 19 de la passerelle plus les 10 du chien de garde, trois renommés en recovery_* — get_config et set_config parce qu'ils entrent en collision avec les outils de la passerelle, health pour la symétrie. restart_server garde son nom nu, car c'est le nom que le message d'erreur d'un outil de gestion mort dit à l'agent d'appeler. Quand le serveur principal est tombé, le pont annonce toujours les 19 outils de gestion avec une note ajoutée — un agent qui ne voit pas load_model ne sait pas que la capacité existe. La boucle qu'il exécute :
list_models(limit=N)— le catalogue, téléchargement le plus récent d'abord. Lisez la ligne recommandée.load_model(**row["load_args"])— transmettez-le inchangé ; un agent qui a choisi une ligne a fini de choisir.load_recommended(model_id, ctx_size=N)quand ce que vous connaissez est le contexte dont vous avez besoin — le seul chemin de chargement qui refuse plutôt que de rétrécir.- Inférence via HTTP, pas via MCP. Nommer un modèle non chargé le charge, avec les défauts du planificateur plutôt que la ligne que vous lisiez.
model_options(model_id)quand la ligne recommandée ne suffit pas : chaque palier de contexte, avec les vitesses.search_models→repo_details→download_modelpour obtenir quelque chose de nouveau.pin_modelpour le modèle qui doit toujours répondre ;reserve_gpus/release_gpuspour des cartes à lui.server_statusetconnection_info— ce qui est résident, qui détient la VRAM, chaque adresse sur laquelle il répond.
Et le paragraphe dont je suis le plus fier, servi à chaque client à la connexion :
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 et cinq commandes d'une ligne
dsh (DeepSeek Harness) change de modèle en modifiant un seul fichier YAML, rechargé à chaud pour la requête suivante. Le bloc fournisseur est en 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 est une référence, jamais une valeur littérale — et un serveur local sans clé a quand même besoin qu'une donnée d'identification soit référencée, car le client compatible OpenAI insiste sur un jeton bearer. Claude Code, en revanche, ne peut pas du tout router sa propre inférence ici : sa référence de protocole de passerelle liste Anthropic Messages, Bedrock et Vertex, et aucun n'est /v1/chat/completions. StudioForge est donc son outil, pas son cerveau — claude mcp add studioforge -- sfctl mcp (le -- est obligatoire) lui donne les 29.
bench-llm a produit les chiffres du rig que les gens me citent en retour — Gemma 4 26B-A4B (QAT, Q4) à 229,0 tok/s, 110 ms jusqu'au premier token, mesurés via LM Studio à l'époque — et c'est un simple client OpenAI, donc une URL de base lui suffit ; mais il exécute pkill -f llama-server entre les benchmarks, ce qui tue chaque backend StudioForge sur la machine. Le reste tient en une ligne chacun : Open WebUI, OPENAI_API_BASE_URL ; LibreChat, un point de terminaison custom avec baseURL et models.fetch: true ; aider, OPENAI_API_BASE puis --model openai/<id> ; Continue, provider: openai plus apiBase. La clé d'URL de base s'écrit différemment dans chacun d'eux, ce qui est la source la plus fiable de soirées perdues dans cet écosystème.
Ce dans quoi un agent choisit
Le catalogue fait du choix de modèle une consultation plutôt qu'une devinette : trié du téléchargement le plus récent au plus ancien, une ligne par palier de contexte, chacune portant fits par rapport à la VRAM libre en direct, devices, les types KV, max_parallel, recommended_parallel, une confidence, une colonne if_gpus_idle et load_args. Une ligne est un vrai appel de plan, donc elle ne peut pas promettre ce qu'un chargement refuserait — et if_gpus_idle est la différence entre un agent qui abandonne et un agent qui appelle unload_model.
repo_details est celle qui vaut la peine d'être nommée : elle lit un en-tête GGUF à distance, via des requêtes HTTP Range — 2 à 15 Mo, surtout les tableaux de chaînes à préfixe de longueur du tokenizer, mis en cache sur disque — au lieu de télécharger 20 Go pour savoir si ça tient, et un CDN qui répond à une requête en plage par un 200 et le corps entier est détecté et refusé. Ce qui revient est une matrice context_fit du même planificateur qu'un vrai chargement utilise :
| Quant | 1× RTX 5090 | 2× RTX 5090 | Les quatre cartes |
|---|---|---|---|
| BF16 (51,8 Gio) | — les poids seuls ne tiennent pas | 32k en q8_0 | 256k |
| Q8_0 (27,9 Gio) | — | 256k | 256k |
| Q5_K_M (19,3 Gio) | 128k en q8_0 | 256k | 256k |
| IQ2_M (10,5 Gio) | 256k | 256k | 256k |
Tiré du guide OpenClaw du dépôt, calculé sur ce rig, pour unsloth/Qwen3.8-27B-GGUF. max_ctx est la plus grande fenêtre avec un cache f16 pleine qualité ; un chiffre q8_0 n'apparaît que là où il va plus loin.
Ce que dit le tableau : c'est la quantification qui décide de la fenêtre, pas le nombre de cartes — de BF16 à Q5_K_M, « ne tient pas du tout » devient 128k sur une carte — et c'est la propre réponse du planificateur, donc là où un palier n'est atteignable qu'en quantifiant le cache, la matrice dit « en q8_0 » plutôt que de le compter comme une victoire. Pour de vrais chiffres, le playbook tient en trois étapes : un benchmark de placement (chaque mode GPU sous son propre bail, débit issu des propres chronométrages de llama-server), puis benchmark_parallel sur le gagnant, puis reserve_gpus pour le verrouiller. Ne benchmarkez jamais un modèle avec lequel quelqu'un est en pleine conversation.
Remplaçant direct de LM Studio
La compatibilité était la contrainte de conception, et le dépôt liste ce qui a été emprunté pour que personne n'ait à deviner : le port 1234 ; /v1/models listant le téléchargé plutôt que le chargé ; le chargement juste-à-temps ; le TTL d'inactivité ; le ttl par requête ; la disposition publisher/repo/ utilisée sur place, donc pas d'étape d'import et les deux programmes partagent une seule bibliothèque ; le miroir /api/v0/models ; et le lien profond lmstudio://open_from_hf. Migrer un client est un changement d'hôte, pas un changement d'hôte-et-de-port.
| LM Studio 0.4.21 | StudioForge 1.26-08-23 | |
|---|---|---|
| Moteur | ses propres builds llama.cpp plus MLX sur Apple | llama-server en amont, un seul build épinglé (b10425, CUDA 13.3), testé avant activation |
| Chemins non routés | 200 avec un corps d'erreur — son journal dit Returning 200 anyway | 404 avec une enveloppe JSON, et du JSON sur chaque statut |
| Erreurs | de la prose non structurée que les clients matchent par regex | un error.code stable, diagnostics sous error.studioforge |
| Config de chargement | context_length ignoré sur l'un des deux chemins de chargement ; repetition_penalty silencieusement ignoré | un seul chemin de chargement, chaque champ honoré, valeurs effectives renvoyées ; alias d'échantillonneur acceptés |
| Quand ça ne tient pas | « réduira automatiquement la taille de l'offload GPU … et le reste dans la RAM système » — un déversement CPU silencieux, par conception | 507 insufficient_vram avec octets requis et disponibles, libre par GPU, le plus grand contexte qui tiendrait, suggestions ordonnées |
| Multi-GPU | répartition prioritaire ou égale, bascules par GPU, parallélisme tensoriel depuis la 0.4.15 | un planificateur dimensionnant contexte, type KV et slots par placement, inclinant les fractions de répartition pour la couche de sortie |
| TTL d'inactivité | 60 minutes ; l'auto-éviction garde au plus 1 modèle chargé en JIT | 1 800 s livrées (15 min sur mon rig), balayées toutes les 15 s ; autant qu'il en tient, pins et baux décidant qui reste |
| Gestion à distance | /api/v1 load/unload/download ; LM Link en aperçu, à monétiser, découverte via le hub de LM Studio | /api REST, sfctl, 29 outils MCP ; la portée est votre LAN ou votre propre VPN maillé |
| MCP | hôte uniquement — il consomme des serveurs MCP | un serveur MCP pour sa propre gestion, plus un sur le chien de garde |
| Source | fermée ; gratuite pour usage personnel et professionnel interne | source dans le zip, et aucune licence choisie à ce jour |
Vérifié contre le changelog, la doc et le suivi de bugs de LM Studio le 2026-08-23, version 0.4.21 (publiée le 12 août 2026). La ligne 2 figure dans son suivi de bugs public ; les lignes 3–4 sont ce que mon propre client a dû contourner sur l'API 0.3.x. Je n'ai retesté aucun d'eux contre la 0.4.21, alors lisez-les comme « documenté à un moment donné », pas « cassé aujourd'hui ».
Ce que dit le tableau : c'est une meilleure application de bureau que ceci ne le sera jamais — GUI soignée, MLX sur Apple Silicon, un point de terminaison compatible Anthropic, un compagnon mobile, une équipe qui livre toutes les deux semaines — et la divergence est philosophique plutôt que fonctionnelle. La posture par défaut de LM Studio est le meilleur effort : le faire tourner coûte que coûte. La mienne est refuser-et-expliquer. Là où un agent décide de ce qui se charge, le meilleur effort est le mauvais défaut, car rien en aval ne peut distinguer le rapide du lent sans mesurer.
Comment il se compare
| Serveur · moteur · licence | Hot-swap / TTL d'inactivité | Placement multi-GPU | Refuse le déversement CPU | Gestion distante / MCP |
|---|---|---|---|---|
| StudioForge 1.26-08-23 llama.cpp, un build épinglé · licence : aucune pour l'instant | ✅ JIT · TTL 1 800 s, balayage 15 s | planifié par modèle, cartes mélangées, pins + baux | ✅ -ngl 999 + --fit off, 507 avec les chiffres | ✅ REST + 29 outils MCP |
| LM Studio 0.4.21 llama.cpp maison + MLX · fermé | ✅ JIT · 60 min, auto-éviction vers 1 | répartition prioritaire/égale, parallélisme tensoriel | ❌ réduit l'offload, reste en RAM | REST ; MCP hôte uniquement |
| Ollama 0.32.15 llama.cpp/GGML ; MLX sur Apple · MIT | ✅ keep_alive 5 min · 3 résidents par GPU | étalement automatique sur les cartes | ❌ déverse, montre le % CPU dans ollama ps | /api/* riche ; pas de serveur MCP |
| llama-server router (b105xx, août 2026) c'est llama.cpp · MIT | ✅ un enfant par modèle · --sleep-idle-seconds, LRU par nombre | -sm / -ts / -dev manuels | ❌ --fit on rétrécit votre plan | /models/load|unload |
| llama-swap v251 un proxy qui lance les autres · MIT | ✅ tout le produit · ttl par modèle/groupe | ❌ ce que dit votre cmd | n/a — proxy uniquement | /ui + routes en amont |
| vLLM 0.27.1 propre (PagedAttention) · Apache-2.0 | ❌ un modèle par processus | parallélisme tensoriel / pipeline / expert | partiel — pas de chemin d'offload par couches | LoRA uniquement, « dev local » |
| KoboldCpp 1.119 fork llama.cpp + image/audio · AGPL-3.0 | ✅ --admin + --routermode | --tensor_split manuel | ❌ déverse | /api/admin/* ; client MCP uniquement |
| TextGen (ex-oobabooga) 4.9 5 chargeurs dont ExLlamaV3, TRT-LLM · AGPL-3.0 | ✅ bascule sans redémarrage · TTL ? | --tensor-split manuel | ❌ déverse | /v1/internal/model/* ; client MCP |
| TabbyAPI (rolling) ExLlamaV3 uniquement — pas de GGUF · AGPL-3.0 | ✅ admin + chargement en ligne · TTL ? | gpu_split_auto activé par défaut | ✅ de fait — ExLlama n'a pas de chemin CPU | admin-key /v1/model/load |
| Jan 0.8.4 mode router llama.cpp · Apache-2.0 | ✅ via le router · TTL ? | hérité de llama.cpp | ❌ déverse | /v1/orchestrations ; client MCP |
| LocalAI 4.9.0 60+ backends en images conteneur · MIT | ✅ à la demande · WATCHDOG_IDLE_TIMEOUT | « ajustement GPU automatique des modèles » | ❌ « Aucun GPU requis » | REST + UI ; client MCP uniquement |
| GPUStack 2.2.3 vLLM, SGLang, MindIE, VoxBox · Apache-2.0 | partiel — déploiements en cluster | Spread/Binpack auto, multi-nœud | ? | API complète de gestion de cluster |
Vérifié depuis des sources primaires le 2026-08-23. Un point d'interrogation signifie inconnu, pas « non ». Les workers de GPUStack sont Linux uniquement ; vLLM n'a pas de support Windows natif (WSL ou forks uniquement).
Ce que dit le tableau :
- Le chargement JIT et le TTL d'inactivité ne sont pas nouveaux et je ne les revendique pas. LM Studio, Ollama, llama-swap et LocalAI font les deux, et les drapeaux par groupe
swap/exclusive/persistentde llama-swap sont un moteur de politique réellement élégant. - Trois choses y sont rares : refuser de tourner plutôt que déverser sur le CPU (seul TabbyAPI s'en approche, et uniquement parce qu'ExLlama n'a pas de chemin CPU) ; un planificateur qui dimensionne contexte et slots selon les cartes qu'il a réellement trouvées ; et une gestion publiée sous forme d'outils MCP, ce qu'aucun autre de la catégorie ne fait à ma connaissance.
- La chose la plus proche en amont est le propre router de llama.cpp, un combat loyal : multi-modèle avec isolation des processus, gratuit, dans le binaire que vous avez. Ce qui lui manque est une éviction basée sur la mémoire plutôt que sur le compte, les pins, les baux, et un refus portant des chiffres ; il a bien une mise en veille (
--sleep-idle-seconds), bien qu'un poll de/metricsle réveille.
Plusieurs bonnes idées sont empruntées, et le dépôt dit lesquelles. Le Modelfile d'Ollama est devenu les modèles virtuels, si bien que deux personas sur une même base partagent un seul llama-server, et son keep_alive est devenu le ttl par requête. La surface de réglages à trois niveaux de TextGen a été adoptée directement, « drapeaux supplémentaires » bruts inclus, avec des drapeaux validés contre le propre --help du moteur épinglé au moment de l'enregistrement. La philosophie d'artefact unique de KoboldCpp est la raison pour laquelle les moteurs vivent dans des répertoires versionnés.
Installation
Windows, la plateforme de référence, en quatre étapes : installez Git, Python 3.12+, uv et un pilote NVIDIA à jour ; clonez le dépôt ou décompressez le téléchargement ; double-cliquez sur launchers\Update StudioForge.bat, qui malgré son nom est l'étape de première exécution — il construit le virtualenv, installe la version llama.cpp la plus récente qui a un build pour votre pilote, la teste et s'y épingle (b10425 est le build sur lequel cet article a été mesuré, pas celui que vous obtiendrez) ; puis launchers\Start StudioForge.bat, ou launchers\StudioForge Tray.bat si vous le voulez dans la zone de notification, et le panneau s'ouvre sur http://127.0.0.1:8080 dans son onglet Configuration.
Linux, quatre lignes — plus cmake et un toolkit CUDA dont le nvcc correspond à votre pilote, car l'amont ne publie aucune archive CUDA Linux à quelque tag que ce soit et le moteur est construit depuis les sources une fois par version :
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
Pour une machine headless, deploy/ contient deux unités systemd utilisateur — des unités utilisateur exprès, car le processus doit tourner comme l'utilisateur connecté qui possède la bibliothèque de modèles, le venv et les nœuds de périphériques GPU. Le chien de garde n'est délibérément pas BindsTo= la passerelle et utilise Restart=always : il existe pour être debout quand la passerelle ne l'est pas. Puis sudo loginctl enable-linger "$USER", le même schéma que l'article sur ComfyUI headless. La première exécution s'ouvre sur Configuration, où Détecter la bibliothèque LM Studio sonde d'abord le downloadsFolder dans ~/.lmstudio/settings.json.
| Service | Port par défaut | Clé de config |
|---|---|---|
Passerelle — /v1, /api, /mcp | 1234 | server.port |
| Panneau de contrôle web | 8080 | gui.port |
| Chien de garde de récupération | 1235 | watchdog.port |
Enfants llama-server (loopback uniquement) | 18100–18200 | gateway.child_port_start / _end |
Ce que dit le tableau : le port 1234 signifie « le serveur de modèles local » sur mes deux machines et ce n'est pas la même chose — la machine de l'agent exécute le sien sur 127.0.0.1:1234, en loopback, tandis que le rig sert my-gpu-rig:1234 à travers le VPN maillé — et seuls trois ports sont jamais joignables, avec une validation de config refusant toute collision entre un port de service et la plage des enfants au chargement.
La règle du répertoire de données est SF_DATA_DIR d'abord, puis le dossier d'un fichier --config, puis <repo>/data dans un checkout — l'ordre complet, et pourquoi data_dir n'est jamais réécrit dans config.yaml, se trouve dans docs/SETUP.md. Une instance possède un répertoire de données, garanti par un verrou OS exclusif ; une seconde instance est en lecture seule.
La sécurité, honnêtement
Une règle sous tout cela : les lectures, l'inférence et la résidence restent ouvertes ; changer la machine, non. Avec server.api_key non défini, une requête mutante vers une route qui change la machine n'est acceptée que d'un appelant sur cette machine, ou avec le PIN MCP envoyé comme X-MCP-Pin ou comme jeton bearer — tout le reste reçoit 403 remote_admin_requires_credential. L'ensemble verrouillé est la config, les redémarrages, les moteurs, les mises à jour, la récupération de VRAM, les téléchargements, les baux, les suppressions et les deux écritures par modèle qui survivent à l'instance. Le problème qu'il a corrigé était le mien : n'importe qui sur le LAN pouvait faire PATCH /api/config, définir lui-même server.api_key et m'enfermer dehors — alors que l'outil MCP set_config, même capacité dans le même processus, exigeait le PIN.
- Le PIN ne protège que MCP. C'est un code d'appairage que vous lisez sur la bannière de démarrage, limité aux outils de gestion. Ce n'est pas une clé API.
server.api_keyest la vraie donnée d'identification et estnullpar défaut. Définissez-la et elle couvre/v1,/api,/mcpet le chien de garde ; le PIN continue de fonctionner sur les deux points de terminaison MCP à côté.- Le bind livré est
0.0.0.0sur les trois écouteurs. La ligne Exposition réseau de l'onglet Configuration passe à l'ambre et devient requise dès qu'un écouteur est exposé sans clé — en vérifiant les trois, carserver.hosten loopback avecgui.hostsur0.0.0.0lisait autrefois vert alors que le panneau était grand ouvert. - Une requête navigateur cross-origin n'est pas « cette machine », même en loopback. Avec
cors_origins: ["*"], n'importe quelle page que vous visitez pourrait prévolerPATCH /api/configsur127.0.0.1:1234et arriver en paraissant locale — donc la comparaison d'origine inclut le port, etOrigin: nullcompte comme étranger. CORS régit ce qu'une page peut lire, jamais à qui le serveur fait confiance. Le websocket du panneau a une version réservée à l'hôte de la même porte, car le panneau est atteint via le port depuis lequel il a été servi. - Un navigateur distant sur une installation sans clé obtient les lectures et l'inférence, des 403 sur les changements de machine, et le PIN retenu — sinon n'importe quoi sur le LAN pourrait lire le PIN depuis un point de terminaison ouvert et l'utiliser. Le PIN était du théâtre exactement quand il comptait.
- Les images sont récupérées sous une garde SSRF qui bloque le loopback, le link-local, le privé, l'ULA et l'espace CGNAT — la plage
100.64/10, où vit chaque pair du VPN maillé — et qui résout une seule fois, se connectant à l'adresse vérifiée avec leHostet le SNI d'origine. - Rien ne quitte la machine sans qu'on le demande. Les seuls appels sortants sont Hugging Face pour les modèles, GitHub pour le build épinglé de
llama-serveret sa vérification de mise à jour, la vérification optionnelle de version de StudioForge, et les URL d'images nommées par une requête ; l'auto-mise à jour rapporte « non configurée » sans appel réseau jusqu'à ce que vous définissiezupdate.repo, et un test unitaire le fige.
Deux limites énoncées plutôt que cachées : un contrôle d'adresse de pair fait confiance à tout ce qui est en loopback, ce qui derrière un reverse proxy est le proxy, donc mettez le proxy derrière server.api_key ; et il n'y a pas d'authentification au-delà d'une clé partagée unique — pas de comptes, pas de limitation de débit. La règle maison de l'article sur OpenClaw s'applique toujours : loopback plus un proxy authentifiant, ou un VPN maillé, jamais l'internet brut.
La partie honnête
Écrit simplement pour que vous puissiez décider avant d'installer :
- Windows est la plateforme de référence — c'est ce contre quoi la zone de notification, la garde de VRAM par objet job et les compteurs GPU par processus ont été construits. Linux est supporté et la CI exécute les deux, et il est moins éprouvé au combat ; le chemin de construction depuis les sources a sa construction de commande testée mais n'a jamais été exercé de bout en bout ici. macOS n'est pas supporté : pas de CUDA.
- NVIDIA uniquement. Le planificateur lit NVML, le moteur est un build CUDA, et l'affinité des quants s'exprime en capacités de calcul.
- L'estimation de VRAM est une estimation. Les poids atterrissent à 2 % près de la taille du fichier et le KV est exact d'après la géométrie par couches, mais le tampon de calcul est une fraction calibrée, réglée une fois au démarrage, bornée à 0,03–0,15 et conservée en mémoire uniquement — donc une mauvaise calibration est annulée par un redémarrage. Deux historiques de calibration sur cette machine ont été contaminés et sont désormais entièrement ignorés.
- La répartition multi-GPU est proportionnelle, pas mesurée. Elle ne modélise pas la bande passante d'interconnexion, et mélanger les générations fait tourner au rythme de la carte la plus lente.
- Les estimations de concurrence sont de l'arithmétique. L'estimateur suppose des slots à moitié pleins et dérate les modèles MoE d'une moitié forfaitaire, et
--ctx-checkpointsn'est pas modélisé du tout. Les deux erreurs pointent vers moins de slots, ce qui est la direction sûre — mais exécutez le benchmark parallèle avant de faire confiance à 8. - Les estimations de vitesse utilisent les chiffres nominaux des fournisseurs — 5090 à 1 792 Go/s et 209 TFLOPS fp16, 3090 à 936 et 71, aucun mesuré ici. Il y a exactement deux ancres de calibration, toutes deux sur ce rig, toutes deux à un slot : un 31B dense mesuré à 39,4 tok/s contre une estimation de 36,1, et un 122B MoE mesuré à 37,3 contre 47,4. Rien n'est validé à quatre ou huit slots, ni sur un modèle dense au-dessus de 31B.
- Réserver un GPU pour un autre programme ne contraint que mon planificateur. Rien ne l'impose à l'autre programme, et rien ne l'empêche de prendre la mémoire en premier.
- Une instance par répertoire de données, et le verrou couvre le répertoire de données plutôt que la bibliothèque de modèles — deux instances avec des répertoires de données différents sur une même bibliothèque restent deux écrivains.
- Aucune licence n'a été choisie. Il n'y a délibérément aucun fichier
LICENSE, etpyproject.tomlle dit dans un commentaire — Se le procurer explique ce que cela signifie en pratique. - Il n'y a pas d'audit de sécurité tiers. Les affirmations ci-dessus décrivent ce que fait le code ; j'ai écrit les deux. Lisez le code source — c'est pour cela que c'est un téléchargement et pas un service.
2 503 tests unitaires passent et 17 sont ignorés, en 327 secondes sur cette machine ; la CI exécute la même suite sur Windows et Ubuntu avec la sonde GPU forcée vers un backend nul. Une seconde suite charge de vrais poids sur de vrais GPU et est désélectionnée par défaut et verrouillée derrière une variable d'environnement — ceinture et bretelles, après l'incident d'orphelin ci-dessus. Pas fait, ou pas activé : l'A/B de micro-batch à 8 slots, les préréglages d'échantillonneur nommés et mypy en CI ; l'auto-mise à jour de l'app est écrite mais reste éteinte jusqu'à ce que vous définissiez vous-même update.repo.
Pièges
La liste honnête — des choses qui ont réellement mordu, dans l'ordre approximatif du temps qu'elles ont coûté :
- LM Studio sur 1234 doit être quitté d'abord. Les deux ne peuvent pas détenir le port ; le prévol nomme le détenteur plutôt que d'imprimer une traceback de bind, et
server.portle déplace. La bibliothèque peut être partagée sans problème. - Deux drapeaux llama.cpp ne veulent pas dire ce qu'ils semblent dire.
--ctx-sizeest le budget à travers tous les slots, pas par slot ;--fitest activé par défaut en amont, aux côtés de--n-gpu-layers auto— les deux ci-dessus, sous le planificateur. - Les modèles de raisonnement renvoient une réponse vide sous
--reasoning-format auto. Même prompt, seul le drapeau change :content0 caractère etreasoning_content316 sousauto;content323 sousnone.reasoning_contentn'est pas dans le schéma OpenAI, donc un client standard lit une chaîne vide et conclut que le modèle n'a rien dit. Je fais tourner le 31B surdeepseekparce que mes clients lisent ce champ ; tout le reste reçoitnone. - Les préversions
vX.Y.Zde llama.cpp ne portent aucun asset CUDA Windows. Un tagv0.1.2siégeait au-dessus de deux buildsbNNNNordinaires sans aucune archive préconstruite, donc l'onglet Serveur offrait une mise à jour derrière un bouton qui ne pouvait qu'échouer. Les tags sont filtrés vers^b\d+$désormais. - Ne faites jamais de tree-kill sur la racine de la zone de notification avec un onglet navigateur ouvert sur le panneau. Sous un stub de lanceur venv, le chien de garde est le petit-enfant du serveur, et un redémarrage s'est un jour terminé avec le serveur mort, le chien de garde mort et rien de lancé. Utilisez le menu de la zone de notification, le panneau, ou
sfctl recover --restart. pkill -f llama-serverdepuis un autre outil tue vos backends. bench-llm fait exactement cela entre les exécutions — ci-dessus, sous les clients harnais.- Le PIN MCP n'est pas une clé API, et un serveur sans clé a quand même besoin d'une donnée d'identification de substitution dans certains clients — ci-dessus, sous dsh.
- La clé MCP d'OpenClaw est
mcp.servers, imbriquée sousmcp. La map platemcpServersn'est pas une clé que son schéma connaît — ci-dessus, sous OpenClaw. - Deux raccourcis de mesure vous mentiront. Répéter un prompt dans un benchmark spéculatif chronomètre le cache de prompt (+751 % contre +0,4 %), et une formule KV couches × têtes × contexte est 4× à côté sur un Qwen3.5 — les deux ci-dessus, sous le planificateur.
/propsrapporte aussispeculative.types: "none"pendant le drafting, donc liseztimings.draft_nsur une vraie complétion. - Les modèles de vision ne tirent aucun bénéfice du cache de prompt. llama.cpp désactive lui-même la réutilisation du cache pour les modèles multimodaux, et chaque image est budgétée à 1 024 tokens sauf si les métadonnées mmproj disent le contraire — assez pour qu'une fenêtre de 8k soit surtout des images.
Se le procurer
Le zip ci-dessous est tout : l'arbre source tagué, les tests, la doc, les lanceurs, les unités systemd, plus un dossier dist/ contenant les deux wheels et le sdist pour installer sans étape de build. studioforge-2026-08.zip — version v1.26-08-23, 4 238 728 octets (4,04 Mio), SHA-256 :
84f4f828b5c75206236890f28e8651c96146a7bb39c14e21b13a0922a13f7d3f studioforge-2026-08.zip
226 entrées sous un seul répertoire de premier niveau, construit avec git archive depuis le tag annoté v1.26-08-23 au commit 0610446, donc il ne peut contenir que des fichiers suivis — pas de config.yaml, pas de data/, pas de dérogations locales. Le source est aussi sur github.com/LaserLloyd/StudioForge. Il lui faut Python 3.12+, uv, un pilote NVIDIA de la série 580 et au-delà, et un dossier de GGUF. Licence : pas encore choisie, donc tous les droits sont formellement réservés — en pratique, traitez-le comme le reste des téléchargements de ce site : gratuit pour usage personnel, et si vous le voulez commercialement, demandez-moi.
Si quelque chose casse, écrivez-moi — l'adresse est sur la page À propos — et envoyez la forme de la panne plutôt que votre config : le error.code, les chiffres d'un 507, les vingt dernières lignes de logs/models/<model>.log. Jamais le PIN ni la clé. Et si vous construisez le planificateur de bin-packing conjoint, les préréglages d'échantillonneur nommés, ou un chemin AMD qui fonctionne vraiment avant moi, je préfère intégrer le vôtre plutôt que d'écrire le mien.
Où cela me laisse
Ce à quoi je ne m'attendais pas, c'est à quel point tout cela s'est révélé être de la mesure plutôt que du code. Le planificateur est de l'arithmétique que n'importe qui pourrait écrire ; ce qui l'a rendu digne de confiance, c'est de lire la propre géométrie KV de llama.cpp au lieu d'une formule, puis de mesurer le coude des slots à 2 alors que l'estimateur disait 8. Presque chaque décision de ce journal a commencé comme un chiffre qui contredisait une croyance. Si vous faites déjà tourner LM Studio sur 1234, toute l'expérience tient en un clone, un fichier batch, et pointer models.dir vers le dossier que vous avez déjà — et si elle ne gagne pas sa place en un après-midi, votre ancienne configuration est intacte.
À voir aussi : DeepSeek Harness (dsh) (où ce serveur apparaît pour la première fois, comme un bloc fournisseur inexpliqué), bench-llm (d'où viennent les chiffres de tokens par seconde du rig, et l'outil qui tuera vos backends), Mon installation OpenClaw (l'article qui a nommé le problème que ceci résout), et DisPatch (l'app de chat devant lui).
Téléchargements
Gratuit pour un usage personnel. Si ça vous fait gagner un après-midi, le bouton café n'est pas loin.