The Problem We're Solving
You've got a language model that can follow instructions beautifully — it can write code, answer questions, reason through problems. But here's the wall every developer hits: an LLM alone can't actually do anything in the real world.
Your model can tell you what the weather is, but it can't call the weather API. It can explain how to write a database query, but it can't run one against your actual database. It can suggest which tool to use, but it needs you to wire up the logic to actually invoke that tool.
That gap — between what an LLM can decide to do and what it can actually execute — is exactly what LangChain agents bridge. And they do it with elegance: you describe your tools, the agent figures out when to use them, and the framework handles all the plumbing.
But "agents" isn't one thing. It's a stack. And before you can build a production agent that talks to APIs, handles errors, and loops with human feedback, you need to understand Runnables — the fundamental building block that makes everything work.
This guide walks you through that entire stack. By the end, you'll understand not just how to build agents, but why each piece exists and how to compose them for production workloads.
What We'll Cover
- Runnables: The Foundation — Understanding the interface that powers everything
- Chaining Components — Building your first multi-step workflow
- Building a Code Generator — A practical first agent that chains LLM calls
- Tools: Teaching Agents to Act — How to give your agent superpowers
- Creating Custom Tools — Wrapping your Python code so agents can use it
- Tool Binding and Calling — How the model decides which tool to use
- The Agent Loop: Execution and Control — Manual agent construction for fine-grained control
- Integrating Real APIs — A complete weather agent example
- Advanced Patterns and create_agent — Modern LangChain shortcuts and when to use them
- Pitfalls and Production Lessons — Common failures and how to avoid them
1. Runnables: The Foundation
Before agents, before tools, before any of the magic — there's Runnable.
If you've used LangChain, you've probably seen this pattern:
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model="gpt-4")
result = model.invoke("What is 2 + 2?")
That invoke() call? That's a Runnable. The model is a Runnable. And understanding what Runnables are is the key to everything that follows.
What is a Runnable?
A Runnable is LangChain's abstraction for anything that takes input, processes it, and returns output. It's a contract that says: "I can be invoked, streamed, batched, or composed with other things."
Think of it like this: if you've ever used Unix pipes (cat file | grep pattern | wc -l), Runnables work the same way. Each pipe operation is independent, but you can compose them. Each step doesn't need to know about the others — it just needs to understand input and output.
Here's the key insight: everything in LangChain is a Runnable. Models, prompt templates, output parsers, tools, chains — they all implement the same interface. That's what makes composition so powerful.
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
# Each of these is a Runnable
prompt = ChatPromptTemplate.from_template("Tell me a joke about {topic}")
model = ChatOpenAI(model="gpt-4")
parser = StrOutputParser()
# Chain them together using the pipe operator |
# This creates a new Runnable that chains all three
joke_chain = prompt | model | parser
# Now invoke it with input
result = joke_chain.invoke({"topic": "debugging"})
print(result)
# Output: "Why do programmers prefer dark mode? Because light attracts bugs!"
Notice the | operator. That's the pipe — it chains Runnables together. When you invoke the final chain, LangChain automatically:
- Passes your input to the first Runnable (prompt)
- Takes that output and passes it to the next (model)
- Takes that output and passes it to the next (parser)
- Returns the final result
This is declarative composition. You describe the flow, LangChain handles the threading.
Why This Matters for Agents
Agents are just more complex Runnable chains. An agent is:
- A prompt (Runnable)
- A model (Runnable)
- A tool executor (Runnable)
- Looped until the model says "stop"
Once you get the Runnable pattern, agents become less mysterious. They're just composition taken a step further.
2. Chaining Components
Let's build something slightly more complex — a chain that combines multiple steps.
Suppose you're building a product recommendation system. You need to:
- Take a user's preferences
- Pass them to an LLM to generate a search query
- Use that query to find products
- Pass the results back to the LLM to write recommendations
Here's how you'd structure this with Runnables:
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
# Step 1: Generate a search query from preferences
query_prompt = ChatPromptTemplate.from_template(
"""Given these user preferences, generate a concise search query.
User preferences: {preferences}
Search query:"""
)
model = ChatOpenAI(model="gpt-4")
query_parser = StrOutputParser()
# This chain transforms preferences → search query
search_query_chain = query_prompt | model | query_parser
# Step 2: Mock product search (in reality, this calls an API)
def search_products(query):
"""Simulate a product search. In production, this hits your database."""
# Pretend we found these products
return f"Found products matching '{query}': Laptop Pro 15, Gaming Mouse RGB, USB-C Hub"
# Step 3: Generate recommendations from search results
recommendation_prompt = ChatPromptTemplate.from_template(
"""You are a product recommendation expert.
User preferences: {preferences}
Search results: {search_results}
Write a brief recommendation based on these results."""
)
recommendation_chain = recommendation_prompt | model | query_parser
# Now compose the entire flow
from langchain_core.runnables import RunnablePassthrough
# RunnablePassthrough keeps the original input flowing through the chain
full_chain = (
RunnablePassthrough.assign(search_results=search_query_chain | (lambda q: search_products(q)))
| recommendation_chain
)
# Execute it
result = full_chain.invoke({"preferences": "I need a laptop for video editing"})
print(result)
This is still just a chain — deterministic, no looping. But notice the pattern: you describe the structure, compose Runnables, and invoke the whole thing. Agents will follow this exact pattern, just with the addition of a loop and the ability to dynamically choose which tool to use.
3. Building a Code Generator: A Practical First Agent
Let's build something that feels agent-like: a chain that generates Python code for a user request, and optionally improves it.
This isn't a full agent yet (no dynamic tool selection), but it demonstrates the chain patterns you'll see in real agents.
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
model = ChatOpenAI(model="gpt-4")
# Step 1: Generate initial code
code_gen_prompt = ChatPromptTemplate.from_template(
"""You are an expert Python developer.
Generate clean, well-commented Python code for this request.
Request: {request}
Only output the code, no explanations."""
)
# Step 2: Review and improve the code
review_prompt = ChatPromptTemplate.from_template(
"""Review this Python code for:
- Efficiency
- Readability
- Best practices
- Potential bugs
Code:
{code}
Provide the improved code with inline comments explaining any changes."""
)
# Compose into a pipeline
code_gen_chain = code_gen_prompt | model | StrOutputParser()
# This is the key: pass the original request through, while also computing the generated code
code_review_chain = (
RunnablePassthrough.assign(code=code_gen_chain)
| review_prompt
| model
| StrOutputParser()
)
# Execute
result = code_review_chain.invoke({
"request": "Write a function that checks if a number is prime"
})
print(result)
Here's what's happening under the hood:
-
RunnablePassthrough.assign()keeps the original input and adds a new field (code) computed by runningcode_gen_chain - The enriched input flows to
review_prompt - The model reviews the code
- The parser returns clean text
This pattern is crucial for agents: at each step, you're enriching the context with new information, and that context flows to the next step.
4. Tools: Teaching Agents to Act
Now we get to the real power. A Tool is how you tell an agent: "Here's something you can do in the real world."
Before we build custom tools, let's understand what a Tool is:
from langchain_core.tools import tool
# Define a tool using the @tool decorator
@tool
def get_current_time() -> str:
"""Get the current date and time.
This tool is useful when the user asks about the current time,
date, or needs to schedule something."""
from datetime import datetime
return datetime.now().isoformat()
@tool
def calculator(expression: str) -> str:
"""Evaluate a mathematical expression.
Args:
expression: A valid Python math expression (e.g., '2 + 2' or 'sqrt(16)')
This tool is useful for calculations the model should perform precisely."""
try:
# Use Python's eval (never do this in production with untrusted input!)
result = eval(expression)
return f"Result: {result}"
except Exception as e:
return f"Error: {e}"
# Tools are now Runnables
print(get_current_time.invoke({}))
print(calculator.invoke({"expression": "2 ** 10"}))
Notice the docstrings. The docstring is what the LLM sees. It's your API documentation for the model. Good docstrings = good tool usage.
A Tool is:
- A callable function
- A name (derived from function name)
- A description (the docstring)
- Input schema (the function parameters)
- A Runnable that can be invoked
The model will never see your code. It only sees the tool name, description, and parameter names. So write docstrings like you're explaining it to a non-programmer.
5. Creating Custom Tools
Let's build tools for a practical scenario: an assistant that helps with data analysis.
from langchain_core.tools import tool
import json
# Tool 1: Parse CSV-like data
@tool
def parse_csv_data(data_str: str) -> str:
"""Parse a CSV string into structured data.
Args:
data_str: CSV data as a string (comma-separated values)
Returns JSON representation of the data with column headers and rows.
Example: If given "name,age\nAlice,30\nBob,25", returns a JSON array.
Use this when you need to work with CSV data."""
try:
lines = data_str.strip().split('\n')
headers = lines[0].split(',')
rows = []
for line in lines[1:]:
values = line.split(',')
row = {headers[i]: values[i] for i in range(len(headers))}
rows.append(row)
return json.dumps(rows, indent=2)
except Exception as e:
return f"Error parsing CSV: {e}"
# Tool 2: Calculate statistics
@tool
def calculate_statistics(numbers_str: str) -> str:
"""Calculate basic statistics (mean, median, std dev, min, max) from a list of numbers.
Args:
numbers_str: Space or comma-separated numbers
Use this when the user asks for statistics or summary data."""
import statistics
try:
# Handle both space and comma separation
numbers = [float(x) for x in numbers_str.replace(',', ' ').split()]
return json.dumps({
"count": len(numbers),
"mean": statistics.mean(numbers),
"median": statistics.median(numbers),
"stdev": statistics.stdev(numbers) if len(numbers) > 1 else 0,
"min": min(numbers),
"max": max(numbers)
}, indent=2)
except Exception as e:
return f"Error calculating statistics: {e}"
# Tool 3: Filter data
@tool
def filter_data(json_data: str, field: str, value: str) -> str:
"""Filter a JSON array by matching a field to a value.
Args:
json_data: JSON array as a string
field: The field name to filter by
value: The value to match
Returns JSON array containing only matching rows.
Example: Filter users by age == 30."""
try:
data = json.loads(json_data)
filtered = [row for row in data if str(row.get(field)) == value]
return json.dumps(filtered, indent=2)
except Exception as e:
return f"Error filtering data: {e}"
# Collect tools for the agent
tools = [parse_csv_data, calculate_statistics, filter_data]
Key principles for custom tools:
- Docstrings are your API contract — the model only sees the name and docstring, not the code
- Keep it simple — tools should do one thing well, not be mini-applications
- Handle errors gracefully — return meaningful error messages, not stack traces
- Test independently — before adding a tool to an agent, verify it works standalone
- Type hints matter — they become part of the tool schema the model sees
6. Tool Binding and Calling
This is where it gets interesting. How does the model know to call a tool? And how do we actually invoke it?
LangChain uses the function calling capability of modern LLMs. Here's the flow:
- You give the model a set of tools
- The user asks a question
- The model sees the user's request and the list of available tools
- The model decides: "I should call tool X with these parameters"
- LangChain intercepts that decision and actually calls the tool
- The result goes back to the model
- The model generates a response based on the tool result
Let's see it in action:
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain.tools.render import format_tool_to_openai_function_calls
model = ChatOpenAI(model="gpt-4")
@tool
def multiply(a: int, b: int) -> int:
"""Multiply two numbers together."""
return a * b
@tool
def add(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
tools = [multiply, add]
# Bind tools to the model
# This tells the model: "You have these tools available, and here's how to request them"
model_with_tools = model.bind_tools(tools)
# When we invoke it, the model returns a message with a "tool_call" in it
response = model_with_tools.invoke("What is 5 times 3, plus 7?")
print("Response:", response)
print("Tool calls:", response.tool_calls)
# The response includes:
# - .content: The text response
# - .tool_calls: A list of tool invocations the model decided to make
# Example tool_call:
# {
# "name": "multiply",
# "args": {"a": 5, "b": 3},
# "id": "call_123"
# }
The key moment: when you call invoke(), the model returns immediately with its tool decisions. It doesn't execute the tools. That's your job (or the agent loop's job, which we'll see next).
The model is saying: "Here's what I want to do next." You (the programmer) decide whether to actually do it, error-check it, or modify it based on human feedback.
7. The Agent Loop: Manual Construction
An agent loop is how you handle the back-and-forth between the model and tools. The model decides to call a tool, you execute it, you feed the result back to the model, and repeat until the model says "done."
Here's a manual agent loop — this is the essence of what higher-level abstractions hide:
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage, AIMessage
# Define tools
@tool
def search_knowledge_base(query: str) -> str:
"""Search the company knowledge base for information.
Args:
query: A search query
Returns relevant documents."""
# Mock implementation
if "python" in query.lower():
return "Document 1: Python Best Practices\nDocument 2: Python Performance Tips"
return "No documents found"
@tool
def fetch_docs(doc_id: str) -> str:
"""Fetch the full content of a document by ID.
Args:
doc_id: The document identifier
Returns the full document content."""
docs = {
"doc1": "Python is a high-level language...",
"doc2": "Use list comprehensions for performance...",
}
return docs.get(doc_id, "Document not found")
tools = [search_knowledge_base, fetch_docs]
model = ChatOpenAI(model="gpt-4")
model_with_tools = model.bind_tools(tools)
# The agent loop
def run_agent_loop(user_input: str, max_iterations: int = 10):
"""
Run the agent loop until the model stops requesting tools.
This manually handles:
- Sending user input to the model
- Detecting tool calls in the response
- Executing tools
- Feeding results back to the model
- Looping until the model says "done"
"""
# Start with the user's message
messages = [HumanMessage(content=user_input)]
for i in range(max_iterations):
# Get the model's response (might include tool calls)
response = model_with_tools.invoke(messages)
# If the model didn't request any tools, it's done
if not response.tool_calls:
return response.content
# Add the model's response to the message history
# This is important: the model needs to see its own reasoning
messages.append(AIMessage(content=response.content, tool_calls=response.tool_calls))
# Execute each tool call the model requested
for tool_call in response.tool_calls:
tool_name = tool_call['name']
tool_args = tool_call['args']
# Find the tool and execute it
tool = next((t for t in tools if t.name == tool_name), None)
if not tool:
result = "Error: Tool not found"
else:
result = tool.invoke(tool_args)
# Add the tool result to the message history
# The model will see what the tool returned
messages.append(ToolMessage(
content=result,
tool_call_id=tool_call['id']
))
print(f"[Iteration {i+1}] Agent decided to call tools. Processing...")
return "Max iterations reached"
# Run it
result = run_agent_loop("I need to learn about Python performance optimization")
print("Final answer:", result)
Here's what's happening:
- Initial input: User message goes into the message list
- Model invocation: Call the model with all messages so far
- Tool decision: Check if the model decided to use any tools
- Tool execution: Actually run the tools and capture results
- Feedback loop: Add tool results back to messages so the model sees what happened
- Repeat: Next iteration, the model has full context including previous tool results
The message history is the key. Each iteration, you're building a transcript:
Human: [question]
AI: I'll search for information... [tool_call: search]
Tool: [search result]
AI: Now I'll fetch the full document... [tool_call: fetch]
Tool: [document content]
AI: Based on the documents, here's what I found...
This manual loop teaches you what's happening. In production, you'll use LangChain's AgentExecutor or create_agent, which handles this looping for you. But understanding the manual loop is critical — it's where bugs appear, where you need human-in-the-loop intervention, and where you can add monitoring.
8. Integrating Real APIs: A Complete Weather Agent
Let's build something practical: a weather agent that can:
- Check current weather in a city
- Get a forecast
- Suggest activities based on weather
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
import requests
import json
# Real API tools using OpenWeatherMap API
@tool
def get_current_weather(city: str) -> str:
"""Get the current weather for a city.
Args:
city: The city name (e.g., 'London', 'Tokyo')
Returns weather information including temperature, conditions, and humidity.
Use this when the user asks about current weather."""
try:
# In production, use your actual API key
api_key = "YOUR_OPENWEATHER_API_KEY"
url = f"https://api.openweathermap.org/data/2.5/weather?q={city}&appid={api_key}&units=metric"
response = requests.get(url, timeout=5)
response.raise_for_status()
data = response.json()
return json.dumps({
"city": data.get('name'),
"temp": data['main']['temp'],
"feels_like": data['main']['feels_like'],
"condition": data['weather'][0]['main'],
"humidity": data['main']['humidity'],
"wind_speed": data['wind']['speed']
})
except requests.RequestException as e:
return f"Error fetching weather: {e}"
@tool
def get_weather_forecast(city: str, days: int = 3) -> str:
"""Get weather forecast for a city.
Args:
city: The city name
days: Number of days to forecast (1-5)
Returns forecast data."""
try:
api_key = "YOUR_OPENWEATHER_API_KEY"
# Using free tier endpoint
url = f"https://api.openweathermap.org/data/2.5/forecast?q={city}&appid={api_key}&units=metric"
response = requests.get(url, timeout=5)
response.raise_for_status()
data = response.json()
# Parse into forecast summary
forecasts = []
for item in data['list'][::8]: # Every 8 entries = ~1 day
forecasts.append({
"time": item['dt_txt'],
"temp": item['main']['temp'],
"condition": item['weather'][0]['main']
})
return json.dumps(forecasts[:days])
except requests.RequestException as e:
return f"Error fetching forecast: {e}"
@tool
def suggest_activity(weather_condition: str, temperature: float) -> str:
"""Suggest activities based on weather conditions.
Args:
weather_condition: Weather type (e.g., 'Sunny', 'Rainy', 'Cloudy')
temperature: Temperature in Celsius
Returns activity suggestions appropriate for the weather."""
suggestions = {
"Sunny": {
"hot": "🏖️ Beach, outdoor sports, cycling",
"warm": "⛳ Golf, hiking, picnic",
"cool": "🚴 Jogging, sightseeing"
},
"Rainy": {
"hot": "🎬 Indoor activities, museum, shopping",
"warm": "📚 Reading, indoor sports",
"cool": "☕ Cozy cafes, bookstores"
},
"Cloudy": {
"hot": "🎮 Outdoor games, park",
"warm": "🎨 Photography, exploring",
"cool": "🥾 Hiking, nature walks"
}
}
# Categorize temperature
temp_cat = "hot" if temperature > 25 else ("warm" if temperature > 15 else "cool")
weather_suggestions = suggestions.get(weather_condition, suggestions["Cloudy"])
return weather_suggestions.get(temp_cat, "Indoor activities recommended")
tools = [get_current_weather, get_weather_forecast, suggest_activity]
Now let's build the agent:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, ToolMessage, AIMessage
model = ChatOpenAI(model="gpt-4")
model_with_tools = model.bind_tools(tools)
def run_weather_agent(user_input: str, max_iterations: int = 10):
"""Run the weather agent loop."""
messages = [HumanMessage(content=user_input)]
for iteration in range(max_iterations):
response = model_with_tools.invoke(messages)
# If no tool calls, the model is done
if not response.tool_calls:
print(f"\n✅ Agent response:\n{response.content}")
return response.content
# Add model's message with tool calls
messages.append(AIMessage(content=response.content, tool_calls=response.tool_calls))
# Execute each tool
for tool_call in response.tool_calls:
tool_name = tool_call['name']
tool_args = tool_call['args']
tool = next((t for t in tools if t.name == tool_name), None)
if tool:
print(f"\n🔧 Calling {tool_name} with {tool_args}")
result = tool.invoke(tool_args)
print(f"📊 Result: {result[:100]}...") # Truncate for readability
else:
result = "Error: Tool not found"
messages.append(ToolMessage(
content=result,
tool_call_id=tool_call['id']
))
return "Max iterations reached"
# Use it
run_weather_agent("What's the weather like in London? And what activities should I do?")
When you run this, here's what happens:
- User asks about weather in London
- Agent calls
get_current_weather("London") - Agent sees the result (sunny, 18°C)
- Agent calls
suggest_activity("Sunny", 18) - Agent synthesizes everything into a response
The magic: the agent figured out which tools to call and in what order just by understanding the user's request and the tool descriptions.
9. Advanced Patterns: create_agent and Modern LangChain
Manually writing the loop works, but LangChain has abstractions to make this easier. The most practical one is create_agent (or AgentExecutor).
from langchain_openai import ChatOpenAI
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.tools import tool
# Define your tools (same as before)
@tool
def get_current_weather(city: str) -> str:
"""Get the current weather for a city."""
# Implementation...
pass
tools = [get_current_weather]
model = ChatOpenAI(model="gpt-4")
# Create a system prompt
system_prompt = """You are a helpful weather assistant.
You have access to weather tools that let you look up current conditions,
forecasts, and suggest activities.
When a user asks about weather:
1. Look up the current weather
2. Provide clear, conversational responses
3. If relevant, suggest activities
Always be friendly and practical."""
# Create the prompt template with a placeholder for agent messages
prompt = ChatPromptTemplate.from_messages([
("system", system_prompt),
("user", "{input}"),
MessagesPlaceholder(variable_name="agent_scratchpad"), # Where tool steps go
])
# Create the agent
agent = create_tool_calling_agent(model, tools, prompt)
# Create an executor (this handles the loop for you)
agent_executor = AgentExecutor(
agent=agent,
tools=tools,
verbose=True, # Print each step
max_iterations=10,
handle_parsing_errors=True
)
# Run it
result = agent_executor.invoke({
"input": "What's the weather in Tokyo and what should I do?"
})
print(result["output"])
What changed:
- Instead of manually writing the loop,
AgentExecutordoes it for you - You define the tools and a system prompt
-
create_tool_calling_agentbuilds the agent for you - You invoke the executor once and it handles all the looping
This is production code. It handles errors, formats outputs, manages context — all the things you'd have to build manually in the loop.
When to use create_agent:
- When you want a standard agent that follows tools until it has an answer
- When you want LangChain to manage the message history
- Most of the time in production
When to use the manual loop:
- When you need custom logic (e.g., human approval after each step)
- When you need fine-grained control over what happens between tool calls
- When debugging why an agent is making bad tool choices
10. Pitfalls and Production Lessons
You've built agents. Now here's what bites you in production.
Pitfall 1: Ambiguous Tool Descriptions
❌ Bad:
@tool
def process_data(input: str) -> str:
"""Process the input data."""
pass
The model has no idea what this does. It will guess wrong.
✅ Good:
@tool
def calculate_monthly_revenue(year: int, month: int) -> str:
"""Calculate total revenue for a specific month.
Args:
year: The year (e.g., 2024)
month: The month (1-12)
Returns the revenue as a formatted string with currency.
Use this when the user asks about revenue, earnings, or sales for a specific month."""
pass
Every tool call the model makes comes from the docstring. Spend time there.
Pitfall 2: Tools That Fail Silently or Crash
❌ Bad:
@tool
def fetch_user(user_id: int) -> dict:
"""Fetch a user by ID."""
response = requests.get(f"https://api.example.com/users/{user_id}")
return response.json() # What if user_id doesn't exist? What if network fails?
The agent can't handle errors. The whole chain breaks.
✅ Good:
@tool
def fetch_user(user_id: int) -> str:
"""Fetch a user by ID.
Returns a JSON string with user details or an error message."""
try:
response = requests.get(
f"https://api.example.com/users/{user_id}",
timeout=5
)
response.raise_for_status()
return json.dumps(response.json())
except requests.RequestException as e:
return f"Error fetching user {user_id}: {e}"
except Exception as e:
return f"Unexpected error: {e}"
Always return a string (not objects). Always catch errors. Give the model something to work with.
Pitfall 3: Infinite Loops
An agent can loop forever if:
- The model keeps requesting the same tool with the same arguments
- A tool never gives the model enough information to decide "I'm done"
- The model misunderstands what it's supposed to do
Protection:
agent_executor = AgentExecutor(
agent=agent,
tools=tools,
max_iterations=10, # Hard cap — without this, you pay forever
early_stopping_method="force", # Stop after max_iterations
handle_parsing_errors=True
)
Pitfall 4: Tool Hallucination
The model might call a tool that doesn't exist or with completely wrong parameters if:
- The tool description is vague
- Parameter names don't match what the model expects
- The model is confused about what tools are available
Protection:
# Be explicit in tool descriptions
@tool
def search_database(
query: str,
table: str, # Not "table_name" — name your params clearly
limit: int = 10
) -> str:
"""Search the database for records.
Args:
query: The search term (e.g., "email LIKE 'john%'")
table: The table name ('users', 'products', 'orders')
limit: Maximum number of results to return (1-100, default 10)
This tool searches the database directly."""
Pitfall 5: Trusting External APIs Without Timeout
# ❌ This will hang if the API is slow or dead
response = requests.get(url)
# ✅ Always timeout
response = requests.get(url, timeout=5)
When an API hangs, your agent hangs. Your user waits forever.
Pitfall 6: Not Testing Tools Independently
Before you add a tool to an agent, test it in isolation:
# Test the tool by itself first
result = fetch_user.invoke({"user_id": 123})
print(result)
# Verify it handles errors
result = fetch_user.invoke({"user_id": -1})
print(result)
Agent debugging is hard. Single-tool debugging is easy.
Pitfall 7: Token Bloat from Message History
As an agent loops, its message history grows:
User: [question]
AI: [tool call]
Tool: [large result]
AI: [tool call]
Tool: [large result]
...
After 10 iterations with large API responses, you're sending kilobytes of context to the model. This is slow and expensive.
Protection:
# Summarize or truncate tool results
@tool
def fetch_large_dataset(...) -> str:
data = get_data()
# Only return the most relevant fields
summary = {k: v for k, v in data.items() if k in ['id', 'name', 'status']}
return json.dumps(summary)
Pitfall 8: Not Monitoring What Tools Are Actually Called
Add logging:
def run_agent_with_logging(user_input, tools, model, max_iterations=10):
messages = [HumanMessage(content=user_input)]
for iteration in range(max_iterations):
response = model_with_tools.invoke(messages)
if not response.tool_calls:
return response.content
messages.append(AIMessage(content=response.content, tool_calls=response.tool_calls))
for tool_call in response.tool_calls:
# Log what the agent decided
print(f"[AGENT LOG] Iteration {iteration+1}: Called {tool_call['name']} with {tool_call['args']}")
# ... execute tool ...
In production, send this to an observability platform (Datadog, New Relic, etc.). You need to see what your agent is doing.
11. Conclusion
You now understand the entire stack:
- Runnables are the abstraction that lets you compose operations
-
Prompts, models, and parsers chain together via the
|operator - Tools are how you give agents agency — they describe what the agent can do
- Tool binding lets the model request tool execution
- Agent loops (manual or automated) handle the back-and-forth between the model and tools
-
AgentExecutor and
create_agentwrap the loop for production use
The mental model is simple: describe tools, describe what you want, let the LLM decide which tools to use, execute those tools, feed results back, repeat.
The complexity comes in production:
- Making tool descriptions so clear the model never misunderstands
- Handling errors gracefully so one bad tool call doesn't break the chain
- Monitoring what the agent actually does
- Managing context so you don't waste tokens on massive message histories
Start with a simple agent — weather, calculator, knowledge-base lookup. Test it thoroughly. Only then add the complexity: human-in-the-loop, custom logic between tool calls, streaming responses.
The agents you build today are just the start. Tomorrow, you'll add:
- Retrieval-augmented generation (RAG) for smarter context
- Memory systems so agents learn from past conversations
- Multi-agent systems where agents delegate to each other
- Tool-use patterns that don't exist yet
But all of that builds on what you've learned here. Get Runnables in your bones. Understand the loop. Write clear tool descriptions. Test everything. Then build.
The rest is details.
Top comments (0)