vLLM 部署实操:起一个 OpenAI 兼容服务
团队开会决定「这个模型我们自己部署」的那一刻,最先冒出来的担心通常不是显卡够不够,而是一句更实际的话:那我们代码里那一堆调用是不是要重写一遍?
答案是不用。vLLM 本身就提供一个 OpenAI 兼容的 HTTP 服务,你原来用的那套 SDK、那套请求体结构、那套流式解析逻辑,基本原封不动,改的是 base_url 指向哪里。真正变化的在另一头:健康检查、指标采集、显存怎么分、并发上限压在哪里——这些以前是厂商在他们的机房里默默替你扛掉的,现在成了你的运维责任。
我见过不少团队把自建的评估重心放在「代码改造量」上,然后发现改造量约等于零,于是很兴奋地上了线,接着在第一次流量高峰被 KV cache 不够的报错打回来。所以这篇按真实的部署顺序讲:先把服务起来,再把可观测性接上,最后把显存和并发这笔账算明白。关于自建和调 API 的取舍本身,可以看 自建推理还是直接调 API;这篇只谈「已经决定自建之后」的事。
一、起服务:vllm serve
启动命令的形式很简单:
vllm serve <model>
官方文档给的示例是:
vllm serve NousResearch/Meta-Llama-3-8B-Instruct
文档里出现的启动参数包括 --chat-template(指向一个 jinja 模板文件,形如 --chat-template ./path-to-chat-template.jinja)、--chat-template-content-format、--enable-offline-docs;文档另外提到存在 --api-key、--served-model-name、--host、--port 这几个参数。
这里有一件事我要说清楚,而且希望你把它当成本文最有用的一条建议:这篇文章里不会出现任何一个参数的默认值,也不会出现默认端口。
不是我偷懒,是因为官方那一页并没有明确给出这些默认值,而我不打算靠印象补上去。推理框架的默认值是这类项目里变动最频繁的东西之一——某个版本把批处理策略换了,默认值跟着调;某个参数被标记废弃,默认行为整个换掉。你在文章里读到一个默认值,半年后照着它去排查问题,排查方向从第一步就是错的。这种坑我见过太多次,代价通常是一整个下午。
所以正确的做法只有一个:
vllm serve --help
以 vllm serve --help 的输出和你当次安装版本对应的官方文档为准。这条命令花你十秒钟,比任何二手资料都准确。部署脚本里也建议把关键参数显式写全,哪怕写的值和默认值一样——显式写出来的配置在版本升级时不会被悄悄改掉,隐式依赖默认值的配置会。
--served-model-name 值得单独说
这个参数决定了客户端请求体里 model 字段该填什么。它之所以重要,是因为你本地加载模型时用的路径或者仓库名往往又长又难看,而调用方代码里写的模型名最好是一个稳定、短、和业务语义对齐的标识符。把两者解耦,你以后换底层模型权重的时候,调用方一行都不用动。
它是否生效,不用猜,请求一下 /v1/models 就知道——返回的模型列表里出现什么名字,客户端就该填什么名字。这是整个部署过程里第一个应该做的验证动作,比发一条真实推理请求快得多,也不消耗显存。
关于 OpenAI 兼容协议本身的字段边界、哪些参数是真兼容哪些是形似而已,可以参考 OpenAI 兼容接口到底兼容到什么程度。
二、官方端点清单:这才是自建被低估的收益
vLLM 的 OpenAI 兼容服务支持的 API 路径,官方给出了完整清单。按用途分组是这样的:
| 分组 | 路径 |
|---|---|
| 文本生成 | /v1/completions、/v1/chat/completions、/v1/chat/completions/batch、/v1/responses |
| 向量与池化 | /v1/embeddings、/v2/embed(Cohere embed API)、/classify、/score |
| 语音 | /v1/audio/transcriptions、/v1/audio/translations、/v1/realtime |
| 基础设施 | /version、/health、/load、/v1/models、/metrics(Prometheus 格式) |
前三组是业务面的,看名字就知道用途。真正决定这套东西能不能进生产的,是第四组里的三个:
/v1/models —— 前面说过,它验证 --served-model-name 是否生效。除此之外它还有个不太被提起的用途:做健康探测。它足够轻,不触发推理,不占 KV cache,网关侧拿它做上游存活探测的成本几乎为零。
/health —— 直接接进 K8s 的 liveness / readiness 探针,或者接进负载均衡器的健康检查。有这个端点意味着你的服务能被现有编排体系当成一个普通的 HTTP 服务来管理,而不是一个需要特殊照顾的黑盒。滚动更新、故障摘除、自动重启,全都能走标准流程。
/metrics —— Prometheus 格式,直接接进你已经有的监控体系,不需要额外写 exporter。
我想在这里下一个判断,因为它常被自建方案的反对者忽略:这三个端点的存在,意味着「自建 ≠ 失去可观测性」,恰恰相反,自建让你拿到了比调厂商 API 更细的观测粒度。
调云端 API 的时候,你能观测到的只有你自己这一侧:请求发出去了,多久回来的,返回码是什么。至于队列里排了多少请求、KV cache 用了多少、批处理有没有打满、是 prefill 慢还是 decode 慢——这些你一概不知道,出了问题只能开工单等回复。自建之后这些都在 /metrics 里躺着,你可以画曲线、设告警、做容量规划,可以在容量真正见底之前两周就看出趋势。
这笔账在做自建决策的时候几乎没人算进去,但它在系统跑了半年之后价值很大。具体哪些指标名可用,以你当次版本的官方文档为准——指标名同样是会随版本变的东西,我不在这里列。
三、显存与并发:KV cache 才是真瓶颈
服务起来了,接下来是所有自建团队都会撞上的那堵墙。先把官方文档里跟显存直接相关的几个参数摆出来:
| 参数 | 官方说明要点 |
|---|---|
gpu_memory_utilization | 提高它可以给 KV cache 更多空间 |
max_num_seqs | 一批里的并发请求数上限;调小会减少并发请求数,从而需要更少的 KV cache 空间 |
max_num_batched_tokens | 单批次最大 token 数,影响 prefill 与 decode 的平衡;官方提到大于 8192 有利于吞吐 |
max_model_len | 限制单个序列的最大 token 数 |
tensor_parallel_size | 把模型权重切分到多张 GPU,使每张卡有更多显存留给 KV cache |
pipeline_parallel_size | 把模型层分布到多张 GPU,降低每张卡上模型权重所需显存 |
kv_cache_memory | 直接指定 KV cache 大小,跳过显存 profiling 阶段 |
把这张表读明白的关键,是理解显存被分成了性质完全不同的两块。
模型权重是固定开销。 模型加载完,这部分就定了,不管你服务空转还是被打爆,它一直占在那里。它决定了你「能不能跑起来」。
KV cache 是可变开销。 它随着并发请求数和每个序列的长度增长——每多一个并发请求、每多生成一个 token,就要多存一份注意力的键值。它决定了你「能同时服务多少人、单次能吐多长」。
这就解释了一个非常常见的困惑:为什么服务单独测一发请求好好的,一上真实流量就崩?因为单发请求时 KV cache 几乎没被用到,你测的只是「权重装得下」这件事,跟并发能力完全是两码事。压测的时候如果只用单并发跑,等于什么都没测。
顺着这个逻辑,表里的参数就能分成两类:max_num_seqs、max_num_batched_tokens、max_model_len 是在约束 KV cache 的需求侧(少几个并发、短一点的序列,就少要一点缓存);gpu_memory_utilization、tensor_parallel_size、pipeline_parallel_size、kv_cache_memory 是在调整 KV cache 的供给侧(多分点显存给它,或者把权重摊到更多卡上腾出空间)。
顺带说一句 max_num_batched_tokens:官方提到大于 8192 有利于吞吐,但它同时又是吃 KV cache 的,所以它天然处在吞吐和并发的拉锯中间。想清楚你的负载是长文本少并发,还是短请求高并发,这个参数的方向完全相反。吞吐这条线的取舍可以延伸阅读 吞吐、延迟与并发怎么权衡。
至于「某个模型需要多少 GB 显存」这种问题,我不给数字,因为准确答案取决于精度、量化方案、上下文长度、并发目标和框架版本,任何脱离这些前提的数字都是误导。正确的路径是按公式自己估(参数量乘以每参数字节数,加上 KV cache,再留出激活和碎片的余量),然后以你实际部署时的实测为准。
四、OOM 和「not enough KV cache space」怎么办
官方给出的缓解顺序是明确的四步。按顺序来,别跳步,因为每一步的代价是递增的:
第一步,提高 gpu_memory_utilization。 这是最便宜的一招——不动架构,不动请求特征,只是允许 vLLM 占用更大比例的显存,从而给 KV cache 腾出空间。代价是留给显存碎片、临时激活和其他进程的余量变小了,压太满可能换来另一种形态的不稳定。所以是往上调,不是往极限调。它的默认值官方页未明确给出,我不写,用 --help 查你自己那个版本的。
第二步,降低 max_num_seqs 或 max_num_batched_tokens。 这一步从需求侧下手:少接一点并发,或者把单批 token 数压下来,需要的 KV cache 自然就少了。代价很直接——吞吐下降,排队变长。但请注意,「排队」比「OOM 崩掉」好得多:排队只是慢,OOM 是整个服务挂掉,正在跑的请求全部陪葬。在容量不足时主动降并发,是把不可控的失败换成可控的延迟,这个交换在生产环境里几乎总是划算的。
第三步,提高 tensor_parallel_size。 把模型权重切分到多张 GPU 上,每张卡的权重占用变小,剩给 KV cache 的空间变大。代价是同步开销——权重被切开了,每一层的计算都需要卡间通信来汇总,这个开销是实打实存在的,卡间互联带宽差的时候尤其明显。所以这一步在前两步都不够时才用。
第四步,提高 pipeline_parallel_size。 把模型的层分布到多张 GPU 上,同样降低每张卡上权重所需的显存。代价是延迟——请求要依次穿过分布在不同卡上的层,链路变长了。
另外表里还有 kv_cache_memory,它可以直接指定 KV cache 大小,跳过显存 profiling 阶段。这在启动时间敏感的场景(比如需要快速扩容的弹性伸缩)或者 profiling 结果不稳定的时候有用,但它意味着你要自己对这个值负责,profiling 帮你兜底的那层保险没了。
最后提醒一句,这四步的顺序本身就是信息:官方把「调参数」排在「加卡分布式」前面,说明相当一部分 OOM 是配置没调好,而不是硬件真的不够。先按顺序试完前两步再考虑加卡,能省下的可能是一笔不小的预算。
五、把自建服务接进现有架构
服务跑起来了,别急着让业务代码直连它。更稳的做法是:把这个 vLLM 实例当成网关的一个上游通道挂上去。
无论你用的是 LiteLLM 还是 New API 这类自部署网关,它们管理上游通道的方式都是统一的——你的 vLLM 服务对它们来说,就是又一个 OpenAI 兼容的上游而已,配置方式和配一个云厂商通道没有本质区别。这样做直接带来几个好处:
一是自建和云 API 可以共存并互为回退。自建实例挂了、正在滚动升级、或者显存打满开始排队时,流量可以自动落到云 API 上;反过来云厂商出故障或者限速时,自建实例接住。两条腿走路,比任何一条腿单独走都稳。
二是灰度迁移变得可控。刚上自建,先切 5% 流量过去观察,看 /metrics 的曲线,看错误率,确认没问题再往上加。不用赌一次性全切。
三是成本和用量的口径统一。自建的成本是卡的折旧和电费,云 API 的成本是 token 单价,两者口径完全不同,但如果都从同一个网关走,你至少能拿到统一的调用量和 token 数,再各自换算成钱。没有这个统一口径,「自建到底省没省钱」这个问题永远吵不出结果。
四是鉴权和配额可以复用。你不需要在 vLLM 这一侧重新设计一套 key 管理,网关那边已经有了。
vLLM 的基础部署与原理可以回看 vLLM 是什么、解决了什么问题。
部署自查清单
上线前,把这几条逐条走一遍:
vllm serve --help跑一遍,把你要用的参数的当前版本行为确认清楚,不要照抄任何二手资料里的默认值,包括本文没写的那些。- 请求
/v1/models,确认返回的模型名和你--served-model-name设的一致,也和调用方代码里写的一致。 - 把
/health接进 K8s 探针或负载均衡的健康检查,并主动验证一次失败摘除——故意停掉服务,看流量有没有被正确摘走。 - 把
/metrics接进 Prometheus,至少给 KV cache 使用率和排队情况配上告警,别等 OOM 了才知道。 - 用多并发压测,不要用单并发验收。单并发只能证明权重装得下,证明不了并发能力。
- 把 OOM 的四步缓解顺序(
gpu_memory_utilization→max_num_seqs/max_num_batched_tokens→tensor_parallel_size→pipeline_parallel_size)写进运维手册,值班的人不该在半夜临时查文档。 - 把这个实例挂到网关上做上游通道,配好到云 API 的回退,先切小比例流量灰度。
- 部署脚本里显式写全关键参数,不依赖隐式默认值,避免版本升级后行为静默改变。