Welcome, aspiring Applied AI Engineer! In our journey so far, we’ve explored the foundational concepts of AI, Large Language Models (LLMs), prompt engineering, tool use, Retrieval-Augmented Generation (RAG), and the nascent world of agentic AI. Now, it’s time to bring these pieces together and build something truly functional and exciting: a Smart Research Assistant Agent.

This chapter is your opportunity to put theory into practice. You’ll learn to design and implement a multi-agent system capable of understanding a research query, searching for information online, synthesizing findings, and presenting a coherent summary. We’ll leverage a modern agentic framework to orchestrate our agents, managing their states and interactions. Get ready to write some code, solve problems, and witness the power of AI agents in action!

By the end of this chapter, you’ll have a working research assistant and a deeper understanding of how to architect sophisticated AI-driven workflows, which is a core skill for any professional applied AI engineer. Let’s dive in!

Core Concepts: Architecting a Smart Research Assistant

Before we jump into coding, let’s conceptualize what our Smart Research Assistant Agent will do and how it will operate. Imagine you have a complex question, and instead of manually searching and sifting through countless articles, you could delegate that entire task to an intelligent system. That’s what we’re building!

What is a Smart Research Assistant Agent?

At its heart, our research assistant is an autonomous AI system designed to:

  1. Understand a user’s research query: Interpret the intent and scope of the request.
  2. Plan a research strategy: Break down the query into actionable steps, identifying what information is needed.
  3. Execute research tasks: Use external tools (like a web search engine) to gather relevant data.
  4. Process and synthesize information: Read through the retrieved content, extract key facts, and identify patterns.
  5. Generate a coherent summary: Present the findings in an organized and understandable manner.

This isn’t just a simple prompt-and-response system; it involves decision-making, tool utilization, and sequential processing—all hallmarks of agentic AI.

Key Components of Our Multi-Agent System

To achieve these capabilities, we’ll design a system with distinct roles, much like a team of human researchers.

  • The Orchestrator/Planner Agent: This is the “brain” of our operation. It receives the initial query, decides the overall strategy, determines if a search is needed, if summarization is required, or if the task is complete. It guides the flow of information.
  • The Search Agent (Tool User): This agent is responsible for interacting with the external world. Given a search query from the Planner, it will use a web search tool to find relevant information and return it.
  • The Summarizer/Synthesizer Agent: Once information is gathered, this agent takes the raw search results and distills them into concise, relevant answers, addressing the original research question.
  • Memory Module: For a single research task, the “memory” will primarily be the state of our agentic workflow, which passes information between agents as they collaborate. For more advanced, long-running assistants, this would involve more sophisticated memory mechanisms like vector stores.
  • Tool Integration: This is how our agents interact with the outside world. For this project, our primary tool will be a web search API.

Choosing an Agentic Framework for 2026

The landscape of agentic AI frameworks is rapidly evolving. As of early 2026, several robust options exist for building multi-agent systems:

  • LangGraph (part of LangChain): Excellent for defining complex, stateful, and cyclic agent workflows using a graph-based approach. It provides explicit control over agent interaction and state transitions, making it ideal for learning and understanding agent orchestration.
  • AutoGen (Microsoft): Focuses on multi-agent conversations where agents can communicate and collaborate to solve tasks. It’s powerful for scenarios requiring dynamic dialogue between agents.
  • CrewAI: Offers a high-level abstraction for defining “crews” of agents with specific roles, goals, and backstories, simplifying the creation of complex collaborative systems.

For this project, we’ll use LangGraph. Its explicit graph definition allows us to clearly visualize and control the flow of our research assistant, making it a perfect pedagogical tool for understanding multi-agent orchestration and state management.

System Design Pattern: A Visual Workflow

Let’s visualize the flow of our research assistant. This diagram illustrates how our agents will interact and how information will move through the system.

flowchart TD Start[User Query] --> Planner[Planner Agent: Decide Action] Planner -->|Needs Research| SearchTool[Search Tool: Web Search] SearchTool --> ResearchAgent[Research Agent: Execute Search] ResearchAgent -->|Results Found| Planner Planner -->|Synthesize & Summarize| SummarizerAgent[Summarizer Agent: Synthesize & Summarize] SummarizerAgent --> End[Final Answer] Planner -->|No More Research/Done| End

This flowchart shows a cyclical process: the Planner decides, potentially triggering a search, which then feeds back to the Planner for further decision-making, until a final summary can be generated.

Step-by-Step Implementation: Building Our Research Assistant

Let’s get our hands dirty! We’ll build this project incrementally, explaining each piece of code as we go.

1. Project Setup and Dependencies

First, create a new directory for your project and set up a virtual environment. This keeps your project’s dependencies isolated and clean.

mkdir smart_research_agent
cd smart_research_agent
python3 -m venv venv
source venv/bin/activate  # On Windows, use `venv\Scripts\activate`

Next, install the necessary libraries. As of January 2026, these are the recommended stable versions:

pip install langgraph==0.0.45 \
            langchain-openai==0.1.1 \
            duckduckgo-search==5.1.0 \
            python-dotenv==1.0.1
  • langgraph: The framework for building our agentic workflow.
  • langchain-openai: The integration for OpenAI’s LLMs (or other compatible LLMs via LangChain).
  • duckduckgo-search: A simple, free, and unauthenticated web search tool for our agent. You could substitute this with tavily-python or serper-api if you have API keys for them.
  • python-dotenv: For securely loading API keys from a .env file.

2. Environment Variables

We’ll need an OpenAI API key for our LLMs. Create a file named .env in your project’s root directory:

OPENAI_API_KEY="your_openai_api_key_here"

Important: Replace "your_openai_api_key_here" with your actual OpenAI API key. Remember to keep this file out of version control (e.g., add it to .gitignore).

3. Initialize Our Project File

Create a Python file, say research_agent.py, where we’ll write all our code.

# research_agent.py

import os
from dotenv import load_dotenv
from typing import List, Annotated, TypedDict

from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, END

# Load environment variables from .env file
load_dotenv()

# Set up the LLM
# We're using GPT-4o for its advanced reasoning and tool-use capabilities.
# Adjust model_name if you have access to a different model or prefer an older one.
llm = ChatOpenAI(model_name="gpt-4o", temperature=0)

print("Setup complete: LLM initialized and environment variables loaded.")

Explanation:

  • os and dotenv: Used to load our OPENAI_API_KEY securely.
  • typing: Essential for defining the AgentState with type hints, which improves code readability and maintainability.
  • langchain_core.tools.tool: A decorator to easily define custom tools our agents can use.
  • langchain_openai.ChatOpenAI: Our interface to the Large Language Model. We’re setting temperature=0 for more deterministic and factual responses, which is ideal for research.
  • langgraph.graph.StateGraph, END: The core components from LangGraph for building our agent workflow.

4. Define Our Tools

Our research assistant needs to search the web. We’ll create a simple web search tool using duckduckgo-search.

# research_agent.py (add to existing file)

from duckduckgo_search import DDGS

@tool
def web_search(query: str) -> str:
    """
    Performs a web search using DuckDuckGo and returns the snippets.
    Useful for answering questions about current events or facts.
    """
    with DDGS() as ddgs:
        results = ddgs.text(keywords=query, region='wt-wt', max_results=5)
        if not results:
            return "No relevant search results found."
        
        # Concatenate snippets for the LLM
        snippets = [f"Title: {r['title']}\nLink: {r['href']}\nSnippet: {r['body']}" for r in results]
        return "\n\n".join(snippets)

print("Tool 'web_search' defined.")

Explanation:

  • The @tool decorator turns our web_search function into a tool that our LangChain-compatible LLM can detect and use.
  • The docstring is crucial! It tells the LLM what the tool does and when it’s useful. This is how the LLM decides to call the tool.
  • We use DDGS().text to get text-based search results. We limit max_results to 5 to avoid overwhelming the LLM with too much information.
  • The results are formatted into a single string for easy consumption by the LLM.

5. Define the Agent State

LangGraph operates on a shared state object that is passed between nodes (agents). We need to define what information our agents will share.

# research_agent.py (add to existing file)

class AgentState(TypedDict):
    """
    Represents the state of our agentic workflow.
    This is passed between different nodes in the graph.
    """
    query: str  # The initial research query
    research_results: Annotated[List[str], lambda x, y: x + y] # Accumulates search results
    final_answer: str # The synthesized final answer
    iterations: int # Track number of iterations for loop control

print("AgentState defined.")

Explanation:

  • TypedDict: Allows us to define a dictionary with type hints, ensuring consistency.
  • query: The user’s original research question.
  • research_results: This is where all the snippets from our web searches will be collected. Annotated[List[str], lambda x, y: x + y] is a special LangGraph syntax. It means research_results is a list of strings, and when new results are added, they should be appended to the existing list (effectively concatenating lists). This is how our agent builds up its knowledge.
  • final_answer: Where the synthesized answer will be stored.
  • iterations: A simple counter to prevent infinite loops, a common pitfall in agentic systems.

6. Define the Agent Nodes (Functions)

Now, let’s create the Python functions that represent our individual agent roles. Each function will receive the current AgentState, perform its task, and return an updated state.

The Research Agent Node

This agent uses the web_search tool to gather information.

# research_agent.py (add to existing file)

def research_agent_node(state: AgentState) -> AgentState:
    """
    This node represents the Research Agent.
    It uses the web_search tool to find information related to the query.
    """
    print(f"\n--- Research Agent: Searching for '{state['query']}' ---")
    
    # Bind tools to the LLM for function calling
    research_llm = llm.bind_tools([web_search])
    
    # Prompt the LLM to use the web_search tool
    response = research_llm.invoke(f"Search for information about: {state['query']}. "
                                   "Use the 'web_search' tool to find relevant data. "
                                   "Ensure to extract specific facts or key points from the search results.")
    
    tool_calls = response.tool_calls
    
    if not tool_calls:
        print("--- Research Agent: No tool calls made. Returning empty results. ---")
        return {"research_results": ["No new search results."]}
    
    # Execute tool calls
    new_results = []
    for tool_call in tool_calls:
        if tool_call.get("name") == "web_search":
            search_query = tool_call["args"].get("query")
            if search_query:
                print(f"--- Research Agent: Executing web_search for: {search_query} ---")
                tool_output = web_search.invoke(search_query)
                new_results.append(tool_output)
            else:
                new_results.append("Error: web_search tool called without a query.")
        else:
            new_results.append(f"Unknown tool call: {tool_call.get('name')}")

    updated_state = {"research_results": new_results, "iterations": state['iterations'] + 1}
    print(f"--- Research Agent: Found {len(new_results)} new search result segments. Current iterations: {updated_state['iterations']} ---")
    return updated_state

Explanation:

  • research_llm = llm.bind_tools([web_search]): This is critical! We “bind” our web_search tool to a new instance of our LLM. This tells the LLM that it has this tool available for use and provides the necessary metadata for function calling.
  • The invoke call includes a prompt that guides the LLM to use the web_search tool.
  • response.tool_calls: LangChain’s way of extracting the tool calls suggested by the LLM.
  • We iterate through tool_calls and manually invoke the web_search function. In more complex setups, LangGraph can automate this, but explicit invocation here helps understanding.
  • We update research_results with the new findings and increment the iterations counter.

The Summarizer Agent Node

This agent takes the accumulated research results and synthesizes a final answer.

# research_agent.py (add to existing file)

def summarizer_agent_node(state: AgentState) -> AgentState:
    """
    This node represents the Summarizer Agent.
    It takes all accumulated research results and synthesizes a final answer.
    """
    print("\n--- Summarizer Agent: Synthesizing final answer ---")
    
    # Combine all research results into a single string
    all_results = "\n\n".join(state['research_results'])
    
    # Craft a prompt for summarization
    summarize_prompt = f"""
    You are an expert research assistant.
    Based on the following research results, provide a comprehensive and concise answer to the user's query.
    Your answer should be well-structured, factual, and directly address the original query.
    If the results are insufficient, state that clearly.

    Original Query: {state['query']}

    Research Results:
    {all_results}

    Final Answer:
    """
    
    response = llm.invoke(summarize_prompt)
    updated_state = {"final_answer": response.content, "iterations": state['iterations'] + 1}
    print("--- Summarizer Agent: Final answer generated ---")
    return updated_state

Explanation:

  • This agent simply uses the LLM to process the all_results string and generate a final_answer.
  • The prompt is carefully designed to guide the LLM towards a comprehensive and factual summary.

The Planner/Decider Agent Node

This is the control center. It decides whether more research is needed, if it’s time to summarize, or if the task is complete. Crucially, it returns a string representing the next node to execute.

# research_agent.py (add to existing file)

def planner_agent_node(state: AgentState) -> str:
    """
    This node represents the Planner Agent.
    It decides the next action based on the current state.
    Returns a string indicating the next node to transition to.
    """
    print("\n--- Planner Agent: Deciding next action ---")
    
    # Check for loop termination (max iterations)
    MAX_ITERATIONS = 5
    if state['iterations'] >= MAX_ITERATIONS:
        print(f"--- Planner Agent: Max iterations ({MAX_ITERATIONS}) reached. Forcing summarization. ---")
        return "summarize"

    # Analyze current state and decide
    # If no research results yet, or if they are insufficient, we need to research.
    if not state.get('research_results') or "No relevant search results found." in state['research_results']:
        print("--- Planner Agent: Initial research needed. ---")
        return "research"
    
    # If we have some results, ask the LLM if more research is needed or if we can summarize.
    decision_prompt = f"""
    You are a Planner Agent. Your goal is to determine the next step in a research process.
    Given the original query and the current research results, decide if more research is needed
    or if enough information has been gathered to provide a final answer.

    Return "research" if more web search is required to answer the query comprehensively.
    Return "summarize" if enough information is available to provide a final answer.

    Original Query: {state['query']}

    Current Research Results (partial view, potentially truncated):
    {state['research_results'][-1000:]} # Show last part of results to save tokens

    Based on the above, should we "research" more or "summarize"?
    """
    
    response = llm.invoke(decision_prompt)
    decision = response.content.strip().lower()

    if "research" in decision:
        print("--- Planner Agent: Decided to research more. ---")
        return "research"
    else:
        print("--- Planner Agent: Decided to summarize. ---")
        return "summarize"

Explanation:

  • MAX_ITERATIONS: A crucial safeguard against infinite loops, a common problem in agentic systems. If the agent gets stuck, we force it to summarize.
  • The planner_agent_node returns a string (“research” or “summarize”) which LangGraph uses to determine the next transition.
  • The prompt for the planner is very specific, asking it to return one of two keywords, making it easier for us to parse its decision.
  • We only show a partial view of research_results to the LLM to save on token usage, which is a practical consideration for cost and latency in real-world applications.

7. Build the LangGraph Workflow

Now we connect our nodes into a directed graph using StateGraph.

# research_agent.py (add to existing file)

# 1. Create a StateGraph
workflow = StateGraph(AgentState)

# 2. Add the nodes
workflow.add_node("research", research_agent_node)
workflow.add_node("summarize", summarizer_agent_node)

# 3. Set the entry point
workflow.set_entry_point("planner") # The planner agent is the first node to run

# 4. Add the conditional edge from the planner
# This is where the planner's decision determines the next step.
workflow.add_conditional_edges(
    "planner", # From the 'planner' node
    planner_agent_node, # Use the planner's output to decide
    {
        "research": "research", # If planner returns "research", go to "research" node
        "summarize": "summarize", # If planner returns "summarize", go to "summarize" node
    },
)

# 5. Add edges from other nodes
# After research, always go back to the planner to decide the next step.
workflow.add_edge("research", "planner")

# After summarization, the process ends.
workflow.add_edge("summarize", END)

# 6. Compile the graph
app = workflow.compile()

print("LangGraph workflow compiled.")

Explanation:

  • workflow = StateGraph(AgentState): Initializes our graph with the AgentState we defined earlier.
  • workflow.add_node("name", function): Registers our agent functions as nodes in the graph.
  • workflow.set_entry_point("planner"): Specifies that the workflow always starts with the planner node.
  • workflow.add_conditional_edges(...): This is the magic of LangGraph! It takes the output of the planner_agent_node (which is “research” or “summarize”) and uses it to decide which node to transition to next.
  • workflow.add_edge("from_node", "to_node"): Defines unconditional transitions. After research, we always want the planner to re-evaluate. After summarization, we are done (END).
  • app = workflow.compile(): Finalizes the graph, making it ready to run.

8. Run the Research Assistant!

Finally, let’s put our Smart Research Assistant to the test!

# research_agent.py (add to existing file)

if __name__ == "__main__":
    print("\n--- Running Smart Research Assistant ---")
    
    initial_state = {
        "query": "What are the latest advancements in quantum computing as of early 2026?",
        "research_results": [],
        "final_answer": "",
        "iterations": 0
    }

    # Stream the output for better visibility of agent steps
    for s in app.stream(initial_state):
        if "__end__" not in s:
            print(s) # Print intermediate states
            print("---" * 20)
        else:
            final_state = s["__end__"]
            print(f"\nFinal Answer: {final_state['final_answer']}")
            print(f"Total Iterations: {final_state['iterations']}")

Explanation:

  • We define an initial_state with our research query.
  • app.stream(initial_state): Runs the compiled graph. stream is useful because it yields the state after each node execution, allowing us to see the agent’s progress step-by-step.
  • When the graph reaches END, the __end__ key will contain the final state, from which we extract our final_answer.

Congratulations! You’ve just built a functional multi-agent research assistant. Run the research_agent.py file and observe how the agents collaborate.

python research_agent.py

You’ll see output indicating the Planner deciding, the Research Agent performing searches, and eventually the Summarizer delivering the final answer.

Mini-Challenge: Adding a Refinement Loop

Our current research assistant is good, but what if the initial search results aren’t quite enough, or the Planner thinks the query needs to be broken down further? Let’s enhance it!

Challenge: Modify the planner_agent_node and the graph to include a “refine” step. If the initial search results are too broad or insufficient, the Planner should be able to generate a refined sub-query or a set of follow-up questions for the research_agent to tackle.

Hint:

  1. Add a new key to AgentState, perhaps current_sub_query: str, which the Planner can update. The research_agent_node would then use this current_sub_query instead of the original query.
  2. Modify the planner_agent_node to have a third possible return value, "refine_query".
  3. Add a new conditional edge from planner for "refine_query" that loops back to the research node, but this time, the research_agent_node should use the refined query.
  4. Ensure your planner_agent_node prompt instructs the LLM on how to generate a refined query if it decides on the “refine_query” path.

What to Observe/Learn: This challenge will deepen your understanding of dynamic decision-making within agent graphs and how agents can iteratively improve their approach to a complex problem. You’ll see how a single agent (the Planner) can adapt its strategy based on the ongoing state of the research.

Common Pitfalls & Troubleshooting

Building agentic systems can be tricky. Here are some common issues you might encounter and how to troubleshoot them:

  1. API Key Errors:

    • Symptom: AuthenticationError, RateLimitError, or Missing API Key messages.
    • Fix: Double-check your .env file for correct OPENAI_API_KEY spelling and value. Ensure you’ve run load_dotenv() and that the environment variable is correctly picked up. If it’s a RateLimitError, you might be hitting the API too frequently; try a smaller max_results for duckduckgo_search or consider a paid API with higher limits.
  2. LLM Not Using Tools (Function Calling Issues):

    • Symptom: The research_agent_node prints “No tool calls made,” even when research is clearly needed.
    • Fix:
      • Prompt Engineering: Review the prompt to research_llm.invoke(). Is it clear that the LLM should use web_search? Explicitly tell it to “Use the ‘web_search’ tool.”
      • Tool Docstring: Ensure the web_search function’s docstring is descriptive and accurate, as the LLM uses this to understand the tool’s purpose.
      • bind_tools: Confirm llm.bind_tools([web_search]) is correctly applied to the LLM instance used for tool calling.
  3. Agent Getting Stuck in a Loop:

    • Symptom: The output continuously cycles between “Planner Agent: Decided to research more.” and “Research Agent: Searching…” without ever summarizing.
    • Fix:
      • MAX_ITERATIONS: This is your primary safeguard. Adjust MAX_ITERATIONS in planner_agent_node to a reasonable limit.
      • Planner’s Decision Logic: Refine the prompt to planner_agent_node. Ensure it has clear criteria for deciding when to summarize. For example, emphasize “enough information” or “comprehensiveness.”
      • Information Overload: If research_results becomes too large, the LLM might struggle to process it effectively for decision-making. Consider techniques like summarizing intermediate results or using RAG to retrieve only the most relevant portions for the planner.
  4. Inaccurate or Incomplete Summaries:

    • Symptom: The final answer doesn’t fully address the query or contains irrelevant information.
    • Fix:
      • Summarizer Prompt: Enhance the summarize_prompt in summarizer_agent_node. Be more explicit about the desired structure, tone, and what to prioritize. Ask it to cross-reference facts.
      • Quality of Research Results: The summary is only as good as the input. If the web_search tool isn’t returning good results, investigate the search queries being generated by the research_agent.

Summary

Phew! You’ve just completed a significant project, building a Smart Research Assistant Agent from the ground up. Let’s recap what we’ve accomplished:

  • Multi-Agent System Design: You learned to break down a complex task into distinct agent roles (Planner, Researcher, Summarizer) and understood their collaborative workflow.
  • Tool Integration: You successfully integrated an external web search tool, enabling your agents to interact with real-world information sources.
  • State Management with LangGraph: You gained hands-on experience with StateGraph to define a shared state (AgentState) and manage information flow between agents.
  • Agent Orchestration: You built a graph-based workflow, using conditional edges to allow the Planner agent to dynamically control the execution path based on the current state.
  • Practical Application of LLMs: You saw how LLMs can be leveraged not just for text generation, but for decision-making (Planner), tool invocation (Research Agent), and information synthesis (Summarizer).
  • Robustness Considerations: You implemented safeguards like MAX_ITERATIONS to prevent common agentic pitfalls.

This project is a tangible demonstration of applied AI engineering principles. You’ve moved beyond theoretical understanding to building a real, intelligent application. This is the essence of being an Applied AI Engineer!

In the next chapters, we will delve deeper into evaluating these systems, optimizing their performance, and preparing them for robust production deployment. Keep experimenting with your research assistant, try different queries, and think about how you could expand its capabilities!


References

  1. LangChain Documentation: https://www.langchain.com/docs/
  2. LangGraph Documentation: https://langchain-ai.github.io/langgraph/
  3. OpenAI API Documentation: https://platform.openai.com/docs/
  4. DuckDuckGo Search Library: https://github.com/deedy5/duckduckgo_search
  5. Python-dotenv Documentation: https://pypi.org/project/python-dotenv/

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.