StudioForge: um servidor de LLM somente GPU que substituiu o LM Studio no meu rig, operado remotamente pelos meus agentes
- Categoria
- IA e LLM Local
- Publicado em
- 23 agosto 2026
- Por
- Jacob Lloyd — escrito com ajuda de IA, depois do projeto
- Tempo de leitura
- 60 min de leitura
Em termos simples: Este é um programa que roda no computador que tem as placas de vídeo. Os aplicativos pedem a ele uma resposta de um modelo de IA, e ele descobre em quais placas aquele modelo cabe, o inicia, o mantém aquecido enquanto está em uso e o desliga de novo quando fica em silêncio. A regra que ele nunca quebra é que um modelo ou cabe inteiramente nas placas de vídeo ou é recusado — ele não roda silenciosamente metade dele no processador lento e deixa você se perguntando por que tudo ficou arrastado. O porém: ele precisa de placas NVIDIA, é melhor testado no Windows, e eu ainda não escolhi uma licença para ele.
O StudioForge é o servidor de modelos local que escrevi para substituir o LM Studio no meu rig com GPU: um supervisor do llama.cpp com uma API compatível com OpenAI na porta 1234 — a porta do LM Studio, de propósito — um painel de controle no navegador, um sidecar de recuperação que responde quando o processo principal não responde, e um plano de gerenciamento publicado via MCP para que um agente em outra máquina possa operá-lo sem shell.
Ele existe por causa de um registro de incidentes que deixou de ser engraçado. Um 200 que não provava nada: o LM Studio responde caminhos não roteados com um status de sucesso e um corpo de erro, com o próprio log dizendo Returning 200 anyway. O /v1/models listando tudo que foi baixado em vez do que foi carregado, então "o que está realmente residente?" não tinha resposta. Modelos de raciocínio estourando uma janela de 8.192 tokens, porque o contexto que eu definia na configuração do cliente não era o contexto que o carregamento usava — no LM Studio a janela é fixada no momento do carregamento, e nada me avisava. E o caro: um modelo que não cabia era reduzido em vez de recusado — o comportamento documentado é que ele "vai reduzir automaticamente o tamanho do offload de GPU … e o restante na RAM do sistema", o que troca velocidade de GPU por velocidade de RAM do sistema e reporta sucesso de qualquer jeito.
Se você leu o artigo sobre o dsh, já conheceu este servidor: o bloco de provedor rotulado como "StudioForge (GPU rig)" é isto. É a ele que a stack de oito agentes recorre pela rede para trabalho pesado, com quem o DisPatch conversa, e o que o OpenClaw Email pede um modelo de chat e um modelo de embeddings. Meu artigo sobre o OpenClaw nomeou o problema — "minutos de tempo de carregamento de modelo, malabarismo de VRAM entre serviços" — e o que vem a seguir é a resposta, com cada regra carregando a medição que a forçou.
tl;dr
- O que é: um servidor de LLM somente GPU, compatível com OpenAI, sobre o
llama-serverdo llama.cpp, medido no buildb10425(CUDA 13.3). Um gateway em1234, um painel de controle em8080, um watchdog em1235, um processo filho por modelo carregado em18100–18200. - O que ele faz: carrega um modelo no primeiro uso, planejando seu contexto, tipo de cache KV, colocação de GPU e número de slots contra a VRAM livre naquele instante; o descarrega por inatividade; mantém modelos fixados residentes; entrega placas inteiras a um único modelo quando solicitado; e publica 29 ferramentas via MCP — 19 de gerenciamento, 10 de recuperação.
- O que ele nunca faz: transbordar para a CPU (um modelo cabe inteiramente na VRAM ou é recusado com os números), ligar para casa (as únicas chamadas de saída são Hugging Face para modelos, GitHub para o build fixado do
llama-servere sua verificação de atualização, a verificação de lançamento do StudioForge que é opcional, e URLs de imagem que uma requisição nomeia), ou rodar inferência via MCP — o plano de controle não tem ferramenta de completion e diz isso em maiúsculas. - O que você precisa: GPUs NVIDIA em um driver da série 580 ou mais recente, Python 3.12+, uv, uma pasta de GGUFs. Nunca rodou um modelo localmente? Comece aqui.
- Com o que você fica: uma base URL que todo cliente OpenAI na sua rede usa sem alteração, um painel que nomeia o que ocupa cada gigabyte em cada placa, e um assistente que pode dizer "carregue o 27B em 128k nas duas 5090s" e ver isso acontecer.
- A parte honesta: o Windows é a plataforma de referência, somente NVIDIA, e ainda não há arquivo de licença — leia Como obter primeiro.
O que ele faz, em uma imagem
O que ele não é:
- Não é um modelo. Ele roda os GGUFs que você tem e busca mais na mesma pasta e layout que o LM Studio usa.
- Não é um motor de inferência. O llama.cpp faz a matemática; isto decide qual processo roda com quais flags em quais placas.
- Não é um app de chat. Existe uma aba Chat, e ela existe para provar que o caminho real de requisição funciona.
- Não é um cluster. Uma máquina, suas próprias placas. O backend RPC do llama.cpp existe e não está conectado.
- Não é um servidor de inferência em CPU. Não há nenhum valor de
--n-gpu-layersalém de999em lugar algum da base de código. - Não é uma segunda superfície de API. Sem
/api/generatedo Ollama, sem API do KoboldCpp — a superfície OpenAI, um espelho/api/v0com sabor de LM Studio e o REST de gerenciamento/api, e é só isso.
O que roda onde
| Peça | Onde | Para que serve |
|---|---|---|
| Gateway | o host de GPU, um processo, 1234 | /v1, /mcp (19 ferramentas), /api. Mantém o registro, o planejador e o supervisor. |
| Painel de controle | mesmo processo, segundo uvicorn, 8080 | Dashboard, Setup, Models, Download, Chat, Server, Logs. |
| Watchdog | um processo separado, 1235 | Seu próprio servidor MCP, 10 ferramentas de recuperação. Ele sobrevive ao que supervisiona. |
Filhos llama-server | 18100–18200, somente loopback | Um por modelo carregado; uma falha derruba um modelo, nunca o gateway. |
Companion sfctl | a máquina do agente | Um cliente HTTP puro — Python 3.11+, sem CUDA, sem dependência de servidor. Também a ponte stdio MCP. |
| Biblioteca GGUF | models.dir, onde já está | Indexada no lugar; nada copiado. O LM Studio continua usando a mesma pasta. |
O que a tabela diz:
- Só o host de GPU instala alguma coisa. Um cliente precisa de uma base URL; um agente precisa de um pacote Python pequeno, e somente para as ferramentas de gerenciamento.
- Os processos filhos são invisíveis de fora. Eles se vinculam a
127.0.0.1e nada mais, então o gateway é a única superfície pública — o que faz de uma única chave de API uma fronteira real.
Como uma requisição flui
Tudo que um cliente pode errar é verificado antes do primeiro byte, porque uma requisição inválida deve receber um 4xx real (um 404 para um id de modelo que não existe) com um corpo JSON, em vez de um frame de erro enterrado dentro de um stream SSE 200 — clientes lidam bem com o primeiro e rotineiramente lidam mal com o segundo. O que o GET /health diz no meu 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}
- O
can_serveé a resposta ao problema do 200-que-não-prova-nada. Ele é false enquanto a primeira varredura da biblioteca roda, enquanto ostatuspermaneceokporque o processo está vivo e é isso que um verificador de liveness pergunta. OGET /health?deep=trueroda uma completion real de 8 tokens (uma chamada de embeddings para modelos de embeddings) contra cada modelo carregado — e sem nada carregado ele respondeno_models_loadedem vez de passar, porque uma sonda que não pode falhar é pior do que nenhuma sonda. - O alias
local-modelresolve.local-model,default,autoecurrenttodos mapeiam paramodels.default_model. Clientes do LM Studio caem para essa string literal, então retornar 404 para ela os quebra à toa. - Um modelo frio não parece um travamento. O stream aberto carrega
: loading <model id> (5s)a cada cinco segundos, e então: prefilling <model id> (Ns)até o primeiro token real — linhas de comentário SSE que todo parser ignora. Um socket em silêncio por tanto tempo dispara um read timeout, e um cliente que faz retry empilha mais prefill sobre um lote saturado. - Um carregamento por vez, na máquina inteira. Dois modelos frios uma vez foram planejados simultaneamente para as mesmas placas; um morreu com
CUDA error: out of memory, seu retry despejou o outro modelo novo, e a requisição daquele cliente caiu em um processo filho morto. - Um
ttlno nível da requisição move o temporizador de inatividade e nada mais.ttl: 0é a forma no wire de fixado em todo lugar aqui, então ele é ignorado em vez de honrado — um cliente enviando{"ttl": 60}costumava desfixar o que o dono dele tinha fixado.
A verdade sobre a VRAM: o planejador
Esta é a parte que ninguém mais faz, então aqui está ela em detalhe em vez de adjetivos. O core/planner.py tem 3.188 linhas respondendo a uma pergunta antes de um modelo iniciar: dada a VRAM genuinamente livre em cada placa agora, qual é a melhor janela, qualidade de cache e número de slots que este modelo pode ter — e se a resposta for nenhuma, o que devo lhe dizer?
A escada, e o que ela se recusa a trocar
Os degraus desenhados ali são ilustrativos, não os padrões enviados: o mecanismo é exato, os números são um exemplo. O que o config.example.yaml envia é target_ctx: 1048576 como alvo e default_ctx: 8192 como piso, com o alvo limitado primeiro à janela treinada do modelo — embora na primeira execução o tune_for_hardware escreva default_ctx: 16384 quando a menor placa tem 24 GiB ou mais, e 8192 de 12 GiB para cima; meu rig roda com um piso de 128000. Passar de uma janela treinada exige escala RoPE e degrada a qualidade, então um nível acima dela nunca é oferecido. Um ctx_size explícito é uma escada de um degrau.
Duas passadas, a segunda somente quando a primeira falhou em todo lugar. O motivo é um carregamento às 12:03 numa tarde: 79.832 MB livres, mais 19.423 MB recuperáveis de um modelo ocioso. Com esse orçamento, 262144 com cache q4_0 cabia em 96.004 MB, e 65536 com f16 completo cabia em 95.236 MB. O que carregou foi 8192/f16 em 89.860 MB — o modelo pagou o preço total da despejo e recebeu a menor janela da escada.
KV não é um número por modelo
Quase toda calculadora de VRAM online calcula o KV como camadas × cabeças × dim-de-cabeça × 2 × bytes × contexto. Certo para um Llama, muito errado para duas famílias que as pessoas realmente rodam: o Gemma 3 e 4 intercalam cinco camadas de janela deslizante para cada uma completa, com metade da dimensão de cabeça, e o Qwen3.5, 3.6 e 3.8 declaram full_attention_interval = 4, então um cache KV existe a cada quarta camada e o resto são camadas recorrentes Gated-DeltaNet com um estado fixo por sequência.
O custo de errar isso: um Gemma-4 31B solicitado com 262.144 tokens foi estimado em 480 GiB de KV e limitado a 65.536, enquanto o log de calibração registrava predicted_mb=95615 actual_mb=40037 por semanas. Depois da correção de geometria, o previsto e o real caem ambos em 38 GiB em duas 5090s com n_ctx=262144 — um destravamento de 4× que levou junto toda a frota Gemma-4. Cobrar KV de todas as camadas de um Qwen3.5 era o mesmo bug com outro chapéu: uma sobrecarga direta de 4×.
Dois detalhes que valem roubar. A contagem de células de janela deslizante precisa espelhar o llama.cpp exatamente; um multiplicador fixo de 1,25× estava errado na direção perigosa, 3,6× abaixo em quatro slots. E o attention_kind é derivado da geometria das camadas em vez de general.architecture — onde não pode ser derivado, ele reporta unknown, que significa "desconfie de todo número de KV aqui", nunca "suponha o caso barato".
A qualidade do cache é escolhida dentro de cada degrau em vez de trocada por uma janela mais larga: f16/f16 → q8_0/q8_0 → q8_0 K + q4_0 V, com o q4_0 simétrico removido de todo caminho automático. Não é gosto — com um cache q4_0 na chave K, o Qwen2.5-7B reproduz apenas 11,7% dos tokens que seu eu f16 produz, enquanto um par q8_0/q8_0 casado fica em divergência KL de 0,0018.
Quatro placas, duas gerações
O rig tem duas RTX 5090 e duas RTX 3090 — nominalmente placas de 32 GB e 24 GB, que o painel conta como 31,84 GiB e 24,0 GiB, 111,7 GiB no total — em driver 610.88, driver CUDA 13.3. Placa única primeiro, sempre — um modelo dividido em PCIe sem NVLink é significativamente mais lento — e o planejador só anula isso quando a colocação em placa única fica limitada a um slot, a divisão pelo menos o dobra, toda placa adicionada é pelo menos tão capaz e você deixou o número de slots no automático. Essa última condição não é gentileza: uma divisão roda no ritmo do membro mais lento.
| Colocação (1.5B Q4_K_M, duas 3090s, ctx 8k) | Geração | Processamento de prompt |
|---|---|---|
| Uma 3090 | 352,5 tok/s | 2.803,6 tok/s |
Duas, -sm layer | 344,4 tok/s | 2.722,5 tok/s |
Duas, -sm tensor | 294,3 tok/s | 1.182,0 tok/s |
Duas, -sm row | falha: error loading model: device CUDA2 does not support split buffers | |
O que a tabela diz:
- Uma placa venceu duas nos dois eixos. A divisão por camadas custa cerca de 2% da geração; a divisão por tensor custa 17% da geração e 58% do processamento de prompt — então o modo tensor é opcional, e só uma medição pode escolhê-lo.
- O
-sm rowestá morto no CUDA. O parser o aceita e o carregamento então falha, então ele é bloqueado antes de o processo filho ser gerado, não depois.
Dois detalhes de colocação só aparecem quando você mede por placa. A camada de saída é cobrada do último dispositivo, porque os quantizadores mantêm os tensores de embedding e saída em Q6_K ou Q8_0 mesmo dentro de um arquivo Q4: um 27B planejado como --device CUDA1,CUDA0 --tensor-split 0.5079,0.4921 pousou 15,52 GiB na CUDA0 — o último dispositivo, aquele que a divisão deu menos — contra 14,48 da CUDA1. E o llama.cpp abre um contexto CUDA em todo dispositivo visível — ~0,22 GiB em uma 3090, 0,43 GiB em uma 5090 — por isso a coluna de colocação tem um piso de 512 MiB.
Quantas conversas uma colocação vale
O --ctx-size do llama.cpp é o orçamento total de KV compartilhado entre os slots, não a janela por slot — uma flag amplamente mal interpretada, e o README do upstream não explica isso. Um carregamento com --ctx-size 4096 e sem --parallel reporta total_slots: 4: 1.024 tokens por conversa. O StudioForge inicia com ctx_per_slot × parallel. Então: quantos slots valem a pena, que é onde eu parei de confiar na minha própria aritmética.
| Concorrentes | Por stream | Agregado | p50 | p95 | Lote alcançado |
|---|---|---|---|---|---|
| 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, uma RTX 3090, 8.192 tokens por slot, KV f16, oito slots iniciados, prompts de 512 tokens, 192 tokens gerados cada.
O que a tabela diz:
- O estimador disse 8. A medição disse 2. Em quatro slots cada stream cai para 44% da velocidade solo, abaixo de um piso de 65%; a regra pega o maior de 1/2/4/8 que fica acima desse piso e ainda ganha 15% de agregado sobre o nível abaixo.
- O agregado nunca para de subir — oito slots movem 1,9× os tokens de um — enquanto uma única conversa desaba para 27%. Uma regra que maximiza o agregado escolheria 8, e todo usuário experimentaria um modelo três vezes mais lento do que a placa consegue rodar.
- O batching é real, não enfileiramento. O lote alcançado subindo de 1,00 → 1,84 → 3,46 → 6,03 prova passos de decodificação compartilhados; reproduzido em três execuções com variação de 2%.
Uma segunda execução em duas 3090s com 32.768 por slot foi de 301,7 → 230,5 por stream e respondeu 2 de novo. Aliás, o catálogo havia previsto 308,0 tok/s para essa colocação e a execução mediu 301,7 — 2% de diferença, melhor do que eu esperava. As linhas agora carregam max_parallel (quantos cabem) ao lado de recommended_parallel (quantos valem rodar).
Duas flags que medi antes de confiar
A decodificação especulativa é uma vitória de stream único. Qwen3.8-27B Q5_K_S com cabeça MTP, uma 3090, quatro prompts distintos de 256 tokens, cache de prompt desligado: sem especulação 37,75 tok/s; draft-mtp em profundidade 3 deu 50,70 tok/s, +34,3%, com aceitação de 0,528. A profundidade 4 cai para 47,48, porque a aceitação cai para 0,446 e todo token rejeitado extra foi verificado à toa. O ngram-mod conseguiu +0,4% e não emitiu nenhum rascunho.
O que traz a armadilha: o mesmo prompt três vezes mediu +751% naquele 27B. Repita um prompt e você está medindo o cache de prompt e chamando isso de rascunho. Acima de quatro slots, o auto agora retorna none e diz o porquê — "a especulação é uma vitória de stream único e prejudica um lote saturado" — depois que uma execução carregou um 27B com --parallel 8 e o auto ainda escolheu draft-mtp, vendo a cabeça MTP e não o número de slots.
O micro-batch compra prefill com VRAM. O mesmo 1.5B, um prompt de 5.166 tokens: -ub 512 (o padrão do motor) deu 15.232 tok/s em 1.492 MiB; -ub 1024, 17.307 tok/s (+13,6%) em 1.562 MiB; -ub 2048, 18.061 tok/s (+18,6%) em 1.702 MiB. Ele ficou desligado por muito tempo porque o buffer de computação cresce com -ub, o planejador não o modelava, e um buffer não modelado transforma um encaixe em um out-of-memory. O planejador o cobra agora, arredondado para cima de modo a errar em direção a recusar, e aumenta o micro-batch automaticamente apenas acima de quatro slots.
A recusa, com os números
Toda execução passa --fit off e --n-gpu-layers 999, e o segundo é uma constante, não uma configuração. Isso importa mais do que antes: o build fixado b10425 envia -fit, --fit [on|off] — "se deve ajustar argumentos não definidos para caber na memória do dispositivo" — padrão on, ao lado de --n-gpu-layers auto; ambos chegaram ao upstream no PR #16653 em dezembro de 2025. Esse par é precisamente um caminho silencioso de offload parcial: um padrão razoável para um servidor de uso geral, e exatamente o comportamento que este projeto existe para recusar. Quando nada cabe, a resposta é HTTP 507 com a aritmética no corpo, aparado aqui:
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 }}}
Essa é uma recusa real, capturada enquanto eu escrevia isto: o 31B pediu para carregar em uma única RTX 3090. O texto nomeia a falta e os detentores; o error.studioforge carrega a mesma falha como dados — os bytes necessários e disponíveis, VRAM livre por placa, a estimativa dividida em pesos, KV, buffer de computação, modelo de projeção e contexto CUDA, todo processo segurando VRAM, e notes dizendo em qual degrau da escada ele estava quando desistiu. Quando uma janela menor caberia, o max_ctx_that_fits a nomeia, calculado na geometria por camada para que a oferta seja uma que o próximo carregamento aceite; aqui nem o piso coube, então é null em vez de um número que falharia. Uma recusa que não é sobre um modelo ocupado não carrega retry_after_s, porque "tente de novo mais tarde" é um conselho ruim quando nada vai mudar.
Fixações, TTLs, locações e o rebalanceador
Uma fixação é um estado desejado, não uma isenção. Ela costumava significar TTL zero, exclusão de toda escada de despejo e um aquecimento na inicialização — e não a quarta coisa: um modelo fixado nunca era recarregado, então um processo filho que entrasse em loop de crash além do max_restarts por modelo ficava em state="failed" sem segurar nada. Um reconciliador agora acompanha a varredura de 15 segundos, recuando de 60 s até um teto de 900 s. A única coisa que vence uma fixação é uma pessoa: uma descarga explícita marca o id como suprimido e ele permanece desligado.
Uma locação é uma placa pertencente a um modelo. Uma placa locada está ausente da visão de GPU de todos os outros modelos — não classificada por último, não é uma opção — e o dono é forçado exatamente àquelas placas, dimensionado pelo estimador, no modo de divisão que o próprio benchmark dele mediu como mais rápido ali. Uma placa já locada é um conflito 409, nunca uma tomada. Uma locação sem modelo segura placas para algo fora do servidor: reserve_gpus(devices=[3], reason="ComfyUI render") é o motivo de minha geração de imagens e meus modelos de linguagem terem parado de brigar.
O rebalanceador corrige a boa decisão de ontem. Às 13:42, um 27B foi planejado para as placas [1, 3] — uma divisão entre camadas compartilhando a GPU1 — porque um 31B segurava [1, 0, 2] e um app de geração de imagens segurava 7,5 GiB da GPU2. Às 13:53, o 31B encolheu para [0, 1]; a partir daí [2, 3] ficou livre e estritamente melhor, e o 27B permaneceu onde estava. Então um modelo ocioso agora é movido quando um plano sem despejo em suas configurações atuais exatas o coloca fora de toda placa compartilhada. Tokens por segundo estimados nunca justificam uma mudança.
Ele olha uma vez por minuto, e só quando o mundo mudou: só em uma máquina tranquila, só para um modelo ocioso há cinco minutos, uma mudança por modelo a cada 30 minutos — porque uma realocação é um recarregamento e um recarregamento descarta o cache de prompt, e na carga de trabalho de conversa longa deste rig aquele cache era 93% de um prompt de 98 mil tokens. A despejo tem três regras rígidas além disso — nunca um modelo fixado, nunca um no meio de uma requisição, nunca uma instância carregando — e uma placa locada não está na visão do planejador, para começar. Um carregamento just-in-time nunca pode definir force.
Quando as coisas quebram
A VRAM morre com o processo que a tomou. No Windows, os filhos vivem em um job object anônimo criado com JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, então o kernel mata cada membro quando o último handle fecha — não importa como o pai termine. Anônimo, porque um job nomeado seria compartilhado com qualquer coisa que adivinhasse o nome. O Linux ganha um shim PR_SET_PDEATHSIG, que cobre um kill -9 do gateway; é um melhor esforço em vez de uma garantia do kernel, então a varredura de inicialização e o reclaim_orphan_engines pegam o que escapa.
Por isso existe uma varredura de inicialização. Em 18 de agosto de 2026, ~10 GiB na GPU0 e ~15,6 GiB na GPU1 estavam indisponíveis com "tudo parado". Os detentores eram três filhos llama-server.exe de uma execução python -m pytest tests -q que um agente de codificação havia iniciado e que desde então saiu. Todo detentor agora é classificado — ours, child-of-live-process, orphan, other-instance, foreign — e apenas orphan é morto, seguro por construção porque nada mais inicia binários da nossa árvore de motores.
Nomear quem segura o quê levou duas tentativas. O NVML reporta zero de memória usada por processo no Windows, então os tamanhos vêm do contador que a coluna "Dedicated GPU memory" do Gerenciador de Tarefas lê — mas isso é um total por processo entre adaptadores, então a coluna de dispositivo estava errada: um processo reportado em CUDA0,1,2,3 na verdade segurava 15,52 GiB na CUDA0 e nada nas 3090s. A correção une o LUID do adaptador a um endereço de barramento PCI, e uma armadilha ganha o parágrafo: o número do barramento no busId do NVML é hexadecimal. "00000000:42:00.0" é barramento 66, não 42 — uma resposta errada e confiante, indistinguível de uma certa.
Uma descarga é verificada, não anunciada. O supervisor recheca o pid com uma proteção de tempo de criação contra reuso de pid, escala um sobrevivente para um tree-kill forçado e registra a VRAM antes e depois; um sobrevivente levanta um 500 dizendo para você matá-lo manualmente. Uma descarga que reporta sucesso enquanto o processo permanece residente é a mentira mais cara que este sistema pode contar, porque todo carregamento posterior é então planejado contra uma VRAM que não está livre.
Códigos de saída são vocabulário. 2 é um erro de configuração nomeando a chave. 3 é um conflito de porta, e a bandeja nunca reergue na porta em conflito — ela espera o detentor responder /health como um servidor StudioForge e se anexa. 75 é "reinicialização solicitada": o servidor drena, define o código e encerra graciosamente — 1,0 s da solicitação à saída, medido — e a bandeja reergue sem gastar uma tentativa de crash. Essa distinção existe porque um reinício via GUI uma vez produziu dois servidores disputando o 1234, e três crashes contados depois a bandeja ficou em Crashed — see the logs folder ao lado de um servidor saudável que ela não conseguia mais parar.
Quando o gateway está travado em vez de morto, você fala com o watchdog: um processo separado sempre ativo em 1235, construído com argparse e logging da stdlib, então ele inicia mesmo quando o config.yaml é a coisa quebrada. Suas dez ferramentas são health, get_config, set_config, restart_server, kill_model, nuke_all_models, reclaim_orphan_engines, tail_logs, gpu_status e rollback_update. Ele reimplementa a regra de órfão localmente em vez de importar o módulo que a possui — o processo de recuperação não deve importar a stack que ele conserta.
O painel de controle
O painel em 8080 é um segundo servidor uvicorn dentro do mesmo processo, compartilhando o grafo de objetos do gateway por referência — não há URLs absolutas nele, o que o faz funcionar de forma idêntica sobre HTTP puro em uma VPN mesh e atrás de um front end HTTPS.
9,7 tok/s é a aritmética desta aba — chunks em streaming sobre o tempo de relógio desde o momento em que a requisição saiu, prefill incluído — não o tempo de geração do motor, que é o 58,77 tok/s no cartão do modelo. Um chat bem-sucedido aqui é evidência de que um cliente vai funcionar, não um caminho simulado que pode divergir.
sfctl, o PIN de pareamento nunca precisa aparecer em um arquivo de configuração.
Quatro linhas mais abaixo no mesmo buffer são as que eu leio quando um carregamento me surpreende — a linha de comando que ele construiu, o processo que respondeu, e o planejador corrigindo o próprio dever de casa (timestamps e nomes de logger aparados):
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'
Isso é o planejador se pegando em 3 GB em uma placa, 4 GB no par, e dizendo qual placa e por quê — a camada de saída pousou no último dispositivo, exatamente onde ele a cobra, e a cobrança ainda assim ficou curta.
Usando-o como backend de harness
Seis clientes na minha rede falam com essa coisa e apenas um deles sabe que é o StudioForge. Essa é a intenção. Cinco deles estão desenhados abaixo; o OpenClaw Email é o sexto.
Qualquer cliente OpenAI
Duas variáveis de ambiente. server.api_key é null por padrão, então qualquer string não vazia funciona — a maioria dos clientes OpenAI se recusa a iniciar com uma vazia.
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
O GET /v1/models lista tudo que está baixado, no estilo do LM Studio, adicionando state e — quando residente — ctx_per_slot, max_parallel e parallel_limited_by, porque um comprimento de contexto sozinho é ambíguo quando um modelo roda mais de um slot. Os ids fazem round-trip: o id completo publisher/repo/file, um nome de arquivo simples, ou publisher/name, sem diferenciar maiúsculas. O DisPatch precisou só de uma nova base URL; o OpenClaw Email é o cliente mais exigente, querendo um modelo de chat e um modelo de embeddings e chamando /v1/models na inicialização para perguntar o que está realmente sendo servido.
OpenClaw, em outra máquina
A máquina do agente instala um pequeno wheel: sfctl, que tem como alvo o Python 3.11 em vez do 3.12 do servidor porque a máquina que roda o agente frequentemente fica atrás do rig, e que deliberadamente não depende do pacote do servidor — sem CUDA, sem planejador, sem registro.
sfctl servers add rig http://my-gpu-rig:1234 --api-key <PIN> --use
openclaw mcp add studioforge --command sfctl --arg mcp
Ou à mão — o detalhe que custa uma tarde às pessoas. A chave do OpenClaw é mcp.servers, aninhada sob mcp: o mapa plano mcpServers está certo para o Claude Code, o Cline e o LibreChat, e não é uma chave que o schema do OpenClaw conhece. A inferência é um caminho separado, sob models.providers — note baseUrl, com rl em minúsculas:
// ~/.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 } ] } } } }
O que o agente ganha é uma lista mesclada de 29 ferramentas: as 19 do gateway mais as 10 do watchdog, três renomeadas como recovery_* — get_config e set_config porque colidem com ferramentas do gateway, health por simetria. O restart_server mantém o nome simples, porque é o nome que a mensagem de erro de uma ferramenta de gerenciamento morta diz ao agente para chamar. Quando o servidor principal está fora, a ponte ainda anuncia todas as 19 ferramentas de gerenciamento com uma nota anexada — um agente que não consegue ver load_model não sabe que a capacidade existe. O loop que ele roda:
list_models(limit=N)— o catálogo, download mais recente primeiro. Leia a linha recomendada.load_model(**row["load_args"])— passe-o adiante sem alterar; um agente que escolheu uma linha terminou de escolher.load_recommended(model_id, ctx_size=N)quando o que você sabe é o contexto de que precisa — o único caminho de carregamento que recusa em vez de encolher.- Inferência por HTTP, não MCP. Nomear um modelo não carregado o carrega, com padrões do planejador em vez da linha que você estava lendo.
model_options(model_id)quando a linha recomendada não é suficiente: todo nível de contexto, com velocidades.search_models→repo_details→download_modelpara obter algo novo.pin_modelpara o modelo que deve sempre responder;reserve_gpus/release_gpuspara placas só dele.server_statuseconnection_info— o que está residente, quem segura VRAM, todo endereço em que responde.
E o parágrafo de que mais me orgulho, servido a todo cliente na conexão:
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 e cinco one-liners
O dsh (DeepSeek Harness) troca de modelo editando um arquivo YAML, recarregado a quente para a próxima requisição. O bloco de provedor é 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>
O apiKeyEnv é uma referência, nunca um literal — e um servidor local sem chave ainda precisa de alguma credencial referenciada, porque o cliente compatível com OpenAI insiste em um bearer token. O Claude Code, por outro lado, não consegue rotear a própria inferência para cá de jeito nenhum: a referência de protocolo de gateway dele lista Anthropic Messages, Bedrock e Vertex, e nenhum é /v1/chat/completions. Então o StudioForge é a ferramenta dele, não o cérebro — claude mcp add studioforge -- sfctl mcp (o -- é obrigatório) entrega a ele todas as 29.
O bench-llm produziu os números do rig que as pessoas citam de volta para mim — Gemma 4 26B-A4B (QAT, Q4) a 229,0 tok/s, 110 ms até o primeiro token, medido através do LM Studio na época — e é um cliente OpenAI simples, então uma base URL é tudo de que precisa; mas ele roda pkill -f llama-server entre benchmarks, o que mata todo backend do StudioForge na máquina. O resto é uma linha cada: Open WebUI, OPENAI_API_BASE_URL; LibreChat, um endpoint custom com baseURL e models.fetch: true; aider, OPENAI_API_BASE e então --model openai/<id>; Continue, provider: openai mais apiBase. A chave da base URL é escrita de forma diferente em cada um deles, que é a fonte mais confiável de noites desperdiçadas neste ecossistema.
A partir do que um agente escolhe
O catálogo transforma a escolha de um modelo em uma consulta em vez de um chute: ordenado com o download mais recente primeiro, uma linha por nível de contexto, cada uma carregando fits contra a VRAM livre ao vivo, devices, tipos de KV, max_parallel, recommended_parallel, uma confidence, uma coluna if_gpus_idle e load_args. Uma linha é uma chamada de plano real, então ela não pode prometer o que um carregamento recusaria — e if_gpus_idle é a diferença entre um agente desistir e um agente chamar unload_model.
O repo_details é o que vale nomear: ele lê um cabeçalho de GGUF remotamente, por requisições HTTP Range — 2–15 MB, na maioria os arrays de strings com prefixo de comprimento do tokenizador, em cache no disco — em vez de baixar 20 GB para descobrir se cabe, e uma CDN que responde a uma requisição range com um 200 e o corpo inteiro é detectada e recusada. O que volta é uma matriz context_fit do mesmo planejador que um carregamento real usa:
| Quant | 1× RTX 5090 | 2× RTX 5090 | As quatro placas |
|---|---|---|---|
| BF16 (51,8 GiB) | — só os pesos não cabem | 32k em q8_0 | 256k |
| Q8_0 (27,9 GiB) | — | 256k | 256k |
| Q5_K_M (19,3 GiB) | 128k em q8_0 | 256k | 256k |
| IQ2_M (10,5 GiB) | 256k | 256k | 256k |
Do guia OpenClaw do repositório, calculado neste rig, para unsloth/Qwen3.8-27B-GGUF. O max_ctx é a maior janela em um cache f16 de qualidade total; um número q8_0 aparece apenas onde ele alcança mais longe.
O que a tabela diz: a quantização decide a janela, não o número de placas — de BF16 para Q5_K_M transforma "não cabe de jeito nenhum" em 128k em uma placa — e é a resposta do próprio planejador, então onde um nível só é alcançável quantizando o cache, a matriz diz "em q8_0" em vez de contar como vitória. Para números reais, o roteiro tem três passos: um benchmark de colocação (cada modo de GPU sob a própria locação, throughput dos próprios tempos do llama-server), então benchmark_parallel no vencedor, e então reserve_gpus para fixar. Nunca faça benchmark de um modelo com o qual alguém está no meio de uma conversa.
Substituto direto do LM Studio
A compatibilidade foi a restrição de design, e o repositório lista o que foi emprestado para que ninguém precise adivinhar: a porta 1234; o /v1/models listando baixados em vez de carregados; carregamento just-in-time; TTL de inatividade; o ttl por requisição; o layout publisher/repo/ usado no lugar, então não há etapa de importação e os dois programas compartilham uma biblioteca; o espelho /api/v0/models; e o deep link lmstudio://open_from_hf. Migrar um cliente é uma mudança de host, não uma mudança de host e porta.
| LM Studio 0.4.21 | StudioForge 1.26-08-23 | |
|---|---|---|
| Motor | builds próprios do llama.cpp mais MLX na Apple | llama-server upstream, um build fixado (b10425, CUDA 13.3), smoke-testado antes da ativação |
| Caminhos não roteados | 200 com um corpo de erro — o log dele diz Returning 200 anyway | 404 com um envelope JSON, e JSON em todo status |
| Erros | prosa não estruturada que os clientes casam com regex | um error.code estável, diagnósticos em error.studioforge |
| Configuração de carregamento | context_length ignorado em um dos dois caminhos de carregamento; repetition_penalty ignorado silenciosamente | um caminho de carregamento, todo campo honrado, valores efetivos ecoados; aliases de sampler aceitos |
| Quando não cabe | "vai reduzir automaticamente o tamanho do offload de GPU … e o restante na RAM do sistema" — um transbordo silencioso para a CPU, por design | 507 insufficient_vram com bytes necessários e disponíveis, livre por GPU, o maior contexto que caberia, sugestões ordenadas |
| Multi-GPU | divisão por prioridade ou uniforme, toggles por GPU, tensor parallel desde a 0.4.15 | um planejador dimensionando contexto, tipo de KV e slots por colocação, inclinando as frações de divisão para a camada de saída |
| TTL de inatividade | 60 minutos; auto-evict mantém no máximo 1 modelo carregado JIT | 1.800 s enviados (15 min no meu rig), varridos a cada 15 s; quantos couberem, com fixações e locações decidindo quem fica |
| Gerenciamento remoto | /api/v1 load/unload/download; LM Link em preview, a ser monetizado, descoberta via o hub do LM Studio | REST /api, sfctl, 29 ferramentas MCP; o alcance é sua LAN ou sua própria VPN mesh |
| MCP | somente host — ele consome servidores MCP | um servidor MCP para o próprio gerenciamento, mais um no watchdog |
| Fonte | fechada; gratuito para uso pessoal e empresarial interno | fonte no zip, e nenhuma licença escolhida ainda |
Verificado contra o changelog, a documentação e o bug tracker do próprio LM Studio em 2026-08-23, versão 0.4.21 (lançada em 12 de agosto de 2026). A linha 2 está no bug tracker público dele; as linhas 3–4 são o que o meu próprio cliente teve que contornar na API 0.3.x. Não retestei nenhuma delas contra a 0.4.21, então leia-as como "documentado em algum momento", não "quebrado hoje".
O que a tabela diz: ele é um app de desktop melhor do que isto jamais será — GUI polida, MLX no Apple Silicon, um endpoint compatível com Anthropic, um companion móvel, uma equipe lançando quinzenalmente — e a divisão é filosófica, não de recursos. A postura padrão do LM Studio é de melhor esforço: faça rodar de algum jeito. A minha é recusar-e-explicar. Onde um agente decide o que carrega, o melhor esforço é o padrão errado, porque nada rio abaixo consegue distinguir rápido de lento sem medir.
Como ele se compara
| Servidor · motor · licença | Hot-swap / TTL de inatividade | Colocação multi-GPU | Recusa transbordo para CPU | Gerenciamento remoto / MCP |
|---|---|---|---|---|
| StudioForge 1.26-08-23 llama.cpp, um build fixado · licença: nenhuma ainda | ✅ JIT · TTL de 1.800 s, varredura de 15 s | planejado por modelo, placas mistas, fixações + locações | ✅ -ngl 999 + --fit off, 507 com números | ✅ REST + 29 ferramentas MCP |
| LM Studio 0.4.21 llama.cpp próprio + MLX · fechado | ✅ JIT · 60 min, auto-evict para 1 | divisão por prioridade/uniforme, tensor parallel | ❌ reduz o offload, resto na RAM | REST; MCP apenas host |
| Ollama 0.32.15 llama.cpp/GGML; MLX na Apple · MIT | ✅ keep_alive 5 min · 3 por GPU residentes | espalhamento automático entre as placas | ❌ transborda, mostra % de CPU em ollama ps | /api/* rico; sem servidor MCP |
| roteador llama-server (b105xx, agosto de 2026) é o llama.cpp · MIT | ✅ um filho por modelo · --sleep-idle-seconds, LRU por contagem | -sm / -ts / -dev manuais | ❌ --fit on encolhe seu plano | /models/load|unload |
| llama-swap v251 um proxy que gera outros · MIT | ✅ o produto inteiro · ttl por modelo/grupo | ❌ o que o seu cmd disser | n/d — só proxy | /ui + rotas do upstream |
| vLLM 0.27.1 próprio (PagedAttention) · Apache-2.0 | ❌ um modelo por processo | parallel de tensor / pipeline / expert | parcial — sem caminho de offload de camadas | só LoRA, "local dev" |
| KoboldCpp 1.119 fork do llama.cpp + imagem/áudio · AGPL-3.0 | ✅ --admin + --routermode | --tensor_split manual | ❌ transborda | /api/admin/*; só cliente MCP |
| TextGen (ex-oobabooga) 4.9 5 loaders incl. ExLlamaV3, TRT-LLM · AGPL-3.0 | ✅ troca sem reiniciar · TTL ? | --tensor-split manual | ❌ transborda | /v1/internal/model/*; cliente MCP |
| TabbyAPI (rolling) só ExLlamaV3 — sem GGUF · AGPL-3.0 | ✅ admin + carregamento inline · TTL ? | gpu_split_auto ligado por padrão | ✅ na prática — o ExLlama não tem caminho de CPU | admin-key /v1/model/load |
| Jan 0.8.4 modo router do llama.cpp · Apache-2.0 | ✅ via o router · TTL ? | herdado do llama.cpp | ❌ transborda | /v1/orchestrations; cliente MCP |
| LocalAI 4.9.0 60+ backends como imagens de contêiner · MIT | ✅ sob demanda · WATCHDOG_IDLE_TIMEOUT | "automatic GPU model fitting" | ❌ "No GPU required" | REST + UI; só cliente MCP |
| GPUStack 2.2.3 vLLM, SGLang, MindIE, VoxBox · Apache-2.0 | parcial — implantações de cluster | Spread/Binpack automático, multi-nó | ? | API completa de gerenciamento de cluster |
Verificado em fontes primárias em 2026-08-23. Um ponto de interrogação significa desconhecido, não "não". Os workers do GPUStack são só Linux; o vLLM não tem suporte nativo ao Windows (só WSL ou forks).
O que a tabela diz:
- Carregamento JIT e TTL de inatividade não são novidade, e eu não estou reivindicando isso. LM Studio, Ollama, llama-swap e LocalAI fazem ambos, e as flags por grupo
swap/exclusive/persistentdo llama-swap são um motor de políticas genuinamente elegante. - Três coisas ali são raras: recusar a rodar em vez de transbordar para a CPU (só o TabbyAPI chega perto, e só porque o ExLlama não tem caminho de CPU); um planejador que dimensiona contexto e slots para as placas que realmente encontrou; e gerenciamento publicado como ferramentas MCP, que nada mais na categoria faz, pelo que encontrei.
- A coisa mais próxima no upstream é o próprio router do llama.cpp, uma briga justa: multi-modelo com isolamento de processo, gratuito, no binário que você tem. O que lhe falta é despejo baseado em memória em vez de contagem, fixações, locações e uma recusa carregando números; ele tem um sleep de inatividade (
--sleep-idle-seconds), embora uma consulta a/metricso acorde.
Várias boas ideias são emprestadas, e o repositório diz quais. O Modelfile do Ollama virou modelos virtuais, então duas personas sobre uma base compartilham um único llama-server, e o keep_alive dele virou o ttl por requisição. A superfície de configurações em três níveis do TextGen foi adotada diretamente, com "extra flags" brutas incluídas, com as flags validadas contra o próprio --help do motor fixado no momento de salvar. A filosofia de artefato único do KoboldCpp é o motivo de os motores viverem em diretórios versionados.
Instalação
Windows, a plataforma de referência, em quatro passos: instale o Git, o Python 3.12+, o uv e um driver NVIDIA atual; clone o repositório ou descompacte o download; clique duas vezes em launchers\Update StudioForge.bat, que apesar do nome é a etapa de primeira execução — ele constrói o virtualenv, instala o lançamento mais novo do llama.cpp que tem um build para o seu driver, faz smoke-test e fixa nele (b10425 é o build em que este artigo foi medido, não o que você vai receber); e então launchers\Start StudioForge.bat, ou launchers\StudioForge Tray.bat se quiser na área de notificação, e o painel abre em http://127.0.0.1:8080 na aba Setup.
Linux, quatro linhas — mais cmake e um CUDA toolkit cujo nvcc corresponda ao seu driver, porque o upstream não publica nenhum arquivo CUDA para Linux em tag alguma e o motor é construído a partir da fonte uma vez por versão:
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 uma máquina headless, o deploy/ contém duas units user do systemd — units user de propósito, porque o processo deve rodar como o usuário de login que é dono da biblioteca de modelos, do venv e dos device nodes de GPU. O watchdog deliberadamente não usa BindsTo= no gateway e usa Restart=always: ele existe para estar de pé quando o gateway não está. Então sudo loginctl enable-linger "$USER", o mesmo padrão do artigo sobre ComfyUI headless. A primeira execução abre na Setup, onde Detect LM Studio library sonda primeiro o downloadsFolder em ~/.lmstudio/settings.json.
| Serviço | Porta padrão | Chave de configuração |
|---|---|---|
Gateway — /v1, /api, /mcp | 1234 | server.port |
| Painel de controle web | 8080 | gui.port |
| Watchdog de recuperação | 1235 | watchdog.port |
Filhos llama-server (somente loopback) | 18100–18200 | gateway.child_port_start / _end |
O que a tabela diz: a porta 1234 significa "o servidor de modelo local" nas duas minhas máquinas e elas não são a mesma coisa — a máquina do agente roda o seu em 127.0.0.1:1234, loopback, enquanto o rig serve my-gpu-rig:1234 pela VPN mesh — e apenas três portas são alcançáveis, com a validação de configuração recusando uma colisão entre qualquer porta de serviço e a faixa de filhos no momento do carregamento.
A regra do diretório de dados é SF_DATA_DIR primeiro, depois a pasta de um arquivo --config, depois <repo>/data em um checkout — a ordem completa, e o porquê de data_dir nunca ser gravado de volta no config.yaml, está no docs/SETUP.md. Uma instância é dona de um diretório de dados, imposto por um lock exclusivo do SO; uma segunda instância é somente leitura.
Segurança, honestamente
Uma regra por baixo de tudo: leituras, inferência e residência permanecem abertas; mudar a máquina não. Com server.api_key não definida, uma requisição mutadora a uma rota que muda a máquina só é aceita de um chamador nesta máquina, ou com o PIN MCP enviado como X-MCP-Pin ou como bearer token — qualquer outra coisa recebe 403 remote_admin_requires_credential. O conjunto bloqueado é configuração, reinicializações, motores, atualizações, recuperação de VRAM, downloads, locações, exclusões e as duas escritas por modelo que sobrevivem à instância. O problema que isso corrigiu era meu: qualquer um na LAN podia PATCH /api/config, definir server.api_key ele mesmo e me trancar para fora — enquanto a ferramenta MCP set_config, mesma capacidade no mesmo processo, exigia o PIN.
- O PIN protege só o MCP. É um código de pareamento que você lê no banner de inicialização, com escopo nas ferramentas de gerenciamento. Não é uma chave de API.
server.api_keyé a credencial real e énullpor padrão. Defina-a e ela cobre/v1,/api,/mcpe o watchdog; o PIN continua funcionando nos dois endpoints MCP ao lado.- O bind enviado é
0.0.0.0nos três listeners. A linha Network exposure da aba Setup fica âmbar e obrigatória no momento em que qualquer listener é exposto sem chave — verificando os três, porqueserver.hostem loopback comgui.hostem0.0.0.0costumava aparecer verde enquanto o painel estava totalmente aberto. - Uma requisição de navegador cross-origin não é "esta máquina", mesmo em loopback. Com
cors_origins: ["*"], qualquer página que você visite poderia fazer preflight dePATCH /api/configem127.0.0.1:1234e chegar parecendo local — então a comparação de origem inclui a porta, eOrigin: nullconta como estrangeiro. O CORS governa o que uma página pode ler, nunca em quem o servidor confia. O websocket do painel tem uma versão só-para-host do mesmo portão, porque o painel é alcançado pela porta de onde foi servido. - Um navegador remoto em uma instalação sem chave recebe leituras e inferência, 403 em mudanças na máquina, e o PIN retido — caso contrário, qualquer coisa na LAN poderia ler o PIN de um endpoint aberto e usá-lo. O PIN era teatro exatamente quando importava.
- Imagens são buscadas sob uma proteção de SSRF que bloqueia loopback, link-local, privado, ULA e espaço CGNAT — a faixa
100.64/10, onde todo peer de VPN mesh vive — e resolve uma vez, conectando ao endereço validado com oHostoriginal e o SNI. - Nada sai da máquina sem ser pedido. As únicas chamadas de saída são Hugging Face para modelos, GitHub para o build fixado do
llama-servere sua verificação de atualização, a verificação de lançamento do StudioForge que é opcional, e URLs de imagem que uma requisição nomeia; o self-update reporta "não configurado" sem uma chamada de rede até você definirupdate.repo, e um teste unitário fixa isso.
Dois limites declarados em vez de ocultos: uma verificação de endereço de peer confia no que está em loopback, que atrás de um proxy reverso é o proxy, então coloque o proxy atrás de server.api_key; e não há autenticação além de uma única chave compartilhada — sem contas, sem rate limiting. A regra da casa do artigo sobre o OpenClaw ainda vale: loopback mais um proxy autenticador, ou uma VPN mesh, nunca a internet crua.
A parte honesta
Escrito de forma clara para que você decida antes de instalar:
- O Windows é a plataforma de referência — é contra ele que a bandeja, a proteção de VRAM por job object e os contadores de GPU por processo foram construídos. O Linux é suportado e a CI roda os dois, e é menos testado em batalha; o caminho de build a partir da fonte tem a construção de comandos testada, mas nunca foi exercitado de ponta a ponta aqui. O macOS não é suportado: sem CUDA.
- Somente NVIDIA. O planejador lê o NVML, o motor é um build CUDA, e a afinidade de quant é expressa em compute capabilities.
- A estimativa de VRAM é uma estimativa. Os pesos ficam dentro de 2% do tamanho do arquivo e o KV é exato a partir da geometria por camada, mas o buffer de computação é uma fração calibrada, ajustada uma vez na inicialização, limitada a 0.03–0.15 e mantida só em memória — então uma calibração ruim é desfeita por uma reinicialização. Dois históricos de calibração nesta máquina foram contaminados e agora são ignorados por completo.
- A divisão multi-GPU é proporcional, não medida. Ela não modela a largura de banda do interconecte, e misturar gerações roda no ritmo da placa mais lenta.
- As estimativas de concorrência são aritméticas. O estimador assume que os slots ficam meio cheios e reduz modelos MoE por uma metade fixa, e
--ctx-checkpointsnão é modelado de forma alguma. Ambos os erros apontam para menos slots, que é a direção segura — mas rode o benchmark paralelo antes de confiar em 8. - As estimativas de velocidade usam números nominais do fabricante — 5090 a 1792 GB/s e 209 TFLOPS fp16, 3090 a 936 e 71, nenhum medido aqui. Há exatamente duas âncoras de calibração, ambas neste rig, ambas em um slot: um 31B denso mediu 39,4 tok/s contra uma estimativa de 36,1, e um 122B MoE mediu 37,3 contra 47,4. Nada é validado em quatro ou oito slots, nem em um modelo denso acima de 31B.
- Reservar uma GPU para outro programa restringe apenas o meu planejador. Nada impõe isso contra o outro programa, e nada o impede de tomar a memória primeiro.
- Uma instância por diretório de dados, e o lock cobre o diretório de dados em vez da biblioteca de modelos — duas instâncias com diretórios de dados diferentes sobre uma biblioteca ainda são duas escritoras.
- Nenhuma licença foi escolhida. Deliberadamente não há arquivo
LICENSE, e opyproject.tomldiz isso em um comentário — Como obter tem o que isso significa na prática. - Não há auditoria de segurança de terceiros. As afirmações acima descrevem o que o código faz; eu escrevi ambas. Leia a fonte — é por isso que é um download e não um serviço.
2.503 testes unitários passam e 17 pulam, em 327 segundos nesta máquina; a CI roda a mesma suíte no Windows e no Ubuntu com a sonda de GPU forçada a um backend nulo. Uma segunda suíte carrega pesos reais em GPUs reais e é desselecionada por padrão e bloqueada atrás de uma variável de ambiente — cinto e suspensório, depois do incidente de órfão acima. Não feito, ou não ligado: o A/B de micro-batch de 8 slots, presets de sampler nomeados e mypy na CI; o self-update do app está escrito mas fica desligado até você definir update.repo você mesmo.
Pegadinhas
A lista honesta — coisas que realmente morderam, em ordem aproximada de quanto tempo custaram:
- O LM Studio na 1234 deve ser fechado primeiro. Os dois não podem segurar a porta; o preflight nomeia o detentor em vez de imprimir um traceback de bind, e
server.porta move. A biblioteca pode ser compartilhada sem problema. - Duas flags do llama.cpp não significam o que parecem.
--ctx-sizeé o orçamento entre todos os slots, não por slot;--fittem padrão on no upstream, ao lado de--n-gpu-layers auto— ambos acima, sob o planejador. - Modelos de raciocínio retornam uma resposta vazia sob
--reasoning-format auto. Mesmo prompt, só a flag mudou:content0 caracteres ereasoning_content316 sobauto;content323 sobnone. Oreasoning_contentnão está no schema OpenAI, então um cliente padrão lê uma string vazia e conclui que o modelo não disse nada. Eu rodo o 31B emdeepseekporque meus clientes leem esse campo; todo o resto recebenone. - Pré-lançamentos
vX.Y.Zdo llama.cpp não carregam ativo CUDA para Windows. Uma tagv0.1.2ficou acima de dois builds comunsbNNNNsem nenhum arquivo pré-compilado, então a aba Server oferecia uma atualização atrás de um botão que só podia falhar. As tags agora são filtradas para^b\d+$. - Nunca faça tree-kill na raiz da bandeja com uma aba do navegador aberta no painel. Sob um stub de launcher de venv, o watchdog é o neto do servidor, e um reinício uma vez terminou com o servidor morto, o watchdog morto e nada gerado. Use o menu da bandeja, o painel, ou
sfctl recover --restart. - O
pkill -f llama-serverde outra ferramenta mata seus backends. O bench-llm faz exatamente isso entre execuções — acima, sob os clientes de harness. - O PIN MCP não é uma chave de API, e um servidor sem chave ainda precisa de uma credencial placeholder em alguns clientes — acima, sob o dsh.
- A chave MCP do OpenClaw é
mcp.servers, aninhada sobmcp. O mapa planomcpServersnão é uma chave que o schema dele conhece — acima, sob o OpenClaw. - Dois atalhos de medição vão mentir para você. Repetir um prompt em um benchmark especulativo cronometra o cache de prompt (+751% contra +0,4%), e uma fórmula de KV de camadas × cabeças × contexto erra em 4× em um Qwen3.5 — ambos acima, sob o planejador. O
/propstambém reportaspeculative.types: "none"enquanto rascunha, então leiatimings.draft_nde uma completion real. - Modelos de visão não têm benefício de cache de prompt. O llama.cpp desabilita o reuso de cache para modelos multimodais ele mesmo, e cada imagem é orçada em 1.024 tokens a menos que os metadados do mmproj digam o contrário — o bastante para uma janela de 8k ser majoritariamente imagens.
Como obter
O zip abaixo é a coisa inteira: a árvore de fonte com tag, os testes, a documentação, os launchers, as units do systemd, mais uma pasta dist/ com os dois wheels e o sdist para que você possa instalar sem uma etapa de build. studioforge-2026-08.zip — lançamento v1.26-08-23, 4.238.728 bytes (4,04 MiB), SHA-256:
84f4f828b5c75206236890f28e8651c96146a7bb39c14e21b13a0922a13f7d3f studioforge-2026-08.zip
226 entradas sob um diretório de nível superior, construído com git archive da tag anotada v1.26-08-23 no commit 0610446, então ele só pode conter arquivos rastreados — sem config.yaml, sem data/, sem overrides locais. A fonte também está em github.com/LaserLloyd/StudioForge. Ele precisa de Python 3.12+, uv, um driver NVIDIA da série 580 para cima, e uma pasta de GGUFs. Licença: ainda não escolhida, então todos os direitos estão formalmente reservados — na prática, trate-o como o resto dos downloads deste site: gratuito para uso pessoal, e se você o quiser comercialmente, me pergunte.
Se algo quebrar, me mande um email — o endereço está na página Sobre — e envie o formato da falha em vez da sua configuração: o error.code, os números de um 507, as últimas vinte linhas de logs/models/<model>.log. Nunca o PIN ou a chave. E se você construir o planejador de bin-packing conjunto, os presets de sampler nomeados, ou um caminho AMD que realmente funcione antes de mim, eu prefiro mesclar o seu a escrever o meu.
Onde isso me deixa
O que eu não esperava era quanto disso acabou sendo medição em vez de código. O planejador é aritmética que qualquer um poderia escrever; o que o tornou confiável foi ler a própria geometria de KV do llama.cpp em vez de uma fórmula, e então medir o joelho de slots em 2 quando o estimador disse 8. Quase toda decisão naquele log começou como um número que discordava de uma crença. Se você já roda o LM Studio na 1234, o experimento inteiro é um clone, um arquivo batch, e apontar models.dir para a pasta que você já tem — e se ele não ganhar o lugar dele em uma tarde, sua configuração antiga fica intacta.
Relacionados: DeepSeek Harness (dsh) (onde este servidor aparece pela primeira vez, como um bloco de provedor sem explicação), bench-llm (de onde vêm os números de tokens por segundo do rig, e a ferramenta que vai matar seus backends), Minha configuração do OpenClaw (o post que nomeou o problema que isto resolve), e DisPatch (o app de chat na frente dele).
Downloads
Gratuito para uso pessoal. Se isso te economizar uma tarde, o botão do café está logo ali.