模块 10 · 第 4 课

在自己电脑上跑模型:Ollama 和量化

把开源模型放到自己电脑上运行。先估算一个模型要占多少内存,再手写量化,看权重压到 8 位、4 位、2 位时变小多少、变差多少,最后用 Ollama 跑起来。

  • 约 40 分钟
  • 难度:进阶
  • 实测:2026-09-15 torch 2.14,Apple M4 CPU;Ollama 用法以其官方文档为准

第一部分一直在调用 DeepSeek 的 API。这很方便,但有些场合你会希望模型跑在自己的机器上:数据不能出公司、没有网络、调用量大到 API 太贵,或者只是想试试。

这一课讲在自己电脑上运行开源模型。关键的问题是:我的电脑装得下多大的模型?答案取决于两件事,模型有多少参数,以及每个参数用几个字节存。

python memory_estimate.py
python quantize.py

估算内存

模型的权重是一大堆数字。每个数字用 32 位小数(FP32)存要 4 个字节,用 16 位(BF16 或 FP16)要 2 个字节。所以权重占的内存大约是:

参数量 × 每个参数的字节数

以第 3 课用的 Qwen2.5-0.5B-Instruct 为例(它有 4.94 亿个参数):

== 1. Qwen2.5-0.5B-Instruct 的权重,在不同精度下占多少
  FP32        1.84 GB
  BF16/FP16   0.92 GB
  INT8        0.46 GB
  4 比特        0.23 GB

它下载下来的文件 model.safetensors 是 988 MB,就是 BF16 的大小(988 MB 约等于 0.92 GB,差别是 1000 和 1024 的换算)。

运行时,除了权重,还有第 09 模块第 6 课讲的 KV 缓存。它的大小可以从模型的配置算出来:每个词元、每一层,存一份 K 和一份 V:

def kv_cache_gb(n_layers, n_kv_heads, head_dim, n_tokens, bytes_per_value=2):
    # 每个词元、每一层,要存一个 K 和一个 V,各是 n_kv_heads × head_dim 个数
    return 2 * n_layers * n_kv_heads * head_dim * n_tokens * bytes_per_value / 1024**3
== 2. 它的 KV 缓存(每层 2 组 K/V,每组 64 维,BF16)
  每个词元 12 KB
    1000 个词元:0.01 GB
   32000 个词元:0.37 GB
  如果不用分组查询注意力(14 个头各存一份 K/V),32000 个词元要 2.56 GB,是现在的 7 倍

最后一行是分组查询注意力的作用:Qwen2.5-0.5B 有 14 个注意力头,但只有 2 组 K、V,缓存省了 7 倍。上下文长的时候,这个差距非常关键。

实际估算时,一个简单的规则是:参数量 × 每个参数的字节数,再留两成余量给缓存和其他开销:

== 3. 粗略估算:参数量 × 每个参数的字节数,再留两成余量给缓存和其他开销
   0.5 B 参数:FP32    2.2 GB  BF16/FP16    1.1 GB  INT8    0.6 GB  4 比特    0.3 GB
     7 B 参数:FP32   31.3 GB  BF16/FP16   15.6 GB  INT8    7.8 GB  4 比特    3.9 GB
    14 B 参数:FP32   62.6 GB  BF16/FP16   31.3 GB  INT8   15.6 GB  4 比特    7.8 GB
    32 B 参数:FP32  143.1 GB  BF16/FP16   71.5 GB  INT8   35.8 GB  4 比特   17.9 GB
    70 B 参数:FP32  312.9 GB  BF16/FP16  156.5 GB  INT8   78.2 GB  4 比特   39.1 GB

这张表可以直接拿来用。一台 16 GB 内存的笔记本,BF16 只能勉强跑 7B 的模型;换成 4 比特,14B 的模型也能跑。长上下文要另外多算 KV 缓存。

从表里也能看出为什么大家都在谈"量化":同一个模型,4 比特只要 BF16 的四分之一。

手写量化

量化就是用更少的比特存每个参数。最简单的做法叫对称量化:一组数里找出绝对值最大的,把范围 [-最大值, 最大值] 平均分成若干格,每个数就近落到一个格子上,只存格子的编号(一个小整数)和这组数的缩放系数。

def quantize(w, bits, group=None):
    """对称量化:每组数用一个缩放系数,把 [-最大绝对值, 最大绝对值] 映射到整数 [-qmax, qmax]。
    返回"量化后再还原"的权重,以及实际要存的整数和缩放系数。"""
    qmax = 2 ** (bits - 1) - 1  # 8 比特是 127,4 比特是 7
    shape = w.shape
    w = w.reshape(-1, group) if group else w.reshape(shape[0], -1)  # 按组,或者按行
    scale = w.abs().amax(dim=1, keepdim=True) / qmax
    q = torch.round(w / scale).clamp(-qmax, qmax)  # 这就是要存下来的整数
    return (q * scale).reshape(shape), q, scale

看一个例子,8 个数量化成 4 比特(-7 到 7 的整数):

== 1. 一个例子:把 8 个小数量化成 4 比特整数
  原来:   [0.077, -0.0147, -0.1089, 0.0284, -0.0542, -0.0699, 0.0202, 0.0419]
  整数:   [5, -1, -7, 2, -3, -4, 1, 3](缩放系数 0.01556)
  还原后: [0.0778, -0.0156, -0.1089, 0.0311, -0.0467, -0.0623, 0.0156, 0.0467]

最大的 -0.1089 对应 -7,缩放系数就是 0.1089 / 7 = 0.01556。其他数除以它再四舍五入。还原时乘回去,大部分数的误差在 0.003 左右,-0.0542 变成了 -0.0467,误差大一些。

每个数从 32 位变成了 4 位,只多存了一个缩放系数。

量化我们的小 GPT

把第 09 模块训练的小 GPT 的所有矩阵都量化,看看模型变小了多少、变差了多少(LayerNorm 和偏置很小,保持原样):

== 2. 整个模型量化之后
                                大小      验证损失
  32 位小数(原模型)             6.16 MB     4.476   白露起春光,知君得舞衣。雪时疑是静,山意势悠扬。
  8 比特,每行一个系数             1.58 MB     4.476   白露起春光,知君得舞衣。雪时疑是静,山意势悠扬。
  4 比特,每行一个系数             0.81 MB     4.542   云泉四百古,一树两三五。雪路之山在,山闾势悠扬。
  4 比特,每 32 个数一个系数        0.89 MB     4.512   白露起春光,知君得舞衣。雪时疑报晓,山下势悠扬。
  2 比特,每 32 个数一个系数        0.51 MB     6.933   百日起春光津倚郭对舞儿年,一之州在子。闾里里里里

每一行用同样的随机种子写一首诗,方便比较。

8 比特:大小变成原来的四分之一,验证损失是 4.476,和原模型一模一样,连写出的诗都一字不差。8 比特量化几乎是"免费"的。

4 比特,每行一个系数:大小再减半,损失从 4.476 升到 4.542,诗也变了。一行有几百个数,只要其中有一个特别大的,缩放系数就被它撑大,其他数都只能挤在少数几个格子里,误差就大了。

4 比特,每 32 个数一个系数:多存一些缩放系数(大小从 0.81 MB 变成 0.89 MB),损失降回 4.512,诗也基本回到了原来的样子,只改了两处。分组越小,每组的缩放系数越贴合这组数,误差越小。实际使用的 4 比特量化,基本都是分组的。

2 比特:只有 -1、0、1 三个值可用,模型彻底坏了,损失 6.93,写出来的东西格式全乱,最后还在重复"里里里里"。

结论和实际使用的经验一致:8 比特几乎无损;4 比特有一点损失,但换来了四分之一的大小,是在自己电脑上跑模型最常用的选择;再往下,效果会迅速变差。

真实的量化方法比这里复杂:会处理特别大的"异常值",会根据一些数据来决定怎么量化误差最小,还有专门为 4 比特设计的数据格式(第 2 课提到的 QLoRA 就用了一种叫 NF4 的格式)。但基本原理就是这一节的这几行代码。

用 Ollama 运行模型

自己写代码量化、运行大模型很繁琐。Ollama 是一个把这些都打包好的工具:一条命令下载一个已经量化好的模型并运行。

安装(截至 2026 年 9 月,以官方说明为准):

# macOS 和 Linux
curl -fsSL https://ollama.com/install.sh | sh

# Windows(PowerShell)
irm https://ollama.com/install.ps1 | iex

macOS 和 Windows 也可以从 ollama.com 下载安装包。

运行第 3 课用过的 Qwen2.5-0.5B:

ollama run qwen2.5:0.5b

第一次运行会自动下载模型,然后进入对话。截至 2026 年 9 月,Ollama 模型库里的 qwen2.5:0.5b 默认是 Q4_K_M 量化的版本,大小 398 MB,比原来 BF16 的 988 MB 小了一半多。Q4_K_M 是 llama.cpp 定义的一种 4 比特分组量化格式,和上面手写的"4 比特,分组"是一个思路,只是更精细。

同一个模型通常有好几个量化版本可选,比如 qwen2.5:0.5b-instruct-q8_0(8 比特)、qwen2.5:0.5b-instruct-fp16(16 位,不量化)。按上面的内存估算选择合适的版本。

用 API 调用本地模型

Ollama 运行后,会在本机的 11434 端口提供一个和 OpenAI 兼容的接口。这意味着第一部分写的所有代码,改三个环境变量就能换成本地模型:

export LLM_BASE_URL=http://localhost:11434/v1
export LLM_API_KEY=ollama        # 本地服务不检查密钥,但 openai 库要求有一个值
export LLM_MODEL=qwen2.5:0.5b

第 00 模块第 2 课把模型的地址、密钥、名字都放在环境变量里,就是为了这一刻。

不过要有合理的预期:0.5B 的模型在第 3 课已经见识过,它会把北京的地理说错。本地能流畅运行的小模型,在复杂任务上和 DeepSeek 这类大模型差距很大。第 06 模块的评估集这时又派上了用场:换成本地模型后跑一遍,看看哪些任务它还能胜任。

用上自己微调的模型

Ollama 也能运行自己的模型。它可以导入 Hugging Face 格式(safetensors)的模型和 GGUF 格式的模型,方法是写一个叫 Modelfile 的配置文件,用 FROM 指向模型文件,再用 ollama create 创建。

第 3 课微调的 RepoBot,可以先把 LoRA 合并回原模型(merge_and_unload)再保存,然后按官方文档导入。官方文档特别说明:导入 GGUF 模型时 Ollama 不会帮你量化,需要先用 llama.cpp 的工具量化好。具体步骤请以 Ollama 的 导入文档 为准,这部分变化比较快。

练习

  1. 查一下你电脑的内存(或显卡的显存),按本课的估算表,你最大能跑多少参数的模型?4 比特和 8 比特各是多少?
  2. quantize.py 里试试 3 比特、分组 32,以及 4 比特、分组 8 和 128,把大小和验证损失列成一张表。
  3. 安装 Ollama,运行一个小模型,把第 03 模块的 RepoBot v1 改成调用它(只改环境变量),看看它能不能正常工作。

自测

1. 怎样估算一个 7B 参数的模型在 BF16 下要占多少内存?

参数量乘以每个参数的字节数:70 亿 × 2 字节 = 140 亿字节,约 13 GB。再加上 KV 缓存和其他开销,留两成余量,大约 16 GB。

2. 4 比特量化时,为什么"每 32 个数一个缩放系数"比"每行一个缩放系数"效果好?

缩放系数由这组数里绝对值最大的那个决定。一行有几百个数,只要有一个特别大,整行的缩放系数就被撑大,其他数只能挤在很少的几个整数上,误差很大。分成小组后,每组的缩放系数更贴合这组数,误差小得多,代价只是多存一些缩放系数。

3. 为什么第一部分的代码换成 Ollama 的本地模型时,几乎不用改?

Ollama 提供了和 OpenAI 兼容的接口,而第一部分的代码用的是 openai 库,并把接口地址、密钥和模型名都放在环境变量里。只要把这三个变量改成 Ollama 的地址和模型名,代码就能直接调用本地模型。

提问与讨论

这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。

提问 +3 积分,回答别人 +6 积分。内容经审核后公开。

正在加载讨论…