---
title: 本地 LLM 部署笔记
url: https://doc.liz6.com/homelab/local-llm
locale: zh
area: homelab
tags:
- homelab
date: 2026-07-01
modified: 2026-08-24
description: 用消费级显卡跑本地大模型,核心是一道预算题:模型权重、KV cache、桌面环境在抢同一块显存,怎么分决定了能跑多大的模型、开多长的上下文。本文以一张 24G 的 RX 7900 XTX 为实例,给出选型方法和完整可复现配置,并延伸到 16G / 12G 显卡。所有命令兼容 Linux,WSL2 同理。
---

# 本地 LLM 部署笔记

> 用消费级显卡跑本地大模型,核心是一道预算题:模型权重、KV cache、桌面环境在抢同一块显存,怎么分决定了能跑多大的模型、开多长的上下文。本文以一张 24G 的 RX 7900 XTX 为实例,给出选型方法和完整可复现配置,并延伸到 16G / 12G 显卡。所有命令兼容 Linux,WSL2 同理。

## 一、约束:一道不等式,不分卡

本地部署的所有决定——选哪个量化、KV cache 设什么精度、上下文开多长——都在解同一道题:

> **模型权重 + KV cache + 运行缓冲 + 桌面余量 ≤ 显存容量**

这道不等式对 24G、16G、12G 都成立,只是右边的数不同。把它当预算来分,是整个部署的通用起点。本机是一张 7900 XTX(24GB),下文以它为实例展开。

## 二、第一步:按你的显卡选后端

llama.cpp 支持多种 GPU 后端,**不需要装任何厂商的 AI 训练栈**(CUDA Toolkit / ROCm / oneAPI),只走显卡驱动自带的运行时。按你的卡选:

| 你的显卡 | 后端 | 编译开关 |
|----------|------|----------|
| **NVIDIA**(GTX 10 系及以上) | CUDA | `-DGGML_CUDA=ON` |
| **AMD**(RX 系列 / Radeon VII) | Vulkan | `-DGGML_VULKAN=ON` |
| **Intel Arc** | Vulkan | `-DGGML_VULKAN=ON` |
| **Apple Silicon**(M 系) | Metal | `-DGGML_METAL=ON` |

几个常见疑问:

**"N 卡要不要装 CUDA Toolkit?"**——不用。llama.cpp 只依赖显卡驱动自带的 CUDA 运行时,不需要装好几个 G 的 CUDA Toolkit。

**"AMD 为什么是 Vulkan 不是 ROCm?"**——ROCm 是给 PyTorch / vLLM 做训练的,安装麻烦(Windows 上尤其折腾),GGUF 推理完全用不上。Vulkan 走显卡驱动自带的运行时,Linux 和 Windows 都开箱即用。

**"集显和独显都在怎么办?"**——Vulkan 用户用 `GGML_VK_VISIBLE_DEVICES=0` 锁独显(0 = 第一块);CUDA 用户用 `CUDA_VISIBLE_DEVICES=0`。Metal 用户通常不需要。

> **Windows 用户**:下面的命令全部在 WSL2 里跑。WSL2 能直接访问 GPU(N 卡装官方驱动即用;A 卡需内核 5.15+ 且装 AMD 官方 WSL 驱动),编译安装流程和 Linux 一致。先 `wsl --install` 装好发行版,后续步骤照做即可。

下面进入核心问题:显存怎么分。

## 三、怎么选:把显存花在刀刃上

能调的旋钮就四个:参数量和权重量化在下载选文件时定死;上下文长度和 KV 精度是启动参数,每次可调。先看本机的答案,再讲怎么在自己的卡上做同样的决策。

### 本机的答案:先保模型规模,再按工作负载切 KV 档

选 **Qwen3.6-35B-A3B**(MoE,总参数 35B 但每次只激活 3B,所以有小模型的速度、大模型的容量)。真正的取舍不是在 35B 与 8B 之间反复横跳,而是保住 35B 的表达与一致性,再为同一模型准备两档 KV:**日常交互用 Q8_0 + 128K**,真正需要超长输入时切 **q4_0 + 192K**。

<svg viewBox="0 0 720 384" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="24G 显存预算:同一模型按工作负载切换 KV 档位">
  <rect width="720" height="384" fill="#ffffff"/>
  <text x="360" y="28" text-anchor="middle" font-size="18" font-weight="700" fill="#1f2933">24G 怎么花:同一模型按工作负载切 KV 档</text>
  <text x="80" y="60" font-size="13" font-weight="700" fill="#1f2933">容量优先档实测常驻(24G 卡 · idle)</text>
  <line x1="656" y1="68" x2="656" y2="128" stroke="#ef4444" stroke-width="1.5" stroke-dasharray="5 4"/>
  <text x="656" y="62" text-anchor="end" font-size="11" font-weight="700" fill="#dc2626">24G</text>
  <rect x="80" y="70" width="407" height="44" fill="#4f46e5"/>
  <text x="283" y="97" text-anchor="middle" font-size="12" font-weight="600" fill="#ffffff">模型 IQ4_XS 16.96 GiB</text>
  <rect x="487" y="70" width="116" height="44" fill="#0d9488"/>
  <text x="545" y="90" text-anchor="middle" font-size="10" fill="#ffffff">KV·q4_0</text>
  <text x="545" y="103" text-anchor="middle" font-size="10" fill="#ffffff">192K+缓冲</text>
  <rect x="603" y="70" width="53" height="44" fill="#86efac"/>
  <text x="629" y="97" text-anchor="middle" font-size="10" font-weight="700" fill="#166534">余2.2G</text>
  <text x="80" y="133" font-size="12" fill="#475569">实测 used 21.8G / free 2.2G — 顶得从容,不是刚好塞满。</text>
  <line x1="80" y1="150" x2="656" y2="150" stroke="#e2e8f0" stroke-width="1"/>
  <text x="80" y="176" font-size="13" font-weight="700" fill="#1f2933">同样这张卡,KV 精度决定能开多长上下文(按位宽反比估算)</text>
  <text x="86" y="205" font-size="12" fill="#64748b">f16 KV</text>
  <rect x="200" y="192" width="75" height="20" rx="3" fill="#cbd5e1"/>
  <text x="283" y="207" font-size="11" fill="#64748b">≈ 48K</text>
  <text x="86" y="241" font-size="12" fill="#64748b">Q8_0 KV</text>
  <rect x="200" y="228" width="150" height="20" rx="3" fill="#94a3b8"/>
  <text x="358" y="243" font-size="11" fill="#64748b">≈ 96K 舒适档</text>
  <text x="86" y="277" font-size="12" font-weight="700" fill="#0f766e">q4_0 KV(本机)</text>
  <rect x="200" y="264" width="300" height="20" rx="3" fill="#0d9488"/>
  <text x="508" y="279" font-size="11" font-weight="700" fill="#0f766e">192K ✓</text>
  <rect x="60" y="304" width="600" height="66" rx="8" fill="#f0fdfa" stroke="#99f6e4"/>
  <text x="76" y="330" font-size="12.5" fill="#115e59">同一个 35B 保留两档:交互档用 Q8_0 兼顾稳定性,容量档用 q4_0 把窗口拉到 192K。</text>
  <text x="76" y="352" font-size="12.5" fill="#115e59">窗口规格不等于实时可用长度:越长越慢,真正吃到 100K 以上时再切容量档。flash-attn 是量化 KV 的前提。</text>
</svg>

拆开这 24G:

- **模型权重 16.96 GiB**(IQ4_XS)。MoE 让它跑得快,IQ4_XS 让质量够用。
- **容量档用 q4_0 + flash-attn**,把 192K 上下文压进约 5G。若换 f16,同样上下文要四倍显存直接溢出;Q8_0 在真正吃到 100K 左右时会开始挤占余量并显著降速。
- **交互档用 Q8_0 + 128K 上限**,客户端只放出 120K,并在约 55K 开始压缩旧消息。平时不会真的把 128K 全部填满,换来更稳的生成轨迹;偶尔超过 100K 仍能跑,只是首字延迟已不适合实时聊天。
- **容量档实测常驻 21.8G,余 2.2G**,idle 掉到低功耗档,桌面无压力。

早期跑过 Q3_K_XL + 短上下文的保守配置;现在固定 IQ4_XS 权重,只按负载切 KV 档。**默认 Q8 交互档,超长输入才切 Q4 容量档**;桌面偶尔重载吃紧时再切一个 Q3 权重档兜底。

### 旋钮在哪调、什么时候定

套到自己的卡上,四个旋钮分两拨:

<svg viewBox="0 0 720 300" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="选型两步:下载时定参数量与量化,启动时定上下文与KV">
  <defs><marker id="mah" markerWidth="10" markerHeight="8" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#475569"/></marker></defs>
  <rect width="720" height="300" fill="#ffffff"/>
  <text x="360" y="28" text-anchor="middle" font-size="17" font-weight="700" fill="#1f2933">选型分两步:下载时定「参数量+量化」,启动时定「上下文+KV」</text>
  <rect x="40" y="50" width="300" height="28" rx="6" fill="#4f46e5"/>
  <text x="190" y="69" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">① 下载时 · 刻进文件、事后改不了</text>
  <rect x="52" y="90" width="276" height="44" rx="6" fill="#eef2ff" stroke="#c7d2fe"/>
  <text x="66" y="109" font-size="12" font-weight="700" fill="#3730a3">参数量</text>
  <text x="66" y="126" font-size="11" fill="#4f46e5">选模型仓库 · 8B / 14B / 35B-A3B</text>
  <rect x="52" y="140" width="276" height="44" rx="6" fill="#eef2ff" stroke="#c7d2fe"/>
  <text x="66" y="159" font-size="12" font-weight="700" fill="#3730a3">权重量化</text>
  <text x="66" y="176" font-size="11" fill="#4f46e5">选哪个 .gguf · Q4_K_M / IQ4_XS / Q8_0</text>
  <rect x="52" y="196" width="276" height="38" rx="6" fill="#e0e7ff"/>
  <text x="190" y="220" text-anchor="middle" font-size="12" font-weight="600" fill="#3730a3">GGUF:~/models/…-IQ4_XS.gguf</text>
  <rect x="380" y="50" width="300" height="28" rx="6" fill="#0d9488"/>
  <text x="530" y="69" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">② 启动时 · 每次可调</text>
  <rect x="392" y="90" width="276" height="44" rx="6" fill="#f0fdfa" stroke="#99f6e4"/>
  <text x="406" y="109" font-size="12" font-weight="700" fill="#115e59">上下文</text>
  <text x="406" y="126" font-size="11" fill="#0f766e">-c 196608(192K)</text>
  <rect x="392" y="140" width="276" height="44" rx="6" fill="#f0fdfa" stroke="#99f6e4"/>
  <text x="406" y="159" font-size="12" font-weight="700" fill="#115e59">KV 精度</text>
  <text x="406" y="176" font-size="11" fill="#0f766e">-ctk / -ctv q4_0(需 --flash-attn)</text>
  <rect x="392" y="196" width="276" height="38" rx="6" fill="#ccfbf1"/>
  <text x="530" y="220" text-anchor="middle" font-size="12" font-weight="600" fill="#115e59">llama-server 跑起来</text>
  <line x1="330" y1="215" x2="388" y2="215" stroke="#475569" stroke-width="1.8" marker-end="url(#mah)"/>
  <text x="360" y="262" text-anchor="middle" font-size="12" fill="#64748b">想换更大模型 / 更高质量 → 重新下文件;想要更长上下文 / 更省显存 → 改启动参数即可。</text>
</svg>

### 第一步:选参数量 + 权重量化(下载时定)

**选参数量**靠后面的「分档表」——24G 上 30–35B,16G 上 14B,12G 上 8B。

**选权重量化**:同一个模型在 HF 上会有一排 GGUF,文件名后缀就是量化级别:

- 数字 ≈ bit 数:`Q8_0`(8bit,近无损、最大)> `Q6_K` > `Q5_K_M` > `Q4_K_M`(4bit,最通用的平衡点)> `Q3_K_M`(省显存、质量下降)。
- `IQ4_XS` / `IQ3_XXS` 是 i-quant:同 bit 更小、质量接近,需 imatrix(发布者已附)。显存紧就用它。
- `_S / _M / _L` = 小 / 中 / 大变体,同 bit 里 `_M` 最常用。

**怎么挑**:GGUF 文件体积 ≈ 它占的显存。拿权重预算对着文件大小,挑最大能装下的:

```text
权重预算 = 显存 − KV cache − 桌面余量(留 ~2G)
例:24 − 5(192K q4_0) − 2 ≈ 17G → 35B 用 IQ4_XS(实际 16.96G)正好;
      换 Q5_K_M(~24G)就装不下。
```

经验优先级:先奔 `Q4_K_M`;装不下往下降 `IQ4_XS` → `IQ3`;有富余往上升 `Q5_K_M` / `Q6_K`。别用 Q3 以下,除非别无选择。

### 第二步:定 KV 精度(启动时调)

KV 精度和上下文长度都是启动参数,每次可调。KV 靠三个开关:

| 开关 | 作用 | 常用值 |
|------|------|--------|
| `--flash-attn` / `-fa` | **量化 KV 的前提,必开** | 开 / 关 |
| `--cache-type-k` / `-ctk` | K cache 精度 | `f16`(默认)/ `q8_0` / `q4_0` |
| `--cache-type-v` / `-ctv` | V cache 精度 | 同上 |

K 和 V **分开设**,精度阶梯 `f16 → q8_0 → q4_0`,占显存大致 `1 → 1/2 → 1/4`——这就是为什么 q4_0 能开到 f16 约 4 倍的上下文。三种典型档:

```bash
# 激进:长上下文优先
llama-server ... -fa -ctk q4_0 -ctv q4_0 -c 196608
# 保守:质量优先,约 96K 是本机全显存舒适档;服务上限可再留余量
llama-server ... -fa -ctk q8_0 -ctv q8_0 -c 98304
# 非对称:K 对量化更敏感,极限省显存时保 K、压 V
llama-server ... -fa -ctk q8_0 -ctv q4_0
```

**怎么验证调了多少**:启动日志会打印 `KV self size = ...`,直接看占多大。质量上 q8_0 基本无损,q4_0 在日常对话和 coding 里也难感知,只有长文本精确检索偶尔露怯。查显存占用的命令因平台而异:`nvidia-smi`(N 卡)、`cat /sys/class/drm/card0/device/mem_info_vram_used`(AMD Linux)、任务管理器 → 性能 → GPU(Windows)。

> 坑:不开 `-fa` 时设 `-ctv q4_0` 会报错或被忽略——量化 V cache 依赖 flash attention。

### 不同显存的起点

同一套预算公式往下推(**这些是估算,按自己卡实测校准**):

| 显存 | 现实选择 |
|------|----------|
| **24G** | 30–35B MoE(IQ4/Q4)或 32B dense IQ4;长上下文靠 q4_0 KV |
| **16G** | 14B dense Q4–Q5,或 30B MoE Q3 + 适度层 offload;中等上下文 |
| **12G** | 8–9B Q4–Q5 舒适(如 Qwen3-8B GGUF ~5.4G),或 14B Q4 紧凑 |
| **8G** | 7–8B Q4,短上下文 |

**显存不够时的砍法,按优先级**:先降 KV 精度(f16→q8_0→q4_0)保上下文 → 再压上下文长度 → 最后才换更小的模型或更狠的权重量化。别忘了桌面和游戏也在抢这张卡,留几 G 给它。

决策做完,现在把模型拉下来、跑起来。

## 四、跑起来:从安装到第一句回复

**装 llama.cpp**(要带 server + 对应后端,按优先级):

1. 用 [官方 release 二进制](https://github.com/ggerganov/llama.cpp/releases)——全平台通用,下载解压即用,已含各后端。
2. Linux 发行版有现成包:`apt install llama-cpp`(Debian/Ubuntu)、`pacman -S llama.cpp-vulkan`(Arch)。
3. 以上都没,从源码编译(把 `<YOUR_BACKEND>` 换成第二节选的开关,如 `-DGGML_CUDA=ON`):

```bash
git clone https://github.com/ggerganov/llama.cpp --depth 1
cd llama.cpp
cmake -B build <YOUR_BACKEND> -DLLAMA_CURL=ON
cmake --build build --config Release -j
# 产物在 build/bin/
```

**下载模型**到本地模型目录:

```bash
pip install -U "huggingface_hub[cli]"
# 在 HF 搜 "<模型名> GGUF",找 unsloth / bartowski 的量化仓库
hf download unsloth/Qwen3.6-35B-A3B-MTP-GGUF \
  --include "*IQ4_XS*.gguf" --local-dir ~/models
```

大模型 GGUF 常分片(`…-00001-of-0000N.gguf`),`--include "*IQ4_XS*"` 一次下全;llama.cpp 指向第一片会自动加载其余。

**启动**(单模型,适合初次试用):

```bash
llama-server \
  -m ~/models/Qwen3.6-35B-A3B-MTP-UD-IQ4_XS.gguf \
  -ngl 99 -c 196608 \
  --flash-attn --cache-type-k q4_0 --cache-type-v q4_0 \
  --host 0.0.0.0 --port 18080
```

Vulkan 用户如果有多块 GPU,在前面加 `GGML_VK_VISIBLE_DEVICES=0` 锁独显;CUDA 用户跳过。

`http://localhost:18080` 是 OpenAI 兼容端点。验证跑通了没:

```bash
curl http://localhost:18080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"hi"}]}'
```

> 路径里的 `MTP` 是什么、为什么用 unsloth 的 GGUF、不用行不行——下面第五节专门讲。

单模型命令适合试用;日常常驻推荐 **router 模式**(models.ini 集中管多个模型,按需 LRU 换出),MTP 加速和潮汐调度也都基于 router 模式,下面展开。

## 五、提速:MTP 投机解码

### 这是什么

MTP(Multi-Token Prediction)是一种**投机解码**技术:一次前向预测多个 token 而非逐 token 串行生成。本机用 Qwen3.6-35B-A3B(IQ4_XS)实测:不开 MTP 约 75 t/s,开了 ~111 t/s,提速约 50%。你的模型和量化不同,实际提升会有差异。

标准 llama.cpp 模型没有这个能力——它来自 **unsloth**(一个专门做 GGUF 量化和加速的团队)在量化时**额外往 GGUF 里嵌入了草稿头(draft head)**。草稿头先"猜"接下来 2 个 token,主模型一次前向并行验证;猜对白赚,猜错回退。无需独立的草稿模型,也不占额外显存:

<svg viewBox="0 0 720 300" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="MTP 投机解码原理">
  <defs>
    <marker id="lah" markerWidth="10" markerHeight="8" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#475569"/></marker>
  </defs>
  <rect width="720" height="300" fill="#ffffff"/>
  <text x="360" y="30" text-anchor="middle" font-size="18" font-weight="700" fill="#1f2933">MTP 投机解码:一次前向产出多个 token</text>
  <text x="48" y="92" font-size="13" font-weight="600" fill="#64748b">无 MTP</text>
  <text x="48" y="108" font-size="11" fill="#94a3b8">逐 token</text>
  <rect x="130" y="74" width="80" height="40" rx="5" fill="#e2e8f0"/><text x="170" y="99" text-anchor="middle" font-size="12" fill="#334155">前向 → t₁</text>
  <rect x="230" y="74" width="80" height="40" rx="5" fill="#e2e8f0"/><text x="270" y="99" text-anchor="middle" font-size="12" fill="#334155">前向 → t₂</text>
  <rect x="330" y="74" width="80" height="40" rx="5" fill="#e2e8f0"/><text x="370" y="99" text-anchor="middle" font-size="12" fill="#334155">前向 → t₃</text>
  <text x="430" y="99" font-size="12" fill="#94a3b8">… 每次前向只出 1 个</text>
  <text x="48" y="192" font-size="13" font-weight="600" fill="#1d4ed8">MTP</text>
  <text x="48" y="208" font-size="11" fill="#60a5fa">草稿头+验证</text>
  <rect x="130" y="166" width="150" height="56" rx="6" fill="#dbeafe" stroke="#60a5fa"/>
  <text x="205" y="189" text-anchor="middle" font-size="12" font-weight="600" fill="#1e40af">草稿头预测</text>
  <text x="205" y="208" text-anchor="middle" font-size="12" fill="#1e3a8a">t₁, t₂(一次提 2 个)</text>
  <line x1="280" y1="194" x2="320" y2="194" stroke="#475569" stroke-width="1.6" marker-end="url(#lah)"/>
  <rect x="324" y="166" width="160" height="56" rx="6" fill="#eef2ff" stroke="#818cf8"/>
  <text x="404" y="189" text-anchor="middle" font-size="12" font-weight="600" fill="#3730a3">主模型一次前向</text>
  <text x="404" y="208" text-anchor="middle" font-size="12" fill="#4338ca">并行验证 t₁,t₂</text>
  <line x1="484" y1="194" x2="524" y2="194" stroke="#475569" stroke-width="1.6" marker-end="url(#lah)"/>
  <rect x="528" y="166" width="150" height="56" rx="6" fill="#dcfce7" stroke="#4ade80"/>
  <text x="603" y="189" text-anchor="middle" font-size="12" font-weight="600" fill="#166534">接受 ✓✓ / ✓✗</text>
  <text x="603" y="208" text-anchor="middle" font-size="12" fill="#15803d">接受率 ~52%</text>
  <rect x="60" y="250" width="600" height="34" rx="6" fill="#f0fdf4"/>
  <text x="72" y="271" font-size="12" fill="#166534">`spec-draft-n-max=2`,draft 接受率 ~52% → 实测 tg ~111 t/s。草稿头内置 GGUF,不占额外槽位。</text>
</svg>

### 怎么找、不用怎么办

**MTP 不是模型原生的功能,是 unsloth 在量化时额外加的。** 所以:

- **只有在 HF 上搜 `unsloth/<模型名>-MTP-GGUF` 才有 MTP 版**,例如 [`unsloth/Qwen3.6-35B-A3B-MTP-GGUF`](https://huggingface.co/unsloth/Qwen3.6-35B-A3B-MTP-GGUF)。bartowski 等常规量化仓库没有。
- **文件名含 `MTP` 字样**,如 `…-MTP-UD-IQ4_XS.gguf`。不含就不带草稿头,无法投机解码。
- **不是所有模型都有 unsloth 出 MTP 版**——unsloth 主要给 Qwen 系列加。没有就用普通 GGUF,照样跑,只是没有 MTP 加速。

**不用 MTP 的配置**:下载命令里去掉 `MTP`,换普通仓库(如 `bartowski/Qwen3.6-35B-A3B-GGUF`);配置文件删掉 `spec-type` 和 `spec-draft-n-max` 两行,其余不变。

### router 模式与完整预设

如果想管理多个模型或常驻开机自启,升级为 **router 模式**:将所有模型和参数写入 `~/.config/llama.cpp/models.ini`,server 用 `--models-preset` 加载,`--models-max 1` 限制同时只驻留一个(24G 只装得下一个),LRU 自动换出:

```ini
# ~/.config/llama.cpp/models.ini
[qwen3.6-mtp-instruct]
model = ~/models/Qwen3.6-35B-A3B-MTP-UD-IQ4_XS.gguf
ngl = 99                      # 全部层上 GPU(99=all)
ctx-size = 196608             # 192K 上下文
chat-template = chatml        # OpenAI 对话模板,不用工具也建议设
chat-template-kwargs = {"enable_thinking": false}
flash-attn = 1
cache-type-k = q4_0
cache-type-v = q4_0
spec-type = draft-mtp          # 以下两行仅 MTP 模型需要
spec-draft-n-max = 2
```

> 坑:`chat-template=chatml` 必须显式设——用模型原生模板时,工具调用前缀处可能生成错误 token 导致提前 EOS(llama.cpp #19513)。不用 MTP、不用工具调用也一样要设。

加新模型只需在 INI 里加一条,Open WebUI 列表自动更新。systemd unit 和启用命令见「完整配置」一节。

## 六、常驻调度:模型在线,功耗潮汐(Linux)

跑得快是一时,常驻不浪费才是日常。以下基于 Linux systemd 和 AMD sysfs 接口(WSL2 用户直接跟本节走,发行版自带 systemd;纯 Windows 用户跳过,核心思路一样:开后台进程托管 llama-server,按 GPU 负载切功耗档):

<svg viewBox="0 0 720 340" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="显存与功耗的潮汐调度状态机">
  <defs>
    <marker id="sah" markerWidth="10" markerHeight="8" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#64748b"/></marker>
  </defs>
  <rect width="720" height="340" fill="#ffffff"/>
  <text x="360" y="30" text-anchor="middle" font-size="18" font-weight="700" fill="#1f2933">显存潮汐:模型常驻,功耗与占用随负载涨落</text>
  <rect x="80" y="80" width="220" height="78" rx="10" fill="#dcfce7" stroke="#22c55e"/>
  <text x="190" y="110" text-anchor="middle" font-size="14" font-weight="700" fill="#166534">空闲常驻</text>
  <text x="190" y="131" text-anchor="middle" font-size="11" fill="#15803d">模型在显存 · GPU 档位 0</text>
  <text x="190" y="147" text-anchor="middle" font-size="11" fill="#15803d">低功耗待命</text>
  <rect x="430" y="80" width="220" height="78" rx="10" fill="#dbeafe" stroke="#3b82f6"/>
  <text x="540" y="110" text-anchor="middle" font-size="14" font-weight="700" fill="#1e40af">推理中</text>
  <text x="540" y="131" text-anchor="middle" font-size="11" fill="#1d4ed8">gpu_busy ≥ 50% → 档位 5</text>
  <text x="540" y="147" text-anchor="middle" font-size="11" fill="#1d4ed8">COMPUTE 全速</text>
  <rect x="255" y="232" width="220" height="78" rx="10" fill="#ffedd5" stroke="#f97316"/>
  <text x="365" y="262" text-anchor="middle" font-size="14" font-weight="700" fill="#9a3412">游戏中</text>
  <text x="365" y="283" text-anchor="middle" font-size="11" fill="#c2410c">gamemode → 停 llama-server</text>
  <text x="365" y="299" text-anchor="middle" font-size="11" fill="#c2410c">整卡显存让给游戏</text>
  <line x1="300" y1="108" x2="426" y2="108" stroke="#64748b" stroke-width="1.6" marker-end="url(#sah)"/>
  <text x="363" y="101" text-anchor="middle" font-size="11" fill="#475569">来请求</text>
  <line x1="426" y1="134" x2="300" y2="134" stroke="#64748b" stroke-width="1.6" marker-end="url(#sah)"/>
  <text x="363" y="151" text-anchor="middle" font-size="11" fill="#475569">空闲 &gt; 90s → 降档</text>
  <line x1="210" y1="160" x2="300" y2="230" stroke="#64748b" stroke-width="1.6" marker-end="url(#sah)"/>
  <text x="208" y="205" text-anchor="end" font-size="11" fill="#475569">游戏启动</text>
  <line x1="320" y1="230" x2="235" y2="160" stroke="#64748b" stroke-width="1.6" marker-end="url(#sah)"/>
  <text x="330" y="205" text-anchor="start" font-size="11" fill="#475569">结束 → warmup ~6s</text>
</svg>

三个机制:

**① 开机 warmup。** router 懒加载,靠 systemd `ExecStartPost` 启动后主动打一发请求灌模型进显存。warmup payload 必须 `-d @文件`——内联 JSON 会被 systemd 吞掉引号。warmup.json 就一句话:`{"model":"qwen3.6-mtp-instruct","messages":[{"role":"user","content":"hi"}],"max_tokens":1}`。

**② 功耗按真实负载切档。** `llama-gpu-sync.timer` 每 15s 触发,判据是 `gpu_busy_percent ≥ 50%` 才切 COMPUTE(档 5),闲置超过 90s 降回 BOOTUP_DEFAULT(档 0),只碰 0/5、不碰游戏用的 `3d_full_screen`。关键在**用 GPU 真实占用而非"模型是否加载"**——常驻但不推理就该待在省电档。AMD 通过 sysfs `pp_power_profile_mode` 实现,N 卡走 `nvidia-smi -pl`。

**③ 游戏时整卡让出。** [gamemode](https://github.com/FeralInteractive/gamemode)(多数发行版有包)检测到 Steam 游戏启动即停 llama-server,显存全让给游戏,结束再拉起。模型还在 page cache 里,warmup ~6s 恢复:

```ini
# ~/.config/gamemode.ini
[custom]
start = systemctl --user stop llama-server
end   = systemctl --user start llama-server
```

> **完整 systemd unit、功耗切档脚本、sudoers 免密、enable 命令见「完整配置」一节。**

## 七、性能基线

llama.cpp 跑分术语:

- **pp512(prompt processing)**:并行处理 512 个输入 token 的速度(token/s),决定"读长上下文"有多快。
- **tg128(text generation)**:连续生成 128 个输出 token 的速度,即直观的"吐字"速度——人阅读约 5–10 t/s,100+ 就是"话没说完已刷屏"。
- **MTP draft 接受率**:草稿头猜的 token 被主模型验证通过的比例,越高等效提速越多。

本机实测(Qwen3.6-35B-A3B · IQ4_XS · 192K):

| 指标 | 数值 | 什么概念 |
|------|------|----------|
| pp512 | ~3150 t/s | 短基准预填充很快;不可线性外推到 100K |
| tg128 | ~111 t/s | 远超阅读速度,体感即时 |
| MTP 接受率 | ~52% | 草稿头约一半命中 |
| 常驻显存 | 21.8 / 24 GB | 余 2.2G |

**自己测**:`llama-bench` 一条命令,换卡换量化对比:

```bash
llama-bench -m ~/models/Qwen3.6-35B-A3B-MTP-UD-IQ4_XS.gguf -ngl 99
# 输出即 pp512 / tg128 两行
# 常驻服务跑着时需先停: systemctl --user stop llama-server(Linux)
```

### 角色扮演负载实测:8B 大窗口、35B 与 KV 精度

2026-08-24 为 SillyTavern 的长对话负载做了一轮本机 A/B。问题不是"哪个模型标称窗口更大",而是三个更实际的问题:

1. 为大窗口换成 8B,角色扮演质量会损失多少?
2. 35B 把 KV 从 Q8_0 降到 q4_0,是否会稳定损伤文笔或事实保持?
3. 100K–192K 虽然能配置,每轮重新读取这么长的历史是否仍适合实时聊天?

模型质量与长上下文 A/B 只使用合成场景与随机事实码。短场景使用相同提示与采样条件;长文本把 8 个目标事实分散在全文不同位置,按严格 JSON 映射计分;主观质量由交换 A/B 顺序的双盲评分汇总。后文的轮数估算只对本地既有记录做 token 聚合,不输出或保留正文。样本量不大,分数只用于本机选型,不是通用模型排行榜。

#### 模型规模:8B 的大窗口补不回质量差距

| 指标 | 35B-A3B | 8B | 解读 |
|------|--------:|---:|------|
| 短场景双盲综合分 | **8.64** | 6.36 | 差距主要在文笔、角色可信、连续性与避免重复 |
| 暖机生成速度 | **126.7 t/s** | 107.4 t/s | MoE 的激活参数小,35B 总参不等于每 token 都算 35B |
| 中位完整响应延迟 | 3.74s | **3.53s** | 实际体感基本同档 |
| 冷加载 | 12.73s | **3.47s** | 8B 的明确优势只有首次加载快 |

8B 并没有以更快的暖机推理换回质量;本机后端下,35B-A3B 反而有更高的生成吞吐。若任务是沉浸式长对话,尤其要求模型主动推进、保持人物状态且避免在同一场景打转,**不应仅为窗口规格把主模型降到 8B**。

#### 长上下文:能放进去不等于用得稳、等得起

下表时间是整次非流式请求的墙钟时间,上下文长度为约数;不同模型分词器会让实际输入有少量偏差。

| 目标上下文 | 35B · Q8_0 KV | 35B · q4_0 KV | 8B · Q8_0 KV |
|-----------:|---------------:|---------------:|--------------:|
| 22K | 8/8 · 约 11.3s | 8/8 · 约 14–15s | 6/8 · 约 23.5s |
| 52K | — | 8/8 · 约 39.9s | 8/8 · 约 72.5s |
| 100K | 8/8 · 约 111.5s | 8/8 · 约 101.7s | 6/8 · 约 231.8s |
| 160K | — | 8/8 · 约 215.1s | 连接超过约 5 分钟后中断,后端仅处理到约 73% |

这里有三个反直觉结果:

- **8B 的标称长窗口不是稳定利用能力。** 它在 52K 全对,却在 22K 与 100K 都只有 6/8,说明小模型对事实位置和具体输入更敏感,不是简单地"越短越准"。
- **8B 在长输入上反而更慢。** 100K 比 35B q4_0 慢约 2.3 倍;160K 在当前路由连接时限内已不可用。
- **超长窗口的主要代价是每轮首字等待。** 35B q4_0 的 160K 虽然全对,但约 215 秒才完成。聊天每轮都重读历史,因此 192K 更像容量上限或离线文档档,不是应该长期顶满的实时工作区。

#### KV 精度:会改变生成轨迹,但没有稳定降质证据

同一个 35B、同一权重、相同种子与提示,只切换 KV cache 精度:

| 对照 | Q8_0 | q4_0 |
|------|-----:|-----:|
| 22K 精确检索 | 8/8 | 8/8 |
| 100K 精确检索 | 8/8 | 8/8 |
| 短场景双盲分 | **9.13** | 8.38 |
| 22K 长场景双盲分 | 7.90 | **9.30** |
| 思考格式标签泄漏 | 0/5 | 1/5 |

短场景 Q8_0 更好,长场景 q4_0 反而更好,评分方向发生反转。这说明 KV 量化足以让确定性生成走上不同 token 轨迹,单次输出可能变好也可能变差;但结合 22K–160K 检索全对,**本轮没有观察到 q4_0 会持续降低文本质量或上下文精度**。另一方面,q4_0 的一次格式标签泄漏和小样本波动也说明它不应被称为"完全无损"。

因此采取双档而非押注单档:

| 用途 | 服务端上下文 | KV | 客户端预算 | 选择理由 |
|------|-------------:|----|-----------:|----------|
| 日常 SillyTavern / 角色扮演 | 131,072 | Q8_0 / Q8_0 | 122,880 | 默认档;保留生成稳定性,同时远大于 32K |
| 超长文档 / 极长历史临时续接 | 196,608 | q4_0 / q4_0 | 按任务临时切换 | 100K–160K 检索稳定,容量与带宽更划算 |

#### SillyTavern 的实际压缩与输出预算

当前交互档不是等到 120K 塞满才总结:

- 摘要调用使用独立的 8,192 上下文 / 160 输出 token 非流式预设,但仍指向同一个 35B 路由,避免为了摘要反复卸载和重载模型。
- 自动总结开启,延迟 2、每批 2;最早在前几轮就开始**准备**逐段摘要。
- 真正排除旧原文并注入摘要的阈值是客户端窗口的 45%,即 `122880 × 45% = 55296` token。
- 达到阈值后保留最后一条用户消息,旧原文由短期/长期摘要接管;因此会话不是到固定轮数就结束,而是逐渐从逐字历史过渡到压缩记忆。
- 本机一个只统计 token、不查看正文的极小样本中,完整一轮中位数约 425 token。纯数学约 130 轮触发;扣掉角色卡、世界书、系统提示并考虑回复波动,**保守按 80–120 轮原文历史理解**。长回复会更早,短回复会更晚。
- 中文并非 1 字 = 1 token。本机分词样本约为 1 token ≈ 1.62 个中文字符;原先 1200 token 已约等于 1900 字,当前 1600 token 上限约 2600 字。`max_tokens` 是上限而非目标长度,给到 1600 是为了避免自然转场被硬截断,不是鼓励每轮灌满。

最终决策可以压成一句话:

> **保住 35B 的角色扮演质量;日常用 128K 级 Q8 + 约 55K 提前压缩,真正需要读取 100K 以上时才切 192K Q4。不要为了标称大窗口换成 8B,也不要把能启动的上下文上限误当成适合实时聊天的长度。**

## 八、跑通之后:接客户端

llama-server 暴露 **OpenAI 兼容端点**(`http://localhost:18080/v1`),任何能填自定义 base_url 的客户端都能接——Open WebUI、IDE 插件、CLI agent,把 base_url 指过来即可。

到这里模型已经能用了。但**"拿它干嘛、哪些活该交给它、哪些该留给 Claude Opus、要不要配云端 fallback"是另一个话题**——那才是本地模型真正值得琢磨的地方,单独一篇讲:见 [local-llm-usage.md](local-llm-usage.md)。

## 九、踩坑合集

- **systemd 内联 JSON 被吞引号** → warmup 必须 `-d @文件`。
- **核显和独显共存时模型跑到了核显上** → 用 `GGML_VK_VISIBLE_DEVICES=0`(Vulkan)或 `CUDA_VISIBLE_DEVICES=0`(CUDA)锁独显。
- **量化 KV 没开 flash-attn** → `--flash-attn` 是 `cache-type q4_0/q8_0` 的前提,否则被忽略或报错。
- **工具调用提前 EOS** → `chat-template=chatml` 显式指定,避开原生模板 bug(llama.cpp #19513)。
- **以为常驻=一直高功耗** → 用 `gpu_busy_percent`(AMD)或 `nvidia-smi`(N 卡)切档,空闲降到省电档。
- **显存预算算错** → 先估 `模型体积 + KV(层数×ctx×精度) + 缓冲 + 桌面`,留 1–2G 余量,别顶到零。

## 十、完整配置(Linux)

前面各节为讲清"做什么",只贴了关键片段;这里把整条链路的所有文件完整列出,按顺序创建,最后 enable。

> WSL2 用户直接跟本节走;纯 Windows 用户用任务计划程序替代 systemd,功耗切档按 AMD/N 卡分别处理。

### 前置:脚本 + 免密 sudo

`llama-profile-sync` 会调用 `gpu-profile-set`,后者需要 root 写 `pp_power_profile_mode`(AMD GPU sysfs);systemctl 的 `ExecStopPost` 同理。user unit 无法弹 sudo 密码提示,必须配免密:

```bash
sudo tee /usr/local/bin/gpu-profile-set <<'EOF'
#!/bin/sh
case "$1" in
    bootup_default) idx=0 ;;
    3d_full_screen) idx=1 ;;
    power_saving)   idx=2 ;;
    video)          idx=3 ;;
    vr)             idx=4 ;;
    compute)        idx=5 ;;
    [0-5])          idx=$1 ;;
    *) echo "Unknown profile: $1" >&2; exit 1 ;;
esac
printf "%s\n" "$idx" > /sys/class/drm/card0/device/pp_power_profile_mode
EOF
sudo chmod 755 /usr/local/bin/gpu-profile-set

sudo tee /usr/local/bin/llama-profile-sync <<'EOF'
#!/bin/bash
PROFILE_PATH="/sys/class/drm/card0/device/pp_power_profile_mode"
BUSY_PATH="/sys/class/drm/card0/device/gpu_busy_percent"
STATE="/tmp/llama-profile-last-busy"
BUSY_THRESHOLD=50
HOLD_SECONDS=90

current_idx() { grep '\*' "$PROFILE_PATH" | awk '{print $1}'; }

set_profile() {
    local want=$1 cur
    cur=$(current_idx)
    case "$cur" in 0|5) ;; *) return 0 ;; esac
    case "$want" in
        compute)        [ "$cur" = "5" ] && return 0 ;;
        bootup_default) [ "$cur" = "0" ] && return 0 ;;
    esac
    sudo /usr/local/bin/gpu-profile-set "$want"
}

if ! systemctl --user is-active --quiet llama-server 2>/dev/null; then
    set_profile bootup_default; exit 0
fi
busy=$(cat "$BUSY_PATH" 2>/dev/null || echo 0)
now=$(date +%s)
if [ "${busy:-0}" -ge "$BUSY_THRESHOLD" ]; then
    echo "$now" > "$STATE"
    set_profile compute
else
    last=$(cat "$STATE" 2>/dev/null || echo 0)
    [ $((now - last)) -lt "$HOLD_SECONDS" ] && set_profile compute || set_profile bootup_default
fi
EOF
sudo chmod 755 /usr/local/bin/llama-profile-sync

# 免密 sudo(username 换成自己的):
# echo "USERNAME ALL=(ALL) NOPASSWD: /usr/local/bin/gpu-profile-set" | sudo tee /etc/sudoers.d/llama-profile
```

> `pp_power_profile_mode` / `gpu_busy_percent` 是 AMD GPU sysfs 接口。N 卡功耗管理走 `nvidia-smi -pl` 或 `nvidia-persistenced`,不适用本节脚本。

### systemd user unit(三个文件)

```ini
# ~/.config/systemd/user/llama-server.service
[Unit]
Description=llama.cpp server (router mode, Vulkan)
After=network.target

[Service]
Environment=GGML_VK_VISIBLE_DEVICES=0     # Vulkan 锁独显; CUDA 用户删掉此行或换 CUDA_VISIBLE_DEVICES=0
ExecStart=/usr/bin/llama-server \
    --models-preset %h/.config/llama.cpp/models.ini \
    --models-max 1 \
    --host 0.0.0.0 \
    --port 18080 \
    --metrics
# warmup: 预加载模型进显存; -d @file 避开 systemd 引号吞掉
ExecStartPost=-/usr/bin/bash -c 'for i in $(seq 1 60); do curl -sf --max-time 90 http://127.0.0.1:18080/v1/chat/completions -H "Content-Type: application/json" -d @%h/.config/llama.cpp/warmup.json >/dev/null && exit 0; sleep 2; done'
# 服务停止时 GPU 回省电档
ExecStopPost=/usr/bin/sudo /usr/local/bin/gpu-profile-set bootup_default   # AMD 功耗管理; N 卡用户删掉此行
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target
```

```ini
# ~/.config/systemd/user/llama-gpu-sync.service
[Unit]
Description=Sync GPU profile with llama-server model state
After=llama-server.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/llama-profile-sync
```

```ini
# ~/.config/systemd/user/llama-gpu-sync.timer
[Unit]
Description=Poll llama-server model state for GPU profile switching

[Timer]
OnBootSec=15s
OnUnitActiveSec=15s

[Install]
WantedBy=timers.target
```

### warmup

```bash
echo '{"model":"qwen3.6-mtp-instruct","messages":[{"role":"user","content":"hi"}],"max_tokens":1}' \
  > ~/.config/llama.cpp/warmup.json
```

### 启用

```bash
# linger — 常驻模型的关键:退出登录后服务仍存活
sudo loginctl enable-linger "$USER"

# reload + enable
systemctl --user daemon-reload
systemctl --user enable --now llama-server.service
systemctl --user enable --now llama-gpu-sync.timer

# 验证
systemctl --user status llama-server
systemctl --user status llama-gpu-sync.timer
```

> `--user` 不是 `--system`:GPU 上下文(DRM render node)在用户会话里,跑 system unit 反而可能打不开设备;权限也小得多。

## 相关文档

- [local-llm-usage.md](local-llm-usage.md) — 跑起来之后:边界(本地 vs Opus)、能干什么、llm-jobs 自动化、实操血泪
- [software.md](software.md) — 软件栈清单
- [timers-and-crons.md](timers-and-crons.md) — llama-gpu-sync.timer 等定时任务
- [monitoring.md](monitoring.md) — 监控架构(GPU 指标经 node_exporter→Prometheus→Grafana)
