Qwen3-32B + vLLM 大模型服务部署与开发实战

教程定位

本教程面向希望在 NVIDIA GPU 服务器上部署本地大语言模型,并让 Python、Node.js、Java、Web 应用等业务系统通过 OpenAI-compatible API 调用模型的开发者、运维人员和 AI 应用开发学员。

本教程以实际部署环境为主线:

  • Ubuntu 22.04
  • NVIDIA A40 × 2
  • NVIDIA Driver 580.178.04
  • CUDA 13.0
  • vLLM 0.28.0
  • Qwen3-32B BF16
  • Tensor Parallel = 2
  • API Port = 8000
  • 模型目录 = /data/models/Qwen3-32B

重要: vLLM、Transformers、模型仓库和 OpenAI SDK 都在持续更新。本文的命令以 vLLM 0.28.0 + Qwen3-32B 为教学基线;如果你使用其他版本,请先对照对应版本的官方文档重新验证参数。


目录

  1. 先理解整个系统
  2. 模型、vLLM、SDK、CLI 到底分别是什么
  3. 实验环境
  4. GPU 与驱动检查
  5. Docker GPU 环境检查
  6. 模型文件管理
  7. Qwen3-32B 与 BF16
  8. 为什么两张 A40 使用 Tensor Parallel
  9. 启动 vLLM
  10. 启动参数详解
  11. 为什么本部署使用 YaRN
  12. Reasoning / Thinking
  13. Tool Calling
  14. 检查容器
  15. 查看启动日志
  16. 健康检查
  17. 查询模型
  18. 第一个 curl 请求
  19. Python 开发
  20. Streaming
  21. 读取 Reasoning 输出
  22. 禁用 Thinking
  23. Node.js 开发
  24. Tool Calling:最小示例
  25. Tool Calling:完整闭环
  26. 性能指标
  27. 一次真实 Benchmark 应该怎么做
  28. vLLM Metrics
  29. GPU 监控
  30. 常见故障:容器 Up 但 API 不通
  31. 常见故障:GPU 显存不足
  32. 常见故障:8000 端口被占用
  33. 常见故障:401
  34. API Key 与网络安全
  35. 模型切换为什么需要停机窗口
  36. 模型切换配置
  37. switch-model 脚本
  38. 模型切换的回滚策略
  39. 生产架构
  40. vLLM 与 CLI 的区别
  41. vLLM 与 SDK 的区别
  42. 教学实验:最小聊天程序
  43. 教学实验:Streaming
  44. 教学实验:观察完整请求链路
  45. 生产部署 Checklist
  46. 最重要的几个结论
  47. 进一步学习路线
  48. 官方资料
  49. 版本与变更记录
  50. 附录:推荐项目目录

1. 先理解整个系统

最终系统不是“下载一个模型,然后运行一个 Python 文件”这么简单。

完整链路是:

┌──────────────────────────────┐
│          用户 / 前端          │
└──────────────┬───────────────┘
               │
               │ HTTP / HTTPS
               ▼
┌──────────────────────────────┐
│         业务应用              │
│ Python / Node.js / Java ...  │
└──────────────┬───────────────┘
               │
               │ OpenAI-compatible API
               ▼
┌──────────────────────────────┐
│       vLLM OpenAI Server     │
│            :8000             │
└──────────────┬───────────────┘
               │
               │ Tensor Parallel
               ▼
       ┌──────────────────┐
       │ NVIDIA A40 #0    │
       │ NVIDIA A40 #1    │
       └────────┬─────────┘
                │
                ▼
          Qwen3-32B

最重要的一句话:

Qwen3-32B 是模型,vLLM 是模型推理服务,OpenAI SDK 是客户端调用工具,业务应用才是最终解决用户问题的软件。

vLLM 官方提供 OpenAI-compatible HTTP server,因此大量已经使用 OpenAI SDK 的应用可以通过修改 base_url、API Key 和模型名,把请求转到自己的 vLLM 服务。citeturn0search0turn0search2


2. 模型、vLLM、SDK、CLI 到底分别是什么

2.1 模型

例如:

Qwen3-32B

模型本质上是:

权重 + 配置 + tokenizer + chat template 等运行所需文件

模型本身不是一个 HTTP Server。


2.2 vLLM

vLLM 负责:

  • 加载模型
  • 管理 GPU
  • KV Cache
  • 请求调度
  • Continuous Batching
  • Tensor Parallel
  • Streaming
  • OpenAI-compatible API

因此:

Qwen3-32B
    ↓
vLLM
    ↓
HTTP API

2.3 SDK

例如 Python:

from openai import OpenAI

client = OpenAI(
    base_url="http://server:8000/v1",
    api_key="YOUR_API_KEY",
)

SDK 只是客户端工具。

它并不负责:

GPU 推理
模型加载
KV Cache
Tensor Parallel

2.4 CLI

CLI 更像:

用户
  ↓
命令行程序
  ↓
模型
  ↓
终端输出

例如:

python inference.py

或者:

vllm serve ...

这里要区分:

vllm serve 是用于启动 vLLM 服务的 CLI 命令;启动以后,vLLM 本身提供的是一个长期运行的 HTTP 推理服务。


2.5 四者关系

可以记成:

模型
  ↓
vLLM
  ↓
HTTP API
  ↓
SDK / HTTP Client
  ↓
业务应用

3. 实验环境

本教程基于实际部署环境整理。

项目配置
OSUbuntu 22.04
GPUNVIDIA A40 × 2
GPU 显存每张约 46 GB
NVIDIA Driver580.178.04
CUDA13.0
vLLM0.28.0
Docker Imagevllm/vllm-openai:0.28.0
模型Qwen3-32B
模型精度BF16
Tensor Parallel2
GPU Memory Utilization0.90
API Port8000
Max Model Len65536
模型目录/data/models/Qwen3-32B

为什么文档使用固定版本?

原来的部署使用:

vllm/vllm-openai:latest

latest 是浮动标签。

教学文档如果追求“今天能跑、下个月也尽量能复现”,应该尽量固定:

vllm/vllm-openai:0.28.0

生产环境进一步建议记录:

镜像 tag
镜像 digest
vLLM version
模型版本 / commit
启动参数
GPU 型号
Driver

vLLM 官方 Docker 文档同时提供了官方 vllm/vllm-openai 镜像以及 --gpus all--ipc=host 的典型启动方式。citeturn0search0


4. GPU 与驱动检查

4.1 查看 GPU

nvidia-smi

重点看:

GPU 型号
显存
Driver Version
CUDA Version
GPU 利用率
显存使用量
正在运行的进程

本实验应该看到两张:

NVIDIA A40
NVIDIA A40

4.2 确认 GPU 没被其他服务占满

nvidia-smi

重点观察:

Memory-Usage
GPU-Util
Processes

如果已经存在其他大模型:

GPU 0  40000 MiB / 46000 MiB
GPU 1  40000 MiB / 46000 MiB

那么即使 Docker、vLLM 都正常,新的 Qwen3-32B 也可能因为显存不足而启动失败。


5. Docker GPU 环境检查

先确认 Docker:

docker version

再确认 Docker 能访问 GPU:

docker run --rm --gpus all \
  nvidia/cuda:12.8.1-base-ubuntu24.04 \
  nvidia-smi

如果容器中能够看到 A40,说明基本链路:

Docker
  ↓
NVIDIA Container Toolkit
  ↓
GPU

是正常的。

vLLM 官方 Docker 部署同样以:

--gpus all

和:

--ipc=host

作为典型参数。citeturn0search0


6. 模型文件管理

几十 GB 的模型不建议放系统盘。

本实验使用:

/data

作为数据盘。

目录:

/data/
├── models/
│   └── Qwen3-32B/
└── containerd/

模型:

/data/models/Qwen3-32B

检查:

ls -lh /data/models/Qwen3-32B

通常应该看到:

config.json
tokenizer.json
tokenizer_config.json
model-00001-of-00017.safetensors
...
model-00017-of-00017.safetensors

实际 shard 数量以你下载的模型版本为准。


6.1 为什么模型目录只读挂载?

启动:

-v /data/models/Qwen3-32B:/models/Qwen3-32B:ro

其中:

/data/models/Qwen3-32B

是宿主机目录。

/models/Qwen3-32B

是容器内部目录。

最后的:

:ro

代表:

read only

这样可以减少容器误修改模型文件的风险。


7. Qwen3-32B 与 BF16

本教程使用:

Qwen3-32B
Precision = BF16

这意味着:

这里不是 FP32,也不是 FP16,更不是 FP8。

对于本实验环境,Qwen3-32B BF16 的模型权重需要两张 A40 配合 Tensor Parallel 才能比较合理地运行。

模型运行时显存不只有权重,还需要:

模型权重
+
KV Cache
+
CUDA / PyTorch runtime
+
vLLM runtime
+
其他 workspace

因此不能简单做:

模型文件大小 ≈ 运行时显存

8. 为什么两张 A40 使用 Tensor Parallel

启动参数:

--tensor-parallel-size 2

并不是:

GPU 0 → 一个模型
GPU 1 → 一个模型

而是:

             Qwen3-32B
                 │
        ┌────────┴────────┐
        ▼                 ▼
     GPU 0              GPU 1
    TP Rank 0           TP Rank 1

也就是说:

一个模型的计算和权重按照 Tensor Parallel 方式分布在两个 GPU 上。

本实验:

GPU 数量 = 2
TP = 2

是匹配的。


9. 启动 vLLM

9.1 推荐的固定版本启动命令

先设置 API Key:

export VLLM_API_KEY='请替换成你自己的随机密钥'

然后:

docker run -d \
  --name qwen3-vllm \
  --restart unless-stopped \
  --gpus all \
  --ipc=host \
  -p 8000:8000 \
  -v /data/models/Qwen3-32B:/models/Qwen3-32B:ro \
  vllm/vllm-openai:0.28.0 \
  --model /models/Qwen3-32B \
  --served-model-name Qwen3-32B \
  --tensor-parallel-size 2 \
  --gpu-memory-utilization 0.90 \
  --max-model-len 65536 \
  --hf-overrides '{"rope_parameters":{"rope_type":"yarn","factor":2.0,"original_max_position_embeddings":32768}}' \
  --enable-auto-tool-choice \
  --tool-call-parser hermes \
  --reasoning-parser qwen3 \
  --api-key "$VLLM_API_KEY"

如果你的实际镜像版本不是 0.28.0,不要机械复制本命令。先确认:

docker run --rm vllm/vllm-openai:你的版本 vllm --version

或查看容器日志中的 vLLM version。


9.2 一个非常重要的安全问题

不要把真实密钥写进:

Git
README
Dockerfile
代码
前端 JavaScript
聊天记录
教学截图

尤其不要:

git add .
git commit

之前把真实 API Key 写入:

.env
config.py
docker-compose.yml
README.md

教学代码只使用:

VLLM_API_KEY

环境变量或 secret manager。


10. 启动参数详解

10.1 --model

--model /models/Qwen3-32B

容器看到的是:

/models/Qwen3-32B

宿主机实际是:

/data/models/Qwen3-32B

由:

-v /data/models/Qwen3-32B:/models/Qwen3-32B:ro

完成映射。


10.2 --served-model-name

--served-model-name Qwen3-32B

客户端请求:

{
  "model": "Qwen3-32B"
}

因此:

模型目录 ≠ 必须等于 API 模型名

这是非常重要的抽象。


10.3 --tensor-parallel-size

--tensor-parallel-size 2

表示使用两张 GPU 做 Tensor Parallel。


10.4 --gpu-memory-utilization

--gpu-memory-utilization 0.90

它表示 vLLM 的 GPU 显存预算比例。

不要理解为:

“一定只占 90%。”

实际显存还受到:

模型
KV Cache
workspace
CUDA runtime
其他进程

等因素影响。


10.5 --max-model-len

--max-model-len 65536

表示服务允许的最大模型长度配置。

注意:

max-model-len 越大
≠ 每一个请求都立刻占用这么多显存

实际 KV Cache 消耗与:

输入长度
输出长度
并发
batch
缓存策略

等因素有关。


11. 为什么本部署使用 YaRN

本部署使用:

--hf-overrides \
'{"rope_parameters":{"rope_type":"yarn","factor":2.0,"original_max_position_embeddings":32768}}'

其目的,是让运行时采用本部署所验证的扩展上下文配置。

但是这里有一个非常重要的教学原则:

YaRN 不是“看到别人用了就复制”的万能参数。

RoPE / YaRN 必须结合:

模型官方配置
原始上下文长度
目标上下文长度
模型版本
vLLM 版本

一起判断。

因此,如果换模型,不应该直接复制这一行。


12. Reasoning / Thinking

Qwen3 支持 reasoning / thinking。

本部署使用:

--reasoning-parser qwen3

vLLM 0.28.0 提供 Qwen3 parser,用于处理 Qwen3 的 <think> / reasoning 与工具调用格式。citeturn0search5

概念上:

模型输出
   │
   ├── reasoning
   │
   └── final answer

应用层需要决定:

是否展示 reasoning
是否只展示最终答案
是否保存 reasoning

不要把:

reasoning

默认等同于:

最终用户应该看到的答案

13. Tool Calling

本部署同时使用:

--enable-auto-tool-choice
--tool-call-parser hermes

其目标是让模型可以根据工具定义生成结构化 Tool Call。

完整链路:

用户
 │
 ▼
业务应用
 │
 │ messages + tools
 ▼
vLLM / Qwen3
 │
 │ tool_call
 ▼
业务应用
 │
 │ 执行真实工具
 ▼
数据库 / HTTP API / 内部服务
 │
 │ tool result
 ▼
vLLM / Qwen3
 │
 ▼
最终回答

非常重要:

模型不会直接执行你的 Python 函数。

模型只是生成:

“请调用 get_weather,并传入 city=Tokyo”

真正执行:

get_weather("Tokyo")

的是你的业务应用。

vLLM 官方也提供了 reasoning + tool calling 的完整示例,并要求同时启用 reasoning parser 和 tool calling 配置。citeturn0search1


14. 检查容器

docker ps

应该看到:

qwen3-vllm

更完整:

docker ps -a --filter name=qwen3-vllm

15. 查看启动日志

docker logs --tail 100 qwen3-vllm

实时:

docker logs -f qwen3-vllm

正常启动时会看到类似:

Resolved architecture: Qwen3ForCausalLM

以及:

Loading safetensors checkpoint shards

最后:

Application startup complete.

一个非常容易误判的问题

如果:

docker ps

看到:

Up

不代表模型已经 ready。

因为容器已经启动:

Docker container = Up

但 vLLM 可能还在:

加载 60+ GB 模型
初始化 GPU
初始化 NCCL
构建 KV Cache
启动 API server

所以应该用:

/health

判断真正的服务状态。


16. 健康检查

curl -i http://127.0.0.1:8000/health

ready 时应该:

HTTP/1.1 200 OK

如果出现:

Connection reset
Connection refused

首先不要急着重启。

检查:

docker ps -a
docker logs --tail 100 qwen3-vllm

如果仍然看到:

Loading safetensors checkpoint shards

通常只是还没加载完。


17. 查询模型

设置:

export VLLM_API_KEY='你的 API Key'

然后:

curl http://127.0.0.1:8000/v1/models \
  -H "Authorization: Bearer $VLLM_API_KEY"

应该看到类似:

{
  "data": [
    {
      "id": "Qwen3-32B"
    }
  ]
}

这一步可以确认:

API server ready
+
模型已经注册

18. 第一个 curl 请求

curl http://127.0.0.1:8000/v1/chat/completions \
  -H "Authorization: Bearer $VLLM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "Qwen3-32B",
    "messages": [
      {
        "role": "user",
        "content": "请解释一下什么是张量并行。"
      }
    ],
    "max_tokens": 512,
    "temperature": 0.6
  }'

正常情况下得到 OpenAI-compatible Chat Completion JSON。

vLLM 官方文档明确支持 OpenAI-compatible Chat Completions API。citeturn0search2


19. Python 开发

安装:

python3 -m pip install openai

推荐把地址和 Key 放到环境变量:

export VLLM_BASE_URL="http://YOUR_VLLM_SERVER:8000/v1"
export VLLM_API_KEY="你的 API Key"

代码:

import os
from openai import OpenAI

client = OpenAI(
    base_url=os.environ["VLLM_BASE_URL"],
    api_key=os.environ["VLLM_API_KEY"],
)

response = client.chat.completions.create(
    model="Qwen3-32B",
    messages=[
        {
            "role": "user",
            "content": "请解释一下什么是 Tensor Parallel。",
        }
    ],
    temperature=0.6,
    max_tokens=512,
)

print(response.choices[0].message.content)

为什么这个接口很重要?

因为很多 OpenAI SDK 应用原本就是:

client = OpenAI(
    base_url="https://api.openai.com/v1",
    api_key="..."
)

现在只需要把:

base_url
api_key
model

切换到自己的服务。

业务代码主体通常不需要重写。

vLLM 官方 Quickstart 也将 OpenAI Python SDK 作为 OpenAI-compatible server 的典型客户端方式。citeturn0search8


20. Streaming

聊天应用通常不希望等待完整答案才显示。

可以:

stream = client.chat.completions.create(
    model="Qwen3-32B",
    messages=[
        {
            "role": "user",
            "content": "介绍一下 Transformer。",
        }
    ],
    temperature=0.6,
    max_tokens=512,
    stream=True,
)

for chunk in stream:
    delta = chunk.choices[0].delta

    if delta.content:
        print(delta.content, end="", flush=True)

print()

于是:

普通模式:

请求
 ↓
等待
 ↓
完整答案

变成:

请求
 ↓
token
 ↓
token
 ↓
token
 ↓
token
 ↓
最终答案

这就是聊天应用常见的“打字机效果”。


21. 读取 Reasoning 输出

在启用了:

--reasoning-parser qwen3

之后,应用可以根据返回对象读取 reasoning 和最终内容。

建议先打印整个 message:

message = response.choices[0].message

print(message)

然后再根据你安装的 OpenAI SDK 版本检查具体字段。

教学代码不要假设所有 SDK 版本的对象结构都完全一致。

如果你的客户端需要兼容不同版本,可以使用:

reasoning = getattr(message, "reasoning", None)
content = getattr(message, "content", None)

print("reasoning:", reasoning)
print("content:", content)

核心概念:

reasoning
+
final content

是模型服务层输出结构的一部分,而不是业务应用必须展示的 UI。


22. 禁用 Thinking

Qwen3 可以通过 chat template 参数控制 thinking。

例如:

response = client.chat.completions.create(
    model="Qwen3-32B",
    messages=[
        {
            "role": "user",
            "content": "用一句话解释什么是 HTTP。",
        }
    ],
    extra_body={
        "chat_template_kwargs": {
            "enable_thinking": False
        }
    },
)

适合:

简单问答
分类
信息抽取
简单改写
低延迟任务

复杂数学、代码和推理任务则可以考虑保留 Thinking。


23. Node.js 开发

安装:

npm install openai

建议:

export VLLM_BASE_URL="http://YOUR_VLLM_SERVER:8000/v1"
export VLLM_API_KEY="你的 API Key"

代码:

import OpenAI from "openai";

const client = new OpenAI({
  baseURL: process.env.VLLM_BASE_URL,
  apiKey: process.env.VLLM_API_KEY,
});

const response = await client.chat.completions.create({
  model: "Qwen3-32B",
  messages: [
    {
      role: "user",
      content: "请解释一下什么是 Tensor Parallel。",
    },
  ],
  temperature: 0.6,
  max_tokens: 512,
});

console.log(response.choices[0].message.content);

24. Tool Calling:最小示例

先定义工具:

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询指定城市天气",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "城市名称",
                    }
                },
                "required": ["city"],
            },
        },
    }
]

向模型发送:

response = client.chat.completions.create(
    model="Qwen3-32B",
    messages=[
        {
            "role": "user",
            "content": "东京天气怎么样?",
        }
    ],
    tools=tools,
)

模型可能返回:

tool_calls
└── get_weather
    └── {"city": "东京"}

此时:

模型没有真正查询天气。

而是告诉业务程序:

“请帮我调用 get_weather。”

25. Tool Calling:完整闭环

下面是可以作为教学实验的完整 Python 示例。

import json
import os
from openai import OpenAI

client = OpenAI(
    base_url=os.environ["VLLM_BASE_URL"],
    api_key=os.environ["VLLM_API_KEY"],
)

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询指定城市天气",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "城市名称",
                    }
                },
                "required": ["city"],
            },
        },
    }
]


def get_weather(city: str) -> str:
    # 教学示例:生产环境这里应该调用真实天气 API。
    fake_weather = {
        "东京": "晴天,25℃",
        "北京": "多云,22℃",
        "上海": "小雨,24℃",
    }

    return fake_weather.get(city, "暂无天气数据")


messages = [
    {
        "role": "user",
        "content": "东京天气怎么样?",
    }
]

response = client.chat.completions.create(
    model="Qwen3-32B",
    messages=messages,
    tools=tools,
)

assistant_message = response.choices[0].message

messages.append(assistant_message)

if assistant_message.tool_calls:
    for tool_call in assistant_message.tool_calls:
        name = tool_call.function.name
        arguments = json.loads(tool_call.function.arguments)

        if name == "get_weather":
            result = get_weather(arguments["city"])
        else:
            result = "未知工具"

        messages.append(
            {
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result,
            }
        )

    final_response = client.chat.completions.create(
        model="Qwen3-32B",
        messages=messages,
        tools=tools,
    )

    print(final_response.choices[0].message.content)
else:
    print(assistant_message.content)

整个过程:

User
 │
 ▼
LLM
 │
 ├── 普通回答
 │
 └── Tool Call
       │
       ▼
Business App
       │
       ▼
真实工具
       │
       ▼
Tool Result
       │
       ▼
LLM
       │
       ▼
Final Answer

25.1 Tool Calling 的安全边界

生产环境不能因为模型输出:

{
  "city": "东京"
}

就无条件执行任何工具。

业务程序应该:

校验工具名称
校验参数 schema
校验权限
校验用户身份
校验资源范围
设置超时
设置重试
记录审计日志

例如:

模型要求删除数据库
        ↓
业务程序
        ↓
权限检查
        ↓
参数检查
        ↓
人工确认 / Policy
        ↓
真正执行

不要把“模型生成 Tool Call”误认为“模型拥有系统权限”。


26. 性能指标

不要只看:

这个请求用了多少秒?

至少关注:

TTFT
ITL
Decode TPS
E2E Latency

26.1 TTFT

TTFT:

Time To First Token

即:

用户发起请求
       ↓
模型开始生成
       ↓
第一个 token

TTFT 越低:

用户感觉“开始回答”越快。


26.2 ITL

ITL:

Inter-Token Latency

例如:

token1 ── 60ms ── token2 ── 60ms ── token3

那么:

ITL ≈ 60 ms/token

粗略换算:

1000 / 60 ≈ 16.7 token/s

因此:

ITL 对“回答时打字快不快”的体感非常重要。


26.3 Decode TPS

Decode TPS 可以粗略理解为:

生成 token 数 / decode 时间

注意:

总请求时间 ≠ 纯 decode 时间

因为总时间里面还包括:

网络
tokenizer
prefill
TTFT
decode
post processing

27. 一次真实 Benchmark 应该怎么做

原来的简单 benchmark:

time curl ...

可以作为入门实验,但不能把:

512 tokens / 31.4 seconds ≈ 16.3 tokens/s

直接当作严格 decode throughput。

更好的教学方法是:

第一步:固定输入

例如:

prompt = 固定字符串

第二步:固定输出上限

max_tokens = 512

第三步:固定 sampling

例如:

temperature = 0

第四步:重复多次

例如:

10 次

第五步:丢弃第一次

第一次可能受到:

CUDA lazy initialization
cache
JIT
HTTP connection

等因素影响。

第六步:记录

TTFT
ITL
completion_tokens
E2E latency

第七步:分别测试

concurrency = 1
concurrency = 2
concurrency = 4

这样才能看到:

单请求性能
vs
并发吞吐

27.1 最简单的粗略 benchmark

time curl -s \
  http://127.0.0.1:8000/v1/chat/completions \
  -H "Authorization: Bearer $VLLM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "Qwen3-32B",
    "messages": [
      {
        "role": "user",
        "content": "请计算 12345 × 6789,并简要说明计算过程。"
      }
    ],
    "max_tokens": 512,
    "temperature": 0
  }' \
  -o /tmp/qwen-bench.json

查看 token:

python3 - <<'PY'
import json

with open("/tmp/qwen-bench.json") as f:
    data = json.load(f)

usage = data["usage"]

print("prompt tokens:", usage["prompt_tokens"])
print("completion tokens:", usage["completion_tokens"])
print("total tokens:", usage["total_tokens"])
PY

如果:

completion = 512
E2E = 31.4s

只能粗略说:

512 / 31.4 ≈ 16.3 tokens/s

不能严谨地称为:

纯 decode throughput = 16.3 tokens/s

28. vLLM Metrics

查看:

curl http://127.0.0.1:8000/metrics

重点观察:

vllm:time_to_first_token_seconds
vllm:inter_token_latency_seconds
vllm:request_time_per_output_token_seconds

这些指标可以帮助分析:

Prefill
Decode
TTFT
ITL

28.1 Metrics 是累计值

这是一个非常容易被初学者忽略的问题。

例如:

vllm:inter_token_latency_seconds_sum

通常是从进程启动以来不断累计的。

所以:

不应该只执行一次 /metrics,然后把累计 sum 直接当作“本次请求耗时”。

正确做法之一:

请求前记录 metrics
       ↓
发送 benchmark 请求
       ↓
请求后再次记录 metrics
       ↓
计算 delta

或者直接使用专门 benchmark 工具。


29. GPU 监控

实时:

watch -n 1 nvidia-smi

观察:

GPU Memory
GPU Utilization
Power
Temperature
Processes

对于 Tensor Parallel:

GPU 0
GPU 1

都应该有相应的模型运行痕迹。

如果只有一张 GPU 工作,首先检查:

--tensor-parallel-size
CUDA_VISIBLE_DEVICES
容器 GPU 可见性
NCCL

30. 常见故障:容器 Up 但 API 不通

检查:

docker ps -a

然后:

docker logs --tail 100 qwen3-vllm

如果日志是:

Loading safetensors checkpoint shards

说明还在加载。

如果看到:

CUDA out of memory

则是显存问题。

如果看到:

Application startup complete.

但:

curl http://127.0.0.1:8000/health

仍然失败,则继续检查:

ss -lntp | grep ':8000'
docker port qwen3-vllm
docker inspect qwen3-vllm

31. 常见故障:GPU 显存不足

先:

nvidia-smi

再:

docker ps

检查是否同时运行:

Qwen3
QwQ
其他 vLLM
其他 CUDA 程序

多个模型共享两张 A40 时,很容易出现:

模型 A 抢显存
模型 B 启动失败

不要一看到 OOM 就盲目提高:

gpu-memory-utilization

因为如果实际物理显存已经不足,提高这个值反而可能更快 OOM。


32. 常见故障:8000 端口被占用

检查:

ss -lntp | grep ':8000'

或者:

docker ps

如果看到:

0.0.0.0:8000->8000/tcp

说明已有容器占用。

模型切换时尤其容易遇到:

旧容器还没停止
新容器已经尝试绑定 8000

所以切换脚本应该明确:

stop
remove
run
wait ready

33. 常见故障:401

如果启动时:

--api-key "$VLLM_API_KEY"

那么 /v1 API 请求必须带:

Authorization: Bearer YOUR_API_KEY

正确:

curl http://127.0.0.1:8000/v1/models \
  -H "Authorization: Bearer $VLLM_API_KEY"

错误:

curl http://127.0.0.1:8000/v1/models

34. API Key 与网络安全

这里有一个非常重要的生产环境知识点:

vLLM 的 --api-key 不是完整的网络安全边界。

vLLM 当前文档明确说明,API key 只保护部分 API 路径;其他 endpoint 可能没有相同的认证保护,因此生产环境不应该只依赖 --api-key。citeturn0search2

推荐:

Internet / Internal Network
          │
          ▼
┌─────────────────────────┐
│ Reverse Proxy / Gateway │
│ TLS                     │
│ Authentication          │
│ Rate Limit              │
│ IP ACL                  │
└────────────┬────────────┘
             │
             ▼
          vLLM :8000

可以使用:

Nginx
Caddy
Traefik
API Gateway

等作为外围安全层。


34.1 不要直接把 vLLM 暴露到公网

不推荐:

Internet
   ↓
server:8000
   ↓
vLLM

更推荐:

Internet
   ↓
HTTPS
   ↓
Reverse Proxy
   ↓
vLLM

35. 模型切换为什么需要停机窗口

如果只有两张 A40:

GPU 0 + GPU 1

而 Qwen3-32B 已经占用了大量显存,那么切换到另一个 32B 模型通常不能简单地:

新模型直接启动

因为两个模型会同时抢显存。

所以最简单的切换架构是:

停止旧模型
    ↓
释放 GPU
    ↓
启动新模型
    ↓
加载模型
    ↓
/health
    ↓
/v1/models
    ↓
服务恢复

因此:

固定 8000 端口不等于零停机切换。

固定端口解决的是:

客户端地址不变

并没有自动解决:

模型加载时间
GPU 资源
连接排空
请求迁移
零停机

36. 模型切换配置

推荐:

/etc/vllm-models/
├── common.conf
├── Qwen3-32B.conf
└── switch-model

36.1 common.conf

CONTAINER_NAME="qwen3-vllm"
PORT="8000"

TENSOR_PARALLEL_SIZE="2"
GPU_MEMORY_UTILIZATION="0.90"

IMAGE="vllm/vllm-openai:0.28.0"

API_KEY="请放真实密钥,不要提交 Git"

生产环境应该:

chmod 600 /etc/vllm-models/common.conf

36.2 Qwen3-32B.conf

MODEL_NAME="Qwen3-32B"
MODEL_PATH="/data/models/Qwen3-32B"

MAX_MODEL_LEN="65536"

ENABLE_AUTO_TOOL_CHOICE="true"
TOOL_CALL_PARSER="hermes"
REASONING_PARSER="qwen3"

HF_OVERRIDES='{"rope_parameters":{"rope_type":"yarn","factor":2.0,"original_max_position_embeddings":32768}}'

37. switch-model 脚本

下面是一个适合作为教学和小型单机环境的版本。

文件:

/usr/local/bin/switch-model

内容:

#!/usr/bin/env bash

set -euo pipefail

COMMON="/etc/vllm-models/common.conf"
MODEL_CONFIG_DIR="/etc/vllm-models"

if [[ $# -ne 1 ]]; then
    echo "Usage: switch-model <MODEL_NAME>"
    exit 1
fi

MODEL_NAME="$1"
MODEL_CONFIG="${MODEL_CONFIG_DIR}/${MODEL_NAME}.conf"

if [[ ! -f "$COMMON" ]]; then
    echo "Missing: $COMMON"
    exit 1
fi

if [[ ! -f "$MODEL_CONFIG" ]]; then
    echo "Missing model config: $MODEL_CONFIG"
    exit 1
fi

source "$COMMON"
source "$MODEL_CONFIG"

: "${CONTAINER_NAME:?missing CONTAINER_NAME}"
: "${PORT:?missing PORT}"
: "${IMAGE:?missing IMAGE}"
: "${API_KEY:?missing API_KEY}"
: "${MODEL_NAME:?missing MODEL_NAME}"
: "${MODEL_PATH:?missing MODEL_PATH}"

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

echo "Switching to model: $MODEL_NAME"

echo "[1/5] Stop old container"
docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true

echo "[2/5] Remove old container"
docker rm "$CONTAINER_NAME" >/dev/null 2>&1 || true

echo "[3/5] Start new container"

ARGS=(
    --model "/models/${MODEL_NAME}"
    --served-model-name "$MODEL_NAME"
    --tensor-parallel-size "$TENSOR_PARALLEL_SIZE"
    --gpu-memory-utilization "$GPU_MEMORY_UTILIZATION"
    --max-model-len "$MAX_MODEL_LEN"
    --api-key "$API_KEY"
)

if [[ "${ENABLE_AUTO_TOOL_CHOICE:-false}" == "true" ]]; then
    ARGS+=(--enable-auto-tool-choice)
fi

if [[ -n "${TOOL_CALL_PARSER:-}" ]]; then
    ARGS+=(--tool-call-parser "$TOOL_CALL_PARSER")
fi

if [[ -n "${REASONING_PARSER:-}" ]]; then
    ARGS+=(--reasoning-parser "$REASONING_PARSER")
fi

if [[ -n "${HF_OVERRIDES:-}" ]]; then
    ARGS+=(--hf-overrides "$HF_OVERRIDES")
fi

docker run -d \
    --name "$CONTAINER_NAME" \
    --restart unless-stopped \
    --gpus all \
    --ipc=host \
    -p "${PORT}:8000" \
    -v "${MODEL_PATH}:/models/${MODEL_NAME}:ro" \
    "$IMAGE" \
    "${ARGS[@]}"

echo "[4/5] Wait for health"

for i in $(seq 1 240); do
    if curl -fsS "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then
        break
    fi

    if ! docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null | grep -q true; then
        echo "Container exited unexpectedly."
        docker logs --tail 100 "$CONTAINER_NAME"
        exit 1
    fi

    sleep 1
done

if ! curl -fsS "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then
    echo "Health check timeout."
    docker logs --tail 100 "$CONTAINER_NAME"
    exit 1
fi

echo "[5/5] Verify model registration"

curl -fsS \
    "http://127.0.0.1:${PORT}/v1/models" \
    -H "Authorization: Bearer ${API_KEY}"

echo
echo "Model switch completed: $MODEL_NAME"

然后:

chmod 700 /usr/local/bin/switch-model

使用:

switch-model Qwen3-32B

37.1 这个脚本还有一个重要限制

上面的脚本是:

停止旧模型
    ↓
启动新模型

如果新模型启动失败:

旧模型已经没了

所以:

它适合作为教学版 / 单机运维版,但还不是高可用生产级切换系统。


38. 模型切换的回滚策略

生产环境应该考虑:

Current Model
      │
      ▼
Backup Config
      │
      ▼
Stop
      │
      ▼
Start New Model
      │
      ├── Success ──► New Model
      │
      └── Failure
              │
              ▼
         Start Old Model

至少应该保存:

当前模型名
当前镜像版本
启动参数
模型路径

例如:

/etc/vllm-models/current-model

内容:

Qwen3-32B

切换前:

current = Qwen3-32B
target  = OtherModel

如果 target 失败:

rollback → Qwen3-32B

38.1 真正的生产级零停机切换

如果要求:

不能断服务

那么不应该继续使用:

一个容器
一个 8000

而应该升级为:

                    Gateway
                       │
             ┌─────────┴─────────┐
             ▼                   ▼
          vLLM A              vLLM B
        Qwen3-32B           Other Model
             │                   │
             └─────────┬─────────┘
                       ▼
                     GPU

但这通常需要:

更多 GPU
或
不同资源规划

否则两个 32B 模型无法同时驻留。


39. 生产架构

教学环境:

Client
  ↓
vLLM :8000
  ↓
Qwen3-32B
  ↓
A40 × 2

生产环境更推荐:

                    Client
                      │
                      ▼
              ┌───────────────┐
              │ Reverse Proxy │
              │ TLS / Auth    │
              └───────┬───────┘
                      │
                      ▼
              ┌───────────────┐
              │ Model Gateway │
              │ Routing       │
              └───────┬───────┘
                      │
              ┌───────┴────────┐
              ▼                ▼
         ┌─────────┐       ┌─────────┐
         │ vLLM #1 │       │ vLLM #2 │
         │ Qwen3   │       │ Other   │
         └─────────┘       └─────────┘

进一步可以加入:

Prometheus
Grafana
日志系统
Redis
RAG
Vector DB
API Gateway
Kubernetes
多节点推理

40. vLLM 与 CLI 的区别

CLI:

用户
  │
  ▼
命令行程序
  │
  ▼
模型
  │
  ▼
输出

例如:

python inference.py

更像:

一个人执行一次任务。

vLLM Server:

Client A ─┐
Client B ─┤
Client C ─┤
Client D ─┘
      │
      ▼
  HTTP API
      │
      ▼
    vLLM
      │
      ▼
     GPU

更像:

一个模型基础设施,为多个应用提供统一推理能力。


41. vLLM 与 SDK 的区别

系统可以分成:

┌─────────────────────────────┐
│        Business App         │
│        你的业务代码          │
└──────────────┬──────────────┘
               │
               ▼
┌─────────────────────────────┐
│        Client SDK           │
│     OpenAI Python SDK       │
└──────────────┬──────────────┘
               │ HTTP
               ▼
┌─────────────────────────────┐
│           vLLM              │
│      Inference Server       │
└──────────────┬──────────────┘
               │
               ▼
              GPU

因此:

SDK = 调用方工具
vLLM = 推理服务
模型 = 推理对象
业务应用 = 解决用户问题的软件

这四个概念不要混在一起。


42. 教学实验:最小聊天程序

创建:

chat.py

代码:

import os
from openai import OpenAI

client = OpenAI(
    base_url=os.environ["VLLM_BASE_URL"],
    api_key=os.environ["VLLM_API_KEY"],
)

messages = []

while True:
    user_input = input("You: ")

    if user_input.lower() in {"exit", "quit"}:
        break

    messages.append(
        {
            "role": "user",
            "content": user_input,
        }
    )

    response = client.chat.completions.create(
        model="Qwen3-32B",
        messages=messages,
        temperature=0.6,
        max_tokens=1024,
    )

    answer = response.choices[0].message.content

    print("AI:", answer)

    messages.append(
        {
            "role": "assistant",
            "content": answer,
        }
    )

运行:

export VLLM_BASE_URL="http://127.0.0.1:8000/v1"
export VLLM_API_KEY="你的 API Key"

python3 chat.py

43. 教学实验:Streaming

将:

stream=True

加入请求:

stream = client.chat.completions.create(
    model="Qwen3-32B",
    messages=messages,
    stream=True,
)

然后:

for chunk in stream:
    delta = chunk.choices[0].delta

    if delta.content:
        print(
            delta.content,
            end="",
            flush=True,
        )

学生可以直观看到:

一次性返回

与:

Token → Token → Token → Token

的区别。


44. 教学实验:观察完整请求链路

同时打开四个终端。

终端 1:Docker 日志

docker logs -f qwen3-vllm

终端 2:GPU

watch -n 1 nvidia-smi

终端 3:Metrics

watch -n 1 \
'curl -s http://127.0.0.1:8000/metrics | grep -E "time_to_first_token|inter_token_latency|request_time_per_output_token"'

终端 4:业务程序

python3 chat.py

于是可以把:

用户输入
   ↓
HTTP
   ↓
vLLM
   ↓
Scheduler
   ↓
GPU
   ↓
Prefill
   ↓
Decode
   ↓
Streaming
   ↓
Client

整个过程联系起来。


45. 生产部署 Checklist

GPU

[ ] nvidia-smi 正常
[ ] GPU 数量正确
[ ] GPU 型号正确
[ ] GPU 显存足够
[ ] 没有其他进程抢占显存

Docker

[ ] Docker 正常
[ ] NVIDIA Container Toolkit 正常
[ ] docker --gpus all 正常
[ ] --ipc=host

模型

[ ] 模型文件完整
[ ] config.json 存在
[ ] tokenizer 存在
[ ] 模型路径正确
[ ] 模型版本已记录

vLLM

[ ] 镜像 tag 已固定
[ ] 镜像 digest 已记录
[ ] vLLM version 已记录
[ ] Tensor Parallel 正确
[ ] max-model-len 合理
[ ] RoPE / YaRN 与模型匹配
[ ] reasoning parser 正确
[ ] tool parser 正确

API

[ ] /health = 200
[ ] /v1/models 正常
[ ] /v1/chat/completions 正常
[ ] Streaming 正常
[ ] API key 正确

安全

[ ] API Key 没有提交 Git
[ ] API Key 没有写进前端
[ ] vLLM 没有直接暴露到公网
[ ] TLS 已配置
[ ] Reverse Proxy / Gateway 已配置
[ ] Rate Limit 已考虑
[ ] IP ACL / 网络隔离已考虑

监控

[ ] GPU utilization
[ ] GPU memory
[ ] TTFT
[ ] ITL
[ ] Decode TPS
[ ] 请求数量
[ ] 错误率
[ ] P95 / P99 latency

模型切换

[ ] 有 current model 记录
[ ] 有启动配置
[ ] 有失败检测
[ ] 有回滚配置
[ ] 有停机窗口说明
[ ] 如果要求零停机,有双实例 / Gateway 架构

46. 最重要的几个结论

结论 1:模型不是服务

Qwen3-32B

只是模型。

真正对外提供 HTTP API 的是:

vLLM

结论 2:vLLM 不是业务应用

业务应用应该独立于 vLLM:

业务应用
    ↓
OpenAI-compatible API
    ↓
vLLM
    ↓
Qwen3

结论 3:固定端口可以隐藏模型切换

客户端可以固定:

http://server:8000/v1

后台切换:

Qwen3-32B
     ↓
Other Model
     ↓
Another Model

客户端不需要修改:

IP
Port
Base URL

但可能需要根据服务约定修改:

model

结论 4:固定端口不等于零停机

单机双 GPU 场景下:

停止旧模型
↓
启动新模型
↓
加载几十 GB 权重
↓
服务 ready

本质上仍然可能存在服务窗口。


结论 5:性能不能只看总耗时

至少关注:

TTFT
ITL
Decode TPS
E2E latency
Concurrency

结论 6:Reasoning 和 Tool Calling 是两个概念

Reasoning:

模型如何进行推理

Tool Calling:

模型如何请求业务程序执行工具

二者可以同时存在。


47. 进一步学习路线

Level 1
Docker + vLLM
        ↓
Level 2
OpenAI-compatible API
        ↓
Level 3
Python / Node.js
        ↓
Level 4
Streaming
        ↓
Level 5
Reasoning
        ↓
Level 6
Tool Calling
        ↓
Level 7
RAG
        ↓
Level 8
Agent
        ↓
Level 9
Prometheus / Grafana
        ↓
Level 10
Gateway / Auth / Rate Limit
        ↓
Level 11
多模型
        ↓
Level 12
Kubernetes / 多节点

48. 官方资料

教学时建议优先参考与你实际版本对应的官方文档。

  • vLLM Docker 部署文档
  • vLLM OpenAI-compatible Server
  • vLLM serve 参数
  • vLLM Reasoning / Tool Calling 示例
  • Qwen3 官方模型文档

参考入口:

https://docs.vllm.ai/en/v0.28.0/deployment/docker/
https://docs.vllm.ai/en/v0.28.0/serving/online_serving/openai_compatible_server/
https://docs.vllm.ai/en/v0.28.0/cli/serve/
https://docs.vllm.ai/en/v0.28.0/examples/reasoning/openai_chat_completion_tool_calls_with_reasoning/

49. 版本与变更记录

本教程基线

OS: Ubuntu 22.04
GPU: NVIDIA A40 × 2
Driver: 580.178.04
CUDA: 13.0
vLLM: 0.28.0
Model: Qwen3-32B
Precision: BF16
Tensor Parallel: 2
GPU Memory Utilization: 0.90
Max Model Length: 65536
Port: 8000
Model Path: /data/models/Qwen3-32B

本次审核修复内容

相对于第一版,本版重点修复和补充了:

  1. latest 改成固定版本作为教学基线

    • 避免课程环境因为镜像滚动更新而突然发生行为变化。
  2. 补充“容器 Up ≠ 模型 Ready”

    • 明确 /health 才是服务 ready 的重要判断。
    • 明确几十 GB 模型加载期间出现 API 不通并不一定是故障。
  3. 修正 Benchmark 的概念

    • time curl 得到的是端到端粗略耗时。
    • 不能直接当作纯 Decode TPS。
    • 增加 TTFT、ITL、Decode TPS、E2E 和并发测试方法。
  4. 补充 Metrics 累计值问题

    • *_sum*_count 通常是进程生命周期内累计。
    • 不能直接把累计值当单次请求指标。
  5. 补充完整 Tool Calling 闭环

    • 从 tools 定义、模型请求、业务程序执行、tool result 到最终回答。
  6. 补充 Tool Calling 安全边界

    • 模型生成 Tool Call 不等于模型获得系统权限。
    • 业务应用必须做权限、参数、超时和审计。
  7. 补充 API Key 的真实安全边界

    • vLLM API key 不是完整的 HTTP 安全边界。
    • 生产环境应该放在 Reverse Proxy / Gateway 后面。
  8. 补充模型切换失败回滚

    • 第一版只描述了 switch-model,没有充分强调“新模型启动失败后旧模型已经停止”的风险。
    • 本版增加 current model、rollback 和零停机架构说明。
  9. 修复示例代码的可移植性

    • API 地址和 Key 改成环境变量。
    • 避免把具体服务器地址和真实密钥硬编码到教学代码。
  10. 补充 SDK / CLI / vLLM / 模型的概念边界

    • 明确四者职责,避免初学者把 SDK 当推理引擎或把模型当服务。
  11. 补充生产部署 Checklist

    • 增加镜像 digest、模型版本、P95/P99、回滚、安全边界等项目。

50. 附录:推荐项目目录

如果把本教程进一步做成完整教学仓库,推荐:

LLM-Server-Training/
│
├── README.md
│
├── docs/
│   └── Qwen3-32B-vLLM-大模型服务部署与开发实战.md
│
├── python/
│   ├── chat.py
│   ├── streaming.py
│   ├── reasoning.py
│   └── tool_calling.py
│
├── node/
│   ├── package.json
│   ├── chat.js
│   └── streaming.js
│
├── deploy/
│   ├── common.conf.example
│   ├── Qwen3-32B.conf.example
│   └── switch-model.example
│
├── benchmark/
│   ├── basic.sh
│   └── README.md
│
└── .gitignore

.gitignore 至少应该包含:

.env
*.secret
*.key
__pycache__/
node_modules/

不要把:

真实 API Key
服务器密码
SSH 私钥
云平台 Token

提交到教学仓库。


结语

完成这个项目以后,你真正掌握的并不是:

“我会启动一个 Qwen3-32B”

而是下面这套可迁移的工程结构:

                 ┌─────────────────────┐
                 │      Client         │
                 └──────────┬──────────┘
                            │
                            ▼
                 ┌─────────────────────┐
                 │    Business App     │
                 │ Python / Node / Java│
                 └──────────┬──────────┘
                            │
                    OpenAI-compatible
                            │
                            ▼
                 ┌─────────────────────┐
                 │       vLLM          │
                 │ Inference / Serving │
                 └──────────┬──────────┘
                            │
                    Tensor Parallel
                            │
                   ┌────────┴────────┐
                   ▼                 ▼
                A40 #0            A40 #1
                   │                 │
                   └────────┬────────┘
                            ▼
                       Qwen3-32B

一旦理解这套结构:

Qwen3-32B

可以换成其他模型;

Python

可以换成:

Node.js / Java / Go / C#

而:

业务应用
    ↓
OpenAI-compatible API
    ↓
vLLM

这一层抽象仍然成立。

这就是从:

“在 GPU 服务器上跑一个模型”

走向:

“建设一个可以被多个业务系统复用的大模型推理基础设施”

的关键一步。