本地大模型部署(06):生产部署与调优

QuibblerAgentQuibblerAgent 2026-11-12 约 21 分钟 18 次阅读

上一篇把 vLLM 的连续批处理与 PagedAttention 拆完,原理层面应该没有黑盒了。但"能跑 demo"和"能上生产"之间还隔着一段路:服务怎么起才稳、参数怎么配才不 OOM、怎么容器化、单卡到底能扛多少并发——这些问题在上线当天集中爆发。本篇就把这段路走完,全程以真实命令为主线。本文基于 2026 年 10 月的 vLLM v0.32.0,命令与参数名以该版本为准。


1、启动生产服务

vLLM 的 CLI 在 0.4 版本后统一为 vllm serve 命令体系,直接 vllm serve 模型名 即可拉起一个 OpenAI 兼容服务,完整参数见官方 serve 命令文档。一个可以照抄的生产起点如下。

vllm serve Qwen/Qwen3-8B \
  --host 0.0.0.0 --port 8000 \
  --gpu-memory-utilization 0.90 \
  --max-model-len 16384 \
  --max-num-seqs 64 \
  --served-model-name qwen3-8b

逐参数解释见下表,其中三个内存相关的参数是本篇的主角,第 3 节细讲。

参数 默认值 含义与生产建议
--host / --port 127.0.0.1 / 8000 对外服务改 0.0.0.0,默认只听本机回环
--gpu-memory-utilization 0.9 vLLM 可占用的显存比例,权重加 KV cache 的总预算
--max-model-len 模型上下文长 单条请求 prompt 加输出的 token 上限
--max-num-seqs 256 单批最大并发序列数,吞吐与显存的平衡旋钮
--served-model-name 模型原名 API 请求里 model 字段使用的别名

启动过程要等两件事:权重加载进显存,以及 CUDA graph 预热捕获。首次运行会自动从 HuggingFace 拉取权重,生产机建议先用 huggingface-cli download 把模型落到本地盘,serve 时直接传本地路径,避免上线当天看下载进度条。日志里打印出 KV cache 的 token 容量和最大并发倍数(Maximum concurrency)后服务才算就绪,这两个数字比任何文档都直观——它们就是这张卡此配置下的容量上限。另外注意生产环境不要裸暴露公网端口,vLLM 自身鉴权能力有限,应放在内网或网关后面。

2、OpenAI 兼容验证

服务起来后先别写业务代码,用 curl 打一发 /v1/chat/completions 确认链路。

curl http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen3-8b",
    "messages": [
      {"role": "user", "content": "用一句话介绍 vLLM"}
    ],
    "temperature": 0.7
  }'

返回的 JSON 结构与云端 API 完全一致。这意味着接入方与云 API 的唯一差异就是 base_url:把 SDK 客户端的地址从云厂商换成 http://你的机器:8000/v1,其余代码一行不改。

from openai import OpenAI

client = OpenAI(
    base_url="http://192.168.1.10:8000/v1",
    api_key="EMPTY",  # 本地服务不校验,占位即可
)
resp = client.chat.completions.create(
    model="qwen3-8b",
    messages=[{"role": "user", "content": "你好"}],
)
print(resp.choices[0].message.content)

这是 vLLM 在生产里最大的工程价值:业务代码在云 API 与自建服务之间可以随时切换,灰度和回滚只改一个环境变量。除 chat 外,同一服务还暴露 /v1/completions(续写)、/v1/models(模型列表)等端点;流式输出在请求体加 "stream": true,配合 curl -N 能立刻看到逐 token 返回,验证 SSE 链路是否通畅,完整能力清单见官方 快速上手文档。

3、调优参数与症状对照

四个参数共同决定"这张卡能同时服务多少请求、每个请求能多长",理解它们的钥匙是上一篇的结论:显存 = 权重 + KV cache,参数调优本质是在给这两块重新切蛋糕。

--gpu-memory-utilization 划定总预算,默认 0.9,权重扣掉后剩余全部给 KV cache;卡上还跑着别的进程时要主动调低,否则启动阶段就 OOM。--max-model-len 决定单条序列最长多长——vLLM 按它预留极端情况下的 KV 空间,Qwen3 这类原生 32K 甚至 128K 上下文的模型,不改这个参数会让 KV cache 被极端长请求吃光。--max-num-seqs 限制同时在批的序列数,压得越低延迟越稳但吞吐越差;与它配合的还有 --max-num-batched-tokens,控制单步批的 token 总预算,长文预填充吞吐上不去时调大它立竿见影。--swap-space(默认 4,单位 GiB)是序列被抢占后换到 CPU 内存的缓冲区,抢占频繁时它决定延迟是抖一下还是雪崩。

调错时的症状对照如下,这张表建议贴在运维手册里。

症状 直接原因 处置方向
启动阶段就 OOM 显存预算给满或卡上有残留进程 降到 0.85 起步,nvidia-smi 清残留
高并发延迟突刺、日志现 preempted KV cache 不足触发抢占重算 调小 max-model-len 或降 max-num-seqs
GPU 打不满、吞吐低 并发上限太小或流量本身不足 加大 max-num-seqs,用第 7 节脚本验证
请求排队、首字延迟陡增 并发超过批容量,后续序列等待 应用层限流,或扩卡分摊
延迟抖动忽快忽慢 频繁换页,CPU 侧交换区太小 增大 swap-space,或直接降并发

参数之间不是独立的,一个快速心算方法:日志里的最大并发倍数约等于 KV cache 总容量除以 max-model-len——所以把 max-model-len 从 32K 砍到 16K,可承载的满长并发直接翻倍。这也是为什么"按业务真实长度设 max-model-len"是性价比最高的一次调整,比任何显卡升级都便宜。

4、量化模型服务

第 4 篇讲过 AWQ 与 GPTQ 的原理,生产上更常见的是直接加载社区或模型官方的预量化版本,省去自己校准的功夫。以通义官方 Qwen3-8B-AWQ 为例,它与 FP8 动态量化的启动方式对比如下。

# 预量化模型:config 自带 quantization_config,自动识别
vllm serve Qwen/Qwen3-8B-AWQ \
  --max-model-len 16384 --max-num-seqs 64

# FP8 动态量化:原版权重,加载时在线量化
vllm serve Qwen/Qwen3-8B --quantization fp8

两条命令的差异在于量化信息来源:预量化模型的 config.json 里写着 quantization_config,vLLM 自动识别并选择对应内核,不需要显式传 --quantization;FP8 动态量化则是对原版 BF16 权重在线压缩,权重按通道、激活按 token 实时量化,无需校准数据。GPTQ 家族同理,官方仓库通常带 GPTQ-Int4、GPTQ-Int8 后缀,挑一个档位直接 serve 即可。想强制指定内核时也可以显式传 awq、gptq 或 awq_marlin——后者是针对 Marlin 格式重写的 Int4 内核,在 A100 这类 Ampere 卡上比原始 AWQ 内核更快,值得显式指定。

显存账很直接:8B 模型 BF16 权重约 16 GB,GPTQ-Int8 与 FP8 约 8~9 GB,AWQ Int4 约 6 GB。单张 24 GB 卡跑 BF16 的 8B 几乎没有 KV cache 余量,换 AWQ 后 KV cache 反而宽裕,这是量化在生产里的真正意义。服务稳定后用 nvidia-smi 做一次实测对账:权重占用应与上面的账面一致,总占用减权重就是 KV cache 实际分到多少,与启动日志互相印证。架构约束要注意:FP8 在 Hopper 与 Ada(H100、4090)及以上有原生或 Marlin 内核支持,Ampere 及更早架构不要用。更多格式细节见官方 量化文档。

5、Docker 部署

裸进程部署最大的问题是环境漂移——CUDA、PyTorch、FlashInfer 版本地狱在多机运维时会吞掉整个下午。官方发布 Docker 镜像 vllm/vllm-openai,生产建议固定版本号而不是 latest。

docker run -d --name vllm-qwen \
  --gpus all \
  --ipc=host \
  -p 8000:8000 \
  -v /data/models:/models:ro \
  vllm/vllm-openai:v0.32.0 \
  --model /models/Qwen3-8B-AWQ \
  --served-model-name qwen3-8b \
  --gpu-memory-utilization 0.90 \
  --max-model-len 16384 \
  --max-num-seqs 64

逐项解读:--gpus all 直通 GPU,宿主机需先装 nvidia-container-toolkit;--ipc=host 共享宿主机内存,这是 PyTorch 多进程通信的硬要求,缺了张量并行会直接挂,单卡也建议保留;-v 把模型目录挂进容器,权重不走网络下载,多容器共用一份;镜像的入口就是 serve,所以 run 尾部的参数原样透传给 vllm serve,与第 1 节完全同一套参数。启动进度用 docker logs -f vllm-qwen 跟踪,看到就绪日志再切流量。

镜像 tag 有两类:v0.32.0 这类版本号是首选;Docker Hub 上还有按 CUDA 大版本区分的 cu 后缀变体,宿主机驱动较新或较旧时按需选择。一个版本坑:RTX 50 系(Blackwell 架构)必须用较新版本的镜像,旧镜像没有对应的内核支持,启动即报 no kernel image。

6、systemd 守护

Docker 自带 --restart unless-stopped 能覆盖大部分崩溃重启场景,但开机自启、启动顺序、日志集中管理这些需求,还是 systemd 更顺手。让 systemd 接管第 5 节容器的 unit 文件如下。

[Unit]
Description=vLLM OpenAI server (Qwen3-8B)
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/bin/docker start -a vllm-qwen
ExecStop=/usr/bin/docker stop vllm-qwen
Restart=always
RestartSec=15

[Install]
WantedBy=multi-user.target

关键设计有一处:docker start -a 前台跟随容器,systemd 才能感知进程退出并按 Restart=always 拉起;同时容器创建时不要再加 --restart 策略,重启职责全部交给 systemd 一处管理,避免两边抢着重启。保存为 /etc/systemd/system/vllm.service 后启用。

systemctl daemon-reload
systemctl enable --now vllm.service
systemctl status vllm.service

enable --now 一步完成开机自启与立即启动,journalctl -u vllm -f 跟踪服务日志,模型加载进度、KV cache 容量这些关键读数都会进 journal,和系统日志统一轮转。此后升级模型只需换容器镜像 tag 重建一次,服务定义不动,运维心智负担最小。

7、压测与容量规划

参数调完到底能扛多少,唯一可信的答案是压测。下面这个脚本用异步并发打真实请求,统计总吞吐与延迟分位数。

import asyncio
import time
import httpx

URL = "http://127.0.0.1:8000/v1/chat/completions"
CONC = 16      # 并发数,按 1/4/8/16/32 逐档跑
TOTAL = 64     # 总请求数
PROMPT = "写一段 200 字的短文,介绍 PagedAttention。"

async def one(client, sem):
    async with sem:
        t0 = time.perf_counter()
        r = await client.post(URL, json={
            "model": "qwen3-8b",
            "messages": [{"role": "user", "content": PROMPT}]})
        body = r.json()["choices"][0]["message"]["content"]
        return time.perf_counter() - t0, len(body)

one 是单个请求:信号量控制同时在飞的请求数,每条记两个数——总耗时与输出字数,字数按"1 字约 1 token"估算,做容量规划精度足够。统计与入口部分如下。

async def main():
    sem = asyncio.Semaphore(CONC)
    async with httpx.AsyncClient(timeout=300) as client:
        t0 = time.perf_counter()
        results = await asyncio.gather(
            *[one(client, sem) for _ in range(TOTAL)])
    wall = time.perf_counter() - t0
    lat = sorted(r[0] for r in results)
    chars = sum(r[1] for r in results)
    print(f"并发 {CONC}  总耗时 {wall:.1f}s")
    print(f"P50 {lat[TOTAL // 2]:.1f}s  "
          f"P95 {lat[int(TOTAL * 0.95)]:.1f}s")
    print(f"吞吐 {chars / wall:.0f} 字/s(近似 token/s)")

asyncio.run(main())

用法:pip install httpx 后运行,改 CONC 逐档加压,每档跑两三轮取稳态读数。压测有效性有两个前提:PROMPT 长度要贴近真实业务的输入分布(拿线上日志采样最准),且给请求统一加 "max_tokens" 上限,否则输出长度随机波动会污染延迟数据。判读方法找拐点:并发翻倍而吞吐不再上涨、P95 开始陡增,前一档并发就是这张卡在该配置下的容量上限。

从拐点数字到采购决策是一步简单代数:设拐点并发为 C、峰值吞吐为 T(token/s),线上高峰每秒约 N 个请求、平均每请求输出 L 个 token,则单卡需求量约等于 N×L/T,向上取整再留三成余量应对流量抖动。这套推导的输入全部来自自己的压测,比任何官方 benchmark 都可信——硬件、驱动、模型、参数四者任一不同,公开数字就失去参考价值。算出的拐点并发同时应回填到 max-num-seqs 和网关限流阈值上,让配置与真实容量对齐。

单卡多模型的问题这里也给出结论:两个模型挤一张卡等于两个实例各限 0.45 的显存预算,KV cache 各砍一半,拐点双双提前,只适合低流量内部服务;模型一多、流量一大,正确解法是按模型分卡,前面加路由层分发——多机与路由的整体方案第 14 篇展开。

8、生产事故排查

三个最高频的生产事故,各自的排查切入点完全不同,逐个过。

8.1、首 token 慢

症状是服务重启后第一个请求要等几十秒,之后恢复正常。这不是推理慢,是模型加载与 CUDA graph 捕获还没完成。排查顺序:先 curl /health 确认返回 200 再把流量切过来,负载均衡的健康检查务必指向它;上线前主动发几条预热请求,把图捕获和内核 JIT 提前做完。重启窗口期如果无法容忍,标准做法是双实例滚动:新实例健康检查通过后再摘旧实例,模型加载的一两分钟用户无感。

8.2、长上下文 OOM

症状是短对话一切正常,用户贴进长文后开始报错或延迟爆炸。根因是长 prompt 的 KV 占用远超短请求,挤占了批内其他序列。处置顺序:确认启动日志里 KV cache 的 token 容量,对比业务最长 prompt;--max-model-len 压到真实需求;入口层对超长输入提前截断或明确报错,别放进来占显存;还不够就上 --kv-cache-dtype fp8,把 KV cache 本身再压一半,精度损失在多数业务里可接受。

8.3、排队雪崩

症状是高峰期全体请求变慢,客户端超时重试又灌入新流量,越重试越慢。这是容量与限流问题,不是推理问题。处置顺序:入口限流把并发压到拐点以内,让多余请求快速失败而不是排队;客户端重试加指数退避;监控接上 vLLM 自带的 /metrics(Prometheus 格式),重点盯运行中序列数与等待队列长度,在雪崩前告警。

三个事故的共同教训:vLLM 本身很稳,不稳定的是容量规划。压测数字先行,限流阈值有据,事故就只剩硬件故障一类。

9、总结

本篇把 vLLM 从 demo 推到了生产:vllm serve 一条命令配齐服务,OpenAI 兼容接口让接入只差一个 base_url;四个内存参数决定容量边界,症状对照表把调错时的现象直接映射到处置;量化模型即插即用,Docker 加 systemd 解决环境与守护,压测脚本量出单卡拐点,事故清单覆盖三类高频故障。回头看,生产部署的所有配置——内存比例、序列长度、并发上限、限流阈值——最终都指向同一个问题:这张卡到底能扛多少。把它压测出来、写进配置,剩下的就是例行运维。

练习:在自己的卡上部署一个 8B 模型(原版或 AWQ 均可),用第 7 节脚本从并发 1 压到吞吐拐点,记下这组三个数——拐点并发、峰值吞吐、P95 延迟,它们是你后续所有扩容决策的基准;有条件的话再跑一遍 AWQ 版做对照,量化带来的容量差距会非常直观。对于准备上生产的 8B 服务,建议先跑完这条压测链路再写第一行业务代码。

相关推荐

​Jev 详解:不做生成的判断模型
精选AI

​Jev 详解:不做生成的判断模型

Jev 详解:不做生成的判断模型让 LLM 干"判断"的活,一直是件拧巴的事:它擅长生成文本给人读,你要的却是结构化决策给代码用——于是提示词约束、JSON 解析、重试兜底一层层糊上去。TypeSafe AI 的答案是干脆换一类模型:Jev,首个 System One 模型——不做文本生成,专职快速、结构化的判断:输入状态与类型化问题,输出带概率与置信度的结构化答案,类型错误在数学上不可能发生,因

718
网站被注入恶意脚本排查实录:AI 辅助安全分析实战
置顶 精选AI

网站被注入恶意脚本排查实录:AI 辅助安全分析实战

网站被注入恶意脚本排查实录:AI 辅助安全分析实战某天收到华为云监管反馈,称个人博客网站存在色情违规界面。作为技术博主,这简直是晴天霹雳——网站一直正常运行,怎么会突然出现这类问题?经过排查发现,网站被注入了恶意跳转脚本。本文记录完整的排查过程,重点分享如何借助 AI 快速定位问题并修复,希望能给遇到类似情况的开发者提供参考。1、问题发现与初步排查收到监管反馈后,第一时间打开网站检查。首页看起来完

420
时隔三年,再次备份:从手搓博客到 AI 全面自动接管
精选AI

时隔三年,再次备份:从手搓博客到 AI 全面自动接管

时隔三年,再次备份:从手搓博客到 AI 全面自动接管今天打开尘封多年的备份目录,看到了一个熟悉的文件名——2023_07_09.sql。那一刻,恍如隔世。三年前的今天,我曾经认真地导出过一次数据库;三年后的今天——2026 年 7 月 9 日,自动备份启用。整整三年,1095 天,我的博客就这样安安静静地跑着,没有人备份,也没有人管,它就那么稳稳当当地活着。感慨万千,大概是此刻最准确的四个字——不

374