Back to blogs

From Solo Agent to Team Player: Architecting Multi-Agent Systems with LangGraph

March 01, 2024

From Solo Agent to Team Player: Architecting Multi-Agent Systems with LangGraph

The relentless pursuit of AGI often fixates on scaling single, monolithic models. While impressive, these systems inherently struggle with the kind of flexible, multi-modal, and specialized intelligence we see in nature. The human brain isn't a single, colossal neural network; it's a vast, distributed network of specialized modules, each an expert in its domain, collaborating through complex pathways. This MoE (Mixture of Experts) architecture, where different parts of the brain activate for different tasks, is my ultimate research north star.

My vision for truly intelligent systems isn't about larger models, but smarter architectures. This leads me directly to multi-agent systems – a first, crude approximation of distributed intelligence. We're not just chaining prompts anymore; we're building collaborative teams.

Why Multi-Agent Systems? Beyond the Monolith.

Single LLMs, even the most powerful, hit a wall when faced with complex, multi-step problems requiring diverse skills. They hallucinate, struggle with long-term coherence, and lack true specialization. Trying to force a single model to act as researcher, writer, and editor simultaneously is like asking a single person to ace every Olympic event. It's inefficient, leads to mediocre results, and fundamentally misunderstands the problem.

Multi-agent systems, conversely, allow us to:

  1. Specialize: Each agent focuses on a specific task, leveraging fine-tuned prompts or even different models/toolsets.
  2. Parallelize: While not strictly parallel in a synchronous LangGraph setup, tasks can be logically delegated and processed sequentially, mimicking a parallel workflow.
  3. Iterate & Refine: Agents can review each other's work, suggest improvements, and refine outputs over multiple cycles, much like a human team.
  4. Manage Complexity: Break down a daunting problem into manageable sub-problems, each handled by a dedicated expert.

Now, a quick word on frameworks. Look, I'm a competitive programmer; my portfolio prioritizes raw APIs, performance, and minimal abstraction. Heavy frameworks, especially those that obscure the underlying logic with layers of unnecessary wrappers, are a hard pass. LangChain, for the most part, feels like a complex abstraction searching for a problem.

However, when it comes to defining complex, cyclical state machines for agents, LangGraph offers a focused DSL that, I admit, can streamline the process compared to writing raw FSM logic from scratch. It's a tool for graph definition, not an all-encompassing LLM orchestration layer. For this specific challenge – modeling a dynamic, collaborative workflow – it serves a clear purpose. If I were building this in production, I'd likely port the core graph logic to a custom TypeScript implementation for type safety, performance, and complete control, but for rapid prototyping and demonstrating the architecture, LangGraph is a pragmatic choice.

The Problem: Automated Technical Research & Content Generation

Let's build a "Research Team." Our goal: Given a technical query, generate a concise, well-researched, and well-written technical summary. This is a multi-step process:

  1. Understand the Request: Clarify the user's intent.
  2. Research: Gather relevant information.
  3. Draft: Synthesize research into a coherent summary.
  4. Review: Critically evaluate the draft for accuracy, clarity, and completeness.
  5. Refine: Incorporate feedback and improve the draft.
  6. Finalize: Deliver the polished summary.

This naturally maps to a supervisor agent coordinating several specialized worker agents.

Architecting the "Research Team": A State Machine Graph

The core idea is a state machine graph.

  • Nodes: Represent our agents or specific functions they perform.
  • Edges: Represent the flow of control, often conditionally, between these nodes.
  • State: A shared, mutable context that all agents can read and update. This is the single source of truth for the entire system.

1. The Shared State (AgentState)

This is critical. A well-defined state prevents agents from hallucinating context or performing redundant work. We'll use a TypedDict for explicit typing, even in Python.

from typing import TypedDict, List, Optional
from langchain_core.messages import BaseMessage
 
class AgentState(TypedDict):
    """
    Represents the state of our multi-agent system.
    Shared context accessible and modifiable by all agents.
    """
    query: str                       # The initial user query/topic
    research_results: List[str]      # Accumulated research findings
    draft: Optional[str]             # Current draft of the summary
    review_comments: List[str]       # Feedback from the reviewer
    iterations: int                  # Number of review-refine cycles
    max_iterations: int              # Maximum allowed review-refine cycles
    next_action: Optional[str]       # Supervisor's decision on the next agent to invoke
    messages: List[BaseMessage]      # For conversational history if needed (e.g., LangGraph's default)

2. The Agents (Nodes)

We'll define our agents as simple Python functions that take the AgentState and return an updated state. Each agent's "intelligence" comes from an LLM call, but the logic and state manipulation are pure Python. We'll use the raw openai client for direct API access.

import os
from openai import OpenAI
from typing import Callable, Literal
from langchain_core.messages import HumanMessage, SystemMessage, FunctionMessage
from langgraph.graph import StateGraph, END
 
# --- LLM Client Setup ---
# Use raw OpenAI client for direct API interaction, avoiding LangChain's LLM wrappers.
client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
 
def call_llm(prompt: str, model: str = "gpt-4o", temperature: float = 0.7) -> str:
    """Helper to make direct LLM calls."""
    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        temperature=temperature,
    )
    return response.choices[0].message.content
 
# --- Agent Definitions ---
 
class Agents:
    """Contains our specialized agent functions."""
 
    def __init__(self, llm_model: str = "gpt-4o"):
        self.llm_model = llm_model
 
    def supervisor_agent(self, state: AgentState) -> AgentState:
        """
        The orchestrator. Decides which worker agent to invoke next or if the task is complete.
        Returns a state dict with 'next_action'.
        """
        current_draft_status = "no draft yet"
        if state.get("draft"):
            current_draft_status = "draft exists"
 
        review_status = "no review comments"
        if state.get("review_comments"):
            review_status = f"{len(state['review_comments'])} review comments exist"
 
        supervisor_prompt = f"""
        You are the Supervisor. Your goal is to manage a research and content generation team.
        The current query is: "{state['query']}"
 
        Current Status:
        - Research Results: {len(state['research_results'])} pieces of information gathered.
        - Draft Status: {current_draft_status}
        - Review Status: {review_status}
        - Iterations: {state['iterations']}/{state['max_iterations']}
 
        Based on the current state, decide the next logical step.
        Choose one of the following actions: 'research', 'draft', 'review', 'revise', 'finish'.
 
        Rules:
        1. If no research has been done, or existing research is insufficient, choose 'research'.
        2. If research is sufficient and no draft exists, choose 'draft'.
        3. If a draft exists and has not been reviewed (or needs further review), choose 'review'.
        4. If review comments exist and iterations < max_iterations, choose 'revise'.
        5. If a draft has been reviewed, all comments addressed (or max_iterations reached), choose 'finish'.
 
        Your output MUST be a single word, one of: research, draft, review, revise, finish.
        """
        print(f"--- Supervisor Thinking ---")
        action = call_llm(supervisor_prompt, model=self.llm_model, temperature=0.1).strip().lower()
        print(f"Supervisor decided: {action}")
        return {"next_action": action}
 
    def researcher_agent(self, state: AgentState) -> AgentState:
        """
        Gathers information based on the query.
        (For this example, we'll simulate web search with an LLM call.)
        """
        print(f"--- Researcher Working ---")
        research_prompt = f"""
        You are a highly skilled researcher. Find 3-5 key facts or pieces of information
        about the following topic: "{state['query']}".
        Focus on technical aspects and provide concise bullet points.
        If previous research results exist, try to expand on them or find complementary information.
        Previous results: {state['research_results']}
        """
        new_research = call_llm(research_prompt, model=self.llm_model).strip()
        
        updated_results = state.get("research_results", []) + [new_research]
        print(f"Researcher found: {new_research[:100]}...")
        return {"research_results": updated_results}
 
    def writer_agent(self, state: AgentState) -> AgentState:
        """
        Drafts the technical summary based on research results.
        """
        print(f"--- Writer Working ---")
        research_summary = "\n".join(state["research_results"])
        writer_prompt = f"""
        You are a technical writer. Draft a concise, informative summary (approx. 200-300 words)
        on the topic: "{state['query']}".
        Use the following research results:
        ---
        {research_summary}
        ---
        Focus on clarity, accuracy, and technical detail appropriate for a developer audience.
        """
        draft = call_llm(writer_prompt, model=self.llm_model).strip()
        print(f"Writer drafted: {draft[:100]}...")
        return {"draft": draft}
 
    def reviewer_agent(self, state: AgentState) -> AgentState:
        """
        Reviews the draft and provides constructive criticism.
        """
        print(f"--- Reviewer Working ---")
        draft = state["draft"]
        reviewer_prompt = f"""
        You are a critical technical reviewer. Evaluate the following draft for accuracy,
        clarity, completeness, and adherence to the topic "{state['query']}".
        Provide constructive feedback, suggesting specific improvements. If the draft is perfect,
        state "No specific changes needed."
        Draft:
        ---
        {draft}
        ---
        """
        comments = call_llm(reviewer_prompt, model=self.llm_model).strip()
        
        updated_comments = state.get("review_comments", []) + [comments]
        print(f"Reviewer commented: {comments[:100]}...")
        return {"review_comments": updated_comments}
 
    def reviser_agent(self, state: AgentState) -> AgentState:
        """
        Revises the draft based on reviewer comments.
        Increments the iteration count.
        """
        print(f"--- Reviser Working ---")
        original_draft = state["draft"]
        review_feedback = "\n".join(state["review_comments"])
        reviser_prompt = f"""
        You are a meticulous reviser. Improve the following draft based on the provided
        review feedback. Ensure all comments are addressed while maintaining the
        original intent and technical accuracy for the topic "{state['query']}".
        Original Draft:
        ---
        {original_draft}
        ---
        Review Feedback:
        ---
        {review_feedback}
        ---
        Provide the revised draft.
        """
        revised_draft = call_llm(reviser_prompt, model=self.llm_model).strip()
        
        # Clear review comments for the next cycle and increment iteration
        new_iterations = state.get("iterations", 0) + 1
        print(f"Reviser revised. Iteration: {new_iterations}")
        return {"draft": revised_draft, "review_comments": [], "iterations": new_iterations}
 

3. Building the Graph with LangGraph

Now we tie these agent functions together into a dynamic workflow using StateGraph.

# Initialize our agents
team_agents = Agents()
 
# Build the graph
workflow = StateGraph(AgentState)
 
# Add nodes for each agent function
workflow.add_node("supervisor", team_agents.supervisor_agent)
workflow.add_node("researcher", team_agents.researcher_agent)
workflow.add_node("writer", team_agents.writer_agent)
workflow.add_node("reviewer", team_agents.reviewer_agent)
workflow.add_node("reviser", team_agents.reviser_agent)
 
# Set the entry point - always start with the supervisor
workflow.set_entry_point("supervisor")
 
# Define conditional edges from the supervisor
workflow.add_conditional_edges(
    "supervisor",
    lambda state: state["next_action"], # The supervisor's decision dictates the next node
    {
        "research": "researcher",
        "draft": "writer",
        "review": "reviewer",
        "revise": "reviser",
        "finish": END # If supervisor says 'finish', the graph ends
    }
)
 
# Define regular edges for worker agents to return to the supervisor
workflow.add_edge("researcher", "supervisor")
workflow.add_edge("writer", "supervisor")
workflow.add_edge("reviewer", "supervisor")
workflow.add_edge("reviser", "supervisor")
 
# Compile the graph
app = workflow.compile()
 
# Optional: Visualize the graph (requires pygraphviz or pydot)
# from IPython.display import Image, display
# display(Image(app.get_graph().draw_png()))

Running the System & Observing Collaboration

Let's put our research team to work. Notice how the supervisor intelligently directs the flow based on the current state, mimicking a real team leader.

# --- Example Execution ---
initial_state: AgentState = {
    "query": "Explain transformer models in AI.",
    "research_results": [],
    "draft": None,
    "review_comments": [],
    "iterations": 0,
    "max_iterations": 2, # Allow up to 2 review/revise cycles
    "next_action": None,
    "messages": [] # LangGraph internally uses this for conversational state sometimes
}
 
print(f"\n--- Starting Research for: {initial_state['query']} ---")
final_state = {}
for s in app.stream(initial_state):
    print(f"Current State: {list(s.keys())[0]} - {s}")
    final_state.update(s)
    print("---")
 
print("\n--- Final Output ---")
print(f"Topic: {final_state['supervisor']['query']}")
print(f"Final Draft:\n{final_state['supervisor']['draft']}")
print(f"Total Iterations: {final_state['supervisor']['iterations']}")

You'll observe an output stream showing the supervisor making decisions, delegating to the researcher, then writer, then reviewer, and potentially multiple revise cycles before ultimately finishing. Each agent's print statement shows its contribution to the shared state.

What I Learned & Future Directions

This exercise in architecting a multi-agent system with LangGraph highlighted several critical insights:

  1. Explicit State is Paramount: A well-defined, centralized AgentState is the backbone of any complex multi-agent system. It's the shared blackboard, the single source of truth that prevents agents from losing context, repeating work, or diverging. Without it, you're just chaining prompts, not building an intelligent system.
  2. Architectural Clarity through Graphs: Modeling the problem as a state machine graph forces clear thinking about roles, responsibilities, and transitions. It's a powerful way to visualize and manage complexity that simple sequential chains simply cannot.
  3. The Supervisor is Key: The quality of the supervisor agent's decision-making directly impacts the efficiency and effectiveness of the entire system. Its prompt engineering for structured output (e.g., forcing a specific action keyword) is crucial. This "manager" role is a foundational component of true MoE systems.
  4. Beyond LangGraph's Comfort Zone: While LangGraph provides a neat DSL for graph definition, I'm already thinking about how to abstract this logic into a custom framework. For production-grade systems, I'd move to TypeScript, where type definitions and interfaces enforce strict contracts, preventing runtime errors and improving maintainability. We could build a custom graph executor that leverages asynchronous processing for genuine parallelism.
  5. Connecting to the MoE Vision: This multi-agent setup is a tangible step towards my goal of replicating brain-like architectures. Each agent is an "expert" module, activated conditionally based on the task's current needs. The supervisor acts as a rudimentary "attention mechanism" or "gating network," directing information flow.

My journey continues towards truly distributed intelligence. Future work involves:

  • Dynamic Agent Creation: What if the supervisor could spin up new, temporary agents for niche tasks?
  • Self-Modifying Graphs: Can the system learn to adapt its own workflow based on past performance or new problem types?
  • Integrated Memory: Moving beyond simple state to robust, long-term memory systems for agents.
  • Tool Integration: Seamlessly integrating real-world tools (web search, code interpreters, databases) beyond simulated functions.

The future of AI isn't just about bigger models; it's about building smarter teams of models. This graph-based architecture is a fundamental blueprint for that vision. It's about designing intelligence, not just training it.