Working with Subgraphs in LangGraph

usmanmalik57 1 Tallied Votes 113 Views Share

LangGraph is an agentic framework for orchestrating complex language model workflows as graphs of nodes and edges. Subgraphs in LangGraph are simply graphs used as nodes within a larger graph. In other words, an entire graph (with its internal nodes and logic) can be encapsulated and treated as a single node in a parent graph. This modular design makes it easier to break down complex workflows into smaller, manageable components. Subgraphs are especially useful for building multi-component AI systems – for example, multi-agent setups and for reusing logic across different workflows.

This article will explore how to define and use subgraphs in LangGraph, including a simple example and a more advanced Retrieval-Augmented Generation (RAG) example with conditional routing.

Installing and Importing Required Libraries

The following script installs the libraries and modules you need to run scripts in this article.

!pip install langchain-community
!pip install langchain-openai
!langchain-text-splitters
!pip install langgraph
!langchain-core
!pip install pypdf
!pip install chromadb

The script below imports the required modules into your Python application.

from langchain_openai import ChatOpenAI
from langchain_openai import OpenAIEmbeddings
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.documents import Document
from langchain_community.vectorstores import Chroma
from langchain_core.documents import Document
from langgraph.graph import START, StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langchain_core.messages import HumanMessage

from typing_extensions import List, TypedDict
from IPython.display import Image, display
import os

from google.colab import userdata
OPENAI_API_KEY = userdata.get('OPENAI_API_KEY')

A Simple Example of Subgraphs in LangGraph

Let us start with a basic example to illustrate what subgraphs are and how to use them. Suppose we want to build a small Q&A workflow that takes a user question, gets an answer from an OpenAI model, and then suggests a followup question. We will implement this in LangGraph by creating a subgraph that handles the two-step process (answering and suggesting a followup), and then include that subgraph in a parent graph.

We will use the OpenAI gpt-4o model to generate responses to user queries. The script below defines our LLM.

llm = ChatOpenAI(model = 'gpt-4o',
                api_key = OPENAI_API_KEY)

Next, we will define states for our parent graph and the subgraph.
The two graphs have the duplicate keys (query, answer, followup, trace) so that the parent and subgraph share these state channels.

class ParentState(TypedDict):
    query: str
    answer: str
    followup: str
    trace: list[str]


class QASubgraphState(TypedDict):
    query: str            # (shared with parent)
    answer: str           # (shared with parent)
    followup: str         # (shared with parent)
    trace: list[str]      # (shared with parent)

In the subgraph, we will define two nodes as simple Python functions. The first node answer_question uses the LLM to generate an answer for the user's query, and stores it in the state under the "answer" key. The second node suggest_followup question, storing the result under followup.

The two subgraph nodes: answer_question and suggest_followup, and the parent node parent_node will also update the trace list by appending trace messages.

def answer_question(state: QASubgraphState) -> QASubgraphState:
    question = state["query"]
    trace = state.get("trace", [])
    trace.append("subgraph:answer_question")
    response = llm([HumanMessage(content=question)]).content
    return {"answer": response, "trace": trace}

def suggest_followup(state: QASubgraphState) -> QASubgraphState:
    trace = state.get("trace", [])
    trace.append("subgraph:suggest_followup")
    prompt = f"Based on the answer: '{state['answer']}', suggest a relevant follow-up question."
    suggestion = llm([HumanMessage(content=prompt)]).content
    return {"followup": suggestion, "trace": trace}

def parent_node(state: ParentState) -> ParentState:
    trace = state.get("trace", [])
    trace.append("parent:qa_flow")
    return {"trace": trace}

Next, we build and compile the subgraph, then build the parent graph and insert the subgraph as a node:

subgraph_builder = StateGraph(QASubgraphState)
subgraph_builder.add_node("answer_question", answer_question)
subgraph_builder.add_node("suggest_followup", suggest_followup)
subgraph_builder.add_edge(START, "answer_question")
subgraph_builder.add_edge("answer_question", "suggest_followup")
subgraph_builder.add_edge("suggest_followup", END)
qa_subgraph = subgraph_builder.compile()


parent_builder = StateGraph(ParentState)
parent_builder.add_node("parent_node", parent_node)
parent_builder.add_node("qa_flow", qa_subgraph)
parent_builder.add_edge(START, "parent_node")
parent_builder.add_edge("parent_node", "qa_flow")
parent_builder.add_edge("qa_flow", END)

parent_graph = parent_builder.compile()

You can see how the parent and subgraph look by displaying them as follows:

display(Image(parent_graph.get_graph().draw_mermaid_png()))
display(Image(qa_subgraph.get_graph().draw_mermaid_png()))

Output (Parent Graph)

img1.png

Output (Subgraph)

img2.png

Now we can run the parent graph with an example question:

query = "What is the capital of France?"
result_state = parent_graph.invoke({"query": query})

print("Question:", query)
print("Answer:", result_state["answer"])
print("Follow-up question:", result_state["followup"])
print("Nodes tracing", result_state['trace'])

When you invoke the graph, LangGraph will execute the subgraph as part of the flow. The final result_state returned is a dictionary containing the updated state after the graph finishes. In this case, it should contain the original question, the answer from the LLM, a followup question, and the trace list. For example, you might see:

Output:

img3.png

The trace list shows the graph execution flow. It shows that the query was first passed to the parent_node, which forwarded it to the subgraph nodes answer_question and suggest_followup.

This simple example demonstrates how a subgraph can encapsulate a multi-step LLM workflow (answering and then suggesting a question). The parent graph doesn't need to know the details of those steps; it just treats the subgraph as a single node that takes a query and produces an answer and followup in the state.

RAG with Subgraphs and Conditional Routing

For a more advanced example, let's consider a Retrieval-Augmented Generation (RAG) scenario. In a RAG application, the system augments the LLM by retrieving relevant documents from an external knowledge base and providing that information to the LLM to improve its answer. We will demonstrate how to utilize subgraphs for the retrieval step and how to route queries to this subgraph only when necessary.

Define the RAG Subgraph

First, we prepare the knowledge base from a PDF. We will use LangChain's document loader and vector store for this.

data_url = "https://northwestcricket.com/wp-content/uploads/2023/04/laws-of-cricket-2017-code-3rd-edition-2022_1.pdf"

loader = PyPDFLoader(data_url)
docs = loader.load()
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,  # chunk size (characters)
    chunk_overlap=200,  # chunk overlap (characters)
    add_start_index=True,  # track index in original document
)
all_splits = text_splitter.split_documents(docs)

print(f"Document split into {len(all_splits)} sub-documents.")

Output:

Document split into 300 sub-documents.
embeddings = OpenAIEmbeddings()

vector_store= Chroma.from_documents(
    documents=all_splits,
    embedding=embeddings
)

Now we define the subgraph that performs retrieval and then uses the LLM to answer with the retrieved context. We will define a state schema for this subgraph and the functions for its nodes:

class RagState(TypedDict):
    query: str
    answer: str
    docs: str

def retrieve_docs(state: RagState) -> RagState:
    query = state["query"]
    results = vector_store.similarity_search(query)
    context_texts = [doc.page_content for doc in results]
    state_update: RagState = {"docs": " ".join(context_texts)}
    return state_update

def answer_with_docs(state: RagState) -> RagState:
    query = state["query"]
    context = state["docs"]
    prompt = f"Use the following document excerpt to answer the question.\nDocument: {context}\nQuestion: {query}\nAnswer:"
    answer = llm([HumanMessage(content=prompt)]).content
    return {"answer": answer}

Here, RagState includes query and answer (which will be shared with the parent graph's state) and a docs field to hold the retrieved text (this is used internally by the subgraph only). The retrieve_docs node uses our retriever to get relevant chunks from the document and joins them into a single string. The answer_with_docs node then constructs a prompt that includes the retrieved document text and the question, and calls the LLM to produce an answer. The answer is stored in the "answer" key of the state.

Now we build the RAG subgraph:

rag_builder = StateGraph(RagState)
rag_builder.add_node("retrieve", retrieve_docs)
rag_builder.add_node("answer", answer_with_docs)
rag_builder.add_edge(START, "retrieve")
rag_builder.add_edge("retrieve", "answer")
rag_builder.add_edge("answer", END)
rag_subgraph = rag_builder.compile()
display(Image(rag_subgraph.get_graph().draw_mermaid_png()))

Output:

img4.png

The above graph can be invoked as a standalone graph, as the following script shows.

result = rag_subgraph.invoke({"query": "What is the law to take tea when a team is 9 wickets down?"})
print(result['answer'])

Output:

img5.png

Define the Direct Answer Node

Next, we create a simple node for the direct answer path (when we decide retrieval is not needed). This node will call the LLM directly on the query. We also define the ParentState for our main graph, which in this case contains a query, answer, and category. The category stores a string that defines whether we need to call the RAG subgraph or the parent node.

class ParentState(TypedDict):
    query: str
    answer: str
    category:str

def direct_answer(state: ParentState) -> ParentState:
    question = state["query"]
    response = llm([HumanMessage(content=question)]).content
    return {"answer": response}

Routing Logic with a Conditional Edge

To decide which path to take for a given query, we introduce a router node route_decision and a router function router_node. The routing_decision The routing_decision function passes the input query to an LLM to decide whether to send this query to the RAG subgraph or process it directly. The returned value (rag or direct) is stored in the category attribute of the graph. The router_node returns this value.

We will use the route_decision and router_node in a conditional edge in our graph to select whether to answer the query using the subgraph or the direct parent node.

def router_node(state: ParentState) -> ParentState:
    return state["category"]


def route_decision(state: ParentState) -> str:

    question = state["query"]
    routing_prompt = f"If the question is about cricket sport, return `rag`, else return `direct`. Question: {question}. The answer should be a single word."
    analysis = llm([HumanMessage(content=routing_prompt)]).content
    decision = analysis.strip().lower()

    print(f"=========== The query is routed to =========== : {decision}")
    if "rag" in decision:
        return {"category": "rag"}
    else:
        return {"category": "direct"}

Define the Parent Graph

Finally, we put everything together in the parent graph:

parent_builder = StateGraph(ParentState)
parent_builder.add_node("router", route_decision)
parent_builder.add_node("direct", direct_answer)
parent_builder.add_node("rag", rag_subgraph)

parent_builder.add_edge(START, "router")
parent_builder.add_conditional_edges(
    "router",
    router_node,
      {"rag": "rag",
       "direct": "direct"}
)

parent_builder.add_edge("direct", END)
parent_builder.add_edge("rag", END)

parent_graph = parent_builder.compile()
display(Image(parent_graph.get_graph().draw_mermaid_png()))

Output:

img6.png

The above image shows our graph structure.

With the graph compiled, we can test it out on different queries:

q1 = "How do I reverse a string in Python? Give only one method."
q2 = "What is the rule to call stumps when a team is 9 wickets down?"

for q in [q1, q2]:
    result = parent_graph.invoke({"query": q})
    print(f"Q: {q}")
    print(f"A: {result['answer']}\n")

In the first query q1, the question is a general programming question. The routing function will probably determine this can be answered directly.

For the second query q2, the phrasing "wickets down" strongly suggests that we need to consult the PDF, as this term is related to the sport of cricket.

The below output confirms our assumption.

Output:

img7.png

Conclusion

Subgraphs in LangGraph provide a powerful way to structure complex LLM applications. By encapsulating related nodes into reusable components, subgraphs help in managing state across multi-step operations and keeping the overall graph organized.

With a good understanding of subgraphs, you can confidently build more complex LangGraph applications, orchestrating sophisticated behaviors such as tool use, multi-step reasoning, and dynamic decision-making in your LLM-powered systems. Happy graph building!

Be a part of the DaniWeb community

We're a friendly, industry-focused community of developers, IT pros, digital marketers, and technology enthusiasts meeting, networking, learning, and sharing knowledge.