上个月接了一个需求:每天凌晨要把50万条用户反馈做情感分析和关键词提取。用API调的话,按GPT-4o-mini的价格算,一天光这个任务就要烧150块。自己部署模型是省钱了,但单条推理的吞吐量又不够——一张RTX 4090跑7B模型,单条推理大概200ms,50万条要跑将近28小时。

后来用了动态批处理,把吞吐量提升了8倍,同样的任务3.5小时就跑完了。这篇文章把方案整理出来。

为什么批处理能提速

GPU的计算特性是:处理1条数据和处理8条数据的时间差不多。这是因为GPU有几千个核心,单条推理时大部分核心都在空转。批处理就是把多条请求合并成一批一起推理,充分利用GPU的并行计算能力。

flowchart TD A[请求队列] --> B[批次收集] B --> C{达到批次条件?} C -->|是| D[合并为一个批次] C -->|否| B D --> E[GPU推理] E --> F[拆分结果] F --> G[返回各请求]

方案选型

对比了几种批处理方案:

方案              接入复杂度  吞吐量提升  适用场景
单条推理          低          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;等待太久用户体验差,太短批次凑不满。根据你的场景多试几组参数,找到最优平衡点。