NorthGradient
Start reading
Building Production Agents with LangGraph Browse lessons

Building Production Agents with LangGraph · Building Production Agents with LangGraph · 7 min read

Agent architecture

Most agent bugs are not logic bugs. They are structure bugs: state mixed with logic, a workflow no one can draw, a tool that does too many things. These are cheap to avoid up front and expensive to fix later. This lesson covers five decisions you make before writing any logic.

Structure the agent so every node can be tested on its own, every failure has one clear source, and every run can be replayed.

Keep state and logic in separate objects

State is the agent’s notebook: the task, results so far, counters, and flags. No methods, no logic. In LangGraph, define it as a TypedDict:

from typing import TypedDict

class AgentState(TypedDict):
    task:     str    # the user's request
    messages: list   # conversation history
    result:   str    # final answer
    step:     int    # current step

Logic is a pure function: state in, new state out. It never writes to self or a global, and never changes the state it was given:

def plan_step(state: AgentState) -> AgentState:
    plan = call_llm(f"Plan how to complete: {state['task']}")
    return {**state, "messages": state["messages"] + [plan]}

A good test: could you hand the state to a separate process and continue the run there? If not, something is tangled.

Draw the workflow as a graph

Before coding, sketch the agent as boxes (nodes) and arrows (edges). Each box does one job. Each arrow is a rule for what runs next. Then turn the sketch into a StateGraph:

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

class State(TypedDict):
    question: str
    answer:   str

def search(state: State) -> State:
    return {**state, "answer": web_search(state["question"])}

def summarize(state: State) -> State:
    return {**state, "answer": call_llm(f"Summarize: {state['answer']}")}

# a conditional edge: returns the name of the next node
def next_step(state: State) -> str:
    return "summarize" if len(state["answer"]) > 500 else END

graph = StateGraph(State)
graph.add_node("search", search)
graph.add_node("summarize", summarize)
graph.set_entry_point("search")
graph.add_conditional_edges("search", next_step)
graph.add_edge("summarize", END)

app = graph.compile()

You could write the same flow as if/else, but it gets unreadable fast and cannot be drawn, checkpointed, or replayed. If you cannot draw the graph, you do not understand it well enough to code it yet.

Name the four loop phases

The perceive, reason, act, update loop from lesson 1 should be four named functions, not one big function that does everything:

def perceive(state: AgentState) -> AgentState:
    return {**state, "observation": read_environment()}

def reason(state: AgentState) -> AgentState:
    decision = call_llm(f"Given: {state['observation']}. What next?")
    return {**state, "next_action": decision}

def act(state: AgentState) -> AgentState:
    return {**state, "tool_result": run_tool(state["next_action"])}

def update(state: AgentState) -> AgentState:
    return {**state, "history": state["history"] + [state["tool_result"]]}

Now you can test reason on its own: pass a fake state, check that next_action comes back right. No real model or tool needed.

Split large graphs into subgraphs

Once a graph passes 6 to 8 nodes, it gets hard to read and hard for two people to work on at once. A subgraph is its own graph that you compile, then drop into a parent graph as a single node:

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

# a small research agent, built and compiled on its own
class ResearchState(TypedDict):
    query:   str
    sources: list

def fetch_sources(state: ResearchState) -> ResearchState:
    return {**state, "sources": web_search(state["query"])}

research_graph = StateGraph(ResearchState)
research_graph.add_node("fetch", fetch_sources)
research_graph.set_entry_point("fetch")
research_graph.add_edge("fetch", END)
research_agent = research_graph.compile()

# in the parent graph, one node runs the whole research agent
def run_research(state: dict) -> dict:
    result = research_agent.invoke({"query": state["topic"], "sources": []})
    return {**state, "sources": result["sources"]}

Keep one subgraph per file. Each can be built and tested without touching the others.

Give each tool one job

A tool should do one thing, and its name should say that thing without the word “and”:

from langchain_core.tools import tool

@tool
def get_news(topic: str) -> list[str]:
    """Get the latest 5 news headlines about a topic."""
    return fetch_news_api(topic)

@tool
def get_weather(city: str) -> dict:
    """Get the current temperature and conditions for a city."""
    return fetch_weather_api(city)

A single get_all_data() that calls several APIs hides several failures in one call. When it breaks, you cannot tell which part failed, and retrying it repeats the calls that already worked.

Next, we look at how agents remember things across steps and sessions, and the quiet ways that memory breaks.