vLLM Explained

Structured outputs: JSON schemas, grammars and token bitmasks in vLLM

AdvancedvLLM conceptsabout 13 min read + 4 min deep dives

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

In one sentence

vLLM compiles a request's constraint into a grammar in the engine core. Every step then masks the logits, so only allowed tokens can be sampled.

The idea#

Structured outputs force the answer into a format. Examples are valid JSON for a schema, a string that matches a regex, one of a list of choices, or text of a grammar. The method is a token mask. At each step, a grammar state machine says which tokens may come next, and the sampler sees -inf for all others (Structured outputs: token masks). This page shows how vLLM splits that work between its processes.

The API server turns the request fields into one constraint and picks a structured output backend for it. The engine core compiles the constraint into a grammar matcher, in a thread pool, while the request waits. In every step, the engine core fills a grammar bitmask row for each constrained request; the worker applies it to the logits on the GPU. After the step, the engine core feeds the sampled token to the matcher.

An everyday comparison. Think of a form with boxes that accept only digits. Before the clerk starts, someone prints the form (compile). For every character, the form shows which keys still work (the bitmask), and the clerk can press only those. The comparison stops being true in one place: vLLM's clerk may write freely for a while first. While a reasoning model thinks, the mask allows every token; the form starts only after the thinking ends.

The picture#

Figure: the API server builds and validates the constraint (panel 1), the engine core compiles it once (panel 2), and every step masks the logits with a fresh bitmask row (panel 3).
Open full size ↗

Read the panels from top to bottom; each panel runs in a different place. Blue is the API server, violet and teal are the engine core process, amber is the worker on the GPU. The parameter strip shows the bitmask size for a 151,936-token vocabulary: 4,748 int32 words, 18,992 bytes per row.

In the code#

Step 1: the request fields#

Three fields lead to a constraint. All end in one StructuredOutputsParams inside SamplingParams:

The sampling parameters are made with structured_outputs=self.extract_structured_outputs() for chat and for completions (vllm/entrypoints/openai/chat_completion/protocol.py:801, vllm/entrypoints/openai/completion/protocol.py:427).

Step 2: StructuredOutputsConfig and the backend choice#

StructuredOutputsBackend = Literal[
    "auto", "xgrammar", "guidance", "outlines", "lm-format-enforcer"
]


@config
class StructuredOutputsConfig:
    """Dataclass which contains structured outputs config for the engine."""

    backend: StructuredOutputsBackend = "auto"
    """Which engine will be used for structured outputs (e.g. JSON schema,
    regex, etc) by default. With "auto", we will make opinionated choices
    based on request contents and what the backend libraries currently support,
    so the behavior is subject to change in each release."""
    disable_any_whitespace: bool = False
    """If `True`, json output will always be compact without any whitespace.
    If `False`, the model may generate whitespace between JSON fields,
    which is still valid JSON. This is only supported for xgrammar
    and guidance backends."""
    disable_additional_properties: bool = False
    """If `True`, the `guidance` backend will not use `additionalProperties`
    in the JSON schema. This is only supported for the `guidance` backend and
    is used to better align its behaviour with `outlines` and `xgrammar`."""
    reasoning_parser: str = ""
    """Select the reasoning parser depending on the model that you're using.
    This is used to parse the reasoning content into OpenAI API format."""
    reasoning_parser_plugin: str = ""
    """Path to a dynamically reasoning parser plugin that can be dynamically
    loaded and registered."""
    enable_in_reasoning: bool = False
    """Whether to use structured input for reasoning."""

The backend is set with --structured-outputs-config.backend; the default is auto. A search of vllm/ finds no other assignment to this field; EngineArgs merges only the reasoning parser into this config (vllm/engine/arg_utils.py:2187-2204). The choice happens per request, when InputProcessor validates the sampling parameters inside AsyncLLM.add_request (vllm/v1/engine/input_processor.py:157-162):

        else:
            # NOTE: backend must be "auto" here, because we have
            # checked supported_backends above.
            # In this mode, we set opinionated defaults based on what we think
            # will satisfy the most use cases without having to worry about
            # this setting. We include fallback behavior here, but not with any
            # other setting where a specific backend was specified.
            try:
                validate_xgrammar_grammar(self)
                self.structured_outputs._backend = "xgrammar"
            except VLLMValidationError:
                # The request either failed validation
                # or includes some jsonschema feature(s) that
                # are not supported in xgrammar.

                skip_guidance = _is_non_tekken_mistral(tokenizer)

                # Check if schema has features unsupported by guidance
                so_params = self.structured_outputs
                if not skip_guidance and so_params.json:
                    if isinstance(so_params.json, str):
                        try:
                            schema = json_mod.loads(so_params.json)
                        except json_mod.JSONDecodeError as e:
                            raise VLLMValidationError(
                                "Invalid JSON grammar specification."
                            ) from e
                    else:
                        schema = so_params.json
                    skip_guidance = has_guidance_unsupported_json_features(schema)

                if skip_guidance:
                    # Fall back to outlines for non-Tekken Mistral tokenizers or
                    # schemas containing features unsupported by guidance.
                    validate_structured_output_request_outlines(self)
                    self.structured_outputs._backend = "outlines"
                else:
                    # Fall back to guidance by default.
                    if not GuidanceBackend.is_tokenizer_supported(tokenizer):
                        raise VLLMValidationError(
                            "No compatible structured output backend was found for "
                            "this request. XGrammar rejected the constraint, and "
                            "Guidance does not support this tokenizer. Configure a "
                            "fast tokenizer or select a compatible backend explicitly."
                        ) from None
                    validate_guidance_grammar(
                        self,
                        tokenizer=_get_llg_tokenizer(tokenizer),
                    )
                    self.structured_outputs._backend = "guidance"
            # Remember that this backend was set automatically
            self.structured_outputs._backend_was_auto = True
  1. Lines 1397-1399: auto tries xgrammar first. If xgrammar accepts the constraint, the request uses xgrammar.
  2. Lines 1405-1425: else outlines, for a non-Tekken Mistral tokenizer or a JSON schema with features that guidance does not support.
  3. Lines 1427-1439: else guidance, if the tokenizer is a fast Hugging Face tokenizer; else HTTP 400.
  4. Line 1441: _backend_was_auto remembers that the choice was automatic.

A Tekken Mistral tokenizer with a Lark grammar goes to guidance directly (vllm/sampling_params.py:1374-1389). A fixed backend (not auto) validates with that backend only and has no fallback (vllm/sampling_params.py:1336-1373).

Step 3: compile in the engine core, wait in the scheduler#

The engine core's input thread turns the EngineCoreRequest into a Request and starts the grammar at once (vllm/v1/engine/core.py:1128-1135). A request with a constraint starts in the status WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR, not WAITING (vllm/v1/request.py:112-117).

        request_backend = structured_outputs._backend
        assert request_backend is not None
        if self.backend_name is not None and request_backend != self.backend_name:
            raise VLLMValidationError(
                "Structured output processing only supports one backend per engine. "
                f"The engine is already using '{self.backend_name}', but this "
                f"request resolved to '{request_backend}'. Configure "
                "`structured_outputs_config.backend` explicitly or use schemas "
                "supported by the initialized backend."
            )

        # Initialize the backend the first time it is needed.
        #
        # NOTE: We only support a single backend. We do NOT support different
        # backends on a per-request basis in V1 (for now, anyway...).
        # _backend is set in Processor._validate_structured_output
        if self.backend is None:
            vocab_size = self.vllm_config.model_config.get_vocab_size()
            if request_backend == "xgrammar":
                self.backend = XgrammarBackend(
                    self.vllm_config,
                    tokenizer=self.tokenizer,
                    vocab_size=vocab_size,
                )
            elif request_backend == "guidance":
                self.backend = GuidanceBackend(
                    self.vllm_config,
                    tokenizer=self.tokenizer,
                    vocab_size=vocab_size,
                )
            elif request_backend == "outlines":
                from vllm.v1.structured_output.backend_outlines import OutlinesBackend

                self.backend = OutlinesBackend(
                    self.vllm_config,
                    tokenizer=self.tokenizer,
                    vocab_size=vocab_size,
                )
            elif request_backend == "lm-format-enforcer":
                from vllm.v1.structured_output.backend_lm_format_enforcer import (  # noqa: E501
                    LMFormatEnforcerBackend,
                )

                self.backend = LMFormatEnforcerBackend(
                    self.vllm_config,
                    tokenizer=self.tokenizer,
                    vocab_size=vocab_size,
                )
            else:
                raise ValueError(
                    f"Unsupported structured output backend: {request_backend}"
                )
            self.backend_name = request_backend

        grammar: Future[StructuredOutputGrammar] | StructuredOutputGrammar
        if self._use_async_grammar_compilation:
            grammar = self.executor.submit(self._create_grammar, request)
        else:
            try:
                grammar = self._create_grammar(request)
            except Exception as e:
                grammar = Future()
                grammar.set_exception(e)
        request.structured_output_request.grammar = grammar
  1. Lines 143-150: the engine has one backend. A request with another backend than the one already built gets VLLMValidationError. The input thread finishes the request with a validation error, which the client sees as HTTP 400, or as an error chunk in a stream (vllm/v1/engine/core.py:1999-2005, vllm/v1/engine/core.py:2158-2167).
  2. Lines 157-193: the first constrained request builds the backend.
  3. Lines 196-197: the compile runs in a ThreadPoolExecutor with (CPU count + 1) // 2 workers (vllm/v1/structured_output/__init__.py:95-96); the request keeps a Future. With the external_launcher executor the compile runs at once instead, so that every rank switches state at the same time (vllm/v1/structured_output/__init__.py:65-74).

The scheduler's waiting pass skips the request until the Future is done (vllm/v1/core/sched/scheduler.py:3291-3303). Then the status becomes WAITING, and the request is scheduled like any other. A compile error finishes the request with FINISHED_ERROR and a validation_error, which the front end turns into VLLMValidationError (vllm/v1/core/sched/scheduler.py:2399-2425, vllm/outputs.py:30-35).

The xgrammar backend keeps two compilers with a cache. One is for JSON, regex, choice and EBNF grammars; it never allows special tokens. The other is for structural tags and Lark grammars, which may need special tokens (vllm/v1/structured_output/backend_xgrammar.py:84-112). The cache is limited by VLLM_XGRAMMAR_CACHE_MB, 512 MB by default (vllm/v1/structured_output/backend_xgrammar.py:215-224, vllm/envs.py:1689). So the same schema compiles fast the second time (cache inside xgrammar; library behaviour).

Step 4: the bitmask of each step#

In the default engine loop, the engine core schedules a step, starts the forward pass, and then asks for the bitmask while the GPU runs (vllm/v1/engine/core.py:724-745):

    def get_grammar_bitmask(
        self, scheduler_output: SchedulerOutput
    ) -> GrammarOutput | None:
        # Collect list of scheduled request ids that use structured output.
        # The corresponding rows of the bitmask will be in this order.
        if not scheduler_output.has_structured_output_requests:
            return None

        structured_output_request_ids = [
            req_id
            for req_id in scheduler_output.num_scheduled_tokens
            if (req := self.requests.get(req_id))
            and (req.use_structured_output and not req.is_prefill_chunk)
        ]
        if not structured_output_request_ids:
            return None

        bitmask = self.structured_output_manager.grammar_bitmask(
            self.requests,
            structured_output_request_ids,
            scheduler_output.scheduled_spec_decode_tokens,
        )
        spec_tokens = scheduler_output.scheduled_spec_decode_tokens
        num_acceptable_drafts = [
            len(strip_speculative_padding(spec_tokens.get(req_id, [])))
            for req_id in structured_output_request_ids
        ]
        return GrammarOutput(
            structured_output_request_ids, bitmask, num_acceptable_drafts
        )

Only requests that sample in this step get a row. A chunk of a long prefill that is not the last one gets none (line 2098). grammar_bitmask fills the rows (vllm/v1/structured_output/__init__.py:382-531):

  • The bitmask has max_num_seqs × (1 + num_speculative_tokens) rows, allocated once (lines 397-406). Each structured request owns mask_stride rows: one per draft position plus one for the bonus token (line 395).
  • A request that is still thinking gets a full row of ones: no constraint (lines 241-247 and 466-469).
  • A request in its format gets the matcher's mask for the next token (line 242).
  • Above 128 structured requests and without speculative decoding, up to 8 threads fill the rows in batches of 16 (lines 79-87 and 416-448).

The result goes to the worker as a GrammarOutput with a numpy array (vllm/v1/structured_output/__init__.py:523-531).

Step 5: masking on the worker#

Model Runner V2 applies the mask after compute_logits and before the sampler (The model runner (Model Runner V2)):

            sample_hidden_states = hidden_states[input_batch.logits_indices]
            logits = self.model.compute_logits(sample_hidden_states)

        invalid_drafts = None
        # A diffusion prefill has no logit rows even when a bitmask row
        # arrived for it.
        if grammar_output is not None and logits.shape[0] > 0:
            # Apply grammar bitmask to the logits in-place.
            assert self.structured_outputs_worker is not None
            self.structured_outputs_worker.apply_grammar_bitmask(
                logits,
                input_batch,
                grammar_output.structured_output_request_ids,
                grammar_output.grammar_bitmask,
            )
            invalid_drafts = grammar_invalid_drafts(
                input_batch,
                grammar_output.structured_output_request_ids,
                grammar_output.num_acceptable_drafts,
            )

        sampler_output: SamplerOutput | None
        if input_batch.num_reqs == 0:
            # This rank owns no requests this step. It contributes an
            # all-padding block to the gather below.
            sampler_output = None
        elif input_batch.num_draft_tokens == 0 or self.rejection_sampler is None:
            assert self.sampler is not None
            sampler_output = self.sampler(logits, input_batch)

StructuredOutputsWorker keeps an int32 [max_num_logits, cdiv(vocab_size, 32)] buffer on the GPU (vllm/v1/worker/gpu/structured_outputs.py:60-68). It copies the rows on a side CUDA stream, then a Triton kernel writes -inf into each logit whose bit is 0 (vllm/v1/worker/gpu/structured_outputs.py:80-124, vllm/v1/worker/gpu/structured_outputs.py:162-171). The sampler then sees only allowed tokens.

Step 6: after the step#

update_from_output feeds the new tokens to the matcher (vllm/v1/core/sched/scheduler.py:2279-2290):

    def accept_tokens(self, request: "Request", new_token_ids: list[int]) -> bool:
        """Advance grammar with accepted tokens. Returns False on rejection."""
        if not request.use_structured_output or not new_token_ids:
            return True

        structured_req = request.structured_output_request
        if TYPE_CHECKING:
            assert structured_req is not None
        grammar = structured_req.grammar
        if TYPE_CHECKING:
            assert isinstance(grammar, StructuredOutputGrammar)

        grammar_start, _ = self._get_constraint_bounds(
            request, new_token_ids, spec_tokens_committed=True
        )
        # Early return only when no reasoning-end token is present.
        # Otherwise, latch structured_req.reasoning_ended.
        if grammar_start > len(new_token_ids):
            return True
        structured_req.reasoning_ended = True
        return grammar.accept_tokens(request.request_id, new_token_ids[grammar_start:])

Tokens before the end of the thinking are not fed. When the end token is among the new tokens, reasoning_ended is latched, and the grammar starts after it (lines 545-552). If the matcher rejects a token, the request finishes with an error; the log says "Unexpected", because the mask should make this impossible (vllm/v1/core/sched/scheduler.py:2282-2290). The matcher is built with the request's stop tokens (vllm/v1/structured_output/__init__.py:216-223, vllm/v1/structured_output/backend_xgrammar.py:198-203). Two parts are library behaviour of xgrammar. At the end of the format, the mask allows a stop token. After the matcher accepts a stop token, it reports is_terminated(). vLLM then stops feeding the matcher, and any later row would be a full row (vllm/v1/structured_output/backend_xgrammar.py:271-286, vllm/v1/structured_output/__init__.py:241-247).

Step 7: async scheduling, speculative decoding and reasoning#

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 JSON-schema request, step by step

Server: vllm serve Qwen/Qwen2.5-0.5B-Instruct on one GPU, default flags (max_num_seqs from the GPU size, async scheduling on, no reasoning parser). Request (example):

{"messages": [{"role": "user", "content": "Give me a city as JSON."}],
 "response_format": {"type": "json_schema",
   "json_schema": {"name": "city", "schema": {"type": "object",
      "properties": {"name": {"type": "string"}}, "required": ["name"]}}}}
  1. API server. to_sampling_params makes StructuredOutputsParams(json={...schema...}) (vllm/entrypoints/generate/base/protocol.py:192-195). In add_request, the input processor validates with xgrammar; it accepts, so _backend = "xgrammar" and _backend_was_auto = True.
  2. Input thread. Request starts as WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR. This is the first constrained request, so the manager builds XgrammarBackend: two compilers, each with max_threads=8 (vllm/v1/structured_output/backend_xgrammar.py:215-224). The compile job goes to the pool.
  3. Scheduler. While the Future runs, each waiting pass skips the request. When it is done, the request becomes WAITING and is admitted.
  4. Prefill step. The whole prompt fits into one step, so the request is not a partial prefill chunk; it gets bitmask row 0. The matcher allows only tokens that can start a JSON object of the schema; every other token gets -inf.
  5. Size of the mask. The Qwen2.5 vocabulary has 151,936 entries (inferred from its config.json). One row is cdiv(151936, 32) = 4,748 int32 words = 18,992 bytes. With 3 structured requests in a step, the array is int32 [3, 4748], about 57 KB, copied to the GPU once per step.
  6. Decode steps. With async scheduling, the next step is scheduled while the token of this step is in flight, so pending_structured_output_tokens is true. The engine core starts the forward pass and processes the previous output, which feeds the token to the matcher. Only then does it fill the row and sample.
  7. End. After {"name": "Paris"}, the format is complete. The mask then allows a stop token of the request (here <|im_end|>), and the request finishes with finish_reason: "stop" when the model samples it.
Deep dive

Costs: where structured outputs spend time

CostWhereWhenLines
validation of the constraintAPI server, in add_requestevery requestvllm/sampling_params.py:1336-1441
grammar compileengine core thread poolper request; repeat schemas hit the xgrammar cachevllm/v1/structured_output/__init__.py:195-204
wait for the compilescheduleruntil the Future is done: adds to TTFTvllm/v1/core/sched/scheduler.py:3291-3295
fill the bitmaskengine core main thread (threads above 128 requests)every step with constrained requestsvllm/v1/structured_output/__init__.py:416-521
copy and apply the maskworker, GPUevery stepvllm/v1/worker/gpu/structured_outputs.py:70-128
deferred samplingengine core loopasync scheduling with a constrained request in decodevllm/v1/engine/core.py:737-749
advance the matcherengine core, update_from_outputevery new tokenvllm/v1/structured_output/__init__.py:533-553

The code gives no measured numbers for these costs. Measure with and without the constraint on your own workload (vllm bench serve, Benchmarking vLLM: vllm bench serve, throughput, latency and startup).

Deep dive

Errors: what fails where

SituationWhereResult
two of json, regex and choice in one requestrequest validator400 (vllm/entrypoints/openai/chat_completion/protocol.py:931-945)
a constraint and a named tool_choicerequest validator400 (vllm/entrypoints/openai/chat_completion/protocol.py:946-955)
empty choice list, empty grammar or schema, NUL in a regexinput processor400 (vllm/sampling_params.py:1266-1308)
no backend accepts the constraint (auto)input processor400 (vllm/sampling_params.py:1428-1434)
a model loaded without a tokenizer (--skip-tokenizer-init)input processor400 (vllm/sampling_params.py:1242-1245)
request resolves to another backend than the engine'sengine core input threadvalidation error: 400, or an error chunk in a stream (vllm/v1/structured_output/__init__.py:143-150)
compile failsengine core thread poolrequest finished with a validation error (vllm/v1/core/sched/scheduler.py:3296-3301)
the matcher rejects a sampled tokenupdate_from_outputrequest finished with an error, logged as unexpected (vllm/v1/core/sched/scheduler.py:2279-2290)
old guided_* fieldpydantic modelno error, no constraint, a warning in the log
Deep dive

Flags and environment variables

SettingDefaultEffect
--structured-outputs-config.backendauto (vllm/config/structured_outputs.py:20)Fix one backend; no fallback then.
--structured-outputs-config.disable_any_whitespacefalse (vllm/config/structured_outputs.py:25)Compact JSON; only xgrammar and guidance, else ValueError at start-up (vllm/config/structured_outputs.py:62-66).
--structured-outputs-config.disable_additional_propertiesfalseGuidance only.
--structured-outputs-config.enable_in_reasoningfalse (vllm/config/structured_outputs.py:40)Constrain the thinking too.
--reasoning-parser""Lets the engine core find the end of the thinking.
VLLM_XGRAMMAR_CACHE_MB512 (vllm/envs.py:1689)Size limit of the xgrammar compile cache. The type stub at vllm/envs.py:226 says 0; the reader returns 512.

What can go wrong#

  • Unconstrained output with HTTP 200. The request uses guided_json or another removed field (Step 1).
  • JSON appears only after a long thinking text. With a reasoning parser, the grammar starts after the thinking (Step 7). Set enable_in_reasoning or turn the thinking off.
  • The JSON is cut off. max_tokens ended the request before the matcher reached a final state; check finish_reason: "length".
  • Slow first token for new schemas. The request waits for its compile (Step 3). Repeat schemas hit the cache.
  • Lower throughput with async scheduling. One constrained decode request defers the sampling of the whole step (Step 7).

Why it matters. Structured outputs make agent and tool pipelines reliable, but they add a compile wait, a per-step CPU job and, with async scheduling, less overlap. Knowing where each part runs tells you what to tune.

Check yourself#

  1. 1
    Which field of the OpenAI API asks for JSON that matches a schema, and what does vLLM turn it into?
    easy
    Show answer

    response_format with type: "json_schema". vLLM turns it into StructuredOutputsParams(json=<schema>) (vllm/entrypoints/generate/base/protocol.py:192-195).

  2. 2
    In which process and on which thread does the grammar compile, and what does the request do meanwhile?
    medium
    Show answer

    In the engine core process, in the ThreadPoolExecutor of the structured output manager; the input thread submits the job (vllm/v1/engine/core.py:1128-1135, vllm/v1/structured_output/__init__.py:196-197). The request waits in the status WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR, and the scheduler skips it until the Future is done (vllm/v1/core/sched/scheduler.py:3291-3303).

  3. 3
    How large is one bitmask row for a vocabulary of 151,936 tokens? How many rows does a structured request own with 3 speculative tokens?
    medium
    Show answer

    cdiv(151936, 32) = 4,748 int32 words, 18,992 bytes (vllm/v1/worker/gpu/structured_outputs.py:63-65). With 3 speculative tokens, mask_stride = 3 + 1 = 4 rows per request (vllm/v1/structured_output/__init__.py:393-395).

  4. 4
    The server uses async scheduling. A batch has 200 normal requests and 1 JSON-schema request in decode. What happens to the sampling of that step, and why?
    hard
    Show answer

    The step is scheduled while the previous token of the JSON request is still in flight, so the scheduler sets pending_structured_output_tokens for the step (vllm/v1/core/sched/async_scheduler.py:37-39). The engine core starts the forward pass but defers sample_tokens for the whole batch. It first processes the previous step's output, then fills the bitmask and samples (vllm/v1/engine/core.py:737-749, vllm/v1/engine/core.py:789-810). The flag is per batch, so all 201 requests wait (derived).

  5. 5
    A reasoning model with --reasoning-parser deepseek_r1 gets a JSON-schema request. Which tokens does the grammar see, and what is the bitmask while the model thinks?
    hard
    Show answer

    While the thinking is not ended, the constraint bounds start after the thinking, so the rows are full rows: every token is allowed (vllm/v1/structured_output/__init__.py:300-319, vllm/v1/structured_output/__init__.py:241-247). accept_tokens ignores the tokens before the end; when </think> arrives, it latches reasoning_ended and feeds only the tokens after it to the matcher (vllm/v1/structured_output/__init__.py:545-553).