生产部署:让 mini vLLM 从能跑变成能稳定服务

到上一篇为止,mini vLLM 已经可以通过 OpenAI 风格 API 对外提供服务。

用户可以调用 /v1/completions/v1/chat/completions。非流式请求会等待完整结果,流式请求会通过 SSE 持续返回增量 token。API 层会把外部请求转换成内部 GenerateRequest,再交给 Engine。Engine 负责调度 Sequence,ModelRunner 执行模型,Block Manager 管理 KV cache,Sampler 选择 token,最后输出 RequestOutput。

从功能上看,它已经像一个 LLM serving 系统了。

但能跑和能上线,是两件事。

本地 demo 中,模型加载成功,接口返回文本,就已经让人很开心。

生产环境里,问题会多得多。

模型可能加载失败。

GPU 可能 OOM。

请求可能瞬间暴涨。

某些客户端可能流式连接很慢。

某些请求可能带着超长 prompt,把 KV cache 占满。

某次发布可能引入性能回退。

某个进程退出时,仍然有请求正在生成。

某张 GPU 可能突然不可用。

日志里如果没有 request_id,根本不知道哪个请求出了问题。

metrics 如果不完整,也不知道系统到底是 prefill 慢、decode 慢、queue 堵了,还是 KV blocks 不够。

所以最后一篇,我们不再继续加新的推理算法,而是讨论 mini vLLM 如何进入生产部署阶段。

这一篇的核心问题是:

一个 LLM 推理服务,除了模型推理本身,还需要哪些工程能力,才能稳定运行?

部署的第一件事,是把配置显式化

很多 demo 项目会把模型路径、端口、最大 batch tokens、最大并发数、block size、GPU 数量直接写在代码里。

这在本地实验中没问题。

但在生产环境里,这些都应该变成配置。

因为不同部署环境会有不同资源。

同一个服务可能在 A100、H100、4090 或其他 GPU 上运行。

有的实例只跑一个 7B 模型,有的实例跑 70B 模型。

有的场景偏长上下文,有的场景偏短问答。

这些都会影响调度参数。

mini vLLM 至少应该把这些配置显式化:

model_path
tokenizer_path
host
port
dtype
quantization_config
tensor_parallel_size
max_num_batched_tokens
max_num_seqs
max_model_len
block_size
gpu_memory_utilization
max_prefill_chunk_size
enable_prefix_cache
enable_chunked_prefill
enable_speculative_decoding
request_timeout
max_queue_size

配置可以来自命令行参数、环境变量或配置文件。

关键是不要让这些值散落在代码中。

生产部署时,一个参数调错就可能导致巨大差异。

例如 max_num_batched_tokens 太小,GPU 利用率可能很差。

太大,单轮 step 可能变慢,流式输出间隔变长。

gpu_memory_utilization 太激进,可能导致 OOM。

太保守,KV cache block pool 太小,最大并发下降。

max_prefill_chunk_size 太大,长 prompt 仍然会阻塞 decode。

太小,prefill 效率可能下降。

这些参数不是写死一次就结束,而是需要根据 benchmark 和线上指标不断调优。

所以配置显式化,是生产化的第一步。

启动流程要可观测、可失败、可恢复

推理服务启动时,最重的动作是加载模型。

模型加载可能失败。

失败原因可能是模型路径不存在,权重文件损坏,显存不够,量化 kernel 不支持当前 GPU,tensor parallel 配置和模型结构不匹配,tokenizer 文件缺失,或者某些依赖版本不兼容。

如果启动失败,服务不应该假装启动成功。

它应该明确退出,并输出可诊断日志。

一个清晰的启动流程应该包括:

读取配置
初始化日志系统
检测 GPU 和通信环境
加载 tokenizer
加载模型权重
初始化量化或 tensor parallel backend
初始化 KV cache block pool
启动 Engine loop
启动 HTTP server
暴露健康检查接口

在这些步骤中,任何一步失败,都应该有明确错误信息。

例如:

模型加载失败:权重文件不存在
KV cache 初始化失败:可用显存不足
Tensor parallel 初始化失败:attention heads 不能被 TP size 整除
量化 backend 初始化失败:当前 GPU 不支持该 kernel

这类错误不能只在栈追踪里出现。它们应该被整理成清楚的日志。

启动成功后,也不能立刻假设服务可用。

最好有一个 warmup 阶段。

warmup 可以发送一个小请求,让模型执行一次 prefill 和 decode。这样可以提前触发 kernel 初始化、CUDA graph 准备、缓存分配等延迟。

否则第一个真实用户请求可能承担所有冷启动成本。

生产服务里,冷启动延迟经常会成为第一个用户请求的异常高延迟。

所以更好的方式是:

模型加载完成
执行 warmup
warmup 成功
服务进入 ready 状态

这也引出了健康检查。

健康检查不只是进程活着

服务部署到 Kubernetes 或其他平台时,通常会有 health check。

但 LLM 推理服务的健康状态不能只看进程是否存在。

一个进程可能还活着,但模型没加载成功。

HTTP server 可能能响应,但 GPU 已经 OOM。

Engine loop 可能已经卡住,但 health endpoint 仍然返回 200。

所以健康检查最好分成两类。

第一类是 liveness。

它表示进程是否还活着,事件循环是否还能响应。

如果 liveness 失败,平台可以重启进程。

第二类是 readiness。

它表示服务是否准备好接收请求。

只有模型加载完成、KV cache 初始化完成、warmup 成功后,readiness 才应该返回成功。

这两个概念要分开。

刚启动时,liveness 可以成功,但 readiness 应该失败。

模型还在加载,服务还不能接收请求。

如果 readiness 过早成功,上游流量会打进来,用户请求会排队甚至失败。

对于 mini vLLM,可以设计几个状态:

STARTING
LOADING_MODEL
WARMING_UP
READY
DRAINING
FAILED

健康检查根据状态返回不同结果。

当服务进入 READY,才接收新请求。

当服务进入 DRAINING,表示正在优雅关闭,不再接收新请求,但会继续处理已有请求。

这会让部署和滚动更新更安全。

日志必须围绕 request_id 组织

LLM 服务的日志如果没有 request_id,基本很难排查问题。

一个请求从 API 层进入,经过 Scheduler、Block Manager、ModelRunner、Sampler,再通过流式接口返回。中间任何地方都可能出错。

如果没有统一 request_id,就无法把这些事件串起来。

所以每个请求创建时,就应该生成 request_id。

这个 request_id 应该贯穿:

请求接收
参数校验
tokenization
入队
prefill 开始和结束
first token 生成
decode 进度
finished
cancelled
error
资源释放

日志中至少应该包含:

request_id
model
prompt_tokens
max_tokens
stream
arrival_time
first_token_time
finish_time
stop_reason
error_type

对于调试复杂问题,还可以记录:

scheduled_step_id
allocated_blocks
prefix_cache_hit
prefill_chunk_count
decode_token_count

当然,日志不能记录敏感内容。

默认不应该把用户 prompt 和生成结果完整写入日志。

如果业务需要采样日志,也应该经过脱敏和权限控制。

生产环境里,日志的作用是排查系统行为,而不是保存用户数据。

所以更合理的是记录 token 数、状态、耗时和错误类型,而不是原文内容。

Metrics 比日志更适合看系统健康

日志适合排查单个请求。

Metrics 适合观察系统整体状态。

一个推理服务必须有 metrics,否则无法判断系统瓶颈在哪里。

mini vLLM 可以从几类指标开始。

第一类是请求指标。

requests_total
requests_running
requests_waiting
requests_finished
requests_failed
requests_cancelled

第二类是延迟指标。

ttft_seconds
tpot_seconds
e2e_latency_seconds
queue_time_seconds
prefill_time_seconds
decode_step_time_seconds

这些最好用 histogram 记录,这样可以看 P50、P90、P99。

第三类是吞吐指标。

prompt_tokens_total
output_tokens_total
output_tokens_per_second
requests_per_second

第四类是调度指标。

waiting_queue_length
running_queue_length
prefilling_queue_length
num_batched_tokens_per_step
num_decode_sequences_per_step
num_prefill_tokens_per_step
step_duration_seconds

第五类是 KV cache 指标。

kv_blocks_total
kv_blocks_used
kv_blocks_free
kv_blocks_prefix_cache
kv_block_utilization
kv_allocation_failures

第六类是 prefix cache 指标。

prefix_cache_hits
prefix_cache_misses
prefix_cache_hit_rate
prefix_cache_evictions

第七类是 GPU 指标。

gpu_memory_used
gpu_memory_free
gpu_utilization
gpu_oom_count

这些指标可以接 Prometheus,然后通过 Grafana 画图。

有了这些指标,很多问题就能被快速定位。

TTFT 上升,同时 waiting_queue_length 上升,说明排队严重。

TPOT 上升,同时 step_duration 变长,可能是 decode 阶段变慢。

kv_blocks_free 接近 0,说明 KV cache 压力很大。

prefix_cache_hit_rate 很低,但 prefix_cache_blocks 很多,说明缓存策略不划算。

gpu_utilization 很低,但 queue 很长,可能是调度参数太保守,或者 CPU 侧成为瓶颈。

没有 metrics,这些都只能猜。

限流和准入控制是保护系统,不是拒绝用户

LLM 请求成本差异非常大。

一个短 prompt、短输出请求可能很轻。

一个超长 prompt、长输出请求可能占用大量 prefill 时间和 KV cache。

如果没有限流和准入控制,少数重请求就可能拖垮整个服务。

所以生产服务必须有几层保护。

第一层是请求参数限制。

例如:

max_prompt_tokens
max_tokens
max_total_tokens
max_stop_words
max_request_body_size

如果用户传入超过限制的请求,应该在 API 层直接拒绝,而不是等进入 Engine 后再失败。

第二层是队列限制。

如果 waiting queue 太长,新请求继续进入只会增加排队延迟。此时可以返回 429 或 503,让客户端稍后重试。

第三层是 KV cache 准入控制。

如果 Block Manager 判断 free blocks 不足,不应该盲目接收请求。可以让请求排队,也可以直接拒绝,具体取决于服务策略。

第四层是租户或 API key 级限流。

如果服务面向多个用户或业务方,就要避免某个用户占满所有资源。

限流不是为了拒绝用户,而是为了保护系统稳定。

没有限流,系统可能在高峰期彻底 OOM 或请求延迟全部失控。有限流,至少可以让系统在容量范围内稳定服务。

超时策略要分阶段

LLM 请求可能很长,所以超时策略不能只设置一个简单的总超时。

更合理的是分阶段看。

第一种是排队超时。

请求进入系统后,如果长时间没有被调度,说明系统负载太高。可以取消请求并返回超时。

第二种是首 token 超时。

如果请求已经进入系统,但迟迟没有产生第一个 token,可能是长 prefill、资源不足或调度问题。

第三种是总生成超时。

如果请求生成时间超过上限,可以取消。

第四种是流式空闲超时。

如果流式连接长时间没有输出 token,客户端可能会认为服务卡住。

这些超时对应不同问题。

queue timeout:
  系统排队压力过大

first token timeout:
  prefill 或调度问题

generation timeout:
  输出过长或 decode 过慢

stream idle timeout:
  流式体验问题

每种超时都应该有明确 stop_reason 或 error_type。

不要把所有超时都混成一个 generic timeout。

这样 metrics 才能告诉我们到底是哪一段出问题。

GPU OOM 要尽量提前避免,而不是事后处理

GPU OOM 是推理服务最危险的错误之一。

一旦 OOM,进程可能进入不可预测状态。即使捕获异常,也可能有显存碎片或 CUDA 状态异常。

所以最好的策略是提前避免 OOM。

Block Manager 的存在就是为了做准入控制。

它应该根据 free blocks 判断请求是否能运行。

Scheduler 也应该在调度前检查 block budget。

同时,服务启动时不应该把显存用到 100%。

要保留一部分余量给临时 buffer、kernel workspace、通信 buffer 和碎片。

所以通常会有类似:

gpu_memory_utilization = 0.90

表示最多把 90% 的可用显存用于模型和 KV cache,保留 10% 作为安全空间。

这个值不能盲目调太高。

如果显存余量太小,在长上下文、batch 波动或 kernel 临时分配时很容易 OOM。

生产服务中,宁愿稍微降低理论最大并发,也要避免频繁 OOM。

如果真的发生 OOM,服务应该记录详细上下文:

当前 running 请求数
waiting 队列长度
KV blocks 使用量
最后一次调度的 prefill tokens
最后一次调度的 decode sequences
模型配置
量化配置

然后通常更安全的处理方式是重启 worker,而不是继续运行一个状态可能已经损坏的进程。

优雅关闭:不要让正在生成的请求突然消失

生产部署会频繁发生重启。

比如发布新版本、节点维护、配置更新、自动扩缩容。

如果服务收到关闭信号后立刻退出,正在生成的请求会直接断开。

这对用户体验很差。

更合理的是优雅关闭。

优雅关闭分几步。

首先,服务进入 DRAINING 状态。

这时 readiness 返回失败,负载均衡不再把新请求发进来。

其次,服务停止接收新请求。

已有请求继续生成。

然后等待已有请求完成,或者等待一个最大 drain timeout。

如果超时仍未完成,就取消剩余请求,释放资源,然后退出。

流程可以理解成:

收到 SIGTERM
readiness 变为 false
停止接收新请求
继续处理已有请求
等待完成或达到 drain timeout
释放资源
退出进程

这对流式请求尤其重要。

很多请求可能已经输出了一半,直接断开会很突兀。

当然,服务也不能无限等待。长输出请求可能生成很久,所以需要 drain timeout。

这又是一个取舍:尽量保护用户体验,但也要保证部署系统能完成滚动更新。

多副本部署和负载均衡

单个 mini vLLM worker 能力有限。

生产环境通常会部署多个副本。

如果每个副本都加载完整模型,它们之间可以通过负载均衡分发请求。

这种方式简单,适合模型可以单副本放入 GPU 的场景。

如果模型使用 tensor parallel,一个副本可能占用多张 GPU。此时一个 serving replica 不是一个进程,而是一组协同进程或一个多 GPU worker。

无论哪种方式,负载均衡都要考虑请求特性。

普通 HTTP 负载均衡可能只看连接数或轮询。

但 LLM 请求差异很大,一个长 prompt 长输出请求和一个短请求成本完全不同。

更理想的是根据 worker 的实时负载路由。

例如每个 worker 暴露:

waiting queue length
running sequence count
free KV blocks
recent TTFT
recent TPOT

上游 router 根据这些指标选择较空闲的 worker。

第一版可以先用简单轮询或最少连接。

但要知道,随着请求差异增大,智能路由会变得重要。

尤其是有些 worker 的 KV blocks 已经接近耗尽时,继续把长 prompt 发过去很容易排队或失败。

滚动发布要防止性能回退

推理服务发布新版本时,不只是功能正确就够了。

任何改动都可能影响性能。

例如:

改了 Scheduler 策略
改了 block size
改了 quantization backend
改了 chat template
改了 sampling 逻辑
升级了 PyTorch 或 CUDA

这些都可能造成性能或输出行为变化。

所以生产发布最好有几个阶段。

先在测试环境跑 benchmark。

再在少量流量上灰度。

观察 metrics。

如果 TTFT、TPOT、错误率、OOM、取消率、P99 延迟明显变差,就回滚。

这要求服务本身有版本标识。

日志和 metrics 中应该包含:

service_version
model_version
engine_config_hash

否则出了问题,很难知道是哪一次发布引入的。

技术博客里可以强调一点:LLM 推理系统的优化很容易互相影响,所以发布时一定要用指标验证,而不是凭感觉。

Docker 镜像和运行环境

生产部署通常会用容器。

Docker 镜像应该包含稳定的运行环境。

关键依赖包括:

Python
PyTorch
CUDA runtime
模型推理 backend
tokenizer 依赖
HTTP server
metrics exporter

如果使用 Triton、FlashAttention、FlashInfer 或自定义 CUDA kernel,还要确保它们和 CUDA、PyTorch、GPU 架构兼容。

镜像中不一定要直接打包模型权重。

模型很大,通常可以在启动时从挂载目录或模型仓库加载。

但这样也要处理模型下载失败、文件不完整、权限错误等问题。

容器启动脚本应该尽量简单。

不要在启动时做过多隐式操作。

比如自动下载多个大模型、自动编译 kernel、自动修改配置。这些都会让启动不可控。

更好的方式是把镜像和模型准备流程分开。

部署时明确指定模型路径和配置。

服务启动时验证配置和文件是否存在。

安全和权限

推理服务通常会暴露 HTTP API。

即使只是内部服务,也应该考虑基本安全。

第一,认证。

如果不是完全内网隔离,API 应该有 token 或其他认证机制。

第二,请求大小限制。

不要允许无限大的 request body,否则容易被恶意请求或误用拖垮。

第三,参数限制。

限制 max_tokens、prompt 长度、并发连接数。

第四,日志脱敏。

不要默认记录用户原始 prompt 和输出。

第五,错误信息控制。

内部栈追踪不应该直接返回给用户。

第六,模型访问权限。

如果服务支持多个模型,不同用户可能只能访问部分模型。

这些看起来和推理算法无关,但对生产系统非常重要。

一个没有限流、没有认证、没有请求大小限制的推理服务,很容易被误用或攻击。

生产化之后,Engine 要有清晰的生命周期

回顾整个系列,Engine 是 mini vLLM 的核心。

在生产服务中,它不再是一个临时对象,而是长期运行的系统组件。

它的生命周期大概是:

初始化配置
加载模型
初始化 tokenizer
初始化 Scheduler
初始化 Block Manager
初始化 AttentionBackend
启动 engine loop
接收请求
持续 step
处理取消和超时
释放 finished 请求资源
暴露 metrics
优雅关闭

每一步都可能失败,也都应该可观测。

生产化不是给代码套一层 HTTP server 就结束。

它意味着 Engine 要从“算法对象”变成“服务对象”。

服务对象必须处理生命周期、资源、错误、指标、并发和关闭。

这也是很多推理系统真正复杂的地方。

PagedAttention 很核心,但只靠 PagedAttention 不能上线。

Scheduler 很关键,但只靠 Scheduler 也不能上线。

上线需要把这些能力放进一个可靠的服务壳里。

小结

这一篇我们讨论了 mini vLLM 的生产部署。

当推理引擎具备 OpenAI API 兼容能力后,它已经可以被应用调用。但要稳定运行,还需要一整套工程能力。

配置要显式化,不能把关键参数写死在代码里。

启动流程要可观测,模型加载、KV cache 初始化、warmup 和 health check 都要有明确状态。

健康检查要区分 liveness 和 readiness,避免模型还没准备好就接收流量。

日志要围绕 request_id 组织,方便串联请求生命周期。

Metrics 要覆盖请求、延迟、吞吐、调度、KV cache、prefix cache 和 GPU 状态。

限流和准入控制用于保护系统,避免少数重请求拖垮服务。

超时要分阶段,包括排队超时、首 token 超时、总生成超时和流式空闲超时。

GPU OOM 要尽量通过 block budget 和显存余量提前避免。

优雅关闭要让服务停止接收新请求,同时尽可能完成已有请求。

多副本部署需要负载均衡,最好结合实时负载指标。

滚动发布要通过 benchmark 和线上 metrics 防止性能回退。

Docker、模型文件、依赖版本、安全认证、日志脱敏,也都是生产服务必须考虑的内容。

到这里,整个 mini vLLM 系列就完成了一条完整路径:

从一个最简单的 generate 函数开始,逐步理解 KV cache、Sequence、Scheduler、continuous batching、PagedAttention、Block Manager、流式输出、采样系统、prefix caching、chunked prefill、benchmark、speculative decoding、tensor parallel、量化、OpenAI API 兼容和生产部署。

这条路径的核心,不是复刻某个具体项目的所有代码,而是理解一个高性能 LLM 推理系统为什么会长成这样。

最开始我们只是调用模型。

后来我们开始管理 token。

再后来,我们管理请求状态、调度策略、KV cache、显存 blocks、流式连接、多 GPU、低精度权重和生产资源。

这就是从“调用 LLM”到“实现 LLM serving system”的完整转变。