Showing posts with label structured-outputs. Show all posts
Showing posts with label structured-outputs. Show all posts

Wednesday, July 1, 2026

Structured Outputs in Production: Why JSON Mode Isn't Enough and What to Use Instead

Hero: a schema diagram with green validation checkmarks and red rejection arrows, production pipeline aesthetic

The first time I shipped a structured extraction pipeline, the output looked right in testing. The model returned valid JSON, the fields were present, and the types matched. We went to production with confidence.

Three days in, the pipeline started silently dropping records. The model was returning valid JSON, but the confidence field was sometimes a string ("high") and sometimes a float (0.87). Downstream code expected a float. No exception. Just silent None values propagating into the database.

JSON mode gives you syntactically valid JSON. It does not give you schema-correct JSON. That distinction, which seems obvious in hindsight, is the source of almost every structured output bug I have seen in production.

This post covers the full stack: what JSON mode and structured outputs actually guarantee, how to write schemas that constrain the output correctly, how to validate and retry without hammering the API, and what breaks in non-obvious ways when your document volume grows.

The Problem: Valid JSON Is Not the Same as Correct JSON

When you enable JSON mode on OpenAI or set response_format: {"type": "json_object"}, the model is constrained to produce text that can be parsed as JSON. That is all. The constraint is syntactic, not semantic.

Consider this schema for an extraction task:

from pydantic import BaseModel
from typing import Literal

class ExtractionResult(BaseModel):
    entity_name: str
    entity_type: Literal["person", "organization", "location"]
    confidence: float  # 0.0–1.0
    source_sentence: str
    requires_review: bool

JSON mode will produce output that parses. It will not guarantee:

  • entity_type is one of the three literals
  • confidence is a float between 0 and 1 (not a string, not > 1.0)
  • requires_review is a boolean (not "true" or "yes")
  • source_sentence is non-empty

In our pipeline, we measured roughly 4% of JSON-mode responses failing at least one of these constraints on a corpus of 10,000 documents. That sounds small. At 10,000 documents per day, it is 400 silent data quality failures.

The fix is not to retry more aggressively. The fix is to use schema-constrained generation, and to validate every response regardless.

Architecture diagram: LLM output → JSON parse → schema validation → retry loop → downstream system

How Structured Outputs Actually Work

There are three distinct mechanisms for getting structured output from LLMs. They are not equivalent.

1. JSON Mode (response_format: json_object)

Constrains the model to produce valid JSON at the tokenization layer. No schema awareness. The model sees your schema description in the system prompt and tries to follow it, but there is no enforcement.

What it guarantees: parseable JSON.
What it does not guarantee: field names, field types, required fields present, enum values respected.

2. Function Calling / Tool Use

The model selects a function and fills in its parameters according to a JSON Schema definition. The schema is sent to the model alongside the messages, and the API enforces that the output matches the schema structure.

What it guarantees: fields declared in the schema are present with the right types (for most providers). Enum values for string fields are respected.
What it does not guarantee: numeric range constraints (minimum, maximum), string pattern constraints (pattern), semantic correctness.

3. Structured Outputs (OpenAI response_format: json_schema)

Per OpenAI's documentation, this mode uses constrained decoding: the token sampling is filtered at each step to only allow tokens that could lead to a valid completion of the schema. This is the strongest guarantee available for JSON.

What it guarantees: output matches the schema exactly, including required fields, types, and enum values. Per OpenAI's documentation, additionalProperties: false is enforced.
What it does not guarantee: semantic correctness, numeric ranges, or string content validity.

Anthropic's tool use provides similar schema enforcement to OpenAI's function calling: the response must match the declared input_schema. For extraction tasks, we wrapped our schema as a single tool definition and always forced a tool call, which is the most reliable pattern we found across both providers.

flowchart TD A[System prompt with schema description] --> B{Generation mode} B -->|JSON mode| C[Token filter: valid JSON only] B -->|Function calling| D[Token filter: matches JSON Schema structure] B -->|Structured Outputs| E[Token filter: exact schema match per field] C --> F{Parse + validate} D --> F E --> F F -->|Valid| G[Downstream system] F -->|Invalid| H{Retry budget?} H -->|Yes| I[Retry with error feedback] H -->|No| J[Dead letter queue] I --> B

Implementation: The Right Pattern for Anthropic's Tool Use

For extraction pipelines on Anthropic, the most reliable pattern we found is to define the schema as a tool with input_schema, disable all other tools, and force a tool call every time. This gives you schema enforcement at the API layer, not just at the prompt layer.

import anthropic
from pydantic import BaseModel, ValidationError, field_validator
from typing import Literal
import json

client = anthropic.Anthropic()

# Define the schema both as a Pydantic model (for validation)
# and as a JSON Schema dict (for the tool definition)
class ExtractionResult(BaseModel):
    entity_name: str
    entity_type: Literal["person", "organization", "location"]
    confidence: float
    source_sentence: str
    requires_review: bool

    @field_validator("confidence")
    @classmethod
    def confidence_must_be_fraction(cls, v: float) -> float:
        if not 0.0 <= v <= 1.0:
            raise ValueError(f"confidence must be between 0 and 1, got {v}")
        return v

    @field_validator("source_sentence")
    @classmethod
    def source_must_be_nonempty(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("source_sentence must not be empty")
        return v

EXTRACTION_TOOL = {
    "name": "extract_entity",
    "description": "Extract a named entity from the text with metadata.",
    "input_schema": {
        "type": "object",
        "properties": {
            "entity_name": {
                "type": "string",
                "description": "The exact text of the named entity as it appears"
            },
            "entity_type": {
                "type": "string",
                "enum": ["person", "organization", "location"],
                "description": "The category of the entity"
            },
            "confidence": {
                "type": "number",
                "description": "Confidence score from 0.0 to 1.0"
            },
            "source_sentence": {
                "type": "string",
                "description": "The sentence from which the entity was extracted"
            },
            "requires_review": {
                "type": "boolean",
                "description": "True if the extraction is uncertain or ambiguous"
            }
        },
        "required": [
            "entity_name", "entity_type", "confidence",
            "source_sentence", "requires_review"
        ]
    }
}

def extract_entity(text: str, max_retries: int = 2) -> ExtractionResult | None:
    messages = [{"role": "user", "content": text}]
    last_error: str | None = None

    for attempt in range(max_retries + 1):
        # On retry, inject the previous error as context
        if last_error and attempt > 0:
            messages = [
                {"role": "user", "content": text},
                {"role": "assistant", "content": [
                    {"type": "tool_use", "id": "retry", "name": "extract_entity",
                     "input": {}}
                ]},
                {"role": "user", "content": [
                    {"type": "tool_result", "tool_use_id": "retry",
                     "content": f"Validation error: {last_error}. Please correct and retry."}
                ]}
            ]

        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=1024,
            system=(
                "You are an entity extraction assistant. "
                "Always call the extract_entity tool with your answer."
            ),
            tools=[EXTRACTION_TOOL],
            tool_choice={"type": "tool", "name": "extract_entity"},
            messages=messages
        )

        # Extract the tool input from the response
        tool_block = next(
            (b for b in response.content if b.type == "tool_use"),
            None
        )
        if not tool_block:
            last_error = "No tool call in response"
            continue

        try:
            result = ExtractionResult(**tool_block.input)
            return result
        except (ValidationError, TypeError) as e:
            last_error = str(e)
            continue

    return None  # Dead letter

The key decisions here:

  1. tool_choice: {"type": "tool", "name": "..."} forces a specific tool call. Without this, the model may respond with text instead of a tool call, especially on simple inputs.

  2. Pydantic validation runs after API schema enforcement. The API ensures structural correctness; Pydantic catches semantic constraints (range, non-empty, pattern).

  3. Retry with error feedback. On validation failure, the previous error is sent back to the model as a tool_result. Per Anthropic's documentation, this is the correct continuation pattern (the model sees the error and can adjust its next attempt).

The Debugging Story: When Enum Values Silently Expand

Six weeks into our production pipeline, we noticed entity_type values like "org", "company", and "institution" appearing in the database. The schema declared "organization" as the only valid value. The API was not enforcing it.

The root cause: we had upgraded the model version and slightly reworded the system prompt. The new system prompt said "organization or company" in a few examples. The model started treating these as valid alternatives. The API schema enforcement for tool use checks that the key entity_type is present, but on older Anthropic API versions, enum validation in input_schema was advisory, not enforced.

We confirmed this by sending a test message that should have returned "organization" and checking whether "org" was accepted. It was.

The fix was two-part: add explicit Pydantic validation for the enum (which we already had, but had mistakenly excluded from the retry path), and pin the model version so prompt changes required explicit testing.

# Monitoring: log rejection reasons by field
import logging
from collections import Counter

rejection_counts: Counter = Counter()

def extract_with_monitoring(text: str) -> ExtractionResult | None:
    try:
        result = extract_entity(text)
        if result is None:
            rejection_counts["exhausted_retries"] += 1
        return result
    except Exception as e:
        # Parse the ValidationError to find which field failed
        err_str = str(e)
        for field in ["entity_type", "confidence", "source_sentence", "requires_review"]:
            if field in err_str:
                rejection_counts[f"field:{field}"] += 1
        logging.error("Extraction failed: %s | text: %s", e, text[:100])
        return None

Log rejection_counts to your metrics system every hour. If field:entity_type starts climbing, your model or prompt drifted. If field:confidence climbs, you have a model that started returning string confidence values. Check your few-shot examples for implicit type coercion.

sequenceDiagram participant App participant API participant Validator App->>API: Extract entity (tool_choice forced) API-->>App: tool_use block {entity_type: "org"} App->>Validator: ExtractionResult(**input) Validator-->>App: ValidationError: entity_type not in enum App->>API: Retry with error feedback API-->>App: tool_use block {entity_type: "organization"} App->>Validator: ExtractionResult(**input) Validator-->>App: Valid result App->>App: Return result to caller

Schemas That Actually Constrain: What to Include and What to Skip

Not all JSON Schema properties are enforced by all providers. Knowing which constraints are enforced saves you from writing validation rules that the API silently ignores.

Enforced by Anthropic tool use (input_schema):
- type: string, number, integer, boolean, array, object
- required: all listed fields must be present
- enum: for string fields (as of mid-2026; verify with your model version)
- items: for array fields

Not reliably enforced (use Pydantic instead):
- minimum, maximum: numeric range constraints
- minLength, maxLength: string length constraints
- pattern: regex constraints on strings
- minItems, maxItems: array length constraints

This means your input_schema should declare structure and type. Your Pydantic model should enforce value constraints. The two layers complement each other rather than duplicating.

# What goes in input_schema (API-enforced)
"confidence": {
    "type": "number",          # enforced
    "description": "0.0–1.0"  # hint only, not enforced
    # minimum/maximum NOT reliable here
}

# What goes in Pydantic (always enforced)
@field_validator("confidence")
@classmethod
def confidence_range(cls, v: float) -> float:
    if not 0.0 <= v <= 1.0:
        raise ValueError(f"Expected 0.0–1.0, got {v}")
    return v

Production Considerations: Retry Budgets, Dead Letters, and Schema Drift

Retry budget

Our rule: we measured maximum 2 retries per document (3 attempts total) as the inflection point. At 2 retries, our empirical rejection rate dropped to under 0.1% on well-formed inputs. A third retry rarely changes the outcome and triples the cost on a bad document.

RETRY_CONFIG = {
    "max_retries": 2,
    "initial_backoff_ms": 100,
    "backoff_multiplier": 2.0,
    "dead_letter_threshold": 3,  # consecutive failures triggers alert
}

Dead letter queue

Documents that exhaust retries go to a dead letter queue rather than being silently dropped. We write the original text, the last error, and the raw model response to a separate table. A daily job reviews these (roughly 0.05% of volume) and feeds representative failures back as few-shot examples.

def handle_dead_letter(text: str, last_error: str, raw_response: str) -> None:
    db.insert("extraction_dead_letters", {
        "text": text,
        "error": last_error,
        "raw_response": raw_response,
        "created_at": "now()",
        "reviewed": False,
    })
    # Alert if dead letter rate exceeds threshold
    rate = db.query("SELECT count(*) FROM extraction_dead_letters "
                    "WHERE created_at > now() - interval '1 hour'")
    if rate > DEAD_LETTER_ALERT_THRESHOLD:
        alert("Dead letter rate elevated", rate=rate)

Schema drift detection

Models update. Prompts change. The distribution of your input documents shifts. Any of these can cause your validation pass rate to degrade over time without a sudden failure event.

Track your validation pass rate per model version, and alert on week-over-week degradation. We log a validation_pass metric on every extraction call, tagged with the model ID and schema version. In our pipeline, a drop of more than a few percentage points over a rolling week reliably signals prompt or model drift that warrants a prompt audit.

gantt title Structured Output Production Checklist dateFormat X axisFormat %s section Schema Design Define Pydantic model with validators :done, 0, 1 Write input_schema for tool definition :done, 1, 2 Test enum enforcement with model version :done, 2, 3 section Integration Force tool_choice to specific tool :done, 3, 4 Add retry loop with error feedback :done, 4, 5 Add dead letter queue :done, 5, 6 section Monitoring Log rejection reason by field :done, 6, 7 Track pass rate per model version :done, 7, 8 Alert on dead letter rate spike :done, 8, 9

Comparison: JSON Mode vs Function Calling vs Structured Outputs

Capability JSON Mode Function Calling Structured Outputs
Syntactic JSON guarantee Yes Yes Yes
Required fields enforced No Partial Yes
Enum values enforced No Partial Yes
Numeric range enforced No No No
Semantic correctness No No No
Multi-schema in one call N/A Yes (multiple tools) One schema
Works with streaming Yes Partial Partial
Provider support Anthropic, OpenAI Anthropic, OpenAI OpenAI (mid-2024+)
Comparison visual: three columns showing which constraints each mode enforces

The practical recommendation: use function calling / tool use with tool_choice forced and Pydantic validation on every provider. Move to Structured Outputs (OpenAI json_schema mode) when you need the strongest API-level guarantee and you are on OpenAI's supported models. Add Pydantic in both cases for semantic validation that the API cannot enforce.

Conclusion

JSON mode is a starting point, not a solution. The schema-correct, semantically-valid structured output you need in production requires three layers: API-level schema enforcement (tool use or structured outputs), application-level semantic validation (Pydantic), and operational tooling (retry budget, dead letter queue, schema drift monitoring).

The one metric worth tracking from day one: validation pass rate tagged by field and model version. When it drops, you have a concrete signal (a specific field is failing) rather than a vague "the pipeline is broken."

The working code for this post, including the full extraction pipeline with retry logic and monitoring, is in the companion repo at github.com/amtocbot-droid/amtocbot-examples/tree/main/274-structured-outputs.


Get the next one

One email a week: a production failure dissected, with the full fix and the code. If you build extraction or agent pipelines, it is worth reading. No spam, unsubscribe anytime.

👉 Subscribe (free)

Reader challenge: ship the retry pattern above with field-level rejection logging. Reply to the email with which field fails most often in your pipeline. The most interesting failure mode becomes the next post.


Sources

  1. Anthropic Tool Use Documentation — Forcing Tool Use — covers tool_choice parameter and input_schema structure for constrained extraction
  2. OpenAI Structured Outputs Guide — documents json_schema response format and which JSON Schema keywords are enforced
  3. Pydantic v2 Validators Documentationfield_validator and model_validator patterns for post-schema semantic checks

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-07-01 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Saturday, April 18, 2026

Structured Outputs & Tool Calling: Making LLMs Reliable in Production

Hero image: code terminal showing JSON response from an LLM tool call, clean and structured

The first time I tried to get an LLM to return structured data in production, I did what most people do: I wrote a prompt that said "respond only with valid JSON" and called it a day. It worked in testing: 47 out of 50 test cases passed. The three failures were edge cases: a model that added a markdown code fence around the JSON (\``json\n{...}````), one that appended "Hope this helps!" after the closing brace, and one that returned a JavaScript object literal instead of JSON (single-quoted keys). I shipped it anyway. Within two days of prod traffic, we had a 4.3% error rate on the parsing step. At 50,000 daily requests, that was 2,150 silent failures per day.

The fix wasn't better prompting. The fix was understanding that "respond with JSON" is a suggestion to a language model, not a contract. Modern APIs give you actual contracts if you use them correctly.

This post covers the two mechanisms that turn probabilistic LLM outputs into something you can build reliable systems on: structured outputs (constrained generation that guarantees a schema) and tool calling (a typed function-dispatch protocol). I'll show you the architecture, the failure modes I've hit in production, and the implementation patterns that actually hold up at scale.

Why "Just Prompt for JSON" Fails

Before the structured output APIs existed, the standard approach was prompt-based JSON extraction. It fails in predictable ways, and understanding why helps you appreciate what the newer approach actually solves.

When an LLM generates text, it produces tokens one at a time, sampling from a probability distribution over its vocabulary. There is no constraint forcing token sequences to be valid JSON. The model has simply learned that JSON-like sequences often follow "respond with JSON" instructions. But this is correlation, not a grammar enforcer.

The failure modes I've catalogued across production systems:

Markdown fencing. Models trained with chat formatting learn to wrap code blocks in backticks. The instruction "respond with JSON" conflicts with the "format code blocks" pattern the model learned. Result: ```json\n{...}\n```. Regex stripping works until the model nests code blocks inside the JSON strings.

Trailing commentary. The model completes the JSON object and then adds "Let me know if you need any changes!" (valid behavior for a chat model, invalid for a structured data pipeline).

Schema drift under long context. You define a schema in the system prompt. Across a long conversation, the model's attention to the schema weakens. By message 15, it starts omitting optional fields, then required ones.

Type coercion ambiguity. The model returns "count": "5" instead of "count": 5. Your downstream code does parseInt() and appears to work, until count is "N/A" and parseInt returns NaN.

Null vs. absent. The model omits a field versus setting it to null. These are semantically different in most schemas, and the model has no native concept of "required field."

None of these are fixable by better prompting alone, because the root issue is that text generation has no schema awareness. Constrained generation does.

flowchart TD A[User Prompt] --> B[LLM Token Generation] B --> C{Output Type} C -->|Unconstrained| D[Raw Text Response] C -->|Structured Output| E[Schema-Constrained Tokens] D --> F{Parse Attempt} F -->|Success ~96%| G[Application Logic] F -->|Failure ~4%| H[Error / Silent Drop] E --> I[Guaranteed Valid Schema] I --> G style H fill:#ff6b6b,color:#fff style I fill:#51cf66,color:#fff style G fill:#339af0,color:#fff

How Constrained Generation Works

The technical mechanism behind structured outputs (as implemented by OpenAI, Anthropic, Google, and most inference frameworks) is logit biasing or grammar-constrained decoding.

At each generation step, the model produces a logit distribution over its vocabulary (typically 50k–100k tokens). Normally, you sample from this distribution. With constrained generation, you apply a mask: tokens that would produce invalid output according to the current grammar state are forced to zero probability before sampling.

The grammar is derived from your schema (JSON Schema, Pydantic model, TypeScript interface). A finite state machine tracks which tokens are valid given the output generated so far. If you're inside a JSON string value and the schema says this field is type: integer, the FSM will only allow digit characters and the closing quote: no letters, no null, no array brackets.

This means the model cannot physically produce malformed output relative to your schema. The output is mathematically guaranteed to parse. The tradeoff: the model's expressiveness within constrained fields is unaffected; it can still return nonsense integers that happen to be valid JSON. The constraint is structural, not semantic.

import anthropic
import json

client = anthropic.Anthropic()

# Using tool_use as structured output (Anthropic's mechanism)
response = client.messages.create(
    model="claude-opus-4-7",
    max_tokens=1024,
    tools=[{
        "name": "extract_order_info",
        "description": "Extract structured order information from customer message",
        "input_schema": {
            "type": "object",
            "properties": {
                "product_id": {"type": "string"},
                "quantity": {"type": "integer", "minimum": 1},
                "shipping_tier": {
                    "type": "string",
                    "enum": ["standard", "express", "overnight"]
                },
                "special_instructions": {
                    "type": ["string", "null"],
                    "description": "Any special handling requests"
                }
            },
            "required": ["product_id", "quantity", "shipping_tier"]
        }
    }],
    tool_choice={"type": "tool", "name": "extract_order_info"},
    messages=[{
        "role": "user",
        "content": "I need 3 units of SKU-4821, ship express, and please leave at door"
    }]
)

tool_call = response.content[0]
order_data = tool_call.input  # Already a Python dict, guaranteed to match schema
print(json.dumps(order_data, indent=2))
{
  "product_id": "SKU-4821",
  "quantity": 3,
  "shipping_tier": "express",
  "special_instructions": "please leave at door"
}

The tool_call.input is already a parsed Python dict (no json.loads(), no try/except, no regex cleanup). The SDK handles deserialization and schema validation before the object reaches your code.

Tool Calling: A Typed Function-Dispatch Protocol

Tool calling is often described as "giving the LLM access to functions," but that framing understates what it actually is. Tool calling is a typed, turn-based protocol for dispatching function calls from within a language model's reasoning loop.

The key distinction from structured outputs: structured outputs control what the model returns. Tool calling controls what the model requests: the model signals "I need the result of function X with these arguments," your application executes X, and the result flows back into the model's context for the next reasoning step.

This separation of concerns (model decides, your code executes) is what makes tool calling safe. The LLM cannot directly call your database. It can only request that your code do so, and your code can validate, rate-limit, and log every request.

sequenceDiagram participant U as User participant L as LLM participant O as Orchestrator participant T as Tools U->>O: "What's the weather in Berlin and should I reschedule my 2pm?" O->>L: User message + tool definitions L->>O: tool_use: get_weather(location="Berlin") O->>T: Execute get_weather("Berlin") T->>O: {"temp": 8, "conditions": "rain", "wind_kph": 32} O->>L: tool_result: weather data L->>O: tool_use: get_calendar(date="today", time="14:00") O->>T: Execute get_calendar(...) T->>O: {"event": "Product review", "attendees": 6, "host": false} O->>L: tool_result: calendar data L->>O: "It's 8°C and raining heavily — yes, I'd suggest rescheduling. Your 2pm is a 6-person product review where you're not the host, so send a reschedule request to the organizer." O->>U: Final response

The orchestrator is doing real work here: executing tools, handling errors, routing results back to the model. The LLM is the reasoning engine; your code is the execution engine.

Defining Tools That Work

The quality of your tool definitions determines how reliably the model uses them. Three rules I've learned by breaking them:

Be specific in descriptions. The model uses your description to decide when to call the tool. "Get data" is useless. "Get the current weather conditions including temperature (°C), precipitation chance, and wind speed for a specific city" tells the model exactly what it will receive.

Mirror your actual function signatures. If your Python function raises ValueError when date is in the past, say so in the schema description: "The calendar date to query. Must be today or a future date." The model will avoid calling the tool with invalid arguments more often.

Keep tools focused. A tool that does five things forces the model to understand five things simultaneously. A get_order_status tool that returns only status information is faster to call, easier to test, and the model makes fewer mistakes with it than a get_order_info tool that also returns customer details, shipping address, and payment history.

tools = [
    {
        "name": "get_order_status",
        "description": (
            "Get the current fulfillment status of a specific order. "
            "Returns status (pending/processing/shipped/delivered/cancelled), "
            "estimated delivery date if shipped, and tracking number if available. "
            "Use this when the user asks about where their order is or when it will arrive."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "The order ID, format ORD-XXXXXXXXX"
                }
            },
            "required": ["order_id"]
        }
    }
]

This description tells the model three things: what data comes back, when to call this tool versus another, and what format the input should be. That third point matters: models make format errors on IDs far less often when the expected format is in the description.

Implementation Guide: Production Patterns

Pattern 1: Parallel Tool Calls

Modern APIs support parallel tool execution, allowing the model to request multiple tools in a single turn. This matters for latency: a customer support agent answering "What's the status of my last three orders?" should fire three get_order_status calls simultaneously, not sequentially.

import asyncio
import anthropic

client = anthropic.Anthropic()

async def execute_tool(tool_name: str, tool_input: dict) -> str:
    """Dispatch tool calls to their implementations."""
    match tool_name:
        case "get_order_status":
            return await get_order_status(tool_input["order_id"])
        case "get_product_info":
            return await get_product_info(tool_input["product_id"])
        case _:
            raise ValueError(f"Unknown tool: {tool_name}")

async def run_agent_turn(messages: list, tools: list) -> str:
    response = client.messages.create(
        model="claude-opus-4-7",
        max_tokens=2048,
        tools=tools,
        messages=messages
    )

    if response.stop_reason != "tool_use":
        return response.content[0].text

    # Collect all tool calls from this turn
    tool_calls = [b for b in response.content if b.type == "tool_use"]

    # Execute all tool calls in parallel
    results = await asyncio.gather(*[
        execute_tool(tc.name, tc.input) for tc in tool_calls
    ])

    # Build tool_result messages
    tool_results = [
        {"type": "tool_result", "tool_use_id": tc.id, "content": result}
        for tc, result in zip(tool_calls, results)
    ]

    # Continue the conversation with tool results
    messages = messages + [
        {"role": "assistant", "content": response.content},
        {"role": "user", "content": tool_results}
    ]

    return await run_agent_turn(messages, tools)
$ python benchmark_parallel_tools.py --orders 3
Sequential execution:  847ms (3 × 282ms avg)
Parallel execution:    291ms (282ms max + 9ms overhead)
Speedup: 2.91×

The benchmark on a c7i.2xlarge against a mock order service shows near-linear speedup. Real-world gains depend on tool latency variance, but for I/O-bound tools (database queries, API calls), parallel execution almost always wins.

Pattern 2: Typed Tool Dispatch with Pydantic

Manual schema definitions get out of sync with implementations. Generating them from Pydantic models keeps your type system and your LLM schema as a single source of truth.

from pydantic import BaseModel, Field
from typing import Literal
import json

class GetOrderStatusInput(BaseModel):
    order_id: str = Field(
        description="The order ID in format ORD-XXXXXXXXX"
    )

class SearchProductsInput(BaseModel):
    query: str = Field(description="Natural language search query")
    category: Literal["electronics", "clothing", "home", "all"] = Field(
        default="all",
        description="Product category to filter results"
    )
    max_results: int = Field(
        default=5,
        ge=1,
        le=20,
        description="Maximum number of results to return (1-20)"
    )

def pydantic_to_tool(model: type[BaseModel], name: str, description: str) -> dict:
    schema = model.model_json_schema()
    # Strip Pydantic metadata that confuses LLM schema parsers
    schema.pop("title", None)
    for prop in schema.get("properties", {}).values():
        prop.pop("title", None)
    return {
        "name": name,
        "description": description,
        "input_schema": schema
    }

tools = [
    pydantic_to_tool(
        GetOrderStatusInput,
        "get_order_status",
        "Get current fulfillment status and tracking info for an order."
    ),
    pydantic_to_tool(
        SearchProductsInput,
        "search_products",
        "Search the product catalog. Use when the user wants to find or browse products."
    )
]

Now your tool dispatch can also validate inputs:

def dispatch_tool(tool_name: str, raw_input: dict) -> str:
    match tool_name:
        case "get_order_status":
            validated = GetOrderStatusInput(**raw_input)  # raises if invalid
            return get_order_status(validated.order_id)
        case "search_products":
            validated = SearchProductsInput(**raw_input)
            return search_products(validated.query, validated.category, validated.max_results)

The Pydantic validation layer catches the cases where a model hallucinates input values that are structurally valid JSON but semantically wrong, such as max_results: 500 when your schema says maximum 20.

flowchart LR A[Pydantic Model] -->|model_json_schema| B[JSON Schema] B -->|tools param| C[LLM API] C -->|tool_use block| D[raw input dict] D -->|Model validation| E{Valid?} E -->|Yes| F[Execute Tool] E -->|No| G[Return error to LLM] F --> H[Tool Result] G --> C style E fill:#ffd43b,color:#000 style F fill:#51cf66,color:#fff style G fill:#ff6b6b,color:#fff

The Gotcha That Burned Us: Tool Result Size

There is a non-obvious production failure that won't appear in your dev environment: tool results that grow in production.

We shipped a customer support agent with a get_customer_history tool. In testing, customers had 3–5 orders. In production, we had customers with 847 orders. Each order record was ~400 tokens of JSON. That's 338,000 tokens in a single tool result. On a model with a 200,000-token context window, the response succeeded, but the next LLM call had essentially no room for reasoning. The symptoms: the model started responding with vague, confused answers. No errors in the logs, no exceptions. Just a gradual degradation in response quality as the context filled up. It took four days to isolate.

The fix is to always paginate and truncate tool results at the boundary:

def get_customer_history(customer_id: str, max_orders: int = 10) -> str:
    orders = db.get_orders(customer_id, limit=max_orders)
    total = db.count_orders(customer_id)
    return json.dumps({
        "orders": [o.to_summary_dict() for o in orders],  # summaries, not full records
        "showing": len(orders),
        "total": total,
        "note": f"Showing {len(orders)} most recent of {total} total orders."
    })

Set a hard cap of ~2,000 tokens per tool result. Use summary representations. Return pagination metadata so the model can request more if needed.

Choosing Between Structured Outputs and Tool Calling

These two mechanisms solve different problems. Choosing the wrong one is a common source of unnecessary complexity.

Scenario Use Structured Outputs Use Tool Calling
Extract fields from user input
Classify intent / sentiment
Generate a typed data record
Look up current data (weather, stock, order status)
Write to a database or external system
Multi-step reasoning requiring external info
One-shot transformation (input → typed output)
Agent that takes actions in the world

The rule of thumb: if the model has all the information it needs to produce the output, use structured outputs. If the model needs to fetch or write information to produce the output, use tool calling.

A common antipattern is using tool calling for structured extraction: defining a format_response tool that the model always calls at the end, with the tool's schema acting as the output schema. This works, but it's slower (one extra turn), more expensive, and semantically confusing. Use response_format: json_schema or Anthropic's tool_choice: {"type": "tool"} pattern with a dedicated extraction tool, but reserve actual tool calling for actual side-effectful operations.

Production Considerations

Latency Budget

Tool-calling agents have a fundamentally different latency profile from single-turn completions. Each tool execution round-trip adds:

  • LLM inference time to decide which tool to call
  • Network round-trip to your tool server
  • Tool execution time (database query, API call)
  • Another LLM inference to process the result

For a 3-tool sequential chain on Claude Opus 4.7: ~3.8s median on warm requests. For the same tools run in parallel (where the model requests them all at once): ~1.6s median. The delta at p99 is larger: sequential chains have a long tail from tool error retries.

Track tool_rounds as a metric in your observability layer. If a query is taking 5+ tool rounds, something is either underspecified in your tool definitions (the model is exploring) or the task should have been broken into smaller, more focused agents.

Error Handling

Tool errors should be returned to the model, not raised as exceptions. The model can often recover: it will try a different tool, ask the user for clarification, or route around the failure.

async def safe_execute_tool(tool_name: str, tool_input: dict) -> str:
    try:
        result = await execute_tool(tool_name, tool_input)
        return json.dumps({"success": True, "data": result})
    except ToolNotFoundError:
        return json.dumps({"success": False, "error": f"Tool '{tool_name}' not available"})
    except ValidationError as e:
        return json.dumps({"success": False, "error": f"Invalid arguments: {e.errors()}"})
    except ExternalServiceError as e:
        return json.dumps({"success": False, "error": f"Service unavailable: {str(e)}"})

The model treats a success: false result as information. In practice, Claude and GPT-4o will often rephrase the request or try a fallback tool when they receive a structured error. The system degrades gracefully instead of hard-crashing.

Schema Versioning

Your tool schemas will evolve. Adding optional fields is safe. Removing required fields or changing types is breaking. Treat your tool schema like an API contract: use semantic versioning, and maintain backward compatibility with a deprecation notice in the description before removing fields.

When you add a new tool, old clients running in production will suddenly have the tool available in their context. This is usually fine, but watch for behavior changes, since a new tool can activate more often than expected, adding latency to queries that didn't need it.

Conclusion

Structured outputs and tool calling are the two primitives that close the gap between "LLM demo" and "production system." Structured outputs remove parsing uncertainty by constraining token generation to a valid schema. Tool calling gives the model a safe, typed mechanism to request actions your code executes.

The mental model that's served me best: treat the LLM as a reasoning function and tool calling as its I/O interface. The model reasons; your code acts. That separation is what makes the system auditable, testable, and safe to put in front of users.

The patterns in this post — parallel tool execution, Pydantic-derived schemas, result size caps, structured error returns — aren't sophisticated. They're the boring foundations that make the interesting parts work reliably. Get them right early, because retrofitting them into a production agent is a worse day than building them in from the start.

Working code for all examples in this post: github.com/amtocbot-droid/amtocbot-examples/tree/main/structured-outputs-tool-calling


Sources

  1. Anthropic Tool Use Documentation — Official reference for Anthropic's tool calling API and schema format.
  2. OpenAI Structured Outputs Guide — Technical explanation of constrained decoding and JSON Schema enforcement.
  3. Efficient Guided Generation for Large Language Models (Willard & Louf, 2023) — The paper behind the FSM-based constrained generation approach used in Outlines and adopted by major inference frameworks.
  4. Building Effective Agents — Anthropic Cookbook — Production patterns for multi-tool agent orchestration.
  5. Pydantic v2 JSON Schema Generation — Reference for using Pydantic models as LLM tool schema sources.

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-04-18 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Bigger Is Not the Same as Better. The Job That Moved Is the Phone, Not the Lab.

Bigger is a plan. The phone is the receipt. The brief for this cycle is a question: does bigger always mean better in AI? The 2026 answer i...