Skip to Content

推理的工程挑战

把一个训练好的模型部署到生产环境,核心关注三个指标:延迟(Latency)吞吐量(Throughput)显存(VRAM)

延迟是从请求到达到第一个 token(或完整结果)返回的时间,对用户体验直接可见。对话场景通常要求 P99 延迟在 1 秒以内,首 token 延迟(TTFT, Time to First Token)在 500ms 以内。

吞吐量是单位时间内能处理的 token 数量(tokens/second)或请求数量(QPS)。高吞吐量意味着更低的单请求成本,对 ToB 服务尤其重要。

显存决定了能部署多大的模型,以及批处理的上限。显存不足会触发 OOM(Out of Memory)导致服务崩溃,或强迫使用更小的 batch size,降低吞吐量。

三者之间存在权衡:

  • 增大 batch size → 吞吐量上升,但延迟也上升(每个请求要等攒够一批才处理)
  • 量化到低精度 → 显存减少,吞吐量通常上升,但精度略有损失
  • 增加并发流 → 吞吐量上升,但竞争显存,可能反而增加延迟

工程上不存在”最优解”,需要根据具体业务 SLA 和硬件条件做权衡。

模型加载与显存管理

精度格式

模型权重有几种精度格式,影响显存占用和计算速度:

格式位宽显存(以 1B 参数为例)特点
fp3232位浮点4GB训练默认,精度最高
fp1616位浮点2GB推理常用,大多数 GPU 支持
bf1616位脑浮点2GB更大的指数范围,A100/H100 原生支持,训练更稳定
INT88位整数1GB量化,精度小幅下降
INT44位整数0.5GB激进量化,精度损失明显但通常可接受

fp16bf16 的显存占用相同,区别在数值范围:fp16 数值范围较小,大模型推理时容易出现数值溢出;bf16 的指数位更多,数值范围与 fp32 相同,在 Ampere 架构以上的 GPU(A100、RTX 3090)上是更好的选择。

加载时通过 torch_dtype 指定精度:

from transformers import AutoModelForCausalLM import torch model = AutoModelForCausalLM.from_pretrained( "gpt2", torch_dtype=torch.float16, # 或 torch.bfloat16 )

device_map=“auto”

device_map="auto"accelerate 库提供的自动设备分配功能。它分析模型各层的参数量,按显存大小把模型层分配到可用的设备上,支持多 GPU 甚至 CPU-offload(把放不下的层卸载到内存)。

model = AutoModelForCausalLM.from_pretrained( "meta-llama/Llama-2-7b-hf", torch_dtype=torch.float16, device_map="auto", # 自动分配到可用 GPU,不够则溢出到 CPU )

device_map="auto" 适合快速验证阶段。生产部署时建议明确指定 device_map 的分配策略,或直接使用 vLLM 等专业推理框架,避免跨设备通信带来的性能损耗。

多 GPU 推理

两种方式:

模型并行(Model Parallelism):把模型的不同层分配到不同 GPU,device_map="auto" 默认就是这种方式。适合单个模型放不进一张显卡的场景,但 GPU 间通信有开销。

张量并行(Tensor Parallelism):把同一层的权重矩阵按列或行切分到多 GPU,需要专门的框架支持(vLLM、Megatron-LM)。通信开销更大,但计算并行度更高,适合追求极致吞吐量的场景。

量化

原理简介

量化(Quantization)把浮点数权重压缩成低比特整数表示。以 INT8 量化为例:

浮点权重值 w ∈ [w_min, w_max] 映射到整数 q ∈ [-128, 127] 量化:q = round(w / scale + zero_point) 反量化:w ≈ scale * (q - zero_point)

scalezero_point 是量化参数,每个 tensor 或每行/列存储一套,推理时先反量化再做矩阵乘法(Weight-only quantization),或直接用整数运算(全整数量化)。

精度损失来源于量化误差(w 和反量化后的近似值之差),通常在 0.5% 以内,大多数任务可以接受。

bitsandbytes 4-bit 量化

bitsandbytes 是目前最易用的量化库,支持 INT8 和 NF4(4-bit Normal Float)量化:

from transformers import AutoModelForCausalLM, BitsAndBytesConfig import torch # load_in_4bit=True 背后做了什么: # 1. 将模型权重从 fp16/fp32 量化为 NF4 格式(4 bit) # 2. 计算时反量化为 fp16 再做矩阵乘法(compute_dtype) # 3. 激活值仍然以 fp16 存储 # 整体效果:显存减少约 75%,推理速度接近 fp16 bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_compute_dtype=torch.float16, # 计算时临时转回 fp16 bnb_4bit_use_double_quant=True, # 对量化参数再做一次量化,进一步省显存 bnb_4bit_quant_type="nf4", # NF4 比 fp4 精度更高 ) model = AutoModelForCausalLM.from_pretrained( "facebook/opt-125m", quantization_config=bnb_config, device_map="auto", )

注意:bitsandbytes 的量化功能需要 CUDA,CPU-only 环境无法使用。

量化对精度的影响

在主流基准测试(MMLU、HellaSwag)上,4-bit 量化通常导致 1-3% 的性能下降,对大多数应用场景可以接受。

精度损失与模型大小有关:参数量越大的模型,量化对精度的影响越小。7B 参数模型 4-bit 量化的精度损失远小于 1B 参数模型。这是因为大模型有更强的”冗余”,低精度表示仍然能捕获主要信息。

KV Cache

为什么需要 KV Cache

Decoder 模型(GPT、LLaMA 等)生成文本是逐 token 的自回归过程:每次前向传播只生成一个新 token,然后把这个 token 拼到输入末尾,再做下一次前向传播。

问题在于,每次前向传播都要重新计算所有已生成 token 的 K(Key)和 V(Value)矩阵。如果已生成 100 个 token,第 101 次前向传播就要做 100 次”没有新信息的重复计算”。

KV Cache 如何减少重复计算

KV Cache 的解决方案是:把每次前向传播中计算出的 K 和 V 矩阵缓存起来,下次只计算新 token 的 K 和 V,再和缓存的历史 K、V 拼接后做 attention。

无缓存(第 n+1 步):计算 n+1 个 token 的 K、V,做全量 attention 有缓存(第 n+1 步):只计算第 n+1 个 token 的 K、V,与缓存的 n 组 K、V 拼接后做 attention

KV Cache 避免了对历史 token 重复计算 K、V 的线性投影。无缓存时,生成第 t 步需要对所有 t 个 token 重新计算一遍 K 和 V;有了缓存,每步只计算新 token 的 K/V,历史 token 的结果从缓存中直接读取。生成 n 个 token 的 KV 投影总次数从 $O(n^2)$ 降为 $O(n)$,长序列生成时提速显著。Attention 点积计算本身(Query 与所有历史 Key 做相似度)仍是 $O(n)$(每步),但这部分开销通常小于 KV 投影。

代价是显存:每个 token、每个 attention 头都需要存储 K 和 V,KV Cache 的显存占用随序列长度线性增长。以 LLaMA-7B 为例,生成 2048 token 的 KV Cache 约占用 1GB 显存。

HuggingFace 的 generate() 默认启用 KV Cache,通过 use_cache=True(默认值)控制。

批处理

Dynamic Batching

服务端接收到请求后,不必等每个请求都有结果才返回,可以把多个并发请求打包成一个 batch 一起处理,提高 GPU 利用率。这就是 Dynamic Batching。

静态 batching 要求 batch 内所有序列长度相同,不够则 padding 到最长。Dynamic batching 则是随时把可以合批的请求打包,无需等待固定 batch size 凑满。

Padding 和 Attention Mask

批量推理时,不同长度的序列需要 padding 到相同长度。padding token 不应该参与 attention 计算,这由 attention_mask 控制:

# attention_mask: 1 表示真实 token,0 表示 padding token # tokenizer 会自动生成 inputs = tokenizer( texts, padding=True, # 自动 padding 到 batch 内最长序列 truncation=True, max_length=512, return_tensors="pt", ) # inputs["attention_mask"] 的形状:(batch_size, seq_len) # 值为 1 的位置参与 attention,值为 0 的位置被 mask 掉 outputs = model(**inputs)

padding 位置的选择也有讲究:

  • 右 padding(默认):在序列末尾补零,适合 encoder 模型(BERT)
  • 左 padding:在序列开头补零,适合 decoder 模型(GPT)——因为生成时从序列末尾开始,右侧补零会导致生成错误

生产级推理服务

vLLM:PagedAttention

vLLM 是目前生产环境最常用的 LLM 推理框架,核心创新是 PagedAttention

传统推理框架为每个请求预分配一整块连续的 KV Cache 显存(按最大序列长度),导致大量碎片化浪费。PagedAttention 借鉴操作系统的虚拟内存管理思想:把 KV Cache 分成固定大小的”页”(page),按需分配,用页表管理物理显存到逻辑序列的映射。结果是显存利用率从约 60% 提升到 90% 以上,吞吐量大幅提升。

vLLM 的使用方式:

from vllm import LLM, SamplingParams llm = LLM(model="meta-llama/Llama-2-7b-hf") sampling_params = SamplingParams(temperature=0.7, max_tokens=256) outputs = llm.generate(["Tell me about transformers."], sampling_params) print(outputs[0].outputs[0].text)

vLLM 还支持兼容 OpenAI API 的服务端模式:

python -m vllm.entrypoints.openai.api_server \ --model meta-llama/Llama-2-7b-hf \ --port 8000

启动后就可以用 OpenAI SDK 直接访问,迁移成本极低。

Ollama:本地部署 LLM

Ollama 是面向本地部署场景的工具,安装简单,支持 Mac/Linux/Windows:

# 安装 Ollama(Linux) curl -fsSL https://ollama.com/install.sh | sh # 下载并运行模型(自动处理量化和显存分配) ollama run llama3 # Python 调用 import ollama response = ollama.chat(model='llama3', messages=[ {'role': 'user', 'content': 'What is a transformer model?'} ]) print(response['message']['content'])

Ollama 自动做 4-bit 量化,7B 模型在 16GB 内存的 MacBook 上就能流畅运行。

选型建议

场景推荐方案
本地开发、原型验证Ollama
生产服务、高并发vLLM
中等规模、快速上线HuggingFace TGI(Text Generation Inference)
只需要 embeddingsentence-transformers + FastAPI

性能调优工具

测量延迟和吞吐量

import time import torch def benchmark(model, inputs, num_runs=100, warmup=10): """ 测量模型推理的延迟和吞吐量。 warmup:前几次推理用于 JIT 编译和 GPU 预热,不计入统计。 """ # GPU 操作是异步的,需要 synchronize() 确保计时准确 if torch.cuda.is_available(): torch.cuda.synchronize() # 预热 for _ in range(warmup): with torch.no_grad(): model(**inputs) if torch.cuda.is_available(): torch.cuda.synchronize() # 正式测量 latencies = [] for _ in range(num_runs): start = time.perf_counter() with torch.no_grad(): outputs = model(**inputs) if torch.cuda.is_available(): torch.cuda.synchronize() end = time.perf_counter() latencies.append((end - start) * 1000) # 转换为毫秒 import numpy as np return { "mean_ms": np.mean(latencies), "p50_ms": np.percentile(latencies, 50), "p99_ms": np.percentile(latencies, 99), }

测量时常见的坑:

  • 忘记 torch.cuda.synchronize():GPU 操作异步,不同步会导致计时提前结束,测出来的延迟偏低
  • 没有 warmup:第一次推理包含 JIT 编译时间,不代表稳态性能
  • 只测平均延迟:P99 延迟才是用户体验的真实上界,尤其在有 GC 的 Python 环境中

显存分析

# 查看当前显存占用 print(f"已分配显存:{torch.cuda.memory_allocated() / 1024**2:.1f} MB") print(f"缓存显存: {torch.cuda.memory_reserved() / 1024**2:.1f} MB") # 峰值显存(从进程启动以来的最大值) print(f"峰值显存: {torch.cuda.max_memory_allocated() / 1024**2:.1f} MB") # 重置峰值统计(用于对比不同阶段的显存峰值) torch.cuda.reset_peak_memory_stats()

PyTorch Profiler 可以给出更细粒度的 op-level 分析,适合定位具体的显存瓶颈:

from torch.profiler import profile, ProfilerActivity with profile(activities=[ProfilerActivity.CUDA], profile_memory=True) as prof: with torch.no_grad(): model(**inputs) print(prof.key_averages().table(sort_by="cuda_memory_usage", row_limit=10))
Last updated on