top of page

Tutorial 3: Structured Outputs

  • Contributor
  • 5 days ago
  • 2 min read

Don't parse LLM prose with regex. Use structured outputs. Schemas guarantee shape.

Step 1: The Problem (10 min)

LLM returns:

Sure! Here's the extraction:
Name: Alice
Email: alice@x.com
Phone: 555-1234

Parsing reliably is annoying. LLM adds preamble; varies format.

Structured outputs solve this.

Step 2: JSON Mode (15 min)

OpenAI / Anthropic / others:

response = openai.chat.completions.create(
  model='gpt-4o-mini',
  messages=[...],
  response_format={ 'type': 'json_object' }
)

Guaranteed: response is valid JSON.

But: shape not guaranteed. May miss expected fields.

Step 3: JSON Schema (15 min)

response_format = {
  'type': 'json_schema',
  'json_schema': {
    'name': 'invoice',
    'schema': {
      'type': 'object',
      'properties': {
        'invoice_number': { 'type': 'string' },
        'total': { 'type': 'number' },
      },
      'required': ['invoice_number', 'total']
    }
  }
}

Now: response strictly matches schema.

For: data extraction with structure.

Step 4: Pydantic Integration (15 min)

from pydantic import BaseModel
from openai import OpenAI

class Invoice(BaseModel):
    invoice_number: str
    total: float
    line_items: list[str]

response = OpenAI().beta.chat.completions.parse(
  model='gpt-4o-2024-08-06',
  messages=[...],
  response_format=Invoice,
)

invoice = response.choices[0].message.parsed
# invoice is typed Invoice instance

Pydantic + structured output = ergonomic.

For TS: similar via Zod with libraries.

Step 5: Instructor Library (15 min)

import instructor
from openai import OpenAI

client = instructor.from_openai(OpenAI())

invoice = client.chat.completions.create(
  model='gpt-4o-mini',
  response_model=Invoice,
  messages=[...]
)

Handles retries, validation, retries-with-error-feedback.

For Python: standard now.

Step 6: Function Calling (15 min)

tools = [{
  'type': 'function',
  'function': {
    'name': 'extract_invoice',
    'parameters': Invoice.model_json_schema()
  }
}]

response = openai.chat.completions.create(
  model='gpt-4o',
  messages=[...],
  tools=tools,
  tool_choice={ 'type': 'function', 'function': { 'name': 'extract_invoice' }}
)

args = json.loads(response.choices[0].message.tool_calls[0].function.arguments)
invoice = Invoice(**args)

Tool calling + forced tool choice = structured output (older approach).

Newer: response_format directly.

Step 7: Enums and Constraints (15 min)

from typing import Literal

class Classification(BaseModel):
    sentiment: Literal['positive', 'negative', 'neutral']
    confidence: float  # 0-1
    topics: list[str]  # max 3

LLM constrained to enum values.

For: classification; routing decisions.

Step 8: Nested Structures (15 min)

class Address(BaseModel):
    street: str
    city: str
    state: str

class Customer(BaseModel):
    name: str
    email: str
    addresses: list[Address]

Arbitrary nesting. LLM fills correctly.

For complex docs: invoices with line items, etc.

Step 9: Validation and Retries (15 min)

Schema match isn't enough:

class Invoice(BaseModel):
    total: float

    @validator('total')
    def positive(cls, v):
        if v < 0: raise ValueError('must be positive')
        return v

Pydantic validation catches.

Instructor: auto-retries with error context.

For: semantic correctness beyond schema.

Step 10: Cost / Latency (10 min)

Structured output: roughly same cost as freeform.

Token usage: slightly higher (model emits more structured tokens).

Latency: minimal difference.

Quality: significantly better for downstream parsing.

ROI strong.

What You Just Did

Structured outputs: problem, JSON mode, JSON schema, Pydantic, Instructor, function calling, enums/constraints, nested, validation, cost.

Common Failure Modes

Parse prose with regex. Brittle.

Schema too lenient. Get garbage in valid shape.

No validation. Schema-valid but semantically wrong.

Old function-calling pattern without retry. Wasteful.

Don't use Pydantic / Zod. Manual JSON parsing.

bottom of page