Skip to main content

Composing Complex LLM Workflows: Advanced LangChain Patterns

When chains and agents aren't enough—when you need conditional branching, iterative loops, human feedback integration, or coordinated multi-agent reasoning—use LangGraph. LangGraph is LangChain's orchestration layer for stateful workflows, enabling complex patterns like ReAct loops, plan-and-execute, and hierarchical agent teams. It's the natural evolution from simple chains to production-grade LLM systems.

I started with chains, outgrew them, hacked together orchestration logic, then discovered LangGraph solved all the problems with a cleaner abstraction. Suddenly, complex workflows were readable and testable.

Introduction to LangGraph​

LangGraph builds state machines where nodes are LLM calls and edges are transitions. State persists across steps, enabling loops and branching:

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

# Define state schema
class State(TypedDict):
input: str
reasoning: str
action: str
observation: str

# Create graph
graph = StateGraph(State)

# Define nodes (functions that process state)
def think_node(state: State) -> State:
# Generate reasoning
state["reasoning"] = "Let me think about this..."
return state

def act_node(state: State) -> State:
# Decide on action
state["action"] = "Search for information"
return state

def observe_node(state: State) -> State:
# Observe result
state["observation"] = "Found relevant documents"
return state

# Add nodes to graph
graph.add_node("think", think_node)
graph.add_node("act", act_node)
graph.add_node("observe", observe_node)

# Add edges (transitions)
graph.add_edge("think", "act")
graph.add_edge("act", "observe")
graph.add_edge("observe", END)

# Set entry point
graph.set_entry_point("think")

# Compile and run
runnable = graph.compile()
result = runnable.invoke({"input": "What is async/await?"})

This defines a linear workflow: think → act → observe. State flows through each node, accumulating information.

Conditional Branching with Router Nodes​

Add logic to decide which node to go to next:

from langgraph.graph import StateGraph, END
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

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

def classify_node(state: State) -> State:
"""Decide if we need to retrieve documents."""
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)

prompt = ChatPromptTemplate.from_template(
"Is this a factual question requiring retrieval? Answer YES or NO: {question}"
)

response = (prompt | model | StrOutputParser()).invoke(
{"question": state["question"]}
)

state["needs_retrieval"] = "YES" if "YES" in response.upper() else "NO"
return state

def retrieve_and_answer(state: State) -> State:
"""Retrieve documents and answer."""
# Retrieve + generate
state["answer"] = "Answer based on retrieved documents..."
return state

def direct_answer(state: State) -> State:
"""Answer without retrieval."""
state["answer"] = "Answer from model knowledge..."
return state

def route_retrieval(state: State) -> str:
"""Router function: decide next node."""
if state["needs_retrieval"] == "YES":
return "retrieve"
else:
return "direct"

# Build graph
graph = StateGraph(State)

graph.add_node("classify", classify_node)
graph.add_node("retrieve", retrieve_and_answer)
graph.add_node("direct", direct_answer)

graph.add_edge("classify", "route")
graph.add_conditional_edges(
"classify",
route_retrieval,
{"retrieve": "retrieve", "direct": "direct"}
)

graph.add_edge("retrieve", END)
graph.add_edge("direct", END)

graph.set_entry_point("classify")

runnable = graph.compile()
result = runnable.invoke({"question": "What is Python?"})

The route_retrieval function returns a string determining which node to visit next. This enables dynamic workflows.

Looping and Iteration with LangGraph​

Implement ReAct-style loops:

from langgraph.graph import StateGraph

class State(TypedDict):
question: str
thoughts: list[str]
actions: list[str]
observations: list[str]
final_answer: str
iterations: int

def think_step(state: State) -> State:
"""Generate thought."""
state["thoughts"].append("I need to search for information...")
return state

def act_step(state: State) -> State:
"""Execute action."""
state["actions"].append("Search the web")
state["observations"].append("Found relevant results")
return state

def should_continue(state: State) -> str:
"""Decide to continue or finish."""
state["iterations"] += 1
if state["iterations"] >= 3 or "final answer" in state["observations"][-1].lower():
return "finish"
else:
return "continue"

def finish(state: State) -> State:
"""Generate final answer."""
state["final_answer"] = "Based on my reasoning..."
return state

graph = StateGraph(State)

graph.add_node("think", think_step)
graph.add_node("act", act_step)
graph.add_node("finish", finish)

graph.add_edge("think", "act")
graph.add_conditional_edges(
"act",
should_continue,
{"continue": "think", "finish": "finish"}
)

graph.add_edge("finish", END)
graph.set_entry_point("think")

runnable = graph.compile()
result = runnable.invoke({
"question": "How do async/await work?",
"thoughts": [],
"actions": [],
"observations": [],
"final_answer": "",
"iterations": 0
})

This loop iterates (think → act → check) until it reaches a stopping condition.

Human-in-the-Loop: Pausing for Input​

Pause execution for human review or correction:

from langgraph.graph import StateGraph, END, Interrupt

class State(TypedDict):
draft_response: str
human_feedback: str
final_response: str

def generate_draft(state: State) -> State:
"""Generate a draft response."""
state["draft_response"] = "This is my draft answer..."
return state

def review_with_human(state: State) -> State:
"""Pause and wait for human feedback."""
# In production, this integrates with your API/UI
raise Interrupt("Please review the draft and provide feedback.")

def revise(state: State) -> State:
"""Revise based on feedback."""
state["final_response"] = f"Revised answer incorporating: {state['human_feedback']}"
return state

graph = StateGraph(State)

graph.add_node("generate", generate_draft)
graph.add_node("review", review_with_human)
graph.add_node("revise", revise)

graph.add_edge("generate", "review")
graph.add_edge("review", "revise")
graph.add_edge("revise", END)

graph.set_entry_point("generate")

runnable = graph.compile()

# Execution
try:
result = runnable.invoke({"draft_response": "", "human_feedback": "", "final_response": ""})
except Interrupt as interrupt:
# Handle the pause—get feedback, then resume
print(f"Paused: {interrupt}")
# Resume with: runnable.invoke(..., checkpoint_id=..., feedback=...)

Interrupts pause the graph, return control to the user/API, and resume when feedback is provided.

Multi-Agent Patterns: Hierarchical Teams​

Coordinate multiple agents toward a common goal:

from langgraph.graph import StateGraph

class State(TypedDict):
task: str
planner_output: str
executor_output: str
reviewer_output: str
final_output: str

model = ChatOpenAI(model="gpt-4o-mini")

def planner_node(state: State) -> State:
"""Plan the approach."""
prompt = ChatPromptTemplate.from_template(
"Create a plan to solve: {task}"
)
state["planner_output"] = (prompt | model | StrOutputParser()).invoke(
{"task": state["task"]}
)
return state

def executor_node(state: State) -> State:
"""Execute the plan."""
prompt = ChatPromptTemplate.from_template(
"Execute this plan: {plan}"
)
state["executor_output"] = (prompt | model | StrOutputParser()).invoke(
{"plan": state["planner_output"]}
)
return state

def reviewer_node(state: State) -> State:
"""Review and improve."""
prompt = ChatPromptTemplate.from_template(
"Review this output and suggest improvements: {output}"
)
state["reviewer_output"] = (prompt | model | StrOutputParser()).invoke(
{"output": state["executor_output"]}
)
return state

def finalize_node(state: State) -> State:
"""Combine outputs into final response."""
state["final_output"] = f"""
Plan: {state["planner_output"]}
Execution: {state["executor_output"]}
Review: {state["reviewer_output"]}
"""
return state

graph = StateGraph(State)

graph.add_node("plan", planner_node)
graph.add_node("execute", executor_node)
graph.add_node("review", reviewer_node)
graph.add_node("finalize", finalize_node)

graph.add_edge("plan", "execute")
graph.add_edge("execute", "review")
graph.add_edge("review", "finalize")
graph.add_edge("finalize", END)

graph.set_entry_point("plan")

runnable = graph.compile()
result = runnable.invoke({
"task": "Design a caching strategy for a web app",
"planner_output": "",
"executor_output": "",
"reviewer_output": "",
"final_output": ""
})

Three agents (planner, executor, reviewer) collaborate on the same task.

Pattern Comparison: Chains vs. Agents vs. LangGraph​

PatternUse CaseStateLoopsBranching
ChainSequential stepsNoNoNo
AgentAutonomous tool-callingImplicitYesImplicit (routing)
LangGraphComplex workflowsExplicitYesExplicit

Choose chains for simple sequences, agents for tool-calling, LangGraph for everything else.

Key Takeaways​

  • LangGraph builds state machines where nodes are LLM calls and edges are transitions
  • State persists across steps, enabling complex stateful workflows
  • Conditional edges route execution based on node output
  • Loops implement ReAct and iterative reasoning patterns
  • Interrupts pause execution for human feedback
  • Multi-agent patterns coordinate teams toward shared goals
  • LangGraph is the natural evolution when chains and agents reach their limits

Frequently Asked Questions​

When should I use LangGraph instead of chains?​

When you need loops, conditional branching, or explicit state management. Chains are simpler; use them for linear workflows. Switch to LangGraph when logic becomes complex.

Can I convert an agent to LangGraph?​

Yes. The underlying logic is the same—the difference is explicit state management. LangGraph gives you more control and visibility.

How do I debug LangGraph workflows?​

Use runnable.get_graph().visualize() to see the graph structure. Inspect state at each step. Set breakpoints in nodes or use langchain.debug = True.

Can LangGraph integrate with external systems?​

Yes. Nodes can call APIs, databases, or external tools. State is just a dict, so it carries any data through the workflow.

What's the maximum complexity LangGraph can handle?​

LangGraph scales to dozens of nodes and complex branching. For very complex systems (100+ nodes), consider modular subgraphs or external orchestration (Airflow, Prefect).

Further Reading​