最近在 Ubuntu 20.04 上把一个 Hugging Face Transformers 文本分类模型从 Python 脚本阶段推到正式推理服务,整个过程比预想中曲折得多。模型下载、依赖版本冲突、显存溢出、并发一高响应就变慢……每个环节都有坑。这篇就当我的实战笔记,把从零搭建、优化到部署的完整链路梳理出来,给正在做同类事情的开发者当个参考。会覆盖环境准备、模型加载、服务化、模型量化加速、并发与稳定性,以及最后的问题排查。
1. 项目背景与整体思路
1.1 这个项目到底要解决什么问题
先说背景。某项目组的内部场景是把一组文本分类模型部署到一台 Ubuntu 20.04 服务器上,对外提供 HTTP 推理接口。需求点其实很朴素:
- 输入一段文本,返回分类标签和置信度
- 支持一定的并发请求,不能一个调用就把服务拖死
- 模型加载后要能长时间稳定运行,不能动不动就 OOM
- 延迟要压到可接受范围,先跑通再逐步优化
这类需求在内部工具链、智能客服、内容审核场景里非常普遍。但实际做起来就会发现,跑通一个模型和把它做成可用的服务,中间隔着一整套工程化流程。数据预处理、模型加载、推理逻辑封装、接口设计、显存管理、性能优化,每一环都可能成为瓶颈。
1.2 方案选型:为什么是 Ubuntu 20.04 + Transformers
选型上其实没有太多悬念。Ubuntu 20.04 是目前生产环境里覆盖率很高的操作系统版本,内核、驱动、容器支持都比较成熟,而且很多 GPU 服务器出厂预装的就是它。Hugging Face Transformers 则是当前使用范围最广的模型加载与推理框架之一,几乎所有主流预训练模型都能用几行代码加载,生态成熟度非常高。
有人会问,为什么不直接上 TensorRT 或者纯手写优化?这个问题我当时的判断是:要先把完整链路跑通,再考虑深度优化。直接上手底层方案会让问题域变得过于复杂,调试成本很高。先用 Transformers 把模型、分词器、推理逻辑全部封装好,跑通服务后再用半精度、量化、算子优化等手段逐步提速,这才是稳妥的路线。
1.3 部署方案的整体架构
整个部署架构分四层:
- 接入层:FastAPI 提供 HTTP 接口,接收请求,返回结果
- 业务逻辑层:文本预处理、模型推理、标签映射、异常捕获
- 模型层:Hugging Face Transformers 加载预训练模型,支持 FP32/FP16/INT8 切换
- 基础设施层:Ubuntu 20.04 + CUDA + PyTorch + 一块 NVIDIA GPU
这个架构的好处是每一层都能独立测试和替换。比如业务逻辑层可以单独做接口调试,模型层可以在不改动上层代码的情况下切换量化方式。我实际部署的时候也是按这个顺序逐步推进的:先搭环境,再加载模型,然后写接口,最后做优化和压测。
2. 环境准备与依赖安装
2.1 Ubuntu 20.04 基础环境配置
拿到服务器第一件事不是装 Python 包,而是看清硬件和系统状态。建议先跑一遍基础检查命令:
lsb_release -a nvidia-smi python3 --versionUbuntu 20.04 自带 Python 3.8,这个版本运行 Transformers 4.x 完全没有问题。关键是显卡驱动和 CUDA。nvidia-smi能正常输出的话,驱动层面就没大问题。如果没有任何输出,先解决驱动安装,否则后面所有 GPU 相关操作都会白搭。
注意一个容易踩的坑:系统自带的 Python 3 是发行版管理的,不要直接在系统 Python 环境里pip install,很容易把系统包搞乱。老老实实建虚拟环境:
python3 -m venv venv source venv/bin/activate pip install --upgrade pip虚拟环境隔离依赖这件事,做项目时可能觉得麻烦,但等你同时维护两三个模型服务时就知道它有多重要了。不同模型的依赖版本经常互相冲突,没有虚拟环境基本就是灾难。
2.2 CUDA 工具链与 PyTorch 安装
PyTorch 是 Transformers 的底层核心,它的 CUDA 版本必须和显卡驱动匹配。nvidia-smi里显示的 CUDA 版本是驱动支持的最高版本,不代表 PyTorch 需要装对应版本。PyTorch 的 CUDA 运行时是随 pip 包自带的,所以只要驱动够新,直接装对应的 cu118/cu121 版本即可。
我实际装的版本组合:
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118装完后验证 CUDA 是否可用:
import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))这一步输出 True 就说明环境通了。如果输出 False,大概率是驱动问题,去查驱动版本和 PyTorch 要求的 CUDA 是否匹配。
有人问过 CPU 推理行不行。行,但性能差距极大。文本分类任务里,GPU 推理延迟大约是 CPU 的十分之一甚至更低,而且并发能力完全不同。既然服务器带 GPU,就一定要把 GPU 用起来。
2.3 安装 Transformers 与相关依赖
主要安装这几个包:
pip install transformers pip install accelerate pip install bitsandbytes pip install fastapi uvicorn pip install onnxruntime-gpuaccelerate对设备分配、显存管理和量化加载非常重要,建议不管用不用都装上。bitsandbytes是 8-bit 量化必需的后端。onnxruntime-gpu留作后续性能优化时的备选方案。
版本方面,Transformers 4.x 各系列差异较大,推荐安装较新的版本。如果在生产环境需要锁版本,建议装完后用pip freeze > requirements.txt把环境固定下来,避免某次更新导致行为变化。
安装完成后可以跑一个小测试,验证库能否正常导入并完成一次最小推理。不要等模型都下好了再测试,小步快跑能更快排除环境问题。
3. 模型获取、加载与基础推理
3.1 把模型下载到本地缓存
很多人第一次做 Hugging Face Transformers 部署时,习惯直接让代码在线下载模型。这种方式在测试环境没问题,但生产环境下有两个隐患:第一,网络不稳定时下载会中断,导致加载失败;第二,每次冷启动都依赖网络,服务重启变得不可控。
更好的做法是提前把模型下载到本地指定目录,然后离线加载。执行下面的命令:
python -c " from huggingface_hub import snapshot_download snapshot_download(repo_id='bert-base-uncased', cache_dir='./model_cache') "模型和分词器文件会落入./model_cache目录。之后在代码里通过cache_dir参数指定这个路径,即使断网也能加载。
这里建议下载模型前先想清楚模型规模。如果是 BERT-base 这种 400M 参数级别的模型,模型文件大约 400 到 500MB,下载和加载都很快。如果是数 B 参数的生成式模型,那就要考虑模型分片、显存占用和加载时间,部署思路会完全不同。
3.2 用 AutoModel + Pipeline 搭建基础推理
Transformers 的 pipeline 封装了分词、模型推理、结果解析的完整流程,用于快速验证效果非常方便:
from transformers import pipeline classifier = pipeline( "text-classification", model="./model_cache/models--bert-base-uncased", device=0 ) result = classifier("This is a great movie!") print(result)device=0表示使用第一张 GPU。参数化加载方式也值得掌握,因为 pipeline 的可控性相对有限,而真实服务往往需要更多控制权:
from transformers import AutoTokenizer, AutoModelForSequenceClassification model_path = "./model_cache/models--bert-base-uncased" tokenizer = AutoTokenizer.from_pretrained(model_path) model = AutoModelForSequenceClassification.from_pretrained(model_path) model.to("cuda") model.eval()这样写的好处是能自定义预处理和后处理逻辑,对于特殊业务需求(比如自定义标签映射、增加置信度阈值)更有掌控力。
3.3 基础推理性能摸底
初始化完成后,我习惯先做一次性能摸底,记录原始延迟数据。用 2000 条测试样本来回推理十次,取平均延迟和显存占用。这组数据是后续所有优化的基准线。
我当时的基线数据是:BERT-base 模型 FP32 精度下,单条文本推理平均耗时 150ms 左右,显存占用约 1.8GB。GPU 利用率只有 30% 上下。数据一出来就很明确:模型本身有优化空间,服务层也有并发和批处理优化的空间。后面所有的改动都要和这份基准数据对比,没有基准,所谓的优化就没有参考系。
4. 推理服务化与接口实现
4.1 用 FastAPI 搭建推理接口
为什么选 FastAPI?生态成熟、性能好、自带请求校验、自动生成接口文档,写一个推理服务只需要很少代码。而且它和 Transformers 都是 Python 生态,不用担心跨语言通信问题。
先做第一版简单服务:
import time from fastapi import FastAPI, HTTPException from pydantic import BaseModel from transformers import pipeline app = FastAPI(title="Text Classification Service") classifier = pipeline("text-classification", model="./model_cache/models--bert-base-uncased", device=0) class PredictRequest(BaseModel): text: str class PredictResponse(BaseModel): label: str score: float latency_ms: float @app.get("/health") def health(): return {"status": "ok"} @app.post("/predict", response_model=PredictResponse) def predict(req: PredictRequest): start = time.perf_counter() try: out = classifier(req.text)[0] except Exception as e: raise HTTPException(status_code=500, detail=str(e)) latency = (time.perf_counter() - start) * 1000 return PredictResponse(label=out["label"], score=out["score"], latency_ms=round(latency, 2))启动命令:
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 1注意一点:模型加载是耗时操作,放到模块导入时执行可以避免每次请求都重新加载。pipeline 在import阶段初始化,服务启动后模型就常驻显存,后续请求直接复用。
4.2 并发瓶颈与第一个优化点
用wrk或locust简单压测一下就会发现,并发数一上来,响应延迟迅速恶化。原因不在 FastAPI,而在于 Transformers 的推理函数是同步阻塞的。FastAPI 的def端点会在线程池中执行,线程池默认大小只有 40 左右,而且每个线程都在抢 GIL。
第一个优化点:把同步推理放到asyncio里,通过线程池隔离执行。代码改成:
import asyncio from concurrent.futures import ThreadPoolExecutor executor = ThreadPoolExecutor(max_workers=4) @app.post("/predict", response_model=PredictResponse) async def predict(req: PredictRequest): loop = asyncio.get_running_loop() start = time.perf_counter() try: out = await loop.run_in_executor(executor, classify, req.text) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) latency = (time.perf_counter() - start) * 1000 return PredictResponse(label=out["label"], score=out["score"], latency_ms=round(latency, 2))线程数不建议盲目放大。PyTorch 推理时底层会释放 GIL,但数据预处理和后处理片段仍受 GIL 约束,线程过多反而降低效率。我这里实测 4 线程效果不错,具体还要结合模型规模和 GPU 显存调整。
4.3 批处理队列:吞吐量翻倍的关键
GPU 计算有个特点:单卡单条数据推理和单卡多条数据 batch 推理的耗时相差不大,但后者能处理的请求数却成倍增加。利用这个特性,可以在服务层做一个简单的动态批处理队列,把一小段时间窗口内的请求合并成一个 batch,一次推理返回多个结果。
简化版批处理器核心思路:
import asyncio import torch class BatchProcessor: def __init__(self, model, tokenizer, max_batch=32, max_wait=0.05, device="cuda"): self.model = model self.tokenizer = tokenizer self.max_batch = max_batch self.max_wait = max_wait self.device = device self.queue = asyncio.Queue() self._task = asyncio.create_task(self._loop()) async def process(self, text: str): future = asyncio.get_running_loop().create_future() await self.queue.put((text, future)) return await future async def _loop(self): while True: if self.queue.empty(): await asyncio.sleep(self.max_wait) continue items = [] while len(items) < self.max_batch and not self.queue.empty(): items.append(self.queue.get_nowait()) batch_text = [t for t, _ in items] with torch.no_grad(): inputs = self.tokenizer(batch_text, return_tensors="pt", padding=True, truncation=True).to(self.device) outputs = self.model(**inputs) scores = torch.softmax(outputs.logits, dim=-1) for idx, (_, fut) in enumerate(items): fut.set_result(scores[idx])实际压测对比下,单条请求逐个推理的吞吐大约是 30 QPS,接入动态 batching 后提升到 80 QPS 左右,而显存占用几乎没涨。这个优化收益非常明显,而且实现成本很低,强烈建议在服务层加上。
5. 模型层优化:半精度、量化与加速
5.1 FP16 半精度推理
模型层优化的第一步是降低精度。BERT 这类模型在 FP32 下推理时,很多计算单元的精度其实是过量的。把权重改成 FP16 后,显存占用直接减半,计算速度也有提升。
用torch.autocast是最稳妥的方式:
import torch with torch.no_grad(), torch.autocast(device_type="cuda", dtype=torch.float16): outputs = model(**inputs)实测结果:FP16 相比 FP32,延迟从 150ms 降到 98ms,大概提速 35% 到 40%。显存占用从 1.8GB 降到约 0.95GB。分类任务的精度几乎没有损失,F1 值下降了不到 0.2 个百分点,完全在可接受范围内。
需要注意:FP16 只对 CUDA 核心上计算友好的算子有效,CPU 上不使用。另外个别任务(比如某些回归模型)对精度更敏感,上 FP16 前最好用测试集做一次一致性校验。我的做法是取 1000 条样本,对比 FP32 和 FP16 输出的余弦相似度,低于 0.99 就警惕一点,低于 0.95 就直接放弃 FP16。
5.2 INT8 量化:显存再砍一半
FP16 还不够,继续压显存可以上 INT8 量化。Transformers 配合 bitsandbytes 可以做到模型加载时直接量化,无需额外训练。
from transformers import AutoModelForSequenceClassification model = AutoModelForSequenceClassification.from_pretrained( "./model_cache/models--bert-base-uncased", load_in_8bit=True, device_map="auto", )代码执行后模型权重以 8-bit 精度保存,显存占用从 FP16 的 0.95GB 进一步降到约 0.55GB。但注意,8-bit 量化对延迟降低并不明显,有时候甚至会略微变慢,因为反量化过程有额外开销。量化最大的价值是省显存,让更大的模型能跑在同样一张卡上。
这里有个坑:量化模型加载后,某些层可能被放到 CPU 上,速度不升反降。务必用model.device或print(model.hf_device_map)检查模型各模块的真实位置。
5.3 ONNX Runtime 加速与算子融合
如果 FP16 和量化还不够,可以转 ONNX Runtime 做图优化。Transformers 提供了一键导出 ONNX 的工具:
python -m transformers.onnx --model=./model_cache/models--bert-base-uncased onnx/然后用 onnxruntime-gpu 加载推理:
import onnxruntime as ort import numpy as np sess_options = ort.SessionOptions() sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL ort_session = ort.InferenceSession("onnx/model.onnx", sess_options, providers=["CUDAExecutionProvider"]) def onnx_predict(text): inputs = tokenizer(text, return_tensors="np") logits = ort_session.run(None, dict(inputs))[0] return logitsORT 会把图内的算子做融合,减少 kernel 切换和显存读写次数。我实测在 FP16 ONNX 模式下,延迟从 98ms 再压到 72ms 左右。这里耗时提高主要来自模型本身的计算结构优化,而不是单纯的精度降低。
不过 ONNX 对动态形状支持有限,padding 策略需要固定。服务中最好提前设置max_length和截断逻辑,避免输入长度剧烈变化触发 ONNX 的动态 shape 分支,影响推理效率。
5.4 优化效果汇总
把这几种方式放在一起对比,效果非常直观:
| 方案 | 延迟(ms) | 显存占用(GB) | 相对 FP32 提速 | 说明 |
|---|---|---|---|---|
| FP32 基线 | 150 | 1.80 | 1x | 初始状态 |
| FP16 | 98 | 0.95 | 约 1.5x | 精度几乎无损 |
| FP16 + 动态批处理 | 85(吞吐角度) | 0.95 | 吞吐提升 2-3 倍 | 服务层优化 |
| INT8 量化 | 110 | 0.55 | 延迟略升 | 显存节约明显 |
| FP16 + ONNX Runtime | 72 | 0.95 | 约 2.1x | 算子融合生效 |
综合看,FP16 + 动态批处理 + ONNX Runtime 的组合最均衡:延迟低、吞吐高、显存可控、精度保持良好。 INT8 量化则留给那些显存紧张、需要给更大模型腾地方的场景。
6. 生产环境落地与稳定性保障
6.1 冷启动优化
模型服务重启时最大的问题是冷启动时间。BERT-base 模型加载大约需要 20 到 30 秒,如果每次发布都要重启,体验会很糟糕。
几个实用手段:
- 预加载模型到共享内存:把模型权重在单独的预热脚本中加载一次,再通过页缓存的方式加速二次加载
- 服务启动时不加载 FP32 原始权重,直接加载 FP16/INT8 版本
- 用
torch.compile或 ONNX 预构建会话缓存,跳过编译步骤
我实际操作中把 FP16 权重先转成 safetensors 格式保存,加载时间从 25 秒压到 12 秒左右。safetensors 相比 bin 格式,读盘和反序列化都更快,也是建议优先使用的模型存储格式。
6.2 显存管理与自动重启
长时间运行的服务,显存只升不降是最常见的问题。这里有几点经验:
- 推理代码里始终用
torch.no_grad()包裹,避免构建计算图 - 设置
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,减少显存碎片化 - 启动时为业务缓存预留显存,比如限制 batch size 或最大并发数
还有一个经验:不要在显存快满的时候才求助。应该在服务里加显存监控,当显存占用超过 80% 时告警,超过 90% 时自动拒绝新请求,防止 OOM 导致进程崩溃。简单实现一个高水位保护逻辑:
import threading def get_gpu_memory(): free, total = torch.cuda.mem_get_info(0) used = total - free return used / total def check_gpu_memory(): if get_gpu_memory() > 0.9: raise HTTPException(status_code=503, detail="GPU memory high")每个/predict入口先执行这个检查,能挡住大部分显存险情。
6.3 压测过程记录与调优
压测工具我用locust和wrk搭配。wrk负责单纯压力测试,locust更灵活,可以模拟不同用户的请求间隔。
压测中发现,并发数从 10 提高到 50 时,响应延迟并没有线性增长,原因在于动态批处理把大量请求合并了。继续提高到 100 并发,延迟陡增。此时 GPU 利用率从 60% 升到 99%,说明 GPU 已经饱和。这时候优先做的是加机器或降级策略,而不是继续压单机。
一个值得记录的经验:调优时每次只改一个变量。比如先调 batch size,记录数据,再调工作线程数,再改精度。不要同时改多个参数,否则出了问题根本定位不到是哪个改动导致了性能恶化。
7. 常见问题与排查技巧实录
7.1 模型加载报错:OSError 或文件缺失
这类错误通常和缓存路径有关。排查顺序是:先确认cache_dir的绝对路径是否存在,再确认目录内部结构是否符合transformers的预期。
常见原因是多进程同时下载模型导致缓存文件损坏。解决办法是删除对应模型目录重新下载,并且一次只跑一个下载进程。
7.2 CUDA Out of Memory
显存不足分两种情况:一是模型本身太大装不下,二是推理过程中某个 batch 过大导致峰值暴涨。
排查方法:
nvidia-smi如果模型加载后显存已经占满,考虑换量化模型或减小模型规模。如果模型加载没问题、请求一多就 OOM,多半是 batch 太大,调小动态批处理的最大 batch 值即可。
我遇到过比较隐蔽的情况:使用了padding=True但没有设置max_length,某个超长文本进来后 batch 内 padding 到最长句子长度,显存瞬间飙升。给 tokenizer 加上max_length=512, truncation=True后问题消失。
7.3 服务响应正常但吞吐很低
延迟不高但并发吞吐上不去,问题往往在 GIL 或事件循环阻塞。FastAPI 中同步端点在事件循环里执行会直接卡住整个服务,必须用async def+run_in_executor把推理挪到独立线程,或者用动态批处理把请求排队合并。
另外几个隐性因素也要排查:
tokenizer是否每次请求都重新加载- 模型是否在每次请求时调用
.cuda()或.to()切换设备 pipeline是否在请求函数内部创建
这些都是典型的“代码能跑但性能差”的问题,把模型和分词器提升为全局对象后,吞吐会有质变。
7.4 GPU 利用率低但延迟高
GPU 利用率低延迟却高,典型的拖后腿因素是数据预处理和 CPU 算子。BERT 的分词、padding、numpy 转换都在 CPU 上执行,如果 CPU 已经打满,GPU 只能干等。
优化方向:
- 用
tokenizer的批处理模式一次处理多条文本 - 预先做长度裁剪,避免超长文本拖慢所有请求
- 检查是否引入了不必要的 Python 级循环
还有一个细节:不要用perf_counter测量单独一段 GPU 推理的耗时,它包含了很多 CPU 和 GPU 之间的同步开销。要看真实的 GPU 计算时间,用torch.cuda.synchronize()包住推理区间再计时。
7.5 量化模型推理结果异常
INT8 量化后模型输出和 FP32 差别过大,这个要引起警惕。主要排查点:
- 模型是否在量化前执行过
model.eval() - 是否存在未适配 8-bit 的自定义层
- 输入是否出现极端长文本,导致量化误差累积
通常分类任务对量化不敏感,但如果业务场景对置信度数值要求很高,建议用测试集对比量化前后输出的分布差异。如果出现明显的右偏或标准差变大,量化方案就要重新评估了。
8. 部署完成后的几点个人心得
整套部署做完后,我最大的体会是:模型推理部署这件事,技术瓶颈往往不在模型本身,而在工程细节。显存管理、并发控制、批处理、冷启动,每一项都比单纯调from_pretrained复杂得多,需要反复实测、持续调整。
几个建议供参考:
- 无论如何先把基准性能数据留下。环境、模型、参数、测试集都要记录,否则后续所有优化都会变成无头苍蝇
- 部署方案先跑通再优化。第一版服务越简单越好,先保证功能正确,再逐步叠加优化策略
- 性能优化时优先做动态批处理和 FP16,这两个投入产出比最高,INT8 和 ONNX 属于进阶手段,按需使用
- 已上线服务一定要做显存高水位保护,宁可拒绝请求,也不要让进程 OOM 崩溃
最后再分享一个小技巧:用torch.profiler对推理过程做一次 profiler 分析,能直观看到 CPU 和 GPU 的时间分配。我最初以为瓶颈在模型计算,跑完 profiler 才发现 30% 的时间都花在 tokenizer 和数组转换上。排掉这些隐藏开销之后,延迟又稳步降了一截。这个工具值得在实际项目里多用几次。