Module 2: The Python AI Agent Stack
Frameworks & Environment Setup
Introduction: Standing on the Shoulders of Frameworks
In Module 1, we built an agent from scratch using the raw OpenAI API. That was intentional — understanding the fundamentals matters. But in production, you do not want to manage conversation history, tool execution, error handling, and state management by hand.
That is where frameworks come in. In this module, you will learn the tools that professional AI engineers use every day:
- LangChain: The most widely adopted framework for building LLM applications
- LangGraph: Purpose-built for stateful, multi-step agent workflows
- OpenAI Agents SDK & Anthropic tool use: Alternative approaches worth understanding
By the end of this module, you will have a fully configured development environment and will have built your first LangChain chain.
2.1 LangChain Core: Chains, Tools, and Memory
What is LangChain?
LangChain is a framework for building applications powered by large language models. It provides:
- Standardized interfaces for interacting with any LLM provider
- Tool abstractions for giving agents capabilities
- Memory systems for maintaining context
- Chain composition for building complex workflows from simple pieces
Think of LangChain as the "Express.js of AI" — it does not replace the underlying APIs, but it gives you a structured, productive way to build on top of them.
Chat Models: The Foundation
Everything starts with a chat model — a wrapper around an LLM that provides a consistent interface:
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
# OpenAI
llm_openai = ChatOpenAI(model="gpt-4", temperature=0)
# Anthropic
llm_claude = ChatAnthropic(model="claude-3-sonnet-20240229", temperature=0)
# Same interface, different providers
response = llm_openai.invoke("What is the capital of France?")
print(response.content) # "The capital of France is Paris."
The temperature parameter controls randomness. Use 0 for deterministic, factual tasks and higher values (0.5-1.0) for creative tasks.
Prompt Templates: Structured Input
Hard-coding prompts as strings gets messy fast. Prompt templates let you define reusable, parameterized prompts:
from langchain_core.prompts import ChatPromptTemplate
# Define a reusable prompt template
prompt = ChatPromptTemplate.from_messages([
("system", "You are a {role}. Respond in {language}."),
("user", "{question}")
])
# Use it with different parameters
messages = prompt.invoke({
"role": "helpful travel advisor",
"language": "English",
"question": "What should I visit in Tokyo?"
})
response = llm_openai.invoke(messages)
print(response.content)
Chains: Composing Steps
A chain connects multiple steps into a pipeline. LangChain uses the pipe operator (|) to compose chains in a readable, functional style:
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
# Define each piece
prompt = ChatPromptTemplate.from_messages([
("system", "You are a concise technical writer."),
("user", "Explain {topic} in 3 bullet points.")
])
model = ChatOpenAI(model="gpt-4", temperature=0)
output_parser = StrOutputParser()
# Compose them into a chain using the pipe operator
chain = prompt | model | output_parser
# Run the chain
result = chain.invoke({"topic": "machine learning"})
print(result)
# • Machine learning is a subset of AI...
# • It works by training algorithms on data...
# • Common types include supervised, unsupervised...
What is happening here:
promptformats the user's input into a proper messagemodelsends the message to the LLM and gets a responseoutput_parserextracts the text content from the response object
This is the LCEL (LangChain Expression Language) — a declarative way to build data processing pipelines for LLM applications.
Output Parsers: Structured Responses
Raw LLM output is just text. Output parsers transform it into structured data:
from langchain_core.output_parsers import JsonOutputParser
from pydantic import BaseModel, Field
# Define the structure you want
class MovieReview(BaseModel):
title: str = Field(description="The movie title")
rating: int = Field(description="Rating from 1-10")
summary: str = Field(description="One-sentence summary")
parser = JsonOutputParser(pydantic_object=MovieReview)
prompt = ChatPromptTemplate.from_messages([
("system", "Analyze the movie and respond in JSON format.\n{format_instructions}"),
("user", "Review the movie: {movie}")
])
chain = prompt | model | parser
result = chain.invoke({
"movie": "The Matrix",
"format_instructions": parser.get_format_instructions()
})
print(result)
# {"title": "The Matrix", "rating": 9, "summary": "A groundbreaking sci-fi..."}
print(type(result)) # <class 'dict'>
Memory: Maintaining Context
By default, each LLM call is stateless — the model has no memory of previous interactions. LangChain provides memory systems to maintain conversation context:
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory
# Create the model and prompt
model = ChatOpenAI(model="gpt-4", temperature=0)
prompt = ChatPromptTemplate.from_messages([
("system", "You are a helpful assistant."),
MessagesPlaceholder(variable_name="history"),
("user", "{input}")
])
chain = prompt | model
# Store for conversation histories (keyed by session ID)
store = {}
def get_session_history(session_id: str):
if session_id not in store:
store[session_id] = InMemoryChatMessageHistory()
return store[session_id]
# Wrap the chain with message history
chain_with_history = RunnableWithMessageHistory(
chain,
get_session_history,
input_messages_key="input",
history_messages_key="history"
)
# First message
config = {"configurable": {"session_id": "user-123"}}
response1 = chain_with_history.invoke(
{"input": "My name is Alice and I love hiking."},
config=config
)
print(response1.content)
# "Nice to meet you, Alice! Hiking is a wonderful hobby..."
# Second message — the agent remembers
response2 = chain_with_history.invoke(
{"input": "What outdoor activities would you recommend for me?"},
config=config
)
print(response2.content)
# "Since you love hiking, Alice, you might also enjoy..."
The agent remembers Alice's name and her interest in hiking because the memory system automatically includes previous messages in each new request.
2.2 LangGraph: Stateful Multi-Step Workflows
Why LangGraph?
LangChain chains are great for linear workflows — input goes in, output comes out. But real agents need more:
- Branching: Different actions based on intermediate results
- Looping: Repeating steps until a condition is met
- State management: Tracking information across many steps
- Human-in-the-loop: Pausing for human approval
LangGraph adds these capabilities by modeling agent workflows as state machines — directed graphs where nodes are actions and edges define the flow.
The Core Concepts
from langgraph.graph import StateGraph, START, END
from typing import TypedDict
# 1. Define State — what the agent tracks
class AgentState(TypedDict):
query: str
result: str
steps_taken: int
# 2. Define Nodes — functions that process state
def research(state: AgentState) -> dict:
"""Research node: look up information."""
return {
"result": f"Found info about: {state['query']}",
"steps_taken": state["steps_taken"] + 1
}
def summarize(state: AgentState) -> dict:
"""Summarize node: condense the research."""
return {
"result": f"Summary: {state['result'][:100]}...",
"steps_taken": state["steps_taken"] + 1
}
# 3. Build the Graph — connect nodes with edges
graph = StateGraph(AgentState)
graph.add_node("research", research)
graph.add_node("summarize", summarize)
graph.add_edge(START, "research")
graph.add_edge("research", "summarize")
graph.add_edge("summarize", END)
# 4. Compile and Run
app = graph.compile()
result = app.invoke({
"query": "LangGraph tutorials",
"result": "",
"steps_taken": 0
})
print(result)
# {"query": "LangGraph tutorials", "result": "Summary: Found info about...", "steps_taken": 2}
We will dive deep into LangGraph in Module 4, where you will build a full stateful agent with conditional routing and human-in-the-loop patterns. For now, understand that LangGraph is where you go when your agent needs to be more than a simple chain.
2.3 OpenAI Agents SDK vs Anthropic Tool Use
The Provider Landscape
Beyond LangChain and LangGraph, the major LLM providers offer their own agent-building approaches. Understanding these helps you make informed architectural decisions.
OpenAI Agents SDK
OpenAI provides a dedicated SDK for building agents with built-in tool calling, handoffs between agents, and guardrails:
from openai import OpenAI
client = OpenAI()
# OpenAI's native tool calling
response = client.chat.completions.create(
model="gpt-4",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is the weather in Paris?"}
],
tools=[{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"}
},
"required": ["city"]
}
}
}]
)
Strengths: Tight integration with OpenAI models, simple API, built-in streaming. Limitations: Locked to OpenAI models, less flexibility for complex workflows.
Anthropic Tool Use
Anthropic (Claude) implements tool use with a similar but distinct approach:
from anthropic import Anthropic
client = Anthropic()
response = client.messages.create(
model="claude-3-sonnet-20240229",
max_tokens=1024,
tools=[{
"name": "get_weather",
"description": "Get weather for a city",
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name"
}
},
"required": ["city"]
}
}],
messages=[
{"role": "user", "content": "What is the weather in Paris?"}
]
)
Strengths: Excellent reasoning, long context window, strong at following complex instructions. Limitations: Locked to Anthropic models.
Why We Use LangChain
LangChain abstracts over all of these providers, giving you:
| Feature | Raw SDKs | LangChain |
|---|---|---|
| Provider switching | Rewrite code | Change one line |
| Tool definition | Provider-specific | Unified format |
| Memory management | Build from scratch | Built-in |
| Chain composition | Manual | Pipe operator |
| Community tools | N/A | Hundreds available |
# Switch providers with one line change
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
# Everything else stays the same
llm = ChatOpenAI(model="gpt-4") # OpenAI
# llm = ChatAnthropic(model="claude-3-sonnet-20240229") # Anthropic
chain = prompt | llm | output_parser
result = chain.invoke({"topic": "AI agents"})
This flexibility is why LangChain is the standard choice for production agent development.
2.4 Setting Up Your Development Environment
Prerequisites
Before we begin, make sure you have:
- Python 3.11 or higher (
python --version) - pip (comes with Python)
- A text editor or IDE (VS Code recommended, with the Python extension)
- An OpenAI API key (sign up at platform.openai.com)
Step 1: Create Your Project
# Create and navigate to project directory
# mkdir agentic-ai-course && cd agentic-ai-course
# Create a virtual environment
# python -m venv venv
# Activate it
# source venv/bin/activate (Mac/Linux)
# venv\Scripts\activate (Windows)
Step 2: Install Dependencies
# Core LangChain packages
# pip install langchain langchain-openai langchain-anthropic langchain-core
# LangGraph for stateful workflows
# pip install langgraph
# Utility packages
# pip install python-dotenv pydantic
# Optional but recommended
# pip install tavily-python # Web search tool
# pip install faiss-cpu # Vector database
Step 3: Configure Environment Variables
Create a .env file in your project root:
OPENAI_API_KEY=sk-your-openai-key-here
ANTHROPIC_API_KEY=sk-ant-your-anthropic-key-here
TAVILY_API_KEY=tvly-your-tavily-key-here
Important: Add .env to your .gitignore to keep your API keys out of version control:
# .gitignore
.env
venv/
__pycache__/
Step 4: Verify Your Setup
Create verify_setup.py:
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
load_dotenv()
# Check that the API key is loaded
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
print("ERROR: OPENAI_API_KEY not found in .env file")
exit(1)
print("API key loaded successfully.")
# Test the connection
llm = ChatOpenAI(model="gpt-4", temperature=0)
response = llm.invoke("Say 'Hello, Agent!' and nothing else.")
print(f"LLM response: {response.content}")
print("\nSetup complete! You are ready to build agents.")
Run it:
# python verify_setup.py
If you see "Setup complete!", you are ready to go.
Project: Build Your First LangChain Chain
Now let's build something practical — a chain that takes user input, processes it through an LLM with structured output, and formats the result.
The Task
Build a "Code Reviewer" chain that:
- Accepts a code snippet from the user
- Analyzes it for issues, best practices, and improvements
- Returns structured feedback
The Code
Create code_reviewer.py:
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser
from pydantic import BaseModel, Field
load_dotenv()
# Define the structured output format
class CodeReview(BaseModel):
language: str = Field(description="The programming language of the code")
issues: list[str] = Field(description="List of issues found in the code")
suggestions: list[str] = Field(description="List of improvement suggestions")
rating: int = Field(description="Code quality rating from 1 to 10")
summary: str = Field(description="One-paragraph summary of the review")
# Set up the output parser
parser = JsonOutputParser(pydantic_object=CodeReview)
# Create the prompt template
prompt = ChatPromptTemplate.from_messages([
(
"system",
"You are an expert code reviewer. Analyze the provided code snippet "
"and provide a detailed review. Be constructive and specific.\n\n"
"{format_instructions}"
),
(
"user",
"Please review this code:\n\n```{language}\n{code}\n```"
)
])
# Create the model
model = ChatOpenAI(model="gpt-4", temperature=0)
# Compose the chain
chain = prompt | model | parser
def review_code(code: str, language: str = "python") -> dict:
"""Review a code snippet and return structured feedback."""
result = chain.invoke({
"code": code,
"language": language,
"format_instructions": parser.get_format_instructions()
})
return result
def display_review(review: dict) -> None:
"""Display the review in a readable format."""
print(f"\n{'=' * 60}")
print(f"CODE REVIEW — {review['language'].upper()}")
print(f"Rating: {review['rating']}/10")
print(f"{'=' * 60}")
print(f"\nSummary:\n{review['summary']}")
if review["issues"]:
print(f"\nIssues Found ({len(review['issues'])}):")
for i, issue in enumerate(review["issues"], 1):
print(f" {i}. {issue}")
if review["suggestions"]:
print(f"\nSuggestions ({len(review['suggestions'])}):")
for i, suggestion in enumerate(review["suggestions"], 1):
print(f" {i}. {suggestion}")
print(f"\n{'=' * 60}")
def main():
# Example code to review
sample_code = """
def get_user_data(id):
import requests
r = requests.get(f"http://api.example.com/users/{id}")
data = r.json()
name = data['name']
email = data['email']
age = data['age']
return {"name": name, "email": email, "age": age, "raw": data}
"""
print("Reviewing code snippet...")
review = review_code(sample_code, "python")
display_review(review)
# Interactive mode
print("\nPaste your own code to review (type 'done' on a new line to submit):")
lines = []
while True:
line = input()
if line.strip().lower() == "done":
break
lines.append(line)
if lines:
user_code = "\n".join(lines)
print("\nReviewing your code...")
review = review_code(user_code)
display_review(review)
if __name__ == "__main__":
main()
Run It
# python code_reviewer.py
What You Built
This project demonstrates the core LangChain patterns:
- Prompt Template: Reusable, parameterized prompt with system instructions
- Chat Model: The LLM that processes the prompt
- Output Parser: Transforms raw text into a structured Python dictionary
- Chain Composition: All three pieces connected with the pipe operator
- Pydantic Schema: Type-safe definition of the expected output structure
This is the foundation of every LangChain application. Chains are composable — you can take this code review chain and plug it into a larger workflow that, for example, reads files from a repository, reviews each one, and generates a summary report.
Key Takeaways
- LangChain provides standardized interfaces for LLMs, prompts, output parsing, and memory
- Chains compose steps with the pipe operator:
prompt | model | parser - LangGraph extends LangChain for stateful workflows with branching and looping
- Provider flexibility means you can switch between OpenAI, Anthropic, and others with minimal code changes
- Structured output with Pydantic schemas turns unstructured LLM text into typed Python objects
Exercises
Before moving to Module 3, try these challenges:
-
Swap providers: Change the code reviewer to use Anthropic Claude instead of GPT-4. How does the output differ? (Hint: just change the import and model initialization.)
-
Add memory: Extend the code reviewer so it remembers previous reviews in the same session. Ask it to compare the current code with previously reviewed code.
-
Chain of chains: Build a second chain that takes the review output and generates a refactored version of the code. Pipe the reviewer's output into the refactoring chain.
-
Custom output schema: Modify the
CodeReviewPydantic model to include additional fields likesecurity_issues,performance_notes, andtest_suggestions. -
Streaming: Modify the chain to use streaming output so you can see the review being generated in real time. (Hint: use
chain.stream()instead ofchain.invoke().)
Next up: Module 3, where we give agents real power by implementing tool calling with Pydantic schemas and the @tool decorator.

