All articles
Build Guides

Build a Multi-Agent Research Assistant with Groq, Serper, and Llama 3.3

FDE Coach EditorialJuly 20, 202610 min read

What We're Building

We're assembling a multi-agent research assistant that takes a loose research question and returns a structured, cited brief. Three specialized agents collaborate: one plans targeted search queries, another fetches web results via Serper's free tier, and a third synthesizes findings into a coherent document with inline citations. All inference runs on Groq's LPU inference engine using Llama 3.3—free tier, no credit card gymnastics.

Feature list:

  • Natural language research question input
  • Automated query decomposition into 3-5 targeted search strings
  • Live web retrieval via Serper.dev (2,500 free queries/month)
  • Source-grounded synthesis with inline citations
  • Full agent traceability via LangGraph state
  • Runs entirely on free-tier infrastructure

Architecture & Agent Flow

Three agents operate on a shared state graph. The orchestrator (LangGraph) routes messages between them until a termination condition is met. Groq hosts the LLM, Serper provides the search endpoint, and LangChain supplies the tool interfaces.

The Query Planner never touches the web—it's pure reasoning over the research question. The Web Researcher is a tool-calling agent that loops over planned queries and collects results. The Synthesis Agent consumes raw results and produces the final brief. This separation keeps each agent focused and debuggable.

Prerequisites (Free Tier)

ServiceFree Tier LimitSign-Up Link
Groq~14,400 requests/day, 30 req/min on Llama 3.3 70Bconsole.groq.com
Serper.dev2,500 queries/monthserper.dev
Python 3.10+N/Apython.org

Grab API keys from both dashboards. Export them:

export GROQ_API_KEY="gsk_..."
export SERPER_API_KEY="..."

Install the core dependencies:

pip install langgraph langchain langchain-groq langchain-community requests

No vector database, no Pinecone, no paid embedding models. Serper returns structured JSON—we parse it directly.

Step 1: Scaffold the Python Project

Single file for clarity. Create research_agent.py:

import os
import json
import requests
from typing import TypedDict, List, Annotated
from langgraph.graph import StateGraph, END
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_groq import ChatGroq

GROQ_API_KEY = os.environ["GROQ_API_KEY"]
SERPER_API_KEY = os.environ["SERPER_API_KEY"]

llm = ChatGroq(
    model="llama-3.3-70b-versatile",
    temperature=0.3,
    api_key=GROQ_API_KEY
)

We pin temperature at 0.3—low enough for factual consistency, high enough to avoid brittle outputs. The llama-3.3-70b-versatile model hits the sweet spot between speed and reasoning depth on Groq's free tier.

Step 2: Define the Agent Graph State

LangGraph requires a typed state dictionary. This is the shared memory every agent reads and writes:

class ResearchState(TypedDict):
    question: str
    search_queries: List[str]
    search_results: Annotated[List[dict], lambda x, y: x + y]  # reducer: append
    raw_snippets: str
    final_brief: str
    iteration: int

The Annotated type with a reducer function means agents append to search_results rather than overwriting—critical when the Web Researcher loops over multiple queries.

Step 3: Build the Query Planner Agent

The planner receives the research question and outputs a JSON list of search queries. We use structured prompting with an explicit JSON schema:

def query_planner(state: ResearchState) -> ResearchState:
    system_prompt = """You are a senior research strategist. Given a research question, 
    generate 3-5 targeted Google search queries that will surface diverse, high-quality sources.
    
    Rules:
    - Vary query angles (definitions, recent developments, opposing views, statistics)
    - Keep queries under 12 words
    - Output ONLY a valid JSON list of strings, no commentary
    
    Example output: ["transformer architecture explained 2024", "attention mechanism limitations survey"]
    """
    
    messages = [
        SystemMessage(content=system_prompt),
        HumanMessage(content=f"Research question: {state['question']}")
    ]
    
    response = llm.invoke(messages)
    
    # Parse the JSON list from the LLM response
    try:
        queries = json.loads(response.content)
        state["search_queries"] = queries if isinstance(queries, list) else []
    except json.JSONDecodeError:
        # Fallback: extract bracketed content
        import re
        match = re.search(r'\[.*?\]', response.content, re.DOTALL)
        state["search_queries"] = json.loads(match.group(0)) if match else [state["question"]]
    
    state["iteration"] = 0
    return state

JSON parsing from LLM outputs is inherently flaky. The try/except with regex fallback keeps the pipeline resilient. If everything fails, we fall back to the raw question as a single query.

Step 4: Build the Web Researcher Agent

This agent iterates over search_queries, calls Serper for each, and accumulates results. Serper's free tier returns organic results, knowledge graph entries, and related questions—we extract what matters:

def serper_search(query: str) -> List[dict]:
    """Call Serper.dev search API. Returns list of result dicts."""
    url = "https://google.serper.dev/search"
    headers = {"X-API-KEY": SERPER_API_KEY, "Content-Type": "application/json"}
    payload = {"q": query, "num": 5}  # 5 results per query to stay within rate limits
    
    response = requests.post(url, headers=headers, json=payload, timeout=10)
    response.raise_for_status()
    data = response.json()
    
    results = []
    for item in data.get("organic", []):
        results.append({
            "title": item.get("title", ""),
            "link": item.get("link", ""),
            "snippet": item.get("snippet", ""),
            "query": query
        })
    return results


def web_researcher(state: ResearchState) -> ResearchState:
    current_idx = state.get("iteration", 0)
    queries = state.get("search_queries", [])
    
    if current_idx >= len(queries):
        # All queries processed; compile raw snippets for synthesis
        snippets = []
        for i, result in enumerate(state.get("search_results", [])):
            snippets.append(f"[Source {i+1}] {result['title']}\n{result['snippet']}\nURL: {result['link']}")
        state["raw_snippets"] = "\n\n".join(snippets)
        return state
    
    query = queries[current_idx]
    new_results = serper_search(query)
    state["search_results"] = new_results  # reducer appends
    state["iteration"] = current_idx + 1
    return state

The iteration counter gates execution: LangGraph will call this node repeatedly until iteration >= len(queries). Each call fires one Serper request—staying well within the 2,500/month free quota for typical research sessions.

Step 5: Build the Synthesis Agent

The synthesis agent consumes raw_snippets and produces a structured brief. We prompt for markdown output with inline citations referencing source numbers:

def synthesis_agent(state: ResearchState) -> ResearchState:
    system_prompt = """You are a research analyst synthesizing web search results into a concise brief.
    
    Output format (markdown):
    ## Executive Summary
    2-3 sentences capturing the core findings.
    
    ## Key Findings
    - Bullet points with inline citations like [1], [2]
    - Each point should reference specific sources
    
    ## Contradictions or Gaps
    - Note any conflicting information or missing perspectives
    
    ## Sources
    Numbered list of all sources with titles and URLs
    
    Rules:
    - Only use information present in the provided snippets
    - Do not hallucinate facts or sources
    - Cite generously—every claim needs a [source number]
    - If snippets are insufficient, state that clearly
    """
    
    messages = [
        SystemMessage(content=system_prompt),
        HumanMessage(content=f"Research question: {state['question']}\n\nSource material:\n{state['raw_snippets']}")
    ]
    
    response = llm.invoke(messages)
    state["final_brief"] = response.content
    return state

Grounding is the hard problem here. The prompt explicitly constrains the model to source material—but Llama 3.3 isn't immune to hallucination. We mitigate by keeping snippets verbatim in the prompt rather than summarizing them first.

Step 6: Wire the LangGraph Orchestrator

We define a conditional edge: after each web_researcher call, either loop back for more queries or proceed to synthesis:

def should_continue_research(state: ResearchState) -> str:
    if state.get("iteration", 0) < len(state.get("search_queries", [])):
        return "web_researcher"
    return "synthesis_agent"


def build_graph() -> StateGraph:
    workflow = StateGraph(ResearchState)
    
    workflow.add_node("query_planner", query_planner)
    workflow.add_node("web_researcher", web_researcher)
    workflow.add_node("synthesis_agent", synthesis_agent)
    
    workflow.set_entry_point("query_planner")
    workflow.add_edge("query_planner", "web_researcher")
    
    workflow.add_conditional_edges(
        "web_researcher",
        should_continue_research,
        {
            "web_researcher": "web_researcher",
            "synthesis_agent": "synthesis_agent"
        }
    )
    
    workflow.add_edge("synthesis_agent", END)
    
    return workflow.compile()

This is the entire orchestration logic. LangGraph handles state persistence, node execution order, and the conditional loop. No external queue, no Redis, no database.

Step 7: Run the Research Assistant

Wrap it with a CLI entry point:

if __name__ == "__main__":
    graph = build_graph()
    
    question = input("Research question: ")
    
    initial_state = {
        "question": question,
        "search_queries": [],
        "search_results": [],
        "raw_snippets": "",
        "final_brief": "",
        "iteration": 0
    }
    
    print("\nPlanning queries...")
    final_state = graph.invoke(initial_state)
    
    print("\n" + "="*60)
    print(final_state["final_brief"])
    print("="*60)
    
    # Save to file
    with open("research_brief.md", "w") as f:
        f.write(f"# Research Brief: {question}\n\n{final_state['final_brief']}")
    print("\nSaved to research_brief.md")

Run it:

python research_agent.py

Sample session:

Research question: How are enterprises using small language models in production?

Planning queries...
[web_researcher fires 4 times]

============================================================
## Executive Summary
Enterprises are increasingly deploying small language models (SLMs)...
============================================================
Saved to research_brief.md

End-to-end latency: roughly 8-15 seconds for 4 queries, depending on Groq queue depth. The free tier occasionally throttles during peak hours—adding a 2-second sleep between Serper calls helps.

Sensible Extensions

Add a fact-checking agent: Insert a fourth node between web_researcher and synthesis_agent that cross-references claims across sources. This is where you'd add a /blog/codebase-qa-tool-llamaindex-cloudflare style retrieval layer for internal document grounding.

Persist research sessions: Swap the in-memory state for SQLite. LangGraph supports checkpointers—add SqliteSaver for resumable research threads. This pattern mirrors the persistence layer in our /blog/build-personalized-newsletter-agent-rss-groq-supabase guide.

Multi-turn refinement: Add a human-in-the-loop node where the user reviews the brief and asks follow-up questions. The graph cycles back to the planner with refined context.

Source diversity scoring: Weight results by domain authority. Serper returns position data—deduplicate by domain and prioritize .edu, .gov, and established publishers.

Common Pitfalls

Serper rate limiting at 2,500/month: That's ~83 queries/day. If you're running this in a team, you'll hit the ceiling fast. Implement client-side caching of search results keyed by query string. A simple dict cache cuts repeat calls by 40%+ in typical usage.

Llama 3.3 JSON unreliability: The planner node parses JSON from free-text LLM output. Even with strong prompting, Llama sometimes wraps JSON in markdown fences or adds trailing commas. The regex fallback handles 90% of failures; for production, add a retry loop with response_format={"type": "json_object"} if Groq exposes it.

Context window overflow: Each Serper snippet is ~150 tokens. With 5 queries × 5 results, that's 3,750 tokens of source material plus the prompt. Llama 3.3's 128k context window handles this easily, but if you scale to 20+ queries, implement snippet truncation or a map-reduce synthesis pattern.

Hallucinated citations: The synthesis agent occasionally cites source numbers that don't exist. Add a post-processing validation step that regex-extracts all [N] references and checks them against the actual source list length.

FAQ

Why Groq instead of OpenAI's free tier? Groq's LPU architecture delivers 300+ tokens/second on Llama 3.3 70B—roughly 4x faster than GPT-3.5 Turbo on comparable hardware. For a multi-step agent pipeline, that speed difference compounds. Plus, no credit card required for the free tier.

Can I swap Serper for Brave Search or Bing? Yes. The serper_search function is the only integration point. Brave's free tier offers 2,000 queries/month with a similar JSON response shape. Swap the endpoint and auth header, and the rest of the pipeline stays identical.

How do I deploy this as an API? Wrap the graph invocation in a FastAPI endpoint. LangGraph graphs are serializable—you can deploy on a single $6/month DigitalOcean droplet. For serverless, consider the approach in our /blog/codebase-qa-tool-llamaindex-cloudflare guide using Cloudflare Workers for the orchestration layer.

Is this pattern production-ready? For internal tools and prototyping, absolutely. For customer-facing products, you'll want: (1) a proper observability layer (LangSmith or equivalent), (2) rate-limit-aware retry logic with exponential backoff, and (3) human review gates before publishing synthesized content. The architecture scales—the hardening doesn't.

How does this compare to Perplexity or ChatGPT with browsing? This gives you full control over the retrieval and synthesis pipeline. You choose the search engine, the chunking strategy, the citation format. Perplexity is a black box. When you need auditability—like /blog/vulnhunter-agentic-code-review-at-capital-one-scale requires for security research—owning the pipeline matters.

Where do I go from here to master agent architectures? Building and debugging multi-agent systems is a core Forward Deployed Engineer skill. The patterns here—state graphs, tool-calling agents, conditional routing—show up in every enterprise AI deployment. If you're preparing for FDE roles that demand this depth, check out our /blog/fde-interview-loop-prep-guide for what technical rounds actually test.

#multi-agent#research#langchain#llama-3

Want to build like a Forward Deployed Engineer?

FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.

Explore the program

More build guides

August 15 · 0d left
Enroll Now