一个训练好的权重文件,离「能被业务调用的服务」还差很远:要处理并发请求、要拼接与截断输入、要管理显存、要暴露指标、要能灰度与回滚。把模型变成服务的过程叫模型服务化(Model Serving),由一类专门的推理引擎(Inference Engine)承担。
选错引擎的代价非常直接。同一张 A100,用朴素 Flask 包一层接口,吞吐可能只有每秒几十个 token;换成 vLLM 的连续批处理,同样硬件能到上千。本文对比三种主流方案——NVIDIA Triton、vLLM、Hugging Face TGI,讲清各自的适用边界与生产部署要点。
推理引擎到底解决什么
把服务化拆开,引擎要做的事分三层:
- 协议层:接收 HTTP/gRPC 请求,做鉴权、限流、超时控制、流式返回(Server-Sent Events)。业务方不应感知底层是 PyTorch 还是 ONNX。
- 调度层:把并发请求攒成批次、排队、抢占、回收显存。这是引擎性能差异的核心来源,也是「动态批处理」与「连续批处理」的分水岭。
- 执行层:把张量送进 GPU,选择 kernel、管理算子融合与并行切分(张量并行 / 流水并行),并处理不同精度(FP16 / BF16 / INT8 / FP8)。
一个常被忽略的事实是:推理瓶颈往往不在算力,而在显存带宽。生成式模型逐 token 解码时,每步都要把全部权重从显存读一遍。因此引擎的两条主线是——减少重复访存(KV Cache、前缀缓存)与提高批次密度(让 GPU 每步处理更多请求)。
还有一组必须提前想清楚的约束:吞吐与延迟此消彼长。批次越大,单步摊薄的开销越少,吞吐越高;但每个请求要等更久才被组批,尾延迟随之上升。引擎的所有调度旋钮,本质上都在这个权衡曲线上选点。而冷启动是另一条暗线:7B 模型加载动辄二三十秒,扩容时若不预热,流量高峰期的弹性扩容反而会引发一波超时。
批处理的数学:为什么连续批处理更快
设单个请求的解码步数为 L,单步耗时为 t(与批大小近似无关,因为瓶颈在显存带宽而非算力),批大小为 B。静态批处理的总时间是 L·t,产出 B·L 个 token,吞吐为 B/t。
问题出在 L 不一致时:一批里有一个请求要生成 1000 个 token,另一个只要 50 个,整批必须等前者跑完,后者占着的显存白白空转。GPU 利用率在批尾急剧下降——这就是「气泡(bubble)」。
连续批处理在每个解码步重新组批:完成的请求立刻退出,队列里的新请求马上补位。吞吐的近似模型变为:只要队列不空,每一步都是满批,于是有效吞吐趋近 B/t 的上界,而不是被最慢请求拖到 B/(L_max·t) 的均值。
# 连续批处理的极简调度循环
def step_loop(queue, running, max_batch, max_tokens):
while queue or running:
# 1. 回收已结束的序列
running = [s for s in running if not s.finished]
# 2. 用空闲显存预算补位新请求
while queue and len(running) < max_batch:
s = queue.pop(0)
if s.prompt_len + s.max_new_tokens > max_tokens:
s.truncate(max_tokens) # 超长请求截断
running.append(s)
# 3. 对当前 running 做一次前向,每序列生成一个 token
logits = model.forward_batch([s.tokens for s in running])
for s, lg in zip(running, logits):
s.append_token(sample(lg)) # 逐 token 追加
真实实现(vLLM 的 Scheduler)还要处理抢占、分块预填充(chunked prefill)与显存换出,但骨架就是这三步。
Triton Inference Server:多框架通用服务
Triton 是 NVIDIA 出品的通用推理服务器,最大的特点是框架无关:同一个进程里可以同时托管 PyTorch、TensorRT、ONNX Runtime、TensorFlow、Python 后端甚至自定义 C++ 后端。
模型仓库(Model Repository)
Triton 按固定目录结构加载模型,每个模型一个子目录,版本用数字目录区分:
model_repository/
├── text_embedding/
│ ├── config.pbtxt
│ ├── 1/
│ │ └── model.onnx
│ └── 2/
│ └── model.onnx
└── reranker/
├── config.pbtxt
└── 1/
└── model.plan
目录里的数字就是版本号,Triton 默认加载最新版本,并支持多版本共存与灰度切换。
config.pbtxt 关键参数
name: "text_embedding"
platform: "onnxruntime_onnx"
max_batch_size: 64
input [
{
name: "input_ids"
data_type: TYPE_INT32
dims: [ -1 ]
}
]
output [
{
name: "embedding"
data_type: TYPE_FP32
dims: [ 768 ]
}
]
dynamic_batching {
preferred_batch_size: [ 8, 16, 32 ]
max_queue_delay_microseconds: 5000
}
instance_group [
{
count: 2
kind: KIND_GPU
gpus: [ 0, 1 ]
}
]
几个必须理解的字段:
| 参数 | 作用 | 调优方向 |
|---|---|---|
max_batch_size | 单次推理最大批大小 | 受显存限制,过大易 OOM |
preferred_batch_size | 优先攒到的批次档位 | 与 kernel 高效档位对齐 |
max_queue_delay_microseconds | 攒批等待上限 | 越大吞吐越高、延迟越差 |
instance_group.count | 每 GPU 上的模型实例数 | 提升并发,代价是显存翻倍 |
kind | KIND_GPU / KIND_CPU | 按算力需求选择 |
max_queue_delay_microseconds 是延迟与吞吐的直接旋钮。设成 5000(5ms)意味着请求最多等 5ms 攒批;对在线问答这种延迟敏感场景,通常设 1~5ms,而对离线批处理可以放到几十毫秒。
集成(Ensemble)与业务逻辑脚本
当服务需要「预处理 → 模型 → 后处理」多步串联时,Triton 提供 Ensemble 把多个模型串成一张有向图,数据在 Triton 内部以共享内存传递,不经过网络:
ensemble_scheduling {
step [
{ model_name: "tokenizer" model_version: -1
input_map { key: "text" value: "text" }
output_map { key: "input_ids" value: "input_ids" } }
{ model_name: "text_embedding" model_version: -1
input_map { key: "input_ids" value: "input_ids" }
output_map { key: "embedding" value: "embedding" } }
]
}
复杂的 Python 逻辑(如动态路由、外部特征拼接)则用 BLS(Business Logic Scripting) 后端,在 Python 里以进程内调用的方式触发其他模型,避免网络往返。
动态批处理与序列批处理
Triton 的 dynamic_batching 会把同一模型、不同请求的输入在批维度上拼接。对 Transformer 这类变长输入,用 sequence_batching 维护会话状态,避免每次重算上下文。这两者与 vLLM 的连续批处理并不是一回事——Triton 的批一旦形成就跑到结束,中途不会插入新请求。对生成式 LLM,Triton 通常配合 TensorRT-LLM 后端(inflight_batcher_llm)才能获得连续批处理能力。
vLLM:为 LLM 而生的引擎
vLLM 的核心创新是 PagedAttention:把 KV Cache 按固定大小的块(block)管理,像操作系统分页一样用页表映射逻辑序列到物理块。这解决了传统实现中「按最大长度预分配显存」造成的巨大浪费。
传统方案里,一个序列的 KV Cache 必须连续分配,长度按 max_model_len 预留。实际生成长度若只有一半,另一半显存就永远闲置,显存利用率常低至 20%~40%。分页后,块按需分配、用多少占多少,显存碎片几乎消失,实测可把显存利用率拉到 90% 以上,直接转化为更大的批大小与更高的吞吐。
连续批处理与抢占
vLLM 的调度器维护 waiting 与 running 两个队列,每个解码步尝试把 waiting 里的请求塞进 running。当显存不足以容纳所有 running 序列时,触发抢占(preempt):选一个序列换出,其 KV 块释放(recompute 策略)或搬到 CPU 内存(swap 策略)。
recompute:丢弃 KV,需要时重算前缀。适合序列短、算力富余的场景。swap:把 KV 换到 CPU 内存(--swap-space指定 GB 数),需要时换回。适合显存紧张但内存充裕的场景。
抢占次数是重要的健康指标:持续抢占说明显存不足、批密度被迫降低,应调低 --max-num-seqs 或升级显存。
启动与参数
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen2.5-7B-Instruct \
--tensor-parallel-size 2 \
--gpu-memory-utilization 0.90 \
--max-model-len 8192 \
--max-num-seqs 256 \
--enable-prefix-caching \
--swap-space 8 \
--port 8000
关键参数含义:
--tensor-parallel-size:把单层权重按列/行切到多卡,需要卡间高速互联(NVLink)。--gpu-memory-utilization:允许 vLLM 占用的显存比例,KV Cache 用剩余显存;0.90 是常用起点。--max-num-seqs:同时在飞的序列数上限,直接决定批密度。--enable-prefix-caching:相同前缀(如系统提示词)复用 KV,多轮对话场景收益显著。
vLLM 对外暴露 OpenAI 兼容接口,客户端可直接用 openai SDK 指向本地地址,迁移成本极低。
量化与 LoRA 多租户
vLLM 支持 GPTQ、AWQ、FP8 等量化权重直接加载,也支持运行期挂载多个 LoRA 适配器,实现「一个基座服务多个微调租户」:
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen2.5-7B-Instruct \
--quantization awq \
--enable-lora \
--max-loras 4 --max-lora-rank 32 \
--lora-modules tenant-a=/adapters/a tenant-b=/adapters/b
请求时通过 model 字段指定租户名,vLLM 在每步动态合并 LoRA 权重。这让「按客户定制」不必为每个客户单独起一个进程,显存利用率大幅提升。
TGI:Hugging Face 的生产化封装
TGI(Text Generation Inference)由 Hugging Face 维护,主打「开箱即用」:内置 token 流式输出、token 级水印、张量并行、量化加载与 OpenAI 兼容路由。
docker run --gpus all --shm-size 1g -p 8080:80 \
-v $PWD/data:/data \
ghcr.io/huggingface/text-generation-inference:latest \
--model-id meta-llama/Llama-3.1-8B-Instruct \
--num-shard 2 \
--max-input-length 4096 \
--max-total-tokens 8192 \
--max-batch-prefill-tokens 8192
TGI 的批处理同样支持连续组批,--max-batch-prefill-tokens 控制预填充阶段(prefill)的总 token 预算,是防止长输入把显存打爆的重要护栏。相比 vLLM,TGI 的配置项更少、更偏约定优于配置,适合快速上线标准 LLM。
三者对比与选型
| 维度 | Triton | vLLM | TGI |
|---|---|---|---|
| 定位 | 通用多框架服务器 | LLM 专用引擎 | LLM 生产化封装 |
| 批处理 | 动态批 / 序列批 | 连续批处理 | 连续批处理 |
| 显存管理 | 手动配置 | PagedAttention | PagedAttention 变体 |
| 多模型托管 | 原生支持 | 单模型为主 | 单模型 |
| 张量并行 | 支持 | 支持 | 支持 |
| 流式输出 | 支持 | 支持 | 原生优化 |
| 适合场景 | 传统 CV/NLP 多模型 | 自建 LLM 服务 | 快速上线 HF 模型 |
选型经验:多模型混布、需要严格版本管理用 Triton;追求 LLM 吞吐与成本用 vLLM;想最快跑通 Hugging Face 模型用 TGI。三者并不互斥——常见做法是 Triton 托管嵌入与重排模型,vLLM 单独托管生成模型,前面再挂一层网关做路由。
性能基准与压测
上线前必须拿到本机的真实曲线,而不是看论文数字。推荐用固定并发、逐级加压的方式,记录 TTFT 与吞吐:
# 用 vLLM 自带的基准脚本测吞吐
python benchmarks/benchmark_throughput.py \
--model Qwen/Qwen2.5-7B-Instruct \
--dataset-name sharegpt \
--num-prompts 1000 \
--output-len 256
# 用 vLLM 的在线压测测延迟
python benchmarks/benchmark_serving.py \
--model Qwen/Qwen2.5-7B-Instruct \
--num-prompts 500 --request-rate 20 \
--metric-percentiles 50,90,99
一份典型的结果(A100 80G,7B BF16):
| 并发 | 吞吐 (tok/s) | TTFT P50 (ms) | TPOT P99 (ms) |
|---|---|---|---|
| 1 | 55 | 40 | 22 |
| 8 | 380 | 65 | 25 |
| 32 | 950 | 180 | 38 |
| 128 | 1400 | 720 | 95 |
读法:吞吐随并发上升,但 TTFT 在并发 32 之后快速劣化。业务若要求首字延迟 < 200ms,就要把单实例并发压在这个拐点之下,多起实例横向扩展,而不是继续加大 --max-num-seqs。
生产部署要点
健康检查与就绪探针
模型加载可能耗时数十秒到数分钟(大模型权重读盘 + 编译)。就绪探针必须等引擎真正可服务后再放流量,否则会有一波 5xx:
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 30
扩缩容信号
GPU 服务不能只看 CPU 使用率。合理信号是队列深度与KV Cache 利用率:
# vLLM 暴露的 Prometheus 指标
vllm:num_requests_waiting # 排队请求数,持续 >0 说明要扩容
vllm:gpu_cache_usage_perc # KV Cache 占用率
vllm:time_to_first_token_seconds # TTFT,SLA 核心
按 num_requests_waiting 做 HPA 自定义指标,比按 GPU 利用率更贴合真实负载。GPU 扩缩容还要注意:缩容要慢、扩容要快——新 Pod 加载权重期间无法服务,缩得太急会在流量回升时踩空。
版本灰度与回滚
模型迭代频繁,必须支持不重启切换版本。Triton 通过模型控制 API 显式加载/卸载版本,天然支持多版本共存:
# 加载新版本 3,此时 2 与 3 同时在内存
curl -X POST localhost:8000/v2/repository/models/text_embedding/versions/3/load
# 灰度:把 10% 流量切到 v3
# 观察指标后,卸载旧版本
curl -X POST localhost:8000/v2/repository/models/text_embedding/versions/2/unload
vLLM/TGI 单进程只托管一个模型,版本切换靠蓝绿部署:新版本起一组新 Pod,就绪后把 Service 权重逐步切过去,旧 Pod 保留一段时间以便快速回滚。无论哪种方式,都要保留「一键回到上一版本」的能力——模型质量问题是概率性的,指标恶化常常滞后几小时才显现。
常见坑
- 冷启动慢:权重放本地 NVMe 或做镜像预热,别每次从对象存储拉。
- OOM 随机出现:
gpu-memory-utilization设太高,留 5%~10% 余量给临时激活。 - 首 token 慢、后续快:预填充阶段计算密集,可调
max-batch-prefill-tokens限制单批预填充量。 - 流式返回被缓冲:反向代理(Nginx/Ingress)需关闭 response buffering,否则流式变成一次性返回。
- 多实例争抢显存:同卡跑多个引擎时,各自按整卡比例估算会超配,需显式限制显存。
成本估算与硬件选型
服务化的最终约束是钱。一张 A100 80G 的云端按需价约 3~4 美元/小时,能否跑满决定了单位 token 成本。粗略估算:
| 场景 | 卡型 | 单卡吞吐 | 每小时 token 数 | 每百万 token 成本 |
|---|---|---|---|---|
| 7B 对话 | A10G 24G | ~900 tok/s | 3.2M | ~0.4 美元 |
| 7B 对话(量化) | A10G 24G | ~1500 tok/s | 5.4M | ~0.25 美元 |
| 70B 对话 | 2×A100 80G | ~700 tok/s | 2.5M | ~2.8 美元 |
| 嵌入模型 | T4 16G | ~5000 req/s | — | 极低 |
从表里能读出两条选型原则:能用小模型解决就别上大模型(7B 与 70B 的成本差近十倍);嵌入与重排这类小模型别占大卡,用 T4/L4 甚至 CPU 就够。真实场景里,把 80% 的简单请求路由给小模型、只把难题交给大模型,是比任何单点优化都有效的降本手段。
引擎之外:网关与观测
引擎只负责单模型的推理。生产系统还需要一层**推理网关(Inference Gateway)**承担:
- 多模型路由:按请求复杂度、租户、成本选择后端模型。
- 语义缓存:命中相似历史请求直接返回,省下一次完整推理。
- 限流与配额:按租户维度限制 QPS 与 token 消耗。
- 降级与熔断:后端过载时切到更小模型或返回兜底答案。
观测上,除了引擎自身的 TTFT/TPOT/队列深度,还要在网关侧记录端到端延迟、缓存命中率、路由分布与错误码。只有把「引擎指标」与「业务指标」打通,才能在故障时快速定位是引擎排队还是网关慢。
小结
模型服务化的本质是在显存、算力与延迟之间做资源调度。Triton 提供通用性与多模型治理,vLLM 用 PagedAttention 与连续批处理把 LLM 吞吐拉满,TGI 用约定优于配置换上线速度。理解批处理与显存管理这两条主线,就能在选型时不迷信单点指标。引擎之上还有容量规划与弹性伸缩的问题,可结合 LLM 推理引擎 的横向对比与 模型部署 的工程实践一起看;若要在已有优化基础上继续压榨吞吐,LLM 推理优化 给出了从 KV Cache 到投机解码的完整路径。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。