Pooling models and embeddings in vLLM serving
Code version: vLLM @ 3e44c0d · 2026-10-22
In one sentence
A pooling model runs the prompt once, reduces the hidden states to a vector or a score, and finishes. Its task decides the HTTP routes.
The idea#
A generative model ends with an LM head and a sampler, and it runs many steps per request. A pooling model ends with a pooler. The pooler turns the hidden states of the prompt into one vector (an embedding), a few class scores, or one vector per token. The request then finishes after its prompt is processed: there is no decode phase.
vLLM runs one runner type per instance. With --runner pooling, the same engine core, scheduler and KV cache serve the request, but the worker calls the pooler instead of the sampler. vLLM can also convert a generative architecture: --convert embed or --convert classify wraps the model class. So, for example, a *ForCausalLM checkpoint can serve embeddings from its last hidden state.
An everyday comparison. A generative model is a writer who adds one word at a time. A pooling model is a reader who reads the whole text once and then writes one summary number or one index card. The comparison stops being true in one place. vLLM's reader may read a long text in several chunks, but only for some pooling types (Step 4).
The picture#
Read the panels from top to bottom. Amber is the generative path, teal and emerald the pooling path. A generative model never gets the pooling routes, and a pooling model never gets the chat routes.
In the code#
Step 1: the pooling tasks#
The task names are a Literal type. The old names score and encode are removed and raise a validation error with a hint:
PoolingTask = Literal[
"embed",
"classify",
"token_embed",
"token_classify",
"plugin",
"embed&token_classify",
]
POOLING_TASKS: tuple[PoolingTask, ...] = get_args(PoolingTask)
_REMOVED_POOLING_TASK_MESSAGES = {
"score": "`score` task was removed; use `classify` instead.",
"encode": (
"`encode` task was removed; use `token_embed` or `token_classify` instead."
),
}
def check_removed_pooling_task(task: object) -> None:
if isinstance(task, str) and (message := _REMOVED_POOLING_TASK_MESSAGES.get(task)):
raise VLLMValidationError(message, parameter="task")
ScoreType = Literal["bi-encoder", "cross-encoder", "late-interaction"]
SCORE_TYPE_MAP: dict[PoolingTask, ScoreType] = {
"embed": "bi-encoder",
"classify": "cross-encoder",
"token_embed": "late-interaction",
}| Task | Output | Typical model |
|---|---|---|
embed | one vector per request | sentence embedding models |
classify | class scores per request; also cross-encoder scoring | *ForSequenceClassification, rerankers |
token_embed | one vector per token; late-interaction scoring | ColBERT-style models |
token_classify | scores per token | *ForTokenClassification, process reward models |
plugin | whatever an IO processor plugin returns | see Plugins in the API server |
When a model supports several tasks, get_pooling_task picks one. The order is: --pooler-config '{"task": ...}' if set, else token_classify for *ForTokenClassification. Otherwise it takes the first of embed&token_classify, embed, classify, token_embed, token_classify, plugin (vllm/config/model.py:1918-1951). Reward models have no own task name; their default convert is embed (vllm/config/model.py:2353-2354).
Step 2: --runner and --convert#
Both options default to auto (vllm/config/model.py:163-169). The runner types are generate, pooling and draft; a pooling runner allows the converts embed and classify (vllm/config/scheduler.py:20, vllm/config/model.py:124-128).
- Runner. A set
--convertforcespooling. Else a sentence-transformers config means pooling; else the registry decides; else the architecture suffix decides (vllm/config/model.py:1283-1329). - Convert. A native pooling model needs none. Else the suffix decides, and a generative architecture run as pooling becomes
embed(vllm/config/model.py:1331-1360).
_SUFFIX_TO_DEFAULTS: list[tuple[str, tuple[RunnerType, ConvertType]]] = [
("ForCausalLM", ("generate", "none")),
("ForConditionalGeneration", ("generate", "none")),
("ChatModel", ("generate", "none")),
("LMHeadModel", ("generate", "none")),
("ForTextEncoding", ("pooling", "embed")),
("EmbeddingModel", ("pooling", "embed")),
("ForSequenceClassification", ("pooling", "classify")),
("ForTokenClassification", ("pooling", "classify")),
("ForAudioClassification", ("pooling", "classify")),
("ForImageClassification", ("pooling", "classify")),
("ForVideoClassification", ("pooling", "classify")),
("ClassificationModel", ("pooling", "classify")),
("ForRewardModeling", ("pooling", "embed")),
("RewardModel", ("pooling", "embed")),
# Let other `*Model`s take priority
("Model", ("pooling", "embed")),
]The convert is applied when vLLM resolves the model class (vllm/model_executor/model_loader/utils.py:325-333). as_embedding_model subclasses the model and gives it an embedding pooler. By default, it takes the normalized hidden state of the last token (vllm/model_executor/models/adapters.py:250-281). as_seq_cls_model adds a score linear layer with num_labels outputs and a classification pooler (vllm/model_executor/models/adapters.py:307-378). Both return a class unchanged if it already pools.
vllm serve Qwen/Qwen3-Embedding-0.6B --runner pooling
# LAST-token embeddings from a chat model:
vllm serve meta-llama/Llama-3.1-8B-Instruct --convert embed
Step 3: Pooler and PoolerConfig#
Every pooler implements one small interface:
class Pooler(nn.Module, ABC):
"""The interface required for all poolers used in pooling models in vLLM."""
@abstractmethod
def get_supported_tasks(self) -> Set[PoolingTask]:
"""Determine which pooling tasks are supported."""
raise NotImplementedError
def get_pooling_updates(self, task: PoolingTask) -> PoolingParamsUpdate:
"""Construct the updated pooling parameters to use for a supported task."""
return PoolingParamsUpdate()
@abstractmethod
def forward(
self,
hidden_states: torch.Tensor,
pooling_metadata: PoolingMetadata,
) -> PoolerOutput:
raise NotImplementedErrorDispatchPooler holds one sub-pooler per task and calls the one for the request's task (vllm/model_executor/layers/pooler/special.py:25-57). --pooler-config sets PoolerConfig:
- Pooling types. Sequence pooling
CLS,LAST,MEAN; token poolingALL,STEP(vllm/config/pooler.py:16-20).pooling_typefills one of the two by its value (vllm/config/pooler.py:143-168). - Defaults. The user's value wins. Next comes the sentence-transformers config of the checkpoint, then the model class default (
vllm/config/model.py:775-803). The class defaults areLASTandALL(vllm/model_executor/models/interfaces_base.py:163,vllm/model_executor/models/interfaces_base.py:173). - Normalization.
normalizewas removed;use_activationreplaces it (vllm/config/pooler.py:125-137). Forembed, an unsetuse_activationmeans true: the embedding is L2-normalized (vllm/pooling_params.py:215-217). - Dimensions. A request can ask for fewer
dimensionsonly if the model is matryoshka; the head then cuts the vector before it normalizes (vllm/pooling_params.py:219-242,vllm/model_executor/layers/pooler/seqwise/heads.py:84-115). - Long inputs.
enable_chunked_processingandmax_embed_lenlet an embedding model accept inputs above its position limit, by weighted averaging of chunks; not for multimodal inputs (vllm/config/pooler.py:80-96).
Step 4: what is turned off for pooling models#
- Async scheduling is off by default for pooling models. The code says it "negatively impacts performance of pooling models" (
vllm/config/vllm.py:1664-1675). An explicit--async-schedulingis not rejected. - Chunked prefill and prefix caching. Encoder-only (bidirectional) pooling models support neither. Decoder pooling models support both only with
LASTorALLpooling;MEAN,CLSandSTEPturn them off (vllm/config/model.py:2193-2228,vllm/config/model.py:2239-2270). These properties set only the defaults. An explicit--enable-chunked-prefillor--enable-prefix-cachingonly logs a warning that the model does not officially support it (vllm/engine/arg_utils.py:3031-3035,vllm/engine/arg_utils.py:3051-3060,vllm/engine/arg_utils.py:3069-3078). - Logits processors.
--logits-processorson a pooling model raisesValueError; entry-point processors are skipped (vllm/v1/worker/gpu/sample/logits_processor/loader.py:172-179). - The sampler. The model runner builds a
PoolingRunnerinstead of a sampler (vllm/v1/worker/gpu/model_runner.py:467,vllm/v1/worker/gpu/model_runner.py:534-535).
Step 5: the endpoints#
The API server adds the pooling routes only when the model has a pooling task (vllm/entrypoints/launchers/api_server/routers.py:80-83). Which routes it adds depends on the task:
def register_pooling_api_routers(
app: FastAPI,
supported_tasks: tuple["SupportedTask", ...],
model_config: ModelConfig | None = None,
):
if model_config is None:
return
pooling_task = model_config.get_pooling_task(supported_tasks)
if pooling_task is not None:
from .pooling.api_router import router as pooling_router
app.include_router(pooling_router)
if "classify" in supported_tasks:
from .classify.api_router import (
router as classify_router,
)
app.include_router(classify_router)
if pooling_task == "token_embed":
from .token_embed.api_router import router as token_embed_router
app.include_router(token_embed_router)
elif "embed" in supported_tasks:
from .embed.api_router import router as embed_router
app.include_router(embed_router)
if enable_scoring_api(supported_tasks, model_config):
from .scoring.api_router import router as score_router
| Route | When | Source |
|---|---|---|
POST /pooling | any pooling task | vllm/entrypoints/pooling/pooling/api_router.py:27-28 |
POST /classify | classify supported | vllm/entrypoints/pooling/classify/api_router.py:26 |
POST /v1/embeddings, /v2/embed | embed supported and the selected task is not token_embed (the embedding endpoint) | vllm/entrypoints/pooling/embed/api_router.py:28-29, vllm/entrypoints/pooling/embed/api_router.py:46-47 |
POST /v1/embeddings | task token_embed: per-token vectors | vllm/entrypoints/pooling/token_embed/api_router.py:25-26 |
/score, /v1/score, /rerank, /v1/rerank, /v2/rerank | selected task embed or token_embed, or classify with num_labels == 1 | vllm/entrypoints/pooling/utils.py:220-238 |
The serving classes send the prompt with PoolingParams through AsyncLLM.encode (vllm/v1/engine/async_llm.py:1034-1037). An IO processor plugin turns custom input into pooling input, and the result into a custom response. Only the pooling entry points use it (Plugins in the API server, Step 2).
Step 6: the pooling runner in Model Runner V2#
In a pooling model, the worker calls model_runner.pool() right after execute_model, not sample_tokens (vllm/v1/worker/gpu_worker.py:1355-1359). pool() calls the PoolingRunner and returns an AsyncPoolingOutput, which copies the result to the CPU on a side stream (vllm/v1/worker/gpu/model_runner.py:2318-2358):
def pool(
self,
hidden_states: torch.Tensor,
input_batch: InputBatch,
req_states: RequestState,
) -> tuple[PoolerOutput, list[bool]]:
hidden_states = hidden_states[: input_batch.num_tokens]
pooling_metadata = self._get_pooling_metadata(
input_batch, req_states, hidden_states.device
)
num_reqs = input_batch.num_reqs
# Pooling has no speculative tokens, so this CPU upper bound is exact.
seq_lens_cpu = input_batch.seq_lens_cpu_upper_bound[:num_reqs]
pooling_metadata.build_pooling_cursor(
input_batch.num_scheduled_tokens,
seq_lens_cpu,
device=hidden_states.device,
query_start_loc_gpu=input_batch.query_start_loc[: num_reqs + 1],
)
pooler_output = self.model.pooler(hidden_states, pooling_metadata)
finished_mask = pooling_metadata.get_pooling_cursor().get_finished_mask()
pooler_output = self.late_interaction_runner.postprocess_pooler_output(
raw_pooler_output=pooler_output,
pooling_params=pooling_metadata.pooling_params,
req_ids=input_batch.req_ids,
finished_mask=finished_mask,
)
return pooler_output, finished_maskThe pooling cursor knows, per request, which rows of this step belong to it and whether the prompt is complete (finished_mask). The scheduler then finishes a request as soon as its pooler output arrives:
elif request.pooling_params and pooler_output is not None:
# Pooling stops as soon as there is output.
request.status = RequestStatus.FINISHED_STOPPED
stopped = TrueModel Runner V2 supports the tasks embed, classify, token_embed, token_classify and embed&token_classify. A model whose selected task is not in this list fails at start-up with a hint to use VLLM_USE_V2_MODEL_RUNNER=0 (vllm/v1/worker/gpu/pool/pooling_runner.py:20-50).
Deep dive#
The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.
Deep diveWorked example: one /v1/embeddings request with LAST pooling
Server (example): vllm serve meta-llama/Llama-3.1-8B-Instruct --convert embed on one GPU. Hidden size 4,096.
- Start-up.
--convert embedforces--runner pooling. The class becomesLlamaForCausalLMwrapped byas_embedding_model, named with the suffixForEmbedding(vllm/model_executor/models/adapters.py:279). There is no sentence-transformers config, soseq_pooling_typeis the class defaultLAST. Pooling task:embed. Async scheduling: off. Chunked prefill and prefix caching: allowed, because LAST pooling on a decoder model supports them. - Routes.
/pooling,/v1/embeddings,/v2/embed, and the score and rerank routes (embed enables scoring). No/v1/chat/completions. - Request.
{"model": "...", "input": "vLLM is fast", "dimensions": 256}→ a validation error, because Llama-3.1 is not a matryoshka model (is_matryoshkareads the HF config,vllm/config/model.py:2083-2091). Withoutdimensions, the request goes through. - Step. Say the prompt has 6 tokens. The scheduler schedules all 6; the forward pass gives
hidden_states [6, 4096]for this request. - Pool. LAST takes row 5:
[4096]. The head casts it to the head dtype, applies no projector, keeps all 4,096 dimensions, and L2-normalizes it (use_activationtrue by default for embed). - Finish. The output reaches the scheduler, the request becomes
FINISHED_STOPPEDin this same update, and the API server returns one vector of 4,096 floats.
Deep divePoolerConfig fields
| Field | Default | Effect |
|---|---|---|
task | none (auto) | Pick one of the model's tasks (vllm/config/pooler.py:33-36). |
pooling_type / seq_pooling_type / tok_pooling_type | none → ST config → model default | Step 3. |
use_activation | none (pooler default, mostly true) | Normalize (embed) or activation (classify) (vllm/config/pooler.py:60-64). |
dimensions | none | Matryoshka truncation (vllm/config/pooler.py:75-79). |
enable_chunked_processing, max_embed_len | false, none | Long-input embeddings (vllm/config/pooler.py:80-96). |
logit_mean, logit_sigma | none | Score calibration: activation((logit - mean) / sigma) (vllm/config/pooler.py:98-111). |
step_tag_id, returned_token_ids | none | Process reward models (vllm/config/pooler.py:113-121). |
PoolerConfig.compute_hash has no factors: the pooler settings do not change the compiled graph (vllm/config/pooler.py:186-201).
Deep diveWhy some pooling types turn off chunked prefill and prefix caching
The code does not state the reasons for the rule in ModelConfig (Step 4, vllm/config/model.py:2193-2228). The pooling methods show them:
- LAST takes the row of the last token in the step (
vllm/model_executor/layers/pooler/seqwise/methods.py:50-57). Earlier chunks and cached prefix blocks only have to provide KV, which they do. - MEAN and CLS raise
RuntimeErrorfor a partial prefill. MEAN divides the sum of the step's rows by the full prompt length (vllm/model_executor/layers/pooler/seqwise/methods.py:37-47,vllm/model_executor/layers/pooler/seqwise/methods.py:60-106). Only token pooling (ALL) keeps the rows of earlier chunks (vllm/v1/pool/metadata.py:48-54). A cached prefix block has KV but no hidden states, so a prefix-cache hit would also lose rows (derived). - Bidirectional (encoder-only) models: every token attends to every other token, so a chunk cannot be computed before the rest of the prompt exists (derived).
So a BERT-style embedding model processes each prompt in one step. Without chunked prefill, vLLM raises an unset max_num_batched_tokens to at least max_model_len (vllm/engine/arg_utils.py:3177-3186).
What can go wrong#
- 404 on
/v1/chat/completions. The model runs as a pooling model, so the generate routes do not exist (vllm/entrypoints/launchers/api_server/routers.py:55-60). - No
/scoreor/rerankfor a classifier. Scoring needsnum_labels == 1forclassify(vllm/entrypoints/pooling/utils.py:231-236). - "
scoretask was removed". Useclassify;encodebecametoken_embedortoken_classify(vllm/tasks.py:20-30). - "Parameter
normalizewas removed". Useuse_activation(vllm/config/pooler.py:125-135). - "does not support Matryoshka embeddings". The request sent
dimensionsto a model without matryoshka support (vllm/pooling_params.py:225-230). - Start-up error with a hint to
VLLM_USE_V2_MODEL_RUNNER=0. The selected task is not one that Model Runner V2 supports (Step 6).
Why it matters. Embedding and reranking servers depend on settings that differ from chat serving. The pooling type decides whether chunked prefill and prefix caching work. The task decides which routes a client can call.
Check yourself#
- 1Which option turns aeasy
*ForCausalLMcheckpoint into an embedding model, and what does it do to the class?Show answer
--convert embed. vLLM wraps the class withas_embedding_model, which adds an embedding pooler (vllm/model_executor/model_loader/utils.py:328-330,vllm/model_executor/models/adapters.py:250-281). - 2A model is served with taskmedium
classifyand 3 labels. Which routes exist?Show answer
/poolingand/classify. No scoring routes, becauseclassifyenables them only withnum_labels == 1(vllm/entrypoints/pooling/factories.py:120-130,vllm/entrypoints/pooling/utils.py:231-236). - 3Why does a decoder embedding model with MEAN pooling not use prefix caching?medium
Show answer
is_prefix_caching_supportedreturns false for MEAN, CLS and STEP pooling on decoder models (vllm/config/model.py:2239-2270). MEAN needs the hidden states of all prompt tokens in one step, and it raisesRuntimeErrorfor a partial prefill (vllm/model_executor/layers/pooler/seqwise/methods.py:66-68). Cached KV blocks give no hidden states (derived). - 4In Model Runner V2, which call produces the pooler output, and when does the scheduler finish the request?hard
Show answer
The worker calls
model_runner.pool()right afterexecute_model(vllm/v1/worker/gpu_worker.py:1355-1359); it runs thePoolingRunnerand returns anAsyncPoolingOutput(vllm/v1/worker/gpu/model_runner.py:2338-2357). The scheduler setsFINISHED_STOPPEDin the update that receives the pooler output (vllm/v1/core/sched/scheduler.py:2263-2266).