Learning Roadmap

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.

How to use this: don't try to master every phase before moving on. Learn enough of phases 1–5 to be dangerous, then start building (Phase 12) in parallel — real projects are what make the earlier concepts stick. A suggested month-by-month plan is at the end.
Phase 1

Learn Python

Python is the lingua franca of AI. Everything below — frameworks, tools, RAG pipelines — is Python. The sections that follow are a complete, example-driven reference. Skim what you know; study the gotchas.

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.

Tip: Everything in Python is an object, including numbers and functions. 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:

CategoryTypesMutable?Example
Numericint, float, complexNo42, 3.14, 2+3j
BooleanboolNoTrue, False
TextstrNo"hello"
Sequencelist, tuple, rangelist only[1,2], (1,2)
MappingdictYes{"a": 1}
Setset, frozensetset only{1, 2}
Binarybytes, bytearraybytearrayb"abc"
NoneNoneTypeNone

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
Gotcha: 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
Short-circuit trick: 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
    ...
Idiom: Prefer iterating the object directly (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
Classic gotcha — mutable default arguments: defaults are evaluated once at definition time.
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 bucket

Returning 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
Tip: Building a big string in a loop? Append to a 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)
Performance: append/pop at the end and index access are O(1). Insert/remove/pop at the front are O(n) — for a queue, use 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
When to use: tuple for a fixed heterogeneous record whose meaning is positional (a coordinate, an RGB triple, a DB row); list for a growing homogeneous collection.
Note: "Immutable" means you can't rebind elements. If an element is itself mutable, it can still change: 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)}
Keys must be hashable: strings, numbers, and tuples work; lists and dicts do not.

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

ModeMeaning
"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")
Anti-patterns to avoid: never write a bare 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.
EAFP vs LBYL: Python favors "Easier to Ask Forgiveness than Permission" — 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)
Convention: Python has no true private members. One underscore _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
Why it matters: installing packages globally leads to version conflicts between projects. A venv keeps Project A on 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
Best practice: always 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'}
Style: keep comprehensions to a single, readable line. If you need two filters and a nested loop, a regular 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
Don't assign a lambda to a name to reuse it (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
Pythonic take: a comprehension is usually clearer than 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
Why care: generators let you process a 10 GB file line by line, or model an infinite stream, without ever loading it all. They're exhausted after one pass — iterate again and you get nothing. The 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>
Use them for any acquire/release pair: files, network sockets, database transactions, locks (with lock:), or temporarily changing state. Put the yield inside a try/finally if cleanup must run even when the block raises.
Phase 2

Software Engineering

Agents are software. Before the AI parts, you need the habits that keep any codebase shippable: version control, HTTP, auth, config, logging, and tests.

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:

project/ ├── app.py # entry point ├── agents/ # agent definitions ├── tools/ # callable tools the agents use ├── prompts/ # prompt templates ├── memory/ # short- & long-term memory ├── database/ # models, queries, migrations └── config/ # settings, env loading
Phase 3

Databases

Agents need to store state, look up facts, and cache expensive results. Know one relational DB well, plus a document store and a cache.

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

TypePickUse for
RelationalPostgreSQLProduction default; rich features, JSON columns, vector extension (pgvector)
Relational (embedded)SQLiteLocal dev, prototypes, single-file storage — zero setup
Document (NoSQL)MongoDBFlexible/nested schemas, rapid iteration
Cache / in-memoryRedisSession 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()
Phase 4

APIs

One of the biggest force-multipliers. If you can integrate an API, you can turn it into an agent tool. Most "useful agent" ideas are just "LLM + the right APIs."

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

LibraryWhen
requestsSimple, synchronous, the classic choice for scripts
httpxModern; sync and async, HTTP/2 — good default for new code
aiohttpFully 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()
Practical habits: read the API's rate limits and pagination; retry with backoff on 429/5xx; keep keys in env vars; and wrap each integration behind a small function so it can later become an agent tool (Phase 8).
Phase 5

AI Fundamentals

You don't need to train models or read papers. You need a working mental model of how LLMs behave so you can build reliably on top of them.

ConceptWhat to understand
LLMA model that predicts the next token; through scale it appears to reason, summarize, and write code
TokensSub-word chunks the model reads/writes; you pay per token and limits are counted in tokens (~4 chars ≈ 1 token in English)
Context windowMax tokens the model can "see" at once (prompt + response). Exceed it and older content is dropped
EmbeddingsText → a vector of numbers capturing meaning; similar text → nearby vectors. The basis of search & RAG
TemperatureRandomness knob: 0 = deterministic/focused, higher = more creative/varied
HallucinationConfident but wrong output; mitigated by grounding the model in real data (RAG) and tools
Function / tool callingThe model returns a structured request to call your code; you run it and feed the result back. This is what makes an "agent"
RAGRetrieve relevant documents, then let the LLM answer from them (Phase 10)
Fine-tuningFurther-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)
Phase 6

Prompt Engineering

Treat prompts like programs: precise inputs, defined output format, examples, and tests. This is the highest-leverage skill for reliable agents.

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}
"""
Get structured output reliably: ask for JSON and validate it. Pair prompts with a 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
Phase 7

AI Frameworks

Frameworks handle the plumbing — message loops, tool routing, memory, retries — so you focus on behavior. Learn one deeply rather than all of them shallowly.

FrameworkSweet spot
LangChainBroad ecosystem of integrations; good for chaining LLM calls and tools
LangGraphGraph-based control flow for stateful, cyclic agent workflows — strong for complex multi-step/multi-agent logic
OpenAI Agents SDKLightweight, first-party agents + tools + handoffs; great starting point
LlamaIndexRAG-first: ingestion, indexing, retrieval over your data
CrewAIRole-based crews of collaborating agents; quick to prototype teams
AutoGenConversational 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],
)
Phase 8

Build Tools

An agent is only as useful as the tools it can call. A "tool" is just a well-described function the LLM can invoke — this is where Phase 4 (APIs) pays off.

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"
Tool design rules: one clear job per tool; descriptive name and docstring (the model chooses tools from these); typed, validated arguments; and return concise strings/JSON — not giant blobs that flood the context window. Make destructive tools (send email, delete row) require a human-approval step.
Phase 9

Memory

LLMs are stateless between calls. Memory is how an agent remembers the conversation, the user, and relevant knowledge across turns and sessions.

KindHoldsTypical store
Short-termCurrent conversation historyA list of messages in the context window; Redis for sessions
Long-termUser preferences, facts learned over timeSQL / document DB, keyed by user
KnowledgeDocuments the agent can searchVector 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
Phase 10

RAG — Retrieval-Augmented Generation

Essential. RAG grounds the model in your data so it answers from real documents instead of hallucinating.

User asks a question │ Embed the question & search the vector DB │ Retrieve the most relevant document chunks │ Put those chunks into the prompt │ LLM answers using ONLY those documents

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
Why it matters: RAG is how you build a chatbot over your company's docs, a PDF Q&A tool, or a support agent — without fine-tuning and with citations you can trust.
Phase 11

Multi-Agent Systems

Where it gets exciting. Instead of one do-everything agent, a manager delegates to specialists — each with its own role, tools, and prompt.

Manager Agent │ ┌────────┬───┴────┬─────────┬───────────┐ Research Coding Testing Reporting Deployment Agent Agent Agent Agent Agent

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],
)
Cost & control: multi-agent loops can spiral into many LLM calls. Cap iterations, add a human-approval step for high-stakes actions, and log every hop (Phase 14) so you can see what happened.
Phase 12

Build Real Projects

The most important phase. Concepts stick only when you ship something end to end. Build small, finish it, then add complexity.

Beginner project ideas

Each of these exercises the whole stack — APIs, prompts, memory, and often RAG:

ProjectSkills it forces
Email assistantGmail API, tool-calling, drafting prompts, human approval before send
PDF chatbotChunking, embeddings, vector search — a complete RAG loop
Resume reviewerStructured output, role prompting, scoring rubric
SQL assistantNatural language → SQL, database tool, guardrails against destructive queries
Portfolio tip: finish two or three projects, put them on GitHub with a clear README and a short demo, and deploy at least one (Phase 13). A deployed, working agent beats a dozen half-built notebooks.
Phase 13

Cloud & Deployment

An agent on your laptop isn't a product. Learn to package it, serve it, and ship it reliably.

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"]
Phase 14

Observability

Real AI systems are non-deterministic and cost money per call. You can't improve what you can't see — instrument everything.

Track these from day one of any production agent:

SignalWhy
LoggingWhat the agent did, which tools it called, and why
TracingFollow one request across agents/tools/LLM calls end to end
EvaluationScore output quality against a fixed test set so changes don't regress
Cost trackingTokens × price per call — agent loops get expensive fast
Latency monitoringWhere time goes; retrieval vs generation vs tool calls
Prompt versioningPrompts 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.

Study Plan

A Realistic Timeline

Roughly six months to job-ready, with two optional months for production depth. Adjust to your pace — the key is building alongside learning.

WindowFocus
Month 1–2Python + Git + REST APIs + SQL (Phases 1–4). Solid fundamentals first.
Month 3LLM fundamentals, prompting, the OpenAI API — and build a few simple AI apps (Phases 5–6).
Month 4RAG, vector databases, and an AI framework like LangGraph or the OpenAI Agents SDK (Phases 7, 9, 10).
Month 5Multi-agent workflows, tool integration (Slack, Jira, Google APIs), and FastAPI (Phases 8, 11, 13).
Month 6Build two or three end-to-end portfolio projects and deploy them (Phases 12–13).
Month 7–8 (optional)Advanced topics: evaluation, observability, Docker, cloud deployment, authentication, and production best practices (Phases 13–14).
The one habit that matters most: start building real projects (Phase 12) by Month 3 and never stop. Everything else — frameworks, RAG, memory — makes far more sense once you've felt the problem it solves.

End of roadmap. Phase 1 examples run on Python 3.10+; later phases show representative code for the OpenAI Agents SDK, Chroma, FastAPI, and friends. Save this file anywhere and open it in a browser — no internet connection required.