一、先想清楚:推理优化到底在优化什么

很多人做推理优化只盯着「模型变小」,其实大模型服务的瓶颈是一个三角形:显存占用(Memory)、吞吐(Throughput)、首字延迟(TTFT / Latency)。量化主要压显存,但压完显存后瓶颈往往会转移到内存带宽上——因为 decode 阶段每生成一个 token 都要把全部权重从显存读一遍,是典型的 memory-bound 计算。这也是为什么 INT4 权重即使「计算量」没减少,生成速度依然能快 2~3 倍:权重搬运变少了。

所以在动手前先问自己三个问题:目标是单卡跑得下,还是多用户吞吐高,还是端侧低延迟?三个目标对应的优化组合完全不同。比如离线批量生成,用 GPTQ + continuous batching 收益最大;交互式聊天,则要重点压 TTFT,考虑预填(prefill)与解码(decode)分离。

提示:先用 nvidia-smi 或 profiler 看清楚瓶颈是「显存不够」还是「带宽打满」。用量化去解决带宽问题是对的,用量化去解决算力不足问题基本没用,那是 batch size 和算子融合的战场。

二、量化是什么:从 FP16 到 INT4 的数学直觉

量化的本质是用更少的比特表示权重,并把精度损失控制在可接受范围内。一个 FP16 权重占 16 bit,INT4 只占 4 bit,理论上模型体积直接缩到 1/4。核心公式很简单:q = round(w / scale) + zero_point,推理时反量化 w ≈ (q - zero_point) * scale。

精度从哪里来?关键在于 scale 的粒度。如果整个权重矩阵共用一个 scale,量化误差会非常大;而如果按 per-channel(每个输出通道一组 scale)甚至 per-group(每 32~128 个元素一组),误差就能被局部数据分布吸收。所以现代方案几乎都是 group-wise quantization,这也是 GPTQ、AWQ 的默认做法。

import torch

def quantize_int8_per_channel(w: torch.Tensor):
    """最朴素的 per-channel 对称量化,帮助理解 scale 是怎么来的"""
    scales = w.abs().amax(dim=1, keepdim=True) / 127.0   # 每个输出通道一组 scale
    q = torch.clamp(torch.round(w / scales), -127, 127).to(torch.int8)
    return q, scales

w = torch.randn(256, 512)
q, s = quantize_int8_per_channel(w)
w_hat = q.float() * s
print("体积比:", q.numel() * 8 / (w.numel() * 16), "相对误差:", (w - w_hat).norm() / w.norm())

这里可以看到 scale 是由该组数据的绝对值最大值推出来的。AWQ 的洞察更进一步:不是所有权重同等重要,激活值 activation 大的那些通道对应的权重应该「温柔对待」,可以用逐通道缩放(per-channel scaling) 把敏感权重的量化误差压下去,而不是简单地平均分配比特。

三、主流量化方案怎么选:PTQ / GPTQ / AWQ / GGUF

量化分两大流派:QAT(训练时量化) 和 PTQ(训练后量化)。工业界落地几乎全是 PTQ,因为没人愿意为了量化重新训练一次 70B 模型。PTQ 里又分几个有代表性的方案:

提示:选型口诀——做微调选 0,做推理服务选 1/2,跑 CPU 或端侧选 3。别一开始就去调 KV Cache 量化,先把权重量化做对,收益占比通常超过 80%。

四、动手实践:两行代码加载 4-bit 模型

实际工程里最省事的入口是 transformers + bitsandbytes,它不需要提前生成量化权重文件,加载时把 FP16 权重动态压成 NF4:

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

model_id = "Qwen/Qwen2.5-7B-Instruct"

model = AutoModelForCausalLM.from_pretrained(
    model_id,
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,   # 计算仍然用高精度,只有存储被压缩
    bnb_4bit_use_double_quant=True,          # 对 scale 再量化一次,省一点显存
    device_map="auto",
)

tokenizer = AutoTokenizer.from_pretrained(model_id)
inputs = tokenizer("推理优化的核心是", return_tensors="pt").to(model.device)
print(tokenizer.decode(model.generate(**inputs, max_new_tokens=32)[0]))

如果要做生产部署,应该提前离线量化好权重并存成 safetensors,而不是每次启动都现场量化。用 AutoGPTQ 或 llm-compressor 生成量化模型后,vLLM、TensorRT-LLM 可以直接加载并使用专用的 W4A16 kernel,吞吐比动态量化高不少。另外注意 bnb_4bit_compute_dtype 这一项:存储用 4bit,计算回 BF16,这是「量化不掉精度」的关键设计。

五、量化之外:KV Cache、批处理与投机解码

权重量化只解决「模型大」的问题,长上下文场景下真正吃显存的是 0。一个 7B 模型用 FP16 存 KV,单条 4K 上下文就要占掉 1~2GB。对应的优化手段是 KV Cache 量化(压缩到 INT8/FP8)以及 2——把 KV Cache 像操作系统内存一样分页管理,消除碎片,配合 continuous batching 让不同长度的请求动态拼批,吞吐能翻好几倍。

另一条完全不同的加速路线是投机解码(Speculative Decoding):用一个 1B 的小模型先「猜」几个 token,大模型一次性并行验证,猜对就接受。它不损失任何输出质量,因为最终采样分布与大模型单独推理完全一致,纯粹是把串行的 decode 换成「草稿 + 验证」的半并行结构。现在的 vLLM、SGLang 都已经内置了这些特性,通常通过启动参数开启:

# vLLM 示例:量化模型 + 连续批处理 + 投机解码
vllm serve ./qwen2.5-7b-awq \
  --quantization awq \
  --max-model-len 8192 \
  --gpu-memory-utilization 0.9 \
  --speculative-config '{"method": "ngram", "num_speculative_tokens": 3}'

六、部署与压测:用数据说话而不是靠感觉

优化做完一定要压测,否则你不知道自己是真的变快了,还是只是感觉变快了。核心指标就四个:TTFT(首 token 延迟)、TPOT(每 token 生成时间)、吞吐 QPS、显存占用。压测可以用 vllm bench serve 或 genai-perf,模拟不同并发下曲线变化——重点看并发上升时延迟是否线性恶化,这决定了你的服务能不能扛住流量。

最后给出几条我踩过的坑,当作收尾:

提示:把「模型选型 → 量化方案 → 推理引擎 → 压测调参」当作一条流水线来迭代,每次只改一个变量并记录指标。推理优化没有银弹,只有在你的具体硬件、具体业务负载下的最优解。