vLLM 推理内核深度解析

第17章 API 服务器与生产部署

作者 杨艺韬 · 9,628 字

第17章 API 服务器与生产部署

“The last mile is often the hardest.” — 任何上过线的工程师都懂

本章要点

  • 读懂 vllm.entrypoints.openai 下的三层结构:api_server.py 负责 FastAPI 路由和生命周期,serving_*.py 负责协议翻译,EngineClient 负责连接推理引擎
  • 掌握 Chat 请求从 messagesengine_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)
  • 用户心智零成本——openai Python 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)→ 构造 SamplingParamsengine.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 上。

路由源码位置生产含义
/healthapi_server.py:391engine_client.check_health(),适合 liveness/readiness 的基础探测
/loadapi_server.py:398返回 server_load_metrics,统计会占用 GPU 的在线请求路由
/pingapi_server.py:416复用 health,主要满足 SageMaker 这类平台约定
/tokenize / /detokenizeapi_server.py:422 / 437复用 tokenizer 服务,方便客户端先算 token 长度或调试模板
/v1/modelsapi_server.py:452展示 base model、LoRA、prompt adapter 组合后的可服务模型名
/v1/chat/completionsapi_server.py:466Chat Completions 主入口,返回 JSON 或 text/event-stream
/v1/completionsapi_server.py:489旧式文本 completion,很多遗留应用仍依赖
/v1/embeddingsapi_server.py:508 之后embed、pooling、score、rerank、transcription 这些任务按模型能力启用

这里有两个很容易漏掉的工程事实。

第一,init_app_state() 会按 model_config.runner_typemodel_config.task 决定哪些 handler 真正存在。生成模型会创建 OpenAIServingChatOpenAIServingCompletion;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 抛异常会立即减一;如果返回 JSONResponseStreamingResponse,则把减一动作挂到 Starlette background task 上,等响应完成后再执行。这个细节对流式请求尤其重要:SSE 连接还没结束时,服务端不能把负载提前减掉,否则外部调度器会误以为副本已经空闲。

所以 /load 更适合做”当前副本还有多少活跃业务响应”的近实时信号,而不是长期趋势监控。长期趋势仍应看 Prometheus 的 num_requests_waitingrequest_queue_time_seconds、TTFT/TPOT histogram 和 token counter。把这两类信号分开,外部网关才能同时做到短周期避让繁忙副本、长周期判断是否扩容。

17.1.3 OpenAI 协议字段映射(关键几项)

OpenAI Chat Completions 的字段非常丰富,vLLM 的兼容层不是一张简单的 dict 映射表,而是一条带校验、模板渲染、adapter 选择和采样参数构造的流水线。以下是最关键的几项映射:

OpenAI 字段vLLM 内部字段备注
messagesprompt_token_ids(经 chat template 渲染)模板来自 tokenizer 的 chat_template
model用于路由到正确的 LoRA 适配器(若启用 --enable-lora单模型部署时忽略
stream控制 SSE vs 一次性响应true 时返回 AsyncGenerator
max_tokens / max_completion_tokensSamplingParams.max_tokenso1 系列用后者
temperatureSamplingParams.temperature0 时切到 greedy path
top_pSamplingParams.top_pnucleus sampling
top_kSamplingParams.top_kOpenAI 没有,vLLM 扩展
n并行采样数触发 KV Cache COW
stopSamplingParams.stop字符串或 token id 列表
presence_penalty / frequency_penalty同名字段logits processor
logprobs / top_logprobsSamplingParams.logprobs需要额外 GPU 计算
tools / tool_choicechat template + tool parserauto 需要显式启用 parser,Mistral tokenizer 有专门路径
response_format.type: json_objectguided decoding / structured outputs约束采样能力取决于后端配置和模型输出形态
response_format.type: json_schemaschema 约束采样更严格,但要把 schema 编译成可执行约束
seedSamplingParams.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() 得到 conversationrequest_promptsengine_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-templateinit_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_textsall_previous_token_idsprev_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 包
logprobsrequest.logprobstop_logprobs 同时存在时构造 chat logprobs打开后会增加序列化和输出体积,不应在高 QPS 默认启用
tool/reasoning parser根据 tool_choice_auto 和 reasoning 配置维护 previous text/token 状态防止半截 JSON、reasoning token 或控制 token 泄露给客户端
usage chunkstream_options.include_usage 时,结束前发送 choices=[] 的 usage chunk客户端计费和审计依赖这一块,不要只读最后 [DONE]
异常parser 创建失败或 generator 抛异常时,yield SSE error,再 yield [DONE]HTTP 状态可能已经是 200,客户端必须解析 SSE 内部错误

这个表解释了为什么 Chat API 的流式逻辑没有被抽成一个通用 streaming.py。Chat 的 delta 可能是 contentreasoning_contenttool_callslogprobsusage 的组合;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 就一定成功”。更稳的客户端逻辑是:

  1. HTTP 层只判断连接是否建立、认证是否通过、content type 是否合理。
  2. SSE 层逐条解析 data:,遇到 JSON 里的 error 字段要走失败路径。
  3. 只有看到正常 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-135lifespan(),你会看到这两行:

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_clientlog_statsvllm_configopenai_serving_modelsopenai_serving_chatopenai_serving_completionopenai_serving_poolingopenai_serving_embeddingopenai_serving_scoresopenai_serving_tokenizationopenai_serving_transcriptiontaskenable_server_load_trackingserver_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 keyargs.api_key 优先于 VLLM_API_KEY,只保护 /v1 前缀/health/metrics/load 仍需靠网络层隔离
Request ID--enable-request-id-headers 会警告高 QPS 可能影响性能需要链路追踪才开启,默认不要为了”看起来完整”打开
Response debug logVLLM_DEBUG_LOG_API_SERVER_RESPONSE 会警告可能包含敏感信息只在本地复现问题时开,生产避免
自定义 middlewareargs.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_utilizationmax_model_len、并发上限共同决定先用目标输入长度压测,而不是凭经验拉满
CUDA Graph / warmup提升稳定运行效率,但会增加启动阶段工作低延迟服务保留;频繁冷启动场景谨慎权衡

加速技巧

  • 权重放在 本地 NVMe 上而不是远程挂载(网络 fs / OSS fuse)
  • 启用 tensorizerrunai-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_swappedvllm:cpu_cache_usage_percvllm:gpu_prefix_cache_hit_rate 等指标;V1 logger 则更强调 gpu_prefix_cache_queriesgpu_prefix_cache_hits。第二,Avg prompt throughputAvg generation throughputLoggingStatLogger 打到日志里的吞吐,不是同名 Prometheus gauge。做 Grafana 面板时不要把日志字段和 Prometheus 指标混在一起。

17.6.2 事故时第一眼看的三个图

  1. TTFT p99 曲线 + num_requests_waiting——判断”是计算慢了还是队列堵了”
  2. gpu_cache_usage_perc + num_preemptions_total——判断”是 KV 不够了导致抢占”
  3. prompt_tokens_total / generation_tokens_total 的 rate——判断吞吐是否随流量和 batch 参数变化

这三组图不能覆盖所有事故,但足以把问题先分到”排队”、“KV/抢占”、“模型计算/吞吐”几个大类,后续再看引擎日志、GPU 利用率和平台事件。

17.6.3 Grafana Dashboard

vLLM 官方仓库提供了一套 Prometheus/Grafana 示例,当前路径是 examples/online_serving/prometheus_grafana/grafana.jsonprometheus.yamldocker-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_waitinggpu_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_seqsmax_model_lengpu_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 startupgpu_memory_utilization 太高 + 别的进程占 GPUnvidia-smi降到 0.85,或独占 GPU
OOM at runtimeKV 池不够 + 某个大请求进来gpu_cache_usage_percmax_num_seqs,降 max_model_len
TTFT p99 飙升队列堵,waiting 多num_requests_waiting横向扩容,或开 Chunked Prefill
TPOT 尖刺周期性抢占频繁num_preemptions_totalKV 池不够,同 OOM at runtime 处理
吞吐远低于预期max_num_seqsmax_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.py1806OpenAI 兼容的 Pydantic 模型——本目录最重 23%,覆盖 chat / completion / embedding / score / transcription 等所有协议字段
serving_chat.py1210/v1/chat/completions——chat template + 工具调用 + 流式编排
api_server.py1130FastAPI app + lifespan + 路由注册
serving_engine.py569隐藏的基类——5 个 serving_* 共享的 validate_request / prepare_input 等公共逻辑
serving_completion.py557/v1/completions(旧版 text completion)
run_batch.py439/v1/batches——离线批处理接口
serving_score.py439/v1/score——文本对相关性打分(跨编码器模型用)
serving_transcription.py421/v1/audio/transcriptions——Whisper 兼容
serving_models.py314/v1/models——LoRA 列表管理
cli_args.py296vllm serve 参数解析
serving_embedding.py / serving_pooling.py / serving_tokenization.py / logits_processors.py89~245余下小服务

openai/tool_parsers/ 12 文件 2681 行——工具调用的模型差异主要在这里——

文件模型家族
hermes_tool_parser.py370Nous Hermes(最大)
mistral_tool_parser.py342Mistral
jamba_tool_parser.py303Jamba
pythonic_tool_parser.py292Python 风格函数调用
llama_tool_parser.py262Llama 3
granite_20b_fc_tool_parser.py254IBM Granite 20B FC
granite_tool_parser.py232IBM Granite
internlm2_tool_parser.py211InternLM 2
abstract_tool_parser.py163ABC 基类
phi4mini_tool_parser.py110Phi-4 Mini
utils.py123公共工具
__init__.py19parser 注册入口

cli/ 12 文件 530 行——

CLI 层递归总共只有 530 行,vllm servevllm benchvllm collect-envvllm chat/complete 的注册都在这里。cli/serve.py 自身只有 59 行,它解析参数后调用 vllm.entrypoints.openai.api_server.run_server(args);这印证了 §17.3 的结论:vllm serve 是命令包装,真正的服务生命周期在 api_server.py

三条值得记住的事实——

  1. protocol.py 单文件 1806 行,约占 openai/ 的 23%。OpenAI 兼容最大的工程债不是路由逻辑,而是规范完整性:每个请求/响应字段、默认值、错误形态和 validator 都要跟上生态预期。
  2. tool_parsers/ 有 2681 行、9 个模型家族 parser 文件。多模型生产部署最常踩坑的地方往往不是 /v1/chat/completions 路由,而是每个模型的 tool calling 格式不同:XML、JSON、pythonic、特殊标签和 tokenizer 行为都会影响解析。
  3. 没有独立的通用 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.statebuild_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