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:
- Understand a user’s research query: Interpret the intent and scope of the request.
- Plan a research strategy: Break down the query into actionable steps, identifying what information is needed.
- Execute research tasks: Use external tools (like a web search engine) to gather relevant data.
- Process and synthesize information: Read through the retrieved content, extract key facts, and identify patterns.
- 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
stateof 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.
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 withtavily-pythonorserper-apiif you have API keys for them.python-dotenv: For securely loading API keys from a.envfile.
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:
osanddotenv: Used to load ourOPENAI_API_KEYsecurely.typing: Essential for defining theAgentStatewith 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 settingtemperature=0for 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
@tooldecorator turns ourweb_searchfunction 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().textto get text-based search results. We limitmax_resultsto 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 meansresearch_resultsis 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” ourweb_searchtool 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
invokecall includes a prompt that guides the LLM to use theweb_searchtool. response.tool_calls: LangChain’s way of extracting the tool calls suggested by the LLM.- We iterate through
tool_callsand manually invoke theweb_searchfunction. In more complex setups, LangGraph can automate this, but explicit invocation here helps understanding. - We update
research_resultswith the new findings and increment theiterationscounter.
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_resultsstring and generate afinal_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_nodereturns 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 viewofresearch_resultsto 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 theAgentStatewe 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 theplannernode.workflow.add_conditional_edges(...): This is the magic of LangGraph! It takes the output of theplanner_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_statewith our research query. app.stream(initial_state): Runs the compiled graph.streamis 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 ourfinal_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:
- Add a new key to
AgentState, perhapscurrent_sub_query: str, which the Planner can update. Theresearch_agent_nodewould then use thiscurrent_sub_queryinstead of the originalquery. - Modify the
planner_agent_nodeto have a third possible return value,"refine_query". - Add a new conditional edge from
plannerfor"refine_query"that loops back to theresearchnode, but this time, theresearch_agent_nodeshould use the refined query. - Ensure your
planner_agent_nodeprompt 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:
API Key Errors:
- Symptom:
AuthenticationError,RateLimitError, orMissing API Keymessages. - Fix: Double-check your
.envfile for correctOPENAI_API_KEYspelling and value. Ensure you’ve runload_dotenv()and that the environment variable is correctly picked up. If it’s aRateLimitError, you might be hitting the API too frequently; try a smallermax_resultsforduckduckgo_searchor consider a paid API with higher limits.
- Symptom:
LLM Not Using Tools (Function Calling Issues):
- Symptom: The
research_agent_nodeprints “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 useweb_search? Explicitly tell it to “Use the ‘web_search’ tool.” - Tool Docstring: Ensure the
web_searchfunction’s docstring is descriptive and accurate, as the LLM uses this to understand the tool’s purpose. bind_tools: Confirmllm.bind_tools([web_search])is correctly applied to the LLM instance used for tool calling.
- Prompt Engineering: Review the prompt to
- Symptom: The
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. AdjustMAX_ITERATIONSinplanner_agent_nodeto a reasonable limit.- Planner’s Decision Logic: Refine the prompt to
planner_agent_node. Ensure it has clear criteria for deciding when tosummarize. For example, emphasize “enough information” or “comprehensiveness.” - Information Overload: If
research_resultsbecomes 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.
Inaccurate or Incomplete Summaries:
- Symptom: The final answer doesn’t fully address the query or contains irrelevant information.
- Fix:
- Summarizer Prompt: Enhance the
summarize_promptinsummarizer_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_searchtool isn’t returning good results, investigate the search queries being generated by theresearch_agent.
- Summarizer Prompt: Enhance the
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
StateGraphto 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_ITERATIONSto 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
- LangChain Documentation: https://www.langchain.com/docs/
- LangGraph Documentation: https://langchain-ai.github.io/langgraph/
- OpenAI API Documentation: https://platform.openai.com/docs/
- DuckDuckGo Search Library: https://github.com/deedy5/duckduckgo_search
- 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.