上个月接了一个需求:每天凌晨要把50万条用户反馈做情感分析和关键词提取。用API调的话,按GPT-4o-mini的价格算,一天光这个任务就要烧150块。自己部署模型是省钱了,但单条推理的吞吐量又不够——一张RTX 4090跑7B模型,单条推理大概200ms,50万条要跑将近28小时。
后来用了动态批处理,把吞吐量提升了8倍,同样的任务3.5小时就跑完了。这篇文章把方案整理出来。
为什么批处理能提速
GPU的计算特性是:处理1条数据和处理8条数据的时间差不多。这是因为GPU有几千个核心,单条推理时大部分核心都在空转。批处理就是把多条请求合并成一批一起推理,充分利用GPU的并行计算能力。
方案选型
对比了几种批处理方案:
方案 接入复杂度 吞吐量提升 适用场景
单条推理 低 1x 实时对话
静态批处理 中 3-4x 离线任务
动态批处理 高 6-10x 混合负载
连续批处理(vLLM) 低 8-12x 通用
静态批处理最简单,凑够N条一起推理;动态批处理根据当前负载自动调整批次大小;连续批处理是vLLM的核心特性,请求可以随时加入正在执行的批次。
异步队列架构
用Redis List做请求队列,worker从队列取任务做批处理:
import json
import time
import redis
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
class BatchInferenceWorker:
def __init__(self, model_path, redis_url="redis://localhost:6379",
max_batch_size=8, max_wait_ms=100):
self.model = AutoModelForCausalLM.from_pretrained(
model_path, torch_dtype=torch.float16, device_map="auto"
)
self.tokenizer = AutoTokenizer.from_pretrained(model_path)
self.redis = redis.from_url(redis_url)
self.max_batch_size = max_batch_size
self.max_wait_ms = max_wait_ms
def collect_batch(self):
"""从队列收集一个批次的请求"""
batch = []
start = time.time()
while len(batch) < self.max_batch_size:
# 用BLPOP阻塞等待,避免忙轮询
elapsed_ms = (time.time() - start) * 1000
remaining = self.max_wait_ms - elapsed_ms
if remaining <= 0:
break
result = self.redis.blpop("inference:queue", timeout=remaining / 1000)
if result:
_, data = result
batch.append(json.loads(data))
else:
break # 超时,用当前批次推理
return batch
def run_batch(self, batch):
"""批量推理"""
prompts = [item["prompt"] for item in batch]
inputs = self.tokenizer(prompts, return_tensors="pt", padding=True, truncation=True).to(self.model.device)
with torch.no_grad():
outputs = self.model.generate(**inputs, max_new_tokens=512)
results = []
for i, output in enumerate(outputs):
# 只取新生成的token
new_tokens = output[inputs["input_ids"].shape[1]:]
text = self.tokenizer.decode(new_tokens, skip_special_tokens=True)
results.append({
"task_id": batch[i]["task_id"],
"result": text
})
return results
def process_loop(self):
"""主循环"""
while True:
batch = self.collect_batch()
if not batch:
continue
results = self.run_batch(batch)
# 把结果写回Redis
for r in results:
self.redis.hset("inference:results", r["task_id"], json.dumps(r, ensure_ascii=False))
self.redis.expire(f"inference:results:{r['task_id']}", 3600)
动态批次大小
固定的max_batch_size不够灵活。当请求量大的时候应该用大批次,请求量小的时候用小批次减少等待时间:
class DynamicBatcher:
def __init__(self, min_batch=1, max_batch=16, max_wait_ms=200):
self.min_batch = min_batch
self.max_batch = max_batch
self.max_wait_ms = max_wait_ms
self.recent_latencies = []
def get_batch_size(self, queue_depth):
"""根据队列深度和历史延迟动态调整批次大小"""
if queue_depth > 100:
return self.max_batch
elif queue_depth > 20:
return min(self.max_batch, queue_depth // 3)
else:
return self.min_batch
def get_wait_time(self, queue_depth):
"""队列深时少等,队列浅时多等"""
if queue_depth > 50:
return 50 # 50ms
elif queue_depth > 10:
return 100
else:
return self.max_wait_ms
vLLM连续批处理
如果不想自己实现队列和批处理逻辑,直接用vLLM是最省事的方案。vLLM内置了连续批处理,请求来了自动加入当前批次:
vllm serve Qwen/Qwen2.5-7B-Instruct \
--max-num-seqs 16 \
--max-num-batched-tokens 8192 \
--gpu-memory-utilization 0.9
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1")
# 多个请求并发提交,vLLM自动做批处理
import asyncio
from concurrent.futures import ThreadPoolExecutor
def analyze_one(text):
response = client.chat.completions.create(
model="Qwen/Qwen2.5-7B-Instruct",
messages=[
{"role": "system", "content": "分析用户反馈的情感,返回JSON格式。"},
{"role": "user", "content": text}
],
temperature=0
)
return response.choices[0].message.content
# 并发提交50万条
with ThreadPoolExecutor(max_workers=32) as pool:
results = list(pool.map(analyze_one, feedback_texts))
vLLM会自动把并发请求合并成批次,你只需要控制并发数就行。
性能对比
在同一张RTX 4090上,用Qwen2.5-7B-Instruct处理50万条反馈:
方案 批次大小 吞吐量 总耗时
单条推理 1 5条/秒 27.8h
静态批处理 8 35条/秒 4.0h
动态批处理 1-16 42条/秒 3.3h
vLLM连续批处理 自动 55条/秒 2.5h
vLLM的连续批处理效果最好,因为它能在推理过程中动态插入新请求,不用等当前批次全部完成。
写在最后
批处理推理是离线任务的必选项。如果你的场景是实时对话,用vLLM的连续批处理就够了;如果是离线任务,静态批处理实现简单效果也不错。
关键参数是批次大小和等待时间:批次太大延迟高,太小浪费GPU;等待太久用户体验差,太短批次凑不满。根据你的场景多试几组参数,找到最优平衡点。