AI模型压缩进阶踩坑记录
要把一个 7B 语言模型塞进 8GB 内存的边缘设备——这不是"优化一下体验",是硬件硬顶在那儿。
原始模型推理显存超 12GB,设备可用内存只有 8GB;用户提问后 2 秒内得开始吐字,实际首次推理经常拖到 5 秒以上。量化、剪枝、蒸馏怎么组合,得在真实约束下一个个试。
为什么要压缩模型
压缩模型的原因通常很直接:硬件跑不动。
我遇到的情况是这样:需要在算力有限的边缘设备上部署一个语言模型,原始模型在推理时显存占用超过了 12GB,但设备只有 8GB 可用内存。除了硬件限制,还有推理延迟的要求——用户提问后 2 秒内要开始输出,但原始模型的首次推理需要 5 秒以上。
模型压缩的主要方向有几个:
- 量化:把高精度数值(FP32/FP16)换成低精度(INT8/INT4),用精度换空间和速度
- 剪枝:删除模型中不那么重要的参数或层,减少计算量
- 蒸馏:用大模型教小模型,让小模型学到大模型的能力
- 架构优化:把复杂结构换成轻量级变体,比如 Transformer 换成 MobileBERT
这些方法不是互斥的,通常会组合使用。但怎么组合、顺序如何,取决于具体场景和模型特性。
量化:从 FP16 到 INT8 的实战
量化是最先尝试的方向,因为它相对容易实施,效果也比较直接。
Post-Training Quantization(PTQ)
PTQ 是在模型训练完成后直接进行量化,不需要重新训练。我先用 PyTorch 的量化 API 做了尝试:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch.quantization as quant
# 加载原始模型
model_path = "/models/llama-2-7b-chat"
model = AutoModelForCausalLM.from_pretrained(
model_path,
torch_dtype=torch.float16,
device_map="auto"
)
tokenizer = AutoTokenizer.from_pretrained(model_path)
# 准备校准数据(需要真实输入,不是随便生成的)
def prepare_calibration_data(tokenizer, num_samples=100):
samples = []
with open("/data/calibration_text.txt", "r", encoding="utf-8") as f:
text = f.read()
sentences = text.split("\n")
for i in range(min(num_samples, len(sentences))):
encoded = tokenizer(sentences[i], return_tensors="pt", truncation=True, max_length=512)
samples.append(encoded["input_ids"])
return torch.cat(samples, dim=0)
calibration_data = prepare_calibration_data(tokenizer)
# 动态量化(只量化权重,不量化激活)
model_quantized = quant.quantize_dynamic(
model,
{torch.nn.Linear}, # 只量化 Linear 层
dtype=torch.qint8
)
# 保存量化后的模型
torch.save(model_quantized.state_dict(), "/models/llama-2-7b-chat-dynamic-int8.pt")
动态量化的好处是不需要校准数据,实施简单。但我测了一下,模型大小只减少了约 25%,推理速度提升不明显,而且在某些任务上精度下降了 3-4 个百分点。这说明单纯把权重换成 INT8 不够,激活值也需要量化。
静态量化(需要校准)
静态量化需要对激活值进行校准,效果更好但步骤更复杂:
# 静态量化配置
quantization_config = quant.get_default_qconfig("x86")
model.qconfig = quantization_config
model_prepared = quant.prepare(model, inplace=True)
# 校准过程
with torch.no_grad():
for i in range(0, len(calibration_data), 4):
batch = calibration_data[i:i+4].to(model.device)
_ = model(batch)
if i % 20 == 0:
print(f"Calibration progress: {i}/{len(calibration_data)}")
# 转换为量化模型
model_quantized = quant.convert(model_prepared, inplace=True)
这次效果好很多,模型大小减少了 60%,推理速度提升了约 2.5 倍。但坑来了:
坑 1:校准数据质量很重要。一开始随便用了一些网页文本做校准,结果模型在专业领域问答时完全不可用。后来换成与实际使用场景接近的技术文档,问题才解决。
坑 2:某些层不适合量化。模型中的 LayerNorm 和残差连接如果也被量化,会导致数值不稳定。需要手动排除这些层:
def get_layer_name(module):
for name, child in module.named_modules():
yield child, name
# 为不同层设置不同的量化策略
for module, name in get_layer_name(model):
if "LayerNorm" in name or "layernorm" in name.lower():
module.qconfig = None # 不量化 LayerNorm
坑 3:INT8 不是终点。在极端资源受限场景下,可以尝试 INT4 量化,但这会显著影响模型质量。我的测试显示 INT4 在通用任务上准确率下降了 15-20%,只在特定简单任务(比如文本分类)上勉强可用。
剪枝:删除冗余参数
量化解决了存储和部分计算问题,但模型的结构冗余还在。剪枝的目标是删除那些对输出影响较小的参数。
结构化剪枝 vs 非结构化剪枝
非结构化剪枝可以任意删除单个权重参数,但需要特殊硬件支持才能加速,实际意义有限。结构化剪枝删除整个通道、层或注意力头,更容易在普通硬件上加速。
我尝试了基于重要性的结构化剪枝:
import torch.nn.utils.prune as prune
def compute_importance_scores(model, calibration_data):
"""计算每个层的重要性分数"""
importance_scores = {}
with torch.no_grad():
for batch_idx in range(0, len(calibration_data), 8):
batch = calibration_data[batch_idx:batch_idx+8].to(model.device)
output = model(batch, output_hidden_states=True)
# 使用梯度信息或激活值统计作为重要性指标
# 这里用输出方差作为简化示例
hidden_states = output.hidden_states
for layer_idx, hidden_state in enumerate(hidden_states[1:]):
variance = torch.var(hidden_state, dim=[0, 1]).mean().item()
importance_scores[f"layer_{layer_idx}"] = variance
return importance_scores
importance_scores = compute_importance_scores(model, calibration_data)
# 对重要性低的层进行剪枝
threshold = 0.3 # 剪掉重要性分数低于阈值的层
for name, module in model.named_modules():
if "layers" in name:
layer_num = int(name.split(".")[3])
if importance_scores.get(f"layer_{layer_num}", 0) < threshold:
prune.l1_unstructured(module, name="weight", amount=0.4) # 剪掉 40% 参数
坑 4:剪枝比例很敏感。一开始尝试剪掉 50% 的参数,模型直接废了。后来调整策略:先剪 10%,测效果;再剪 10%,再测。最后在 30% 的剪枝比例下找到了平衡点——精度损失约 2%,推理速度提升 40%。
坑 5:剪枝后需要微调。直接剪枝后的模型通常需要少量微调来恢复性能:
from transformers import TrainingArguments, Trainer
# 准备少量训练数据(剪枝微调不需要太多)
train_dataset = prepare_training_data("/data/fine_tune_samples.jsonl", max_samples=1000)
training_args = TrainingArguments(
output_dir="/models/pruned_finetuned",
num_train_epochs=3,
per_device_train_batch_size=4,
learning_rate=1e-5,
logging_steps=10,
save_steps=50,
fp16=True,
)
trainer = Trainer(
model=model_quantized,
args=training_args,
train_dataset=train_dataset,
)
trainer.train()
微调过程中,学习率需要设置得比正常训练低一个数量级,否则会破坏剪枝的效果。
知识蒸馏:用大模型教小模型
剪枝和量化都是在现有结构上做优化,而知识蒸馏是让一个小模型(学生)去学习一个大模型(教师)的知识。
我尝试用 LLaMA-2-7B 作为教师,训练一个 3B 的学生模型:
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
TrainingArguments,
Trainer,
DataCollatorForLanguageModeling
)
# 教师模型
teacher_model = AutoModelForCausalLM.from_pretrained(
"/models/llama-2-7b-chat",
torch_dtype=torch.float16,
device_map="auto"
)
teacher_model.eval()
# 学生模型(可以从头训练或用预训练的小模型)
student_model = AutoModelForCausalLM.from_pretrained(
"/models/tinyllama-3b",
torch_dtype=torch.float16
)
# 蒸馏损失函数
class DistillationLoss:
def __init__(self, temperature=2.0, alpha=0.5):
self.temperature = temperature
self.alpha = alpha
self.kl_div = torch.nn.KLDivLoss(reduction="batchmean")
self.ce_loss = torch.nn.CrossEntropyLoss()
def __call__(self, student_outputs, teacher_outputs, labels):
# 软标签损失(教师输出)
student_log_probs = torch.nn.functional.log_softmax(
student_outputs.logits / self.temperature, dim=-1
)
teacher_probs = torch.nn.functional.softmax(
teacher_outputs.logits / self.temperature, dim=-1
)
distill_loss = self.kl_div(student_log_probs, teacher_probs) * (self.temperature ** 2)
# 硬标签损失(真实标签)
ce_loss = self.ce_loss(
student_outputs.logits.view(-1, student_outputs.logits.size(-1)),
labels.view(-1)
)
return self.alpha * distill_loss + (1 - self.alpha) * ce_loss
distillation_loss = DistillationLoss(temperature=3.0, alpha=0.7)
# 训练参数
training_args = TrainingArguments(
output_dir="/models/distilled_student",
num_train_epochs=5,
per_device_train_batch_size=8,
gradient_accumulation_steps=2,
learning_rate=5e-5,
warmup_steps=100,
logging_steps=20,
save_steps=200,
fp16=True,
deepspeed="ds_config_zero3.json", # 使用 DeepSpeed 优化显存使用
)
class DistillationTrainer(Trainer):
def compute_loss(self, model, inputs, return_outputs=False):
# 教师前向传播
with torch.no_grad():
teacher_outputs = teacher_model(**inputs)
# 学生前向传播
student_outputs = model(**inputs)
# 计算蒸馏损失
loss = distillation_loss(
student_outputs,
teacher_outputs,
inputs["labels"]
)
return (loss, student_outputs) if return_outputs else loss
# 准备训练数据
train_dataset = prepare_training_data("/data/distillation_data.jsonl")
trainer = DistillationTrainer(
model=student_model,
args=training_args,
train_dataset=train_dataset,
data_collator=DataCollatorForLanguageModeling(
tokenizer=tokenizer,
mlm=False
),
)
trainer.train()
坑 6:温度参数需要调。温度控制软标签的平滑度,温度高时软标签更平滑,温度低时接近 hard label。我的经验是温度 2.0-4.0 比较合适,具体需要根据任务调整。
坑 7:教师模型和学生模型的差距不能太大。7B 教 3B 还可以,但如果用 70B 教 3B,学生会学不会。中间需要一些过渡模型,或者用多个教师模型。
坑 8:蒸馏数据要多样。如果只用问答数据做蒸馏,学生模型在生成任务上会很差。我最终用了问答、续写、摘要三类数据的混合,比例大概是 5:3:2。
部署优化:从模型到服务
模型压缩完成后,还需要在部署层面做优化。我遇到的典型问题包括:内存占用高、首次推理慢、并发性能差。
模型格式转换
Hugging Face 的模型格式便于训练和实验,但不适合部署。我尝试了几种部署格式:
# 转换为 ONNX 格式
python -m transformers.onnx \
--model /models/llama-2-7b-chat-int8 \
--feature_map causal-lm-with-past \
/models/llama-2-7b-chat-int8.onnx
# 转换为 GGML 格式(适合 CPU 部署)
python convert_llama_weights_to_ggml.py \
/models/llama-2-7b-chat-int8 \
/models/llama-2-7b-chat-int8-ggml \
q8_0
# 转换为 GGUF 格式(新版 llama.cpp)
python convert.py /models/llama-2-7b-chat-int8 \
--outfile /models/llama-2-7b-chat-int8.gguf \
--outtype q8_0
GGUF 格式在 CPU 上效果最好,但只支持推理不支持训练。ONNX 格式兼容性好,但模型文件较大。根据实际场景选择。
推理引擎优化
直接用 PyTorch 做推理效率不高,我尝试了几个推理引擎:
# 使用 vLLM 加速推理
from vllm import LLM, SamplingParams
llm = LLM(
model="/models/llama-2-7b-chat-int8",
quantization="awq", # 使用 AWQ 量化
tensor_parallel_size=2, # 使用 2 张 GPU
max_model_len=2048,
gpu_memory_utilization=0.9,
)
sampling_params = SamplingParams(
temperature=0.7,
top_p=0.9,
max_tokens=512,
)
def generate_response(prompt):
outputs = llm.generate([prompt], sampling_params)
return outputs[0].outputs[0].text
# 使用 TensorRT-LLM(需要先转换模型)
# python convert_checkpoint.py --model_dir /models/llama-2-7b-chat-int8 \
# --output_dir /models/llama-2-7b-chat-int8-trtllm
vLLM 的 PagedAttention 技术显著提升了并发性能,我在 8 个并发请求场景下测得吞吐量提升了 3 倍。但 vLLM 对 CUDA 版本有要求,需要 11.8+,这在一些老服务器上是个问题。
坑 9:内存碎片化。在高并发场景下,GPU 内存碎片化会导致 OOM。解决办法是定期重启推理服务,或者使用内存池机制:
import gc
import torch
def cleanup_memory():
gc.collect()
torch.cuda.empty_cache()
torch.cuda.synchronize()
# 在推理循环中定期清理
request_count = 0
while True:
# 处理请求
process_request()
request_count += 1
if request_count % 100 == 0:
cleanup_memory()
坑 10:KV Cache 占用。生成任务中 KV Cache 会占用大量显存,特别是长文本场景。可以通过限制上下文长度和优化 KV Cache 存储来缓解:
# 限制 KV Cache 大小
llm = LLM(
model="/models/llama-2-7b-chat-int8",
quantization="awq",
max_model_len=1024, # 从 2048 减到 1024
enable_prefix_caching=True, # 启用前缀缓存
)
服务化部署
最后把推理服务封装成 API:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import uvicorn
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="Optimized LLM API")
class GenerationRequest(BaseModel):
prompt: str
max_tokens: int = 256
temperature: float = 0.7
@app.post("/generate")
async def generate(request: GenerationRequest):
try:
logger.info(f"Processing request: {request.prompt[:50]}...")
response = generate_response(request.prompt)
return {"response": response}
except Exception as e:
logger.error(f"Error processing request: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/health")
async def health_check():
return {"status": "healthy", "model": "llama-2-7b-chat-int8"}
if __name__ == "__main__":
uvicorn.run(
app,
host="0.0.0.0",
port=8000,
workers=2, # 根据 GPU 数量调整
log_level="info"
)
使用 gunicorn + uvicorn 部署可以进一步提升并发性能:
gunicorn -w 4 -k uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--timeout 300 \
--access-logfile - \
--error-logfile - \
api_server:app
结果和对比
经过上述优化后,最终效果如下:
| 指标 | 原始模型 | 优化后 | 变化 |
|---|---|---|---|
| 模型大小 | 13.2GB | 2.1GB | -84% |
| 显存占用 | 12.5GB | 3.8GB | -70% |
| 首次推理延迟 | 5.2s | 1.8s | -65% |
| 吞吐量(req/s) | 0.8 | 3.2 | +300% |
| 任务准确率 | 92.3% | 89.7% | -2.6% |
下图把压缩前后的五项核心指标并列,便于直观感受体积、延迟与精度之间的 trade-off。

精度损失 2.6 个百分点,但性能提升显著;对边缘设备上的技术问答场景,这个取舍是可以接受的。
一些经验总结
折腾了这么久,有些经验值得记录:
没有通用最优方案。不同模型、不同场景、不同硬件,最优策略不同。别人的经验可以参考,但一定要在自己场景上测试。
循序渐进。不要一开始就上所有优化手段,先做量化和剪枝,再考虑蒸馏和架构优化。每做一步都要测效果。
量化是最划算的。投入产出比最高,相对简单,效果明显。先做量化,不够再考虑其他方法。
校准数据要真实。无论是量化校准还是蒸馏训练,数据都要接近实际使用场景。用随机数据校准的模型在实际任务上表现会很差。
部署优化不容忽视。模型再小,如果推理服务写得烂,整体性能也好不了。模型压缩和服务优化要一起考虑。
监控和调试很重要。部署后要持续监控显存占用、推理延迟、错误率等指标。出现问题要有办法定位,比如用 profiling 工具分析瓶颈。
最后的话
模型压缩这事儿,说到底就是在有限资源下最大化可用性。没有银弹,只有不断的尝试和调整。有些问题可能永远没有完美答案,但在解决这些问题的过程中,对模型本身的理解也会加深。
压缩后的模型在性能上可能永远比不上原始模型,但它能在更多地方跑起来,让更多人用上,这本身就有价值。就像搬家时整理箱子,书确实少了些,但至少搬得动了,到了新家还能读,这不就够了吗?
版权声明: 本文首发于 指尖魔法屋-AI模型压缩进阶踩坑记录(https://blog.thinkmoon.cn/post/255-ai-model-compression-training-deployment/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。