📝 Variables & Data Types
Python's building blocks. Every AI program starts here — master this and everything else clicks into place.
age = 25 and already knows it's an integer. This is called dynamic typing and it makes Python much faster to write than languages like Java or C++.name = "Amara", you're creating a jar labelled name and putting "Amara" inside. Later you just say name and Python goes and fetches whatever's in that jar.temperature = 36.6 create?"Hello" or 'Hello'.F-strings (formatted strings) are the modern way to build dynamic text. Prefix the string with
f and put any variable or expression inside {curly braces}. Python replaces {name} with whatever name contains at that moment.This is essential for AI engineering because every prompt you send to an LLM is a string you build on the fly — the user's question, the documents you retrieved, the context from your database — all stitched together with f-strings.
prompt = f"Summarize this for a {audience}: {article_text}". Master f-strings early — you will use them in literally every AI project you ever build.str() returns a human-friendly string (e.g. str(42) → '42'). repr() returns a developer-friendly, unambiguous representation (e.g. repr('hello') → "'hello'" with quotes). Rule of thumb: use str() for display, repr() for debugging."".join(list_of_strings) — it's O(n) fast. Never use result += string in a loop because Python strings are immutable, so each += creates a brand new string and copies everything — that's O(n²) slow for large lists.s = 'hello'; s[0] = 'H' raises a TypeError. To 'change' a string you must create a new one. This matters for performance (avoid rebuilding strings in loops) and for safety (strings can be dict keys because they never change).** is power, // is floor division, % is modulo (remainder). These appear constantly in AI metrics.🔀 Control Flow
Make your programs smart. The logic behind every AI confidence threshold, safety filter, and training loop.
In Python, you write
if condition: and indent the code underneath it by 4 spaces. Python uses this indentation (not curly braces like JavaScript) to know which lines belong to each block.if = "check this first". elif = "if the first check failed, try this". else = "if nothing matched, do this". Only one branch runs — Python finds the first true condition and stops checking.== checks if two values are equal. is checks if they are the exact same object in memory. Example: a = [1,2,3]; b = [1,2,3]; a == b is True (same value), but a is b is False (different objects). Always use == for value comparisons. Only use is when checking for None: if result is None:.False, 0, 0.0, '' (empty string), [] (empty list), {} (empty dict), None. Everything else is truthy. So if my_list: is a Pythonic way to check if a list has items.match/case (structural pattern matching) when you have many branches based on the structure or value of a variable — similar to a switch statement in other languages. It's especially powerful when matching against dictionary shapes, tuples, or class instances. For simple if/elif chains with basic comparisons, regular if/elif is often cleaner.range(n) gives you the numbers 0, 1, 2, … n-1. enumerate(my_list) gives you both the index AND the value as a tuple: (0, 'first'), (1, 'second') etc. Use enumerate whenever you need both the position and the item — it's more Pythonic than for i in range(len(list)): item = list[i].for key, value in my_dict.items():. The .items() method returns key-value pairs as tuples. You can also use .keys() (keys only) or .values() (values only). In Python 3, all of these return lightweight view objects, not full copies of the data.def big_range(): for i in range(10_000_000): yield i. This uses almost no memory because it produces one number at a time on demand. Compare to list(range(10_000_000)) which allocates all 10 million numbers at once. Use generators when processing large datasets or infinite streams.📋 Lists & Comprehensions
Lists are the workhorse of Python. Master slicing, sorting, and comprehensions — you'll use them in literally every AI script you write.
Indexing starts at
0 for the first item. Negative indexes count from the end: -1 is the last item, -2 is second-to-last. This comes up constantly when grabbing the latest message in a conversation or the last row of a dataset.history[-1]. This pattern appears in nearly every AI script you'll write.list[start:stop:step]. start is included, stop is excluded. Leave any part blank to mean "from the beginning" or "to the end".This is enormously useful for things like taking the last 10 messages of a conversation to stay within a context window, or batching a dataset into chunks.
None, not a new list — a common beginner mistake is writing my_list = my_list.sort(), which sets the list to None!).sorted(list), on the other hand, returns a brand-new sorted list and leaves the original untouched. Know the difference.my_list = my_list.sort() sets my_list to None because .sort() returns nothing. Just call my_list.sort() on its own line, or use sorted(my_list) if you want a new list.[expression for item in iterable if condition]. It replaces the classic pattern of creating an empty list and appending inside a loop.Dict and set comprehensions follow the same idea:
{k: v for k, v in pairs} and {x for x in items}. You'll use these constantly to clean and reshape data before sending it to a model.(x for x in items) uses round brackets instead of square brackets and produces items lazily — one at a time — instead of building the whole list in memory. Use it when you're processing a huge dataset and only need to iterate once, or when piping into a function like sum() or any() that doesn't need a full list. Use a list comprehension when you need to use the result multiple times or need list-specific operations like indexing.list.append(x) is O(1) — constant time, regardless of list size, because Python lists have spare capacity at the end. list.insert(0, x) is O(n) — it has to shift every existing element over by one position to make room at the front. If you frequently insert at the front, use collections.deque instead, which is O(1) at both ends.new_list = old_list.copy() or new_list = old_list[:] for a shallow copy — fine for lists of simple values like numbers or strings. But if your list contains other lists or dicts, a shallow copy still shares those nested objects. For a true independent copy, use import copy; new_list = copy.deepcopy(old_list). Forgetting this is one of the most common sources of confusing bugs in Python.You don't need to memorise the syntax. Just know the most common patterns and that Python's
re module is your tool for this job.⚙️ Functions
Write once, use anywhere. Functions are the building blocks of every Python library — including NumPy, PyTorch, and Anthropic's SDK.
def and call it whenever you need it.Functions can accept inputs (called parameters) and send back a result (using
return). If you omit return, the function returns None automatically.Default values: if a caller doesn't supply an argument, Python uses the default you specified. Type hints (like
task: str) are optional labels that tell other developers what type is expected — Python does NOT enforce them, but they make code much easier to read and tools can check them.*args collects any number of positional arguments into a tuple: def log(*messages) lets you call log('a', 'b', 'c'). **kwargs collects any number of keyword arguments into a dict: def config(**settings) lets you call config(model='claude', temp=0.7). The asterisks are the syntax — you can name them anything, but *args and **kwargs are universal conventions.@decorator_name above the function definition. Example: a @retry decorator automatically retries a function on failure — you write the retry logic once and apply it to any function. Under the hood, @retry is just syntactic sugar for my_function = retry(my_function).def make_multiplier(n): return lambda x: x * n. When you call double = make_multiplier(2), the returned lambda 'closes over' n=2 and remembers it forever. Useful for creating specialised functions (like prompt builders with a fixed system prompt) without classes.yield to produce values one at a time, pausing and resuming between each. This saves huge amounts of memory when processing large datasets because you only hold one item in memory at a time, not the whole list. In AI, generators are useful for streaming LLM responses token-by-token.📦 Data Structures
Lists, dicts, sets, and tuples are Python's containers. AI datasets are just big collections of these — understand them deeply.
Dicts are the single most important data structure for AI engineering because JSON — the format of every API response — maps directly to Python dicts. When Claude responds to your API call, you get back a Python dict. When you send a request, you're sending a dict. Learn dicts deeply.
if item in my_list) is O(n) — it scans every element until it finds a match or reaches the end. This means for 1 million items: dict lookup takes roughly the same time as for 10 items, but list search takes ~100,000× longer. Always use a dict or set for membership checks on large collections.defaultdict from the collections module automatically creates a default value for missing keys, so you never get a KeyError. Example: defaultdict(list) means missing keys return [] automatically — great for grouping items. Counter counts occurrences of items and is perfect for word frequency analysis, finding most-common tokens, etc. Both are regular dicts with extra powers — no new syntax to learn.list(range(10_000_000)) allocates ~80MB of RAM. A generator produces elements one at a time on demand and uses almost no memory. range(10_000_000) itself is a generator-like object using just a few bytes. For large AI datasets (millions of text samples), always use generators or lazy-loading to avoid running out of RAM.🛡️ Error Handling
Production AI systems fail constantly — rate limits, timeouts, bad JSON. This module teaches you to handle failure gracefully.
try: put the risky code here — anything that might fail (API calls, file reads, type conversions).except ExceptionType: runs only if that specific error occurs. You can have multiple except blocks for different error types.finally: runs no matter what — even if an error occurred. Perfect for cleanup: closing files, logging, releasing resources.In production AI systems, every single API call should be wrapped in try/except. Networks fail, rate limits hit, responses are malformed — it's not a question of if, but when.
RateLimitError, FileNotFoundError, ValueError etc. This way you handle known failures gracefully and let unexpected bugs bubble up so you can find and fix them. Catch bare Exception only as a last-resort safety net (usually at the top level of your app) — and always log it: logger.error('Unexpected error', exc_info=True). Never write except: pass — this silently swallows bugs and makes debugging a nightmare.raise (no argument) re-raises the original exception, preserving the full traceback. raise e (where e is the caught exception) also re-raises but resets the traceback to the current line — losing information about where the error originally occurred. Use raise (bare) to re-raise, or raise NewException('message') from e to chain exceptions while preserving the cause.import time; for attempt in range(max_retries): try: return api_call() except RateLimitError: time.sleep(2 ** attempt). In production, use the tenacity library: @retry(wait=wait_exponential(min=1, max=60), stop=stop_after_attempt(5)) — it handles jitter, logging, and edge cases for you.logging instead of print(). Logs have levels (DEBUG, INFO, WARNING, ERROR), timestamps, and write to files and monitoring tools like Datadog.🏛️ Object-Oriented Programming
Classes and objects — how PyTorch, LangChain, and every major AI library is built. Understanding OOP lets you read and extend any library.
A class is a blueprint. An object (or instance) is something built from that blueprint. You can build many objects from one class — each one is independent.
__init__ is the constructor — it runs automatically when you create a new object and sets up its initial data. self refers to the specific object being created or used — it's how the object refers to its own data.You use OOP constantly in AI engineering without realising:
client = anthropic.Anthropic() creates an object from Anthropic's class. model = Sequential() in Keras, df = pd.DataFrame() in Pandas — all OOP.__init__: called when creating an object. __repr__: what print(obj) shows. __len__: what len(obj) returns. __getitem__: makes obj[key] work. __enter__ / __exit__: makes with obj: work. You implement these to make your custom classes work naturally with Python's built-in syntax.self (the instance). A @classmethod receives cls (the class itself) — useful for alternative constructors: @classmethod def from_json(cls, data): return cls(**data). A @staticmethod receives neither — it's just a regular function namespaced inside the class for organisation. Rule of thumb: use @classmethod for factory methods, @staticmethod for utility functions related to the class.class Dog(Animal)): Dog IS-A Animal. Dog gets all of Animal's methods. Use when there's a genuine is-a relationship. Composition: Dog HAS-A Collar. Dog contains a Collar object as an attribute. Use when you want to combine behaviours without tight coupling. Modern best practice: prefer composition over inheritance. Inheritance creates tight coupling — changing the parent class can break all subclasses. Composition is more flexible and easier to test.📦 Modules, Packages & Environments
Most beginners quit here because pip install breaks. This module prevents that — virtual environments, pip, uv, and project structure.
pip install a package, where does it go? By default, it installs globally on your computer. This causes a serious problem: Project A needs numpy==1.24 but Project B needs numpy==2.0. They can't both be installed globally.A virtual environment is an isolated copy of Python just for one project. Each project has its own folder of packages that don't interfere with anything else. You "activate" it before working and "deactivate" when done.
Every single professional Python project uses a virtual environment. If you skip this step, you will eventually break something. It's not optional.
pip is Python's standard package installer — it works everywhere but can be slow for large dependency trees. uv is a new, ultra-fast package manager written in Rust (made by Astral). It's 10–100× faster than pip and handles virtual environments too. For 2026, uv is rapidly becoming the industry standard. Start new projects with uv venv && uv pip install .... Both install the same packages from PyPI — uv is just much faster and more reliable with complex dependencies..env file contains secrets: API keys, database passwords, private tokens. If you push it to GitHub (even a private repo), those secrets can leak — through forks, collaborators, security breaches, or accidentally making the repo public. Once a secret is in Git history, it's there forever even if you delete the file later. The correct approach: add .env to your .gitignore file, commit a .env.example with dummy values as a template, and share real secrets through a secrets manager or secure channel.requirements.txt is a simple list of packages — it works but has no standard for specifying metadata, build systems, or development vs production dependencies. pyproject.toml is the modern standard (PEP 517/518) that combines package metadata, dependencies, dev tools config (black, pytest, mypy) and build instructions all in one file. Tools like Poetry, uv, and Hatch all use pyproject.toml. For new projects in 2026, prefer pyproject.toml.⚡ Async Python
Modern AI engineering is async. Running 10 LLM calls in parallel instead of sequentially cuts wait time from 30 seconds to 3.
Async lets your program start an operation, then do other things while waiting for it to finish. When you
await something, Python doesn't freeze — it switches to other pending tasks and comes back when the result is ready.Think of it like a chef: a synchronous chef puts bread in the oven and stares at it for 20 minutes. An async chef puts bread in the oven, goes and chops vegetables, stirs the soup, then checks the bread when the timer goes off.
In AI engineering: if you have 10 documents to summarise and each LLM call takes 3 seconds, sequential = 30 seconds. Async = ~3 seconds. Same work, 10× faster.
multiprocessing gives you this — great for CPU-bound tasks (data processing, image resizing). For LLM API calls: use async (concurrency). For data preprocessing: use multiprocessing (parallelism).asyncio.gather(*tasks): runs all tasks concurrently and waits for ALL of them to finish. Returns a list of results in the same order. Use this when you want all results before proceeding. asyncio.create_task(coro): schedules a coroutine to run but doesn't wait — your code continues immediately. The task runs in the background. Use this when you want to start something and check its result later, or when tasks have different completion times and you want to process each as it finishes.multiprocessing (separate processes, no shared GIL) or libraries like NumPy that release the GIL internally.🧪 Testing & Logging
Professional engineers test their code. pytest + the logging module are the two tools that separate production AI code from scripts.
pytest is Python's most popular testing library. You write functions that start with
test_, use assert to check expected outcomes, and run pytest in the terminal. It finds and runs all your tests automatically.Why test AI code specifically? AI systems are fragile — a prompt change, a model update, or a config change can silently break everything. Tests catch regressions. They also make refactoring safe: if your tests pass after changes, you know nothing broke.
Start small: test your prompt-building functions, your JSON parsers, your data cleaning utilities. You don't need to test the LLM itself — test the code around it.
build_prompt('Summarize', 'text') returns a string containing 'Summarize'.Integration tests: test multiple components working together. Slower, test realistic scenarios. Example: test that your full RAG pipeline retrieves relevant documents and generates a sensible answer for a known question.
Best practice: many fast unit tests (run on every save), fewer slower integration tests (run before deploy).
In Python:
from unittest.mock import patch; with patch('anthropic.Anthropic') as mock: mock.return_value.messages.create.return_value = fake_responseUse mocks for: external APIs, databases, filesystem operations — anything slow, expensive, or non-deterministic. Never mock your own business logic.
For LLM output quality: use evals — run a golden dataset through your full pipeline and score outputs with a rubric or another LLM as judge. Evals are separate from unit tests.
📁 Files & JSON
Read datasets, write results, and parse JSON — the format of every API response and config file.
JSON is the universal format for structured data — it looks like a Python dict and maps to one directly. Every API response is JSON. Every config file is usually JSON.
pathlib: the modern way to work with file paths.
Path("config.json") works on Windows, Mac, and Linux automatically.The
with open(...) as f: pattern is a context manager. It automatically closes the file when the block ends, even if an error occurs — preventing file corruption.Context managers (
with open(...) as f:): always use this pattern for files. It automatically closes the file when the block ends — even if an error occurs. Never do f = open(...) without with in production code.JSON is the most important format in AI engineering. Every API request/response, every config file, every dataset you download is JSON. It maps directly to Python dicts and lists —
json.loads() converts JSON text to Python, json.dumps() converts Python to JSON text.pathlib.Path: the modern way to handle file paths. Cleaner than string concatenation, works on Windows/Mac/Linux automatically.
Path("data") / "results.json" is the right way to build paths.'r': read text (default). File must exist.'w': write text. Creates file if it doesn't exist. Overwrites existing content.'a': append text. Creates file if needed. Adds to end of existing content — use for logs.'rb'/'wb': read/write binary. Use for images, PDFs, audio, any non-text file.'r+': read and write (file must exist).Common mistake: using
'w' when you meant 'a' — deletes all existing log entries.from pathlib import Path; import jsonconfig_path = Path('config.json')config = json.loads(config_path.read_text()) if config_path.exists() else {}Or with explicit error handling:
try: config = json.loads(Path('config.json').read_text())except FileNotFoundError: config = {}except json.JSONDecodeError as e: logger.error(f'Invalid JSON: {e}'); config = {}json.dumps(data): converts Python dict → JSON string. The 's' = 'string'.json.loads(text): converts JSON string → Python dict.json.dump(data, file): writes Python dict → JSON directly to a file object. No 's'.json.load(file): reads JSON from a file object → Python dict.Use
dumps/loads when working with strings (API responses, in-memory data). Use dump/load when reading/writing files directly.🔢 NumPy
Fast numerical computing. AI models are math on arrays of numbers — NumPy is your gateway to that world.
NumPy arrays are like lists but: (1) all elements must be the same type, (2) math operations apply to every element automatically without loops, (3) stored in contiguous memory — much faster to process.
Broadcasting: when you write
weights * 2, NumPy automatically multiplies every element by 2 — you don't write a loop. This is called broadcasting and it's the key to fast numerical code.Every neural network, embedding model, and ML algorithm works by doing math on large arrays. NumPy is the foundation — PyTorch and TensorFlow are built on the same principles.
ndarray — an N-dimensional array that does math on millions of numbers at once.Why is it so fast? Python lists store each element as a separate Python object with overhead. NumPy arrays store raw numbers in contiguous memory blocks and use optimised C code for operations — 10–100× faster for math.
Vectorised operations mean you operate on entire arrays at once instead of looping:
weights * 2 multiplies every element by 2 simultaneously — no Python loop needed.Every tensor in PyTorch, every feature matrix in scikit-learn, every embedding vector — they are all NumPy arrays underneath. Understanding NumPy gives you the intuition to understand what AI models are actually computing: just chains of matrix math.
matrix (100, 3) + vector (3,) — NumPy automatically 'broadcasts' the vector across all 100 rows, as if it were repeated 100 times, but without actually copying it. This saves memory and is much faster than explicit loops.Rules: dimensions are compared from right to left. Dimensions must be equal, or one of them must be 1. Broadcasting is used constantly in neural network layers.
.shape: a tuple describing the array dimensions. (100, 1536) = 100 rows, 1536 columns..reshape(new_shape): rearrange elements into a new shape (total elements must stay the same). array.reshape(10, 10) turns a 100-element array into a 10×10 matrix..T or .transpose(): flip rows and columns. A (3, 4) matrix becomes (4, 3). Used constantly in matrix multiplication (you often need to transpose one matrix to make dimensions align).[1,2,3] · [4,5,6] = 1×4 + 2×5 + 3×6 = 32.In AI, dot products are used: (1) To compute similarity between embedding vectors (the core of semantic search). (2) In every neuron of a neural network — a neuron's output is the dot product of its inputs and weights, passed through an activation function. (3) In attention mechanisms (the 'QK' product in transformers). Understanding the dot product is understanding the mathematical heart of modern AI.
🐼 Pandas
The go-to library for data manipulation. Load, clean, filter, group, and analyse any dataset.
The core object is a DataFrame: a table with named columns and indexed rows. Each column is a Series (a single column of data). You can think of a DataFrame as a dict of Series objects.
In AI engineering, Pandas is your first tool for every new dataset: load it, inspect it, find problems, clean it, then feed clean data to your model or pipeline. Garbage in = garbage out — data cleaning is often 70% of AI project work.
Key mental model: most Pandas operations create a new DataFrame rather than modifying in place. Chain operations with
.: df.dropna().rename(columns={...}).reset_index()..loc[label]: access rows/columns by label (name). If your index is dates, df.loc['2024-01-15'] gets that row. If columns are named, df.loc[:, 'price'] gets the price column..iloc[integer]: access by integer position (0-based). df.iloc[0] = first row. df.iloc[:, 2] = third column. Works regardless of index labels.Important gotcha:
.loc is inclusive of the end index (df.loc[0:5] gives rows 0,1,2,3,4,5). .iloc is exclusive (df.iloc[0:5] gives rows 0,1,2,3,4).df.isnull().sum() — shows count of NaN per column.Options:
df.dropna() removes any row with a NaN. df.dropna(subset=['price']) only drops rows where 'price' is missing. df.fillna(0) replaces NaN with 0. df['col'].fillna(df['col'].mean()) fills with the column average. df.interpolate() fills gaps in time series data with interpolated values.Rule: never silently drop data without understanding why it's missing. Missing data often contains signal — maybe those rows failed validation for a reason.
groupby splits your DataFrame into groups, applies a function to each group, and combines the results. The classic 'split-apply-combine' pattern.Example:
df.groupby('model')['tokens'].mean() — for each unique model name, calculate the average token count. You get a new Series with model names as index and mean tokens as values.Common aggregations:
.mean(), .sum(), .count(), .max(), .min(), .agg({'col1': 'mean', 'col2': 'sum'}) for different aggregations per column. Essential for any data analysis or reporting in AI projects.📡 Working with REST APIs
requests, auth headers, rate limits, retries — everything to call any web API from Python.
GET: fetch data (read).
requests.get(url)POST: send data to create or trigger something (write).
requests.post(url, json=data)Authentication: most APIs require you to prove who you are. The most common pattern is a Bearer token in the Authorization header:
{"Authorization": "Bearer your-api-key"}. This is exactly how Anthropic, OpenAI, and Gemini work under the hood — even when you use their official SDKs, they're just making these same HTTP requests for you.Understanding raw HTTP requests makes you a better AI engineer — you can debug issues, use APIs that don't have Python SDKs, and understand what the SDKs are doing internally.
HTTP methods:
GET = fetch data (no side effects). POST = send data to create something or trigger an action. Most LLM calls are POST. PUT/PATCH = update. DELETE = remove.Authentication: most APIs require proof of identity. The universal pattern: include your API key in an
Authorization: Bearer your-key HTTP header. This is exactly how Anthropic, OpenAI, Gemini, and Cohere all work.Status codes tell you what happened: 200 = success. 400 = you sent bad data. 401 = not authenticated. 403 = authenticated but not allowed. 429 = too many requests (rate limited — slow down!). 500 = server error (their problem). Always call
response.raise_for_status() to automatically raise an exception for 4xx/5xx.requests: the classic Python HTTP library. Synchronous only — each call blocks until complete. Simple, widely used, excellent documentation. Fine for scripts and simple tools.httpx: modern replacement for requests. Supports both sync AND async (async with httpx.AsyncClient() as client:). Same API as requests but async-capable. Recommended for AI applications where you make many concurrent API calls.Rule: use
requests for simple scripts. Use httpx in FastAPI apps or anywhere you need async — the Anthropic SDK uses httpx internally.429 Too Many Requests response with a Retry-After header telling you how long to wait.Strategy: (1) Exponential backoff with jitter: wait 1s, then 2s, then 4s, then 8s... plus random jitter to avoid all clients retrying simultaneously. (2) Rate limiting your own code: use
asyncio.Semaphore(10) to cap concurrent requests. (3) Batch wisely: use Claude's Batch API for large jobs — processes up to 10,000 requests at 50% discount. (4) Use the tenacity library for production-grade retry logic.Important request headers for AI APIs:
Authorization: Bearer sk-ant-... — your API keyContent-Type: application/json — tells server you're sending JSONanthropic-version: 2023-06-01 — pins API version to avoid breaking changesImportant response headers:
x-ratelimit-remaining-requests — how many requests left this minuteretry-after — how many seconds to wait when rate limitedAlways check response headers when debugging API issues.
🧠 LLM APIs
Call real AI models from Python. Anthropic, OpenAI, Gemini — learn the universal pattern that works for all of them.
The key concepts:
System prompt: instructions that apply to the whole conversation. Sets the AI's persona, rules, and behaviour. Sent separately from messages.
Messages: a list of turns. Each message has a
role ("user" or "assistant") and content (the text). You send the full history each time.max_tokens: the maximum number of tokens the model can generate in its response. One token ≈ 4 characters or ¾ of a word.
model: which model to use — different models have different costs, speeds, and capabilities.
The pattern is always the same: build a messages list, call the API, extract the text from
response.content[0].text.with client.messages.stream(...) as stream: for text in stream.text_stream: print(text, end='', flush=True).tool_use block instead of text. You execute the function with the model's chosen arguments, send the result back as a tool_result message, and the model uses that result to form its final answer. This is how AI agents work — the model can search the web, query a database, send emails, all by calling your Python functions.✍️ Prompt Engineering
Small prompt changes can double output quality. This separates mediocre AI apps from outstanding ones.
Zero-shot: just ask the question with no examples. Simplest, works for easy tasks.
Few-shot: give 2–3 examples of input→output pairs before your actual question. The model learns the pattern from the examples. Works very well for formatting and classification tasks.
Chain-of-thought: add "think step by step" or "reason through this carefully before answering". Forces the model to show its working, which dramatically improves accuracy on logic, math, and multi-step tasks.
Structured output: tell the model exactly what format to respond in (JSON, bullet points, table). Critical for AI apps that need to parse the response programmatically.
SUMMARISE_V2_2026_03. (3) Use a dedicated prompt management tool (LangSmith, PromptLayer, Weights & Biases) for A/B testing different versions. (4) Never change a production prompt without running it through your eval dataset first. (5) Keep a changelog explaining why each version changed.🧬 Embeddings & Vector Search
Convert text to numbers, compare semantic meaning, power AI search systems.
The magic: texts with similar meaning produce similar vectors. "Dog" and "puppy" produce vectors that are close together. "Dog" and "quantum physics" produce vectors far apart.
This lets you do something powerful: semantic search. Instead of searching for exact keywords, you convert a query to a vector, then find documents whose vectors are closest. "What animals make good pets?" finds articles about dogs, cats, and rabbits — even if those exact words don't appear in the articles.
Embeddings are the foundation of RAG: you embed all your documents once and store the vectors. At query time, you embed the question and find the nearest document vectors. That's your retrieval step.
An embedding model processes text and outputs a vector — typically 768 to 3072 numbers. The key property: texts with similar meanings produce similar vectors, even if they use completely different words.
Example: "The car broke down" and "My vehicle stopped working" produce very similar vectors. "I love pizza" produces a very different vector. This lets you find semantically similar documents without exact keyword matching.
Cosine similarity measures the angle between two vectors — 1.0 means identical meaning, 0 means unrelated, -1 means opposite. You use it to rank documents by relevance to a query.
This is the foundation of semantic search, RAG retrieval, duplicate detection, clustering, and recommendation systems.
Dense embeddings (like text-embedding-3-small or voyage-3): every dimension has a value. Capture semantic meaning. Can match 'car' with 'vehicle', 'fast' with 'quick'. Require a vector database for efficient search.
Modern RAG systems use hybrid search — combine both for best results.
Good defaults:
voyage-3 (Anthropic's recommended, excellent quality), text-embedding-3-small (OpenAI, cheap and good), all-MiniLM-L6-v2 (free, local, fast for prototyping).Solution: use Approximate Nearest Neighbour (ANN) algorithms like HNSW (Hierarchical Navigable Small World) or IVF (Inverted File Index). These trade a tiny bit of accuracy for massive speed gains. All production vector databases (Pinecone, Weaviate, Chroma, pgvector) use ANN algorithms internally.
🔍 RAG — Retrieval-Augmented Generation
The most-hired AI skill right now. Build systems that search your own documents and answer questions about them using an LLM.
RAG (Retrieval-Augmented Generation) solves this by:
1. Storing your documents as searchable vectors in a database
2. Finding the most relevant passages when a user asks a question
3. Feeding those passages to the LLM as context
4. Generating an answer grounded in your actual documents
This is how products like "chat with your PDF", internal knowledge bases, customer support bots, and code-search tools are built. It's arguably the most commercially valuable AI engineering skill in 2026 — virtually every enterprise AI product uses some form of RAG.
Fine-tuning: actually trains the model on your data, changing its weights permanently. Pros: model 'memorises' patterns and style, faster inference (no retrieval step). Cons: expensive, slow to update, risk of forgetting other knowledge.
Rule of thumb: use RAG first for knowledge/facts. Use fine-tuning for style, format, or domain-specific behaviour that doesn't change often.
Typical starting points: 512–1024 tokens with 10–20% overlap between chunks. For legal/medical documents with dense information: smaller chunks (~256 tokens). For narrative text: larger chunks (~1024 tokens). Always experiment: run your eval set, measure retrieval accuracy, and tune from there. Also consider semantic chunking — splitting at sentence/paragraph boundaries rather than fixed token counts.
A reranker is a second model (like Cohere's Rerank or a cross-encoder) that takes the top-k retrieved chunks and re-scores them based on true relevance to the query. It's slower but much more accurate.
Add a reranker when: your RAG answers are often 'almost right but missing the best source'. Typical pipeline: retrieve top 20 with vector search → rerank → send top 5 to LLM.
Tools: RAGAS (open-source RAG evaluation framework) automates much of this using an LLM as a judge. Build a golden dataset of question/answer/source-document triples and run it regularly.
Hybrid search combines both: run vector search AND keyword search in parallel, then merge the results (usually with Reciprocal Rank Fusion). This catches both 'find me documents about transformer architecture' (semantic) and 'find the exact string GPT-4o-mini' (keyword).
In production, hybrid search consistently outperforms either method alone. Most modern vector databases (Weaviate, Pinecone, Chroma) support hybrid search natively.
🤖 AI Agents
Agents plan, use tools, and complete multi-step tasks autonomously. The frontier of AI engineering in 2026.
How tool use works:
1. You describe your functions to the model (name, parameters, what it does)
2. The model decides which tool to call and what arguments to pass
3. You execute the function in Python and get the result
4. You send the result back to the model
5. The model uses the result to continue reasoning or give a final answer
This creates a loop where the model can autonomously search the web, query databases, write files, send emails, call APIs — anything you expose as a tool. The model is the brain; your Python functions are its hands.
This think-before-acting pattern dramatically improves agent accuracy on multi-step tasks. Most modern agent frameworks implement ReAct or a variant automatically.
(1) Max iterations: hard limit on how many tool calls the agent can make (e.g. 20). Raise a TimeoutError if exceeded.
(2) Tool call history: track what the agent has already done and warn/stop if it repeats the same call with the same args.
(3) Step timeouts: each individual tool call has a timeout.
(4) Budget tracking: limit total tokens spent. Always have a human-review step for high-stakes actions.
PydanticAI: type-safe agent framework from the Pydantic team. Clean, Pythonic API with first-class support for structured outputs. Great for type-checking and validation.
OpenAI Agents SDK: OpenAI's official framework for building agents with handoffs between specialised sub-agents.
Claude with raw tool use: for simple agents, you don't need a framework at all — just implement the tool-call loop yourself as shown in this lesson.
(1) Short-term (in-conversation): pass the full message history with every API call — Claude already does this.
(2) Long-term (across sessions): store important facts in a database. Before each conversation, retrieve relevant memories and inject them into the system prompt: 'User's name is Amara. Preferred language: Python. Current project: RAG chatbot.'
(3) Semantic memory: store memories as embeddings in a vector DB and retrieve the most relevant ones for each query — scales to thousands of memories.
🔌 Model Context Protocol (MCP)
MCP is the new industry standard for connecting AI to tools. Knowing this is a top hiring signal in 2026 — it sets you apart.
MCP (Model Context Protocol) solves this by creating a universal standard. An MCP server exposes tools in a standardised format. Any AI model that speaks MCP can use any MCP server — just like any device with USB-C can charge from any USB-C charger.
How it works:
• You build an MCP server that exposes tools (functions the AI can call)
• An MCP client (Claude Desktop, your app) connects to the server
• The AI discovers what tools are available and calls them as needed
In 2026, MCP is rapidly becoming the standard way to give AI models access to external systems. Knowing how to build MCP servers is a strong signal on a resume.
MCP is universal: build an MCP server once, and any MCP-compatible client can use it. It also solves discovery (clients can ask servers what tools they have), versioning, and security (structured permission model). Think of it as the difference between a custom cable and USB-C.
FastMCP.An MCP client is a program that uses those capabilities: it connects to one or more MCP servers, discovers what's available, and calls tools on behalf of an AI model. Claude Desktop, your custom Python app, and many code editors are MCP clients.
Tools: functions the AI can call to take actions or get dynamic data. Examples:
search_web(query), query_database(sql), send_email(to, subject, body). The AI decides when to call them.Resources: static or semi-static content the AI can read. Examples: a file, a database record, an API response. Think of these as 'things to read' vs tools which are 'things to do'.
Prompts: reusable prompt templates that the client can inject into conversations. Useful for standardising how the AI approaches certain tasks.
OpenAPI/REST: designed for human developers to call services over HTTP. Documentation-focused. The developer reads the spec and writes code to call the API.
MCP: designed for AI models to discover and call tools dynamically at runtime. The AI reads the tool descriptions and decides when/how to call them — no human writing the call code. MCP also runs locally (stdio) or over SSE, not just HTTP. They're complementary: you might wrap an existing REST API as an MCP tool.
🚀 Deployment
FastAPI for your AI backend, Docker for packaging, environment variables for secrets, cloud for shipping. Ship your first AI product.
Auto-documentation: visit
/docs and get a full interactive Swagger UI automatically generated from your code — no extra work.Pydantic validation: define your request/response shapes as classes. FastAPI automatically validates inputs and returns helpful error messages if they're wrong.
Async first: built on async Python, so it handles thousands of concurrent requests efficiently — perfect for AI apps where each request waits on an LLM call.
Type safety: Python type hints give you autocomplete and catch bugs early.
The pattern: define what data comes in (
ChatRequest), call your AI model, return the result. FastAPI handles everything else.uvicorn main:app --reload (dev) or uvicorn main:app --workers 4 (prod).Gunicorn: a mature WSGI server that can manage multiple uvicorn worker processes. Use in production for stability and process management:
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker.Hypercorn: another ASGI server, supports HTTP/2 and WebSockets better than uvicorn. Less common but worth knowing exists.
Typical production setup: Gunicorn managing 4 Uvicorn workers, behind an Nginx reverse proxy.
Options in order of security:
(1) Environment variables at runtime:
docker run -e ANTHROPIC_API_KEY=sk-... my-app. Simple but keys appear in process lists.(2) Docker secrets: Docker Swarm's built-in secret management. Secrets are mounted as files inside the container, not environment variables.
(3) Cloud secret managers: AWS Secrets Manager, GCP Secret Manager, Azure Key Vault. Your container fetches secrets at startup using an IAM role — the secret never appears in any config file.
GET /health endpoint that returns {"status": "ok"} when the service is running correctly. Load balancers, orchestrators (Kubernetes), and monitoring tools ping this endpoint every few seconds.Why it matters: if your AI app crashes or gets stuck, the load balancer detects the failed health check and stops sending traffic to that instance. A new instance starts automatically. Without a health check, traffic keeps going to a broken server and users get errors.
Good health checks also verify dependencies: can we reach the database? Is the model loaded? Not just 'is the process running?'
from fastapi.security import HTTPBearer; security = HTTPBearer()Then add a dependency:
async def verify_key(token = Depends(security)): if token.credentials != VALID_KEY: raise HTTPException(401)Add it to your route:
@app.post('/chat', dependencies=[Depends(verify_key)])For user-facing apps: use JWT tokens with OAuth2 (FastAPI has built-in support). For internal microservices: mTLS or shared secret headers. Never expose an AI endpoint with no auth — you'll rack up API costs from abuse.