Python AI Structured Output: 5 Core Patterns for Reliable JSON from LLMs

AI与大数据

AI Structured Output: Why LLM's "Free Text" Is a Production Nightmare

LLMs output free text by default. Ask for JSON, and you get explanatory text mixed in; request an integer, and you receive the string "42"; define a schema, and fields randomly go missing. Structured Output forces LLMs to return data strictly following a predefined schema — the critical step from "chat toy" to "production system." In 2026, OpenAI natively supports Structured Output, the Instructor library enables automatic retries, and Outlines guarantees 100% format compliance through constrained decoding.

This article walks through 5 core patterns, covering the full pipeline from Pydantic Schema definition → Function Calling → Instructor retry → constrained decoding → multi-model adaptation.


Core Concepts

Concept Description
Structured Output LLM outputs structured data (e.g., JSON) following a predefined schema
Pydantic Model Python data validation library; defines output schema with type annotations
Function Calling OpenAI API's function invocation mechanism that constrains output format
Instructor Pydantic-based LLM structured output library with automatic retry support
Constrained Decoding Constrains output at the token level to ensure schema compliance
JSON Schema Specification for describing JSON data structures; the foundation of structured output
Output Validation Validates LLM output to ensure type and field completeness
Multi-Model Adapter Adapter layer for unified structured output across different LLM providers

Problem Analysis: 5 Pain Points of Unstructured LLM Output

  1. Unstable Format: Same prompt, LLM sometimes returns JSON, sometimes Markdown-wrapped JSON
  2. Unreliable Types: Request integer, LLM may return string "42" or float 42.0
  3. Missing Fields: Schema defines 10 fields, LLM randomly omits 2-3
  4. Hallucinated Content: LLM injects explanatory text in JSON values, e.g., "price": "around 100 dollars"
  5. Parse Crashes: Production JSON parse failure rates reach 5-15%, each crash requires manual intervention

Step-by-Step: 5 Core AI Structured Output Patterns

Pattern 1: Pydantic Schema Definition + Basic Structured Output

pip install pydantic==2.11 openai==1.82
from pydantic import BaseModel, Field
from openai import OpenAI
import json

class ProductInfo(BaseModel):
    name: str = Field(description="Product name")
    price: float = Field(description="Product price in USD")
    category: str = Field(description="Product category")
    in_stock: bool = Field(description="Whether in stock")
    tags: list[str] = Field(description="Product tag list")

client = OpenAI()

# Method 1: Using response_format parameter (OpenAI native structured output)
response = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "You are a product information extraction assistant. Extract structured product info from user descriptions."},
        {"role": "user", "content": "This MacBook Pro 14-inch costs $1499, belongs to the laptop category, currently in stock, tags are Apple, premium, productivity tool"},
    ],
    response_format=ProductInfo,
)

product = response.choices[0].message.parsed
print(f"Product: {product.name}")
print(f"Price: {product.price}")
print(f"Category: {product.category}")
print(f"In Stock: {product.in_stock}")
print(f"Tags: {product.tags}")
print(f"Type verification: price is {type(product.price).__name__}")

# Method 2: Manual JSON Schema + parse validation
class OrderItem(BaseModel):
    item_name: str = Field(description="Item name")
    quantity: int = Field(gt=0, description="Purchase quantity")
    unit_price: float = Field(ge=0, description="Unit price")

class CustomerOrder(BaseModel):
    order_id: str = Field(description="Order ID")
    customer_name: str = Field(description="Customer name")
    items: list[OrderItem] = Field(description="Order item list")
    total_amount: float = Field(ge=0, description="Order total amount")

response2 = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "Extract order information, strictly return JSON."},
        {"role": "user", "content": "Order ORD-20260621, customer John Smith, bought 2 keyboards at $49 each, 1 monitor at $399, total $497"},
    ],
    response_format={"type": "json_object"},
)

raw_json = json.loads(response2.choices[0].message.content)
order = CustomerOrder.model_validate(raw_json)
print(f"\nOrder: {order.order_id}, Customer: {order.customer_name}")
for item in order.items:
    print(f"  {item.item_name} x{item.quantity} @ {item.unit_price}")
print(f"Total: {order.total_amount}")

Pattern 2: OpenAI Function Calling Structured Output

from pydantic import BaseModel, Field
from openai import OpenAI
import json

class WeatherQuery(BaseModel):
    city: str = Field(description="City name")
    temperature: float = Field(description="Temperature in Celsius")
    condition: str = Field(description="Weather condition: sunny/cloudy/rain/snow")
    humidity: int = Field(ge=0, le=100, description="Humidity percentage")
    wind_speed: float = Field(ge=0, description="Wind speed in km/h")

class TravelPlan(BaseModel):
    destination: str = Field(description="Destination")
    days: int = Field(gt=0, description="Number of travel days")
    budget: float = Field(ge=0, description="Budget in USD")
    activities: list[str] = Field(description="Recommended activities list")
    weather_advice: str = Field(description="Weather-related advice")

client = OpenAI()

# Use Function Calling to enforce structured output
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_travel_plan",
            "description": "Generate a travel plan",
            "parameters": TravelPlan.model_json_schema(),
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Get weather information",
            "parameters": WeatherQuery.model_json_schema(),
        },
    },
]

response = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "You are a travel planning assistant. Generate travel plans and weather info based on user needs."},
        {"role": "user", "content": "I plan to visit Tokyo for 5 days with a budget of $3000"},
    ],
    tools=tools,
    tool_choice="auto",
)

for tool_call in response.choices[0].message.tool_calls:
    func_name = tool_call.function.name
    func_args = json.loads(tool_call.function.arguments)

    if func_name == "get_travel_plan":
        plan = TravelPlan.model_validate(func_args)
        print(f"Destination: {plan.destination}")
        print(f"Days: {plan.days}")
        print(f"Budget: {plan.budget}")
        print(f"Activities: {', '.join(plan.activities)}")
        print(f"Weather advice: {plan.weather_advice}")
    elif func_name == "get_weather":
        weather = WeatherQuery.model_validate(func_args)
        print(f"\n{weather.city}: {weather.temperature}°C, {weather.condition}, Humidity {weather.humidity}%, Wind {weather.wind_speed}km/h")

Pattern 3: Instructor Library for Retry-Based Structured Output

pip install instructor==1.7 pydantic==2.11
import instructor
from pydantic import BaseModel, Field
from openai import OpenAI

class SentimentResult(BaseModel):
    text: str = Field(description="Analyzed text")
    sentiment: str = Field(description="Sentiment: positive/negative/neutral")
    confidence: float = Field(ge=0, le=1, description="Confidence score 0-1")
    keywords: list[str] = Field(description="Keyword list")

class ArticleSummary(BaseModel):
    title: str = Field(description="Article title")
    summary: str = Field(min_length=50, max_length=200, description="50-200 word summary")
    key_points: list[str] = Field(min_length=2, max_length=5, description="2-5 key points")
    reading_time_minutes: int = Field(gt=0, description="Estimated reading time in minutes")
    difficulty: str = Field(description="Difficulty: beginner/intermediate/advanced")

# Wrap OpenAI client with instructor
client = instructor.from_openai(OpenAI())

# Automatic retry: if output doesn't match schema, instructor retries automatically
sentiment = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "You are a sentiment analysis expert."},
        {"role": "user", "content": "This product is absolutely amazing! The interface is clean, features are powerful, highly recommended!"},
    ],
    response_model=SentimentResult,
    max_retries=3,
)

print(f"Sentiment: {sentiment.sentiment}")
print(f"Confidence: {sentiment.confidence}")
print(f"Keywords: {', '.join(sentiment.keywords)}")

# Nested models + strict validation
summary = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "You are an article summary generator."},
        {"role": "user", "content": "Python 3.13 was officially released in October 2024, bringing free-threaded mode (PEP 703), an improved interactive interpreter, better error messages, and other major updates. Free-threaded mode allows Python to run without the GIL, significantly improving multi-threaded performance. The new REPL supports multi-line editing and syntax highlighting, greatly enhancing the development experience."},
    ],
    response_model=ArticleSummary,
    max_retries=3,
)

print(f"\nTitle: {summary.title}")
print(f"Summary: {summary.summary}")
for i, point in enumerate(summary.key_points, 1):
    print(f"  Point {i}: {point}")
print(f"Reading time: {summary.reading_time_minutes} minutes")
print(f"Difficulty: {summary.difficulty}")

Pattern 4: Outlines/Structured Generation (Constrained Decoding)

pip install outlines==0.1 pydantic==2.11 transformers
import outlines
from pydantic import BaseModel, Field
import json

class EntityExtraction(BaseModel):
    person_names: list[str] = Field(description="List of person names")
    organizations: list[str] = Field(description="List of organizations")
    locations: list[str] = Field(description="List of locations")
    dates: list[str] = Field(description="List of dates")

class CodeAnalysis(BaseModel):
    language: str = Field(description="Programming language")
    complexity: str = Field(description="Complexity: low/medium/high")
    functions: list[str] = Field(description="List of function names")
    imports: list[str] = Field(description="List of imported modules")
    issues: list[str] = Field(description="List of potential issues")

# Use Outlines for constrained decoding (local model)
# Note: First run downloads the model, approximately 1.5GB
model = outlines.models.transformers("microsoft/Phi-3-mini-4k-instruct")

# Method 1: JSON Schema-based constrained generation
generator = outlines.generate.json(model, EntityExtraction.model_json_schema())

text = "On March 15, 2026, John Smith attended the Google Developer Conference in San Francisco and discussed AI technology applications in Seattle with Jane Doe."
result = generator(text)
print(f"Persons: {result['person_names']}")
print(f"Organizations: {result['organizations']}")
print(f"Locations: {result['locations']}")
print(f"Dates: {result['dates']}")

# Method 2: Regex-based constrained generation
# Ensures 100% format compliance
phone_generator = outlines.generate.regex(model, r"\d{3}-\d{4}-\d{4}")
phone = phone_generator("My phone number is")
print(f"\nPhone: {phone}")

# Method 3: Choice-based constrained generation (classification task)
choice_generator = outlines.generate.choice(model, ["positive", "negative", "neutral"])
sentiment = choice_generator("This product is great, I'm very satisfied!")
print(f"Sentiment: {sentiment}")

# Method 4: Pydantic Model-based constrained generation
code_generator = outlines.generate.json(model, CodeAnalysis.model_json_schema())
code_text = """
import os
import sys
from typing import List

def process_data(items: List[str]) -> dict:
    result = {}
    for item in items:
        result[item] = len(item)
    return result

def main():
    data = process_data(["hello", "world"])
    print(data)
"""
code_result = code_generator(code_text)
print(f"\nLanguage: {code_result['language']}")
print(f"Complexity: {code_result['complexity']}")
print(f"Functions: {code_result['functions']}")

Pattern 5: Multi-Model Structured Output Adapter

from pydantic import BaseModel, Field
from openai import OpenAI
import anthropic
import google.generativeai as genai
import json
from abc import ABC, abstractmethod
from typing import TypeVar, Type

T = TypeVar("T", bound=BaseModel)

class BookRecommendation(BaseModel):
    title: str = Field(description="Book title")
    author: str = Field(description="Author name")
    genre: str = Field(description="Genre")
    rating: float = Field(ge=0, le=5, description="Rating 0-5")
    reason: str = Field(description="Recommendation reason")

class StructuredOutputAdapter(ABC):
    """Multi-model structured output adapter base class"""

    @abstractmethod
    def generate(self, prompt: str, response_model: Type[T]) -> T:
        pass

class OpenAIAdapter(StructuredOutputAdapter):
    def __init__(self, api_key: str, model: str = "gpt-4o-2024-08-06"):
        self.client = OpenAI(api_key=api_key)
        self.model = model

    def generate(self, prompt: str, response_model: Type[T]) -> T:
        response = self.client.beta.chat.completions.parse(
            model=self.model,
            messages=[
                {"role": "system", "content": "Return data strictly following the schema."},
                {"role": "user", "content": prompt},
            ],
            response_format=response_model,
        )
        return response.choices[0].message.parsed

class AnthropicAdapter(StructuredOutputAdapter):
    def __init__(self, api_key: str, model: str = "claude-sonnet-4-20250514"):
        self.client = anthropic.Anthropic(api_key=api_key)
        self.model = model

    def generate(self, prompt: str, response_model: Type[T]) -> T:
        schema = response_model.model_json_schema()
        response = self.client.messages.create(
            model=self.model,
            max_tokens=1024,
            messages=[{"role": "user", "content": prompt}],
            tools=[{
                "name": "structured_output",
                "description": "Return structured data",
                "input_schema": schema,
            }],
            tool_choice={"type": "tool", "name": "structured_output"},
        )
        for block in response.content:
            if block.type == "tool_use":
                return response_model.model_validate(block.input)
        raise ValueError("No tool use in response")

class GeminiAdapter(StructuredOutputAdapter):
    def __init__(self, api_key: str, model: str = "gemini-2.0-flash"):
        genai.configure(api_key=api_key)
        self.model_name = model

    def generate(self, prompt: str, response_model: Type[T]) -> T:
        model = genai.GenerativeModel(
            self.model_name,
            generation_config={"response_mime_type": "application/json"},
        )
        schema_instructions = f"Return data strictly matching this JSON Schema:\n{json.dumps(response_model.model_json_schema(), ensure_ascii=False)}"
        response = model.generate_content(f"{schema_instructions}\n\n{prompt}")
        raw = json.loads(response.text)
        return response_model.model_validate(raw)

# Unified calling interface
def get_recommendation(adapter: StructuredOutputAdapter, query: str) -> BookRecommendation:
    return adapter.generate(query, BookRecommendation)

# Usage example (requires corresponding API key)
# openai_adapter = OpenAIAdapter(api_key="sk-xxx")
# result = get_recommendation(openai_adapter, "Recommend a beginner-friendly AI book")
# print(f"Recommendation: '{result.title}' by {result.author}, rating {result.rating}")

# Universal validation layer: regardless of model, final validation through Pydantic
def safe_parse(raw_json: dict, model: Type[T]) -> T:
    """Universal safe parse: validation + default value filling"""
    try:
        return model.model_validate(raw_json)
    except Exception as e:
        print(f"Validation failed: {e}")
        defaults = {}
        for field_name, field_info in model.model_fields.items():
            if field_info.is_required():
                raise ValueError(f"Required field {field_name} is missing")
            defaults[field_name] = field_info.default
        return model.model_validate(defaults)

Pitfall Guide

Pitfall 1: Pydantic Fields Missing description

# ❌ Wrong: no description, LLM doesn't know what to fill in
class BadModel(BaseModel):
    x1: str
    x2: float
    x3: list[str]

# ✅ Correct: add description to every field
class GoodModel(BaseModel):
    product_name: str = Field(description="Full product name")
    price: float = Field(description="Price in USD")
    tags: list[str] = Field(description="Product tags, e.g., 'electronics', 'sale'")

Pitfall 2: Relying on LLM to Auto-Output JSON Without response_format

# ❌ Wrong: only prompting for JSON, format is unstable
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "Please return product info in JSON format"}],
)

# ✅ Correct: explicitly set response_format
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "Please return product info in JSON format"}],
    response_format={"type": "json_object"},
)

Pitfall 3: Ignoring Pydantic Validation Errors

# ❌ Wrong: directly json.loads without validation
data = json.loads(response.choices[0].message.content)
price = data["price"]  # Might be string "42.0" instead of float

# ✅ Correct: use Pydantic validation
from pydantic import ValidationError
try:
    product = ProductInfo.model_validate_json(response.choices[0].message.content)
    price = product.price  # Guaranteed to be float type
except ValidationError as e:
    print(f"Validation failed: {e}")

Pitfall 4: Deeply Nested Models Causing LLM Output Chaos

# ❌ Wrong: 3+ levels of nesting, LLM极易出错
class DeepNested(BaseModel):
    level1: "Level1Model"  # -> level2: Level2Model -> level3: Level3Model

# ✅ Correct: flattened design, max 2 levels of nesting
class OrderItem(BaseModel):
    name: str = Field(description="Item name")
    qty: int = Field(description="Quantity")

class Order(BaseModel):
    items: list[OrderItem] = Field(description="Item list")  # Only 1 level of nesting
    total: float = Field(description="Total price")

Pitfall 5: Improper tool_choice in Function Calling

# ❌ Wrong: tool_choice="auto" means LLM might not call the function
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[...],
    tools=tools,
    tool_choice="auto",  # LLM might respond with plain text
)

# ✅ Correct: force calling a specific function
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[...],
    tools=tools,
    tool_choice={"type": "function", "function": {"name": "get_travel_plan"}},
)

Error Troubleshooting

# Error Message Cause Solution
1 ValidationError: missing field LLM output missing required field Use Field(default=...) or increase max_retries
2 json.decoder.JSONDecodeError LLM output is not valid JSON Set response_format={"type": "json_object"}
3 ValidationError: value is not a valid float LLM returns string instead of number Pydantic auto-conversion or use Field(coerce_numbers=True)
4 InstructorRetryError: max retries exceeded Still doesn't match schema after N retries Simplify schema or optimize prompt descriptions
5 TypeError: 'NoneType' object is not subscriptable LLM returns empty content Check API key quota and model availability
6 ValidationError: list should have at least N items List field has insufficient elements Use Field(min_length=N) and optimize prompt
7 StructuredOutputError: schema too complex Schema is too complex Flatten model, reduce nesting levels
8 RateLimitError API call rate exceeded Add backoff retry or reduce concurrency
9 ValidationError: string too long/short String length doesn't match constraints Adjust max_length/min_length or optimize prompt
10 ToolCallNotFoundError LLM didn't call tool Set tool_choice to force calling

Advanced Optimization

  1. Streaming Structured Output: Use instructor's create_partial method for streaming parsing — validate as you generate
  2. Schema Caching: Cache JSON Schema for requests with the same schema, reducing repeated serialization overhead
  3. Multi-Schema Routing: Dynamically select different Pydantic Models based on user intent for flexible structured output
  4. Output Post-Processing Pipeline: Pydantic validation → business rule checking → default value filling → logging, forming a complete data quality assurance chain
  5. A/B Test Schemas: Use schemas of different granularity for the same task, comparing LLM output quality and token consumption

Comparison

Dimension OpenAI Structured Output Instructor Outlines LMQL Guidance
Format Guarantee ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
Auto Retry ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
Local Model Support ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
Multi-Model Adaptation ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐ ⭐⭐⭐
Learning Curve ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐ ⭐⭐⭐
Type Safety ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐

Summary: AI structured output is the essential path from "chat-style AI" to "production-grade AI." Pydantic Schema → Function Calling → Instructor retry → constrained decoding → multi-model adaptation — five pillars working together, this is the best practice for Python AI structured output in 2026. Core principles: Schema is contract, validation is guarantee, retry is fault tolerance.


Try these browser-local tools — no sign-up required →

#结构化输出#AI JSON#Pydantic#Python AI#2026#AI与大数据