
1. 批量推理到底在解决什么问题1.1 从一次尴尬的线上事故说起去年冬天我帮一个做智能客服的团队排查线上问题他们的场景很典型白天用户咨询量不大单条请求的响应速度也还行但每天凌晨两点会跑一批历史会话的意图重分类任务一次要处理八十多万条文本。他们最初的做法很朴素写了个 Python 脚本用for循环一条一条调本地部署的推理服务结果这批任务从凌晨两点一直跑到第二天上午十点还没结束GPU 利用率在监控面板上像心电图一样忽高忽低平均只有百分之十几。这个现象背后就是批量推理要解决的核心矛盾。单条推理追求的是低延迟用户发一句话希望几百毫秒内看到回复而批量推理追求的是高吞吐同样一批数据希望单位时间内处理得越多越好。这两个目标在工程实现上几乎是相反的为了降低单条延迟你会希望请求一到就立刻调度、立刻占用算力、立刻返回为了提升整体吞吐你反而希望攒一攒、凑一凑把多个请求打包成一个大批次一起送进 GPU让矩阵乘法的维度尽可能大把显存带宽和计算单元喂饱。同一个推理引擎比如 vLLM白天在线上服务时用的是连续批处理Continuous Batching配合较小的批大小保证首 token 延迟到了夜间离线任务就应该切换到另一套参数配置把max_num_seqs、max_num_batched_tokens拉高甚至关掉一些为交互场景优化的调度策略。这就是标题里说的“同一个引擎相反的目标”——引擎是同一套代码、同一份权重但调度目标、参数取向、资源编排方式完全不同。1.2 谁需要认真对待批量推理如果你符合下面任意一条批量推理就不是一个可以糊弄过去的环节手上有几十万到上亿条文本、图片或音频需要做离线打标、分类、摘要、向量化在做 RAG 系统的知识库构建需要把海量文档切片后批量生成 embedding做模型评测要在几千条测试集上跑多个模型的对比做数据清洗和合成用大模型批量生成训练语料做推荐系统的特征回填需要对历史行为做批量打分。这些场景的共同点是数据量大、对单条延迟不敏感、对总耗时和成本极度敏感。一条请求慢两百毫秒没人会在意但整批任务多跑六个小时就意味着 GPU 租用成本直接翻倍。我见过太多团队在模型选型上反复纠结却在批量推理的工程实现上随手写个循环最后算力账单里一大半都浪费在了调度空转和显存碎片上。1.3 批量推理和在线推理的本质差异很多人以为批量推理就是“把在线接口循环调用一遍”这个认知偏差会导致后面所有的优化都跑偏。我把两者的关键差异整理成一张表你可以对照自己的场景看看维度在线推理批量推理核心指标首 token 延迟、尾延迟总吞吐、单位成本请求到达随机、稀疏、不可预测已知、可一次性枚举批大小策略动态、偏小静态或大动态、偏大调度优先级公平性、超时保护吞吐最大化、显存利用率失败处理立即重试、降级记录后跳过、批量重跑资源编排常驻服务、弹性扩缩一次性任务、用完即释放典型工具vLLM 在线模式、TGIvLLM 离线模式、Ray Data看这张表你会发现批量推理其实更接近传统的大数据离线作业而不是 Web 服务。它需要的是任务编排、分片、容错、断点续跑这一整套东西而不是一个 HTTP 接口。这也是为什么后面我会重点讲 Ray Data 和 Kubernetes 的配合——它们本来就是为这类离线作业设计的。2. 引擎选型为什么是 vLLM而不是别的2.1 vLLM 在批量场景下的三个杀手锏选 vLLM 做批量推理的底座不是因为它名气大而是它在批量场景下有三个实打实的优势。第一个是PagedAttention。传统推理框架给每个请求预分配一块连续的 KV Cache 显存请求长度不可预测时要么浪费按最大长度分配要么频繁重分配按实际长度动态扩。PagedAttention 把 KV Cache 切成固定大小的块像操作系统管理内存页一样按需分配显存浪费能压到百分之几以内。批量推理里请求长度往往参差不齐短的两百 token长的八千 token这个机制带来的显存节省直接决定了你一张卡能同时塞多少条请求。第二个是连续批处理。静态批处理要等一整批请求全部生成完才能开始下一批一条长请求会拖住整批。连续批处理在每一步解码后就把已完成的请求踢出、把新请求塞进来GPU 几乎不会空转。在离线批量场景下这个机制让吞吐能比静态批处理高出两到四倍。第三个是离线推理接口。vLLM 提供了LLM.generate()这样的离线入口直接吃一个 prompt 列表内部自动做批调度不需要你起 HTTP 服务、不需要处理网络开销。这一点在批量任务里非常关键——网络往返和序列化在百万级请求下会变成不可忽视的成本。2.2 vLLM 和 SGLang 在批量场景的取舍最近社区里 SGLang 和 vLLM 的对比讨论很多我两个都用过说说批量场景下的实际感受。SGLang 的RadixAttention在前缀共享明显的场景下确实猛比如你有一批请求都带着同一个很长的系统提示词或者做多轮对话的批量回放它能把这些公共前缀的 KV Cache 复用起来吞吐提升非常可观。如果你的批量任务恰好是这种形态SGLang 值得一试。但 vLLM 的优势在于生态成熟度和稳定性。它的离线接口 API 更稳定和 Ray、Kubernetes 的集成案例更多社区里踩过的坑也更多遇到问题更容易搜到答案。而且 vLLM 对量化模型的支持更全面AWQ、GPTQ、FP8 这些量化格式在批量场景下能显著降低显存占用让你用更少的卡跑更多的数据。我的建议是前缀共享极强、追求极致吞吐可以试 SGLang追求稳定、需要复杂编排、要用量化选 vLLM。批量推理最怕的不是慢一点而是跑到一半崩了还得从头再来稳定性权重应该给得很高。2.3 版本选择的坑别盲目追新热词里有一条“vllm 新版本性能下降”这不是个例。vLLM 迭代非常快几乎每周都有新版本但新版本不一定适合你的批量场景。我踩过的坑是某个版本为了优化在线场景的调度公平性调整了调度器的默认行为结果离线批量吞吐掉了将近百分之二十。我的做法是锁定版本、做好基准测试。具体来说选定一个版本后用你自己的真实数据跑一次基准记录吞吐和显存峰值升级前先在测试环境用同样的数据跑一遍对比数字把镜像 tag 写死比如vllm/vllm-openai:v0.27.1这种带具体版本号的绝对不要用latest。提示批量任务的镜像一定要固定 digest 或精确版本号。用latest意味着某天凌晨任务重跑时拉到的镜像可能已经变了行为不一致会让你排查到怀疑人生。3. 用 Ray Data 把批量任务拆开3.1 为什么不能一个进程吃下所有数据假设你要给一千万条文本生成 embedding。最直觉的做法是全部读进内存组成一个大列表丢给 vLLM 的generate()。这个做法在小数据量下没问题但一千万条文本光原始数据就可能几十 GB加上 tokenize 后的结果、中间状态内存直接爆掉。就算内存扛得住单进程单卡的吞吐也有上限你没法利用多卡并行。Ray Data 解决的就是这个问题。它把数据集抽象成一个分布式的流式管道数据被切成块block在多个 worker 上并行处理每个 worker 负责一部分数据处理完的块流向下一个阶段。整个过程是流式的不需要把全量数据同时放进内存。3.2 Ray Data 管道的四个阶段一个典型的批量推理管道长这样import ray from ray.data import Dataset from vllm import LLM, SamplingParams # 1. 读取阶段从各种数据源加载 ds ray.data.read_parquet(s3://my-bucket/input-data/) # 2. 预处理阶段清洗、截断、构造 prompt def build_prompt(row): text row[content][:4000] # 截断防止超长 return {prompt: f请对以下文本分类\n{text}\n类别} ds ds.map(build_prompt) # 3. 推理阶段每个 worker 持有一个 vLLM 实例 class VLLMPredictor: def __init__(self): self.llm LLM( modelQwen/Qwen2.5-7B-Instruct, tensor_parallel_size1, max_num_seqs256, gpu_memory_utilization0.9, ) self.sampling SamplingParams(temperature0, max_tokens16) def __call__(self, batch): prompts batch[prompt] outputs self.llm.generate(prompts, self.sampling) return {result: [o.outputs[0].text for o in outputs]} ds ds.map_batches( VLLMPredictor, batch_size512, concurrency4, # 4 个并发 worker num_gpus1, # 每个 worker 占 1 张卡 ) # 4. 写出阶段 ds.write_parquet(s3://my-bucket/output-data/)这四个阶段对应了批量推理的完整生命周期。读取阶段要注意数据源的分片Parquet 天然支持按 row group 切分比读一堆小 JSON 文件高效得多。预处理阶段是最容易被忽视的很多人把原始数据直接丢给模型结果超长文本触发截断逻辑、脏数据导致 tokenize 报错整个任务挂掉。推理阶段是核心下面单独展开。写出阶段建议用 Parquet 而不是 JSON压缩率高、读取快、schema 明确。3.3 并发度和批大小的调参逻辑concurrency和batch_size这两个参数是批量推理调优的重头戏很多人凭感觉设结果要么 GPU 没吃满要么显存爆掉。先说concurrency它决定同时有多少个 worker 在跑。每个 worker 占一张 GPUnum_gpus1所以concurrency基本等于你分配的 GPU 数量。如果你有 8 张卡设concurrency8就能全部用上。但要注意如果单卡显存不够放下一个完整的 vLLM 实例你就得考虑用张量并行让多个 worker 共享一个模型实例这时候num_gpus要设成小数比如num_gpus0.5表示两个 worker 共用一张卡。再说batch_size它决定每个 worker 一次处理多少条数据。这个值不是越大越好。批太大单次generate()调用会占用大量显存做 KV Cache可能触发 OOM批太小GPU 利用率上不去调度开销占比过高。我的经验值是从 256 开始试观察显存峰值和吞吐逐步加到显存用到百分之八十五左右为止。这里有个容易忽略的点batch_size和 vLLM 内部的max_num_seqs是两回事。batch_size是 Ray 层面一次喂给 vLLM 的请求数max_num_seqs是 vLLM 内部同时调度的序列数。如果batch_size远大于max_num_seqs多出来的请求会在 vLLM 内部排队不会浪费但也不会提升吞吐。理想情况下两者接近让 Ray 的批和 vLLM 的调度窗口匹配。3.4 显存估算动手算一遍批量推理最容易翻车的地方就是显存。我给你一个粗略但实用的估算方法以 7B 模型、FP16 精度为例模型权重本身占7B × 2 字节 14GB。KV Cache 的计算稍微复杂每个 token 的 KV Cache 大小约等于2 × 层数 × 隐藏维度 × 2 字节。以 Qwen2.5-7B 为例28 层、隐藏维度 3584那么每个 token 约2 × 28 × 3584 × 2 401KB。如果平均序列长度 1024同时调度 256 条序列KV Cache 就是401KB × 1024 × 256 ≈ 105GB。这个数字一看就超了单张 80GB 卡的容量。所以实际部署时要么降低max_num_seqs要么用张量并行把模型和 KV Cache 分摊到多张卡要么上量化把权重压到 4bit。这就是为什么前面强调要动手算——凭感觉设参数十有八九会 OOM。注意gpu_memory_utilization设成 0.9 意味着 vLLM 会尝试占用 90% 的显存剩下的留给 CUDA 上下文和其他开销。设太高比如 0.98容易在长序列时 OOM设太低比如 0.7则浪费显存。0.85 到 0.92 是比较稳的区间。4. Kubernetes 上的批量任务编排4.1 为什么批量推理要上 K8s有人会问批量任务跑一次就完了用 K8s 是不是杀鸡用牛刀我的回答是数据量小、跑一次就完确实不用但只要涉及多卡、多机、需要重跑、需要排队K8s 就是刚需。批量推理的资源需求是脉冲式的任务来了要几十张卡任务跑完这些卡就该释放给别的任务。如果用手动管理你得盯着任务什么时候结束、手动释放机器效率极低。K8s 的 Job 和 CronJob 天然适合这种场景任务提交后自动调度、自动分配 GPU、跑完自动回收。更重要的是容错。批量任务动辄跑几个小时中间某张卡出问题、某个 worker 挂掉是常态。K8s 的 Job 支持backoffLimitworker 失败后自动重启配合 Ray 的断点续跑能力可以从上次的进度继续而不是从头再来。4.2 GPU 资源声明的正确姿势在 K8s 里申请 GPU最基础的方式是在 Pod spec 里写resources: limits: nvidia.com/gpu: 1这要求集群里装了 NVIDIA 的设备插件。但批量推理场景下这种粗粒度的申请往往不够用。比如你想让两个小模型共享一张卡或者想把一张卡切成几份给不同的任务就需要GPU 虚拟化方案比如 HAMi 这类工具。它能把一张物理卡切成多个虚拟 GPU每个 Pod 申请nvidia.com/gpu: 0.5这样的份额。不过我要提醒一句GPU 虚拟化在批量推理里要慎用。切分后的显存和算力都是打折的如果每个分片都跑一个 vLLM 实例KV Cache 空间会被严重压缩反而容易 OOM。虚拟化更适合推理负载轻、模型小的场景。大模型的批量推理老老实实一卡一实例更稳。4.3 一个可复用的 Job 模板下面这个 Job 模板是我在实际项目里反复用过的做了精简你可以直接改成自己的apiVersion: batch/v1 kind: Job metadata: name: batch-inference-job spec: backoffLimit: 3 ttlSecondsAfterFinished: 3600 template: spec: restartPolicy: OnFailure containers: - name: inference image: vllm/vllm-openai:v0.27.1 command: [python, /workspace/run_batch.py] resources: limits: nvidia.com/gpu: 4 volumeMounts: - name: data mountPath: /data - name: model-cache mountPath: /root/.cache/huggingface env: - name: RAY_ADDRESS value: auto volumes: - name: data persistentVolumeClaim: claimName: batch-data-pvc - name: model-cache persistentVolumeClaim: claimName: model-cache-pvc几个关键点值得说明。ttlSecondsAfterFinished让任务完成后一小时自动清理 Pod避免堆积一堆 Completed 状态的 Pod 占着资源。restartPolicy: OnFailure保证 worker 挂了会重启。模型缓存挂一个 PVC 非常重要——如果不挂每次任务启动都要重新下载几十 GB 的权重光下载就能耗掉半小时。数据卷同理输入输出都走 PVC 或对象存储挂载。4.4 断点续跑批量任务的保命符批量任务最怕的就是跑到百分之九十崩了然后从头再来。断点续跑的实现思路是每处理完一个分片就把进度记录到外部存储。具体做法是把输入数据按行数或文件切分成多个分片每个分片处理完后在输出目录写一个标记文件或者往一个进度表里写一条记录。任务重启时先读进度跳过已完成的分片。Ray Data 本身对某些数据源支持 checkpoint但更通用的做法是自己控制分片粒度。我一般会把分片大小控制在单分片处理时间五到十分钟。太小了进度记录开销占比高太大了崩一次损失太多。这个粒度下即使任务中途挂了最多损失十分钟的算力。5. 实操中踩过的坑和排查技巧5.1 吞吐上不去的五个常见原因批量推理跑起来后第一件事是看吞吐。如果发现 GPU 利用率上不去、总耗时远超预期按下面这个顺序排查现象可能原因排查方法解决方向GPU 利用率低于 30%批太小或数据加载慢看nvidia-smi和 CPU 占用增大 batch_size加速数据读取显存快满但吞吐低KV Cache 碎片或序列过长看 vLLM 日志的 running/waiting 队列限制 max_tokens调 max_num_seqs吞吐忽高忽低数据分片不均看各 worker 处理速度重新分片打散长尾数据启动后长时间无输出模型加载或下载慢看容器日志挂载模型缓存 PVC多卡吞吐不线性通信瓶颈或负载不均看 NCCL 日志检查张量并行配置和网络这张表里的每一条我都在真实项目里遇到过。最常见的是第一条和第四条。数据加载慢尤其隐蔽——你以为瓶颈在 GPU其实 CPU 在拼命解析 JSONGPU 在等数据。解决办法是把输入数据预先转成 Parquet 或 Arrow 格式读取速度能快一个数量级。5.2 长序列导致的 OOM 怎么破批量推理里最头疼的就是长序列。你的数据里可能百分之九十九都是短文本但总有那么几条超长的一旦它们被调度进来KV Cache 瞬间膨胀直接 OOM 把整个 worker 干掉。我的处理策略是分层处理先按长度把数据分桶短序列一批、长序列单独一批。短序列用大批大小跑长序列用小批大小甚至单条跑。这样既保证了整体吞吐又避免了长尾数据拖垮整个任务。具体实现上可以在预处理阶段统计每条数据的 token 长度按长度区间打标签然后按标签分组处理。vLLM 的max_model_len参数要设成你数据里的最大长度但max_num_seqs要根据当前批次的实际长度动态调整——这一步 Ray Data 的map_batches里可以自己控制。提示如果数据里存在极端长尾比如有几千条超过模型上下文长度的一定要在预处理阶段就截断或过滤掉不要让它们进入推理阶段。截断策略要结合业务分类任务截断尾部通常没问题摘要任务截断可能丢关键信息。5.3 结果顺序错乱和去重问题批量推理有个隐蔽的坑输出顺序和输入顺序对不上。Ray Data 是分布式并行的多个 worker 同时处理不同的分片写出时顺序是乱的。如果你后续要按原始顺序对齐结果必须在数据里带一个唯一 ID输出时也带上最后按 ID 排序。另一个坑是重复处理。任务失败重跑时如果进度记录不精确已经处理过的数据可能被再处理一遍导致输出里有重复。解决办法是在输出端做幂等——按 ID 去重或者用支持覆盖写的存储格式。5.4 监控指标该看哪些批量任务跑起来后别只盯着“跑完了没”。我一般会监控这几个指标吞吐每秒处理多少条这是最核心的指标GPU 利用率稳定在百分之七十以上算健康显存峰值离上限留百分之十以上余量各 worker 进度差异差异过大说明分片不均失败重试次数频繁重试说明有系统性问题。这些指标可以通过 Ray Dashboard 看也可以导出到 Prometheus 做告警。批量任务不需要实时告警但任务结束后要能复盘——哪个阶段慢、哪张卡拖后腿这些数据是下次调优的依据。6. 成本优化的几个实战思路6.1 量化用精度换显存和速度批量推理对精度的小幅下降通常不敏感这给了量化很大的空间。7B 模型从 FP16 量化到 AWQ 4bit显存占用从 14GB 降到约 4GB同样的卡能塞下更多序列吞吐提升明显。实测下来AWQ 量化在分类、抽取这类任务上准确率下降通常在百分之一以内完全可接受。但要注意量化不是免费的。反量化计算会带来额外开销在某些卡上可能抵消掉显存节省带来的收益。而且不是所有模型都有现成的量化版本自己量化需要校准数据集有额外工作量。我的建议是先用官方或社区提供的量化版本试效果好再考虑自己量化。6.2 抢占式实例便宜但要有容错云上的抢占式 GPU 实例价格可能只有按需实例的三分之一甚至更低对批量任务非常有吸引力。但抢占式实例随时可能被回收这就要求你的任务必须支持断点续跑。前面讲的进度记录机制在这里就是刚需。我的做法是把批量任务设计成可中断、可恢复的然后用抢占式实例跑。任务被回收了K8s 会自动重新调度从上次进度继续。这样既省了钱又不影响最终结果。前提是进度记录要足够频繁损失控制在可接受范围内。6.3 批大小和成本的量化关系批大小对成本的影响不是线性的。批太小GPU 利用率低单位数据的算力成本高批太大可能 OOM 导致任务失败重跑反而更贵。存在一个成本最优的批大小通常在显存用到百分之八十五左右的位置。我做过一组对比测试同一个 7B 模型处理十万条文本批大小总耗时GPU 利用率单位成本6482 分钟41%基准的 1.8 倍25638 分钟78%基准51235 分钟83%基准的 0.95 倍1024OOM 重跑-基准的 1.3 倍可以看到从 64 加到 256成本几乎腰斩从 256 加到 512收益已经很小再往上就 OOM 了。所以调参的目标不是“越大越好”而是找到那个拐点。7. 从单机到集群的扩展路径7.1 单机多卡先跑通再扩展我建议所有批量推理项目都从单机多卡开始。一台 8 卡机器用 Ray 起一个本地集群concurrency8先把整个管道跑通。这个阶段的目标不是性能而是验证正确性——数据读取对不对、prompt 构造对不对、输出格式对不对、断点续跑能不能工作。单机阶段最容易暴露的问题是数据格式和预处理逻辑。这些问题在单机上排查成本低一旦上了多机集群日志分散、复现困难排查成本会高很多。7.2 多机集群网络和存储是瓶颈扩展到多机后瓶颈往往从 GPU 转移到网络和存储。模型权重加载、数据读取、中间结果传输都要走网络。这时候要注意几点模型缓存要共享用 NFS 或对象存储做模型缓存避免每台机器都下载一遍数据本地性尽量让计算靠近数据或者把数据预先分发到各节点网络带宽张量并行对网络带宽要求高跨机张量并行最好用高速网络。多机场景下Ray 的集群模式能自动处理节点发现和任务分发但前提是各节点的环境要一致——Python 版本、CUDA 版本、依赖库版本都要对齐否则会出现各种诡异的错误。7.3 任务队列让批量任务排队跑当团队里有多个人都要跑批量任务时就需要一个任务队列来管理 GPU 资源。K8s 的 Job 本身有调度能力但多个 Job 争抢 GPU 时默认是先到先得容易造成资源碎片。更优雅的做法是用Kueue这类批处理调度器它支持队列、配额、优先级能让多个批量任务有序地共享 GPU 资源池。配置好配额后每个团队提交的任务会在自己的配额内排队不会互相抢占。这个在多人协作的环境里非常实用。8. 一些不那么显然的经验8.1 预热很重要vLLM 实例启动后第一次推理会明显慢——CUDA 内核要编译、显存要分配、各种缓存要建立。如果批量任务一开始就处理真实数据前几百条会拖慢整体进度。我的做法是在正式处理前用几条假数据做一次预热推理把各种初始化开销提前消化掉。8.2 日志要结构化批量任务的日志量很大几百万条数据的处理过程如果每条都打日志日志文件能撑爆磁盘。但完全不打日志出问题又没法排查。我的做法是结构化日志加采样每个分片记录开始和结束、处理条数、耗时、失败条数单条数据的详细日志只在失败时记录。这样既能定位问题又不会淹没在日志海里。8.3 输出要可验证批量任务跑完后怎么确认结果是对的我的习惯是在输出里保留足够的元信息原始 ID、输入摘要、模型输出、耗时、使用的模型版本。这样后续做质量抽检时能快速定位到具体是哪条数据、用了哪个版本、输出了什么。没有这些元信息出了问题只能干瞪眼。8.4 别忽视 tokenizer 的开销在大批量场景下tokenize 本身可能成为瓶颈。尤其是用 Python 的 tokenizer 逐条处理时CPU 会成为限制。解决办法是用批量 tokenize 接口或者用 Rust 实现的快速 tokenizer。vLLM 内部已经做了优化但如果你在预处理阶段自己调 tokenizer要注意这一点。8.5 版本锁定要贯彻到底前面提过镜像版本要锁定其实模型版本、依赖库版本、甚至 CUDA 版本都要锁定。批量任务的可复现性依赖于整个环境的确定性。我见过因为 transformers 库小版本升级导致 tokenizer 行为变化最终输出结果和上次不一致的案例。在批量场景下这种不一致可能意味着整个数据集要重跑。批量推理这件事说到底就是把“同一个引擎”用在“相反的目标”上。在线服务那套为延迟优化的思路到了离线批量场景要整个反过来。引擎还是那个引擎但调度策略、参数配置、资源编排、容错机制全都要重新设计。我个人的体会是批量推理的难点从来不在模型本身而在工程——怎么把数据喂饱、怎么让 GPU 不空转、怎么在崩了之后不从头再来。把这几个问题解决了批量推理的效率和成本自然就上去了。