Hello everyone, welcome to the twenty-seventh issue of The Main Thread. After spending last two article talking about distributed systems, we will shift our focus back to AI engineering in this article - after all, learning how to apply distributed systems principles in AI engineering is what makes an engineer different from others.

In this issue, we will learn about problems and techniques on getting structured data from LLMs. It is harder than it looks.

We ask for JSON, we get JSON with a conversational preamble. We ask for specific fields but we get fields with creative names. We ask for an array, but we get an object. We ask for a number but we get a string containing a number, sometimes with a dollar sign.

One of the common issues I have seen systems crash because a model decided to return ”price”: “varies“ instead of ”price”: 29.99”. Having a markdown code fence around the JSON is one of the major problems in broken pipelines. No one validated the output schema, and the application failed silently.

Let me put your mind at ease. Structured output from LLMs is a solved problem (if you know the patterns). This article talks about those patterns, approaches that work, validation strategies that catch failures, and fallbacks that keep our system running when the model gets creative.

The Three Approaches

There are three ways to get structured output from modern LLMs. Each has different reliability characteristics.

1. JSON Mode

This is the simplest approach in which we tell the model to output JSON, and enable JSON mode to guarantee syntactically valid JSON.

from openai import OpenAI

client = OpenAI()

response = client.chat.completions.create(
    model="gpt-4o",
    response_format={"type": "json_object"},  # Guarantees valid JSON
    messages=[
        {
            "role": "system",
            "content": "Extract product info. Return JSON with fields: name, price, currency."
        },
        {
            "role": "user",
            "content": "The Nike Air Max 90 is currently $129.99"
        }
    ]
)

data = json.loads(response.choices[0].message.content)
# {"name": "Nike Air Max 90", "price": 129.99, "currency": "USD"}

This mode guarantees:

  • Output will be a valid JSON (parseable, no syntax errors)

  • No markdown code fences or conversational text

This model doesn’t guarantee:

  • Correct schema (might have wrong fields)

  • Correct types (might return ”129.99" instead of 129.99).

  • All required fields present

JSON mode is necessary but not sufficient. We will still get valid JSON that doesn’t match our schema.

2. Function Calling/Tool Use

This is a more structured approach where we define a function schema, and the model returns arguments that match the schema.

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "user", "content": "The Nike Air Max 90 is currently $129.99"}
    ],
    tools=[
        {
            "type": "function",
            "function": {
                "name": "extract_product",
                "description": "Extract product information from text",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "name": {
                            "type": "string",
                            "description": "Product name"
                        },
                        "price": {
                            "type": "number",
                            "description": "Price as a decimal number"
                        },
                        "currency": {
                            "type": "string",
                            "enum": ["USD", "EUR", "GBP"],
                            "description": "Currency code"
                        }
                    },
                    "required": ["name", "price", "currency"]
                }
            }
        }
    ],
    tool_choice={"type": "function", "function": {"name": "extract_product"}}
)

# Extract the structured arguments
tool_call = response.choices[0].message.tool_calls[0]
data = json.loads(tool_call.function.arguments)

The function/tool calling adds:

  • Schema is explicitly defined with JSON schema

  • Model is “guided“ toward the schema

  • Required fields are most likely to be present

  • Enum constraints are usually respected

The function/tool calling doesn’t guarantee:

  • Types are still sometimes wrong

  • Complex nested schema can confuse the model

  • The model might still hallucinate invalid enum values

3. Structured Outputs (Schema Enforcement)

This is the newest and most reliable approach where the model’s output is constrained to exactly match a JSON schema.

from openai import OpenAI
from pydantic import BaseModel

class Product(BaseModel):
    name: str
    price: float
    currency: str

client = OpenAI()

response = client.beta.chat.completions.parse(
    model="gpt-4o",
    messages=[
        {"role": "user", "content": "The Nike Air Max 90 is currently $129.99"}
    ],
    response_format=Product  # Pydantic model defines exact schema
)

product = response.choices[0].message.parsed
# Product(name='Nike Air Max 90', price=129.99, currency='USD')

The structured outputs guarantee:

  • Output matches the schema exactly

  • Types are correct

  • Required fields are present

  • Enum values are valid (if defined)

The structured outputs doesn’t guarantee:

  • Some schema features aren’t supported (recursive schema, some formats)

  • Slightly higher latency due to constrained decoding

Recommendation: Use structured outputs when available. Fallback to function calling with validation when not.

Schema Design for Reliability

The schema we provide dramatically affects the output quality. Here’s what works.

Keep It Flat When Possible

Deep nesting confuses models and makes validation harder

Subscribe to keep reading

This content is free, but you must be subscribed to The Main Thread to continue reading.

Already a subscriber?Sign in.Not now

Reply

Avatar

or to participate

Keep Reading