量化与蒸馏:把模型便宜地放进生产

同样一个模型,部署方式的成本能差十倍。量化、蒸馏、缓存,三条降本路线各有适用场景,这篇给出选型决策。
把大模型塞进生产环境,这件事在 2026 年已经不再是"要不要做"的问题,而是"用多低的成本做到不掉链子"的问题。过去两年里,我所在的小团队先后把对话机器人、合同要素抽取、以及一套内部代码审查助手从云端 API 迁到了本地或准本地部署。每一次迁移背后最硬的约束都不是"模型够不够聪明",而是显存、延迟和每张卡每小时的真实账单。
很多团队在谈降本时,第一反应是"换更小的模型"。但现实里,更小的模型往往意味着重新训练或重新筛选,周期长、风险高。更顺手的杠杆其实是两条已经非常成熟的路线:量化和蒸馏。它们都能让同一个(或近似的)能力用更少的资源跑起来,但原理、代价和适用边界完全不同。这篇文章我想把这两件事拆开讲清楚,并且给出一个我自己一直在用的、用评测集而非"体感"来判断是否掉点的工作流。
需要提前划清一个边界:量化和蒸馏解决的是"成本与尺寸",不是"能力上限"。如果你的模型本身就答不对你的任务,压再小、蒸再狠也没用,这时候该回去换更大的教师模型或更好的数据,而不是在压缩环节死磕。很多团队把降本当成万能药,结果在错误的方向上越走越远。降本的前提,是先用全精度确认"这件事模型本来就能做"。
先说结论,方便没耐心的朋友:绝大多数对话与抽取类场景,INT8 甚至 INT4 量化就能省下一半以上的显存而质量几乎无感;但凡是涉及精确多步推理、数值计算、长链逻辑一致性的任务,量化带来的微小抖动会在链路末端被放大,这时候要么保留更高精度,要么上蒸馏把能力"固化"到一个更小的原生模型里。
量化到底在省什么
要理解量化,先得理解模型推理时显存花在了哪里。一个 7B 的模型,如果以 FP16(每个参数 2 字节)存储权重,仅权重就需要约 14 GB 显存;加上推理时的激活值、KV Cache、框架与 CUDA 上下文开销,单卡 24GB 的卡(比如常见的消费级 4090)其实很紧张。量化做的事情,本质是把"每个数字用多少位来表示"这件事压缩下去。
FP16 是 16 位浮点,能表示的动态范围很大,但绝大多数权重和激活值其实并不需要那么宽的表示区间。INT8 用 8 位整数,每个参数 1 字节,理论上权重体积直接减半;INT4 用 4 位,直接变成四分之一。听起来很美,但整数没有小数点,必须把浮点数值"映射"到整数网格上,这个映射过程就是量化的核心,也是质量损失的根源。
从 FP16 到 INT8
最朴素的量化叫对称均匀量化。假设某层权重的最大值是 3.2、最小值是 -3.0,我们把它线性映射到 INT8 的 [-127, 127]。公式可以写成:
import numpy as np
def linear_quantize(weights: np.ndarray, bits: int = 8):
# 对称量化:以最大绝对值作为缩放基准
qmax = 2 ** (bits - 1) - 1 # INT8 正数上限 127
scale = np.max(np.abs(weights)) / qmax
# 量化到整数,再反量化回浮点(模拟精度损失)
q = np.round(weights / scale).clip(-qmax, qmax)
deq = q * scale
return deq, scale
w = np.random.randn(1024) * 0.5
deq_w, scale = linear_quantize(w)
# 用均方误差粗看损失
mse = np.mean((w - deq_w) ** 2)
print(f"scale={scale:.4f} mse={mse:.6f}")这段代码就是量化的全部缩影:选一个 scale,把浮点压成整数,推理时再乘回 scale 还原。问题在于,真实权重不是均匀分布的,少数极端值会把 scale 撑得很大,导致大量"正常大小"的权重被压到相邻整数、区分度丧失。这就是为什么"裸量化"效果往往很差,也催生了后面要讲的那些更聪明的格式。
权重与激活:两条不同的量化路径
这里有个关键的工程分叉:权重量化和激活量化。
权重量化只压缩模型参数,推理时把权重反量化回浮点再算,好处是实现简单、几乎不损失精度,但省下的只是"存储权重"那部分显存,计算量没降。激活量化则连每层的中间结果也用低精度表示,能真正加速计算(因为现代 GPU 的 INT8 张量核心吞吐远高于 FP16),但激活值动态范围大、容易出离群点,量化难度高,需要校准(calibration)。
# 权重量化:只量化 W,激活仍用浮点
# y = dequant(W_int8) @ x_float
# 激活量化:W 和 x 都量化,需要校准集合估计激活范围
def calibrate_activation_range(calib_loader, model, num_batches=32):
amin, amax = float("inf"), float("-inf")
for i, batch in enumerate(calib_loader):
if i >= num_batches:
break
act = model.silent_forward(batch) # 仅前向拿激活,不反向
amin = min(amin, float(act.min()))
amax = max(amax, float(act.max()))
return amin, amax我自己的经验是:如果你的瓶颈是"显存放不下模型",权重量化 + 一张够大的卡就够了;如果你的瓶颈是"吞吐/延迟",那必须上激活量化(也就是真正走 INT8 推理内核),否则只省了显存没省算力。
怎么知道量化后没"烂掉"
这是整个降本过程里最容易被敷衍的一步。太多团队的做法是"部署上去,让同事试试,感觉还行就上线"。问题在于,体感是会被锚定的:你先看了 FP16 的输出,再去看 INT8 的输出,会不自觉地觉得"差不多";反过来,如果你先知道这是量化版,又会过度放大每一个瑕疵。
自建评测集而不是靠感觉
我的做法是:在动量化之前,先花半天搭一个属于自己业务的评测集。它不需要大,50 到 200 条真实样例就够了,但必须覆盖你真正关心的失败模式。比如合同抽取,我就专门放了一批"条款嵌套""金额单位混乱""同一主体多种写法"的难例。
import json, re
from pathlib import Path
def build_eval_set(samples: list[dict], out_path: str):
# samples: [{"input": "...", "expect": "..."}]
# expect 可以是精确字符串,也可以是用于判定的正则/关键词
Path(out_path).write_text(json.dumps(samples, ensure_ascii=False, indent=2))
def score_output(pred: str, expect: dict) -> float:
# 一个简单的判定函数:关键词命中率
if "regex" in expect:
return 1.0 if re.search(expect["regex"], pred) else 0.0
if "keywords" in expect:
hits = sum(1 for k in expect["keywords"] if k in pred)
return hits / len(expect["keywords"])
return 1.0 if pred.strip() == expect["exact"].strip() else 0.0
def run_eval(model_fn, eval_path: str) -> float:
cases = json.loads(Path(eval_path).read_text())
scores = [score_output(model_fn(c["input"]), c["expect"]) for c in cases]
return sum(scores) / len(scores)有了这个 run_eval,量化就变成了可决策的事:我先跑一遍 FP16 拿基线分数(比如 0.91),再跑 INT8(0.90)、INT4(0.86)。数字是冷冰冰的,但它让你能回答老板那个最讨厌的问题:"你的'感觉还行'到底是多少还行?"
一句话原则:量化上线前,必须有一个数字化的回归基线。没有基线,就永远在和自己的记忆辩论。
GGUF、GPTQ、AWQ:格式的定位差异
这三种格式常被混为一谈,但它们解决的是不同环节的问题,选型时得先想清楚"你的模型最终跑在哪、被谁加载"。
GGUF 是 llama.cpp 生态的容器格式,它的强项是"跨硬件、低门槛"。一个 GGUF 文件把分词器、权重、元数据全打包了,可以在 CPU、Metal、CUDA 上用同一套接口加载,社区里的 llama-cli、ollama 都吃得很好。它的量化通常是在权重建模层面做的(比如 Q4_K_M 这种档位),适合"我要在开发机、树莓派、甚至笔记本上都能跑同一个模型"的场景。
GPTQ 和 AWQ 则是针对 NVIDIA GPU 的权重量化算法,输出的是和 Hugging Face transformers 兼容的权重。GPTQ 的思路是"逐层量化 + 用少量校准数据做误差补偿",它对校准集比较敏感,但压缩比高、生态成熟;AWQ 的核心观察是"权重里只有一小部分(激活敏感的)通道真正重要",所以它不是均匀量化,而是保护重要通道、对不重要的通道更激进地压缩。实际体感上,AWQ 在较低比特(比如 4bit)下往往比 GPTQ 更稳,但 GPTQ 的工具链和现成模型更多。
# 用 transformers 加载一个 GPTQ 量化模型(已离线转换好的)
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct-GPTQ-Int4",
device_map="auto",
trust_remote_code=True,
)
tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct-GPTQ-Int4")
# 用 llama.cpp / GGUF 的场景,则是另一套加载逻辑
# from llama_cpp import Llama
# llm = Llama(model_path="qwen2.5-7b-instruct-q4_k_m.gguf", n_gpu_layers=-1)我的取舍很简单:如果部署目标是"一个标准 Linux GPU 服务器,用 Python 服务包起来",优先 AWQ/GPTQ,因为它们和 transformers、vLLM 这种推理栈直接衔接;如果目标是"多样化终端、想用 ollama 一把梭、甚至离线分发",GGUF 更省心。
蒸馏:用大模型教小模型
量化的天花板很明显,你没法把 7B 压到 1B 还指望它干一样的活。当你需要的不只是"省显存",而是"用一个本质更小的模型"时,就要上蒸馏(distillation)。
蒸馏的师生框架很直觉:有一个已经很强、但太贵的"教师模型"(比如 70B 或闭源 API),我们让它对大量无标注数据生成"软标签"或"思维过程",再用这些合成数据去训练一个小的"学生模型"。学生学到的不只是最终答案,还有教师的置信度分布(软标签里蕴含的"这道题 A 比 B 更可能"的暗知识)。
师生框架与代价
蒸馏最迷人的地方在于:学生不需要原始训练数据,只要有"能跑教师模型"的能力就行。但代价也真实存在:
- 数据生成成本:让教师给几十万条 prompt 打标,本身就是一笔推理开销,往往比训练本身还贵。
- 能力上限被教师锁死:学生很难超过教师,而且会继承教师的偏见与错误。
- 蒸馏不是免费的午餐,尤其当你的任务很窄、领域很强时,直接用教师 API 做"few-shot 兜底"可能比养一个学生更划算。
# 用教师模型生成蒸馏训练数据(简化版)
from transformers import pipeline
teacher = pipeline("text-generation", model="big-teacher-70b", device_map="auto")
def distill_one(prompt: str) -> dict:
out = teacher(prompt, max_new_tokens=512, do_sample=False,
return_full_text=False, output_scores=True)
text = out[0]["generated_text"]
# 把教师的输出当作学生的监督信号
return {"prompt": prompt, "completion": text}
train_data = [distill_one(p) for p in unlabeled_prompts[:20000]]
# 之后用标准 SFT 把 train_data 喂给学生模型我越来越倾向于把蒸馏看作"为特定子任务定制模型"的手段,而不是"全面替代大模型"的手段。泛化能力,终究是参数规模给的。
什么时候量化够用,什么时候不够
这是全文最该被记住的一节。我的判断框架基于一个观察:量化损失是"噪声",它在每一步推理里引入微小扰动;当任务对"每一步都精确"不敏感时,噪声被平均掉了,你无感;当任务要求"多步严格一致"时,噪声会累积、甚至雪崩。
够用的场景:
- 开放域对话:用户要的是自然、合理,不是唯一正确答案,INT4 的微小抖动完全淹没在语言本身的自由度里。
- 信息抽取 / 分类:输出空间有限且结构化,量化对"该抽哪段"的判断影响极小。
- 摘要、改写:同理,语义层面的容错率高。
不够用的场景:
- 精确数值推理:比如"算一下这笔分期的总利息",INT4 在某个中间步骤把 0.73 变成 0.75,结果就偏了,而且用户会立刻发现。
- 长链逻辑 / 多跳问答:每一步的微小错误在链末端被放大,越长的推理越危险。
- 代码生成里的"标识符一致性":变量名、API 签名这类东西,量化抖动偶尔会让模型拼错一个字母,而编译器会毫不留情地报错。
# 一个"任务是否适合激进量化"的粗判函数
def quant_friendly(task: dict) -> str:
if task["output_space"] == "open_text":
return "INT4 通常可上"
if task["requires"] == "exact_arithmetic":
return "至少 INT8,最好 FP16"
if task["requires"] == "multi_step_logic":
return "INT8 起步,长链请保留 FP16"
return "先跑评测集再说"部署工具链与显存收益估算
选定格式后,工具链基本是确定的:GPTQ/AWQ 权重配 transformers 或 vLLM;GGUF 配 llama.cpp / ollama;需要高并发服务就用 vLLM 或 TGI,它们对量化内核的支持已经相当完整。
显存收益不是简单的"除以 2 除以 4"。推理时真正占显存的有四块:权重、激活、KV Cache、固定开销。权重随比特线性下降,但 KV Cache 取决于上下文长度和并发数,和量化比特关系没那么大。所以"量化把显存从 14G 降到 4G"只考虑了权重,真实服务里你还得给 KV Cache 留位置。
一个显存估算的代码示例
def estimate_vram_gb(
params_b: float, # 参数量(十亿)
bits: int, # 权重量化比特
ctx_len: int, # 上下文长度
concurrency: int, # 并发请求数
hidden: int = 4096, # 隐藏维度(粗略估算用)
) -> float:
# 权重显存
weight_gb = params_b * 1e9 * (bits / 8) / 1e9
# KV Cache:每层 2(K 和 V)* 字节数 * 层数近似用 hidden 代
kv_bytes = 2 * (bits / 8) * ctx_len * concurrency * hidden * 32
kv_gb = kv_bytes / 1e9
# 激活与框架开销,给个经验常数
overhead_gb = 1.5
return weight_gb + kv_gb + overhead_gb
# 7B 模型,INT4,上下文 4k,并发 8
print(estimate_vram_gb(7, 4, 4096, 8)) # 远小于 FP16 的版本
print(estimate_vram_gb(7, 16, 4096, 8)) # FP16 基线,注意 KV 不变跑过这个估算你会得到一个反直觉的结论:当并发和上下文都很长时,KV Cache 可能比权重还占地方,这时候单纯压权重比特收益有限,得从"限制上下文""加连续批处理(continuous batching)"下手。这正是 vLLM 这类引擎的价值所在。
成本收益的思考框架
最后一节,我想给一个能落地的决策框架,而不是又甩一堆名词。每次有人问我"要不要量化/蒸馏",我就让他先回答四件事:
- 你的瓶颈是显存、延迟、还是钱?量化主要解决显存,激活量化才解决延迟,蒸馏解决"长期单位成本"。
- 你的失败代价有多高?对话机器人答错一句没人记得;财务抽取错一个金额就是要赔钱的,后者对精度零容忍。
- 你能不能承受一次性的蒸馏/评测投入?蒸馏前期贵,量化几乎零成本。
- 流量稳不稳?流量稳定且长期,本地量化划算;流量稀疏波动,云端 API 反而更省。
def decide(route: dict) -> str:
if route["failure_cost"] == "high" and route["traffic"] == "spiky":
return "用云端大模型 API,别折腾本地"
if route["bottleneck"] == "vram" and route["failure_cost"] != "high":
return "先 INT8 量化,配评测集回归"
if route["bottleneck"] == "long_term_cost" and route["traffic"] == "stable":
return "评估蒸馏到小模型,算清一次性投入"
return "先建评测集,用数据说话"踩坑这件事,我吃过最贵的亏是一次"为了省显存把抽取模型压到 INT4,体感还行就上线了",结果上线两周后业务方才发现某些长合同里金额字段开始漂。根因就是我没有评测集,只有体感。后来补了 120 条难例评测集,INT4 分数掉到 0.82、INT8 是 0.905、FP16 是 0.91,于是老老实实退回 INT8。这件事让我把"先建评测集"写进了团队的部署 checklist,列为不可跳过项。
另一个坑是 GGUF 和 GPTQ 混用导致工具链分裂:我们曾经一半服务用 ollama 跑 GGUF,一半用 vLLM 跑 GPTQ,运维要维护两套监控和两套版本矩阵,最后统一到 AWQ + vLLM,心智负担小了一大半。格式选择不只是技术指标,也是组织成本。
灰度、监控与回滚:量化不是一次性决策
一个容易踩的误区是把量化当成"转换一次、永远生效"的动作。但模型、数据、业务都在变:你今天 INT8 掉 1 个点能接受,三个月后业务加了长合同子类,可能就掉到不能忍。所以我现在的做法是把量化版本纳入"可灰度、可监控、可回滚"的运营闭环。
具体做法不复杂:线上同时保留两个版本(比如 FP16 基线 + INT8 主力),用一小部分流量打基线版本,持续对比评测集分数和关键业务指标。一旦发现主力版本掉出阈值,自动或手动切回基线。这个机制的关键不是技术多炫,而是"你永远有一个可信的对照"。
import random
def route_with_canary(req_id: str, canary_ratio: float = 0.05) -> str:
# 用请求 id 的哈希做稳定分流,避免同一请求在两次间跳版本
bucket = (hash(req_id) % 100) / 100
return "fp16_baseline" if bucket < canary_ratio else "int8_main"
def alert_if_drift(main_score: float, base_score: float, tol: float = 0.02):
if base_score - main_score > tol:
# 真实系统里这里接告警 + 自动回滚触发器
print(f"[ALERT] 主力版本相对基线掉点 {base_score - main_score:.3f},建议回滚")回滚本身也要提前演练。我见过团队量化上线后发现问题,却不知道怎么快速切回旧权重,因为转换脚本没留、原 FP16 文件被清理了。教训很朴素:量化产物和原始权重都要进版本管理,回滚路径要在上线前就跑通一次,而不是出事当晚现找。
另外补充一个常被问到的问题:动态量化(per-tensor)和静态量化(per-channel / per-token)怎么选?动态量化在推理时实时算激活的 scale,省去校准步骤、实现简单,但每步都多一次统计,略有开销;静态量化用校准集提前固定 scale,推理更快但依赖校准质量。我的经验是中小模型用动态足够,大模型或对延迟极敏感的服务值得花时间做静态校准。
回到开头那句话:量化和蒸馏不是"更差的模型",而是"更聪明的成本结构"。它们的前提永远是一个能被数字回答的问题,"掉了几点"。把这个问题量化出来,你就从"感觉还行"的泥潭里爬出来了,剩下的只是按预算做取舍。这,才是工程。