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
| Component | Version | Purpose |
|---|---|---|
| Python | 3.11+ | Runtime |
| LangChain | v1.2+ | LLM framework |
| LangGraph | v1.0+ | Functional API + StateGraph |
| langchain-openai | latest | Model provider |
| langsmith | latest | Tracing and evaluation SDK |
| pydantic | v2+ | Data models |
| python-dotenv | latest | Environment 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.
| Version | Module | What you added | What changed |
|---|---|---|---|
| v1 | M6: Functional API | Base agent with @entrypoint, @task, parallel search with futures, structured report with Pydantic | From zero to a working end-to-end agent |
| v2 | M7: Advanced Flows | Retry logic, robust error handling, conditional branching | The agent stops crashing when something fails |
| v3 | M8: Memory | Persistence with a checkpointer, research history, message trimming | The agent remembers and summarizes past research |
| v4 | M9: Human-in-the-Loop | Approval before expensive actions, feedback on drafts, editable state | A human supervises the agent's decisions |
| v5 | M10: Multi-Agent | 4 specialized agents (researcher, analyst, writer, supervisor), StateGraph | Specialization produces higher quality |
| v6 | M11: Deep Agents | Planning with write_todos, filesystem for artifacts, subagent spawning | The agent plans and executes autonomously |
| v7 | M12: Production | LangSmith tracing, evaluation, cost tracking, rate limiting, production checklist | The 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
--evaland 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
--checkand verify a perfect score - ✅ The CLI supports all 3 modes —
python 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
- LangSmith Documentation — The complete observability and evaluation platform
- LangGraph Functional API —
@entrypointand@taskin depth - LangGraph Platform — Managed deployment for LangGraph agents
- LangChain How-To Guides — Practical guides for the whole ecosystem
- LangGraph Examples — Code examples in the official repository
- LangChain Community — Community for questions, ideas, and contributions
Module 12 — LangChain & LangGraph: From Chains to Agents