Building AI Agents from Scratch using LangChain, OpenAI & Python
An AI Agent represents a shift from passive text completion to autonomous goal execution. By pairing a Large Language Model (LLM) with callable functions known as Tools, agents can reason over user inputs, delegate computational or retrieval tasks to external systems, and synthesize real-time results into coherent answers.
1. The Core Mental Model of an AI Agent
A standalone Large Language Model operates purely as a probabilistic next-token generator. While powerful, it suffers from two major limitations:
- Knowledge Cutoffs: Inability to fetch live or proprietary data.
- Deterministic Computation Deficits: Frequent hallucinations during complex mathematical or symbolic calculations.
An AI Agent solves this by separating reasoning from execution.
Real-Life Analogy: The Architect and the Contractor
Consider an architect (the LLM) designing a building. While the architect possesses the intelligence to plan the structure, calculate spatial layouts, and instruct laborers, they do not manually pour concrete, install electrical wiring, or plumb water pipes. Instead, the architect delegates specialized operations to licensed sub-contractors (Tools). When a contractor finishes their task, they report back to the architect, who inspects the work and determines the next step.
2. Anatomy of a Custom Tool
In LangChain, a Tool is a Python function exposed to an LLM with structured metadata. When an agent determines it cannot or should not answer a query natively, it issues a structured payload directing the runtime to execute a specific tool.
For an LLM to utilize a Python function effectively, three criteria must be fulfilled:
- The
@toolDecorator: Wraps the standard Python callable into an instance of LangChain'sBaseTool. - Explicit Type Annotations: Arguments must have defined types (e.g.,
a: int, b: int). LangChain converts these type hints into a JSON Schema that informs the LLM what data types are required. - Descriptive Docstrings: The docstring functions as the documentation read by the LLM. It must explain:
- What the function does.
- When the function should be invoked.
- What each argument represents.
Note: If you omit the docstring or write an ambiguous description, the LLM will struggle to decide whether to call the tool or will pass improperly structured arguments.
3. Step-by-Step Implementation: Building Custom Math Tools
To illustrate the mechanics without the noise of external network APIs, we define two deterministic arithmetic tools: addition and multiplication.
Step 1: Environment Configuration
Before executing any LangChain code, load your API credentials from an environment file:
from dotenv import load_dotenv
# Load OPENAI_API_KEY from .env
load_dotenv()
Step 2: Defining the Addition and Multiplication Tools
from langchain_core.tools import tool
@tool
def add_numbers(a: int, b: int) -> int:
"""
Return the sum of two numbers.
Args:
a (int): The first number.
b (int): The second number.
"""
return a + b
@tool
def multiply_numbers(a: int, b: int) -> int:
"""
Return the product of two numbers.
Args:
a (int): The first number.
b (int): The second number.
"""
return a * b
4. Manual Testing and Direct Tool Invocation
Before attaching tools to an autonomous agent, verify their direct execution. LangChain tools implement the Runnable interface and can be executed via the .invoke() method.
Because the tool may receive multiple keyword arguments, inputs must be provided as a dictionary:
# Direct tool execution
result_add = add_numbers.invoke({"a": 12, "b": 3})
print(f"Addition Result: {result_add}")
result_mul = multiply_numbers.invoke({"a": 12, "b": 3})
print(f"Multiplication Result: {result_mul}")
Output:
Addition Result: 15
Multiplication Result: 36
5. Wiring the Agent Core: LLM, Tools, and System Prompt
An agent brings together three fundamental components:
- LLM: The central reasoning engine (e.g.,
gpt-4). - Tools List: The suite of capabilities exposed to the model.
- System Prompt: Behavioral guidelines establishing the persona and execution constraints.
from langchain_openai import ChatOpenAI
from langchain.agents import create_agent
# 1. Initialize the primary model
llm = ChatOpenAI(model="gpt-4", temperature=0)
# 2. Bundle the available tools into a list
tools = [add_numbers, multiply_numbers]
# 3. Define the operational mandate
system_prompt = (
"You are a math teacher. Always use the provided tools "
"to perform any mathematical calculations."
)
# 4. Construct the agent
agent = create_agent(
model=llm,
tools=tools,
system_prompt=system_prompt
)
6. Invoking the Agent
When interacting with the agent runtime, input is structured as a dictionary containing a messages key. This key holds a sequence of conversation turns formatted with standard roles ("user", "assistant", or "system").
# Define user query
user_input = {
"messages": [
{"role": "user", "content": "What is 2 + 3?"}
]
}
# Run the agent pipeline
response = agent.invoke(user_input)
7. Deep-Dive: Dissecting the Agent Execution State
The returned response object is a dictionary containing the full execution transcript inside response["messages"]. Iterating through this sequence reveals the underlying ReAct (Reasoning + Acting) execution loop:
for msg in response["messages"]:
print(f"Type: {type(msg).__name__}")
print(f"Content: {msg.content}")
if hasattr(msg, "tool_calls") and msg.tool_calls:
print(f"Tool Calls: {msg.tool_calls}")
print("-" * 50)
Execution Transcript Breakdown
Extracting the Final Answer Cleanly
In production applications, intermediate tool metadata should be abstracted from the end-user. Access the final generated response via the last element of the message list:
final_answer = response["messages"][-1].content
print(final_answer)
# Output: "The sum of 2 and 3 is 5."
8. Multi-Step Reasoning and Tool Chaining
Agents excel at resolving multi-step problems requiring chained operations and correct operator precedence. Consider the compound expression:
When submitted to the agent:
compound_query = {
"messages": [
{"role": "user", "content": "What is 2 + 30 * 10?"}
]
}
compound_response = agent.invoke(compound_query)
print(compound_response["messages"][-1].content)
Output:
The result of 2 + 30 * 10 is 302.
What Happened Behind the Scenes?
The agent autonomously decomposed the problem based on arithmetic rules:
9. Architectural Comparison
10. Common Production Pitfalls
- Vague Docstrings:
- Anti-Pattern:
def add(a, b): """Adds stuff.""" - Consequence: The LLM may hallucinate when to choose between overlapping tools. Write clear, unambiguous intent statements.
- Anti-Pattern:
- Missing or Loose Type Hints:
- Anti-Pattern:
def query_db(query, limit): ... - Consequence: The model may provide a string for
limit(e.g.,"five"instead of5), causing runtime exceptions in the Python tool.
- Anti-Pattern:
- Unvalidated Return Values:
- Consequence: If a tool raises an unhandled exception or returns raw binary data, the agent loop may crash or exceed model context limits. Always return structured strings, numbers, or serialized JSON.
11. Summary and Key Takeaways
- Composition of an Agent: An agent is the union of an LLM (reasoning engine), tools (execution capabilities), and a prompt (behavioral boundary).
- The Role of the
@toolDecorator: Converts regular Python functions into model-readable schemas using function names, type hints, and docstrings. - The Docstring is Model Documentation: The docstring is not merely for developers; it is injected into the model's context to guide tool selection.
- Dynamic Argument Parsing: When invoking a tool, the LLM maps natural language inputs into structured key-value dictionaries matching the function's signature.
- The ReAct Execution Cycle: The agent operates via an iterative loop:
- Receive input.
- Emit a
tool_callsrequest. - Execute the tool and capture output as a
ToolMessage. - Pass the output back to the LLM for final generation.
- Autonomous Tool Chaining: Complex tasks are systematically broken down; the agent can invoke multiple tools sequentially, using the output of one as the input for the next.
- Production Response Extraction: Extract the final synthesized response directly using
response["messages"][-1].content.