把 LLM 装进你自己的电脑

{{ intro_card( points=[ "Ollama 安装、服务排查、Modelfile / 环境变量进阶配置", "模型文件格式生态:GGUF / safetensors / 各种量化方案怎么选", "GGUF 内部结构:用 Python 打开看元数据 + 张量", "vLLM 生产部署:启动参数、多卡 TP、prefix caching、监控指标", "Colab Pro 上跑 vLLM 的实战代码 + 8 种推理框架横向对比", ], audience="想把开源 LLM 跑在自己电脑或服务器上、并了解从开发到生产差异的工程师", prerequisites="装过 Python、用过 OpenAI API(前 9 篇的内容)", time="35 分钟(含代码示例)", ) }}

为什么要跑本地模型

前面九篇全都在调云端 API,这是开发者入门的最低门槛——几毛钱就能把整个系列跑完。但只要你的项目稍微往前走一步,本地模型的价值就开始显现:

  • 隐私与合规——医疗记录、法律文书、企业内部文档,明确不能发到第三方 API
  • 成本——批量处理场景(给 10 万条历史数据打标签)用 API 算下来可能几千块,自己一台 Mac 跑一晚上是零边际成本
  • 延迟——本地推理省去了网络往返,对话类产品的首 token 延迟能降到几百毫秒
  • 可用性——API 会限流、会故障、会涨价,本地不会
  • 学习——理解模型的实际参数、量化、内存占用,对做 AI 应用是不可替代的直觉

本篇用 Ollama 做本地模型的入口。它是开源 LLM 领域事实上的"Docker"——统一的命令行、统一的模型仓库、OpenAI 兼容的 HTTP 接口,学会它基本就学会了本地模型的全部常用操作。生产场景会再补一段 vLLM。

模型文件格式生态:先把谁是谁分清楚

跑本地模型之前,要先理解一个事实:HuggingFace 仓库里同一个模型经常有好几个版本——Qwen2.5-7B-Instruct / Qwen2.5-7B-Instruct-AWQ / Qwen2.5-7B-Instruct-GGUF——它们其实是同一组权重的不同格式 / 量化包,对应不同的推理栈。下面把这块拆清楚,后面看到任何模型仓库都不会再发懵。

格式可以分成三类:文件容器格式量化方案编译产物

类别一:文件容器格式(怎么把权重和元信息存进文件)

格式主要生态单文件安全典型扩展名
GGUFllama.cpp / Ollama / LM Studio是(纯二进制).gguf
GGML(GGUF 的前身,已废弃)旧版 llama.cpp.bin(同名易混淆)
safetensorsHuggingFace / vLLM / transformers大模型分片是(纯二进制).safetensors
PyTorch pickle老 transformers / 学术分片否,可执行任意代码.bin / .pt / .pth / .ckpt
MLX(Apple 原生)mlx-lm分片.npz / .safetensors
ONNXONNX Runtime / DirectML.onnx

两个关键区别要记住:

  • GGUF 是单文件容器:权重 + tokenizer + vocab + metadata + chat template 全打包进一个 .gguf 文件,迁移、分发、Ollama 的内容寻址都靠这个特性
  • safetensors 是纯权重容器:只存张量,没有 tokenizer 和元信息——所以 HF 模型目录里会同时有 config.jsontokenizer.json*.safetensors 一堆文件
  • safetensors vs .bin(PyTorch pickle):功能等价,但 .bin 用 pickle 反序列化,理论上可被植入恶意代码(已有 CVE 案例)。所有新模型都应该用 safetensors

GGUF 文件内部到底有什么,用 Python 看一眼:

pip install gguf
python -c "
from gguf import GGUFReader
r = GGUFReader('qwen2.5-7b.gguf')
for f in r.fields:
    print(f, r.fields[f].parts)
"

会看到几百个键值对:general.architecture: qwen2 / general.name: Qwen2.5-7B-Instruct / qwen2.context_length: 32768 / tokenizer.ggml.tokens: [完整词表] / tokenizer.ggml.merges: [BPE 合并规则] / ……以及紧跟在元信息后面的全部 tensor 数据。这就是"单文件就能跑"背后的实质——元信息和权重在同一份文件里,加载时直接 mmap。

类别二:量化方案(怎么把权重压缩)

这些是"权重怎么压缩到 4-bit / 8-bit"的算法,不是独立文件格式。它们最终还是写在 safetensors 或 GGUF 文件里,通过元信息标识:

方案精度用在哪备注
K-quants(Q4_K_M / Q5_K_M / Q6_K / Q8_0 ...)2~8-bitGGUF / llama.cpp / Ollama 专属下一节会展开
AWQ(Activation-aware Weight Quantization)4-bitvLLM / TGI / autoawq权重感知量化,质量损失小
GPTQ3 / 4 / 8-bitvLLM / TGI / AutoGPTQ基于二阶导数的量化
FP88-bit 浮点vLLM(仅 H100+ 原生)几乎无损,速度比 BF16 快 1.5~2 倍
bitsandbytes (BNB)4 / 8-bittransformers / 微调接入简单但速度比 AWQ/GPTQ 慢一档
EETQ8-bitTGIHuggingFace 内部使用

关键事实

  • GGUF 用的 K-quants(Q4_K_M 这套)是 llama.cpp 自己的方案不能被 vLLM 直接读
  • 反过来 AWQ / GPTQ 量化模型也不能被 Ollama 直接用,要先转成 GGUF
  • 所以同一个 7B 模型在 HuggingFace 上会有:原版 BF16(vLLM 用)/ -AWQ(vLLM 量化)/ -GGUF(Ollama 用)三套仓库

类别三:编译产物(针对特定硬件预编译)

格式谁用特点
TensorRT-LLM engineTensorRT-LLM针对特定卡 + 特定 batch size 编译,速度极致但换卡就要重编
OpenVINO IROpenVINO(Intel)Intel CPU / GPU / NPU 优化
CoreMLApple Neural EngineiOS / macOS 端,主要是小模型

这类格式不是通用的,是给特定硬件做了 ahead-of-time 编译。本系列不展开。

怎么辨别一个仓库是哪种格式

下载之前看仓库名和文件后缀:

看到的特征是什么怎么跑
仓库名带 -GGUF 或文件 .ggufGGUFOllama / llama.cpp / LM Studio
仓库名带 -AWQ,文件 .safetensorsAWQ 量化的 HF 模型vLLM --quantization awq
仓库名带 -GPTQ,文件 .safetensorsGPTQ 量化的 HF 模型vLLM --quantization gptq
仓库名带 -FP8,文件 .safetensorsFP8 量化vLLM --quantization fp8(H100+)
只有 .safetensors 没有量化后缀BF16 / FP16 原版vLLM / transformers(要更大显存)
仓库里只有 .bin 没有 .safetensors老 PyTorch pickle能不用就不用,安全性差
仓库名带 -MLXApple MLX 格式mlx-lm,仅 Apple Silicon

一句话总结这一节:跟 Ollama / llama.cpp 走 → 选 GGUF;跟 vLLM / 生产服务 走 → 选 safetensors(AWQ / GPTQ / FP8 量化版);这两条路在文件层面互不通用,需要互转。

理解了这点,后面"为什么 Ollama 这套和 vLLM 这套配置完全不一样"就不会再困惑。

安装 Ollama

Ollama 支持 macOS、Linux、Windows。官网 ollama.com 直接下载对应安装包。macOS 也可以 brew install ollama

安装完成后它会作为后台服务启动,监听 http://localhost:11434。验证:

ollama --version
curl http://localhost:11434
# 返回 "Ollama is running"

如果 ollama -v 报这个

Warning: could not connect to a running Ollama instance
Warning: client version is 0.12.6

说明客户端装好了,但服务端没起来(Ollama 是 C/S 架构,跟 Docker 类似)。按系统启动:

# macOS(官网安装包)
open -a Ollama

# macOS(Homebrew)
brew services start ollama        # 后台常驻
ollama serve                       # 或前台跑,方便看日志

# Linux
sudo systemctl start ollama        # 启动
sudo systemctl enable ollama       # 开机自启
ollama serve                       # 或前台跑

# Windows
# 开始菜单点 Ollama,或命令行 ollama serve

启动后再 ollama -v 应该能看到 server 版本号;curl http://localhost:11434 返回 Ollama is running 也代表服务端正常。

第一个本地模型

Ollama 的命令行和 Docker 几乎一样:

# 拉取模型
ollama pull qwen2.5:7b

# 直接命令行对话(交互式)
ollama run qwen2.5:7b

# 列出已下载的模型
ollama list

# 删除模型
ollama rm qwen2.5:7b

第一次 ollama run 会自动先 pull。模型文件会存到 ~/.ollama/models/,一个 7B 量化模型大约 4~5 GB。

模型选型:开源 LLM 一览

2026 年初,开源 LLM 的中文场景主流选择:

通用对话(按综合能力从强到弱)

  • qwen2.5:72bqwen2.5:32bqwen2.5:7b——阿里通义千问,中文最好,对硬件要求跨度大,各尺寸都能下探
  • llama3.3:70bllama3.2:3bllama3.2:1b——Meta Llama,英文强中文一般,3B/1B 小模型在移动设备/边缘部署强势
  • deepseek-v3deepseek-r1——DeepSeek,推理强,完整版参数量大对硬件要求高。有官方蒸馏的小版本 deepseek-r1:7b
  • gemma3:4bgemma3:27b——Google 开源,特点是小模型质量相对高

代码

  • qwen2.5-coder:7bqwen2.5-coder:32b——目前开源最强代码模型之一
  • codellama:7b——老牌选择,但已被 qwen-coder 系列超过

Embedding

  • bge-m3——第 05 篇介绍过的中文 + 多语言最强开源 embedding
  • nomic-embed-text——英文场景的轻量选择

入门建议:先装 qwen2.5:7b,4~5 GB 大小,16GB 内存的机器就能跑,中英文能力都不错,覆盖本系列绝大部分场景。

硬件需求快速估算

模型能不能跑起来,核心看你的"可用显存/内存"和模型的"权重大小"能否匹配。一个粗略的估算公式:

运行所需内存 ≈ 模型参数量(B)× 每参数字节数 + 上下文缓存

每参数字节数由量化级别决定。Ollama 默认拉的模型通常是 4-bit 量化(Q4_K_M),即每参数 0.5 字节。几个常见配置的实际门槛:

模型参数量4-bit 量化后大小推荐最低内存
1B 小模型1B~0.7GB4GB
3B3B~2GB8GB
7B7B~4.5GB16GB
13B13B~8GB24GB
32B32B~20GB48GB
72B72B~40GB96GB

几个关键提醒:

  • Mac Apple Silicon 非常合适——M 系列芯片的统一内存架构让大模型也能跑,M1 Max 32GB 可以舒服跑 13B,M2 Ultra 128GB 可以跑 72B
  • 纯 CPU 能跑但慢——没有 GPU 的机器 7B 模型可能只有每秒 5~10 个 token,用来做日常对话就很受挫
  • Windows + N 卡——CUDA 支持良好,性能好于同价位 Mac,但要自行管理驱动和显存
  • 上下文越长越吃内存——32K 上下文比 4K 上下文多占很多内存,拉不动时先把 context 缩小

Ollama 的存储结构:内容寻址 + 共享层

GGUF 单文件容器是什么前面"模型文件格式生态"已经讲了。这一节讲 Ollama 怎么把 GGUF 文件落到磁盘上——它不是直接把 .gguf 丢在 ~/.ollama 里完事,而是像 Docker 镜像那样按内容寻址(content-addressable)拆成 blob 存储,多模型共享底层文件。理解这个机制,对 debug、清理空间、做自定义模型衍生都很有用。

先理清几个概念:blob / 内容寻址 / manifest / layer

Ollama 这套存储借鉴了 Docker 镜像和 Git 的设计。理解下面四个词,整个机制就清楚了:

blob——全称 Binary Large OBject。在这套体系里特指一段按内容哈希命名的、不可修改的字节序列。普通文件按"路径 + 文件名"找,blob 按"SHA256 哈希"找。文件名就是数据本身算出来的 64 位十六进制串:

sha256-2bada8a7450677000f678be90653b85d364de7db25eb5ea54136ada5f3933730

改一个字节,哈希就变了。所以 blob 一旦写入就不能修改,要"改"只能存一份新内容(新哈希)。

内容寻址(content-addressable)——用上面这种"内容哈希当文件名"的存储方式,叫内容寻址存储。三个直接结果:

  1. 自动去重——两份完全相同的数据哈希一样,磁盘上只存一份
  2. 完整性校验——下完算一遍哈希跟文件名对比,立刻能发现传输是不是出错
  3. 可安全分发——给别人一个哈希,他下完算哈希跟你给的对得上就能确认没被中间人篡改

layer(层)——一个模型不会被作为一整块 blob 存,而是拆成几个小块:权重一块、chat template 一块、默认 system prompt 一块、LICENSE 一块……每一块叫一个 layer,对应磁盘上一个 blob。拆开存的好处是衍生模型可以共享部分 layer 不重存——后面 qwen-py 复用 qwen2.5:7b 的 4.7 GB 权重 blob,新增的只有自己的几百字节 system 和 params。这套设计直接借鉴自 Docker 镜像(Docker 一个 image 就是若干 layer 组成的)。

manifest(清单)——一个 JSON 文件,列出"这个逻辑模型由哪些 blob(按哈希)组成"。它本身很小(几百字节),是 ollama run qwen2.5:7b 那个逻辑名和磁盘上散落 blob 之间的索引。等价于 Docker 的 image manifest,事实上 Ollama 用的就是 Docker / OCI 的 manifest 格式(application/vnd.docker.distribution.manifest.v2+json),所以你 ollama push 出去的模型也能被 Docker registry 兼容工具读取。

把这四个概念串起来:ollama run qwen2.5:7b → 找 manifests/.../qwen2.5/7b 这个 JSON → 看里面列了哪几个 layer / blob 的 hash → 去 blobs/ 目录读对应 blob → 加载到内存。

~/.ollama 目录长什么样

~/.ollama/
├── models/
│   ├── blobs/                                    # 实际数据,按内容 SHA256 命名
│   │   ├── sha256-2bada8a7...                    # GGUF 权重(qwen2.5:7b,4.7 GB)
│   │   ├── sha256-183715c4...                    # GGUF 权重(qwen2.5:1.5b,1 GB)
│   │   ├── sha256-2f15b321...                    # config(487 B,指向当前模型的元信息)
│   │   ├── sha256-eb440283...                    # chat template(Go text/template,1.5 KB)
│   │   ├── sha256-66b9ea09...                    # 默认 system prompt(68 B)
│   │   ├── sha256-832dd9e0...                    # LICENSE(11 KB)
│   │   └── ...
│   └── manifests/                                # 清单:"逻辑名" → "blob 列表"
│       └── registry.ollama.ai/
│           └── library/
│               └── qwen2.5/
│                   ├── 7b                        # qwen2.5:7b 的 manifest
│                   ├── 1.5b                      # qwen2.5:1.5b 的 manifest
│                   └── latest
├── cache/                                        # Ollama 自己用的缓存(推荐模型列表等)
│   └── model-recommendations.json
├── history                                       # ollama run 的命令历史
├── id_ed25519                                    # 服务实例的密钥(几百字节)
└── id_ed25519.pub

manifests/.../7b 是个 JSON 文件,列出这个模型由哪些 blob 组成。实际拉下来的 qwen2.5:7b 的真实 manifest 长这样(我机器上当前的内容,blob 哈希都是真实的):

cat ~/.ollama/models/manifests/registry.ollama.ai/library/qwen2.5/7b \
  | python -m json.tool
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "digest": "sha256:2f15b3218f0552c60647ce60ada83632d2c09755b16259b13e3e4458e9ae419d",
    "size": 487
  },
  "layers": [
    { "mediaType": "application/vnd.ollama.image.model",    "digest": "sha256:2bada8a7...", "size": 4683073952 },
    { "mediaType": "application/vnd.ollama.image.system",   "digest": "sha256:66b9ea09...", "size": 68 },
    { "mediaType": "application/vnd.ollama.image.template", "digest": "sha256:eb440283...", "size": 1482 },
    { "mediaType": "application/vnd.ollama.image.license",  "digest": "sha256:832dd9e0...", "size": 11343 }
  ]
}

每一种 mediaType 对应一类内容,但不是所有模型都同时拥有全部 layer。哪些必有、哪些条件出现:

layer mediaType内容出现条件典型大小
application/vnd.ollama.image.model模型权重 + tokenizer + metadata(GGUF 单文件)总是有几 GB
application/vnd.ollama.image.templatechat template(多轮消息怎么拼成 prompt,Ollama 用 Go text/template,注意不是 HF 那边的 Jinja2)几乎总有(chat/instruct 模型;纯 base 模型可能没有)1~2 KB
application/vnd.ollama.image.system默认 system prompt(纯文本)模型自带默认 system prompt 时;或者你的 Modelfile 里写了 SYSTEM几十到几百字节
application/vnd.ollama.image.license许可证文本模型作者给 LICENSE 文件时几 KB 到几十 KB
application/vnd.ollama.image.params模型默认 PARAMETER(temperature 等)只在 Modelfile 写了 PARAMETER ... 才会出现;官方原版模型一般没有几十字节
application/vnd.ollama.image.adapterLoRA adapter 权重Modelfile 里写了 ADAPTER ./xxx.gguf 才有几十 MB
application/vnd.ollama.image.projector视觉编码器 / mmproj只有多模态模型才有(llava、qwen2.5-vl、moondream 等)几百 MB

注意:tokenizer 没有独立的 layer——因为 GGUF 是单文件容器,权重 / tokenizer / vocab / metadata 已经全部打包在 image.model 那个 blob 里了。这点和 HuggingFace 格式(tokenizer.json + *.safetensors 分开)不同。

"GGUF 是单文件" vs "Ollama 存一个模型却有好几个 blob" 看上去矛盾,其实不

读到这你可能会困惑:前面"模型文件格式生态"说 GGUF 是单文件,怎么 Ollama 一个模型对应 4~5 个 blob?

两件事不冲突:

  • GGUF 作为文件格式确实是单文件——那个几 GB 的 image.model blob 本身就是一个完整、独立的 GGUF 文件。把它复制出来 cp xxx /tmp/qwen.gguf 改个扩展名,能直接被 llama.cpp 加载,里面打包了权重 + tokenizer + 词表 + 所有元信息(包括 GGUF 内置的 chat template)
  • Ollama 在 GGUF 之外额外维护了几个小 overlay blob(template / system / params / license)

可以验证那个大 blob 真的是 GGUF。看文件头前 4 个字节:

head -c 4 ~/.ollama/models/blobs/sha256-2bada8a7... | xxd
# 47 47 55 46  GGUF        ← magic number 就是 ASCII "GGUF"

也可以直接用 gguf 库读它的元信息:

pip install gguf
python3 -c "
from gguf import GGUFReader
r = GGUFReader('/path/to/sha256-2bada8a7...')
for f in r.fields:
    if 'template' in f or 'chat' in f:
        print(f)
"
# 会看到 tokenizer.chat_template 等字段,证明 GGUF 内部其实也带 chat template

那为什么 Ollama 不直接用 GGUF 里的 template,还要单独存一份?

因为 Ollama 需要让你"覆盖"。GGUF 是不可修改的二进制文件,写进去就改不了。如果强制用 GGUF 内置的 template,Modelfile 里的 TEMPLATE 指令就没法生效。

所以 Ollama 的设计是用 overlay layer 机制让这些小元信息可以独立覆盖

  • 你 Modelfile 没写 TEMPLATE → 用 image.template overlay 里的版本(Ollama 拉模型时初始化的)
  • 你 Modelfile 写了 TEMPLATE → 生成一个新的 image.template blob,覆盖默认的

SYSTEM / PARAMETER 同理。这就是 layer 拆分的根本动机:让大块的权重 + tokenizer 锁在不可改的 GGUF 里享受去重,把可能要被用户覆盖的小元信息拆出来做成可独立覆盖的 overlay

顺带一提:Ollama 的 image.template 用的不是 Jinja2,是 Go text/template——因为 Ollama 是 Go 写的,看到的语法是 {{ if .Messages }}{{ .System }}{{ end }} 这种 Go template 风格。HuggingFace 的 tokenizer_config.json 里的 chat_template 才是 Jinja2。两套模板引擎写法接近但不通用。

用一个真实例子看"什么时候有 / 没有"

光看表抽象。直接对比两个真实 manifest:qwen2.5:7b(官方原版)qwen-py(前面用 Modelfile 自定义的版本,FROM qwen2.5:7b + PARAMETER + SYSTEM)

qwen2.5:7b 的 layers

有  image.model      (4.7 GB,GGUF 权重)
有  image.system     (68 B,模型自带的默认 system prompt)
有  image.template   (1.5 KB,Qwen2.5 的 chat template)
有  image.license    (11 KB,Apache 2.0)
无  image.params     ← 官方没预设默认 PARAMETER
无  image.adapter    ← 没有 LoRA
无  image.projector  ← 纯文本模型,没有视觉编码器

qwen-py 的 layers

有  image.model      ← 复用 qwen2.5:7b 的同一个 blob(digest 完全一样)
有  image.template   ← 复用 qwen2.5:7b 的同一个 blob(digest 完全一样)
有  image.license    ← 复用 qwen2.5:7b 的同一个 blob(digest 完全一样)
有  image.system     ← 新 blob(230 B),因为你在 Modelfile 里写了新的 SYSTEM
有  image.params     ← 新 blob(88 B),因为 Modelfile 里有 PARAMETER,整个 layer 是新的

两点重要观察

  1. qwen-py 的 manifest 里,前三个 layer 都带一个 "from": "qwen2.5:7b" 字段,明确告诉 Ollama "这个 blob 是从父镜像继承来的"。共享的不是名字而是 SHA256 内容哈希——磁盘上 image.model 那个 4.7 GB 的 blob 只存一份,qwen2.5:7b 和 qwen-py 都引用它

  2. 你的 Modelfile 里有 SYSTEM """..."""PARAMETER ...,所以 image.system 被你的新内容覆盖(hash 变了,从 66b9ea09...e6a68fa6...),image.params 多出来一个新 layer

这正是"内容寻址 + 共享层"设计的好处:你做 N 个自定义衍生模型,重达 4.7 GB 的权重只存一次,每个衍生只多占几百字节的 system / params 增量。

可以自己跑一遍验证:

# 看官方原版有哪些 layer
cat ~/.ollama/models/manifests/registry.ollama.ai/library/qwen2.5/7b \
  | python -m json.tool | grep mediaType

# 看你自定义的衍生模型有哪些 layer
cat ~/.ollama/models/manifests/registry.ollama.ai/library/qwen-py/latest \
  | python -m json.tool | grep -E "mediaType|from"

# 对比两个的 digest,相同的就是被共享的 blob

怎么判断一个模型是不是多模态

最快的方法:拉下来之后看 manifest 里有没有 image.projector

ollama pull llava:7b
cat ~/.ollama/models/manifests/registry.ollama.ai/library/llava/7b \
  | python -m json.tool | grep mediaType
# 多模态模型会多一个 application/vnd.ollama.image.projector layer

常用的查看命令:

ollama list                          # 列出本地所有模型
ollama show qwen2.5:7b               # 看模型元信息(架构、context、量化)
ollama show qwen2.5:7b --modelfile   # 看完整的 Modelfile 定义
du -sh ~/.ollama/models/             # 看磁盘总占用

一个 "看模型文件" 的实操练习

# 拉个最小模型
ollama pull qwen2.5:1.5b

# 看本地 manifests 目录
ls -la ~/.ollama/models/manifests/registry.ollama.ai/library/qwen2.5/

# 看 manifest 内容(这个模型由哪些 blob 组成)
cat ~/.ollama/models/manifests/registry.ollama.ai/library/qwen2.5/1.5b \
  | python -m json.tool

# 看 blob 文件大小
ls -lh ~/.ollama/models/blobs/ | head -10

# 看模型元信息
ollama show qwen2.5:1.5b

# 看完整的 Modelfile
ollama show qwen2.5:1.5b --modelfile

跑完这一套,你对"模型在磁盘上到底是什么"就有具象感觉了。后面无论调 vLLM、做量化、自托管,都不会再对着一堆文件名发懵。

GGUF 内部结构:用 Python 打开看一眼

讲完"模型在磁盘上长什么样",再往下一层——把那个几 GB 的 GGUF blob 真的打开,看看里面具体放了些什么。理解这一层后,"量化"那节的 Q4_K_M / Q6_K 这些就不是抽象的级别名,而是看得见摸得着的张量精度选择。

装个库 + 一个能读的脚本

pip install gguf

最简版(你之前那段)只 print(field.parts) 会输出裸 numpy 数组,看着像乱码。下面是解码到可读形式的版本:

from gguf import GGUFReader, GGUFValueType

# 直接指向 Ollama 的 blob,或者一个独立的 .gguf 文件
path = "/Users/你/.ollama/models/blobs/sha256-2bada8a7..."
r = GGUFReader(path)

print(f"元数据字段数: {len(r.fields)}, 张量数: {len(r.tensors)}\n")

# 元数据
for name, field in r.fields.items():
    t = field.types[0] if field.types else None
    if t == GGUFValueType.STRING:
        val = field.parts[-1].tobytes().decode('utf-8')
        print(f"{name} = {val[:100]!r}")
    elif t == GGUFValueType.ARRAY:
        print(f"{name} = <array, {len(field.data)} 个元素>")
    else:
        try:
            print(f"{name} = {field.parts[-1][0]}")
        except Exception:
            print(f"{name} = <{t.name if t else 'unknown'}>")

# 张量
for ts in r.tensors[:5]:
    print(f"{ts.name}  shape={tuple(ts.shape)}  dtype={ts.tensor_type.name}")

4 个命名空间 + 张量列表

qwen2.5:7b 实际输出 37 个元数据字段 + 339 个张量,元数据按命名空间分成 4 块:

GGUF.*(3 个)—— 容器格式版本信息

GGUF.version       = 3      # GGUF 格式版本(V1/V2 已经过时,主流 V3)
GGUF.tensor_count  = 339    # 这个文件里有多少个张量
GGUF.kv_count      = 34     # 多少个 key-value 元数据对

读 GGUF 文件最先解析这块——确认格式版本能读,然后知道后面要读多少元数据 + 张量。

general.*(16 个)—— 模型卡片信息(不影响推理,但决定 UI 展示和管理)

general.architecture          = 'qwen2'              # 架构家族
general.name                  = 'Qwen2.5 7B Instruct'
general.size_label            = '7B'
general.basename              = 'Qwen2.5'            # 基础模型名
general.finetune              = 'Instruct'           # 微调类型
general.license               = 'apache-2.0'
general.base_model.0.name     = 'Qwen2.5 7B'         # 基于哪个 base model 微调
general.file_type             = 15                   # 量化类型 ID
general.quantization_version  = 2                    # 量化方案版本

general.architecture 是关键——告诉加载器"按 qwen2 的方式解析后面的字段和张量"。换成 llama 模型这里就是 'llama',加载器走 llama 分支。

general.file_type 是个枚举:

含义
0F32(全精度)
1F16
7Q8_0
14Q4_K_S
15Q4_K_M(Ollama 默认拉的就是这个)
17Q5_K_M
18Q6_K

<架构>.*(这里是 qwen2.*,8 个)—— 真正影响推理的超参

qwen2.block_count                       = 28          # Transformer 层数
qwen2.context_length                    = 32768       # 最大上下文(32K)
qwen2.embedding_length                  = 3584        # 隐藏层维度
qwen2.feed_forward_length               = 18944       # FFN 中间层维度
qwen2.attention.head_count              = 28          # attention head 数量
qwen2.attention.head_count_kv           = 4           # KV head 数量 → 28/4 = 7-to-1 GQA
qwen2.rope.freq_base                    = 1000000.0   # RoPE 位置编码基频
qwen2.attention.layer_norm_rms_epsilon  = 1e-06

这些就是 HuggingFace config.json 里同一组字段的 GGUF 版本

GGUF 字段HF config.json 对应字段意义
qwen2.block_countnum_hidden_layersTransformer 堆几层
qwen2.embedding_lengthhidden_size每个 token 向量多少维
qwen2.feed_forward_lengthintermediate_sizeFFN 中间层多大
qwen2.attention.head_countnum_attention_heads多少个 attention head
qwen2.attention.head_count_kvnum_key_value_headsGQA 的 KV head 数
qwen2.context_lengthmax_position_embeddings最大上下文
qwen2.rope.freq_baserope_thetaRoPE 频率参数

注意 head_count = 28 vs head_count_kv = 4——这表示 7-to-1 的 GQA(Grouped Query Attention):28 个 Query head 共享 4 个 K/V head。这是 Qwen2.5 / Llama 3 主流模型节省 KV cache 显存的关键技巧(前面 vLLM 章节里 --tensor-parallel-size 必须能整除 head_count 的约束也是查这里的字段)。

tokenizer.*(10 个)—— tokenizer 全套

tokenizer.ggml.model            = 'gpt2'                   # BPE 算法家族(不是说用 GPT-2 模型)
tokenizer.ggml.pre              = 'qwen2'                  # pre-tokenizer 方案
tokenizer.ggml.tokens           = <array, 152064 elements> # 完整词表
tokenizer.ggml.token_type       = <array, 152064 elements> # 每个 token 的类型
tokenizer.ggml.merges           = <array, 151387 elements> # BPE 合并规则
tokenizer.ggml.bos_token_id     = 151643
tokenizer.ggml.eos_token_id     = 151645
tokenizer.ggml.add_bos_token    = False                    # Qwen2.5 不自动加 BOS
tokenizer.chat_template         = '{%- if tools %}...'     # Jinja2 chat template

两点要留意

  1. 词表 152064 个 token + merges 151387 条,这两个数组加起来几 MB,是 tokenizer 体积的主要来源
  2. tokenizer.chat_templateJinja2 格式(看 {%- if tools %} 语法)——这印证了前面说的:GGUF 内部其实也带 chat template,是 Jinja2 风格(跟 HF tokenizer_config.json 一致)。Ollama 自己额外存的 image.template blob 才是 Go template 风格,是 Ollama 自己额外维护用于 overlay 覆盖的

r.tensors —— 339 个权重张量

跑一下张量列表前几个:

token_embd.weight              shape=(3584, 152064)  dtype=Q4_K   # embedding 矩阵
blk.0.attn_norm.weight         shape=(3584,)         dtype=F32    # 第 0 层 attention 前的 norm
blk.0.attn_q.weight            shape=(3584, 3584)    dtype=Q4_K   # 第 0 层 Q 投影
blk.0.attn_k.weight            shape=(3584, 512)     dtype=Q4_K   # K(4 个 head × 128 = 512)
blk.0.attn_v.weight            shape=(3584, 512)     dtype=Q4_K   # V 同理
blk.0.attn_output.weight       shape=(3584, 3584)    dtype=Q4_K   # attention 输出投影
blk.0.ffn_norm.weight          shape=(3584,)         dtype=F32    # FFN 前的 norm
blk.0.ffn_gate.weight          shape=(3584, 18944)   dtype=Q4_K   # FFN gate(SwiGLU)
blk.0.ffn_up.weight            shape=(3584, 18944)   dtype=Q4_K   # FFN up
blk.0.ffn_down.weight          shape=(18944, 3584)   dtype=Q6_K   # FFN down(精度更高!)

三点观察:

  1. 命名规律blk.<层号>.<子模块>.weight/bias。28 层模型从 blk.0.*blk.27.*,每层约 11 个张量。token_embd.weightoutput.weight 是首尾的 embedding / 输出投影
  2. shape 跟 config 一一对应token_embd(3584, 152064) = (embedding_length, vocab_size)attn_k.weight(3584, 512) 印证 GQA(4 个 KV head × 128 = 512)
  3. 不同张量用不同量化精度(重点!):
    • norm.weight / bias 这种敏感的小张量用 F32(不量化)
    • 主体权重(attn_q/k/v/output, ffn_gate/up)用 Q4_K(4-bit)
    • ffn_down.weight 单独用 Q6_K(6-bit,更高精度)—— 因为 FFN down 这一步对量化误差最敏感,损精度会明显伤输出质量

这种逐张量自适应精度就是 "Q4_K_M" 里那个 "M"(Mixed)的来源,区别于 Q4_0 的"所有张量一刀切 4-bit"。这也是为什么 Q4_K_M 在 4-bit 段质量明显好过 Q4_0——它把宝贵的高精度位用在了对量化最敏感的权重上。

一句话总结

GGUF 内部 = 4 块元数据 + 一堆张量

  • GGUF.* = 容器格式版本
  • general.* = 模型卡片(名字、许可、量化类型)
  • <架构>.* = 推理超参(block_count / embedding_length / GQA 配置等)
  • tokenizer.* = tokenizer 全套(词表、merges、特殊 token、Jinja2 chat template)
  • 张量按 blk.<层号>.<子模块> 命名,同一文件里不同张量可以用不同量化精度——这是 K-quants 比 Q4_0 质量好的根本原因

下一节正式讲量化,K-quants 那些 Q4_K_M / Q5_K_M / Q6_K 的命名就有具体含义了——M 是 Mixed,K 是 K-quants 这套方案,数字是 bit 数。

量化:怎么把大模型塞进小内存

"量化"是把模型权重从 16-bit 浮点降到 4-bit、8-bit 整数,用精度损失换内存占用。不同量化级别的命名惯例(Ollama / llama.cpp 社区通用):

  • Q2_K——2-bit,极致压缩,多数场景下质量损失明显,仅推荐资源极度受限时
  • Q3_K_M——3-bit,比 Q4 再小约 25%,质量损失开始可感
  • Q4_K_M——4-bit 量化,质量与体积的平衡点,Ollama 默认。推荐
  • Q4_K_S——4-bit 的精简变体,比 Q4_K_M 再小约 8%,质量略低
  • Q5_K_M——5-bit,相对 Q4 质量略好,内存多约 25%
  • Q6_K——6-bit,接近原始精度,内存大约是 Q4 的 1.5 倍
  • Q8_0——8-bit,接近无损但内存约为 Q4 的两倍
  • F16——16-bit 原始精度,主要用于微调或研究

qwen2.5:7b 为例,不同量化的体积与典型推理速度参考(M2 Max 64GB):

量化文件大小推理速度(tok/s)困惑度(PPL)相对损失
Q2_K2.8 GB~55+12%
Q3_K_M3.5 GB~48+5%
Q4_K_M4.4 GB~42+1.5%
Q5_K_M5.1 GB~37+0.6%
Q6_K6.0 GB~32+0.2%
Q8_07.7 GB~25~0%
F1614.4 GB~14基准

(PPL 数字来自 llama.cpp 社区实测,仅作参考。不同模型、不同数据集差异较大。)

想手动指定量化级别:

ollama pull qwen2.5:7b-instruct-q8_0
ollama pull qwen2.5:7b-instruct-q5_k_m
ollama pull qwen2.5:7b-instruct-fp16

经验取舍:

  • 日常对话、问答、RAG:直接用默认 Q4_K_M,质量足够
  • 代码生成、结构化输出:建议升到 Q5_K_MQ6_K,量化损失对边界情况更敏感
  • 微调底座、研究对比:用 F16,避免量化误差污染实验结果
  • 小内存设备(树莓派、嵌入式):可以试 Q3_K_M,但要在自己的评测集上验证质量是否够用

用 OpenAI SDK 调用 Ollama

这是 Ollama 生态设计上最聪明的一步:它内置了 OpenAI 兼容的 HTTP 接口。你在前面几篇写的所有代码,只要把 base_url 换掉就能切到本地模型:

from openai import OpenAI

client = OpenAI(
    api_key="ollama",  # 任意非空字符串,本地不校验
    base_url="http://localhost:11434/v1",
)

resp = client.chat.completions.create(
    model="qwen2.5:7b",
    messages=[{"role": "user", "content": "用一句话解释什么是协程"}],
)
print(resp.choices[0].message.content)

甚至流式、tools、多轮对话都兼容。这意味着你的应用可以在开发时用云端 API 调试,生产时切到本地部署,代码完全不动

给 02 篇那个 ai.py 封装加一个环境变量开关就行:

# ai.py
import os
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()

USE_LOCAL = os.getenv("USE_LOCAL_LLM") == "1"

if USE_LOCAL:
    _client = OpenAI(api_key="ollama", base_url="http://localhost:11434/v1")
    DEFAULT_MODEL = "qwen2.5:7b"
else:
    _client = OpenAI(
        api_key=os.getenv("DEEPSEEK_API_KEY"),
        base_url="https://api.deepseek.com/v1",
    )
    DEFAULT_MODEL = "deepseek-chat"

切换本地/云端只要 export USE_LOCAL_LLM=1,业务代码零改动。

Ollama 进阶配置:环境变量、Modelfile、并发

默认配置能跑,但要把 Ollama 调到接近"能给小团队用"的状态,需要了解几个关键开关。

常用环境变量(在启动前 export,或写进 systemd / launchd):

变量默认值说明
OLLAMA_HOST127.0.0.1:11434监听地址,设成 0.0.0.0:11434 才能给局域网访问
OLLAMA_KEEP_ALIVE5m模型在内存里保留时间,长会话场景建议 24h-1(永驻)
OLLAMA_NUM_PARALLEL1(旧版) / 4(新版)单模型并发请求数,吃显存换吞吐
OLLAMA_MAX_LOADED_MODELS1同时常驻的模型数量,多模型路由场景调大
OLLAMA_NUM_GPU自动卸载到 GPU 的层数,CPU/GPU 混合时手动调
OLLAMA_FLASH_ATTENTION0设为 1 启用 FlashAttention,长上下文时显存占用明显下降
OLLAMA_KV_CACHE_TYPEf16KV cache 精度,可选 q8_0 / q4_0 进一步省显存

例:把 Ollama 暴露给同一局域网内的同事,并让模型常驻不卸载:

export OLLAMA_HOST=0.0.0.0:11434
export OLLAMA_KEEP_ALIVE=-1
export OLLAMA_NUM_PARALLEL=4
ollama serve

Modelfile:自定义模型参数与系统提示

Ollama 用 Modelfile(语法类似 Dockerfile)来定义模型变体,可以预设 system prompt、温度、上下文长度等。注意两个要点:

  • PARAMETER 行不能写行尾注释# xxx 写在同一行末尾会被当成参数值的一部分,导致 parse 错误。注释要单独一行
  • FROM 必须是第一行非空非注释内容,否则会报 Error: no FROM line

正确写法:

FROM qwen2.5:7b

# 推理参数
PARAMETER temperature 0.3
PARAMETER top_p 0.9

# 上下文窗口(默认 2048,qwen2.5:7b 上限 32K)
PARAMETER num_ctx 8192

# 单次生成上限
PARAMETER num_predict 2048

# 重复惩罚(1.0 不惩罚,1.05 温和,超过 1.15 容易影响代码质量)
PARAMETER repeat_penalty 1.05

# 默认 system prompt
SYSTEM """
你是一个严谨的 Python 工程师助手,回答要求:
1. 代码优先用最近 3 年的标准库与主流第三方库
2. 给出代码块时一并说明所需依赖版本
3. 不确定的地方明确指出,不编造 API
"""

构建并验证:

ollama create qwen-py -f ./Modelfile

# 验证 PARAMETER 都生效
ollama show qwen-py --modelfile

# 试聊一句
ollama run qwen-py "你好,介绍一下你自己"

之后业务代码里 model="qwen-py" 就行,无需在每次请求里重复发送 system prompt。

Modelfile 常见报错排查

  • Error: no FROM line —— 最常见的两种原因:
    1. 文件 BOM:Windows 记事本 / 某些编辑器保存时会在文件开头插入不可见的 BOM 字节,让 FROM 不在真正的第一行。file Modelfile 输出里如果有 with BOM 就是这个问题,用 cat > Modelfile << 'EOF' ... EOF 在终端里重写一遍能彻底绕过
    2. 路径错ollama create -f ./Modelfile 用的是当前目录,先 pwd + ls Modelfile 确认
  • Error: unknown parameter ... —— 多半是 PARAMETER 行尾跟了注释(见上文),把注释挪到独立行
  • 用最简版(只保留 FROM + 一个 PARAMETER)能 build 成功 → 说明 Ollama 没问题,原 Modelfile 内容有格式问题,建议用 heredoc 在终端重写

并发与显存权衡

OLLAMA_NUM_PARALLEL=N 会让单个模型同时处理 N 个请求,但每个请求都需要独立的 KV cache 空间。粗略估算:单请求 KV cache ≈ num_ctx × hidden_size × num_layers × 2 × 2 字节(f16)。qwen2.5:7bnum_ctx=8192 下,单请求 KV cache 约 0.5~1 GB。

并发开 4:基础模型权重 4.5 GB + 4 × ~1 GB KV cache ≈ 8.5 GB。16 GB 内存的机器可以稳定跑,8 GB 的机器只能并发 1。

本地 Function Calling 和结构化输出

新版 Ollama 对 Function Calling 和结构化输出的支持已经比较完善,但不是所有模型都一样——模型本身需要在训练时就见过工具调用的数据。推荐在以下模型上使用:

  • qwen2.5:7b 及以上——原生支持 Function Calling,质量较好
  • llama3.3:70bllama3.2:3b(instruct 版)——支持但工具选择准确性略弱
  • 特定的工具优化版本如 hermes3firefunction——针对工具调用特别训练过

纯靠 Prompt 让 7B 模型稳定输出 JSON 比云端大模型难一些,搭配 instructor 库(04 篇)的重试机制能大幅提升成功率。

生产部署:从 Ollama 升到 vLLM

Ollama 适合开发、演示、低并发场景。真正上线要处理几十上百并发请求时,Ollama 的串行推理会成为瓶颈,这时候换 vLLM

  • PagedAttention:把 KV cache 分页管理,碎片率从普通推理的 60%~80% 降到不到 4%
  • Continuous batching:请求到达即拼批,GPU 利用率长期保持在 80%+
  • 真正的批处理并发:一张 A100 在常见 7B 模型上能稳定服务 200~500 个并发请求
  • 兼容 Hugging Face 模型格式与 OpenAI API 协议:迁移成本小

上面两个核心机制需要稍微展开一下:

  • KV cache——LLM 推理时,每生成一个 token 都要拿当前 token 跟前面所有 token 算 attention。如果每次都从头算前面所有 token 的 K(Key)和 V(Value),开销是 O(N²)。所以工程上会把前面已经算好的 K/V 存起来下次直接用,这块缓存就叫 KV cache,是 LLM 推理时显存的主要消耗大头之一
  • PagedAttention——传统实现给每个请求预分配一整块连续显存当 KV cache,但实际生成长度不确定,多数请求占不满,造成 60%~80% 的内存浪费。vLLM 借鉴操作系统虚拟内存的思路:把 KV cache 切成固定大小的"页"(page),用到才分配,不连续也无所谓,整块显存利用率拉到 96% 以上。这是 vLLM 吞吐数倍于普通推理的核心机制
  • Continuous batching(连续批处理)——传统 batch 是"凑齐 N 个请求一起跑,跑完再凑下一批",所有请求得等最慢那个。vLLM 在每个 forward step 之间动态调整 batch:哪个请求已经生成完了立刻拿走,新到的请求随时塞进来,GPU 不闲一分钟

vLLM 部署复杂度明显高于 Ollama(需要 NVIDIA GPU + CUDA、Python 环境、模型下载、生产环境还要负载均衡和监控),不建议在原型阶段引入。但你要知道它存在,规模上去时能平滑切换。

安装 vLLM

vLLM 当前主流要求 Linux + NVIDIA GPU(CUDA 12.x),最低 Compute Capability 7.0(V100 之后的卡都支持)。AMD ROCm、Intel XPU、TPU 版本也在迭代但成熟度较低。

# 用 uv(推荐)
uv pip install vllm

# 或者 pip
pip install vllm

# 验证
python -c "from vllm import LLM; print('ok')"

如果你的环境是 conda,建议单独建 env —— vLLM 依赖 PyTorch 的具体 CUDA build,常和已有环境冲突:

conda create -n vllm python=3.11 -y
conda activate vllm
uv pip install vllm

启动 OpenAI 兼容服务

vLLM 的最常用方式是 vllm serve —— 启动一个 OpenAI 兼容的 HTTP 服务:

vllm serve Qwen/Qwen2.5-7B-Instruct \
  --host 0.0.0.0 \
  --port 8000 \
  --gpu-memory-utilization 0.9 \
  --max-model-len 8192 \
  --max-num-seqs 64 \
  --dtype bfloat16 \
  --enable-prefix-caching

跑起来后,端点是 http://localhost:8000/v1,可以直接复用 OpenAI SDK:

from openai import OpenAI

client = OpenAI(api_key="EMPTY", base_url="http://localhost:8000/v1")
resp = client.chat.completions.create(
    model="Qwen/Qwen2.5-7B-Instruct",
    messages=[{"role": "user", "content": "解释一下 PagedAttention"}],
)
print(resp.choices[0].message.content)

关键启动参数详解

vLLM 的启动参数多达几十个,下面这些是生产环境真正需要调的:

参数默认说明与调参建议
--model必填HuggingFace repo id 或本地路径,如 /data/models/Qwen2.5-7B
--gpu-memory-utilization0.90GPU 显存使用比例,多模型共卡时要调小;单模型独占调到 0.92~0.95
--max-model-len模型上限单请求最大 token 数(输入 + 输出之和)。调小能省 KV cache 显存
--max-num-seqs256同时处理的最大请求数。受 KV cache 容量限制,显存够大可调大
--max-num-batched-tokens自动单个 batch 的最大 token 数,影响首 token 延迟 vs 吞吐
--dtypeautofloat16 / bfloat16 / float32。A100/H100 优先 bfloat16,T4/V100 用 float16
--tensor-parallel-size1多卡张量并行的卡数,必须能整除模型的 attention heads 数
--pipeline-parallel-size1流水线并行(跨多机),单机内基本不用
--quantizationawq / gptq / fp8 / bitsandbytes,模型本身要是对应量化格式
--enable-prefix-cachingfalse命中相同前缀的请求直接复用 KV cache,RAG / 多轮对话非常有用
--enable-chunked-prefill视版本把长 prompt 分段 prefill,避免阻塞短请求,长上下文场景必开
--swap-space4 (GiB)CPU 内存交换区,应对显存不够时把部分 KV cache 卸下来
--max-lora-rank16启用 LoRA 适配器服务时的最大 rank
--served-model-name模型 id客户端调用时用的 model 字段名,可以改成短名比如 qwen-7b
--api-key设置后客户端必须带 Authorization: Bearer xxx

一个生产环境(单卡 A100 80GB,跑 Qwen2.5-32B-Instruct AWQ 量化版)的典型启动命令:

vllm serve Qwen/Qwen2.5-32B-Instruct-AWQ \
  --host 0.0.0.0 --port 8000 \
  --served-model-name qwen-32b \
  --quantization awq \
  --dtype float16 \
  --gpu-memory-utilization 0.92 \
  --max-model-len 16384 \
  --max-num-seqs 128 \
  --enable-prefix-caching \
  --enable-chunked-prefill \
  --api-key sk-internal-xxxxxxxx

vLLM 用的 HuggingFace 模型文件长什么样

vLLM 加载的是 HuggingFace 格式——和 Ollama 用的 GGUF 单文件不一样,HF 把模型拆成一堆散文件:

Qwen2.5-7B-Instruct/
├── config.json                              # 模型架构配置(必读)
├── generation_config.json                   # 默认生成参数
├── tokenizer.json                           # 快速 tokenizer 二进制
├── tokenizer_config.json                    # tokenizer 元信息 + chat template
├── special_tokens_map.json                  # 特殊 token(BOS/EOS/PAD)映射
├── vocab.json                               # 词表(BPE 模型有)
├── merges.txt                               # BPE 合并规则
├── model.safetensors                        # 单文件权重(小模型)
├── model-00001-of-00004.safetensors         # 分片权重(> ~5GB 的模型)
├── model-00002-of-00004.safetensors
├── model-00003-of-00004.safetensors
├── model-00004-of-00004.safetensors
├── model.safetensors.index.json             # 分片索引(哪个权重在哪个分片里)
├── README.md
└── LICENSE

几个关键文件 vLLM 启动时都会读:

config.json —— 模型架构定义,决定"这是什么模型、多大、多少层"。打开看几个关键字段:

cat config.json | python -m json.tool | head -20
{
  "architectures": ["Qwen2ForCausalLM"],
  "hidden_size": 3584,                  // 隐藏层维度
  "num_hidden_layers": 28,              // Transformer 层数
  "num_attention_heads": 28,            // attention head 数量
  "num_key_value_heads": 4,             // GQA 的 KV head 数
  "intermediate_size": 18944,           // FFN 中间层维度
  "vocab_size": 152064,                 // 词表大小
  "max_position_embeddings": 32768,     // 最大上下文长度
  "torch_dtype": "bfloat16",            // 训练时的精度
  ...
}

vLLM 启动时 --tensor-parallel-size 必须能整除 num_attention_heads,就是查这里的字段。

generation_config.json —— 模型作者推荐的默认生成参数(temperature / top_p / repetition_penalty 等)。不传 SamplingParams 时会用这里的默认值。

tokenizer.json + tokenizer_config.json

  • tokenizer.json:HuggingFace "快速 tokenizer" 的二进制格式,包含完整词表 + BPE 合并规则,加载快。BPE(Byte-Pair Encoding)是把"高频字节组合"合并成一个 token 的算法,让"hello" 变成 1 个 token、生僻字拆成多个 token
  • tokenizer_config.json:tokenizer 元信息,最重要的字段是 chat_template —— 一段 Jinja2 模板,定义"多轮对话怎么拼成一个字符串送进模型"。Jinja2 是 Python 生态最常用的模板引擎(Flask / Ansible 都在用),{% %} 是控制语句,{{ }} 是变量插值
cat tokenizer_config.json \
  | python -c "import sys, json; print(json.load(sys.stdin)['chat_template'])"

Qwen2.5 的输出:

{%- for message in messages %}
{{- '<|im_start|>' + message.role + '\n' + message.content + '<|im_end|>\n' }}
{%- endfor %}

这就是为什么 messages=[{"role":"user","content":"..."}] 能被模型正确理解 —— tokenizer 按这个模板把消息列表拼成最终送进模型的字符串。

*.safetensors —— 真正的权重文件

大于约 5 GB 的模型会切成多个分片(model-00001-of-00004.safetensors),model.safetensors.index.json 是索引,告诉加载器"哪个权重在哪个分片里"。

用 Python 看一眼权重内容:

from safetensors import safe_open

with safe_open("model.safetensors", framework="pt") as f:
    for key in list(f.keys())[:5]:
        tensor = f.get_tensor(key)
        print(f"{key:60s}  shape={tuple(tensor.shape)}  dtype={tensor.dtype}")

输出大概像:

model.embed_tokens.weight                      shape=(152064, 3584)  dtype=torch.bfloat16
model.layers.0.self_attn.q_proj.weight         shape=(3584, 3584)    dtype=torch.bfloat16
model.layers.0.self_attn.k_proj.weight         shape=(512, 3584)     dtype=torch.bfloat16
model.layers.0.self_attn.v_proj.weight         shape=(512, 3584)     dtype=torch.bfloat16
model.layers.0.self_attn.o_proj.weight         shape=(3584, 3584)    dtype=torch.bfloat16

每一项是一个权重张量,名字反映了它在网络里的位置(哪一层、哪个子模块)。

HuggingFace 缓存:vLLM 下载到哪里了

vLLM 不会直接把模型存在你执行 vllm serve 的目录,它走 HuggingFace Hub 缓存——也是内容寻址 + 软链接的设计("内容寻址"概念跟 Ollama 那节讲的一样;"软链接" symlink 是 Unix 的一种特殊文件,本身只存一个"指向真实文件路径"的字符串,访问软链接相当于访问被指向的真实文件,类似 Windows 的快捷方式但更轻量):

~/.cache/huggingface/hub/
├── models--Qwen--Qwen2.5-7B-Instruct/
│   ├── refs/
│   │   └── main                      # 文本文件,内容是当前 commit hash
│   ├── snapshots/
│   │   └── 3d5d59d.../               # 一个 commit hash 对应一个目录
│   │       ├── config.json -> ../../blobs/abc...      # 全是软链接
│   │       ├── tokenizer.json -> ../../blobs/def...
│   │       ├── model.safetensors -> ../../blobs/ghi...
│   │       └── ...
│   └── blobs/                        # 真正的数据文件,按 hash 命名
│       ├── abc...                    # 配置内容
│       ├── def...                    # tokenizer
│       └── ghi...                    # 权重
└── version.txt

refs/main → 指向当前用的 commit hash → 那个 hash 对应的 snapshots/<hash>/ 目录里全是软链接 → 软链接指向 blobs/ 里实际的数据文件。

这种设计的好处

  • 同一个 repo 不同 revision(不同 commit)共享相同的 blob,多 checkout 不占额外空间
  • 切换 revision 只是把软链接重指向,零拷贝

自定义缓存位置(C 盘满 / SSD 不够时常用):

export HF_HOME=/data/huggingface       # 整个 HF 缓存搬走
# 或只搬模型缓存
export HUGGINGFACE_HUB_CACHE=/data/hf_hub

清理缓存

du -sh ~/.cache/huggingface/hub/

# 用 HF 官方工具清理(推荐,安全)
pip install -U "huggingface_hub[cli]"
huggingface-cli scan-cache              # 看每个模型占多少
huggingface-cli delete-cache            # 交互式删除

多卡张量并行

模型放不下单卡时(比如单卡 24 GB 跑 32B 模型),用 --tensor-parallel-size 在多卡上切分:

# 4 张卡跑 70B 模型
vllm serve meta-llama/Llama-3.3-70B-Instruct \
  --tensor-parallel-size 4 \
  --gpu-memory-utilization 0.9 \
  --max-model-len 8192 \
  --dtype bfloat16

注意:

  • TP size 必须能整除模型的 attention heads 数(比如 70B 模型有 64 个 head,TP=4 / 8 OK,TP=3 / 5 不行)
  • 卡之间需要 NVLink 或者高带宽 PCIe,否则通信开销吃掉收益
  • TP=2 相比 TP=1 不是 2 倍吞吐,通常是 1.6~1.8 倍

离线批处理:直接用 LLM

不需要 HTTP 服务、只想批量处理一批 prompt 时,用 LLM 类更直接:

from vllm import LLM, SamplingParams

llm = LLM(
    model="Qwen/Qwen2.5-7B-Instruct",
    gpu_memory_utilization=0.9,
    max_model_len=4096,
    dtype="bfloat16",
)

sampling_params = SamplingParams(
    temperature=0.3,
    top_p=0.9,
    max_tokens=512,
)

prompts = [
    "用一句话解释什么是 PagedAttention。",
    "解释一下 Continuous Batching。",
    "vLLM 和 Ollama 的主要区别?",
]

# 一次批量推理,自动 batching
outputs = llm.generate(prompts, sampling_params)
for o in outputs:
    print(o.outputs[0].text)

这种方式适合:给 10 万条数据打标签、跑离线评测、批量生成训练数据。同样的硬件,批处理吞吐通常是逐条请求的 5~10 倍。

vLLM 的量化路径

vLLM 不直接读 Ollama 用的 GGUF 量化(虽然有实验支持),它原生支持的量化主要是:

  • AWQ(Activation-aware Weight Quantization):4-bit,权重感知量化,质量损失小,速度快。Qwen / Llama / Mistral 主流模型在 HuggingFace 上都有官方或社区 AWQ 版本(搜 *-AWQ 后缀)
  • GPTQ:4-bit,基于二阶导数的逐层量化,质量与 AWQ 接近,部分模型 GPTQ 版本更新更慢
  • FP8(H100 及以上才支持原生):8-bit 浮点,质量几乎无损,速度比 BF16 快 1.5~2 倍。需要 vllm>=0.5 + Hopper 架构 GPU
  • bitsandbytes:8-bit / 4-bit,集成简单但速度比 AWQ/GPTQ 慢一档,适合快速验证

启动量化模型时模型本身要是对应量化格式,例如:

# AWQ 量化模型
vllm serve Qwen/Qwen2.5-32B-Instruct-AWQ --quantization awq

# FP8(仅 H100/H200/B200)
vllm serve neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8 --quantization fp8

GGUF ↔ HuggingFace 格式互转

前面"模型文件格式生态"那节讲过,GGUF 量化(K-quants)和 HF 量化(AWQ / GPTQ / FP8)互不通用。两边生态之间偶尔需要互转。

HF → GGUF(自己微调的模型想给 Ollama 用):

# llama.cpp 自带转换脚本
git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp

# 1) 先转成 GGUF(fp16)
python convert_hf_to_gguf.py ../path/to/Qwen2.5-7B-Instruct \
  --outfile qwen2.5-7b.gguf \
  --outtype f16

# 2) 再量化到 Q4_K_M(可选,体积从 14 GB → 4.5 GB)
./llama-quantize qwen2.5-7b.gguf qwen2.5-7b-q4_k_m.gguf Q4_K_M

然后用 Modelfile 引入到 Ollama:

FROM ./qwen2.5-7b-q4_k_m.gguf

PARAMETER temperature 0.3
SYSTEM "你是 Python 助手"

构建:ollama create my-qwen -f ./Modelfile

GGUF → HF:不常见,因为 GGUF 已经损失了一些训练相关的元信息,不可逆。需要 HF 格式时一般直接去原始的 HuggingFace 仓库下载。

性能调优经验

几个反复有效的经验:

  • --enable-prefix-caching 几乎总该开:RAG 系统的 retrieved chunks 重复率很高、多轮对话的 system prompt 完全相同,prefix cache 命中率经常能到 30%~70%,对应 token 成本直接打折
  • --max-num-seqs 大不等于吞吐高:调到显存即将爆的临界值通常最优,再大会触发 swap 反而变慢。建议从 64 开始,逐倍翻
  • --enable-chunked-prefill 让短请求不被长请求阻塞:当系统里同时有 8K 长 prompt 和 200-token 短请求时,开了之后 P99 延迟能降一半
  • --max-model-len 不要直接设到模型上限:每个并发请求都按这个分配 KV cache 上限,128K 上下文 × 64 并发 = 几十 GB 显存全用在 cache 上,吞吐反而下降
  • 优先用 bfloat16:A100/H100/L40S 上 bfloat16 数值范围比 float16 大,长上下文更稳定,速度持平

监控与可观测性

vLLM 暴露 Prometheus 指标在 /metrics,几个关键指标:

  • vllm:num_requests_running / vllm:num_requests_waiting:实时处理与排队中的请求数。waiting 长期 > 0 说明吞吐不够
  • vllm:gpu_cache_usage_perc:KV cache 占用率。长期 > 95% 要么调大显存、要么调小 max-num-seqs
  • vllm:time_to_first_token_seconds:首 token 延迟。chunked prefill 没开时这个值会被长 prompt 拉爆
  • vllm:time_per_output_token_seconds:每 token 延迟。受批大小影响,大批量会变大但吞吐更高

接 Grafana 模板可以直接用 vLLM 官方仓库的 grafana.json

在 Colab Pro 上跑 vLLM(演示 / 学习场景)

如果手头没有 GPU 又想动手跑 vLLM,Colab Pro 是最划算的方式:每月 $9.99 + 100 算力单元,T4 大约 2 unit/h、L4 大约 5 unit/h、A100 大约 12~15 unit/h。日常学习推荐 T4 + 1.5B/3B 小模型L4 + 7B,A100 留给跑 32B AWQ 做质量对比。

但 vLLM 在 Colab 这种 notebook 环境里不能直接照搬服务器配置——它默认的 V1 引擎在 notebook 子进程模型下经常初始化失败。下面这套是 T4 上验证过能稳的最小可跑版:

# ── Cell 1:环境 ──
import os
os.environ["VLLM_USE_V1"] = "0"            # 关 V1 引擎,notebook 兼容性好
os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "1"

!nvidia-smi                                 # 确认 GPU 在
!pip install -q vllm hf_transfer

# ── Cell 2:加载模型 ──
from vllm import LLM, SamplingParams

llm = LLM(
    model="Qwen/Qwen2.5-1.5B-Instruct",     # 演示用 1.5B,加载快
    dtype="float16",                        # T4 / V100 只能 fp16;L4 / A100 可以 bfloat16
    gpu_memory_utilization=0.80,            # Colab 上别开太满,留余地
    max_model_len=2048,                     # 演示用 2K 就够,省 KV cache
    enforce_eager=True,                     # 不编译 CUDA graph,notebook 启动更稳
)

# ── Cell 3:推理 ──
sp = SamplingParams(temperature=0.3, max_tokens=300)
out = llm.generate(["用一句话解释 PagedAttention"], sp)
print(out[0].outputs[0].text)

几个 Colab 上跑 vLLM 必踩的坑

报错原因解法
RuntimeError: Engine core initialization failedV1 引擎 + notebook 子进程冲突VLLM_USE_V1=0 + enforce_eager=True
torch.cuda.OutOfMemoryErrorT4 16 GB 显存紧张gpu_memory_utilization 降到 0.7,max_model_len 降到 1024
CUDA error: no kernel image is availablevLLM 版本跟 Colab 预装 torch 不匹配pip install vllm==0.6.3 锁稳定版
第一次 LLM(...) 卡 15 分钟首次下载 14 GB 模型到 Colab 临时盘HF_HOME 挂到 Drive 缓存:os.environ["HF_HOME"] = "/content/drive/MyDrive/hf_cache"
UserWarning: Error while fetching HF_TOKEN没设 HuggingFace token公开模型可以忽略;要下载门禁模型(Llama 等)才需要在 Colab 左侧 Secrets 面板(钥匙形图标)里加 HF_TOKEN
Drive 挂载 400 错浏览器多账号 / 第三方 cookie 被禁无痕窗口只登一个账号;或者干脆不挂 Drive(模型重连会重下,1.5B 重下也就一两分钟)

算力单元规划

GPUunit/h100 unit 能跑适合
T4~2~50 小时学概念、跑 1.5B/3B、Q4 量化 7B
L4~5~20 小时7B BF16 / 13B AWQ / 32B AWQ(紧)
V100~5~20 小时同 L4 但显存 16 GB,能跑的模型小一档
A100~12~15~7 小时32B BF16 / 70B AWQ,只在质量对比时用

预下模型省演示时间:在课前一晚用一个 cell 跑:

from huggingface_hub import snapshot_download
snapshot_download("Qwen/Qwen2.5-1.5B-Instruct")
snapshot_download("Qwen/Qwen2.5-7B-Instruct-AWQ")

之后任何 notebook 挂同一个 Drive(HF_HOME 指向 Drive 的同一目录),模型从 Drive 直接读,T4 上 1.5B 模型 30 秒能加载完。

Colab 上跑 vLLM 不适合的场景

  • 长期服务:Colab session 最长 24 小时,到点必断,不要拿来当生产 API
  • 高并发压测:T4 共享、A100 排队,吞吐数字不稳定,做 benchmark 用 RunPod / Modal 更可靠
  • 跑 70B BF16:单卡 Colab 装不下,且 Colab 不提供多卡 runtime

推理框架横向对比

除了 Ollama 和 vLLM,本地推理生态还有几个常见框架,简单对比:

框架主要语言硬件量化并发模型典型场景
OllamaGo + llama.cppCPU / Apple Silicon / NVIDIA / AMDGGUF(Q2~Q8)串行 +(新版)并行开发、本地工具、Apple Silicon
llama.cppC++同上GGUF串行 / 简单并发嵌入式、Ollama 的底层
LM StudioElectron 桌面同 llama.cppGGUF串行桌面 GUI 用户,不写代码
vLLMPython + CUDANVIDIA / AMD ROCm / Intel XPUAWQ / GPTQ / FP8 / BNBContinuous batching生产服务,高并发
TGI(Text Generation Inference)Rust + PythonNVIDIA / AMDbitsandbytes / GPTQ / EETQDynamic batchingHuggingFace 生态、企业级
SGLangPython + CUDANVIDIAAWQ / FP8RadixAttention复杂提示流程、Agent 场景吞吐最高
MLXPython + Swift / MetalApple Silicon 专属4-bit / 8-bit串行Apple Silicon 上的原生最优
TensorRT-LLMC++ / PythonNVIDIA(编译特定卡)INT8 / INT4 / FP8极致延迟敏感场景,迁移成本高

选型建议(从简到繁):

  1. 个人 / 小团队 / 开发期:Ollama(Apple Silicon)/ LM Studio(桌面 GUI)
  2. 企业内部服务、几十并发:vLLM 单卡或多卡 + --enable-prefix-caching
  3. 复杂 Agent / 多分支提示流程:SGLang,它的 RadixAttention 对共享前缀的处理比 vLLM 更彻底
  4. 极致延迟(< 100ms 首 token)+ 大规模:TensorRT-LLM,前提是团队愿意投入工程师时间做模型编译与调优
  5. HuggingFace 深度用户:TGI,和 transformers 生态结合最自然

对 99% 的中小项目来说,选型不会复杂到这一步——Ollama 开发 → vLLM 生产这条最朴素的路径能覆盖绝大多数需求。

实战场景:什么时候用本地

结合几个真实场景感受下本地模型的定位:

适合本地的

  • 批处理文档打标签、提取结构化字段(不需要最新知识,速度稳定)
  • 内部工具类聊天助手(数据不出内网)
  • 代码补全(低延迟比 smarts 更重要)
  • 边缘设备上的小能力(智能家居语音唤醒、App 内助手)

不适合本地的

  • 需要前沿推理能力的任务——开源模型目前仍落后 Claude/GPT 旗舰一档
  • 极低频、一次性任务——不如直接调 API 省事
  • 对小团队或个人无法摊销 GPU 成本的场景

本地和云端不是二选一,很多成熟产品是混合架构:简单任务走本地省钱,难题路由到云端。

本篇要点

  • 格式生态先分清:GGUF(单文件,Ollama / llama.cpp)和 safetensors(散文件,HuggingFace / vLLM)互不通用。同一个模型在 HF 上常见三套仓库:原版 BF16 / -AWQ / -GGUF,给不同栈用
  • 量化方案分两套:GGUF 用 K-quants(Q4_K_M / Q5_K_M ...),vLLM 用 AWQ / GPTQ / FP8 / BNB
  • Ollama 是开源 LLM 的 "Docker",命令行、模型仓库、OpenAI 兼容 API 一站式
  • 入门首选 qwen2.5:7b,4~5GB 大小、16GB 内存可用、中英都强
  • 硬件门槛主要看可用内存和量化级别,Apple Silicon 的统一内存对大模型友好
  • Ollama 存储是内容寻址 + 共享层:每个模型由若干 image.model / template / system / license / params / adapter / projector 层组成,多模型共享底层 blob
  • Ollama 量化按场景选:日常用 Q4_K_M,代码/结构化输出建议 Q5_K_M 或 Q6_K,研究用 F16
  • Ollama 进阶用 Modelfile 固化系统提示和参数,环境变量调并发 / 显存 / 上下文
  • 通过 base_url=http://localhost:11434/v1 直接复用之前所有 OpenAI SDK 代码
  • 生产级部署换 vLLM:PagedAttention + Continuous Batching,吞吐是 Ollama 的几倍到几十倍
  • vLLM 关键参数:gpu-memory-utilization / max-model-len / max-num-seqs / enable-prefix-caching
  • vLLM 用 HF 模型文件(config.json + tokenizer.json + *.safetensors),下到 ~/.cache/huggingface/hub/ 的 refs/snapshots/blobs 三层软链接缓存
  • 多卡用 --tensor-parallel-size,但要满足 head 数能整除的约束
  • 跨生态用 llama.cpp/convert_hf_to_gguf.py 把 HF 模型转成 GGUF 给 Ollama 用
  • 本地和云端是互补,根据任务特性路由

下一篇

第 11 篇进入 UI 层:用 Streamlit 把前面写好的所有能力(Chat、RAG、Function Calling、Agent)做成一个真正能用的聊天应用,支持流式输出、多轮对话、会话保存、文件上传。这是把 AI 应用推到用户面前的最后一公里,也是 Python 生态相对最省事的一段。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:本地模型:用 Ollama 跑开源 LLM

本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/10-本地模型Ollama/

本文最后一次更新为 天前,文章中的某些内容可能已过时!

目录