ntroduction
The term “AI agent” is one of the most popular right now. They emerged
after the LLM hype, when people realized that the latest LLM capabilities
are impressive but that they can only perform tasks on which they have
been explicitly trained. In that sense, normal LLMs do not have tools that
would allow them to do anything outside their scope of knowledge.
RAG
To address this, Retrieval-Augmented Generation (RAG) was later
introduced to retrieve additional context from external data sources and
inject it into the prompt, so the LLM becomes aware of more context. We
can roughly say that RAG made the LLM more knowledgeable, but for
more complex problems, the LLM + RAG approach still failed when the
solution path was not known in advance.
RAG pipeline
Agents
Agents are a remarkable concept built around LLMs that
introduce state, decision-making, and memory. Agents can be thought
of as a set of predefined tools for analyzing results and storing them in
memory for later use before producing the final answer.
LangGraph
LangGraph is a popular framework used for creating agents. As the name
suggests, agents are constructed using graphs with nodes and edges.
Nodes represent the agent’s state, which evolves over time. Edges define
the control flow by specifying transition rules and conditions between
nodes.
To better understand LangGraph in practice, we will go through a detailed
example. While LangGraph might seem too verbose for the problem
below, it usually has a much larger impact on complex problems with
large graphs.
First, we need to install the necessary libraries.
langgraph==1.0.5
langchain-community==0.4.1
jupyter==1.1.1
notebook==7.5.1
langchain[openai]
Then we import the necessary modules.
import os
from dotenv import load_dotenv
import json
import random
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from [Link] import StateGraph, START, END
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langchain.chat_models import init_chat_model
from [Link] import tool
from [Link] import Image, display
We would also need to create an .env file and add
an OPENAI_API_KEY there:
OPENAI_API_KEY=...
Then, with load_dotenv(), we can load the environment variables into
the system.
load_dotenv()
Extra functionalities
The function below will be useful for us to visually display constructed
graphs.
def display_graph(graph):
return display(Image(graph.get_graph().draw_mermaid_png()))
Agent
Let us initialize an agent based on GPT-5-nano using a simple command:
llm = init_chat_model("openai:gpt-5-nano")
State
In our example, we will construct an agent capable of answering questions
about soccer. Its thought process will be based on retrieved statistics
about players.
To do that, we need to define a state. In our case, it will be an entity
containing all the information an LLM needs about a player. To define a
state, we need to write a class that inherits from [Link]:
class PlayerState(BaseModel):
question: str
selected_tools: Optional[List[str]] = None
name: Optional[str] = None
club: Optional[str] = None
country: Optional[str] = None
number: Optional[int] = None
rating: Optional[int] = None
goals: Optional[List[int]] = None
minutes_played: Optional[List[int]] = None
summary: Optional[str] = None
When moving between LangGraph nodes, each node takes as input an
instance of PlayerState that specifies how to process the state. Our task
will be to define how exactly that state is processed.
Tools
First, we will define some of the tools an agent can use. A tool can be
roughly thought of as an additional function that an agent can call to
retrieve the information needed to answer a user’s question.
To define a tool, we need to write a function with a @tool decorator. It is
important to use clear parameter names and function docstrings, as the
agent will consider them when deciding whether to call the tool based on
the input context.
To make our examples simpler, we are going to use mock data instead of
real data retrieved from external sources, which is usually the case for
production applications.
In the first tool, we will return information about a player’s club and
country by name.
@tool
def fetch_player_information_tool(name: str):
"""Contains information about the football club of a player and its
country"""
data = {
'Haaland': {
'club': 'Manchester City',
'country': 'Norway'
},
'Kane': {
'club': 'Bayern',
'country': 'England'
},
'Lautaro': {
'club': 'Inter',
'country': 'Argentina'
},
'Ronaldo': {
'club': 'Al-Nassr',
'country': 'Portugal'
if name in data:
print(f"Returning player information: {data[name]}")
return data[name]
else:
return {
'club': 'unknown',
'country': 'unknown'
def fetch_player_information(state: PlayerState):
return fetch_player_information_tool.invoke({'name': [Link]})
You might be asking why we place a tool inside another function, which
seems like over-engineering. In fact, these two functions have different
responsibilities.
The function fetch_player_information() takes a state as a parameter and
is compatible with the LangGraph framework. It extracts the name field
and calls a tool that operates on the parameter level.
It provides a clear separation of concerns and allows easy reuse of the
same tool across multiple graph nodes.
Then we have an analogous function that retrieves a player’s jersey
number:
@tool
def fetch_player_jersey_number_tool(name: str):
"Returns player jersey number"
data = {
'Haaland': 9,
'Kane': 9,
'Lautaro': 10,
'Ronaldo': 7
if name in data:
print(f"Returning player number: {data[name]}")
return {'number': data[name]}
else:
return {'number': 0}
def fetch_player_jersey_number(state: PlayerState):
return fetch_player_jersey_tool.invoke({'name': [Link]})
For the third tool, we will be fetching the player’s FIFA rating:
@tool
def fetch_player_rating_tool(name: str):
"Returns player rating in the FIFA"
data = {
'Haaland': 92,
'Kane': 89,
'Lautaro': 88,
'Ronaldo': 90
if name in data:
print(f"Returning rating data: {data[name]}")
return {'rating': data[name]}
else:
return {'rating': 0}
def fetch_player_rating(state: PlayerState):
return fetch_player_rating_tool.invoke({'name': [Link]})
Now, let us write several more graph node functions that will retrieve
external data. We are not going to label them as tools as before, which
means they won’t be something the agent decides to call or not.
def retrieve_goals(state: PlayerState):
name = [Link]
data = {
'Haaland': [25, 40, 28, 33, 36],
'Kane': [33, 37, 41, 38, 29],
'Lautaro': [19, 25, 27, 24, 25],
'Ronaldo': [27, 32, 28, 30, 36]
if name in data:
return {'goals': data[name]}
else:
return {'goals': [0]}
Here is a graph node that retrieves the number of minutes played over the
last several seasons.
def retrieve_minutes_played(state: PlayerState):
name = [Link]
data = {
'Haaland': [2108, 3102, 3156, 2617, 2758],
'Kane': [2924, 2850, 3133, 2784, 2680],
'Lautaro': [2445, 2498, 2519, 2773],
'Ronaldo': [3001, 2560, 2804, 2487, 2771]
if name in data:
return {'minutes_played': data[name]}
else:
return {'minutes_played': [0]}
Below is a node that extracts a player’s name from a user question.
def extract_name(state: PlayerState):
question = [Link]
prompt = f"""
You are a football name extractor assistant.
Your goal is to just extract a surname of a footballer in the following
question.
User question: {question}
You have to just output a string containing one word - footballer surname.
"""
response = [Link]([HumanMessage(content=prompt)]).content
print(f"Player name: ", response)
return {'name': response}
Now is the time when things get interesting. Do you remember the three
tools we defined above? Thanks to them, we can now create a planner
that will ask the agent to choose a specific tool to call based on the
context of the situation:
def planner(state: PlayerState):
question = [Link]
prompt = f"""
You are a football player summary assistant.
You have the following tools available: ['fetch_player_jersey_number',
'fetch_player_information', 'fetch_player_rating']
User question: {question}
Decide which tools are required to answer.
Return a JSON list of tool names, e.g. ['fetch_player_jersey_number',
'fetch_rating']
"""
response = [Link]([HumanMessage(content=prompt)]).content
try:
selected_tools = [Link](response)
except:
selected_tools = []
return {'selected_tools': selected_tools}
In our case, we will ask the agent to create a summary of a soccer player.
It will decide on its own which tool to call to retrieve additional data.
Docstrings under tools play an important role: they provide the agent with
additional context about the tools.