定位:既适合第一次接触大模型量化的读者,也足够支撑你把 AWQ 用到真实项目中。

上一篇我们把 AWQ 的核心原理讲清楚了:为什么不能只看权重大小,为什么激活能帮助定位重要通道,以及如何通过等价缩放降低 INT4 量化误差。本文不再停留在概念层,而是把这套方法落到一条可以执行、可以验证、可以排错的工程链路上。

你会完成这样一件事:把一个 FP16/BF16 大语言模型压缩为 W4A16 的 AWQ 模型,保存到本地,用 vLLM 或 Transformers 加载,再从模型体积、显存、速度、困惑度和真实任务表现几个角度判断它到底“省了多少、损了多少、值不值得部署”。


先说一个 2026 年必须知道的版本变化

很多旧教程——包括本文原始版本——把 AutoAWQ 当作默认工具。这个选择在 2024 年前后很合理,但现在需要更新:

  • AutoAWQ 仓库已于 2025 年 5 月归档,并明确标注停止维护;
  • vLLM 已把相关能力接入 llm-compressor,官方文档也把它作为新的推荐量化路线;
  • Transformers 仍能加载许多 AWQ 模型,但“能加载”不等于“在你的硬件上一定能得到 INT4 kernel 的性能收益”;
  • 因此,本文主线改为:LLM Compressor 负责量化,vLLM 负责高性能推理
  • 为了兼容旧项目和已有 AutoAWQ 教程,文末仍保留一套“遗留兼容路线”,并明确它适合什么、不适合什么。

这不是简单地把库名替换掉。工具链变化会影响环境隔离、模型保存格式、推理后端、性能测试方法以及排错思路。本文会把这些差异讲透。


你最终会得到什么

完成本文后,你手上会有以下产物:

awq-project/
├── quantize_awq.py              # 主量化脚本
├── calibration.jsonl            # 可选:你自己的校准数据
├── inspect_model.py             # 检查模型配置与文件体积
├── benchmark_vllm.py            # 简单离线性能测试
├── evaluate_ppl.py              # 困惑度评测示例
├── eval_prompts.jsonl           # 固定的业务测试集
└── outputs/
    └── qwen2.5-1.5b-awq-w4a16/  # 量化模型目录

你还会真正理解下面这些问题:

  1. W4A16group_size=128、非对称量化分别是什么意思;
  2. 为什么校准数据的“代表性”通常比“数量”更重要;
  3. AWQ 到底是在“保留少量 FP16 权重”,还是在做别的事;
  4. 为什么磁盘体积接近四分之一,运行显存却未必也正好变成四分之一;
  5. 为什么量化后有时更快,有时只省显存,甚至在某些场景反而更慢;
  6. 怎样做公平的 FP16 与 INT4 对比,而不是只看一条回答“感觉还行”;
  7. 什么时候选 AWQ,什么时候更适合 GPTQ、bitsandbytes、FP8 或 GGUF。

阅读方式:入门、进阶、深入三条线

本文内容较长,可以按目标阅读:

  • 第一次上手:先读第 1~6 节,跑通量化和推理;
  • 准备做项目:继续读第 7~11 节,完成评测、性能测试和排错;
  • 想真正理解 AWQ:重点读第 12~14 节,把代码参数和数学原理对应起来。

不要一开始就执着于把所有细节背下来。大模型量化最有效的学习方式是:先获得一个可运行的结果,再用评测和异常反推原理。

目录

  1. 先建立全局认识:我们到底在做什么
  2. 开始之前:模型、硬件和环境怎么选
  3. 校准数据:AWQ 成败最容易被低估的一环
  4. 主线方案:用 LLM Compressor 完成 AWQ W4A16 量化
  5. 把量化脚本拆开:每一段代码到底做了什么
  6. 关键参数详解:从会填配置到知道为什么
  7. 加载量化模型:功能检查和高性能推理要分开
  8. 量化前后怎么评测:不要用“看起来差不多”代替证据
  9. 如何解释评测结果:几个最容易误判的现象
  10. 常见报错与排障:先按症状定位,不要盲目重装
  11. 遗留兼容路线:继续使用 AutoAWQ 时要知道什么
  12. 深入原理:AWQ 为什么能在 INT4 下保住质量
  13. AWQ、GPTQ、bitsandbytes、FP8、GGUF 怎么选
  14. 从实验到生产:一份可执行的上线清单
  15. 把整条链路再串一次
  16. 小结
  17. 附录 A:从“第一次跑通”到“可上线候选”的三轮实验法
  18. 附录 B:新手最常问的 26 个问题
  19. 附录 C:从“能运行”到“可上线”的完整业务化迭代案例
  20. 参考资料与版本说明

一、先建立全局认识:我们到底在做什么

1.1 一条 AWQ 链路包含哪些步骤

把一个原始模型变成可部署的 AWQ 模型,工程上通常分为八步:

1. 选择原始 FP16/BF16 模型
2. 准备少量有代表性的校准数据
3. 用校准数据跑前向传播,收集关键激活统计
4. 搜索每层或每组通道的缩放系数
5. 应用等价缩放,并把权重量化为 INT4
6. 以推理后端能识别的格式保存模型
7. 加载量化模型,先做功能冒烟测试
8. 对比质量、显存、延迟、吞吐和稳定性

最容易被忽略的是第 6~8 步。很多人以为“量化脚本不报错”就算完成,实际上:

  • 模型可能没有真正按压缩格式保存;
  • 推理框架可能把权重反量化回高精度计算,只是能运行,并没有加速;
  • 输出看起来正常,但某类任务精度已经明显下降;
  • 单请求延迟变低了,但高并发吞吐反而不如原模型;
  • 模型权重省了很多显存,长上下文下却被 KV Cache 占满。

所以,量化成功不是一个布尔值,而是一组需要验证的结果。

1.2 AWQ 是训练吗

通常不是。

AWQ 属于 PTQ(Post-Training Quantization,训练后量化)。它不需要像微调那样反向传播几轮,也不需要更新整个模型的知识。它主要做的是:

  1. 用少量数据运行前向传播;
  2. 观察激活分布;
  3. 搜索更适合量化的缩放方式;
  4. 把权重压缩到低比特格式。

因此,AWQ 的校准数据量远小于训练数据量,运行时间也通常远短于微调。但“不是训练”不代表“随便拿几条文本就行”。因为缩放系数来自这些样本上的激活统计,校准集偏离真实业务,量化误差也可能偏向错误的方向。

1.3 W4A16 到底表示什么

W4A16 可以拆成两部分:

  • W4:模型的主要线性层权重以 4 bit 形式存储;
  • A16:推理时激活通常仍以 FP16 或 BF16 一类 16 bit 浮点格式计算。

它不是“整个模型所有东西都变成 INT4”。通常仍有一些内容保持高精度,例如:

  • LayerNorm 参数;
  • 部分 embedding;
  • lm_head
  • scale、zero-point 等量化元数据;
  • 中间激活;
  • KV Cache;
  • 某些模型架构中被明确排除的模块。

这也是为什么 4 bit 模型的实际文件体积和运行显存都不会严格等于 FP16 的 25%。

1.4 量化是一次性成本,推理是重复成本

量化过程可能需要几分钟,也可能需要几小时,取决于模型大小、校准长度、样本数量、搜索网格和硬件。但量化结果保存后,可以反复加载和部署。

可以把总成本写成:

总成本 = 一次量化成本 + 每次推理成本 × 推理次数

如果模型只临时跑十次,花很久做精细量化可能不划算;如果模型要在线服务几个月,哪怕每次请求只省一点显存或带宽,累计收益也可能非常大。


二、开始之前:模型、硬件和环境怎么选

2.1 先用小模型跑通,不等于只会量化小模型

入门最常见的错误,是一上来就选择 14B、32B 甚至 70B 模型。大模型并不会让你学到更多基础概念,只会让环境、显存、下载和排错成本一起上升。

本文示例优先使用:

MODEL_ID = "Qwen/Qwen2.5-1.5B-Instruct"

选择它的原因不是它“最好”,而是:

  • 参数量适合个人显卡做流程验证;
  • 中英文能力都比纯英文微型模型更适合中文读者检查输出;
  • 使用标准 Transformers 接口;
  • 量化前后文件体积差异足够明显;
  • 同一套流程可迁移到更大的同类模型。

如果你的环境或当前版本对该架构支持不完整,可以先换成官方 AWQ 示例中使用的 Llama 系模型,或选择一个结构相近、公开可下载的 Llama 架构小模型。工具对模型架构的支持是版本相关能力,不是算法理论上的保证。

2.2 用参数量估算“权重下限”

只看权重本身,可以用一个简单公式估算体积:

权重体积 ≈ 参数量 × 每个参数的字节数

常见精度的理论字节数:

格式 每个参数理论占用
FP32 4 字节
FP16 / BF16 2 字节
INT8 1 字节
INT4 0.5 字节

因此:

参数量 FP16 权重理论值 INT4 原始权重理论值 实际 AWQ 文件常见范围说明
1.5B 约 3.0 GB 约 0.75 GB 还要加 scale、zero-point、高精度模块和配置文件
7B 约 14 GB 约 3.5 GB 实际通常高于 3.5 GB
14B 约 28 GB 约 7 GB 量化阶段所需内存远高于最终文件体积
70B 约 140 GB 约 35 GB 通常需要多卡或大量 CPU 内存协同

这张表只能估算“权重存储下限”,不能直接当成显存需求。运行时还要考虑:

  • CUDA kernel 工作区;
  • 模型未量化模块;
  • 临时张量;
  • 输入激活;
  • KV Cache;
  • 推理框架预留的显存池;
  • 批大小和上下文长度。

2.3 量化阶段为什么可能比推理阶段更吃资源

最终模型是 INT4,不代表量化时模型一开始就是 INT4。量化过程往往需要:

  1. 先加载原始 FP16/BF16 权重;
  2. 运行校准样本,缓存部分激活;
  3. 为候选缩放系数计算误差;
  4. 生成并保存压缩权重。

所以一个能在 8 GB 显存里推理的 7B INT4 模型,不代表你也能在同样 8 GB 显存里轻松完成它的量化。遇到显存不足时,可以尝试:

  • 减少 num_calibration_samples
  • 缩短 max_seq_length
  • 使用 CPU offload;
  • 关闭其他占用显存的进程;
  • 用更小模型先验证脚本;
  • 对大模型使用多卡或框架提供的分层加载机制。

2.4 推荐把量化环境和推理环境分开

大模型工具链经常围绕 PyTorch、CUDA、Transformers 形成严格依赖。llm-compressorvLLM 的最佳依赖组合不一定完全一致,因此更稳妥的方式是创建两个虚拟环境:

.venv-quant   # 安装 llmcompressor,负责量化
.venv-serve   # 安装 vllm,负责推理和性能评测

量化环境

python -m venv .venv-quant
source .venv-quant/bin/activate
python -m pip install --upgrade pip
pip install llmcompressor datasets transformers accelerate safetensors

推理环境

python -m venv .venv-serve
source .venv-serve/bin/activate
python -m pip install --upgrade pip
pip install vllm

如果你使用 Conda、uv 或容器,也可以采用同样的“环境隔离”思想。重点不是命令长什么样,而是:不要为了修一个库的版本,把另一个已经能运行的环境一起改坏。

2.5 先做环境体检

在正式量化前,运行下面的脚本:

# check_env.py
import importlib.metadata as metadata
import platform

import torch

packages = [
    "torch",
    "transformers",
    "datasets",
    "llmcompressor",
    "compressed-tensors",
]

print("Python:", platform.python_version())
print("Platform:", platform.platform())
print("CUDA available:", torch.cuda.is_available())
print("Torch CUDA version:", torch.version.cuda)

if torch.cuda.is_available():
    print("GPU:", torch.cuda.get_device_name(0))
    capability = torch.cuda.get_device_capability(0)
    print("Compute capability:", capability)
    free_bytes, total_bytes = torch.cuda.mem_get_info()
    print(f"Free VRAM: {free_bytes / 1024**3:.2f} GiB")
    print(f"Total VRAM: {total_bytes / 1024**3:.2f} GiB")

for name in packages:
    try:
        print(f"{name}: {metadata.version(name)}")
    except metadata.PackageNotFoundError:
        print(f"{name}: not installed")

同时保存:

nvidia-smi
pip freeze > requirements-lock.txt

以后遇到“昨天能跑,今天不能跑”的情况,版本记录比回忆可靠得多。


三、校准数据:AWQ 成败最容易被低估的一环

3.1 校准数据不是训练数据,但也不能乱选

AWQ 不需要让模型“学会”校准文本,它只需要观察这些文本经过模型时的激活分布。直觉上,线性层输出可以写成:

y = xW

如果某个输入通道 x_j 经常很大,那么该通道对应权重发生一点量化误差时,对输出的影响也更大。AWQ 正是借助激活统计,判断哪些通道需要更多保护。

这意味着:

  • 中文客服模型最好用中文客服式对话校准;
  • 代码模型最好包含代码、注释和长缩进结构;
  • 工具调用模型最好包含真实的 system/user/assistant/tool 轮次;
  • 长文模型不能只用几十个 token 的短句;
  • 数学模型最好包含公式、数字和推理链式文本;
  • 领域模型应覆盖真实术语和常见格式。

校准集不需要很大,但必须“像你以后真正会喂给模型的东西”。

3.2 多少条样本才够

没有一个对所有模型都最优的固定数字。实用上可以从下面的范围开始:

目标 样本数建议 最大长度建议 说明
只验证流程 32~64 256~512 快,但不适合据此判断最终质量
普通聊天模型 128~256 512~1024 常见起点
领域模型或长文本 256~512 1024~2048 更重视覆盖面和长度分布
正式生产候选 先做消融实验 按业务分布 比较 128/256/512 的收益是否值得成本

官方示例常把 256 条作为起点。原始教程中使用 128 条也并非错误;更准确的说法是:128 和 256 都是常见起步值,最终应通过量化后评测决定,而不是靠经验数字拍板。

3.3 校准数据要和聊天模板一致

对 Instruct/Chat 模型,原始字段通常不是模型真正看到的文本。模型看到的是 tokenizer 根据聊天模板拼接后的序列,例如:

<|im_start|>system
你是一个有帮助的助手。<|im_end|>
<|im_start|>user
解释一下 AWQ。<|im_end|>
<|im_start|>assistant
AWQ 是……<|im_end|>

因此,不要简单地把 userassistant 文本用换行拼起来。应该使用模型自己的:

tokenizer.apply_chat_template(...)

模板中的特殊 token、角色标记和轮次边界都会影响激活分布。

3.4 推荐的本地 JSONL 格式

准备一个 calibration.jsonl,每行一条样本:

{"messages":[{"role":"system","content":"你是企业知识库助手。"},{"role":"user","content":"合同里不可抗力条款一般解决什么问题?"},{"role":"assistant","content":"不可抗力条款通常用于约定……"}]}
{"messages":[{"role":"user","content":"写一个 Python 函数,返回列表中出现频率最高的元素。"},{"role":"assistant","content":"可以使用 collections.Counter……"}]}
{"messages":[{"role":"user","content":"把下面这段会议纪要压缩成三个行动项:……"},{"role":"assistant","content":"1. ……\n2. ……\n3. ……"}]}

如果你只有纯文本,也可以用:

{"text":"这是一段与未来推理场景接近的文本。"}

正式项目里建议记录校准集的:

  • 来源和许可;
  • 语言比例;
  • 领域比例;
  • token 长度分布;
  • 是否包含多轮对话;
  • 是否包含隐私信息;
  • 去重规则;
  • 数据版本或哈希。

3.5 校准集和评测集必须分开

校准过程虽然不是训练,但它确实利用了样本分布来选择缩放系数。为了避免自我欺骗,评测集应该独立于校准集。

正确做法是:

校准集:帮助选择量化参数
评测集:判断量化后是否真的保住能力

如果你用同一批文本做两件事,结果可能过于乐观,尤其是数据量很小时。

3.6 先看 token 长度,不要只看字符长度

下面的脚本可以统计本地校准集的 token 长度:

# inspect_calibration.py
import json
from pathlib import Path

from transformers import AutoTokenizer

MODEL_ID = "Qwen/Qwen2.5-1.5B-Instruct"
DATA_PATH = Path("calibration.jsonl")

tokenizer = AutoTokenizer.from_pretrained(
    MODEL_ID,
    trust_remote_code=True,
)

lengths = []
with DATA_PATH.open("r", encoding="utf-8") as f:
    for line_no, line in enumerate(f, start=1):
        if not line.strip():
            continue
        item = json.loads(line)
        if "messages" in item:
            text = tokenizer.apply_chat_template(
                item["messages"],
                tokenize=False,
                add_generation_prompt=False,
            )
        elif "text" in item:
            text = item["text"]
        else:
            raise ValueError(f"第 {line_no} 行缺少 messages 或 text 字段")

        token_ids = tokenizer(text, add_special_tokens=False).input_ids
        lengths.append(len(token_ids))

if not lengths:
    raise RuntimeError("没有读取到有效样本")

lengths.sort()

def percentile(p: float) -> int:
    index = min(int((len(lengths) - 1) * p), len(lengths) - 1)
    return lengths[index]

print("样本数:", len(lengths))
print("最短:", lengths[0])
print("P50:", percentile(0.50))
print("P90:", percentile(0.90))
print("P95:", percentile(0.95))
print("最长:", lengths[-1])

如果你的 P95 已经接近 1500 token,而量化时 max_seq_length=512,那大量真实结构会被截断。反过来,如果业务都是短问答,直接把长度开到 4096 只会增加时间和内存。


四、主线方案:用 LLM Compressor 完成 AWQ W4A16 量化

4.1 量化脚本的设计目标

下面的脚本不是最短的“十行演示”,而是一份适合长期复用的基础版本。它会:

  • 支持公开演示数据和本地 JSONL;
  • 正确应用聊天模板;
  • 固定随机种子;
  • 输出量化配置;
  • 以压缩格式保存;
  • 记录关键依赖版本;
  • 在保存前做一次简单生成检查;
  • 对常见输入错误给出明确异常。

4.2 完整脚本:quantize_awq.py

from __future__ import annotations

import argparse
import importlib.metadata as metadata
import json
import random
from pathlib import Path
from typing import Any

import torch
from datasets import Dataset, load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer

from compressed_tensors.offload import dispatch_model
from llmcompressor import oneshot
from llmcompressor.modifiers.quantization import QuantizationModifier

# 新版路径。部分旧版 llm-compressor 仍保留兼容导入路径。
try:
    from llmcompressor.modifiers.transform.awq import AWQModifier
except ImportError:
    from llmcompressor.modifiers.awq import AWQModifier


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="使用 LLM Compressor 对 Hugging Face 因果语言模型执行 AWQ W4A16 量化"
    )
    parser.add_argument(
        "--model-id",
        default="Qwen/Qwen2.5-1.5B-Instruct",
        help="Hugging Face 模型 ID 或本地模型目录",
    )
    parser.add_argument(
        "--output-dir",
        default="outputs/qwen2.5-1.5b-awq-w4a16",
        help="量化模型保存目录",
    )
    parser.add_argument(
        "--calibration-file",
        default=None,
        help="可选,本地 JSONL;每行包含 messages 或 text 字段",
    )
    parser.add_argument(
        "--num-samples",
        type=int,
        default=256,
        help="校准样本数",
    )
    parser.add_argument(
        "--max-seq-length",
        type=int,
        default=512,
        help="校准时最大 token 长度",
    )
    parser.add_argument(
        "--seed",
        type=int,
        default=42,
        help="随机种子",
    )
    parser.add_argument(
        "--trust-remote-code",
        action="store_true",
        help="仅在信任模型仓库代码时启用",
    )
    return parser.parse_args()


def set_seed(seed: int) -> None:
    random.seed(seed)
    torch.manual_seed(seed)
    if torch.cuda.is_available():
        torch.cuda.manual_seed_all(seed)


def messages_to_text(tokenizer: Any, messages: list[dict[str, str]]) -> str:
    if not isinstance(messages, list) or not messages:
        raise ValueError("messages 必须是非空列表")

    return tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=False,
    )


def load_calibration_dataset(
    tokenizer: Any,
    calibration_file: str | None,
    num_samples: int,
    seed: int,
) -> Dataset:
    if num_samples <= 0:
        raise ValueError("num_samples 必须大于 0")

    if calibration_file:
        path = Path(calibration_file)
        if not path.exists():
            raise FileNotFoundError(f"校准文件不存在: {path}")

        dataset = load_dataset(
            "json",
            data_files=str(path),
            split="train",
        )

        def preprocess_local(example: dict[str, Any]) -> dict[str, str]:
            if example.get("messages"):
                text = messages_to_text(tokenizer, example["messages"])
            elif isinstance(example.get("text"), str):
                text = example["text"]
            else:
                raise ValueError("每条本地样本必须包含 messages 或 text")
            return {"text": text.strip()}

        dataset = dataset.map(preprocess_local)
    else:
        # 公开数据只用于演示。正式项目应替换为贴近业务的校准集。
        # 只取一个有限候选池,避免为了教学示例下载并处理整个数据集。
        demo_pool_size = max(num_samples * 4, num_samples)
        dataset = load_dataset(
            "HuggingFaceH4/ultrachat_200k",
            split=f"train_sft[:{demo_pool_size}]",
        )

        def preprocess_demo(example: dict[str, Any]) -> dict[str, str]:
            return {
                "text": messages_to_text(tokenizer, example["messages"]).strip()
            }

        dataset = dataset.map(preprocess_demo)

    dataset = dataset.filter(
        lambda example: isinstance(example.get("text"), str)
        and len(example["text"].strip()) > 0
    )
    dataset = dataset.shuffle(seed=seed)

    take = min(num_samples, len(dataset))
    if take == 0:
        raise RuntimeError("校准数据经过清洗后为空")

    if take < num_samples:
        print(f"警告:只找到 {take} 条有效样本,少于请求的 {num_samples} 条")

    return dataset.select(range(take))


def package_versions() -> dict[str, str]:
    names = [
        "torch",
        "transformers",
        "datasets",
        "llmcompressor",
        "compressed-tensors",
    ]
    result: dict[str, str] = {}
    for name in names:
        try:
            result[name] = metadata.version(name)
        except metadata.PackageNotFoundError:
            result[name] = "not-installed"
    return result


def smoke_test(model: Any, tokenizer: Any) -> None:
    prompt = "请用两句话解释什么是大模型权重量化。"
    messages = [{"role": "user", "content": prompt}]

    if hasattr(tokenizer, "apply_chat_template"):
        text = tokenizer.apply_chat_template(
            messages,
            tokenize=False,
            add_generation_prompt=True,
        )
    else:
        text = prompt

    inputs = tokenizer(text, return_tensors="pt")
    device = getattr(model, "device", next(model.parameters()).device)
    inputs = {key: value.to(device) for key, value in inputs.items()}

    with torch.inference_mode():
        output = model.generate(
            **inputs,
            max_new_tokens=80,
            do_sample=False,
        )

    generated = output[0, inputs["input_ids"].shape[1] :]
    response = tokenizer.decode(generated, skip_special_tokens=True)
    print("\n========== 量化后冒烟测试 ==========")
    print(response.strip())
    print("====================================\n")


def main() -> None:
    args = parse_args()
    set_seed(args.seed)

    output_dir = Path(args.output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    print("[1/5] 加载 tokenizer")
    tokenizer = AutoTokenizer.from_pretrained(
        args.model_id,
        trust_remote_code=args.trust_remote_code,
    )

    print("[2/5] 加载原始模型")
    model = AutoModelForCausalLM.from_pretrained(
        args.model_id,
        dtype="auto",
        low_cpu_mem_usage=True,
        trust_remote_code=args.trust_remote_code,
    )

    print("[3/5] 准备校准数据")
    calibration_dataset = load_calibration_dataset(
        tokenizer=tokenizer,
        calibration_file=args.calibration_file,
        num_samples=args.num_samples,
        seed=args.seed,
    )
    print("有效校准样本数:", len(calibration_dataset))

    # AWQModifier 负责搜索并应用缩放;QuantizationModifier 负责真正的 W4A16 量化。
    recipe = [
        AWQModifier(duo_scaling="both"),
        QuantizationModifier(
            ignore=["lm_head"],
            scheme="W4A16_ASYM",
            targets=["Linear"],
        ),
    ]

    print("[4/5] 执行 AWQ 搜索与 W4A16 量化")
    oneshot(
        model=model,
        dataset=calibration_dataset,
        recipe=recipe,
        max_seq_length=args.max_seq_length,
        num_calibration_samples=len(calibration_dataset),
    )

    # 量化流程可能把部分模块卸载到 CPU;生成前按框架规则重新调度。
    dispatch_model(model)

    # 这一步只检查输出是否明显异常,不代替正式评测。
    smoke_test(model, tokenizer)

    print("[5/5] 保存压缩模型")
    model.save_pretrained(
        output_dir,
        save_compressed=True,
    )
    tokenizer.save_pretrained(output_dir)

    run_metadata = {
        "model_id": args.model_id,
        "output_dir": str(output_dir),
        "calibration_file": args.calibration_file,
        "num_samples": len(calibration_dataset),
        "max_seq_length": args.max_seq_length,
        "seed": args.seed,
        "recipe": {
            "algorithm": "AWQ",
            "scheme": "W4A16_ASYM",
            "ignore": ["lm_head"],
            "targets": ["Linear"],
            "duo_scaling": "both",
        },
        "versions": package_versions(),
    }
    (output_dir / "quantization_run.json").write_text(
        json.dumps(run_metadata, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )

    print(f"量化完成,模型已保存到: {output_dir.resolve()}")


if __name__ == "__main__":
    main()

4.3 运行命令

先用公开演示数据跑通:

source .venv-quant/bin/activate
python quantize_awq.py \
  --model-id Qwen/Qwen2.5-1.5B-Instruct \
  --output-dir outputs/qwen2.5-1.5b-awq-w4a16 \
  --num-samples 256 \
  --max-seq-length 512 \
  --trust-remote-code

换成自己的数据:

python quantize_awq.py \
  --model-id Qwen/Qwen2.5-1.5B-Instruct \
  --output-dir outputs/qwen2.5-1.5b-awq-domain \
  --calibration-file calibration.jsonl \
  --num-samples 256 \
  --max-seq-length 1024 \
  --trust-remote-code

trust_remote_code 会执行模型仓库提供的 Python 代码。只有在你信任模型来源、审查过仓库内容时才应该开启。对不需要该参数的模型,可以省略。

4.4 不要只看“程序结束”,还要检查输出目录

量化完成后,至少确认:

outputs/qwen2.5-1.5b-awq-w4a16/
├── config.json
├── generation_config.json       # 可能存在
├── model*.safetensors
├── tokenizer.json               # 具体文件随 tokenizer 而异
├── tokenizer_config.json
└── quantization_run.json

重点打开 config.json,查找 quantization_config。字段命名会随保存格式和版本变化,但你应该能看到类似信息:

  • 量化方法;
  • 权重位数;
  • group size;
  • 是否对称;
  • 目标模块;
  • 被排除的模块;
  • 压缩格式。

如果输出目录大小几乎和原模型一样,或配置里完全没有量化信息,不要急着进入部署。先检查:

  • 是否用了 save_compressed=True
  • 保存的是不是量化后的 model 对象;
  • 是否误把输出写回原模型目录;
  • 当前版本是否支持目标架构;
  • 日志中是否有“只做模拟量化”或“无法压缩”的警告。

五、把量化脚本拆开:每一段代码到底做了什么

完整脚本能跑通只是第一步。真正到了项目里,你一定会改模型、改数据、改长度、改量化方案。下面把关键逻辑逐段拆开。

5.1 为什么先加载 tokenizer,再加载模型

校准数据需要通过模型自己的聊天模板转成真实输入形式,而聊天模板通常存放在 tokenizer 配置中。因此先加载 tokenizer,才能正确处理 messages

tokenizer.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=False,
)

这里的 add_generation_prompt=False 很重要。校准样本如果已经包含 assistant 回答,就不需要再追加“等待模型生成”的提示标记。推理时则常设为 True,告诉模型接下来轮到 assistant 输出。

5.2 为什么校准集里保留 text 字段即可

LLM Compressor 的 oneshot 接口可以接收包含文本字段的数据集,并根据 max_seq_length 完成校准所需的处理。我们没有把所有 token 预先写入磁盘,是为了让脚本更简单,也减少 tokenizer 和模型配置不一致的风险。

不过,当你需要更严格地控制 padding、截断、特殊 token 或 loss mask 时,可以在进入 oneshot 前自行完成 tokenization。那属于进阶用法,务必确认当前 llm-compressor 版本期望的数据字段。

5.3 AWQModifierQuantizationModifier 为什么是两个对象

这是现代实现里非常值得理解的一点:

recipe = [
    AWQModifier(duo_scaling="both"),
    QuantizationModifier(
        ignore=["lm_head"],
        scheme="W4A16_ASYM",
        targets=["Linear"],
    ),
]

它们不是重复工作,而是分工不同:

  • AWQModifier:观察激活,搜索缩放系数,并对相互关联的层做等价变换;
  • QuantizationModifier:按照指定量化格式,把目标权重真正映射、打包并保存为低比特表示。

可以把它类比成:

AWQModifier          = 先把地形整理得更适合修路
QuantizationModifier = 真正铺设四车道低比特公路

只做后者,更接近普通的 round-to-nearest 分组量化;加上 AWQ 的缩放搜索,才是在保护激活敏感通道。

5.4 为什么通常排除 lm_head

lm_head 把隐藏状态映射到词表 logits,直接决定下一个 token 的分数。它可能:

  • 参数量相对主体不算最大;
  • 对最终 token 排名敏感;
  • 与 embedding 共享权重;
  • 在不同模型架构中有特殊处理。

因此很多权重量化配方会保留 lm_head 的高精度。这样牺牲少量压缩率,换取更稳的输出质量和兼容性。

这不是绝对规则。如果你的后端明确支持、评测也证明量化 lm_head 没问题,可以进一步尝试。但不要为了少省一点体积,在没有评测的情况下把所有层一刀切。

5.5 save_compressed=True 为什么不能漏

量化框架在内存中可能维护:

  • 原始浮点权重;
  • 伪量化权重;
  • 量化参数;
  • 压缩后的 packed tensor。

如果保存接口没有明确要求压缩格式,得到的文件可能仍然接近高精度体积,或者只能用于研究验证,无法被推理 kernel 直接消费。

所以:

model.save_pretrained(output_dir, save_compressed=True)

不是一个可有可无的优化参数,而是“最终是否真的保存压缩权重”的关键开关之一。

5.6 冒烟测试为什么不能替代正式评测

脚本量化后生成一小段文本,主要用于发现这类灾难性问题:

  • 输出全是乱码;
  • 只重复一个 token;
  • 生成立即结束;
  • 聊天模板错误;
  • 权重保存前已经损坏;
  • device 或 dtype 处理异常。

但一条回答看起来正常,不能证明:

  • 数学能力没下降;
  • 长上下文没退化;
  • 英文或代码能力没退化;
  • 并发吞吐更高;
  • 困惑度只轻微变化;
  • 特定业务意图识别没掉点。

因此,冒烟测试只能回答“模型还活着吗”,不能回答“模型还好吗”。


六、关键参数详解:从会填配置到知道为什么

6.1 scheme="W4A16_ASYM"

这个名字包含三层信息:

  • W4:权重 4 bit;
  • A16:激活 16 bit;
  • ASYM:使用非对称量化,通常包含 zero-point。

对一个权重组,非对称量化可以写成:

q = clamp(round(w / scale) + zero_point, q_min, q_max)
ŵ = scale × (q - zero_point)

其中:

  • w 是原始浮点权重;
  • q 是低比特整数;
  • ŵ 是反量化后的近似权重;
  • scale 决定量化步长;
  • zero_point 把真实的零映射到整数网格中的某个位置。

如果权重分布不是围绕 0 完全对称,非对称量化往往能更充分地利用有限的整数范围。

对称量化与非对称量化的直觉差异

假设某组权重大致分布在:

[-0.2, 1.0]

对称量化为了同时覆盖正负范围,通常会按 [-1.0, 1.0] 设计网格,负半轴中大量区间实际上没有权重使用。非对称量化可以让网格更贴近 [-0.2, 1.0],减少浪费。

代价是需要额外保存 zero-point,并且不同 kernel 对非对称格式的优化程度可能不同。

6.2 group_size=128 是在哪里体现的

很多 W4A16 预设会使用分组量化,常见 group size 是 128。意思是:每 128 个权重共享一套 scale,非对称时通常还共享 zero-point。

为什么要分组?假设一整行权重既有极小值,也有少量极大值。如果整行共用一个 scale,极大值会拉大量化范围,让多数小权重只能落在很粗的网格上。切成小组后,每组可以有自己的范围,误差通常更小。

group size 的基本权衡

group size 精度倾向 元数据开销 kernel 友好度 常见用途
32 较高 较高 依后端而定 极度重视质量
64 中高 较常见 质量优先实验
128 平衡 较低 广泛支持 常用默认值
256 略低 更低 依后端而定 更重视体积或吞吐
per-channel 视实现而定 不同 后端依赖强 特定算法或硬件

不要把 128 理解成“数学上最优”。它是算法质量、存储开销和 kernel 支持之间常见的工程折中。

量化完成后,应该检查 config.json 里的真实 group size,而不是只凭预设名字猜测。预设可能随版本演进。

6.3 duo_scaling="both"

AWQ 搜索缩放系数时,可以只依据激活,也可以同时考虑激活和权重。duo_scaling 常见含义是:

  • True:激活与权重共同参与缩放因子的构造;
  • False:主要按激活信息构造;
  • "both":搜索过程中同时尝试两类候选,再选误差更小的方案。

"both" 通常更稳,但搜索工作量更大。第一次跑通可以保留官方示例设置;如果量化时间明显成为瓶颈,再做消融:

duo_scaling=True  vs  duo_scaling="both"

比较的不只是耗时,还要看最终 PPL 和业务指标。

6.4 n_grid:搜索精细度

AWQ 不会在连续实数空间里无限精确地搜索缩放,而是通常对一组候选点做网格搜索。n_grid 控制候选数量:

  • 网格更多:搜索更慢,可能找到更好的缩放;
  • 网格更少:速度更快,可能牺牲一点质量。

对于入门,使用默认值最稳。只有在你已经确认量化耗时主要花在缩放搜索,并且有完整评测集时,才值得改它。

6.5 num_calibration_samples

增加样本可能带来更稳定的激活统计,但收益通常不是线性的:

32 → 128:可能是明显改善
128 → 256:可能继续改善
256 → 512:可能只有小幅收益
512 → 2048:可能主要增加耗时

具体曲线取决于模型和数据分布。最好的做法不是争论“128 还是 256”,而是做一个小型消融表:

样本数 量化耗时 PPL 业务准确率 备注
64 流程验证
128
256
512

一旦 256 到 512 的质量收益很小,就没有必要继续堆数据。

6.6 max_seq_length

这个参数决定校准样本最多保留多少 token。它影响:

  • 能否覆盖长文本中的后半部分;
  • 激活分布是否包含长上下文模式;
  • 校准显存;
  • 量化耗时;
  • 缓存激活的大小。

推荐根据业务 token 分布来选:

max_seq_length ≈ 校准数据 token 长度的 P90~P95

如果 P95 太大导致资源不够,可以分层抽样:大多数样本用中等长度,少量样本覆盖长上下文,而不是把所有样本都强行扩到最大长度。

6.7 targets=["Linear"]

Transformer 的大部分参数集中在线性层:

  • Attention 的 q_projk_projv_projo_proj
  • MLP 的 gate_projup_projdown_proj
  • 某些架构里的专家层。

Linear 作为目标,能覆盖主要权重,同时避免对 LayerNorm 等模块做不合适的 INT4 处理。

但“所有 Linear 都应该量化”也不是永恒真理。多模态模型、MoE、特殊路由层和音频模型可能需要排除额外模块。量化新架构时应先查看官方支持列表和保存后的配置。

6.8 AWQ mappings:为什么模型架构必须被识别

AWQ 的等价缩放不是对每一层孤立地乘一个数。为了保持函数等价,某个层输出被缩放后,下游接收该激活的线性层也要做对应补偿。

例如,一个简化映射可能表示:

input_layernorm 的输出
    ├── 进入 q_proj
    ├── 进入 k_proj
    └── 进入 v_proj

框架必须知道哪些层共享同一输入、哪些层需要平衡,才能正确应用缩放。不同架构的模块命名和连接关系不同,因此需要 mappings。

如果报错类似:

No AWQ mapping found for architecture ...

它通常不是“CUDA 坏了”,而是当前版本无法自动推断该模型的缩放关系。解决方向包括:

  1. 升级到支持该架构的版本;
  2. 使用官方已有示例模型验证环境;
  3. 明确传入自定义 mappings;
  4. 换用另一种量化方法;
  5. 等待或贡献架构适配。

不要把一个未知架构强行伪装成 Llama 映射。能跑完不等于缩放关系正确。

6.9 offload_device

AWQ 需要缓存激活和中间信息。显存紧张时,可以把缓存卸载到 CPU:

AWQModifier(
    duo_scaling="both",
    offload_device=torch.device("cpu"),
)

这样通常能减少 GPU 压力,但会增加 CPU 内存占用和数据搬运时间。它解决的是“空间不够”,不是免费优化。

如果仍然 OOM,按以下顺序缩减通常比较合理:

先减 max_seq_length
再减 num_calibration_samples
再考虑 offload
最后才大幅减少搜索精度或更换方案

因为长度对单样本激活缓存影响很大,而样本数量影响统计覆盖。


七、加载量化模型:功能检查和高性能推理要分开

7.1 为什么要区分“能加载”和“跑得快”

量化模型可能有三种运行状态:

  1. 原生低比特 kernel:权重保持压缩格式,计算或数据搬运针对 INT4 优化;
  2. 运行时反量化:文件是压缩的,但计算前转换为 FP16/BF16;
  3. 伪量化验证:用高精度张量模拟量化误差,适合研究,不代表部署性能。

Transformers 很适合做接口兼容和输出检查,但高性能吞吐通常应交给支持对应压缩格式的推理引擎,例如 vLLM。不要仅凭 nvidia-smi 或“代码能生成文本”判断是否走了 INT4 kernel。

7.2 用 Transformers 做冒烟测试

# smoke_test_transformers.py
from pathlib import Path

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

MODEL_PATH = Path("outputs/qwen2.5-1.5b-awq-w4a16")

if not MODEL_PATH.exists():
    raise FileNotFoundError(MODEL_PATH)

tokenizer = AutoTokenizer.from_pretrained(
    MODEL_PATH,
    trust_remote_code=True,
)
model = AutoModelForCausalLM.from_pretrained(
    MODEL_PATH,
    dtype="auto",
    device_map="auto",
    trust_remote_code=True,
)

messages = [
    {
        "role": "user",
        "content": "请用三点解释 AWQ 为什么需要校准数据。",
    }
]
text = tokenizer.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=True,
)
inputs = tokenizer(text, return_tensors="pt").to(model.device)

with torch.inference_mode():
    output = model.generate(
        **inputs,
        max_new_tokens=160,
        do_sample=False,
    )

new_tokens = output[0, inputs.input_ids.shape[1] :]
print(tokenizer.decode(new_tokens, skip_special_tokens=True))

这段代码主要检查:

  • tokenizer 是否完整保存;
  • chat template 是否正常;
  • Transformers 是否识别量化配置;
  • 模型能否生成合理文本。

它不是最终性能基准。

7.3 用 vLLM 做离线推理

在推理环境里:

# infer_vllm.py
from vllm import LLM, SamplingParams

MODEL_PATH = "outputs/qwen2.5-1.5b-awq-w4a16"

llm = LLM(
    model=MODEL_PATH,
    trust_remote_code=True,
    max_model_len=4096,
    gpu_memory_utilization=0.85,
)

sampling_params = SamplingParams(
    temperature=0.0,
    max_tokens=160,
)

prompts = [
    "请解释 AWQ 中激活感知的含义。",
    "比较 group-wise 量化与 per-tensor 量化。",
]

outputs = llm.generate(prompts, sampling_params)

for output in outputs:
    print("PROMPT:", output.prompt)
    print("ANSWER:", output.outputs[0].text.strip())
    print("-" * 60)

较新的 vLLM 通常会从模型配置中识别压缩格式。若你的版本要求显式指定量化后端,应以当前版本文档和启动日志为准,不要复制旧教程里的参数后忽略警告。

看启动日志时重点关注什么

  • 是否识别了量化配置;
  • 选择了什么 kernel/backend;
  • 是否回退到较慢实现;
  • 模型权重实际 dtype;
  • GPU 架构是否支持目标 kernel;
  • 最大上下文长度和 KV Cache 容量;
  • 是否出现“不支持某模块”的警告。

7.4 启动 OpenAI 兼容服务

source .venv-serve/bin/activate
vllm serve outputs/qwen2.5-1.5b-awq-w4a16 \
  --served-model-name qwen-awq \
  --max-model-len 4096 \
  --gpu-memory-utilization 0.85 \
  --trust-remote-code

本地测试:

curl http://127.0.0.1:8000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "qwen-awq",
    "messages": [
      {"role": "user", "content": "一句话说明 W4A16。"}
    ],
    "temperature": 0,
    "max_tokens": 100
  }'

生产环境还要增加:

  • 反向代理;
  • 身份认证;
  • 请求限流;
  • 超时和重试;
  • 指标监控;
  • 日志脱敏;
  • 多副本和健康检查;
  • 模型版本回滚。

不要把一个裸露的本地推理端口直接暴露到公网。

7.5 生成参数要固定,量化前后才有可比性

对比模型时,至少固定:

temperature
 top_p
 top_k
 repetition_penalty
 max_tokens
 stop 条件
 chat template
 system prompt
 随机种子(后端支持时)

最简单的确定性比较通常使用:

temperature = 0.0

即使使用贪心解码,FP16 和 INT4 的输出也可能不同。原因是量化让 logits 出现微小变化,一旦某一步 token 排名交换,后面的上下文就完全不同。这并不自动等于能力崩坏,也不意味着逐字相同才算通过。


八、量化前后怎么评测:不要用“看起来差不多”代替证据

8.1 先定义你要证明什么

一份完整的量化评测至少回答四类问题:

维度 核心问题 常用指标
可用性 模型能不能正确加载和生成 冒烟测试、错误率、空输出率
质量 模型能力损失多少 PPL、任务准确率、人工评分、业务通过率
资源 到底省了多少 磁盘、CPU RAM、模型显存、KV Cache 容量
性能 到底快了多少 TTFT、TPOT、单请求延迟、吞吐、并发能力

只报一个“显存下降 60%”是不完整的。只报一个“PPL 只涨 0.2”也不完整。量化是质量和效率之间的交易,必须同时展示交易的两端。

8.2 建议采用三层评测

第一层:功能回归

用 20~100 条固定 prompt 覆盖:

  • 中文问答;
  • 英文问答;
  • 摘要;
  • 信息抽取;
  • 数学;
  • 代码;
  • 长上下文;
  • 你的真实业务任务。

这一层主要发现灾难性退化和格式问题。

第二层:模型级质量

使用:

  • 困惑度 PPL;
  • 公共 benchmark;
  • 领域验证集准确率;
  • 结构化输出合法率;
  • 工具调用成功率。

这一层判断量化误差是否在可接受范围内。

第三层:系统级性能

在真实推理后端中测试:

  • 冷启动时间;
  • Time to First Token;
  • Time per Output Token;
  • tokens/s;
  • requests/s;
  • P50/P95/P99 延迟;
  • 不同并发和上下文长度下的显存与吞吐。

这一层决定模型能不能进入生产。


8.3 磁盘体积对比

# inspect_model.py
from __future__ import annotations

import argparse
import json
from pathlib import Path


def human_bytes(size: int) -> str:
    units = ["B", "KiB", "MiB", "GiB", "TiB"]
    value = float(size)
    for unit in units:
        if value < 1024 or unit == units[-1]:
            return f"{value:.2f} {unit}"
        value /= 1024
    raise AssertionError("unreachable")


def weight_size(path: Path) -> int:
    suffixes = {".safetensors", ".bin", ".pt", ".pth"}
    return sum(
        file.stat().st_size
        for file in path.rglob("*")
        if file.is_file() and file.suffix in suffixes
    )


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("paths", nargs="+")
    args = parser.parse_args()

    for raw_path in args.paths:
        path = Path(raw_path)
        if not path.exists():
            print(f"不存在: {path}")
            continue

        size = weight_size(path)
        print(f"\n模型目录: {path}")
        print("权重文件体积:", human_bytes(size))

        config_path = path / "config.json"
        if config_path.exists():
            config = json.loads(config_path.read_text(encoding="utf-8"))
            quant_config = config.get("quantization_config")
            print("quantization_config:")
            print(json.dumps(quant_config, ensure_ascii=False, indent=2))
        else:
            print("未找到 config.json")


if __name__ == "__main__":
    main()

使用:

python inspect_model.py \
  /path/to/original-model \
  outputs/qwen2.5-1.5b-awq-w4a16

为什么不要把整个目录大小都算作权重

tokenizer、词表、README、图片、日志和评测文件都可能在模型目录中。如果你的目标是比较压缩率,最好只统计权重后缀。若要评估真实分发成本,再统计整个目录。

8.4 显存对比:先明确你在测什么

“显存占用”至少可以指四种不同东西:

  1. 模型加载后、尚未推理的已分配显存;
  2. 一次请求中的峰值显存;
  3. 推理引擎预留后的进程显存;
  4. 在给定上下文和并发下的稳定运行显存。

它们不能混在一起比较。

Transformers 中测单次峰值的基础脚本

# measure_memory.py
from __future__ import annotations

import argparse

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer


def mib(value: int) -> float:
    return value / 1024**2


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--model", required=True)
    parser.add_argument("--max-new-tokens", type=int, default=128)
    parser.add_argument("--trust-remote-code", action="store_true")
    args = parser.parse_args()

    if not torch.cuda.is_available():
        raise RuntimeError("该脚本需要 CUDA GPU")

    torch.cuda.empty_cache()
    torch.cuda.reset_peak_memory_stats()

    tokenizer = AutoTokenizer.from_pretrained(
        args.model,
        trust_remote_code=args.trust_remote_code,
    )
    model = AutoModelForCausalLM.from_pretrained(
        args.model,
        dtype="auto",
        device_map="auto",
        trust_remote_code=args.trust_remote_code,
    )

    torch.cuda.synchronize()
    after_load_allocated = torch.cuda.memory_allocated()
    after_load_reserved = torch.cuda.memory_reserved()

    prompt = "请解释量化误差为什么会影响下一个 token 的排序。"
    messages = [{"role": "user", "content": prompt}]
    text = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
    )
    inputs = tokenizer(text, return_tensors="pt").to(model.device)

    with torch.inference_mode():
        _ = model.generate(
            **inputs,
            max_new_tokens=args.max_new_tokens,
            do_sample=False,
        )

    torch.cuda.synchronize()

    print(f"加载后 allocated: {mib(after_load_allocated):.0f} MiB")
    print(f"加载后 reserved:  {mib(after_load_reserved):.0f} MiB")
    print(f"峰值 allocated:   {mib(torch.cuda.max_memory_allocated()):.0f} MiB")
    print(f"峰值 reserved:    {mib(torch.cuda.max_memory_reserved()):.0f} MiB")


if __name__ == "__main__":
    main()

必须分别在独立进程中运行原模型和量化模型:

python measure_memory.py --model /path/to/fp16 --trust-remote-code
python measure_memory.py --model outputs/qwen2.5-1.5b-awq-w4a16 --trust-remote-code

不要在同一个 Python 进程中先加载 A、删除、再加载 B,然后把结果当作绝对公平。CUDA allocator、缓存和碎片可能残留,尤其是大型模型。

vLLM 的显存为什么看起来“总是很满”

vLLM 会根据 gpu_memory_utilization 规划 KV Cache 和工作区。量化后节省出的权重显存,可能被它转化为更多 KV Cache 容量。因此 nvidia-smi 看到的总占用可能变化不大,但可服务的并发或上下文容量变大了。

这是好事,不是量化失效。比较 vLLM 时更应该看:

  • 同样上下文长度下能支持多少并发;
  • 同样并发下最大上下文能到多少;
  • 引擎日志中的权重与 KV Cache 规划;
  • 是否出现 OOM;
  • 吞吐和延迟。

8.5 一个可重复的 vLLM 离线吞吐测试

# benchmark_vllm.py
from __future__ import annotations

import argparse
import json
import statistics
import time
from pathlib import Path

from vllm import LLM, SamplingParams


def load_prompts(path: str | None) -> list[str]:
    if path is None:
        base = [
            "请用通俗语言解释什么是模型量化。",
            "比较 AWQ 与 GPTQ 的主要差异。",
            "写一个 Python 函数判断字符串是否为回文。",
            "把下面观点改写得更严谨:量化模型一定比原模型快。",
            "为什么长上下文会显著增加 KV Cache 占用?",
            "给出三个评估聊天模型量化质量的指标。",
            "解释对称量化和非对称量化的区别。",
            "什么情况下应该优先选择 GGUF 而不是 AWQ?",
        ]
        return base * 4

    prompts: list[str] = []
    with Path(path).open("r", encoding="utf-8") as f:
        for line in f:
            if not line.strip():
                continue
            item = json.loads(line)
            prompt = item.get("prompt")
            if not isinstance(prompt, str) or not prompt.strip():
                raise ValueError("每行必须包含非空 prompt 字段")
            prompts.append(prompt)
    return prompts


def percentile(values: list[float], p: float) -> float:
    ordered = sorted(values)
    index = min(int((len(ordered) - 1) * p), len(ordered) - 1)
    return ordered[index]


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--model", required=True)
    parser.add_argument("--prompts", default=None)
    parser.add_argument("--max-tokens", type=int, default=128)
    parser.add_argument("--max-model-len", type=int, default=4096)
    parser.add_argument("--gpu-memory-utilization", type=float, default=0.85)
    parser.add_argument("--trust-remote-code", action="store_true")
    args = parser.parse_args()

    prompts = load_prompts(args.prompts)
    if not prompts:
        raise RuntimeError("测试 prompt 为空")

    llm = LLM(
        model=args.model,
        trust_remote_code=args.trust_remote_code,
        max_model_len=args.max_model_len,
        gpu_memory_utilization=args.gpu_memory_utilization,
    )
    sampling = SamplingParams(
        temperature=0.0,
        max_tokens=args.max_tokens,
    )

    # 预热:排除首次 kernel 初始化、图捕获等额外成本。
    _ = llm.generate(prompts[:2], sampling)

    request_latencies: list[float] = []
    total_output_tokens = 0

    # 为了得到简单的逐请求延迟,逐条调用;吞吐则还要做批量测试。
    start_all = time.perf_counter()
    for prompt in prompts:
        start = time.perf_counter()
        output = llm.generate([prompt], sampling)[0]
        request_latencies.append(time.perf_counter() - start)
        total_output_tokens += len(output.outputs[0].token_ids)
    total_time = time.perf_counter() - start_all

    print("模型:", args.model)
    print("请求数:", len(prompts))
    print("总输出 tokens:", total_output_tokens)
    print(f"总耗时: {total_time:.3f} s")
    print(f"输出吞吐: {total_output_tokens / total_time:.2f} tokens/s")
    print(f"平均请求延迟: {statistics.mean(request_latencies):.3f} s")
    print(f"P50 请求延迟: {percentile(request_latencies, 0.50):.3f} s")
    print(f"P95 请求延迟: {percentile(request_latencies, 0.95):.3f} s")

    # 批量调用用于观察调度和吞吐。
    start_batch = time.perf_counter()
    batch_outputs = llm.generate(prompts, sampling)
    batch_time = time.perf_counter() - start_batch
    batch_tokens = sum(len(item.outputs[0].token_ids) for item in batch_outputs)
    print(f"批量总耗时: {batch_time:.3f} s")
    print(f"批量输出吞吐: {batch_tokens / batch_time:.2f} tokens/s")


if __name__ == "__main__":
    main()

分别测试:

python benchmark_vllm.py \
  --model /path/to/fp16-model \
  --trust-remote-code

python benchmark_vllm.py \
  --model outputs/qwen2.5-1.5b-awq-w4a16 \
  --trust-remote-code

这段脚本还不算生产级 benchmark

它适合教学和快速比较,但没有完整测量:

  • TTFT;
  • TPOT;
  • 多并发客户端;
  • 请求到达率;
  • 输入 token 吞吐;
  • P99;
  • 连续批处理稳定状态;
  • 服务端网络开销。

正式部署建议使用 vLLM 自带 benchmark 工具、GuideLLM 或其他支持 OpenAI 兼容端点的压测工具,并保存完整参数。

8.6 困惑度 PPL:怎么计算才不容易写错

困惑度反映模型对真实文本序列的平均不确定性。对于同一 tokenizer 和同一评测集,PPL 越低通常越好。

从 token 平均负对数似然出发:

PPL = exp(总负对数似然 / 预测 token 数)

下面的实现不依赖模型直接返回 loss,而是从 logits 手工计算,因此适配性更好:

# evaluate_ppl.py
from __future__ import annotations

import argparse
import json
import math
from pathlib import Path

import torch
import torch.nn.functional as F
from transformers import AutoModelForCausalLM, AutoTokenizer


def load_texts(path: str) -> list[str]:
    texts: list[str] = []
    with Path(path).open("r", encoding="utf-8") as f:
        for line_no, line in enumerate(f, start=1):
            if not line.strip():
                continue
            item = json.loads(line)
            text = item.get("text")
            if not isinstance(text, str) or not text.strip():
                raise ValueError(f"第 {line_no} 行缺少非空 text 字段")
            texts.append(text.strip())
    return texts


@torch.inference_mode()
def evaluate(
    model: torch.nn.Module,
    tokenizer,
    texts: list[str],
    max_length: int,
) -> tuple[float, float, int]:
    total_nll = 0.0
    total_tokens = 0

    for text in texts:
        encoded = tokenizer(
            text,
            return_tensors="pt",
            truncation=True,
            max_length=max_length,
            add_special_tokens=True,
        )
        input_ids = encoded.input_ids
        if input_ids.shape[1] < 2:
            continue

        input_ids = input_ids.to(model.device)
        logits = model(input_ids=input_ids).logits

        # 第 t 个位置的 logits 用来预测第 t+1 个 token。
        shift_logits = logits[:, :-1, :].float().contiguous()
        shift_labels = input_ids[:, 1:].contiguous()

        nll = F.cross_entropy(
            shift_logits.view(-1, shift_logits.shape[-1]),
            shift_labels.view(-1),
            reduction="sum",
        )
        total_nll += nll.item()
        total_tokens += shift_labels.numel()

    if total_tokens == 0:
        raise RuntimeError("没有足够 token 用于评测")

    mean_nll = total_nll / total_tokens
    ppl = math.exp(mean_nll)
    return ppl, mean_nll, total_tokens


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--model", required=True)
    parser.add_argument("--data", required=True)
    parser.add_argument("--max-length", type=int, default=1024)
    parser.add_argument("--trust-remote-code", action="store_true")
    args = parser.parse_args()

    texts = load_texts(args.data)
    tokenizer = AutoTokenizer.from_pretrained(
        args.model,
        trust_remote_code=args.trust_remote_code,
    )
    model = AutoModelForCausalLM.from_pretrained(
        args.model,
        dtype="auto",
        device_map="auto",
        trust_remote_code=args.trust_remote_code,
    )
    model.eval()

    ppl, mean_nll, token_count = evaluate(
        model=model,
        tokenizer=tokenizer,
        texts=texts,
        max_length=args.max_length,
    )

    print("模型:", args.model)
    print("评测文本数:", len(texts))
    print("预测 token 数:", token_count)
    print(f"Mean NLL: {mean_nll:.6f}")
    print(f"PPL: {ppl:.6f}")


if __name__ == "__main__":
    main()

评测数据 ppl_eval.jsonl

{"text":"模型量化通过降低权重表示精度来减少存储和带宽开销。"}
{"text":"在真实系统中,首 token 延迟、生成吞吐和最大并发需要分别衡量。"}
{"text":"A careful evaluation should compare quality and efficiency under identical settings."}

运行:

python evaluate_ppl.py \
  --model /path/to/fp16-model \
  --data ppl_eval.jsonl \
  --trust-remote-code

python evaluate_ppl.py \
  --model outputs/qwen2.5-1.5b-awq-w4a16 \
  --data ppl_eval.jsonl \
  --trust-remote-code

PPL 的五个常见误区

  1. 用几百个字就下结论。 样本太少时波动很大;
  2. 原模型和量化模型用了不同 tokenizer。 结果不可比;
  3. 把聊天模板后的文本和裸文本混着评。 输入分布不同;
  4. 校准集和 PPL 集相同。 结果偏乐观;
  5. 认为 PPL 小幅变化就能代表所有任务。 指令遵循、工具调用和结构化输出未必与 PPL 同步。

8.7 固定业务测试集,比随机聊天更有价值

创建 eval_prompts.jsonl

{"id":"qa-001","category":"知识问答","prompt":"为什么量化后磁盘体积不会严格变成四分之一?","must_include":["scale"]}
{"id":"code-001","category":"代码","prompt":"写一个带类型标注的 Python LRU 缓存示例。","must_include":["class"]}
{"id":"extract-001","category":"抽取","prompt":"从文本中抽取公司、金额和日期,并输出 JSON:某公司于 2026 年 3 月获得 5000 万元融资。","json_schema":"object"}
{"id":"reason-001","category":"推理","prompt":"如果 FP16 权重占 14GB,忽略额外开销,INT4 权重理论上约占多少?请说明计算。","expected":"3.5GB"}

对每个模型保存:

  • 原始输出;
  • 解析后的结构化结果;
  • 自动评分;
  • 人工评分;
  • 是否通过业务门槛。

最后形成这样的结果表:

类别 FP16 AWQ INT4 差值 是否通过
知识问答准确率
JSON 合法率
代码单测通过率
长文摘要评分
工具调用成功率

对业务而言,这张表通常比单一 PPL 更有说服力。

8.8 一张建议使用的最终汇总表

不要预先填“典型值”,而是把你的真实测量写进去:

指标 FP16/BF16 原模型 AWQ W4A16 变化 测试条件
权重文件体积 同一文件统计规则
加载后模型显存 独立进程
峰值显存 相同输入/输出长度
PPL 同一 tokenizer 和数据
业务通过率 固定评测集
单请求 P50 相同后端与参数
单请求 P95 相同后端与参数
批量 tokens/s 相同 batch 和长度
最大稳定并发 相同 SLA
最大可用上下文 相同 GPU

这张表才是你决定“是否上线”的核心证据。


九、如何解释评测结果:几个最容易误判的现象

9.1 为什么 INT4 文件不是 FP16 的精确四分之一

理论上:

FP16 = 16 bit
INT4 = 4 bit
4 / 16 = 25%

但模型目录里还有:

  • 每组 scale;
  • 非对称量化的 zero-point;
  • 未量化层;
  • embedding 或 lm_head;
  • tensor 索引;
  • 对齐和打包开销;
  • 配置与 tokenizer。

因此看到原权重体积的 25%~35% 一类结果并不奇怪。模型越小,固定开销占比越明显;group size 越小,量化元数据越多。

9.2 为什么显存没有按四分之一下降

运行显存可以粗略拆成:

总显存 = 模型权重 + KV Cache + 激活 + 工作区 + 框架预留

AWQ 主要压缩的是“模型权重”。如果你的场景是:

  • 上下文很长;
  • batch 很大;
  • 并发很多;
  • KV Cache 占主要部分;

那么压缩权重后,总显存下降比例自然小于 75%。

反过来,量化省下的空间可以容纳更多 KV Cache,于是你获得的收益可能体现为:

  • 支持更长上下文;
  • 支持更多并发;
  • 减少张量并行卡数;
  • 在更小 GPU 上运行。

9.3 为什么量化后不一定更快

量化加速需要同时满足几个条件:

  1. 推理瓶颈确实受内存带宽影响;
  2. 后端有针对该格式和 GPU 的高效 kernel;
  3. 反量化或 unpack 的额外计算小于带宽收益;
  4. batch、序列长度和调度方式适合该 kernel;
  5. 没有因为兼容性回退到慢路径。

以下场景可能只省显存、不明显加速:

  • 模型很小,Python 和调度开销占比高;
  • GPU 算力弱,INT4 kernel 不成熟;
  • prefill 是计算瓶颈;
  • batch 很大,FP16/BF16 Tensor Core 已经非常高效;
  • 后端把压缩权重反量化后再算;
  • CPU 到 GPU 数据传输成为瓶颈;
  • 量化格式与 kernel 不匹配。

所以“INT4 一定更快”是错误结论。更严谨的说法是:在合适的硬件、后端和负载下,权重量化可以通过减少权重读取与显存压力改善性能。

9.4 Prefill 和 Decode 为什么要分开看

大模型生成分两阶段:

Prefill

一次性处理输入 prompt。输入越长,矩阵计算越大,通常更偏计算密集。

Decode

每次生成一个新 token,反复读取模型权重。单步计算规模相对小,常更受内存带宽影响。

AWQ W4A16 的优势经常在 decode 阶段更明显,因为权重读取量下降。但对超长 prompt 的 prefill,收益可能不同。

因此,一个只报告“平均 tokens/s”的 benchmark 可能掩盖:

  • 首 token 变快还是变慢;
  • 后续 token 生成是否加速;
  • 输入长短变化后表现如何。

9.5 为什么回答不同,不等于能力下降

假设原模型某一步 logits 排名是:

A: 8.001
B: 8.000

量化后变成:

A: 7.997
B: 7.999

模型就会选择 B。下一步输入上下文不同,后续整段文本都可能分叉。即使两条回答质量相当,字面也会完全不同。

因此不要用编辑距离或逐字一致率作为聊天模型的唯一质量标准。更合理的是:

  • 事实是否正确;
  • 任务是否完成;
  • 格式是否满足;
  • 代码是否通过测试;
  • 结构化输出能否解析;
  • 人工偏好是否显著下降。

9.6 为什么小模型的相对损失可能更明显

小模型本身冗余较少,决策边界可能更脆弱。相同的量化误差,在 1.5B 模型上未必和 70B 模型上表现一致。

所以用 1.5B 跑通流程非常合适,但不能直接推断:

1.5B 量化损失很小 → 70B 也一定一样

同样也不能反过来推断。每个目标模型都要评测。


十、常见报错与排障:先按症状定位,不要盲目重装

10.1 ImportError: cannot import name AWQModifier

可能原因

LLM Compressor 的模块路径在不同版本中调整过。新版本通常位于:

from llmcompressor.modifiers.transform.awq import AWQModifier

部分旧版本可能使用:

from llmcompressor.modifiers.awq import AWQModifier

处理方式

主脚本中已经使用兼容导入:

try:
    from llmcompressor.modifiers.transform.awq import AWQModifier
except ImportError:
    from llmcompressor.modifiers.awq import AWQModifier

但长期方案不是无限兼容所有版本,而是:

  1. 记录当前可用版本;
  2. 固定依赖;
  3. 以该版本官方示例为准;
  4. 升级时重新跑回归测试。

10.2 量化时 CUDA OOM

先确认是不是别的进程占显存

nvidia-smi

然后按顺序尝试

  1. max_seq_length 从 2048 降到 1024 或 512;
  2. 把样本数从 256 降到 128;
  3. 开启 CPU offload;
  4. 换更小模型验证;
  5. 关闭同时运行的推理服务和 Notebook;
  6. 对大模型使用多卡或官方的大模型 offload 示例;
  7. 检查是否意外以 FP32 加载。

不建议的第一反应

不要一看到 OOM 就把样本数降到 4 条。这样可能让脚本跑完,却失去校准意义。先降低单样本长度通常更合理。

10.3 报“不支持的模型架构”或找不到 AWQ mappings

原因

AWQ 需要理解层之间的缩放对应关系。新架构、混合注意力、MoE、多模态或自定义 remote code 模型可能尚未在当前版本注册。

处理

  • 查看当前版本支持的架构和官方示例;
  • 尝试更新 llm-compressor;
  • 用一个官方支持模型验证环境;
  • 为目标架构编写 mappings,并用逐层误差和端到端评测验证;
  • 无法可靠映射时,改用 GPTQ、RTN、FP8 或其他已支持方案。

不要仅靠修改 config.json 中的 architectures 字段绕过检查。

10.4 日志显示量化完成,但文件几乎没变小

重点检查:

model.save_pretrained(output_dir, save_compressed=True)

此外确认:

  • 统计的是权重文件,而不是缓存目录;
  • 没有把原始模型权重也复制到输出目录;
  • quantization_config 存在;
  • 权重文件时间戳确实来自本次运行;
  • 保存过程没有因磁盘满而中断;
  • 没有使用 save_compressed=False

10.5 量化后输出乱码、重复或答非所问

按以下顺序排查:

1. tokenizer 是否来自同一模型

tokenizer = AutoTokenizer.from_pretrained(quantized_model_path)

不要把另一个版本的 tokenizer 混进来。

2. chat template 是否一致

原模型和量化模型必须使用同一模板。缺少 generation prompt、角色名错误或特殊 token 丢失,都可能造成明显异常。

3. 量化模型是否保存完整

检查 safetensors 分片和 index 文件是否齐全,磁盘是否写满。

4. 后端是否真正支持该格式

某些后端可能能读取配置,却不支持某些模块组合。

5. 校准数据是否严重错位

例如中文领域模型只用英文百科校准,且样本极短。

6. 对比未量化模型

使用相同 prompt 和生成参数,确认问题不是原模型自身行为。

7. 检查日志中的 warning

不要只搜索 error。很多量化和 kernel 回退问题只以 warning 形式出现。

10.6 量化模型能运行,但速度没提升

检查四件事:

  1. 后端:是否使用 vLLM 或其他有对应 INT4 kernel 的引擎;
  2. 日志:是否回退到反量化或通用实现;
  3. 负载:单条短请求可能被固定开销主导;
  4. 指标:你测的是模型加载时间、首 token、decode,还是总请求时间。

还要确认测试不是把“第一次运行的编译和预热”算进量化模型,而 FP16 模型已经预热。

10.7 vLLM 中 nvidia-smi 显示量化前后显存都接近上限

这通常与显存池和 KV Cache 规划有关。保持相同 gpu_memory_utilization 时,vLLM 会尽量利用可用显存。

更合理的比较是:

  • 同一 GPU 能否把 7B 换成 14B;
  • 同一模型能否提高 max_model_len
  • 同一 SLA 下能否提高并发;
  • 引擎报告的 KV Cache block 数;
  • 实际 OOM 边界。

10.8 PPL 极大或直接溢出

可能原因:

  • tokenizer 不匹配;
  • 文本被错误编码;
  • 模型输出 logits 已损坏;
  • 把第 t 个 logits 和第 t 个标签对齐,而不是预测 t+1;
  • 评测文本与模型语言完全不匹配;
  • 把 padding token 也计入 loss;
  • 模型在错误的聊天模板上评估;
  • 量化后权重异常。

先用 3~5 条短文本打印 mean NLL,再扩大数据。不要只看最后的 exp()

10.9 生成立即结束或只输出空字符串

检查:

  • eos_token_id
  • pad_token_id
  • max_new_tokens
  • chat template 是否已经包含 assistant 结束标记;
  • 解码时是否错误切片;
  • stop token 是否设置过多;
  • prompt 是否被截断到只剩特殊 token。

正确切片通常是:

new_tokens = output[0, inputs.input_ids.shape[1] :]

10.10 安装 AutoAWQ 后 Transformers 版本被改动

这是 AutoAWQ 旧工具链的常见问题。不要在已经稳定的主开发环境里直接安装。新建独立环境,并在安装后运行:

pip freeze > autoawq-lock.txt

如果其他项目依赖较新的 Transformers,保持环境隔离,不要试图让一个环境同时满足所有库。

10.11 下载模型中断或 safetensors 报损坏

可能是缓存分片不完整。处理时:

  • 确认磁盘空间;
  • 检查具体报错的分片文件;
  • 重新下载损坏分片;
  • 不要在多个进程同时写同一输出目录;
  • 对正式产物保存文件哈希;
  • 上传前先本地重新加载一次。

10.12 量化很慢,是不是卡住了

AWQ 需要逐层运行校准和缩放搜索。判断是否真的卡死,应看:

  • GPU 利用率是否周期性变化;
  • CPU 和磁盘是否在工作;
  • 日志中的层编号是否前进;
  • 是否频繁 CPU/GPU offload;
  • 是否在处理超长样本;
  • 是否设置了较大的 n_grid

如果每层都在前进,只是慢,那是性能问题;如果同一层长时间无任何资源活动,才更像死锁或异常。

10.13 多模态和 MoE 模型为什么更复杂

多模态模型可能包含:

  • 视觉编码器;
  • 投影层;
  • 语言模型主体;
  • 不同 dtype 的模块。

MoE 模型还包含:

  • 路由器;
  • 大量专家;
  • 稀疏激活路径;
  • 特殊 offload 需求。

这类模型不能简单套用“量化所有 Linear,排除 lm_head”的通用配方。应从该架构的官方示例开始,并分别评估视觉、路由和语言输出。


十一、遗留兼容路线:继续使用 AutoAWQ 时要知道什么

这一节用于复现旧教程、维护已有模型或使用只支持 AutoAWQ 格式的遗留系统。新项目优先考虑前文的 LLM Compressor 路线。

11.1 为什么仍然保留 AutoAWQ 教程

尽管 AutoAWQ 已停止维护,它仍然有现实价值:

  • 大量旧 AWQ 模型由它生成;
  • 很多历史文章和脚本都基于它;
  • 某些内部系统已经围绕其格式构建;
  • 用它理解 w_bitq_group_sizezero_pointversion 很直观。

问题不在于“旧代码立刻不能用”,而在于未来的 PyTorch、Transformers、CUDA 和模型架构变化不会再由该项目持续适配。

11.2 单独创建遗留环境

python -m venv .venv-autoawq
source .venv-autoawq/bin/activate
python -m pip install --upgrade pip

先按照你的 CUDA 版本安装匹配的 PyTorch wheel,再安装:

pip install autoawq datasets

AutoAWQ 归档说明中给出的最后测试组合包括 Torch 2.6.0 和 Transformers 4.51.3,但实际安装时 autoawq 可能调整 Transformers 版本。不要把这组版本当成跨平台万能命令;应以你的 CUDA wheel、操作系统和安装日志为准,并在成功后锁定环境。

11.3 AutoAWQ 完整量化脚本

# legacy_quantize_autoawq.py
from __future__ import annotations

import argparse
import json
from pathlib import Path

from awq import AutoAWQForCausalLM
from datasets import load_dataset
from transformers import AutoTokenizer


def load_calibration_texts(
    tokenizer,
    calibration_file: str | None,
    n_samples: int,
) -> list[str]:
    if calibration_file:
        dataset = load_dataset(
            "json",
            data_files=calibration_file,
            split="train",
        )
        texts: list[str] = []
        for item in dataset:
            if item.get("messages"):
                text = tokenizer.apply_chat_template(
                    item["messages"],
                    tokenize=False,
                    add_generation_prompt=False,
                )
            elif isinstance(item.get("text"), str):
                text = item["text"]
            else:
                continue
            if text.strip():
                texts.append(text.strip())
            if len(texts) >= n_samples:
                break
        return texts

    dataset = load_dataset(
        "wikitext",
        "wikitext-2-raw-v1",
        split="train",
    )
    texts = []
    for item in dataset:
        text = item["text"].strip()
        if len(text) > 80:
            texts.append(text)
        if len(texts) >= n_samples:
            break
    return texts


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--model-id",
        default="Qwen/Qwen2.5-1.5B-Instruct",
    )
    parser.add_argument(
        "--output-dir",
        default="outputs/qwen2.5-1.5b-autoawq-int4",
    )
    parser.add_argument("--calibration-file", default=None)
    parser.add_argument("--num-samples", type=int, default=128)
    parser.add_argument("--trust-remote-code", action="store_true")
    args = parser.parse_args()

    output_dir = Path(args.output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    tokenizer = AutoTokenizer.from_pretrained(
        args.model_id,
        trust_remote_code=args.trust_remote_code,
    )
    model = AutoAWQForCausalLM.from_pretrained(
        args.model_id,
        low_cpu_mem_usage=True,
        use_cache=False,
    )

    calibration_texts = load_calibration_texts(
        tokenizer=tokenizer,
        calibration_file=args.calibration_file,
        n_samples=args.num_samples,
    )
    if not calibration_texts:
        raise RuntimeError("校准数据为空")

    quant_config = {
        "zero_point": True,
        "q_group_size": 128,
        "w_bit": 4,
        "version": "GEMM",
    }

    model.quantize(
        tokenizer,
        quant_config=quant_config,
        calib_data=calibration_texts,
    )

    model.save_quantized(output_dir)
    tokenizer.save_pretrained(output_dir)

    (output_dir / "legacy_quant_config.json").write_text(
        json.dumps(quant_config, indent=2),
        encoding="utf-8",
    )
    print("保存完成:", output_dir.resolve())


if __name__ == "__main__":
    main()

11.4 AutoAWQ 加载与推理

# legacy_infer_autoawq.py
import torch
from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer

MODEL_PATH = "outputs/qwen2.5-1.5b-autoawq-int4"

model = AutoAWQForCausalLM.from_quantized(
    MODEL_PATH,
    fuse_layers=True,
)
tokenizer = AutoTokenizer.from_pretrained(
    MODEL_PATH,
    trust_remote_code=True,
)

messages = [
    {
        "role": "user",
        "content": "解释 AWQ 为什么属于训练后量化。",
    }
]
text = tokenizer.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=True,
)
inputs = tokenizer(text, return_tensors="pt").to("cuda")

with torch.inference_mode():
    output = model.generate(
        **inputs,
        max_new_tokens=160,
        do_sample=False,
    )

new_tokens = output[0, inputs.input_ids.shape[1] :]
print(tokenizer.decode(new_tokens, skip_special_tokens=True))

fuse_layers=True 是否可用、能否与其他注意力优化同时使用,取决于架构和版本。遇到异常时先关闭融合,验证基本加载,再逐项开启优化。

11.5 GEMMGEMV 不要只按一句口诀选择

旧 AutoAWQ 配置中常见:

"version": "GEMM"

传统直觉是:

  • GEMV 更偏 batch=1 的 decode;
  • GEMM 更适合批量和通用吞吐。

但实际性能还受:

  • GPU 架构;
  • kernel 版本;
  • batch;
  • prompt 长度;
  • decode 长度;
  • 推理框架;
  • 模型形状;
  • 是否使用 fused modules。

因此,把 version 视为权重打包与 kernel 兼容选择,而不是“选了 GEMV 就一定低延迟”的保证。最终仍要 benchmark。

11.6 什么时候不应该继续用 AutoAWQ

  • 新模型架构需要持续适配;
  • 你准备升级到新 Transformers/PyTorch;
  • 生产系统依赖长期安全维护;
  • 需要与最新 vLLM 压缩格式深度集成;
  • 需要多种量化方案统一管理;
  • 项目还没有任何历史包袱。

这些情况下,迁移成本通常越早承担越低。


十二、深入原理:AWQ 为什么能在 INT4 下保住质量

12.1 先从均匀量化说起

对一组浮点权重,4 bit 只能表示 16 个离散整数状态。量化的本质是把连续实数映射到有限网格:

浮点权重 w
    ↓ 除以 scale、四舍五入、截断
整数 q
    ↓ 乘回 scale
近似权重 ŵ

误差是:

ΔW = W - Ŵ

如果只最小化权重误差:

||W - Ŵ||

就默认所有权重误差同等重要。但模型真正关心的是输出变化。

12.2 输出误差取决于激活

线性层:

Y = XW

量化后:

Ŷ = XŴ

输出误差:

Y - Ŷ = X(W - Ŵ) = XΔW

这条式子是理解 AWQ 的核心。

即使两个权重元素的 |Δw| 一样大,它们对应的输入激活不同,对输出的影响也不同。如果某个输入通道经常有大幅激活,那么该通道的权重误差会被放大。

因此,应该保护的不是“数值最大的权重”,而是“在真实输入分布下对输出更敏感的权重通道”。

12.3 激活为什么能充当重要性信号

把输出误差按输入通道展开:

XΔW = Σ_j X_j ΔW_j

如果某个通道 X_j 在校准数据上幅度大、出现频繁,那么 ΔW_j 对输出的贡献往往更重要。

AWQ 会用校准样本统计激活尺度,并据此构造通道缩放候选。它不需要完整训练集,也不需要反向传播 Hessian,就能得到一个实用的重要性近似。

12.4 等价缩放:先改变数值分布,再保持函数不变

考虑:

Y = XW

对每个输入通道引入正缩放 s

X' = X / s
W' = sW

则:

X'W' = (X / s)(sW) = XW

在无限精度下,模型函数没有变化。但我们不是直接使用 W',而是量化它:

Ŷ = (X / s) Q(sW)

不同的 s 会改变 sW 的数值分布,从而改变量化网格如何覆盖权重,也就改变最终误差。

AWQ 的关键问题变成:

寻找 s,使 ||XW - (X/s)Q(sW)|| 尽可能小

它不是神奇地消除量化误差,而是用等价变换把有限的 INT4 精度分配到更重要的通道上。

12.5 “保护 1% 重要权重”不等于留下 1% FP16

很多介绍会说 AWQ 保护约 1% 的显著权重。这句话适合传达直觉,但容易产生误解:

误解:99% 权重是 INT4,1% 权重单独保持 FP16

典型 AWQ 的优势之一恰恰是保持硬件友好的统一低比特权重格式。所谓“保护”主要通过通道缩放,让重要权重在量化网格中得到更好的表示,而不是一定采用混合精度稀疏存储。

这使得它比“零散保留高精度异常值”的方案更容易用规则 kernel 加速。

12.6 为什么要做网格搜索

缩放太小和太大都可能出问题:

  • 太小:重要通道仍然落在粗糙网格;
  • 太大:权重范围被拉宽,其他值量化变粗或发生截断;
  • 不同层、不同通道的最佳缩放不同。

所以实现会尝试多组候选缩放,计算量化后输出与原输出的差异,再选择误差较小的方案。

这是为什么:

  • 校准需要实际前向传播;
  • n_grid 会影响时间;
  • 校准样本分布会影响最佳缩放;
  • 量化是一次性但不完全免费的过程。

12.7 为什么 group-wise 量化和 AWQ 互补

AWQ 解决“哪些通道更重要、怎样缩放更合适”;group-wise 量化解决“不要让过大的统计范围污染整块权重”。

两者叠加:

  1. 用激活感知缩放改善通道分布;
  2. 把权重分成小组;
  3. 每组独立计算 scale/zero-point;
  4. 量化并打包为 INT4。

因此常见配置不是只写一个 w_bit=4,而是同时包含:

4 bit + group size + asymmetric + AWQ scaling

12.8 为什么 AWQ 通常不量化激活到 4 bit

W4A16 保持激活 16 bit,有几个现实原因:

  • 激活分布随输入动态变化;
  • 激活异常值更难处理;
  • 权重是静态的,适合离线校准和打包;
  • 只压缩权重已经能显著减少模型存储和带宽;
  • 激活低比特需要更复杂的校准和 kernel 支持。

如果目标是进一步压缩激活和 KV Cache,就进入 W8A8、FP8、W4A8、KV Cache 量化等更复杂路线。那不再是本文的 W4A16 范畴。

12.9 AWQ 的局限

AWQ 不是“无损压缩”。它可能在以下情况下表现不理想:

  • 极端低比特;
  • 校准数据与业务严重错位;
  • 小模型冗余不足;
  • 新架构 mappings 不成熟;
  • 对 logits 排名极敏感的任务;
  • 特定语言或领域样本覆盖不足;
  • 推理后端缺少高效 kernel;
  • 长上下文瓶颈主要来自 KV Cache,而非权重。

正确态度不是相信“几乎无损”的口号,而是把它当成一个通常很强、但必须实测的压缩候选。


十三、AWQ、GPTQ、bitsandbytes、FP8、GGUF 怎么选

13.1 先明确:它们不完全是同一层面的东西

  • AWQ、GPTQ 是量化算法或方法族;
  • bitsandbytes 是低比特加载和训练工具库;
  • FP8 是数值格式与量化路线;
  • GGUF 是模型文件格式和 llama.cpp 生态的一部分,内部可以包含多种量化类型;
  • vLLM、llama.cpp 是推理引擎;
  • LLM Compressor 是模型压缩工具链。

因此,“AWQ 和 vLLM 哪个好”本身就像问“压缩算法和播放器哪个好”,比较维度不一致。

13.2 常见路线对比

路线 典型格式 是否要校准 主要优势 主要限制 更适合
AWQ W4A16 激活感知、INT4 质量好、硬件友好 依赖架构映射和后端 kernel GPU 推理与服务部署
GPTQ W4A16 等 层级误差重构成熟、模型生态广 量化耗时和实现复杂度可能更高 GPU 推理、已有 GPTQ 生态
bitsandbytes NF4 4-bit 通常不做 AWQ 式校准 易加载、适合 QLoRA 和低显存微调 部署吞吐不一定优于专用 serving 格式 训练、微调、研究验证
FP8 W8A8/W8A16 等 视方案而定 新硬件上吞吐强、精度通常稳 对硬件能力要求高,压缩率低于 INT4 数据中心新 GPU、高吞吐服务
GGUF 多种 Q4/Q5/Q8 转换方案相关 CPU、Apple Silicon、本地生态成熟 与 GPU 服务端 kernel 路线不同 笔记本、CPU、本地应用
BF16/FP16 高精度 质量基准、兼容性强 显存和带宽成本高 资源充足、质量优先、基准对照

13.3 AWQ 与 GPTQ:最常被放在一起比较

AWQ 的思路

  • 用激活找到敏感通道;
  • 通过等价缩放改善权重分布;
  • 再做硬件友好的低比特量化。

GPTQ 的思路

  • 逐层或逐块处理权重;
  • 利用近似二阶信息或重构目标补偿量化误差;
  • 通过权重更新或误差传播尽量保持层输出。

实际选择

选择 AWQ 的常见理由:

  • 目标后端对 AWQ 支持好;
  • 希望较快完成高质量 W4A16;
  • 业务校准数据容易准备;
  • 模型架构 mappings 已成熟。

选择 GPTQ 的常见理由:

  • 已有成熟 GPTQ 模型和 kernel;
  • 目标架构的 GPTQ 支持更好;
  • 希望尝试 act-order、不同重构配置;
  • 评测显示 GPTQ 在你的任务上损失更小。

不存在一个对所有模型都稳赢的方法。最可靠的决策方式是:

同一原模型 + 同一校准集 + 同一评测集 + 同一推理后端
分别量化 AWQ 和 GPTQ,再比较质量与系统指标

13.4 AWQ 与 bitsandbytes 4-bit

bitsandbytes 的 4-bit 加载非常适合:

  • 在有限显存中加载大模型;
  • 做 LoRA/QLoRA 微调;
  • 快速研究和验证。

但它不等价于“为生产 serving 预先打包好的 AWQ INT4 权重”。是否能加速、使用什么 kernel、能否高效批处理,都取决于后端集成。

简单说:

低显存微调优先想到 bitsandbytes / QLoRA
GPU 低比特部署可以重点比较 AWQ / GPTQ / FP8

13.5 AWQ 与 GGUF

GGUF 更像一套本地推理生态的统一载体。它非常适合:

  • CPU 推理;
  • Apple Silicon;
  • llama.cpp;
  • 桌面应用;
  • 离线个人助手;
  • 多种 Q4/Q5/Q8 量化等级灵活切换。

AWQ 更常见于:

  • NVIDIA/AMD GPU;
  • 服务端推理;
  • vLLM 等高吞吐引擎;
  • W4A16 权重带宽优化。

如果目标是“让模型在笔记本 CPU 上跑”,优先研究 GGUF;如果目标是“让 GPU 服务端用更少显存承载更多请求”,AWQ 更值得优先评估。

13.6 AWQ 与 FP8

FP8 通常压缩率不如 INT4,但在支持 FP8 的现代 GPU 上,可以同时兼顾精度和吞吐。选择时关注:

  • GPU 是否有高效 FP8 支持;
  • 模型是权重瓶颈还是计算瓶颈;
  • 是否需要量化激活;
  • 质量门槛;
  • 并发和 batch;
  • 目标云实例价格。

如果你的 GPU 对 FP8 非常友好,而模型本来就能放下,FP8 可能比 W4A16 更适合高吞吐。若首要目标是把更大模型塞进有限显存,INT4 更有吸引力。

13.7 什么时候直接下载预量化模型

适合直接下载的情况:

  • 只想快速部署;
  • 发布者可信;
  • 量化配置和后端与你一致;
  • 有公开评测;
  • 模型版本准确;
  • 不需要领域校准。

适合自己量化的情况:

  • 模型经过内部微调;
  • 业务分布特殊;
  • 需要可审计的校准和评测过程;
  • 想比较不同 group size 或算法;
  • 需要固定量化工具版本;
  • 供应链安全要求高。

下载预量化模型时至少检查:

  • 原始基础模型;
  • revision/commit;
  • 量化方法;
  • bits 和 group size;
  • 对称或非对称;
  • 排除模块;
  • 校准集说明;
  • 推荐后端;
  • 文件哈希;
  • 许可证。

13.8 一个实用决策树

模型在目标硬件上能否以 BF16/FP16 满足成本和 SLA?
├── 能
│   ├── 质量极敏感 → 先保持高精度
│   └── 想提高容量/降低成本 → 比较 FP8 与 INT4
└── 不能
    ├── 目标是 GPU 服务端
    │   ├── 架构和后端支持 AWQ → 先试 AWQ W4A16
    │   ├── GPTQ 生态更成熟 → 试 GPTQ
    │   └── 新 GPU、吞吐优先 → 评估 FP8
    └── 目标是 CPU/个人设备
        └── 优先评估 GGUF / llama.cpp

十四、从实验到生产:一份可执行的上线清单

14.1 量化产物必须可追溯

建议在模型目录保存:

quantization_run.json
requirements-lock.txt
source_model_revision.txt
calibration_manifest.json
benchmark_results.json
quality_results.json
checksums.sha256

至少记录:

  • 原模型 ID 和 commit/revision;
  • 量化工具版本;
  • PyTorch、CUDA、Transformers 版本;
  • 算法和量化格式;
  • bits、group size、对称性;
  • 排除模块;
  • 校准样本数和长度;
  • 校准集版本或哈希;
  • 随机种子;
  • 量化硬件;
  • 推理后端版本;
  • 评测集版本;
  • 结果和接受标准。

没有这些信息,几个月后很难解释两个“看起来同名”的 AWQ 模型为什么表现不同。

14.2 上线前设定明确门槛

门槛必须在看结果前定义。示例:

质量门槛
- 业务准确率下降不超过 1 个百分点
- JSON 合法率不低于原模型 99%
- 关键安全测试不得新增失败
- PPL 相对增幅不超过团队设定阈值

性能门槛
- 同一 GPU 上稳定并发提升至少 30%
- P95 延迟不高于原模型
- 单请求 TTFT 不恶化超过设定值
- 24 小时压力测试无 OOM

这些数字只是格式示例,不是适用于所有项目的统一标准。真正阈值应来自业务成本和风险。

14.3 测试条件必须写进结果

一句“AWQ 快 1.8 倍”没有足够信息。至少附带:

GPU 型号与数量
驱动/CUDA
vLLM 版本
模型 revision
输入长度分布
输出长度
并发数
batch 策略
max_model_len
gpu_memory_utilization
采样参数
是否预热
统计时长
P50/P95/P99

不同条件下的数字可能完全相反。

14.4 先灰度,不要直接全量替换

推荐部署顺序:

离线评测
→ 影子流量
→ 1% 灰度
→ 10% 灰度
→ 50% 灰度
→ 全量

灰度期间比较:

  • 请求成功率;
  • 输出长度;
  • 空响应率;
  • 格式解析失败;
  • 用户重试率;
  • 人工投诉;
  • TTFT/TPOT;
  • GPU 利用率;
  • OOM 和重启;
  • 特定任务质量。

14.5 保留高精度回退路径

上线 AWQ 不意味着删除原模型。至少在验证期保留:

  • FP16/BF16 模型;
  • 上一个稳定量化版本;
  • 可快速切换的服务配置;
  • 模型版本路由;
  • 失败请求回退策略。

量化问题可能只在特定语言、长上下文或罕见格式上出现。没有回退路径,轻微质量问题会被放大成线上事故。

14.6 监控不只看 GPU

生产监控建议包含:

系统层

  • GPU 显存;
  • GPU 利用率;
  • 功耗与温度;
  • CPU RAM;
  • 请求队列;
  • 服务重启;
  • CUDA OOM;
  • kernel 回退告警。

性能层

  • TTFT;
  • TPOT;
  • tokens/s;
  • requests/s;
  • P50/P95/P99;
  • 输入/输出 token 分布;
  • 并发数。

质量层

  • 空回答率;
  • 截断率;
  • JSON 解析失败;
  • 工具调用失败;
  • 代码执行失败;
  • 拒答率变化;
  • 用户重试和负反馈。

量化后的质量漂移不一定以“模型报错”形式出现,更多时候表现为业务指标缓慢恶化。

14.7 校准数据也需要安全治理

不要因为校准集“小”就忽视隐私:

  • 不把用户密码、密钥和身份证号写进校准文件;
  • 对内部对话做脱敏;
  • 明确数据访问权限;
  • 记录来源和保留期限;
  • 不把内部校准集随模型公开上传;
  • 检查模型输出目录里是否意外复制了数据文件。

校准只需要分布代表性,不需要保留真实用户身份。

14.8 模型升级后必须重新量化和评测

即使模型名字只从 v1.0 变成 v1.1,权重分布、层结构、tokenizer 或聊天模板都可能变化。不要直接复用旧模型的:

  • 缩放系数;
  • packed 权重;
  • 校准结论;
  • 性能数据;
  • 质量门槛结果。

正确流程是:

新原模型 → 新量化产物 → 新回归评测 → 新灰度

十五、把整条链路再串一次

到这里,我们已经不只是“运行了一段量化代码”,而是完成了一套完整方法:

选模型
  ↓
估算权重、显存和硬件边界
  ↓
建立独立量化环境
  ↓
准备贴近业务的校准数据
  ↓
应用聊天模板并检查 token 分布
  ↓
AWQ 搜索激活感知缩放
  ↓
W4A16 分组非对称量化
  ↓
以压缩格式保存
  ↓
Transformers 冒烟测试
  ↓
vLLM 高性能推理
  ↓
磁盘、显存、PPL、业务质量、延迟和吞吐评测
  ↓
灰度、监控和回滚

最值得记住的不是某个 API,而是下面六条原则。

原则一:先明确目标,再选择量化

你是想:

  • 让模型放进一张卡;
  • 增加并发;
  • 降低云成本;
  • 提高 decode 速度;
  • 运行更大模型;
  • 部署到个人设备?

目标不同,最佳格式和后端也不同。

原则二:校准数据决定 AWQ 看见什么

它不需要很多,但必须像真实输入。语言、领域、聊天模板和长度分布都比简单堆数量更重要。

原则三:量化配置必须和推理 kernel 对得上

算法质量再好,如果保存格式无法被后端高效消费,最终也只能得到一个“体积小但不快”的模型。

原则四:量化是质量与效率的交易

必须同时报告:

省了多少 + 损了多少 + 在什么条件下

原则五:单条回答不是评测

使用固定数据、确定性参数、业务指标和系统指标。回答不同是正常现象,任务质量下降才是问题。

原则六:工具会变,方法论比库名更耐用

AutoAWQ 会停止维护,LLM Compressor 的 API 也可能继续演进,但下面的逻辑长期成立:

代表性校准 → 误差感知量化 → 后端兼容保存 → 严谨对照评测

十六、小结

本文把 AWQ 从一个“听起来很厉害的 4 bit 算法”落成了可执行、可解释、可验证的工程流程。

你现在应该能清楚回答:

  1. AWQ 在做什么? 通过校准数据观察激活,用等价缩放保护敏感通道,再把主要线性层权重压缩为低比特格式。
  2. 为什么是 W4A16? 权重占模型存储和反复读取的大头,先压到 4 bit 可以获得显著收益;激活保留 16 bit,降低动态量化难度。
  3. 为什么需要校准数据? 因为同样大小的权重误差,在不同激活通道上造成的输出影响不同。
  4. 为什么不能只看模型文件大小? 运行时还有高精度模块、KV Cache、激活、kernel 工作区和框架预留。
  5. 为什么量化后不一定加速? 加速依赖硬件、kernel、后端、batch、prefill/decode 比例和实际瓶颈。
  6. 如何判断量化是否成功? 同时检查压缩配置、模型体积、功能输出、PPL、业务任务、显存、延迟、吞吐和稳定性。
  7. 现在该用什么工具? 新项目优先评估 LLM Compressor + vLLM;AutoAWQ 更适合遗留兼容和旧流程复现。

量化的真正价值,不是把一个 16 改成 4,而是让模型在可接受的质量损失下,进入原本无法承载它的硬件和成本区间。

下一篇进入 GGUF 时,思路会明显变化:重点不再是服务端 GPU 上的 W4A16 kernel,而是模型文件格式、CPU/Apple Silicon、本地推理、不同 Q4/Q5 量化等级,以及 llama.cpp 如何把大模型带到普通个人设备上。



附录 A:从“第一次跑通”到“可上线候选”的三轮实验法

很多读者照着教程完成一次量化后,会立刻陷入新的困惑:接下来该调什么?到底是校准集不好,还是 group size 不合适?模型回答变了,是正常波动还是能力下降?如果每次都凭感觉改一个参数,不仅耗时,而且很难知道是哪项变化真正起作用。

更稳妥的方式,是把量化实验拆成三轮,每一轮只解决一个层级的问题。

A.1 第一轮:只验证链路完整,不追求最优结果

第一轮的目标非常单纯:证明你的环境、模型架构、校准数据格式、保存格式和推理后端能够首尾相接。

这一轮可以使用:

  • 较小的模型;
  • 32~64 条校准样本;
  • 256~512 的最大序列长度;
  • 默认量化配方;
  • 5~10 条冒烟 prompt。

第一轮不应该花时间争论 128 还是 256 条样本,也不应该直接跑几个小时的完整 benchmark。你只需要确认下面这些事实:

  1. 原模型能够正常加载;
  2. 校准数据经过聊天模板后不是空文本;
  3. 日志确实进入 AWQ 搜索,而不是跳过算法;
  4. 保存目录中出现压缩权重和量化配置;
  5. 量化模型能被目标推理后端加载;
  6. 输出没有乱码、无限重复或立即终止;
  7. 权重文件体积明显下降;
  8. 整个过程可以在同样命令下重复执行。

如果第一轮失败,不要急着扩大数据或换大模型。先把失败归入下面四类之一:

环境问题:依赖、CUDA、编译、驱动、导入路径
架构问题:模型不受支持、mappings 不完整、特殊模块未排除
数据问题:字段错误、模板错误、文本为空、长度异常
产物问题:未压缩保存、文件缺失、后端不识别格式

只要能准确归类,排障速度就会快很多。

A.2 第二轮:建立“可比较的基线”

第一轮跑通后,第二轮才开始追求可比较性。此时建议固定:

  • 目标模型 revision;
  • 校准集版本;
  • 评测集版本;
  • 随机种子;
  • 最大序列长度;
  • 推理后端版本;
  • 所有生成参数;
  • benchmark 的输入和输出长度;
  • GPU 和驱动环境。

然后生成一个基线 AWQ 模型,例如:

256 条校准样本
max_seq_length = 512
W4A16_ASYM
group_size = 128
duo_scaling = both
排除 lm_head

这一轮最重要的产物不是模型文件,而是一张完整结果表。你要知道基线模型在以下方面表现如何:

  • 压缩率;
  • PPL;
  • 业务任务准确率;
  • 结构化输出成功率;
  • 单请求延迟;
  • 批量吞吐;
  • 最大稳定上下文;
  • 最大稳定并发;
  • 是否出现新的错误类型。

为什么一定要先有基线?因为后续任何调参都要回答一句话:

它相对基线改善了什么,又付出了什么代价?

没有基线,看到一个 PPL 数字或 tokens/s 数字都无法判断好坏。

A.3 第三轮:一次只改变一个主要变量

量化实验最忌讳一次同时改五个参数。例如你同时:

  • 把样本从 128 增加到 512;
  • 把长度从 512 增加到 2048;
  • 把校准集从英文换成中文;
  • 把 group size 从 128 改成 64;
  • 把后端版本也升级了。

即使结果变好,你也不知道真正原因;结果变差,更不知道该退回哪一步。

建议按下面顺序做消融。

实验组一:校准数据数量

固定其他所有条件,只比较:

64 / 128 / 256 / 512 条

观察质量曲线何时趋于平缓。如果 256 和 512 的业务结果几乎相同,而量化时间明显增加,就选择 256。

实验组二:校准数据分布

固定数量和长度,只改变数据来源:

通用数据
业务数据
通用数据与业务数据混合

如果业务数据让领域任务提高,却让通用能力明显下降,可以调整混合比例,而不是简单选择其中一边。

实验组三:最大序列长度

比较:

512 / 1024 / 2048

重点看长上下文任务。如果业务请求 P95 只有 400 token,把校准长度拉到 2048 未必值得;如果模型主要处理长文,512 又可能无法覆盖关键激活模式。

实验组四:group size

在后端支持的前提下比较:

64 / 128 / 256

通常 group 越小,质量更有机会提高,但模型文件、元数据和 kernel 效率可能变化。不要只比较 PPL,还要重新测吞吐。

实验组五:缩放搜索策略

比较 duo_scaling=Trueduo_scaling="both",或者在明确理解影响后调整搜索网格。这里的目标是判断更长搜索是否换来了稳定的质量收益。

A.4 怎样判断一个差异是不是“噪声”

如果两次测试只差极小数值,不要立刻宣布胜负。需要考虑:

  • 测试样本是否太少;
  • 服务端是否有其他负载;
  • GPU 频率和温度是否变化;
  • 请求长度是否完全一致;
  • 是否预热;
  • 随机采样是否关闭;
  • 网络 benchmark 是否受客户端影响;
  • PPL 数据是否足够大;
  • 人工评分者是否一致。

性能测试应重复多轮,并报告中位数或置信区间。质量测试应扩大样本,并关注不同任务类别是否呈一致趋势。

例如,吞吐从 100.0 变成 101.2 tokens/s,可能只是测量波动;结构化输出合法率从 99.5% 降到 93%,通常就不是噪声。判断差异时要结合指标的业务意义,而不是只看相对百分比。

A.5 一个推荐的实验命名规范

不要把目录命名成:

final
final2
final-new
really-final

推荐把关键信息写进名字:

qwen2.5-1.5b-awq-w4a16-g128-n256-l512-seed42
qwen2.5-1.5b-awq-w4a16-g64-n256-l512-seed42
qwen2.5-1.5b-awq-w4a16-g128-n512-l1024-seed42

同时使用一个实验表记录模型目录、代码 commit 和结果。目录名不能替代元数据,但能显著减少拿错模型的概率。

A.6 怎样写一份合格的量化实验结论

不合格的结论:

AWQ 效果很好,模型基本无损,速度提升明显。

合格的结论应该像这样组织:

在指定 GPU、vLLM 版本和固定输入输出长度下,AWQ W4A16 模型相对 BF16 基线减少了多少权重体积,并在某一并发范围内改善了何种性能指标。质量方面,PPL、业务准确率和结构化输出合法率分别变化多少。长上下文类别出现了什么退化,因此当前版本适合哪些流量,不适合哪些流量。下一步准备通过增加长文本校准样本或调整 group size 验证该问题。

这种结论包含:

  • 条件;
  • 数字;
  • 优势;
  • 限制;
  • 决策;
  • 下一步。

它才真正能帮助团队做工程判断。


附录 B:新手最常问的 26 个问题

B.1 没有 NVIDIA GPU 能做 AWQ 吗

算法层面并不只属于 NVIDIA,但具体工具和 kernel 有硬件要求。本文主线面向 GPU 量化与 vLLM 部署。没有合适 GPU 时,可以使用云端 GPU 完成量化,或改走 GGUF、CPU 量化、Apple Silicon 生态。不要把“AWQ 是数学方法”和“某个 Python 包只能在特定硬件上运行”混为一谈。

B.2 量化一定需要原模型完整加载到 GPU 吗

不一定。现代框架可以分层处理、CPU offload 或多卡协同。但量化仍需要访问原始高精度权重,并运行校准前向。最终 INT4 模型能放进一张卡,不代表量化阶段也一定只需要同样资源。

B.3 校准文本需要答案完全正确吗

校准主要看激活分布,不像监督微调那样直接学习答案标签。因此答案不必像训练集一样逐字完美。但文本应当自然、格式正确、贴近真实输入。大量乱码、错误角色、异常长度和重复模板会污染统计。

B.4 能不能只用用户问题,不放 assistant 回答

可以,但要看实际推理分布。如果真实系统经常处理多轮对话,完整轮次更有代表性。只用用户问题会让模型主要看到 prompt 阶段的激活,可能覆盖不足。最稳妥的是按线上请求结构采样并脱敏。

B.5 128 条和 256 条到底选哪个

先用 128 或 256 建立基线,再通过评测决定。资源紧张时 128 是合理起点;正式候选常值得尝试 256。关键不是数字本身,而是增加样本后质量是否继续改善。

B.6 校准数据可以和微调数据一样吗

可以从微调数据中抽取代表性样本,但应注意隐私、许可、去重和评测泄漏。领域 SFT 模型通常从同领域数据中校准更合理,同时建议加入一定比例的通用数据,避免只覆盖窄场景。

B.7 量化会改变模型知识吗

它不通过训练增加或删除知识,但数值近似会改变模型输出分布。某些原本处于临界排名的 token 会交换顺序,于是表现得像“忘了”或“答错了”。这属于数值误差对行为的影响。

B.8 为什么量化后同一问题回答完全不同

生成是自回归过程。某一步 token 选择发生变化,后续上下文就会分叉。应比较任务完成度和事实正确性,而不是要求整段字符串一致。

B.9 量化后 PPL 下降了,是不是比原模型更强

有可能是测量波动、数据过小、模板差异或正则化式偶然效果。不要仅凭一次小规模 PPL 下降宣称能力提升。扩大数据并查看下游任务。如果多个独立指标都稳定提高,才值得进一步分析。

B.10 为什么 4 bit 模型还能看到 FP16/BF16

因为 W4A16 只表示主要权重是 4 bit,激活和某些模块仍是 16 bit。推理 kernel 也可能在计算时把权重块反量化到寄存器或更高精度乘加。低比特存储不等于整个计算图都用整数。

B.11 能不能把 KV Cache 也量化成 4 bit

那是另一条优化路线,风险和支持情况不同。W4A16 主要压权重。KV Cache 量化常见 FP8 或其他格式,需要单独校准、后端支持和长上下文质量评测。不要把两种改动混在第一次实验中。

B.12 lm_head 为什么常常不量化

它直接产生词表 logits,对 token 排名敏感,而且在一些模型中与 embedding 共享。保留高精度通常只增加有限体积,却能提高兼容性和质量稳定性。是否量化应通过后端支持和评测决定。

B.13 group size 越小越好吗

不一定。更小的组通常有更细的 scale,可能降低误差,但会增加元数据并影响 kernel 效率。最终目标不是最小 PPL,而是在质量、体积和速度之间找到合适点。

B.14 为什么下载的 AWQ 模型在我的 GPU 上不快

可能因为 GPU 架构、后端版本、量化格式、kernel、batch 和上下文不同。发布者的 benchmark 条件不一定与你一致。先看启动日志是否走了预期 kernel,再用你的真实负载测试。

B.15 可以在 Windows 上直接做吗

具体支持随 PyTorch、CUDA、vLLM 和量化库版本变化。很多高性能 serving 工具优先支持 Linux。Windows 用户可以考虑 WSL2、Linux 双系统、容器或远程 GPU。生产部署通常更建议使用官方支持良好的 Linux 环境。

B.16 Mac 更适合 AWQ 还是 GGUF

如果目标是在 Apple Silicon 本地运行,GGUF/llama.cpp 或 MLX 生态通常更自然。AWQ 更常见于 GPU 服务端。也存在 Mac 上的 AWQ 支持路径,但选型应从目标推理引擎出发,而不是只看模型文件后缀。

B.17 为什么先用小模型,最后却必须在大模型上重测

小模型用来验证流程和代码,大模型才是实际交付对象。不同参数规模、层数和冗余会改变量化敏感度;小模型结果只能证明方法可执行,不能替代目标模型评测。

B.18 量化模型能继续微调吗

取决于格式和工具。部署用 packed AWQ 权重通常不是最方便的训练起点。需要低显存微调时,更常见的是 bitsandbytes 4-bit + LoRA/QLoRA,完成微调后再针对最终模型做部署量化。

B.19 已经有 LoRA,应该先合并还是直接量化

常见流程是先把 LoRA 与基础模型合并成最终高精度权重,再对最终模型做 AWQ,并使用贴近该微调模型业务的数据校准。不同后端也可能支持运行时 LoRA,但量化基座与适配器组合需要单独验证。

B.20 什么情况下应该放弃 AWQ

当目标架构长期不受支持、质量损失超过门槛、目标后端没有高效 kernel、FP8 在你的硬件上更优、或 GGUF 更符合终端场景时,就应该换路线。工程目标不是证明 AWQ 必须成功,而是找到总成本最低、风险可控的部署方案。

B.21 模型越大,量化收益一定越大吗

从绝对显存和磁盘节省看,大模型通常更明显,因为权重占用更大。但系统收益还取决于并行方式、通信、KV Cache、请求长度和后端 kernel。70B 模型从多卡降到更少卡,可能带来巨大的部署价值;也可能因为张量并行通信和长上下文瓶颈,速度提升没有想象中明显。应分别计算“每个请求成本”“每张卡吞吐”和“可用模型规模”,而不是只看压缩百分比。

B.22 为什么要保存原模型 revision,而不是只记模型名称

模型仓库可以在名称不变的情况下更新权重、配置、tokenizer 或聊天模板。同一个 Qwen/... 名称在不同时间下载,未必得到完全相同文件。保存 commit/revision 和文件哈希,才能复现量化结果,也能判断线上异常是否来自原模型更新,而不是量化参数变化。

B.23 业务数据很少,怎么做校准

可以从真实业务模板、公开领域文本、合成样本和通用对话中构造混合校准集。重点是覆盖语言、格式、长度和关键术语,而不是追求大量独立事实。合成数据应检查模板多样性,避免所有样本只有同一种句式。即使只有几十条真实样本,也可以围绕常见任务类型扩展表达方式,再用独立真实评测集验证。

B.24 是否应该把最难的样本都放进校准集

校准集应代表真实分布,而不是只收集极端难例。全部使用超长、复杂、罕见输入,可能让缩放策略过度偏向少数场景。更合理的是按线上比例采样,同时适当提高关键高风险场景的覆盖。难例主要用于评测和压力测试,校准与评测承担的角色不同。

B.25 如何判断量化项目已经“做完”

不是脚本跑完,也不是模型上传完成,而是满足预先定义的质量、性能、稳定性和可回滚要求。一个可交付的量化模型应当能够被重新构建、被独立加载、通过固定评测、在目标负载下稳定运行,并且团队知道它在哪些场景可能退化。只要这些条件还有一项没有证据,项目就仍处于实验阶段。

B.26 最后一个建议:保留实验耐心

量化不是把参数从十六位改成四位这么简单,而是一场围绕数据分布、数值误差、硬件能力和业务目标的系统实验。先小规模验证,再逐步扩大;先记录事实,再解释原因;先设门槛,再看结果。这样做看似比复制一段代码慢,却能显著减少后续反复返工,也更容易得到真正可信、可以交付的模型。


附录 C:从“能运行”到“可上线”的完整业务化迭代案例

本节是一个方法示例,用来展示团队如何根据评测现象迭代 AWQ 模型。场景、比例和门槛是为了说明决策过程,不是对所有项目都适用的固定答案,也不是本文作者在特定硬件上实测后给出的通用结论。

很多教程在量化模型成功生成后就结束了,但真实项目最难的部分往往从这里才开始。模型能够回答问题,只能证明格式和运行链路大体正确;它是否适合业务,还要看真实请求分布、失败代价、服务成本和回滚能力。

下面假设我们在做一个企业客服助手。它需要完成四类任务:回答产品政策问题、解释订单状态、总结较长的客服工单,以及按固定 JSON Schema 提取工单字段。模型原本以 BF16 运行,团队希望通过 AWQ W4A16 降低显存占用,让单卡容纳更多并发请求。

C.1 第一步不是量化,而是把目标写成验收条件

“尽量无损”“最好更快”都不是可执行的要求。团队应先把抽象目标拆成可以测量的门槛。一个示例验收表可以这样写:

维度 示例指标 示例门槛 为什么要测
知识问答 人工正确率或自动评分 相对 BF16 基线下降不超过团队可接受范围 判断核心回答能力是否退化
工单总结 关键信息召回率 订单号、时间、诉求、处理结果不能系统性漏掉 长文本中的小误差可能造成业务误判
结构化抽取 JSON 合法率、字段准确率 格式合法率应接近基线,关键字段不能明显下降 一段“看起来通顺”的文本可能仍无法被程序消费
安全与合规 越权回答率、敏感信息泄漏率 不得突破既有红线 低频但高风险,不能被平均分掩盖
性能 TTFT、TPOT、P50/P95 延迟、吞吐 在目标并发下满足服务等级 单请求快不代表高并发也快
资源 权重体积、峰值显存、可承载并发 能达到部署成本目标 量化的价值最终要落到资源与成本
稳定性 长时间运行错误率、OOM 次数 灰度期间无新增系统性错误 冒烟测试无法覆盖持续负载

表里的具体数字必须由业务风险决定。客服建议类应用和医疗、法律、金融等高风险应用,不应使用同一套容忍度。最重要的是在看到量化结果之前先定门槛,避免团队因为“已经花了很多时间”而事后降低标准。

C.2 建立真正可比较的 BF16 基线

量化模型永远要和某个明确的基线比较。这个基线不能只是模型名称,还应固定:

  • 模型仓库 revision 或权重哈希;
  • tokenizer 和聊天模板;
  • system prompt;
  • 推理后端及版本;
  • temperaturetop_p、停止词和最大输出长度;
  • 输入长度分桶;
  • 并发、批处理策略和 GPU;
  • 评测集版本;
  • 评分脚本版本。

假如 BF16 使用 vLLM,而 AWQ 使用 Transformers 单条 generate(),两边的性能数字没有直接可比性。假如一边加了系统提示词,另一边没有,质量差异也不能归因于量化。基线的价值不是“给原模型测一次分”,而是提供一把之后始终不变的尺子。

入门者可以先把所有生成都设为确定性解码,以减少随机波动;正式评测再补充与线上一致的采样配置。对于人工评分,应尽量盲评,不让评分者提前知道哪条来自 BF16、哪条来自 AWQ。

C.3 第一轮:先用默认方案建立量化基线

团队先采用一套保守起点:256 条通用对话校准样本、固定随机种子、W4A16_ASYM、保留 lm_head 高精度,并使用目标推理后端支持良好的默认分组设置。第一轮的目标不是直接得到最终模型,而是回答三个问题:

  1. 当前模型架构和工具链能否完成量化、保存与重新加载;
  2. 目标 GPU 是否真正走到预期的 AWQ kernel;
  3. 质量问题主要出现在哪些任务类别。

假设第一轮出现下面的现象:普通 FAQ 与 BF16 接近,单轮对话也没有明显异常;但长工单总结更容易漏掉中间段落中的处理结果,JSON 抽取偶尔会多输出解释性文本,双语请求的术语一致性也比基线差。

此时不能简单得出“AWQ 不适合这个模型”的结论。更合理的第一反应是检查校准分布:通用短对话是否覆盖了长工单、固定 Schema、双语术语和企业 system prompt?如果没有,量化过程看到的激活模式就与线上分布存在偏差。

另一个常见误区是只看整体平均分。假如 FAQ 占评测集大多数,它可以把长文和结构化任务的下降稀释掉。业务评测应按任务类型、语言、输入长度和风险级别分层报告,不能只给一个总分。

C.4 第二轮:只改变校准数据,验证“分布匹配”假设

为了知道问题是否来自校准数据,第二轮应尽量只改一个变量。模型 revision、量化格式、样本数量、随机种子和推理配置保持不变,只重构 256 条校准样本的组成。例如:

  • 35% 来自真实多轮客服对话的脱敏样本;
  • 25% 是不同长度的工单与摘要任务;
  • 20% 覆盖目标 JSON Schema、缺失字段和异常输入;
  • 10% 是中英混合及专业术语场景;
  • 10% 保留通用对话,防止校准集过度收窄。

这些比例不是最佳答案,而是把线上流量结构映射到校准集的一种示例。真正实施时,还要做四件事。

第一,按 token 长度分桶。不能让 256 条样本几乎都是 100 token 左右,然后期待模型在 2000 token 工单上保持同样稳定。可以让短、中、长样本都出现,并确保关键的长文本结构不会在预处理时被截断。

第二,保留真实模板。system、user、assistant 的角色边界、工具调用标记、JSON 提示和特殊 token 都可能影响激活。把对话拍平成一段没有角色标记的纯文本,虽然代码能跑,却未必代表线上输入。

第三,去除评测泄漏。校准集可以与评测集来自同一业务分布,但不应直接复制同一批问题和答案。否则结果可能看起来变好,却无法证明对未见请求也有效。

第四,完成隐私治理。真实客服数据应脱敏、最小化使用并遵守内部权限和保留规则。量化只需要代表性的语言与格式,不需要保留可以识别具体用户的信息。

第二轮完成后,如果长工单召回率和 JSON 合法率明显回升,而通用能力没有同步恶化,就支持“校准分布不匹配是主要原因”的假设。反之,如果问题几乎没有变化,就应继续检查截断、量化敏感层、后端实现或评测本身,而不是继续盲目堆同类数据。

C.5 第三轮:再单独验证长度与量化粒度

假如第二轮改善了格式任务,但长工单仍然退化,可以做第三轮。此时不要同时改五个参数,而是设计小型消融实验。

先固定业务校准集,只调整 max_seq_length,比较 512、1024 或更贴近真实请求分布的长度。重点检查:

  • 长样本是否在 tokenizer 后被截断;
  • 更长校准是否带来不可接受的显存与时间成本;
  • 改善是否只发生在长文任务,还是所有任务都变化;
  • 线上输入的 P95、P99 长度是否真的需要更高上限。

如果长度调整仍不足,再在推理后端明确支持的前提下比较不同 group size。更小分组可能降低局部量化误差,但也会增加量化元数据,并可能改变 kernel 效率。每次改变粒度后,都要重新测质量和性能,不能只看某一个 PPL 数字。

这一轮特别适合使用“现象—假设—实验—结论”记录法。例如:

现象 假设 只改变什么 能支持假设的结果
长文中段信息易丢 校准序列太短 max_seq_length 长文指标回升,短任务基本不变
JSON 易多出前后缀 校准缺少真实模板 数据分布 合法率回升,普通问答无明显损失
PPL 尚可但业务准确率下降 通用 PPL 不代表领域任务 评测维度而非量化参数 领域分层指标暴露稳定差异
显存降低但吞吐没提升 当前瓶颈不在权重带宽或 kernel 未命中 后端与负载 更换正确 kernel 或提高并发后收益出现
只有某一语言退化 校准语言比例失衡 语言构成 该语言任务回升且其他语言可接受

这种记录方式能防止团队陷入“参数试了一圈,最后不知道为什么这个版本更好”的局面。

C.6 性能没有提升时,先确认测的是什么

假设 AWQ 权重体积已经明显下降,但在线 P95 延迟几乎没有改善。这不一定说明量化失败。需要把请求拆成几个阶段:

  • 排队;
  • tokenizer;
  • prefill;
  • 首 token;
  • decode;
  • 后处理与网络传输。

如果服务的大部分时间花在排队或外部检索,模型计算加速对端到端延迟影响有限。如果请求输入很长、输出很短,prefill 占比可能更高;如果输出很长,decode 和权重读取更关键。如果 batch 太小,kernel 启动开销可能抵消收益;如果 batch 很大,吞吐改善又可能比单请求延迟更明显。

因此,性能结论至少要同时报告 TTFT、TPOT、端到端延迟和吞吐,并按输入长度、输出长度与并发分桶。只写“速度提升 30%”而不说明测试条件,几乎无法指导部署。

还要检查启动日志和模型配置,确认后端识别了量化格式并加载对应 kernel。模型能够生成文本,只能证明兼容路径存在,不能证明低比特计算路径已经生效。

C.7 决定是否上线:看约束,而不是追求每项都赢

经过几轮实验,团队通常会得到一个折中结果:AWQ 可能在权重体积和可承载并发上显著受益,FAQ 与抽取任务达到门槛,但极长工单仍有轻微下降;或者质量完全合格,但目标低并发场景没有明显延迟收益。

此时决策不应变成“AWQ 好不好”,而应回答:

  • 当前收益是否解决了最重要的部署约束;
  • 退化是否集中在可识别、可路由的请求类别;
  • 是否可以让超长或高风险请求继续走 BF16;
  • 省下的显存是否能转化为更高并发、更少 GPU 或更大上下文;
  • 运维复杂度和回滚成本是否可接受;
  • 未来模型升级后,量化与评测流程能否自动重跑。

一种现实做法是分层路由:大多数普通请求使用 AWQ,少量超长、高风险或对数值极敏感的请求仍使用高精度模型。量化不是非黑即白的全量替换,也可以是系统级成本优化的一部分。

C.8 灰度发布和回滚必须在正式流量前准备好

一个合格的上线计划至少包括:

  1. 先在离线数据上通过全部硬门槛;
  2. 用小比例影子流量比较 AWQ 与 BF16,不把实验输出直接返回用户;
  3. 检查真实输入长度、语言和任务分布是否与校准及评测集一致;
  4. 逐步扩大灰度比例,同时监控质量代理指标、错误率、超时、OOM、TTFT 和吞吐;
  5. 保留一键切回 BF16 的模型版本、配置和路由开关;
  6. 为异常任务保存经过脱敏的最小复现样本,进入下一轮评测集;
  7. 达到预设观察周期后再决定是否全量。

不要在出现异常后才临时设计回滚。模型格式、服务镜像、路由配置和监控面板都应在上线前验证。量化模型节省的基础设施成本,不能以降低事故恢复能力为代价。

C.9 这个案例真正想教会你的方法

这个案例没有给出一个“神奇参数组合”,因为真正可靠的方法不是背参数,而是建立闭环:

明确业务约束
    ↓
固定 BF16 基线
    ↓
用保守配置建立 AWQ 基线
    ↓
按任务与长度定位退化
    ↓
提出可验证的原因假设
    ↓
一次只改变少量变量
    ↓
同时复测质量、性能和稳定性
    ↓
灰度、监控、回滚

对初学者来说,跑通脚本是第一层能力;知道每个参数影响什么,是第二层能力;能从异常指标判断该改数据、长度、粒度、后端还是业务路由,才是第三层能力。到了这一层,你不只是“会用 AWQ”,而是在做一项可解释、可复现、可交付的模型工程工作。


参考资料与版本说明

本文按 2026 年 7 月的公开工具状态修订。大模型量化软件更新很快,实际使用时应以目标版本官方文档为准。

  1. AutoAWQ GitHub:归档与停止维护说明
  2. vLLM:AutoAWQ 弃用提示与 LLM Compressor 推荐路线
  3. LLM Compressor:AWQ 示例与 Recipe
  4. LLM Compressor:AWQModifier API
  5. vLLM:量化支持概览
  6. Hugging Face Transformers:AWQ 加载文档
  7. AWQ 官方研究仓库:Activation-aware Weight Quantization
  8. LLM Compressor:安装说明
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐