Skip to main content

Book 3 · The Senior AI Engineer · 9 min read

LangGraph Tutorial: Build a Multi-Node AI Agent in Python

Seven code paths through a single chat() function become unmaintainable. LangGraph replaces nested if/elif with a StateGraph: typed state shared by all nodes, one operation per node, and conditional edge functions that hold all routing logic in one readable place.

LangGraph Tutorial: Build a Multi-Node AI Agent in Python

A RAG pipeline that routes queries through HyDE, CRAG, and multi-query decomposition cannot be expressed as a linear chain. It is a directed graph. LangGraph makes that graph explicit — with typed state, named nodes, and conditional edges that hold all routing logic.

The Valid Failure

ShopBot's Book 2 pipeline was a sequential chain:

chunks = retrieve(query)
answer = generate(query, chunks)

By Book 3, it needed to:

  • Classify whether the query was vague
  • If vague → HyDE
  • If specific → standard retrieval
  • Grade the retrieved chunks (CRAG)
  • If PARTIAL → expand retrieval
  • If IRRELEVANT → refuse
  • If compound query → decompose first

Seven code paths through a single chat() function. Seven if/elif blocks nested inside each other. When a bug appeared — the wrong route being taken — no one could draw a diagram of what the code actually did.

LangGraph replaced the nested if/elif with a graph that could be drawn on a whiteboard.

The Core Concepts

State — a typed dictionary shared by all nodes. Every node reads from state and returns an update to state.

Nodes — functions that take state and return state. One node = one operation.

Edges — connections between nodes. Regular edges are unconditional. Conditional edges call a function to decide which node to go to next.

Step 1: Define the State Schema

from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, END

class ShopBotState(TypedDict):
    query: str
    is_vague: bool
    sub_queries: list[str]
    chunks: list[dict]
    crag_verdict: str   # RELEVANT | PARTIAL | IRRELEVANT
    answer: str

Every field the pipeline might need lives in the state. Nodes cannot pass data to each other directly — only through state.

Step 2: Write the Nodes

def classify_node(state: ShopBotState) -> ShopBotState:
    """Classify query as vague or specific."""
    state["is_vague"] = is_vague_query(state["query"])
    return state

def hyde_node(state: ShopBotState) -> ShopBotState:
    """For vague queries: generate hypothetical, retrieve against it."""
    hypothetical = generate_hypothetical(state["query"])
    state["chunks"] = retrieve_by_text(hypothetical)
    return state

def retrieve_node(state: ShopBotState) -> ShopBotState:
    """For specific queries: standard retrieval."""
    state["chunks"] = retrieve(state["query"])
    return state

def crag_grade_node(state: ShopBotState) -> ShopBotState:
    """Grade the top retrieved chunk."""
    if not state["chunks"]:
        state["crag_verdict"] = "IRRELEVANT"
    else:
        verdict = grade_chunk(state["query"], state["chunks"][0]["text"])
        state["crag_verdict"] = verdict
    return state

def expand_retrieve_node(state: ShopBotState) -> ShopBotState:
    """For PARTIAL verdict: add more chunks."""
    extra = retrieve(state["query"], top_k=5)
    existing_texts = {c["text"] for c in state["chunks"]}
    new_chunks = [c for c in extra if c["text"] not in existing_texts]
    state["chunks"] = state["chunks"] + new_chunks
    return state

def generate_node(state: ShopBotState) -> ShopBotState:
    """Generate answer from chunks."""
    state["answer"] = generate(state["query"], state["chunks"])
    return state

def refuse_node(state: ShopBotState) -> ShopBotState:
    """No evidence — refuse rather than hallucinate."""
    state["answer"] = "I don't have enough information about that in our catalog."
    return state

Step 3: Define the Routing Functions

def route_by_vagueness(state: ShopBotState) -> str:
    return "hyde" if state["is_vague"] else "retrieve"

def route_by_crag_verdict(state: ShopBotState) -> str:
    verdict = state["crag_verdict"]
    if verdict == "RELEVANT":
        return "generate"
    elif verdict == "PARTIAL":
        return "expand_retrieve"
    else:
        return "refuse"

Routing functions read state and return the name of the next node as a string. All routing logic lives in these functions — not scattered through the node code.

Step 4: Build the Graph

graph = StateGraph(ShopBotState)

# Add nodes
graph.add_node("classify", classify_node)
graph.add_node("hyde", hyde_node)
graph.add_node("retrieve", retrieve_node)
graph.add_node("crag_grade", crag_grade_node)
graph.add_node("expand_retrieve", expand_retrieve_node)
graph.add_node("generate", generate_node)
graph.add_node("refuse", refuse_node)

# Entry point
graph.set_entry_point("classify")

# Conditional routing after classify
graph.add_conditional_edges("classify", route_by_vagueness, {
    "hyde": "hyde",
    "retrieve": "retrieve",
})

# Both retrieval paths go to grading
graph.add_edge("hyde", "crag_grade")
graph.add_edge("retrieve", "crag_grade")

# Conditional routing after grading
graph.add_conditional_edges("crag_grade", route_by_crag_verdict, {
    "generate": "generate",
    "expand_retrieve": "expand_retrieve",
    "refuse": "refuse",
})

# Expand retrieval goes to generate (not back to grading)
graph.add_edge("expand_retrieve", "generate")

# Terminal nodes
graph.add_edge("generate", END)
graph.add_edge("refuse", END)

# Compile
app = graph.compile()

Step 5: Run the Graph

def chat(query: str) -> str:
    initial_state: ShopBotState = {
        "query": query,
        "is_vague": False,
        "sub_queries": [],
        "chunks": [],
        "crag_verdict": "",
        "answer": "",
    }
    final_state = app.invoke(initial_state)
    return final_state["answer"]

What the Graph Looks Like

          [classify]
         /          \
      [hyde]      [retrieve]
         \          /
        [crag_grade]
       /      |      \
[expand]  [generate]  [refuse]
   |
[generate]

Every routing decision is in the conditional edges. Every operation is in a node. The graph can be drawn on one whiteboard.

Inspecting the Final State for Debugging

The final state records every decision the pipeline made:

final_state = app.invoke(initial_state)

print(f"Was vague: {final_state['is_vague']}")
print(f"CRAG verdict: {final_state['crag_verdict']}")
print(f"Chunks used: {len(final_state['chunks'])}")
print(f"Answer: {final_state['answer']}")

When a query produces the wrong answer, the state tells you exactly which route was taken and what evidence was used — without re-running the query.

The Valid Knowledge

  • One node = one operation: nodes that do two things are two nodes waiting to happen. Small nodes make routing failures easy to identify.
  • All routing logic in edge functions: a node that calls if is_vague: retrieve_hyde() is a routing decision inside a node — this hides the graph structure. Put it in a conditional edge.
  • State is the single source of truth: nodes communicate only through state. If a node needs a value, it reads it from state. If a node produces a value, it writes it to state.
  • The final state is your debugging tool: when an answer is wrong, read the final state. The routing path and the evidence used are both there.
  • LangGraph vs LangChain chains: chains are linear and implicit. LangGraph is graphical and explicit. For pipelines with conditional routing, LangGraph is the correct tool — chains cannot express branches.

This is the concept. The book is the system.

Book 3: The Senior AI Engineer

Articles give you understanding. The book gives you a working system — full production code, RAGAS evaluation scores, and the patterns that hold up at 11 PM when something breaks.