四卡 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:按 Ollama eval_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()

Read more

Mac 上 Skill CLI 无法执行的坑:最后其实一条命令就够了

我在做 Amazon skills 的过程中,逐步把本地 CLI 从 Python 脚本切到 Go 二进制。这样做的好处很明显:用户不用装 Python、不用配依赖,解压 skill 后直接运行。但在 macOS 上,我们反复遇到一个看起来很玄的问题:同一个二进制,在 Linux/Windows 上正常,在 Mac 上就是执行不了。 当时遇到的现象 常见报错大概有几类: * 双击或 agent 调用 CLI 时,系统提示文件来自未知开发者,无法打开。 * 终端里执行时提示 Permission denied。 * 已经 chmod +x 了,仍然被 macOS 拦截。 * Apple

By ladydd

当我把全世界人群的基因 PCA 跑出来后,看见了一个倒 L 型

最近我把之前学的一些分子人类学知识,终于真正落地了。 不是停留在看论文、看别人画图、看别人解释“人群结构”这些概念,而是自己把数据处理完,自己跑 PCA,自己把全世界不同人群放到一张图上。 然后那一刻,我真的被击中了。 图上出现了一个非常漂亮的倒 L 型。 一端是非洲,另一端逐渐拉向东亚,中间有中东、欧洲、南亚、欧亚大陆上的各种过渡人群。它不是那种随机散点图,而是有方向、有骨架、有历史感的结构。 我第一眼看到的时候,脑子里直接冒出一句话: 这不像是一张普通统计图,这像是人类迁徙史在二维空间里留下的影子。 当然,后来我也提醒自己,PCA 不能被过度浪漫化。它不是地图,也不是时间轴,更不是“谁从哪里走到哪里”的直接证据。PCA 本质上是把高维基因差异压缩到几个主成分上,用最大方差方向把样本摊开。它可以帮助我们观察人群结构、相似性、分化和混合,但不能单独承担全部历史解释。PCA 在群体遗传学里常用于观察 population structure

By ladydd

吞吐与延迟:一个厨房比喻讲透性能压测

写于 2026-06-26。背景: MCP 服务 跑在 3 台 ClickHouse(每台 16 核 / 64G,1 分片 3 副本)上。 我们花了一整轮做公网压测,把这套系统的极限、天花板和杠杆全摸清了。这篇把"吞吐 / 延迟 / 排队"这三个最容易混的概念讲透,配我们自己的真实实测数据。 一句话结论 我们这套系统的吞吐天花板 ≈ 76 req/s。 往里塞再多并发(100、200、300、500),每秒"做完"的还是大约 76 个,多出来的全在排队。系统不会崩,只会让每个人等得更久。 天花板能不能抬?

By ladydd

面向 AI Agent 的 ClickHouse 集群调优实战:从病根定位到"近乎白捡"

我们最近把一套面向 AI agent 的分析型数据服务,从单机迁到了 ClickHouse 集群(1 分片 × 3 副本 + HAProxy 入口),并做了一轮系统的性能调优。 这篇不止于"我们改了哪些参数",更想讲清楚背后的思路:怎么用数据定位病根、怎么在"空间/复杂度/收益"之间取舍、怎么做到改完能验证、出事能秒回滚、上线不断服务。如果你也在为高重复、只读、模板化的负载(AI agent、看板、报表 API)调 ClickHouse,这套方法可以直接借鉴。 一条主线贯穿全文:先吃透流量特征,再分层优化,每一步都可验证、可回滚。 一、起点:先吃透你的流量长什么样 任何优化的第一步不是动手,

By ladydd
陕公网安备61011302002223号 | 陕ICP备2025083092号