Build a LangChain Agent with Memory and Tools (Full Example)
Build a complete LangChain conversational agent with persistent memory, multiple tools, and step-by-step trace — from setup to a production-ready implementation with code.
Get more content like this on Telegram!
Daily AI tips, notes & resources — free
I want to show you what a genuinely complete agent looks like — not a toy example that calls one function, but an agent with multiple tools, persistent memory across turns, proper error handling, and a trace you can follow step by step. This is the kind of agent that can actually handle a real conversation.
By the end of this guide, you will have a conversational research assistant that remembers what you discussed earlier, can search the web, check the weather, calculate things, and look up product information. The architecture scales directly to production — the patterns here are what I use in real deployments.
If you need background on either memory or tools individually first, check the AI agent memory and planning guide and Build AI agent with LangChain. For understanding the full agent landscape, AI agents explained provides good context.
What We Are Building
A conversational assistant called "Aria" that:
- Remembers the conversation across multiple turns (per-user memory)
- Can search the web for current information
- Can perform calculations
- Can look up product information from a simulated catalog
- Can check the current date and time
- Handles tool failures gracefully without crashing
The agent also provides a complete step-by-step trace so you can see exactly how it reasons through each response.
Setup
pip install langchain langchain-openai langchain-community \
duckduckgo-search fastapi uvicorn python-dotenv
# .env
OPENAI_API_KEY=your_key_here
LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=your_langsmith_key # optional but recommended
LANGCHAIN_PROJECT=aria-agent
Step 1: Define the Tools
Each tool is a Python function decorated with @tool. The docstring is what the LLM reads to understand what the tool does and when to use it — write it carefully.
import os
import math
from datetime import datetime
from dotenv import load_dotenv
from langchain_core.tools import tool
from langchain_community.tools import DuckDuckGoSearchRun
load_dotenv()
# Tool 1: Web search
search = DuckDuckGoSearchRun()
@tool
def search_web(query: str) -> str:
"""Search the internet for current information about a topic, person, or event.
Use this when you need facts that might have changed recently or are not in your training data.
Input: a search query string.
"""
try:
result = search.run(query)
return result if result else "No results found for this query."
except Exception as e:
return f"Search failed: {str(e)}. Try a different query."
# Tool 2: Calculator
@tool
def calculate(expression: str) -> str:
"""Perform mathematical calculations.
Input: a valid Python math expression as a string (e.g., '15 * 1.08', 'math.sqrt(144)', '(100 - 20) / 4').
Do NOT include variable names — only numbers and operators.
"""
try:
# Restrict to safe math operations
safe_globals = {"math": math, "__builtins__": {}}
result = eval(expression, safe_globals)
return f"Result: {result}"
except ZeroDivisionError:
return "Error: Division by zero"
except Exception as e:
return f"Calculation error: {str(e)}. Check the expression format."
# Tool 3: Product catalog lookup
PRODUCT_CATALOG = {
"python course": {
"name": "Python Complete Course",
"price": 149.99,
"description": "40-hour comprehensive Python course from basics to advanced topics",
"rating": 4.8,
"in_stock": True,
},
"ai toolkit": {
"name": "AI Toolkit Pro",
"price": 299.99,
"description": "50 pre-built AI agent templates with customization guides",
"rating": 4.6,
"in_stock": True,
},
"data science bundle": {
"name": "Data Science Bundle",
"price": 199.99,
"description": "Complete data science curriculum: NumPy, Pandas, Scikit-learn, visualization",
"rating": 4.7,
"in_stock": False,
},
"ml fundamentals": {
"name": "Machine Learning Fundamentals",
"price": 129.99,
"description": "Math and implementation foundations of machine learning algorithms",
"rating": 4.5,
"in_stock": True,
},
}
@tool
def lookup_product(product_name: str) -> str:
"""Look up information about a product in our catalog.
Use this when a user asks about specific products, their prices, availability, or descriptions.
Input: the product name or a close approximation (e.g., 'python course', 'AI toolkit').
"""
query = product_name.lower().strip()
# Direct match
if query in PRODUCT_CATALOG:
p = PRODUCT_CATALOG[query]
stock_status = "In Stock" if p["in_stock"] else "Currently Out of Stock"
return (f"Product: {p['name']}\n"
f"Price: ${p['price']}\n"
f"Rating: {p['rating']}/5.0\n"
f"Status: {stock_status}\n"
f"Description: {p['description']}")
# Fuzzy match
for key, product in PRODUCT_CATALOG.items():
if query in key or key in query:
p = product
stock_status = "In Stock" if p["in_stock"] else "Currently Out of Stock"
return (f"Product: {p['name']}\n"
f"Price: ${p['price']}\n"
f"Rating: {p['rating']}/5.0\n"
f"Status: {stock_status}\n"
f"Description: {p['description']}")
available = ", ".join(PRODUCT_CATALOG.keys())
return f"Product '{product_name}' not found. Available products: {available}"
# Tool 4: Date and time
@tool
def get_current_datetime(timezone: str = "UTC") -> str:
"""Get the current date and time.
Use this when the user asks about the current time, date, or wants to know what day it is.
Input: timezone name (default: 'UTC'). Examples: 'UTC', 'US/Eastern', 'Europe/London'.
"""
try:
from zoneinfo import ZoneInfo
tz = ZoneInfo(timezone)
now = datetime.now(tz)
return f"Current datetime ({timezone}): {now.strftime('%A, %B %d, %Y at %I:%M %p %Z')}"
except Exception:
now = datetime.utcnow()
return f"Current datetime (UTC): {now.strftime('%A, %B %d, %Y at %I:%M %p UTC')}"
# Tool 5: Unit converter
@tool
def convert_units(value: float, from_unit: str, to_unit: str) -> str:
"""Convert between common units of measurement.
Supports: temperature (celsius/fahrenheit/kelvin), length (meters/feet/inches/km/miles),
weight (kg/lbs/grams/ounces).
Input: numeric value, source unit, target unit.
"""
from_unit = from_unit.lower().strip()
to_unit = to_unit.lower().strip()
conversions = {
("celsius", "fahrenheit"): lambda v: v * 9/5 + 32,
("fahrenheit", "celsius"): lambda v: (v - 32) * 5/9,
("celsius", "kelvin"): lambda v: v + 273.15,
("kelvin", "celsius"): lambda v: v - 273.15,
("meters", "feet"): lambda v: v * 3.28084,
("feet", "meters"): lambda v: v / 3.28084,
("km", "miles"): lambda v: v * 0.621371,
("miles", "km"): lambda v: v / 0.621371,
("kg", "lbs"): lambda v: v * 2.20462,
("lbs", "kg"): lambda v: v / 2.20462,
}
key = (from_unit, to_unit)
if key in conversions:
result = conversions[key](value)
return f"{value} {from_unit} = {result:.4f} {to_unit}"
return f"Conversion from {from_unit} to {to_unit} is not supported."
# Collect all tools
tools = [search_web, calculate, lookup_product, get_current_datetime, convert_units]
print(f"Tools loaded: {[t.name for t in tools]}")
Step 2: Build the Agent
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.agents import create_openai_tools_agent, AgentExecutor
llm = ChatOpenAI(model="gpt-4o", temperature=0.3)
# System prompt that defines Aria's personality and behavior
system_prompt = """You are Aria, a helpful AI assistant for AiTechWorlds.com.
Your capabilities:
- Search the web for current information
- Perform calculations
- Look up product catalog information
- Get the current date and time
- Convert between units
Guidelines:
- Be conversational and remember context from earlier in our conversation
- Use tools when you need current or specific information
- For calculations, always use the calculator tool rather than computing in your head
- When looking up products, always check the catalog first before making up information
- If a tool fails, explain what happened and suggest alternatives
- Keep responses focused and concise — don't pad with unnecessary text
Current date context: You were last updated in early 2026. Use search_web for anything after that."""
prompt = ChatPromptTemplate.from_messages([
("system", system_prompt),
MessagesPlaceholder(variable_name="chat_history"),
("human", "{input}"),
MessagesPlaceholder(variable_name="agent_scratchpad"),
])
agent = create_openai_tools_agent(
llm=llm,
tools=tools,
prompt=prompt,
)
agent_executor = AgentExecutor(
agent=agent,
tools=tools,
verbose=True, # shows the full reasoning trace
max_iterations=10, # prevent infinite loops
handle_parsing_errors=True,
return_intermediate_steps=True, # capture the tool call trace
)
print("Agent created successfully")
Step 3: Add Per-Session Memory
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_community.chat_message_histories import ChatMessageHistory
from langchain_core.chat_history import BaseChatMessageHistory
# In-memory store — swap for Redis in production
session_store: dict[str, BaseChatMessageHistory] = {}
def get_session_history(session_id: str) -> BaseChatMessageHistory:
if session_id not in session_store:
session_store[session_id] = ChatMessageHistory()
return session_store[session_id]
# Wrap agent executor with memory management
agent_with_memory = RunnableWithMessageHistory(
agent_executor,
get_session_history,
input_messages_key="input",
history_messages_key="chat_history",
)
print("Memory wrapper added")
Step 4: A Full Conversation Trace
Here is a multi-turn conversation showing the agent using tools and remembering context:
def chat(message: str, session_id: str = "demo_session", show_trace: bool = True):
"""Send a message to the agent and show the response with optional trace."""
config = {"configurable": {"session_id": session_id}}
result = agent_with_memory.invoke(
{"input": message},
config=config
)
print(f"\n{'='*65}")
print(f"USER: {message}")
if show_trace and "intermediate_steps" in result:
steps = result["intermediate_steps"]
if steps:
print(f"\n[TRACE — {len(steps)} step(s)]")
for i, (action, observation) in enumerate(steps):
print(f" Step {i+1}: Tool '{action.tool}'")
print(f" Input: {str(action.tool_input)[:100]}")
print(f" Output: {str(observation)[:120]}")
print(f"\nARIA: {result['output']}")
return result["output"]
# Run the full conversation
print("Starting multi-turn conversation with Aria...\n")
# Turn 1: Simple greeting with personal info
chat("Hi Aria! I'm Marcus, and I'm interested in learning Python. What do you recommend?",
session_id="marcus_001")
# Turn 2: Memory test — agent should remember the user's name and interest
chat("What's my name and what was I asking about?",
session_id="marcus_001")
# Turn 3: Tool use — product lookup
chat("How much does the Python course cost, and is it in stock?",
session_id="marcus_001")
# Turn 4: Tool use — calculation
chat("If I buy the Python course and the AI Toolkit together, and I have a 15% discount, what's the total?",
session_id="marcus_001")
# Turn 5: Tool use — web search for current info
chat("What are the latest Python 3.14 features? I want to know what's new.",
session_id="marcus_001")
# Turn 6: Tool use — date/time
chat("What day is it today?",
session_id="marcus_001")
# Turn 7: Context recall combining memory + tool use
chat("Based on everything we've discussed, summarize what I should do this week to start my Python journey.",
session_id="marcus_001")
Step 5: Understanding the Trace Output
When verbose=True and return_intermediate_steps=True, you see the agent's reasoning process. Here is what a typical tool-using turn looks like for the pricing calculation:
> Entering new AgentExecutor chain...
Invoking: `lookup_product` with `{'product_name': 'python course'}`
Product: Python Complete Course
Price: $149.99
...
Invoking: `lookup_product` with `{'product_name': 'ai toolkit'}`
Product: AI Toolkit Pro
Price: $299.99
...
Invoking: `calculate` with `{'expression': '(149.99 + 299.99) * (1 - 0.15)'}`
Result: 382.482
> Finished chain.
ARIA: With a 15% discount on both the Python Complete Course ($149.99)
and the AI Toolkit Pro ($299.99), your total comes to $382.48.
Here is the breakdown:
- Combined original price: $449.98
- Discount (15%): $67.50
- Final total: $382.48
This trace shows exactly why observability matters: you can verify the agent called the right tools, passed the right arguments, and used the tool outputs correctly in its final response.
Step 6: Building the FastAPI Endpoint
# aria_api.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional
import uvicorn
app = FastAPI(title="Aria Agent API", version="1.0")
class ChatRequest(BaseModel):
session_id: str
message: str
show_trace: bool = False
class TraceStep(BaseModel):
tool: str
input: str
output: str
class ChatResponse(BaseModel):
session_id: str
response: str
trace: list[TraceStep] = []
turn_count: int
@app.post("/chat", response_model=ChatResponse)
async def chat_endpoint(request: ChatRequest):
if not request.message.strip():
raise HTTPException(status_code=400, detail="Message cannot be empty")
config = {"configurable": {"session_id": request.session_id}}
try:
result = await agent_with_memory.ainvoke(
{"input": request.message},
config=config
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Agent error: {str(e)}")
trace_steps = []
if request.show_trace and "intermediate_steps" in result:
for action, observation in result["intermediate_steps"]:
trace_steps.append(TraceStep(
tool=action.tool,
input=str(action.tool_input)[:200],
output=str(observation)[:300],
))
history = get_session_history(request.session_id)
turn_count = len(history.messages) // 2
return ChatResponse(
session_id=request.session_id,
response=result["output"],
trace=trace_steps,
turn_count=turn_count,
)
@app.delete("/sessions/{session_id}")
async def clear_session(session_id: str):
if session_id in session_store:
session_store[session_id].clear()
return {"message": f"Session {session_id} cleared"}
raise HTTPException(status_code=404, detail="Session not found")
@app.get("/sessions/{session_id}/history")
async def get_history(session_id: str):
if session_id not in session_store:
raise HTTPException(status_code=404, detail="Session not found")
messages = session_store[session_id].messages
return {
"session_id": session_id,
"turn_count": len(messages) // 2,
"messages": [
{"role": msg.type, "content": msg.content[:200]}
for msg in messages
]
}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8002)
Test it:
# Start the server
uvicorn aria_api:app --reload --port 8002
# First turn
curl -X POST http://localhost:8002/chat \
-H "Content-Type: application/json" \
-d '{"session_id": "marcus_001", "message": "Hi, I am Marcus. What products do you have?", "show_trace": true}'
# Second turn — tests memory
curl -X POST http://localhost:8002/chat \
-H "Content-Type: application/json" \
-d '{"session_id": "marcus_001", "message": "What was I asking about earlier?", "show_trace": false}'
Adding a Custom Tool at Runtime
One useful pattern: let authorized users register new tools without redeploying the server.
def add_tool_to_agent(new_tool, session_id: str = None):
"""Add a new tool to the agent dynamically."""
global tools, agent, agent_executor, agent_with_memory
tools.append(new_tool)
# Rebuild the agent with the updated tools list
agent = create_openai_tools_agent(llm=llm, tools=tools, prompt=prompt)
agent_executor = AgentExecutor(
agent=agent,
tools=tools,
verbose=True,
max_iterations=10,
handle_parsing_errors=True,
return_intermediate_steps=True,
)
agent_with_memory = RunnableWithMessageHistory(
agent_executor,
get_session_history,
input_messages_key="input",
history_messages_key="chat_history",
)
print(f"Tool '{new_tool.name}' added. Current tools: {[t.name for t in tools]}")
# Example: add a new tool dynamically
@tool
def get_joke(topic: str = "general") -> str:
"""Tell a short, clean joke on a given topic. Use when user wants a joke or some humor."""
jokes = {
"programming": "Why do programmers prefer dark mode? Because light attracts bugs.",
"python": "Why is Python so popular? Because the syntax is clear — no curly brace drama.",
"general": "Why don't scientists trust atoms? Because they make up everything.",
}
return jokes.get(topic.lower(), jokes["general"])
add_tool_to_agent(get_joke)
Error Handling and Edge Cases
Real agents encounter messy situations. Here is how to handle the most common ones:
from langchain_core.callbacks import BaseCallbackHandler
class AgentErrorHandler(BaseCallbackHandler):
"""Catches and logs agent errors gracefully."""
def on_tool_error(self, error, **kwargs):
print(f"[WARNING] Tool error: {error}")
# In production, send this to your error tracking (Sentry, etc.)
def on_agent_action(self, action, **kwargs):
# Log each tool call for monitoring
print(f"[AGENT] Calling tool: {action.tool}")
def on_agent_finish(self, finish, **kwargs):
# Log successful completions
print(f"[AGENT] Finished: {finish.return_values.get('output', '')[:100]}")
error_handler = AgentErrorHandler()
# Rebuild with error handler
agent_executor = AgentExecutor(
agent=agent,
tools=tools,
verbose=False, # use the callback handler instead
max_iterations=10,
handle_parsing_errors=True,
return_intermediate_steps=True,
callbacks=[error_handler],
)
Architecture Overview
Here is how all the pieces fit together:
User Message
↓
FastAPI Endpoint
↓
RunnableWithMessageHistory
├── Gets session history from store
└── Injects history into agent input
↓
AgentExecutor
├── ChatPromptTemplate (system + history + input + scratchpad)
├── GPT-4o (decides: respond directly OR call tool)
└── Tool Loop:
├── search_web → DuckDuckGo results
├── calculate → Python eval result
├── lookup_product → catalog response
├── get_current_datetime → formatted time
└── convert_units → conversion result
↓
Final Response
↓
Session history updated
↓
Response returned to user
Each component is replaceable. Swap GPT-4o for Claude or Gemini. Swap DuckDuckGo search for Google or Brave. Swap in-memory session store for Redis. The architecture stays the same.
What to Build Next
This agent architecture is ready to expand in several directions. You can add a retrieval tool that searches your own document collection — the RAG system tutorial shows exactly how to build that retriever. You can wire it to an OpenAI Assistants API backend instead of a local agent — OpenAI Assistants API guide covers the trade-offs.
For comparing this pattern to other agent frameworks, AutoGPT vs BabyAGI gives historical context on how agentic patterns evolved. For production observability, add LangSmith tracing to the agent by setting LANGCHAIN_TRACING_V2=true — every tool call becomes fully visible in the dashboard.
Conclusion
A LangChain agent with memory and tools is not dramatically more complex than a simple chatbot — it is just more capable. The core additions are three things: a set of well-documented tool functions, a prompt that includes the conversation history placeholder, and a session store that persists history between API calls.
The trace output is what makes agents debuggable. Always keep return_intermediate_steps=True in development so you can see exactly what the agent decided and why. In production, LangSmith captures this automatically.
The Aria agent in this guide handles the most common assistant patterns: web search for current information, calculations, catalog lookups, and multi-turn context retention. From here, the next steps are specific to your domain — swap the product catalog for your actual data source, add domain-specific tools, and deploy behind proper authentication.
Build it, trace it, and expand it incrementally. Drop a comment if you run into a specific pattern that is not covered here.
FAQs
What is the difference between a LangChain chain and a LangChain agent? A chain follows a fixed sequence of steps — it does X, then Y, then Z in a predetermined order. An agent decides which steps to take based on the LLM's reasoning. The agent sees the user's input, decides which tool (if any) to call, observes the tool output, and then decides what to do next. This makes agents flexible but also less predictable than chains.
How do I add memory to an existing agent without rewriting it? Wrap your existing AgentExecutor in RunnableWithMessageHistory. Pass the executor as the runnable, provide a get_session_history function that returns a BaseChatMessageHistory keyed on session ID, and specify the input and history message keys. Your agent code stays unchanged — only the invocation pattern changes.
How many tools can a LangChain agent use effectively? In practice, agents work best with 3-10 well-defined tools. Beyond 10-15 tools, the agent starts selecting tools incorrectly or getting confused about which tool fits the task. If you need many tools, consider grouping them into toolkits with clear categorical names, or use a router that picks which subset of tools to give the agent based on the query topic.
Frequently Asked Questions
AiTechWorlds Team
✓ Verified WriterThe AiTechWorlds team is passionate about AI, technology, and education. We create high-quality, research-backed content to help you learn, grow, and succeed in the modern digital world.
Related Articles
5 AutoGen Conversational Patterns (One-Shot, Multi-Turn, Hierarchical)
Master AutoGen's 5 core agent interaction models — from one-shot requests to hierarchical orchestration — with full code examples and use case comparisons.
AutoGen vs LangChain: Which for Multi-Agent Systems in 2026?
AutoGen vs LangChain for multi-agent systems in 2026 — feature comparison, same use case in both frameworks, and an honest verdict on when each wins.
5 AutoGPT Memory Types (Vector, Redis, File, Conversation)
Compare AutoGPT's 5 memory backends — local file, Redis, Pinecone, Milvus, and Weaviate. Choose the right one for speed, cost, and persistence needs.
AutoGPT vs LangChain Agents: Which is More Autonomous?
Compare AutoGPT's zero-shot autonomy against LangChain's ReAct agents. Discover which handles complex tasks better and when to choose each framework.