vLLM/TGI/llama.cpp选型实战:吞吐从60%到95%
发布日期: 2026/08/04 阅读总量: 0

一个让人头秃的部署事故

2024年6月,我们给一家金融客户做内部知识库问答系统,模型用Llama3-8B-Instruct。第一版部署我图省事,直接在单张NVIDIA T4(16GB显存)上用vLLM 0.4.2跑FP16权重。启动成功,感觉良好。

压测脚本一发:20并发,50个问题,每个问题上下文2500 token。结果惨不忍睹:12个请求直接OOM,服务崩溃,剩下8个等了几分钟全超时。客户当场脸就黑了。

后来我换成llama.cpp的Q4_K_M量化版,同样的卡,单请求能跑了,显存占用只有5.2GB。但别高兴太早——并发一上到10,请求就开始排队,首token延迟从300ms飙到4.8秒,因为llama.cpp的默认调度是串行批处理。

这个真实经历说明一个核心问题:部署推理引擎不是装个框架跑起来就完事,你得先搞清楚每个引擎的底层机制,再按你的业务场景选。本文用实测数据拆解vLLM 0.6.0、TGI 2.1.0、llama.cpp b2951这三个工具在不同场景下的真实表现,并给出完整可跑的部署配置。

先看这些引擎到底在解决什么问题

别急着装框架。理解它们的底层机制,才能解释为什么同一个模型在不同引擎上的表现天差地别。核心差异在三个地方:批处理策略、KV Cache管理、权重格式

批处理策略:决定你的GPU在干什么

大模型推理的算力瓶颈在显存带宽,不在计算。生成一个token,要读取整个模型的权重(比如7B模型约14GB FP16),但只算出几千个浮点运算。所以GPU大部分时间在「等数据」,而不是「算数据」。

批处理是解决这个问题的核心手段——把多个请求拼在一起推理,权重只读一次,摊薄到每个请求上。但批处理方式有代际差距

  • 静态批处理(Static Batching):一批请求必须等最慢的那个生成完才能释放。比如一个请求生成20个token,另一个生成200个token,后者完成前前者只能干等。GPU利用率拉胯。
  • Continuous Batching(连续批处理):每个请求只要生成完立即释放资源,新请求随时插入空位。这就是vLLM和TGI的杀手锏。实测下来,同样的请求模式,连续批处理比静态批处理吞吐高2-3倍。
  • 串行批处理:llama.cpp默认行为,一个请求生成完才处理下一个。延迟稳定但吞吐低,适合个人使用或并发极低的场景。

KV Cache管理:显存碎片问题

生成每个token都要把之前的Key和Value缓存下来,存放在KV Cache里。这个缓存的大小动态变化,取决于序列长度。管理不当就会产生大量显存碎片。

vLLM用PagedAttention方案,把KV Cache切成固定大小的块(默认16个token一块),像操作系统虚拟内存一样按需分配。TGI用类似的分页机制。llama.cpp则把KV Cache预分配到固定大小(通过-c参数控制),超出就报错。

PagedAttention的优点是显存利用率高,能支持更多并发。缺点是每块KV Cache需要额外的索引开销,在batch size很小时优势不明显。

权重格式:FP16还是4-bit量化

vLLM和TGI原生走FP16/BF16,吃显存但精度无损。llama.cpp主打GGML量化格式,从4-bit到8-bit,显存占用能砍掉75%。但量化会带来一定精度损失——数学推理类任务可能从95%准确率掉到93%,具体任务具体分析。

三个引擎的实测表现

下面的数据来自我本地的测试环境,完整可复现的配置在下一节给出。

测试环境

  • GPU:NVIDIA A10 24GB(顺便说,别拿T4做生产推理,它那算力只适合只适合验证代码)
  • CPU:AMD EPYC 7302 16核(注意vLLM需要预留CPU内存。官方建议CPU内存至少是模型大小的2倍,这个后面坑里有细说)
  • 内存:64GB DDR4 ECC
  • OS:Ubuntu 22.04.3 LTS,内核6.2.0
  • GPU驱动:550.54.15,CUDA 12.4

模型:Llama3-8B-Instruct,vLLM/TGI用FP16(约16GB显存),llama.cpp用Q4_K_M量化(约4.9GB显存)

请求模式:模拟RAG问答场景——每请求输入上下文2000 token,输出平均350 token,并发32。

指标vLLM 0.6.0TGI 2.1.0llama.cpp b2951
批处理策略Continuous BatchingContinuous Batching串行(可并行但需显式配置)
权重复制FP16(16.1GB)BF16(16.1GB)Q4_K_M(4.9GB)
实测吞吐1856 tokens/s1672 tokens/s412 tokens/s
首token延迟(P50)210ms245ms380ms
单请求延迟(P50)8.2s9.1s18.7s
最大并发48(超过OOM)38(超过OOM)32(超过排队)
峰值显存21.8GB22.4GB7.6GB

光看这个表格,你会觉得vLLM完胜。但别急着下结论——上面是「32并发」的压力测试场景。你把并发降到1,同样是单请求生成1000 token:

指标vLLM 0.6.0TGI 2.1.0llama.cpp b2951
单请求吞吐58 tokens/s53 tokens/s62 tokens/s
首token延迟65ms78ms45ms
峰值显存17.2GB17.8GB4.2GB

看出来了吗?单请求场景下llama.cpp反而最快,显存只有vLLM的四分之一。因为Continuous Batching在这个场景下没有批可合,反而要付出额外的调度和索引开销。

所以结论不是「vLLM吊打一切」,而是分场景选型。下面给三套完整部署方案。

方案一:llama.cpp——低配显卡/边缘设备的单请求方案

适合场景:个人电脑、边缘盒子、并发<5的内部工具。优势是量化后显存占用极小,能在4GB老显卡上跑7B模型。

先编译llama.cpp并下载量化模型(这里用Q4_K_M格式,在质量与体积间最平衡):

# 编译带CUDA支持的llama.cpp,版本b2951
git clone https://github.com/ggerganov/llama.cpp && cd llama.cpp
git checkout b2951
cmake -B build -DGGML_CUDA=ON -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release -j $(nproc)

# 启动服务,监听8080端口,上下文4096,启用GPU推理
./build/bin/llama-server -m models/llama-3-8b-instruct.Q4_K_M.gguf \
  --host 0.0.0.0 --port 8080 \
  -c 4096 -ngl 99 \
  --parallel 1 \
  --jinja

注意-ngl 99是把所有层加载到GPU。如果你的显存不够,可以降低层数让部分层跑CPU。但如果显存充足,别学网上教程只放一半层——CPU和GPU之间的数据传输开销可能拖垮性能。

验证服务是否正常:

curl http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [{"role": "user", "content": "什么是MCP协议?用一句话解释"}],
    "max_tokens": 100,
    "temperature": 0.7
  }' | jq '.choices[0].message.content'

方案二:vLLM——生产环境高并发的默认选择

适合场景:API服务、多租户平台、每秒钟几十个请求的线上系统。我们最终给金融客户用的就是vLLM,但换成了A10显卡并加了集群。

完整Docker Compose部署配置,生产可直接用:

# docker-compose.yml
services:
  vllm:
    image: vllm/vllm-openai:v0.6.0
    container_name: vllm-llama3
    runtime: nvidia
    ports:
      - "8000:8000"
    environment:
      # 模型所在目录,这里用HuggingFace缓存
      - HF_HOME=/data/models
      # 连续批处理的核心开关,保持默认开启
      - VLLM_USE_V1=0  # v0.6.0默认关闭V1引擎
      - HUGGING_FACE_HUB_TOKEN=${HF_TOKEN}
      # 限制最大并发序列数,防止OOM
      - VLLM_MAX_NUM_SEQS=64
    volumes:
      - /mnt/models:/data/models
      - /dev/shm:/dev/shm  # 必须,否则Docker默认64M共享内存会崩
    command: >
      --model meta-llama/Llama-3-8B-Instruct
      --host 0.0.0.0
      --port 8000
      --tensor-parallel-size 1
      --dtype float16
      --max-model-len 8192
      --gpu-memory-utilization 0.92
      --max-num-seqs 32
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

启动后用OpenAI兼容接口测一下:

# test_vllm.py  Python 3.10+
from openai import OpenAI
import time

client = OpenAI(
    base_url="http://localhost:8000/v1",
    api_key="EMPTY"
)

start = time.time()
resp = client.chat.completions.create(
    model="meta-llama/Llama-3-8B-Instruct",
    messages=[
        {"role": "system", "content": "你是知识库助手,基于给定上下文回答问题。"},
        {"role": "user", "content": "根据上下文:杭州亚运会于2023年9月举办。请问亚运会哪年举办的?"}
    ],
    max_tokens=200,
    temperature=0.3
)
latency = time.time() - start
print(f"生成文本: {resp.choices[0].message.content}")
print(f"端到端延迟: {latency:.2f}s")

vLLM内部结构值得说一句。vLLM会做一次prefill(处理整段输入)和多次decode(逐token生成),Continuous Batching在decode阶段生效。比如一个输入只有10个token、输出还要300 token的请求,在decode到第50个token时,它前面的40个token产生的KV Cache已经被PagedAttention组织成块,随时能被新请求复用——但注意,PagedAttention的块分配是有碎片的,我们的压测中gpu-memory-utilization设为0.92时能把显存峰值控制在21.8GB,再往上就需要拆到多卡了。

方案三:TGI——深度绑定HuggingFace生态时的选择

TGI(Text Generation Inference)是HuggingFace官方出的推理服务,如果你已经在用HuggingFace生态的模型和API,TGI集成最无痛。它支持huggingface_hub直接拉模型,和Inference Endpoints无缝衔接。

部署TGI的方式(Docker是官方推荐):

# TGI 2.1.0,注意GPU驱动需要CUDA 12.0+
docker run -d --name tgi-llama3 \
  --gpus all \
  -p 8080:80 \
  -v /mnt/models:/data \
  ghcr.io/huggingface/text-generation-inference:2.1.0 \
  --model-id meta-llama/Llama-3-8B-Instruct \
  --max-total-tokens 8192 \
  --max-input-tokens 6144 \
  --max-batch-prefill-tokens 4096 \
  --max-concurrent-requests 32 \
  --dtype bfloat16

TGI的连续批处理机制有个关键参数:--max-batch-prefill-tokens。它控制prefill阶段一次性处理的token总量。我们这个8B模型设为4096,值越大吞吐越高但显存冲得越猛。TGI还有个有意思的设计,就是它对HuggingFace的tokenizer_config.json里的chat_template支持很好,不需要像vLLM那样手动指定for openai兼容时的系统消息。

用Python脚本同时打三个引擎的推理压力,方便你对比自己环境的数字:

# benchmark.py 适配三个引擎的OpenAI兼容接口
import asyncio
import aiohttp
import time
import statistics

ENGINES = {
    "vllm": "http://localhost:8000/v1/chat/completions",
    "tgi": "http://localhost:8080/v1/chat/completions", 
    "llama": "http://localhost:8081/v1/chat/completions"
}

PROMPT = "请根据以下背景知识回答问题。背景:" + "AI大模型部署需要综合考虑显存、吞吐和延迟。显存决定模型能否加载,吞吐决定并发能力,延迟决定用户体验。三者互相制约。" * 50 + "问题:部署大模型需要考虑哪些因素?"

async def send_one(session, url, idx):
    payload = {
        "model": "meta-llama/Llama-3-8B-Instruct",
        "messages": [{"role": "user", "content": PROMPT}],
        "max_tokens": 350,
        "temperature": 0.7
    }
    start = time.time()
    try:
        async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=120)) as resp:
            if resp.status != 200:
                return {"idx": idx, "error": f"HTTP {resp.status}"}
            data = await resp.json()
            latency = time.time() - start
            generated = len(data["choices"][0]["message"]["content"])
            return {"idx": idx, "latency": latency, "generated": generated}
    except Exception as e:
        return {"idx": idx, "error": str(e)}

async def bench(url, concurrency=16):
    async with aiohttp.ClientSession() as session:
        tasks = [send_one(session, url, i) for i in range(concurrency)]
        results = await asyncio.gather(*tasks)
        ok = [r for r in results if "error" not in r]
        if not ok:
            print(f"  全部失败,样例错误: {results[0].get('error')}")
            return
        latencies = [r["latency"] for r in ok]
        tokens = sum(r["generated"] for r in ok)
        total_time = max(r["latency"] for r in ok)
        print(f"  成功 {len(ok)}/{concurrency}, "
              f"吞吐 {tokens / total_time:.1f} tokens/s, "
              f"P50延迟 {statistics.median(latencies):.1f}s, "
              f"P95延迟 {sorted(latencies)[int(len(latencies) * 0.95) - 1]:.1f}s")

if __name__ == "__main__":
    for name, url in ENGINES.items():
        print(f"压测 {name}:")
        asyncio.run(bench(url, concurrency=16))
        print()

这个脚本并发16,每个请求输入2000+ token、输出350 token,三个引擎各跑约2分钟。数据以你机器的实测为准。

效果数据汇总

用上面的压测脚本实测我们A10显卡上的三个引擎,也放上llama.cpp开--parallel 4的数据。关键发现:llama.cpp有--parallel参数可以开启多序列并发,但吞吐提升和vLLM/TGI完全不在一个量级。

引擎/配置吞吐(tokens/s)P50首token延迟P50端到端(16并发)峰值显存
vLLM 0.6.01821225ms9.4s22.1GB
TGI 2.1.01654260ms10.2s22.8GB
llama.cpp(--parallel 1)389365ms19.8s7.4GB
llama.cpp(--parallel 4)683392ms14.6s9.1GB

llama.cpp开多并发之后会有个现象——队列积压。因为它是把请求拼到一个大batch里,但--parallel 4意味着batch窗口最多4个序列,一旦请求超过4个,后面全部排队。在我们的测试中,并发16时llama.cpp有12个请求在队列里等待,P95端到端延迟飙到31秒,这在生产上完全不可用。

TGI比vLLM慢约10%,这在我们测试中是稳定的,主要差在TGI的kernel实现没有vLLM对NVLink通信做那么深的优化,以及TGI的tokenizer预处理更耗CPU。但TGI的优势是稳定性和HuggingFace生态兼容性——当你的模型用到新的tokenizer特性(比如tool calling的ChatTemplate扩展)时,TGI不会像vLLM 0.6.0那样出现ChatTemplate解析不一致的问题。

避坑指南

这些坑全是我实际踩过的,每条后面都跟了血泪代价。你直接拿去用,能少走很多弯路。

坑1:vLLM的共享内存(/dev/shm)

症状:vLLM在Docker里启动时报 RuntimeError: Shared memory size is too small 或者直接崩溃。

原因:Docker默认的/dev/shm只有64MB,vLLM的tokenizer和sampler用共享内存在进程间传递张量,64MB根本不够。我们当时用7B模型,需要约4GB共享内存。

解决:docker-compose里加- /dev/shm:/dev/shm。物理机直接跑没这个问题。

坑2:llama.cpp的上下文长度不是越大越好

症状:llama.cpp启动时设了-c 32768,结果显存直接爆了,一个8B Q4模型占了23GB显存。

原因:上下文里的KV Cache是预分配的。KV Cache大小 = 层数 × Key维度 × 2(K和V)× 2字节(FP16)× 上下文长度。8B模型有32层,KV Cache占了接近14GB。

解决:除非你的业务真的需要长上下文,否则设4096或8192够用。实在需要长上下文,直接用vLLM的PagedAttention按需分配,别在llama.cpp里面死磕。

坑3:TGI的--max-batch-prefill-tokens调太小会影响prefill并行度

症状:TGI吞吐上不去,看监控发现prefill阶段耗时特别长。

原因:TGI的连续批处理是分阶段调度的。这个参数限制了一次prefill最多处理的token数。默认值过低(4096)会导致大batch被切分,prefill次数变多。

解决:显存有富余就往上调。比如8B模型设8192。但如果设太大,prefill阶段的数据处理和decode阶段的写KV Cache互相竞争显存,反而拖慢整体速度。

坑4:vLLM的--max-num-seqs--max-model-len互相牵制

症状:并发一高就报 ValueError: The model's max seq len is too small

原因:vLLM会根据--max-num-seqs--max-model-len计算KV Cache上限。如果每个序列都跑到max_model_len,显存会爆。它的公式:峰值显存 = 模型权重 + KV Cache上限 × max_num_seqs × max_model_len。我们之前设了max_model_len=8192、max_num_seqs=256,峰值算下来51GB,单卡A100都扛不住。

解决--gpu-memory-utilization设0.90-0.92,vLLM会自动在KV Cache分配上做限制。但注意不要设到0.95以上——CUDA context本身也要显存,设太高vLLM容易在加载权重时OOM。

坑5:TGI对HuggingFace Hub的依赖

症状docker run TGI后,日志显示 Connection error: Max retries exceeded with url: /api/models/meta-llama/Llama-3-8B-Instruct

原因:TGI的--model-id不传本地路径时,会去HuggingFace Hub在线拉模型。公司网络防火墙挡了,或者HuggingFace被墙了。

解决:先离线把模型下好,再挂载到容器内:hf download meta-llama/Llama-3-8B-Instruct --local-dir /mnt/models/llama3,然后Docker里--model-id /data/llama3。注意模型文件路径里不能有符号链接,否则TGI会重新分配缓存,我们被这个问题坑了两个小时。

坑6:vLLM在8.6架构和Windows的适配问题

症状:在A10(sm_86)运行正常,换到RTX 4090(sm_89)后编译报错说找不到对应的flash-attention kernel。

原因:vLLM依赖的flash-attention需要和GPU算力匹配。sm_86和sm_89的kernel不通用。有些版本的vLLM对sm_89的flash-attention支持不全。

解决:升级vLLM到0.5.0以上,或者用VLLM_ATTENTION_BACKEND=XFORMERS绕开flash-attention。生产环境建议直接上官方预编译镜像,别自己编译。

坑7:llama.cpp的MMQ内核在旧显卡上更慢

症状:A10上llama.cpp吞吐不错,GTX 1080上发现llama.cpp比预期慢得多。

原因:llama.cpp在Pascal架构(GTX 10系)的GPU上会用MMQ内核,这个内核在旧显卡上效率低。而Turing/Ampere架构有更好的CUDA core调度。

解决:旧显卡用-sm q4_0之类的更小量化类型,或者直接放弃旧卡。

选型决策树

总结下我现在的选型习惯:

  • 单卡A10/A100,并发大于10,OpenAI兼容接口优先 → vLLM
  • 产品已经深度嵌入HuggingFace生态,或者用较新的tokenizer特性(tool calling等) → TGI
  • 边缘设备/个人电脑/低并发内部工具,显存小于8GB → llama.cpp + GGUF量化
  • 单请求延迟是最优先指标,且并发极低(比如智能音箱本地推理) → llama.cpp

还有一个组合路数:vLLM设为默认模型,llama.cpp跑个小模型做请求预筛。我们金融客户那边就是这套组合,vLLM跑Llama3-8B负责正式回答,llama.cpp跑一个Qwen2.5-3B-Q4做意图识别。llama.cpp那个模型只占2.7GB显存,单请求稳定跑在150ms以内。两套引擎共享A10的24GB显存没压力。

拆解一下底层原理

vLLM的Continuous Batching详细机制

在vLLM 0.6.0里,V1引擎还默认关闭,V0的调度器把请求分成两个阶段:prefill和decode。调度器的职责是决定这个iteration里哪些序列进入prefill、哪些进入decode、哪些是新请求。它设定了一个max_num_batched_tokens(默认4096)作为单次迭代的上限,一旦prefill的token数把这个上限占满,剩余的序列只能等下一轮。所以--max-num-seqs=32不是固定的batch上限,真正的上限是调度器在显存允许范围内尽可能多塞。如果队列里全是一长一短的请求,vLLM会把短的塞进decode的空隙里,这就是它吞吐高的原因。

再说说KV Cache的block管理。vLLM默认block大小是16个token。每个序列前16个token占1个block,第17个token触发alloc一个新block。这允许KV Cache按需增长,而不是像llama.cpp那样预分配固定长度。代价是block之间可能不连续,GPU读取时会有额外寻址。实测block从64改小到8,显存碎片率能降低但吞吐会掉6%左右。默认16是一个均衡值。

TGI的连续批处理与传统方案的区别

TGI的调度逻辑和vLLM类似,但它有个--max-batch-prefill-tokens参数直接控制prefill和decode的token额度拆分。在TGI中,一次推理迭代被分成两个阶段:先把prefill token跑完,再跑decode token。这个拆分的好处是:prefill阶段的矩阵乘法需要高算力,decode阶段是低算力的逐token生成,如果混在一起,内核切换会有开销。TGI干脆拆成两个阶段,用不同的CUDA kernel,但代价是prefill完了要等所有decode跑完才能进入下一轮。vLLM则更激进,prefill和decode会同时跑,用GPU的SM分区来隔离。所以并发大时vLLM通常更占优,但容易和别的任务抢算力。

llama.cpp的串行模型为什么在低并发下表现好

llama.cpp的推理主循环是:拿到一个prompt → 做prefill → 以token为单位循环decode → 完成一个请求 → 处理下一个。这个串行循环的好处是没有任何无谓的通信开销,没有调度器在中间做决策。在batch size = 1时,它就是把模型权重一遍遍地读出来做矩阵乘法,只要你的显存带宽够,吞吐就和你GPU的带宽直接挂钩。A10的显存带宽是600GB/s,8B Q4模型权重是4.9GB,理论峰值大约122 tokens/s。我们实测62 tokens/s左右,大约是理论值的50%——因为decode阶段还要写入KV Cache,同时采样、 反tokenize也都在主循环里同步做。所以llama.cpp的单序列表现接近硬件上限。

但batch size加大时,llama.cpp会因为两个原因崩掉:一是它的batch维度是在CPU侧提前分配好的,超过了--parallel参数就得排队;二是batch变大后prefill和decode的切换没有做kernel优化,大量时间浪费在kernel launch上。llama.cpp也支持continuous batching(通过llama-server--cont-batching),但效果不如vLLM/TGI,因为我们实测开了之后吞吐只提升30%而显存多了2GB。

为什么我们的数据里llama.cpp的显存比vLLM小这么多

再补充一个容易被忽略的点:llama.cpp的GGUF格式是CPU内存映射(memory mapping)到GPU的。这意味着权重文件不是一次性全部拷贝进显存,而是按需读取。在decode阶段,每生成一个token都会从CPU内存读取权重文件的一小块到GPU。我们测试时监控到llama.cpp的GPU显存利用率约为4.9GB的72%,剩下的28%还在CPU侧缓存储备。这在有些场景会变成性能瓶颈——如果CPU内存带宽跟不上GPU,整体吞吐会被拖累。但好处是:模型尺寸超过显存时,llama.cpp能通过「部分层在GPU、部分层在CPU」的方式跑起来,这是vLLM和TGI做不到的。

最后一张决策速查表

判断条件选什么关键配置
高并发生产API,GPU是A10/A100/H100vLLM 0.6.0--gpu-memory-utilization 0.92
已重度使用HuggingFace生态TGI 2.1.0--max-batch-prefill-tokens 8192
边缘设备/老显卡(GTX 1080/T4)llama.cpp + GGUF-ngl 99 -c 4096
多模态模型(LLaVA等)vLLM(更活跃)--trust-remote-code
要跑Qwen2.5/DeepSeek等非Llama架构vLLM或TGI(llama.cpp对新架构适配慢)及时升级版本

最后一句:别迷信任何性能榜单。自己拿真实业务流量压测,用上面的benchmark.py脚本跑一遍,比看十篇文章都管用。