vLLM Explained

Tour: Build a toy plugin

IntermediateGuided toursabout 17 min read + 3 min deep dives

Code version: vLLM @ 3e44c0d · 2026-10-22

In one sentence

We write one small plugin package with four parts, start vllm serve with it, and check in the logs that each part loaded.

The idea#

The tour Tour: Life of a plugin follows an existing package through the start-up. This tour goes the other way: you write the package. It has four parts, and each part uses one extension point:

  1. a model: a new architecture name in the model registry;
  2. a layer replacement: your own subclass of RMSNorm, a CustomOp;
  3. a stat logger that receives the statistics of the server;
  4. an endpoint: one new HTTP route.

The first two parts come from one general plugin, so they reach every process. The last two parts act only in the front end. Every vLLM call in the toy is one that a test plugin of the vLLM repository also makes. So each stop shows the real test plugin next to the toy.

An everyday comparison. Think of a kit that adds parts to a car. One part goes into the engine bay, one replaces a standard part, one is a gauge on the dashboard and one is a new button. The kit has one label per part, so the factory knows where each part goes. The comparison stops in one place. A vLLM server is like several copies of the car, one per process. The engine-bay parts go into every copy, and the dashboard parts go into the front copy only.

The picture#

Figure: the five files of the toy package, its three entry points, and what each one registers (panel 1). Panel 2: the setting that makes each part act.
Open full size ↗

Read panel 1 row by row, from the file to the entry point to the effect. Green parts run in every process; blue parts run only in the front end. Panel 2 is the checklist for the start command in stop 8.

StopWhat you write or doTemplate in the repository
1Package layout and entry pointstests/plugins/*/setup.py; vLLM's pyproject.toml
2The general plugin: register a lazy modelvllm_add_dummy_model/__init__.py
3The model classvllm_add_dummy_model/my_opt.py
4The op replacement with register_ootvllm_add_dummy_platform/dummy_custom_ops.py
5The switch that makes the op runvllm_add_dummy_platform/dummy_platform.py
6A stat loggervllm_add_dummy_stat_logger
7An endpointvllm_add_dummy_endpoint_plugin
8Install and startCI steps in .buildkite/test_areas/plugins.yaml
9Check that each part loadedthe log lines of vllm/plugins/__init__.py and others

1Lay out the package and publish the entry points#

A plugin package is a normal Python package. vLLM finds it only through its entry points. The toy has five modules:

vllm-toy-plugin/
  pyproject.toml
  vllm_toy_plugin/
    __init__.py       # register(): the general plugin (stop 2)
    model.py          # ToyLlamaForCausalLM (stop 3)
    ops.py            # ToyRMSNorm (stop 4)
    stat_logger.py    # ToyStatLogger (stop 6)
    endpoint.py       # ToyEndpointPlugin (stop 7)

The test plugins use setup.py with an entry_points dictionary. The stat logger test plugin is the shortest example:

setup(
    name="dummy_stat_logger",
    version="0.1",
    packages=["dummy_stat_logger"],
    entry_points={
        "vllm.stat_logger_plugins": [
            "dummy_stat_logger = dummy_stat_logger.dummy_stat_logger:DummyStatLogger"  # noqa
        ]
    },
)

vLLM itself uses the newer pyproject.toml form for its own general plugins:

[project.entry-points."vllm.general_plugins"]
lora_filesystem_resolver = "vllm.plugins.lora_resolvers.filesystem_resolver:register_filesystem_resolver"
lora_hf_hub_resolver = "vllm.plugins.lora_resolvers.hf_hub_resolver:register_hf_hub_resolver"

The toy uses the same form, with one table per plugin group:

# pyproject.toml (illustrative)
[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"

[project]
name = "vllm-toy-plugin"
version = "0.1.0"

[tool.setuptools]
packages = ["vllm_toy_plugin"]

[project.entry-points."vllm.general_plugins"]
toy_register = "vllm_toy_plugin:register"

[project.entry-points."vllm.stat_logger_plugins"]
toy_stat_logger = "vllm_toy_plugin.stat_logger:ToyStatLogger"

[project.entry-points."vllm.endpoint_plugins"]
toy_endpoint = "vllm_toy_plugin.endpoint:ToyEndpointPlugin"

The left side of each line is the entry-point name. VLLM_PLUGINS matches this name exactly, not the package name (vllm/plugins/__init__.py:63-64). The right side is module:attribute. Each group expects a different kind of object. The general group needs a function, the stat logger group a StatLoggerBase subclass, and the endpoint group a factory with no arguments. The table in How vLLM finds and loads plugins lists all six groups.

2Write the general plugin: register the model lazily#

vLLM calls a general plugin function with no arguments, once in each process (vllm/plugins/__init__.py:82-90). With TP = 2 that means four processes, and the function must do the same thing each time. The model test plugin shows the pattern: check first, then register. Its last two registrations use the lazy string form:

    # Test passing lazy model
    if "MyGemma2Embedding" not in ModelRegistry.get_supported_archs():
        ModelRegistry.register_model(
            "MyGemma2Embedding",
            "vllm_add_dummy_model.my_gemma_embedding:MyGemma2Embedding",
        )

    if "MyLlava" not in ModelRegistry.get_supported_archs():
        ModelRegistry.register_model("MyLlava", "vllm_add_dummy_model.my_llava:MyLlava")

The toy does the same for one architecture, and also imports the module with the op replacement:

# vllm_toy_plugin/__init__.py (illustrative)
def register():
    """General plugin. vLLM calls it once in every process."""
    from vllm import ModelRegistry

    # The model: a "module:Class" string. Nothing is imported now.
    if "ToyLlamaForCausalLM" not in ModelRegistry.get_supported_archs():
        ModelRegistry.register_model(
            "ToyLlamaForCausalLM", "vllm_toy_plugin.model:ToyLlamaForCausalLM"
        )

    # The op: importing the module runs @RMSNorm.register_oot (stop 4).
    import vllm_toy_plugin.ops  # noqa: F401

The page Out-of-tree models and the model registry explains the registry and its lazy entries.

3Write the model class#

The model test plugin subclasses an in-tree model and changes one method:

class MyOPTForCausalLM(OPTForCausalLM):
    def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
        # this dummy model always predicts the first token
        logits = super().compute_logits(hidden_states)
        if logits is not None:
            logits.zero_()
            logits[:, 0] += 1.0
        return logits

The toy subclasses Llama and changes nothing, so it runs any Llama-architecture checkpoint under a new name:

# vllm_toy_plugin/model.py (illustrative)
from vllm.model_executor.models.llama import LlamaForCausalLM


class ToyLlamaForCausalLM(LlamaForCausalLM):
    """Llama under a new architecture name. Override methods here."""

The class inherits the constructor __init__(self, *, vllm_config, prefix="", ...) (vllm/model_executor/models/llama.py:484-490). That matters: initialize_model checks for both parameter names, and otherwise warns and guesses old arguments (vllm/model_executor/model_loader/utils.py:96-115). The registry also checks the structure, for example embed_input_ids and compute_logits (vllm/model_executor/models/interfaces_base.py:69-76, vllm/model_executor/models/interfaces_base.py:140-147, Model capability protocols (SupportsPP, SupportsLoRA, ...)).

vLLM finds the class by the architecture name in the checkpoint's config.json. To use a normal Llama checkpoint, rename the architecture at start-up. A vLLM test does the same for its OPT model (tests/distributed/test_distributed_oot.py:20-21):

--hf-overrides '{"architectures": ["ToyLlamaForCausalLM"]}'

On the first start, the front end inspects the lazy entry in a subprocess and caches the result on disk (vllm/model_executor/models/registry.py:1088-1116). That subprocess runs register() too (vllm/model_executor/models/registry.py:1612). Later starts read the cache, until model.py changes.

4Replace an op with register_oot#

The platform test plugin replaces the rotary embedding layer. The decorator goes on a subclass of the in-tree class:

from vllm.model_executor.layers.rotary_embedding import RotaryEmbedding


# Register CustomRotaryEmbedding to CustomOP.
@RotaryEmbedding.register_oot
class DummyRotaryEmbedding(RotaryEmbedding):
    """Original rotary positional embedding."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.addition_config = True

    def forward_oot(self, *args, **kwargs) -> tuple[torch.Tensor, torch.Tensor]:
        return super().forward_oot(*args, **kwargs)

The toy replaces RMSNorm, which every Llama decoder layer builds (vllm/model_executor/models/llama.py:308-309):

# vllm_toy_plugin/ops.py (illustrative)
import torch

from vllm.model_executor.layers.layernorm import RMSNorm


@RMSNorm.register_oot            # the key is "RMSNorm", the class name
class ToyRMSNorm(RMSNorm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.toy = True            # a visible mark: the swap happened

    def forward_cuda(self, x: torch.Tensor, residual: torch.Tensor | None = None):
        # Your CUDA kernel goes here. The toy calls the in-tree path.
        return super().forward_cuda(x, residual)

The decorator stores the class under a key and gives the class that key as its name:

    @classmethod
    def register_oot(cls, _decorated_op_cls=None, name: str | None = None):
        def decorator(op_cls):
            reg_name = name if name is not None else cls.__name__
            assert reg_name not in op_registry_oot, f"Duplicate op name: {reg_name}"
            op_cls.name = reg_name
            op_registry_oot[reg_name] = op_cls
            return op_cls

        if _decorated_op_cls is None:
            # Called with parentheses: @CustomOP.register_oot()
            # or @CustomOP.register_oot(name="...")
            # So, _decorated_op_cls is None.
            # We return the actual decorator function.
            return decorator
        elif isinstance(_decorated_op_cls, type):  # Check if it's a class
            # Called without parentheses: @CustomOP.register_oot
            # The first argument is the class itself.
            # We call the 'decorator' function immediately with the class.
            return decorator(_decorated_op_cls)
        else:
            # Handle other unexpected cases if necessary
            raise TypeError("Decorator can only be applied to classes.")
  1. Line 337. Without name=, the key is the name of the class on which you call the decorator: RMSNorm.register_oot gives "RMSNorm".
  2. Lines 338-340. A second registration with the same key fails the assert. The toy class gets name = "RMSNorm".
  3. Lines 349-353. @RMSNorm.register_oot without parentheses passes the class as the first argument.
  4. Lines 354-356. A string as the first argument raises TypeError.

When a Llama layer calls RMSNorm(...), CustomOp.__new__ finds "RMSNorm" in the out-of-tree registry and makes a ToyRMSNorm instead (vllm/model_executor/custom_op.py:107-126). The lookup uses the Python class name of the called class (line 109). So if you use the keyword form, the value must be the class name: @CustomOp.register_oot(name="RMSNorm"), not name="rms_norm". (Derived from lines 109 and 117.)

5Make the op actually run#

Each op picks one method when it is built. A disabled op takes forward_native; an enabled op takes the method of the platform (vllm/model_executor/custom_op.py:183-205). Whether an op is enabled depends on the list compilation_config.custom_ops:

        enabled = f"+{cls.name}" in custom_ops
        disabled = f"-{cls.name}" in custom_ops
        if enabled and disabled:
            raise ValueError(
                "custom_ops cannot both enable and disable the same operation: "
                f"{cls.name}. Remove either the '+' or '-' directive"
            )

        return (CustomOp.default_on() or enabled) and not disabled

The base mode is "all" or "none". vLLM adds it after it parses the flags:

        if all(s not in self.compilation_config.custom_ops for s in ("all", "none")):
            if (
                self.compilation_config.backend == "inductor"
                and self.compilation_config.mode != CompilationMode.NONE
            ):
                self.compilation_config.custom_ops.append("none")
            else:
                self.compilation_config.custom_ops.append("all")

With the default compilation (Inductor, mode VLLM_COMPILE), the base mode is "none". The page CustomOp, out-of-tree ops and IR op providers gives the full rule. So you have two switches:

SettingEffect on ToyRMSNorm on CUDA
nothing (default)disabled: forward_native
--compilation-config '{"custom_ops": ["+RMSNorm"]}'enabled: ToyRMSNorm.forward_cuda. vLLM still appends "none", so the other ops stay off.
--compilation-config '{"custom_ops": ["+rms_norm"]}'disabled: forward_native, with a WARNING that the switch "has no effect"
--enforce-eagermode NONE, so base mode "all": every op enabled, the toy included (vllm/config/vllm.py:1745-1746)

The name quirk. The in-tree class registered the op name rms_norm (vllm/model_executor/layers/layernorm.py:38-39). But register_oot gave ToyRMSNorm the name "RMSNorm" (stop 4, line 339), and enabled() builds the switch from that name (line 282). So +rms_norm matches nothing in this model, and custom_op_log_check warns (vllm/config/compilation.py:1341-1361). (Derived from the cited lines; no test covers it.)

Which method runs on an enabled op depends on the platform. On CUDA it is forward_cuda (line 205 of custom_op.py). On an out-of-tree platform it is forward_oot (lines 202-203). The test platform turns every op on in its config hook (tests/plugins/vllm_add_dummy_platform/vllm_add_dummy_platform/dummy_platform.py:21-23). A general plugin such as the toy has no config hook, so the user must set the switch.

6Add a stat logger#

A stat logger receives the scheduler and iteration statistics in the front end. The test plugin implements the minimal interface:

class DummyStatLogger(StatLoggerBase):
    """A dummy stat logger for testing purposes.
    Implements the minimal interface expected by StatLoggerManager.
    """

    def __init__(self, vllm_config, engine_idx=0):
        self.vllm_config = vllm_config
        self.engine_idx = engine_idx
        self.recorded = []
        self.logged = False
        self.engine_initialized = False

    def record(
        self, scheduler_stats, iteration_stats, mm_cache_stats=None, engine_idx=0
    ):
        self.recorded.append(
            (scheduler_stats, iteration_stats, mm_cache_stats, engine_idx)
        )

    def log(self):
        self.logged = True

    def log_engine_initialized(self):
        self.engine_initialized = True

The base class has three abstract methods: the constructor, record and log_engine_initialized (vllm/v1/metrics/loggers.py:54-81). The toy counts the generated tokens and prints them:

# vllm_toy_plugin/stat_logger.py (illustrative)
from vllm.logger import init_logger
from vllm.v1.metrics.loggers import StatLoggerBase

logger = init_logger("vllm.toy_plugin")    # a child of "vllm", so INFO lines show


class ToyStatLogger(StatLoggerBase):
    def __init__(self, vllm_config, engine_index: int = 0):
        self.engine_index = engine_index
        self.generated = 0

    def record(self, scheduler_stats, iteration_stats,
               mm_cache_stats=None, engine_idx=0):
        if iteration_stats is not None:
            self.generated += iteration_stats.num_generation_tokens

    def log(self):
        logger.info("toy: engine %d generated %d tokens",
                    self.engine_index, self.generated)

    def log_engine_initialized(self):
        logger.info("toy: engine %d is ready", self.engine_index)

7Add an endpoint#

An endpoint plugin adds HTTP routes. Its entry point gives a factory with no arguments, and the object must have name, required_tasks, attach_router and init_state (vllm/plugins/endpoint_plugins/interface.py:49-94). The test plugin uses the class itself as the factory:

class DummyAdminEndpointPlugin:
    name = "dummy_admin_endpoint_plugin"
    required_tasks: tuple[str, ...] | None = None

    def attach_router(self, app: FastAPI) -> None:
        @app.get("/v1/admin/scheduler_config")
        async def scheduler_config(raw_request: Request):
            engine_client = raw_request.app.state.dummy_engine_client
            if engine_client is None:
                raise HTTPException(
                    status_code=503,
                    detail="scheduler_config requires an engine, which this "
                    "server does not have",
                )
            results = await engine_client.collective_rpc("get_scheduler_config")
            return {"scheduler_config": results}

    async def init_state(self, engine_client, state, args) -> None:
        state.dummy_engine_client = engine_client

The toy route needs no engine, so its init_state does nothing:

# vllm_toy_plugin/endpoint.py (illustrative)
from fastapi import FastAPI


class ToyEndpointPlugin:
    name = "toy_endpoint"        # use the entry-point name here too
    required_tasks = None        # no task needed

    def attach_router(self, app: FastAPI) -> None:
        @app.get("/v1/toy/hello")
        async def hello():
            return {"plugin": "vllm_toy_plugin", "status": "loaded"}

    async def init_state(self, engine_client, state, args) -> None:
        pass                     # this route does not use the engine
  1. Phase A. build_app calls attach_endpoint_plugins after the core routes (vllm/entrypoints/launchers/app.py:47-51). A plugin route with the same path and method as a core route replaces it (vllm/plugins/endpoint_plugins/interface.py:72-74).
  2. Phase B. init_endpoint_plugins_state awaits init_state of each attached plugin (vllm/plugins/endpoint_plugins/interface.py:163-164).

The interface module says that an endpoint should not open new paths into the engine. A plugin that needs engine-side code pairs the endpoint with a general plugin (vllm/plugins/endpoint_plugins/interface.py:5-16). The page Plugins in the API server explains the route replacement in detail.

8Install and start the server#

CI installs a test plugin in editable mode, then runs the tests (.buildkite/test_areas/plugins.yaml:37-39). Do the same with the toy, in the same Python environment as vLLM:

pip install -e ./vllm-toy-plugin

# List EVERY plugin you need: the endpoint forces an allowlist (stop 7).
export VLLM_PLUGINS=toy_register,toy_stat_logger,toy_endpoint

vllm serve meta-llama/Llama-3.2-1B-Instruct \
    --hf-overrides '{"architectures": ["ToyLlamaForCausalLM"]}' \
    --compilation-config '{"custom_ops": ["+RMSNorm"]}'

curl http://localhost:8000/v1/toy/hello

The checkpoint is an example: any Llama-architecture checkpoint works. Three rules about VLLM_PLUGINS matter here:

  • The value is split at commas, and the parts are not stripped (vllm/envs.py:1163-1167). Do not put spaces after the commas.
  • The list also filters the platform group and vLLM's own general plugins. This list drops the two LoRA resolvers (pyproject.toml:45-47). Add lora_filesystem_resolver if you use it.
  • An empty string is a list that matches nothing (vllm/plugins/__init__.py:107-109).

With TP = 2, register() runs in four processes; the stat logger and the endpoint load once, in the front end. The tour Tour: Life of a plugin shows where.

9Check that each part loaded#

Start with VLLM_LOGGING_LEVEL=DEBUG the first time. The default level is INFO (vllm/envs.py:860), and some of the lines below are DEBUG lines. The table lists what to look for, in start-up order:

PartLog lineLevelWhere in the code
discovery, general group"Available plugins for group vllm.general_plugins:" and "- toy_register -> vllm_toy_plugin:register"DEBUGvllm/plugins/__init__.py:48-54
allowlist"Loading plugin toy_register"DEBUG (general group); INFO (other groups)vllm/plugins/__init__.py:65-66
broken import"Failed to load plugin ..." with a tracebackERRORvllm/plugins/__init__.py:71-72
model cache"Cache model info for class ... miss" on the first startDEBUGvllm/model_executor/models/registry.py:1100-1104
model"Resolved architecture: ToyLlamaForCausalLM"INFOvllm/config/model.py:744
stat logger, discovery"Available plugins for group vllm.stat_logger_plugins:"INFOvllm/plugins/__init__.py:52-54
op swap (each worker)"Instantiating custom op: RMSNorm using ..."DEBUGvllm/model_executor/custom_op.py:121-125
op switch (each worker)"enabled custom ops: ..." should contain RMSNormDEBUGvllm/config/compilation.py:1329-1330
stat logger, readythe toy's own "toy: engine 0 is ready"INFOvllm/v1/engine/async_llm.py:214
endpoint"Loaded endpoint plugin toy_endpoint"INFOvllm/plugins/__init__.py:156

You can also check the first two parts without a server, in one Python process. The functions below are the ones that vLLM itself calls; the test of the stat logger plugin uses the loader the same way (tests/plugins_tests/test_stats_logger_plugins.py:13-21):

# quick check (illustrative)
from vllm.plugins import load_general_plugins
from vllm.v1.metrics.loggers import load_stat_logger_plugin_factories

load_general_plugins()                       # runs toy_register once
from vllm import ModelRegistry
from vllm.model_executor.custom_op import op_registry_oot
print("ToyLlamaForCausalLM" in ModelRegistry.get_supported_archs())   # True
print(op_registry_oot)                       # {'RMSNorm': <class 'vllm_toy_plugin.ops.ToyRMSNorm'>}
print(load_stat_logger_plugin_factories())   # [<class 'vllm_toy_plugin.stat_logger.ToyStatLogger'>]

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

Which process imports which toy module

For vllm serve -tp 2 with the default spawn start method. (Derived from the call sites cited on this page and on How vLLM finds and loads plugins.)

ModuleFront endEngine coreEach workerRegistry subprocess (cache miss only)
__init__.py (register)yesyes (vllm/v1/engine/core.py:132)yes (vllm/v1/worker/worker_base.py:284)yes (vllm/model_executor/models/registry.py:1612)
ops.pyyesyesyesyes
model.pyno, for a plain text model (derived)no, for a plain text model (derived)yes, when it builds the model (vllm/model_executor/model_loader/utils.py:305-313)yes: this is the inspection
stat_logger.pyyes (vllm/v1/engine/async_llm.py:156)nonono
endpoint.pyyes, if allowlisted (vllm/entrypoints/launchers/app.py:51)nonono

ops.py runs in every process because register() imports it. Only the workers build layers, so only there does the swap matter. The import in the other processes is cheap but not free: it imports layernorm.py.

The "no" for model.py is derived from the callers of get_model_cls and resolve_model_cls at this commit. The front end and the engine core use only the model info, which the registry subprocess or the disk cache gives (stop 3). Some features ask the loader for the class and so import it: multimodal input processing (vllm/multimodal/registry.py:115-119), speech-to-text serving (vllm/entrypoints/speech_to_text/base/serving.py:158-160), scoring (vllm/entrypoints/pooling/scoring/io_processor.py:457-460) and a model-declared KV cache grouping (vllm/v1/core/kv_cache_planning.py:1669-1681).

Deep dive

Two ways to bring a layer: replacement or platform method

General plugin with register_oot (this toy)Platform plugin with forward_oot
What changesthe class that the model buildsthe method that an enabled op calls
Platformany; also CUDAonly the out-of-tree platform (vllm/model_executor/custom_op.py:202-203)
Turned on bythe user: +RMSNorm or base mode "all"the platform's check_and_update_config can set custom_ops (tests/plugins/vllm_add_dummy_platform/vllm_add_dummy_platform/dummy_platform.py:21-23)
Method to overridethe one of the current platform, for example forward_cudaforward_oot
Conflictsa second register_oot with the same key fails the assert (vllm/model_executor/custom_op.py:338)only one out-of-tree platform can be active (vllm/platforms/__init__.py:272-276)

The test platform combines both: a platform plugin and a general plugin that calls register_oot (tests/plugins/vllm_add_dummy_platform/setup.py:10-17). The page CustomOp, out-of-tree ops and IR op providers also explains the IR op providers, a second level of kernel choice inside RMSNorm.forward_native.

Deep dive

Testing the toy like vLLM tests its own plugins

vLLM's CI runs the plugin model tests in a fresh process: "it needs a clean process" (.buildkite/test_areas/plugins.yaml:50-51). The reason is the once-per-process guard: after the first load_general_plugins(), a test in the same process cannot load a changed plugin again (vllm/plugins/__init__.py:83-85).

What can go wrong#

  • A plugin function that is not re-entrant. vLLM calls it once per process, but in every process (vllm/plugins/__init__.py:82-90). The design doc asks for a function that "can be called multiple times without causing issues" (docs/design/plugin_system.md:60). Check the registry before you register, as the test plugin does. Keep decorators at module level. A decorator inside the function body runs again when your own code or a test calls the function twice. Then register_oot fails its assert (vllm/model_executor/custom_op.py:338). Side effects outside the process, such as a file write, happen once in each process: four times or more with TP = 2.
  • Eager class registration. register_model("ToyLlamaForCausalLM", ToyLlamaForCausalLM) imports the model module in every process and inspects it at once (vllm/model_executor/models/registry.py:1194-1195). The docstring gives the main risk: an import that starts CUDA, and then "Cannot re-initialize CUDA in forked subprocess" (vllm/model_executor/models/registry.py:1167-1173). vllm serve uses spawn, and vLLM switches to spawn when CUDA is already started (vllm/utils/system_utils.py:145-162). But vLLM used as a library defaults to fork (vllm/envs.py:936-940). Use the string form.
  • VLLM_PLUGINS disables the LoRA resolvers. The endpoint forces you to set the list (stop 7). Every name that is not in it is skipped, also vLLM's own lora_filesystem_resolver and lora_hf_hub_resolver (pyproject.toml:45-47). The loader does not log a skipped name, and the general group logs its plugin list only at DEBUG level. So nothing shows at INFO (vllm/plugins/__init__.py:48-66).
  • The wrong switch name. +rms_norm does not enable ToyRMSNorm; +RMSNorm does (stop 5).
  • Your INFO lines do not show. A logger that is not under vllm. has no vLLM handler and gets the WARNING level of the root logger (stop 6).
  • A broken import is only logged. A typo in the entry-point value gives "Failed to load plugin ..." and the server starts without the part (vllm/plugins/__init__.py:71-72). An exception inside register() is not caught and stops the process (vllm/plugins/__init__.py:89-90).
  • A stale entry point. You renamed a function but did not install the package again. The old value is still in the entry-point metadata, and the import fails (Python entry points).

Why it matters. Most plugin bugs are not in the plugin's own logic. They are in the wiring: the entry-point name, the allowlist, the switch name, the process. The log lines of stop 9 check the wiring before you debug any kernel or model code.

Check yourself#

  1. 1
    Which VLLM_PLUGINS value do you need for the toy with its endpoint, and why can you not leave the variable unset?
    easy
    Show answer

    toy_register,toy_stat_logger,toy_endpoint, with no spaces. Endpoint plugins load only when VLLM_PLUGINS is set and names them (vllm/plugins/__init__.py:122-131). The list then filters every other group too, so the other two names must be in it (vllm/plugins/__init__.py:63-64).

  2. 2
    What is wrong with register_model("ToyLlamaForCausalLM", "vllm_toy_plugin.model.ToyLlamaForCausalLM")?
    easy
    Show answer

    The string has no colon. The lazy form must be "module:Class" with exactly one colon, else register_model raises ValueError (vllm/model_executor/models/registry.py:1187-1191).

  3. 3
    You start the server with no compilation flags. Is ToyRMSNorm built? Does its forward_cuda run?
    medium
    Show answer

    It is built: CustomOp.__new__ swaps every RMSNorm for it (vllm/model_executor/custom_op.py:117-126). Its forward_cuda does not run. The default base mode is "none" (vllm/config/vllm.py:1819-1824), so the op is disabled and uses forward_native (vllm/model_executor/custom_op.py:189-192).

  4. 4
    You pass --compilation-config '{"custom_ops": ["+rms_norm"]}'. What happens, and why?
    hard
    Show answer

    ToyRMSNorm stays disabled, and vLLM warns that the switch has no effect. register_oot set the attribute name of the class to the key "RMSNorm" (vllm/model_executor/custom_op.py:337-339). enabled() looks for "+RMSNorm" (vllm/model_executor/custom_op.py:282). After the model build, rms_norm is not among the op names of the model, so custom_op_log_check logs the warning (vllm/config/compilation.py:1341-1361). (Derived.)

  5. 5
    Your ToyStatLogger uses logging.getLogger(__name__), which gives the name vllm_toy_plugin.stat_logger. Its logger.info lines never show. Why, and what is the fix?
    hard
    Show answer

    vLLM attaches its handler only to the logger "vllm" (vllm/logger.py:100-105). A logger outside that tree has no level of its own. It gets the level of Python's root logger, WARNING by default, and drops INFO records. The root logger also has no handler by default, so Python prints only warnings and errors, through its last-resort handler. Use a name under vllm., for example init_logger("vllm.toy_plugin") (vllm/logger.py:328-335). (Derived from the logging config and the Python logging rules.)