模組 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 點。內容經審核後公開。

正在載入討論…