Capstone: Production AI Agent System

10 min read Module 10 of 10 Topic 30 of 30

What you'll learn

  • Integrate LangGraph, Pydantic AI, RAG, multi-agent orchestration, and streaming into one cohesive system
  • Deploy the complete system as a FastAPI service with observability and security
  • Understand the architectural decisions that differentiate production from prototype
  • Know what to look for when diagnosing problems in a live agent system
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

The Capstone: ResearchPro Agent System

In this capstone, we build ResearchPro, a production multi-agent research and report system that applies every concept from the course:

flowchart LR
    subgraph API["FastAPI · Auth · Rate Limit · SSE"]
        EP[Endpoint]
    end
    subgraph LG["LangGraph Supervisor · Budget · Checkpoints"]
        SUP[Supervisor]
    end
    subgraph Workers["Parallel Workers via Send"]
        RES["Research Agent\nweb + RAG"]
        ANA["Analysis Agent\ncode execution"]
        WRI["Writer Agent\nstructured output"]
    end
    subgraph Infra["Infrastructure"]
        QD[("Qdrant\nmemory")]
        PG[("Postgres\nstate")]
        RD[("Redis\ncache")]
        LS["LangSmith\ntraces"]
    end
    EP --> SUP
    SUP --> RES & ANA & WRI
    RES & ANA & WRI --> SUP
    LG -.-> Infra
    API -.-> Infra
    style SUP fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style EP fill:#EEF0F7,stroke:#818CF8,color:#0F172A
    style RES fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style ANA fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style WRI fill:#f0fdf9,stroke:#0D9488,color:#0F172A

System State Design

The state schema is the single source of truth for everything that flows between nodes, all parallel workers read from and write to this shared structure.

# src/researchpro/state.py
from typing import Annotated, TypedDict
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages
import operator

class ResearchProState(TypedDict):
    # Input
    topic: str
    report_format: str  # "brief" | "detailed" | "executive"
    
    # Agent communication: add_messages reducer appends instead of replacing
    messages: Annotated[list[BaseMessage], add_messages]
    
    # Research phase
    research_queries: list[str]
    # operator.add reducer accumulates findings from all parallel workers into a single list
    research_findings: Annotated[list[dict], operator.add]
    
    # Analysis phase: stores the code-execution agent's inputs and outputs
    data_points: dict
    analysis_code: str | None
    analysis_output: str | None
    
    # Report phase
    report_sections: dict  # section_name → content, built incrementally by the writer node
    final_report: str | None
    
    # Control fields: used by routing functions to decide which node runs next
    current_phase: str  # "research" | "analysis" | "writing" | "complete"
    iteration_count: int
    budget_spent_usd: float  # tracked per-node so the supervisor can halt on cost overrun
    error: str | None

The Full Supervisor Graph

The graph topology below defines the exact execution order: planning, parallel research, aggregation, analysis, writing, and quality review, with conditional edges for revision loops.

# src/researchpro/graph.py
from langgraph.graph import StateGraph, END
from langgraph.types import Send
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver

async def build_researchpro_graph(checkpointer):
    builder = StateGraph(ResearchProState)
    
    # Phase 1: Query Planning: one LLM call that produces the list of research queries
    builder.add_node("plan_research", plan_research_queries)
    
    # Phase 2: Parallel Research (map-reduce): fan_out_research_queries dispatches one worker per query
    builder.add_node("research_worker", run_research_worker)
    builder.add_node("aggregate_research", aggregate_research_findings)  # waits for all workers to finish
    
    # Phase 3: Data Analysis: runs the E2B code-execution agent on the aggregated findings
    builder.add_node("analyze_data", run_data_analysis)
    
    # Phase 4: Report Writing: Pydantic AI structured output produces a validated FullReport object
    builder.add_node("write_report", write_structured_report)
    
    # Phase 5: Quality Check: optional revision loop before returning to the caller
    builder.add_node("quality_check", check_report_quality)
    
    # Routing: fan_out_research_queries returns a list of Send() objects, one per query
    builder.set_entry_point("plan_research")
    builder.add_conditional_edges("plan_research", fan_out_research_queries)
    builder.add_edge("research_worker", "aggregate_research")
    builder.add_edge("aggregate_research", "analyze_data")
    builder.add_edge("analyze_data", "write_report")
    builder.add_conditional_edges("write_report", route_quality_check, {
        "quality_check": "quality_check",
        END: END,  # skip quality check if the report already meets the threshold
    })
    builder.add_conditional_edges("quality_check", route_after_quality, {
        "write_report": "write_report",  # loop back for a revision pass
        END: END,                         # accepted, return to caller
    })
    
    # Postgres checkpointer persists state after every node: runs can be resumed after failures
    return builder.compile(checkpointer=checkpointer)

def fan_out_research_queries(state: ResearchProState) -> list[Send]:
    """Fan out to parallel research workers, one per query."""
    # Send() fans out to one research_worker node per query: they all run in parallel
    return [
        Send("research_worker", {"query": q, "topic": state["topic"]})
        for q in state["research_queries"]
    ]

Key Node Implementations

Each node below is a pure async function that receives state and returns a partial state update, LangGraph merges the returned dict into the shared state before calling the next node.

# src/researchpro/nodes.py

async def plan_research_queries(state: ResearchProState) -> dict:
    """Generate 3-5 targeted research queries for the topic."""
    from pydantic_ai import Agent
    
    class QueryPlan(BaseModel):
        queries: list[str] = Field(min_length=3, max_length=5)  # enforce 3–5 queries via Pydantic validation
        reasoning: str
    
    # result_type=QueryPlan forces Pydantic AI to validate the LLM output before returning it
    planner = Agent("openai:gpt-5.6-sol", result_type=QueryPlan)
    result = await planner.run(
        f"Generate research queries for: {state['topic']}\nFormat: {state['report_format']}"
    )
    
    return {
        "research_queries": result.data.queries,
        "current_phase": "research",
        "messages": [AIMessage(content=f"Planning: {result.data.reasoning}")],
    }

async def run_research_worker(state: dict) -> dict:
    """Individual research worker, searches web + knowledge base."""
    query = state["query"]
    
    # asyncio.gather runs web search and vector retrieval concurrently: halves latency vs sequential
    web_results, kb_results = await asyncio.gather(
        safe_search_and_process(query),   # uses dual LLM pattern from lesson 29 to sanitize content
        vector_store.search(query, top_k=5),
    )
    
    finding = {
        "query": query,
        "web_findings": web_results[:2000],                        # truncate to keep token cost bounded
        "kb_findings": [r["content"] for r in kb_results],
    }
    
    # Return a single-item list: the operator.add reducer in state will append this to research_findings
    return {"research_findings": [finding]}

async def write_structured_report(state: ResearchProState) -> dict:
    """Write the final report using Pydantic AI for structured output."""
    
    class ReportSection(BaseModel):
        title: str
        content: str = Field(min_length=200)            # reject sections that are too thin
        key_points: list[str] = Field(min_length=3, max_length=7)
    
    class FullReport(BaseModel):
        title: str
        executive_summary: str = Field(min_length=100, max_length=500)
        sections: list[ReportSection] = Field(min_length=3)  # at least 3 sections required
        conclusion: str
        data_sources: list[str]
    
    # Pydantic AI validates the model output against FullReport before this await resolves
    writer = Agent("anthropic:claude-sonnet-5", result_type=FullReport)
    
    # Cap at 5 findings × 1000 chars each to stay within a reasonable context budget
    context = "\n\n---\n\n".join([
        f"Research: {f['web_findings'][:1000]}"
        for f in state["research_findings"][:5]
    ])
    
    result = await writer.run(
        f"Write a {state['report_format']} report on '{state['topic']}'\n\nContext:\n{context}"
    )
    
    # Convert the validated Pydantic object to a markdown string for storage and delivery
    report = result.data
    markdown = f"# {report.title}\n\n## Executive Summary\n{report.executive_summary}\n\n"
    for section in report.sections:
        markdown += f"## {section.title}\n{section.content}\n\n"
    markdown += f"## Conclusion\n{report.conclusion}"
    
    return {"final_report": markdown, "current_phase": "complete"}

FastAPI Service with Full Observability

The API endpoint returns immediately with a run_id while the graph executes in the background, callers poll /research/{run_id} for the result rather than holding a long HTTP connection open.

# src/researchpro/api.py
from fastapi import FastAPI, Depends, BackgroundTasks
from langsmith import trace

app = FastAPI(title="ResearchPro API", version="1.0.0")

@app.post("/research/start")
async def start_research(
    request: ResearchRequest,
    background_tasks: BackgroundTasks,
    api_meta: dict = Depends(check_rate_limit),  # rate limiter runs before the handler executes
):
    run_id = str(uuid.uuid4())
    
    async def execute():
        # LangSmith trace context wraps the entire run: every node call appears as a child span
        with trace(
            name="ResearchPro Run",
            project_name="researchpro-production",
            tags=[f"format:{request.format}", f"user:{api_meta['user_id']}"],
            metadata={"topic": request.topic[:100], "user_id": api_meta["user_id"]},
        ):
            checkpointer = await create_postgres_checkpointer()
            graph = await build_researchpro_graph(checkpointer)
            
            config = {
                "configurable": {"thread_id": run_id},  # thread_id links this run to its checkpointed state
                "recursion_limit": 50,                  # LangGraph raises an error if the graph cycles more than 50 times
            }
            
            # All state fields must be explicitly initialized: there are no defaults in TypedDict
            initial_state: ResearchProState = {
                "topic": request.topic,
                "report_format": request.format,
                "messages": [],
                "research_queries": [],
                "research_findings": [],
                "data_points": {},
                "analysis_code": None,
                "analysis_output": None,
                "report_sections": {},
                "final_report": None,
                "current_phase": "planning",
                "iteration_count": 0,
                "budget_spent_usd": 0.0,
                "error": None,
            }
            
            result = await graph.ainvoke(initial_state, config=config)
            
            # Write the completed result to the in-memory task store (replace with Redis in production)
            task_store[run_id] = {
                "status": "completed",
                "report": result.get("final_report"),
                "completed_at": datetime.utcnow().isoformat(),
            }
    
    # Mark the run as started before adding it to the background: callers may poll immediately
    task_store[run_id] = {"status": "running", "created_at": datetime.utcnow().isoformat()}
    background_tasks.add_task(execute)
    
    # Return immediately: the 2–5 minute graph execution happens in the background
    return {"run_id": run_id, "status": "running", "poll_url": f"/research/{run_id}"}

The Production Readiness Checklist

Before going live with any agent system, verify:

Reliability

  • Iteration limits (max 20 steps per phase)
  • Cost limits ($2 per run maximum)
  • Timeout (5 minutes wall clock)
  • LLM fallback: primary → secondary → cached response
  • All tool failures return structured errors (no uncaught exceptions)

Observability

  • Every run creates a LangSmith trace with user ID and run ID
  • Cost per run is tracked and logged
  • Error rate dashboard is set up
  • Latency percentiles (p50, p95, p99) are monitored

Security

  • Input classification before routing to agent
  • External content sandboxed with dual LLM
  • Tool access controlled by user tier
  • Output scanned for PII leakage
  • API rate limiting per key

Quality

  • Golden dataset of ≥50 test cases
  • Evaluator suite runs on every deployment
  • Regression threshold: quality must not drop >5%

Operations

  • Dockerfile with multi-stage build
  • Kubernetes deployment with HPA
  • CI/CD pipeline with automated tests
  • Rollback procedure documented and tested
  • On-call runbook for common failure modes

What Comes Next

You’ve now built every component of a production AI agent system:

  • Tool calling and schema design
  • LangGraph state machines with checkpointing
  • Vector memory and agentic RAG
  • Multi-agent orchestration and handoffs
  • Parallel execution and streaming
  • Observability, cost control, and evaluation
  • Docker, Kubernetes, and CI/CD
  • MCP, code execution, and browser agents
  • Security, guardrails, and injection defense

The next step is building for your specific domain. The patterns are general; the implementations are yours to adapt. Ship something. Observe it in production. Iterate on what breaks.

That’s what production AI engineering looks like.

Project: Take a repetitive, multi-step task from your own work. Map it to one of the architectural patterns from Module 1. Build the agent using the stack from this course. Instrument it with LangSmith from day one. Deploy it to Cloud Run or a Kubernetes cluster. Leave it running for a week. Review the traces. What surprised you? What broke? What do you fix first?

The answer to those questions is your next project.

Knowledge Check

3 questions to test your understanding

1 A production agent suddenly starts producing incorrect answers on a task type it handled correctly before. What should be your first diagnostic step?

2 Your agent's average cost per run increased from $0.05 to $0.20 after a recent update. What are the most likely causes to investigate?

3 What is the most important difference between a prototype AI agent and a production AI agent?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan