Let's put the theory of agents and tools into practice. In this section, we'll build a LangChain agent equipped with custom tools that interact with external data sources or perform specific calculations. This exercise simulates a common production scenario where an agent needs specialized capabilities beyond the LLM's built-in knowledge.
Our goal is to create an agent that can answer questions about current weather and estimate driving times between locations. This requires giving the agent access to two distinct functionalities via custom tools.
Imagine you need an assistant that can answer queries like:
To handle these requests, the agent needs:
We will implement these as custom LangChain tools and integrate them into a ReAct agent.
First, let's create a tool to get weather data. For a real application, you'd likely use a service like OpenWeatherMap, WeatherAPI, or a similar provider. This usually involves obtaining an API key. For simplicity in this practice section, we'll define a function that returns mock weather data. However, we'll structure it as if it were calling a real API.
# prerequisites: Ensure you have langchain and langchain_openai installed
# pip install langchain langchain_openai python-dotenv
import os
import random
from dotenv import load_dotenv
from langchain.tools import BaseTool, Tool
from typing import Type, Optional
from pydantic.v1 import BaseModel, Field # Use pydantic v1 for BaseTool compatibility
# Load environment variables (optional, for API keys if using real APIs)
load_dotenv()
# --- Weather Tool Implementation ---
class WeatherInput(BaseModel):
"""Input schema for the Weather Tool."""
location: str = Field(description="The city name for which to get the weather.")
def get_current_weather(location: str) -> str:
"""
Simulates fetching current weather for a location.
In a real application, this would call an external weather API.
"""
print(f"---> Calling Weather Tool for: {location}")
# Simulate API call
try:
# Mock data generation
temp_celsius = random.uniform(5.0, 35.0)
conditions = random.choice(["Sunny", "Cloudy", "Rainy", "Windy", "Snowy (unlikely!)"])
humidity = random.randint(30, 90)
return f"The current weather in {location} is {temp_celsius:.1f}°C, {conditions}, with {humidity}% humidity."
except Exception as e:
return f"Error fetching weather for {location}: {e}"
# Option 1: Using the Tool decorator (simpler for basic functions)
# weather_tool = Tool.from_function(
# func=get_current_weather,
# name="Weather Checker",
# description="Useful for finding the current weather conditions in a specific city. Input should be a city name.",
# args_schema=WeatherInput
# )
# Option 2: Subclassing BaseTool (more control, better for complex logic/state)
class WeatherTool(BaseTool):
name: str = "Weather Checker"
description: str = "Useful for finding the current weather conditions in a specific city. Input should be a city name."
args_schema: Type[BaseModel] = WeatherInput
def _run(self, location: str) -> str:
"""Use the tool."""
return get_current_weather(location)
async def _arun(self, location: str) -> str:
"""Use the tool asynchronously."""
# For this simple mock function, async isn't strictly necessary,
# but it demonstrates the pattern for real async API calls.
# In a real scenario, you'd use an async HTTP client (e.g., aiohttp).
return self._run(location) # Simulate async call
weather_tool = WeatherTool()
# Test the tool directly (optional)
# print(weather_tool.run({"location": "London"}))
# print(weather_tool.run("Paris")) # Also accepts direct string input if args_schema allows
Key points about this tool:
WeatherInput
Schema: We define a Pydantic model WeatherInput
to specify the expected input (location
). This helps LangChain validate inputs and provides structure. Using Pydantic v1 (pydantic.v1
) is often needed for compatibility with older BaseTool
structures.get_current_weather
Function: This is the core logic. It currently uses random data but mimics the structure of an API call handler, including basic error handling. The print
statement helps trace tool execution.WeatherTool
Class: We subclass BaseTool
for more explicit control.
name
: A concise identifier for the tool.description
: Crucial for the agent. The LLM uses this description to decide when to use the tool and what input to provide. Make it clear and informative.args_schema
: Links to our Pydantic input model._run
: The synchronous execution method._arun
: The asynchronous execution method. Even if the underlying function is synchronous, implementing _arun
is good practice for compatibility with asynchronous agent executors.Next, we need a tool to estimate driving times. Again, real-world implementations might use APIs like Google Maps Distance Matrix or OSRM. We'll simulate this with a simple calculation.
# --- Driving Time Tool Implementation ---
class DrivingTimeInput(BaseModel):
"""Input schema for the Driving Time Tool."""
origin: str = Field(description="The starting city or location.")
destination: str = Field(description="The destination city or location.")
def estimate_driving_time(origin: str, destination: str) -> str:
"""
Simulates estimating driving time between two locations.
Assumes a fixed average speed for simplicity.
"""
print(f"---> Calling Driving Time Tool for: {origin} to {destination}")
# Very simplified distance simulation based on city name lengths
# (Replace with a real distance calculation or API call in production)
simulated_distance_km = abs(len(origin) - len(destination)) * 50 + random.randint(50, 500)
average_speed_kph = 80
if simulated_distance_km == 0: # Avoid division by zero for same origin/destination
return f"Origin and destination ({origin}) are the same."
time_hours = simulated_distance_km / average_speed_kph
hours = int(time_hours)
minutes = int((time_hours - hours) * 60)
return f"The estimated driving time from {origin} to {destination} is approximately {hours} hours and {minutes} minutes ({simulated_distance_km} km)."
class DrivingTimeTool(BaseTool):
name: str = "Driving Time Estimator"
description: str = ("Useful for estimating the driving time between two cities. "
"Input should be the origin city and the destination city.")
args_schema: Type[BaseModel] = DrivingTimeInput
def _run(self, origin: str, destination: str) -> str:
"""Use the tool."""
return estimate_driving_time(origin, destination)
async def _arun(self, origin: str, destination: str) -> str:
"""Use the tool asynchronously."""
# Simulate async call for demonstration
return self._run(origin, destination)
driving_tool = DrivingTimeTool()
# Test the tool directly (optional)
# print(driving_tool.run({"origin": "Paris", "destination": "Berlin"}))
This tool follows the same pattern as the weather tool: an input schema (DrivingTimeInput
), a core logic function (estimate_driving_time
), and a BaseTool
subclass (DrivingTimeTool
) providing the name, description, and execution methods. The description clearly states its purpose and expected inputs.
Now that we have our custom tools, let's integrate them into an agent. We'll use a ReAct (Reasoning and Acting) agent, a common pattern where the LLM reasons about which action (tool) to take next based on the input and previous steps.
# --- Agent Setup ---
from langchain_openai import ChatOpenAI
from langchain import hub
from langchain.agents import create_react_agent, AgentExecutor
# Ensure you have OPENAI_API_KEY set in your environment or .env file
# os.environ["OPENAI_API_KEY"] = "your_api_key"
if not os.getenv("OPENAI_API_KEY"):
print("Warning: OPENAI_API_KEY not set. Agent execution will likely fail.")
# 1. Initialize the LLM
# Using a chat model is generally recommended for modern agents
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
# 2. Define the list of tools
tools = [weather_tool, driving_tool]
# 3. Get the prompt template
# Pulls a predefined ReAct prompt optimized for chat models
# You can explore other prompts on the LangChain Hub: https://smith.langchain.com/hub
prompt = hub.pull("hwchase17/react-chat")
# Inspect the prompt template if curious: print(prompt.template)
# 4. Create the ReAct Agent
# This binds the LLM, tools, and prompt together
agent = create_react_agent(llm, tools, prompt)
# 5. Create the Agent Executor
# This runs the agent loop (thought -> action -> observation -> ...)
agent_executor = AgentExecutor(
agent=agent,
tools=tools,
verbose=True, # Set to True to see the agent's reasoning steps
handle_parsing_errors=True, # Add robustness for potential LLM output parsing issues
max_iterations=5 # Prevent potential infinite loops
)
print("Agent Executor created successfully.")
Let's break down the agent creation:
ChatOpenAI
. Temperature is set to 0 for more deterministic responses suitable for tool use. Remember to handle your API key securely (environment variables are common).weather_tool
and driving_tool
instances into a list.hwchase17/react-chat
). This template structures the interaction, guiding the LLM to think step-by-step and decide which tool to use (or if it can answer directly).create_react_agent
: This function constructs the core agent logic, connecting the LLM's reasoning ability with the available tools and the prompt structure.AgentExecutor
: This is the runtime environment for the agent. It takes the agent logic and the tools, then manages the execution cycle:
weather_tool.run("London")
).verbose=True
is highly recommended during development to observe the agent's thought process.handle_parsing_errors=True
makes the agent more resilient if the LLM generates output that doesn't perfectly match the expected format.max_iterations
is a safety mechanism.With the agent executor ready, let's test it with different queries.
# --- Running the Agent ---
print("\n--- Running Simple Weather Query ---")
response1 = agent_executor.invoke({
"input": "What's the weather like right now in Toronto?",
"chat_history": [] # Start with empty history for single-turn queries
})
print("\nFinal Answer:", response1['output'])
print("\n--- Running Simple Driving Time Query ---")
response2 = agent_executor.invoke({
"input": "How long does it take to drive from Berlin to Munich?",
"chat_history": []
})
print("\nFinal Answer:", response2['output'])
print("\n--- Running Multi-Tool Query ---")
response3 = agent_executor.invoke({
"input": "Can you tell me the current weather in Rome and also how long it might take to drive there from Naples?",
"chat_history": []
})
print("\nFinal Answer:", response3['output'])
# Example of a query the agent should answer directly (if possible)
# print("\n--- Running Non-Tool Query ---")
# response4 = agent_executor.invoke({
# "input": "What is the capital of France?",
# "chat_history": []
# })
# print("\nFinal Answer:", response4['output'])
Observe the output when verbose=True
. You should see something like this pattern for each query involving tools:
Weather Checker
) and the required input ({"location": "Toronto"}
).AgentExecutor
runs the weather_tool
with "Toronto", captures its output (the simulated weather string), and feeds this back to the LLM.Pay close attention to how the agent uses the exact names and input schemas defined in your BaseTool
subclasses. The quality of the tool description
is paramount for the agent's ability to select the correct tool.
This practical exercise demonstrated the fundamental workflow for creating a LangChain agent with custom capabilities:
Tool.from_function
or by subclassing BaseTool
. Pay careful attention to the name
, description
, and args_schema
.AgentExecutor
to manage the runtime loop.verbose=True
to understand the agent's reasoning and tool usage.From here, you can explore:
create_openai_tools_agent
, create_tool_calling_agent
), which can offer more reliable input/output parsing compared to ReAct's text-based approach._arun
methods) for I/O-bound tasks like API calls.Building agents with custom tools is a core technique for creating powerful, specialized LLM applications that can interact with the world and solve complex problems.
© 2025 ApX Machine Learning