七~十二、输入/输出处理与支撑模块(深度逐行解析)
本文档对
vllm/v1/engine/子目录中的输入处理、输出处理、反分词器、Logprobs计算、并行采样及类型定义等六大模块进行逐行级深度剖析。
七、InputProcessor 深度解析(逐行代码走读)
7.1 模块概览与设计定位
input_processor.py(444行)是 vLLM V1 引擎前端的请求入口守门人 ------所有从 API 层进入引擎的请求,必须经过 InputProcessor.process_inputs() 的完整验证、预处理、分词与多模态处理,最终被转化为标准化的 EngineCoreRequest,才能被送入 EngineCore 调度执行。
其核心职责:
- 参数验证:SamplingParams / PoolingParams 合法性校验
- LoRA 验证:LoRA 请求与配置一致性
- 多模态处理 :将多模态输入转化为
MultiModalFeatureSpec列表 - 分词与输入预处理:原始提示 → 分词 → token ID 序列
- 请求ID随机化:确保内部请求ID全局唯一性
- 提示长度校验:防止超长提示进入引擎
7.2 导入依赖分析
python
import time
from collections.abc import Mapping
from typing import Any, Literal
import vllm.envs as envs
from vllm.config import VllmConfig
from vllm.inputs import (
EngineInput,
PromptType,
SingletonInput,
split_enc_dec_input,
)
from vllm.inputs.preprocess import InputPreprocessor
from vllm.logger import init_logger
from vllm.lora.request import LoRARequest
from vllm.multimodal import MULTIMODAL_REGISTRY, MultiModalRegistry
from vllm.multimodal.encoder_budget import MultiModalBudget
from vllm.multimodal.inputs import MultiModalFeatureSpec
from vllm.multimodal.utils import argsort_mm_positions
from vllm.platforms import current_platform
from vllm.pooling_params import PoolingParams
from vllm.renderers import BaseRenderer, renderer_from_config
from vllm.sampling_params import SamplingParams
from vllm.tasks import GENERATION_TASKS, POOLING_TASKS, SupportedTask
from vllm.tokenizers import TokenizerLike
from vllm.utils import length_from_prompt_token_ids_or_embeds, random_uuid
from vllm.utils.jsontree import json_iter_leaves
from vllm.v1.engine import EngineCoreRequest
关键依赖解读:
InputPreprocessor:负责原始提示的预处理(分词+多模态编码),是 InputProcessor 的内部组件split_enc_dec_input:将编码器-解码器架构的输入拆分为 encoder/decoder 两部分MultiModalBudget:计算多模态编码器缓存大小,决定是否跳过提示长度检查argsort_mm_positions:按多模态占位符在输入序列中的位置排序,保证多模态特征按正确顺序排列BaseRenderer / renderer_from_config:渲染器,用于 chat 模板渲染和分词(v0.18+ 的新架构)json_iter_leaves:遍历 JSON 树叶子节点,用于验证mm_hashes结构current_platform.validate_request:平台级请求校验(如特定硬件限制)
7.3 InputProcessor.init() 逐行解析
python
class InputProcessor:
def __init__(
self,
vllm_config: VllmConfig,
renderer: BaseRenderer | None = None,
*,
mm_registry: MultiModalRegistry = MULTIMODAL_REGISTRY,
) -> None:
参数:
vllm_config:全局配置对象,包含 model/cache/lora/scheduler/speculative/structured_outputs/observability 等子配置renderer:可选的渲染器,如未提供则从配置自动创建------这是 v0.18 版本引入的新架构,将分词逻辑从 InputProcessor 中解耦mm_registry:多模态注册表,默认使用全局单例MULTIMODAL_REGISTRY
python
self.vllm_config = vllm_config
self.model_config = model_config = vllm_config.model_config
self.cache_config = vllm_config.cache_config
self.lora_config = vllm_config.lora_config
self.scheduler_config = vllm_config.scheduler_config
self.speculative_config = vllm_config.speculative_config
self.structured_outputs_config = vllm_config.structured_outputs_config
self.observability_config = vllm_config.observability_config
将各子配置提取为实例属性,方便后续方法直接访问。设计意图 :避免每次调用时都穿透 vllm_config.xxx_config,减少属性查找开销。
python
self.generation_config_fields = model_config.try_get_generation_config()
从模型配置中提取 generation_config.json 的字段。这些字段用于后续将生成配置(如 bos/eos token、默认温度等)注入 SamplingParams。try_get_ 前缀暗示可能返回 None(部分模型无此配置)。
python
self.renderer = renderer or renderer_from_config(vllm_config)
渲染器初始化:如果外部未提供,则通过 renderer_from_config() 从配置自动创建。渲染器负责 chat 模板渲染和分词------这是 v0.18 架构演进的关键组件。
python
self.supports_mm_inputs = mm_registry.supports_multimodal_inputs(model_config)
self.mm_encoder_cache_size = 0
self.skip_prompt_length_check = False
if self.supports_mm_inputs:
mm_budget = MultiModalBudget(vllm_config, mm_registry)
self.mm_encoder_cache_size = mm_budget.encoder_cache_size
self.skip_prompt_length_check = (
mm_budget.processor.info.skip_prompt_length_check
)
mm_budget.reset_cache() # Not used anymore
多模态初始化逻辑:
supports_mm_inputs:查询注册表判断当前模型是否支持多模态输入- 如果支持,创建
MultiModalBudget计算:encoder_cache_size:编码器缓存大小,用于后续验证单个多模态项的嵌入token数是否超出缓存skip_prompt_length_check:某些多模态处理器需要先处理多模态内容才能确定最终token数(如图片可能产生不同数量的token),因此允许跳过严格的提示长度预检查
mm_budget.reset_cache():预算计算完成后清空缓存------预算对象仅用于获取这两个参数,不保留状态
设计洞察 :skip_prompt_length_check 体现了多模态场景下的一个重要权衡------严格预检查可能导致合法请求被拒绝(因为多模态token数在处理前不确定),但跳过检查又可能导致处理时才发现超长。架构选择了信任多模态处理器的策略。
7.4 tokenizer 属性
python
@property
def tokenizer(self) -> TokenizerLike | None:
return self.renderer.tokenizer
def get_tokenizer(self) -> TokenizerLike:
return self.renderer.get_tokenizer()
两个 tokenzier 访问器:
tokenizer属性:可能返回 None(渲染器无 tokenizer 时)get_tokenizer()方法:保证返回非 None 的 tokenizer(内部断言)
这反映了 vLLM 对"分词可选"场景的支持------某些模型/配置不需要分词(如直接使用 embeds 输入)。
7.5 _validate_params() 逐行解析
python
def _validate_params(
self,
params: SamplingParams | PoolingParams,
supported_tasks: tuple[SupportedTask, ...],
) -> None:
"""Raise `ValueError` if SamplingParams or PoolingParams is not valid."""
参数:
params:用户提供的采样/池化参数supported_tasks:模型支持的任务类型元组
设计意图:将验证逻辑集中在此,而非分散在 API 层,是因为 InputProcessor 是引擎前端的唯一入口------任何绕过 API 层的调用也需要经过此验证。
python
if isinstance(params, SamplingParams):
supported_generation_tasks = [
task for task in supported_tasks if task in GENERATION_TASKS
]
if not supported_generation_tasks:
raise ValueError("This model does not support generation")
过滤出模型支持的生成类任务。如果模型不支持任何生成任务但用户传了 SamplingParams,则报错。例如:嵌入模型不支持生成。
python
params.verify(
self.model_config,
self.speculative_config,
self.structured_outputs_config,
self.tokenizer,
)
调用 SamplingParams 自身的 verify() 方法进行深度校验------包括 max_tokens 范围、temperature 合法性、top_p/top_k 约束、guided decoding(JSON schema)兼容性等。将验证委托给参数对象本身,遵循了"信息专家"原则------参数对象最了解自己的约束。
python
if params.thinking_token_budget is not None and (
self.vllm_config.reasoning_config is None
or not self.vllm_config.reasoning_config.enabled
):
raise ValueError(
"thinking_token_budget is set but reasoning_config is "
"not configured. Please set --reasoning-config to use "
"thinking_token_budget."
)
思维链(Chain-of-Thought)token 预算验证:如果用户设置了 thinking_token_budget 但未启用推理配置,则拒绝。这确保了思维链功能仅在显式配置时才可用,避免用户误设参数导致非预期行为。
python
elif isinstance(params, PoolingParams):
supported_pooling_tasks = [
task for task in supported_tasks if task in POOLING_TASKS
]
if not supported_pooling_tasks:
raise ValueError("This model does not support pooling")
池化任务的类似验证逻辑。
python
if params.task is None:
if "token_embed" in supported_pooling_tasks:
params.task = "token_embed"
elif "token_classify" in supported_pooling_tasks:
params.task = "token_classify"
elif "plugin" in supported_pooling_tasks:
params.task = "plugin"
自动推断池化任务类型:当用户未指定任务类型时,按优先级自动选择------token_embed > token_classify > plugin。这个优先级反映了最常见用例的统计分布。
python
if params.task not in supported_pooling_tasks:
raise ValueError(
f"Unsupported task: {params.task!r} "
f"Supported tasks: {supported_pooling_tasks}"
)
params.verify(self.model_config)
验证推断(或显式指定)的任务类型是否被模型支持,然后委托 PoolingParams 自身验证。
python
else:
raise TypeError(
f"params must be either SamplingParams or PoolingParams, "
f"but got {type(params).__name__}"
)
类型守卫------防止传入了未预期的参数类型。
7.6 _validate_lora() 逐行解析
python
def _validate_lora(self, lora_request: LoRARequest | None) -> None:
if lora_request is None:
return
无 LoRA 请求,直接返回。
python
if not self.lora_config:
raise ValueError(
f"Got lora_request {lora_request} but LoRA is not enabled!"
)
用户传了 LoRA 请求但引擎未启用 LoRA 配置------这是配置不一致错误。
python
if self.tokenizer is not None:
logger.warning_once(
"vLLM has deprecated support for supporting different "
"tokenizers for different LoRAs. By default, vLLM uses base "
"model's tokenizer. If you are using a LoRA "
"with its own tokenizer, consider specifying `--tokenizer "
"[lora_path]` to use the LoRA tokenizer."
)
弃用警告 :vLLM 曾经支持为不同 LoRA 使用不同 tokenizer,但这带来了极大的工程复杂度(缓存一致性、分词不一致等),现已弃用。此警告仅在首次遇到时打印(warning_once)。
7.7 _get_mm_identifier() 逐行解析
python
def _get_mm_identifier(
self,
mm_hash: str,
lora_request: LoRARequest | None,
) -> str:
设计背景 :多模态编码器的输出缓存使用 mm_hash 作为缓存键。但在启用 enable_tower_connector_lora 时,同一个多模态输入在不同 LoRA 下会产生不同的嵌入表示(因为 LoRA 可能修改编码器层)。因此,缓存键必须包含 LoRA 信息以避免错误的缓存命中。
python
if (
lora_request is None
or self.lora_config is None
or not self.lora_config.enable_tower_connector_lora
):
return mm_hash
return f"{lora_request.lora_name}:{mm_hash}"
分支逻辑:
- 无 LoRA 请求 / 未启用 LoRA / 未启用 tower_connector_lora → 使用原始 hash
- 启用了 tower_connector_lora → 在 hash 前拼接
lora_name:前缀
格式选择:冒号分隔符简单直观,且 LoRA name 不太可能包含冒号。如果未来可能出现冒号,可考虑使用更复杂的分隔符或编码。
7.8 assign_request_id() 静态方法逐行解析
python
@staticmethod
def assign_request_id(request: EngineCoreRequest):
"""Replace the externally supplied request ID with an internal request ID
that adds 8 random characters in order to ensure uniqueness.
"""
设计动机:外部提供的 request_id(如 OpenAI API 兼容层中的用户定义 ID)不能保证全局唯一。多个客户端可能使用相同的 request_id,导致引擎内部状态冲突。因此,vLLM 在内部使用"外部ID + 随机后缀"作为唯一标识。
python
if request.external_req_id is not None:
raise ValueError(
"The external_req_id field should not be set on EngineCoreRequests"
" passed to vLLM; use the request_id field."
)
防御性断言 :external_req_id 是此方法的输出字段,如果输入时已设置,说明调用方误用了接口。
python
request.external_req_id = request.request_id
保存原始 request_id 到 external_req_id------这是在输出时返回给用户用的标识符。
python
if envs.VLLM_DISABLE_REQUEST_ID_RANDOMIZATION:
logger.warning_once(
"VLLM_DISABLE_REQUEST_ID_RANDOMIZATION is set and will be "
"removed in a future release. Duplicate externally-provided "
"request IDs may cause failures and/or subtle correctness errors."
)
兼容性逃生舱:允许通过环境变量禁用随机化,但发出强烈警告------重复 ID 可能导致请求状态混乱。这是为了支持某些遗留系统的需求。
python
else:
request.request_id = f"{request.external_req_id}-{random_uuid():.8}"
ID 生成策略 :{原始ID}-{8位随机十六进制}。8个十六进制字符 = 32位随机性 = 约43亿种可能。对于单引擎实例而言,碰撞概率极低。.8 格式化截断 UUID 到前8位。
设计权衡:为什么不用完整 UUID?因为 request_id 会在引擎内部大量传递和比较(每次调度迭代),过长的 ID 会增加内存和比较开销。8位后缀在单实例场景下足够安全。
7.9 process_inputs() 逐行解析------核心入口
这是 InputProcessor 最核心的方法,约70行代码,将原始请求完整转化为 EngineCoreRequest。
python
def process_inputs(
self,
request_id: str,
prompt: PromptType | EngineInput,
params: SamplingParams | PoolingParams,
supported_tasks: tuple[SupportedTask, ...],
arrival_time: float | None = None,
lora_request: LoRARequest | None = None,
tokenization_kwargs: dict[str, Any] | None = None,
trace_headers: Mapping[str, str] | None = None,
priority: int = 0,
data_parallel_rank: int | None = None,
resumable: bool = False,
) -> EngineCoreRequest:
参数详解:
| 参数 | 类型 | 说明 |
|---|---|---|
request_id |
str | 外部请求ID(由调用方提供) |
prompt |
PromptType | EngineInput | 提示内容------可以是原始文本、token ID列表、或已处理的EngineInput |
params |
SamplingParams | PoolingParams | 采样/池化参数 |
supported_tasks |
tuple[SupportedTask, ...] | 模型支持的任务类型 |
arrival_time |
float | None | 请求到达时间戳(None则使用当前时间) |
lora_request |
LoRARequest | None | LoRA 适配器请求 |
tokenization_kwargs |
dict | None | 分词额外参数(已弃用) |
trace_headers |
Mapping | None | 分布式追踪头(OpenTelemetry) |
priority |
int | 请求优先级(0=默认) |
data_parallel_rank |
int | None | 数据并行目标 rank |
resumable |
bool | 是否可恢复(流式输入模式) |
python
self._validate_params(params, supported_tasks)
self._validate_lora(lora_request)
先进行参数和 LoRA 验证------快速失败策略,尽早拒绝不合法请求,避免后续无效计算。
python
parallel_config = self.vllm_config.parallel_config
dp_size = parallel_config.data_parallel_size
dp_local_size = parallel_config.data_parallel_size_local
num_ranks = dp_local_size if parallel_config.local_engines_only else dp_size
if data_parallel_rank is not None and not (0 <= data_parallel_rank < num_ranks):
raise ValueError(
f"data_parallel_rank {data_parallel_rank} "
f"is out of range [0, {num_ranks})."
)
数据并行 Rank 验证:
dp_size:全局数据并行大小dp_local_size:本节点数据并行大小local_engines_only:如果为 True,只在本节点内路由请求,因此 rank 范围是dp_local_size- 否则,rank 范围是全局
dp_size
这体现了 vLLM 的多级数据并行架构------同一请求可以被路由到不同节点的不同 rank。
输入处理的双路径架构
python
if isinstance(prompt, dict) and "type" in prompt:
if tokenization_kwargs:
logger.warning_once(
"Passing tokenization_kwargs to InputProcessor is deprecated "
"and will be removed in v0.18. You should instead pass "
"them to Renderer.render_cmpl() or Renderer.render_chat()."
)
if arrival_time is None:
arrival_time = prompt.get("arrival_time", time.time())
processed_inputs: EngineInput = prompt
路径1:已处理的 EngineInput ------当 prompt 是带有 "type" 键的字典时,说明它已经过 Renderer 处理(这是 v0.18 推荐的新路径)。此时直接使用,跳过分词步骤。
tokenization_kwargs 的弃用警告:在新架构中,分词参数应在 Renderer 层传入,而非在 InputProcessor 层。
python
else:
logger.warning_once(
"Passing raw prompts to InputProcessor is deprecated "
"and will be removed in v0.18. You should instead pass "
"the outputs of Renderer.render_cmpl() or Renderer.render_chat()."
)
if arrival_time is None:
arrival_time = time.time()
processed_inputs = self.input_preprocessor.preprocess(
prompt,
tokenization_kwargs=tokenization_kwargs,
)
路径2:原始提示 ------传统路径,调用 InputPreprocessor.preprocess() 进行分词和多模态处理。此路径已弃用,将在 v0.18 移除。
架构演进洞察:v0.18 将分词职责从 InputProcessor 分离到 Renderer,是为了:
- 让 InputProcessor 专注于验证和组装
- Renderer 可以在 API 层就完成分词,减少引擎前端的开销
- 支持 chat 模板渲染的更灵活控制
python
current_platform.validate_request(processed_inputs, params)
平台级验证------某些硬件平台可能对请求有特殊约束(如 TPU 的 batch size 限制、特定模型的输入格式要求等)。
python
encoder_inputs, decoder_inputs = split_enc_dec_input(processed_inputs)
self._validate_model_inputs(encoder_inputs, decoder_inputs)
将输入拆分为编码器/解码器部分并验证。对于纯解码器模型(如 LLaMA),encoder_inputs 为 None。
python
if decoder_inputs["type"] == "embeds":
prompt_token_ids = None
prompt_embeds = decoder_inputs["prompt_embeds"]
else:
prompt_token_ids = decoder_inputs["prompt_token_ids"]
prompt_embeds = None
互斥分支:token IDs 和 embeds 不能同时存在。embeds 模式用于直接提供嵌入向量的场景(如从外部编码器获取的嵌入)。
SamplingParams 后处理
python
sampling_params = None
pooling_params = None
if isinstance(params, SamplingParams):
sampling_params = params.clone()
必须克隆:在多进程场景下(如 MPClient),原始 params 可能被多个子进程共享。克隆确保每个请求有独立的参数副本。
python
if sampling_params.max_tokens is None:
seq_len = length_from_prompt_token_ids_or_embeds(
prompt_token_ids, prompt_embeds
)
sampling_params.max_tokens = self.model_config.max_model_len - seq_len
自动计算 max_tokens :如果用户未指定,则默认填满到模型最大长度。length_from_prompt_token_ids_or_embeds 统一处理 token IDs 和 embeds 两种输入的长度计算。
python
sampling_params.update_from_generation_config(
self.generation_config_fields,
self.renderer.get_eos_token_id(),
)
if self.tokenizer is not None:
sampling_params.update_from_tokenizer(self.tokenizer)
将模型的 generation_config 和 tokenizer 信息注入 SamplingParams------例如设置默认的 eos_token_id、repetition_penalty 等。这确保了模型级默认参数不会被忽略。
python
else:
pooling_params = params.clone()
池化参数直接克隆,无需额外的后处理。
多模态特征组装
python
mm_features: list[MultiModalFeatureSpec] | None = None
if decoder_inputs["type"] == "multimodal":
decoder_mm_inputs = decoder_inputs["mm_kwargs"]
decoder_mm_positions = decoder_inputs["mm_placeholders"]
decoder_mm_hashes = decoder_inputs["mm_hashes"]
仅当输入类型为 "multimodal" 时才处理多模态特征。三个关键字段:
mm_kwargs:多模态编码器的输入数据(如图像像素张量)mm_placeholders:多模态占位符在token序列中的位置信息mm_hashes:多模态内容的哈希值,用于编码器缓存命中
python
if not all(
isinstance(leaf, str) for leaf in json_iter_leaves(decoder_mm_hashes)
):
raise ValueError(
f"mm_hashes must contain only strings, got: {decoder_mm_hashes}. "
"This is likely due to an incorrect custom implementation of "
"MultiModalProcessor.apply method."
)
哈希类型验证 :使用 json_iter_leaves 遍历嵌套的 mm_hashes 结构(可能是 {"image": ["hash1", "hash2"], "video": ["hash3"]} 形式),确保所有叶子节点都是字符串。这是防御性检查,防止自定义多模态处理器返回非法类型。
python
sorted_mm_idxs = argsort_mm_positions(decoder_mm_positions)
位置排序:多模态占位符必须按其在token序列中的位置排序。例如,如果文本是 "看这张图然后看这张",则 image1 必须在 image2 之前处理。
python
mm_features = []
for modality, idx in sorted_mm_idxs:
base_mm_hash = decoder_mm_hashes[modality][idx]
mm_features.append(
MultiModalFeatureSpec(
data=decoder_mm_inputs[modality][idx],
modality=modality,
identifier=self._get_mm_identifier(
base_mm_hash,
lora_request,
),
mm_position=decoder_mm_positions[modality][idx],
mm_hash=base_mm_hash,
)
)
MultiModalFeatureSpec 组装:
| 字段 | 来源 | 说明 |
|---|---|---|
data |
mm_kwargs[modality][idx] | 多模态编码器的实际输入数据 |
modality |
排序结果 | 模态类型("image"/"video"/"audio"等) |
identifier |
_get_mm_identifier() | 缓存键(可能包含 LoRA 前缀) |
mm_position |
mm_placeholders[modality][idx] | 占位符位置信息 |
mm_hash |
mm_hashes[modality][idx] | 原始内容哈希 |
identifier vs mm_hash 的区别:
mm_hash:纯内容哈希,用于跨请求的编码器缓存命中identifier:可能包含 LoRA 信息的复合键,用于编码器缓存的精确查找
EngineCoreRequest 构建
python
return EngineCoreRequest(
request_id=request_id,
prompt_token_ids=prompt_token_ids,
prompt_embeds=prompt_embeds,
mm_features=mm_features,
sampling_params=sampling_params,
pooling_params=pooling_params,
arrival_time=arrival_time,
lora_request=lora_request,
cache_salt=decoder_inputs.get("cache_salt"),
priority=priority,
data_parallel_rank=data_parallel_rank,
trace_headers=trace_headers,
resumable=resumable,
)
最终将所有处理后的数据打包为 EngineCoreRequest。注意 cache_salt------这是一个 KV 缓存盐值,用于缓存隔离(不同前缀/配置的缓存不会冲突)。
7.10 _validate_prompt_len() 逐行解析
python
def _validate_prompt_len(
self,
prompt_len: int,
prompt_type: Literal["encoder", "decoder"],
):
if self.skip_prompt_length_check:
return
多模态场景下可能跳过长度检查(如前所述)。
python
if prompt_len == 0 and prompt_type == "decoder":
raise ValueError(f"The {prompt_type} prompt cannot be empty")
解码器提示不能为空(但编码器提示可以为空------某些编码器-解码器模型允许空的编码器输入)。
python
model_config = self.model_config
max_prompt_len = (
model_config.max_model_len
if prompt_type == "decoder"
else self.mm_encoder_cache_size
)
不同的长度上限:
- 解码器:使用
max_model_len(模型支持的最大序列长度) - 编码器:使用
mm_encoder_cache_size(预分配的编码器缓存大小)
python
if prompt_len > max_prompt_len:
if self.supports_mm_inputs:
suggestion = (
"Make sure that `max_model_len` is no smaller than the "
"number of text tokens plus multimodal tokens. For image "
"inputs, the number of image tokens depends on the number "
"of images, and possibly their aspect ratios as well."
)
else:
suggestion = (
"Make sure that `max_model_len` is no smaller than the "
"number of text tokens."
)
raise ValueError(
f"The {prompt_type} prompt (length {prompt_len}) is "
f"longer than the maximum model length of {max_prompt_len}. "
f"{suggestion}"
)
超长提示错误------附带场景化的修复建议。
python
elif prompt_len == max_prompt_len and model_config.runner_type == "generate":
suggestion = (
"Make sure that `max_model_len` is no smaller than the "
"number of text tokens (prompt + requested output tokens)."
)
raise ValueError(
f"The {prompt_type} prompt (length {prompt_len}) plus the number of "
f"requested output tokens (at least 1) is longer than the maximum "
f"model length of {max_prompt_len}. {suggestion}"
)
精确等于最大长度的特殊处理:对于生成任务,即使提示恰好等于 max_model_len,也无法生成任何 token(至少需要1个生成token的位置)。因此也报错。但池化任务不需要生成,所以仅对 generate runner_type 检查。
7.11 _validate_model_input() 逐行解析
python
def _validate_model_input(
self,
prompt_input: SingletonInput,
prompt_type: Literal["encoder", "decoder"],
) -> None:
验证单个模型输入(编码器或解码器)。
python
prompt_ids = (
None
if prompt_input["type"] == "embeds"
else prompt_input["prompt_token_ids"]
)
prompt_embeds = (
prompt_input["prompt_embeds"] if prompt_input["type"] == "embeds" else None
)
prompt_len = length_from_prompt_token_ids_or_embeds(prompt_ids, prompt_embeds)
self._validate_prompt_len(prompt_len, prompt_type)
统一处理 token IDs 和 embeds 两种输入模式,获取长度后验证。
python
if prompt_input["type"] == "multimodal":
decoder_mm_positions = prompt_input["mm_placeholders"]
for modality, mm_positions in decoder_mm_positions.items():
for mm_position in mm_positions:
num_embeds = mm_position.get_num_embeds()
if num_embeds > self.mm_encoder_cache_size:
raise ValueError(
f"The {prompt_type} prompt contains a(n) {modality} item "
f"with {num_embeds} embedding tokens, which exceeds the "
f"pre-allocated encoder cache size "
f"{self.mm_encoder_cache_size}. Please reduce the input "
f"size or increase the encoder cache size "
f"by setting --limit-mm-per-prompt at startup."
)
多模态嵌入数验证 :每个多模态项(如一张高分辨率图片可能产生数千个token)的嵌入数不能超过编码器缓存大小。否则运行时会 OOM。提供修复建议:减小输入尺寸或增大 --limit-mm-per-prompt。
python
if prompt_ids and tokenizer is not None:
max_input_id = max(prompt_ids, default=0)
model_vocab_size = model_config.get_vocab_size()
if max_input_id > max(tokenizer.max_token_id, model_vocab_size - 1):
raise ValueError(f"Token id {max_input_id} is out of vocabulary")
Token ID 越界检查:
- 取 prompt 中最大的 token ID
- 使用
max(tokenizer.max_token_id, model_vocab_size - 1)作为阈值 - 取两者最大值的原因在注释中详细说明:
- Qwen3 模型:语言模型有 tokenizer 中不存在的额外 token
- 多模态模型:tokenizer 中有模型嵌入层不存在的占位符 token
- 因此仅用单一阈值会产生假阳性
设计洞察 :这个 max() 操作是一种保守策略------只要 token ID 在任一词汇表范围内,就认为合法。这避免了因模型/tokenizer 词汇表不一致导致的误拒。
7.12 _validate_model_inputs() 逐行解析
python
def _validate_model_inputs(
self,
encoder_input: SingletonInput | None,
decoder_input: SingletonInput,
):
if encoder_input is not None:
self._validate_model_input(encoder_input, prompt_type="encoder")
self._validate_model_input(decoder_input, prompt_type="decoder")
简单的委托方法------分别验证编码器和解码器输入。编码器输入可选(纯解码器模型无编码器)。
7.13 InputProcessor 整体架构总结
┌──────────────────────────────────────────────┐
│ API Layer (OpenAI/...) │
└──────────────┬───────────────────────────────┘
│ raw request
▼
┌──────────────────────────────────────────────┐
│ InputProcessor │
│ │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ _validate_params│ │ _validate_lora │ │
│ └─────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ process_inputs() │ │
│ │ ├─ 双路径: EngineInput / raw prompt │ │
│ │ ├─ split_enc_dec_input │ │
│ │ ├─ _validate_model_inputs │ │
│ │ ├─ SamplingParams 后处理 │ │
│ │ └─ 多模态特征组装 │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ assign_request_id() │ │
│ │ └─ 外部ID + 随机后缀 = 内部唯一ID │ │
│ └──────────────────────────────────────┘ │
└──────────────┬───────────────────────────────┘
│ EngineCoreRequest
▼
┌──────────────────────────────────────────────┐
│ EngineCore │
└──────────────────────────────────────────────┘
八、OutputProcessor 深度解析(逐行代码走读)
8.1 模块概览与设计定位
output_processor.py(812行)是 vLLM V1 引擎前端的出口枢纽 ------将 EngineCore 输出的原始 token ID 序列、logprob 张量、pooling 结果等,经过反分词、logprob 计算、流式/非流式输出组装,最终转化为用户可见的 RequestOutput / PoolingRequestOutput。
核心职责:
- 请求状态管理:追踪每个活跃请求的解码/日志概率/统计状态
- 流式输出控制:stream interval、DELTA/FINAL_ONLY 模式
- 并行采样聚合:best-of-n 场景下的子请求输出合并
- 请求生命周期管理:添加、完成、中止、错误传播
- 统计与追踪:指标收集和 OpenTelemetry 追踪
8.2 导入依赖分析
python
import asyncio
from collections import defaultdict, deque
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any, cast
import numpy as np
import torch
asyncio:用于异步输出收集器(RequestOutputCollector)defaultdict, deque:请求ID映射和流式输入队列numpy, torch:处理路由专家和嵌入输出
python
from vllm.outputs import (
STREAM_FINISHED,
CompletionOutput,
PoolingOutput,
PoolingRequestOutput,
RequestOutput,
)
from vllm.sampling_params import RequestOutputKind
RequestOutputKind:输出模式枚举(DELTA / FINAL_ONLY / CUMULATIVE)STREAM_FINISHED:流式完成信号常量
python
from vllm.v1.engine import EngineCoreOutput, EngineCoreRequest, FinishReason
from vllm.v1.engine.detokenizer import IncrementalDetokenizer
from vllm.v1.engine.logprobs import LogprobsProcessor
from vllm.v1.engine.parallel_sampling import ParentRequest
三大支撑组件的导入------反分词器、日志概率处理器、并行采样管理器。
8.3 RequestOutputCollector 逐行解析
python
EMPTY_CPU_TENSOR = torch.empty(0, device="cpu")
共享的空 CPU 张量,用作中止请求时 pooling 输出的占位符------避免为每个中止请求分配新张量。
python
class RequestOutputCollector:
"""
Collects streamed RequestOutputs per individual request,
for hand-off to the consuming asyncio generate task.
When streaming deltas, RequestOutputs are merged if the
producer gets ahead of the consumer.
"""
设计背景:在 AsyncLLM 中,引擎前端的输出处理线程(生产者)和用户的 asyncio 生成器(消费者)运行在不同的执行上下文中。RequestOutputCollector 是它们之间的有界缓冲区。
python
def __init__(self, output_kind: RequestOutputKind, request_id: str):
self.aggregate = output_kind == RequestOutputKind.DELTA
self.request_id = request_id
self.output: RequestOutput | PoolingRequestOutput | Exception | None = None
self.ready = asyncio.Event()
self._input_stream_task: asyncio.Task | None = None
关键字段:
aggregate:DELTA 模式下是否合并连续输出------如果消费者跟不上,多个增量输出可以合并为一个output:单槽缓冲(不是队列!)------一次只持有一个输出,消费者取走后才接收下一个ready:asyncio 事件,实现生产者-消费者同步_input_stream_task:输入流任务引用,用于取消
为什么是单槽而非队列? 因为 DELTA 模式下,如果消费者落后,多个增量输出可以被合并为一个------不需要保留历史。这简化了实现并减少了内存使用。
python
def put(self, output: RequestOutput | PoolingRequestOutput | Exception) -> None:
"""Non-blocking put operation."""
if self.output is None or isinstance(output, Exception):
self.output = output
self.ready.set()
空槽或异常直接放入。
python
elif isinstance(self.output, RequestOutput) and isinstance(
output, RequestOutput
):
self.output.add(output, aggregate=self.aggregate)
合并逻辑 :两个 RequestOutput 通过 add() 方法合并。aggregate=True 时执行增量合并(delta 累加),否则后者覆盖前者。
python
elif isinstance(self.output, PoolingRequestOutput) and isinstance(
output, PoolingRequestOutput
):
self.output = output
Pooling 输出总是后者覆盖前者(池化结果只有最终值有意义)。
python
async def get(self) -> RequestOutput | PoolingRequestOutput:
"""Get operation blocks on put event."""
while (output := self.output) is None:
await self.ready.wait()
self.output = None
self.ready.clear()
if isinstance(output, Exception):
raise output
return output
异步获取:等待 ready 事件,取出输出,清空缓冲区,重置事件。异常直接抛出------让 generate() 协程处理错误。
python
def get_nowait(self) -> RequestOutput | PoolingRequestOutput | None:
"""Non-blocking get operation."""
output = self.output
if output is not None:
self.output = None
self.ready.clear()
if isinstance(output, Exception):
raise output
return output
非阻塞版本------用于同步 API(LLMEngine)场景。
python
def close(self):
if self._input_stream_task is not None:
self._input_stream_task.cancel()
self._input_stream_task = None
def __del__(self):
if (task := self._input_stream_task) is not None:
task.get_loop().call_soon_threadsafe(task.cancel)
self._input_stream_task = None
资源清理 :关闭时取消关联的输入流任务。__del__ 使用 call_soon_threadsafe 因为析构可能发生在非事件循环线程。
8.4 OutputProcessorOutput 数据类
python
@dataclass
class OutputProcessorOutput:
request_outputs: list[RequestOutput | PoolingRequestOutput]
reqs_to_abort: list[str]
process_outputs() 的返回值:
request_outputs:本次迭代产生的输出列表(LLMEngine 同步模式使用)reqs_to_abort:需要中止的请求ID列表(detokenizer 检测到 stop string 但 EngineCore 尚未标记完成的请求)
8.5 StreamingUpdate 数据类
python
@dataclass
class StreamingUpdate:
"""Streaming input update data for output processor."""
prompt: str | None
prompt_token_ids: list[int] | None
arrival_time: float
final: bool = False
流式输入更新的数据容器------用于支持请求的增量提示追加。final=True 标记这是最后一个更新。
8.6 RequestState 逐行解析
RequestState 是输出处理器的核心数据结构,追踪每个活跃请求的完整状态。
python
class RequestState:
def __init__(
self,
request_id: str,
external_req_id: str,
parent_req: ParentRequest | None,
request_index: int,
lora_request: LoRARequest | None,
output_kind: RequestOutputKind,
prompt: str | None,
prompt_token_ids: list[int] | None,
prompt_embeds: torch.Tensor | None,
logprobs_processor: LogprobsProcessor | None,
detokenizer: IncrementalDetokenizer | None,
max_tokens_param: int | None,
arrival_time: float,
queue: RequestOutputCollector | None,
log_stats: bool,
stream_interval: int,
top_p: float | None = None,
n: int | None = None,
temperature: float | None = None,
stream_input: bool = False,
):
参数分组:
- 身份信息:request_id, external_req_id, parent_req, request_index
- 模型相关:lora_request, prompt, prompt_token_ids, prompt_embeds
- 输出处理组件:logprobs_processor, detokenizer
- 参数快照:max_tokens_param, top_p, n, temperature(用于追踪指标)
- 输出控制:output_kind, queue, stream_interval
- 统计:log_stats, arrival_time
- 流式输入:stream_input
python
self.request_id = request_id
self.external_req_id = external_req_id
self.parent_req = parent_req
self.request_index = request_index
self.lora_request = lora_request
self.lora_name = lora_request.lora_name if lora_request is not None else None
self.output_kind = output_kind
self.prompt = prompt
self.prompt_token_ids = prompt_token_ids
self.prompt_embeds = prompt_embeds
self.prompt_len = length_from_prompt_token_ids_or_embeds(
self.prompt_token_ids, self.prompt_embeds
)
self.logprobs_processor = logprobs_processor
self.detokenizer = detokenizer
self.max_tokens_param = max_tokens_param
self.top_p = top_p
self.n = n
self.temperature = temperature
self.is_prefilling = True
self.queue = queue
self.num_cached_tokens = 0
关键状态字段:
is_prefilling:当前是否处于 prefill 阶段------首次收到 EngineCoreOutput 后翻转为 Falsenum_cached_tokens:prefix caching 命中的 token 数
python
self.stats = RequestStateStats(arrival_time=arrival_time) if log_stats else None
self.stream_interval = stream_interval
self.sent_tokens_offset = 0
self.streaming_input = stream_input
self.input_chunk_queue: deque[StreamingUpdate] | None = (
deque() if stream_input else None
)
stats:请求级统计(仅在 log_stats=True 时创建,避免无谓开销)stream_interval:流式输出间隔------每 N 个 token 输出一次sent_tokens_offset:已发送的 token 偏移量(用于 DELTA 模式和 stream interval)input_chunk_queue:流式输入队列------当请求支持增量提示追加时使用
apply_streaming_update()
python
def apply_streaming_update(self, update: StreamingUpdate) -> None:
self.streaming_input = not update.final
如果更新是最终的,关闭流式输入模式。
python
if update.prompt:
self.prompt = (
(self.prompt + update.prompt) if self.prompt else update.prompt
)
if self.prompt_token_ids:
self.prompt_token_ids.extend(update.prompt_token_ids or ())
else:
self.prompt_token_ids = update.prompt_token_ids or []
assert self.prompt_token_ids is not None
self.prompt_len = len(self.prompt_token_ids)
增量更新提示内容------拼接文本和 token IDs。
python
if self.stats is not None:
self.stats.arrival_time = update.arrival_time
self.is_prefilling = True
更新到达时间并重新标记为 prefilling(因为追加了新的输入)。
from_new_request() 工厂方法
python
@classmethod
def from_new_request(
cls,
tokenizer: TokenizerLike | None,
request: EngineCoreRequest,
prompt: str | None,
parent_req: ParentRequest | None,
request_index: int,
queue: RequestOutputCollector | None,
log_stats: bool,
stream_interval: int,
) -> "RequestState":
从 EngineCoreRequest 创建 RequestState 的工厂方法。
python
if sampling_params := request.sampling_params:
if not sampling_params.detokenize:
tokenizer = None
output_kind = sampling_params.output_kind
logprobs_processor = LogprobsProcessor.from_new_request(
tokenizer=tokenizer,
request=request,
)
detokenizer = IncrementalDetokenizer.from_new_request(
tokenizer=tokenizer,
request=request,
)
条件组件创建:
- 如果
detokenize=False,将 tokenizer 设为 None,这样 detokenizer 和 logprobs_processor 都会使用空实现 - LogprobsProcessor 和 IncrementalDetokenizer 都有工厂方法根据请求参数自动选择合适的实现
python
max_tokens_param = sampling_params.max_tokens
top_p = sampling_params.top_p
n = sampling_params.n
temperature = sampling_params.temperature
else:
logprobs_processor = None
detokenizer = None
max_tokens_param = None
top_p = None
n = None
temperature = None
assert request.pooling_params is not None
output_kind = request.pooling_params.output_kind
池化请求不需要 detokenizer 和 logprobs_processor------直接输出嵌入向量。
python
assert request.external_req_id is not None
确保外部请求ID已被赋值(assign_request_id 必须在此之前调用)。
make_request_output()------输出组装核心
python
def make_request_output(
self,
new_token_ids: list[int],
pooling_output: torch.Tensor | None,
finish_reason: FinishReason | None,
stop_reason: int | str | None,
kv_transfer_params: dict[str, Any] | None = None,
routed_experts: np.ndarray | None = None,
) -> RequestOutput | PoolingRequestOutput | None:
返回 None 的情况:本次迭代不需要产生输出(如 stream interval 未达到、FINAL_ONLY 模式下请求未完成)。
python
finished = finish_reason is not None
final_only = self.output_kind == RequestOutputKind.FINAL_ONLY
if not finished and final_only:
return None
FINAL_ONLY 模式:只返回最终输出,中间迭代返回 None。
python
if self.stream_interval > 1:
assert self.detokenizer is not None
if not (
finished
or self.sent_tokens_offset == 0
or self.detokenizer.num_output_tokens() - self.sent_tokens_offset
>= self.stream_interval
):
return None
Stream Interval 过滤:仅在以下条件之一满足时产生输出:
- 请求已完成
- 是第一个 token(
sent_tokens_offset == 0) - 累积的 token 数达到 stream interval 阈值
这避免了逐 token 输出时的过高网络开销。
python
if self.output_kind == RequestOutputKind.DELTA:
new_token_ids = self.detokenizer.output_token_ids[
self.sent_tokens_offset :
]
self.sent_tokens_offset = self.detokenizer.num_output_tokens()
DELTA 模式下的 token 截取:只取自上次发送以来新增的 token,并更新偏移量。
python
external_req_id = self.external_req_id
if pooling_output is not None:
return self._new_request_output(
external_req_id,
[self._new_pooling_output(pooling_output)],
finished,
)
Pooling 输出直接组装。
python
output = self._new_completion_output(
new_token_ids, finish_reason, stop_reason, routed_experts
)
if self.parent_req is None:
outputs = [output]
else:
outputs, finished = self.parent_req.get_outputs(self.request_id, output)
if not outputs:
return None
external_req_id = self.parent_req.external_req_id
并行采样处理:
- 无 parent:直接输出单个 completion
- 有 parent:通过
ParentRequest.get_outputs()聚合子请求输出,可能返回 None(其他子请求尚未完成且模式为 FINAL_ONLY)
python
return self._new_request_output(
external_req_id, outputs, finished, kv_transfer_params
)
最终组装 RequestOutput。
_new_request_output()
python
def _new_request_output(
self,
external_req_id: str,
outputs: list[CompletionOutput] | list[PoolingOutput],
finished: bool,
kv_transfer_params: dict[str, Any] | None = None,
) -> RequestOutput | PoolingRequestOutput:
prompt_token_ids = self.prompt_token_ids
if prompt_token_ids is None and self.prompt_embeds is not None:
prompt_token_ids = [0] * len(self.prompt_embeds)
assert prompt_token_ids is not None
embeds 到 token IDs 的适配 :当使用 embeds 输入时,没有实际的 token IDs,但输出格式要求提供。使用 [0] * len 作为占位符------这些 token IDs 仅用于长度信息,不会被实际解码。
python
first_output = outputs[0]
if isinstance(first_output, PoolingOutput):
assert len(outputs) == 1
return PoolingRequestOutput(
request_id=external_req_id,
outputs=first_output,
num_cached_tokens=self.num_cached_tokens,
prompt_token_ids=prompt_token_ids,
finished=finished,
)
Pooling 输出路径------单个 PoolingOutput 封装为 PoolingRequestOutput。
python
assert self.logprobs_processor is not None
if self.output_kind == RequestOutputKind.DELTA:
prompt_logprobs = self.logprobs_processor.pop_prompt_logprobs()
else:
prompt_logprobs = self.logprobs_processor.prompt_logprobs
DELTA 模式的 prompt logprobs 特殊处理:DELTA 模式下,prompt logprobs 只在 prefill 结束时返回一次,之后就被"弹出"(pop),不再保留。这是为了遵循 OpenAI API 的 DELTA 语义------每个增量输出只包含新增信息。
python
return RequestOutput(
request_id=external_req_id,
lora_request=self.lora_request,
prompt=self.prompt,
prompt_token_ids=prompt_token_ids,
prompt_logprobs=prompt_logprobs,
outputs=cast(list[CompletionOutput], outputs),
finished=finished,
kv_transfer_params=kv_transfer_params,
num_cached_tokens=self.num_cached_tokens,
metrics=self.stats,
)
最终构建 RequestOutput------注意使用 external_req_id(而非内部 request_id)作为返回给用户的标识符。
_new_completion_output()
python
def _new_completion_output(
self,
token_ids: list[int],
finish_reason: FinishReason | None,
stop_reason: int | str | None,
routed_experts: np.ndarray | None = None,
) -> CompletionOutput:
assert self.detokenizer is not None
assert self.logprobs_processor is not None
finished = finish_reason is not None
delta = self.output_kind == RequestOutputKind.DELTA
text = self.detokenizer.get_next_output_text(finished, delta)
if not delta:
token_ids = self.detokenizer.output_token_ids
文本获取:
- DELTA 模式:获取自上次发送以来的增量文本
- 非DELTA 模式:获取全部输出文本,并使用 detokenizer 的完整 token ID 列表(而非增量 token IDs)
python
logprobs = self.logprobs_processor.logprobs
if delta and logprobs:
logprobs = logprobs[-len(token_ids) :]
DELTA 模式的 logprobs 截取:只保留与当前增量 token 对应的 logprobs 条目。
python
return CompletionOutput(
index=self.request_index,
text=text,
token_ids=token_ids,
routed_experts=routed_experts,
logprobs=logprobs,
cumulative_logprob=self.logprobs_processor.cumulative_logprob,
finish_reason=str(finish_reason) if finished else None,
stop_reason=stop_reason if finished else None,
)
组装 CompletionOutput------注意 finish_reason 和 stop_reason 仅在请求完成时设置。
8.7 OutputProcessor 逐行解析
python
class OutputProcessor:
"""Process EngineCoreOutputs into RequestOutputs."""
def __init__(
self,
tokenizer: TokenizerLike | None,
*,
log_stats: bool,
stream_interval: int = 1,
tracing_enabled: bool = False,
):
self.log_stats = log_stats
self.tokenizer = tokenizer
self.stream_interval = stream_interval
self.request_states: dict[str, RequestState] = {}
self.parent_requests: dict[str, ParentRequest] = {}
self.external_req_ids: defaultdict[str, list[str]] = defaultdict(list)
self.lora_states = LoRARequestStates(log_stats)
self.tracing_enabled = tracing_enabled
核心数据结构:
| 字段 | 类型 | 说明 |
|---|---|---|
request_states |
dict[str, RequestState] | 内部请求ID → 请求状态映射 |
parent_requests |
dict[str, ParentRequest] | 父请求ID → ParentRequest 映射(并行采样) |
external_req_ids |
defaultdict[str, list[str]] | 外部请求ID → 内部请求ID列表映射(一对多,因为并行采样) |
lora_states |
LoRARequestStates | LoRA 请求统计 |
abort_requests()
python
def abort_requests(self, request_ids: Iterable[str], internal: bool) -> list[str]:
中止请求的复杂逻辑------需要处理:
- 内部 vs 外部请求ID
- 并行采样的父子请求关系
- 输出队列通知(确保 generate() 协程能收到中止信号)
python
internal_req_ids = []
for request_id in request_ids:
if internal:
internal_req_ids.append(request_id)
if req_state := self.request_states.get(request_id):
external_req_id = req_state.external_req_id
internal_ids = self.external_req_ids[external_req_id]
internal_ids.remove(request_id)
if not internal_ids:
del self.external_req_ids[external_req_id]
内部ID路径:直接使用,并从外部ID映射中移除。
python
elif internal_ids := self.external_req_ids.pop(request_id, []):
internal_req_ids.extend(internal_ids)
外部ID路径:一个外部ID可能对应多个内部ID(并行采样),全部取出并中止。
python
request_ids_to_abort = []
for request_id in internal_req_ids:
req_state = self.request_states.pop(request_id, None)
if req_state is not None:
self.lora_states.request_finished(request_id, req_state.lora_name)
request_ids_to_abort.append(request_id)
if req_state.queue is not None and (
request_output := req_state.make_request_output(
new_token_ids=[],
pooling_output=EMPTY_CPU_TENSOR
if req_state.detokenizer is None
else None,
finish_reason=FinishReason.ABORT,
stop_reason=None,
kv_transfer_params=None,
)
):
req_state.queue.put(request_output)
为已中止的请求生成最终输出:创建一个 finish_reason=ABORT 的输出,放入队列以解除 generate() 协程的等待。
EMPTY_CPU_TENSOR 的使用:对于 pooling 请求(detokenizer 为 None),需要一个非 None 的 pooling_output 来进入 pooling 分支。
python
elif parent := self.parent_requests.get(request_id):
if parent.child_requests:
child_reqs = list(parent.child_requests)
child_reqs = self.abort_requests(child_reqs, internal=True)
request_ids_to_abort.extend(child_reqs)
self.parent_requests.pop(request_id, None)
递归中止:如果请求ID是父请求,递归中止所有子请求。
add_request()
python
def add_request(
self,
request: EngineCoreRequest,
prompt: str | None,
parent_req: ParentRequest | None = None,
request_index: int = 0,
queue: RequestOutputCollector | None = None,
) -> None:
request_id = request.request_id
req_state = self.request_states.get(request_id)
if req_state is not None:
self._update_streaming_request_state(req_state, request, prompt)
return
流式输入处理:如果请求ID已存在,说明这是同一个请求的增量更新(而非新请求),进入流式更新路径。
python
req_state = RequestState.from_new_request(...)
self.request_states[request_id] = req_state
if parent_req:
self.parent_requests[parent_req.request_id] = parent_req
self.external_req_ids[req_state.external_req_id].append(request_id)
新请求:创建状态,注册父子关系,建立外部ID映射。
_update_streaming_request_state()
python
def _update_streaming_request_state(
self, req_state: RequestState, request: EngineCoreRequest, prompt: str | None
) -> None:
if not request.resumable:
if req_state.input_chunk_queue is None:
self._finish_request(req_state)
if req_state.queue is not None:
req_state.queue.put(STREAM_FINISHED)
elif req_state.input_chunk_queue:
req_state.input_chunk_queue[-1].final = True
else:
req_state.streaming_input = False
return
非 resumable 的最终更新:三种情况------
- 引擎已完成(input_chunk_queue 为 None)→ 立即完成请求
- 有排队的更新 → 标记最后一个为 final
- 无排队更新 → 关闭流式输入模式
python
update = StreamingUpdate(
prompt=prompt,
prompt_token_ids=request.prompt_token_ids,
arrival_time=request.arrival_time,
)
if req_state.input_chunk_queue is None:
req_state.apply_streaming_update(update)
req_state.input_chunk_queue = deque()
else:
req_state.input_chunk_queue.append(update)
resumable 更新的两种处理:
- 引擎已完成当前输入 → 立即应用更新(不排队)
- 引擎仍在处理 → 排队等待
设计洞察 :input_chunk_queue 的状态有三种:
deque()(非空或空deque):引擎正在处理None:引擎已完成当前输入,等待下一个- 无 deque(初始 None 后变成 deque):在流式输入期间动态切换
process_outputs()------输出处理主循环
python
def process_outputs(
self,
engine_core_outputs: list[EngineCoreOutput],
engine_core_timestamp: float | None = None,
iteration_stats: IterationStats | None = None,
) -> OutputProcessorOutput:
这是整个输出处理管线的主入口,处理一次调度迭代产生的所有 EngineCoreOutput。
python
request_outputs: list[RequestOutput | PoolingRequestOutput] = []
reqs_to_abort: list[str] = []
for engine_core_output in engine_core_outputs:
req_id = engine_core_output.request_id
req_state = self.request_states.get(req_id)
if req_state is None:
continue
遍历所有输出,跳过已中止请求的输出(可能在中止和输出之间存在竞争)。
python
self._update_stats_from_output(
req_state, engine_core_output, engine_core_timestamp, iteration_stats
)
步骤1:更新统计信息。
python
new_token_ids = engine_core_output.new_token_ids
pooling_output = engine_core_output.pooling_output
finish_reason = engine_core_output.finish_reason
stop_reason = engine_core_output.stop_reason
kv_transfer_params = engine_core_output.kv_transfer_params
routed_experts = engine_core_output.routed_experts
提取 EngineCoreOutput 的各字段。
python
if req_state.is_prefilling:
if engine_core_output.prefill_stats is not None:
req_state.num_cached_tokens = (
engine_core_output.prefill_stats.num_cached_tokens
)
req_state.is_prefilling = False
Prefill 完成检测:首次收到输出时标记 prefill 完成,并记录 prefix caching 命中的 token 数。
python
if pooling_output is None:
assert req_state.detokenizer is not None
assert req_state.logprobs_processor is not None
stop_string = req_state.detokenizer.update(
new_token_ids, finish_reason == FinishReason.STOP
)
if stop_string:
finish_reason = FinishReason.STOP
stop_reason = stop_string
步骤2:反分词和 stop string 检测------关键交互点:
- Detokenizer 负责增量解码和 stop string 匹配
- 如果检测到 stop string,覆盖 EngineCore 的 finish_reason
- 这意味着 EngineCore 可能不知道 stop string 的匹配(它只检测 EOS token),detokenizer 在前端补充了这一层检测
python
req_state.logprobs_processor.update_from_output(engine_core_output)
步骤3:更新 logprobs。
python
if request_output := req_state.make_request_output(
new_token_ids,
pooling_output,
finish_reason,
stop_reason,
kv_transfer_params,
routed_experts,
):
if req_state.streaming_input:
request_output.finished = False
if req_state.queue is not None:
req_state.queue.put(request_output)
else:
request_outputs.append(request_output)
步骤4:组装输出------
- 流式输入模式下,即使 finish_reason 不为 None,也标记为未完成(因为后续可能还有输入更新)
- AsyncLLM:放入队列
- LLMEngine:添加到返回列表
python
if finish_reason is not None:
if req_state.streaming_input:
if req_state.input_chunk_queue:
update = req_state.input_chunk_queue.popleft()
req_state.apply_streaming_update(update)
else:
req_state.input_chunk_queue = None
流式输入完成处理:当前子请求完成时,应用队列中的下一个更新(如果有)。
python
else:
self._finish_request(req_state)
if not engine_core_output.finished:
reqs_to_abort.append(req_id)
非流式输入完成处理:
- 从请求状态字典中移除
- 关键 :如果
engine_core_output.finished为 False 但 detokenizer 检测到 stop string 导致 finish_reason 不为 None,需要在 EngineCore 中中止此请求
python
self._update_stats_from_finished(
req_state, finish_reason, iteration_stats
)
if self.tracing_enabled:
self.do_tracing(engine_core_output, req_state, iteration_stats)
更新完成统计和追踪。
do_tracing()------OpenTelemetry 追踪
python
def do_tracing(
self,
engine_core_output: EngineCoreOutput,
req_state: RequestState,
iteration_stats: IterationStats | None,
) -> None:
为完成的请求生成 OpenTelemetry span,包含延迟分解:
TIME_TO_FIRST_TOKEN:首 token 延迟E2E:端到端延迟TIME_IN_QUEUE:排队等待时间TIME_IN_MODEL_PREFILL:prefill 模型推理时间TIME_IN_MODEL_DECODE:decode 模型推理时间TIME_IN_MODEL_INFERENCE:总推理时间
以及使用量指标:
PROMPT_TOKENS:提示 token 数COMPLETION_TOKENS:生成 token 数
还有请求参数(top_p, max_tokens, temperature, n)作为属性。
8.8 OutputProcessor 整体架构总结
┌──────────────────────────────────────────────┐
│ EngineCore │
│ EngineCoreOutput[] per iteration │
└──────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ OutputProcessor │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ process_outputs() 主循环 │ │
│ │ 1) 更新统计 │ │
│ │ 2) Detokenizer.update() → stop检测 │ │
│ │ 3) LogprobsProcessor.update() │ │
│ │ 4) RequestState.make_request_output │ │
│ │ 5) 完成处理 / 流式更新 │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────┐ │
│ │RequestSta│ │ParentRequest │ │RequestOut│ │
│ │ tes │ │ (并行采样) │ │putCollect│ │
│ └──────────┘ └──────────────┘ └──────────┘ │
└──────────────┬───────────────────────────────┘
│ RequestOutput / PoolingRequestOutput
▼
┌──────────────────────────────────────────────┐
│ AsyncLLM (queue.put) / LLMEngine (list) │
└──────────────────────────────────────────────┘
九、Detokenizer 深度解析(逐行代码走读)
9.1 模块概览与设计定位
detokenizer.py(339行)负责将 token ID 序列增量转化为文本,并在转化过程中检测 stop string。这是 vLLM 流式输出的关键组件------用户看到的每一个增量文本片段,都经过此模块的增量解码逻辑。
核心设计:
- 三级继承体系:IncrementalDetokenizer(基类/空实现)→ BaseIncrementalDetokenizer(stop string 逻辑)→ Fast/SlowIncrementalDetokenizer(具体解码)
- 快慢双路径:Fast 使用 tokenizers 库的 DecodeStream(Rust 实现),Slow 使用 Python 增量解码
- Stop buffer 机制:为避免在 stop string 边界处截断,保留一定长度的文本缓冲
9.2 全局常量
python
USE_FAST_DETOKENIZER = version.parse(tokenizers.__version__) >= version.parse("0.22.0")
INVALID_PREFIX_ERR_MSG = "Invalid prefix encountered"
USE_FAST_DETOKENIZER:tokenizers >= 0.22.0 支持带 prefill ids 的 DecodeStream,决定是否使用快速路径INVALID_PREFIX_ERR_MSG:tokenizers 库的特定错误消息,用于异常恢复
9.3 IncrementalDetokenizer 基类
python
class IncrementalDetokenizer:
def __init__(self):
self.token_ids: list[int] = []
@property
def output_token_ids(self) -> list[int]:
return self.token_ids
def num_output_tokens(self) -> int:
return len(self.token_ids)
def update(self, new_token_ids: list[int], stop_terminated: bool) -> str | None:
self.token_ids.extend(new_token_ids)
return None
def get_next_output_text(self, finished: bool, delta: bool) -> str:
return ""
空实现/占位符 :当不需要反分词时(detokenize=False),使用此基类。所有方法返回空/None,但 token_ids 仍然被记录(用于长度计算)。
python
@classmethod
def from_new_request(
cls,
tokenizer: TokenizerLike | None,
request: EngineCoreRequest,
) -> "IncrementalDetokenizer":
assert request.sampling_params is not None
if tokenizer is None:
return IncrementalDetokenizer()
if USE_FAST_DETOKENIZER and isinstance(tokenizer, PreTrainedTokenizerFast):
return FastIncrementalDetokenizer(tokenizer, request)
return SlowIncrementalDetokenizer(tokenizer, request)
工厂方法的三条路径:
- 无 tokenizer → 空实现
- Fast tokenizer + tokenizers >= 0.22 → 快速路径
- 其他 → 慢速路径
9.4 BaseIncrementalDetokenizer 逐行解析
python
class BaseIncrementalDetokenizer(IncrementalDetokenizer, ABC):
def __init__(self, request: EngineCoreRequest):
super().__init__()
Stop string 配置
python
params = request.sampling_params
assert params is not None
if params.stop is None:
self.stop = []
elif isinstance(params.stop, str):
self.stop = [params.stop]
else:
self.stop = params.stop
self.min_tokens = params.min_tokens
self.include_stop_str_in_output = params.include_stop_str_in_output
参数归一化 :stop 可能是 None、str 或 list[str],统一为 list。
python
if self.stop and not self.include_stop_str_in_output:
self.stop_buffer_length = max(len(s) for s in self.stop) - 1
else:
self.stop_buffer_length = 0
self._last_output_text_offset: int = 0
Stop buffer 机制:
核心问题:增量解码时,stop string 可能跨越多个 token。例如 stop="End",当前 token 解码为 "En",下一个 token 可能是 "d"。如果立即输出 "En",之后发现 "End" 匹配,就需要回溯------这是不可接受的。
解决方案:保留 max_stop_string_length - 1 个字符不输出。只有当请求完成时(确保不会再有更多 token),才输出全部文本。
_last_output_text_offset:DELTA 模式下记录上次输出到的位置。
python
self.output_text = ""
累积的输出文本。
update()------增量解码与 stop 检测核心
python
def update(self, new_token_ids: list[int], stop_terminated: bool) -> str | None:
if not new_token_ids:
return None
空 token 列表直接返回。
python
if stop_terminated and not self.include_stop_str_in_output:
skipped_stop_token_id = new_token_ids[-1]
new_token_ids = new_token_ids[:-1]
else:
skipped_stop_token_id = None
Stop token 处理:如果 EngineCore 检测到 stop(EOS token 匹配)但用户不想在输出中包含 stop string,则跳过最后一个 token 的解码。但 token ID 仍然被记录(用于 token_ids 列表的完整性)。
python
stop_check_offset = len(self.output_text)
for new_token_id in new_token_ids:
self.token_ids.append(new_token_id)
self.output_text += self.decode_next(new_token_id)
if self.min_tokens and self.num_output_tokens() <= self.min_tokens:
stop_check_offset = len(self.output_text)
逐 token 增量解码:
- 将 token ID 追加到列表
- 调用
decode_next()(由子类实现)获取解码文本 - 累积到
output_text
min_tokens 的 stop 检查偏移 :在 min_tokens 未达到之前,不应该检测 stop string。因此将 stop_check_offset 推进到当前位置------这意味着 stop string 搜索只在新产生的字符范围内进行。
python
if skipped_stop_token_id is not None:
self.token_ids.append(skipped_stop_token_id)
将跳过的 stop token ID 追加到列表(但不解码)。
python
stop_string = None
if self.stop and self.num_output_tokens() > self.min_tokens:
stop = check_stop_strings(
output_text=self.output_text,
new_char_count=len(self.output_text) - stop_check_offset,
stop=self.stop,
include_in_output=self.include_stop_str_in_output,
)
if stop is not None:
stop_string, truncate_to = stop
if truncate_to != -1:
self.output_text = self.output_text[:truncate_to]
Stop string 检测:仅当超过 min_tokens 阈值时才检查。如果检测到,截断输出文本。
get_next_output_text()------增量文本获取
python
def get_next_output_text(self, finished: bool, delta: bool) -> str:
buffer_length = 0 if finished else self.stop_buffer_length
if not delta:
if not buffer_length:
return self.output_text
return self.output_text[:-buffer_length]
非 DELTA 模式:返回全部文本(扣除 stop buffer)。
python
length = len(self.output_text) - buffer_length
last_offset = self._last_output_text_offset
if last_offset < length:
self._last_output_text_offset = length
return self.output_text[last_offset:length]
return ""
DELTA 模式:只返回自上次调用以来新增的文本(扣除 stop buffer)。这是流式 SSE 输出的核心------每个增量只包含新增内容。
9.5 FastIncrementalDetokenizer 逐行解析
python
class FastIncrementalDetokenizer(BaseIncrementalDetokenizer):
def __init__(self, tokenizer: PreTrainedTokenizerFast, request: EngineCoreRequest):
super().__init__(request)
sampling_params = request.sampling_params
assert sampling_params is not None
self.request_id = request.request_id
self.skip_special_tokens = sampling_params.skip_special_tokens
self.tokenizer: Tokenizer = tokenizer._tokenizer
获取底层的 tokenizers 库 Tokenizer 对象(不是 transformers 的封装)。
python
self.stream = DecodeStream(
ids=request.prompt_token_ids,
skip_special_tokens=self.skip_special_tokens,
)
DecodeStream 初始化:使用 prompt token IDs 作为 prefill------这是 tokenizers >= 0.22.0 的新特性,允许在创建 DecodeStream 时就提供已知的前缀 token,避免重复解码。
python
self.spaces_between_special_tokens = (
sampling_params.skip_special_tokens
or sampling_params.spaces_between_special_tokens
)
if not self.spaces_between_special_tokens:
added_token_ids = getattr(self.tokenizer, "added_token_ids", None)
if added_token_ids is None:
self.tokenizer.added_token_ids = added_token_ids = {
tid: tok.content
for tid, tok in self.tokenizer.get_added_tokens_decoder().items()
}
if added_token_ids:
self.last_special = False
self.added_token_ids = added_token_ids
else:
self.spaces_between_special_tokens = True
Special token 间距处理:
- 当
skip_special_tokens=True时,自动不显示 special token,间距无意义 - 当
spaces_between_special_tokens=True时,在 special token 之间插入空格 - 当两者都为 False 时,需要精确控制间距------使用
added_token_ids字典检测 special token,避免错误插入空格
decode_next()
python
def decode_next(self, next_token_id: int) -> str:
token = self._protected_step(next_token_id)
if not self.spaces_between_special_tokens:
special_token = self.added_token_ids.get(next_token_id)
is_special = special_token is not None
if is_special and self.last_special:
token = special_token
self.last_special = is_special
return token or ""
Special token 处理逻辑:
- 如果当前 token 是 special token 且上一个也是 special token,不插入空格------直接使用 token 的原始内容
- 否则,使用 DecodeStream 的输出(可能包含前缀空格)
_protected_step()------带异常恢复的步进
python
def _protected_step(self, next_token_id: int) -> str | None:
try:
token = self.stream.step(self.tokenizer, next_token_id)
except (OverflowError, TypeError):
logger.exception("Encountered invalid token id: %r", next_token_id)
token = None
except Exception as e:
if not str(e).startswith(INVALID_PREFIX_ERR_MSG):
raise e
logger.warning(
"Encountered invalid prefix detokenization error"
" for request %s, resetting decode stream.",
self.request_id,
)
self.stream = DecodeStream(skip_special_tokens=self.skip_special_tokens)
token = self.stream.step(self.tokenizer, next_token_id)
return token
三层异常处理:
- OverflowError/TypeError:token ID 越界(如超大的无效 ID),记录并返回 None
- Invalid prefix error :tokenizers 库在遇到非单调 UTF-8 输出时抛出此错误------这通常是因为某些 token 的组合产生了无效的 UTF-8 前缀。恢复策略:重置 DecodeStream(不带 prefill),然后重新步进当前 token
- 其他异常:向上传播
设计洞察:Invalid prefix 错误的恢复是"有损"的------重置后丢失了 prompt 的解码上下文。但对于后续 token 的解码,这通常不影响正确性,因为 UTF-8 字符边界很快会重新对齐。
9.6 SlowIncrementalDetokenizer 逐行解析
python
class SlowIncrementalDetokenizer(BaseIncrementalDetokenizer):
def __init__(self, tokenizer: TokenizerLike, request: EngineCoreRequest):
super().__init__(request)
self.tokenizer = tokenizer
params = request.sampling_params
assert params is not None
self.prompt_len = length_from_prompt_token_ids_or_embeds(
request.prompt_token_ids, request.prompt_embeds
)
慢速解码器需要区分 prompt token 和 output token------因为 token_ids 列表包含两者。
python
if request.prompt_token_ids is not None:
self.tokens, self.prefix_offset, self.read_offset = (
convert_prompt_ids_to_tokens(
tokenizer=tokenizer,
prompt_ids=request.prompt_token_ids,
skip_special_tokens=params.skip_special_tokens,
)
)
else:
self.tokens = [""] * self.prompt_len
self.prefix_offset = 0
self.read_offset = 0
Prompt token 预处理:将 prompt token IDs 转换为 token 字符串列表,并计算增量解码所需的偏移量。对于 embeds 输入,使用空字符串占位。
python
self.token_ids.extend(request.prompt_token_ids or [0] * self.prompt_len)
token_ids 包含 prompt token------这是与 FastIncrementalDetokenizer 的关键区别。Fast 使用 DecodeStream 的 prefill 功能,不需要在 token_ids 中包含 prompt;Slow 需要将 prompt 和 output token 统一在 token_ids 列表中,因为增量解码需要完整的前缀。
python
self.skip_special_tokens = params.skip_special_tokens
self.spaces_between_special_tokens = params.spaces_between_special_tokens
output_token_ids 属性覆盖
python
@property
def output_token_ids(self) -> list[int]:
if self.prompt_len:
return self.token_ids[self.prompt_len :]
return self.token_ids
def num_output_tokens(self) -> int:
return len(self.token_ids) - self.prompt_len
关键覆盖:因为 token_ids 包含 prompt token,output_token_ids 需要切片排除 prompt 部分。
decode_next()
python
def decode_next(self, next_token_id: int) -> str:
new_tokens, decoded_text, prefix_offset, read_offset = detokenize_incrementally(
tokenizer=self.tokenizer,
all_input_ids=self.token_ids,
prev_tokens=self.tokens,
prefix_offset=self.prefix_offset,
read_offset=self.read_offset,
skip_special_tokens=self.skip_special_tokens,
spaces_between_special_tokens=self.spaces_between_special_tokens,
)
self.tokens.extend(new_tokens)
self.prefix_offset = prefix_offset
self.read_offset = read_offset
return decoded_text
调用 detokenize_incrementally() ------这是一个基于前缀比较的增量解码算法:
- 维护
prefix_offset和read_offset两个指针 - 每次只解码自上次以来新增的部分
- 利用 UTF-8 字符边界的重叠来处理多字节字符的跨 token 分割
9.7 check_stop_strings() 函数逐行解析
python
def check_stop_strings(
output_text: str,
new_char_count: int,
stop: list[str],
include_in_output: bool,
) -> tuple[str, int] | None:
if not new_char_count or not stop:
return None
提前退出:无新字符或无 stop string。
python
for stop_str in stop:
stop_string_len = len(stop_str)
stop_index = output_text.find(stop_str, 1 - new_char_count - stop_string_len)
if stop_index == -1:
continue
优化的搜索范围 :1 - new_char_count - stop_string_len 作为搜索起始位置------只搜索新字符可能触及的范围。这是因为之前的字符已经被搜索过了(增量检查)。
python
if include_in_output:
stop_index += stop_string_len
if stop_index >= len(output_text):
return stop_str, -1
包含 stop string 在输出中:截断点移到 stop string 末尾。如果 stop string 恰好在输出末尾,不需要截断(返回 -1)。
python
return stop_str, stop_index
return None
不包含 stop string:截断点在 stop string 开头。
9.8 Detokenizer 整体架构总结
┌──────────────────────┐
│ IncrementalDetokenizer│ ← 空实现(detokenize=False)
│ (基类/占位符) │
└──────────┬───────────┘
│
┌──────────┴───────────┐
│BaseIncrementalDetoken│
│ (stop string 逻辑) │
│ ├─ stop_buffer │
│ ├─ min_tokens │
│ └─ get_next_output │
└──────┬───────┬───────┘
│ │
┌──────────────┘ └──────────────┐
│ │
┌───────────┴───────────┐ ┌──────────────┴──────────┐
│FastIncrementalDetoken │ │SlowIncrementalDetoken │
│ (tokenizers Rust) │ │ (Python incremental) │
│ ├─ DecodeStream │ │ ├─ detokenize_increm. │
│ ├─ _protected_step │ │ ├─ prefix/read offset │
│ └─ special token │ │ └─ prompt/output split │
│ handling │ │ │
└───────────────────────┘ └─────────────────────────┘