一. 前言:模型能塞進記憶體,不代表量化工作做完了 #
在 Apple Silicon 上跑本地 LLM,最常見的第一個建議就是:「下載 4-bit 版本。」 這句話沒錯,卻省略了真正麻煩的部分。 同一個模型可以轉成 8-bit、4-bit,甚至讓不同 layer 使用不同 bit 數。 檔案變小以後,輸出品質、載入時間和生成速度也不一定等比例變化。 所以真正該問的不是「4-bit 好不好」,而是:
- 這個模型能不能正確轉成 MLX 格式?
- 4-bit 和 8-bit 各省下多少磁碟與記憶體?
group_size改變後,品質差異值得那一點空間嗎?- mixed-bit recipe 是否比統一 4-bit 更適合自己的任務?
- 要怎麼用同一組 prompts 公平比較?
今天拍拍君會做一條可重跑的量化流水線。 我們不只執行 mlx_lm.convert,還會保存設定、檢查產物,並建立小型 A/B 評測。 如果你只想先把模型跑起來,請看 MLX-LM 本地模型推論。 如果你還不熟 MLX 的 array、lazy evaluation 與基本 API,可以先看 Python MLX 入門。 這篇從「原始 Hugging Face checkpoint 已經選好」開始,只處理轉換與量化。
二. 先建立可重現的量化專案 #
先準備乾淨環境:
uv init mlx-quant-lab
cd mlx-quant-lab
uv add mlx-lm psutil
確認 CLI:
uv run mlx_lm.convert --help
uv run mlx_lm.generate --help
建議的目錄如下:
mlx-quant-lab/
├── models/
├── prompts.jsonl
├── results/
├── scripts/
│ ├── inspect_model.py
│ └── benchmark.py
└── README.md
把來源模型固定成 shell 變數:
MODEL_ID="Qwen/Qwen3-0.6B"
正式專案還應該記錄:
uv run python -c "from importlib.metadata import version; print(version('mlx-lm'))"
git rev-parse HEAD 2>/dev/null || true
模型倉庫也可能持續更新。 如果結果要長期可重現,請另外保存 Hugging Face revision,而不是只記 repo 名稱。
三. 先做不量化的 MLX 基準版本 #
轉換與量化是兩件事。 第一步先把 Hugging Face checkpoint 轉成 MLX 可載入的格式,但不壓低 bit 數:
uv run mlx_lm.convert \
--hf-path "$MODEL_ID" \
--mlx-path models/qwen3-0.6b-bf16 \
--dtype bfloat16
這個版本是後面比較的基準。 --dtype 常見選項包含:
float16;bfloat16;float32。
不要把 dtype 轉換和整數量化混為一談。 bfloat16 仍是浮點權重;4-bit affine quantization 則會把可量化 layer 轉成低 bit 表示。 先確認產物:
find models/qwen3-0.6b-bf16 -maxdepth 1 -type f -print
du -sh models/qwen3-0.6b-bf16
一般會看到 config.json、tokenizer 檔案和一個或多個 safetensors。 轉換器會拒絕寫入已存在的輸出資料夾。 這其實是好事:它避免你不小心把兩次實驗混在一起。 請為每個設定使用不同路徑,不要先刪目錄再假裝它們是同一次實驗。
四. 建立 8-bit 版本:先求穩,再往下壓 #
8-bit 通常是很實用的第一個量化基準:
uv run mlx_lm.convert \
--hf-path "$MODEL_ID" \
--mlx-path models/qwen3-0.6b-8bit-g64 \
--quantize \
--q-bits 8 \
--q-group-size 64
命名裡直接寫出 8bit-g64,未來比較時會省很多力氣。 兩個核心參數是:
--q-bits:每個量化權重使用的 bit 數--q-group-size:多少個權重共享一組量化參數
bit 數越低,模型通常越小。 但「理論上每個權重 4 bit」不代表整個目錄正好縮成四分之一。 原因包含 tokenizer、config、scale、bias,以及未被量化的參數。 所以請量實際產物,不要只用心算。
du -sk models/qwen3-0.6b-* | sort -n
五. 建立 4-bit 版本:本機推論的甜蜜點候選 #
接著做統一 4-bit:
uv run mlx_lm.convert \
--hf-path "$MODEL_ID" \
--mlx-path models/qwen3-0.6b-4bit-g64 \
--quantize \
--q-bits 4 \
--q-group-size 64
4-bit 常被當成本機 LLM 的起點,原因很直覺: 權重占用明顯下降;較容易放進 unified memory;同一台機器可以測更大的模型;下載與保存成本較低。
但它不是自動勝出。 小模型本來容量就有限,再壓低精度後,某些任務可能更容易出現: 固定格式漏欄位;數字或短推理不穩;少見詞彙輸出退化;同一 prompt 的結果變動更大。
所以 4-bit 是「值得測的候選」,不是「不用測的答案」。
六. group_size 到底在控制什麼?
#
量化不是替整個矩陣只找一個 scale。 MLX 會按 group 對權重做量化。 較小的 group 通常能更細緻地貼近局部權重分布,但需要更多 scale 等附加資料。 較大的 group 附加成本較低,卻可能讓同一組裡差異較大的權重共用參數。 你可以再建立一個 4-bit、group size 32 的候選:
uv run mlx_lm.convert \
--hf-path "$MODEL_ID" \
--mlx-path models/qwen3-0.6b-4bit-g32 \
--quantize \
--q-bits 4 \
--q-group-size 32
比較時至少保留這三組:
| 版本 | bits | group size | 角色 |
|---|---|---|---|
| BF16 | 16 | — | 品質基準 |
| Q8 | 8 | 64 | 保守量化 |
| Q4 | 4 | 64 | 空間優先 |
如果 Q4 的品質邊界很接近,再加入 Q4/G32。 不要一開始排列組合十幾組。 先用三個候選找到趨勢,再針對有疑問的區域加實驗。
七. Mixed Quant:不是每一層都該被同樣對待 #
統一 4-bit 很容易理解,但不同 layer 對量化誤差的敏感度可能不同。 MLX-LM 的轉換器內建 mixed-bit recipes:
mixed_2_6;mixed_3_4;mixed_3_6;mixed_4_6。
例如建立 3/4-bit 混合版本:
uv run mlx_lm.convert \
--hf-path "$MODEL_ID" \
--mlx-path models/qwen3-0.6b-mixed-3-4 \
--quantize \
--q-group-size 64 \
--quant-predicate mixed_3_4
這類 recipe 不是把整個模型平均切成兩半。 轉換器會按 layer 位置與 module 名稱套用不同精度,對部分投影層與輸出 head 保留較高 bit 數。 因此 mixed quant 的真正問題是:
在接近的模型大小下,它是否比統一低 bit 更能保住你的任務品質? 請不要只看 recipe 名稱猜答案。 模型架構不符合預期 key 時,轉換器也可能拒絕套用 recipe。 這時應該保存錯誤訊息並換策略,不要假裝它已經混合量化。
八. 讀 config.json,確認不是只有資料夾名稱改了
#
實驗命名可以寫錯,模型設定比較難騙人。 建立 scripts/inspect_model.py:
from __future__ import annotations
import json
import sys
from pathlib import Path
def main() -> None:
model_dir = Path(sys.argv[1])
config = json.loads(
(model_dir / "config.json").read_text(encoding="utf-8")
)
quant = config.get("quantization")
legacy = config.get("quantization_config")
print(json.dumps(quant or legacy, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
逐一檢查:
for model in models/*; do
echo "=== $model"
uv run python scripts/inspect_model.py "$model"
done
統一量化通常會看到 group_size、bits 和 mode。 mixed-bit 模型則可能保存更細的 per-layer 設定。 如果輸出是 null,先不要進行 benchmark。 你可能做的是 dtype 轉換,而不是自己以為的低 bit 量化。
九. 先做 smoke test,避免拿壞產物跑半天 #
每個模型先用同一個短 prompt:
for model in \
models/qwen3-0.6b-bf16 \
models/qwen3-0.6b-8bit-g64 \
models/qwen3-0.6b-4bit-g64 \
models/qwen3-0.6b-mixed-3-4
do
uv run mlx_lm.generate \
--model "$model" \
--prompt "只回答一個 JSON:{\"status\":\"ok\"}" \
--max-tokens 40
done
smoke test 要確認: tokenizer 可以載入;權重沒有 shape mismatch;prompt 可以送入;至少能生成文字;process 沒有立刻吃光記憶體。
它不負責證明品質。 它只負責把壞產物擋在正式評測門外。
十. 建立固定 prompts:別用聊天感覺決定勝負 #
建立 prompts.jsonl:
{"id":"format-01","prompt":"只輸出 JSON,欄位為 title 與 priority。內容:登入頁按鈕失效,影響全部使用者。"}
{"id":"summary-01","prompt":"把這句話壓成 20 字內:部署成功但健康檢查持續 timeout,需要檢查反向代理與應用程式 port。"}
{"id":"classify-01","prompt":"只回答 bug、feature 或 question:希望匯出結果能支援 Parquet。"}
{"id":"reason-01","prompt":"盒子有紅藍球各 3 顆,不放回抽 2 顆。用兩步驟說明抽到同色球的機率。"}
評測集應該來自你真正的使用情境。 建議至少混合: 格式遵循;摘要;分類;短推理;中英文或專有名詞;自己最在意的 domain prompt。
十筆真實任務,通常比一百筆隨便湊的問題更有判斷力。
十一. 寫一個可比較的 benchmark runner #
建立 scripts/benchmark.py:
from __future__ import annotations
import argparse
import json
import time
from pathlib import Path
import mlx.core as mx
from mlx_lm import generate, load
def read_jsonl(path: Path) -> list[dict[str, str]]:
return [
json.loads(line)
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--model", required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
rows = read_jsonl(Path("prompts.jsonl"))
started = time.perf_counter()
model, tokenizer = load(args.model)
mx.eval(model.parameters())
load_seconds = time.perf_counter() - started
results = []
for row in rows:
prompt = tokenizer.apply_chat_template(
[{"role": "user", "content": row["prompt"]}],
tokenize=False,
add_generation_prompt=True,
)
t0 = time.perf_counter()
answer = generate(
model,
tokenizer,
prompt=prompt,
max_tokens=160,
verbose=False,
)
results.append(
{
"id": row["id"],
"seconds": round(time.perf_counter() - t0, 4),
"answer": answer,
}
)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(
{"model": args.model, "load_seconds": load_seconds, "rows": results},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
if __name__ == "__main__":
main()
執行每個版本:
for name in bf16 8bit-g64 4bit-g64 mixed-3-4; do
uv run python scripts/benchmark.py \
--model "models/qwen3-0.6b-$name" \
--output "results/$name.json"
done
為了公平,請關閉其他吃記憶體的程式,並讓每個版本都跑相同 prompts 與 token 上限。
十二. 速度要分開看:載入、prefill、generation #
只記總秒數會把不同成本混在一起。 本地推論至少有三段:
- 模型載入
- prompt prefill
- token generation
短 prompt 加長輸出,主要看 generation。 長文件加短回答,則可能主要卡在 prefill。 mlx_lm.generate 的 verbose 輸出可以提供 prompt 與 generation throughput。 正式測速時,可另外保留一輪 CLI 原始輸出:
uv run mlx_lm.generate \
--model models/qwen3-0.6b-4bit-g64 \
--prompt "請列出五個量化模型評測項目。" \
--max-tokens 200 \
2>&1 | tee results/q4-speed.txt
第一次執行還可能包含檔案 cache 與系統暖身成本。 至少跑三次,分開報 cold run 和 warm run。
十三. 記憶體評測:看峰值,不只看模型目錄 #
磁碟大小只是靜態權重的一部分。 執行時還需要: 模型參數;tokenizer 與 Python process;prompt tokens;KV cache;MLX/Metal 配置與暫存空間。
macOS 可以先用 /usr/bin/time -l 包住命令:
/usr/bin/time -l uv run mlx_lm.generate \
--model models/qwen3-0.6b-4bit-g64 \
--prompt "用三點說明量化的風險。" \
--max-tokens 256 \
> results/q4-memory.txt 2>&1
所有版本要使用相同 prompt 長度與 max_tokens。 KV cache 會隨 context 增長;用短 prompt 測出的峰值不能代表長文件工作流。 如果你的真實任務會塞 8K tokens,就應該另外建立長 context 測試。
十四. 品質評測:先做規則,再做人工盲測 #
量化品質不應只看「答案讀起來還行」。 對固定格式任務,可以自動檢查: JSON 能否 parse;必要欄位是否存在;enum 是否落在允許集合;摘要是否超過長度;分類是否命中標籤。
對開放式回答,建議把模型名稱隱藏後人工比較。 可以用這個簡單評分表:
| 指標 | 0 分 | 1 分 | 2 分 |
|---|---|---|---|
| 正確性 | 錯誤 | 部分正確 | 正確 |
| 格式 | 無法使用 | 可修復 | 完全符合 |
| 完整性 | 漏重點 | 少量遺漏 | 足夠完整 |
| 穩定性 | 多次差很大 | 偶有偏移 | 大致一致 |
如果 BF16、Q8 和 Q4 在你的 30 筆任務都同分,就選更小或更快的版本。 如果 Q4 在關鍵格式任務明顯掉分,Q8 或 mixed quant 才是更合理的答案。
十五. 不要踩的幾個坑 #
1. 直接量化別人已經量化過的模型 #
優先從原始或較高精度 checkpoint 建立候選。 反覆低 bit 轉換可能把誤差再疊上去。
2. 只測一個漂亮 prompt #
單筆 demo 很容易被運氣騙。 至少覆蓋幾種任務,並重跑重要樣本。
3. 不保存轉換參數 #
把完整命令、來源 revision、mlx-lm 版本和輸出路徑寫進 README。 否則三週後只剩「這個資料夾好像是 4-bit」。
4. 以為低 bit 一定更快 #
速度會受模型、序列長度、記憶體壓力和 kernel 支援影響。 請量測,不要用檔名推論效能。
5. 忽略模型授權 #
轉換格式不會消除原始模型 license。 要上傳到 Hugging Face 或散布給別人前,先確認使用與再發布條款。
十六. 如何做最後選擇? #
拍拍君會用這個順序:
- 先淘汰無法載入或格式錯誤的版本
- 再淘汰關鍵任務品質不合格的版本
- 比較剩餘版本的峰值記憶體
- 比較真實 prompt 的 warm throughput
- 最後才看磁碟大小與分享成本
一個實用的結果表可能長這樣:
| 版本 | 大小 | 峰值記憶體 | 速度 | 任務通過率 | 決定 |
|---|---|---|---|---|---|
| BF16 | 實測 | 實測 | 實測 | 實測 | 品質基準 |
| Q8/G64 | 實測 | 實測 | 實測 | 實測 | 保守候選 |
| Q4/G64 | 實測 | 實測 | 實測 | 實測 | 空間候選 |
| Mixed 3/4 | 實測 | 實測 | 實測 | 實測 | 平衡候選 |
別把網路上的 benchmark 數字直接填進來。 這張表的價值就在於它反映你的 Mac、模型和工作負載。
結語 #
量化不是按下 --quantize 就結束,而是一個取捨實驗。 MLX-LM 已經把 Hugging Face 到 MLX 的轉換、統一低 bit 與 mixed-bit recipe 包成好用的 CLI。 真正需要你負責的,是建立可信的比較方式。 先保留 BF16 基準,再建立 Q8、Q4 與一個 mixed quant 候選。 檢查 config.json、做 smoke test,最後用同一組 prompts 比較大小、峰值記憶體、速度和品質。 如果 Q4 通過所有關鍵任務,那就開心省下記憶體。 如果它掉分,也不是量化失敗,而是你成功找到自己的品質邊界。 這才是工程上真正有用的答案。🔭