AI and Machine Learning

Build AI Agents with Function Calling in Python

Master building intelligent AI agents using Python and LLM function calling. Learn to integrate custom tools, manage state, and deploy robust, real-world applications.

Khader Vali October 1, 2026 17 min read

Building Intelligent AI Agents with Function Calling in Python

As senior engineers, we’ve witnessed the rapid evolution of software, from monolithic applications to microservices, and now into the exciting realm of AI-powered systems. The advent of Large Language Models (LLMs) has undeniably opened new frontiers, allowing us to imbue our applications with unprecedented natural language understanding and generation capabilities. However, standalone LLMs, while powerful, often hit a ceiling when they need to interact with the real world—fetch live data, perform calculations, or execute actions beyond their training data.

This is where the concept of function calling emerges as a game-changer. It’s the critical bridge that transforms a “smart chatbot” into a truly intelligent “AI agent” capable of perceiving, reasoning, and acting. In this comprehensive guide, we’ll dive deep into building robust and effective AI agents in Python, leveraging the power of function calling to unlock dynamic, real-world interactions.

We’ll explore the underlying principles, walk through practical code examples using popular LLM APIs, discuss architectural patterns, and consider real-world applications and best practices. By the end of this article, you’ll have a solid understanding and the practical skills to architect your own sophisticated AI agents.

The Foundation: Large Language Models (LLMs) and Their Limitations

Before we integrate function calling, let’s briefly revisit the core capabilities and inherent limitations of LLMs. At their heart, LLMs like OpenAI’s GPT models, Anthropic’s Claude, or Google’s Gemini, are highly sophisticated pattern-matching machines. Trained on colossal datasets of text and code, they excel at tasks such as:

  • Natural Language Understanding: Comprehending complex queries, identifying entities, and extracting information.
  • Natural Language Generation: Producing coherent, contextually relevant, and creative text.
  • Reasoning: Performing various forms of logical deduction, synthesis, and problem-solving within their textual domain.
  • Code Generation: Writing, refactoring, and explaining code in various programming languages.

However, despite these impressive abilities, LLMs operate within a confined digital universe. Their knowledge is static, limited to their last training cut-off date. This leads to several critical limitations for practical applications:

  • Lack of Real-time Information: LLMs cannot access current events, live stock prices, or the latest weather forecasts. They can only rely on the data they were trained on.
  • Inability to Perform Actions: An LLM cannot book a flight, send an email, query a database, or control external hardware. It can only generate text that describes such actions.
  • Computational Constraints: While they can perform basic arithmetic, complex calculations, simulations, or data analysis are beyond their core capabilities and often lead to “hallucinations” or inaccuracies.
  • Limited Specificity: For highly specialized domains, an LLM’s general training might not provide the depth or accuracy required without fine-tuning or external knowledge.
  • Hallucinations: LLMs are known to confidently generate factually incorrect information, especially when pressed for details outside their factual knowledge base or when trying to “guess” an answer.

These limitations highlight a crucial gap: LLMs are powerful brains, but they lack hands to interact with the world and eyes to perceive real-time data. Function calling provides precisely these missing senses and limbs.

Build AI Agents with Function Calling in Python
Generated Image

Enter Function Calling: Bridging the Gap

Function calling is a mechanism that allows an LLM to intelligently decide when to use a tool (a defined function) and to respond with the parameters required to call that tool. Instead of directly answering a user’s prompt, the LLM might suggest calling a specific function with specific arguments, based on its understanding of the user’s intent and the available tools.

Think of it this way: you’re building a personal assistant. If you ask it “What’s the weather like in New York?”, a standalone LLM might say, “I don’t have real-time information.” But an LLM equipped with function calling, when presented with a “get_current_weather” tool, will understand that your request aligns with that tool’s capability. It will then output a structured request like, “Call get_current_weather(location='New York')“. Your application then executes this function, gets the actual weather data, and feeds it back to the LLM. The LLM can then synthesize a natural language response based on the real data: “The weather in New York is currently sunny with a temperature of 75°F.”

How Function Calling Works (High-Level Flow):

  1. Tool Definition: You, the developer, define a set of functions (tools) that your AI agent can use. For each function, you provide a clear description of what it does, its name, and a schema for its input parameters (including types, descriptions, and whether they are required).
  2. User Prompt: A user sends a request or question to your AI agent.
  3. LLM Inference (with Tools): Your application sends the user’s prompt to the LLM, along with the descriptions of all available tools.
  4. LLM Decision: The LLM analyzes the prompt and the tool descriptions. It decides one of three things:
    • It can answer the question directly using its internal knowledge.
    • It needs to call one or more tools to fulfill the request, and it provides the necessary function name and arguments.
    • It needs more information from the user to proceed.
  5. Tool Execution (if needed): If the LLM suggests calling a tool, your application intercepts this suggestion, parses the function name and arguments, and executes the corresponding Python function.
  6. Result Feedback: The output from the executed tool (e.g., weather data, database query result) is then sent back to the LLM, usually as part of a continued conversation turn.
  7. Final Response: The LLM processes the tool’s output and generates a natural language response to the user, incorporating the real-world information obtained from the tool.

This iterative process—LLM suggests, application executes, LLM synthesizes—forms the core of an AI agent’s interaction loop.

Core Concepts of Function Calling

To implement function calling effectively, we need to understand a few key concepts:

1. Tool Definition (Function Description)

This is arguably the most critical part. The LLM doesn’t “know” your Python functions; you need to describe them in a structured way that the LLM can understand. Most LLM APIs (like OpenAI) use a JSON schema format for this. The description should be precise, clear, and comprehensive, explaining what the function does, what problem it solves, and what its parameters are.

A good tool definition includes:

  • name: A unique, descriptive name for the function (e.g., get_current_weather, search_products).
  • description: A natural language explanation of the function’s purpose. This is crucial for the LLM to understand when to use it.
  • parameters: A JSON schema defining the function’s input arguments. This specifies each parameter’s name, type (string, number, boolean, array), description, and whether it’s required.

The better your descriptions, the more accurately the LLM will decide when and how to call your functions.

2. Orchestration Logic (The Agent Loop)

The “agent loop” is the control flow that manages the conversation and tool execution. It’s the brain of your AI agent that decides the next step based on the LLM’s output. This loop typically involves:

  • Receiving user input.
  • Sending input and tool definitions to the LLM.
  • Parsing the LLM’s response.
  • If a tool call is suggested, executing the tool and capturing its output.
  • Adding the tool’s output back to the conversation history.
  • Looping until a final, direct LLM response is generated or a maximum number of steps is reached.

This loop enables multi-turn interactions where the agent might need to call several tools sequentially or ask clarifying questions before arriving at a final answer.

3. LLM Integration

Modern LLM providers offer APIs specifically designed to facilitate function calling. These APIs abstract away much of the complexity, allowing you to focus on defining your tools and orchestrating the agent logic. We’ll primarily use OpenAI’s API for our examples, as it’s widely adopted and provides a robust function calling interface.

The key is understanding how to correctly format your tool definitions and how to interpret the LLM’s responses, which might include a direct message, a function call suggestion, or an instruction to ask the user for more information.

Building Blocks in Python

Let’s get practical. To build our AI agent, we’ll need:

1. Choosing an LLM Provider

For consistency and broad applicability, we’ll use OpenAI’s API. Ensure you have an API key. You’ll need to install the OpenAI Python client library:

pip install openai python-dotenv

It’s good practice to manage your API key using environment variables, for example, in a .env file:

OPENAI_API_KEY="your_openai_api_key_here"

2. Defining Tools (Python Functions and their LLM Schemas)

Our tools will be regular Python functions. Alongside them, we’ll create a dictionary that describes them in the JSON schema format expected by the LLM. Let’s start with a simple weather fetching tool.

import os
import json
import requests
from dotenv import load_dotenv
from openai import OpenAI

# Load environment variables
load_dotenv()
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")

# Initialize OpenAI client
client = OpenAI(api_key=OPENAI_API_KEY)

# --- 1. Define our Python tools ---

def get_current_weather(location: str, unit: str = "fahrenheit") -> dict:
    """
    Get the current weather in a given location.

    Args:
        location (str): The city and state, e.g., "San Francisco, CA" or "London, UK".
        unit (str): The unit of temperature. Can be "celsius" or "fahrenheit". Defaults to "fahrenheit".

    Returns:
        dict: A dictionary containing weather information (location, temperature, unit, description).
    """
    # This is a mock implementation. In a real application, you'd call a weather API.
    print(f"DEBUG: Calling get_current_weather for {location} in {unit}")
    
    weather_data = {
        "San Francisco, CA": {"temperature": 68, "unit": "fahrenheit", "description": "Partly cloudy"},
        "London, UK": {"temperature": 18, "unit": "celsius", "description": "Rainy"},
        "Tokyo, JP": {"temperature": 25, "unit": "celsius", "description": "Sunny"},
        "New York, NY": {"temperature": 72, "unit": "fahrenheit", "description": "Clear skies"},
    }

    # Simulate fetching data, including error for unknown locations
    if location in weather_data:
        data = weather_data[location]
        if unit == "celsius" and data["unit"] == "fahrenheit":
            data["temperature"] = round((data["temperature"] - 32) * 5/9, 2)
            data["unit"] = "celsius"
        elif unit == "fahrenheit" and data["unit"] == "celsius":
            data["temperature"] = round((data["temperature"] * 9/5) + 32, 2)
            data["unit"] = "fahrenheit"
        return {"location": location, **data}
    else:
        return {"error": f"Could not retrieve weather for {location}. Location not found in mock data."}


# --- 2. Describe our tools to the LLM using JSON Schema ---

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_current_weather",
            "description": "Get the current weather in a given location, including temperature and conditions.",
            "parameters": {
                "type": "object",
                "properties": {
                    "location": {
                        "type": "string",
                        "description": "The city and state, e.g., 'San Francisco, CA' or 'London, UK'.",
                    },
                    "unit": {
                        "type": "string",
                        "enum": ["celsius", "fahrenheit"],
                        "description": "The unit of temperature to return.",
                    },
                },
                "required": ["location"],
            },
        },
    }
]

# A dictionary to map tool names to actual Python functions
available_functions = {
    "get_current_weather": get_current_weather,
}

Notice how the tools list contains a dictionary describing our get_current_weather function. The description and parameter descriptions are crucial for the LLM to understand when and how to call this function.

3. The Agent Loop (Orchestration)

Now, let’s put it all together into an interactive agent loop. This loop will manage the conversation, decide when to call tools, and feed the results back to the LLM.

def run_conversation():
    messages = []
    
    # Initial system message to set the agent's persona or instructions
    messages.append({
        "role": "system",
        "content": "You are a helpful AI assistant. You can answer questions and provide real-time information by calling tools."
    })

    print("AI Agent: Hello! How can I help you today?")

    while True:
        user_input = input("You: ")
        if user_input.lower() in ["exit", "quit"]:
            print("AI Agent: Goodbye!")
            break

        messages.append({"role": "user", "content": user_input})

        try:
            # Step 1: Send user message and available tools to the LLM
            response = client.chat.completions.create(
                model="gpt-4o", # Or "gpt-3.5-turbo", "gpt-4-turbo" etc.
                messages=messages,
                tools=tools, # Provide the tool definitions
                tool_choice="auto", # Let the LLM decide whether to call a tool or not
                temperature=0.7 # Adjust for creativity vs. focus
            )
            response_message = response.choices[0].message
            
            # Step 2: Check if the LLM wants to call a function
            if response_message.tool_calls:
                tool_calls = response_message.tool_calls
                messages.append(response_message) # Extend conversation with the tool call request

                for tool_call in tool_calls:
                    function_name = tool_call.function.name
                    function_to_call = available_functions.get(function_name)

                    if function_to_call:
                        try:
                            function_args = json.loads(tool_call.function.arguments)
                            function_response = function_to_call(**function_args)
                            
                            messages.append({
                                "tool_call_id": tool_call.id,
                                "role": "tool",
                                "name": function_name,
                                "content": json.dumps(function_response), # Add tool output to messages
                            })
                            print(f"DEBUG: Executed tool '{function_name}' with args {function_args}")
                            print(f"DEBUG: Tool response: {function_response}")

                        except json.JSONDecodeError:
                            error_msg = f"Error: Failed to parse tool arguments for {function_name}. Args: {tool_call.function.arguments}"
                            print(error_msg)
                            messages.append({
                                "tool_call_id": tool_call.id,
                                "role": "tool",
                                "name": function_name,
                                "content": json.dumps({"error": error_msg}),
                            })
                        except Exception as e:
                            error_msg = f"Error executing tool '{function_name}': {e}"
                            print(error_msg)
                            messages.append({
                                "tool_call_id": tool_call.id,
                                "role": "tool",
                                "name": function_name,
                                "content": json.dumps({"error": error_msg}),
                            })
                    else:
                        error_msg = f"Error: Unknown tool '{function_name}' requested by LLM."
                        print(error_msg)
                        messages.append({
                            "tool_call_id": tool_call.id,
                            "role": "tool",
                            "name": function_name,
                            "content": json.dumps({"error": error_msg}),
                        })
                
                # Step 3: Send tool output back to the LLM for final response
                second_response = client.chat.completions.create(
                    model="gpt-4o",
                    messages=messages,
                    temperature=0.7 # No tools needed for this final synthesis step
                )
                print(f"AI Agent: {second_response.choices[0].message.content}")
                messages.append(second_response.choices[0].message) # Add LLM's final response

            else:
                # Step 2 (Alternative): LLM provides a direct text response
                print(f"AI Agent: {response_message.content}")
                messages.append(response_message) # Add LLM's direct response

        except Exception as e:
            print(f"An error occurred during LLM interaction: {e}")
            print("AI Agent: I'm sorry, I encountered an error. Please try again.")
            # Optionally, pop the last user message to prevent infinite error loop
            if messages and messages[-1]["role"] == "user":
                messages.pop()

# Run the conversation
if __name__ == "__main__":
    run_conversation()

This run_conversation function demonstrates a basic agent loop. It continuously takes user input, sends it to the LLM, and handles both direct LLM responses and function call requests. If a function is called, its output is fed back to the LLM to generate a user-friendly answer.

Demonstration of the Weather Agent:

When you run this script, you’ll see output similar to this:

AI Agent: Hello! How can I help you today?
You: What's the weather like in London?
DEBUG: Calling get_current_weather for London, UK in fahrenheit
DEBUG: Tool response: {'location': 'London, UK', 'temperature': 64.4, 'unit': 'fahrenheit', 'description': 'Rainy'}
AI Agent: The weather in London is currently rainy with a temperature of 64.4°F.

You: How about Tokyo in Celsius?
DEBUG: Calling get_current_weather for Tokyo, JP in celsius
DEBUG: Tool response: {'location': 'Tokyo, JP', 'temperature': 25, 'unit': 'celsius', 'description': 'Sunny'}
AI Agent: In Tokyo, the weather is sunny with a temperature of 25°C.

You: Tell me a joke.
AI Agent: Why don't scientists trust atoms? Because they make up everything!

You: What's the weather in Mars?
DEBUG: Calling get_current_weather for Mars in fahrenheit
DEBUG: Tool response: {'error': 'Could not retrieve weather for Mars. Location not found in mock data.'}
AI Agent: I'm sorry, I couldn't retrieve the weather for Mars. My current tools don't have information for that location.

You: exit
AI Agent: Goodbye!

This simple example clearly illustrates the power of function calling. The LLM intelligently decides when to use the get_current_weather tool, extracts the correct parameters, and then synthesizes the tool’s output into a natural language response.

Build AI Agents with Function Calling in Python
Generated Image

Advanced Agent Design Patterns

While the basic loop is functional, real-world agents often require more sophistication.

1. Multi-Tool Agents

Agents can be equipped with numerous tools, each serving a specific purpose (e.g., calendar management, email sending, database querying, image generation). The LLM’s ability to discern which tool (or tools) to use from a large set is remarkable, provided the tool descriptions are clear and distinct. When multiple tools are available, the LLM will often chain them implicitly within the conversation turns if one tool’s output is necessary for another, or if multiple pieces of information are needed to fulfill a complex request.

2. Stateful Agents: Maintaining Conversation History and Context

Our current agent maintains conversation history within the messages list. This is crucial for context. For longer-running agents or agents interacting with different users, you might need:

  • Persistent Storage: Store conversation history in a database (e.g., Redis for chat history, PostgreSQL for user profiles).
  • Session Management: Associate conversations with specific user sessions.
  • Memory Systems: Integrate advanced memory mechanisms (e.g., using LangChain or LlamaIndex) that can summarize past conversations, retrieve relevant information from long-term memory, or manage a knowledge base.

3. Error Handling and Robustness

Tools can fail! Network issues, invalid API calls, or unexpected data can cause errors. Your agent loop needs to gracefully handle these scenarios:

  • Tool-specific Error Handling: Each tool function should have its own try-except blocks.
  • Agent-level Error Reporting: If a tool fails, return a structured error message to the LLM. The LLM can then inform the user, suggest alternatives, or ask for clarification.
  • Retries: For transient errors (e.g., network timeout), implement retry mechanisms.
  • Timeouts: Ensure tools have timeouts to prevent the agent from hanging indefinitely.

4. User Confirmation for Sensitive Actions

If an agent is about to perform a destructive or sensitive action (e.g., “delete all files,” “transfer money,” “send an email”), it’s critical to prompt the user for confirmation before executing the tool. This adds a layer of safety and prevents unintended consequences. This typically involves an extra turn where the LLM asks for confirmation, and only proceeds if the user explicitly agrees.

5. Chaining Tools and Complex Workflows

Complex requests often require chaining multiple tools. For example, “Find me flights to Paris next month, then check hotel availability near the Eiffel Tower.” This might involve:

  1. search_flights(destination='Paris', month='next month')
  2. LLM processes flight results.
  3. search_hotels(location='Eiffel Tower', check_in='...', check_out='...')
  4. LLM processes hotel results and combines information.

The agent loop inherently supports this by continually feeding tool outputs back to the LLM until a final response is ready. More advanced frameworks like LangChain provide explicit “chains” and “agents” that can orchestrate these multi-step workflows with greater control and observability.

Code Example 2: E-commerce Product Search & Order Status Agent

Let’s build a slightly more complex agent that can handle e-commerce queries. This agent will have two tools: one for searching products and another for checking order status.

import os
import json
import time # For simulating network latency
from dotenv import load_dotenv
from openai import OpenAI

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

# --- Define E-commerce Mock Data and Tools ---

mock_products_db = [
{"id": "P001", "name": "Red T-Shirt", "category": "Apparel", "price": 19.99, "color": "red", "size": ["S", "M", "L"]},
{"id": "P002", "name": "Blue Jeans", "category": "Apparel", "price": 49.99, "color": "blue", "size": ["28", "30", "32", "34"]},
{"id": "P003", "name": "Wireless Headphones", "category": "Electronics", "price": 129.99, "color": "black"},
{"id": "P004", "name": "Laptop Backpack", "category": "Accessories", "price": 35.00, "color": "grey"},
{"id": "P005", "name": "Green T-Shirt", "category": "Apparel", "price": 22.50, "color": "green", "size": ["M", "L", "XL"]},
{"id": "P006", "name": "Smartwatch", "category": "Electronics", "price": 199.99, "color": "silver"},
]

mock_orders_db = {
"ORD12345": {"status": "Shipped", "items": ["P001", "P004"], "tracking_number": "TRK987654321"},
"ORD67890": {"status": "Processing", "items": ["P003"], "tracking_number": None},
"ORD11223": {"status": "Delivered", "items": ["P002"], "tracking_number": "TRK123456789"},
}

def search_products(query: str, category: str = None, color: str = None, min_price: float = None, max_price: float = None) -> list[dict]:
"""
Searches for products in the e-commerce store based on various criteria.

Args:
query (str): A general search term (e.g., "t-shirt", "headphones").
category (str, optional): Filter by product category (e.g., "Apparel", "Electronics").
color (str, optional): Filter by product color.
min_price (float, optional): Minimum price for products.
max_price (float, optional): Maximum price for products.

Returns:
list[dict]: A list of matching product dictionaries.
"""
print(f"DEBUG: Calling search_products with query='{query}', category='{category}', color='{color}', min_price={min_price}, max_price={max_price}")
time.sleep(1) # Simulate API latency

results = []
query_lower = query.lower()

Written by

Khader Vali

Senior Software Engineer specializing in cloud architecture, real-time systems, and enterprise-scale applications.

Share this article

Related Articles

Vector Databases Under the Hood: ChromaDB vs Pinecone vs Qdrant

Jul 06, 2026 · 19 min read

Build Python AI Agents with Function Calling

Jul 09, 2026 · 17 min read

Evaluating LLM Outputs: Metrics, Benchmarks, & Human Feedback

Sep 27, 2026 · 16 min read