先把最丢人的事说了
上个月我把 Qwen 2.5 3B Instruct 的 FP16 权重直接复制到树莓派 5(8GB 版,Ubuntu 24.04)上,想跑个本地问答机器人。结果模型加载到一半直接 OOM,系统卡死,只能强制断电。后来硬着头皮开了 4GB swap,模型是加载进去了,推理速度 2.1 token/s——生成一句「你好」要等 8 秒。
这不是我操作失误。FP16 模型光权重就 6.7GB,树莓派 8GB 内存还要兼顾系统、Python 运行时和 KV cache,怎么都放不下。就算放得下,边缘设备的短板不是算力而是内存带宽。
这篇文章记录了我从 FP16 → 动态量化 → GGUF 静态量化全过程,包含可复现的代码、实测数据和避坑经验。看完你也能把自己的模型量化到能在树莓派上流畅跑。
边缘部署的核心瓶颈不是算力
树莓派 5 用的 SoC 是 BCM2712,四核 Cortex-A76,最高 2.4GHz。CPU 算力不算差,跑 3B 模型的理论计算量大概 2 * 3B * 256 tokens = 1.5 TFLOPs,实际算力完全够。
真正的瓶颈是内存带宽。树莓派 5 的内存是 LPDDR4X,带宽理论 17GB/s,实测只有 12~14GB/s。LLM 推理是 memory-bound 任务,生成每个 token 需要把整个模型权重从内存搬一遍。模型 6.7GB,算下来 6.7GB / 13GB/s ≈ 0.5s,加上其他开销,实际 2.1 token/s 已经算不错了。
所以边缘部署的核心公式就一句话:模型文件越小,推理越快。量化是最直接的解法。
两种量化方案对比
我试了两种主流方案,结论差异很大。
方案一:PyTorch 官方动态量化
代码量最少,但效果远低于预期。动态量化(weight-only quantization)只量化权重,activation 保持 FP32,推理时才计算 scale 映射,适合 CPU 部署。
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model_id = "Qwen/Qwen2.5-3B-Instruct"
model = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=torch.float16)
tokenizer = AutoTokenizer.from_pretrained(model_id)
# 动态量化:只量化 Linear 和 Embedding 层
quantized_model = torch.quantization.quantize_dynamic(
model,
{torch.nn.Linear, torch.nn.Embedding},
dtype=torch.qint8
)
torch.save(quantized_model.state_dict(), "qwen3b_dynamic_int8.pth")
print(f"原始模型大小: {sum(p.numel() * p.element_size() for p in model.parameters()) / 1e9:.2f}GB")
print(f"量化后模型大小: {sum(p.numel() * p.element_size() for p in quantized_model.parameters()) / 1e9:.2f}GB")
实测结果:模型从 6.7GB 降到 3.4GB,但推理速度从 2.1 token/s 变成了 1.8 token/s。原因有两个:一是动态量化在推理时要做反量化计算,额外消耗 CPU 周期;二是 PyTorch 的 INT8 算子没有针对 ARMv8 指令集做充分优化,跑在 x86 上有优化,跑在 ARM 上没吃到红利。
结论:PyTorch 动态量化不适合边缘 CPU 部署。放弃。
方案二:GGUF 静态量化(llama.cpp)
这是最后真正跑通的方案。GGUF 是 llama.cpp 的模型格式,支持静态的 block-wise 量化,并且针对 ARM NEON 指令集做了汇编级优化。
核心思路:先把 HuggingFace 模型转成 GGUF,再用自带工具做 K-quant 量化。K-quant 家族(Q4_K_M、Q4_K_S、Q8_0)和普通的 Q4_0/Q4_1 区别在于:Q4_K_M 会对不同层采用不同量化策略——注意力层的 K 和 Q 用更高精度,FFN 的中间层用更低精度,混合后整体精度更高。
我对比了 Q8_0、Q4_K_M、Q4_0、Q2_K 和 FP16,生成速度如下表格:
| 量化格式 | 模型大小 | 推理速度 | 内存占用 | 困惑度 |
|---|---|---|---|---|
| FP16 原版 | 6.7GB | 2.1 token/s | OOM + swap | 5.42 |
| Q8_0 | 3.4GB | 6.8 token/s | 4.8GB | 5.45 |
| Q4_K_M | 2.15GB | 11.4 token/s | 3.9GB | 5.78 |
| Q4_0 | 1.9GB | 11.8 token/s | 3.6GB | 6.03 |
| Q2_K | 1.3GB | 13.2 token/s | 3.1GB | 6.87 |
速度测试方法:固定 64 token 的 prompt,生成 256 个 token,取 5 次平均值。困惑度测试用 wikitext-2-raw 子集 1000 条样本算平均 PPL。Q4_K_M 是我最终选的落地点——质量接近 Q8_0,体积小 37%,速度快 67%。
完整部署流程
第一步:环境
# 树莓派 5,8GB,Ubuntu 24.04 LTS
uname -m # aarch64
cat /proc/cpuinfo | grep -m 1 "Model" # Raspberry Pi 5 Model B Rev 1.0
free -h
# total used free shared buff/cache available
# Mem: 7.6Gi 381Mi 5.5Gi 9.0Mi 1.7Gi 6.9Gi
第二步:编译 llama.cpp
llama.cpp 官方 master 分支,2025-03-20 commit,支持 Qwen2.5。交叉编译不搞,直接在树莓派上原生编译,过程约 25 分钟。
sudo apt update && sudo apt install -y git build-essential cmake
git clone https://github.com/ggerganov/llama.cpp.git
cd llama.cpp
# 切换到一个稳定 commit,避免新代码引入兼容问题
git checkout 9b2023
mkdir build && cd build
# 开启 ARM NEON 加速和 native 指令集优化
cmake .. -DGGML_NATIVE=ON -DGGML_BLAS=ON -DGGML_BLAS_VENDOR=OpenBLAS
make -j4
要特别注意 -DGGML_NATIVE=ON,不开的话生成的代码是通用 ARMv8 指令集,开之后编译器会启用本机 ISA 扩展,性能能差 10% 左右。
第三步:模型转换与量化
HuggingFace 的 safetensors 格式不能直接喂给 llama.cpp,要先转 GGUF 再量化。两个命令搞定:
# 1. 下载 HF 模型(在 x86 机器上做,树莓派太慢)
huggingface-cli download Qwen/Qwen2.5-3B-Instruct --local-dir qwen25-3b-fp16
# 2. 转 GGUF (F16),然后量化到 Q4_K_M
python3 convert_hf_to_gguf.py qwen25-3b-fp16 \
--outfile qwen25-3b-fp16.gguf \
--outtype f16
./build/bin/llama-quantize \
qwen25-3b-fp16.gguf \
qwen25-3b-q4_k_m.gguf \
q4_k_m
llama-quantize 输出信息里有两行关键参数,直接决定了量化后质量:
llama_model_quantize_internal: block_size = 32
llama_model_quantize_internal: quantizing to Q4_K_M
llama_model_quantize_internal: model size = 6687.92 MB
llama_model_quantize_internal: quant size = 2151.14 MB
block_size = 32 就是 block-wise 量化的核心:每 32 个权重共享一个 FP32 scale,量化误差被局部化,不会像 per-tensor 量化那样出现全局误差放大。
第四步:推理脚本
直接用 llama.cpp 的 C++ 推理也行,但工程上做 API 服务更实用。我用的 llama-cpp-python,版本 0.3.7,需要自己编译(pip 装的是 x86 预编译包,ARM 上跑不了)。
# 在树莓派上编译 llama-cpp-python
CMAKE_ARGS="-DGGML_NATIVE=ON" pip install llama-cpp-python==0.3.7 --no-cache-dir
# llm_server.py
from llama_cpp import Llama
llm = Llama(
model_path="/home/pi/models/qwen25-3b-q4_k_m.gguf",
n_ctx=2048, # 上下文长度
n_threads=4, # 树莓派 4 核 A76,设多了反而慢
n_gpu_layers=0, # 纯 CPU 推理
verbose=False,
# KV cache 也量化,可以再省 200MB 内存
cache_type_k="q8_0",
cache_type_v="q8_0",
)
def chat(prompt: str, max_tokens: int = 256) -> str:
output = llm.create_chat_completion(
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
temperature=0.7,
top_p=0.9,
)
return output["choices"][0]["message"]["content"].strip()
if __name__ == "__main__":
print(chat("什么是模型量化?用三句话解释"))
第五步:封装成 HTTP 服务
用 FastAPI 包一层,容器化部署。Dockerfile 和 docker-compose 如下:
# docker-compose.yml
services:
llm-server:
build: .
ports:
- "8080:8080"
volumes:
- /home/pi/models:/models
environment:
- MODEL_PATH=/models/qwen25-3b-q4_k_m.gguf
- N_THREADS=4
restart: unless-stopped
deploy:
resources:
limits:
memory: 4.5G
# 测试 API
curl -s http://192.168.1.100:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "qwen25-3b-q4_k_m",
"messages": [{"role": "user", "content": "讲个冷笑话"}],
"max_tokens": 128
}'
量化为什么会伤害精度
量化本质是把连续的 FP32 数值映射到离散的 INT 网格。以 Q4_K_M 为例,每个 32 维的权重块:
- 先统计该块 32 个权重的最大值
max_val,得到 scale =max_val / 7 - 每个权重除以 scale 后四舍五入到 [-7, 7] 的整数(4bit 有符号数,取一半范围是为了对称量化)
- 存储时只存 32 个 4bit 整数 + 1 个 FP32 scale,总计 16 字节,相比 32 个 FP32 的 128 字节,压缩 8 倍
Q4_K_M 的「K」指 K-quant 算法,它引入了一个超参 importance matrix:对 attention 层的 Q、K、V、O 投影矩阵采用更窄的量化范围(相当于 Q5_K 的精度),而对 FFN 的 gate/up 投影采用 Q4_K。这样做的依据是 LLM 推理时,attention 投影对激活值的敏感度高于 FFN 中间层。
从困惑度数据看:FP16 → Q8_0 涨了 0.03,几乎无损;Q8_0 → Q4_K_M 涨了 0.33,可以接受;Q4_K_M → Q2_K 涨了 1.09,已经明显劣化,对话质量会变差。
实际体验上,Q4_K_M 和原版在普通问答上感知不到差别,复杂逻辑推理(比如多步数学题)会偶尔犯明显错误。如果业务场景对推理质量要求极高,建议至少用 Q8_0。
KV cache 量化注意
我最后配置里开了 cache_type_k="q8_0"。KV cache 量化是额外的内存优化:默认 KV cache 是 FP16,假设 2048 上下文,3B 模型每层 28 维配置,KV cache 大概占 0.5~0.8GB。量化成 Q8_0 能省一半,但会引入微小误差。
测试结果:context 长度 ≤2048 时,KV cache 量化的输出和 FP16 几乎相同;context 长度 4096 时,个别 case 会出现「答非所问」。原因大概率是长上下文的 KV 累积误差被反复读取和写入,逐渐放大。所以 KV cache 量化只建议配 2048 上下文。
避坑指南
这些坑都是我实际踩过的,写出来希望你别再踩。
坑 1:PyTorch 动态量化在 ARM 上反而变慢
前面提过,PyTorch 的 INT8 算子优化主要集中在 x86 的 AVX512 和 VNNI 指令集,ARM NEON 的优化不完善。在树莓派上做动态量化,每次推理要先反量化再计算,开销比省内存带来的收益大。不要因为代码短就选它——这是一个典型的「看着好用,实际白干」的方案。
坑 2:llama.cpp 线程数不是越大越好
树莓派 5 是四核 CPU,n_threads=4 是最优解。我试过 6 和 8 n_threads=6 反而慢到 8.9 token/s。因为 llama.cpp 的线程调度会引入上下文切换开销,线程数超过物理核心数后没有收益。如果你用的是 Jetson 或其他多核设备,建议用 n_threads = 物理核心数。
坑 3:量化校准集不要用训练数据
llama-quantize 支持可选校准集 --calibration-file(针对 imatrix),如果你加了这个参数,千万不要用训练集或领域语料。用训练集会过拟合到校准数据分布,部署时遇到真实提问精度下降明显。官方推荐用 wiki.train.raw 这种通用语料,但如果你做垂直领域应用,用 50~100 条领域内的通用文本效果更好。别多,100 条就够,多了浪费时间。
坑 4:树莓派散热不行会导致降频
没装散热风扇之前,Q4_K_M 跑了 10 分钟后 CPU 温度飙到 84°C,树莓派自动降频到 1.2GHz,token/s 从 11.4 掉到 6.5。加了一个 15 块钱的官方散热风扇后,温度稳定在 64°C,速度不再掉。边缘部署不是只做软件优化,散热是最容易被忽略的硬件瓶颈。
坑 5:量化后模型文件不可逆
GGUF 量化是 lossy 且不可逆的。如果你量化完发现效果不行,只能重新从 FP16 转。所以请务必保留 FP16 的 GGUF 文件。另外别把 tokenizer 也量化了,llama-quantize 会默认保留 tokenizer 相关张量为 FP32,手动改配置容易把嵌入层量化导致输出乱码。
坑 6:注意 swap 会害死你的 SD 卡
我在调试阶段为了跑 FP16,给树莓派开了 4GB swap,直接把一张 64GB 的闪迪 A2 卡写废了。swap 在 SD 卡上的随机写入会快速消耗寿命。如果非要跑大模型,用 USB3.0 移动固态硬盘做 swap 盘,或者直接在 SSD 上启动系统。
效果总结
最后落地到生产环境的是:树莓派 5(8GB)+ Q4_K_M + llama-cpp-python + FastAPI,部署为局域网内的私有 LLM API。效果:
- 模型体积:6.7GB → 2.15GB,减小 68%
- 推理速度:2.1 token/s → 11.4 token/s,提升 5.4 倍
- 首 token 延迟:约 1.7s(64 token prompt)
- 服务内存占用:3.9GB(含上下文和 KV cache)
- 同一张 16GB SD 卡上可以同时跑程序和数据
如果你也要做边缘部署,直接抄作业:模型大于 10GB 先用 AWQ 或 GPTQ 配合 GPU 量化;模型小于 10GB 直接用 GGUF Q4_K_M;模型小于 2GB 才考虑 Q8_0 保质量。数据比经验值钱,量化参数一定自己跑一遍 PPL 和速度测试再决定,别直接照搬。