Last quarter, I shipped an agent designed to triage inbound support tickets. It was supposed to read a customer’s email, classify the issue, and route it to the right team. Simple enough, right? For the first few days, it worked. Then, a customer wrote in about a billing discrepancy. The agent, instead of escalating, got stuck in a loop, trying to ‘resolve’ the issue by repeatedly asking for account details it already had. It burned through $300 in tokens in an hour, then finally spat out a generic ‘we’re looking into it’ email. The customer was furious. I was furious. This wasn’t just a silent failure; it was an expensive, reputation-damaging one. That’s the reality of how to build AI agents for production: the happy path is easy, the failure modes are brutal.
The promise of agents is seductive. You hear about them ‘reasoning’ and ‘planning,’ but in practice, they’re brittle state machines with a language model at the core. They fail for predictable reasons: bad tool definitions, unclear instructions, unexpected user input, or just plain old LLM hallucinations. Debugging these issues isn’t like debugging traditional code. You can’t just set a breakpoint and inspect variables. The ‘state’ is often implicit in the conversation history, and the ‘logic’ is a black box. This opacity makes iterating on agent behavior a frustrating exercise in guesswork.
Frameworks vs. Platforms: Picking Your Battles
When you’re trying to figure out how to build agents that actually work, you quickly run into two camps: frameworks and platforms. Frameworks like LangGraph, CrewAI, or AutoGen give you the primitives to compose LLM calls, tool use, and state transitions. They’re code-first, offering maximum control over the agent’s internal workings. Platforms like Lindy agent platform or Bardeen.ai offer a more visual, low-code approach, often with pre-built integrations and a simpler setup. They’re great for quick prototypes or simpler tasks, but I’ve found they hit a ceiling fast when you need custom logic, complex error handling, or specific governance requirements. For anything serious, anything that touches real money or real user data, you’re probably going to be writing code.
My experience tells me that for production-grade agents, you need the explicit control a framework provides. You need to define exactly what happens at each step, what tools are available, and how the agent reacts to different outcomes. This isn’t about making things harder; it’s about making them predictable. Predictability is your best friend when an agent starts misbehaving at 3 AM.
How to Build AI Agents with LangGraph: A Structured Approach
After that support agent debacle, I decided to rebuild using LangGraph. It’s an extension of LangChain, and it forces you to think about your agent as a state machine, which is exactly what you need for reliability. You define nodes (LLM calls, tool calls, human interventions) and edges (transitions between nodes based on output). This explicit graph structure makes it much easier to visualize and, crucially, debug the agent’s flow. It’s a massive improvement over trying to guess why a chain of prompts went sideways.
Here’s a simplified example of how you might structure a basic agent that can call a tool:
from langgraph.graph import StateGraph, END
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
# Define a simple tool
@tool
def search_web(query: str) -> str:
"""Searches the web for a given query and returns results."""
# In a real app, this would hit a search API like Google Search or Brave Search
if "weather" in query.lower():
return "It's sunny and 75 degrees in San Francisco right now."
if "capital of france" in query.lower():
return "The capital of France is Paris."
return f"Search results for '{query}': [Some relevant info from a mock search]"
# Define the agent state
class AgentState:
messages: list
# Define the nodes
def call_llm(state: AgentState):
llm = ChatOpenAI(model="gpt-4o", temperature=0)
response = llm.invoke(state.messages)
return {"messages": [response]}
def call_tool(state: AgentState):
last_message = state.messages[-1]
tool_calls = last_message.tool_calls
tool_outputs = []
for tool_call in tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call["args"]
if tool_name == "search_web":
output = search_web.invoke(tool_args["query"])
tool_outputs.append(ToolMessage(content=output, tool_call_id=tool_call["id"])) # Pass tool_call_id
else:
tool_outputs.append(ToolMessage(content=f"Unknown tool: {tool_name}", tool_call_id=tool_call["id"])) # Pass tool_call_id
return {"messages": tool_outputs}
# Define the graph
workflow = StateGraph(AgentState)
workflow.add_node("llm", call_llm)
workflow.add_node("tool_node", call_tool)
workflow.set_entry_point("llm")
# Define conditional edges
def should_continue(state: AgentState):
last_message = state.messages[-1]
if last_message.tool_calls:
return "tool_node"
return "end"
workflow.add_conditional_edges(
"llm",
should_continue,
{"tool_node": "tool_node", "end": END}
)
workflow.add_edge("tool_node", "llm") # After tool, go back to LLM for synthesis
app = workflow.compile()
# Example usage
# print(app.invoke({"messages": [HumanMessage(content="What's the weather like in San Francisco?")]}))
# print(app.invoke({"messages": [HumanMessage(content="What is the capital of France?")]}))
# print(app.invoke({"messages": [HumanMessage(content="Tell me a joke.")]}))
This explicit structure, where you define each step and its transitions, is a lifesaver. It makes the agent’s behavior predictable, which is a huge win for debugging. You can trace the exact path it took, see which node it entered, and what output it generated. This level of control is what separates a prototype from a production system.