想把 DeepSeek-V4.1-Flash 这类大模型放在自己机房里跑,最现实的问题就是显存不够。本文记录一台 4 卡 4090 机器上的实测结果:模型能跑起来,长上下文也能吃下,但速度只适合内部低频使用,不适合对外提供高并发服务。

如果你正准备做类似尝试,可以直接参考本文的 docker-compose.yml 与调优结论。

先说结论

项目实测结果
硬件4× RTX 4090 48GB,单卡 49140 MiB,共约 192GB 显存
模型DeepSeek-V4.1-Flash-1017,GGUF UD-IQ3_S 量化,8 个分片
推理后端llama.cpp server-cuda 官方镜像
启动耗时容器启动到模型加载完成约 11 秒(mmap + lazy load)
显存占用四卡共约 187GB,单卡 45~48GB
Prefill 速度稳定后约 100 tokens/s
Decode 速度约 10~12 tokens/s
最大上下文65536 tokens(单 slot)
适用场景内部低频问答、长文档分析;不适合多并发线上服务

一句话:能跑,长上下文可用,但速度属于"能等"的级别。 65000 tokens 的 prompt 需要约 11 分钟才能完成预填充。

硬件与系统环境

项目配置
GPUNVIDIA GeForce RTX 4090 × 4,驱动 595.99.02,CUDA 13.2
单卡显存49140 MiB(48GB 版本,非公版 24GB)
内存125 GiB,Swap 8 GiB
操作系统用户app@aiwork1,模型与 compose 文件放在 ~/ai/docker_m/deepseek-v41-flash-1017

重要前提:这里用的是 48GB 显存的 4090(涡轮/魔改版本)。普通 24GB 4090 四卡只有 96GB 显存,无法用本文这套配置放下整个模型,必须改用更激进的量化或更多 CPU 卸载。

四张卡的拓扑从 nvidia-smi 看是两两一组(81/82 与 C1/C2 两个 PCIe 域),跨组通信会慢一些,这也是后面 --tensor-split "1,1,1,1" 均分仍能接受的原因——瓶颈主要不在卡间带宽,而在 CPU 侧卸载的张量。

软件环境

使用官方 llama.cpp CUDA 镜像,省去自己编译 CUDA 后端:

image: ghcr.io/ggml-org/llama.cpp:server-cuda

模型目录以只读方式挂载:

volumes:
  - /home/app/ai/models/DeepSeek-V4.1-Flash-1017-GGUF:/models:ro

模型选择:为什么用 UD-IQ3_S

模型文件为 UD-IQ3_S(Unsloth Dynamic 3-bit 动态量化)的 8 分片 GGUF:

/models/UD-IQ3_S/DeepSeek-V4.1-Flash-1017-UD-IQ3_S-00001-of-00008.gguf

选择理由很直接:在 192GB 显存下,只有 3-bit 级别的量化才可能把权重基本放进 GPU,同时给 64K 上下文的 KV Cache 留出空间。UD-IQ3_S 属于动态量化,对敏感层保留更高精度,实际效果通常好于普通均匀 IQ3。

如果显存更紧张,可以退到 IQ2 系列;如果追求质量,则需要 6 卡以上或换用更大显存的卡。

完整 docker-compose.yml

这是本机实际在用的配置,可以直接复制修改:

services:
  deepseek-v41-flash-1017:
    image: ghcr.io/ggml-org/llama.cpp:server-cuda
    container_name: deepseek-v41-flash-1017
    restart: no
    ports:
      - "8813:8813"
    volumes:
      - /home/app/ai/models/DeepSeek-V4.1-Flash-1017-GGUF:/models:ro
    gpus: all
    shm_size: "16gb"
    ulimits:
      memlock: -1
      stack: 67108864
    environment:
      NVIDIA_VISIBLE_DEVICES: all
      NVIDIA_DRIVER_CAPABILITIES: compute,utility
    command:
      - --model
      - /models/UD-IQ3_S/DeepSeek-V4.1-Flash-1017-UD-IQ3_S-00001-of-00008.gguf
      - --host
      - 0.0.0.0
      - --port
      - "8813"
      # 多 GPU
      - --split-mode
      - layer
      - --tensor-split
      - "1,1,1,1"
      - --n-gpu-layers
      - "999"
      # 上下文先保持小
      - --ctx-size
      - "65536"
      - --parallel
      - "1"
      - --fit
      - off
      # Engram + 强制更多大张量到 CPU(关键)
      - --load-mode
      - mmap
      - --lazy-mode
      - on
      - --override-tensor
      - "per_layer_token_embd.weight=CPU,engram.*=CPU,blk\\.(0|1|2|3|11|12|15|25|26|27|31|35)\\.ffn_(down|gate|up)_exps\\.weight=CPU"
      # Flash Attention + 更省的 KV
      - --flash-attn
      - on
      - --cache-type-k
      - q8_0
      - --cache-type-v
      - q8_0
      # 采样
      - --temp
      - "1.0"
      - --top-p
      - "1.0"
      - --top-k
      - "0"
      - --min-p
      - "0.0"
      # 模板
      - --jinja
      - --reasoning
      - on
      - --reasoning-format
      - deepseek
      - --chat-template-kwargs
      - '{"reasoning_effort":75}'
    logging:
      driver: json-file
      options:
        max-size: "100m"
        max-file: "3"

启动与查看日志:

docker compose up -d
docker logs -f deepseek-v41-flash-1017

关键参数说明

1. 卸载策略:--override-tensor 是核心

这套配置真正的关键不在多卡切分,而在这里:

--override-tensor "per_layer_token_embd.weight=CPU,engram.*=CPU,blk\\.(0|1|2|3|11|12|15|25|26|27|31|35)\\.ffn_(down|gate|up)_exps\\.weight=CPU"
  • per_layer_token_embd.weight=CPU:逐层 token embedding 体量大、访问稀疏,放 CPU 省显存代价最小。
  • engram.*=CPU:Engram 相关张量整体放 CPU。
  • blk\.(...)\.ffn_(down|gate|up)_exps\.weight=CPU:把指定层(03、11、12、15、2527、31、35)的 MoE 专家 FFN 权重压到 CPU。

因为 DeepSeek 是 MoE 架构,专家权重占大头。挑出这些层交给 CPU,可以把每卡显存从"不够"降到"刚好够",代价是 decode 时要通过 PCIe 读取部分权重。

注意 YAML 里正则的反斜杠写成 \\.,最终传给程序的才是 \.。

2. --load-mode mmap + --lazy-mode on

  • mmap:权重按需从页缓存映射,启动只需 11 秒,不必把上百 GB 权重全部读进内存。
  • lazy-mode on:延迟加载,模型"加载完成"并不等于权重已就位,前几个请求会明显变慢(见下文实测)。

代价是首次访问会触发磁盘/页缓存读取,并且依赖系统内存做缓存。本机 free -h 显示 buff/cache 高达 93Gi、free 只剩 933Mi,说明页缓存确实被模型文件占满了。

3. --flash-attn on + KV Cache 量化

--flash-attn on --cache-type-k q8_0 --cache-type-v q8_0

65536 tokens 的 KV Cache 在 FP16 下非常吃显存,改成 q8_0 大约省一半,是能把上下文开到 64K 的前提。实测精度损失在可接受范围内。

4. --split-mode layer --tensor-split "1,1,1,1"

按层切分到四张卡,四卡等分。切分后仍出现一条警告:

resolve_fused_ops: layer 11 is assigned to device CUDA1 but fused DeepSeek V4 HC pre is assigned to device CUDA0
resolve_fused_ops: fused DeepSeek V4 HC pre not supported, set to disabled

说明融合算子(fused DeepSeek V4 HC pre)在当前版本还不支持跨设备,被自动禁用。这属于正常降级,不影响正确性,但会损失一点性能。

5. 采样与推理

--temp 1.0 --top-p 1.0 --top-k 0 --min-p 0.0

按官方推荐放开采样。对话模板侧使用 --jinja --reasoning on --reasoning-format deepseek,并通过 reasoning_effort: 75 控制思考强度——这个值越高,思考 token 越多,decode 时间越长,本地部署时建议适当降低。

--parallel 1 意味着单 slot 串行处理。192GB 显存下没有余量做多并发,强行提高只会 OOM。

实测性能

以下数据来自同一次会话的 slot print_timing 日志,机器空闲、无其他负载。

短对话

场景promptprefilldecode
首轮请求(冷启动)42 tokens12.72 s / 3.30 tok/s30.13 s / 9.92 tok/s
第二轮(复用前缀,仅 10 个新 token)10 tokens0.60 s / 16.65 tok/s25.55 s / 11.70 tok/s

首轮 42 个 token 花了 12.7 秒,是典型冷启动开销(图初始化 + lazy 加载)。第二轮命中 LCP 前缀缓存(f_sim_best = 0.757),几乎瞬间完成 prefill,decode 提升到 11.7 tok/s。

长上下文(65K)

指标数值
prompt tokens65012
prefill 总耗时659.59 s(约 11 分钟)
prefill 速度98.56 tok/s(稳定在 98~103 tok/s)
decode(300 tokens)24.58 s / 12.16 tok/s
总耗时684.17 s

prefill 速度曲线很平稳:起步 92 tok/s,在 4096 tokens 附近爬到约 103 tok/s,之后随上下文增长缓慢回落到 98 tok/s。说明长上下文的经济性主要取决于总 token 数,而不是衰减。

结论很明确:65K 上下文的首字延迟是分钟级的。适合"提交长文档,等几分钟拿结果"的场景,不适合交互式对话。

显存与内存占用

nvidia-smi 中四卡占用(空闲态,模型常驻):

GPU显存占用温度功耗
045368 MiB / 49140 MiB36°C29W
146816 MiB / 49140 MiB32°C32W
247758 MiB / 49140 MiB35°C20W
347208 MiB / 49140 MiB36°C34W

四卡合计约 187GB,单卡余量只剩 1.43.8GB。推理时 GPU 利用率会打满,但空闲功耗只有 2034W(P8 状态),长期挂着成本可接受。

主机内存:

               total        used        free      shared  buff/cache   available
Mem:           125Gi        32Gi       933Mi       204Mi        93Gi        92Gi
Swap:          8.0Gi       5.9Gi       2.1Gi

有两点值得注意:

  • buff/cache 93Gi 基本都是模型文件的页缓存,free 只剩 933Mi 属于正常现象,available 仍有 92Gi。
  • Swap 已用 5.9Gi,说明运行过程中出现过内存压力。如果遇到偶发卡顿,优先怀疑换页。

踩过的坑与调优建议

1. mmap 与 CPU 卸载同时使用会掉性能

启动日志中的警告值得重视:

tensor overrides to CPU are used with mmap enabled - consider using --load-mode none for better performance

当前这样配是为了快速启动 + 低内存占用。如果机器有足够内存、且不介意启动慢(需要把权重完整读入),改成 --load-mode none 能减少运行时的 mmap 缺页开销,decode 速度会有改善。两种模式是"启动快"和"跑得快"的取舍。

2. 没有任何鉴权,且 CORS 全开

security: no API key is set and CORS allows all origins

这是默认行为,不能直接暴露到公网。至少要做到:

  • 只监听内网地址,前面加 Nginx 反代并做鉴权;
  • 或使用 --api-key 设置密钥。

3. 环境变量与命令行参数冲突

warn: LLAMA_ARG_HOST environment variable is set, but will be overwritten by command line argument --host

镜像自带 LLAMA_ARG_HOST / LLAMA_ARG_PORT,被 command 里的 --host / --port 覆盖,只是提示,不影响运行。想彻底消除可以在 compose 里把这些环境变量删掉。

4. slot 会长时间保留长上下文

日志里可以看到,处理完 65K 的请求后,slot 仍然持有 65338 个 token,后续请求按 LRU 选中它。在 --parallel 1 下这意味着下一个请求会继承旧上下文的显存占用,甚至因前缀相似度低而重新 prefill 一大段。如果不需要长会话记忆,定期重启容器或降低 --ctx-size 更稳妥。

5. 显存余量极小

单卡只剩 1~4GB 余量,任何参数上调都可能 OOM:

  • 想加并发:先降 --ctx-size,再考虑 --parallel 2;
  • 想开更大上下文:把更多层写进 --override-tensor 的 CPU 列表;
  • 想提质量换更高量化:显存不够,只能加卡。

适合谁用

适合:内部知识库长文档分析、离线批处理、个人/小团队的低频调用、需要数据完全不出内网的场景。

不适合:面向用户的在线对话服务(decode 10~12 tok/s、单并发)、需要低首字延迟的 RAG 交互、高 QPS 场景。

如果你的需求是后者,同样四张 4090 更适合跑 30B 级别的稠密模型或更小的 MoE,速度会好一个数量级。跑 V4.1-Flash 这种体量,本质上是"用时间换显存"。

附:快速验证

服务起来后可以这样验证:

curl http://127.0.0.1:8813/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "deepseek-v41-flash-1017",
    "messages": [{"role": "user", "content": "用一句话介绍你自己"}],
    "max_tokens": 128
  }'

同时观察 docker logs -f 里的 print_timing 行,tg 是 decode 速度,prompt eval time 是 prefill 耗时,是最直接的性能依据。