四卡 3090 本地模型部署复盘:Ollama 跑通 35B,以及 GPU0 掉卡问题
这次做的是一轮真实的本地模型部署摸底。
目标不是搭一个临时 Demo,而是把一台四卡 3090 GPU 机器接进自己的日常 AI 使用环境:本机跑 Open WebUI,负责账号、会话和前端配置;GPU 机器只负责模型推理。这样以后换模型、换推理框架、重启服务,都尽量不影响本机的使用入口。
最后结论比较清楚:qwen3.5:35b 的 GGUF Q4_K_M 量化版已经通过 Ollama 跑通,本机 Open WebUI 可以接入,热加载后的聊天速度也能用;但 GPU0 存在明显稳定性问题,重启后能短暂恢复,跑过负载后又会掉到 NVML 异常状态。
状态快照
当前能用的部分:
- 本机 Open WebUI 已部署,作为日常聊天入口。
- GPU 机器上的 Ollama 后端可用。
qwen3.5:35b可以聊天。- 模型当前实际跑在 GPU1/GPU2 上,GPU3 基本空闲。
- 热加载后,短对话生成速度大约在
80 tokens/s左右。
当前不稳定的部分:
- GPU0 会复发 NVML unknown error。
- 重启可以让四张卡短暂恢复,但负载后 GPU0 又掉。
- 当前不是完整四卡健康状态。
qwen3.5:122b文件存在,但没有真正部署成可用服务。- 原生 BF16/FP8 模型和 vLLM 大模型服务还没有部署。
今天真正跑通的是:
本机 Open WebUI -> GPU 机器 Ollama -> qwen3.5:35b GGUF Q4_K_M
不是:
vLLM -> Hugging Face 原生 BF16/FP8 模型
这个边界很重要。今天完成的是 Ollama + GGUF 量化模型的本地聊天链路验证,不是最终的生产级多卡推理方案。
机器环境
这台机器硬件基础不错:
| 项目 | 配置 |
|---|---|
| GPU | 4 x NVIDIA GeForce RTX 3090 |
| 单卡显存 | 24GB |
| CPU | 双路 Intel Xeon E5-2673 v4,80 线程 |
| 内存 | 约 110GiB |
| 系统 | Ubuntu 24.04.3 LTS |
| NVIDIA Driver | 575.64.03 |
| CUDA runtime | 12.9 |
| 容器环境 | Docker + NVIDIA Container Runtime |
四张 3090 合计 96GB 显存,但每张卡仍然只有 24GB。部署大模型时,不能只看总显存,还要看模型能不能合理切分、KV cache 怎么分配、推理框架是否支持对应的并行方式。
先盘点机器,而不是直接下载模型
这台机器不是空机器,上面已经有一些历史服务和模型:
- Docker 里有 Ollama。
- Docker 里有一个旧 Open WebUI。
- 有一个旧的 vLLM embedding 服务。
- Ollama 里已有两个 Qwen3.5 模型。
Ollama 中已有模型:
| 模型 | 格式 | 量化 | 文件大小 | 下载完成时间 | 判断 |
|---|---|---|---|---|---|
qwen3.5:35b |
GGUF | Q4_K_M | 约 23.9GB | 2026-04-03 01:30 | 可以作为本地聊天模型验证 |
qwen3.5:122b |
GGUF | Q4_K_M | 约 81.4GB | 2026-04-03 09:52 | 文件存在,但当前不可用 |
这里有个容易误会的点:模型文件在磁盘上,不代表模型已经部署成功。尤其是 122B 这种规模,文件下载完只能说明“有权重”,能不能用还要看显存、框架、切分策略、加载日志和实际响应速度。
今天验证下来,35B 是现实可用的,122B 不是。
为什么先用 Ollama
如果目标是快速确认“本机 Open WebUI 能不能调用 GPU 机器上的本地模型”,Ollama 是最快路径。
Ollama 的优势是:
- 容器启动简单。
- GGUF 量化模型管理方便。
- Open WebUI 接入成本低。
- 对单人本地聊天足够直接。
但 Ollama 不是最终答案。它更适合本地体验和轻量部署;如果后续要做原生 Hugging Face 权重、多并发、OpenAI-compatible API、长上下文和服务化推理,vLLM 会更合适。
所以今天的策略是先跑通链路,再判断下一步是否切 vLLM。
本机 Open WebUI 的部署方式
Open WebUI 没有继续使用 GPU 机器上的旧实例,而是在本机重新部署了一个 slim 版本。
这么做有两个原因:
第一,Open WebUI 是日常入口,账号、会话记录、模型配置放在本机更好管理。
第二,GPU 机器后续会频繁换模型、停服务、测 vLLM,不应该让前端体验跟着不稳定。
部署后的结构是:
本机 Open WebUI
-> GPU 机器 Ollama API
-> qwen3.5:35b
普通聊天不需要 embedding,所以这次没有让 Open WebUI 下载本地 sentence-transformers 模型。RAG 和 embedding 后面可以单独规划,不和聊天模型混在一起。
35B 跑通,但慢点在加载
qwen3.5:35b 是一个 GGUF Q4_K_M 量化模型。Ollama 里看到的关键信息包括:
architecture: qwen35moe
parameters: 36.0B
context length: 262144
embedding length: 2048
quantization: Q4_K_M
capabilities: completion / vision / tools / thinking
第一次在 Open WebUI 里聊天时,体感很慢。后面确认主要不是网络问题,而是模型加载和上下文设置。
这个模型标称最大上下文是 262144。如果 Open WebUI 直接把这个上下文带进去,KV cache 会非常大,24GB 单卡很容易被拖慢。后来把参数收紧到更适合日常聊天的配置:
{
"num_ctx": 4096,
"num_predict": 1024,
"temperature": 0.6,
"top_p": 0.9,
"think": false,
"keep_alive": "30m"
}
这里最关键的是:
num_ctx=4096:避免一上来就开 256K 上下文。think=false:避免模型输出进入 thinking 字段,导致 Open WebUI 正文看起来为空。
调完之后,35B Q4 作为本地聊天模型是能用的。
性能实测
后面重启了 GPU 机器,补了一轮真实性能测试。
重启后四张 3090 一度全部恢复,nvidia-smi 能看到 GPU0-GPU3,CUDA Driver API 也能正常返回 4 张设备。旧的 vLLM embedding 服务在重启后又自动起来了一次,所以先把它停掉,避免干扰测试。
但测试过程中 GPU0 又复发 NVML unknown error。最终正式 Ollama 后端仍能用,不过处于降级状态:
CUDA 还能看到 3 张卡
qwen3.5:35b 实际加载在物理 GPU1/GPU2
GPU3 基本空闲
GPU0 异常
所以这组数据不是“四卡满血性能”,而是:
Ollama 0.19.0 + qwen3.5:35b Q4_K_M + num_ctx=4096
降级三卡可见环境,实际两张 3090 承载模型
冷启动短请求:
| 指标 | 数值 |
|---|---|
| wall time | 11.27s |
| load duration | 10.51s |
| prompt tokens | 21 |
| output tokens | 32 |
| eval duration | 0.39s |
| generation speed | 约 82 tokens/s |
热加载后,连续 3 条 32-token 短请求:
| run | wall_s | load_s | prompt_tokens | output_tokens | eval_s | tokens/s |
|---|---|---|---|---|---|---|
| 1 | 1.12 | 0.46 | 26 | 32 | 0.39 | 81.27 |
| 2 | 2.31 | 0.40 | 23 | 32 | 0.38 | 84.67 |
| 3 | 0.91 | 0.39 | 28 | 32 | 0.38 | 84.24 |
128-token 热加载请求:
| 指标 | 数值 |
|---|---|
| wall time | 2.87s |
| load duration | 0.44s |
| prompt tokens | 43 |
| output tokens | 128 |
| eval duration | 1.54s |
| generation speed | 83.02 tokens/s |
脚本自检的流式 chat 测试:
| 指标 | 数值 |
|---|---|
| 平均 wall time | 0.74s |
| 平均首 token 延迟 | 0.52s |
| 平均生成速度 | 78.36 tokens/s |
这组数据说明:35B Q4 热加载后的生成速度并不差,日常短对话可用。真正影响体感的是冷启动和 runner 加载,而不是生成阶段本身。
还没有测完整的部分:
num_ctx=8192/16384下的显存和速度变化。- 2 路、4 路并发下的延迟和吞吐。
- GPU0 稳定恢复后的完整四卡状态。
- vLLM 与 Ollama 的同 prompt 对比。
后续复测可以直接用这个脚本:
python3 ollama_perf_bench.py \
--url http://<GPU机器>:11434 \
--model qwen3.5:35b \
--runs 1 \
--num-ctx 4096 \
--num-predict 16 \
--no-warmup
这个脚本走 /api/chat,会输出 wall time、首 token 延迟、Ollama 侧耗时、输出 token 数和 tokens/s。
122B 为什么不算部署成功
qwen3.5:122b 文件确实已经下载完,大小约 81.4GB。但加载时观察到的状态是:
size_vram: 0
offloaded 0/49 layers to GPU
model weights device=CPU size=75.8 GiB
这说明它没有正常进入 GPU,而是退到了 CPU/系统内存。对于 122B 这种规模,这不算部署成功,只能算“文件在磁盘上”。
所以今天对 122B 的结论是:
- 下载完成是真的。
- 当前不可用也是真的。
- 不建议继续用 Ollama 硬试。
如果后续真要跑这个级别的模型,需要先解决 GPU 稳定性,再重新评估推理框架、量化格式、并行方式和上下文长度。
最大问题:GPU0 不稳定
今天最大的硬阻塞不是 35B 的速度,而是 GPU0。
典型报错是:
Unable to determine the device handle for GPU0: 0000:06:00.0: Unknown Error
这个问题比较麻烦:PCIe 层面还能看到卡,但 NVIDIA/NVML 对 GPU0 的访问异常。重启后 GPU0 能短暂恢复,说明不是简单的“永久消失”;但 35B 负载后它又复发,说明稳定性有问题。
它带来的影响:
nvidia-smi不能稳定显示四张卡。- Docker NVIDIA runtime 可能被 NVML 错误影响。
- vLLM 多卡部署会非常难排错。
- 当前 Ollama 可以在剩余卡上跑,但不是完整健康状态。
下一步应该先排查 GPU0,重点看:
- 供电是否稳定。
- PCIe 插槽和转接线是否接触良好。
- 显卡温度和功耗是否异常。
- 内核日志里是否有 NVIDIA Xid 错误。
- 是否需要换槽位、换线、单卡压力测试。
GPU0 不解决,不建议继续上 vLLM 多卡大模型。
embedding 服务的处理
机器上原来还有一个 embedding 服务,用 vLLM 部署 google/embeddinggemma-300m,并且开了多个实例。
今天先把真正占 GPU 的 vLLM embedding 实例停掉。停完以后,vLLM serve 和 EngineCore 进程消失,GPU 显存释放。重启后它又自动起来过一次,后面也再次停掉。
这也说明 embedding 和聊天模型最好分开管理。
embedding 主要服务 RAG 和检索,关注批处理、吞吐和向量维度;聊天模型关注生成质量、上下文、显存和延迟。两者混在同一个部署视角里,很容易判断错资源占用。
Ollama 和 vLLM 怎么取舍
这轮摸底之后,Ollama 和 vLLM 的定位更清楚了。
Ollama 适合:
- 快速体验本地模型。
- 跑 GGUF 量化版本。
- 单人聊天或低并发使用。
- 快速接入 Open WebUI。
vLLM 更适合:
- 正式推理服务。
- OpenAI-compatible API。
- 更好的并发和吞吐。
- Hugging Face safetensors 权重。
- BF16、FP8、AWQ、GPTQ 等服务化部署路线。
如果后续要在这台四卡 3090 上认真跑 coder 模型,可以评估:
Qwen3-Coder-30B-A3B-Instruct-FP8
但前提是先把 GPU0 修稳定。四卡状态不稳定时直接上 vLLM,只会把模型问题、框架问题、驱动问题和硬件问题混在一起。
当前状态
已经完成:
- 本机 Open WebUI 部署完成,并作为使用入口。
- GPU 机器 Ollama 后端可用。
qwen3.5:35b Q4_K_M已跑通。- 模型参数已收紧到
num_ctx=4096,并关闭 thinking。 - 补了一轮真实性能测试。
- 停掉了旧的 vLLM embedding GPU 实例。
- 确认
qwen3.5:122b文件存在但当前不可用。 - 确认 GPU0 异常会复发。
仍未完成:
- 原生 BF16/FP8 模型没有下载。
- vLLM 大模型服务没有部署。
- 没有完整并发性能测试。
- 没有完成 vLLM 与 Ollama 的同 prompt 对比。
- GPU0 没有稳定恢复。
一句话总结:
qwen3.5:35b Q4_K_M 作为 Ollama 本地聊天模型已经可用,热态速度约 80 tokens/s;但 GPU0 存在复发性 NVML 异常,当前不是健康四卡环境。下一步不应该继续堆模型,而应该先排查 GPU0,再考虑 vLLM 和原生 FP8/BF16 模型。
附录:关键输出节选
Ollama 模型文件时间:
2026-04-03 01:28 23869179840 .../models/blobs/sha256-900dde62fb7ebe8a5a25e35d5b7633f403f226a310965fed51d50f5238ba145a
2026-04-03 01:30 710 .../models/manifests/registry.ollama.ai/library/qwen3.5/35b
2026-04-03 09:40 81370024480 .../models/blobs/sha256-93c83617a40560a61cda911ee327efdb5b5fbd39caa8b777a4ec565c0af1af3d
2026-04-03 09:52 710 .../models/manifests/registry.ollama.ai/library/qwen3.5/122b
重启前,三卡 CUDA 探测失败:
$ CUDA_VISIBLE_DEVICES=1,2,3 python3 cuda_probe.py
cuInit 999
cuDeviceGetCount 3 0
重启后,CUDA 一度恢复四张卡:
$ CUDA_VISIBLE_DEVICES=0,1,2,3 python3 cuda_probe.py
cuInit 0
cuDeviceGetCount 0 4
35B 负载后,GPU0 再次出现 NVML 异常,但正式 Ollama 后端仍能在剩余 CUDA 设备上运行:
$ nvidia-smi
Unable to determine the device handle for GPU0: 0000:06:00.0: Unknown Error
...
| 1 NVIDIA GeForce RTX 3090 | 13276MiB / 24576MiB |
| 2 NVIDIA GeForce RTX 3090 | 12854MiB / 24576MiB |
| 3 NVIDIA GeForce RTX 3090 | 4MiB / 24576MiB |
...
| 1 N/A N/A 18853 C /usr/bin/ollama 13266MiB |
| 2 N/A N/A 18853 C /usr/bin/ollama 12844MiB |
Ollama 日志显示模型完整 offload 到 GPU:
ggml_cuda_init: found 3 CUDA devices
offloaded 41/41 layers to GPU
model weights device=CUDA0 size="11.1 GiB"
model weights device=CUDA1 size="10.9 GiB"
model weights device=CPU size="277.3 MiB"
llama runner started in 8.66 seconds
附录:Ollama benchmark 脚本
这次性能测试用的是一个很小的 Python 脚本,只依赖标准库,不需要额外安装包。它调用 Ollama 的 /api/chat 流式接口,记录:
wall_s:本地看到的完整请求耗时。ttft_s:首 token 延迟。ollama_total_s:Ollama 返回的服务端总耗时。load_s:模型加载耗时。output_tokens:输出 token 数。tokens/s:按 Ollamaeval_duration计算的生成速度。
运行示例:
python3 ollama_perf_bench.py \
--url http://<GPU机器>:11434 \
--model qwen3.5:35b \
--runs 1 \
--num-ctx 4096 \
--num-predict 16 \
--no-warmup
完整脚本如下:
#!/usr/bin/env python3
import argparse
import json
import statistics
import time
import urllib.request
DEFAULT_PROMPTS = [
"用三句话解释一下本地大模型部署里 Ollama 和 vLLM 的区别。",
"写一个 Python 函数,输入整数列表,返回去重后按升序排列的结果。",
"假设一台机器有四张 24GB 显存的 3090,部署 30B 级模型时应该注意什么?",
]
def ns_to_s(value):
return value / 1_000_000_000 if value else 0.0
def post_json_stream(url, payload, timeout):
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
for raw_line in resp:
line = raw_line.decode("utf-8").strip()
if not line:
continue
yield json.loads(line)
def run_once(base_url, model, prompt, options, timeout):
endpoint = base_url.rstrip("/") + "/api/chat"
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": True,
"think": False,
"options": options,
}
started = time.perf_counter()
first_token_at = None
final = None
output_parts = []
for event in post_json_stream(endpoint, payload, timeout):
chunk = (event.get("message") or {}).get("content") or event.get("response")
if chunk and first_token_at is None:
first_token_at = time.perf_counter()
if chunk:
output_parts.append(chunk)
if event.get("done"):
final = event
ended = time.perf_counter()
final = final or {}
eval_count = final.get("eval_count") or 0
eval_duration_s = ns_to_s(final.get("eval_duration") or 0)
return {
"wall_s": ended - started,
"ttft_s": (first_token_at - started) if first_token_at else None,
"ollama_total_s": ns_to_s(final.get("total_duration") or 0),
"load_s": ns_to_s(final.get("load_duration") or 0),
"prompt_eval_count": final.get("prompt_eval_count") or 0,
"prompt_eval_s": ns_to_s(final.get("prompt_eval_duration") or 0),
"eval_count": eval_count,
"eval_s": eval_duration_s,
"tokens_per_s": (eval_count / eval_duration_s) if eval_count and eval_duration_s else 0.0,
"output_chars": len("".join(output_parts)),
}
def fmt(value):
if value is None:
return "-"
if isinstance(value, float):
return f"{value:.2f}"
return str(value)
def print_markdown(rows):
print("| run | prompt | wall_s | ttft_s | ollama_total_s | load_s | output_tokens | tokens/s | output_chars |")
print("| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |")
for row in rows:
print(
"| {run} | {prompt} | {wall_s} | {ttft_s} | {ollama_total_s} | {load_s} | {eval_count} | {tokens_per_s} | {output_chars} |".format(
run=row["run"],
prompt=row["prompt_name"],
wall_s=fmt(row["wall_s"]),
ttft_s=fmt(row["ttft_s"]),
ollama_total_s=fmt(row["ollama_total_s"]),
load_s=fmt(row["load_s"]),
eval_count=fmt(row["eval_count"]),
tokens_per_s=fmt(row["tokens_per_s"]),
output_chars=fmt(row["output_chars"]),
)
)
if not rows:
return
warm_rows = [row for row in rows if row["run"] != "warmup"]
sample = warm_rows or rows
print()
print("Summary:")
for key in ["wall_s", "ttft_s", "tokens_per_s"]:
values = [row[key] for row in sample if row[key] is not None]
if values:
print(f"- {key}: avg={statistics.mean(values):.2f}, min={min(values):.2f}, max={max(values):.2f}")
def main():
parser = argparse.ArgumentParser(description="Small Ollama generation benchmark.")
parser.add_argument("--url", default="http://127.0.0.1:11434", help="Ollama base URL")
parser.add_argument("--model", default="qwen3.5:35b", help="Ollama model name")
parser.add_argument("--runs", type=int, default=3, help="Runs per prompt after warmup")
parser.add_argument("--num-ctx", type=int, default=4096, help="Ollama num_ctx")
parser.add_argument("--num-predict", type=int, default=256, help="Ollama num_predict")
parser.add_argument("--timeout", type=int, default=600, help="HTTP timeout seconds")
parser.add_argument("--no-warmup", action="store_true", help="Skip warmup run")
args = parser.parse_args()
options = {
"num_ctx": args.num_ctx,
"num_predict": args.num_predict,
"temperature": 0.6,
"top_p": 0.9,
}
rows = []
if not args.no_warmup:
row = run_once(args.url, args.model, DEFAULT_PROMPTS[0], options, args.timeout)
row.update({"run": "warmup", "prompt_name": "short_explain"})
rows.append(row)
for run_idx in range(1, args.runs + 1):
for prompt_idx, prompt in enumerate(DEFAULT_PROMPTS, start=1):
row = run_once(args.url, args.model, prompt, options, args.timeout)
row.update({"run": run_idx, "prompt_name": f"p{prompt_idx}"})
rows.append(row)
print_markdown(rows)
if __name__ == "__main__":
main()
陕公网安备61011302002223号