前言
当我们在本地部署大模型时,最常见的问题之一就是显存不足(OOM, Out of Memory)。很多人遇到这个问题后的第一反应,往往是更换更高配置的显卡,或者直接换用更小的模型。但实际上,在真正做工程部署时,更合理的思路通常是先尝试通过量化或降低精度的方式优化显存占用,再决定是否需要升级硬件或调整模型规模。
通常来说,在 Transformers 中,缓解显存压力主要有两类常见方式:
- **调整
dtype,降低模型加载精度:**例如将float32降为float16或bfloat16,从而减少模型权重占用的显存。 - **使用
bitsandbytes进行更进一步的低比特量化:**例如将模型以int8甚至fp4/nf4的形式加载,在显存节省方面会更加明显。
这两种方式虽然都能达到“减少显存占用”的目的,但它们的原理、适用场景以及使用方法并不完全相同。下面我们就先介绍一下量化的概念后,再分别来介绍它们的概念与实际用法。
基础理论
在大语言模型的部署过程中,量化(Quantization)是最常见、也最实用的一类优化手段。它的核心目标并不复杂,即用更少的比特来表示模型中的参数和计算过程,从而降低显存占用、减少内存带宽压力,并尽可能提升推理效率。对于参数规模动辄数十亿、上百亿的大模型来说,如果始终使用 FP32 这样的高精度格式,模型往往很难在普通消费级显卡上运行。因此,量化几乎已经成为大模型落地部署时绕不开的一步。
量化的类型
从本质上看,量化做的事情就是把原本用高精度浮点数表示的权重或激活值,映射到更低精度的数据格式中,例如 FP16、BF16、INT8,甚至进一步压缩到 INT4、1-bit 或 1.58-bit。这样做的直接收益是存储成本显著下降。例如,同样一组参数,如果从 FP32 变成 INT8,那么理论上存储空间可以缩小为原来的四分之一。但与此同时,数值表达能力也会下降,因此量化本质上是在“压缩效率”与“精度损失”之间做平衡。
从数据表示角度来说,位数越多,能表示的数值范围通常越大,精度也越高;位数越少,表示能力就越有限。于是,量化不可避免地会带来量化误差,也就是原始值与量化后再还原出来的值之间的偏差。这个误差如果过大,就可能影响模型输出质量。因此,量化并不是简单地“把数字变小”,而是要尽可能在有限的表示空间中保留原始分布的重要信息。
就比如像下面两张图一样,左侧原图能够使用大量连续且丰富的颜色来表示画面细节,因此整体看起来更加自然、平滑;而右侧量化后的图像由于可用颜色数量被大幅压缩,只能从有限的几种颜色中选择最接近的值来表示原始内容,因此虽然整体场景依然保留,但局部细节、颜色过渡和纹理表现都会变得更粗糙,放大后还能明显看到颗粒感和色块感。
对称量化与非对称量化
量化时,一个核心问题是:如何把原来的浮点数范围映射到低比特整数空间中。围绕这一点,最常见的两种方法是对称量化和非对称量化。
对称量化的思路比较直接,它通常以 0 为中心,把浮点范围映射到一个对称的整数区间中,例如 [−127,127]。这类方法实现简单,计算开销也较低,因此在很多场景下使用广泛。典型做法是根据张量中的最大绝对值来确定缩放范围,也就是常见的 absmax 量化。
比如在下面的例子中,这里数值的最大值为 10.8,最小值为 -7.59。但这里的量化并没有以 [−7.59,10.8] 作为量化区间,而是用了一个关于 0 对称的区间[−α, α]=[−10.8, 10.8]。也就是说虽然最小值只有 -7.59,但为了“对称”,左边也扩展到了 -10.8。这就是对称量化的第一个关键点:
先找绝对值最大的那个数,再构造 [−α,α][-\alpha, \alpha][−α,α] 的对称区间。
然后由于要映射到 INT8 格式,所以其值的大小最多就是 。因此一般情况下值应该是从 [−128, 127](这里之所以不是 128 到 -128 是因为 0 也算是一个点)。但是由于需要对称量化,因此这里也需要变成 [−127, 127]。最后通过等比例缩放的方式获取到对应的近似值,比如 10.8 对应的就是 127,而 5.47 对应的就是 36。
对于对称量化而言,其最大的优点就是实现非常简单,计算也很高效。但问题就是当数据分布不对称时,容易浪费量化区间,误差可能更大。
而非对称量化则不要求以 0 为中心,而是将原始数据的最小值和最大值分别映射到量化区间的边界。为了实现这种偏移映射,通常需要额外引入一个零点(zero point)。这种方法对分布不对称的数据更灵活,有时能更充分利用整数表示空间,但实现和计算会稍复杂一些。
比如说这里就直接采用最大值和最小值来进行映射,即量化区间就是 [β,α]=[−7.59, 10.8]。这也体现出其是按照真实数据分布的最小值和最大值来映射,不强行围绕 0 对称。然后对于 INT8 而言,其也是把全部范围都进行使用,即 [−128,127],这样能尽可能把真实范围压满整个整数空间。但是同样的由于不对称,需要的计算公式会比较复杂,需要先根据浮点区间和整数区间算出 scale,再求出一个 zero-point,使得浮点最小值能够对齐到整数最小值,从而让整个浮点区间线性贴合到整数区间上。这也是为什么整体效率也会低一些,但是效果会更好一些。
可以简单理解为对称量化更简洁,非对称量化更灵活,我们可以通过下图清晰地看到两者的区别:
权重量化与激活量化
在大模型中,被量化的对象通常分为两类:权重(weights)和激活(activations)。
权重是模型训练完成后就固定下来的参数,因此它们是静态的、可提前分析的。这使得权重量化相对更容易,也是部署中最常见的量化对象。很多量化方法主要就是围绕权重量化展开的,下面主要讲的就是该部分的量化。
比如一个大模型参数量很多:
- FP32:每个参数 4 字节
- FP16:每个参数 2 字节
- INT8:每个参数 1 字节
- INT4:每个参数 0.5 字节
假如从 FP32 变成 INT4,那就节省了 8 倍的存储和显存开销了。
而激活则不同。激活值是输入经过模型每一层计算后动态产生的,它会随着具体输入内容而不断变化,因此更难提前确定其分布范围。这意味着激活量化往往比权重量化更复杂,也更容易影响模型效果。一般情况下,我们不需要考虑激活部分的量化,只有到极致优化显存消耗的端侧模型才需要进行考虑。
训练后量化与量化感知训练
从应用流程看,量化大体可以分为两类:训练后量化(PTQ, Post-Training Quantization)和量化感知训练(QAT, Quantization Aware Training)。
训练后量化是指模型训练完成之后,再对其进行量化。这种方式最大的优点是简单高效,不需要重新训练模型,因此在大模型部署中非常流行。像 GPTQ、GGUF 等常见方案,本质上都可以归入这一思路。我们下面所讲的量化都属于这个类型。
量化感知训练则更进一步,它会在训练或微调阶段就把量化过程考虑进去。常见做法是训练时插入“假量化(fake quantization)”,也就是前向看起来像低精度计算,但参数更新仍在较高精度下进行。这样模型会在训练过程中逐步适应量化带来的误差,因此通常能比训练后量化取得更好的低比特性能。不过,它的代价也更高,实现复杂度更大。比如 QLoRA 方法就属于这一范畴。
总的来说,随着大模型规模不断增大,单纯依赖更强的硬件并不是长久之计。相比之下,量化提供了一条更具工程价值的路径,即在不显著改动模型结构的前提下,直接降低部署成本。尤其是在本地部署、边缘设备推理、消费级显卡运行等场景中,量化几乎是决定模型能否真正落地的关键技术之一。
因此,可以把量化理解为它不是改变模型“学到了什么”,而是改变模型“如何更高效地存储和计算这些知识”。它解决的核心问题并不是提升模型上限,而是让模型在有限硬件条件下仍然具备可用性。
代码实操
dtype 参数详解
在前面讲解模型加载参数时,我们提到过,dtype的主要作用是指定模型权重在加载时所使用的数据类型。不同的数据类型,会直接影响模型的显存占用、计算速度以及数值稳定性。在深度学习里最常见的三种 dtype 是:
float32(FP32)float16(FP16)bfloat16(BF16)
下面我们分别解释它们的区别。
torch.float32
model = AutoModelForCausalLM.from_pretrained( pretrained_model_name_or_path=model_path, trust_remote_code=True, device_map="auto", dtype=torch.float32,)torch.float32(也就是我们常说的 FP32)是深度学习里最“标准”的浮点数精度之一,遵循IEEE 754 binary32规范。在模型推理语境下,它最大的价值不是“跑得快”,而是数值表达最稳定、误差最小、行为最可预期——尤其适合做对照实验、排查数值问题、验证实现是否正确。
但 FP32 的代价也很直接,其非常吃显存/内存与带宽(FP32 每个参数 4 字节;而 FP16/BF16 是 2 字节)。在本地部署大模型时,很多时候并不是算力不够,而是模型权重、KV Cache、以及中间激活占用把显存顶满了。此时若仍用 FP32 加载权重,往往会很快触发 OOM。因此在真实的推理部署中,FP32 通常不是默认首选,而更多扮演“基准线(baseline)”的角色,然后用它来确认模型在最高精度下的正确性与上限表现,再决定是否切换到 FP16/BF16 或量化精度。
FP32 浮点数由三部分组成:
- Sign(符号位):决定正负
- Exponent(指数位):决定动态范围(能表示多大/多小的数)
- Fraction / Mantissa(尾数位):决定精度(数值刻度有多细)
对于FP32(binary32),其 bit 结构是1 + 8 + 23:
- 8 位指数 → 提供非常宽的动态范围(范围从
-3.4e38到3.4e38) - 23 位尾数 → 提供较高的数值分辨率(细腻的刻度)
torch.float16
model = AutoModelForCausalLM.from_pretrained( pretrained_model_name_or_path=model_path, trust_remote_code=True, device_map="auto", **dtype=torch.float16,**)torch.float16(也就是我们常说的FP16)同样遵循 IEEE 754 的浮点数规范(binary16)。在模型推理语境下,FP16 的核心价值不是“更高级”,而是一个非常务实的取舍用可接受的数值近似,换来显著的显存节省与更高的推理吞吐潜力。也正因为如此,FP16 往往是本地部署大模型时的“默认候选精度”之一。
对于FP16(binary16),其 bit 结构是1 + 5 + 10:
- 5 位指数(范围从
-65504到65504) → 动态范围显著小于 FP32 - 10 位尾数 → 数值分辨率也低于 FP32
由于指数位下降,FP16 的两个典型风险会更容易出现:
- overflow(溢出 → inf):当中间值/激活/logits 峰值过大时更容易发生
- underflow(下溢 → 0):当数值非常小(比如训练中的小梯度)更容易被“舍掉”
但和 FP32 相比,其最直观的差别在于存储成本。FP16 每个参数2 字节,而 FP32 是4 字节。这意味着只看“权重加载”这一项,FP16 通常能让显存压力直接减半;而在推理中大量的权重读取会受到显存带宽影响,数据量更小也往往更利于吞吐(具体是否更快还取决于算子实现、batch size 以及是否 memory-bound)。
因此假如你希望更省显存,让模型更容易装进消费级显卡或你愿意接受少量的数值近似(通常对推理质量影响不大)时可以考虑使用 FP16。
需要注意的是,假如遇到以下情况,比如:
- 训练时经常 NaN/不稳定
- 推理场景包含极长上下文或特别极端的输入分布
- 输出一致性要求极高,且希望有可对照的稳定基准(此时先用 FP32 建 baseline)
此时可能需要谨慎使用 FP16 进行推理。
torch.bfloat16
model = AutoModelForCausalLM.from_pretrained( pretrained_model_name_or_path=model_path, trust_remote_code=True, device_map="auto", **dtype=torch.bfloat16,**)torch.bfloat16也就是BF16,虽然同样是 16 位浮点数,但它和float16的内部结构并不一样。相比float16,bfloat16拥有更大的数值范围,因此在很多情况下能够兼顾较低的显存占用和更好的数值稳定性。
BF16 浮点数的结构是:1 + 8 + 7
- 8 位指数:和FP32 一样多→动态范围更接近 FP32
- 7 位尾数:比 FP16/FP32 都少 →精度更粗
所以在推理中,BF16 的优势往往体现在动态范围更大,尤其在以下场景更有价值:
- 长上下文 / 长序列累积:数值链路变长时,溢出风险更高
- logits 分布极端(softmax 前值可能很大)
- 某些模型在 FP16 下偶发输出异常,换 BF16 能明显改善一致性
也正因如此,bfloat16在近年来的大模型训练与推理中越来越常见。很多新硬件和主流框架也都优先支持 BF16。
但由于BF16 的尾数位更短,意味着它的数值分辨率更低。同样一个区间内,BF16 可表示的“刻度点”比 FP16 更少,因此在非常细微的数值差异上可能更容易丢失细节(这通常在训练时更容易被感知,在推理中多数任务影响不大,但并非完全无感)。
总的来说,对比 FP16 和 BF16 可以简单理解为:
float16:更强调节省显存和提升速度bfloat16:在节省显存的同时,通常有更好的稳定性
因此,如果你的硬件支持 BF16,那么它往往会是一个非常值得优先尝试的选择。
精度对比分析
前面大多数情况下,我们对比的都是指数位的差别。那我们这里可以举一个简单的例子来演示一下其中精度的区别,比如对于 1/3 而言,我们都知道其是一个无穷数 0.33333…,当然由于存在浮点近似误差,所以显示的值并完全一致:
value = 1/3print(format(value, '.60f'))这里我们通过 format() 打印出了后六十位的值:
0.333333333333333314829616256247390992939472198486328125000000那假如此时我们将其转化为 fp32 的格式的话:
tensor_fp32 = torch.tensor(value, dtype = torch.float32)print(f"fp32 tensor: {format(tensor_fp32.item(), '.60f')}")同样打印后 60 位,可以看出显然精度降低了非常多,但是还能保持前面是 8 位是 0.33…:
fp32 tensor: 0.333333343267440795898437500000000000000000000000000000000000假如进一步转化为 fp16 的话:
tensor_fp16 = torch.tensor(value, dtype = torch.float16)print(f"fp16 tensor: {format(tensor_fp16.item(), '.60f')}")此时就只能保证前面 3 位了,而且后面大量的内容都变成 0 了:
fp16 tensor: 0.333251953125000000000000000000000000000000000000000000000000最后再测试一下精度最低的 bf16:
tensor_bf16 = torch.tensor(value, dtype = torch.bfloat16)print(f"bf16 tensor: {format(tensor_bf16.item(), '.60f')}")此时的精度差距就更大了,剩下九位数字了:
bf16 tensor: 0.333984375000000000000000000000000000000000000000000000000000显存消耗对比
那既然我们前面提到了,更换精度能够节省显存,那到底能节省多少呢?下面我们就用例子来真实的展示一下。这里我让 AI 给我生成了一段监控显存使用峰值的代码:
import torchdef format_gb(num_bytes: int) -> str: """ 将“字节数”转换为更直观的 GB 字符串显示。 参数: num_bytes: 显存占用(单位:字节) 返回: 形如 '1.234 GB' 的字符串 """ returnf"{num_bytes / (1024**3):.3f} GB"def get_cuda_devices(): """ 返回当前进程可用的 GPU id 列表(一定是 0..N-1)。 同时做一次 CUDA 上下文初始化,避免某些 notebook 环境的异常。 """ ifnot torch.cuda.is_available(): return [] # 触发 CUDA 上下文初始化(很轻量) _ = torch.cuda.current_device() n = torch.cuda.device_count() return list(range(n))def reset_peaks(devices): """ 对“有效设备 id”重置峰值统计,避免 invalid device。 """ ifnot torch.cuda.is_available(): return n = torch.cuda.device_count() for d in devices: if0 <= int(d) < n: torch.cuda.reset_peak_memory_stats(int(d))def sync_all(devices): """ 同步指定 GPU:等待 GPU 上已经提交的 CUDA 运算全部执行完成。 CUDA 默认是异步执行的: - Python 代码可能继续往下走 - GPU 上的计算与显存分配/释放可能还没完成 如果不 synchronize 就立刻读取 max_memory_allocated, 可能读到偏小/不稳定的峰值。 参数: devices: List[int],需要同步的 GPU device id 列表 """ for d in devices: torch.cuda.synchronize(d)def report_peak_allocated(tag: str, devices): """ 打印指定 GPU 的“峰值实际分配显存”(peak_allocated)。 peak_allocated 的含义: 从最近一次 reset_peak_memory_stats() 之后开始计算, PyTorch 在该 GPU 上“实际分配给张量/中间结果”的显存占用的最大值。 注意: - 这是 PyTorch 统计的“分配给张量”的峰值,不包含驱动/其他进程占用的显存。 - 多卡情况下,这里也会打印所有 GPU 峰值的求和(ALL GPUs SUM)。 参数: tag: 本次统计的阶段标签(例如“模型加载阶段”“生成阶段”) devices: List[int],需要统计的 GPU device id 列表 """ print(f"\n==== {tag} 峰值显存(peak_allocated) ====") total = 0 for d in devices: peak = torch.cuda.max_memory_allocated(d) total += peak print(f"GPU {d}: peak_allocated = {format_gb(peak)}") if len(devices) > 1: print(f"ALL GPUs SUM: peak_allocated = {format_gb(total)}")然后我们可以将其加入到我们的代码中进行使用,每一次都需要先清空再进行指定:
def main(): model_path = r"D:\微调与部署\qwen" devices = get_cuda_devices() ifnot devices: print("当前环境未检测到 CUDA GPU,无法统计显存峰值。") return # ===== 1) 模型加载阶段峰值(含权重搬到 GPU)===== reset_peaks(devices) tokenizer = AutoTokenizer.from_pretrained( pretrained_model_name_or_path=model_path, use_fast=True, local_files_only=True ) model = AutoModelForCausalLM.from_pretrained( pretrained_model_name_or_path=model_path, trust_remote_code=True, device_map="auto", dtype=torch.float32, ) sync_all(devices) report_peak_allocated("模型加载阶段", devices) # ===== 2) 生成阶段峰值(更贴近推理峰值:含激活/KV cache)===== reset_peaks(devices) user_prompt = "你是谁?" messages = [{"role": "user", "content": user_prompt}] text = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True, enable_thinking=False, ) inputs = tokenizer([text], return_tensors="pt").to(model.device) with torch.inference_mode(): generated = model.generate( **inputs, max_new_tokens=60, ) sync_all(devices) report_peak_allocated("生成阶段(含 KV Cache)", devices) # 输出答案 new_tokens = generated[0][inputs["input_ids"].shape[1]:] answer = tokenizer.decode(new_tokens, skip_special_tokens=True).strip() print("\n=== 模型回答 ===") print(answer)if __name__ == "__main__": main()然后当我们的 dtype 设置为 float 32 时,可以看到此时的峰值显存为 2.808 GB,所以很多时候说 0.6B 的模型只要有 3GB 显存的显卡就能跑:
==== 模型加载阶段 峰值显存(peak_allocated) ====GPU 0: peak_allocated = 2.808 GB==== 生成阶段(含 KV Cache) 峰值显存(peak_allocated) ====GPU 0: peak_allocated = 2.827 GB=== 模型回答 ===我是你的智能助手,我是你可以随时联系我的朋友。你可以向我提问,或者分享一些事情,我会尽力帮助你。有什么我可以帮你的吗?当我们将值调整为 float16 时,其峰值显存就会降到 1.5 GB 左右:
==== 模型加载阶段 峰值显存(peak_allocated) ====GPU 0: peak_allocated = 1.408 GB==== 生成阶段(含 KV Cache) 峰值显存(peak_allocated) ====GPU 0: peak_allocated = 1.421 GB=== 模型回答 ===我是你的虚拟助手,我由AI模型生成,旨在帮助你解决问题、提供支持或分享知识。如果你有任何问题或需要帮助,请随时告诉我!类似的,调整成 bfloat16 的话,显存消耗和 float16 是非常接近的,但会低一点点:
==== 模型加载阶段 峰值显存(peak_allocated) ====GPU 0: peak_allocated = 1.401 GB==== 生成阶段(含 KV Cache) 峰值显存(peak_allocated) ====GPU 0: peak_allocated = 1.420 GB=== 模型回答 ===我是你的虚拟助手,我将尽我所能为你提供帮助和支持。有什么可以帮助你的吗?所以可以看出,在显存消耗方面,fp32 确实会比 fp16 或 bf16 要高一倍左右。并且在对一些问题的回复上结果差别并不是很大。
小结
总的来说,FP32、FP16 和 BF16 都属于深度学习中常见的浮点数表示格式,它们之间最核心的区别在于数值范围、表示精度以及显存开销。其中,FP32精度最高、数值范围也较大,计算最稳定,但显存占用最多;FP16在显存占用上更有优势,计算效率也更高,但由于指数位较少,动态范围较小,更容易出现上溢或下溢问题;而BF16虽然尾数位更少、精度略低于 FP16,但由于其指数位与 FP32 一致,因此拥有接近 FP32 的动态范围,在大模型训练与推理中通常表现出更好的数值稳定性。
因此,在实际应用中,这三者并不存在绝对的“谁更好”,而是要根据具体场景进行选择:如果更关注计算稳定性,可以优先考虑 FP32;如果希望在有限显存下获得更高效率,则可以考虑 FP16;而如果硬件支持,并且希望兼顾低显存占用与训练稳定性,那么 BF16 往往是更合适的选择。理解这三种数据格式的差异,有助于我们在后续进行模型训练、推理优化与量化部署时,做出更加合理的技术选择。
bitsandbytes 参数详解
除了通过dtype调整浮点精度之外,Huggingface 官方还比较推荐使用bitsandbytes来实现更进一步的低比特量化。与float16、bfloat16这种“降低浮点精度”的方式不同,bitsandbytes更像是在模型加载阶段,直接把权重压缩到更低的表示形式,例如8 位整数(int8)。
这样做的最大好处就是显存占用会进一步下降,从而让原本无法在本地运行的大模型,也有机会在有限显存的设备上完成部署。
8bit 量化
与前面几种浮点数不同,在 bitsandbytes 中使用的 8bit 量化方式是 INT8 量化。INT8 属于8 位整数(8-bit Integer)表示,也就是说,每个数值只使用8 个比特来存储,总共能够表示 个不同的离散数值。比如这里显示的 10001001 其实就等于值 137。
通常在有符号整数的情况下,INT8 的取值范围为-128 到 127。本质上就是把第一个比特变为负数,其他都保持正数。比如这里同样是 10001001,那得到的值就是 -119 了。
相比 FP32 每个数需要 32 位来表示,INT8 只需要 8 位,因此单从存储角度来看,理论上显存占用可以降到原来的1/4。
不过,INT8 和 FP16、BF16 最大的不同在于它不再使用浮点方式直接表示小数,而是使用有限的整数区间去近似原本连续的浮点数值。这意味着,原始模型中的权重或激活值不能直接原样存成 INT8,而是需要先经过一个“映射”过程,把浮点数压缩到 INT8 的整数范围中;在真正计算时,再根据对应的缩放参数将其近似还原。这个过程其实就是量化最核心的思,即用更少的比特去近似表示原本更精细的数值。
因此,INT8 的优势非常明显,它能够大幅降低模型存储成本,并且在许多硬件平台上还能带来更高的推理效率,所以在大模型部署中非常常见。尤其是在推理场景下,INT8 往往是一种兼顾性能、显存和效果的平衡方案。
当然,它的代价也同样存在,那就是由于整数表示的离散程度远高于浮点数,模型在量化后会不可避免地产生一定的量化误差。如果这种误差较小,那么模型整体效果通常仍能保持较好;但如果压缩得过于激进,或者某些层对精度特别敏感,就可能导致输出质量下降。
假如我们在模型推理中要使用bitsandbytes的量化,我们需要先下载该库:
pip install bitsandbytes在 bitsandbytes 中使用 INT8 量化时,通常通过 BitsAndBytesConfig(load_in_8bit=True) 来完成配置,并在 from_pretrained() 加载模型时通过 quantization_config 参数传入。这样模型会先以 fp16 的方式进行加载,然后再在推理时进行量化,从而在大幅降低推理显存占用的同时,尽量保留关键计算部分的精度:
from transformers import AutoModelForCausalLM, **BitsAndBytesConfig**quant_config = BitsAndBytesConfig(**load_in_8bit=True**)# 不需要在写入 dtype 参数了model = AutoModelForCausalLM.from_pretrained( model_path, **quantization_config=quant_config,** device_map="auto", local_files_only=True, trust_remote_code=True)除了最基础的load_in_8bit=True,官方还提供了一些常见配置参数:
- 动态平衡 CPU 和 GPU:llm_int8_enable_fp32_cpu_offload
如果显存还是不够,可以启用CPU offload。我们可以使用 llm_int8_enable_fp32_cpu_offload=True 来进行实现:
from transformers import BitsAndBytesConfigquant_config = BitsAndBytesConfig( load_in_8bit=True, llm_int8_enable_fp32_cpu_offload=True)这个参数的含义是允许部分权重放到 CPU 上,并且这些被 offload 到 CPU 的权重会保持 FP32,而不是 8-bit。 这适合显存比较紧张、但又想尽量跑更大模型的场景。
- 跳过某些模块不做 INT8: llm_int8_skip_modules
有些模型的某些层对量化比较敏感,这时可以跳过指定模块:
quant_config = BitsAndBytesConfig( load_in_8bit=True, llm_int8_skip_modules=["lm_head"])这表示例如 lm_head 不参与 8-bit 量化,而保留原精度。这个配置属于进阶调参项,通常在模型兼容性或效果调优时使用。Transformers 的量化配置类中支持这类参数。
需要注意的是,官方文档强调,8-bit 权重本身不能像普通全精度权重那样直接训练;但你可以在其上训练额外参数,比如 adapter / LoRA 这类附加参数。
4bit 量化
4-bit 量化是进一步压缩模型权重的一种低比特方案。在 bitsandbytes 中,它通常并不是简单意义上的普通 int4,而是基于 FP4 或 NF4 等 4-bit 量化类型来实现。其中 NF4 还是 QLoRA 中非常常见的一种数据类型。相比 8-bit 量化,4-bit 能带来更激进的显存节省效果,因此在显存非常有限的情况下尤其有价值,但与此同时也往往意味着:
- 对模型效果的影响可能更明显;
- 使用方式和配置通常更复杂一些;
- 对底层库、量化配置以及硬件环境的要求也可能更高。
尽管如此,4-bit 量化依然是当前大模型轻量化中的重要方案,尤其是在 QLoRA 和本地低成本部署场景中应用非常广泛。官方文档中也指出,bitsandbytes 的 4-bit 线性层是 QLoRA 方案的重要基础。这部分内容将在后续 LoRA 微调章节会进一步讲解。
在实操中,最常见的写法就是通过 BitsAndBytesConfig(load_in_4bit=True) 在模型加载时,把线性层替换成 bitsandbytes 的 4-bit 量化层:
from transformers import AutoModelForCausalLM, BitsAndBytesConfig**quant_config = BitsAndBytesConfig(load_in_4bit=True)**# 不需要在写入 dtype 参数了model = AutoModelForCausalLM.from_pretrained( model_path, **quantization_config=quant_config,** device_map="auto")但实际使用时,通常不会只写 load_in_4bit=True,而是配合 bnb_4bit_quant_type、bnb_4bit_compute_dtype、bnb_4bit_use_double_quant 等参数进一步控制量化格式与计算精度。官方文档中把这套 4-bit 路线主要放在 QLoRA 语境下介绍,也就是“模型 4-bit 量化 + LoRA 微调”:
import torchfrom transformers import AutoModelForCausalLM, BitsAndBytesConfigquant_config = BitsAndBytesConfig( load_in_4bit=True, # 开启 4bit 量化 bnb_4bit_quant_type="nf4", # 量化类型:nf4 / fp4 bnb_4bit_compute_dtype=torch.bfloat16, # 计算时使用 bf16 bnb_4bit_use_double_quant=True # 开启双重量化)model = AutoModelForCausalLM.from_pretrained( model_path, quantization_config=quant_config, device_map="auto", local_files_only=True, trust_remote_code=True)bnb_4bit_quant_type:
这个参数决定 4-bit 的量化数据类型。官方支持两种:
其中,官方文档特别提到 NF4 是一种更适合“权重接近正态分布”场景的 4-bit 数据类型,因此在 QLoRA 场景里很常见。
- NF4(NormalFloat4):一种专门为接近正态分布的权重数据设计的 4-bit 量化数据类型,通常在 QLoRA 场景下表现更好。
- FP4(Float4):一种标准的 4-bit 浮点量化格式。
bnb_4bit_compute_dtype:
这个参数控制的是,虽然权重是 4-bit 存储,但实际计算时用什么精度来算是可以自行来进行设定的。比如可以设成:
官方文档说明,这个计算精度可以和输入类型不同,例如输入可能是 FP32,但计算可以设成 BF16 来提速。
- torch.float32
- torch.float16
- torch.bfloat16
bnb_4bit_use_double_quant:
这个参数表示开启 nested quantization / double quantization(双重量化),也就是对第一次量化得到的一些量化常数再次量化,以进一步节省内存。
虽然对于 4bit 或 8bit 量化而言,其能够大大节省显存的消耗,但是推理速度上,量化后可能反而会更慢,主要原因是推理时还会有反量化、混合精度计算、离群值处理等额外开销,而 4bit 比 8bit 更激进,因此常常又会更慢一些。所以假如要速度快又节省显存的话,比较推荐使用 FP16 或 BF16 的方案会更合适一些。
显存消耗对比
那我们可以和前面 fp32,fp16 和 bf16 一样去看一下 8bit 量化和 4bit 量化后的显存消耗。这里只需要结合前面 AI 写好的函数将代码改造成下面这样即可:
def main(): model_path = r"D:\微调与部署\qwen" devices = get_cuda_devices() ifnot devices: print("当前环境未检测到 CUDA GPU,无法统计显存峰值。") return # ===== 1) 模型加载阶段峰值(含权重搬到 GPU)===== reset_peaks(devices) tokenizer = AutoTokenizer.from_pretrained(pretrained_model_name_or_path=model_path, use_fast=True,local_files_only=True) quant_config = BitsAndBytesConfig(load_in_8bit=True) # 不需要在写入 dtype 参数了 model = AutoModelForCausalLM.from_pretrained( model_path, quantization_config=quant_config, device_map="auto", local_files_only=True, trust_remote_code=True ) sync_all(devices) report_peak_allocated("模型加载阶段", devices) # ===== 2) 生成阶段峰值(更贴近推理峰值:含激活/KV cache)===== reset_peaks(devices) user_prompt = "你是谁?" messages = [{"role": "user", "content": user_prompt}] # 关键:用 Qwen3 的 chat template 生成“模型真正想要的输入文本” text = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True, enable_thinking=False, # 先关闭 thinking,输出更直接 ) inputs = tokenizer([text], return_tensors="pt").to(model.device) with torch.inference_mode(): generated = model.generate( **inputs, max_new_tokens=60, ) sync_all(devices) report_peak_allocated("生成阶段(含 KV Cache)", devices) new_tokens = generated[0][inputs["input_ids"].shape[1]:] answer = tokenizer.decode(new_tokens, skip_special_tokens=True).strip() print("\n=== 模型回答 ===") print(answer)if __name__ == "__main__": main()此时我们使用 8bit 量化的情况下,虽然模型加载阶段显存使用和 16bit 的差别不大,但是在推理生成阶段还是降低了一倍:
==== 模型加载阶段 峰值显存(peak_allocated) ====GPU 0: peak_allocated = 1.373 GB==== 生成阶段(含 KV Cache) 峰值显存(peak_allocated) ====GPU 0: peak_allocated = 1.056 GB=== 模型回答 ===我是一个AI助手,我帮助你解决问题和提供支持。你可以告诉我你需要什么帮助,我会尽力为你服务。这个在 4bit 量化的时候就更明显了,我们只需要把 quant_config 调整一下:
quant_config = BitsAndBytesConfig( load_in_4bit=True, # 开启 4bit 量化 bnb_4bit_quant_type="nf4", # 量化类型:nf4 / fp4 bnb_4bit_compute_dtype=torch.bfloat16, # 计算时使用 bf16 bnb_4bit_use_double_quant=True # 开启双重量化 )虽然模型加载阶段的峰值显存也是 1.4 GB 左右,但是推理生成阶段直接降到了 0.822:
==== 模型加载阶段 峰值显存(peak_allocated) ====GPU 0: peak_allocated = 1.377 GB==== 生成阶段(含 KV Cache) 峰值显存(peak_allocated) ====GPU 0: peak_allocated = 0.822 GB=== 模型回答 ===我是AI助手,我是一台基于大语言模型训练出来的智能助手。我能够帮助你回答问题、提供信息、进行对话,并且支持多语言交流。如果你有任何问题,我非常乐意为你提供帮助!从这里我们也可以看出,4bit 和 8bit 量化主要是在推理生成阶段大幅度降低了显存的消耗,能够让我们在更小的模型中进行推理。
总结
本节内容围绕大模型量化与精度控制展开,核心目的是帮助大家理解:当本地部署大模型时,面对显存不足的问题,并不一定只能依赖“换更大的显卡”来解决,更常见、也更具工程价值的思路,是通过降低数值精度或使用低比特量化,在有限硬件条件下让模型尽可能顺利地运行起来。
首先,从理论层面来看,我们明确了量化的本质:它不是改变模型学到的知识,而是改变这些知识的存储与计算方式。通过将原本高精度的浮点表示压缩到更低精度的数据格式中,我们可以有效减少显存占用和内存带宽压力,但与此同时也必须接受一定程度的量化误差。因此,量化本质上始终是在**“资源节省”与“效果保真”**之间寻找平衡。
在具体原理上,我们介绍了几组重要概念:
- 对称量化与非对称量化:前者实现更简单、计算更高效,后者对数据分布的适应性更强;
- 权重量化与激活量化:前者更常见、更容易落地,后者更复杂,通常只在极致优化场景中才重点考虑;
- 训练后量化(PTQ)与量化感知训练(QAT):前者更适合部署阶段直接使用,后者则更适合对低比特性能有更高要求的训练场景。
在实际工程中,我们又从两条最常见的优化路径展开了实操说明。
第一条路径是通过dtype控制模型加载精度,也就是在FP32、FP16、BF16之间进行选择。这里的关键不只是“谁占显存更少”,更重要的是理解它们在动态范围、表示精度和数值稳定性上的差异:
- FP32最稳定、最准确,但显存消耗最大,更适合做基准测试或排查数值问题;
- FP16显存占用更低,推理速度潜力更高,但数值范围较小,更容易出现溢出和下溢;
- BF16在显存上与 FP16 相近,但由于拥有和 FP32 一样的指数位,因此往往能提供更好的数值稳定性,是当前很多大模型推理和训练中非常值得优先尝试的精度方案。
第二条路径则是使用bitsandbytes做更进一步的低比特量化,也就是通过8bit / 4bit的方式进一步压缩权重存储。其中:
- 8bit 量化是一种比较常见的折中方案,通常能在显存节省和模型效果之间取得较平衡的结果;
- 4bit 量化更激进,显存节省效果更明显,但同时也意味着更高的量化误差风险、更复杂的配置方式,以及更高的兼容性要求。
尤其需要强调的是,显存更低并不一定代表推理更快。在真实部署中,4bit 和 8bit 量化虽然能明显降低模型加载时的显存占用,但由于还会涉及反量化、混合精度计算、离群值处理等额外开销,因此推理速度有时反而可能比 FP16 / BF16 更慢。也正因为如此,在很多本地部署场景下:
- 如果你的主要目标是让模型先跑起来、尽量省显存,那么 8bit / 4bit 往往非常有价值;
- 如果你的目标是兼顾速度与较好的稳定性,那么 FP16 或 BF16 通常会是更实用的选择。
总的来说,这一部分内容想传达的核心思想可以归结为一句话:
量化与精度控制并不是“有没有必要学”的附加知识,而是本地部署大模型时最基础、最关键的工程能力之一。
只有真正理解了不同数值格式和量化方式的原理、优缺点与适用场景,我们在面对显存不足、速度不理想、效果下降等实际部署问题时,才能不只是“试着改参数”,而是能够有依据地做出合理的技术选择。
后续当我们继续进入 LoRA、QLoRA、模型微调与更大规模模型部署时,这部分关于FP32 / FP16 / BF16 / INT8 / INT4的理解,也会成为非常重要的前置基础。
说真的,这两年看着身边一个个搞Java、C++、前端、数据、架构的开始卷大模型,挺唏嘘的。大家最开始都是写接口、搞Spring Boot、连数据库、配Redis,稳稳当当过日子。
结果GPT、DeepSeek火了之后,整条线上的人都开始有点慌了,大家都在想:“我是不是要学大模型,不然这饭碗还能保多久?”
我先给出最直接的答案:一定要把现有的技术和大模型结合起来,而不是抛弃你们现有技术!掌握AI能力的Java工程师比纯Java岗要吃香的多。
即使现在裁员、降薪、团队解散的比比皆是……但后续的趋势一定是AI应用落地!大模型方向才是实现职业升级、提升薪资待遇的绝佳机遇!
这绝非空谈。数据说话
2025年的最后一个月,脉脉高聘发布了《2025年度人才迁徙报告》,披露了2025年前10个月的招聘市场现状。
AI领域的人才需求呈现出极为迫切的“井喷”态势
2025年前10个月,新发AI岗位量同比增长543%,9月单月同比增幅超11倍。同时,在薪资方面,AI领域也显著领先。其中,月薪排名前20的高薪岗位平均月薪均超过6万元,而这些席位大部分被AI研发岗占据。
与此相对应,市场为AI人才支付了显著的溢价:算法工程师中,专攻AIGC方向的岗位平均薪资较普通算法工程师高出近18%;产品经理岗位中,AI方向的产品经理薪资也领先约20%。
当你意识到“技术+AI”是个人突围的最佳路径时,整个就业市场的数据也印证了同一个事实:AI大模型正成为高薪机会的最大源头。
最后
我在一线科技企业深耕十二载,见证过太多因技术卡位而跃迁的案例。那些率先拥抱 AI 的同事,早已在效率与薪资上形成代际优势,我意识到有很多经验和知识值得分享给大家,也可以通过我们的能力和经验解答大家在大模型的学习中的很多困惑。
我整理出这套 AI 大模型突围资料包【允许白嫖】:
- ✅从入门到精通的全套视频教程
- ✅AI大模型学习路线图(0基础到项目实战仅需90天)
- ✅大模型书籍与技术文档PDF
- ✅各大厂大模型面试题目详解
- ✅640套AI大模型报告合集
- ✅大模型入门实战训练
这份完整版的大模型 AI 学习和面试资料已经上传CSDN,朋友们如果需要可以微信扫描下方CSDN官方认证二维码免费领取【保证100%免费】
①从入门到精通的全套视频教程
包含提示词工程、RAG、Agent等技术点
② AI大模型学习路线图(0基础到项目实战仅需90天)
全过程AI大模型学习路线
③学习电子书籍和技术文档
市面上的大模型书籍确实太多了,这些是我精选出来的
④各大厂大模型面试题目详解
⑤640套AI大模型报告合集
⑥大模型入门实战训练
👉获取方式:
有需要的小伙伴,可以保存图片到wx扫描二v码免费领取【保证100%免费】🆓