vLLM Explained

Pooling models and embeddings in vLLM serving

AdvancedvLLM conceptsabout 9 min read + 3 min deep dives

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#

Figure: --runner and --convert decide the model class (panel 1), the pooler replaces the sampler and the request ends after one output (panel 2), and the pooling task decides the routes (panel 3).
Open full size ↗

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",
}
TaskOutputTypical model
embedone vector per requestsentence embedding models
classifyclass scores per request; also cross-encoder scoring*ForSequenceClassification, rerankers
token_embedone vector per token; late-interaction scoringColBERT-style models
token_classifyscores per token*ForTokenClassification, process reward models
pluginwhatever an IO processor plugin returnssee 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 --convert forces pooling. 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 NotImplementedError

DispatchPooler 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:

Step 4: what is turned off for pooling models#

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
RouteWhenSource
POST /poolingany pooling taskvllm/entrypoints/pooling/pooling/api_router.py:27-28
POST /classifyclassify supportedvllm/entrypoints/pooling/classify/api_router.py:26
POST /v1/embeddings, /v2/embedembed 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/embeddingstask token_embed: per-token vectorsvllm/entrypoints/pooling/token_embed/api_router.py:25-26
/score, /v1/score, /rerank, /v1/rerank, /v2/rerankselected task embed or token_embed, or classify with num_labels == 1vllm/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_mask

The 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 = True

Model 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 dive

Worked 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.

  1. Start-up. --convert embed forces --runner pooling. The class becomes LlamaForCausalLM wrapped by as_embedding_model, named with the suffix ForEmbedding (vllm/model_executor/models/adapters.py:279). There is no sentence-transformers config, so seq_pooling_type is the class default LAST. Pooling task: embed. Async scheduling: off. Chunked prefill and prefix caching: allowed, because LAST pooling on a decoder model supports them.
  2. Routes. /pooling, /v1/embeddings, /v2/embed, and the score and rerank routes (embed enables scoring). No /v1/chat/completions.
  3. Request. {"model": "...", "input": "vLLM is fast", "dimensions": 256} → a validation error, because Llama-3.1 is not a matryoshka model (is_matryoshka reads the HF config, vllm/config/model.py:2083-2091). Without dimensions, the request goes through.
  4. Step. Say the prompt has 6 tokens. The scheduler schedules all 6; the forward pass gives hidden_states [6, 4096] for this request.
  5. 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_activation true by default for embed).
  6. Finish. The output reaches the scheduler, the request becomes FINISHED_STOPPED in this same update, and the API server returns one vector of 4,096 floats.
Deep dive

PoolerConfig fields

FieldDefaultEffect
tasknone (auto)Pick one of the model's tasks (vllm/config/pooler.py:33-36).
pooling_type / seq_pooling_type / tok_pooling_typenone → ST config → model defaultStep 3.
use_activationnone (pooler default, mostly true)Normalize (embed) or activation (classify) (vllm/config/pooler.py:60-64).
dimensionsnoneMatryoshka truncation (vllm/config/pooler.py:75-79).
enable_chunked_processing, max_embed_lenfalse, noneLong-input embeddings (vllm/config/pooler.py:80-96).
logit_mean, logit_sigmanoneScore calibration: activation((logit - mean) / sigma) (vllm/config/pooler.py:98-111).
step_tag_id, returned_token_idsnoneProcess 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 dive

Why 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:

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#

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#

  1. 1
    Which option turns a *ForCausalLM checkpoint into an embedding model, and what does it do to the class?
    easy
    Show answer

    --convert embed. vLLM wraps the class with as_embedding_model, which adds an embedding pooler (vllm/model_executor/model_loader/utils.py:328-330, vllm/model_executor/models/adapters.py:250-281).

  2. 2
    A model is served with task classify and 3 labels. Which routes exist?
    medium
    Show answer

    /pooling and /classify. No scoring routes, because classify enables them only with num_labels == 1 (vllm/entrypoints/pooling/factories.py:120-130, vllm/entrypoints/pooling/utils.py:231-236).

  3. 3
    Why does a decoder embedding model with MEAN pooling not use prefix caching?
    medium
    Show answer

    is_prefix_caching_supported returns 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 raises RuntimeError for a partial prefill (vllm/model_executor/layers/pooler/seqwise/methods.py:66-68). Cached KV blocks give no hidden states (derived).

  4. 4
    In 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 after execute_model (vllm/v1/worker/gpu_worker.py:1355-1359); it runs the PoolingRunner and returns an AsyncPoolingOutput (vllm/v1/worker/gpu/model_runner.py:2338-2357). The scheduler sets FINISHED_STOPPED in the update that receives the pooler output (vllm/v1/core/sched/scheduler.py:2263-2266).