☰
Transformers推理优化指南:从BERT到ONNX Runtime与量化部署
2026/10/18 19:08:14 网站建设 项目流程

最近帮一位做内容的团队优化他们的情感分析服务,模型用的是 Transformers 里的 BERT-base,服务器是 Ubuntu 20.04,GPU 暂时只有一张,业务方天天抱怨"单条结果要 1 秒多"。其实模型本身没什么问题,就是裸装裸跑,完全没做推理侧的优化。这篇文章就记录一下我是怎么从搭建环境开始,一步步把推理延迟降到可用水平的,主要是 CPU 和单 GPU 场景下的通用做法,适合正在学习或刚准备上线这类服务的开发者参考。

整个过程我会拆成几个阶段来讲:先聊动手前的选型判断,然后是环境搭建、基线测试、瓶颈分析,再到具体的优化手段和最终落地到 API 服务。每一步都会给出实际命令、参数和我在实践中踩过的坑,希望能帮你少走弯路。

1. 动手前先想清楚:模型选型、硬件底数、优化目标三位一体

很多人拿到任务第一反应是"先跑起来再说",这个思路在个人实验里没问题,但一旦涉及部署上线,前面省掉的思考都会在后面加倍还回来。我一般会在敲第一行命令前,先把三件事定下来。

1.1 模型选型:不是越大越好,也不是越小越好

Transformers 模型指的是一类基于自注意力机制的模型,Hugging Face 的 transformers 生态里集合了非常多的预训练权重,比如大家熟知的 BERT、RoBERTa、DistilBERT,以及生成类的 GPT、T5、BART 等等。选模型时我优先看任务类型和算力预算。

以文本分类任务为例,三种典型选择是:

  • BERT-base:通用性强,中文和英文都有大量微调好的权重,但模型参数量约 1.1 亿,推理延迟偏高。
  • DistilBERT:BERT 的蒸馏版本,参数量大约是 BERT 的 60%,速度通常能快 30% 到 40%,精度只下降一点点,是 CPU 部署的首选目标。
  • ALBERT:参数量理论值小,但实际推理速度有时反而不如 DistilBERT,因为它的 Embedding 分解引入了额外计算。

我个人的建议是:如果业务对精度要求极其苛刻,选 BERT 系列再上量化优化;其余大多数场景,直接从 DistilBERT 或其蒸馏版本开始,后续优化空间更大。

1.2 硬件底数:CPU 和 GPU 的优化思路完全不一样

Ubuntu 20.04 是最常见的服务器操作系统,GPU 驱动、CUDA、PyTorch 的环境配置都比较成熟。但我要强调的是,CPU 部署不代表"不专业",反而在成本和推理稳定性上更有优势:

  • 如果 QPS(每秒查询数)要求不高,延迟容忍在 500 毫秒以内,纯 CPU 加量化完全够用。
  • GPU 的优势在于高吞吐并发和超大 batch,但显存有限,需要合理设置 batch size。
  • 没有 GPU 的情况下,模型推理计算完全依赖 CPU,所以量化、算子融合、线程优化就变得尤其重要。

先想清楚你的业务到底是"低延迟单条"还是"高并发批量",这直接决定了后面优化手段的优先级。

1.3 优化目标:延迟、吞吐、成本,不可能三角

在我接触的绝大多数项目里,业务方的需求都可以归纳为三个指标:

指标含义常用工具
延迟(Latency)单条请求从进到出的耗时time、timeit、CUDA events
吞吐(Throughput)单位时间能处理的请求数QPS、TPS
成本(Cost)硬件投入、电力消耗、运维成本规格对比、TCO 估算

如果业务方告诉你"都想要",那基本意味着需要做组合方案:模型压缩 + 动态批处理 + 推理引擎优化。

2. Ubuntu 20.04 上的环境搭建:一套干净、可复现的安装路径

环境这块我踩过很多坑,尤其是 CUDA 和 PyTorch 的版本不对,会出现告警、报错甚至模型推理直接崩掉。这里分享一套我用了很久的安装路径。

2.1 Python 环境的隔离:别再用系统 Python 裸装包

我强烈建议先在 Ubuntu 20.04 上安装 Miniconda,原因很简单:深度学习库的依赖链非常复杂,直接往系统 Python 里装包,很容易把环境搞坏,而且无法多项目共存。

# 安装 Miniconda(假设服务器是 x86_64 架构) wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh bash Miniconda3-latest-Linux-x86_64.sh # 创建独立环境,指定 Python 版本 conda create -n deploy python=3.9 -y conda activate deploy

Python 版本的坑:transformers 在较新版本中逐步提升最低 Python 版本要求,但 3.9 在 Ubuntu 20.04 上兼容性最好,各种依赖库的预编译包也很齐全。

2.2 CUDA、cuDNN 与 PyTorch 的版本三角

PyTorch 是 Transformers 模型推理时最常见的底层框架。版本匹配是最容易出错的地方,配错了会直接导致模型推理时出现CUDA error: no kernel image is available for execution on the device。

这里有一个可以直接照抄的版本组合(适用于 2024 年后安装的稳定版):

# 先查看 GPU 驱动是否可用 nvidia-smi # 安装 PyTorch(以 CUDA 12.1 为例,注意 cuda-toolkit 需提前安装) pip install torch==2.3.1 torchvision==0.18.1 --index-url https://download.pytorch.org/whl/cu121

注意点:

  • nvidia-smi显示的 Driver Version 是驱动版本,它和 CUDA Toolkit 不完全是一回事,请务必先确认驱动版本足够新。
  • PyTorch 的 cu121 版本只依赖 CUDA 12.1 runtime,不是说你服务器装的是 CUDA 11.8 就一定不能跑,实际要看的是 PyTorch 自带的 CUDA 运行时对底层驱动的最低需求。
  • 目前常用的稳定组合是:Ubuntu 20.04 + 驱动 535 + CUDA 12.1 + PyTorch 2.3.1。

2.3 transformers 库安装与版本锁定

pip install transformers==4.41.2 accelerate==0.31.0 pip install sentencepiece # 处理 BERT 系列的分词

为什么必须加accelerate?因为很多较干净的大模型权重格式(执行起来需要更多显存或 requires 多卡)依赖它的调度,加装只有好处没有坏处。

另外,模型推理前的 tokenizer 也需要单独加载。如果环境无法访问外部网络,可以先将模型下载到本地目录再拷贝到服务器,下文会讲。

3. 从加载模型到跑通第一版基线:先让一切跑起来,再谈优化

很多教程会直接跳到"如何用 ONNX 推理",但我坚持认为先跑一个最朴素、最不优化的版本并测出基线,才是整个推理优化项目的关键第一步。因为不测基线,你后面做的任何优化都无法判断到底有没有效。

3.1 模型下载与本地缓存管理

Hugging Face 的 Hub 提供了非常方便的下载方式:

from transformers import AutoTokenizer, AutoModelForSequenceClassification model_id = "distilbert-base-uncased-finetuned-sst-2-english" # 这会在 ~/.cache/huggingface/ 下缓存模型文件 tokenizer = AutoTokenizer.from_pretrained(model_id) model = AutoModelForSequenceClassification.from_pretrained(model_id)

这里有个很实际的问题:在多个模型间来回切换时,缓存目录会越滚越大。我习惯设置环境变量:

export HF_HOME=/data/huggingface export TRANSFORMERS_CACHE=/data/huggingface/transformers

把模型缓存放到数据盘,避免系统盘被塞满。如果目标机器没有外网,就在有网的机器上用snapshot_download将仓库里的文件完整拉到本地,然后整个目录打包拷过去:

from huggingface_hub import snapshot_download snapshot_download(repo_id="distilbert-base-uncased-finetuned-sst-2-english", local_dir="./my_model")

3.2 最小推理脚本与静态输入长度

一个最简单的推理脚本长这样:

import torch import time from transformers import AutoTokenizer, AutoModelForSequenceClassification model_id = "./my_model" device = torch.device("cuda" if torch.cuda.is_available() else "cpu") tokenizer = AutoTokenizer.from_pretrained(model_id) model = AutoModelForSequenceClassification.from_pretrained(model_id).to(device) model.eval() # 关键:静态 pad 到固定长度,避免动态 shape 带来的额外计算 texts = [ "the movie is fantastic", "i would not recommend this product", ] inputs = tokenizer(texts, padding="max_length", max_length=128, truncation=True, return_tensors="pt") inputs = {k: v.to(device) for k, v in inputs.items()} with torch.no_grad(): outputs = model(**inputs) predictions = torch.softmax(outputs.logits, dim=-1) print(predictions)

这里我特意加了padding="max_length", max_length=128,原因后面优化章节会详细讲,简单说就是:固定输入长度能够减少动态 shape 判断,也为下一步 ONNX 导出和静态量化铺路。

3.3 建立基线:延迟与吞吐的初始数据

用下面的方式测试 1000 条文本的推理耗时会比较准确:

python benchmark.py

我习惯记录三个数据:

  • 单条延迟(P50、P99)
  • 吞吐(每秒处理的样本数)
  • 显存占用

以 DistilBERT-base + GPU 为例,我的测试结果一般是 batch=1 时单条约 8-12ms;但如果用 BERT-base,同样的单条会涨到 20-30ms。如果连这个数据都慢得离谱,说明环境有问题,先解决环境而不是盲目堆优化手段。

4. 推理慢在哪儿:花半小时做一次性能拆解

一旦基线数据不理想,问题就来了:到底慢在 tokenizer 前置处理、模型前向计算,还是后处理?凭感觉猜是最低效的优化方式,我习惯用工具把时间分布拆出来。

4.1 用time和CUDA events做阶段计时

CPU 端直接嵌套time.time():

import time t0 = time.time() inputs = tokenizer(texts, padding="max_length", max_length=128, truncation=True, return_tensors="pt") t1 = time.time() print(f"tokenizer: {t1-t0:.4f}s")

GPU 端注意,PyTorch 的 CUDA 操作是异步的,用 Python 的time测不准,必须用 CUDA 事件:

start = torch.cuda.Event(enable_timing=True) end = torch.cuda.Event(enable_timing=True) start.record() with torch.no_grad(): outputs = model(**inputs) end.record() torch.cuda.synchronize() print(f"model forward: {start.elapsed_time(end):.2f}ms")

4.2 三个最常见的性能瓶颈

在我优化过的项目里,推理慢通常不是模型本身多复杂,而是下面几个环节出了问题:

  • 动态 Padding 导致的计算浪费:每个 batch 里最长的文本是 500 个 token,其余 3 条都只有 30 个 token,框架会按 500 去计算 attention 矩阵,大量算力在空转。我第一次跑时单条延迟直接翻倍。
  • batch size 过小导致 GPU 利用率低:图形处理器有成千上万的运算核心,batch=1 时根本无法吃满算力。
  • GPU 和 CPU 之间的数据拷贝:输入要从内存搬到显存,输出要从显存搬回内存,频繁小量拷贝代价极高。

4.3 不要忽略 tokenizer 本身的耗时

你可能觉得分词很快,实际在中文长文本场景下,tokenizer 带来的 CPU 开销并不低。一个常见的坑是每一条请求都重新初始化分词器,或者每次请求都重新加载整个模型。正确的做法是:进程启动时加载一次,后续复用同一个 tokenizer 和同一个模型实例。

用这个思路,我把一次请求阶段计时的典型数据列一下(CPU 上的 BERT-base,单条 60 token):

阶段耗时占比说明
tokenizer15% - 25%python 侧逻辑复杂,会让 CPU 短时打满
模型 forward70% - 80%大头
后处理(softmax 等)5%占比小但可以合并进模型输出

头一次看到这个数据的人通常很惊讶:原来 tokenizer 这么慢。所以优化的第一步不一定是量化模型,而是先看有没有无谓的计算和重复初始化。

5. 优化手段逐个上:按性价比从高到低排列

这一节是全文的重点,我给出手头最常用、实测有效的优化手段,按"操作成本低、收益高"到"操作复杂、收益更高"排列。

5.1 动态量化:CPU 场景的第一选择

PyTorch 的量化有心酸历程,但对 CPU 推理来说,动态量化是最简单有效的一个操作。它只对nn.Linear层做量化,不需要额外的校准数据,一两行代码就能跑:

from transformers import AutoModelForSequenceClassification import torch model_id = "./my_model" model = AutoModelForSequenceClassification.from_pretrained(model_id) quantized_model = torch.quantization.quantize_dynamic( model, {torch.nn.Linear}, dtype=torch.qint8, )

这个过程不需要 GPU,也不需要真正的数据分布,模型大小直接砍到原来的约四分之一。以 BERT-base 在 CPU 上的推理为例,动态量化后速度通常提升 20% 到 40%,精度几乎无损。注意,量化好的模型也要保存下来,不能每次加载都重新量化:

torch.save(quantized_model.state_dict(), "./quantized_distilbert.pt")

但要明确一点:动态量化只优化了上文说的"矩阵乘法"部分,transformer 里还有大量 LayerNorm、GeLU 等操作依旧是浮点计算,所以单模型耗时不会到"打对折"的程度,但已经很可观了。

5.2 静态量化:需要校准数据,但收益更明显

静态量化在推理前预先计算好激活值的变化范围,推理过程中直接使用整数运算,省去了每层激活值的动态量化开销。它的关键是需要一段代表性的校准数据:

model.eval() # 准备好 fp32_prepared_model 和校准数据 model.qconfig = torch.ao.quantization.get_default_qconfig('fbgemm') torch.ao.quantization.prepare(model, inplace=True) # 循环若干 batch 校准 for batch in calibration_loader: model(batch) torch.ao.quantization.convert(model, inplace=True)

在部分模型上静态量化比动态量化再快约 20%,但调校过程会复杂一些。CPU 部署 + INT8 静态量化 + 算子融合,整体延迟可以比原始模型降低约 50% 以上。

5.3 ONNX Runtime 导出:模型无关的加速器

ONNX Runtime 也是 CPU 推理加速的核心方案。它的原理是将模型转换成计算图,并做算子融合、图优化和内存规划。Hugging Face 团队提供了官方的导出支持:

pip install optimum[onnxruntime]

然后在代码里只需要这样:

from optimum.onnxruntime import ORTModelForSequenceClassification from transformers import AutoTokenizer model_id = "./my_model" ort_model = ORTModelForSequenceClassification.from_pretrained(model_id, export=True) tokenizer = AutoTokenizer.from_pretrained(model_id)

ONNX Runtime 配合动态量化,是 CPU 部署中性价比极高的组合。我实测过 DistilBERT 在单核 CPU 上从 150ms 降到 80ms 左右,如果这还不够,可以考虑OrtModelForSequenceClassification的session_options里增加线程数:

import onnxruntime as ort sess_options = ort.SessionOptions() sess_options.intra_op_num_threads = 4 # 根据 vCPU 数调整 ort_model = ORTModelForSequenceClassification.from_pretrained( model_id, export=True, sess_options=sess_options, )

5.4 固定 batch 与 KV 缓存优化

对于生成类模型(如 GPT 类),Transformers 推理时需要通过自回归逐字生成,调参use_cache=True可以复用历史键值,避免重复计算,延迟能改善 30% 以上:

outputs = model.generate(**inputs, use_cache=True)

对于分类模型,则优先固定输入长度和 batch size。你会发现大多数推理引擎(如 ONNX Runtime)更喜欢固定 shape 的计算图,这也是我前面强调max_length=128的原因。

5.5 显存层面的优化:半精度推理

如果有 GPU,最简单的优化是使用torch.float16替代torch.float32:

model.half().to("cuda")

这样做的好处是显存占用减半、计算速度更快,代价是精度可能轻微下降。如果模型里有自定义层对半精度不友好,在做完.half()之后务必用几组数据复测,不能盲信之前的评估指标。

5.6 更激进的选择:模型蒸馏与剪枝

如果上面的优化达到瓶颈,就需要从模型结构层面动手了。蒸馏是拿大模型的输出做监督,训练一个小模型去逼近它的效果;剪枝则是去掉一些贡献不大的 attention head。这类方法通常需要重新训练或微调,不在本文"部署推理"的范围内,但是方向要了解。

6. 从优化完的模型到稳定的服务:API 封装与并发控制

模型优化只是第一段路,真正上线时还需要解决并发、排队、超时、预热、热更新这些问题。我用 FastAPI 做了一个比较轻量的封装,分享给你。

6.1 FastAPI 的基础服务实现

from fastapi import FastAPI, Request from pydantic import BaseModel import torch from transformers import AutoTokenizer app = FastAPI() device = torch.device("cuda" if torch.cuda.is_available() else "cpu") tokenizer = AutoTokenizer.from_pretrained("./my_model") # 假设是已经量化或 ONNX 导出后的模型,这里以普通模型为例 model = torch.load("./quantized_distilbert.pt", map_location=device) model.eval() class PredictRequest(BaseModel): text: str max_length: int = 128 @app.post("/predict") async def predict(req: PredictRequest, request: Request): inputs = tokenizer([req.text], padding="max_length", max_length=req.max_length, truncation=True, return_tensors="pt") inputs = {k: v.to(device) for k, v in inputs.items()} with torch.no_grad(): logits = model(**inputs).logits pred = torch.softmax(logits, dim=-1)[0].tolist() return {"probabilities": pred}

这里有几个很容易踩的坑:

  • 必须使用异步函数与同步推理的正确姿势:上面的代码把同步推理放进了 async 函数,虽然能跑,但在高并发下会阻塞事件循环。更稳妥的方式是用run_in_executor在线程池里执行,或者用队列把所有请求攒起来批处理。
  • 模型预热:服务启动后第一次推理会触发加载和缓存建立,冷启动延迟极高。我通常用lifespan在启动时先跑几条假数据:
    @app.on_event("startup") def warmup(): _ = tokenizer("warmup text", return_tensors="pt") with torch.no_grad(): model(input_ids, attention_mask)

6.2 批处理合并:提升吞吐的大杀器

单条请求推理是最高延迟、最低吞吐的方式。FastAPI 自身不带版本队列,我习惯引入一个简单的人工队列:

  • 请求进来后先入队列,不立刻推理;
  • 攒够 8 条请求或等待 20 毫秒,集中做一次 batch 推理;
  • 把结果按顺序回传。

这样 GPU 利用率明显提升,QPS 往往能提升 2 到 3 倍。实现上可以借助asyncio.Queue和条件变量,也可以直接用现成的scheduler库,比如nvidia-dali这种,但简单场景自己写队列完全够用。

6.3 模型热更新与优雅关闭

模型迭代是常态,直接替换文件后重启服务会丢弃流量。更稳妥的方案是设计两层结构:外层是 API 进程,内层是模型实例池。将模型版本设置为可配置项,更新时预加载新模型到进程内存,通过开关平滑切换,旧请求继续使用旧模型直到处理完毕:

class ModelContainer: def __init__(self): self.model = None self.version = None def load(self, version): # 加载对应版本模型 self.model = ... self.version = version def predict(self, inputs): return self.model(inputs) container = ModelContainer() @app.post("/predict") def predict(req): cur = container.predict(...) return cur

旧进程处理完在队列里的请求后自然退出,新进程继续接收。这个方案完全不需要停机。

7. 实测数据复盘:什么有效、什么无效、优化顺序怎么排

光说方法不够,我把自己在一台 4 核 CPU 机器上的实测结果整理出来,硬件是 Intel 8 vCPU + 16GB 内存,无 GPU,模型选择 DistilBERT-base,单条文本长度固定 128。

7.1 各方案实测对比

方案P50 延迟QPS备注
原始 PyTorch(FP32)125ms8基线
动态量化(INT8)88ms11无精度校准
ONNX Runtime(FP32)72ms14启用图优化
ONNX Runtime + 动态量化53ms19性价比最高
静态量化 + ONNX Runtime48ms21需要校准数据
批量推理(batch=8)单条约 20ms接近 40服务端延迟有感知差异,但吞吐翻倍

可以看到,光靠一个方案很难彻底解决问题,叠加起来才能有质变。

7.2 我推荐的优化顺序

根据上面的数据和工作量,我在项目里一般按这个顺序操作:

  1. 先用脚本跑出原始基线。
  2. 加载时设置torch.set_num_threads合理线程数;默认线程数有时候会炸掉资源。
  3. 直接用quantize_dynamic做动态量化,延迟通常会明显下降。
  4. 导出 ONNX 并开启图优化,这一步往往比量化更有效。
  5. 如果 CPU 资源多但模型复杂,上静态量化,校准数据量一般 500 到 1000 条就够。
  6. 最后再考虑服务化、批处理和缓存。

不要一上来就上静态量化,因为它的校准流程和精度验证都比较费劲,动态量化加 ONNX Runtime 在绝大多数场景已足够。

7.3 容易被忽略的配置细节

  • torch.set_num_threads(4):线程数设太高会造成上下文切换反而更慢。
  • export ONNX时需固定sequence_length,否则动态 shape 会削弱图优化效果。
  • 关闭 PyTorch 的grad:推理时务必在torch.no_grad()下进行,否则模型会构建计算图,内存和耗时都白白增加。
  • 缓存之后的数据传输层也要处理,模型服务的瓶颈往往不在 GPU,而在 CPU 和网络 IO。

8. 后续还能往哪个方向走

如果你已经把上面这些手段都试完了,延迟还是没有达到预期,那就已经进入更复杂的优化圈了,有几条路可以继续走:

  • GPU 专用推理引擎:NVIDIA 的 TensorRT 可以对模型进行更细粒度的层融合和内核选择,比 ONNX Runtime 在 GPU 上再快 20% 左右,但需要花时间处理兼容性和动态 shape 问题。
  • 大模型的专用推理框架:如果你部署的是 GPT、Llama 这类生成模型,直接使用 vLLM 或类似方案,它能通过 PagedAttention 极大地提高长文本生成的显存利用率和吞吐。
  • 模型再蒸馏:如果业务方愿意给出足够的标注数据,蒸馏出一个更小的 mini 模型,推理延迟可以降到原来的五分之一以下。这个动作需要重新走训练流程,但值得做。

这些都是在我手头常见场景下摸索出来的,未必覆盖所有情况,但至少在 Ubuntu 20.04 中搭建 Transformers 模型、然后用各种手段优化推理这一套上,你按顺序走完,性能大概率已经能上线了。

最后再分享一个我自己的习惯:每一轮优化都要重新跑一遍完整的评测流程,用同一组测试集、同一个长度、同一个线程数,数据才有可比性,否则很容易被某一次的偶然波动带偏。优化推理从来不是一次性的工作,随着业务量和数据分布的变化,模型和服务都需要持续调优。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询