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为教学基线;如果你使用其他版本,请先对照对应版本的官方文档重新验证参数。
目录
- 先理解整个系统
- 模型、vLLM、SDK、CLI 到底分别是什么
- 实验环境
- GPU 与驱动检查
- Docker GPU 环境检查
- 模型文件管理
- Qwen3-32B 与 BF16
- 为什么两张 A40 使用 Tensor Parallel
- 启动 vLLM
- 启动参数详解
- 为什么本部署使用 YaRN
- Reasoning / Thinking
- Tool Calling
- 检查容器
- 查看启动日志
- 健康检查
- 查询模型
- 第一个 curl 请求
- Python 开发
- Streaming
- 读取 Reasoning 输出
- 禁用 Thinking
- Node.js 开发
- Tool Calling:最小示例
- Tool Calling:完整闭环
- 性能指标
- 一次真实 Benchmark 应该怎么做
- vLLM Metrics
- GPU 监控
- 常见故障:容器 Up 但 API 不通
- 常见故障:GPU 显存不足
- 常见故障:8000 端口被占用
- 常见故障:401
- API Key 与网络安全
- 模型切换为什么需要停机窗口
- 模型切换配置
- switch-model 脚本
- 模型切换的回滚策略
- 生产架构
- vLLM 与 CLI 的区别
- vLLM 与 SDK 的区别
- 教学实验:最小聊天程序
- 教学实验:Streaming
- 教学实验:观察完整请求链路
- 生产部署 Checklist
- 最重要的几个结论
- 进一步学习路线
- 官方资料
- 版本与变更记录
- 附录:推荐项目目录
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 服务。citeturn0search0turn0search2
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. 实验环境
本教程基于实际部署环境整理。
| 项目 | 配置 |
|---|---|
| OS | Ubuntu 22.04 |
| GPU | NVIDIA A40 × 2 |
| GPU 显存 | 每张约 46 GB |
| NVIDIA Driver | 580.178.04 |
| CUDA | 13.0 |
| vLLM | 0.28.0 |
| Docker Image | vllm/vllm-openai:0.28.0 |
| 模型 | Qwen3-32B |
| 模型精度 | BF16 |
| Tensor Parallel | 2 |
| GPU Memory Utilization | 0.90 |
| API Port | 8000 |
| Max Model Len | 65536 |
| 模型目录 | /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 的典型启动方式。citeturn0search0
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
作为典型参数。citeturn0search0
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 与工具调用格式。citeturn0search5
概念上:
模型输出
│
├── 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 配置。citeturn0search1
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。citeturn0search2
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 的典型客户端方式。citeturn0search8
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。citeturn0search2
推荐:
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
本次审核修复内容
相对于第一版,本版重点修复和补充了:
-
把
latest改成固定版本作为教学基线- 避免课程环境因为镜像滚动更新而突然发生行为变化。
-
补充“容器 Up ≠ 模型 Ready”
- 明确
/health才是服务 ready 的重要判断。 - 明确几十 GB 模型加载期间出现 API 不通并不一定是故障。
- 明确
-
修正 Benchmark 的概念
time curl得到的是端到端粗略耗时。- 不能直接当作纯 Decode TPS。
- 增加 TTFT、ITL、Decode TPS、E2E 和并发测试方法。
-
补充 Metrics 累计值问题
*_sum、*_count通常是进程生命周期内累计。- 不能直接把累计值当单次请求指标。
-
补充完整 Tool Calling 闭环
- 从 tools 定义、模型请求、业务程序执行、tool result 到最终回答。
-
补充 Tool Calling 安全边界
- 模型生成 Tool Call 不等于模型获得系统权限。
- 业务应用必须做权限、参数、超时和审计。
-
补充 API Key 的真实安全边界
- vLLM API key 不是完整的 HTTP 安全边界。
- 生产环境应该放在 Reverse Proxy / Gateway 后面。
-
补充模型切换失败回滚
- 第一版只描述了 switch-model,没有充分强调“新模型启动失败后旧模型已经停止”的风险。
- 本版增加 current model、rollback 和零停机架构说明。
-
修复示例代码的可移植性
- API 地址和 Key 改成环境变量。
- 避免把具体服务器地址和真实密钥硬编码到教学代码。
-
补充 SDK / CLI / vLLM / 模型的概念边界
- 明确四者职责,避免初学者把 SDK 当推理引擎或把模型当服务。
-
补充生产部署 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 服务器上跑一个模型”
走向:
“建设一个可以被多个业务系统复用的大模型推理基础设施”
的关键一步。
评论交流
欢迎留下你的想法