StudioForge:一个纯 GPU 的 LLM 服务器,取代了我机器上的 LM Studio,由我的代理远程运行

发布日期
2026年8月23日
作者
Jacob Lloyd —— 项目完成后,在 AI 协助下撰写
阅读时长
约 62 分钟阅读

简单来说: 这是一个运行在装有显卡的电脑上的程序。各种应用向它请求一个 AI 模型的回答,它会算出这个模型能装进哪几张显卡,启动它,在使用期间让它保持常驻,等它闲置时再把它关掉。它从不打破的规则是:一个模型要么完全装进显卡,要么被拒绝——它不会悄悄把一半模型放到慢速处理器上运行,让你纳闷为什么一切都变得像爬一样慢。需要注意的是:它需要 NVIDIA 显卡,在 Windows 上测试得最充分,而且我还没有为它选择许可证。

StudioForge 是我为取代 GPU 机器上的 LM Studio 而编写的本地模型服务器:一个 llama.cpp 管理器,在 1234 端口上提供兼容 OpenAI 的 API——这个端口特意沿用 LM Studio 的端口——还带一个浏览器控制面板、一个在主进程不响应时仍会应答的恢复 sidecar,以及一个通过 MCP 发布的管理平面,让另一台机器上的代理无需 shell 就能运行它。

它之所以存在,是因为一份事故日志渐渐不再好笑了。一个说明不了任何问题的 200:LM Studio 对未路由的路径返回成功状态和错误正文,它自己的日志写着 Returning 200 anyway/v1/models 列出的是所有已下载的模型,而不是已加载的,所以“到底什么模型常驻在内存里?”这个问题没有答案。推理模型溢出了一个 8,192 token 的窗口,因为我在客户端配置里设置的上下文并不是加载时实际使用的上下文——在 LM Studio 里窗口在加载时就固定了,而且没有任何东西告诉我。还有一个代价高昂的问题:装不下的模型被削减而不是被拒绝——文档记载的行为是它“会自动减小 GPU 卸载大小……其余部分放进系统内存”,这等于用 GPU 速度换来系统内存速度,而且无论哪种情况都报告成功。

如果你读过 那篇 dsh 的深度剖析,你已经见过这台服务器了:那个标注为“StudioForge(GPU rig)”的 provider 块就是它。它是 八代理栈跨越网络去做重活时求助的对象,是 DisPatch 对话的对象,也是 OpenClaw Email 索取聊天模型和嵌入模型的对象。我的 OpenClaw 深度剖析点出了那个问题——“模型加载要花好几分钟,服务之间在争抢显存”——而下面就是答案,每一条规则都带着逼它成形的测量数据。

tl;dr

  • 它是什么:一个基于 llama.cppllama-server 的纯 GPU、兼容 OpenAI 的 LLM 服务器,在构建 b10425(CUDA 13.3)上测量。1234 上的网关,8080 上的控制面板,1235 上的看门狗,每个已加载模型在 18100–18200 上一个子进程。
  • 它做什么:首次使用时加载模型,针对那一刻空闲的显存规划它的上下文、KV 缓存类型、GPU 放置和槽位数量;闲置后卸载;让固定模型保持常驻;按需把整张卡交给一个模型;并通过 MCP 发布 29 个工具——19 个管理、10 个恢复。
  • 它绝不做什么:溢出到 CPU(模型要么完全装进显存,要么附上数字被拒绝)、私自联网(唯一的出站调用是 Hugging Face 取模型、GitHub 取固定的 llama-server 构建及其更新检查、可选加入的 StudioForge 发布检查,以及请求指定的图像 URL),或通过 MCP 运行推理——控制平面没有补全工具,并且用大写字母明说了这一点。
  • 你需要什么:580 系列或更新驱动上的 NVIDIA GPU、Python 3.12+uv、一个 GGUF 文件夹。从没在本地跑过模型?先从这里开始
  • 你最终得到什么:你网络上每个 OpenAI 客户端都原样使用的一个 base URL、一个点名每张卡上每个 GB 由什么占用的面板,以及一个能说“在两块 5090 上以 128k 加载 27B”并让它发生的助手。
  • 实话部分:Windows 是参考平台,仅限 NVIDIA,而且还没有许可证文件——先读 获取

它做什么,一张图看懂

它不是什么:

  • 不是模型。它运行你已有的 GGUF,并把更多 GGUF 下载到 LM Studio 所用的同一文件夹和布局中。
  • 不是推理引擎。数学计算由 llama.cpp 完成;它决定的是哪个进程用哪些标志运行在哪张卡上。
  • 不是聊天应用。虽然有个 Chat 标签页,但它存在的意义是证明真实的请求路径能工作。
  • 不是集群。一台机器,它自己的卡。llama.cpp 的 RPC 后端存在,但未接入。
  • 不是 CPU 推理服务器。代码库中除了 999 之外没有任何 --n-gpu-layers 值。
  • 不是第二套 API 接口。没有 Ollama 的 /api/generate,没有 KoboldCpp API——只有 OpenAI 接口、一个 LM Studio 风格的 /api/v0 镜像和 /api 管理 REST,仅此而已。

什么运行在哪里

组件位置用途
网关GPU 主机,单进程,1234/v1/mcp(19 个工具)、/api。包含注册表、规划器和管理器。
控制面板同一进程,第二个 uvicorn,8080Dashboard、Setup、Models、Download、Chat、Server、Logs。
看门狗一个独立进程1235它自己的 MCP 服务器,10 个恢复工具。它比它所监管的对象活得更久。
llama-server 子进程18100–18200,仅回环每个已加载模型一个;崩溃只波及一个模型,绝不波及网关。
sfctl 伴侣代理的机器一个纯 HTTP 客户端——Python 3.11+,无 CUDA,无服务器依赖。同时也是 stdio MCP 桥。
GGUF 库models.dir,它已经在的地方原地索引;不复制任何东西。LM Studio 继续使用同一个文件夹。

表格说明了什么:

  • 只有 GPU 主机安装任何东西。客户端只需要一个 base URL;代理只需要一个小型 Python 包,而且仅用于管理工具。
  • 子进程对外不可见。它们只绑定 127.0.0.1,仅此而已,因此网关是唯一的公共接口——这正是让一个 API 密钥成为真正边界的原因。

一个请求如何流动

客户端可能犯的所有错误都在第一个字节之前被检查,因为坏请求应当得到真正的 4xx(不存在的模型 id 返回 404)并带 JSON 正文,而不是埋在一个 200 SSE 流里的错误帧——客户端能处理好前者,却经常处理不好后者。我的机器上 GET /health 的输出:

{"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_serve 是对“证明不了任何问题的 200”这个问题的回答。首次库扫描运行期间它为 false,而 status 保持 ok,因为进程还活着——而这正是存活探针所询问的。GET /health?deep=true 会对每个已加载模型运行一次真正的 8 token 补全(对嵌入模型则是嵌入调用)——而在没有加载任何模型时,它回答 no_models_loaded 而不是通过,因为一个不可能失败的探针比没有探针更糟。
  • local-model 别名会解析。local-modeldefaultautocurrent 都映射到 models.default_model。LM Studio 客户端会回退到那个字面字符串,所以对它返回 404 只会白白破坏它们。
  • 冷模型看起来不像挂起。打开的流每五秒发送一次 : loading <model id> (5s),然后发送 : prefilling <model id> (Ns),直到第一个真正的 token——这些都是每个解析器都会忽略的 SSE 注释行。一个安静这么久都没有数据的 socket 会触发读取超时,而重试的客户端会把更多 prefill 压到已经饱和的批处理上。
  • 机器范围内一次只加载一个。两个冷模型曾经同时被规划到同一批卡上;其中一个死于 CUDA error: out of memory,它的重试驱逐了另一个新模型,于是那个客户端的请求撞上了死掉的子进程。
  • 请求级 ttl 只移动空闲计时器,别无他用。ttl: 0 在这里处处都是“已固定”的线上表示,因此它被忽略而非遵守——发送 {"ttl": 60} 的客户端曾经会解除其所有者固定的东西。

显存的真相:规划器

这是别人都不做的那部分,所以我在这里详细说明而不是堆砌形容词。core/planner.py 有 3,188 行,在模型启动前回答一个问题:给定每张卡此刻真正空闲的显存,这个模型能拥有的最佳窗口、缓存质量和槽位数量是什么——如果答案是没有,我该告诉你什么?

阶梯,以及它拒绝交换的东西

图中画的那些阶梯是示意性的,不是实际发布的默认值:机制是精确的,数字只是举例。config.example.yaml 发布的是 target_ctx: 1048576 作为目标,default_ctx: 8192 作为下限,目标先被钳制到模型的训练窗口——不过在首次运行时,当最小的卡有 24 GiB 或更多时,tune_for_hardware 会写入 default_ctx: 16384,从 12 GiB 起则写入 8192;我的机器运行下限 128000。超过训练窗口需要 RoPE 缩放并且会降低质量,所以从不提供高于它的档位。显式的 ctx_size 是一级阶梯。

两遍扫描,只有第一遍在所有地方都失败时才运行第二遍。原因是某天下午 12:03 的一次加载:79,832 MB 空闲,还有 19,423 MB 可从一个闲置模型回收。在那个预算下,q4_0 缓存上的 262144 装得下,占 96,004 MB,而完整 f16 上的 65536 装得下,占 95,236 MB。实际加载的却是 8192/f16 的 89,860 MB——模型付出了驱逐的全部代价,得到的却是阶梯上最小的窗口。

KV 不是一个模型一个数字

网上几乎每个显存计算器都用 层数 × 头数 × 头维度 × 2 × 字节数 × 上下文 来计算 KV。对 Llama 是对的,但对人们实际运行的两个家族却大错特错:Gemma 3 和 4 在半个头维度上每层完整注意力穿插五层滑动窗口,而 Qwen3.5、3.6 和 3.8 声明 full_attention_interval = 4,因此 KV 缓存只存在于每第四层,其余是带固定每序列状态的 Gated-DeltaNet 循环层。

算错的代价:一个请求 262,144 token 的 Gemma-4 31B 被估算为 480 GiB 的 KV,并被封顶在 65,536,而校准日志几周来都记录着 predicted_mb=95615 actual_mb=40037。几何结构修复后,预测值和真实值都落在两块 5090 上 n_ctx=262144 时的 38 GiB——一次 4 倍解锁,带动了整个 Gemma-4 系列。给 Qwen3.5 的每一层都计 KV 是同一个 bug 换了个帽子:直截了当地多算了 4 倍。

两个值得借鉴的细节。滑动窗口单元数必须与 llama.cpp 完全一致;一个固定 1.25 倍乘数错在了危险的方向——在四个槽位下低估了 3.6 倍。而 attention_kind 是从层级几何结构推导出来的,而非 general.architecture——在无法推导的地方它报告 unknown,这意味着“不信任这里的每个 KV 数字”,绝不是“假设廉价的情况”。

缓存质量是在每一级阶梯内部选择的,而不是用来换取更宽窗口的:f16/f16 → q8_0/q8_0 → q8_0 K + q4_0 V,对称的 q4_0 已从所有自动路径中移除。这不是口味问题——使用 q4_0 K 缓存时,Qwen2.5-7B 只能重现其 f16 版本所产生 token 的 11.7%,而匹配的 q8_0/q8_0 对的 KL 散度是 0.0018

四张卡,两个代际

这台机器是两块 RTX 5090 和两块 RTX 3090——标称 32 GB 和 24 GB 的卡,面板把它们算作 31.84 GiB 和 24.0 GiB,总计 111.7 GiB——运行在驱动 610.88、CUDA 驱动 13.3 上。总是优先单卡——在没有 NVLink 的 PCIe 上拆分模型会明显变慢——只有在单卡放置被饿到只有一个槽位、拆分至少让它翻倍、每张新增的卡都至少同样强大、而且你把槽位数量留在自动模式时,规划器才会推翻单卡优先。最后这个条件不是客气:拆分运行的速度取决于最慢的成员。

放置方案(1.5B Q4_K_M,两块 3090,8k 上下文)生成提示词处理
一块 3090352.5 tok/s2803.6 tok/s
两块,-sm layer344.4 tok/s2722.5 tok/s
两块,-sm tensor294.3 tok/s1182.0 tok/s
两块,-sm row失败:error loading model: device CUDA2 does not support split buffers

表格说明了什么:

  • 一块卡在两个维度上都击败了两块。层拆分大约损失 2% 的生成;张量拆分损失 17% 的生成和 58% 的提示词处理——所以张量模式是可选加入的,而且只有测量结果可以选择它。
  • -sm row 在 CUDA 上是死的。解析器接受它,但随后加载失败,所以它在子进程启动之前就被拦截,而不是之后。

有两个放置细节只有在你按卡测量时才会浮现。输出层被计入 最后一个 设备,因为量化器即使在 Q4 文件里也把嵌入张量和输出张量保持在 Q6_K 或 Q8_0:一个按 --device CUDA1,CUDA0 --tensor-split 0.5079,0.4921 规划的 27B 最终在 CUDA0 上落了 15.52 GiB——最后一个设备,拆分分给它的更少——而 CUDA1 上是 14.48。而且 llama.cpp 会在每个可见设备上打开一个 CUDA 上下文——3090 上约 0.22 GiB,5090 上是 0.43 GiB——这就是放置列有 512 MiB 下限的原因。

一个放置方案值多少个对话

llama.cpp 的 --ctx-size 是跨槽位共享的 KV 预算,而不是每个槽位的窗口——一个被广泛误读的标志,上游的 README 也没有说清楚。一个带 --ctx-size 4096 且不带 --parallel 的加载会报告 total_slots: 4:每个对话 1,024 token。StudioForge 以 ctx_per_slot × parallel 启动。然后是:多少个槽位才值得拥有——这正是我不再相信自己算术的地方。

并发单流聚合p50p95实际批处理
1302.8 tok/s302.8 tok/s0.41 s0.41 s1.00
2225.3 tok/s425.3 tok/s0.46 s0.49 s1.84
4134.5 tok/s436.0 tok/s0.83 s1.00 s3.46
883.3 tok/s576.9 tok/s1.57 s1.77 s6.03

Qwen2.5-1.5B-Instruct-Q4_K_M,一块 RTX 3090,每槽位 8,192 token,f16 KV,启动八个槽位,512 token 提示词,各生成 192 token。

表格说明了什么:

  • 估算器说是 8。测量说 2。在四个槽位时每个流降到单机速度的 44%,低于 65% 的下限;规则取 1/2/4/8 中能越过该下限、同时仍比下一级多获得 15% 聚合收益的最大值。
  • 聚合吞吐从不停止攀升——八个槽位搬运的 token 是一个的 1.9 倍——而单个对话却崩溃到 27%。一个最大化聚合的规则会选 8,而每个用户都会体验到比该卡实际能跑的慢三倍的模型。
  • 批处理是真实的,不是排队。实际批处理从 1.00 → 1.84 → 3.46 → 6.03 上升,证明了共享的解码步骤;三次运行重现,误差在 2% 以内。

在每槽位 32,768 的两块 3090 上第二次运行,单流从 301.7 → 230.5,答案又是 2。顺便一提,目录对该放置方案预测的是 308.0 tok/s,而运行实测 301.7——差了 2%,比我预期的好。行现在同时携带 max_parallel(能装多少个)和 recommended_parallel(值得运行多少个)。

我信任之前先测量过的两个标志

投机解码是单流的胜利。带 MTP 头的 Qwen3.8-27B Q5_K_S,一块 3090,四个 不同 的 256 token 提示词,关闭提示缓存:不投机 37.75 tok/s;深度 3 的 draft-mtp 给出 50.70 tok/s,+34.3%,接受率 0.528。深度 4 降到 47.48,因为接受率跌到 0.446,而且每个额外被拒绝的 token 都是白验证的。ngram-mod 只做到了 +0.4%,而且完全没有发出草稿。

这就带来了陷阱:同一个 提示词测三次,在那个 27B 上测出了 +751%。重复一个提示词,你测的是提示缓存,却把它叫作草稿生成。在四个槽位以上,auto 现在返回 none 并说明原因——“投机是单流的胜利,会伤害饱和的批处理”——这是在一次运行以 --parallel 8 加载 27B、而 auto 仍选择 draft-mtp(它只看到了 MTP 头而没有看到槽位数量)之后改的。

微批次用显存换预填充。同一个 1.5B,一个 5,166 token 的提示词:-ub 512(引擎默认)在 1492 MiB 下给出 15,232 tok/s;-ub 1024 在 1562 MiB 下给出 17,307 tok/s(+13.6%);-ub 2048 在 1702 MiB 下给出 18,061 tok/s(+18.6%)。它被关闭了很长时间,因为计算缓冲区随 -ub 增长,规划器没有对它建模,而一个未建模的缓冲区会把“装得下”变成内存不足。规划器现在对它计费,向上取整,以便倾向于拒绝,并且只在四个槽位以上才自动提高微批次。

拒绝,附上数字

每次启动都传 --fit off--n-gpu-layers 999,而第二个是常量,不是设置。这比以前更重要了:固定的构建 b10425 附带了 -fit, --fit [on|off]——“是否调整未设置的参数以装进设备内存”——默认为 on,同时还有 --n-gpu-layers auto;两者都在 2025 年 12 月随上游 PR #16653 落地。这一对正是静默的部分卸载路径:对通用服务器是合理的默认值,而本项目存在的意义正是拒绝这种行为。当什么都装不下时,答案是 HTTP 507,正文里带着算术,这里截取一部分:

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 }}}

这是一次真实的拒绝,写这篇文章时捕获的:这个 31B 请求加载到一块 RTX 3090 上。文字部分点明了缺口和占用者;error.studioforge 把同样的失败作为数据携带——所需和可用的字节数、每张卡的空闲显存、拆分到权重、KV、计算缓冲区、投影模型和 CUDA 上下文的估算、每个占用显存的进程,以及 notes 说明它放弃时正站在阶梯的哪一级上。当更小的窗口能装下时,max_ctx_that_fits 会点名它,按每层几何结构计算,这样给出的建议就是下一次加载会接受的那个;这里连下限都装不下,所以它是 null,而不是一个会失败的数字。与繁忙模型无关的拒绝不带 retry_after_s,因为在什么都不会改变的情况下,“稍后重试”是糟糕的建议。

固定、TTL、租约与再平衡器

固定是一种期望状态,而不是豁免。它过去意味着 TTL 为零、从每个驱逐阶梯中排除、以及启动时预热一次——却少了第四样:固定模型从不被重新加载,所以一个崩溃循环超过其每模型 max_restarts 的子进程会停在 state="failed",什么都不持有。现在一个 reconciler 随 15 秒扫描运行,从 60 秒退避到 900 秒上限。唯一能压过固定的东西是人:显式卸载会把 id 标记为抑制,它就保持关闭。

租约是一张属于一个模型的卡。已租用的卡在其他每个模型的 GPU 视野中都是 缺席 的——不是排在最后,也不是一个选项——而所有者被强制安排到恰好那几张卡上,由估算器计算大小,并采用它自己的基准测试在那里测得最快的拆分模式。一张已经租出去的卡会得到 409 conflict,绝不接管。没有模型的租约为服务器之外的东西保留卡:reserve_gpus(devices=[3], reason="ComfyUI render") 正是我的图像生成和我的语言模型不再打架的原因。

再平衡器修正昨天的好决定。13:42 时,一个 27B 被规划到卡 [1, 3] 上——一个共享 GPU1 的跨代际拆分——因为一个 31B 占着 [1, 0, 2],而一个图像生成应用占了 GPU2 的 7.5 GiB。13:53 时,31B 缩减到 [0, 1];从那以后 [2, 3] 一直空闲而且严格更优,27B 却原地不动。所以现在,当一个按其确切当前设置的无驱逐计划能让闲置模型离开所有共享卡时,它就会被移动。估算的每秒 token 数永远不能证明移动的合理性。

它每分钟查看一次,而且只在世界发生变化时:只在安静的机器上、只针对空闲五分钟的模型、每个模型每 30 分钟最多移动一次——因为迁移就是重新加载,而重新加载会丢掉提示缓存,在这台机器的长对话负载下,那个缓存占了一个 98k token 提示词的 93%。驱逐另有三条硬规则——绝不动固定模型、绝不动正在处理请求的模型、绝不动加载中的实例——而已租用的卡本来就不在规划器的视野里。即时加载永远不能设置 force

出故障的时候

显存随占用它的进程一起消亡。在 Windows 上,子进程生活在一个用 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 创建的匿名 job object 中,因此当最后一个句柄关闭时,内核会杀死每个成员——无论父进程如何结束。之所以匿名,是因为一个具名 job 会被任何猜到名字的东西共享。Linux 得到一个 PR_SET_PDEATHSIG 垫片,它覆盖了对网关的 kill -9;这是尽力而为而非内核保证,所以启动扫描和 reclaim_orphan_engines 会捕获漏网之鱼。

这就是要有启动扫描的原因。2026 年 8 月 18 日,GPU0 上约 10 GiB 和 GPU1 上约 15.6 GiB 不可用,表现是“一切都停了”。占用者是三个 llama-server.exe 子进程,它们的父进程是一个编码代理启动的 python -m pytest tests -q,而该运行早已退出。现在每个占用者都被分类——ourschild-of-live-processorphanother-instanceforeign——而且只有 orphan 会被杀死,从构造上就安全,因为没有别的东西会从我们的 engines 树里启动二进制。

点名谁占了什么花了两轮。NVML 在 Windows 下每个进程报告已用内存为零,所以大小来自任务管理器“Dedicated GPU memory”列所读的计数器——但那是跨适配器的每进程总量,所以 device 列是错的:一个在 CUDA0,1,2,3 上报告的进程,实际在 CUDA0 上占了 15.52 GiB,在 3090 上什么都没占。修复方案把适配器 LUID 接到 PCI 总线地址上,而有一个坑值得专门写这一段:NVML 的 busId 中的总线号是十六进制。"00000000:42:00.0" 是总线 66,不是 42——一个自信的错误答案,与正确答案难以区分。

卸载是被验证的,而不是被宣布的。管理器用带创建时间防护的方式复查 pid,以防 pid 复用,把幸存者升级为强制树杀,并记录前后的显存;幸存者会抛出 500,让你手动杀死它。一个报告成功而进程仍然常驻的卸载,是这个系统能撒的最昂贵的谎,因为之后每次加载都会针对并不空闲的显存来规划。

退出码是词汇表。2 是配置错误,并点明键名。3 是端口冲突,托盘绝不会重生到冲突的端口上——它会等待占用者以 StudioForge 服务器的身份应答 /health,然后改为附加到它。75 是“请求了重启”:服务器排空,设置退出码并优雅关闭——从请求到退出实测 1.0 秒——托盘重生且不消耗崩溃尝试。这个区分之所以存在,是因为一次 GUI 重启曾经产生两个争抢 1234 的服务器,而三次计入的崩溃之后,托盘停在 Crashed — see the logs folder 上,旁边就是一个它已无法停止的健康服务器。

当网关是卡死而非死亡时,你去找看门狗:一个在 1235 上始终运行的独立进程,用 argparse 和 stdlib logging 构建,所以即使 config.yaml 就是坏掉的那个东西,它也能启动。它的十个工具是 healthget_configset_configrestart_serverkill_modelnuke_all_modelsreclaim_orphan_enginestail_logsgpu_statusrollback_update。它在本地重新实现孤儿规则,而不是导入拥有该规则的模块——恢复进程绝不能导入它要修复的那套栈。

控制面板

8080 上的面板是同一进程里的第二个 uvicorn 服务器,通过引用共享网关的对象图——它里面完全没有绝对 URL,这正是它能在网状 VPN 上的普通 HTTP 和 HTTPS 前端后面完全相同地工作的原因。

请求进行中的 StudioForge Dashboard:顶部显示 2 loaded、4 GPU、111.7 GiB 中 45.5 空闲;四张 GPU 卡——两块 RTX 5090、两块 RTX 3090——带有已用、空闲和利用率数字;一个 VRAM 占用者列表,含两个标记为 ours 的 llama-server.exe 进程和五个 foreign 进程;一个空的 GPU 租约面板;以及两个已加载模型卡,一个正在预填充,一个已固定并空闲
请求进行中的仪表板:一个拆分到两块 5090 上的 31B 正在预填充一个 2,700 token 的提示词,一个 26B 固定在一块 3090 上,任何卡上占用显存的每个进程都按份额点名。占用者面板之所以存在,是因为曾经有 25 GiB 不翼而飞。
两个已加载模型卡的特写:31B 显示端口 18100,实际 ctx 262144,1 busy / 0 idle,GPU0、GPU1(layer),TTL 15m 00s,活动请求 1,总计 3,最近 58.77 tok/s,构建 b10425,以及一条槽位行显示 Processing prompt 2202/2742(80%)、cache hit 7/2742;26B 显示端口 18101、GPU2、TTL pinned、最近 46.67 tok/s、slot 0 Idle
从上面一帧裁剪出的同两张卡:每个模型实际分到的端口、实际得到的上下文、实际落到的卡、TTL 倒计时——然后是每个引擎槽位一行。“实际”是从子进程读回来的,而不是来自我们请求的东西。
Setup 标签页:一个题为 Ready to serve, but fix before exposing it 的清单:Network exposure,绿色行有数据目录、E:\LLM\Models 下的 50 个 GGUF 文件、34 个已索引模型、驱动 610.88 和 CUDA 13.3 上的 4 张 GPU、冒烟测试过的引擎 b10425,以及 0.0.0.0:1234 上的网关;一行琥珀色的 Network exposure 带一个 Set API key 按钮;然后是 Model library 区域和 Defaults for every load 字段——上下文下限 128000、目标 1048576、思考模型下限 32768
全新安装会在这里打开,而“全新”是测量出来的而非记忆出来的——没有模型目录、没有索引、或没有引擎。每个未满足的行都带着能修复它的按钮;在我的机器上,唯一一行琥珀色是网关在所有接口上监听却没有 API 密钥,这是一个选择,而非疏忽。
Models 标签页库表格:列有 Name、Downloaded、Size、Quant、Arch、Features、Last used、Status 和 Actions;十一行,从 21.29 和 17.95 GiB 的两个 Qwen3.8-27B 构建,到 0.49 GiB 的 Qwen2.5-0.5B,带有聊天、视觉和嵌入的功能图标;gemma-4-26B 行带有一个 pinned 徽章和一个绿色的 loaded 标签;每行末尾有六个小操作按钮
用表格而不是卡片,因为当模型有几十个时,问题都是比较性的——哪个最新到、哪个是大的、哪个能看图。默认按最新下载优先排序,右侧的状态标签是关于哪些模型常驻的实时真相。
gemma-4-31B-it-QAT-Q4_0(16.44 GiB,训练 ctx 262144)的每模型设置对话框:一个 Optimal settings 区块,每组 GPU 一行——两块 RTX 5090 在 262144 ctx、q8_0、3 槽位,约 62 tok/s;两块 RTX 3090 在 1 槽位,约 34 tok/s;四张 GPU 全用,3 槽位,约 40 tok/s,标记为 fits now;一块 RTX 5090 在 131072 ctx,约 66 tok/s——每一行都有 Load here 和 Measure parallel 按钮;一个 Load at exactly 行,含 64K、128K、256K 和 512K;一个绿色 fit 判定,显示 Fits across GPU1, GPU0, GPU3 (split: layer),KV 缓存 q8_0、flash-attn 开,预测显存 32.55 GiB 按卡拆分;以及 Basic 字段——上下文长度、KV 类型 K 和 V、TTL、Pinned 复选框、草稿模型和设备覆盖
这个对话框把整个论点浓缩在一个屏幕里:模型在每组卡空闲时能做什么、每次改动都会重跑的 fit 判定并附每卡预测显存,然后是每个可以推翻它的旋钮(Advanced 和 Expert 层级在裁剪线以下继续)。不可达的上下文大小会被灰显并附上原因,而不是隐藏——“这个模型为什么做不到 512k”正是那个按钮存在的意义。
在 Hugging Face 上搜索 gemma-4 后的 Download 标签页,按 Downloads (30d) 排序:来自 unsloth、lmstudio-community 和 google 的十二个仓库,每行显示发布者、从 1,187,348 降到 458,712 的下载数、点赞数、量化数量和更新时间,带一个模型卡链接和一个 Quants 按钮
Hugging Face 搜索和下载队列合在一个标签页里。排序标签特意写着“Downloads (30d)”——那个数字是过去三十天的计数,不是历史总量——而 Quants 按钮正是适配数学开始的地方。
unsloth/Qwen3.8-27B-GGUF 量化选择器的顶部:一条标题注释说明在读取模型头之前 fit 只是仅含权重的估算;一行几何结构显示 attention hybrid、65 层、每 token KV 65 KB;然后是十六行,从 51.77 GiB 带琥珀色 needs-multiple-GPUs 标签的 BF16,到 19.28 GiB 的 Q5_K_M,每行都有绿色 fits-one-GPU 标签、针对一块 5090、两块 5090 和四张卡全用的每放置上下文行,以及一个 Download 按钮
在下载任何东西之前,每一行都会对照此刻真正空闲的显存进行检查:每个量化一个 fit 标签,其下是每种放置方案会得到的上下文——一块 5090、两块、或四张卡全用。选择器填充两次:先按文件大小对照空闲显存,这样能立刻显示在屏幕上,然后远程读取 GGUF 头,这样徽章就变成规划器自己的答案。它并不总这么干净:在写这篇文章期间做的一次修复之前,选择器还把仓库的 MTP 草稿模块和它的 imatrix 文件当成模型的量化列了出来。对话框在裁剪线以下继续——共 25 行。
Chat 标签页:一个 Use the loaded model 开关,解析为 gemma-4-31B-it-QAT-Q4_0,2 个已加载模型中最近使用的;一个模型选择器,temperature 0.7、top_p 0.95、max_tokens 1024,和一个显示 9.7 tok/s 的速率标签;一个系统提示词框;以及一段对话记录,用户提示要求用三句话解释什么是 KV 缓存以及为什么它的大小取决于上下文窗口,其后是模型完成的回答
一个测试台,而不是聊天应用:它调用与 OpenAI 端点相同的加载路径,并从相同的子端口流式输出。9.7 tok/s 标签是这个标签页自己的算术——从请求发出那一刻起的墙钟时间内的流式块,含预填充——而不是引擎的生成计时,后者是模型卡上的 58.77 tok/s。这里一次成功的聊天,是客户端能工作的证据,而不是一条会漂移的模拟路径。
Server 标签页顶部:一条 Ready to serve 条,列出模型目录、34 个已索引模型、4 张 GPU 和引擎 b10425;Point OpenClaw at this server 面板展开,显示生成的 OPENAI_BASE_URL 和 OPENAI_API_KEY 对、含 openclaw_cli、openclaw_config(在 mcp.servers 下)和通用 mcpServers 块的 MCP 服务器 JSON,以及伴侣配置;然后是一个 Health 块,显示 version 1.26-08-23、uptime、配置路径和两个已加载模型
Server 标签页为你写好客户端配置——正是我粘贴进 OpenClaw 的那个块,两种形态都有——并在其下显示健康行;同一标签页更下方是引擎列表,以及这台机器实际能运行库中多少内容的报告。由于 MCP 条目 shell 出去调用 sfctl,配对 PIN 根本无需出现在任何配置文件中。
Logs 标签页:来源 StudioForge server、级别 INFO、勾选 Follow,跟踪一个 84 行的内存环形缓冲区——来自 studioforge.core.planner 的重复 load planned 行,显示 chosen ctx=262144 kv=q8_0 parallel=1、devices=[1, 0, 3] 和 estimate_mb=33331,然后是 ttl_s=900 时卸载闲置模型,以及带 vram_reclaimed_mb=37086 的 model_unload_verified
Logs 标签页跟踪内存环形缓冲区:这里是规划器在行动之前写下每一个决策——它选择的上下文、缓存类型、槽位数量和设备,以及它预期加载的代价——然后是一次闲置卸载和它验证回来的显存。这里没有任何东西是事后重建的;它就是规划器当时的想法。

同一缓冲区再往下四行,正是加载让我意外时我会读的那几行——它构建的命令行、应答的进程,以及规划器给自己的作业打分(时间戳和 logger 名已裁剪):

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'

这是规划器在一张卡上抓住了自己 3 GB 的偏差、两张卡合计 4 GB,并说明是哪张卡以及为什么——输出层落在了最后一个设备上,正好是它对它计费的地方,而计费仍然偏少。

StudioForge 的 Windows 通知区菜单:一行标题显示 Running — 1 model loaded, 23.4 GiB free,然后是 Open control panel、Open API docs、Open logs folder、Open models folder、Unload all models (free VRAM)、Restart engines、Start server 置灰、Stop server、Restart server、Copy MCP URL、Copy MCP PIN、一个勾选的 Start at login,以及 Quit
日常使用中,整个东西就是一个托盘图标。标题行就是全部状态——加载了什么、还剩多少显存——而 PIN 是托盘唯一会放进剪贴板的凭据。

把它用作 harness 后端

我网络上有六个客户端和这个东西对话,而只有一个知道它是 StudioForge。这正是重点。下面画了五个;OpenClaw Email 是第六个。

任何 OpenAI 客户端

两个环境变量。server.api_key 开箱即为 null,所以任何非空字符串都可以用——大多数 OpenAI 客户端在密钥为空时会拒绝启动。

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 以 LM Studio 的风格列出所有 已下载 的模型,并附加 state,以及——在常驻时——ctx_per_slotmax_parallelparallel_limited_by,因为一旦模型运行超过一个槽位,仅凭上下文长度就会有歧义。id 可以往返:完整的 publisher/repo/file id、裸文件名,或 publisher/name,不区分大小写。DisPatch 只需要一个新的 base URL;OpenClaw Email 是要求更高的客户端,它想要一个聊天模型 一个嵌入模型,并在启动时调用 /v1/models 来询问真正在服务的是什么。

另一台机器上的 OpenClaw

代理机器安装一个小型 wheel:sfctl,它面向 Python 3.11 而非服务器的 3.12,因为运行代理的机器经常落后于这台设备,而且它故意不依赖服务器包——无 CUDA、无规划器、无注册表。

sfctl servers add rig http://my-gpu-rig:1234 --api-key <PIN> --use
openclaw mcp add studioforge --command sfctl --arg mcp

或者手动配置——这个细节会让人浪费一个下午。OpenClaw 的键mcp.servers,嵌套在 mcp:扁平的 mcpServers 映射对 Claude Code、Cline 和 LibreChat 是正确的,却不是 OpenClaw 的 schema 认识的键。推理是一条单独的路径,在 models.providers 下——注意 baseUrl,其中 rl 是小写:

// ~/.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 } ] } } } }

代理得到的是 合并后的 29 个工具列表:网关的 19 个加上看门狗的 10 个,其中三个改名为 recovery_* —— get_configset_config 是因为它们与网关工具冲突,health 是为了对称。restart_server 保留裸名,因为它是死掉的管理工具的错误消息让代理去调用的名字。当主服务器宕机时,桥仍会宣传全部 19 个管理工具并附加一条说明——一个看不到 load_model 的代理并不知道这种能力存在。它运行的循环是:

  1. list_models(limit=N)——目录,最新下载优先。读取推荐行。
  2. load_model(**row["load_args"])——原样传进去;一个已经选了行的代理就不用再选了。
  3. load_recommended(model_id, ctx_size=N) 当你清楚所需上下文时——这是唯一一条宁可拒绝也不缩减的加载路径。
  4. 通过 HTTP 推理,而非 MCP。指定一个未加载的模型会加载它,用的是 规划器默认值 而非你正在读的那一行。
  5. model_options(model_id) 当推荐行不够时:每个上下文档位,附速度。
  6. search_modelsrepo_detailsdownload_model 来获取新东西。
  7. pin_model 对必须始终应答的模型;reserve_gpus / release_gpus 对属于它自己的卡。
  8. server_statusconnection_info——什么常驻、谁占着显存、它应答的每个地址。

以及我最满意的一段话,每个客户端连接时都会收到:

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,以及五个一行命令

dshDeepSeek Harness)通过编辑一个 YAML 文件切换模型,对下一次请求热重载。提供方块是驼峰命名——baseURLapiKeyEnv

# ~/.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 是一个 引用,绝不是一个字面值——而且一个无密钥的本地服务器仍然需要引用某个凭据,因为兼容 OpenAI 的客户端坚持要一个 bearer token。相比之下,Claude Code 根本无法把自己的推理路由到这里:它的网关协议参考列出 Anthropic Messages、Bedrock 和 Vertex,没有一个是 /v1/chat/completions。所以 StudioForge 是它的 工具,而不是它的大脑——claude mcp add studioforge -- sfctl mcp(那个 -- 是必须的)把全部 29 个交给它。

bench-llm 产出了人们反过来引用给我的那台机器的数字——Gemma 4 26B-A4B(QAT,Q4)在 229.0 tok/s、首 token 110 ms,当时是通过 LM Studio 测的——它是一个普通的 OpenAI 客户端,所以只需要一个 base URL;但它在基准测试之间运行 pkill -f llama-server,这会杀掉机器上每个 StudioForge 后端。其余的每个都是一行:Open WebUIOPENAI_API_BASE_URLLibreChat,一个 custom 端点,带 baseURLmodels.fetch: trueaiderOPENAI_API_BASE 然后 --model openai/<id>Continueprovider: openaiapiBase。base-URL 的键在每个里拼法都不同,这是这个生态里浪费晚上时间最可靠的来源。

代理从什么里挑选

目录让模型选择变成一次查找而非猜测:按最新下载优先排序,每个上下文档位一行,每行携带针对实时空闲显存的 fitsdevices、KV 类型、max_parallelrecommended_parallel、一个 confidence、一个 if_gpus_idle 列和 load_args。一行就是一次真实的规划调用,所以它无法承诺一个加载会拒绝的东西——而 if_gpus_idle 是代理放弃与代理调用 unload_model 之间的区别。

repo_details 是值得点名的一个:它通过 HTTP Range 请求远程读取 GGUF 头——2–15 MB,主要是 tokenizer 的长度前缀字符串数组,缓存在磁盘上——而不是下载 20 GB 来搞清楚它是否装得下;而一个对范围请求回复 200 并附上整个正文的 CDN 会被检测到并拒绝。返回的是与真实加载所用同一规划器产出的 context_fit 矩阵:

量化1× RTX 50902× RTX 5090四张卡全用
BF16(51.8 GiB)— 仅权重就装不下32k at q8_0256k
Q8_0(27.9 GiB)256k256k
Q5_K_M(19.3 GiB)128k at q8_0256k256k
IQ2_M(10.5 GiB)256k256k256k

来自仓库的 OpenClaw 指南,在这台机器上为 unsloth/Qwen3.8-27B-GGUF 计算。max_ctx 是在全质量 f16 缓存下的最大窗口;q8_0 数字只出现在它能达到更远的地方。

表格说明了什么:决定窗口的是量化,而不是卡的数量——从 BF16 到 Q5_K_M,把“完全装不下”变成一张卡上的 128k——而且这是规划器自己的答案,所以在只有量化缓存才能到达的档位,矩阵会写“at q8_0”,而不是把它算作胜利。要拿真实数字,玩法是三步:一次放置基准测试(每种 GPU 模式在自己的租约下,吞吐量来自 llama-server 自己的计时),然后对胜者跑 benchmark_parallel,再用 reserve_gpus 把它锁住。绝不要基准测试一个有人正在对话中的模型。

LM Studio 的即插即换

兼容性是设计约束,仓库列出了借用了什么,这样谁都不用猜:端口 1234/v1/models 列出已下载而非已加载;即时加载;空闲 TTL;每请求 ttl;原地使用的 publisher/repo/ 布局,因此没有导入步骤,两个程序共享一个库;/api/v0/models 镜像;以及 lmstudio://open_from_hf 深链。迁移客户端只是主机变更,而不是主机加端口的变更。

LM Studio 0.4.21StudioForge 1.26-08-23
引擎自己的 llama.cpp 构建,Apple 上另有 MLX上游 llama-server,一个固定构建(b10425,CUDA 13.3),激活前先冒烟测试
未路由路径200 带错误正文——它的日志写着 Returning 200 anyway404 带 JSON 信封,每个状态都是 JSON
错误客户端用正则匹配的非结构化散文稳定的 error.code,诊断在 error.studioforge
加载配置两条加载路径中一条会忽略 context_lengthrepetition_penalty 被静默忽略一条加载路径,每个字段都被遵守,生效值回显;接受采样器别名
装不下时“会自动减小 GPU 卸载大小……其余放进系统内存”——一种设计上就有的静默 CPU 溢出507 insufficient_vram,带所需和可用字节、每 GPU 空闲、能装下的最大上下文、有序建议
多 GPU优先级或均分,每 GPU 开关,0.4.15 起支持张量并行一个规划器按放置方案计算上下文、KV 类型和槽位大小,为输出层倾斜拆分比例
空闲 TTL60 分钟;自动驱逐最多保留 1 个即时加载模型发布为 1,800 秒(我的机器上 15 分钟),每 15 秒扫描一次;能装多少装多少,由固定和租约决定谁留下
远程管理/api/v1 load/unload/download;LM Link 处于预览,将收费,通过 LM Studio 的 hub 发现/api REST、sfctl、29 个 MCP 工具;可达范围是你的局域网或你自己的网状 VPN
MCP仅作宿主——它消费 MCP 服务器一个用于自身管理的 MCP 服务器,看门狗上还有一个
源码闭源;个人和内部商业使用免费源码在 zip 里,而且尚未选择许可证

2026-08-23 对照 LM Studio 自己的 changelog、文档和 bug 跟踪器核对,版本 0.4.21(2026 年 8 月 12 日发布)。第 2 行在它的公开 bug 跟踪器里;第 3–4 行是我自己的客户端在 0.3.x API 上不得不绕开的东西。我没有针对 0.4.21 重新测试其中任何一项,所以请把它们理解为“某个时间点有文档记载”,而不是“今天坏了”。

表格说明了什么:它是一个比这个项目永远都更好的桌面应用——精致的 GUI、Apple Silicon 上的 MLX、兼容 Anthropic 的端点、移动伴侣、每两周发布一次的团队——而分歧是理念性的而非功能性的。LM Studio 的默认姿态是尽力而为:想办法让它跑起来。我的姿态是拒绝并解释。在由代理决定加载什么的地方,尽力而为是错误的默认值,因为下游没有任何东西能在不测量的情况下区分快慢。

与其他方案的比较

服务器 · 引擎 · 许可证热切换 / 空闲 TTL多 GPU 放置拒绝 CPU 溢出远程管理 / MCP
StudioForge 1.26-08-23
llama.cpp,一个固定构建 · 许可证:尚未选择
✅ 即时 · 1,800 s TTL,15 s 扫描按模型规划,混合卡,固定 + 租约-ngl 999 + --fit off,507 带数字✅ REST + 29 个 MCP 工具
LM Studio 0.4.21
自己的 llama.cpp + MLX · 闭源
✅ 即时 · 60 分钟,自动驱逐到 1优先级/均分,张量并行❌ 减少卸载,其余进内存REST;仅作 MCP 宿主
Ollama 0.32.15
llama.cpp/GGML;Apple 上 MLX · MIT
keep_alive 5 分钟 · 每 GPU 常驻 3 个跨卡自动分布❌ 会溢出,在 ollama ps 显示 CPU %丰富的 /api/*;无 MCP 服务器
llama-server router(b105xx,2026 年 8 月)
就是 llama.cpp · MIT
✅ 每模型一个子进程 · --sleep-idle-seconds,按计数 LRU手动 -sm / -ts / -dev--fit on 会缩减你的计划/models/load|unload
llama-swap v251
一个启动其他的代理 · MIT
✅ 整个产品 · 每模型/组 ttl❌ 你的 cmd 说啥就是啥不适用——仅是代理/ui + 上游路由
vLLM 0.27.1
自有(PagedAttention)· Apache-2.0
❌ 每进程一个模型张量 / 流水线 / 专家并行部分——无层卸载路径仅 LoRA,“本地开发”
KoboldCpp 1.119
llama.cpp fork + 图像/音频 · AGPL-3.0
--admin + --routermode手动 --tensor_split❌ 会溢出/api/admin/*;仅作 MCP 客户端
TextGen(前 oobabooga)4.9
5 种加载器,含 ExLlamaV3、TRT-LLM · AGPL-3.0
✅ 无需重启切换 · TTL ?手动 --tensor-split❌ 会溢出/v1/internal/model/*;MCP 客户端
TabbyAPI(滚动)
仅 ExLlamaV3——无 GGUF · AGPL-3.0
✅ admin + 内联加载 · TTL ?gpu_split_auto 默认开启✅ 实际上——ExLlama 没有 CPU 路径admin-key /v1/model/load
Jan 0.8.4
llama.cpp router 模式 · Apache-2.0
✅ 通过 router · TTL ?继承自 llama.cpp❌ 会溢出/v1/orchestrations;MCP 客户端
LocalAI 4.9.0
60+ 后端作为容器镜像 · MIT
✅ 按需 · WATCHDOG_IDLE_TIMEOUT“自动 GPU 模型适配”❌ “无需 GPU”REST + UI;仅作 MCP 客户端
GPUStack 2.2.3
vLLM、SGLang、MindIE、VoxBox · Apache-2.0
部分——集群部署自动 Spread/Binpack,多节点?完整集群管理 API

2026-08-23 从一手来源核对。问号表示未知,而不是“否”。GPUStack 的 worker 仅限 Linux;vLLM 无原生 Windows 支持(仅 WSL 或 fork)。

表格说明了什么:

  • 即时加载和空闲 TTL 并不新鲜,我也没有声称它们是我的发明。LM Studio、Ollama、llama-swap 和 LocalAI 都两者都做,而 llama-swap 的每组 swap / exclusive / persistent 标志是一个真正优雅的策略引擎。
  • 有三样东西是罕见的:宁可拒绝运行也不溢出到 CPU(只有 TabbyAPI 接近,而且只是因为 ExLlama 没有 CPU 路径);一个按它实际找到的卡来计算上下文和槽位大小的规划器;以及把管理发布为 MCP 工具——据我所知,这个类别里没有别的这样做。
  • 上游最接近的东西是 llama.cpp 自己的 router,一场公平的较量:多模型带进程隔离、免费、就在你已有的二进制里。它缺的是基于内存而非基于计数的驱逐、固定、租约,以及带数字的拒绝;它确实有闲置休眠(--sleep-idle-seconds),不过一次 /metrics 轮询会唤醒它。

有几个好主意是借来的,仓库说明了哪些。Ollama 的 Modelfile 变成了虚拟模型,让一个基座上的两个人格共享一个 llama-server,它的 keep_alive 变成了每请求 ttl。TextGen 的三层设置界面被直接采用,连原始的“extra flags”都包含在内,保存时用固定引擎自己的 --help 验证标志。KoboldCpp 的单工件理念,正是引擎住在版本化目录里的原因。

安装

Windows,参考平台,四步:安装 GitPython 3.12+uv 和当前的 NVIDIA 驱动;克隆仓库或解压下载文件;双击 launchers\Update StudioForge.bat,它虽然叫这个名字,其实是首次运行的步骤——它构建虚拟环境、安装为你的驱动提供了构建的最新 llama.cpp 版本、冒烟测试并固定它(b10425 是本文测量所用的构建,不是你会得到的那个);然后是 launchers\Start StudioForge.bat,或者如果你希望它出现在通知区,用 launchers\StudioForge Tray.bat,面板会在 http://127.0.0.1:8080 的 Setup 标签页打开。

Linux,四行——外加 cmake 和一个 nvcc 与驱动匹配的 CUDA 工具包,因为上游在任何 tag 上都不发布 Linux CUDA 归档,引擎每个版本从源码构建一次:

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

对于无头机器,deploy/ 里有两个 systemd user 单元——特意用用户单元,因为进程必须以拥有模型库、venv 和 GPU 设备节点的登录用户身份运行。看门狗故意不 BindsTo= 网关,并使用 Restart=always:它存在的意义就是在网关不在时保持在线。然后 sudo loginctl enable-linger "$USER",和 无头 ComfyUI 那篇文章一样的模式。首次运行在 Setup 打开,其中 Detect LM Studio library 会先探测 ~/.lmstudio/settings.json 里的 downloadsFolder

服务默认端口配置键
网关——/v1/api/mcp1234server.port
Web 控制面板8080gui.port
恢复看门狗1235watchdog.port
llama-server 子进程(仅回环)18100–18200gateway.child_port_start / _end

表格说明了什么:端口 1234 在我两台机器上都表示“本地模型服务器”,但它们不是同一个东西——代理机器在 127.0.0.1:1234 上运行自己的,仅回环,而那台设备通过网状 VPN 提供 my-gpu-rig:1234——而且只有三个端口可达,配置验证会在加载时拒绝任何服务端口与子进程范围发生冲突。

数据目录规则SF_DATA_DIR 优先,然后是一个 --config 文件所在的文件夹,然后是检出中的 <repo>/data——完整顺序,以及为什么 data_dir 从不写回 config.yaml,都在 docs/SETUP.md 里。一个实例拥有一个数据目录,由一个独占 OS 锁强制;第二个实例是只读的。

安全,实话实说

这一切底下有一条规则:读取、推理和常驻保持开放;改动这台机器则不。server.api_key 未设置时,对改动机器路由的变更请求只接受来自本机调用者的,或把 MCP PIN 作为 X-MCP-Pin 或 bearer token 发送的——其他任何情况都会得到 403 remote_admin_requires_credential。受门控的集合是配置、重启、引擎、更新、显存回收、下载、租约、删除,以及两个比实例活得更久的每模型写入。它修复的问题是我自己的:局域网上的任何人都能 PATCH /api/config,自己设置 server.api_key 把我锁在门外——而 MCP 的 set_config 工具,同一进程里的同一能力,却要求 PIN。

  • PIN 只保护 MCP。它是你从启动横幅上读到的配对码,作用范围是管理工具。它不是 API 密钥。
  • server.api_key 是真正的凭据,默认是 null设置它之后,它覆盖 /v1/api/mcp 看门狗;PIN 在它旁边的两个 MCP 端点上继续有效。
  • 发布的绑定在三个监听器上都是 0.0.0.0一旦任何监听器在没有密钥的情况下暴露,Setup 标签页的 Network exposure 行就变琥珀色并要求处理——三个都检查,因为 server.host 在回环上而 gui.host0.0.0.0 上曾经显示绿色,而面板却门户大开。
  • 一个跨源浏览器请求不是“本机”,即使在回环上。cors_origins: ["*"] 下,你访问的任何页面都能对 127.0.0.1:1234 预检 PATCH /api/config 并显得像是本地的——所以源比较包含 端口,而 Origin: null 算作外部。CORS 管的是页面能读什么,绝不是服务器信任谁。面板的 websocket 有同一道门的仅主机版本,因为面板是通过它被提供服务的那个端口访问的。
  • 无密钥安装上的远程浏览器能得到读取和推理、在改动时得到 403,并且 PIN 被扣住——否则局域网上的任何东西都能从一个开放端点读到 PIN 并使用它。PIN 恰恰在最要紧的时候变成了摆设。
  • 图像在 SSRF 防护下抓取,该防护阻止回环、链路本地、私有、ULA 和 CGNAT 空间——100.64/10 范围,每个网状 VPN 对等端都住在那里——并且只解析一次,用原始 Host 和 SNI 连接到已审查的地址。
  • 没有任何东西会在未被要求的情况下离开这台机器。唯一的出站调用是 Hugging Face 取模型、GitHub 取固定的 llama-server 构建及其更新检查、可选加入的 StudioForge 发布检查,以及请求指定的图像 URL;在你设置 update.repo 之前,自更新会报告“未配置”而不发起网络调用,并且有一个单元测试锁定这一点。

有两个限制是明说的而不是隐藏的:对等地址检查信任回环上的任何东西,而在反向代理后面那就是代理,所以要把代理放在 server.api_key 之后;而且除了一个共享密钥之外没有别的认证——没有账户,没有速率限制。OpenClaw 那篇文章里的家规依然适用:回环加一个认证代理,或一个网状 VPN,绝不裸连公网。

实话部分

白纸黑字写清楚,让你在安装之前就能做决定:

  • Windows 是参考平台——托盘、job-object 显存防护和每进程 GPU 计数器都是针对它构建的。Linux 受支持,CI 两者都跑,但实战检验较少;源码构建路径的命令构造经过了测试,但在这里从未端到端实际运行过。macOS 不受支持:没有 CUDA。
  • 仅限 NVIDIA。规划器读取 NVML,引擎是 CUDA 构建,量化亲和性用计算能力表示。
  • 显存估算只是估算。权重落在文件大小的 2% 以内,KV 从每层几何结构精确得出,但计算缓冲区是一个校准比例,启动时调一次,钳制在 0.03–0.15 且只保存在内存里——所以一次糟糕的校准会被重启撤销。这台机器上有两份校准历史被污染,现在被完全忽略。
  • 多 GPU 拆分是比例性的,不是测量出来的。它不对互连带宽建模,混合代际会以较慢的卡的速度运行。
  • 并发估算是算术性的。估算器假设槽位半满,并把 MoE 模型一律打个对折,而 --ctx-checkpoints 完全没建模。两个错误都指向更少的槽位,这是安全的方向——但在相信 8 之前先跑并行基准测试。
  • 速度估算使用厂商标称数字——5090 为 1792 GB/s 和 209 fp16 TFLOPS,3090 为 936 和 71,均非本地实测。恰好有两个校准锚点,都在这台机器上,都在一个槽位:一个稠密 31B 实测 39.4 tok/s 对比估算 36.1,一个 122B MoE 实测 37.3 对比 47.4。没有任何东西在四或八个槽位、或 31B 以上的稠密模型上得到验证。
  • 为另一个程序保留 GPU 只约束我的规划器。没有任何东西对那个程序强制这一点,也没有任何东西阻止它先拿走内存。
  • 每个数据目录一个实例,而且锁覆盖的是数据目录而非模型库——两个拥有不同数据目录的实例共享一个库,仍然是两个写入者。
  • 尚未选择许可证。故意没有 LICENSE 文件,pyproject.toml 用一条注释说明了这一点——获取说明了这在实践中意味着什么。
  • 没有第三方安全审计。上面的说法描述的是代码的行为;代码和说法都是我写的。读源码吧——这正是它以下载形式存在而非服务的原因。

在这台机器上,2,503 个单元测试通过、17 个跳过,用时 327 秒;CI 在 Windows 和 Ubuntu 上运行同一套测试,GPU 探针被强制为空后端。第二套测试把真实权重加载到真实 GPU 上,默认被取消选择,并且有一道环境变量门——双保险,源自上面的孤儿事件。没做的、或没开启的:8 槽位微批次 A/B、命名的采样器预设和 CI 中的 mypy;应用自更新已写好,但在你自己设置 update.repo 之前保持关闭。

踩坑清单

这份诚实的清单——真正咬过我的事情,大致按它们耗费时间的多少排序:

  • 必须先退出 1234 上的 LM Studio。两者不能同时占用该端口;预检会点名占用者而不是打印 bind 回溯,server.port 可以移动它。是可以共享的。
  • 有两个 llama.cpp 标志的意思和它们看起来不一样。--ctx-size 是跨所有槽位的预算,不是每槽位;--fit 上游默认为 on,同时还有 --n-gpu-layers auto——两者都在上文规划器部分。
  • 推理模型在 --reasoning-format auto 下会返回空回复。同样的提示词,只改了标志:autocontent 为 0 字符而 reasoning_content 为 316;nonecontent 为 323。reasoning_content 不在 OpenAI schema 里,所以标准客户端读到一个空字符串,就断定模型什么都没说。我让 31B 跑在 deepseek 上,因为我的客户端读那个字段;其他一切用 none
  • llama.cpp 的 vX.Y.Z 预发布版不带 Windows CUDA 资产。一个标记为 v0.1.2 的排在两个普通 bNNNN 构建之上,却完全没有预构建归档,于是 Server 标签页在一个注定会失败的按钮后面提供了更新。现在 tag 被过滤为 ^b\d+$
  • 绝不要在面板开着浏览器标签页时树杀托盘的根进程。在 venv 启动器 stub 下,看门狗是服务器的进程,一次重启曾经以服务器死亡、看门狗死亡、什么都没启动而告终。用托盘菜单、面板,或 sfctl recover --restart
  • 另一个工具里的 pkill -f llama-server 会杀掉你的后端。bench-llm 在两次运行之间正是这么干的——上文 harness 客户端部分。
  • MCP PIN 不是 API 密钥,而一个无密钥服务器在某些客户端里仍然需要一个占位凭据——上文 dsh 部分。
  • OpenClaw 的 MCP 键是 mcp.servers,嵌套在 mcp 下。扁平的 mcpServers 映射不是它的 schema 认识的键——上文 OpenClaw 部分。
  • 有两个测量捷径会骗你。在投机基准测试里重复一个提示词,测的是提示缓存(+751% 对比 +0.4%),而一个 层数 × 头数 × 上下文 的 KV 公式在 Qwen3.5 上会差 4 倍——两者都在上文规划器部分。/props 在起草时还会报告 speculative.types: "none",所以要从一次真实补全读 timings.draft_n
  • 视觉模型得不到提示缓存的好处。llama.cpp 自己会为多模态模型禁用缓存复用,而且每张图像被预算为 1,024 token,除非 mmproj 元数据另有说明——这足以让一个 8k 窗口大部分都是图片。

获取

下面的 zip 就是全部:打标签的源码树、测试、文档、启动器、systemd 单元,外加一个装着两个 wheel 和 sdist 的 dist/ 文件夹,这样你无需构建步骤就能安装。studioforge-2026-08.zip——版本 v1.26-08-23,4,238,728 字节(4.04 MiB),SHA-256:

84f4f828b5c75206236890f28e8651c96146a7bb39c14e21b13a0922a13f7d3f  studioforge-2026-08.zip

一个顶层目录下的 226 个条目,用 git archive 从注释 tag v1.26-08-23、提交 0610446 构建,因此它只能包含被跟踪的文件——没有 config.yaml、没有 data/、没有本地覆盖。源码也在 github.com/LaserLloyd/StudioForge。它需要 Python 3.12+、uv、580 系列及以上的 NVIDIA 驱动,以及一个 GGUF 文件夹。许可证:尚未选择,因此正式而言所有权利均保留——实际上,请把它当作本站其他下载一样对待:个人使用免费,如果商用,来问我。

如果出了什么问题,给我发邮件——地址在 About 页面上——并发送故障的形状而非你的配置:error.code、507 里的数字、logs/models/<model>.log 的最后二十行。绝不要发 PIN 或密钥。如果你在我之前做出了联合装箱规划器、命名的采样器预设,或一条真正能用的 AMD 路径,我宁愿合并你的而不是写我自己的。

这件事把我带到了哪里

我没想到的是,这其中竟有这么多是测量而非代码。规划器是任何人都能写出的算术;让它值得信赖的,是去读 llama.cpp 自己的 KV 几何结构而不是套公式,然后在估算器说 8 的时候测出槽位拐点在 2。那份日志里几乎每个决策,都始于一个与信念相悖的数字。如果你已经在 1234 上运行 LM Studio,整个实验就是一次克隆、一个批处理文件,把 models.dir 指向你已有的文件夹——如果它一个下午都没能证明自己的价值,你原来的配置原封未动。

相关文章:DeepSeek Harness (dsh)(这个服务器首次出现的地方,作为一个未加解释的 provider 块)、bench-llm(这台机器每秒 token 数字的来源,也是会杀掉你后端的工具)、My OpenClaw Setup(那篇点出它所要解决问题的文章),以及 DisPatch(它前面的聊天应用)。

下载

仅限个人使用免费。如果它替你省下了一下午的时间,欢迎点旁边的咖啡按钮支持一下。


← 更多AI 与本地 LLM