Complete Roadmap to Become an Agentic AI Developer
A 14-phase, example-driven path from Python fundamentals to production multi-agent systems. Written for developers who already program. Phase 1 is a full Python reference; every later phase gives you the concepts, tools, and code to move forward.
Overview Map
Fourteen phases, roughly in order. The first four are classic backend skills that every agentic system rests on; phases 5–11 are the AI-specific core; the last three take you to production. Click any card to jump in.
1. Variables
Python variables are names bound to objects. You don't declare a type — a name simply points at a value, and it can be rebound to a value of any type later. There is no let, var, or type keyword.
x = 10 # x points at an int object
x = "now text" # same name, now bound to a str — perfectly legal
a = b = c = 0 # chained assignment
x, y = 1, 2 # tuple unpacking
x, y = y, x # swap without a temp variable
Naming rules and conventions
Names are case-sensitive, must start with a letter or underscore, and can contain letters, digits, and underscores. Convention (PEP 8) is snake_case for variables and functions, UPPER_CASE for constants, and PascalCase for classes.
id(x) returns an object's identity (its memory address in CPython), and type(x) returns its type.References, not copies
Assignment binds a name to an object; it does not copy. This matters for mutable objects.
a = [1, 2, 3]
b = a # b and a point at the SAME list
b.append(4)
print(a) # [1, 2, 3, 4] — a changed too!
import copy
c = a.copy() # shallow copy — independent top-level list
d = copy.deepcopy(a) # recursively copies nested objects too
Scope: LEGB
Name lookup follows Local → Enclosing → Global → Built-in. Use global to rebind a module-level name inside a function, and nonlocal to rebind a name in an enclosing function.
count = 0
def bump():
global count
count += 1 # without 'global', this would raise UnboundLocalError
2. Data Types
Python is dynamically typed (types are checked at runtime) but strongly typed (it won't silently coerce "3" + 4). The core built-in types:
| Category | Types | Mutable? | Example |
|---|---|---|---|
| Numeric | int, float, complex | No | 42, 3.14, 2+3j |
| Boolean | bool | No | True, False |
| Text | str | No | "hello" |
| Sequence | list, tuple, range | list only | [1,2], (1,2) |
| Mapping | dict | Yes | {"a": 1} |
| Set | set, frozenset | set only | {1, 2} |
| Binary | bytes, bytearray | bytearray | b"abc" |
| None | NoneType | — | None |
Numbers
n = 10 # int — unbounded precision, no overflow
big = 2 ** 1000 # still an exact int
f = 3.14 # float — 64-bit IEEE 754
underscored = 1_000_000 # underscores for readability
hexv, octv, binv = 0xFF, 0o17, 0b1010
print(0.1 + 0.2) # 0.30000000000000004 — float rounding!
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2")) # exact 0.3
Type conversion & checking
int("42") # 42
float(3) # 3.0
str(3.14) # "3.14"
bool(0) # False — 0, "", [], {}, None are all falsy
list("abc") # ['a', 'b', 'c']
type(x) is int # exact type check
isinstance(x, (int, float)) # preferred — respects inheritance
bool is a subclass of int, so True == 1 and isinstance(True, int) are both True.Type hints
Hints are optional annotations — Python does not enforce them at runtime, but tools like mypy and IDEs use them.
def greet(name: str, times: int = 1) -> str:
return ("Hi " + name + " ") * times
nums: list[int] = [1, 2, 3]
3. Operators
Arithmetic
7 / 2 # 3.5 true division — always float
7 // 2 # 3 floor division
7 % 2 # 1 modulo (remainder)
2 ** 10 # 1024 exponent
-7 // 2 # -4 floors toward negative infinity
Comparison, boolean, identity, membership
1 < x <= 10 # chained comparison — very Pythonic
a and b, a or b, not a # short-circuit logic
x is None # identity — use 'is' for None, not ==
"a" in ["a", "b"] # membership — True
and/or return an operand, not a bool. name or "guest" yields "guest" when name is empty.Assignment, walrus, bitwise
x += 1 # augmented: also -= *= /= //= **= %=
if (n := len(data)) > 10: # walrus := assigns AND returns (3.8+)
print(n)
5 & 3, 5 | 3, 5 ^ 3, ~5, 1 << 4 # bitwise AND OR XOR NOT shift
4. Conditions
Indentation defines blocks — there are no braces. The standard PEP 8 indent is 4 spaces.
if score >= 90:
grade = "A"
elif score >= 80:
grade = "B"
else:
grade = "F"
# Ternary (conditional expression)
label = "pass" if score >= 60 else "fail"
Truthiness
Any object can be tested. Falsy values: False, None, 0, 0.0, "", [], {}, (), set(). Everything else is truthy.
if items: # idiomatic — don't write 'if len(items) > 0'
process(items)
match / case (structural pattern matching, 3.10+)
def describe(point):
match point:
case (0, 0):
return "origin"
case (x, 0):
return f"on x-axis at {x}"
case (x, y) if x == y:
return "on diagonal"
case _:
return "somewhere else"
5. Loops
for — iterates over any iterable
for item in ["a", "b", "c"]:
print(item)
for i in range(0, 10, 2): # start, stop (exclusive), step → 0 2 4 6 8
...
for i, val in enumerate(items, start=1): # index + value
print(i, val)
for name, age in zip(names, ages): # parallel iteration
...
for x in items) over for i in range(len(items)). Reach for enumerate when you need the index.while, break, continue, else
while queue:
job = queue.pop()
if job.bad:
continue # skip to next iteration
if job.stop:
break # exit loop entirely
# loop-else runs ONLY if the loop finished without hitting break
for n in range(2, x):
if x % n == 0:
break
else:
print("prime!") # no divisor found
6. Functions
def area(width, height=1): # height has a default
"""Docstring: returns rectangle area."""
return width * height
area(3) # positional → 3
area(3, height=4) # keyword argument → 12
*args and **kwargs
def demo(*args, **kwargs):
print(args) # tuple of extra positional args
print(kwargs) # dict of extra keyword args
demo(1, 2, x=10) # args=(1, 2) kwargs={'x': 10}
nums = [1, 2, 3]
print(*nums) # unpacking: same as print(1, 2, 3)
Positional-only and keyword-only parameters
def f(pos_only, /, normal, *, kw_only):
...
# / marks end of positional-only; * marks start of keyword-only
def bad(item, bucket=[]): # SHARED across all calls!
bucket.append(item); return bucket
bad(1); bad(2) # [1, 2] — not what you want
def good(item, bucket=None):
if bucket is None: bucket = []
bucket.append(item); return bucketReturning multiple values
def min_max(xs):
return min(xs), max(xs) # actually returns a tuple
lo, hi = min_max([3, 1, 9]) # unpacked
Functions are first-class objects: assign them to variables, pass them as arguments, and return them. This underpins decorators, map/filter, and callbacks.
7. Strings
Strings are immutable sequences of Unicode characters. Any "modifying" method returns a new string.
s = "Hello, World"
s[0] # 'H' indexing
s[-1] # 'd' negative index from the end
s[0:5] # 'Hello' slicing [start:stop:step]
s[::-1] # 'dlroW ,olleH' reverse
len(s) # 12
f-strings (formatted string literals, 3.6+)
name, pi = "Ada", 3.14159
f"Hi {name}, pi is {pi:.2f}" # 'Hi Ada, pi is 3.14'
f"{name=}" # "name='Ada'" self-documenting (3.8+)
f"{42:>8}" # right-align in width 8
f"{1234567:,}" # '1,234,567' thousands separator
Common methods
" hi ".strip() # 'hi' (also lstrip/rstrip)
"a,b,c".split(",") # ['a', 'b', 'c']
"-".join(["a", "b"]) # 'a-b'
"Hello".replace("l", "L") # 'HeLLo'
"Hello".lower(), "hi".upper()
"file.txt".endswith(".txt") # True (also startswith)
"abc".find("b") # 1 (-1 if not found)
"42".isdigit(), "ab".isalpha()
Raw, multi-line, and byte strings
path = r"C:\new\test" # raw — backslashes are literal
doc = """line 1
line 2""" # triple-quoted spans lines
data = b"bytes" # bytes, not str; .decode()/.encode() to convert
list and "".join(parts) at the end — concatenating with += repeatedly is O(n²).8. Lists
A list is an ordered, mutable sequence — a dynamic array under the hood. It can hold mixed types, though homogeneous lists are more common.
xs = [1, 2, 3]
xs[0] = 99 # mutable: [99, 2, 3]
xs[1:3] # slice → [2, 3] (returns a NEW list)
xs[::-1] # reversed copy
Core methods
xs.append(4) # add one item to the end
xs.extend([5, 6]) # add many (not append, which would nest)
xs.insert(0, 9) # insert at index
xs.pop() # remove & return last (or pop(i))
xs.remove(9) # remove first matching value
xs.index(5) # first position of value
xs.count(2) # number of occurrences
xs.reverse() # in place
xs.sort() # in place; sorted(xs) returns a new list
xs.sort(key=len, reverse=True) # sort by a key function
Building & copying
grid = [[0] * 3 for _ in range(3)] # 3x3 — correct way
# DON'T: [[0]*3]*3 → three references to the SAME inner list
shallow = xs[:] # or xs.copy() / list(xs)
collections.deque instead.9. Tuples
A tuple is an ordered, immutable sequence. Because it's immutable it's hashable, so tuples can be dict keys and set members.
t = (1, "two", 3.0)
t[0] # 1 — reading is fine
# t[0] = 9 → TypeError: 'tuple' object does not support item assignment
one = (5,) # single-element tuple NEEDS the trailing comma
not_tuple = (5) # just the int 5
empty = () # empty tuple
bare = 1, 2, 3 # parentheses are optional
Unpacking & multiple return
x, y, z = (1, 2, 3)
first, *rest = [1, 2, 3, 4] # first=1, rest=[2,3,4] (star capture)
def stats(): return min(d), max(d) # returns a tuple
Named tuples — readable, still lightweight
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
p.x, p.y # attribute access → 3, 4
p[0] # still indexable → 3
t = ([1], 2); t[0].append(9) is allowed.10. Dictionaries
A dict maps hashable keys to values. Since Python 3.7 insertion order is guaranteed. Lookup, insert, and delete are average O(1).
user = {"name": "Ada", "age": 36}
user["name"] # 'Ada' — KeyError if missing
user.get("email") # None — safe, no error
user.get("email", "n/a") # default if missing
user["age"] = 37 # insert or update
del user["age"] # remove key
"name" in user # membership tests KEYS → True
Iterating
for key in user: # iterates keys by default
...
for k, v in user.items(): # key/value pairs
print(k, v)
user.keys(), user.values() # view objects
Useful patterns
merged = {**a, **b} # merge (b wins on conflicts)
merged = a | b # same, 3.9+
user.setdefault("tags", []).append("x") # get-or-create
from collections import defaultdict, Counter
groups = defaultdict(list) # missing key auto-creates []
groups["a"].append(1)
Counter("mississippi") # {'i':4,'s':4,'p':2,'m':1}
# dict comprehension
squares = {n: n*n for n in range(5)}
11. Sets
A set is an unordered collection of unique, hashable elements. Membership testing is average O(1) — far faster than scanning a list.
s = {1, 2, 3}
empty = set() # {} is an empty DICT, not a set!
s.add(4)
s.discard(9) # no error if absent (remove() raises)
2 in s # fast membership → True
set([1, 1, 2, 2]) # {1, 2} — dedup an iterable
Set algebra
a, b = {1, 2, 3}, {2, 3, 4}
a | b # union {1, 2, 3, 4}
a & b # intersection {2, 3}
a - b # difference {1}
a ^ b # symmetric difference {1, 4}
a <= b # subset test
frozenset is the immutable, hashable version — usable as a dict key or an element of another set. Sets and their comprehensions ({x*x for x in nums}) are the idiomatic way to dedup and to test overlap between collections.
12. File Handling
Always use the with statement — it guarantees the file is closed even if an error occurs.
with open("data.txt", "r", encoding="utf-8") as f:
content = f.read() # whole file as one string
with open("data.txt") as f:
for line in f: # memory-efficient — streams line by line
print(line.rstrip())
Modes
| Mode | Meaning |
|---|---|
"r" | read (default); error if file missing |
"w" | write; truncates existing file |
"a" | append to end |
"x" | create; error if it already exists |
"b" / "t" | binary / text (add to another mode, e.g. "rb") |
"r+" | read and write |
Writing
with open("out.txt", "w") as f:
f.write("one line\n") # write does NOT add a newline
f.writelines(["a\n", "b\n"])
pathlib — the modern path API
from pathlib import Path
p = Path("logs") / "app.txt" # / operator joins paths
p.exists(), p.suffix, p.stem, p.parent
text = p.read_text(encoding="utf-8") # one-liner read
p.write_text("hi") # one-liner write
Structured formats
import json
data = json.loads('{"a": 1}') # str → dict (load() from a file)
text = json.dumps(data, indent=2) # dict → str (dump() to a file)
import csv
with open("t.csv", newline="") as f:
for row in csv.DictReader(f): # each row is a dict keyed by header
print(row["name"])
13. Exception Handling
try:
result = 10 / x
except ZeroDivisionError:
print("cannot divide by zero")
except (TypeError, ValueError) as e: # catch several; bind to e
print(f"bad input: {e}")
else:
print("ran only if no exception")
finally:
print("always runs — cleanup")
Raising and re-raising
if age < 0:
raise ValueError(f"age must be non-negative, got {age}")
try:
risky()
except Exception as e:
log(e)
raise # re-raise the same exception, keeping traceback
raise RuntimeError("wrapped") from e # chain with cause
Custom exceptions
class InsufficientFundsError(Exception):
"""Raised when a withdrawal exceeds the balance."""
raise InsufficientFundsError("balance too low")
except: (it swallows KeyboardInterrupt and system exits) — catch Exception at least. Don't use exceptions for ordinary control flow, and don't silence errors with an empty except: pass.try: d[k] except KeyError — over "Look Before You Leap" checks. It's idiomatic and avoids race conditions.14. Modules
A module is any .py file; a package is a directory of modules (historically containing __init__.py).
import math
math.sqrt(16)
from math import sqrt, pi # bring specific names into scope
from math import sqrt as s # alias
import numpy as np # conventional alias
from mypackage.submod import helper # dotted path into a package
Your own module & the __main__ guard
# file: calc.py
def add(a, b): return a + b
if __name__ == "__main__":
# runs only when executed directly (python calc.py),
# NOT when imported by another module
print(add(2, 3))
Python searches for modules along sys.path (the script's directory, then PYTHONPATH, then installed packages). Standard-library highlights worth knowing: os, sys, datetime, collections, itertools, functools, random, re, json, pathlib.
15. Object-Oriented Programming
class Account:
bank = "Acme" # class attribute — shared by all instances
def __init__(self, owner, balance=0):
self.owner = owner # instance attributes
self.balance = balance
def deposit(self, amount): # instance method — self is the instance
self.balance += amount
return self.balance
acc = Account("Ada", 100)
acc.deposit(50) # 150
Inheritance & super()
class Savings(Account):
def __init__(self, owner, rate):
super().__init__(owner) # call parent constructor
self.rate = rate
def deposit(self, amount): # override
super().deposit(amount * (1 + self.rate))
Dunder methods (operator overloading)
class Vector:
def __init__(self, x, y): self.x, self.y = x, y
def __repr__(self): return f"Vector({self.x}, {self.y})"
def __add__(self, o): return Vector(self.x+o.x, self.y+o.y)
def __eq__(self, o): return (self.x, self.y) == (o.x, o.y)
Vector(1, 2) + Vector(3, 4) # Vector(4, 6)
Other key dunders: __str__ (user-facing string), __len__, __getitem__ (indexing), __iter__/__next__ (iteration), __call__ (make an instance callable), __enter__/__exit__ (context managers).
Properties & encapsulation
class Circle:
def __init__(self, r): self._r = r # _leading underscore = "internal"
@property
def area(self): # call as circle.area, no parentheses
return 3.14159 * self._r ** 2
@area.setter # optional matching setter
def area(self, value): ...
dataclasses — boilerplate-free classes
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
qty: int = 0
# auto-generates __init__, __repr__, __eq__
Product("pen", 1.5)
_x signals "internal"; two leading underscores __x trigger name-mangling to avoid subclass clashes. There's also @classmethod (receives the class as cls) and @staticmethod (receives neither).16. Virtual Environments
A virtual environment is an isolated Python installation with its own packages, so each project's dependencies don't collide. Create one per project.
# create — makes a ./venv folder
python -m venv venv
# activate
source venv/bin/activate # macOS / Linux
venv\Scripts\activate # Windows
# your prompt now shows (venv); python & pip point inside it
deactivate # leave the environment
Django 4 while Project B stays on Django 5. Add venv/ to your .gitignore — you commit the requirements file, not the environment.Popular alternatives you'll encounter: conda (data-science focused, manages non-Python deps too), pipenv, poetry, and the fast newcomer uv (uv venv, uv pip install).
17. pip & Packages
pip is Python's package installer, pulling from PyPI (the Python Package Index).
pip install requests # latest version
pip install "requests==2.31.0" # exact pin
pip install "requests>=2.28,<3" # range
pip install --upgrade requests # upgrade
pip uninstall requests
pip list # installed packages
pip show requests # details for one package
requirements.txt — reproducible installs
# freeze current environment into a file
pip freeze > requirements.txt
# recreate elsewhere
pip install -r requirements.txt
# requirements.txt contents
requests==2.31.0
pandas>=2.0
python-dotenv
pip install inside an activated virtual environment, never system-wide. If a command reports permission errors and suggests sudo pip, that's a sign you forgot to activate your venv.Modern projects increasingly declare dependencies in pyproject.toml instead of requirements.txt, managed by tools like poetry or uv. Both approaches solve the same problem: pinning exactly what your project needs.
18. Comprehensions
A concise, faster way to build a list, set, or dict from an iterable. Read left to right: expression, then loop, then optional filter.
# list comprehension
squares = [n*n for n in range(10)]
evens = [n for n in range(10) if n % 2 == 0] # with filter
labels = ["even" if n%2==0 else "odd" for n in range(5)] # ternary in the expr
# nested — flatten a 2D list
flat = [x for row in matrix for x in row]
# dict and set comprehensions
sq_map = {n: n*n for n in range(5)}
uniq = {c for c in "mississippi"} # {'m','i','s','p'}
for loop is clearer. A comprehension with side effects (calling a function only for its effect) is an anti-pattern — use a loop.19. Lambda
An anonymous, single-expression function. lambda args: expression — no return, no statements, just one expression that becomes the result.
square = lambda x: x * x
square(5) # 25
add = lambda a, b=0: a + b # defaults allowed
Their real use is as short throwaway functions passed to other functions:
people.sort(key=lambda p: p.age) # sort by a computed key
max(words, key=lambda w: len(w)) # longest word
sorted(items, key=lambda t: (t[1], t[0])) # multi-key sort
f = lambda x: ...) — just use def, which gives a proper name in tracebacks. Lambdas shine only when inline and disposable.20. map / filter
map applies a function to every item; filter keeps items where a predicate is true. Both return lazy iterators, so wrap in list() to materialize.
nums = [1, 2, 3, 4]
list(map(lambda x: x*x, nums)) # [1, 4, 9, 16]
list(filter(lambda x: x % 2 == 0, nums)) # [2, 4]
list(map(str.upper, ["a", "b"])) # ['A', 'B'] — pass any callable
list(map(lambda a, b: a+b, [1,2], [10,20])) # [11, 22] — multiple iterables
map/filter with a lambda: [x*x for x in nums] beats map(lambda x: x*x, nums). Reach for map when you already have a named function to apply (map(int, tokens)). Also look at functools.reduce for folding a sequence into one value.21. Generators
A generator produces values lazily, one at a time, holding only the current value in memory — ideal for large or infinite sequences. Any function with yield is a generator function.
def countdown(n):
while n > 0:
yield n # pauses here, resumes on next()
n -= 1
for x in countdown(3): # 3, 2, 1
print(x)
gen = countdown(3)
next(gen) # 3 — pull one value manually
Generator expressions
Like a list comprehension but with parentheses — evaluated lazily, no intermediate list built.
total = sum(n*n for n in range(1_000_000)) # no million-item list in memory
first_big = next(x for x in data if x > 1000) # stop at first match
itertools module (count, cycle, islice, chain, groupby) is built around this model.22. Decorators
A decorator is a function that takes a function and returns a modified function — a clean way to wrap behavior (logging, timing, caching, auth) around existing code. @name is syntactic sugar.
import functools
def timer(func):
@functools.wraps(func) # preserves func's name/docstring
def wrapper(*args, **kwargs):
import time
start = time.perf_counter()
result = func(*args, **kwargs) # call the original
print(f"{func.__name__} took {time.perf_counter()-start:.4f}s")
return result
return wrapper
@timer
def slow():
return sum(range(10_000_000))
# @timer means: slow = timer(slow)
slow() # prints "slow took 0.12s"
Decorators with arguments
def repeat(times): # outer takes the argument
def deco(func):
@functools.wraps(func)
def wrapper(*a, **k):
for _ in range(times):
r = func(*a, **k)
return r
return wrapper
return deco
@repeat(3)
def greet(): print("hi")
Common built-in decorators: @property, @staticmethod, @classmethod, and @functools.lru_cache (memoize results automatically).
23. Context Managers
A context manager defines setup and teardown around a block via the with statement — guaranteeing cleanup even on exceptions. You've already used one: open().
with open("f.txt") as f:
data = f.read()
# file is closed here automatically, even if read() raised
# manage several at once
with open("in.txt") as src, open("out.txt", "w") as dst:
dst.write(src.read())
Writing one — the class protocol
class Timer:
def __enter__(self): # runs at 'with' entry; return value → 'as' target
import time
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_val, tb): # runs at exit, even on error
import time
self.elapsed = time.perf_counter() - self.start
# return True to SUPPRESS an exception; False/None re-raises it
with Timer() as t:
do_work()
print(t.elapsed)
The easy way — contextlib
from contextlib import contextmanager
@contextmanager
def tag(name):
print(f"<{name}>")
yield # everything before = __enter__, after = __exit__
print(f"</{name}>")
with tag("b"):
print("bold") # prints <b> / bold / </b>
with lock:), or temporarily changing state. Put the yield inside a try/finally if cleanup must run even when the block raises.Git & GitHub
Version control is non-negotiable. Learn the everyday loop and branching.
git init
git add . # stage changes
git commit -m "message"
git switch -c feature # create & switch to a branch
git push origin feature
# then open a Pull Request on GitHub for review & merge
Understand: commits, branches, merge vs rebase, pull requests, and resolving conflicts. Keep a .gitignore (never commit venv/, .env, secrets).
The web layer: HTTP, REST, JSON
HTTP is request/response over methods (GET, POST, PUT, DELETE) and status codes (2xx ok, 4xx client error, 5xx server error). A REST API exposes resources at URLs and speaks JSON, which maps cleanly onto Python dicts/lists.
import requests
r = requests.get("https://api.github.com/users/torvalds")
r.status_code # 200
data = r.json() # dict parsed from JSON body
Authentication: OAuth, JWT, webhooks
Authentication proves who you are; authorization is what you may do. Two patterns you'll meet constantly:
OAuth 2.0 — the "Log in with Google/Slack" flow. Your app never sees the password; it receives a scoped access token to call the API on the user's behalf. JWT (JSON Web Token) — a signed, self-contained token (header.payload.signature) the server can verify without a database lookup. Webhooks invert the model: instead of you polling an API, the service POSTs an event to a URL you expose (e.g. "a message was posted in Slack").
Config, logging, testing, debugging
# environment variables keep secrets out of code
import os
from dotenv import load_dotenv # reads a local .env file
load_dotenv()
key = os.getenv("OPENAI_API_KEY")
# logging beats print() — levels, timestamps, redirectable
import logging
logging.basicConfig(level=logging.INFO)
log = logging.getLogger(__name__)
log.info("agent started")
# testing with pytest — the de facto standard
def test_add():
assert add(2, 3) == 5
# run: pytest -q
For debugging, learn breakpoint() (drops into pdb) and your IDE's debugger — stepping through beats scattering prints.
Project structure
A clean layout pays off the moment a project grows. A common agentic shape:
SQL — the core skill
SELECT name, COUNT(*) AS orders
FROM customers c
JOIN purchases p ON p.customer_id = c.id
WHERE c.active = true
GROUP BY name
ORDER BY orders DESC;
Master: SELECT/WHERE, JOIN (inner/left), GROUP BY with aggregates, ORDER BY, and then the intermediate trio:
-- CTE: name a subquery for readability
WITH top_customers AS (
SELECT customer_id, SUM(amount) AS spend
FROM purchases GROUP BY customer_id
)
SELECT * FROM top_customers WHERE spend > 1000;
-- Window function: rank without collapsing rows
SELECT name, amount,
RANK() OVER (PARTITION BY region ORDER BY amount DESC) AS rnk
FROM sales;
Indexes make lookups fast by trading write speed and disk for read speed — add them on columns you filter or join on.
Which database when
| Type | Pick | Use for |
|---|---|---|
| Relational | PostgreSQL | Production default; rich features, JSON columns, vector extension (pgvector) |
| Relational (embedded) | SQLite | Local dev, prototypes, single-file storage — zero setup |
| Document (NoSQL) | MongoDB | Flexible/nested schemas, rapid iteration |
| Cache / in-memory | Redis | Session state, rate limiting, caching LLM responses, queues |
# SQLite ships with Python — great for learning
import sqlite3
conn = sqlite3.connect("app.db")
conn.execute("CREATE TABLE IF NOT EXISTS notes(id INTEGER PRIMARY KEY, body TEXT)")
conn.execute("INSERT INTO notes(body) VALUES (?)", ("hello",)) # ? prevents SQL injection
conn.commit()
Every service worth automating has an API: Google Ads, Meta, Slack, Jira, GitHub, Gmail, and thousands more. The pattern is always the same — authenticate, send a request, parse the JSON, handle errors.
Three Python HTTP clients
| Library | When |
|---|---|
requests | Simple, synchronous, the classic choice for scripts |
httpx | Modern; sync and async, HTTP/2 — good default for new code |
aiohttp | Fully async; many concurrent calls (e.g. fan-out to many APIs) |
# typical authenticated call
import httpx
headers = {"Authorization": f"Bearer {token}"}
r = httpx.post("https://slack.com/api/chat.postMessage",
headers=headers,
json={"channel": "#general", "text": "deploy done"})
r.raise_for_status() # raise on 4xx/5xx
# async fan-out with aiohttp
import asyncio, aiohttp
async def fetch(session, url):
async with session.get(url) as resp:
return await resp.json()
| Concept | What to understand |
|---|---|
| LLM | A model that predicts the next token; through scale it appears to reason, summarize, and write code |
| Tokens | Sub-word chunks the model reads/writes; you pay per token and limits are counted in tokens (~4 chars ≈ 1 token in English) |
| Context window | Max tokens the model can "see" at once (prompt + response). Exceed it and older content is dropped |
| Embeddings | Text → a vector of numbers capturing meaning; similar text → nearby vectors. The basis of search & RAG |
| Temperature | Randomness knob: 0 = deterministic/focused, higher = more creative/varied |
| Hallucination | Confident but wrong output; mitigated by grounding the model in real data (RAG) and tools |
| Function / tool calling | The model returns a structured request to call your code; you run it and feed the result back. This is what makes an "agent" |
| RAG | Retrieve relevant documents, then let the LLM answer from them (Phase 10) |
| Fine-tuning | Further-training a model on your data. Conceptually useful, rarely your first move — prompting + RAG usually suffice |
# the shape of a modern chat + tool-use call (OpenAI-style)
from openai import OpenAI
client = OpenAI()
resp = client.chat.completions.create(
model="gpt-4o",
temperature=0,
messages=[{"role": "user", "content": "Summarize this ticket..."}],
)
print(resp.choices[0].message.content)
Techniques
Role prompting sets behavior ("You are a senior SQL reviewer..."). Few-shot prompting shows 2–5 input/output examples so the model matches the pattern. Chain of thought is the idea that reasoning step-by-step improves hard tasks — understand the concept, though with modern models you generally don't need to ask them to expose their reasoning. Structured / JSON output forces machine-parseable results. Tool-calling and planning prompts direct the model to pick tools or lay out steps.
# role + few-shot + structured output, all in one
SYSTEM = """You are a support classifier.
Return ONLY JSON: {"category": str, "urgent": bool}."""
EXAMPLES = """
Input: "My payment failed twice" -> {"category": "billing", "urgent": true}
Input: "How do I change my avatar?" -> {"category": "account", "urgent": false}
"""
pydantic model (or the provider's "structured output" / "JSON mode") so malformed responses fail loudly instead of corrupting downstream steps.from pydantic import BaseModel
class Ticket(BaseModel):
category: str
urgent: bool
Ticket.model_validate_json(llm_output) # raises if the shape is wrong
| Framework | Sweet spot |
|---|---|
| LangChain | Broad ecosystem of integrations; good for chaining LLM calls and tools |
| LangGraph | Graph-based control flow for stateful, cyclic agent workflows — strong for complex multi-step/multi-agent logic |
| OpenAI Agents SDK | Lightweight, first-party agents + tools + handoffs; great starting point |
| LlamaIndex | RAG-first: ingestion, indexing, retrieval over your data |
| CrewAI | Role-based crews of collaborating agents; quick to prototype teams |
| AutoGen | Conversational multi-agent orchestration (Microsoft) |
Whichever you pick, the concepts transfer. Make sure you can explain and build: agents (an LLM in a loop with tools), tools (functions the agent can call), memory (state across turns), routing (choosing the next step/agent), multi-agent systems (specialists coordinated by a manager), and human approval steps (pause for a person before a risky action).
# OpenAI Agents SDK — an agent with one tool
from agents import Agent, function_tool
@function_tool
def get_weather(city: str) -> str:
return f"Sunny in {city}"
agent = Agent(
name="Assistant",
instructions="Help the user. Use tools when needed.",
tools=[get_weather],
)
Common tools to build: Google Search, Slack, Google Ads, Meta Ads, database query, calculator, email, browser, GitHub, Jira. Each wraps an API or capability behind a clear name, typed arguments, and a docstring the model reads to decide when to use it.
from agents import function_tool
import httpx
@function_tool
def send_slack_message(channel: str, text: str) -> str:
"""Post a message to a Slack channel. Use for notifications."""
r = httpx.post("https://slack.com/api/chat.postMessage",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"channel": channel, "text": text})
return "sent" if r.json().get("ok") else "failed"
| Kind | Holds | Typical store |
|---|---|---|
| Short-term | Current conversation history | A list of messages in the context window; Redis for sessions |
| Long-term | User preferences, facts learned over time | SQL / document DB, keyed by user |
| Knowledge | Documents the agent can search | Vector database (embeddings) |
For knowledge and semantic recall you store embeddings in a vector database and retrieve by similarity. Common choices: Chroma (easy, local/open-source), FAISS (fast in-memory library from Meta), and Pinecone (managed cloud service).
# short-term memory is often just a growing message list
history = [
{"role": "user", "content": "My name is Ada"},
{"role": "assistant", "content": "Nice to meet you, Ada"},
]
# trim or summarize old turns before you exceed the context window
The pipeline, step by step
Chunking splits documents into passages small enough to embed and retrieve (e.g. 500–1000 tokens with slight overlap). Embeddings turn each chunk into a vector. Vector search finds chunks nearest to the question's vector. Retrieval injects those chunks into the prompt. Re-ranking (optional at first) reorders candidates with a stronger model for higher precision.
# minimal RAG with Chroma
import chromadb
client = chromadb.Client()
col = client.create_collection("docs")
col.add(documents=["Refunds take 5-7 days.", "We ship worldwide."],
ids=["d1", "d2"]) # Chroma embeds for you
hits = col.query(query_texts=["how long for a refund?"], n_results=1)
context = hits["documents"][0][0] # -> "Refunds take 5-7 days."
# then: prompt the LLM with `context` + the user question
Each agent has a specialized role and only the tools it needs. The manager (or a router) decides who acts next, passes results between them, and decides when the task is done. This mirrors how a human team divides labor — and keeps each prompt focused and reliable.
# handoff pattern (OpenAI Agents SDK)
from agents import Agent
researcher = Agent(name="Researcher", instructions="Gather facts.")
writer = Agent(name="Writer", instructions="Write the report.")
manager = Agent(
name="Manager",
instructions="Delegate: research first, then hand off to the writer.",
handoffs=[researcher, writer],
)
Beginner project ideas
Each of these exercises the whole stack — APIs, prompts, memory, and often RAG:
| Project | Skills it forces |
|---|---|
| Email assistant | Gmail API, tool-calling, drafting prompts, human approval before send |
| PDF chatbot | Chunking, embeddings, vector search — a complete RAG loop |
| Resume reviewer | Structured output, role prompting, scoring rubric |
| SQL assistant | Natural language → SQL, database tool, guardrails against destructive queries |
Build up in this order: Linux basics (shell, files, permissions) → Docker (package the app + deps into a reproducible image) → FastAPI (expose your agent as an HTTP API) → CI/CD with GitHub Actions (auto-test and deploy on push) → a cloud platform (AWS, Azure, or GCP) for hosting.
# FastAPI: wrap an agent in an endpoint
from fastapi import FastAPI
app = FastAPI()
@app.post("/ask")
def ask(question: str):
answer = run_agent(question)
return {"answer": answer}
# run: uvicorn app:app --reload
# Dockerfile: reproducible everywhere
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
Track these from day one of any production agent:
| Signal | Why |
|---|---|
| Logging | What the agent did, which tools it called, and why |
| Tracing | Follow one request across agents/tools/LLM calls end to end |
| Evaluation | Score output quality against a fixed test set so changes don't regress |
| Cost tracking | Tokens × price per call — agent loops get expensive fast |
| Latency monitoring | Where time goes; retrieval vs generation vs tool calls |
| Prompt versioning | Prompts are code — version them so you can roll back a bad change |
Tools you'll encounter: LangSmith, Langfuse, Phoenix, and Weights & Biases for LLM tracing/eval, plus general APM (Datadog, Grafana) for latency and cost dashboards.
| Window | Focus |
|---|---|
| Month 1–2 | Python + Git + REST APIs + SQL (Phases 1–4). Solid fundamentals first. |
| Month 3 | LLM fundamentals, prompting, the OpenAI API — and build a few simple AI apps (Phases 5–6). |
| Month 4 | RAG, vector databases, and an AI framework like LangGraph or the OpenAI Agents SDK (Phases 7, 9, 10). |
| Month 5 | Multi-agent workflows, tool integration (Slack, Jira, Google APIs), and FastAPI (Phases 8, 11, 13). |
| Month 6 | Build two or three end-to-end portfolio projects and deploy them (Phases 12–13). |
| Month 7–8 | Advanced topics: evaluation, observability, Docker, cloud deployment, authentication, and production best practices (Phases 13–14). |