AI Agents LangGraph
Nodes in LangGraph
Intermediate
graph.add_node()
, and connect them using edges to control the flow of execution.
Nodes can receive the full state (or specific parts of it), perform a wide variety of tasks, from calling LLMs and tools to validation, retrieval, or human approval, and return either full state replacements, partial updates, or no changes at all.
From basic function nodes to specialized ones like LLM Nodes, Tool Nodes, Router Nodes, Retrieval Nodes, Human-in-the-Loop Nodes, Parallel Execution Nodes, and Subgraph Nodes, LangGraph gives you powerful building blocks to create complex, reliable, and modular AI agents and workflows.
Mastering nodes is the key to building scalable, maintainable, and production-ready applications with LangGraph.
Nodes in LangGraph: The Building Blocks of Graph
What is a Node?
A node is the fundamental unit of computation in LangGraph. Think of nodes as the cells of your graph network, they're where the actual processing and actions take place.
Nodes can be:
- Functions that transform data
- Tool calls that interact with external systems
- LLM invocations that generate responses
- Any executable unit that processes state
In essence, a node is where work happens in your graph. Later we will back to this.
What Does a Node Receive?
Like any function, a node receives input to process. In LangGraph, this input is called state .
def my_node(state: State) -> dict:
# state contains all the data flowing through the graph
user_input = state["messages"]
# ... do something
State is the context for agents and workflows:
- It carries data from one node to the next
- It's shared across all nodes in the graph
- It accumulates information as it flows through
When a node executes, it receives the current state and can read any field it needs. In State Management topic state is discussed in details.
What Does a Node Do?
A node performs actions on the state. This can include:
1. Calling Tools
def search_node(state: State) -> dict:
query = state["query"]
results = search_tool(query) # External API call
return {"search_results": results}
2. Performing Calculations
def calculate_node(state: State) -> dict:
total = sum(state["values"])
return {"total": total}
3. Database Operations
def save_to_db_node(state: State) -> dict:
db.insert(state["data"])
return {"saved": True}
4. Web Scraping/API Calls
def fetch_data_node(state: State) -> dict:
url = state["url"]
data = requests.get(url).json()
return {"fetched_data": data}
5. LLM Calls
A node sends a prompt or messages to a Large Language Model (GPT, Claude, Grok, etc.) to generate text, make decisions, or plan next steps.
def llm_node(state: State) -> dict:
response = llm.invoke(state["messages"])
return {"messages": state["messages"] + [response]}
6. State Transformation
A node modifies, filters, summarizes, or restructures the current state without calling any external service.
def transform_node(state: State) -> dict:
processed = process_text(state["input"])
return {"output": processed}
The key principle: Nodes read from state, perform some operation, and return updates to state.
What Does a Node Return?
1. Full State Update (Replace Everything)
def reset_node(state: State) -> State:
return {
"messages": [],
"count": 0,
"status": "reset",
"documents": []
}
2. Partial State Update (Most Common & Recommended)
def increment_node(state: State) -> dict:
return {
"count": state["count"] + 1, # Only update this field
"messages": [AIMessage(content="...")] # We can update multiple fields
}
3. Return None (No State Change)
def logging_node(state: State) -> None:
logger.info(f"Processing state: {state}")
# No return statement = no changes to state
# (implicitly returns None)
How to Add a Node in LangGraph
Two Main Ways to Add a Node
def research_node(state):
# your logic here
return {"documents": [...]}
graph.add_node(research_node) # Node name becomes "research_node"
graph.add_node("research", research_node)
# or
graph.add_node("agent", agent_llm_with_tools)
from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import ToolNode
# Adding different types of nodes
graph = StateGraph(MessagesState)
graph.add_node("agent", agent_node) # LLM / Agent node
graph.add_node("tools", ToolNode(tools)) # Tool node
graph.add_node("retriever", retrieval_node) # Custom node
# With metadata and retry (advanced)
graph.add_node(
"web_search",
web_search_node,
metadata={"description": "Search the internet", "type": "tool"},
retry=RetryPolicy(max_attempts=3) # Auto-retry on failure
)
Node Naming Best Practices
- Use clear, descriptive names that explain what the node does
- Use snake_case (lowercase with underscores)
- Keep names concise but meaningful
- Use verbs when possible (fetch, validate, generate, summarize, etc.)
# Good Examples
builder.add_node("fetch_data", fetch_data)
builder.add_node("validate_input", validate_input)
builder.add_node("generate_response", generate_response)
builder.add_node("retrieve_documents", retrieval_node)
builder.add_node("route_decision", router_node)
builder.add_node("summarize_results", summarizer)
# Bad Examples
builder.add_node("node1", func1) # Too vague
builder.add_node("step", func) # Not descriptive
builder.add_node("my_node", process) # Unclear purpose
builder.add_node("llm", llm_call) # Too generic (what does it do?)
Use names that reflect the action or responsibility of the node. This becomes especially valuable when reading the graph structure or debugging execution flow.
Common Types of Nodes in LangGraph
below we review some common types of nodes. In practice, most LangGraph nodes fall into one of these categories:
| Category | Purpose |
|---|---|
| Processing Nodes | Transform data |
| Reasoning Nodes | LLM thinking/planning |
| Action Nodes | Tool/API execution |
| Control Nodes | Routing/decision-making |
| Memory Nodes | Store/retrieve state |
| Integration Nodes | External systems |
| Human Nodes | Human approval/input |
Technically, all LangGraph nodes are usually just Python callables . The “type” of node is more about:
- its role
- behavior
- workflow purpose
rather than a strict LangGraph class type system.
So:
- “router node”
- “memory node”
- “validation node”
are architectural patterns, not separate built-in LangGraph node classes. However, we review some of common type of them in more details. nevertheless, in other sections we elaborate most of them.
1. Function Nodes
Standard Python functions that process state.
Used for:
- data transformation
- routing preparation
- calculations
This node can be define in several ways:
1.1 Plain Python Function (most common)
from langgraph.graph import StateGraph
def my_node(state: dict):
# Perform any logic
result = some_calculation(state["input"])
return {"output": result, "messages": [...]}
# Add to graph
graph.add_node("my_node", my_node)
1.2 Class with __call__
class MyNode:
def __init__(self, model_name: str):
self.llm = ChatOpenAI(model=model_name)
def __call__(self, state: dict):
response = self.llm.invoke(state["messages"])
return {"messages": [response]}
# Usage
node = MyNode("gpt-4o")
graph.add_node("agent", node)
1.3 Async Function
For I/O-heavy operations (LLM calls, API requests, database queries) to enable concurrent and faster execution.
async def async_node(state: dict):
response = await llm.ainvoke(state["messages"])
return {"messages": [response]}
# Add normally — LangGraph handles async automatically
graph.add_node("async_agent", async_node)
1.4 LangChain Runnable (chain, LLM, etc.)
You can directly use any LangChain Runnable (LLM, chain, prompt + LLM, etc.) as a node.
from langchain_core.runnables import Runnable
# LLM as node
llm = ChatOpenAI(model="gpt-4o")
# Chain as node
chain = prompt | llm | parser
graph.add_node("llm_node", llm) # direct LLM
graph.add_node("chain_node", chain) # full chain
@tool
decorator and run them easily with
ToolNode
. Managing state is simple too, we can use LangChain’s Annotated types and reducers to update the data cleanly without conflicts. Features like human-in-the-loop and interrupts also work smoothly with LangChain. Plus, we can even take a whole LangGraph or a prebuilt LangChain agent and use it as a subgraph inside our main graph.
2. LLM Nodes
- Text generation
- Reasoning & decision making
- Summarization
- Planning & tool selection
- Response formatting
def llm_node(state: MessagesState):
response = llm.invoke(state["messages"]) # or .ainvoke()
return {"messages": [response]}
# Common pattern with tool calling
tools = [search_web, get_weather, run_sql]
llm_with_tools = llm.bind_tools(tools)
def agent_node(state: MessagesState):
response = llm_with_tools.invoke(state["messages"])
return {"messages": [response]}
The LLM can then respond in two ways:
Case 1 — Normal text response:
──────────────────────────────
AIMessage(
content="Paris is the capital of France."
tool_calls=[] ← empty, no tool needed
)
Case 2 — Tool call response:
─────────────────────────────
AIMessage(
content="" ← empty, no text
tool_calls=[{
"name": "get_weather",
"args": {"city": "London"},
"id": "call_abc123"
}]
)
│ agent_node │ ← LLM decides
└──────┬──────┘
│
┌────────────▼─────────────┐
│ tool_calls in response? │
└────────────┬─────────────┘
│ │
YES NO
│ │
┌────────▼──────┐ ┌───▼────┐
│ tool_node │ │ END │
│ (runs tool) │ └────────┘
└────────┬──────┘
│
(result added to state)
│
└──── back to agent_node ──► loops until no tool calls
3. Tool Nodes
- Web search
- Database queries
- Calculator tools
- Weather APIs
- Custom business logic tools
from langgraph.prebuilt import ToolNode
from langchain_core.tools import tool
from langchain_community.tools import TavilySearchResults
# 1. Define your tools using LangChain
@tool
def multiply(a: int, b: int) -> int:
"""Multiply two numbers."""
return a * b
search_tool = TavilySearchResults(max_results=2)
tools = [multiply, search_tool]
# 2. Create ToolNode
tool_node = ToolNode(tools=tools)
# 3. Add to your graph
graph.add_node("tools", tool_node)
# LLM decides which tool to call
graph.add_node("agent", agent_llm_node)
# ToolNode executes the chosen tool
graph.add_node("tools", ToolNode(tools))
# Conditional edge: LLM decides next step
graph.add_conditional_edges("agent", route_tools)
The core difference between LLM Node and Tool Node
| LLM Node | LLM Node |
|
DECIDES which tool to call Outputs tool_calls in AIMessage Uses the LLM (costs tokens) Returns AIMessage |
ACTUALLY RUNS the tool Reads tool_calls, executes them No LLM involved, pure Python Returns ToolMessage |
LLM Node — just thinks:
def agent_node(state: MessagesState):
response = llm_with_tools.invoke(state["messages"])
# LLM looks at messages and says:
# "I think I should call get_weather with city=London"
# but it does NOT call it — just expresses the intent
return {"messages": [response]}
# response looks like:
# AIMessage(
# content="",
# tool_calls=[{"name": "get_weather", "args": {"city": "London"}}]
# )
Tool Node — just executes:
from langgraph.prebuilt import ToolNode
tools = [get_weather, search_web]
tool_node = ToolNode(tools)
# Internally it does roughly this:
# 1. reads tool_calls from last AIMessage
# 2. finds the matching function
# 3. calls get_weather(city="London")
# 4. wraps result in ToolMessage
# No LLM involved at all
Message Flow Makes it Clear
User: "What's the weather in London?"
│
▼
┌──────────────┐
│ agent_node │ llm_with_tools.invoke(messages)
│ (LLM) │
│ │ → AIMessage(tool_calls=[get_weather(London)])
└──────┬───────┘ ← INTENT only, not executed yet
│
▼
┌──────────────┐
│ tool_node │ sees tool_calls in AIMessage
│ (no LLM) │ actually calls get_weather("London")
│ │ → ToolMessage(content="15°C, cloudy")
└──────┬───────┘
│
▼
┌──────────────┐
│ agent_node │ llm sees ToolMessage result in history
│ (LLM) │ → AIMessage(content="It's 15°C and cloudy in London")
└──────────────┘ ← final answer, no tool_calls this time
4. Router Nodes
- Classifying user requests
- Choosing different workflow paths
- Deciding whether to call tools or end the process
- Routing based on LLM output or conditions
def route_tools(state):
"""Router that decides the next node based on the last message."""
messages = state["messages"]
last_message = messages[-1]
# If the LLM called a tool, go to tools node
if last_message.tool_calls:
return "tools"
# Otherwise, end the graph
return "END"
# Add to graph
graph.add_node("agent", agent_node)
graph.add_node("tools", tool_node)
# Conditional routing
graph.add_conditional_edges(
"agent", # Start from agent node
route_tools, # Router function
{
"tools": "tools",
"END": END
}
)
5. Validation Nodes
- JSON validation
- Schema checking
- Response quality checks
- Safety / moderation filters
- Data consistency verification
from pydantic import BaseModel, ValidationError
class ResponseSchema(BaseModel):
answer: str
confidence: float
def validation_node(state):
try:
validated = ResponseSchema.model_validate(state["output"])
return {"validated_output": validated.model_dump()}
except ValidationError as e:
return {"error": str(e), "next": "fix_output"} # Send back for retry
6. Memory Nodes
- Storing memory
- Retrieving memory
- Updating conversation history
- Vector database retrieval (Chroma, Pinecone, etc.)
- Persistent memory updates
- Message summarization
- Conversation history management
def memory_node(state):
# Retrieve relevant past memories
query = state["messages"][-1].content
relevant_memory = vector_retriever.invoke(query)
# Update state with memory
return {
"messages": state["messages"] + relevant_memory,
"memory": "updated"
}
7. Retrieval Nodes
- Retrieving relevant documents
- Searching embeddings (vector similarity search)
- Fetching additional context
- Knowledge base lookups
- Semantic search
from langchain_core.vectorstores import VectorStoreRetriever
# Usually created from a vector store
retriever = vector_store.as_retriever(search_kwargs={"k": 4})
def retrieval_node(state):
"""Retrieve relevant documents based on the user's query."""
query = state["messages"][-1].content
retrieved_docs = retriever.invoke(query)
return {
"documents": retrieved_docs, # Add documents to state
"context": "\n\n".join([doc.page_content for doc in retrieved_docs])
}
# Add to graph
graph.add_node("retrieve", retrieval_node)
8. Human-in-the-Loop Nodes (HITL)
- Approval workflows
- Manual review and editing
- Feedback collection
- Content moderation
- Decision confirmation
- interrupt_before / interrupt_after
- Checkpoints & persistence (SQLite, PostgreSQL, etc.)
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, START, END
# Enable checkpointing
checkpointer = MemorySaver()
# Build graph with interrupt
graph = StateGraph(MessagesState)
# ... add other nodes ...
# Interrupt before a sensitive node
graph.add_node("human_approval", human_approval_node)
# Compile with interrupt
app = graph.compile(
checkpointer=checkpointer,
interrupt_before=["human_approval"] # Pause before this node
)
# Run until interrupt
config = {"configurable": {"thread_id": "123"}}
result = app.invoke(inputs, config)
# Later, after human input:
app.invoke(None, config) # Resume with human feedback
# Human provides feedback
human_feedback = {"messages": [HumanMessage(content="Approved. Proceed.")]}
app.invoke(human_feedback, config)
9. Parallel Execution Nodes
- Parallel searches (e.g., web + vector search)
- Concurrent tool calls
- Batch processing
- Multi-document analysis
- Gathering data from multiple sources
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
# Example: Run multiple retrievers in parallel
def web_search(state):
return {"documents": web_retriever.invoke(state["query"])}
def vector_search(state):
return {"documents": vector_retriever.invoke(state["query"])}
def combine_results(state):
# Merge documents from all parallel branches
return {"documents": state["documents"]}
graph = StateGraph(MessagesState)
graph.add_node("web_search", web_search)
graph.add_node("vector_search", vector_search)
graph.add_node("combine", combine_results)
# Fan-out: Start both searches in parallel
graph.add_edge(START, "web_search")
graph.add_edge(START, "vector_search")
# Fan-in: Wait for both to finish, then combine
graph.add_edge("web_search", "combine")
graph.add_edge("vector_search", "combine")
graph.add_edge("combine", END)
from langgraph.types import Send
def route_to_parallel(state):
return [
Send("web_search", state),
Send("vector_search", state)
]
graph.add_conditional_edges(START, route_to_parallel)
10. Aggregation Nodes
- Merging search results from multiple sources
- Summarizing outputs from parallel operations
- Combining tool responses
- Reducer-based state updates
- Final result synthesis
from langgraph.graph.message import add_messages
from typing import Annotated
# Using built-in reducers (Recommended)
class State(TypedDict):
messages: Annotated[list, add_messages]
documents: Annotated[list, lambda x, y: x + y] # Custom reducer
def aggregate_results(state):
"""Merge and summarize results from parallel branches."""
all_docs = state.get("documents", [])
# Optional: Summarize using LLM
summary = llm.invoke(f"Summarize these documents:\n{all_docs}")
return {
"documents": all_docs, # Keep all raw docs
"summary": summary.content # Add final combined result
}
# Add to graph
graph.add_node("aggregate", aggregate_results)
# Connect parallel branches to aggregator
graph.add_edge("web_search", "aggregate")
graph.add_edge("vector_search", "aggregate")
graph.add_edge("tool_search", "aggregate")
11. Decision Nodes
- Confidence scoring
- Retry decisions
- Fallback selection
- Threshold-based routing
- Quality checks and branching logic
def decision_node(state):
"""Evaluate confidence and decide next action."""
last_message = state["messages"][-1]
confidence = getattr(last_message, "confidence", 0.0)
if confidence >= 0.85:
return "end" # High confidence → finish
elif confidence >= 0.6:
return "retry" # Medium → try again
else:
return "fallback" # Low → use fallback path
# Add to graph
graph.add_node("decide", decision_node)
# Conditional routing based on decision
graph.add_conditional_edges(
"decide",
lambda state: decision_node(state), # or pass the function directly
{
"end": END,
"retry": "agent",
"fallback": "fallback_node"
}
)
12. Subgraph Nodes
- Modular workflows
- Reusable components
- Hierarchical agents (agent teams)
- Multi-stage pipelines
- Easier testing and maintenance
from langgraph.graph import StateGraph, START, END
# 1. Create a reusable subgraph
def create_research_subgraph():
subgraph = StateGraph(MessagesState)
subgraph.add_node("retrieve", retrieval_node)
subgraph.add_node("analyze", analysis_node)
subgraph.add_node("summarize", summarizer_node)
subgraph.add_edge(START, "retrieve")
subgraph.add_edge("retrieve", "analyze")
subgraph.add_edge("analyze", "summarize")
subgraph.add_edge("summarize", END)
return subgraph.compile()
# 2. Add the subgraph as a normal node in the main graph
research_subgraph = create_research_subgraph()
main_graph = StateGraph(MessagesState)
main_graph.add_node("research", research_subgraph) # ← Subgraph as node
main_graph.add_node("final_answer", final_answer_node)
main_graph.add_edge(START, "research")
main_graph.add_edge("research", "final_answer")
main_graph.add_edge("final_answer", END)
13. Node with RunnableConfig (Accessing Metadata & Thread Info)
from langchain_core.runnables import RunnableConfig
from langgraph.graph import MessagesState
def my_node(state: MessagesState, config: RunnableConfig):
"""Node that can access thread metadata and runtime config."""
# Access configurable fields (very common)
thread_id = config["configurable"].get("thread_id")
user_id = config["configurable"].get("user_id")
# Access metadata and tags
metadata = config.get("metadata", {})
tags = config.get("tags", [])
print(f"Running on thread: {thread_id} | User: {user_id}")
# Your logic here...
return {"messages": ["Node executed successfully"]}
- Accessing thread_id for logging or tracing
- Reading custom metadata passed at runtime
- Implementing user-specific or session-specific behavior
- Conditional logic based on tags or metadata
AI agent LangChain LangGraph Python