把 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——它们其实是同一组权重的不同格式 / 量化包,对应不同的推理栈。下面把这块拆清楚,后面看到任何模型仓库都不会再发懵。
格式可以分成三类:文件容器格式、量化方案、编译产物。
类别一:文件容器格式(怎么把权重和元信息存进文件)
| 格式 | 主要生态 | 单文件 | 安全 | 典型扩展名 |
|---|---|---|---|---|
| GGUF | llama.cpp / Ollama / LM Studio | 是 | 是(纯二进制) | .gguf |
| GGML(GGUF 的前身,已废弃) | 旧版 llama.cpp | 是 | 是 | .bin(同名易混淆) |
| safetensors | HuggingFace / vLLM / transformers | 大模型分片 | 是(纯二进制) | .safetensors |
| PyTorch pickle | 老 transformers / 学术 | 分片 | 否,可执行任意代码 | .bin / .pt / .pth / .ckpt |
| MLX(Apple 原生) | mlx-lm | 分片 | 是 | .npz / .safetensors |
| ONNX | ONNX Runtime / DirectML | 是 | 是 | .onnx |
两个关键区别要记住:
- GGUF 是单文件容器:权重 + tokenizer + vocab + metadata + chat template 全打包进一个
.gguf文件,迁移、分发、Ollama 的内容寻址都靠这个特性 - safetensors 是纯权重容器:只存张量,没有 tokenizer 和元信息——所以 HF 模型目录里会同时有
config.json、tokenizer.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-bit | GGUF / llama.cpp / Ollama 专属 | 下一节会展开 |
| AWQ(Activation-aware Weight Quantization) | 4-bit | vLLM / TGI / autoawq | 权重感知量化,质量损失小 |
| GPTQ | 3 / 4 / 8-bit | vLLM / TGI / AutoGPTQ | 基于二阶导数的量化 |
| FP8 | 8-bit 浮点 | vLLM(仅 H100+ 原生) | 几乎无损,速度比 BF16 快 1.5~2 倍 |
| bitsandbytes (BNB) | 4 / 8-bit | transformers / 微调 | 接入简单但速度比 AWQ/GPTQ 慢一档 |
| EETQ | 8-bit | TGI | HuggingFace 内部使用 |
关键事实:
- GGUF 用的 K-quants(Q4_K_M 这套)是 llama.cpp 自己的方案,不能被 vLLM 直接读
- 反过来 AWQ / GPTQ 量化模型也不能被 Ollama 直接用,要先转成 GGUF
- 所以同一个 7B 模型在 HuggingFace 上会有:原版 BF16(vLLM 用)/
-AWQ(vLLM 量化)/-GGUF(Ollama 用)三套仓库
类别三:编译产物(针对特定硬件预编译)
| 格式 | 谁用 | 特点 |
|---|---|---|
| TensorRT-LLM engine | TensorRT-LLM | 针对特定卡 + 特定 batch size 编译,速度极致但换卡就要重编 |
| OpenVINO IR | OpenVINO(Intel) | Intel CPU / GPU / NPU 优化 |
| CoreML | Apple Neural Engine | iOS / macOS 端,主要是小模型 |
这类格式不是通用的,是给特定硬件做了 ahead-of-time 编译。本系列不展开。
怎么辨别一个仓库是哪种格式
下载之前看仓库名和文件后缀:
| 看到的特征 | 是什么 | 怎么跑 |
|---|---|---|
仓库名带 -GGUF 或文件 .gguf | GGUF | Ollama / llama.cpp / LM Studio |
仓库名带 -AWQ,文件 .safetensors | AWQ 量化的 HF 模型 | vLLM --quantization awq |
仓库名带 -GPTQ,文件 .safetensors | GPTQ 量化的 HF 模型 | vLLM --quantization gptq |
仓库名带 -FP8,文件 .safetensors | FP8 量化 | vLLM --quantization fp8(H100+) |
只有 .safetensors 没有量化后缀 | BF16 / FP16 原版 | vLLM / transformers(要更大显存) |
仓库里只有 .bin 没有 .safetensors | 老 PyTorch pickle | 能不用就不用,安全性差 |
仓库名带 -MLX | Apple 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:72b、qwen2.5:32b、qwen2.5:7b——阿里通义千问,中文最好,对硬件要求跨度大,各尺寸都能下探llama3.3:70b、llama3.2:3b、llama3.2:1b——Meta Llama,英文强中文一般,3B/1B 小模型在移动设备/边缘部署强势deepseek-v3、deepseek-r1——DeepSeek,推理强,完整版参数量大对硬件要求高。有官方蒸馏的小版本deepseek-r1:7bgemma3:4b、gemma3:27b——Google 开源,特点是小模型质量相对高
代码
qwen2.5-coder:7b、qwen2.5-coder:32b——目前开源最强代码模型之一codellama:7b——老牌选择,但已被 qwen-coder 系列超过
Embedding
bge-m3——第 05 篇介绍过的中文 + 多语言最强开源 embeddingnomic-embed-text——英文场景的轻量选择
入门建议:先装 qwen2.5:7b,4~5 GB 大小,16GB 内存的机器就能跑,中英文能力都不错,覆盖本系列绝大部分场景。
硬件需求快速估算
模型能不能跑起来,核心看你的"可用显存/内存"和模型的"权重大小"能否匹配。一个粗略的估算公式:
运行所需内存 ≈ 模型参数量(B)× 每参数字节数 + 上下文缓存
每参数字节数由量化级别决定。Ollama 默认拉的模型通常是 4-bit 量化(Q4_K_M),即每参数 0.5 字节。几个常见配置的实际门槛:
| 模型 | 参数量 | 4-bit 量化后大小 | 推荐最低内存 |
|---|---|---|---|
| 1B 小模型 | 1B | ~0.7GB | 4GB |
| 3B | 3B | ~2GB | 8GB |
| 7B | 7B | ~4.5GB | 16GB |
| 13B | 13B | ~8GB | 24GB |
| 32B | 32B | ~20GB | 48GB |
| 72B | 72B | ~40GB | 96GB |
几个关键提醒:
- 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)——用上面这种"内容哈希当文件名"的存储方式,叫内容寻址存储。三个直接结果:
- 自动去重——两份完全相同的数据哈希一样,磁盘上只存一份
- 完整性校验——下完算一遍哈希跟文件名对比,立刻能发现传输是不是出错
- 可安全分发——给别人一个哈希,他下完算哈希跟你给的对得上就能确认没被中间人篡改
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.template | chat 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.adapter | LoRA 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.modelblob 本身就是一个完整、独立的 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.templateoverlay 里的版本(Ollama 拉模型时初始化的) - 你 Modelfile 写了
TEMPLATE→ 生成一个新的image.templateblob,覆盖默认的
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 是新的
两点重要观察:
-
qwen-py的 manifest 里,前三个 layer 都带一个"from": "qwen2.5:7b"字段,明确告诉 Ollama "这个 blob 是从父镜像继承来的"。共享的不是名字而是 SHA256 内容哈希——磁盘上image.model那个 4.7 GB 的 blob 只存一份,qwen2.5:7b 和 qwen-py 都引用它 -
你的 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 是个枚举:
| 值 | 含义 |
|---|---|
| 0 | F32(全精度) |
| 1 | F16 |
| 7 | Q8_0 |
| 14 | Q4_K_S |
| 15 | Q4_K_M(Ollama 默认拉的就是这个) |
| 17 | Q5_K_M |
| 18 | Q6_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_count | num_hidden_layers | Transformer 堆几层 |
qwen2.embedding_length | hidden_size | 每个 token 向量多少维 |
qwen2.feed_forward_length | intermediate_size | FFN 中间层多大 |
qwen2.attention.head_count | num_attention_heads | 多少个 attention head |
qwen2.attention.head_count_kv | num_key_value_heads | GQA 的 KV head 数 |
qwen2.context_length | max_position_embeddings | 最大上下文 |
qwen2.rope.freq_base | rope_theta | RoPE 频率参数 |
注意 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
两点要留意:
- 词表 152064 个 token + merges 151387 条,这两个数组加起来几 MB,是 tokenizer 体积的主要来源
tokenizer.chat_template是 Jinja2 格式(看{%- if tools %}语法)——这印证了前面说的:GGUF 内部其实也带 chat template,是 Jinja2 风格(跟 HF tokenizer_config.json 一致)。Ollama 自己额外存的image.templateblob 才是 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(精度更高!)
三点观察:
- 命名规律:
blk.<层号>.<子模块>.weight/bias。28 层模型从blk.0.*到blk.27.*,每层约 11 个张量。token_embd.weight和output.weight是首尾的 embedding / 输出投影 - shape 跟 config 一一对应:
token_embd是(3584, 152064)=(embedding_length, vocab_size);attn_k.weight是(3584, 512)印证 GQA(4 个 KV head × 128 = 512) - 不同张量用不同量化精度(重点!):
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_K | 2.8 GB | ~55 | +12% |
| Q3_K_M | 3.5 GB | ~48 | +5% |
| Q4_K_M | 4.4 GB | ~42 | +1.5% |
| Q5_K_M | 5.1 GB | ~37 | +0.6% |
| Q6_K | 6.0 GB | ~32 | +0.2% |
| Q8_0 | 7.7 GB | ~25 | ~0% |
| F16 | 14.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_M或Q6_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_HOST | 127.0.0.1:11434 | 监听地址,设成 0.0.0.0:11434 才能给局域网访问 |
OLLAMA_KEEP_ALIVE | 5m | 模型在内存里保留时间,长会话场景建议 24h 或 -1(永驻) |
OLLAMA_NUM_PARALLEL | 1(旧版) / 4(新版) | 单模型并发请求数,吃显存换吞吐 |
OLLAMA_MAX_LOADED_MODELS | 1 | 同时常驻的模型数量,多模型路由场景调大 |
OLLAMA_NUM_GPU | 自动 | 卸载到 GPU 的层数,CPU/GPU 混合时手动调 |
OLLAMA_FLASH_ATTENTION | 0 | 设为 1 启用 FlashAttention,长上下文时显存占用明显下降 |
OLLAMA_KV_CACHE_TYPE | f16 | KV 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—— 最常见的两种原因:- 文件 BOM:Windows 记事本 / 某些编辑器保存时会在文件开头插入不可见的 BOM 字节,让
FROM不在真正的第一行。file Modelfile输出里如果有with BOM就是这个问题,用cat > Modelfile << 'EOF' ... EOF在终端里重写一遍能彻底绕过 - 路径错:
ollama create -f ./Modelfile用的是当前目录,先pwd+ls Modelfile确认
- 文件 BOM:Windows 记事本 / 某些编辑器保存时会在文件开头插入不可见的 BOM 字节,让
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:7b 在 num_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:70b、llama3.2:3b(instruct 版)——支持但工具选择准确性略弱- 特定的工具优化版本如
hermes3、firefunction——针对工具调用特别训练过
纯靠 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-utilization | 0.90 | GPU 显存使用比例,多模型共卡时要调小;单模型独占调到 0.92~0.95 |
--max-model-len | 模型上限 | 单请求最大 token 数(输入 + 输出之和)。调小能省 KV cache 显存 |
--max-num-seqs | 256 | 同时处理的最大请求数。受 KV cache 容量限制,显存够大可调大 |
--max-num-batched-tokens | 自动 | 单个 batch 的最大 token 数,影响首 token 延迟 vs 吞吐 |
--dtype | auto | float16 / bfloat16 / float32。A100/H100 优先 bfloat16,T4/V100 用 float16 |
--tensor-parallel-size | 1 | 多卡张量并行的卡数,必须能整除模型的 attention heads 数 |
--pipeline-parallel-size | 1 | 流水线并行(跨多机),单机内基本不用 |
--quantization | 无 | awq / gptq / fp8 / bitsandbytes,模型本身要是对应量化格式 |
--enable-prefix-caching | false | 命中相同前缀的请求直接复用 KV cache,RAG / 多轮对话非常有用 |
--enable-chunked-prefill | 视版本 | 把长 prompt 分段 prefill,避免阻塞短请求,长上下文场景必开 |
--swap-space | 4 (GiB) | CPU 内存交换区,应对显存不够时把部分 KV cache 卸下来 |
--max-lora-rank | 16 | 启用 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、生僻字拆成多个 tokentokenizer_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-seqsvllm: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 failed | V1 引擎 + notebook 子进程冲突 | 加 VLLM_USE_V1=0 + enforce_eager=True |
torch.cuda.OutOfMemoryError | T4 16 GB 显存紧张 | gpu_memory_utilization 降到 0.7,max_model_len 降到 1024 |
CUDA error: no kernel image is available | vLLM 版本跟 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 重下也就一两分钟) |
算力单元规划
| GPU | unit/h | 100 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,本地推理生态还有几个常见框架,简单对比:
| 框架 | 主要语言 | 硬件 | 量化 | 并发模型 | 典型场景 |
|---|---|---|---|---|---|
| Ollama | Go + llama.cpp | CPU / Apple Silicon / NVIDIA / AMD | GGUF(Q2~Q8) | 串行 +(新版)并行 | 开发、本地工具、Apple Silicon |
| llama.cpp | C++ | 同上 | GGUF | 串行 / 简单并发 | 嵌入式、Ollama 的底层 |
| LM Studio | Electron 桌面 | 同 llama.cpp | GGUF | 串行 | 桌面 GUI 用户,不写代码 |
| vLLM | Python + CUDA | NVIDIA / AMD ROCm / Intel XPU | AWQ / GPTQ / FP8 / BNB | Continuous batching | 生产服务,高并发 |
| TGI(Text Generation Inference) | Rust + Python | NVIDIA / AMD | bitsandbytes / GPTQ / EETQ | Dynamic batching | HuggingFace 生态、企业级 |
| SGLang | Python + CUDA | NVIDIA | AWQ / FP8 | RadixAttention | 复杂提示流程、Agent 场景吞吐最高 |
| MLX | Python + Swift / Metal | Apple Silicon 专属 | 4-bit / 8-bit | 串行 | Apple Silicon 上的原生最优 |
| TensorRT-LLM | C++ / Python | NVIDIA(编译特定卡) | INT8 / INT4 / FP8 | 高 | 极致延迟敏感场景,迁移成本高 |
选型建议(从简到繁):
- 个人 / 小团队 / 开发期:Ollama(Apple Silicon)/ LM Studio(桌面 GUI)
- 企业内部服务、几十并发:vLLM 单卡或多卡 +
--enable-prefix-caching - 复杂 Agent / 多分支提示流程:SGLang,它的 RadixAttention 对共享前缀的处理比 vLLM 更彻底
- 极致延迟(< 100ms 首 token)+ 大规模:TensorRT-LLM,前提是团队愿意投入工程师时间做模型编译与调优
- 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 生态相对最省事的一段。
参考资料
- Ollama 官网 / 模型库 / GitHub
- Ollama 环境变量与配置 FAQ
- Ollama Modelfile 参考
- vLLM 文档 / GitHub
- vLLM 启动参数完整列表
- vLLM PagedAttention 论文
- vLLM 性能调优指南
- vLLM Production Monitoring
- llama.cpp 量化说明
- AWQ 论文 / GPTQ 论文
- SGLang — RadixAttention,复杂提示流程吞吐更优
- TGI — HuggingFace 官方推理服务
- MLX — Apple Silicon 原生推理框架
- Open WebUI — 本地模型的 ChatGPT 风格前端
- LM Studio — 桌面 GUI,零代码用本地模型
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:本地模型:用 Ollama 跑开源 LLM
本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/10-本地模型Ollama/
本文最后一次更新为 天前,文章中的某些内容可能已过时!
目录
- 为什么要跑本地模型
- 模型文件格式生态:先把谁是谁分清楚
- 安装 Ollama
- 第一个本地模型
- 模型选型:开源 LLM 一览
- 硬件需求快速估算
- Ollama 的存储结构:内容寻址 + 共享层
- GGUF 内部结构:用 Python 打开看一眼
- 量化:怎么把大模型塞进小内存
- 用 OpenAI SDK 调用 Ollama
- Ollama 进阶配置:环境变量、Modelfile、并发
- 本地 Function Calling 和结构化输出
- 生产部署:从 Ollama 升到 vLLM
- 在 Colab Pro 上跑 vLLM(演示 / 学习场景)
- 推理框架横向对比
- 实战场景:什么时候用本地
- 本篇要点
- 下一篇
- 参考资料