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.
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.
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)]
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.
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")
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.
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", ...]
Pydantic checks data types and constraints when the object is created — not later at runtime.
Every Pydantic model can generate a JSON Schema that can be sent directly to OpenAI as a tool definition.
The IDE understands the field types and offers autocomplete — fewer bugs, faster development.
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
Always add .env to .gitignore before anything — before git init.
Create a file .env.example with empty keys — so others know which variables are required.
In production — use a secrets manager (AWS Secrets Manager, Doppler, HashiCorp Vault) and not .env.
Set Spending Limits in the OpenAI dashboard — always, without exception.
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())
- checkasyncio.gather for parallel execution
- checkBeautifulSoup for cleaning HTML
- checkStructured Output with Pydantic
- checkToken budget management
- arrow_backAdd saving to a JSON file
- arrow_backAdd rate limiting
- arrow_backSend results via Telegram
- arrow_backSchedule with APScheduler
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.
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})
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.
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"
For short-to-medium conversations. Simple and fast. Downside: loses old context completely. Cost: low and fixed.
For very long conversations. Keeps the general context. An extra cost for summarization — a worthwhile price for better UX.
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
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
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', '?')}")
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.
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"})
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())
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"
)
Memory and Collaboration
CrewAI supports Short-term, Long-term and Entity memory. Agents can share information and build on knowledge accumulated in previous runs.
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.
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.
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()
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.
Checkpointing and Human-in-the-Loop
Saving the agent's state between runs with SqliteSaver. Adding stop points for human approval before critical actions.
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.
Code Review Agent
An Agent that receives a PR, runs linters, reads code, suggests improvements, and highlights security issues. With human approval before commenting.
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.
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"}
Rate Limiting and Auth
slowapi for rate limiting, JWT tokens with python-jose, Redis for storing sessions. Protection against abuse and unexpected charges.
Docker and Docker Compose
A Dockerfile tailored for Python AI apps, multi-stage builds to shrink the image, Docker Compose with Redis and PostgreSQL.
Deploy to Render / Railway
Deploying to Render.com and Railway with automatic CI/CD. Environment variables, custom domain, auto-scaling.
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.
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})
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
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")
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.