“Graphs beat prompt soup.”
– Every tired engineer who’s ever debugged an infinite-loop LLM agent
1 | What even is an Agent?
An agent is nothing more than three moving parts:
• LLM - Chooses what to do next
• Tools - Give the agent hands to touch the outside world
• Prompt - Tells the LLM how to think and act
The LLM runs in a tight loop –
1. Pick a tool & craft its input.
2. Receive the result (observation).
3. Decide what to do next.
4. Repeat until a stopping condition fires.
Plenty of frameworks promise to wrangle that loop; my favourite these days is LangGraph (yes, it has two g’s).
2 | LangGraph in 60 Seconds
LangGraph represents an agent’s workflow as — surprise — graphs:
To put it in simple words, LangGraph represents agent workflow as graphs. Now, these graph structures have 3 major elements –
• State - A snapshot of everything the agent needs to remember (often a TypedDict or Pydantic model).
• Nodes - Regular Python functions that do work and return an updated State.
• Edges - Functions that decide which node to run next.
nodes do the work, edges tell what to do next
Defining a Graph
The 1st thing to do while defining a graph is to define the State of the graph. The State consists of ‘schema of the graph’ and ‘reducer functions’ explaining how to update the state. The schema of the State is the input schema for all of the Nodes and Edges. Nodes use the reducer function to update the State of the system.
Usually, all Nodes in a graph communicate with a single schema i.e., read and write to the same state channels. But, we can also have nodes write to a private channel - Private State, for internal communication.
There are two special nodes - START and END.
• START node represents the node that sends user input to graph. It’s objective is to determine which node to call 1st.
• END node represents the terminal node and used to denote edges that have no action after they are done.
from langgraph.graph import START, END
graph.add_edge(START, "node_a") # entry-point
graph.add_edge("node_a", END)
Edges tell how different nodes communicate with each other and are very important in guiding how the agents work. Four types of Edges –
1. Normal Edges: Go directly from one node to the next.
2. Conditional Edges: Call a function to determine which node(s) to go to next.
3. Entry Point: Which node to call first when user input arrives.
4. Conditional Entry Point: Call a function to determine which node(s) to call first when user input arrives.
And a node can have multiple outgoing edges, which are run in parallel as part of the next superset.
Now, let’s dive deeper by looking at a practical example of building a Text-to-SQL agent using LangGraph. We’ll walk through the key parts to get a sense of how it all comes together.
3 | A Practical Walkthrough — Text-to-SQL Agent
Below is the full script (collapsed for sanity). We’ll unpack it step by step.
3.1 Environment & LLM
model_name = os.getenv("GPT_MODEL", "openai:gpt-4.1")
llm = init_chat_model(model=model_name, temperature=0)
Why a temperature of 0?
For production-ish SQL it’s safer to be deterministic; one flaky token can break a query.
Swap in your favourite model. – Point GPT_MODEL at an Ollama model or leave it to hit OpenAI.
3.2 Connecting to Postgres
connection_string = (
f"postgresql://{username}:{password}@{host}:{port}/{database}"
)
db = SQLDatabase.from_uri(connection_string)
SQLDatabase is LangChain’s lightweight wrapper around SQLAlchemy.
3.3 Tooling up with SQLDatabaseToolkit
toolkit = SQLDatabaseToolkit(db=db, llm=llm)
tools = toolkit.get_tools()
The toolkit auto-generates three tools:
Tool | What it does
sql_db_list_tables | Lists every table the DB user can see
sql_db_schema | Returns the CREATE TABLE DDL
sql_db_query | Executes arbitrary SQL and returns rows
Wrapping each of these in a ToolNode means they can plug straight into a graph node.
Next up, we define several Nodes. Each node handles a distinct task.
3.4 Defining the Nodes
Node name | Responsibility
list_tables | Fire sql_db_list_tables unconditionally so the agent always starts with a map of the territory.
call_get_schema | Ask the LLM to decide which tables matter, then call sql_db_schema.
generate_query | Craft the SQL based on user question and schema, without forcing a tool call — gives the LLM space.
check_query | A mini-lint pass that looks for the usual blunders (NOT IN + NULL, UNION vs UNION ALL, etc.). If it rewrites, it preserves the original message ID so downstream edges still line up.
run_query | Finally executes the SQL and streams back rows.
All five return a dict shaped like {"messages": […]} because that’s the contract of MessagesState.
3.5 Wiring the Edges
Edges define the logic for transitioning from one node to another. In our Text-to-SQL agent:
builder.add_edge(START, "list_tables")
builder.add_edge("list_tables", "call_get_schema")
builder.add_edge("call_get_schema", "get_schema")
builder.add_edge("get_schema", "generate_query")
builder.add_conditional_edges("generate_query", should_continue)
builder.add_edge("check_query", "run_query")
builder.add_edge("run_query", "generate_query")
A quick translation to English (Refer to the graph in the 7th step) –
• We begin at the START node, moving directly into the list_tables node to initialize database awareness.
• Start → list_tables — always.
From there, the workflow moves to call_get_schema → get_schema → generate_query.
• list_tables → call_get_schema — always.
• call_get_schema → get_schema — always.
• get_schema → generate_query — always
The edge from generate_query is conditional (should_continue). If the LLM output includes a query needing validation, it moves to check_query; otherwise, it goes directly to the END node.
generate_query →
•END if the LLM didn’t produce a tool call (e.g. simple natural-language answer).
• check_query if it did request a SQL execution.
Once checked and corrected, the workflow continues to run_query and loops back to generate_query if more queries or refinements are needed
• check_query → run_query → generate_query — a feedback
loop that lets the agent react to query results until it’s happy.
Because edges are ordinary Python functions, adding more intelligence (rate limiting, logging, routing by cost, etc.) is a one-liner.
3.6 Kicking the tyres
Once our graph is set up, we simply query the agent with natural language. For instance, asking:
query_agent(agent, "What is the average salary by department?")
LangGraph’s stream() method yields chunks as they arrive, so in the terminal you’ll watch the thought-process unroll:
> list_tables
> get_schema("employees")
> run_query("SELECT department, AVG(salary)…")
Average salary by department:
• Sales – $92 k
• Engineering – $131 k
• …
3.7 Visualising the Flow
png_bytes = agent.get_graph().draw_mermaid_png()
with open("graph.png", "wb") as f:
f.write(png_bytes)
Mermaid diagrams are my new favourite doc-as-code trick. The generated graph.png looks like this:
(Handy for both README screenshots and onboarding the next engineer who asks “why another framework?”)
4 | Observability 101 — Why You Need It
Shipping an agent to prod without observability is like flying a plane with the cockpit lights off.
Unlike a deterministic API, an LLM-powered workflow can loop, branch, and call tools dozens of times per request — each hop incurring latency, tokens, and cost. To keep users happy (and cloud bills sane) you need to see:
• What the agent did (the exact prompts, queries, tool calls).
• When it did it (latency of every hop, overall wall-clock).
• How much it cost (tokens, image credits, embeddings, etc.).
• Why it failed (stack traces, model errors, tool exceptions).
That trio — logs, metrics, and traces — is what the wider software world calls observability. For LLM apps, the king signal is the trace: a tree that starts at the user request and fans out into every child run, tool invocation, and model call.
Enter LangSmith
LangSmith is LangChain’s hosted (or self-hosted) observability and evaluation platform. Flip one environment variable and every LangChain/LangGraph call streams to a LangSmith backend where you can:
Wiring our Text-to-SQL Agent to LangSmith
The good news: we already did. Notice the decorator:
from langsmith import traceable
@traceable(name="text2sql_agent_main")
def main():
...
Any function wrapped in @traceable becomes a Run in LangSmith. Child runs (LLM calls, tool invocations) are auto-captured, so the whole graph — list_tables → get_schema → generate_query … — shows up as an explorable trace.
Add two environment variables and hit Save:
export LANGSMITH_TRACING="true"
export LANGSMITH_API_KEY="ls_live_xxx" # grab from app.langchain.com
Run your script again, open the LangSmith dashboard, and you’ll see something like:
5 | Take-Home Checklist
• Turn on tracing in dev today — bugs are cheaper before launch.
• Tag your runs (tags=["prod", "user:42"]) so prod traffic doesn’t drown out staging.
• Watch the waterfall; anything >1 s is a conversion killer.
• Set a TTL once you’re happy with retention; traces pile up fast.
• Automate evals — they’re the CI tests of LLM engineering.
6 | What’s Next?
• Swap Postgres for Snowflake, BigQuery, or PlanetScale — The only line you’ll touch is the connection string.
• Add guardrails — Insert a node after generate_query that runs pglast or sqlfluff to enforce style or limit cost.
• Size-up with parallel branches — LangGraph supports fan-out edges — run sentiment analysis and SQL queries at the same time, then merge the results downstream.
Give it a spin, break it, and ping me on 🐦 X or email with what you build.
Happy graphing!
Top comments (0)