随着多模态大模型在生产中落地,除了回答质量,推理速度与吞吐能力同样关键。
本博客使用 vLLM 对 Qwen3-VL-8B-Instruct 进行一次系统的吞吐与延迟测试,并详细解释每个指标的含义,帮助你理解模型的线上表现。


1. 测试目标与背景

这次测试的核心目标是:在 TTFT < 5 秒、TPOT < 50 毫秒 的延迟约束下,找出系统能支撑的最大并发数,并记录相应的 QPS(端到端)Token 吞吐(解码阶段)

这些指标是什么意思?

  • TTFT(Time To First Token):从请求发出到生成第一个 token 的时间。简单说就是“用户提问后,多久看到第一个字”。这个指标主要包含输入处理(图像编码、文本处理)和预填充阶段的时间。数值越小,用户体感越快。
  • TPOT(Time Per Output Token):解码阶段平均每生成一个 token 的耗时。可以理解为“模型开始说话后,每个字的平均间隔”。该指标越稳定,生成越流畅。
  • 并发数(Concurrency):同时向服务端发送的请求数量。高并发场景下,系统需要高效调度 GPU 计算和显存。
  • QPS (E2E):端到端每秒完成的请求数(请求/秒)。反映系统整体吞吐能力。
  • Token Throughput (decode):解码阶段每秒生成的 token 总数,是衡量 GPU 实际算力利用率的关键指标。

2. 测试配置

  • 模型Qwen/Qwen3-VL-8B-Instruct(下载至 ./models/Qwen3-VL-8B-Instruct
  • 推理引擎vLLM==0.23.0(高效的大模型推理框架,支持 PagedAttention、连续批处理等)
  • 精度bfloat16(BF16)—— 兼顾精度与显存占用,是当今大模型推理的主流选择
  • 张量并行(TP)1,即单卡推理(未将模型切分到多张 GPU 上)
  • 输入/输出长度:每条请求 input_len=30 tokens,output_len=300 tokens
  • 图像分辨率:固定 448×448(像素数 200704),模拟典型的多模态输入
  • 请求总数:每个并发点发送 60 条请求
  • 测试并发点1, 2, 4, 6, 8, 10, 12, 14, 16

3. 操作步骤详解

3.1 下载模型

利用 Hugging Face 镜像加速下载,将模型保存到本地目录。

export HF_ENDPOINT="https://hf-mirror.com"
hf download Qwen/Qwen3-VL-8B-Instruct   --local-dir ./models/Qwen3-VL-8B-Instruct

3.2 搭建测试环境

我们使用 conda 创建独立环境,安装指定版本的 PyTorch(CUDA 12.8)和 vLLM。libgl1 用于图像处理相关依赖。

#!/usr/bin/env bash
apt-get update
apt-get install -y libgl1

conda env remove -n "qwen3_vl_tp1_bf16_dev" -y
conda create -n "qwen3_vl_tp1_bf16_dev" -y "python=3.12" pip
conda run -n "qwen3_vl_tp1_bf16_dev" python -m pip install -U pip setuptools wheel
conda run -n "qwen3_vl_tp1_bf16_dev" python -m pip install \
  --index-url https://download.pytorch.org/whl/cu128 \
  "torch==2.11.0" "torchvision==0.26.0" "torchaudio==2.11.0"
conda run -n "qwen3_vl_tp1_bf16_dev" python -m pip install "vllm==0.23.0"

安装完成后打印关键包的版本,方便后续问题追溯。

conda run --live-stream -n "qwen3_vl_tp1_bf16_dev" python - <<'PY'
import importlib.metadata as m
import sys
print(f"python: {sys.version.splitlines()[0]}")
import importlib.metadata as m
for pkg in [
    "vllm","torch","torchvision","torchaudio",
    "transformers","tokenizers","protobuf","typing_extensions",
    "flashinfer-python","opencv-python-headless","Pillow","numpy"]:
    print(f"{pkg}: {m.version(pkg)}")
PY

输出示例(环境信息):

python: 3.12.13 | packaged by conda-forge | (main, Mar  5 2026, 16:50:00) [GCC 14.3.0]
vllm: 0.23.0
torch: 2.11.0+cu128
torchvision: 0.26.0+cu128
torchaudio: 2.11.0+cu128
transformers: 5.13.0
tokenizers: 0.22.2
protobuf: 7.35.1
typing_extensions: 4.15.0
flashinfer-python: 0.6.12
opencv-python-headless: 5.0.0.93
Pillow: 12.2.0
numpy: 2.3.5

3.3 创建测试脚本

我们编写了两个脚本:一键测试脚本结果汇总脚本

3.3.1 结果汇总脚本 summarize_results.py

该脚本读取每个并发点保存的 JSON 结果,提取关键指标并以表格形式输出,同时根据 TTFT < 5000msTPOT < 50ms 判断是否通过。

cat > summarize_results.py << 'EOF'
#!/usr/bin/env python3
import json
import sys
from pathlib import Path
def main() -> int:
    if len(sys.argv) != 2:
        print("Usage: summarize_results.py <result_dir>")
        return 1

    result_dir = Path(sys.argv[1])
    rows = []
    paths = sorted(
        result_dir.glob("conc_*.json"),
        key=lambda path: int(path.stem.split("_")[1]),
    )
    for path in paths:
        with path.open() as f:
            data = json.load(f)
        rows.append(
            {
                "concurrency": data["max_concurrency"],
                "qps_e2e": data["request_throughput"],
                "decode_tps": data["output_throughput"],
                "ttft_ms": data["mean_ttft_ms"],
                "tpot_ms": data["mean_tpot_ms"],
            }
        )

    if not rows:
        print("No result JSON files found.")
        return 1

    print(
        f"{'concurrency':>11} {'ttft_ms':>10} {'tpot_ms':>10} "
        f"{'qps_e2e':>10} {'decode_tps':>12} {'pass':>6}"
    )
    for row in rows:
        passed = row["ttft_ms"] < 5000 and row["tpot_ms"] < 50
        print(
            f"{row['concurrency']:>11} "
            f"{row['ttft_ms']:>10.2f} "
            f"{row['tpot_ms']:>10.2f} "
            f"{row['qps_e2e']:>10.4f} "
            f"{row['decode_tps']:>12.2f} "
            f"{str(passed):>6}"
        )

    best = max(
        (row for row in rows if row["ttft_ms"] < 5000 and row["tpot_ms"] < 50),
        key=lambda x: x["concurrency"],
        default=None,
    )
    if best is not None:
        print("\nBest passing point:")
        print(json.dumps(best, indent=2))
    else:
        print("\nNo concurrency point passed the TTFT/TPOT thresholds.")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
EOF
3.3.2 一键测试脚本 run_benchmark.sh

该脚本依次完成:启动 vLLM 服务 → 等待就绪 → 并发扫描 → 结果汇总
它使用 vllm bench serve 配合 random-mm 数据集自动生成多模态请求,每个请求包含 1 张 448×448 的图像和固定长度的文本,确保测试条件一致。

cat > run_benchmark.sh << 'EOF'
#!/usr/bin/env bash
CONDA_ENV="${CONDA_ENV:-qwen3_vl_tp1_bf16_dev}"
MODEL_PATH="${MODEL_PATH:-./models/Qwen3-VL-8B-Instruct}"
SERVED_MODEL_NAME="${SERVED_MODEL_NAME:-Qwen3-VL-8B-Instruct}"
RESULT_DIR="${RESULT_DIR:-./results_qwen3_vl_tp1_bf16_448}"
PORT="${PORT:-8000}"
HOST="${HOST:-127.0.0.1}"
CUDA_DEVICE="${CUDA_DEVICE:-0}"
MAX_MODEL_LEN="${MAX_MODEL_LEN:-8192}"
GPU_MEMORY_UTILIZATION="${GPU_MEMORY_UTILIZATION:-0.95}"
MAX_NUM_SEQS="${MAX_NUM_SEQS:-64}"

INPUT_LEN="${INPUT_LEN:-30}"
OUTPUT_LEN="${OUTPUT_LEN:-300}"
NUM_PROMPTS="${NUM_PROMPTS:-60}"
IMAGE_PIXELS="${IMAGE_PIXELS:-200704}"   # 448 * 448
CONCURRENCY_LIST="${CONCURRENCY_LIST:-1 2 4 6 8 10 12 14 16}"

mkdir -p "${RESULT_DIR}"

cleanup() {
  if [[ -n "${SERVER_PID:-}" ]] && kill -0 "${SERVER_PID}" 2>/dev/null; then
    kill "${SERVER_PID}" || true
    wait "${SERVER_PID}" || true
  fi
}
trap cleanup EXIT

if [[ ! -d "${MODEL_PATH}" ]]; then
  echo "Model path does not exist: ${MODEL_PATH}"
  exit 1
fi

echo "[1/4] Starting vLLM server"
CUDA_VISIBLE_DEVICES="${CUDA_DEVICE}" conda run -n "${CONDA_ENV}" \
  vllm serve "${MODEL_PATH}" \
  --host "${HOST}" \
  --port "${PORT}" \
  --dtype bfloat16 \
  --tensor-parallel-size 1 \
  --served-model-name "${SERVED_MODEL_NAME}" \
  --trust-remote-code \
  --gpu-memory-utilization "${GPU_MEMORY_UTILIZATION}" \
  --max-model-len "${MAX_MODEL_LEN}" \
  --max-num-seqs "${MAX_NUM_SEQS}" \
  --limit-mm-per-prompt '{"image":1}' \
  --mm-processor-kwargs "{\"min_pixels\":${IMAGE_PIXELS},\"max_pixels\":${IMAGE_PIXELS}}" \
  > "${RESULT_DIR}/server.log" 2>&1 &
SERVER_PID=$!

echo "Server PID: ${SERVER_PID}"

echo "[2/4] Waiting for server readiness"
for _ in $(seq 1 180); do
  if ! kill -0 "${SERVER_PID}" 2>/dev/null; then
    echo "Server exited early. Tail of ${RESULT_DIR}/server.log:"
    tail -n 80 "${RESULT_DIR}/server.log" || true
    exit 1
  fi
  if curl -sf "http://${HOST}:${PORT}/v1/models" > "${RESULT_DIR}/models.json"; then
    break
  fi
  sleep 2
done

if ! curl -sf "http://${HOST}:${PORT}/v1/models" > "${RESULT_DIR}/models.json"; then
  echo "Server failed to become ready. Check ${RESULT_DIR}/server.log"
  exit 1
fi

echo "[3/4] Running benchmark sweep"
for c in ${CONCURRENCY_LIST}; do
  echo "Running concurrency=${c}"
  conda run -n "${CONDA_ENV}" vllm bench serve \
    --backend openai-chat \
    --base-url "http://${HOST}:${PORT}" \
    --endpoint /v1/chat/completions \
    --model "${MODEL_PATH}" \
    --served-model-name "${SERVED_MODEL_NAME}" \
    --dataset-name random-mm \
    --enable-multimodal-chat \
    --input-len "${INPUT_LEN}" \
    --output-len "${OUTPUT_LEN}" \
    --num-prompts "${NUM_PROMPTS}" \
    --request-rate inf \
    --max-concurrency "${c}" \
    --random-mm-base-items-per-request 1 \
    --random-mm-num-mm-items-range-ratio 0 \
    --random-mm-limit-mm-per-prompt '{"image":1,"video":0}' \
    --random-mm-bucket-config '{(448, 448, 1): 1.0}' \
    --save-result \
    --result-dir "${RESULT_DIR}" \
    --result-filename "conc_${c}.json" \
    --disable-tqdm \
    > "${RESULT_DIR}/conc_${c}.log" 2>&1
done

echo "[4/4] Summarizing results"
python summarize_results.py "${RESULT_DIR}"

echo "Done. Raw logs and JSON files are in ${RESULT_DIR}"
EOF

3.4 执行测试

直接运行脚本即可完成全部测试。

bash run_benchmark.sh

4. 测试结果与解读

[1/4] Starting vLLM server
Server PID: 45442
[2/4] Waiting for server readiness
[3/4] Running benchmark sweep
Running concurrency=1
Running concurrency=2
Running concurrency=4
Running concurrency=6
Running concurrency=8
Running concurrency=10
Running concurrency=12
Running concurrency=14
Running concurrency=16
[4/4] Summarizing results
concurrency    ttft_ms    tpot_ms    qps_e2e   decode_tps   pass
          1      74.79      10.46     0.3123        93.68   True
          2      54.05      10.52     0.6251       187.54   True
          4      62.57      10.94     1.1993       359.78   True
          6      87.44      11.09     1.7626       528.78   True
          8      86.46      11.18     2.1896       656.88   True
         10      98.26      11.34     2.8652       859.57   True
         12     129.68      11.46     3.3712      1011.35   True
         14     140.39      11.52     3.3801      1014.02   True
         16     139.60      11.61     4.1586      1247.58   True

最佳通过点(最大并发数 16):

{
  "concurrency": 16,
  "qps_e2e": 4.158604640629904,
  "decode_tps": 1247.5813921889714,
  "ttft_ms": 139.6014109988755,
  "tpot_ms": 11.605190408642628
}
Done. Raw logs and JSON files are in ./results_qwen3_vl_tp1_bf16_448

分析

  • 所有并发点(1~16)均满足 TTFT < 5000msTPOT < 50ms 的约束,说明在单卡 BF16 配置下,Qwen3-VL-8B-Instruct 的响应延迟非常优秀。
  • TTFT 基本保持在 50~140ms 之间,远低于 5 秒阈值,且随并发增加仅有小幅上升,表明图像处理和预填充阶段的高效性。
  • TPOT 稳定在 10~12ms,不会因为并发升高而明显恶化,保证生成过程流畅。
  • QPSToken 吞吐 随并发数线性增长,在并发 16 时达到约 4.16 req/s1247 tokens/s,说明系统吞吐仍有提升空间(尚未触达 GPU 算力瓶颈)。
  • 从趋势上看,如果再增大并发数,TPOT 可能依旧稳定,但 TTFT 会继续上升,最终可能突破 5s 阈值,成为并发上限的关键约束。

5. 总结

本次测试验证了 Qwen3-VL-8B-Instruct 在 vLLM 上的推理性能:在单卡 BF16 条件下,至少可以支撑 16 路并发多模态请求,同时保持极低的延迟。对于需要快速集成多模态能力的团队来说,这是一个可靠且可落地的选择。

如果你对更大并发、更高分辨率的图像、或使用多卡张量并行的场景感兴趣,欢迎按本文提供的方法自行实验,只需调整 CONCURRENCY_LISTIMAGE_PIXELS 等变量即可。


测试结果受硬件环境(GPU 型号、显存带宽等)影响,本文数据供参考,你的实际性能可能有所不同。

更多推荐