Building AI Agents with Tool Use and Function Calling
Master the art of building AI agents that can interact with external tools and APIs. This comprehensive guide covers function calling, tool orchestration, and practical implementation strategies.
AI agents that can use tools and call functions represent the next frontier in autonomous AI systems. Unlike chatbots that only generate text, these agents can interact with the real world—querying databases, calling APIs, manipulating files, and executing complex workflows.
If you've been wondering how to build agents that actually do things rather than just talk about them, this guide will walk you through the essential patterns and implementation strategies.
Understanding AI Agents vs. Simple Chatbots
The key difference between a basic chatbot and an AI agent lies in capability expansion. A chatbot processes text and returns text. An AI agent processes text, determines what actions to take, executes those actions using tools, and then responds based on the results.
Think of it this way: a chatbot can tell you the weather forecast if it memorized it during training. An AI agent can check the current weather by calling a weather API.
The Tool Use Paradigm
Modern AI agents follow a simple but powerful pattern:
- Reasoning: The LLM analyzes the user's request
- Planning: It decides which tools to use and in what order
- Execution: It calls the appropriate functions with the right parameters
- Synthesis: It processes the results and responds to the user
Implementing Function Calling with OpenAI
Let's start with a practical example using OpenAI's function calling API. We'll build an agent that can perform calculations and look up information.
import openai
import json
import requests
from datetime import datetime
class WeatherAgent:
def __init__(self, api_key, weather_api_key):
self.client = openai.OpenAI(api_key=api_key)
self.weather_api_key = weather_api_key
def get_weather(self, location: str) -> str:
"""Get current weather for a location"""
url = f"http://api.openweathermap.org/data/2.5/weather"
params = {
"q": location,
"appid": self.weather_api_key,
"units": "metric"
}
try:
response = requests.get(url, params=params)
data = response.json()
return f"Weather in {location}: {data['weather'][0]['description']}, "
f"Temperature: {data['main']['temp']}°C"
except Exception as e:
return f"Error getting weather: {str(e)}"
def calculate(self, expression: str) -> str:
"""Safely evaluate a mathematical expression"""
try:
# Basic safety check - only allow numbers, operators, and parentheses
allowed_chars = set('0123456789+-*/().')
if not all(c in allowed_chars or c.isspace() for c in expression):
return "Invalid characters in expression"
result = eval(expression)
return str(result)
except Exception as e:
return f"Calculation error: {str(e)}"
def run_agent(self, user_message: str) -> str:
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather information for a location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city name"
}
},
"required": ["location"]
}
}
},
{
"type": "function",
"function": {
"name": "calculate",
"description": "Perform mathematical calculations",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "Mathematical expression to evaluate"
}
},
"required": ["expression"]
}
}
}
]
messages = [
{"role": "user", "content": user_message}
]
response = self.client.chat.completions.create(
model="gpt-4",
messages=messages,
tools=tools,
tool_choice="auto"
)
message = response.choices[0].message
if message.tool_calls:
# Execute the function calls
messages.append(message)
for tool_call in message.tool_calls:
function_name = tool_call.function.name
function_args = json.loads(tool_call.function.arguments)
if function_name == "get_weather":
result = self.get_weather(function_args["location"])
elif function_name == "calculate":
result = self.calculate(function_args["expression"])
else:
result = "Unknown function"
messages.append({
"tool_call_id": tool_call.id,
"role": "tool",
"name": function_name,
"content": result
})
# Get the final response
final_response = self.client.chat.completions.create(
model="gpt-4",
messages=messages
)
return final_response.choices[0].message.content
return message.contentBuilding a More Robust Tool System
While OpenAI's function calling works well, you'll often want more control over tool orchestration. Here's a pattern I've found effective for building more sophisticated agents:
from abc import ABC, abstractmethod
from typing import Dict, List, Any, Optional
import inspect
class Tool(ABC):
"""Base class for all agent tools"""
@abstractmethod
def name(self) -> str:
pass
@abstractmethod
def description(self) -> str:
pass
@abstractmethod
def parameters(self) -> Dict[str, Any]:
pass
@abstractmethod
def execute(self, **kwargs) -> str:
pass
class SearchTool(Tool):
def __init__(self, search_api_key: str):
self.api_key = search_api_key
def name(self) -> str:
return "web_search"
def description(self) -> str:
return "Search the web for current information"
def parameters(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query"
}
},
"required": ["query"]
}
def execute(self, query: str) -> str:
# Implement your search logic here
return f"Search results for: {query}"
class FileManagerTool(Tool):
def name(self) -> str:
return "file_manager"
def description(self) -> str:
return "Read, write, and manage files"
def parameters(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["read", "write", "list"]
},
"path": {"type": "string"},
"content": {"type": "string"}
},
"required": ["action", "path"]
}
def execute(self, action: str, path: str, content: Optional[str] = None) -> str:
try:
if action == "read":
with open(path, 'r') as f:
return f.read()
elif action == "write":
with open(path, 'w') as f:
f.write(content or "")
return f"File written successfully: {path}"
elif action == "list":
import os
return "\n".join(os.listdir(path))
except Exception as e:
return f"File operation error: {str(e)}"
class Agent:
def __init__(self, llm_client, tools: List[Tool]):
self.llm = llm_client
self.tools = {tool.name(): tool for tool in tools}
def get_tool_definitions(self) -> List[Dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": tool.name(),
"description": tool.description(),
"parameters": tool.parameters()
}
}
for tool in self.tools.values()
]
def execute_tool(self, tool_name: str, **kwargs) -> str:
if tool_name not in self.tools:
return f"Unknown tool: {tool_name}"
return self.tools[tool_name].execute(**kwargs)Advanced Patterns and Best Practices
Error Handling and Graceful Degradation
Real-world agents need robust error handling. Tools can fail, APIs can be down, and user inputs can be malformed. Here's how to handle this gracefully:
class ResilientAgent(Agent):
def __init__(self, llm_client, tools: List[Tool], max_retries: int = 3):
super().__init__(llm_client, tools)
self.max_retries = max_retries
def execute_with_retry(self, tool_name: str, **kwargs) -> str:
for attempt in range(self.max_retries):
try:
return self.execute_tool(tool_name, **kwargs)
except Exception as e:
if attempt == self.max_retries - 1:
return f"Tool failed after {self.max_retries} attempts: {str(e)}"
# Log the error and continue
print(f"Attempt {attempt + 1} failed: {str(e)}")Tool Selection and Orchestration
One challenge with multi-tool agents is helping the LLM choose the right tool for the job. Here are some strategies that work well:
- Clear tool descriptions: Make your tool descriptions specific and unambiguous
- Examples in prompts: Show the agent how to use tools correctly
- Tool categories: Group related tools and describe when to use each category
- Validation layers: Check tool calls before execution
Pro tip: I've found that giving tools very specific, verb-based names (like "search_web" instead of "search") helps LLMs choose the right tool more reliably.
Real-World Gotchas and Solutions
Token Limits and Context Management
Function calling can quickly consume your context window, especially with complex tool outputs. Implement context trimming:
def trim_context(messages: List[Dict], max_tokens: int = 8000) -> List[Dict]:
# Keep system message and recent messages
if len(messages) <= 10:
return messages
return [messages[0]] + messages[-9:] # System + last 9 messagesSecurity Considerations
Never execute arbitrary code from LLM outputs. Always validate and sanitize function calls:
- Whitelist allowed functions
- Validate parameters against schemas
- Sanitize file paths and database queries
- Use rate limiting for external API calls
Conclusion
Building AI agents with tool use opens up incredible possibilities, but it requires careful design and robust error handling. Start with simple tools like calculation and web search, then gradually add more sophisticated capabilities.
The key to success is treating tools as reliable building blocks with clear interfaces. Your agent is only as good as its weakest tool, so invest time in making each tool robust and well-documented.
Key takeaways:
- Start simple with basic function calling patterns
- Build a robust tool abstraction layer for complex agents
- Implement proper error handling and retries
- Validate all tool calls for security
- Keep tool descriptions clear and specific
Ready to build your first tool-using agent? Start with the weather example above and gradually expand its capabilities. The future of AI is agents that can act, not just chat.