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.2GB2.1GB-84%
显存占用12.5GB3.8GB-70%
首次推理延迟5.2s1.8s-65%
吞吐量(req/s)0.83.2+300%
任务准确率92.3%89.7%-2.6%

下图把压缩前后的五项核心指标并列,便于直观感受体积、延迟与精度之间的 trade-off。

7B 语言模型压缩前后在模型大小、显存、延迟、吞吐和准确率上的对比

精度损失 2.6 个百分点,但性能提升显著;对边缘设备上的技术问答场景,这个取舍是可以接受的。

一些经验总结

折腾了这么久,有些经验值得记录:

  1. 没有通用最优方案。不同模型、不同场景、不同硬件,最优策略不同。别人的经验可以参考,但一定要在自己场景上测试。

  2. 循序渐进。不要一开始就上所有优化手段,先做量化和剪枝,再考虑蒸馏和架构优化。每做一步都要测效果。

  3. 量化是最划算的。投入产出比最高,相对简单,效果明显。先做量化,不够再考虑其他方法。

  4. 校准数据要真实。无论是量化校准还是蒸馏训练,数据都要接近实际使用场景。用随机数据校准的模型在实际任务上表现会很差。

  5. 部署优化不容忽视。模型再小,如果推理服务写得烂,整体性能也好不了。模型压缩和服务优化要一起考虑。

  6. 监控和调试很重要。部署后要持续监控显存占用、推理延迟、错误率等指标。出现问题要有办法定位,比如用 profiling 工具分析瓶颈。

最后的话

模型压缩这事儿,说到底就是在有限资源下最大化可用性。没有银弹,只有不断的尝试和调整。有些问题可能永远没有完美答案,但在解决这些问题的过程中,对模型本身的理解也会加深。

压缩后的模型在性能上可能永远比不上原始模型,但它能在更多地方跑起来,让更多人用上,这本身就有价值。就像搬家时整理箱子,书确实少了些,但至少搬得动了,到了新家还能读,这不就够了吗?

版权声明: 本文首发于 指尖魔法屋-AI模型压缩进阶踩坑记录https://blog.thinkmoon.cn/post/255-ai-model-compression-training-deployment/) 转载或引用必须申明原指尖魔法屋来源及源地址!