Home / AI & GenAI

AI & GenAITroubleshootingAdvanced

vLLM structured outputs: requests that are slow, rejected with 400, or still return broken JSON

Constrained decoding in vLLM can add seconds to the first request, reject valid-looking schemas, or emit truncated or whitespace-padded JSON. How to tell which backend ran, what failed, and how to fix it.

Deepak Bhardwaj6 min read

Applies tovLLM 0.8+ OpenAI-compatible server (request fields changed from guided_* to structured_outputs around 0.11-0.12; check your version's docs)

Symptoms

You moved an extraction or classification workload onto self-hosted vLLM with JSON schema enforcement and one of these happens:

  • The first request with a new schema takes several seconds longer than the rest.
  • Requests fail with 400 Bad Request mentioning the schema, a regex, or an unsupported feature.
  • The response passes through the constraint but json.loads still fails.
  • The model emits pages of whitespace or newlines until max_tokens.
  • Throughput drops when structured requests are mixed with normal chat traffic.

Constrained decoding works by compiling your schema into a grammar and masking every token that would break it. Each symptom above maps to one part of that pipeline.

Check 1: which request format are you sending?

vLLM changed how structured output is requested. Mixing formats is a common cause of "the constraint was silently ignored" or a 400.

Format Status
response_format={"type": "json_schema", "json_schema": {...}} OpenAI-compatible, supported
extra_body={"structured_outputs": {"json": schema}} Current vLLM-specific form
extra_body={"guided_json": schema} Older form; deprecated and removed in newer releases
guided_decoding_backend in the request Removed; choose the backend at server start

Sending both guided_* and structured_outputs in one request raises an error on versions that support both.

The portable form:

from openai import OpenAI
client = OpenAI(base_url="http://vllm:8000/v1", api_key="token")

schema = {
    "type": "object",
    "properties": {
        "severity": {"type": "string", "enum": ["sev1", "sev2", "sev3"]},
        "service":  {"type": "string"},
        "summary":  {"type": "string", "maxLength": 300},
    },
    "required": ["severity", "service", "summary"],
    "additionalProperties": False,
}

r = client.chat.completions.create(
    model="llama-8b",
    messages=[{"role": "user", "content": "Classify: checkout API 500s in eu-west"}],
    response_format={"type": "json_schema",
                     "json_schema": {"name": "triage", "schema": schema, "strict": True}},
    max_tokens=400,
)
print(r.choices[0].finish_reason, r.choices[0].message.content)

Always log finish_reason. Most "invalid JSON from a constrained model" reports are really length.

Check 2: which backend ran?

vLLM supports several grammar backends: xgrammar, guidance, outlines and lm-format-enforcer, plus auto (the default). With auto, vLLM picks a backend per request based on what the schema needs and what each library supports, and that choice can change between releases.

Find the server setting and any fallback messages:

kubectl -n llm logs deploy/llama-8b | grep -iE "structured|guided|xgrammar|guidance|outlines|fallback" | tail -30
vllm serve --help | grep -iA2 structured

On recent versions the backend is set through the structured outputs config group, for example --structured-outputs-config.backend xgrammar; older versions used --guided-decoding-backend. Names vary by version; check your build's help output.

Tip

In production, pin the backend instead of using auto, and set disable_fallback to true in staging. Then an unsupported schema fails loudly in tests rather than quietly switching to a slower backend in production.

Symptom → cause → fix

Symptom Cause Fix
First request per schema slow, later ones fast Grammar compilation; compiled grammars are cached per schema Warm up each schema after deploy; keep schemas stable (no per-request enums)
Every request slow Schema built dynamically per request, so the cache never hits Move variable parts into the prompt; keep the schema constant
400 on schema Feature not supported by the selected backend (some pattern, format, recursive $ref, complex oneOf) Simplify the schema, or pin a backend that supports the feature
400 on regex Regex dialect differs: xgrammar uses Rust-style syntax, lm-format-enforcer uses Python re Rewrite the pattern for the backend; avoid lookarounds
finish_reason: length, truncated JSON max_tokens too small, or long free-text fields Raise max_tokens; add maxLength to strings, maxItems to arrays
Endless whitespace Grammar allows arbitrary whitespace and the model keeps choosing it Set disable_any_whitespace (xgrammar and guidance only); add a JSON example to the prompt
Valid JSON, wrong content Constraint fixes syntax, not meaning Validate business rules after parsing; evaluate accuracy separately
Reasoning model returns empty or garbled structure Constraint applied to the reasoning section, or not at all Start the server with the reasoning parser and --structured-outputs-config.enable_in_reasoning as your version documents
Throughput drop with mixed traffic Token masks computed per step for constrained sequences Separate deployment for heavy structured workloads, or lower --max-num-seqs there

Why the content gets worse under constraints

A grammar mask removes tokens; it does not make the model smarter. When the schema forces a field order or vocabulary the model would not naturally produce, quality can drop. Three practices help:

  1. Describe the schema in the prompt as well as enforcing it. The model then wants to produce the shape the mask allows.
  2. Put a short reasoning or evidence string field before the decision fields if you need justification. Fields are generated in order.
  3. Prefer enum over free strings for categorical outputs. It is cheaper to enforce and easier to evaluate.

Measure the overhead

Compare constrained and unconstrained latency for the same prompt set before rollout:

import time, statistics

def timed(fmt):
    t = []
    for p in prompts:
        s = time.perf_counter()
        client.chat.completions.create(model="llama-8b", messages=[{"role": "user", "content": p}],
                                       max_tokens=400, **fmt)
        t.append(time.perf_counter() - s)
    return statistics.median(t), sorted(t)[int(len(t) * 0.95)]

print("plain", timed({}))
print("json ", timed({"response_format": {"type": "json_schema",
                      "json_schema": {"name": "triage", "schema": schema}}}))

Run it twice: the first pass includes compilation, the second shows steady state. Also watch vllm:time_to_first_token_seconds and vllm:num_requests_waiting in Prometheus during the test; a growing queue with flat GPU utilisation points at CPU-side grammar work.

Warning

Do not upgrade vLLM in place for a structured-output workload without rerunning your schema tests. Backend defaults, supported schema features and request field names have all changed between minor releases.

Fallback when the constraint fails

Even with enforcement, keep a parser-side safety net:

import json, jsonschema

def parse_or_none(resp):
    c = resp.choices[0]
    if c.finish_reason != "stop":
        return None
    try:
        obj = json.loads(c.message.content)
        jsonschema.validate(obj, schema)
        return obj
    except (json.JSONDecodeError, jsonschema.ValidationError):
        return None

On None, retry once with a larger max_tokens, then route to a fallback model or a human queue. Count each path in your metrics.

Prevent it next time

  • Keep a schema test suite: every production schema, run against the pinned backend with fallback disabled.
  • Pin vLLM version and backend together; treat either change as a release.
  • Warm up schemas in a readiness or post-start hook.
  • Bound every string and array in the schema.
  • Alert on the rate of finish_reason != "stop" for structured requests.