Building Better LLM Agents with LangGraph

Backend Engineer & AI/ML Developer passionate about building scalable APIs, cloud systems, and LLM-powered applications. Sharing insights on Python, Django, FastAPI, LangChain, and deploying AI in production. I love writing about: Backend Engineering: Python, Django, FastAPI, REST APIs, Celery, PostgreSQL, AWS, Docker AI/ML Applications: LLMs, LangChain, Prompt Engineering, NLP, Vector Databases, MLOps Scaling Products: Payment integration, asynchronous systems, and performance optimization On this blog, I’ll share lessons learned, tutorials, and real-world case studies from my journey building production-ready backends and AI applications. My goal is to make complex concepts practical, actionable, and beginner-friendly — especially for engineers looking to move from theory to real-world deployment.
There are times, when building LLM-powered apps, where simple chains stop being enough. Everything feels fine when the task is straightforward, the task requires a linear workflow where things are done step by step, one step after the other and the model mostly only needs to respond once. But the moment you want it to use a tool, inspect the result, and decide what to do next, the agentic workflow starts to break. It may need to call another tool, retry, or stop altogether. A basic pipeline is not designed for that kind of control flow.
In this series, I worked on a City Events Agent, a conversational assistant that can query a local events database, search the web through Tavily, and fetch live weather in the same conversation. This was inspired by an article I saw on Amazon’s blog, link here: https://aws.amazon.com/blogs/machine-learning/build-a-multi-agent-system-with-langgraph-and-mistral-on-aws/ written by Andre Boaventura
My idea was to re-write some parts of this article, making it simpler, more modularized and a little more straight-forward for better understanding, while still explaining the concepts used by LangGraph for building agentic systems.
This is Part 1 of a four-part series where I build that agent from scratch. In this article, I want to focus on the foundation: why LangGraph exists, and the three core primitives everything else is built on: State, Nodes, and Edges.
What breaks with a plain pipeline
The standard mental model for LLM apps is a pipeline. Input moves through a fixed sequence of steps, and output comes out at the end. LangChain’s LCEL follows this model closely. You compose chain components with |, and data flows from left to right.
That could work well for RAG, summarisation, and simple Q&A. It starts to break down when you need any of the following:
Loops - The model calls a tool, sees the result, and decides to call another one.
Conditional branching - where The next step depends on what the model decided, not on a fixed sequence you defined upfront.
Persistent state - Conversation history and intermediate results need to survive across multiple LLM calls.
With a linear chain, you can sometimes find ways around the first two. The third is where things can quickly become awkward. Once state has to persist across multiple steps, you usually end up managing it outside the pipeline.
LangGraph solves this by modelling your agent as a stateful directed graph. Nodes are processing steps. Edges define transitions between those steps. A shared State object moves through the entire execution. In practice, it feels a lot like a state machine designed specifically for LLM orchestration.
The three primitives
State
LangGraph applications are built around a State TypedDict. It gets passed between every node in the graph. Each node reads from it, returns partial updates, and the graph handles how those updates are merged.
For the City Events Agent, the state is intentionally minimal:
agent/state.py
from typing import Annotated from typing_extensions import TypedDict from langgraph.graph.message import add_messages
class State(TypedDict):
messages: Annotated[list, add_messages]
Here the “add_messages” is referred to as a “reducer”, Reducers are the mechanism LangGraph uses to resolve state updates. Different fields can follow different merge strategies, such as append, replace, or a custom merge function. Your nodes only return updates. The graph takes care of how those updates are combined. For example, when building a chatbot agent, you want the last messages to append to the agent and not get over-written by the latest message you sent to the agent, this is what the “add_messages” here does.
Nodes
A node is any Python callable that takes the current state and returns a partial state update. Usually, that means returning a dictionary containing only the fields that changed. There are no base classes to inherit from and no special decorators required.
agent/graph.py (excerpt)
def chatbot(state: State):
return {"messages": [llm_with_tools.invoke(state["messages"])]}
LangGraph also provides prebuilt nodes. A good example is ToolNode, which handles tool execution for you:
from langgraph.prebuilt import ToolNode from agent.tools import ALL_TOOLS
builder.add_node("tools", ToolNode(ALL_TOOLS))
One of the tools in this project queries a local SQLite database for events:
agent/tools.py (excerpt)
import sqlite3
import json
DB_PATH = '/events.db'
@tool
def events_database_tool(city: str) -> str:
"""Query the local SQLite database for events matching a city name.
Args:
city: Name of the city to search for (partial matches supported).
Returns:
JSON string with matching events or an error message.
"""
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute(
"SELECT * FROM local_events WHERE city LIKE ? LIMIT 10",
(f"%{city}%",),
)
rows = cur.fetchall()
events = [dict(zip([d[0] for d in cur.description], row)) for row in rows]
conn.close()
return json.dumps({"events": events, "count": len(events)})
The @tool decorator is required to tell the agent this is a tool that is available for it to use based on the user messages.
Edges
Edges define how the graph moves from one node to another. There are two main kinds: normal edges and conditional edges.
A normal edge always transitions to the next node:
builder.add_edge("tools", "chatbot")
A conditional edge decides where to go based on the current state:
from langgraph.prebuilt import tools_condition
builder.add_conditional_edges("chatbot", tools_condition)
That single conditional edge is what creates the agent loop. It keeps running for as long as the model continues issuing tool calls.
The full graph
agent/graph.py
def build_graph():
llm = init_chat_model(MODEL_NAME)
llm_with_tools = llm.bind_tools(ALL_TOOLS)
def chatbot(state: State):
return {"messages": [llm_with_tools.invoke(state["messages"])]}
builder = StateGraph(State)
builder.add_node("chatbot", chatbot)
builder.add_node("tools", ToolNode(ALL_TOOLS))
builder.add_edge(START, "chatbot")
builder.add_conditional_edges("chatbot", tools_condition)
builder.add_edge("tools", "chatbot")
return builder.compile()
When a user asks something like, “What events are on in Lagos this weekend?”, execution looks like this:
The graph starts at START (which is also one of the nodes originally provided by LangGraph) and immediately enters chatbot.
The LLM sees the question and decides it needs events_database_tool. It returns a message containing a tool call.
tools_condition sees that tool call and routes execution to tools.
ToolNode runs events_database_tool("Lagos") and appends the JSON result to state.
The normal edge sends control back to chatbot.
The LLM now sees the tool result and writes a natural-language response.
Since there are no more tool calls, tools_condition routes execution to END. That same graph can also handle a user asking for events, weather, and a web search in one turn. The loop simply runs multiple times instead of once.
What’s next
At this point, you have the basic skeleton: a stateful graph with two nodes and a conditional edge that creates a tool-calling loop. link to repository Here
In Part 2 of this series, We will go a little further into state management, including reducers, state design patterns, and the kinds of subtle bugs that show up when things go wrong in the middle of a loop. Thanks for reading!



