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.


