Skip to content
Slash AI Production Failures: Robust Function Calling with Pydantic for Reliable Agents
AI Engineering & Developer Tooling

Slash AI Production Failures: Robust Function Calling with Pydantic for Reliable Agents

7 min read
AI AgentsFunction CallingPydanticLLM EngineeringProduction AI

Production AI systems often struggle with unreliable LLM function calls, causing costly failures. Master robust function calling with Pydantic to build self-correcting, predictable AI agents.

The Unseen Costs of Unreliable AI: Why Your Agents Are Failing in Production

Large Language Models (LLMs) have revolutionized what's possible with software, enabling sophisticated capabilities like automated customer support, complex data analysis, and intelligent workflow orchestration. The ability of LLMs to interact with external tools via function calling is a game-changer, bridging the gap between natural language understanding and real-world actions. However, deploying these capabilities in production often reveals a critical flaw: LLMs are inherently non-deterministic, and their outputs, especially function call arguments, can be inconsistent, malformed, or even hallucinated.

This unpredictability isn't just an inconvenience; it's a significant operational risk. Imagine an AI agent designed to automate financial transactions, process sensitive customer data, or manage logistics. If the LLM generates an invalid stock symbol, an incorrect date format, or a missing required parameter for a critical function, the entire workflow grinds to a halt. This leads to:

  • Increased Manual Intervention: Teams constantly debug and manually fix failed AI tasks, driving up operational costs and diverting valuable engineering resources.
  • Poor User Experience: Users encounter errors, delays, or incorrect actions from the AI, leading to frustration, lost trust, and churn.
  • Financial Losses: Incorrect data processing, missed opportunities, or system outages directly impact the bottom line.
  • Slowed Development Cycles: Engineers spend more time building complex, brittle error-handling logic instead of focusing on new features.

The core problem is a lack of structured reliability at the most critical juncture: validating the LLM's suggested function calls before execution. Without a robust mechanism to ensure the LLM's output conforms to expected schemas, your AI agents remain fragile, costly, and unreliable.

The Solution: Structured Validation with Pydantic for Resilient AI Agent Architecture

The key to building truly reliable AI agents lies in imposing strict structure and validation on the LLM's function call outputs. We achieve this by integrating Pydantic – a powerful Python library for data validation and settings management – directly into our function calling workflow. Pydantic allows us to define clear, immutable schemas for our tool arguments, acting as a crucial gatekeeper before any external function is executed.

Here's the architectural concept:

  1. LLM Initiates Function Call: The LLM (e.g., OpenAI's GPT-4, Anthropic's Claude) receives a user prompt and, based on its training and provided tool definitions, suggests a function call with specific arguments.
  2. Pydantic Validation Layer: Instead of directly executing the suggested function, we first parse the LLM's raw argument string and attempt to validate it against a pre-defined Pydantic model for that specific function.
  3. Error Handling & Self-Correction:
    • If validation SUCCEEDS, the function is executed normally.
    • If validation FAILS, Pydantic provides detailed error messages. We then feed these specific, structured error messages back to the LLM as part of the conversation history. This crucial feedback loop instructs the LLM on exactly *what* went wrong with its previous attempt (e.g., "symbol must be a string of length 1-5"), prompting it to self-correct and generate a valid function call in the next turn.
  4. Retry Mechanism: A controlled retry loop ensures the agent attempts to correct its input a predefined number of times, preventing infinite loops while giving the LLM ample opportunity to succeed.

This architecture transforms an otherwise unpredictable LLM interaction into a predictable, robust, and self-correcting process. It ensures that only valid, well-formed arguments ever reach your critical business logic.

Step-by-Step Implementation: Building a Reliable Stock Data Agent

Let's walk through building a simple AI agent that can fetch stock information and company news, making it robust against malformed LLM outputs using Pydantic.

First, ensure you have the necessary libraries installed:

pip install openai pydantic

Now, let's define our Pydantic models and the actual functions (tools) the LLM can call.

import os
import json
from typing import Literal, List

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

# --- 1. Define Pydantic Models for Function Arguments ---

class GetStockPriceArgs(BaseModel):
    symbol: str = Field(..., min_length=1, max_length=5, pattern=r"^[A-Z]{1,5}$", description="The stock ticker symbol (e.g., 'AAPL', 'GOOGL'). Must be 1-5 uppercase letters.")
    date: str = Field(..., pattern=r"^\\d{4}-\\d{2}-\\d{2}$", description="The date in YYYY-MM-DD format (e.g., '2023-10-26').")

class GetCompanyNewsArgs(BaseModel):
    symbol: str = Field(..., min_length=1, max_length=5, pattern=r"^[A-Z]{1,5}$", description="The stock ticker symbol (e.g., 'AAPL', 'GOOGL'). Must be 1-5 uppercase letters.")
    limit: int = Field(1, ge=1, le=10, description="The maximum number of news articles to retrieve, between 1 and 10.")

# --- 2. Define the Actual Tool Functions ---

def get_stock_price(symbol: str, date: str) -> str:
    """Fetches the closing price of a stock for a given date."""
    print(f"[TOOL CALL] Fetching stock price for {symbol} on {date}")
    # In a real application, you would call an external API here
    mock_data = {
        "AAPL": {"2023-10-26": 170.01, "2023-10-27": 169.34},
        "GOOGL": {"2023-10-26": 135.50, "2023-10-27": 134.99}
    }
    price = mock_data.get(symbol, {}).get(date)
    if price is None:
        return f"Could not retrieve price for {symbol} on {date}. Data might not be available or symbol/date is invalid."
    return f"The closing price of {symbol} on {date} was ${price:.2f}."

def get_company_news(symbol: str, limit: int) -> str:
    """Retrieves the latest news headlines for a given company symbol."""
    print(f"[TOOL CALL] Fetching {limit} news articles for {symbol}")
    # In a real application, call a news API
    mock_news = {
        "AAPL": [
            {"title": "Apple Q4 Earnings Beat Expectations", "date": "2023-10-27"},
            {"title": "New iPhone Launch Rumors", "date": "2023-10-26"}
        ],
        "GOOGL": [
            {"title": "Google Cloud Sees Strong Growth", "date": "2023-10-27"},
            {"title": "Alphabet Invests in AI Startup", "date": "2023-10-26"}
        ]
    }
    news_articles = mock_news.get(symbol, [])[:limit]
    if not news_articles:
        return f"No news found for {symbol}."
    formatted_news = "\n".join([f"- {article['title']} ({article['date']})" for article in news_articles])
    return f"Latest news for {symbol}:\n{formatted_news}"

# --- 3. Map tool names to actual functions ---

tools_map = {
    "get_stock_price": get_stock_price,
    "get_company_news": get_company_news,
}

# --- 4. Define OpenAPI-style tool definitions for the LLM ---

llm_tools = [
    {
        "type": "function",
        "function": {
            "name": "get_stock_price",
            "description": GetStockPriceArgs.__doc__, # Using Pydantic docstring
            "parameters": GetStockPriceArgs.model_json_schema()
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_company_news",
            "description": GetCompanyNewsArgs.__doc__,
            "parameters": GetCompanyNewsArgs.model_json_schema()
        }
    }
]

# --- 5. Implement the Agent Logic with Pydantic Validation and Retry ---

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

def run_conversation(user_message: str, max_retries: int = 3):
    messages = [
        {"role": "system", "content": "You are a helpful financial assistant. Use the provided tools to answer questions about stock prices and company news. Respond concisely."},
        {"role": "user", "content": user_message}
    ]

    for retry_count in range(max_retries):
        try:
            response = client.chat.completions.create(
                model="gpt-4o", # Or another suitable model like gpt-3.5-turbo
                messages=messages,
                tools=llm_tools,
                tool_choice="auto",
                temperature=0.2 # Lower temperature for more deterministic function calling
            )

            response_message = response.choices[0].message
            tool_calls = response_message.tool_calls

            if tool_calls:
                messages.append(response_message)
                tool_outputs = []
                for tool_call in tool_calls:
                    function_name = tool_call.function.name
                    function_to_call = tools_map.get(function_name)
                    function_args_str = tool_call.function.arguments

                    if function_to_call:
                        try:
                            # === Pydantic Validation Step ===
                            if function_name == "get_stock_price":
                                validated_args = GetStockPriceArgs.model_validate_json(function_args_str)
                            elif function_name == "get_company_news":
                                validated_args = GetCompanyNewsArgs.model_validate_json(function_args_str)
                            else:
                                raise ValueError(f"Unknown function: {function_name}")

                            # Execute the tool with validated arguments
                            function_response = function_to_call(**validated_args.model_dump())
                            tool_outputs.append({
                                "tool_call_id": tool_call.id,
                                "output": function_response
                            })
                            print(f"[Pydantic Validation] SUCCESS for {function_name}. Output: {function_response[:50]}...")

                        except ValidationError as e:
                            error_message = f"Pydantic validation failed for function '{function_name}'.\n"
                            error_message += f"Please correct the arguments: {e.json()}"
                            tool_outputs.append({
                                "tool_call_id": tool_call.id,
                                "output": error_message
                            })
                            print(f"[Pydantic Validation] FAILED for {function_name}. Error: {error_message[:100]}...")

                        except Exception as e:
                            error_message = f"Error executing function '{function_name}': {str(e)}"
                            tool_outputs.append({
                                "tool_call_id": tool_call.id,
                                "output": error_message
                            })
                            print(f"[Tool Execution] FAILED for {function_name}. Error: {error_message[:100]}...")

                    else:
                        error_message = f"Error: Function '{function_name}' not found."
                        tool_outputs.append({
                            "tool_call_id": tool_call.id,
                            "output": error_message
                        })
                        print(f"[Tool Mapping] FAILED for {function_name}. Error: {error_message[:100]}...")

                messages.extend([
                    {
                        "tool_call_id": tool_output["tool_call_id"],
                        "role": "tool",
                        "name": tool_call.function.name,
                        "content": tool_output["output"]
                    } for tool_call, tool_output in zip(tool_calls, tool_outputs)
                ])

                # Check if any validation failed. If so, retry.
                if any("Pydantic validation failed" in output["output"] for output in tool_outputs):
                    print(f"[Agent] Validation failed. Retrying... (Attempt {retry_count + 1}/{max_retries})")
                    continue # Continue to the next retry loop iteration
                else:
                    # If all tools executed successfully (or no tools were called), break the loop
                    second_response = client.chat.completions.create(
                        model="gpt-4o",
                        messages=messages,
                    )
                    return second_response.choices[0].message.content
            else:
                return response_message.content # LLM responded directly without tools

        except Exception as e:
            print(f"[Agent] An unexpected error occurred: {str(e)}")
            break # Exit on unexpected errors

    print("[Agent] Max retries reached or unrecoverable error. Please refine your query.")
    return "I'm sorry, I couldn't complete your request after several attempts. Could you please rephrase or provide more details?"

# --- Example Usage ---
if __name__ == "__main__":
    print("\n--- Test Case 1: Valid Input ---")
    result = run_conversation("What was the price of AAPL on 2023-10-26?")
    print("Agent Final Response:", result)

    print("\n--- Test Case 2: Invalid Stock Symbol (LLM will correct after feedback) ---")
    result = run_conversation("Tell me the price of APPLE on 2023-10-26.")
    print("Agent Final Response:", result)

    print("\n--- Test Case 3: Invalid Date Format (LLM will correct) ---")
    result = run_conversation("What's the latest news for GOOGL for October 26th 2023?")
    print("Agent Final Response:", result)

    print("\n--- Test Case 4: Invalid Limit (LLM will correct) ---")
    result = run_conversation("Give me 20 news articles for AAPL.")
    print("Agent Final Response:", result)

    print("\n--- Test Case 5: Complex valid query ---")
    result = run_conversation("What's the price of GOOGL on 2023-10-27 and give me 3 news articles for GOOGL?")
    print("Agent Final Response:", result)

    print("\n--- Test Case 6: Non-tool related query ---")
    result = run_conversation("What is the capital of France?")
    print("Agent Final Response:", result)

In this code:

  • We define GetStockPriceArgs and GetCompanyNewsArgs using Pydantic, specifying types, required fields, and even regular expressions for symbol and date formats, and value ranges for limit.
  • The llm_tools list converts these Pydantic schemas into the OpenAPI-style JSON schema that LLMs expect for function calling. This is done using PydanticModel.model_json_schema().
  • The run_conversation function manages the interaction loop.
  • Crucially, inside run_conversation, after the LLM suggests a tool_call, we deserialize its function.arguments string into our Pydantic model using model_validate_json().
  • If ValidationError occurs, we catch it, format the detailed error message, and send it back to the LLM within a tool message. This teaches the LLM to correct its input on subsequent attempts.
  • The retry_count loop ensures the agent attempts self-correction up to max_retries.

How Pydantic Helps

Pydantic's strength lies in its declarative nature. By defining a schema once, you get:

  • Automatic Type Checking: Ensures arguments are of the correct Python type.
  • Data Validation: Enforces constraints like min_length, max_length, pattern (regex), ge (greater than or equal to), etc.
  • Clear Error Messages: When validation fails, Pydantic generates detailed, machine-readable (and human-understandable) error messages, which are invaluable for giving feedback to the LLM.
  • Serialization/Deserialization: Handles converting JSON strings to Python objects and vice-versa seamlessly.

Optimization & Best Practices for Production Reliability

  • Fine-tune Prompting: Even with validation, a clear system prompt guiding the LLM on expected argument formats and emphasizing the importance of accuracy can reduce initial errors. For example, explicitly telling the LLM to use YYYY-MM-DD format.
  • Dynamic Tool Provisioning: For agents with many tools, dynamically providing only the relevant tools based on user intent can reduce context window usage and improve the LLM's focus, leading to fewer errors and lower costs.
  • Custom Error Handling Strategies: For critical workflows, beyond simply retrying, consider escalating to a human-in-the-loop or logging detailed error metrics for analysis.
  • LLM Temperature Tuning: For tasks where accuracy and deterministic output (like function calling) are paramount, a lower temperature setting (e.g., 0.1-0.3) is generally recommended to make the LLM's responses less creative and more focused.
  • Cost-Aware Retries: While retries are essential, repeated LLM calls can increase costs. Implement an exponential backoff strategy and ensure max_retries is reasonable for your budget and latency requirements.
  • Asynchronous Tool Execution: For tools that might take a long time to respond, consider executing them asynchronously to prevent blocking the agent's main loop.

Business Impact & Quantifiable ROI

Implementing robust function calling with Pydantic isn't just a technical best practice; it delivers tangible business value:

  • Reduced Operational Costs (30-50%): By significantly lowering the incidence of failed AI tasks, organizations can drastically reduce the need for manual oversight, debugging, and data correction. This frees up engineering teams to focus on innovation rather than maintenance.
  • Enhanced User Experience & Retention (15-25% improvement): Users interact with more reliable, accurate AI agents. This leads to higher satisfaction, increased trust, and improved retention rates as the AI consistently delivers correct results and actions.
  • Accelerated Development Cycles (20-40% faster): Developers spend less time building brittle input validation logic or custom error parsers. Pydantic provides a standardized, declarative way to define expected inputs, allowing teams to build and deploy new AI features faster.
  • Increased Trust for Critical Applications: With a proven mechanism for input validation and self-correction, businesses can confidently deploy AI agents for high-stakes tasks such as financial trading, healthcare diagnostics, or supply chain management, knowing that critical parameters will always be correctly formatted.
  • Reduced Latency for Recoverable Errors: Instead of immediate failure, agents can self-correct within milliseconds, maintaining a smooth user experience even when the LLM initially makes a mistake, preventing a full round trip of human intervention.

By transforming LLM unpredictability into structured reliability, Pydantic empowers businesses to unlock the full potential of AI agents, moving them from experimental tools to indispensable, production-grade assets.

Conclusion

The promise of AI agents lies in their ability to automate complex tasks and interact with the real world through tools. However, this promise is only fully realized when these interactions are reliable and predictable. The inherent non-determinism of LLMs poses a significant challenge, leading to costly failures and eroding trust in AI systems.

By integrating Pydantic for schema validation and implementing intelligent self-correction loops, developers can build AI agents that are not only powerful but also robust, resilient, and production-ready. This approach mitigates the risks associated with LLM unreliability, drastically reduces operational overhead, enhances user experience, and ultimately unlocks the true business value of AI automation. Embrace structured validation, and transform your AI agents from brittle prototypes into dependable workhorses.

Muhammad Tahir logo

Muhammad Tahir

Building web & mobile apps since 2021. Passionate about clean code and real-world impact.