Phase 2: Prompt Engineering & LLM Patterns

Constrained decoding vs post-processing

Intermediate ~14 min read
Think of it this way A friendly analogy. Read this if the technical version feels dense. Show Hide

Imagine you have a big pile of LEGO bricks and a super cool instruction manual to build something awesome, like a space station! Now, imagine you have a very smart but sometimes a little too enthusiastic helper (that's like an AI called an LLM, or Large Language Model). You want this helper to give you the bricks in exactly the right order so your space station turns out perfect. But sometimes, helpers get distracted or just hand you any random brick. How do you make sure your space station is built right?

One way, called "constrained decoding," is like having a super-duper careful helper. As you follow the instruction manual, for each step, this helper only gives you the precise brick you need next. If the manual says "get a blue 2x4 flat brick," they'll only hand you blue 2x4 flat bricks. They actually won't even let you pick up a red round brick by mistake, because they know it doesn't fit the current step. It's impossible to make a mistake this way because you literally can't put the wrong piece in. Your LEGO space station will be absolutely perfect on the first try, every single time, because you were guided perfectly from the beginning.

The other way, "post-processing," is more like having a helpful but less strict assistant. This assistant might just give you a big pile of bricks, or hand you pieces without checking if they're right for the very next step. So, you build your space station, but maybe you accidentally put a green brick where a red one should be, or forget a window. Once you think you're done, someone else (or a special part of your computer program) takes the finished space station and compares it to the instruction manual. If there's a mistake, they'll say, "Hold on, that piece is wrong!" You might have to take it apart and try again, or they might even try to fix it for you. It's flexible, but it means you might have to spend extra time checking and fixing errors.

So, why does this matter? When you're building software, especially with AI, you often need the AI to give you information in a very specific, structured way, like a perfect LEGO model. If you can use "constrained decoding," your AI will always give you exactly what you asked for, which saves a lot of time because you don't have to check for errors or fix them later. But if you're using "post-processing," you need to be prepared to check the AI's work and fix any parts that don't match your instructions. Knowing the difference helps you build really strong and reliable programs, making sure your digital creations are as sturdy as a perfectly built LEGO space station!

The mental model for constrained decoding starts at the sampling step. Normally, after the transformer computes logits over the full vocabulary, a sampling strategy (temperature, top-p) picks the next token. Constrained decoding inserts a masking pass before that sampling: it builds an automaton from your schema (a JSON Schema, a regex, a context-free grammar) and at each step computes which tokens in the vocabulary would leave the automaton in a valid (non-dead) state. Every other token gets its logit set to negative infinity. The model samples freely within that allowed subset. From the model's perspective it is still doing next-token prediction. From your perspective, the output is guaranteed to be structurally valid.

That guarantee comes with a nuanced caveat: structural validity is not semantic validity. A constrained decoder can produce {"price": -999.0} perfectly happily. Outlines, LM Format Enforcer, and guidance all enforce grammar, not business rules. You still need application-layer validation (a Pydantic model, a JSON Schema validator, range checks) on top. The right mental split is: constrained decoding handles syntax, your code handles semantics. Once you internalize that, you stop expecting it to solve problems it was never designed to solve.

For a real-world scenario, consider a document ingestion pipeline that extracts structured metadata (author, date, topic, confidence score) from 50,000 research papers overnight. With post-processing, a 2% malformed-JSON rate across 50k documents is 1,000 failed records that need retry logic, exponential backoff, and probably a dead-letter queue. That complexity compounds fast. With constrained decoding via vLLM and a JSON Schema, you drop that failure class to zero and the pipeline is simpler end-to-end. A senior engineer on this project would reach for vllm with guided_json in the sampling params, point it at a Pydantic model's .model_json_schema(), and skip writing a single line of JSON repair code.

The tradeoff landscape looks like this. Post-processing works with every LLM API: GPT-4o, Claude, Gemini, a random open-source model you found on Hugging Face. You pay for it with nondeterministic failure rates, retry overhead (latency + cost), and repair logic that tends to grow into a small framework of its own over time. Constrained decoding requires either owning the inference stack or using a provider that exposes a guaranteed mode. OpenAI's json_object mode and structured outputs with a schema are constrained at the provider side but only cover JSON. Anthropic's tool-use enforces a JSON envelope but the inner values are still free-form. True grammar-level control (arbitrary regex, CFGs) means self-hosted inference with Outlines, LM Format Enforcer, or llama.cpp's grammar sampler.

Scale changes the calculus in a specific way. At 10 users, a 2% parse failure rate is invisible. At 10,000 users it's a queue of failed jobs that pages someone at 2am. At 10 million requests per day it is a non-trivial infrastructure cost: every retry is an additional LLM call at full token price, plus the latency penalty. Constrained decoding's upfront cost (you have to self-host or pick a compatible provider) looks cheap at 10M scale compared to the retry tax. The crossover point varies by application, but the rule of thumb is: if you're doing more than a few thousand structured extractions per day and tolerances are tight, seriously evaluate whether you can move to constrained decoding. If you're building a proof of concept or using a provider API you cannot change, invest in a robust post-processing layer instead (see the aidev-structured-output-parsing subtopic for that path).

Latency implications are often overlooked. Constrained decoding's masking step adds a small per-token CPU overhead, typically 5-15% on inference throughput in benchmarks, but eliminates retry latency entirely. A single retry on a 500-token completion at 50ms/token is 25 extra seconds of wall-clock time in the worst case. For synchronous, user-facing flows, one avoided retry is worth far more than 15% slower token generation. For batch async pipelines the math is similar: the retry overhead dominates. The exception is very short completions (under ~50 tokens) where the masking overhead is proportionally larger and failures are rarer anyway because there's less room for the model to go wrong.

Key Takeaways

  • Constrained decoding enforces schema validity token-by-token during generation, not after.
  • Post-processing is universally compatible but requires retry and repair logic for malformed output.
  • Use constrained decoding when you control the inference stack or the provider supports it natively.
  • Budget failure rates honestly: even JSON mode from major providers isn't a 100% guarantee.

Pro tips

  • When using OpenAI's response_format: {"type": "json_schema"} (structured outputs), the schema must use a strict subset of JSON Schema: no anyOf at the top level, no $ref cycles. Test your Pydantic model's .model_json_schema() output against OpenAI's schema validator before you hit production traffic.
  • Constrained decoding can cause the model to stall on very tight schemas if no valid continuation exists given the context. Always set a max_tokens ceiling and handle the case where the response is truncated mid-JSON, because the grammar guarantee only holds if the model gets enough budget to close all open brackets.
  • For post-processing pipelines, track your actual parse failure rate as a metric, not just as logged errors. When it drifts above 1-2%, it's usually a sign that your prompt or schema changed subtly and the model's behavior drifted, not random noise.
  • Mixing strategies is legitimate: use a provider's JSON mode for the outer envelope (guaranteed object), then constrained decoding locally for a critical nested field if you self-host, or accept semantic post-validation on inner values. You don't have to pick one approach for the whole system.

Common pitfalls

  • Mistake: Assuming JSON mode means 100% valid, parseable JSON from hosted APIs. Fix: Always wrap json.loads() in a try/except; providers document best-effort, not hard guarantees, and edge cases exist.
  • Mistake: Using constrained decoding to enforce business rules like value ranges. Fix: Constrained decoding handles syntax only. Run Pydantic or a custom validator on the parsed result for semantic checks.
  • Mistake: Writing repair/retry logic for post-processing without logging raw failures. Fix: Log the raw LLM output on every parse failure to a structured store so you can audit failure modes and improve your prompt.
  • Mistake: Setting a schema with many required fields and a small max_tokens budget under constrained decoding. Fix: Estimate minimum token count for your schema (field names + minimal values), then set max_tokens at least 2x that to prevent mid-generation truncation.

Constrained decoding vs post-processing: when to use which

Option Use when Avoid when
Constrained decoding (self-hosted, e.g., vLLM + Outlines) You control inference, schema failures are costly, volume is high (thousands+ calls/day), or retries are unacceptable on latency grounds. You're using a third-party API you can't modify, or you need quick prototyping without infra overhead.
Provider JSON mode (OpenAI json_object, Anthropic tool use) You're already on a hosted provider, the schema is simple JSON, and you want the lowest-effort improvement over plain text generation. You need a strict typed schema with nested constraints, or you cannot tolerate even rare parsing failures.
Provider structured outputs with schema (OpenAI response_format json_schema) You need provider-side schema enforcement without self-hosting, and your schema fits OpenAI's strict JSON Schema subset. Your schema uses features outside the supported subset (dynamic keys, complex unions), or you're on a provider that doesn't support it.
Post-processing with retry and repair You're using any LLM API (no infra constraints), volume is low-medium, and failure rates are acceptable with retry budget. Parse failure rate exceeds ~2% in production, or each retry materially impacts cost or user-facing latency.

Code Example

python
# outlines==0.0.46  (self-hosted model example)
import outlines
import outlines.models as models
from pydantic import BaseModel

class Product(BaseModel):
    name: str
    price: float
    in_stock: bool

# Load a local model (e.g., Mistral-7B via llama.cpp backend)
model = models.llamacpp("mistral-7b-instruct.Q4_K_M.gguf", n_ctx=2048)

# Constrained decoding: the sampler only permits tokens valid for this schema
generator = outlines.generate.json(model, Product)

result: Product = generator("Describe a red widget priced at $4.99 that is in stock.")
print(result)  # Product(name='Red Widget', price=4.99, in_stock=True)
# result is already a Pydantic model -- no parsing step needed

How this code works

This code demonstrates "constrained decoding," a powerful technique where a large language model (LLM) directly generates structured data that guarantees adherence to a specific format. Instead of the LLM producing free-form text that then needs manual parsing, this method ensures the output is valid from the start, a key advantage over traditional post-processing. The example starts by defining the desired output structure using a Pydantic BaseModel called Product, which specifies required fields like name (string), price (float), and in_stock (boolean). This Product class acts as the strict blueprint for the LLM's output.

Next, a local LLM is loaded using outlines.models.llamacpp, demonstrating outlines' capability to integrate with self-hosted models, here a Mistral-7B instance. The core of constrained decoding happens with outlines.generate.json(model, Product). This line creates a special generator that instructs the LLM to produce JSON only according to the Product schema. When this generator is called with a prompt, like "Describe a red widget...", the result is not a raw JSON string or unvalidated text. Crucially, the result is already a fully validated Pydantic Product object, immediately usable within the program without any further parsing or validation steps. This seamless integration of generation and parsing is a subtle yet significant benefit, eliminating a common point of failure and extra code that post-processing would require.

Production-grade example

Uses guided_json for zero-parse-failures, retries on transport errors only, logs tokens and latency per call.

python
# Production extraction pipeline: constrained decoding via vLLM HTTP API
# vllm>=0.4.0, pydantic>=2.0, tenacity>=8.0
import os
import time
import logging
import httpx
from pydantic import BaseModel, ValidationError
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)

VLLM_BASE = os.environ["VLLM_BASE_URL"]  # e.g. http://localhost:8000
MODEL = os.environ["VLLM_MODEL_NAME"]

class PaperMetadata(BaseModel):
    title: str
    authors: list[str]
    year: int
    topic: str
    confidence: float  # 0.0 - 1.0

JSON_SCHEMA = PaperMetadata.model_json_schema()

@retry(
    retry=retry_if_exception_type((httpx.TimeoutException, httpx.HTTPStatusError)),
    wait=wait_exponential(multiplier=1, min=2, max=30),
    stop=stop_after_attempt(4),
)
def extract_metadata(abstract: str) -> PaperMetadata:
    start = time.perf_counter()
    with httpx.Client(timeout=30.0) as client:
        resp = client.post(
            f"{VLLM_BASE}/v1/chat/completions",
            headers={"Authorization": f"Bearer {os.environ['VLLM_API_KEY']}"},
            json={
                "model": MODEL,
                "messages": [
                    {"role": "system", "content": "Extract paper metadata as JSON."},
                    {"role": "user", "content": abstract},
                ],
                "guided_json": JSON_SCHEMA,  # vLLM constrained decoding param
                "max_tokens": 256,
            },
        )
    resp.raise_for_status()
    data = resp.json()

    elapsed = time.perf_counter() - start
    usage = data.get("usage", {})
    log.info(
        "extraction_complete",
        extra={
            "latency_s": round(elapsed, 3),
            "prompt_tokens": usage.get("prompt_tokens"),
            "completion_tokens": usage.get("completion_tokens"),
        },
    )

    raw = data["choices"][0]["message"]["content"]
    try:
        return PaperMetadata.model_validate_json(raw)
    except ValidationError as exc:
        # Structural JSON is guaranteed; semantic validation can still fail.
        log.error("semantic_validation_failed", extra={"errors": exc.errors(), "raw": raw})
        raise ValueError(f"Schema mismatch after constrained decoding: {exc}") from exc

How this code works

This Python code extracts structured information, specifically academic paper metadata, from an abstract using an AI language model. Its primary job is to demonstrate "constrained decoding" – a technique where the AI is directly guided to produce output that perfectly matches a predefined structure, rather than generating freeform text that needs manual fixing later. This approach significantly reduces errors and simplifies the extraction process compared to traditional "post-processing" methods.

The code defines the desired data shape with PaperMetadata(BaseModel), which becomes JSON_SCHEMA. The extract_metadata function sends the abstract to a vLLM server via an httpx.Client HTTP POST request. The key instruction for constrained decoding is guided_json: JSON_SCHEMA within the API call, which ensures the AI generates JSON strictly adhering to the schema. A subtle yet important detail is that while guided_json guarantees the structure of the JSON output, PaperMetadata.model_validate_json is still used. This is because semantic validation (e.g., ensuring year is a valid number, not just a string) can still fail, leading to a ValidationError even when the JSON structure is correct. The tenacity.retry decorator adds robustness by automatically re-attempting the API call if temporary network issues occur.

Practice & master

Try the exercise, check your understanding, then mark this lesson mastered to track your path to pro.

Exercise

Build a metadata extractor that tries constrained JSON mode first (via OpenAI's response_format), falls back to plain text generation with post-processing if the model is unavailable, and tracks which path each request took. Use a simple ArticleMetadata schema with title, author, and publish_year fields.

python
# openai>=1.30.0, pydantic>=2.0
import os, json, logging
from openai import OpenAI
from pydantic import BaseModel, ValidationError

log = logging.getLogger(__name__)
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

class ArticleMetadata(BaseModel):
    title: str
    author: str
    publish_year: int

def extract_with_constrained(text: str) -> tuple[ArticleMetadata, str]:
    # TODO: call client.chat.completions.create with response_format={"type": "json_object"}
    # Return (ArticleMetadata, "constrained")
    pass

def extract_with_postprocess(text: str) -> tuple[ArticleMetadata, str]:
    # TODO: call without response_format, parse JSON from the response text
    # Return (ArticleMetadata, "postprocess")
    pass

def extract(text: str) -> tuple[ArticleMetadata, str]:
    # TODO: try constrained first, fall back to postprocess on ValidationError or json.JSONDecodeError
    pass

if __name__ == "__main__":
    sample = "Published in 2021 by Jane Smith, 'Deep Learning Patterns' covers neural architectures."
    metadata, path = extract(sample)
    print(f"Path used: {path}")
    print(metadata)

Quick check

  1. What does constrained decoding guarantee that post-processing alone cannot?

  2. You're using the OpenAI API (no self-hosted models). A critical pipeline needs structured JSON with near-zero parse failures. What's the most practical approach?

  3. A post-processing pipeline sees a 0.5% parse failure rate at 1,000 requests/day. You scale to 500,000 requests/day. What is the most significant new problem?

Self-check: Explain in your own words why constrained decoding can still produce semantically invalid output, describe one scenario where you would choose post-processing over constrained decoding despite higher failure risk, and name what changes in that decision as request volume grows.