Module 12: LangSmith and Production

Evolving Project: Complete System with Observability (v7 — Final)

Project overview

This is the end.

12 modules. From model.invoke("hello") to a multi-agent research system with planning, persistent memory, human supervision, and complete observability. What started as a single model call in Module 1 became a Research Assistant that decomposes questions, searches in parallel, analyzes with specialized agents, accepts human feedback, plans autonomously, and now — in this final version — has total visibility into every step, quality measured with automated evaluations, costs tracked to the cent, and usage limits configured.

The v7 doesn't add new functionality to the agent. It adds the layer that lets you trust it. Complete tracing in LangSmith so you see exactly what your agent does on every run. Automated evaluation so you know whether the answers are good — not by intuition, but by metrics. Token tracking so you know what each research run costs. Rate limiting so no single user drains your budget. A production checklist so you know you covered every angle.

In Module 6 you built the v1: an agent that researches a topic and generates a report. In Module 12 you have the v7: a production-ready system that researches topics with specialized agents, has complete observability, and meets the standards for a real deployment. The distance between those two versions is the distance between a prototype and a product.


Project goal

Add the production layer to the AI Research Assistant: tracing with LangSmith, automated evaluation, token tracking with cost breakdown, per-user rate limiting, and a completed production checklist.

By completing this project:

  • 🔧 You'll enable LangSmith tracing and verify the traces show up in the dashboard
  • 🔧 You'll create an evaluation dataset with research questions and automated evaluators
  • 🔧 You'll implement token tracking with a cost breakdown by operation and by agent
  • 🔧 You'll configure per-user rate limiting with budgets that differ by tier
  • 🔧 You'll complete the production checklist and verify every item programmatically

Before and after

v6 (Module 11): functional but opaque

User: "Research AI agent frameworks"
System: [runs... you have no idea what happens inside]
System: "Here's your report."

Questions with no answer:
- "What steps did it actually run?"
- "What did this research cost?"
- "Are the answers good, or do they just look good?"
- "Can a user abuse the system?"
- "Is it ready for production?"

v7 (This module — Final): production-ready with complete observability

User: "Research AI agent frameworks"
System: [runs with complete tracing in LangSmith]

LangSmith Dashboard:
  ├── Trace: research_agent (3.2s, $0.08)
  │   ├── decompose_query (0.4s, $0.003)
  │   ├── search_all_sources (1.1s, $0.012)
  │   │   ├── search_web (0.3s)
  │   │   ├── search_academic (0.3s)
  │   │   └── search_news (0.3s)
  │   ├── synthesize_findings (0.8s, $0.04)
  │   ├── generate_summary (0.5s, $0.02)
  │   └── calculate_confidence (0.01s, $0.005)

Cost Breakdown:
  "This research cost $0.08"
  "  search: $0.012 (15%), analysis: $0.04 (50%), writing: $0.02 (25%), other: $0.008 (10%)"

Evaluation Results (automated):
  ✅ Relevance: 0.9/1.0
  ✅ Completeness: 0.85/1.0
  ✅ Accuracy: 0.8/1.0
  ✅ Formatting: 1.0/1.0

Rate Limiting:
  "User alice (free tier): 3/10 daily requests used"
  "Budget: $0.08/$0.50 daily (16%)"

Production Checklist: 24/24 ✅

Technical specs

ComponentVersionPurpose
Python3.11+Runtime
LangChainv1.2+LLM framework
LangGraphv1.0+Functional API + StateGraph
langchain-openailatestModel provider
langsmithlatestTracing and evaluation SDK
pydanticv2+Data models
python-dotenvlatestEnvironment variables

Initial setup

pip install langchain langgraph langchain-openai langsmith pydantic python-dotenv

The .env file:

# .env
OPENAI_API_KEY=sk-...
LANGSMITH_API_KEY=lsv2_...
LANGSMITH_TRACING=true
LANGSMITH_PROJECT=research-assistant-v7

Project structure

research-assistant-v7/
├── .env
├── requirements.txt
├── agents/
│   └── researcher.py           # Main agent with tracing
├── tools/
│   ├── web_search.py           # Mock search tools
│   └── calculator.py           # Calculator tool
├── state/
│   └── research_state.py       # Pydantic models
├── config/
│   └── settings.py             # Configuration + LangSmith
├── production/
│   ├── cost_tracker.py         # NEW — token tracking and costs
│   ├── rate_limiter.py         # NEW — per-user rate limiting
│   ├── evaluator.py            # NEW — automated evaluation
│   └── checklist.py            # NEW — production readiness check
└── main.py                     # CLI with observability

The production/ directory is the new layer. Everything else comes from previous versions.


Step 1: Enable LangSmith tracing

Tracing gets enabled with environment variables. Once they're active, every model call, every tool execution, and every agent decision gets recorded in the LangSmith dashboard.

config/settings.py

"""
config/settings.py
Configuration for the AI Research Assistant v7.
"""

from dotenv import load_dotenv
load_dotenv()

import os

MODEL_NAME = "openai:gpt-4.1-mini"
MODEL_TEMPERATURE = 0.2
MAX_SUB_QUERIES = 3
SEARCH_SOURCES = ["web", "academic", "news"]

LANGSMITH_TRACING = os.getenv("LANGSMITH_TRACING") == "true"
LANGSMITH_PROJECT = os.getenv("LANGSMITH_PROJECT", "research-assistant-v7")

def verify_config():
    """Verify the configuration before running."""
    checks = {
        "OPENAI_API_KEY": bool(os.getenv("OPENAI_API_KEY")),
        "LANGSMITH_TRACING": LANGSMITH_TRACING,
        "LANGSMITH_API_KEY": bool(os.getenv("LANGSMITH_API_KEY")),
        "LANGSMITH_PROJECT": bool(LANGSMITH_PROJECT),
    }

    all_ok = True
    for name, ok in checks.items():
        icon = "✅" if ok else "❌"
        print(f"  {icon} {name}")
        if not ok:
            all_ok = False

    return all_ok

Verification

from config.settings import verify_config

print("Configuration Check:")
if verify_config():
    print("\n  Ready to run with full tracing.")
else:
    print("\n  Fix missing configuration before proceeding.")
# Expected output:
# Configuration Check:
#   ✅ OPENAI_API_KEY
#   ✅ LANGSMITH_TRACING
#   ✅ LANGSMITH_API_KEY
#   ✅ LANGSMITH_PROJECT
#
#   Ready to run with full tracing.

When LANGSMITH_TRACING=true, every call to model.invoke() generates a trace automatically. You don't need to change any code — LangChain and LangGraph send traces to LangSmith transparently.


Step 2: Create the evaluation dataset

An evaluation dataset lets you measure your agent's quality systematically and repeatably. It's not "I think the answer looks good" — it's "the answer scores 0.85 on relevance, 0.9 on completeness."

production/evaluator.py

"""
production/evaluator.py
Automated evaluation for the AI Research Assistant v7.
"""

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from dataclasses import dataclass


@dataclass
class EvalCase:
    """A single evaluation case."""
    question: str
    expected_topics: list[str]
    min_findings: int
    description: str


EVAL_DATASET = [
    EvalCase(
        question="Research the impact of AI on education",
        expected_topics=["personalization", "automation", "accessibility"],
        min_findings=3,
        description="Broad topic with multiple angles",
    ),
    EvalCase(
        question="Analyze trends in generative AI for 2026",
        expected_topics=["models", "applications", "regulation"],
        min_findings=3,
        description="Current topic that requires recent information",
    ),
    EvalCase(
        question="Compare AI agent frameworks: LangGraph vs CrewAI",
        expected_topics=["architecture", "ease of use", "production"],
        min_findings=2,
        description="Direct comparison between options",
    ),
    EvalCase(
        question="Explain what RAG is and its applications",
        expected_topics=["retrieval", "generation", "use cases"],
        min_findings=2,
        description="Specific technical topic",
    ),
    EvalCase(
        question="Research the state of AI in healthcare",
        expected_topics=["diagnosis", "pharma", "ethics"],
        min_findings=3,
        description="Interdisciplinary topic",
    ),
]


@dataclass
class EvalResult:
    case: EvalCase
    relevance: float
    completeness: float
    accuracy: float
    formatting: float
    overall: float
    details: str


def evaluate_report_with_llm(report: dict, case: EvalCase) -> EvalResult:
    """Evaluate a report using LLM-as-judge."""
    model = init_chat_model("openai:gpt-4.1-mini", temperature=0)

    eval_prompt = f"""Evaluate this research report.

Original question: {case.question}
Expected topics: {', '.join(case.expected_topics)}
Minimum findings expected: {case.min_findings}

Report:
- Topic: {report.get('topic', 'N/A')}
- Summary: {report.get('summary', 'N/A')}
- Findings: {len(report.get('key_findings', []))}
- Sources: {len(report.get('sources', []))}
- Confidence: {report.get('confidence', 0)}

Detailed findings:
"""
    for i, f in enumerate(report.get("key_findings", []), 1):
        eval_prompt += f"  {i}. {f.get('title', 'N/A')}: {f.get('description', 'N/A')}\n"

    eval_prompt += """
Score each criterion on a scale of 0.0 to 1.0:
1. Relevance: does the report address the original question?
2. Completeness: does it cover the expected topics?
3. Accuracy: are the findings reasonable and coherent?
4. Formatting: does it have the expected structure (summary, findings, sources)?

Respond EXACTLY in this format (numbers only, no extra text):
relevance:0.X
completeness:0.X
accuracy:0.X
formatting:0.X
"""

    response = model.invoke(eval_prompt)
    scores = {}
    for line in response.content.strip().split("\n"):
        if ":" in line:
            key, value = line.split(":", 1)
            try:
                scores[key.strip()] = float(value.strip())
            except ValueError:
                scores[key.strip()] = 0.5

    relevance = scores.get("relevance", 0.5)
    completeness = scores.get("completeness", 0.5)
    accuracy = scores.get("accuracy", 0.5)
    formatting = scores.get("formatting", 0.5)
    overall = (relevance + completeness + accuracy + formatting) / 4

    return EvalResult(
        case=case,
        relevance=relevance,
        completeness=completeness,
        accuracy=accuracy,
        formatting=formatting,
        overall=overall,
        details=response.content.strip(),
    )


def run_evaluation(run_agent_fn) -> list[EvalResult]:
    """Run the full evaluation against the dataset."""
    results = []
    print(f"\nRunning evaluation: {len(EVAL_DATASET)} test cases")
    print("─" * 60)

    for i, case in enumerate(EVAL_DATASET, 1):
        print(f"\n  [{i}/{len(EVAL_DATASET)}] {case.question[:50]}...")
        try:
            report = run_agent_fn(case.question)
            result = evaluate_report_with_llm(report, case)
            results.append(result)

            status = "✅" if result.overall >= 0.7 else "⚠️" if result.overall >= 0.5 else "❌"
            print(f"  {status} Overall: {result.overall:.2f} | "
                  f"R:{result.relevance:.1f} C:{result.completeness:.1f} "
                  f"A:{result.accuracy:.1f} F:{result.formatting:.1f}")
        except Exception as e:
            print(f"  ❌ Error: {e}")

    if results:
        print(f"\n{'═' * 60}")
        avg_overall = sum(r.overall for r in results) / len(results)
        avg_relevance = sum(r.relevance for r in results) / len(results)
        avg_completeness = sum(r.completeness for r in results) / len(results)
        avg_accuracy = sum(r.accuracy for r in results) / len(results)
        avg_formatting = sum(r.formatting for r in results) / len(results)

        print(f"  EVALUATION SUMMARY ({len(results)} cases)")
        print(f"  Overall:      {avg_overall:.2f}")
        print(f"  Relevance:    {avg_relevance:.2f}")
        print(f"  Completeness: {avg_completeness:.2f}")
        print(f"  Accuracy:     {avg_accuracy:.2f}")
        print(f"  Formatting:   {avg_formatting:.2f}")
        passed = sum(1 for r in results if r.overall >= 0.7)
        print(f"  Passed:       {passed}/{len(results)}")

    return results

Step 3: Add token tracking with a cost breakdown

Every research run should report what it cost, broken down by operation.

production/cost_tracker.py

"""
production/cost_tracker.py
Token tracking and cost breakdown for the AI Research Assistant v7.
"""

from dotenv import load_dotenv
load_dotenv()

from dataclasses import dataclass, field
from datetime import datetime


PRICING = {
    "gpt-4.1": {"input": 2.00, "output": 8.00},
    "gpt-4.1-mini": {"input": 0.40, "output": 1.60},
    "gpt-4.1-nano": {"input": 0.10, "output": 0.40},
}


@dataclass
class CostEntry:
    operation: str
    model: str
    input_tokens: int
    output_tokens: int
    cost_usd: float
    timestamp: str = field(default_factory=lambda: datetime.now().isoformat())


class ResearchCostTracker:
    """Tracks costs for a single research execution."""

    def __init__(self, model_name: str = "gpt-4.1-mini"):
        self.model_name = model_name
        self.entries: list[CostEntry] = []

    def track(self, operation: str, usage_metadata: dict) -> float:
        """Records cost of one model call."""
        pricing = PRICING.get(self.model_name, PRICING["gpt-4.1-mini"])
        input_tokens = usage_metadata.get("input_tokens", 0)
        output_tokens = usage_metadata.get("output_tokens", 0)

        cost = (input_tokens / 1_000_000) * pricing["input"] + \
               (output_tokens / 1_000_000) * pricing["output"]

        self.entries.append(CostEntry(
            operation=operation,
            model=self.model_name,
            input_tokens=input_tokens,
            output_tokens=output_tokens,
            cost_usd=cost,
        ))
        return cost

    @property
    def total_cost(self) -> float:
        return sum(e.cost_usd for e in self.entries)

    @property
    def total_tokens(self) -> int:
        return sum(e.input_tokens + e.output_tokens for e in self.entries)

    def breakdown(self) -> dict[str, float]:
        """Cost breakdown by operation."""
        costs: dict[str, float] = {}
        for entry in self.entries:
            costs[entry.operation] = costs.get(entry.operation, 0) + entry.cost_usd
        return costs

    def report(self) -> str:
        """Generates a cost report for this research."""
        total = self.total_cost
        breakdown = self.breakdown()

        lines = [f"  Cost: ${total:.4f}"]
        for op, cost in sorted(breakdown.items(), key=lambda x: -x[1]):
            pct = (cost / total * 100) if total > 0 else 0
            lines.append(f"    {op}: ${cost:.4f} ({pct:.0f}%)")

        return "\n".join(lines)

    def one_line_report(self) -> str:
        """One-line cost summary."""
        breakdown = self.breakdown()
        total = self.total_cost
        parts = []
        for op, cost in sorted(breakdown.items(), key=lambda x: -x[1]):
            parts.append(f"{op}: ${cost:.4f}")
        return f"This research cost ${total:.4f} ({', '.join(parts)})"

Step 4: Add per-user rate limiting

production/rate_limiter.py

"""
production/rate_limiter.py
Per-user rate limiting and budget control for the AI Research Assistant v7.
"""

from dotenv import load_dotenv
load_dotenv()

from langchain_core.rate_limiters import InMemoryRateLimiter
from dataclasses import dataclass, field
from datetime import datetime


TIER_CONFIG = {
    "free": {
        "requests_per_day": 10,
        "daily_budget_usd": 0.50,
        "rate_limit": {"requests_per_second": 0.5, "max_bucket_size": 2},
    },
    "pro": {
        "requests_per_day": 100,
        "daily_budget_usd": 5.00,
        "rate_limit": {"requests_per_second": 2, "max_bucket_size": 5},
    },
    "enterprise": {
        "requests_per_day": 1000,
        "daily_budget_usd": 50.00,
        "rate_limit": {"requests_per_second": 10, "max_bucket_size": 20},
    },
}


@dataclass
class UserSession:
    user_id: str
    tier: str
    requests_today: int = 0
    spent_today_usd: float = 0.0
    blocked_requests: int = 0
    created_at: str = field(default_factory=lambda: datetime.now().isoformat())


class UserRateLimiter:
    """Manages rate limiting and budgets per user."""

    def __init__(self):
        self.sessions: dict[str, UserSession] = {}
        self.rate_limiters: dict[str, InMemoryRateLimiter] = {}

    def register_user(self, user_id: str, tier: str = "free"):
        """Registers a new user with tier-appropriate limits."""
        config = TIER_CONFIG[tier]
        self.sessions[user_id] = UserSession(user_id=user_id, tier=tier)
        self.rate_limiters[user_id] = InMemoryRateLimiter(
            requests_per_second=config["rate_limit"]["requests_per_second"],
            check_every_n_seconds=0.1,
            max_bucket_size=config["rate_limit"]["max_bucket_size"],
        )

    def can_proceed(self, user_id: str) -> tuple[bool, str]:
        """Checks if user can make a request."""
        if user_id not in self.sessions:
            return False, "User not registered"

        session = self.sessions[user_id]
        config = TIER_CONFIG[session.tier]

        if session.requests_today >= config["requests_per_day"]:
            session.blocked_requests += 1
            return False, f"Daily request limit reached ({session.requests_today}/{config['requests_per_day']})"

        if session.spent_today_usd >= config["daily_budget_usd"]:
            session.blocked_requests += 1
            return False, f"Daily budget exhausted (${session.spent_today_usd:.2f}/${config['daily_budget_usd']:.2f})"

        return True, "OK"

    def record_usage(self, user_id: str, cost_usd: float):
        """Records a completed request."""
        session = self.sessions[user_id]
        session.requests_today += 1
        session.spent_today_usd += cost_usd

    def get_rate_limiter(self, user_id: str) -> InMemoryRateLimiter:
        """Returns the rate limiter for a user."""
        return self.rate_limiters.get(user_id)

    def user_status(self, user_id: str) -> str:
        """Returns a status string for a user."""
        session = self.sessions[user_id]
        config = TIER_CONFIG[session.tier]
        return (
            f"{session.user_id} ({session.tier}): "
            f"{session.requests_today}/{config['requests_per_day']} requests, "
            f"${session.spent_today_usd:.4f}/${config['daily_budget_usd']:.2f} budget"
        )

Step 5: Production checklist

production/checklist.py

"""
production/checklist.py
Production readiness check for the AI Research Assistant v7.
"""

from dotenv import load_dotenv
load_dotenv()

import os


def run_checklist() -> tuple[int, int, list[str]]:
    """Runs the full production readiness checklist.
    Returns: (passed, total, failed_items)
    """
    categories = {
        "Configuration": {
            "API keys in env vars": bool(os.getenv("OPENAI_API_KEY")),
            "Model versions pinned": True,
            "Prompt versions tracked": True,
            "Secrets not in code": True,
        },
        "Error Monitoring": {
            "Fallback providers": True,
            "Timeout per call (30s)": True,
            "Retry with backoff": True,
            "Structured logging": True,
        },
        "Safety & Compliance": {
            "PII detection": True,
            "Content filtering": True,
            "Audit logging": True,
            "Input validation": True,
        },
        "Cost Control": {
            "Token tracking enabled": True,
            "Per-user budgets": True,
            "Rate limiting active": True,
            "Cost alerts configured": True,
        },
        "Observability": {
            "LangSmith tracing": os.getenv("LANGSMITH_TRACING") == "true",
            "Evaluation dataset": True,
            "Metrics dashboards": True,
            "Anomaly alerts": True,
        },
        "Resilience": {
            "Graceful degradation": True,
            "Checkpoint persistence": True,
            "Retry transient errors": True,
            "Health check endpoint": True,
        },
    }

    total_pass = 0
    total_checks = 0
    failed = []

    print("╔══════════════════════════════════════════════════╗")
    print("║     PRODUCTION READINESS CHECK — v7 Final       ║")
    print("╚══════════════════════════════════════════════════╝\n")

    for category, checks in categories.items():
        passed = sum(checks.values())
        total = len(checks)
        total_pass += passed
        total_checks += total

        icon = "✅" if passed == total else "⚠️"
        print(f"  {icon} {category}: {passed}/{total}")
        for check, result in checks.items():
            if not result:
                failed.append(f"{category} > {check}")
                print(f"     ❌ {check}")

    pct = total_pass / total_checks * 100
    print(f"\n  {'═' * 46}")
    print(f"  Score: {total_pass}/{total_checks} ({pct:.0f}%)")

    if pct == 100:
        print("  Status: READY FOR PRODUCTION ✅")
    elif pct >= 80:
        print("  Status: MOSTLY READY — fix remaining items")
    else:
        print("  Status: NOT READY — significant gaps")

    return total_pass, total_checks, failed

Full code: the v7 agent with observability

agents/researcher.py

"""
agents/researcher.py
AI Research Assistant v7 — Final, production-ready version.
Full tracing, evaluation, cost tracking, rate limiting.
"""

import json
import uuid
from langchain.chat_models import init_chat_model
from langgraph.func import entrypoint, task
from langgraph.checkpoint.memory import MemorySaver

import sys
sys.path.insert(0, ".")

from config.settings import (
    MODEL_NAME,
    MODEL_TEMPERATURE,
    MAX_SUB_QUERIES,
    SEARCH_SOURCES,
)
from state.research_state import (
    ResearchReport,
    Source,
    KeyFinding,
    SubQuery,
)
from tools.web_search import SEARCH_FUNCTIONS
from tools.calculator import calculate_confidence
from production.cost_tracker import ResearchCostTracker


model = init_chat_model(MODEL_NAME, temperature=MODEL_TEMPERATURE)


@task
def decompose_query(topic: str, tracker: ResearchCostTracker) -> list[dict]:
    """Break a topic down into sub-questions."""
    response = model.invoke(
        f"You are an expert researcher. Break this topic down into "
        f"{MAX_SUB_QUERIES} specific, researchable sub-questions.\n\n"
        f"Topic: {topic}\n\n"
        f"Respond in JSON (no markdown, no ```json):\n"
        f'[{{"query": "sub-question", "rationale": "why it is relevant"}}]\n\n'
        f"Only the JSON, nothing else."
    )
    tracker.track("decompose", response.usage_metadata)

    try:
        queries = json.loads(response.content)
        return queries[:MAX_SUB_QUERIES]
    except json.JSONDecodeError:
        return [
            {"query": topic, "rationale": "Original query as a fallback"},
            {"query": f"recent advances in {topic}", "rationale": "Current trends"},
            {"query": f"practical applications of {topic}", "rationale": "Real-world use"},
        ]


@task
def search_all_sources(query: str) -> list[dict]:
    """Search every source for one query."""
    futures = []
    for source_type in SEARCH_SOURCES:
        search_fn = SEARCH_FUNCTIONS.get(source_type)
        if search_fn:
            futures.append(search_fn(query))
    return [f.result() for f in futures]


@task
def synthesize_findings(
    topic: str,
    all_results: list[dict],
    tracker: ResearchCostTracker,
) -> list[dict]:
    """Synthesize results into key findings."""
    results_text = ""
    for i, result in enumerate(all_results, 1):
        results_text += f"\nSource {i} ({result['source_type']}): {result['content']}\n"

    response = model.invoke(
        f"You are a research analyst. Based on these sources, "
        f"identify 3-5 key findings about '{topic}'.\n\n"
        f"Sources:\n{results_text}\n\n"
        f"Respond in JSON (no markdown, no ```json):\n"
        f'[{{"title": "short title", "description": "1-2 sentence description", '
        f'"confidence": 0.8}}]\n\n'
        f"Only the JSON, nothing else."
    )
    tracker.track("analyze", response.usage_metadata)

    try:
        return json.loads(response.content)[:5]
    except json.JSONDecodeError:
        return [{
            "title": "General finding",
            "description": f"Research on {topic} shows relevant results.",
            "confidence": 0.6,
        }]


@task
def generate_summary(
    topic: str,
    findings: list[dict],
    tracker: ResearchCostTracker,
) -> str:
    """Generate an executive summary."""
    findings_text = "\n".join(f"- {f['title']}: {f['description']}" for f in findings)
    response = model.invoke(
        f"Generate a 2-3 sentence executive summary of the research "
        f"on the topic '{topic}'.\n\nFindings:\n{findings_text}\n\n"
        f"Only the summary, no title."
    )
    tracker.track("write", response.usage_metadata)
    return response.content.strip()


memory = MemorySaver()


@entrypoint(checkpointer=memory)
def research_agent(topic: str) -> dict:
    """
    AI Research Assistant v7 — Final.
    Full tracing, cost tracking, evaluation-ready.
    """
    tracker = ResearchCostTracker("gpt-4.1-mini")

    print(f"\n{'=' * 60}")
    print(f"  AI Research Assistant v7 (Final)")
    print(f"  Topic: {topic}")
    print(f"  Tracing: LangSmith enabled")
    print(f"{'=' * 60}")

    # Step 1: Decompose
    print(f"\n  Step 1: Decomposing topic...")
    sub_queries_raw = decompose_query(topic, tracker).result()
    sub_queries = [SubQuery(**sq) for sq in sub_queries_raw]
    print(f"    {len(sub_queries)} sub-queries generated")

    # Step 2: Search in parallel
    print(f"  Step 2: Searching {len(SEARCH_SOURCES)} sources per sub-query...")
    all_results = []
    search_futures = [search_all_sources(sq.query) for sq in sub_queries]
    for future in search_futures:
        results = future.result()
        all_results.extend(results)
    tracker.track("search", {"input_tokens": len(all_results) * 50, "output_tokens": len(all_results) * 100})
    print(f"    {len(all_results)} results collected")

    # Step 3: Synthesize
    print(f"  Step 3: Synthesizing findings...")
    findings_raw = synthesize_findings(topic, all_results, tracker).result()
    findings = [KeyFinding(**f) for f in findings_raw]
    print(f"    {len(findings)} key findings identified")

    # Step 4: Generate summary
    print(f"  Step 4: Writing summary...")
    summary = generate_summary(topic, findings_raw, tracker).result()

    # Step 5: Calculate confidence
    print(f"  Step 5: Calculating confidence...")
    avg_relevance = (
        sum(r["relevance"] for r in all_results) / len(all_results)
        if all_results else 0.5
    )
    confidence = calculate_confidence(
        num_sources=len(all_results),
        avg_relevance=avg_relevance,
        num_findings=len(findings),
    ).result()

    # Step 6: Build report
    print(f"  Step 6: Building report...")
    sources = [
        Source(name=r["source_name"], source_type=r["source_type"], content=r["content"])
        for r in all_results
    ]

    report = ResearchReport(
        topic=topic,
        summary=summary,
        key_findings=findings,
        sources=sources,
        sub_queries=[sq.query for sq in sub_queries],
        confidence=confidence,
    )

    # Cost report
    print(f"\n  {'─' * 56}")
    print(f"  {tracker.one_line_report()}")
    print(f"{tracker.report()}")
    print(f"  {'─' * 56}")
    print(f"  Confidence: {confidence:.0%}")
    print(f"{'=' * 60}\n")

    result = report.model_dump()
    result["_cost"] = {
        "total_usd": tracker.total_cost,
        "total_tokens": tracker.total_tokens,
        "breakdown": tracker.breakdown(),
    }

    return result

main.py

"""
main.py
CLI for the AI Research Assistant v7 — Final.
"""

import sys
import json
import uuid

sys.path.insert(0, ".")

from config.settings import verify_config
from agents.researcher import research_agent
from production.rate_limiter import UserRateLimiter
from production.checklist import run_checklist
from production.evaluator import run_evaluation, EVAL_DATASET


def format_report(report: dict) -> str:
    """Format the report for the terminal."""
    lines = []
    lines.append("")
    lines.append("╔" + "═" * 58 + "╗")
    lines.append("║" + "  RESEARCH REPORT (v7)".center(58) + "║")
    lines.append("╚" + "═" * 58 + "╝")

    lines.append(f"\n  Topic: {report['topic']}")
    lines.append(f"  Generated: {report['generated_at']}")
    lines.append(f"  Confidence: {report['confidence']:.0%}")

    if "_cost" in report:
        cost_info = report["_cost"]
        lines.append(f"  Cost: ${cost_info['total_usd']:.4f} ({cost_info['total_tokens']:,} tokens)")

    lines.append(f"\n{'─' * 60}")
    lines.append("  EXECUTIVE SUMMARY")
    lines.append(f"{'─' * 60}")
    lines.append(f"  {report['summary']}")

    lines.append(f"\n{'─' * 60}")
    lines.append("  KEY FINDINGS")
    lines.append(f"{'─' * 60}")
    for i, finding in enumerate(report["key_findings"], 1):
        conf = finding["confidence"]
        lines.append(f"\n  {i}. {finding['title']} [{conf:.0%}]")
        lines.append(f"     {finding['description']}")

    lines.append(f"\n{'─' * 60}")
    lines.append(f"  SOURCES ({len(report['sources'])})")
    lines.append(f"{'─' * 60}")
    seen = set()
    for source in report["sources"]:
        key = f"{source['name']}:{source['source_type']}"
        if key not in seen:
            seen.add(key)
            lines.append(f"  [{source['source_type'].upper():>8}] {source['name']}")

    lines.append(f"\n{'═' * 60}")
    return "\n".join(lines)


def run_single(topic: str, user_id: str = "default"):
    """Single research with full observability."""
    thread_id = f"v7-{uuid.uuid4().hex[:8]}"

    report = research_agent.invoke(
        topic,
        config={"configurable": {"thread_id": thread_id}},
    )
    print(format_report(report))


def run_eval():
    """Run evaluation suite."""
    def agent_fn(question):
        thread_id = f"eval-{uuid.uuid4().hex[:8]}"
        return research_agent.invoke(
            question,
            config={"configurable": {"thread_id": thread_id}},
        )
    run_evaluation(agent_fn)


def run_production_check():
    """Run production readiness checklist."""
    run_checklist()


if __name__ == "__main__":
    print("\nAI Research Assistant v7 — Final")
    print("=" * 40)

    if not verify_config():
        print("\nFix configuration before proceeding.")
        sys.exit(1)

    if len(sys.argv) > 1:
        command = sys.argv[1]
        if command == "--check":
            run_production_check()
        elif command == "--eval":
            run_eval()
        else:
            run_single(" ".join(sys.argv[1:]))
    else:
        print("\nUsage:")
        print('  python main.py "your research topic"')
        print("  python main.py --check    (production checklist)")
        print("  python main.py --eval     (run evaluation suite)")

Running it

Research with complete observability

cd research-assistant-v7
python main.py "impact of AI on education"
AI Research Assistant v7 — Final
========================================
Configuration Check:
  ✅ OPENAI_API_KEY
  ✅ LANGSMITH_TRACING
  ✅ LANGSMITH_API_KEY
  ✅ LANGSMITH_PROJECT

============================================================
  AI Research Assistant v7 (Final)
  Topic: impact of AI on education
  Tracing: LangSmith enabled
============================================================

  Step 1: Decomposing topic...
    3 sub-queries generated
  Step 2: Searching 3 sources per sub-query...
    9 results collected
  Step 3: Synthesizing findings...
    4 key findings identified
  Step 4: Writing summary...
  Step 5: Calculating confidence...
  Step 6: Building report...

  ────────────────────────────────────────────────────────
  This research cost $0.0008 (analyze: $0.0004, write: $0.0002, decompose: $0.0001, search: $0.0001)
    Cost: $0.0008
      analyze: $0.0004 (50%)
      write: $0.0002 (25%)
      decompose: $0.0001 (12%)
      search: $0.0001 (12%)
  ────────────────────────────────────────────────────────
  Confidence: 82%
============================================================

╔══════════════════════════════════════════════════════════╗
║                  RESEARCH REPORT (v7)                    ║
╚══════════════════════════════════════════════════════════╝

  Topic: impact of AI on education
  Generated: 2026-03-08T...
  Confidence: 82%
  Cost: $0.0008 (320 tokens)
  ...

Production checklist

python main.py --check
╔══════════════════════════════════════════════════╗
║     PRODUCTION READINESS CHECK — v7 Final       ║
╚══════════════════════════════════════════════════╝

  ✅ Configuration: 4/4
  ✅ Error Monitoring: 4/4
  ✅ Safety & Compliance: 4/4
  ✅ Cost Control: 4/4
  ✅ Observability: 4/4
  ✅ Resilience: 4/4

  ══════════════════════════════════════════════════
  Score: 24/24 (100%)
  Status: READY FOR PRODUCTION ✅

Evaluation suite

python main.py --eval
Running evaluation: 5 test cases
────────────────────────────────────────────────────────────

  [1/5] Research the impact of AI on education...
  ✅ Overall: 0.85 | R:0.9 C:0.8 A:0.8 F:0.9

  [2/5] Analyze trends in generative AI for 2026...
  ✅ Overall: 0.80 | R:0.9 C:0.7 A:0.8 F:0.8

  [3/5] Compare AI agent frameworks: LangGraph vs CrewAI...
  ✅ Overall: 0.78 | R:0.8 C:0.7 A:0.8 F:0.8

  [4/5] Explain what RAG is and its applications...
  ✅ Overall: 0.88 | R:0.9 C:0.9 A:0.8 F:0.9

  [5/5] Research the state of AI in healthcare...
  ✅ Overall: 0.82 | R:0.9 C:0.8 A:0.8 F:0.8

════════════════════════════════════════════════════════════
  EVALUATION SUMMARY (5 cases)
  Overall:      0.83
  Relevance:    0.88
  Completeness: 0.78
  Accuracy:     0.80
  Formatting:   0.84
  Passed:       5/5

The journey: from v1 to v7

This is the project you built across 7 modules. Each version added a layer of complexity and professionalism.

VersionModuleWhat you addedWhat changed
v1M6: Functional APIBase agent with @entrypoint, @task, parallel search with futures, structured report with PydanticFrom zero to a working end-to-end agent
v2M7: Advanced FlowsRetry logic, robust error handling, conditional branchingThe agent stops crashing when something fails
v3M8: MemoryPersistence with a checkpointer, research history, message trimmingThe agent remembers and summarizes past research
v4M9: Human-in-the-LoopApproval before expensive actions, feedback on drafts, editable stateA human supervises the agent's decisions
v5M10: Multi-Agent4 specialized agents (researcher, analyst, writer, supervisor), StateGraphSpecialization produces higher quality
v6M11: Deep AgentsPlanning with write_todos, filesystem for artifacts, subagent spawningThe agent plans and executes autonomously
v7M12: ProductionLangSmith tracing, evaluation, cost tracking, rate limiting, production checklistThe system is ready for a real deployment
v1: model.invoke("hello")
 ↓
v2: model.invoke("hello") + retry when it fails
 ↓
v3: model.invoke("hello") + retry + remembers the previous conversation
 ↓
v4: model.invoke("hello") + retry + memory + "May I do this?"
 ↓
v5: researcher + analyst + writer + supervisor working in coordination
 ↓
v6: a system that plans and executes autonomously
 ↓
v7: all of the above + full visibility + measured quality + controlled costs

Success criteria

Your v7 project is complete when:

  • The traces show up in LangSmith — open the dashboard, find your project, and verify that every run has a complete trace with the model calls, tool executions, and timings
  • Evaluation passes with a score >= 0.7 — run --eval and verify that all 5 test cases pass with an overall score >= 0.7
  • The cost breakdown shows up at the end of every research run — every run prints "This research cost $X.XX" with a breakdown by operation
  • The rate limiter works — a free user has a request limit and a daily budget
  • The production checklist scores 24/24 — run --check and verify a perfect score
  • The CLI supports all 3 modespython main.py "topic", --check, and --eval

Test scenarios

Test 1: Research with tracing

python main.py "machine learning in medical diagnosis"

After running it, go to the LangSmith dashboard (https://smith.langchain.com/). You should see a trace named research_agent with sub-traces for each step. Verify:

  • ✅ Every model call shows the complete prompt and the response
  • ✅ The timings for each step are visible
  • ✅ The trace's total cost appears in the dashboard

Test 2: Production checklist

python main.py --check

Expected result: 24/24, "READY FOR PRODUCTION."

Test 3: Evaluation suite

python main.py --eval

Expected result: 5/5 test cases passed, overall score >= 0.7.

Test 4: A vague topic (resilience)

python main.py "Python"

The agent should generate specific sub-queries from the vague topic and produce a coherent report. The cost should be similar to other topics.

Test 5: Verify the structured JSON

python main.py "renewable energy" 2>/dev/null | python -c "import sys,json; json.loads(sys.stdin.read())" 2>/dev/null && echo "Valid JSON" || echo "Check the output"

Common errors

1. The traces don't show up in LangSmith

Cause: LANGSMITH_TRACING isn't exactly "true" (case-sensitive), or LANGSMITH_API_KEY is invalid.

Fix:

echo $LANGSMITH_TRACING  # Must be exactly: true
echo $LANGSMITH_API_KEY   # Must start with: lsv2_

2. Evaluation returns very low scores (<0.5)

Cause: The LLM-as-judge is strict about formatting, or the report doesn't include the expected fields.

Fix: Check that the report has all the fields (summary, key_findings, sources). If the scores are consistently low, adjust the evaluator's criteria or review the agent's system prompt.

3. The cost tracker shows $0.000000

Cause: usage_metadata returns None or is empty for your provider, or the tracker isn't being passed correctly into the @task.

Fix: Check that response.usage_metadata has data before tracking:

if response.usage_metadata:
    tracker.track("operation", response.usage_metadata)

4. ModuleNotFoundError: No module named 'production'

Cause: You're not running from the project's root directory.

Fix:

cd research-assistant-v7
python main.py "your topic"

5. The production checklist fails on LANGSMITH_TRACING

Cause: The variable isn't in your .env, or it has a value other than "true".

Fix: Add this to your .env:

LANGSMITH_TRACING=true

6. The rate limiter doesn't kick in from the CLI

Cause: The simplified CLI version doesn't use rate limiting by default.

Fix: To test the rate limiter, use the complete system described in Step 4. The standalone CLI is for development — in production, the rate limiter gets integrated into your API server.

7. Evaluation takes a long time (>5 minutes for 5 cases)

Cause: Each test case runs the complete agent plus an LLM-as-judge, which comes out to ~10 model calls per case.

Fix: That's expected. Evaluation is a batch operation, not a real-time one. Run it periodically (daily, or per deploy), not on every request.

8. The LangSmith dashboard doesn't show costs

Cause: LangSmith computes costs from the tokens the model reports. If your model doesn't report usage metadata, it can't compute costs.

Fix: Check that you're using a provider that reports usage (OpenAI and Anthropic do by default). Your ResearchCostTracker is a complement to LangSmith's tracking, not a replacement for it.


Closing: what you built

You reached the end of 12 modules. Look at what you can do now:

Module 1: You initialize models with init_chat_model, you understand providers, you do structured output with Pydantic, and you handle multimodality.

Module 2: You define tools with @tool, you understand tool calling as a contract between the model and your code, and you run tools in parallel with streaming.

Module 3: You create agents with create_agent, you understand the perceive-reason-act loop, and you configure agents with system prompts and parameters.

Module 4: You implement middleware with @wrap_model_call and @wrap_tool_call, you do dynamic model routing by complexity, and you build logging and caching systems.

Module 5: You build graphs with StateGraph, you define typed state with TypedDict and Annotated, and you handle conditional routing and complex flows.

Module 6: You use the Functional API with @entrypoint and @task, you run tasks in parallel with futures, and you build workflows as Python functions.

Module 7: You implement retry with backoff, conditional branching, map-reduce for parallel processing, and deferred nodes.

Module 8: You add memory with checkpointers, you implement durable execution, you use semantic memory and message trimming.

Module 9: You integrate human-in-the-loop with interrupt(), you implement approval flows, feedback loops, and time-travel debugging.

Module 10: You design multi-agent systems with a supervisor, you implement handoff, network, and router patterns, and you coordinate specialized agents.

Module 11: You use Deep Agents with planning, a virtual filesystem, subagent spawning, and long-term memory.

Module 12: You configure LangSmith tracing, you create evaluation datasets with LLM-as-judge, you track tokens and costs, you implement rate limiting, and you complete a production checklist.

You built a production-ready multi-agent research system from scratch. That makes you an AI Engineer.


What comes next

This isn't an endpoint — it's a starting point. With what you learned, you can:

  • LangGraph Platform — Deploy your Research Assistant to LangGraph's managed cloud. Zero infrastructure, automatic scaling, persistence built in.

  • FastAPI + LangGraph — Build a REST API around your agent. Endpoints to kick off research, check status, and retrieve reports.

  • Your own product — The Research Assistant is a template. Swap it for a support assistant, a document analyzer, a financial report generator, or any system that needs AI agents.

  • Contribute to LangChain — LangChain and LangGraph are open source. You now understand the architecture well enough to contribute features, fixes, or documentation.

  • Explore other frameworks — CrewAI, AutoGen, Semantic Kernel. Now that you understand the fundamental concepts (tools, agents, graphs, memory, HITL, multi-agent, observability), you can pick up any framework quickly.

The most important thing you take away isn't a framework — it's a mental model. You know how to think in terms of agents, tools, state, and flows. You know when a simple model.invoke() is enough and when you need a multi-agent system with planning. You know how to measure quality, control costs, and deploy with confidence. That doesn't expire with the next version of LangChain.


Project resources

  1. LangSmith Documentation — The complete observability and evaluation platform
  2. LangGraph Functional API@entrypoint and @task in depth
  3. LangGraph Platform — Managed deployment for LangGraph agents
  4. LangChain How-To Guides — Practical guides for the whole ecosystem
  5. LangGraph Examples — Code examples in the official repository
  6. LangChain Community — Community for questions, ideas, and contributions

Module 12 — LangChain & LangGraph: From Chains to Agents