RTX 4090部署8路并发NInfer版Qwen3.8-27B
环境与部署目标
| 项目 | 本文环境 |
|---|---|
| 宿主系统 | Ubuntu 22.04 LTS |
| 显卡 | RTX 4090,驱动报告显存 49,140 MiB,约 48GB |
| 系统内存 | 约 32GB |
| NVIDIA 驱动 | 610.57.04 |
| Docker Engine | 29.8.0 |
| 容器基础环境 | Ubuntu 24.04、CUDA 13.1.2 |
| 引擎 | alanthinker/ninfer-4090-yarn,面向 sm_89 |
| 模型 | Qwen3.8-27B,groupwise-int 格式的 .ninfer 权重 |
| 客户端 | Cherry Studio 2.0.14 |
| 工作目录 | /root/llm |
本文使用的是 48GB 显存的 4090。 常见的 24GB 4090 可以参考引擎仓库的单路部署示例,但不能把本文的 8 路并发、状态缓存和显存实测数字直接套用过去。
这里的 256K 指 262,144 token,是每个会话的上下文上限。输入、历史消息、工具定义以及生成内容都要在这个窗口内安排。虽然仓库名称带有 YaRN,本文没有启用位置缩放,保持原生 256K 配置。
先确认 Docker 能访问显卡
本文从宿主机已经安装 NVIDIA 驱动、Docker Engine 和 NVIDIA Container Toolkit 开始。尚未安装时,可按 Docker 的 Ubuntu 安装文档和 NVIDIA Container Toolkit 安装文档准备环境。
先在宿主机检查:
1 | nvidia-smi |
再检查容器是否能访问 GPU:
1 | docker run --rm --gpus all \ |
能看到显卡信息后,再继续构建推理镜像。编译工具链放在 Docker 构建阶段,宿主机不需要另外安装同一版本的 CUDA Toolkit。
准备目录与源码
下面的命令按 root 用户编写。服务器地址以本文的 192.168.6.53 为例,部署到其他机器时,应改为那台机器实际拥有的内网地址。
1 | mkdir -p /root/llm/models /root/llm/.cache/huggingface |
这里固定了本文使用的源码版本。后面的国内源配置和下载脚本需要按本文补充,避免直接使用仓库后续更新后的默认行为。
GitHub 访问需要代理时,可以只给这次命令设置代理,例如:
1 | https_proxy=http://YOUR_PROXY_HOST:10808 \ |
这是上一条 git clone 的替代写法,不需要重复克隆。YOUR_PROXY_HOST 要替换成自己的代理地址;代理协议应与代理软件提供的端口类型一致。
配置镜像构建与国内源
本机采用以下下载源:
| 用途 | 地址 |
|---|---|
| CUDA 基础镜像 | docker.m.daocloud.io/nvidia/cuda |
| 容器 Ubuntu 软件包 | mirrors.tuna.tsinghua.edu.cn |
| pip、uv 软件包 | pypi.tuna.tsinghua.edu.cn |
| 模型文件 | hf-mirror.com |
镜像站的可用性可能变化。这里把地址放在脚本和构建参数中,方便替换,不需要给宿主机设置永久代理。
1. 容器软件源脚本
在 /root/llm/ninfer-4090-yarn/scripts/configure-cn-sources.sh 写入:
1 |
|
2. Dockerfile
将 /root/llm/ninfer-4090-yarn/Dockerfile 替换为下面的内容。这是本文实际构建使用的文件:
1 | ARG CUDA_REGISTRY=docker.m.daocloud.io |
这个 Dockerfile 分为编译和运行两个阶段。最终镜像只保留可执行程序及运行依赖;移除 CUDA forward-compatibility 库的步骤也予以保留,让容器使用宿主机驱动。
3. 构建脚本
在 /root/llm/build-ninfer.sh 写入:
1 |
|
然后构建:
1 | cd /root/llm |
ninfer-4090:sm89 是本地构建生成的镜像标签。首次构建需要下载基础镜像、安装依赖并编译 CUDA 代码,耗时会受到网络、CPU 和磁盘速度影响。
如果默认镜像站不可用,可以改用其他可用的站点:
1 | CUDA_REGISTRY=docker.1ms.run ./build-ninfer.sh |
下载与引擎匹配的模型
本文使用的模型仓库是 neroued/Qwen3.8-27B-NInfer,文件名为 qwen3_8_27b.ninfer,约 16.96 GiB。
这里需要固定模型版本:该引擎不能读取模型仓库新版 main 中增加的 DFlash2 对象。 文件名相同,并不代表内部格式仍与旧引擎兼容。
将 /root/llm/ninfer-4090-yarn/scripts/download-qwen38.sh 写为:
1 |
|
执行下载:
1 | cd /root/llm |
脚本固定模型修订版本,并在下载结束后校验 SHA-256。校验通过才会把 .part 文件改为正式模型文件名。下载中断后重新执行即可续传;如果此前使用 aria2 下载,需要继续使用 aria2 完成。
模型下载显式绕过代理,默认直接连接 HF 镜像,避免大文件占用代理流量。已有模型也会先做校验,通过后跳过下载。
启动推理服务
在 /root/llm/mirrors.env 写入:
1 | HF_ENDPOINT=https://hf-mirror.com |
在 /root/llm/run-ninfer.sh 写入:
1 |
|
后台启动:
1 | cd /root/llm |
然后观察日志:
1 | docker logs -f ninfer-4090 |
看到 engine ready 和 listening on http://0.0.0.0:8080 后,服务才完成初始化。本机一次启动约花费 44 秒,仅供判断启动过程参考。
容器内部的 0.0.0.0:8080 用于接收 Docker 转发;实际发布到宿主机的地址是 192.168.6.53:8080。本文没有启用 API Key 鉴权,服务面向可信局域网使用。
关键参数如何理解
| 参数 | 本文取值 | 作用 |
|---|---|---|
--max-context |
262144 |
每个会话的上下文上限 |
--default-max-tokens |
65536 |
请求未指定输出上限时,思考与回答合计最多生成 64K token |
--kv-capacity |
auto |
根据可用显存建立共享 KV 池,并预留约 1 GiB 安全余量 |
--max-concurrency |
8 |
最多同时执行的请求数 |
--max-pending-requests |
16 |
等待队列容量 |
--pending-timeout-ms |
600000 |
队列等待超时为 10 分钟 |
--device-state-slots |
16 |
活跃请求之外的显卡状态、检查点容量 |
--host-state-slots |
32 |
主机内存中的状态槽容量 |
--prefill-chunk |
1024 |
输入预填充的分块大小 |
--kv-dtype |
rk4v4-e8 |
E8 四位 KV 存储模式 |
--spec mtp --draft-tokens 3 |
MTP3 | 每轮最多提出 3 个草稿 token,验证后接受 |
--lm-head-draft |
开启 | 使用优化后的草稿输出头 |
--vision |
开启 | 加载视觉能力 |
--preserve-thinking |
开启 | 在后续轮次保留已结束回复的思考内容 |
CUDA Graph 默认开启。--preserve-thinking 控制历史思考内容的保留,不等于强制每次请求开启思考。
256K 与 8 路并发的关系
本机启动日志给出的共享 KV 池为 1,418,496 token。活跃请求和保留的前缀缓存共同使用这部分空间。
因此,单个会话的上限是 262,144,但 8 个会话不能同时各占满 262,144:后者合计需要 2,097,152 token,超过当前池容量。把池容量直接除以 8,只能得到约 177,312 token 的算术均值,并不代表引擎把每路固定限制在这个长度。
只有一个会话活跃时,8 路并发上限不会把它的窗口降为八分之一。如果业务要求多个会话同时跑满 256K,应另外按共享缓存容量规划并发,并实测长上下文负载。
验证接口
先检查健康状态:
1 | curl --noproxy '*' http://192.168.6.53:8080/health |
正常返回:
1 | {"status":"ok"} |
再获取模型信息:
1 | curl --noproxy '*' http://192.168.6.53:8080/v1/models |
关键字段应为:
1 | { |
上面只展示 data 数组中模型对象的部分字段。最后用 Chat Completions 发送一个短请求:
1 | curl --noproxy '*' http://192.168.6.53:8080/v1/chat/completions \ |
--noproxy '*' 让这几次局域网检查直接访问服务器。如果能够返回正常的 choices,再接入客户端。
接入 Cherry Studio 2.0.14
在“设置 → 模型服务”中添加 OpenAI 兼容服务,填写:
| 配置项 | 内容 |
|---|---|
| API 地址 | http://192.168.6.53:8080/v1 |
| API Key | 服务未开启鉴权;客户端必填时可填 ninfer |
| 模型 ID | qwen3.8-27b |
| 聊天接口 | OpenAI Chat Completions |
获取并添加模型后,检查该模型使用的聊天接口,选择 Chat Completions。这里的模型 ID 要与 /v1/models 返回的一致。
遇到 additional response fields 报错
如果出现:
1 | the requested additional response fields have no available response representation |
对应的服务端错误是:
1 | { |
这是 Responses 请求携带非空 include 时触发的参数校验。当前引擎的 Responses 接口只接受省略该字段或空数组。
在 Cherry Studio 中将聊天接口改为 Chat Completions 即可避开这处不兼容。本机已复现该错误,并确认 Chat Completions 请求正常返回 HTTP 200。
为什么获取模型后显示 100 万上下文
Cherry Studio 2.0.14 的模型服务页面可能把 Qwen3.8-27B 标为 1,000,000 token,即使本地 /v1/models 已返回 262,144。
核对该版本源码后,可以看到两步处理:
- OpenAI 兼容模型获取逻辑只把模型 ID、名称和归属方映射到客户端模型对象,没有读取本地服务的上下文长度字段。
- 客户端随后用内置模型库补充配置;该版本的 Qwen3.8-27B 条目写的是
contextWindow: 1000000。
所以,这个数字来自客户端的通用模型资料,不能用来判断本地部署实际开放的长度。给 NInfer 多返回几个同义字段,也不会自动解决 2.0.14 的这条读取链路。
在已添加模型的编辑页面,手动覆盖“上下文窗口”为 262144。对于另外两个字段,可以按日常使用需要制定客户端预算,例如:
| 字段 | 一组日常使用的客户端预算 |
|---|---|
| 上下文窗口 | 262144 |
| 最大输入 token | 196608 |
| 最大输出 token | 65536 |
输入预算按 262144 - 65536 计算,为思考与回复预留 64K。这组输入、输出数值是客户端使用策略,不是模型分别拥有的两个独立窗口,也不是服务端声明的硬性输出上限。 本文用 --default-max-tokens 65536 将未指定请求输出长度时的预算设为 64K;需要长回复时,可以调整客户端预算,但输入与输出仍共用 256K,模板和工具内容也要计入。
模型页的“最大输出”是客户端能力配置,单轮实际输出还会受助手设置和请求参数影响。模型页填写 65536,并不等于每次都会生成 65536 token。请同时检查助手或聊天参数中的输出上限,避免实际请求仍发送较低的数值。
如果要实现“获取模型时自动填好这三个字段”,需要在 Cherry Studio 客户端同时补上远端字段映射、远端部署限制的优先级,以及输入/输出限制的保存逻辑。本文保留现有客户端,采用手动覆盖,不修改推理服务。
绘图或代码生成到一半停止
SVG、HTML 等绘图代码本质上仍是文本输出。实际使用中,一次绘图请求启用了 xhigh 思考,日志显示 max output 8,192,最后恰好生成 8192 token,以 output limit 结束。思考过程也占用输出预算,因此正文或代码可能尚未完成。
本文将服务端默认输出预算提高到 65536。这不扩大 256K 总上下文,也不要求降低并发:那次请求触发的是输出长度限制,而非显存不足。
服务端默认值只对没有指定输出上限的请求生效。若 Cherry Studio 的助手或聊天参数明确发送了 max_tokens: 8192,或者 max_completion_tokens: 8192,仍会在 8K 停止。模型编辑页的最大输出与实际聊天请求的输出参数都应检查;绘图、长代码任务可以使用 65536,并按需要降低思考强度。
调整后,可以在日志的请求开始行确认 max output 65,536。这个数字表示本次请求实际采用的预算。64K 能缓解旧的 8K 截断问题,但长任务仍可能用完预算;达到总上下文上限或客户端中断连接也仍会结束请求。
实测速度与预期
本文单路短提示词测试使用固定的三组任务,每次输出上限为 768 token,关闭思考,温度 0.7、top-p 0.8、top-k 20、presence penalty 1.5、seed 42。三个任务都生成到 768 token,因此这里只比较生成速度,不把它们当作完整任务的质量评测。
| 任务 | MTP3 解码速度 | 草稿接受率 |
|---|---|---|
| 中文科普长文 | 94.3 token/s | 39.8% |
| Python 代码 | 142.2 token/s | 77.0% |
| JSONL 数据 | 142.0 token/s | 77.1% |
每项只测了一次。这组结果说明速度会随内容变化:草稿越容易被主模型接受,每轮能够提交的 token 往往越多。日常中文对话只有 100 多 token/s,并不能单凭这个数字判断部署有问题。
也测试过把草稿长度从 3 提高到 5:中文降到 80.6 token/s,代码为 146.0 token/s,JSONL 为 139.8 token/s。增加草稿长度没有带来普遍收益,因此最终恢复 MTP3。
另一次 8 路并发测试中,每路短提示词 57 token、输出 512 token、关闭思考,8 个请求均成功:
| 统计口径 | 结果 |
|---|---|
| 总输出 | 4096 token |
| 客户端总耗时,含输入处理和等待 | 14.701 秒 |
| 按总耗时计算的聚合速度 | 278.62 token/s |
| 服务端一个完整 5 秒窗口的聚合解码速度 | 321.6 token/s |
| 每路请求的解码速度 | 约 38–41 token/s |
| 显存峰值 | 46,911 / 49,140 MiB |
聚合速度描述整张卡同时服务多个请求的吞吐,不能当作一个聊天窗口的输出速度。以上也没有验证 8 路同时满 256K 或图片推理负载。
当前运行的是 groupwise-int + MTP3。网上的 NVFP4Full + DFlash 成绩使用了不同的量化与推测解码路径,比较时还需要对齐显卡、模型、上下文长度和任务内容。
日常维护与开机自启
服务容器采用 --restart always,停止后会保留。容器异常退出时 Docker 会尝试重新启动;手动执行 docker stop 后,在当前 Docker 服务运行期间保持停止,方便把显卡让给其他任务。下次开机或 Docker 服务重启后,模型会再次自动启动,即使此前是手动停止的。
这套规则适合平时常驻、偶尔停下来跑其他 GPU 任务的场景。如果希望手动停止后连下一次开机也不启动,则应使用 unless-stopped;本文选择 always。
确认 Docker 已设置为开机启动:
1 | systemctl is-enabled docker |
如果尚未启用,执行:
1 | systemctl enable --now docker |
Docker 的重启策略处理进程退出,不会仅因 HTTP 接口无响应就自动重启。每次恢复服务仍需从本地磁盘加载模型,等待日志出现 engine ready 后再使用。
查看、停止与启动
查看状态和日志:
1 | docker ps -a --filter name=ninfer-4090 |
临时停止服务,保留容器并释放模型占用的显存:
1 | docker stop ninfer-4090 |
启动已有容器:
1 | docker start ninfer-4090 |
也可以继续使用脚本:
1 | cd /root/llm |
脚本会检查同名容器:不存在时创建,已停止时启动,正在运行时提示后退出。因此可以重复执行,不会创建多个模型实例。
需要直接重启进程时:
1 | docker restart ninfer-4090 |
上述启动命令默认不等待模型加载完成。可以用 docker logs -f ninfer-4090 观察启动过程,再用前面的 /health 检查确认服务就绪。
修改启动参数或更新镜像
已有容器会沿用创建时的参数。编辑 run-ninfer.sh、mirrors.env,或者重新构建同名镜像后,单纯执行 docker start 不会把这些变更应用到旧容器。
需要应用新配置时,先结束正在进行的请求,再重建容器:
1 | cd /root/llm |
这几条命令只替换服务容器,不删除宿主机挂载的模型和 HF 缓存。单纯调整启动参数不需要重新构建镜像;修改引擎代码或 Dockerfile 时,先执行 ./build-ninfer.sh 构建,再重建容器。
可以检查最终状态:
1 | docker inspect ninfer-4090 --format 'restart={{.HostConfig.RestartPolicy.Name}} auto_remove={{.HostConfig.AutoRemove}}' |
应看到 restart=always auto_remove=false。文中前面的 GPU 环境检查仍使用 docker run --rm,因为那个容器只执行一次 nvidia-smi,不承担常驻服务。







