vLLM 推理内核深度解析
第17章 API 服务器与生产部署
第17章 API 服务器与生产部署
“The last mile is often the hardest.” — 任何上过线的工程师都懂
本章要点
- 读懂
vllm.entrypoints.openai下的三层结构:api_server.py负责 FastAPI 路由和生命周期,serving_*.py负责协议翻译,EngineClient负责连接推理引擎 - 掌握 Chat 请求从
messages到engine_client.generate()的关键路径,包括 adapter、chat template、sampling params、tool parser、reasoning parser - 理解 SSE 流式输出的真实控制点:首个 role chunk、空 chunk 跳过、usage chunk、异常如何写成 SSE error、最后如何发送
[DONE] - 走一遍
lifespan()、build_app()和init_app_state()三个生产入口,理解gc.freeze()、API key、CORS、request id、metrics 挂载的边界 - 拿到 Docker、Kubernetes、HPA、蓝绿发布、压测和容量规划的可落地模板,同时知道哪些数字必须由本机压测给出
- 用源码中实际存在的 Prometheus 指标定位排队、KV 压力、TTFT、TPOT、请求成功率和前缀缓存命中
17.1 OpenAI 兼容层:为什么它是 vLLM 的生产入口
vLLM 能从高性能推理引擎变成大多数团队可以直接上手的在线服务,OpenAI 兼容协议是非常关键的一层。它不是简单地把 generate() 包成一个 HTTP 接口,而是把模型、tokenizer、chat template、采样参数、工具调用、结构化输出、LoRA 和可观测性都放到一个生态熟悉的协议边界里。
当团队决定”我们对外的 API 和 OpenAI 的 Python SDK 一模一样”时,他们解锁的是:
- 所有已经接入 OpenAI 的应用(LangChain、AutoGPT、Continue.dev、VS Code 插件、自建 ChatBot 等等)改一个
base_url就能用 vLLM - 生态里所有新冒出来的 LLM 框架都会自动支持 vLLM(因为它们都先支持 OpenAI)
- 用户心智零成本——
openaiPython SDK 是他们唯一需要读的文档
这是一种”借用既有生态接口”的策略。它让推理系统的性能创新可以被上层应用立即消费,类似思想在其他项目里也有:
- MinIO 采用 S3 API → 直接接入所有 S3 生态
- PostgreSQL 的 pg_stat / SQL 标准 → 让工具链天然兼容
- CloudNative 的 K8s API → 让多云策略可行
17.1.1 vllm/entrypoints/openai 三层结构
源码目录一览:
vllm/entrypoints/
├── llm.py # 离线推理入口(LLM 类)
├── openai/
│ ├── api_server.py # FastAPI app + lifespan + 路由注册
│ ├── serving_chat.py # /v1/chat/completions 协议翻译
│ ├── serving_completion.py # /v1/completions
│ ├── serving_embedding.py # /v1/embeddings
│ ├── serving_tokenization.py # /v1/tokenize 和 /v1/detokenize
│ ├── protocol.py # OpenAI 请求/响应的 Pydantic 模型
│ └── ...
└── cli/
└── serve.py # `vllm serve` CLI 入口
三层责任分离极为清楚:
graph TB
CLI["vllm serve<br/>(cli/serve.py)"] --> APP["FastAPI app<br/>(api_server.py)"]
APP --> SC["OpenAIServingChat<br/>(serving_chat.py)"]
APP --> SC2["OpenAIServingCompletion"]
APP --> SE["OpenAIServingEmbedding"]
SC --> EC["AsyncLLM / EngineClient"]
SC2 --> EC
SE --> EC
EC --> Core["EngineCore<br/>(跨进程)"]
style CLI fill:#8b5cf6,color:#fff,stroke:none
style APP fill:#3b82f6,color:#fff,stroke:none
style SC fill:#f59e0b,color:#fff,stroke:none
style EC fill:#10b981,color:#fff,stroke:none
style Core fill:#ef4444,color:#fff,stroke:none
api_server.py:纯路由 + 依赖注入 + lifespan,不含业务逻辑serving_*.py:每种协议一个类,负责协议字段 ↔ vLLM 内部 DTO 的翻译、模板化 chat template、流式编排AsyncLLM/EngineClient:第 2 章讨论过的”跨进程引擎代理”
一个 HTTP 请求进来的路径:FastAPI 解析 JSON → OpenAIServingChat.create_chat_completion(request) → 模板化(chat template)→ 构造 SamplingParams → engine.generate(prompt_token_ids, sampling_params) → 异步迭代器 → SSE。
17.1.2 真实路由表:不只是三个 OpenAI API
读 api_server.py 时,不能只看 /v1/chat/completions、/v1/completions、/v1/embeddings 这三个最常见入口。当前源码里的路由注册更像一个”协议网关”:健康检查、负载读取、tokenize、detokenize、模型列表、版本信息和多种 task-specific API 都挂在同一个 FastAPI router 上。
| 路由 | 源码位置 | 生产含义 |
|---|---|---|
/health | api_server.py:391 | 调 engine_client.check_health(),适合 liveness/readiness 的基础探测 |
/load | api_server.py:398 | 返回 server_load_metrics,统计会占用 GPU 的在线请求路由 |
/ping | api_server.py:416 | 复用 health,主要满足 SageMaker 这类平台约定 |
/tokenize / /detokenize | api_server.py:422 / 437 | 复用 tokenizer 服务,方便客户端先算 token 长度或调试模板 |
/v1/models | api_server.py:452 | 展示 base model、LoRA、prompt adapter 组合后的可服务模型名 |
/v1/chat/completions | api_server.py:466 | Chat Completions 主入口,返回 JSON 或 text/event-stream |
/v1/completions | api_server.py:489 | 旧式文本 completion,很多遗留应用仍依赖 |
/v1/embeddings 等 | api_server.py:508 之后 | embed、pooling、score、rerank、transcription 这些任务按模型能力启用 |
这里有两个很容易漏掉的工程事实。
第一,init_app_state() 会按 model_config.runner_type 和 model_config.task 决定哪些 handler 真正存在。生成模型会创建 OpenAIServingChat 和 OpenAIServingCompletion;embedding 任务才会创建 OpenAIServingEmbedding;pooling/score/transcription 也分别受 task 类型约束。路由本身都注册了,但 handler 可能是 None,例如一个纯 embedding 模型打 /v1/chat/completions 会得到”模型不支持 Chat Completions API”的错误响应,而不是走到引擎里失败。
第二,mount_metrics() 明确把 /metrics、/health、/load、/ping、/version、/server_info 排除在 FastAPI instrumentator 的普通 HTTP 统计之外,然后单独挂载 Prometheus ASGI app 到 /metrics。这意味着监控端点本身不会污染业务请求延迟,健康检查也不会把低延迟假象写进普通 HTTP histogram。
第三,/load 不是 Prometheus 指标的替代品,而是一个轻量的前端负载信号。api_server.py 把 chat、completion、embedding、pooling、score、rerank、transcription 等会占用 GPU 的路由标上 @load_aware_call;真正的计数逻辑在 vllm/entrypoints/utils.py:73-111。当 --enable-server-load-tracking 打开时,请求进入这些路由会先把 app.state.server_load_metrics 加一;如果 handler 抛异常会立即减一;如果返回 JSONResponse 或 StreamingResponse,则把减一动作挂到 Starlette background task 上,等响应完成后再执行。这个细节对流式请求尤其重要:SSE 连接还没结束时,服务端不能把负载提前减掉,否则外部调度器会误以为副本已经空闲。
所以 /load 更适合做”当前副本还有多少活跃业务响应”的近实时信号,而不是长期趋势监控。长期趋势仍应看 Prometheus 的 num_requests_waiting、request_queue_time_seconds、TTFT/TPOT histogram 和 token counter。把这两类信号分开,外部网关才能同时做到短周期避让繁忙副本、长周期判断是否扩容。
17.1.3 OpenAI 协议字段映射(关键几项)
OpenAI Chat Completions 的字段非常丰富,vLLM 的兼容层不是一张简单的 dict 映射表,而是一条带校验、模板渲染、adapter 选择和采样参数构造的流水线。以下是最关键的几项映射:
| OpenAI 字段 | vLLM 内部字段 | 备注 |
|---|---|---|
messages | prompt_token_ids(经 chat template 渲染) | 模板来自 tokenizer 的 chat_template |
model | 用于路由到正确的 LoRA 适配器(若启用 --enable-lora) | 单模型部署时忽略 |
stream | 控制 SSE vs 一次性响应 | true 时返回 AsyncGenerator |
max_tokens / max_completion_tokens | SamplingParams.max_tokens | o1 系列用后者 |
temperature | SamplingParams.temperature | 0 时切到 greedy path |
top_p | SamplingParams.top_p | nucleus sampling |
top_k | SamplingParams.top_k | OpenAI 没有,vLLM 扩展 |
n | 并行采样数 | 触发 KV Cache COW |
stop | SamplingParams.stop | 字符串或 token id 列表 |
presence_penalty / frequency_penalty | 同名字段 | logits processor |
logprobs / top_logprobs | SamplingParams.logprobs | 需要额外 GPU 计算 |
tools / tool_choice | chat template + tool parser | auto 需要显式启用 parser,Mistral tokenizer 有专门路径 |
response_format.type: json_object | guided decoding / structured outputs | 约束采样能力取决于后端配置和模型输出形态 |
response_format.type: json_schema | schema 约束采样 | 更严格,但要把 schema 编译成可执行约束 |
seed | SamplingParams.seed | 相同种子 + 相同输入 → deterministic output |
OpenAIServingChat.create_chat_completion() 的关键顺序是:先 _check_model(),再检查 engine_client.errored,然后解析 LoRA/prompt adapter,取 tokenizer,处理 Mistral 特例,校验 tool_choice == "auto" 是否具备 --enable-auto-tool-choice 和 --tool-call-parser,再调用 _preprocess_chat() 得到 conversation、request_prompts 和 engine_prompts。只有这些前置步骤都通过,才会进入 request.to_sampling_params() 或 request.to_beam_search_params(),最后调用 engine_client.generate() 或 engine_client.beam_search()。
兼容得如此全面并非免费。serving_chat.py 当前有 1210 行,protocol.py 有 1806 行,二者分别承担”协议执行”和”协议形状”。真正难追的是字段完整性、错误形态、流式增量格式、usage 统计、工具调用增量输出这些边角行为,而不是把 HTTP JSON 转成 Python 对象。
17.1.4 三个容易忽略的细节
细节 1:chat template 主要来自 tokenizer,不是 vLLM 给每个模型手写一份模板。每个 HuggingFace tokenizer 可以携带 Jinja2 格式的 chat_template,vLLM 用它把 messages 数组渲染成单一 prompt 字符串再 tokenize。如果用户传入 --chat-template,init_app_state() 会读取模板,并在非 Mistral tokenizer 场景下和 tokenizer 的官方模板做差异提醒。这个提醒非常重要:模板错了,模型不一定报错,但输出质量、tool calling 格式和 stop token 行为都会漂。
细节 2:tool_choice == "auto" 不是默认魔法。在 HF tokenizer 路径里,serving_chat.py 明确要求 --enable-auto-tool-choice 和 --tool-call-parser 同时存在,否则直接返回错误。Mistral tokenizer 走自己的 tool call 序列化、ID 截断和参数校验路径。这个设计比”尽量猜”更适合生产:工具调用一旦错,通常不是普通文本质量问题,而是下游函数真的会被错误参数调用。
细节 3:流式 tool calling 和非流式 tool calling 是两套解析问题。非流式响应可以等最终文本出来后一次性解析;流式响应必须在每个 delta 上维护 previous_texts、all_previous_token_ids、prev_tool_call_arr 和已输出参数片段,避免把半个 JSON、控制 token 或 reasoning 内容过早发给客户端。换句话说,OpenAI 协议表面是一行 tools 字段,源码里对应的是一整个 parser 目录和大量状态机逻辑。
flowchart LR
Req["ChatCompletionRequest"] --> Check["_check_model / engine errored"]
Check --> Adapter["LoRA / prompt adapter"]
Adapter --> Tok["get_tokenizer"]
Tok --> Tool["tool_choice / parser / Mistral 特例"]
Tool --> Pre["_preprocess_chat"]
Pre --> Params["SamplingParams 或 BeamSearchParams"]
Params --> Engine["engine_client.generate"]
style Req fill:#3b82f6,color:#fff,stroke:none
style Tool fill:#f59e0b,color:#fff,stroke:none
style Engine fill:#10b981,color:#fff,stroke:none
17.2 流式输出:SSE 的非阻塞流水线
流式输出(stream=true)让用户能在第一个 Token 产出时就看到响应,这是所有 Chat 产品体感上和”等几秒才返回一整段”的天壤之别。
17.2.1 端到端链路
sequenceDiagram
autonumber
participant C as 客户端
participant N as Nginx (反向代理)
participant API as FastAPI Server
participant EC as EngineCore (跨进程)
participant W as Worker (GPU)
C->>N: POST /v1/chat/completions<br/>Accept: text/event-stream
N->>API: 转发(proxy_buffering off 必须)
API->>API: chat template 渲染 + tokenize
API->>EC: engine.generate(prompt_token_ids, sp)
Note over API,EC: 返回 AsyncGenerator
loop 每步
EC->>W: schedule + execute
W-->>EC: sampled token id
EC-->>API: AsyncGenerator.next()<br/>(ZMQ 消息)
API->>API: detokenize(token_id)
API-->>N: data: {"delta": {"content": "Hello"}}\n\n
N-->>C: 转发 SSE chunk
end
API-->>N: data: [DONE]\n\n
N-->>C: 转发结束
这条链路的关键不是给每一跳拍一个固定毫秒数,而是理解它的异步边界:
- FastAPI handler 调
create_chat_completion()后,如果返回的是 async generator,就包装成StreamingResponse(..., media_type="text/event-stream") - Chat serving 层在
chat_completion_stream_generator()里async for res in result_generator,每拿到一次引擎输出就决定是否要 yield 一个 SSE chunk - 引擎侧的单步延迟、batch 大小、prefill/decode 混排、KV 命中、网络链路都会改变端到端体验,所以本章不给固定百分比结论;生产上必须用本机模型、本机 prompt 分布和真实反代链路压测
17.2.2 SSE generator 的真实控制点
serving_chat.py 的流式生成器比”每来一个 token 就发一个 token”复杂得多。它至少维护以下控制点:
| 控制点 | 源码行为 | 生产意义 |
|---|---|---|
| 首个 role chunk | 首次迭代时先为每个 choice 发送 delta.role 和空 content | 兼容 OpenAI 客户端对 assistant role 的预期 |
echo | 如果请求要求 echo,会把最后一条用户消息内容先作为 delta 返回 | 兼容 text completion 迁移场景,但会影响客户端展示 |
| 空 chunk 跳过 | chunked prefill 可能产生没有文本和 token 的中间结果,源码直接 continue | 避免客户端收到无意义 SSE 包 |
| logprobs | request.logprobs 和 top_logprobs 同时存在时构造 chat logprobs | 打开后会增加序列化和输出体积,不应在高 QPS 默认启用 |
| tool/reasoning parser | 根据 tool_choice_auto 和 reasoning 配置维护 previous text/token 状态 | 防止半截 JSON、reasoning token 或控制 token 泄露给客户端 |
| usage chunk | stream_options.include_usage 时,结束前发送 choices=[] 的 usage chunk | 客户端计费和审计依赖这一块,不要只读最后 [DONE] |
| 异常 | parser 创建失败或 generator 抛异常时,yield SSE error,再 yield [DONE] | HTTP 状态可能已经是 200,客户端必须解析 SSE 内部错误 |
这个表解释了为什么 Chat API 的流式逻辑没有被抽成一个通用 streaming.py。Chat 的 delta 可能是 content、reasoning_content、tool_calls、logprobs 和 usage 的组合;Completion、Embedding、Transcription 的响应形状都不同。把”流式”抽成公共层反而会把协议细节藏起来,难以精确兼容 OpenAI 客户端。
17.2.3 为什么 SSE 错误不能只看 HTTP 状态码
流式接口有一个反直觉事实:一旦 FastAPI 返回 StreamingResponse,HTTP response header 可能已经发给客户端了。后续如果 tool parser 创建失败、reasoning parser 抛错、引擎 generator 异常,服务端不能再把状态码从 200 改成 500,只能在 SSE 数据里写一个 OpenAI 风格 error,再发送 [DONE] 结束流。
因此生产客户端不能写成”HTTP 200 就一定成功”。更稳的客户端逻辑是:
- HTTP 层只判断连接是否建立、认证是否通过、content type 是否合理。
- SSE 层逐条解析
data:,遇到 JSON 里的error字段要走失败路径。 - 只有看到正常 finish chunk 或 usage chunk 后再看到
[DONE],才把请求视为完整成功。
这和普通 REST API 的错误处理不同,也是很多自研 OpenAI-compatible client 首次接入 vLLM 时最容易漏的地方。
17.2.4 流式的三个生产陷阱
陷阱 1:Nginx 的 proxy_buffering。默认打开时 Nginx 会把上游的响应缓冲起来再批量发给客户端,SSE 完全失效——用户感觉是”等一秒整段一起出”。一定要显式关掉:
location /v1/ {
proxy_pass http://vllm_backend;
proxy_http_version 1.1;
proxy_buffering off; # 关键
proxy_cache off;
proxy_read_timeout 600s; # 长生成要容得下
proxy_connect_timeout 60s;
# SSE 需要保持 connection 活跃
proxy_set_header Connection "";
}
陷阱 2:CDN 的 SSE 兼容性。部分 CDN(老版 CloudFront、某些国内 CDN)对 SSE 支持不佳,会把 text/event-stream 降级缓存或截断。生产部署最好让 SSE 走直连 LB、不经过 CDN。
陷阱 3:客户端超时。OpenAI Python SDK 默认 timeout 是 600s(新版),但很多自研客户端是 30s / 60s。长生成任务(几千 token)会在客户端侧先超时——要么客户端改长,要么服务端限制 max_tokens。
17.3 vllm serve 7 阶段启动流程
flowchart TB
A["1. 解析 CLI 参数 → EngineArgs"] --> B["2. 初始化 AsyncLLM / AsyncEngineCore"]
B --> C["3. fork EngineCore 子进程<br/>+ spawn N 个 Worker 子进程"]
C --> D["4. 每个 Worker: init_device → load_model → profile_memory"]
D --> E["5. 分配 KV Cache + capture CUDA Graph + warmup"]
E --> F["6. gc.collect() + gc.freeze()<br/>冻结启动堆为静态"]
F --> G["7. uvicorn 启动 FastAPI<br/>监听 :8000"]
G --> H["✓ 服务就绪<br/>health check 返回 200"]
style A fill:#3b82f6,color:#fff,stroke:none
style F fill:#f59e0b,color:#fff,stroke:none
style H fill:#10b981,color:#fff,stroke:none
17.3.1 关键细节:gc.freeze() 为什么重要
看 api_server.py:107-135 的 lifespan(),你会看到这两行:
gc.collect()
gc.freeze()
yield
gc.freeze() 是 Python 3.7+ 的 API。vLLM 源码旁边的注释写得很直白:把启动堆标记为 static,让后续 GC 忽略它,从而减少 oldest generation collection 的暂停时间。这里不要擅自扩展成”固定能省多少毫秒”或”一定能改善多少 P99”,因为实际效果取决于模型规模、对象数量、Python 版本、请求热路径分配行为和 GC 触发频率。
更准确的理解是:模型加载、tokenizer、配置、FastAPI app、handler、engine client 和若干长生命周期对象都在服务启动阶段建立;这些对象热启动后大多不会参与每个请求的业务变化。把启动期对象 freeze 后,常规 GC 更关注启动后新产生的对象,减少无意义扫描。对在线推理服务来说,降低偶发暂停比提高平均吞吐更重要,因为用户体感和 SLA 通常看 TTFT/TPOT 的尾部。
这类优化的价值不在于”一行代码改变性能曲线”的戏剧性,而在于它表明 vLLM 把 Python 服务层也当作延迟系统的一部分来治理:GPU kernel、KV Cache、调度器之外,FastAPI 生命周期、后台 task、GC 和引用释放同样会影响线上稳定性。
17.3.1.1 lifespan 里其他 3 个容易忽略的细节
打开 vllm/entrypoints/openai/api_server.py:107 看完整的 lifespan 实现、除了 gc.freeze 外还有三处值得解读:
@asynccontextmanager
async def lifespan(app: FastAPI):
try:
if app.state.log_stats:
engine_client: EngineClient = app.state.engine_client
async def _force_log():
while True:
await asyncio.sleep(10.)
await engine_client.do_log_stats()
task = asyncio.create_task(_force_log())
_running_tasks.add(task) # ← ①
task.add_done_callback(_running_tasks.remove) # ← ②
else:
task = None
# Mark the startup heap as static so that it's ignored by GC.
gc.collect()
gc.freeze()
try:
yield
finally:
if task is not None:
task.cancel()
finally:
del app.state # ← ③
① 10 秒周期日志的 asyncio.create_task + 全局集合引用模式:
task = asyncio.create_task(_force_log())
_running_tasks.add(task)
task.add_done_callback(_running_tasks.remove)
这是应对 asyncio.create_task() 的引用管理问题。后台协程如果只创建、不保存强引用,就可能在生命周期管理上变得不可控。vLLM 把 task 放进模块级 _running_tasks,用 set 明确持有引用。
模式:_running_tasks 是模块级 set(api_server.py 顶层定义),add 给任务加强引用防 GC、add_done_callback(remove) 让任务自然完成时从 set 里清理——既防 GC 又不永久泄漏引用。每个 vLLM 推理进程里可能有几个这样的后台任务(log、metrics push、health check),都走这个模式。
② add_done_callback 的清理配对:
_running_tasks.remove 是做清理的配对函数——任务一旦结束(正常或抛异常),callback 触发把自己从全局 set 里移除。没这个配对、程序退出时 set 会保留所有已完成任务的强引用、task 对象本身成了泄漏源。虽然 Python 退出时 OS 会回收、但在长生命周期里会积累。
③ finally: del app.state 显式切断状态引用:
finally:
del app.state
FastAPI 的 app.state 挂着 engine_client、serving handler、模型配置、请求 logger、server load tracking 等对象。lifespan 退出时用 del 显式切断这个状态根引用,让后续清理不再被 app 对象继续拖住。
用 del 而非逐个字段置空 的好处是不用记住 init_app_state() 到底挂了多少字段。当前源码里 state 至少包括 engine_client、log_stats、vllm_config、openai_serving_models、openai_serving_chat、openai_serving_completion、openai_serving_pooling、openai_serving_embedding、openai_serving_scores、openai_serving_tokenization、openai_serving_transcription、task、enable_server_load_tracking 和 server_load_metrics。逐个清理既容易漏,也会让生命周期和初始化逻辑重复耦合。
三件事合起来的含义:lifespan 不只是”跑 yield 前准备、yield 后清理”的简单结构——它是每一处引用都要显式管理的精密仪器。_running_tasks.add 让后台日志任务有明确生命周期,add_done_callback(remove) 防止已完成任务长期占住 set,del app.state 切断整块服务状态引用。这些细节合起来是 vLLM 生产级服务稳定性的真实组成部分,而不是教科书里 “FastAPI lifespan 很简单” 那种印象。
呼应第 10 章 mcp-python-sdk 的 _sessions vs _session_exit_stacks 两级管理、第 14 章 LangGraph patch_execution_info 的精准字段更新——生产级异步库代码的共同特征就是这种”每一处引用都有明确命运”的工程细致。
17.3.2 build_app():生产边界在哪里
build_app() 是 API Server 的第二个关键入口。它决定哪些能力属于 vLLM 自身,哪些应该交给外部网关、Service Mesh 或平台层。
| 能力 | 源码行为 | 生产建议 |
|---|---|---|
| FastAPI docs | --disable-fastapi-docs 时关闭 OpenAPI/docs/redoc | 公网或多租户环境建议关闭 |
| metrics | 启动时总是 mount_metrics(app) | 用 Prometheus scrape /metrics,不要自己解析日志 |
| CORS | 从 CLI 参数注入 CORSMiddleware | 浏览器直连时配置,服务间调用尽量收窄 |
| JSON 校验失败 | RequestValidationError 统一转 BadRequestError | 客户端可以按 OpenAI 风格错误处理 |
| API key | args.api_key 优先于 VLLM_API_KEY,只保护 /v1 前缀 | /health、/metrics、/load 仍需靠网络层隔离 |
| Request ID | --enable-request-id-headers 会警告高 QPS 可能影响性能 | 需要链路追踪才开启,默认不要为了”看起来完整”打开 |
| Response debug log | VLLM_DEBUG_LOG_API_SERVER_RESPONSE 会警告可能包含敏感信息 | 只在本地复现问题时开,生产避免 |
| 自定义 middleware | args.middleware 支持 class 或 coroutine function | 适合注入审计、租户、限流,但要压测中间件开销 |
最容易误读的是 API key:vLLM 的认证中间件只检查 /v1 路径。这是合理取舍,因为健康检查、metrics 和平台探测经常不带业务 token;但它也意味着你不能把 vLLM 裸露在公网后只依赖 --api-key。生产上至少要用内网 LB、安全组、Ingress auth、mTLS 或 Service Mesh 把非 /v1 管理面收住。
17.3.3 启动慢的原因与缓解
启动耗时不能写成固定数值,因为它取决于模型大小、权重格式、磁盘、网络、tensor parallel、CUDA Graph 捕获、量化后处理和是否启用 frontend multiprocessing。更可靠的表达是拆阶段:
| 阶段 | 主要成本 | 能否并行 / 加速 |
|---|---|---|
| 下载或读取权重 | 远程仓库、对象存储、网络文件系统、本地 SSD 差异巨大 | 预下载、节点缓存、本地 NVMe |
| tokenizer / config 解析 | 通常不是大头,但错误会在这里提前暴露 | 镜像内固定依赖和模板 |
| 权重加载和 TP 切分 | 权重格式、并行度、设备互联影响大 | 使用合适的权重格式和并行加载工具 |
| 量化相关初始化 | 不同 quant method 的 scale、kernel、post-process 逻辑不同 | 优先使用已离线处理好的权重 |
| KV Cache 预算和分配 | 由 gpu_memory_utilization、max_model_len、并发上限共同决定 | 先用目标输入长度压测,而不是凭经验拉满 |
| CUDA Graph / warmup | 提升稳定运行效率,但会增加启动阶段工作 | 低延迟服务保留;频繁冷启动场景谨慎权衡 |
加速技巧:
- 权重放在 本地 NVMe 上而不是远程挂载(网络 fs / OSS fuse)
- 启用
tensorizer或runai-model-streamer做并行加载 - 先离线做 TP-shard,运行时直接读分好的权重
- K8s 部署加镜像预热(把模型权重塞进 Docker image 层)或 initContainer 下载 + hostPath 缓存
还有一个部署层面的细节:启动慢时不要让 livenessProbe 太早介入。Kubernetes 应该用 startupProbe 覆盖模型加载期,等启动探测成功后再让 liveness/readiness 接管。否则容器还在正常加载权重,平台却把它当成卡死进程重启,最终进入 CrashLoopBackOff。
17.4 部署样板:从 Docker 到 K8s
17.4.1 Dockerfile 样板
# 基础镜像选官方
FROM vllm/vllm-openai:v0.8.5
# (可选)把常用模型权重预先下载到镜像,避免启动时下载
RUN huggingface-cli download meta-llama/Llama-3.1-8B-Instruct \
--local-dir /models/llama-3.1-8b
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5m \
CMD curl -f http://localhost:8000/health || exit 1
# 入口
ENTRYPOINT ["python", "-m", "vllm.entrypoints.openai.api_server"]
CMD ["--model", "/models/llama-3.1-8b", "--port", "8000"]
注意 --start-period=5m——启动期间 healthcheck 失败不算”不健康”。对大模型必不可少,否则 K8s 会在启动完成前就 kill 容器、进入 CrashLoopBackOff 循环。
17.4.2 Kubernetes Deployment 样板
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-llama-8b
spec:
replicas: 3 # 3 副本负载均衡
selector:
matchLabels:
app: vllm
template:
metadata:
labels:
app: vllm
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8000"
prometheus.io/path: "/metrics"
spec:
containers:
- name: vllm
image: yourcompany/vllm-llama:v1.0
args:
- --tensor-parallel-size=1
- --gpu-memory-utilization=0.9
- --max-model-len=8192
- --max-num-seqs=128
- --max-num-batched-tokens=4096
ports:
- containerPort: 8000
resources:
limits:
nvidia.com/gpu: 1
memory: 32Gi
cpu: 8
requests:
nvidia.com/gpu: 1
memory: 24Gi
cpu: 4
startupProbe: # 大模型必需
httpGet:
path: /health
port: 8000
periodSeconds: 10
failureThreshold: 60 # 最多容忍 10 分钟启动
livenessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 10
timeoutSeconds: 3
terminationGracePeriodSeconds: 120 # 给正在处理的请求留收尾时间
---
apiVersion: v1
kind: Service
metadata:
name: vllm-llama-8b
spec:
selector:
app: vllm
ports:
- port: 80
targetPort: 8000
sessionAffinity: None # 普通无状态负载均衡;前缀缓存亲和需在网关层另做策略
17.4.3 HPA (Horizontal Pod Autoscaler)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: vllm-llama-8b
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: vllm-llama-8b
minReplicas: 2
maxReplicas: 10
metrics:
# 基于 vLLM 暴露的 queue_size 自动扩缩
- type: Pods
pods:
metric:
name: vllm_num_requests_waiting
target:
type: AverageValue
averageValue: "5" # 每副本平均排队 5 个就扩容
behavior:
scaleDown:
stabilizationWindowSeconds: 600 # 缩容冷却 10 分钟(模型启动慢)
scaleUp:
stabilizationWindowSeconds: 60
LLM 服务的 HPA 和普通 Web 服务不同点在于缩容极慢——冷启动一个 vLLM 副本要几分钟,把它干掉就意味着未来几分钟内如果流量回升要付这笔成本。保守的策略:只在持续 10 分钟空闲才缩。
还有一个指标适配细节:Prometheus 里的原始指标名是 vllm:num_requests_waiting,Kubernetes HPA 不能直接消费 Prometheus 文本格式,通常要经过 Prometheus Adapter、KEDA 或自研 autoscaler 转成 Kubernetes external/pods metric。示例里的 vllm_num_requests_waiting 只是适配后的名字,不是 vLLM 原生暴露的 metric。真正落地时要确认三件事:adapter 查询语句是否带上了模型、副本、namespace 标签;等待队列的聚合方式是 sum 还是 avg;扩容触发后新副本冷启动期间,旧副本是否仍能承受排队。否则 HPA 看似配置正确,实际会因为指标维度错、冷启动滞后或缩容过早而放大尾延迟。
17.5 关键参数调优手册
17.5.1 三个核心旋钮
vllm serve meta-llama/Llama-3.1-70B-Instruct \
--tensor-parallel-size 2 \
--gpu-memory-utilization 0.9 \
--max-model-len 8192 \
--max-num-batched-tokens 4096 \
--max-num-seqs 128 \
--enable-prefix-caching \
--enable-chunked-prefill \
--port 8000
tensor-parallel-size:模型权重所需 GPU 数下限。70B × FP16 = 140GB,A100-80GB 至少 2 卡。如果你想留足 KV 空间,常用 4 或 8。
gpu-memory-utilization(0.9):默认就好。极端情况:
- 有别的进程共享 GPU → 降到 0.7-0.8
- 纯粹追求吞吐上限 → 0.95(但 OOM 风险非零,要配压测验证)
max-num-batched-tokens(4096):每步 GPU kernel 规模。
- 延迟敏感(对话) → 2048
- 吞吐优先(批处理) → 8192 或 16384
max-num-seqs(128):同时 RUNNING 的请求上限。
- 太大 → 抢占频繁,P99 TPOT 抖动
- 太小 → GPU 吃不饱,吞吐浪费
- 根据 KV 预算反推(见 ch05)
17.5.2 三个场景组合
场景 A:低延迟在线对话
--max-num-batched-tokens 2048
--max-num-seqs 64
--enable-prefix-caching # 系统提示能复用
--speculative-model ... # 投机解码进一步降 TPOT
场景 B:高吞吐离线批处理
--max-num-batched-tokens 16384
--max-num-seqs 512
--enforce-eager # 不要 CUDA Graph(batch 大,启动开销摊薄)
场景 C:多租户 LoRA 服务
--enable-lora
--max-loras 8 # 同时激活的 LoRA 数
--max-cpu-loras 32 # CPU 缓存更多,按需热加载
--lora-modules sql=/path/sql_adapter math=/path/math_adapter ...
17.6 可观测性:看哪些指标
17.6.1 Prometheus 指标全集(挑关键的)
# 当前负载
vllm:num_requests_running # RUNNING 数
vllm:num_requests_waiting # WAITING 数
# 资源占用
vllm:gpu_cache_usage_perc # KV 池占用率 (0..1)
# token 计数
vllm:prompt_tokens_total # 累计 prompt token
vllm:generation_tokens_total # 累计生成 token
# 延迟(histogram)
vllm:time_to_first_token_seconds # TTFT
vllm:time_per_output_token_seconds # TPOT
vllm:e2e_request_latency_seconds # 端到端
vllm:request_queue_time_seconds # 排队时间
vllm:request_prefill_time_seconds # prefill 时间
vllm:request_decode_time_seconds # decode 时间
# 前缀缓存
vllm:gpu_prefix_cache_queries # V1 前缀缓存查询数
vllm:gpu_prefix_cache_hits # V1 前缀缓存命中数
# 抢占
vllm:num_preemptions_total # counter
# 请求结果
vllm:request_success_total # 按 finished_reason 标记成功请求
注意两个版本坑。第一,V0 路径里仍能看到 vllm:num_requests_swapped、vllm:cpu_cache_usage_perc、vllm:gpu_prefix_cache_hit_rate 等指标;V1 logger 则更强调 gpu_prefix_cache_queries 和 gpu_prefix_cache_hits。第二,Avg prompt throughput、Avg generation throughput 是 LoggingStatLogger 打到日志里的吞吐,不是同名 Prometheus gauge。做 Grafana 面板时不要把日志字段和 Prometheus 指标混在一起。
17.6.2 事故时第一眼看的三个图
- TTFT p99 曲线 + num_requests_waiting——判断”是计算慢了还是队列堵了”
- gpu_cache_usage_perc + num_preemptions_total——判断”是 KV 不够了导致抢占”
- prompt_tokens_total / generation_tokens_total 的 rate——判断吞吐是否随流量和 batch 参数变化
这三组图不能覆盖所有事故,但足以把问题先分到”排队”、“KV/抢占”、“模型计算/吞吐”几个大类,后续再看引擎日志、GPU 利用率和平台事件。
17.6.3 Grafana Dashboard
vLLM 官方仓库提供了一套 Prometheus/Grafana 示例,当前路径是 examples/online_serving/prometheus_grafana/grafana.json、prometheus.yaml 和 docker-compose.yaml。生产部署可以先导入这份面板,再按自己的网关、Kubernetes 和业务标签改变量名。
17.7 容量规划:从 SLA 倒推 GPU 数
一个常见问题:我要服务某个目标 QPS,输入平均多少 token、输出平均多少 token,TTFT p99 和 TPOT p99 有明确 SLA,需要几卡?
17.7.1 公式推导
步骤 1:先用目标模型压出两条曲线
- 固定输入长度、输出长度和并发,记录 TTFT、TPOT、e2e、tokens/s、
num_requests_waiting、gpu_cache_usage_perc - 分别做短输入长输出、长输入短输出、真实业务 prompt 三组,不要只用随机文本
- 每组至少压到 p95/p99 稳定,再取”满足 SLA 的最大稳定 request/s/GPU”
步骤 2:用实测吞吐反推副本数
required_replicas =
ceil(target_qps / measured_sustainable_qps_per_replica * safety_factor)
safety_factor 不是固定常数。流量平稳、prompt 长度稳定、冷启动快的内部服务可以小一些;多租户、长尾输出明显、模型冷启动慢、升级频繁的服务要留更多冗余。经验数字可以写在团队 runbook 里,但不能跨模型照搬。
步骤 3:用 KV 预算检查并发上限
吞吐够不代表 SLA 一定稳。max_num_seqs、max_model_len、gpu_memory_utilization 和模型 KV 每 token 成本共同决定能同时容纳多少请求。若 gpu_cache_usage_perc 长期贴近高水位,TTFT 可能被排队拖高,TPOT 也可能因为抢占和 batch 变化变抖。
步骤 4:把平台成本计进去
反推 GPU 数时还要加上滚动升级、蓝绿、故障域和冷启动成本。比如 N 个副本刚好服务满载流量,并不代表生产应该只部署 N 个;升级期间至少要保证新旧版本重叠、单节点故障和短时流量峰值不会直接触发排队雪崩。
17.7.2 避开常见误区
- 不要用 “一张卡多少 TFLOPS” 来算——LLM 是 memory-bound,带宽决定实际吞吐
- 要区分 prefill-bound vs decode-bound——RAG 场景通常更看重 prefill,问答场景通常更看重 decode;调参方向不同,不能只看总 tokens/s
- 压测时要用真实的 prompt——用
lorem ipsum压测的结果在生产完全不可用,token embedding 走得太顺畅
17.8 压测:vllm bench 与通用工具
vLLM 安装包里有 vllm bench 子命令,源码在 vllm/entrypoints/cli/benchmark/ 和 vllm/benchmarks/:
# 吞吐压测
vllm bench serve \
--model meta-llama/Llama-3.1-8B \
--dataset-name sharegpt \
--dataset-path ShareGPT_V3_unfiltered_cleaned_split.json \
--num-prompts 1000 \
--request-rate 50
# 单请求延迟
vllm bench latency \
--model meta-llama/Llama-3.1-8B \
--input-len 500 --output-len 200 --batch-size 1
源码里的 serve.py 支持 request rate、burstiness、dataset、percentile、goodput 等参数;latency.py 直接构造 LLM 做单批延迟测量。两者回答的问题不同:前者测在线服务端到端能力,后者更接近引擎本地生成延迟。容量规划优先看 vllm bench serve,定位 kernel 或 batch 形态时再看 latency/throughput 子命令。
用 ShareGPT 或业务采样数据集而不是纯合成数据,这一点尤其重要。真实 prompt 的长度分布、重复前缀、工具调用格式、中文/英文比例和 stop 条件都会影响 tokenizer、prefill、prefix cache 和响应体大小。
通用工具:
- k6(Go 写的现代压测工具):
k6 run --vus 100 --duration 5m script.js - wrk:极简,只看吞吐
- locust(Python):可以写复杂 scenario
压测时保留以下指标:
- p50 / p95 / p99 的 TTFT / TPOT / e2e
- 总吞吐 token/s
- 错误率
- 对应时段的 GPU 利用率、KV 占用、抢占次数
17.9 9 类生产故障排查
| 现象 | 可能原因 | 首查位置 | 解法 |
|---|---|---|---|
| OOM at startup | gpu_memory_utilization 太高 + 别的进程占 GPU | nvidia-smi | 降到 0.85,或独占 GPU |
| OOM at runtime | KV 池不够 + 某个大请求进来 | gpu_cache_usage_perc | 降 max_num_seqs,降 max_model_len |
| TTFT p99 飙升 | 队列堵,waiting 多 | num_requests_waiting | 横向扩容,或开 Chunked Prefill |
| TPOT 尖刺周期性 | 抢占频繁 | num_preemptions_total | KV 池不够,同 OOM at runtime 处理 |
| 吞吐远低于预期 | max_num_seqs 或 max_num_batched_tokens 太小 | Grafana 看 GPU util | 逐步加大这两个参数 |
| Worker 进程崩溃 | CUDA 错 / 显存泄漏 | docker logs,搜 “CUDA error” | 升级驱动或升级 vLLM |
| LoRA 切换慢 | 远程存储 + 未预热 | LoRA 加载日志 | 预加载到本地 SSD;加大 max_cpu_loras |
| SSE 流式卡顿 | Nginx proxy_buffering 未关 | Nginx config | 配置见 17.2.4 |
| 长生成被中断 | 客户端 timeout / LB timeout | 查客户端 / 反代日志 | 改 timeout;限制 max_tokens |
这里还要区分”进程健康”和”服务质量健康”。/health 只证明 engine client 能通过健康检查,不代表当前队列短、KV 充足、TTFT 满足 SLA,也不代表 tool parser 一定能解析某个模型的输出。线上告警应该把 liveness/readiness、Prometheus 延迟指标、错误率、GPU 事件和业务侧成功率分开配置;否则一个副本会在 Kubernetes 看来完全健康,但对用户已经表现为长时间排队或流式中断。
17.10 蓝绿 / 金丝雀:如何安全升级
LLM 服务升级的特殊性:冷启动慢(几分钟)、请求处理时间长(几十秒)、有状态(流式中途不能断)。常规滚动升级可能导致流式请求中断、p99 飙升。
推荐策略:蓝绿部署
1. 启动新版本 Deployment(绿),等 readinessProbe 全部通过
2. 新流量切到绿,旧版本(蓝)继续处理已接收的请求
3. 等蓝的 num_requests_running 归零(或超过 max 生成时长)
4. 删除蓝
K8s 层用两套 Deployment + 一个 Service 切换 selector 实现。
金丝雀部署:如果你担心新版本性能回归,先切 10% 流量到新版本,观察 30 分钟指标没问题再全量。用 Istio / Linkerd 的 traffic splitting。
17.10.1 vllm/entrypoints/ 48 个 Python 文件的真实拆分
§17.1.1 给出三层结构图,但实际目录比图里复杂得多。以当前本地 vLLM 源码统计,vllm/entrypoints/ 递归共有 48 个 Python 文件、14445 行。这个数字会随版本变化,但结构比例很能说明问题。
openai/ 15 文件 7899 行——
| 文件 | 行 | 角色 |
|---|---|---|
protocol.py | 1806 | OpenAI 兼容的 Pydantic 模型——本目录最重 23%,覆盖 chat / completion / embedding / score / transcription 等所有协议字段 |
serving_chat.py | 1210 | /v1/chat/completions——chat template + 工具调用 + 流式编排 |
api_server.py | 1130 | FastAPI app + lifespan + 路由注册 |
serving_engine.py | 569 | 隐藏的基类——5 个 serving_* 共享的 validate_request / prepare_input 等公共逻辑 |
serving_completion.py | 557 | /v1/completions(旧版 text completion) |
run_batch.py | 439 | /v1/batches——离线批处理接口 |
serving_score.py | 439 | /v1/score——文本对相关性打分(跨编码器模型用) |
serving_transcription.py | 421 | /v1/audio/transcriptions——Whisper 兼容 |
serving_models.py | 314 | /v1/models——LoRA 列表管理 |
cli_args.py | 296 | vllm serve 参数解析 |
serving_embedding.py / serving_pooling.py / serving_tokenization.py / logits_processors.py | 89~245 | 余下小服务 |
openai/tool_parsers/ 12 文件 2681 行——工具调用的模型差异主要在这里——
| 文件 | 行 | 模型家族 |
|---|---|---|
hermes_tool_parser.py | 370 | Nous Hermes(最大) |
mistral_tool_parser.py | 342 | Mistral |
jamba_tool_parser.py | 303 | Jamba |
pythonic_tool_parser.py | 292 | Python 风格函数调用 |
llama_tool_parser.py | 262 | Llama 3 |
granite_20b_fc_tool_parser.py | 254 | IBM Granite 20B FC |
granite_tool_parser.py | 232 | IBM Granite |
internlm2_tool_parser.py | 211 | InternLM 2 |
abstract_tool_parser.py | 163 | ABC 基类 |
phi4mini_tool_parser.py | 110 | Phi-4 Mini |
utils.py | 123 | 公共工具 |
__init__.py | 19 | parser 注册入口 |
cli/ 12 文件 530 行——
CLI 层递归总共只有 530 行,vllm serve、vllm bench、vllm collect-env 和 vllm chat/complete 的注册都在这里。cli/serve.py 自身只有 59 行,它解析参数后调用 vllm.entrypoints.openai.api_server.run_server(args);这印证了 §17.3 的结论:vllm serve 是命令包装,真正的服务生命周期在 api_server.py。
三条值得记住的事实——
protocol.py单文件 1806 行,约占 openai/ 的 23%。OpenAI 兼容最大的工程债不是路由逻辑,而是规范完整性:每个请求/响应字段、默认值、错误形态和 validator 都要跟上生态预期。tool_parsers/有 2681 行、9 个模型家族 parser 文件。多模型生产部署最常踩坑的地方往往不是/v1/chat/completions路由,而是每个模型的 tool calling 格式不同:XML、JSON、pythonic、特殊标签和 tokenizer 行为都会影响解析。- 没有独立的通用 streaming 文件。SSE 流式输出散落在每个
serving_*.py里,而不是集中成streaming.py。这是合理取舍:协议特定的流式格式不能跨服务强行复用,chat 的 SSE delta 和 embedding、transcription 的响应形状不是一回事。
17.11 本章小结
API Server 是 vLLM 离用户最近的那一公里。本章覆盖:
- OpenAI 兼容层——借用既有生态接口;三层结构(api_server / serving_chat / engine_client);字段映射和 handler 启用边界
- SSE 流式输出——非阻塞流水线;role chunk、usage chunk、tool/reasoning parser、SSE 内部错误;三个生产陷阱(Nginx buffering / CDN / 客户端 timeout)
- 启动 7 阶段——
gc.freeze()、后台 task 强引用、del app.state和build_app()中间件边界 - 部署样板——Dockerfile、K8s Deployment、HPA(LLM 特有的慢启动/慢缩容策略)
- 三个核心旋钮——
tensor-parallel-size/gpu-memory-utilization/max-num-seqs+ 场景化组合 - 可观测性——Prometheus 指标全集;事故时看三张图
- 容量规划——用实测 sustainable QPS 反推 GPU 数,避开 TFLOPS 和跨模型经验数字误区
- 压测——ShareGPT > 合成数据;
vllm bench官方脚本 - 9 类故障排查 手册
- 蓝绿 / 金丝雀——LLM 服务升级的特殊策略
物理事实:当前本地 vllm/entrypoints/ 递归 48 个 Python 文件、14445 行;protocol.py 单文件 1806 行,约占 openai/ 的 23%;tool_parsers/ 2681 行、9 个模型家族 parser 文件。OpenAI 兼容最大的工程债是规范完整性和模型差异,而不是 FastAPI 路由本身。
源码导航
- OpenAI 入口:
vllm/entrypoints/openai/api_server.py- 协议翻译:
vllm/entrypoints/openai/serving_chat.py/serving_completion.py/serving_embedding.py- CLI:
vllm/entrypoints/cli/- 离线推理:
vllm/entrypoints/llm.py- 协议模型:
vllm/entrypoints/openai/protocol.py- Prometheus 指标:
vllm/v1/metrics/- Benchmark 脚本:
vllm/benchmarks/- 生产样板 Grafana dashboard:
examples/online_serving/prometheus_grafana/grafana.json