LLM Structured Output: JSON Mode, Function Calling, and Constrained Generation

Production patterns for extracting reliable structured data from LLMs including JSON mode, grammar-constrained decoding, and schema validation strategies

#llm#structured-output#json#production-ml
Cover image for the article: LLM Structured Output: JSON Mode, Function Calling, and Constrained Generation

LLMs generate text, but applications need structured data. The gap between free-form generation and reliable JSON output has been one of the biggest pain points in LLM engineering. Modern approaches - from JSON mode to grammar-constrained decoding - make structured extraction reliable enough for production, but each has distinct tradeoffs.

This article benchmarks the major approaches and provides production-ready implementation patterns.

The Reliability Problem

Without constraints, asking an LLM for JSON produces failures at predictable rates:

ApproachValid JSON RateSchema ComplianceLatency Overhead
Prompt-only ("respond in JSON")82-91%65-78%None
JSON mode (provider-enforced)99.8%88-95%+5-10%
Function calling (OpenAI)99.9%97-99%+10-15%
Grammar-constrained (local)100%100%+15-30%
Structured outputs (OpenAI)100%100%+10-15%

For production systems processing thousands of requests daily, even a 1% failure rate means dozens of errors requiring fallback handling.

Chart

Approach 1: Provider JSON Mode

The simplest path to structured output with hosted models:

from openai import OpenAI
from pydantic import BaseModel, Field
from typing import List, Optional
import json

client = OpenAI()

class ProductReview(BaseModel):
    sentiment: str = Field(description="positive, negative, or neutral")
    rating: float = Field(ge=1.0, le=5.0)
    key_topics: List[str] = Field(max_length=5)
    purchase_intent: bool
    summary: str = Field(max_length=200)

def extract_review_json_mode(review_text: str) -> ProductReview:
    """Extract structured data using JSON mode."""
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        response_format={"type": "json_object"},
        messages=[
            {
                "role": "system",
                "content": (
                    "Extract product review data. Respond with a JSON object containing: "
                    "sentiment (positive/negative/neutral), rating (1-5), "
                    "key_topics (list of up to 5 topics), purchase_intent (boolean), "
                    "summary (max 200 chars)"
                ),
            },
            {"role": "user", "content": review_text},
        ],
        temperature=0.1,
    )

    data = json.loads(response.choices[0].message.content)
    return ProductReview(**data)  # Pydantic validation

Approach 2: OpenAI Structured Outputs

The most reliable hosted option with guaranteed schema compliance:

from openai import OpenAI
from pydantic import BaseModel
from typing import List, Literal

class ExtractedEntity(BaseModel):
    name: str
    entity_type: Literal["person", "company", "product", "location"]
    confidence: float
    context: str

class ExtractionResult(BaseModel):
    entities: List[ExtractedEntity]
    relationships: List[dict]
    document_type: str
    language: str

def extract_with_structured_outputs(text: str) -> ExtractionResult:
    """Use OpenAI structured outputs for guaranteed schema compliance."""
    response = client.beta.chat.completions.parse(
        model="gpt-4o-2024-08-06",
        messages=[
            {
                "role": "system",
                "content": "Extract all named entities and their relationships from the text.",
            },
            {"role": "user", "content": text},
        ],
        response_format=ExtractionResult,
        temperature=0.0,
    )

    return response.choices[0].message.parsed

Approach 3: Grammar-Constrained Generation (Local Models)

For self-hosted models, grammar constraints guarantee valid output:

from outlines import models, generate
from pydantic import BaseModel
from typing import List, Literal

# Define output schema
class SentimentAnalysis(BaseModel):
    sentiment: Literal["positive", "negative", "neutral", "mixed"]
    confidence: float
    emotions: List[Literal["joy", "anger", "sadness", "fear", "surprise", "disgust"]]
    aspects: List[dict]

# Load model with grammar constraints
model = models.transformers("mistralai/Mistral-7B-Instruct-v0.3")
generator = generate.json(model, SentimentAnalysis)

def analyze_with_grammar(text: str) -> SentimentAnalysis:
    """Generate constrained output - always valid schema."""
    prompt = f"Analyze the sentiment of this text:\n{text}\n\nAnalysis:"
    result = generator(prompt)
    return result  # Guaranteed to be valid SentimentAnalysis

Approach 4: Retry with Validation

For cases where you cannot use constrained generation:

from pydantic import BaseModel, ValidationError
from tenacity import retry, stop_after_attempt, retry_if_exception_type
import json

class StructuredExtractor:
    def __init__(self, client: OpenAI, model: str = "gpt-4o-mini"):
        self.client = client
        self.model = model

    @retry(
        stop=stop_after_attempt(3),
        retry=retry_if_exception_type((json.JSONDecodeError, ValidationError)),
    )
    def extract(self, text: str, schema: type[BaseModel],
                prompt: str) -> BaseModel:
        """Extract with retry on validation failure."""
        schema_json = schema.model_json_schema()

        response = self.client.chat.completions.create(
            model=self.model,
            response_format={"type": "json_object"},
            messages=[
                {
                    "role": "system",
                    "content": (
                        f"{prompt}\n\n"
                        f"Respond with JSON matching this schema:\n"
                        f"{json.dumps(schema_json, indent=2)}"
                    ),
                },
                {"role": "user", "content": text},
            ],
            temperature=0.0,
        )

        data = json.loads(response.choices[0].message.content)
        return schema(**data)  # Validates against Pydantic schema

    def extract_with_fallback(self, text: str, schema: type[BaseModel],
                             prompt: str) -> dict:
        """Extract with graceful degradation."""
        try:
            result = self.extract(text, schema, prompt)
            return {"success": True, "data": result, "method": "structured"}
        except (json.JSONDecodeError, ValidationError) as e:
            # Fallback: extract what we can
            return {
                "success": False,
                "error": str(e),
                "method": "fallback",
                "partial_data": self._partial_extract(text, schema, prompt),
            }

Benchmark: Extraction Quality

Tested on 5,000 product reviews, extracting 6-field structured output:

MethodSchema ComplianceField AccuracyLatency (P95)Cost/1K
Prompt-only (GPT-4o-mini)78.2%89.1%680ms$1.20
JSON mode (GPT-4o-mini)95.4%91.3%720ms$1.20
Structured outputs (GPT-4o)100%94.8%1.1s$8.50
Function calling (GPT-4o-mini)98.9%92.1%780ms$1.40
Outlines + Mistral-7B100%88.6%2.2s$0.15
Outlines + Llama-3-8B100%91.2%2.5s$0.15

Handling Complex Schemas

For nested, recursive, or large schemas, break extraction into steps:

class HierarchicalExtractor:
    """Multi-step extraction for complex schemas."""

    def __init__(self, client: OpenAI):
        self.client = client

    def extract_document(self, text: str) -> dict:
        """Extract complex document structure in stages."""
        # Stage 1: High-level structure
        structure = self._extract_stage(
            text,
            prompt="Identify the document type, sections, and key metadata.",
            schema=DocumentStructure,
        )

        # Stage 2: Per-section extraction
        sections = []
        for section in structure.sections:
            section_text = self._get_section_text(text, section)
            section_data = self._extract_stage(
                section_text,
                prompt=f"Extract details from this {section.type} section.",
                schema=SectionDetail,
            )
            sections.append(section_data)

        # Stage 3: Cross-reference validation
        validated = self._cross_validate(structure, sections)

        return validated

    def _extract_stage(self, text: str, prompt: str,
                      schema: type[BaseModel]) -> BaseModel:
        """Single extraction stage."""
        response = self.client.beta.chat.completions.parse(
            model="gpt-4o-2024-08-06",
            messages=[
                {"role": "system", "content": prompt},
                {"role": "user", "content": text},
            ],
            response_format=schema,
        )
        return response.choices[0].message.parsed

Production Error Handling

Build robust error handling for production extraction pipelines:

Failure ModeDetectionRecovery
Invalid JSONjson.JSONDecodeErrorRetry with lower temperature
Schema violationPydantic ValidationErrorRetry with explicit schema reminder
Hallucinated fieldsField value outside expected rangeDefault value + flag for review
Truncated outputMissing required fieldsIncrease max_tokens, retry
Rate limit429 responseExponential backoff

Key Takeaways

  • Use structured outputs or function calling for production. Prompt-only JSON extraction fails too often for any system handling more than 100 requests per day.
  • Grammar-constrained generation guarantees validity at the cost of 15-30% higher latency. Use it for self-hosted models where you control the inference stack.
  • Pydantic validation is your safety net. Even with 99.9% reliable generation, validate every output before passing to downstream systems.
  • Break complex schemas into extraction stages. Multi-step extraction with simpler schemas per step outperforms single-shot extraction on complex documents.
  • Cost scales with schema complexity. Each additional field adds tokens to the prompt and output. Minimize schema size to what you actually need.

Structured output from LLMs is now a solved problem at the engineering level. The remaining challenge is quality - making sure the extracted values are semantically correct, not just syntactically valid.

Comments

    No comments yet. Be the first to share your thoughts.