1. 批量推理到底在解决什么问题
先把场景摆出来。你手里有一个已经跑通的推理服务,可能是 vLLM,可能是 SGLang,单条请求响应很快,交互式对话体验也不错。现在业务方找过来,说有一批离线数据要处理:几万条 prompt 需要生成结果,或者几百万条文本需要过一遍 embedding 模型。你第一反应可能是写个脚本,循环调用在线接口,一条一条发。跑起来才发现,GPU 利用率低得可怜,吞吐量惨不忍睹,原本在线服务能跑到几千 tokens/s,批量场景下连十分之一都发挥不出来。
这就是批量推理要解决的核心矛盾。在线推理和离线批量推理,用的是同一个引擎、同一份权重、同一套算子,但目标几乎是相反的。在线推理追求的是低延迟,用户发一条请求,希望几百毫秒内看到第一个 token,所以调度器会把请求拆得尽量细,优先保证单条请求的响应时间。批量推理追求的是高吞吐,没人关心第 37 条数据什么时候出结果,只关心这一批数据总共花了多少时间、用了多少 GPU 小时。目标不同,调度策略、批处理方式、资源分配逻辑就完全不同。
我见过太多团队在这个地方踩坑:拿在线服务的配置直接跑离线任务,结果 GPU 利用率长期在 20% 以下,成本翻了好几倍。反过来,也有人把批量推理的配置搬到在线服务上,延迟直接爆炸,用户体验崩盘。所以理解这两者的差异,不是学术问题,是实打实的成本问题。
这篇文章面向的是已经接触过大模型推理、正在或准备做批量推理任务的工程师。不管你是用 Kubernetes 做集群调度,还是单机多卡跑 Ray Data,或者是直接用 vLLM 的离线接口,这里面的思路和坑都是相通的。我会从整体设计思路讲起,拆解核心细节,给出可复现的实操流程,最后把常见问题和排查技巧整理出来。
2. 批量推理的整体设计与思路拆解
2.1 在线推理和批量推理的目标差异
要理解批量推理怎么设计,先得把在线推理的机制说清楚。在线推理服务通常长这样:一个 HTTP 服务器接收请求,请求进入调度队列,调度器根据当前 GPU 上的显存占用、正在运行的序列数量,决定是否把新请求加入当前批次。这里的关键机制是连续批处理(continuous batching),也叫迭代级调度。传统批处理是等一批请求凑齐了一起跑,跑完再跑下一批,中间 GPU 会空转。连续批处理则是每生成一个 token 就重新检查队列,有完成的序列就踢出去,有新请求就加进来,让 GPU 尽可能不空闲。
这个机制对在线场景非常友好,因为请求是流式到达的,延迟敏感。但它有一个隐含假设:请求的到达是随机的、稀疏的,调度器需要频繁做决策来维持 GPU 利用率。到了批量推理场景,这个假设不成立了。你手里有十万条数据,全部已知,不需要等请求到达,也不需要保证任何单条请求的延迟。这时候连续批处理的频繁调度反而成了开销,调度器每步都在做决策,但这些决策本可以提前规划好。
所以批量推理的设计思路就变成了:把调度开销摊薄,把批大小拉满,把显存用到极致。具体来说,有几个方向:
- 静态批处理:把数据分成固定大小的批次,每批一次性喂给引擎,跑完再跑下一批。实现简单,但批次内序列长度不一时会有 padding 浪费。
- 动态批处理:根据序列长度动态组批,把长度相近的序列放在一起,减少 padding。vLLM 的离线接口内部就做了这件事。
- 分块预填充:把长 prompt 的预填充阶段切成小块,和 decode 阶段交错执行,避免长 prompt 阻塞其他序列。
- 流水线并行:在 Ray Data 这类框架里,把数据加载、tokenize、推理、后处理拆成不同阶段,用流水线的方式重叠执行,让 GPU 不等待数据。
这些手段的目标是一致的:让 GPU 的算力利用率尽可能接近理论上限。在线推理里,GPU 利用率能到 40% 就算不错了,批量推理里,目标是 80% 以上。
2.2 为什么选 vLLM 作为批量推理引擎
市面上推理引擎不少,TensorRT-LLM、SGLang、vLLM、TGI 各有千秋。批量推理场景下,vLLM 的优势比较明显,原因有几个。
第一,PagedAttention 对显存的利用效率高。传统注意力机制的 KV Cache 需要连续显存空间,序列长度不一的时候碎片化严重。PagedAttention 把 KV Cache 分成固定大小的块,像操作系统管理内存页一样管理,显存浪费能压到 4% 以下。批量推理里序列长度差异大,这个机制带来的收益非常直接。
第二,离线接口设计得干净。vLLM 提供了LLM.generate()这样的离线批量接口,不需要起 HTTP 服务,直接在 Python 进程里调用。数据准备好之后一次性传进去,引擎内部自动做动态组批和调度。相比自己写循环调在线接口,省掉了网络开销和序列化开销。
第三,和 Ray Data 的集成成熟。Ray Data 是分布式数据处理框架,vLLM 官方提供了ray.data.llm模块,可以把 vLLM 引擎包装成 Ray 的算子,自动做数据分片、多卡并行、流水线重叠。数据量大的时候,这个组合比单机跑要省心得多。
第四,Kubernetes 生态支持好。vLLM 有官方 Docker 镜像,部署到 K8s 集群里,配合 GPU 调度器(比如 HAMi 做 GPU 虚拟化,或者直接用 NVIDIA device plugin),可以比较方便地做多副本批量任务。
当然,SGLang 在某些场景下吞吐更高,特别是结构化输出和前缀缓存做得好的时候。但批量推理的通用性上,vLLM 的生态和文档更成熟,踩坑成本更低。我个人的选择是:如果任务里有大量共享前缀(比如 few-shot 模板),SGLang 值得试;如果是通用批量生成或 embedding,vLLM 更稳。
2.3 Kubernetes 在批量推理里的角色
单机跑批量推理,数据量不大的时候够用。但数据量上去之后,单机显存和算力都有上限,这时候就需要集群。Kubernetes 在这里的角色是资源编排,不是推理本身。
具体来说,K8s 负责几件事:
- GPU 资源分配:通过 device plugin 把节点上的 GPU 暴露给 Pod,Pod 申请
nvidia.com/gpu: 2就能拿到两张卡。 - 多副本调度:一个批量任务可以起多个 Pod,每个 Pod 跑一部分数据,并行处理。
- 故障恢复:Pod 挂了自动重启,配合 Ray 的容错机制,任务不会因为单点故障全盘重来。
- 弹性伸缩:根据队列长度动态调整 Pod 数量,闲时缩容省钱。
但 K8s 不负责推理引擎内部的调度,那是 vLLM 的事。很多人会把这两层搞混,以为 K8s 能优化推理吞吐,其实 K8s 只管把 Pod 放到合适的节点上,Pod 内部的批处理、显存管理、调度策略,全是 vLLM 在管。
一个典型的批量推理架构是这样的:Ray Data 做数据流水线,vLLM 做推理引擎,K8s 做资源编排。数据从对象存储读进来,Ray Data 分片后分发给各个 vLLM 实例,每个实例内部做动态组批和推理,结果写回存储。这个架构的好处是每一层职责清晰,扩展的时候只需要加 Pod 或者加节点。
3. 核心细节解析与实操要点
3.1 vLLM 离线接口的关键参数
vLLM 的离线批量接口核心是LLM类和SamplingParams。先看引擎初始化:
from vllm import LLM, SamplingParams llm = LLM( model="Qwen/Qwen2.5-7B-Instruct", tensor_parallel_size=2, gpu_memory_utilization=0.9, max_model_len=8192, swap_space=16, enforce_eager=False, )这几个参数每一个都影响批量推理的性能,逐个说。
tensor_parallel_size是张量并行度,也就是用几张卡跑一个模型。7B 模型单卡 24G 显存够用,但如果是 70B 模型,至少需要 4 张 A100 80G。这里有个经验公式:模型参数量乘以 2 字节(FP16)得到权重显存,再加上 KV Cache 和激活值,大概需要权重显存的 1.5 到 2 倍。70B 模型 FP16 权重约 140G,4 张 80G 卡总共 320G,留出 KV Cache 空间后比较稳妥。
gpu_memory_utilization控制 vLLM 预分配的显存比例,默认 0.9。批量推理场景下可以调到 0.92 到 0.95,把更多显存留给 KV Cache,批大小能开得更大。但别调到 0.98,系统本身和其他进程还需要显存,调太高容易 OOM。
max_model_len是模型支持的最大序列长度。这个值直接影响 KV Cache 的预分配大小。如果你实际数据里最长序列只有 2048,但设成 8192,会浪费大量显存。批量推理前一定要统计一下数据的长度分布,把max_model_len设成实际最大长度加上一点余量。
swap_space是 CPU 交换空间,单位 GB。当 GPU 显存不够时,vLLM 会把部分 KV Cache 换到 CPU 内存。批量推理里如果序列长度波动大,适当调大这个值能避免 OOM,但换入换出有开销,能不用尽量不用。
enforce_eager控制是否禁用 CUDA Graph。CUDA Graph 能把一系列 kernel 调用录制成图,减少启动开销,对 decode 阶段提升明显。批量推理里建议保持False,让 vLLM 用 CUDA Graph。但如果遇到奇怪的崩溃,可以设成True排查。
3.2 批大小的选择逻辑
批大小是批量推理里最关键的调优参数。设太小,GPU 利用率上不去;设太大,显存 OOM 或者延迟飙升。vLLM 内部会自动做动态组批,但你可以通过max_num_seqs和max_num_batched_tokens来间接控制。
max_num_seqs是单批次最大序列数,默认 256。这个值决定了同时有多少条序列在跑。对于短序列任务(比如分类、embedding),可以调到 512 甚至 1024。对于长序列生成任务,256 已经比较激进,再大容易 OOM。
max_num_batched_tokens是单批次最大 token 数,默认 8192。这个参数控制的是预填充阶段的批大小。预填充是计算密集型的,token 数越多,GPU 利用率越高。但设太大,单次预填充时间变长,会影响 decode 阶段的交错执行。一般建议设成max_model_len的 2 到 4 倍。
实际调优的时候,我的做法是先用默认值跑一小批数据,看 vLLM 的日志输出。日志里会打印每次迭代的 batch size、GPU 利用率、KV Cache 使用率。如果 KV Cache 使用率长期低于 50%,说明批大小可以往上调;如果接近 100% 且出现 preemption(抢占),说明批大小太大了,需要往下调。
这里有个容易忽略的点:序列长度分布对批大小的影响。如果所有序列长度都是 512,那批大小可以开得很大;如果长度从 128 到 8192 都有,那批大小受最长序列限制,实际利用率会低很多。解决办法是按长度分桶,把长度相近的序列放在一起跑,每个桶用不同的批大小。Ray Data 里可以自定义分桶逻辑,vLLM 离线接口也可以手动分批。
3.3 Ray Data 的数据流水线设计
数据量大的时候,瓶颈往往不在 GPU,而在数据加载和预处理。我见过一个案例,GPU 利用率只有 30%,排查发现是 tokenize 阶段太慢,GPU 一直在等数据。Ray Data 的价值就在这里:它能把数据加载、预处理、推理、后处理拆成流水线,让 CPU 和 GPU 重叠工作。
一个典型的 Ray Data 批量推理流水线:
import ray from ray.data.llm import vLLMEngineProcessorConfig, build_llm_processor config = vLLMEngineProcessorConfig( model="Qwen/Qwen2.5-7B-Instruct", engine_kwargs={ "tensor_parallel_size": 1, "gpu_memory_utilization": 0.9, "max_model_len": 4096, }, concurrency=4, batch_size=64, ) processor = build_llm_processor( config, preprocess=lambda row: { "prompt": row["instruction"], "sampling_params": {"max_tokens": 512, "temperature": 0.7}, }, postprocess=lambda row: { "response": row["generated_text"], "input": row["prompt"], }, ) ds = ray.data.read_parquet("s3://bucket/input/") ds = ds.map_batches(processor, batch_size=64) ds.write_parquet("s3://bucket/output/")这里的关键参数是concurrency和batch_size。concurrency是并行运行的 vLLM 实例数,每个实例占一张卡。batch_size是每次传给 vLLM 的行数。这两个值决定了流水线的并行度和批处理效率。
concurrency的设置要考虑 GPU 数量。如果集群里有 8 张卡,concurrency设成 8,每个实例用一张卡,这样能跑满所有 GPU。如果模型太大单卡放不下,tensor_parallel_size设成 2,concurrency设成 4,总共还是 8 张卡。
batch_size的设置要考虑显存和序列长度。batch_size 太大,单次推理显存不够;太小,GPU 利用率低。一般从 32 或 64 开始试,根据日志调整。
Ray Data 还有一个好处是自动处理数据分片和故障恢复。如果某个 vLLM 实例挂了,Ray 会把它的数据分片重新分配给其他实例,任务不会中断。这在长时间运行的批量任务里非常重要。
3.4 Kubernetes 部署的资源配置
把批量推理部署到 K8s 上,资源申请有几个要点。
GPU 资源申请:
resources: limits: nvidia.com/gpu: 2这表示 Pod 需要 2 张 GPU。注意limits和requests要一致,GPU 不支持超卖。
CPU 和内存也要给够。数据加载和 tokenize 是 CPU 密集型的,如果 CPU 不够,GPU 会等数据。经验值是每张 GPU 配 8 到 16 核 CPU,内存至少是 GPU 显存的 2 倍。比如 2 张 80G 的 A100,配 32 核 CPU 和 320G 内存。
共享内存也要调大。vLLM 用共享内存做进程间通信,默认的 64M 不够用,设成 16G 以上:
volumes: - name: shm emptyDir: medium: Memory sizeLimit: 16Gi volumeMounts: - name: shm mountPath: /dev/shm镜像选择上,vLLM 官方提供了vllm/vllm-openai镜像,但那是给在线服务用的。批量推理建议基于vllm/vllm-openai自己构建,加上 Ray 和数据处理依赖。或者直接用rayproject/ray-ml镜像,里面已经包含了 Ray 和常用 ML 库,再装 vLLM 就行。
节点选择上,用 nodeSelector 或 affinity 把 Pod 调度到有 GPU 的节点:
nodeSelector: nvidia.com/gpu.product: NVIDIA-A100-SXM4-80GB这样可以确保 Pod 跑到指定型号的 GPU 上,避免不同型号混跑导致的兼容性问题。
4. 实操过程与核心环节实现
4.1 单机批量推理的完整流程
先从单机场景说起,这是最基础的,也是排查问题的起点。
第一步,准备数据。假设数据是 JSONL 格式,每行一个样本:
{"id": 1, "prompt": "解释一下什么是机器学习"} {"id": 2, "prompt": "写一段 Python 快速排序"}第二步,统计序列长度分布。这一步很多人跳过,但它直接决定了max_model_len和批大小的设置:
import json from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct") lengths = [] with open("data.jsonl") as f: for line in f: item = json.loads(line) tokens = tokenizer.encode(item["prompt"]) lengths.append(len(tokens)) import numpy as np print(f"max: {max(lengths)}, p99: {np.percentile(lengths, 99)}, p50: {np.percentile(lengths, 50)}")如果 p99 是 1024,max 是 4096,那max_model_len设成 4096 就行,但大部分序列在 1024 以内,可以考虑分桶。
第三步,初始化 vLLM 引擎并跑推理:
from vllm import LLM, SamplingParams llm = LLM( model="Qwen/Qwen2.5-7B-Instruct", tensor_parallel_size=1, gpu_memory_utilization=0.92, max_model_len=4096, ) sampling_params = SamplingParams( temperature=0.7, top_p=0.9, max_tokens=512, ) prompts = [] with open("data.jsonl") as f: for line in f: prompts.append(json.loads(line)["prompt"]) outputs = llm.generate(prompts, sampling_params) with open("output.jsonl", "w") as f: for i, output in enumerate(outputs): result = { "id": i, "prompt": prompts[i], "response": output.outputs[0].text, } f.write(json.dumps(result, ensure_ascii=False) + "\n")这段代码看起来简单,但有几个细节要注意。
llm.generate()是阻塞的,所有数据一次性传进去,引擎内部自动分批。如果数据量特别大(比如几百万条),一次性加载到内存可能爆掉。这时候要手动分批:
batch_size = 10000 for i in range(0, len(prompts), batch_size): batch = prompts[i:i+batch_size] outputs = llm.generate(batch, sampling_params) # 写入结果SamplingParams里的max_tokens要设合理。如果设成 512,但实际输出平均只有 100 token,那有 80% 的 decode 步骤是浪费的。可以先跑一小批统计输出长度分布,再调整。
第四步,监控 GPU 利用率。跑的时候开另一个终端:
watch -n 1 nvidia-smi如果 GPU 利用率长期低于 60%,说明批大小不够或者数据加载是瓶颈。如果显存接近 100% 且出现 OOM,说明批大小太大。
4.2 Ray Data 多卡并行的实现
单机跑通之后,数据量上去就需要多卡并行。Ray Data 的配置前面提过,这里补充几个实操细节。
启动 Ray 集群:
ray start --head --num-gpus=8这表示当前节点有 8 张 GPU。如果是多节点,其他节点用ray start --address=<head-address> --num-gpus=8加入。
然后运行批量推理脚本。Ray Data 会自动把数据分片,分发给各个 vLLM 实例。每个实例占一张卡(或者多张卡,取决于tensor_parallel_size)。
这里有个坑:Ray Data 的默认分片策略可能不均匀。如果数据文件大小差异大,某些实例分到的数据多,某些少,导致负载不均。解决办法是设置override_num_blocks:
ds = ds.repartition(num_blocks=64)把数据重分成 64 块,每块大小相近,这样并行度更均匀。
另一个坑是结果写入的并发冲突。多个实例同时写同一个文件会出问题。Ray Data 的write_parquet会自动处理,每个实例写自己的分片,最后合并。但如果自己写文件,要注意加锁或者用不同的文件名。
4.3 Kubernetes 上的批量任务编排
K8s 上跑批量推理,有两种方式:一种是每个 Pod 跑一个 Ray worker,用 Ray 的集群模式;另一种是每个 Pod 独立跑一个批量任务,用 K8s 的 Job 编排。
Ray 集群模式适合数据量大、需要弹性伸缩的场景。部署 Ray head 和 worker:
apiVersion: apps/v1 kind: Deployment metadata: name: ray-head spec: replicas: 1 template: spec: containers: - name: ray image: rayproject/ray-ml:latest-gpu command: ["ray", "start", "--head", "--num-gpus=0", "--dashboard-host=0.0.0.0"] resources: limits: nvidia.com/gpu: 0 --- apiVersion: apps/v1 kind: Deployment metadata: name: ray-worker spec: replicas: 4 template: spec: containers: - name: ray image: rayproject/ray-ml:latest-gpu command: ["ray", "start", "--address=ray-head:6379", "--num-gpus=2"] resources: limits: nvidia.com/gpu: 2这样有 4 个 worker,每个 2 张卡,总共 8 张卡。Ray head 不占 GPU,只做调度。
K8s Job 模式适合一次性任务,跑完就释放资源:
apiVersion: batch/v1 kind: Job metadata: name: batch-inference spec: parallelism: 4 completions: 4 template: spec: containers: - name: inference image: your-vllm-image:latest command: ["python", "batch_infer.py", "--shard-id", "$(JOB_COMPLETION_INDEX)"] resources: limits: nvidia.com/gpu: 2 restartPolicy: OnFailureparallelism是并行 Pod 数,completions是总完成数。每个 Pod 通过JOB_COMPLETION_INDEX拿到自己的编号,处理对应的数据分片。
两种方式各有优劣。Ray 集群模式更灵活,支持动态扩缩容和故障恢复;K8s Job 模式更简单,适合确定性任务。我一般数据量超过 100 万条用 Ray,小规模用 Job。
4.4 性能调优的参数计算
批量推理的性能调优,核心是找到显存和吞吐的平衡点。这里给一个粗略的计算方法。
假设模型是 7B,FP16 权重约 14G。KV Cache 每 token 占用显存:
KV Cache per token = 2 * num_layers * hidden_size * 2 bytes以 Qwen2.5-7B 为例,28 层,hidden_size 3584:
2 * 28 * 3584 * 2 = 401,408 bytes ≈ 0.4 MB per token如果max_model_len是 4096,单条序列的 KV Cache 约 1.6G。GPU 显存 80G,权重占 14G,激活值和其他开销约 10G,剩余 56G 给 KV Cache。理论上可以同时跑 35 条 4096 长度的序列。但实际因为碎片和调度开销,能跑 25 到 30 条就不错了。
如果序列平均长度只有 512,单条 KV Cache 约 0.2G,那可以同时跑 280 条。这就是为什么长度分布对批大小影响这么大。
调优的时候,先用小批量数据跑,观察 vLLM 日志里的GPU KV cache usage。如果这个值在 80% 到 95% 之间,说明批大小合适。低于 80%,可以调大max_num_seqs;高于 95% 且出现 preemption,需要调小。
5. 常见问题与排查技巧实录
5.1 GPU 利用率上不去的排查思路
这是批量推理最常见的问题。GPU 利用率低,意味着钱白花了。排查顺序如下。
先看数据加载。用iostat或者nvidia-smi dmon看磁盘 IO 和 GPU 利用率的关系。如果 GPU 利用率周期性掉到 0,说明数据加载跟不上。解决办法是用 Ray Data 做预取,或者把数据放到更快的存储上(本地 SSD 比网络存储快很多)。
再看 tokenize。tokenize 是 CPU 操作,如果 CPU 核数不够,tokenize 会成为瓶颈。用htop看 CPU 利用率,如果某个核跑满,说明 tokenize 是单线程的,需要并行化。Ray Data 的map_batches可以设置num_cpus来并行 tokenize。
然后看批大小。如果数据加载和 tokenize 都没问题,GPU 利用率还是低,那就是批大小不够。调大max_num_seqs和max_num_batched_tokens,观察显存和利用率的变化。
最后看模型并行。如果用了 tensor_parallel_size > 1,卡间通信可能成为瓶颈。用nvidia-smi topo -m看卡间连接方式,NVLink 比 PCIe 快很多。如果卡间是 PCIe 且通信量大,考虑换用 pipeline parallel 或者减少并行度。
5.2 OOM 的常见原因和解决
OOM 在批量推理里很常见,原因通常有几个。
一是max_model_len设太大。实际数据最长只有 2048,设成 8192,KV Cache 预分配多了 4 倍。解决办法是统计实际长度分布,设成 p99 加一点余量。
二是批大小太大。max_num_seqs设成 512,但显存只够跑 256 条。解决办法是调小,或者用gpu_memory_utilization控制预分配比例。
三是碎片化。长时间运行后,显存碎片化导致没有连续空间分配。PagedAttention 已经很大程度上解决了这个问题,但如果还是 OOM,可以尝试重启引擎,或者调小block_size。
四是其他进程占用显存。Jupyter、TensorBoard 这些进程可能占着显存不放。用nvidia-smi看哪些进程在占,杀掉不必要的。
5.3 输出质量不稳定的处理
批量推理里,输出质量不稳定通常和采样参数有关。temperature设太高,输出随机性大;设太低,输出重复。批量任务里,如果任务需要确定性输出,把temperature设成 0,top_p设成 1。如果需要多样性,temperature设 0.7 到 0.9,top_p设 0.9 到 0.95。
另一个原因是max_tokens设太小,输出被截断。统计一下输出长度分布,把max_tokens设成 p99 加一点余量。
还有一个容易忽略的点是prompt 格式。批量推理里 prompt 是拼接好的,如果格式和模型训练时不一致,输出质量会下降。比如 ChatML 格式需要<|im_start|>和<|im_end|>标记,漏了的话模型可能输出奇怪的内容。建议用 tokenizer 的apply_chat_template方法生成 prompt,确保格式正确。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决办法 |
|---|---|---|---|
| GPU 利用率低于 50% | 数据加载慢 | 看磁盘 IO 和 GPU 利用率关系 | 用 Ray Data 预取,换本地 SSD |
| GPU 利用率低于 50% | tokenize 慢 | 看 CPU 利用率 | 并行 tokenize,增加 CPU 核数 |
| GPU 利用率低于 50% | 批大小不够 | 看 KV Cache 使用率 | 调大 max_num_seqs |
| OOM | max_model_len 太大 | 统计实际序列长度 | 调小 max_model_len |
| OOM | 批大小太大 | 看显存使用曲线 | 调小 max_num_seqs |
| OOM | 显存碎片 | 看 nvidia-smi 显存分布 | 重启引擎,调小 block_size |
| 输出被截断 | max_tokens 太小 | 统计输出长度 | 调大 max_tokens |
| 输出质量差 | prompt 格式不对 | 检查 prompt 模板 | 用 apply_chat_template |
| 输出质量差 | 采样参数不当 | 检查 temperature/top_p | 调整采样参数 |
| 多卡并行慢 | 卡间通信瓶颈 | nvidia-smi topo -m | 换 NVLink,减少并行度 |
| 任务中途失败 | 单点故障 | 看 Pod 日志 | 用 Ray 容错,加 restartPolicy |
| 结果写入冲突 | 并发写同一文件 | 看文件锁 | 分片写入,最后合并 |
5.5 几个踩过的坑
第一个坑是镜像里的 vLLM 版本和模型不匹配。有次用vllm/vllm-openai:v0.27.1加载 Qwen3-Embedding-0.6B,报了一堆奇怪的错误,排查半天发现是版本太老不支持这个模型架构。解决办法是看 vLLM 的 release notes,确认版本支持目标模型。或者直接用最新版镜像,但要注意新版本可能有性能回退,上线前先小批量测试。
第二个坑是K8s 的 GPU 配额不够。有次提交任务,Pod 一直 Pending,看事件发现是根组织的云原生开发-gpu配额已不够预冻结。这是集群的 GPU 配额限制,需要找管理员扩容,或者等之前的任务释放资源。批量任务最好设置priorityClass,低优先级的任务可以被高优先级的抢占。
第三个坑是共享内存不够。vLLM 用共享内存做进程间通信,默认 64M 在批量推理里完全不够。表现是引擎启动时报Bus error或者shared memory allocation failed。解决办法是在 K8s 里挂载大容量的/dev/shm,或者用--shm-size参数起 Docker 容器。
第四个坑是数据格式不一致。输入数据里有的字段是字符串,有的是列表,tokenize 的时候报错。批量推理前一定要做数据校验,确保所有样本的格式一致。可以用 Ray Data 的map做校验,把不合格的样本过滤掉或者修正。
第五个坑是结果写入太慢。推理跑完了,写结果花了几个小时。原因是结果文件太多小文件,或者写入的存储带宽不够。解决办法是合并小文件,用 Parquet 格式而不是 JSONL,或者写到更快的存储上。
6. 批量推理的扩展方向
批量推理跑通之后,还有几个方向可以继续优化。
一是前缀缓存。如果大量 prompt 共享相同的前缀(比如 few-shot 模板),vLLM 的自动前缀缓存能大幅减少预填充计算。开启方式是设置enable_prefix_caching=True。实测在 few-shot 场景下,吞吐能提升 2 到 3 倍。
二是量化。FP16 推理显存占用大,用 AWQ 或 GPTQ 量化到 4bit,显存占用降到四分之一,批大小能开大 4 倍。代价是精度略有下降,但批量任务里通常可以接受。vLLM 支持加载量化模型,只需要在LLM初始化时指定quantization="awq"。
三是投机解码。用一个小模型做 draft,大模型做 verify,能在保持输出质量的前提下提升 decode 速度。vLLM 支持speculative_model参数,但配置起来比较复杂,适合对吞吐有极致要求的场景。
四是多模态批量推理。如果任务涉及图片或音频,vLLM 也支持多模态模型。数据准备和单模态类似,只是 prompt 里要包含图片路径或 base64 编码。注意多模态模型的显存占用更大,批大小要相应调小。
五是和向量数据库集成。批量 embedding 任务跑完后,结果通常要写入向量数据库(Milvus、Qdrant、pgvector)。Ray Data 可以直接对接这些数据库的写入接口,省掉中间落盘再导入的步骤。
这些扩展方向不是必须的,但了解它们能帮你在遇到性能瓶颈时知道往哪个方向优化。我个人的经验是,先把基础流程跑通,把 GPU 利用率做到 70% 以上,再考虑这些高级特性。基础没打好就上高级特性,往往事倍功半。
最后分享一个我在实际项目里总结的小技巧:批量推理任务开始前,先用 1000 条数据跑一个 mini batch,记录吞吐、显存、GPU 利用率。然后根据这个结果估算全量数据的时间和成本。如果估算结果和预期差距大,先调优再跑全量,别直接上全量数据,跑了一半发现有问题,时间和钱都浪费了。这个 mini batch 测试花不了几分钟,但能省下大量返工时间。