school

Exclusive content for registered members

Join the Academy for free and get full access
to all courses — at no cost

Sign in / Register for free ← Back to the preview
Most popular
smart_toy

AI Agents Development

A full, comprehensive course for building professional AI agents in Python. From Async Python to LangGraph and FastAPI — everything you need to deploy Agents to production.

Module 1 of 6
16%
Python LangChain CrewAI LangGraph FastAPI 25 hours
code
Module 1 — 4 hours

Python for AI Developers

Before you start building Agents, you must master the Python tools every AI framework is built on. This module covers the four critical topics: asynchronous programming, working with HTTP APIs, models with Pydantic, and managing secrets. If you already have Python experience — the emphasis here is on the patterns specific to working with LLMs.

1.1

Async/Await and asyncio in Python

Asynchronous programming is one of the big differences between mediocre AI code and professional AI code. When our AI agent needs to call 5 different APIs, wait for responses from an LLM and update a database — all at the same time — asyncio is the tool that makes it efficient.

Sync vs Async — the practical difference

In the synchronous approach, the code waits for each network request before moving to the next. In the asynchronous approach, all the requests are sent at once and the results are collected together. The performance difference is dramatic — especially with LLMs that have high latency.

import asyncio
import httpx
from typing import List

# old way (synchronous) — slow
def fetch_sync(urls: List[str]):
    results = []
    with httpx.Client() as client:
        for url in urls:
            response = client.get(url)
            results.append(response.json())
    return results

# new way (asynchronous) — 10x faster
async def fetch_async(urls: List[str]):
    async with httpx.AsyncClient() as client:
        tasks = [client.get(url) for url in urls]
        responses = await asyncio.gather(*tasks)
        return [r.json() for r in responses]

# run:
# sync: takes ~5 seconds for 5 URLs
# async: takes ~1 second for 5 URLs

Coroutines and Event Loop

Every function defined with async def is a coroutine. It does not run immediately but returns an object that can be awaited. The event loop manages the execution of all the coroutines — it jumps between tasks when one of them is waiting for input/output.

gather vs wait — when to use each

asyncio.gather() Runs all the coroutines in parallel and returns an ordered list of results. If one fails — they all fail by default. asyncio.wait() Allows finer control — you can collect results by completion order and decide what to do with failures.

Error handling in asynchronous code

async def safe_fetch(url: str) -> dict | None:
    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            response = await client.get(url)
            response.raise_for_status()
            return response.json()
    except httpx.TimeoutException:
        print(f"Timeout: {url}")
        return None
    except httpx.HTTPStatusError as e:
        print(f"HTTP {e.response.status_code}: {url}")
        return None

# gather with return_exceptions=True — does not crash on a single error
results = await asyncio.gather(
    *[safe_fetch(url) for url in urls],
    return_exceptions=True
)
# filter valid results
valid = [r for r in results if r and not isinstance(r, Exception)]
lightbulb

Tip from experience: Always set a timeout for the AsyncClient when working with LLMs. Requests to OpenAI can take up to 120 seconds for long responses — without a timeout, your code will hang forever with no clear error.

1.2

HTTP Clients for AI APIs

The OpenAI and Anthropic SDKs are convenient, but understanding how to communicate with LLMs directly over HTTP — including streaming — is a critical skill. It lets you add retry logic, save logs, manage rate limits and connect any provider you want.

Streaming Response from OpenAI

import httpx
import asyncio
import json
from typing import AsyncGenerator

OPENAI_API_KEY = "sk-..."  # from the actual .env

# Streaming response from OpenAI
async def stream_openai(prompt: str) -> AsyncGenerator[str, None]:
    async with httpx.AsyncClient() as client:
        async with client.stream(
            "POST",
            "https://api.openai.com/v1/chat/completions",
            headers={"Authorization": f"Bearer {OPENAI_API_KEY}"},
            json={
                "model": "gpt-4o",
                "messages": [{"role": "user", "content": prompt}],
                "stream": True
            },
            timeout=60
        ) as response:
            async for line in response.aiter_lines():
                if line.startswith("data: ") and line != "data: [DONE]":
                    chunk = json.loads(line[6:])
                    if content := chunk["choices"][0]["delta"].get("content"):
                        yield content

# usage:
async def main():
    async for token in stream_openai("Tell me a short story"):
        print(token, end="", flush=True)

Automatic Retry Logic

Rate-limit errors (429) and temporary network errors are part of daily life when working with LLMs. The right solution is Exponential Backoff — waiting longer and longer between each retry.

import asyncio
import httpx

async def robust_llm_call(payload: dict, max_retries: int = 3) -> dict:
    delay = 1.0
    for attempt in range(max_retries):
        try:
            async with httpx.AsyncClient(timeout=30) as client:
                resp = await client.post(
                    "https://api.openai.com/v1/chat/completions",
                    headers={"Authorization": f"Bearer {OPENAI_API_KEY}"},
                    json=payload
                )
                if resp.status_code == 429:  # Rate limited
                    retry_after = float(resp.headers.get("retry-after", delay))
                    await asyncio.sleep(retry_after)
                    delay *= 2
                    continue
                resp.raise_for_status()
                return resp.json()
        except httpx.ConnectError:
            if attempt == max_retries - 1:
                raise
            await asyncio.sleep(delay)
            delay *= 2
    raise RuntimeError("All attempts failed")
lightbulb

Using the official SDK: In real projects, you should use theopenai official one — it includes built-in retry logic. The above code is important for understanding what happens behind the scenes.

1.3

Pydantic Models for AI Applications

Pydantic is one of the most important libraries for AI development. It lets us define the agent's data structure — messages, configurations, responses — and ensure everything is valid at runtime. LangChain, FastAPI and the OpenAI SDK were all built on Pydantic.

from pydantic import BaseModel, Field, validator
from typing import Optional, List
from enum import Enum
from datetime import datetime

class MessageRole(str, Enum):
    SYSTEM = "system"
    USER = "user"
    ASSISTANT = "assistant"

class Message(BaseModel):
    role: MessageRole
    content: str
    timestamp: datetime = Field(default_factory=datetime.now)

    @validator('content')
    def content_not_empty(cls, v):
        if not v.strip():
            raise ValueError('Content cannot be empty')
        return v.strip()

class ConversationConfig(BaseModel):
    model: str = "gpt-4o"
    temperature: float = Field(0.7, ge=0.0, le=2.0)
    max_tokens: int = Field(1024, gt=0, le=128000)
    system_prompt: Optional[str] = None
    memory_window: int = Field(10, ge=1, le=100)

class AgentResponse(BaseModel):
    content: str
    tool_calls: List[dict] = []
    tokens_used: int
    cost_usd: float
    duration_ms: int

Structured Output — LLMs that return JSON

One of the most powerful uses of Pydantic with LLMs is forcing the model to return JSON that matches a predefined structure. OpenAI supports this via response_format.

from pydantic import BaseModel
from openai import AsyncOpenAI

class TaskBreakdown(BaseModel):
    title: str
    subtasks: list[str]
    estimated_hours: float
    complexity: str  # "low" | "medium" | "high"
    tools_needed: list[str]

client = AsyncOpenAI()

async def analyze_task(task_description: str) -> TaskBreakdown:
    response = await client.beta.chat.completions.parse(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "Analyze tasks and break them into subtasks."},
            {"role": "user", "content": task_description}
        ],
        response_format=TaskBreakdown
    )
    return response.choices[0].message.parsed

# usage:
# result = await analyze_task("Build a chatbot with memory for a website")
# result.subtasks -> ["Define Pydantic models", "Build a FastAPI endpoint", ...]
Automatic Validation

Pydantic checks data types and constraints when the object is created — not later at runtime.

Automatic JSON Schema

Every Pydantic model can generate a JSON Schema that can be sent directly to OpenAI as a tool definition.

Full Type Safety

The IDE understands the field types and offers autocomplete — fewer bugs, faster development.

1.4

Environment Variables and Secrets Management

Managing secrets is one of the most common mistakes among new developers. An API key exposed on GitHub can cause thousands of dollars in charges. The correct pattern is usingpydantic-settings with .env.

from pydantic_settings import BaseSettings
from functools import lru_cache

class Settings(BaseSettings):
    # AI APIs
    openai_api_key: str
    anthropic_api_key: str = ""

    # Database
    database_url: str = "sqlite:///./app.db"
    redis_url: str = "redis://localhost:6379"

    # App
    debug: bool = False
    secret_key: str
    allowed_origins: list[str] = ["http://localhost:3000"]

    class Config:
        env_file = ".env"
        env_file_encoding = "utf-8"

@lru_cache()  # Singleton pattern — creates once
def get_settings() -> Settings:
    return Settings()

# sample .env file:
# OPENAI_API_KEY=sk-...
# SECRET_KEY=your-secret-key-here
# DEBUG=false

The golden rules for managing secrets

check_circle

Always add .env to .gitignore before anything — before git init.

check_circle

Create a file .env.example with empty keys — so others know which variables are required.

check_circle

In production — use a secrets manager (AWS Secrets Manager, Doppler, HashiCorp Vault) and not .env.

check_circle

Set Spending Limits in the OpenAI dashboard — always, without exception.

Project 1

Async Web Scraper with AI Summary

The first project brings together everything we learned: asynchronous scraping, calling OpenAI, and Pydantic structured output. The result — a tool that scrapes several URLs simultaneously and returns structured summaries in English.

import asyncio
import httpx
from bs4 import BeautifulSoup
from openai import AsyncOpenAI
from pydantic import BaseModel

client = AsyncOpenAI()

class ArticleSummary(BaseModel):
    title: str
    url: str
    summary: str
    key_points: list[str]
    category: str

async def scrape_url(url: str) -> str:
    async with httpx.AsyncClient() as http:
        response = await http.get(url, timeout=10,
                                  headers={"User-Agent": "Mozilla/5.0"})
        soup = BeautifulSoup(response.text, 'html.parser')
        # extract the main text
        for tag in soup(['script', 'style', 'nav', 'footer']):
            tag.decompose()
        return soup.get_text(separator='\n', strip=True)[:3000]

async def summarize_article(url: str, text: str) -> ArticleSummary:
    response = await client.beta.chat.completions.parse(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "Summarize articles in Hebrew. Extract key points."},
            {"role": "user", "content": f"URL: {url}\n\nContent:\n{text}"}
        ],
        response_format=ArticleSummary
    )
    result = response.choices[0].message.parsed
    result.url = url
    return result

async def main():
    urls = [
        "https://techcrunch.com/latest/",
        "https://www.theverge.com/ai-artificial-intelligence",
    ]

    # Scrape in parallel
    texts = await asyncio.gather(*[scrape_url(url) for url in urls])

    # Summarize in parallel
    summaries = await asyncio.gather(*[
        summarize_article(url, text)
        for url, text in zip(urls, texts)
    ])

    for summary in summaries:
        print(f"Title: {summary.title}")
        print(f"Summary: {summary.summary}")
        print(f"Key points: {', '.join(summary.key_points[:3])}")
        print()

asyncio.run(main())
What you learn from the project
  • checkasyncio.gather for parallel execution
  • checkBeautifulSoup for cleaning HTML
  • checkStructured Output with Pydantic
  • checkToken budget management
Extension exercises
  • arrow_backAdd saving to a JSON file
  • arrow_backAdd rate limiting
  • arrow_backSend results via Telegram
  • arrow_backSchedule with APScheduler
link
Module 2 — 5 hours

LangChain Deep Dive

LangChain is the central framework for building LLM applications. In this module we'll go beyond basic examples and build professional pipelines using LCEL, review all the memory types, and build a full RAG pipeline from document embedding to a ready-to-use chatbot.

2.1

LCEL — LangChain Expression Language

LCEL is LangChain's modern API for building chains. Instead of building cumbersome objects, you use the pipe operator (|) to connect components — exactly like in a Unix shell. Every chain in LCEL is asynchronous, parallelizable and streamable out of the box.

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser, JsonOutputParser
from langchain_core.runnables import RunnablePassthrough, RunnableLambda

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# a simple Chain — LCEL syntax
prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a professional AI assistant that answers in English."),
    ("human", "{question}")
])

chain = prompt | llm | StrOutputParser()

# run:
result = chain.invoke({"question": "What is the difference between AI and ML?"})

# a complex Chain with parallel branches
from langchain_core.runnables import RunnableParallel

map_chain = RunnableParallel(
    summary=prompt | llm | StrOutputParser(),
    sentiment=ChatPromptTemplate.from_template(
        "Rate the sentiment of: {question}. Return JSON: {{score: 1-10}}"
    ) | llm | JsonOutputParser()
)

result = map_chain.invoke({"question": "Your product is great!"})
# → {"summary": "...", "sentiment": {"score": 9}}

Streaming with LCEL

async def stream_response(question: str):
    async for chunk in chain.astream({"question": question}):
        print(chunk, end="", flush=True)

# Batch processing — hundreds of questions in parallel
questions = [{"question": q} for q in ["question 1", "question 2", "question 3"]]
results = await chain.abatch(questions, config={"max_concurrency": 5})
info

LCEL vs Legacy Chains: If you see code with LLMChain or ConversationChain — that is old code. LangChain has declared that all legacy chains will be removed. Always use LCEL.

2.2

Memory Types — memory for long conversations

The context window of LLMs is limited and expensive. Choosing the right memory mechanism is the most important architectural decision when building a chatbot. Each approach suits a different use case.

from langchain.memory import (
    ConversationBufferMemory,
    ConversationSummaryMemory,
    ConversationBufferWindowMemory,
    ConversationEntityMemory
)

# Buffer Window — remembers the last N messages
memory = ConversationBufferWindowMemory(
    k=5,  # remembers 5 question-answer pairs
    return_messages=True,
    memory_key="chat_history"
)

# Summary Memory — summarizes long conversations
summary_memory = ConversationSummaryMemory(
    llm=ChatOpenAI(model="gpt-4o-mini"),
    return_messages=True
)

# Entity Memory — remembers facts about people and places
entity_memory = ConversationEntityMemory(
    llm=ChatOpenAI(model="gpt-4o-mini")
)
# "the user mentioned they live in Tel Aviv and work at a startup"
Buffer Window — when to use

For short-to-medium conversations. Simple and fast. Downside: loses old context completely. Cost: low and fixed.

Summary Memory — when to use

For very long conversations. Keeps the general context. An extra cost for summarization — a worthwhile price for better UX.

2.3

RAG Pipeline — Retrieval Augmented Generation

RAG is the architecture that lets an LLM answer questions from documents that were not in its training data. Instead of retraining the model (very expensive), we convert the documents into embeddings, store them in a vector store, and at query time — retrieve the relevant passages and provide them as context.

from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.chains import RetrievalQA

# step 1: loading the PDF
loader = PyPDFLoader("company_docs.pdf")
documents = loader.load()

# step 2: splitting into chunks (Chunking)
splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    separators=["\n\n", "\n", ".", " "]
)
chunks = splitter.split_documents(documents)

# step 3: creating the Vector Store
embeddings = OpenAIEmbeddings()
vectorstore = Chroma.from_documents(
    chunks,
    embeddings,
    persist_directory="./chroma_db"
)

# step 4: creating the RAG Chain
qa_chain = RetrievalQA.from_chain_type(
    llm=ChatOpenAI(model="gpt-4o"),
    retriever=vectorstore.as_retriever(search_kwargs={"k": 4}),
    return_source_documents=True
)

result = qa_chain.invoke({"query": "What is our return policy?"})
print(result["result"])

Advanced RAG — performance improvements

arrow_back
Re-ranking: After an initial retrieval, re-ranks the results by deeper relevance with Cohere Rerank.
arrow_back
Hybrid Search: Combines semantic search (embeddings) with keyword search (BM25) for better results.
arrow_back
Query Expansion: Uses an LLM to rephrase the user's question in additional ways before searching.
2.4

Document Processing — from PDF to Vector Store

Loading documents correctly is critical to RAG success. LangChain supports 50+ document types. The key is keeping metadata on each chunk — file name, page number, date — so they can be shown to the user together with the answer.

from langchain_community.document_loaders import (
    PyPDFLoader,
    WebBaseLoader,
    JSONLoader,
    CSVLoader,
    UnstructuredWordDocumentLoader
)
from langchain_core.documents import Document

# loading multiple documents with metadata
async def load_documents(file_paths: list[str]) -> list[Document]:
    all_docs = []

    for path in file_paths:
        if path.endswith('.pdf'):
            loader = PyPDFLoader(path)
        elif path.endswith('.docx'):
            loader = UnstructuredWordDocumentLoader(path)
        elif path.endswith('.csv'):
            loader = CSVLoader(path)
        else:
            continue

        docs = loader.load()

        # add metadata to each document
        for doc in docs:
            doc.metadata['source_file'] = path
            doc.metadata['loaded_at'] = datetime.now().isoformat()

        all_docs.extend(docs)

    return all_docs
Project 2

PDF Chatbot with conversation memory

The capstone project of module 2: a chatbot that allows questions about PDF documents, remembers the conversation history, and shows the information sources in every answer. A foundation for many AI products — customer support, Q&A over documentation, contract analysis.

from langchain.chains import ConversationalRetrievalChain
from langchain.memory import ConversationBufferWindowMemory
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

def build_pdf_chatbot(persist_dir: str = "./chroma_db") -> ConversationalRetrievalChain:
    embeddings = OpenAIEmbeddings()
    vectorstore = Chroma(
        persist_directory=persist_dir,
        embedding_function=embeddings
    )

    memory = ConversationBufferWindowMemory(
        k=6,
        memory_key="chat_history",
        return_messages=True,
        output_key="answer"
    )

    chain = ConversationalRetrievalChain.from_llm(
        llm=ChatOpenAI(model="gpt-4o", temperature=0),
        retriever=vectorstore.as_retriever(search_kwargs={"k": 4}),
        memory=memory,
        return_source_documents=True,
        verbose=True
    )

    return chain

# usage
chatbot = build_pdf_chatbot()

result = chatbot.invoke({"question": "What are the cancellation terms of the agreement?"})
print(result["answer"])
print("\nSources:")
for doc in result["source_documents"]:
    print(f"  - {doc.metadata.get('source_file')}, page {doc.metadata.get('page', '?')}")
groups
Module 3 — 5 hours

CrewAI — Multi-Agent Systems

CrewAI lets us build teams of AI agents that work together. Each agent gets a defined role, specific tools and clear goals. The agents communicate with each other, delegate tasks and build on each other's work. This is an especially powerful model for complex workflows that require specialization.

3.1

Defining Agents, Tasks and Crew

The fundamentals of CrewAI: an Agent is an agent with a role, goal and background. A Task is a task with a description and an expected outcome. A Crew is the team that manages the execution.

from crewai import Agent, Task, Crew, Process
from crewai_tools import SerperDevTool, WebsiteSearchTool

# shared tools
search_tool = SerperDevTool()
web_tool = WebsiteSearchTool()

# defining agents
researcher = Agent(
    role="Senior Researcher",
    goal="Gather deep, up-to-date information on a given topic",
    backstory="""You are an experienced AI researcher with an excellent ability
    to find relevant information and filter out noise.""",
    tools=[search_tool, web_tool],
    llm="gpt-4o",
    verbose=True
)

writer = Agent(
    role="Professional Content Writer",
    goal="Write engaging and accurate content in English",
    backstory="You are a writer with experience in technology and AI content.",
    llm="gpt-4o",
    verbose=True
)

# defining tasks
research_task = Task(
    description="Research the latest trends in {topic}",
    expected_output="A detailed research report with 5 key trends",
    agent=researcher
)

write_task = Task(
    description="Write a blog article about {topic} in English",
    expected_output="An 800-word article with headings and key points",
    agent=writer,
    context=[research_task]  # receives the research result
)

# running the crew
crew = Crew(
    agents=[researcher, writer],
    tasks=[research_task, write_task],
    process=Process.sequential,
    verbose=True
)

result = crew.kickoff(inputs={"topic": "AI Agents in the 2026 market"})
3.2

Custom Tools for CrewAI

Writing custom tools — connecting to private APIs, an internal database, reading files. Each tool is a simple Python function with a decorator.

from crewai_tools import tool

@tool("Database Query Tool")
def query_db(sql: str) -> str:
    """Query internal DB"""
    conn = get_db_connection()
    result = conn.execute(sql)
    return str(result.fetchall())
3.3

Hierarchical Process

Instead of sequential execution — a Manager agent decides in real time which agent handles each step. More flexible, suited to complex processes.

crew = Crew(
  agents=[researcher, writer, editor],
  tasks=[...],
  process=Process.hierarchical,
  manager_llm="gpt-4o"
)
3.4

Memory and Collaboration

CrewAI supports Short-term, Long-term and Entity memory. Agents can share information and build on knowledge accumulated in previous runs.

Project 3

Content Research Crew

A team of 4 agents: researcher, writer, SEO editor, social manager. Receives a topic and produces: a blog article + 5 Twitter posts + a LinkedIn post.

account_tree
Module 4 — 5 hours

LangGraph — Stateful Agent Workflows

LangGraph lets you build Agent workflows as a directed graph — nodes and edges with conditional routing logic. Unlike linear chains, LangGraph supports loops, dynamic routing and persistent state. This is the tool for building complex Agents like ReAct, Plan-and-Execute and reflection loops.

4.1

State Graphs — the base structure of LangGraph

Every LangGraph workflow starts by defining the State — a TypedDict data structure passed between the nodes. Each node receives the state and returns partial updates to it.

from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated
import operator

class AgentState(TypedDict):
    messages: Annotated[list, operator.add]
    current_task: str
    tools_output: str
    iterations: int
    final_answer: str

# defining nodes
def call_llm(state: AgentState) -> dict:
    messages = state["messages"]
    response = llm.invoke(messages)
    return {"messages": [response], "iterations": state["iterations"] + 1}

def run_tools(state: AgentState) -> dict:
    last_msg = state["messages"][-1]
    # run the Tools the model requested
    results = execute_tool_calls(last_msg.tool_calls)
    return {"tools_output": results}

def should_continue(state: AgentState) -> str:
    last_msg = state["messages"][-1]
    if state["iterations"] > 5:
        return "end"  # prevent an infinite loop
    if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
        return "tools"
    return "end"

# building the graph
workflow = StateGraph(AgentState)
workflow.add_node("llm", call_llm)
workflow.add_node("tools", run_tools)

workflow.set_entry_point("llm")
workflow.add_conditional_edges("llm", should_continue, {
    "tools": "tools",
    "end": END
})
workflow.add_edge("tools", "llm")  # loop: tools → llm → tools...

app = workflow.compile()
4.2

ReAct Agent Pattern

Reason + Act — the paradigm where the model thinks out loud, chooses a tool, and integrates the result. Implemented in LangGraph with tool_calls and ToolMessage.

4.3

Checkpointing and Human-in-the-Loop

Saving the agent's state between runs with SqliteSaver. Adding stop points for human approval before critical actions.

4.4

Multi-Agent with LangGraph

Connecting several graphs as sub-graphs — a Supervisor that manages Worker agents. Each worker is an independent graph with its own state.

Project 4

Code Review Agent

An Agent that receives a PR, runs linters, reads code, suggests improvements, and highlights security issues. With human approval before commenting.

rocket_launch
Module 5 — 4 hours

FastAPI Production Deployment

Our agent needs to be accessible. FastAPI is the standard choice for exposing AI Agents as an API — it is asynchronous from the ground up, generates automatic OpenAPI documentation, and integrates perfectly with the async Python we learned. In this module we'll build a full API with authentication, streaming and Docker deployment.

5.1

FastAPI with Streaming SSE

from fastapi import FastAPI, Depends, HTTPException
from fastapi.responses import StreamingResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
import asyncio

app = FastAPI(title="AI Agent API", version="1.0.0")
security = HTTPBearer()

class ChatRequest(BaseModel):
    message: str
    session_id: str = "default"

async def verify_token(
    credentials: HTTPAuthorizationCredentials = Depends(security)
) -> str:
    token = credentials.credentials
    if not is_valid_token(token):
        raise HTTPException(status_code=401, detail="Invalid token")
    return token

@app.post("/chat/stream")
async def chat_stream(
    request: ChatRequest,
    token: str = Depends(verify_token)
):
    async def generate():
        async for chunk in agent.astream({"message": request.message}):
            yield f"data: {chunk}\n\n"
        yield "data: [DONE]\n\n"

    return StreamingResponse(
        generate(),
        media_type="text/event-stream",
        headers={"X-Session-ID": request.session_id}
    )

@app.get("/health")
async def health_check():
    return {"status": "healthy", "model": "gpt-4o"}
5.2

Rate Limiting and Auth

slowapi for rate limiting, JWT tokens with python-jose, Redis for storing sessions. Protection against abuse and unexpected charges.

5.3

Docker and Docker Compose

A Dockerfile tailored for Python AI apps, multi-stage builds to shrink the image, Docker Compose with Redis and PostgreSQL.

5.4

Deploy to Render / Railway

Deploying to Render.com and Railway with automatic CI/CD. Environment variables, custom domain, auto-scaling.

monitor_heart
Module 6 — 2 hours

Monitoring and Observability

AI in production without monitoring is a risk. Costs can soar, answer quality can drop, and errors can slip by without us knowing. This module covers the standard tools: LangSmith for tracing, Prometheus for metrics and Grafana for dashboards.

6.1

LangSmith Tracing

LangSmith lets you see exactly what happens inside every chain and agent — every LLM call, every tool invocation, how long it took and how many tokens were consumed. Setup requires changing just three lines.

import os

# configuring LangSmith — just 3 lines
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = "ls__..."
os.environ["LANGCHAIN_PROJECT"] = "ai-agents-prod"

# from here on — all LangChain code is traced automatically!
# you can view it at: https://smith.langchain.com

# adding custom metadata to each run
from langchain_core.tracers.context import tracing_v2_enabled

with tracing_v2_enabled(project_name="production", tags=["v2.1"]):
    result = chain.invoke({"question": user_question})
6.2

Cost Tracking and costs

Tracking the cost of each conversation, aggregating usage by user_id, alerts when the daily cost exceeds a threshold. Using tiktoken to compute tokens before sending.

import tiktoken

def count_tokens(text: str, model: str = "gpt-4o") -> int:
    enc = tiktoken.encoding_for_model(model)
    return len(enc.encode(text))

# est. cost before API call
tokens = count_tokens(prompt)
est_cost = tokens * 0.000005  # $5/M tokens
6.3

Prometheus + Grafana

Exposing metrics from FastAPI, a Grafana dashboard with latency percentiles (p50, p90, p99), error rates and token usage over time.

from prometheus_client import Counter, Histogram

llm_calls = Counter("llm_calls_total", "Total LLM calls", ["model"])
llm_latency = Histogram("llm_latency_seconds", "LLM latency")
celebration

You finished the course — what now?

Now you have all the tools to build professional AI Agents. Join the community, share the projects you built, and get feedback from other developers.