# dsh-llama-model-manager [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/doctorxpriestess/dsh-llama-model-manager) [English](README.md) · [简体中文](README.zh-CN.md) **免责声明:本项目在开发过程中广泛使用了 AI 辅助:大部分实现改动由 AI 辅助工作流生成,随后通过运行测试、调试和反复迭代进行验证。 在将本项目用于生产环境之前,请先仔细审查相关改动。** 一个 [DeepSeek Harness](https://github.com/deepseek-ai)(DSH)插件:在 Windows 上用 **`llama-server.exe` 运行本地 GGUF 模型**,并把它以稳定的 OpenAI 兼容接口暴露给 DSH。 它接管模型的完整生命周期——启动、停止、切换、崩溃恢复——因此 DSH 永远只面对一个固定 URL,而它背后的模型可以随时更换。 ``` DSH ──► http://127.0.0.1:8080/v1 ──► 本插件的 Gateway │ (串行化访问、决定用哪个模型) ▼ llama-server.exe ──► your-model.gguf http://127.0.0.1:18080 ``` --- ## 为什么需要它 直接把 DSH 指向 `llama-server` 也能用,直到你想**换模型**为止。那时你得停服务、改 DSH 的 provider 配置、重启,还得祈祷没有请求正在途中。这个插件把这件事变成一次点击,并处理掉那些 容易出错的部分: | 问题 | 处理方式 | |---|---| | 推理途中切换模型会损坏输出 | 串行化门闸:推理持有共享票,切换需要独占票并等待在飞请求排空 | | 停止 `llama-server` 会泄漏约 12 GB 显存 | 送达**真正的** `Ctrl+C`,由 llama.cpp 自己释放模型(`stopMethod: auto`) | | 每次启停都闪出控制台窗口 | 一律以隐藏控制台启动(`CREATE_NO_WINDOW`) | | DSH 崩溃后留下孤儿进程占着端口和显存 | `runtime.json` 记录 + 残留进程安全网,且只会动**能确证属于自己**的进程 | | 端口已被别的程序占用 | 启动前预检端口,并在报错里指出占用者 | | 不知道当前加载的是哪个模型 | 设置页:实时状态、日志、模型列表、启动/停止/切换/重启 | --- ## 环境要求 - **Windows 10/11**(插件仅支持 Windows;停止路径依赖 Win32 控制台语义) - **Node.js ≥ 20.10**(DSH 自带;推荐 v22+) - 带 Web UI 的 **DSH** - 一份 **`llama-server.exe`**——[llama.cpp](https://github.com/ggml-org/llama.cpp) 官方 release 二进制或 conda 包均可 - 一个或多个 **`.gguf`** 模型文件 无 npm 依赖,无构建步骤。 --- ## 安装 ```bash dsh plugin --profile web add github:DoctorxPriestess/dsh-llama-model-manager ``` 然后**重启 DSH**——profile bundles 只在启动时读取。
手动安装(当 dsh plugin 不可用时) 1. 把本仓库放到任意位置,例如 `D:\dsh\plugins\dsh-llama-model-manager`。 2. 链接进 profile 的 `node_modules`: ```powershell New-Item -ItemType Junction ` -Path "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-llama-model-manager" ` -Target "D:\dsh\plugins\dsh-llama-model-manager" ``` 3. 在 `%USERPROFILE%\.dsh\profiles\web\package.json` 的 `dsh.profile.bundles` 末尾追加 `"dsh-llama-model-manager"`。 4. 重启 DSH。 用 junction(而非复制)意味着改动源码后下次重启即生效。
--- ## 配置 打开 **设置 → 本地模型管理**,填两样东西: 1. **llama-server 路径**——`llama-server.exe` 的完整路径。 该 `.exe` 通常只是一个小的启动器,旁边有一个很大的 `llama-server-impl.dll`;请指向 `.exe`。 2. **至少一个模型**——模型 ID、显示名称、`.gguf` 的完整路径。 然后把 DSH 的 provider 指向本 Gateway。在 `%USERPROFILE%\.dsh\settings.yaml` 中: ```yaml llm-pi-ai: # 插件命名空间:providers 在它下面,不是顶层 providers: llamacpp: displayName: llama.cpp 本地 api: openai-completions apiKeyEnv: LLAMACPP_API_KEY baseURL: http://127.0.0.1:8080/v1 models: # 这是 {id, name, ...} 的「列表」,不是 map;也没有模型级 displayName - id: qwen38-iq3s # 必须与插件里配置的模型 ID 一致 name: Qwen3.8-27B IQ3_S contextWindow: 131072 input: - text # 带 mmproj 启动的模型再加一行 image ``` > 设置页的**集成指引 → 复制 YAML** 生成的就是这段,并且已按你配置的模型填好, > 比手抄更靠谱。 > 插件**从不**读写 DSH 的 `settings.yaml`。那个文件完全归你。 端口:Gateway 监听 **8080**(DSH 连接它);`llama-server` 监听 **18080**(内部端口,不暴露给 DSH)。冲突时可在设置页修改。 ### 每个模型的启动参数 `arguments` 会**原样**传给 `llama-server`,追加在自动填充的 `-m / --host / --port` 之后: ``` --ctx-size 131072 -fa on -ctk q4_0 -ctv q4_0 -b 256 -ub 256 -np 1 --jinja ``` 留空则只自动补 `-m`、`--host`、`--port`。 > **`-fa` 的值是*可选*的。** 请写 `-fa on`,不要裸写 `-fa`——裸写会吞掉下一个 flag > (`-fa --no-webui` → `unknown value for --flash-attn: '--no-webui'`)。 若把 `maxConcurrentRequests` 设为大于 1,请给 llama-server 配上对应的 `-np`。 --- ## 停止路径(为什么用 `Ctrl+C` 而不是 `taskkill`) 这是花最多实测才定下来的部分,值得解释。 在 Windows 上,Node 的 `child.kill('SIGINT')` **不会送达任何信号**——libuv 把它编译成 `TerminateProcess()`。它返回 `true`,而目标进程没有任何清理机会。我用一个会在进入 `SIGINT` 处理器时写日志的子进程验证过:处理器从未运行。 `taskkill /PID /T`(不带 `/F`)对控制台进程同样无效——它回答 *"This process can only be terminated forcefully"*,因为 `llama-server` 没有消息循环去接收 `WM_CLOSE`。 真正有效的是**真实控制台控制事件**: ``` AttachConsole(pid) → GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0) ``` 这要求目标进程拥有控制台,而这正是 `windowsHide: true` 提供的(libuv 会传 `CREATE_NO_WINDOW` → 一个**隐藏**控制台)。于是插件拿到了真正的 `Ctrl+C`, **且全程不显示任何窗口**——`llama-server` 收到后会执行自己的清理并调用 `llama_model_free`。 用 27B 模型实测(`npm run e2e:ctrlc`): ``` health ready : 40.1 s VRAM loaded : 15267 MiB (+12077) stop result : {"forced":false,"method":"ctrl-c","code":0} VRAM after : 3187 MiB (-12080) ``` 退出码干净地为 `0`,12 GB 显存归还系统,全程无窗口。 由于 Node 没有对应 API,由一个极小的 PowerShell 辅助脚本(`src/core/send-ctrlc.ps1`)完成 P/Invoke。它以隐藏方式启动,并且在该控制台**并非独占**时会**拒绝广播**——否则 `GenerateConsoleCtrlEvent(…, 0)` 会把 `Ctrl+C` 送给附着在该控制台上的**每一个**进程, 包括 DSH 自己。 升级顺序由 `stopMethod` 控制: | `stopMethod` | 行为 | |---|---| | `auto`(默认) | `Ctrl+C` → 等待 `shutdownTimeoutMs` → `taskkill /T /F` | | `ctrl-c` | 只用 `Ctrl+C`,绝不强制结束 | | `taskkill` | 跳过 `Ctrl+C`,直接强制结束 | 最终兜底使用 **child 句柄**而非 pid,因此 PID 被复用也不会导致插件误杀无关进程。 --- ## 设置项参考 | 设置 | 默认值 | 说明 | |---|---|---| | `llamaServerPath` | *(空)* | `llama-server.exe` 完整路径。必填。 | | `gatewayHost` / `gatewayPort` | `127.0.0.1` / `8080` | DSH 连接的目标。 | | `internalPort` | `18080` | `llama-server` 绑定的端口。 | | `startupTimeoutMs` | `180000` | 27B 模型加载约需 50 秒。 | | `shutdownTimeoutMs` | `30000` | 强制结束前的宽限期。释放 12 GB 约需 5 秒。 | | `stopMethod` | `auto` | 见上文。 | | `healthCheckIntervalMs` | `500` | 加载期间的健康检查间隔。 | | `forceShutdownAfterTimeoutMs` | `300000` | 切换时等待在飞推理的上限。`0` = 一直等。 | | `maxQueuedRequests` | `10` | 队列上限,溢出返回 HTTP 429。 | | `maxConcurrentRequests` | `1` | `-np 1` 的模型请保持 1。 | | `maxRetries` | `1` | 启动失败后的额外重试次数。 | | `startupModel` | `null` | DSH 启动时预加载的模型 ID。 | | `autoRecoverAfterCrash` | `false` | 意外退出后自动重载一次(不会无限循环)。 | | `cleanupStaleProcessOnStart` | `false` | 清理上次运行遗留的进程(见下文)。 | | `requireManagerToken` | `true` | 管理 API 的写操作要求 `x-llama-manager: 1` 请求头。 | 配置存放在独立文件中——`%USERPROFILE%\.dsh\llama-model-manager\config.json`,写入是原子的, 并保留上一版为 `.bak`。 --- ## 管理 API 与 DSH UI 同源:`http://127.0.0.1:3080/llama-model-manager/api/...` 写操作需要请求头 `x-llama-manager: 1`(除非关闭 `requireManagerToken`)。 带非回环 `Host` 头的请求会被拒绝。 | 方法 | 路径 | 用途 | |---|---|---| | `GET` | `/manager/status` | 状态、当前模型、统计、最近日志 | | `GET` | `/manager/health` | 轻量存活探测 | | `GET` | `/manager/logs?limit=N` | 最近日志 | | `GET` | `/manager/config` | 当前配置 + 配置路径 + 警告 | | `PUT` | `/manager/config` | 替换配置(会做规范化和校验) | | `POST` | `/manager/config/validate` | 只校验不应用 | | `GET` | `/manager/models` | 列出已配置模型 | | `POST` | `/manager/models` | 新增或更新模型 | | `DELETE` | `/manager/models/:id` | 删除模型 | | `POST` | `/manager/load` | 加载(或切换到)某模型 | | `POST` | `/manager/unload` | 停止当前模型 | | `POST` | `/manager/restart` | 重启当前(或指定)模型 | | `POST` | `/manager/preview` | 预览将要使用的确切 argv | | `GET`/`DELETE` | `/manager/last-error` | 读取或清除最后一次错误 | | `GET`/`POST` | `/manager/stale-process` | 查看或清理残留进程 | | `POST` | `/manager/scan` | 扫描目录中的 `.gguf` | | `GET` | `/manager/runtime` | 运行时元信息 | Gateway 同时承载 OpenAI 兼容流量(`/v1/chat/completions`、`/v1/models`、`/v1/embeddings` 等), 转发到已加载的模型。注意 `llama-server` 自身的 `/v1/models` **不是** OpenAI 格式,因此 Gateway 会**自行合成**规范的 OpenAI 响应,而不是直接透传。 --- ## 残留进程 在 Windows 上,父进程死亡**不会**带走子进程。若 DSH 在模型已加载时被杀,`llama-server` 会 存活下来占着端口和显存,导致下次启动失败。 插件在模型就绪时写入 `runtime.json`(pid、镜像路径、模型、端口),干净停止时删除。启动时 检查该记录,但**只有同时满足**以下条件才会结束进程: 1. 记录的 pid 仍存活; 2. 其镜像名与记录的可执行文件一致; 3. 它在记录的端口上响应,且 `/v1/models` 报告了记录的模型路径。 被复用的 PID 不可能同时满足这三条。若无法归因,插件会明确告知并**对它不做任何操作**。 --- ## 独立运行 ```bash npm start # 不依赖 DSH,单独跑 Gateway + 管理器 ``` 便于用其他客户端直接访问 Gateway。可按需传 `--port`、`--host`、`--config `, 详见 `src/standalone.js`。 --- ## 开发 ```bash npm test # 66 个单元/集成测试,约 4 秒,不需要模型 npm run preflight # 校验注册到 DSH profile 是否正确 npm run e2e:ctrlc # 真实模型:优雅停止 + 显存释放 npm run e2e:orphan # 真实模型:残留进程安全门 + 清理 ``` 两个 e2e 脚本需要真实模型。路径从 `LLAMA_SERVER_PATH` / `LLAMA_MODEL` 解析, 未设置时回退到插件自己的配置——没有任何硬编码: ```powershell $env:LLAMA_SERVER_PATH = 'C:\path\to\llama-server.exe' $env:LLAMA_MODEL = 'C:\models\your-model.gguf' npm run e2e:ctrlc ``` `docs/ROBUSTNESS.md` 记录了开发过程中发现的**具体缺陷**及其触发条件——包括那些只在 非英文 Windows 上才会出现的问题。 ### 目录结构 ``` src/ index.js DSH 宿主插件(路由 + 生命周期) standalone.js 脱离 DSH 运行 core/ manager.js 模型生命周期、崩溃恢复、残留进程归因 process.js 启动/停止、Ctrl+C 升级链 gate.js 串行化门闸(共享票 vs 独占票) gateway.js OpenAI 兼容反向代理 api.js 管理 API args.js 命令行 tokenizer / argv 构建 config.js 配置 schema、校验、原子保存 health.js 就绪探测、端口检查 send-ctrlc.ps1 Win32 控制台控制事件辅助脚本 lib/client.js 设置页(手写模块,无构建步骤) ``` --- ## 常见问题 **"Gateway 未启动" / 端口被占用**——报错会指出占用进程。改 `gatewayPort`,或停掉占用者。 **模型一直不就绪**——报错会附带 `llama-server` stderr 的最后约 40 行。常见原因:`--ctx-size` 超出可用显存,或 `-ngl` 设得过高。 **错误信息里出现 `0xC0000409`**——这是 `llama-server` 自己调用了 `abort()`(通常是 `GGML_ASSERT` 或 CUDA 失败),而不是被插件停止。插件会把它报告为**崩溃**而非优雅停止,并锁定 该模型以防止无限重启。 **停止要花约 5 秒**——正常。这是 llama.cpp 在释放约 12 GB 的模型。 **设置页没有出现**——插件加载失败。检查 DSH 启动日志,并运行 `npm run preflight`。 --- ## 许可 MIT——见 [LICENSE](LICENSE)。