OpenAIServingChat 深度解读:vLLM 怎样把一次 Chat Completion 跑成 OpenAI 响应

从 vLLM 的 OpenAIServingChat 源码看懂 /v1/chat/completions 的服务编排:chat template 渲染、SamplingParams 构造、EngineClient.generate、streaming SSE、tool/reasoning parser、usage、metrics 和 KV transfer cleanup。

OpenAIServingChat 深度解读:vLLM 怎样把一次 Chat Completion 跑成 OpenAI 响应

上一篇我读的是 ChatCompletionRequest:它回答“请求参数分别控制大模型推理的哪一层”。

这一篇读 OpenAIServingChat。它回答另一个更工程化的问题:

参数已经校验完了,messages 也准备好了,vLLM 到底怎样把一次 /v1/chat/completions 请求跑完,并包装成 OpenAI 兼容响应?

源码位置是 vllm/entrypoints/openai/chat_completion/serving.py,阅读版本是 commit 752a3a504485790a2e8491cacbb35c137339ad34

先给结论:

OpenAIServingChat 不是模型推理层。
它是 OpenAI Chat Completion 协议和 vLLM Engine 之间的服务编排层。

它做四件事:
1. 把 ChatCompletionRequest 渲染成 EngineInput
2. 把请求参数转换成 SamplingParams 或 BeamSearchParams
3. 调 engine_client.generate 拿到 RequestOutput 异步流
4. 把内部 RequestOutput 重组成 OpenAI 的 streaming chunk 或 full response

如果 ChatCompletionRequest 是“参数地图”,OpenAIServingChat 就是“把地图接到运行时上的交通枢纽”。

1. 先看总链路

flowchart TD
  A[FastAPI route receives ChatCompletionRequest] --> B[OpenAIServingChat.create_chat_completion]
  B --> C[KV transfer rejection cleanup wrapper]
  C --> D[_create_chat_completion]
  D --> E[effective chat_template_kwargs]
  D --> F[Parser for reasoning and tool calls]
  D --> G[render_chat_request]
  G --> H[OnlineRenderer.render_chat]
  H --> I[conversation + EngineInput]
  D --> J[get max_tokens]
  J --> K[to_sampling_params or to_beam_search_params]
  K --> L[engine_client.generate or beam_search]
  L --> M[Async stream of RequestOutput]
  M --> N{request.stream}
  N -->|true| O[chat_completion_stream_generator]
  N -->|false| P[chat_completion_full_generator]
  O --> Q[SSE chat.completion.chunk]
  P --> R[ChatCompletionResponse]

这张图里最重要的是:OpenAIServingChat 不直接跑 Transformer forward。它拿到的是 EngineClient.generate(...) -> AsyncGenerator[RequestOutput, None]。真正的调度、prefill、decode、KV cache、GPU 执行都在 engine 里。这个类负责把“Web API 世界”和“Engine 输出世界”接起来。

2. 为什么需要这么一层?

因为 OpenAI Chat Completion API 和 vLLM 内部引擎的对象模型不是一回事。

API 层关心:

id
object
created
model
choices
message / delta
finish_reason
usage
logprobs
tool_calls
stream_options

vLLM engine 层关心:

EngineInput
SamplingParams
BeamSearchParams
request_id
LoRA request
priority
data_parallel_rank
RequestOutput
token ids
prompt token ids
metrics
kv_transfer_params

这两套结构不应该混在一起。否则 engine 会被 HTTP 协议污染,API 层也会被底层调度细节污染。

所以 OpenAIServingChat 的职责是边界转换:

OpenAI request -> vLLM engine request
vLLM engine output -> OpenAI response

这也是它为什么看起来很长。它不是一个算法函数,而是一层协议适配器。

3. 初始化:先把服务能力固定下来

__init__() 注入了几个关键依赖:

engine_client
models
online_renderer
request_logger
chat_template
tool_parser
reasoning_parser
各种 feature flag

这里最值得注意的是 parser 初始化:

ParserManager.get_parser(
    tool_parser_name=tool_parser,
    reasoning_parser_name=reasoning_parser,
    enable_auto_tools=enable_auto_tools,
    model_name=self.model_config.model,
    is_harmony=self.model_config.hf_config.model_type == "gpt_oss",
)

这说明 tool calling 和 reasoning 不是在最终字符串上随便 json.loads 一下。vLLM 会根据模型、工具解析器、reasoning 解析器组合出一个 parser。后面 streaming 和 full response 都会用它把模型输出拆成:

reasoning
content
tool_calls

初始化里还有两个明确的能力边界:

Chat Completion API 不支持 browsing
Chat Completion API 不支持 code interpreter

这不是模型能力问题,而是 API surface 的边界。vLLM 在这里选择让 Chat Completion 保持兼容,复杂内建工具能力走 Responses API。

4. render_chat_request:HTTP 请求先变成 EngineInput

render_chat_request() 做的事情很克制:

1. _check_model(request)
2. 如果 engine 已经 dead,直接抛 dead_error
3. 调 online_renderer.render_chat(request)

为什么 streaming 场景要提前检查 engine dead?源码注释说得很实在:streaming 可能已经给客户端返回了成功 HTTP 状态,后面才开始真正生成。如果 engine 早就死了,必须在进入流式响应前失败,否则客户端会拿到一个看似成功、实际马上出错的流。

OnlineRenderer.render_chat() 才是 chat preprocessing 的核心:它会处理 chat template、工具定义、multimodal、tokenization,最后产出:

conversation: list[ConversationMessage]
engine_inputs: list[EngineInput]

这里的 conversation 是后面 echo、role 判断、工具解析需要的高层对话结构;engine_inputs 是 engine 真正能跑的输入。

5. _create_chat_completion:主编排函数

_create_chat_completion() 是这整个类的中轴。

它按顺序做这些事:

1. 拿 tokenizer
2. 合并 chat_template_kwargs
3. 创建 parser
4. render_chat_request
5. 生成 OpenAI 风格 request_id
6. 绑定 RequestResponseMetadata 到 raw_request.state
7. 解析 LoRA adapter
8. 确定返回给用户看的 model_name
9. 从 header 提取 data_parallel_rank
10. 对每个 EngineInput 计算 max_tokens
11. 构造 SamplingParams 或 BeamSearchParams
12. 调 beam_search 或 engine_client.generate
13. 根据 request.stream 选择 streaming 或 full generator

这一段有几个关键点。

第一,max_tokens 不是简单取用户传参。它调用 get_max_tokens(...),综合:

model_config.max_model_len
request.max_completion_tokens / request.max_tokens
prompt length
server default sampling params
override generation config
truncate_prompt_tokens

这和上一篇讲的一致:生成长度预算必须放进上下文窗口里算。

第二,beam search 和普通 sampling 走不同路径:

request.use_beam_search=True -> self.beam_search(...)
否则 -> self.engine_client.generate(...)

因为 beam search 不是普通逐 token 采样,它要维护多个 beam 的分数、排序和完成状态,所以服务层要显式分流。

第三,reasoning 的结束状态会传给 engine:

include_reasoning=False -> reasoning_ended=True
grammar_from_tool_parser -> reasoning_ended=True
parser.reasoning_parser 存在 -> parser.is_reasoning_end(prompt_token_ids)
否则 -> None

这说明 reasoning 不是只在返回前裁剪文本。服务层会尽早告诉 engine:当前 prompt 是否已经处于 reasoning 结束之后,从而让后续 parser 和生成逻辑对齐。

6. EngineClient.generate 返回的不是文本,而是 RequestOutput 流

engine_client.generate(...) 的协议返回值是:

AsyncGenerator[RequestOutput, None]

这很重要。API 层拿到的不是最终字符串,也不是 token list,而是一条异步的内部输出流。

一个 RequestOutput 里可能带着:

prompt
prompt_token_ids
encoder_prompt_token_ids
outputs
num_cached_tokens
prompt_logprobs
kv_transfer_params
metrics

OpenAIServingChat 后半段所有复杂度,基本都来自这件事:

怎么把内部异步输出流,转成 OpenAI 约定的 response 或 SSE chunk?

7. streaming:为什么第一个 chunk 只发 role?

request.stream=True,代码进入 chat_completion_stream_generator()

这个函数不是简单地:

for token in engine:
    yield token

它必须遵守 OpenAI Chat Completion streaming 的结构。第一轮会先发一个带 role 的空 delta:

delta = {
  "role": "assistant",
  "content": ""
}

这让客户端在还没有内容 token 的时候,就知道后续消息属于哪个 role。

这个第一 chunk 还承担几个调试和观测功能:

return_token_ids=True 时带 prompt_token_ids
return_prompt_text=True 时带渲染后的 prompt_text
include_continuous_usage=True 时带初始 usage

所以第一 chunk 是协议握手,不是模型真的生成了一个空 token。

8. streaming 的核心状态机

streaming generator 维护了几组状态:

previous_num_tokens: 每个 choice 已经发了多少 token
finish_reason_sent: 每个 choice 是否已经发过终止 chunk
tools_streamed: 每个 choice 是否已经流出过 tool_calls
previous_texts: 为日志聚合完整输出
num_prompt_tokens: usage 统计
num_cached_tokens: prompt_tokens_details 统计

每次从 engine 拿到一个 RequestOutput,它会遍历 res.outputs,对每个 choice 做:

1. 生成 logprobs
2. 拿 delta_text 和 token_ids
3. 如果是 chunked prefill 的空输出,跳过
4. 用 parser.parse_delta 拆 content / reasoning / tool_calls
5. 按 include_reasoning 决定是否隐藏 reasoning
6. 如果 parser 暂时没有可发内容,必要时跳过
7. 根据 finish_reason 决定普通 chunk 还是终止 chunk
8. 修正 tool_calls finish_reason
9. 过滤 parallel tool calls
10. 包装成 ChatCompletionStreamResponse
11. yield SSE: data: {...}\n\n

这里的“跳过空输出”很值得注意。chunked prefill 时,engine 可能还在处理长 prompt,并没有生成任何可返回的新 token。API 层不能把这些内部进度伪装成空 chunk 发给用户,所以代码会跳过:

not delta_text
and not output.token_ids
and not previous_num_tokens[i]

这就是服务层隐藏内部调度细节的例子。

9. tool calls 为什么要 parser,而且 streaming 要每个 choice 一个 parser?

工具调用在模型侧通常是特殊文本格式或特殊 token 序列,不是 Python 函数调用。

streaming 更麻烦:工具调用参数可能一段一段流出来。

例如模型可能不是一次性吐出:

{"name": "search", "arguments": {"query": "vLLM"}}

而是分成许多 delta:

{"name"
: "search"
, "arguments"
: {"query"
: "vLLM"}}

所以 streaming 里不能等每个 token 都当普通文本发出去。parser 要维护增量状态,判断当前 delta 是:

普通 content
reasoning
tool call name
tool call arguments
控制 token
暂时还不能发给用户的半截结构

这也是为什么 chat_completion_stream_generator() 给每个 choice 创建一个 parser:

parsers = [self.parser_cls(...) for _ in range(num_choices)]

n > 1 时,每个 choice 的输出流彼此独立。如果共用一个 parser,一个 choice 的半截 JSON 状态可能污染另一个 choice。

10. finish_reason:不是直接照搬 engine 的 finish_reason

OpenAI 协议对工具调用的结束原因有自己的约定:

auto / required tool call -> finish_reason = "tool_calls"
named tool choice        -> finish_reason = "stop"

所以 streaming 里会检查:

if tools_streamed[i] and not tool_choice_function_name:
    finish_reason_ = "tool_calls"
else:
    finish_reason_ = output.finish_reason or "stop"

full response 里也有类似逻辑:

auto_tools_called
or tool_choice == "required" and output.finish_reason == "stop"

这说明 finish_reason 是 API 协议字段,不是 engine 内部状态的原样泄漏。服务层需要把内部结束原因翻译成客户端期待的语义。

11. usage 和 metrics 为什么有两套时机?

非流式响应里,usage 很简单:等最终输出出来后统计。

prompt_tokens = len(final_res.prompt_token_ids)
completion_tokens = sum(len(output.token_ids) for output in final_res.outputs)
total_tokens = prompt_tokens + completion_tokens

streaming 里就复杂了,因为客户端可能想要两种 usage:

continuous usage: 每个 chunk 都带当前累计 usage
final usage: 最后额外发一个 choices=[] 的 usage chunk

所以代码调用 should_include_usage(...) 得到:

include_usage
include_continuous_usage

如果 include_usage=True,最后会发一个特殊 chunk:

choices = []
usage = final_usage
system_fingerprint = ...
metrics = ...

这也是为什么 system fingerprint 有个细节:如果没有最终 usage chunk,就盖在 terminal chunk 上;如果有最终 usage chunk,就让最后 usage chunk 成为真正的最终消息。

12. prompt_tokens_details:缓存 token 和多模态 token 不是模型输出

文件顶部两个 helper 很小,但很能体现 serving 层的职责。

_get_mm_token_counts()EngineInput.mm_placeholders 里统计每种模态占了多少 placeholder token:

image -> token count
audio -> token count
video -> token count

_make_prompt_tokens_details() 再把它和 num_cached_tokens 组成:

PromptTokenUsageInfo(
    cached_tokens=...,
    multimodal_tokens=...
)

这里的 cached tokens 来自 prefix cache,multimodal tokens 来自 prompt 中的占位 token。它们都已经算在 prompt_tokens 里,但单独暴露出来能让调用方知道:

这次 prompt 里有多少是缓存命中的?
这次多模态输入占了多少 token 预算?

这是典型的推理服务可观测性字段,不是模型语义字段。

13. full response:先吃完整个 engine 流,再统一组装

chat_completion_full_generator() 的第一步很直接:

async for res in result_generator:
    final_res = res

它不断消费 engine 输出,但只保留最后一个 RequestOutput。原因是非流式响应只需要最终状态:

完整文本
最终 token ids
最终 finish_reason
完整 tool_calls
完整 usage

然后它对每个 final_res.outputs 构造一个 ChatCompletionResponseChoice

1. 检查 error finish_reason
2. 生成 logprobs
3. parser.parse 拆 reasoning/content/tool_calls
4. 根据 tool_choice 和 enable_auto_tools 构造 ChatMessage
5. 修正 finish_reason
6. 可选返回 token_ids
7. 可选返回 routed_experts
8. 过滤 parallel tool calls

full response 的 parser 比 streaming 简单一些,因为它拿到的是完整输出。它可以一次性判断工具调用结构是否完整,不需要维护增量状态。

14. routed_experts:为什么要 npy + base64?

full response 里有一段很底层:

if output.routed_experts is not None:
    buf = io.BytesIO()
    np.save(buf, output.routed_experts)
    routed_experts_b64 = base64.b64encode(buf.getvalue()).decode("ascii")

这是为了把 MoE expert routing 信息带回 JSON。

问题是:JSON 不能直接携带 numpy ndarray 和原始二进制。于是 vLLM 做了两层编码:

ndarray -> .npy bytes -> base64 string

这又是一个边界转换例子。engine 内部可以用高效数组;API 返回必须是 JSON 安全的字符串。

15. logprobs:内部 token 概率怎样变成 OpenAI 格式

_create_chat_logprobs()_get_top_logprobs() 负责把内部 Logprob 结构转换成 OpenAI 风格:

token
logprob
bytes
top_logprobs

这里有几个边界问题:

第一,有些 token 不一定能干净地 JSON 编码或显示,所以支持:

return_tokens_as_token_ids -> token_id:{id}

第二,OpenAI 格式里有 bytes 字段,所以代码会把 token 用 UTF-8 encode 成 byte list。

第三,logprob 会被下限截到 -9999.0。这是协议兼容和数值展示问题,不是采样本身。

所以 logprobs 不是重新计算概率。它是把 engine 已经产出的 per-token logprob 信息包装成客户端协议。

16. echo:为什么 streaming 和 full 都要单独处理?

echo=True 的语义是把输入最后一条同 role 内容也拼回输出。

streaming 时,它在第一轮 role chunk 之后,额外发一个 echo chunk:

DeltaMessage(content=last_msg_content)

full response 时,它在 choices 已经构造好后,直接把 last message content 拼到 choice.message.content 前面。

同一个功能在两种返回模式下实现不同,是因为:

streaming 要保持增量协议
full response 只需要最终 message

这也是服务层复杂度的来源:同一个 API 参数,在不同响应形态里要落到不同输出结构。

17. KV transfer cleanup:失败也要释放远端 prefill 资源

create_chat_completion() 外面包了一层:

_with_kv_transfer_rejection_cleanup(...)

这层来自 GenerateBaseServing。它处理 disaggregated serving 场景:如果请求启用了 remote prefill,但在真正进入 engine 前失败了,KV connector 可能已经 pin 住了一些远端资源。服务层必须通知 engine:

notify_kv_transfer_request_rejected(...)

否则失败请求也可能泄漏远端 KV blocks。

这段逻辑解释了为什么 serving 层不能只关注“成功生成文本”。在真实推理服务里,失败路径也会占资源,尤其是 KV cache 和远端 prefill 这种跨组件状态。

18. 为什么它要继承 GenerateBaseServing?

OpenAIServingChat 继承 GenerateBaseServing,是因为很多能力不是 chat 独有:

模型检查
LoRA adapter 解析
request id 生成
trace headers
data_parallel_rank
输入日志
错误响应
KV transfer cleanup
token decode fallback
beam search mixin

Chat Completion 只是 OpenAI API 的一种外壳。Completion、Responses、Pooling 等入口也会需要类似的底层服务能力。

把共性放进 base serving,可以避免每个 API endpoint 重复处理 engine 生命周期和服务级边界。

19. 一句话重新理解这个类

OpenAIServingChat 可以这样记:

它不是“调用模型生成文本”的类。
它是“把 OpenAI Chat Completion 协议准确映射到 vLLM Engine,再把 Engine 输出准确映射回 OpenAI 协议”的类。

它真正处理的是这些边界:

ChatCompletionRequest -> ChatParams / EngineInput
ChatCompletionRequest -> SamplingParams / BeamSearchParams
EngineClient.generate -> RequestOutput async stream
RequestOutput -> ChatCompletionStreamResponse
RequestOutput -> ChatCompletionResponse
model text -> reasoning / content / tool_calls
internal token ids/logprobs -> OpenAI logprobs
internal metrics/cache/mm counts -> usage and prompt_tokens_details
internal ndarray -> base64 JSON field
failure before engine -> KV transfer rejection cleanup

这就是它为什么必须这么写。不是因为业务代码复杂,而是因为它站在太多协议边界的交汇处。

20. 自检问题

读完这个类,可以用这些问题检查自己是否真的懂了:

  1. ChatCompletionRequestOpenAIServingChat 的职责差异是什么?
  2. 为什么 streaming 的第一个 chunk 通常只有 role 和空 content?
  3. 为什么 tool calling 需要 parser,而不是直接返回模型文本?
  4. 为什么 n > 1 时 streaming 要给每个 choice 一个 parser?
  5. 为什么 finish_reason 不能总是照搬 engine 输出?
  6. 为什么 full response 可以只保留最后一个 RequestOutput
  7. prompt_tokens_details 里的 cached tokens 和 multimodal tokens 分别来自哪里?
  8. 为什么失败请求也可能需要 KV transfer cleanup?

如果这些问题能答出来,就能看懂 vLLM OpenAI server 的一个核心事实:

LLM serving 的难点不只是“怎么让模型生成 token”,还包括“怎么把 token、状态、错误、工具调用、usage 和观测数据稳定地翻译成外部协议”。