FastAPI for Machine Learning & LLM AI Agents
Go from your first endpoint to production-ready model serving and autonomous AI agents — every concept explained, every step shown, every example runnable.
Preface & How to Read This Book
This book has one promise: by the last page you will be able to take any model — a scikit-learn classifier, a deep-learning network, or a large language model — and wrap it in a fast, well-structured web API that real users can call.
FastAPI has quietly become the default way Python developers ship machine learning and AI. It is fast, it validates data automatically, it generates interactive documentation for free, and it speaks the async language that LLM streaming and concurrent model calls demand. If Python is the language of ML, FastAPI is fast becoming the language of ML in production.
Who this book is for
You know a little Python. You may have trained a model in a notebook, or called an LLM from a script, and now you want to serve it — to turn that code into something with a URL. You do not need prior web-development experience. We start from a blank folder.
How the book is organised
The book is colour-coded so you always know where you are:
Foundations & Robust APIs
The teal chapters teach FastAPI itself — routing, validation, async, dependencies, errors. Everything else builds on these.
Machine Learning
The amber chapters serve classic ML models: training, saving, loading, and exposing predictions safely.
LLM & AI Agents
The violet chapters cover LLM calls, streaming, memory, RAG, and a working tool-using agent.
Production
The slate chapters get you live: config, auth, Docker, scaling, and a final checklist.
How to read the callouts
A shortcut, best practice, or time-saver worth remembering.
Extra context that deepens your understanding without blocking progress.
A common mistake or a thing that will bite you in production.
A concept the rest of the book leans on. Slow down here.
Conventions
Every code block shows the file it belongs in. Lines you type in a terminal start with $ (don't type the dollar sign). When you see http://127.0.0.1:8000, that is your own machine — the server you're running. Click the Copy button on any block to grab the code.
Write a little code → run the server → open the docs at /docs → try it → repeat. FastAPI's auto-generated docs turn learning into a fast feedback loop. Use them constantly.
What Is FastAPI — and Why ML & AI Love It
FastAPI is a modern Python web framework for building APIs. An API (Application Programming Interface) is simply a way for programs to talk to each other over the web: one side sends a request, the other sends back data.
When you train a model, it lives inside your Python process. Nobody else can use it. An API is the bridge: it lets a website, a mobile app, or another service send input (an image, a sentence, a row of numbers) and receive your model's answer (a label, a score, a generated reply). FastAPI is the tool that builds that bridge with the least effort.
Why FastAPI specifically
Genuinely fast
Built on Starlette and Uvicorn, its performance rivals Node.js and Go — important when a model call is already your bottleneck.
Automatic validation
Type hints + Pydantic mean malformed input is rejected before it ever reaches your model. Fewer crashes, clearer errors.
Free interactive docs
Every endpoint gets a live, testable documentation page at /docs with zero extra work.
Async-native
Streaming LLM tokens and firing concurrent model calls is natural with async/await.
How a request actually flows
Understanding this picture makes everything else click. A client sends an HTTP request; FastAPI matches it to one of your functions, validates the data, runs your code (which may call a model), and serializes whatever you return back into JSON.
- Client sends a request — e.g.
POST /predictwith a JSON body of features. - FastAPI routes it to the matching function based on the path and HTTP method.
- Pydantic validates the incoming data against your declared types. Bad data → automatic 422 error.
- Your function runs — load features, call
model.predict(), or stream from an LLM. - FastAPI serializes your return value to JSON and sends the HTTP response back.
The vocabulary you'll see everywhere
| Term | Plain-English meaning |
|---|---|
endpoint / route | A URL your API answers, e.g. /predict. |
| HTTP method | GET reads data, POST sends data to create/process, PUT/PATCH update, DELETE removes. |
| path / query params | Values in the URL: /items/5 (path) or /items?limit=5 (query). |
| request body | A JSON payload sent with the request — how you send features or prompts. |
| status code | A number describing the result: 200 OK, 422 bad input, 500 server error. |
| Pydantic model | A Python class defining the shape of your data, with automatic validation. |
FastAPI is built on two libraries you'll occasionally hear about: Starlette (the web machinery) and Pydantic (the data validation). You rarely use them directly — FastAPI wraps them in a friendly interface.
Installation & Project Setup
Before any code, we set up a clean, isolated environment. Doing this once correctly will save you hours of dependency headaches later.
Step 1 — Check your Python
FastAPI needs Python 3.9 or newer (3.11+ recommended). Verify what you have:
$ python --version
# If that prints Python 2.x, try:
$ python3 --version
# You want: Python 3.11.x (or 3.9+)On macOS and Linux, python may point to an old version. Use python3 and pip3 if so. On Windows, the installer's "Add Python to PATH" box must be checked.
Step 2 — Create a project folder & virtual environment
A virtual environment is a private copy of Python for this project, so its packages never clash with another project's. Always use one.
# 1. make and enter a project folder
$ mkdir ml-api && cd ml-api
# 2. create a virtual environment named .venv
$ python -m venv .venv
# 3. activate it
# macOS / Linux:
$ source .venv/bin/activate
# Windows (PowerShell):
$ .venv\Scripts\Activate.ps1
# your prompt now shows (.venv) — you're inside itStep 3 — Install FastAPI
The [standard] extra bundles everything you need to run a server, including Uvicorn and the new fastapi command-line tool.
$ pip install "fastapi[standard]"That single command installs FastAPI, Uvicorn (the server that actually runs your app), Pydantic (validation), and helpers for forms, files and templates.
Step 4 — Install the libraries for this book
We'll touch ML and LLM tooling later. Install them now so every example just runs. Put this in a requirements.txt:
fastapi[standard]
scikit-learn
joblib
numpy
pydantic-settings
openai
anthropic
python-dotenv$ pip install -r requirements.txtA recommended folder layout
For now, a single file is fine. As projects grow you'll want structure (Chapter 12). Here's where we're heading:
ml-api/
├── .venv/ # virtual environment (never commit this)
├── .env # secrets & config (never commit this)
├── requirements.txt # pinned dependencies
├── main.py # your FastAPI app
└── models/ # saved model files (.joblib, etc.)Create a .gitignore with .venv/ and .env in it immediately. Committing your virtual environment or secrets is the most common beginner mistake.
Your First API in Three Minutes
Time to see FastAPI breathe. We'll write the smallest possible app, run it, and meet the interactive docs that make this framework a joy to learn.
The "hello world" app
Create a file called main.py with exactly this:
from fastapi import FastAPI
# 'app' is the central object that holds your whole API
app = FastAPI(title="My First API")
# @app.get("/") attaches this function to GET requests on "/"
@app.get("/")
def read_root():
return {"message": "Hello, FastAPI!"}Three things are happening. FastAPI() creates your application. The @app.get("/") decorator says "when someone makes a GET request to the root URL, run the function below." The function returns a Python dictionary, which FastAPI automatically converts to JSON.
Run it
$ fastapi dev main.pyYou'll see output ending with a URL. Open http://127.0.0.1:8000 in your browser and you'll see your JSON message. The dev command auto-reloads the server every time you save the file.
You may see tutorials use uvicorn main:app --reload. That still works and does the same thing — main is the file, app is the object inside it. The newer fastapi dev is just a friendlier wrapper.
The magic part — interactive docs
Now visit http://127.0.0.1:8000/docs. FastAPI built a complete, interactive documentation page from your code. You can expand the endpoint, click Try it out, hit Execute, and see the real response — no extra tools needed.
This /docs page (powered by the OpenAPI standard) updates itself as you write code. It is your test console, your reference, and what you'll hand to front-end developers. There's also /redoc for a cleaner reading view.
Add a second endpoint
Let's prove how little code an endpoint takes. Add this below the first function:
@app.get("/health")
def health_check():
# A simple endpoint to confirm the API is alive —
# you'll add one of these to every real service.
return {"status": "ok"}Save, and because the server auto-reloaded, http://127.0.0.1:8000/health already works. You just wrote a health check — the exact endpoint load balancers and Docker use to know your service is running.
Keep the server running in one terminal and your editor open beside it. Edit, save, refresh /docs. That tight loop is how you'll learn the rest of this book quickly.
Foundations
The four chapters that make every later one easy. You'll learn how data enters your API, how FastAPI validates it for free, how you shape what comes back, and how async unlocks concurrency for model calls.
Path & Query Parameters
There are two ways to pass simple values through a URL. Both are declared as plain function arguments, and FastAPI figures out which is which from where they appear.
Path parameters — part of the URL itself
Put a name in curly braces in the route, then add a matching argument. FastAPI captures it and — because you typed it as int — converts and validates it automatically.
@app.get("/items/{item_id}")
def read_item(item_id: int):
return {"item_id": item_id}Visit /items/42 and you get {"item_id": 42}. Visit /items/banana and FastAPI returns a clean 422 error explaining that an integer was expected — you wrote zero validation code.
The type hint is not decoration. item_id: int tells FastAPI to parse, validate, document, and convert the value. Change it to str and "banana" is accepted. Your types are your API contract.
Query parameters — the part after the ?
Any function argument that isn't in the path becomes a query parameter. Give it a default and it becomes optional.
@app.get("/items/")
def list_items(skip: int = 0, limit: int = 10):
# called as /items/?skip=20&limit=5
return {"skip": skip, "limit": limit}Calling /items/?skip=20&limit=5 returns those values. Omit them and you get the defaults 0 and 10.
Optional parameters & validation rules
Use str | None = None for a truly optional value, and Query() to attach rules like minimum length or numeric bounds — all enforced before your code runs.
from fastapi import Query
@app.get("/search")
def search(
q: str = Query(min_length=2, max_length=50),
page: int = Query(default=1, ge=1), # ge = greater-or-equal
):
return {"query": q, "page": page}| Rule | Meaning | Applies to |
|---|---|---|
min_length / max_length | String length bounds | str |
ge / gt | ≥ or > a number | int, float |
le / lt | ≤ or < a number | int, float |
pattern | Must match a regex | str |
When you serve a model, query params are perfect for knobs like ?top_k=5 or ?temperature=0.7 — small settings that tweak how a prediction is made.
Request Bodies & Pydantic Models
URLs are fine for small values, but to send a structured payload — a set of features, a chat message, a nested object — you use a request body. This is where Pydantic, FastAPI's superpower, takes over.
Defining the shape of your data
A Pydantic model is a class that inherits from BaseModel. Each attribute is a field with a type. FastAPI uses it to parse JSON, validate every field, and document the schema.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
price: float
in_stock: bool = True # has a default → optional
tags: list[str] = [] # a list of strings, empty by default
@app.post("/items/")
def create_item(item: Item):
# 'item' is a fully-validated Item object
total = item.price * 1.1 # add 10% tax
return {"name": item.name, "price_with_tax": round(total, 2)}Send this JSON to POST /items/ and you get a validated object back:
{
"name": "Wireless Mouse",
"price": 25.00,
"tags": ["electronics", "accessories"]
}If a field is missing, has the wrong type, or fails a rule, FastAPI never runs your function. It replies 422 with a precise list of what's wrong and where. Your model code only ever sees clean, correct data.
Field constraints & nested models
Use Field() for per-field rules and example values, and nest models inside models for rich structures.
from pydantic import BaseModel, Field
class Image(BaseModel):
url: str
width: int = Field(gt=0)
class Product(BaseModel):
name: str = Field(min_length=1, max_length=100)
price: float = Field(gt=0, description="Price in USD")
image: Image | None = None # a nested model, optionalWhy this matters so much for ML & AI
Models are picky. A scikit-learn model wants exactly four floats in the right order; an LLM endpoint wants a non-empty prompt. Pydantic lets you state those requirements declaratively and have them enforced at the door — turning a class of runtime crashes into friendly validation errors.
class IrisFeatures(BaseModel):
sepal_length: float = Field(gt=0, le=10)
sepal_width: float = Field(gt=0, le=10)
petal_length: float = Field(gt=0, le=10)
petal_width: float = Field(gt=0, le=10)
class ChatRequest(BaseModel):
message: str = Field(min_length=1, max_length=4000)
temperature: float = Field(default=0.7, ge=0, le=2)Name your models by direction: IrisFeatures (input) and Prediction (output). It keeps large APIs readable and pairs perfectly with response models, next chapter.
Responses, Models & Status Codes
You've controlled what comes in. Now control what goes out: declare the exact shape of your response, set the right status code, and never leak internal fields by accident.
Response models — the output contract
Pass response_model= to the decorator. FastAPI will filter your return value to match, validate it, and document it. Anything not in the model is stripped out.
from pydantic import BaseModel
class UserIn(BaseModel):
username: str
password: str # sensitive — must never be returned
class UserOut(BaseModel):
username: str # safe to expose
@app.post("/users/", response_model=UserOut)
def create_user(user: UserIn):
# even though we return the whole object, FastAPI
# filters it down to UserOut — password is dropped.
return userThe response model is a safety net, not just documentation. Returning a database object with a password or api_key field? A response model that omits it guarantees it never reaches the client.
Setting status codes
The default is 200 OK. For "created", use 201. Set it on the decorator for the success case, and raise HTTPException for errors (full treatment in Chapter 10).
from fastapi import HTTPException, status
@app.post("/users/", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def create_user(user: UserIn):
if user.username == "admin":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already taken",
)
return userThe status codes you'll actually use
| Code | Name | When |
|---|---|---|
200 | OK | Successful GET or general success. |
201 | Created | A POST created a new resource. |
400 | Bad Request | The client sent something wrong you caught yourself. |
401 / 403 | Unauthorized / Forbidden | Missing/invalid auth, or not allowed. |
404 | Not Found | The requested resource doesn't exist. |
422 | Unprocessable Entity | Validation failed — FastAPI sends this for you. |
500 | Server Error | Your code crashed. Aim to never send these. |
Use the status constants (status.HTTP_201_CREATED) instead of raw numbers. They read better and your editor autocompletes them.
Async & Concurrency, Demystified
You'll see async def all over AI code. Here's what it really means, when to use it, and the one rule that keeps your ML API fast under load.
The problem async solves
Most of an AI request is waiting — waiting on an LLM API, a database, a downloaded file. While one request waits, a normal (synchronous) server sits idle. An async server uses that waiting time to serve other requests. More throughput, same hardware.
import httpx
# 'async def' makes this a coroutine; 'await' yields control while waiting
@app.get("/joke")
async def get_joke():
async with httpx.AsyncClient() as client:
resp = await client.get("https://api.example.com/joke")
return resp.json()When to use which
| Your endpoint mostly… | Use | Why |
|---|---|---|
| waits on network/LLM/DB (with async libraries) | async def | The server stays free during the wait. |
| does fast in-memory work | def | Simpler; FastAPI handles it in a threadpool. |
| runs heavy CPU work (model.predict on big data) | def | FastAPI offloads def routes to threads, so they don't freeze the server. |
Never call a blocking function (a slow non-async call, or a heavy CPU task) directly inside an async def route. It freezes the entire server for every user. Either use an async library, or make the route a plain def and let FastAPI move it to a thread.
Running things at the same time
Need three LLM calls? Don't wait for them one by one. Fire them together with asyncio.gather and wait once.
import asyncio
@app.post("/analyze")
async def analyze(text: str):
# run three independent async tasks concurrently
sentiment, summary, keywords = await asyncio.gather(
get_sentiment(text),
get_summary(text),
get_keywords(text),
)
return {"sentiment": sentiment, "summary": summary, "keywords": keywords}If each call takes one second, doing them sequentially costs three seconds; with gather it costs roughly one. This pattern is gold for agents that consult several tools at once.
Starting out, "when unsure, use def" is safe advice — FastAPI's threadpool protects you. Reach for async deliberately once you're calling async-native LLM and HTTP clients.
Building Robust APIs
The difference between a demo and a service. Dependency injection, middleware, graceful errors, background work, file handling, and a project structure that scales past one file.
Dependency Injection
Dependency injection sounds intimidating; it's really just "shared setup code, declared as a parameter." It's how FastAPI gives every endpoint a database connection, the current user, or a loaded model — without repetition.
A dependency is just a function
Write a function that returns something, then ask for it with Depends(). FastAPI calls it for you and passes the result in.
from fastapi import Depends
# a reusable dependency: common list parameters
def pagination(skip: int = 0, limit: int = 10):
return {"skip": skip, "limit": limit}
@app.get("/users/")
def list_users(page: dict = Depends(pagination)):
return page
@app.get("/items/")
def list_items(page: dict = Depends(pagination)):
return page # same logic, written onceAnything a dependency declares — query params, bodies, other dependencies — is merged into the endpoint's docs and validation. Dependencies compose: a dependency can depend on other dependencies, all the way down.
Dependencies with setup & teardown
Use yield to run code before and after the request — perfect for opening and closing a database session or any resource that must be cleaned up.
def get_db():
db = SessionLocal() # open connection
try:
yield db # hand it to the endpoint
finally:
db.close() # always runs, even on error
@app.get("/customers/")
def read_customers(db = Depends(get_db)):
return db.query(Customer).all()Why this is the backbone of ML APIs
A dependency is the cleanest way to access a model loaded once at startup, enforce an API key, or build a per-request context. You'll meet these exact patterns again in the ML and agent chapters.
from fastapi import Depends, Header, HTTPException
# a guard dependency reused across many protected routes
def require_api_key(x_api_key: str = Header()):
if x_api_key != "secret-key":
raise HTTPException(status_code=401, detail="Invalid API key")
return x_api_key
@app.post("/predict", dependencies=[Depends(require_api_key)])
def predict():
return {"prediction": 1}When a dependency only guards (no value needed in the function), list it in the decorator's dependencies=[...] instead of the signature — cleaner.
Middleware & CORS
Middleware is code that wraps every request — running before your endpoint and after your response. Use it for logging, timing, and the one setting that trips up every beginner: CORS.
Writing custom middleware
This example times every request and adds the duration to a response header — invaluable for spotting slow model calls.
import time
from fastapi import Request
@app.middleware("http")
async def add_timing(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request) # run the endpoint
duration = time.perf_counter() - start
response.headers["X-Process-Time"] = f"{duration:.3f}s"
return responseCORS — letting browsers call your API
Browsers block a web page on site-a.com from calling an API on site-b.com unless that API explicitly allows it. If your React or mobile-web front-end can't reach your API, CORS is almost always why.
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://myfrontend.com"], # who may call you
allow_credentials=True,
allow_methods=["*"], # GET, POST, ...
allow_headers=["*"],
)It's tempting to set allow_origins=["*"] to make the error disappear. That's fine for a quick local test, but in production list your real front-end domains. A wildcard with credentials is a security hole and is actually disallowed by browsers.
Middleware runs in the reverse of the order you add it. The last one added is the outermost wrapper. For most apps you won't notice, but keep it in mind when stacking several.
Errors & Custom Validation
Things go wrong: a model isn't loaded, an ID doesn't exist, an LLM times out. Good APIs fail clearly. Here's how to raise meaningful errors and shape them consistently.
Raising HTTP errors
HTTPException stops the endpoint immediately and sends a proper error response with your message.
from fastapi import HTTPException
products = {1: "Laptop", 2: "Phone"}
@app.get("/products/{pid}")
def get_product(pid: int):
if pid not in products:
raise HTTPException(
status_code=404,
detail=f"Product {pid} not found",
)
return {"id": pid, "name": products[pid]}Custom validation beyond types
Pydantic validators check rules a type alone can't express — like "end date must be after start date" or "prompt can't be only whitespace."
from pydantic import BaseModel, field_validator
class Prompt(BaseModel):
text: str
@field_validator("text")
@classmethod
def not_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("Prompt cannot be empty or whitespace")
return v.strip()One consistent error shape for the whole app
An exception handler catches a type of error everywhere and formats it uniformly — so clients always parse errors the same way.
from fastapi import Request
from fastapi.responses import JSONResponse
class ModelNotReadyError(Exception):
pass
@app.exception_handler(ModelNotReadyError)
async def model_not_ready(request: Request, exc: ModelNotReadyError):
return JSONResponse(
status_code=503,
content={"error": "model_unavailable",
"detail": "The model is still loading. Try again shortly."},
)For an AI service, define a small set of custom exceptions (RateLimited, ModelNotReadyError, UpstreamTimeout) and one handler each. Your clients get clean, predictable errors instead of raw 500s.
Background Tasks & File Uploads
Two everyday needs for AI services: doing slow work after responding, and accepting files like images, audio, or documents to feed a model.
Background tasks — respond now, work later
Some work (sending an email, writing a log, kicking off a long job) shouldn't make the user wait. Hand it to BackgroundTasks and FastAPI runs it after the response is sent.
from fastapi import BackgroundTasks
def log_prediction(features: list, result: int):
with open("predictions.log", "a") as f:
f.write(f"{features} -> {result}\n")
@app.post("/predict")
def predict(features: list[float], bg: BackgroundTasks):
result = 1 # pretend model output
bg.add_task(log_prediction, features, result) # runs after response
return {"prediction": result}Background tasks run in the same process — great for quick jobs. For heavy or long-running work (fine-tuning, big batch jobs) you'll want a real task queue like Celery or RQ. Background tasks are the right tool for "small and fire-and-forget."
Accepting file uploads
Declare a parameter of type UploadFile. You get the filename, content type, and async methods to read the bytes — ideal for an image-classification model.
from fastapi import UploadFile, HTTPException
@app.post("/classify-image")
async def classify_image(file: UploadFile):
if not file.content_type.startswith("image/"):
raise HTTPException(400, "Please upload an image file")
contents = await file.read() # raw bytes
# img = Image.open(io.BytesIO(contents)) # then feed to your model
return {"filename": file.filename, "size_bytes": len(contents)}Multiple files & form fields together
from fastapi import UploadFile, Form
@app.post("/batch-classify")
async def batch_classify(
files: list[UploadFile],
model_name: str = Form(default="resnet50"),
):
return {"model": model_name, "count": len(files)}Large uploads can exhaust memory if you read them whole. For big files, stream in chunks (while chunk := await file.read(1_000_000):) or write straight to disk, and set a size limit at your proxy.
Structuring a Project That Grows
One main.py is perfect for learning and small services. Past a few endpoints, split the app into routers and modules so it stays navigable.
Routers — endpoints in their own files
An APIRouter is a mini-app you can develop separately and plug into the main one. Group related routes (all the prediction endpoints, all the chat endpoints) into a router.
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter(prefix="/predict", tags=["predictions"])
class Features(BaseModel):
values: list[float]
@router.post("/")
def make_prediction(f: Features):
return {"prediction": sum(f.values)} # placeholder logicfrom fastapi import FastAPI
from routers import predict, chat
app = FastAPI(title="ML & AI Service")
app.include_router(predict.router)
app.include_router(chat.router)
@app.get("/health")
def health():
return {"status": "ok"}A layout that scales
app/
├── main.py # creates FastAPI(), includes routers
├── config.py # settings (Chapter 24)
├── dependencies.py # shared Depends() functions
├── routers/
│ ├── predict.py # /predict endpoints
│ └── chat.py # /chat endpoints
├── schemas/ # Pydantic models (in & out)
│ ├── predict.py
│ └── chat.py
├── services/ # the real logic: model loading, LLM calls
│ ├── ml_model.py
│ └── llm.py
└── models/ # saved .joblib / weightsRouters handle HTTP. schemas/ define data shapes. services/ hold the actual ML/LLM logic with no web code in them. This split means you can test your model logic without spinning up a server, and reuse it anywhere.
The tags=["predictions"] argument groups endpoints into labelled sections on the /docs page. With a dozen routes, this makes your documentation dramatically easier to scan.
Serving Machine Learning
Take a trained scikit-learn model and turn it into a fast, validated, production-shaped prediction service — covering training, persistence, single and batch predictions, image inputs, and loading the model exactly once.
How ML Model Serving Works
"Serving" a model means making its predictions available over the web. The model is trained once, saved to a file, and loaded by your API; then every request runs a fast prediction. Let's get the mental model right before code.
Training vs. serving — two different worlds
| Training | Serving (this part) | |
|---|---|---|
| When | Once, offline | Every request, live |
| Goal | Learn patterns from data | Apply them, fast & reliably |
| Speed need | Can take hours | Milliseconds matter |
| Output | A saved model file | A JSON prediction |
The serving lifecycle
- Train & evaluate a model in a notebook or script (Chapter 14).
- Serialize it to a file with
joblibso it survives outside the training process. - Load it once when the API starts — not on every request (Chapter 17).
- Validate input with a Pydantic model so the features match what the model expects.
- Predict & respond — call
model.predict()and return clean JSON.
A model is a frozen function of its features. If it was trained on four numbers in a fixed order, it will silently produce garbage if you send them in a different order or scale. Your Pydantic schema is the contract that prevents this — it's not optional polish, it's correctness.
Any preprocessing you did before training (scaling, encoding) must happen identically at serve time. Save the preprocessing steps alongside the model — a scikit-learn Pipeline is the cleanest way to bundle both into one file.
Train, Save & Load a Model
Let's create a real model file you'll serve in the next chapter. We'll train a classifier on the classic Iris dataset and persist it with joblib.
The training script
Run this once. It trains a model and writes models/iris_model.joblib. Note the Pipeline: it bundles scaling and the classifier so serving applies the exact same steps.
import joblib
from pathlib import Path
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import RandomForestClassifier
# 1. load data
iris = load_iris()
X, y = iris.data, iris.target
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
# 2. bundle preprocessing + model so serving matches training exactly
pipeline = Pipeline([
("scaler", StandardScaler()),
("clf", RandomForestClassifier(n_estimators=100, random_state=42)),
])
pipeline.fit(X_train, y_train)
# 3. evaluate
accuracy = pipeline.score(X_test, y_test)
print(f"Test accuracy: {accuracy:.2%}")
# 4. save — model + class names together
Path("models").mkdir(exist_ok=True)
joblib.dump(
{"pipeline": pipeline, "target_names": list(iris.target_names)},
"models/iris_model.joblib",
)
print("Saved to models/iris_model.joblib")$ python train.py
# Test accuracy: 100.00%
# Saved to models/iris_model.joblibLoading it back
import joblib
bundle = joblib.load("models/iris_model.joblib")
pipeline = bundle["pipeline"]
names = bundle["target_names"] # ['setosa', 'versicolor', 'virginica']
pred = pipeline.predict([[5.1, 3.5, 1.4, 0.2]])
print(names[pred[0]]) # -> setosaYou'll see pickle used too. joblib is preferred for scikit-learn because it handles large NumPy arrays more efficiently. Either way, only ever load model files you trust — unpickling untrusted files can run arbitrary code.
Saving a dict bundle (model + class names + maybe a version string) means the serving code needs no hard-coded labels. Add "version": "1.0" now and thank yourself at deployment time.
The Prediction Endpoint
Now the payoff: a complete, validated prediction API for the model you just trained. This is the canonical pattern you'll reuse for almost any tabular model.
The full app
import joblib
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI(title="Iris Classifier API")
# load once at import time (improved in Chapter 17)
bundle = joblib.load("models/iris_model.joblib")
pipeline = bundle["pipeline"]
target_names = bundle["target_names"]
# --- input contract ---
class IrisInput(BaseModel):
sepal_length: float = Field(gt=0, le=10, examples=[5.1])
sepal_width: float = Field(gt=0, le=10, examples=[3.5])
petal_length: float = Field(gt=0, le=10, examples=[1.4])
petal_width: float = Field(gt=0, le=10, examples=[0.2])
# --- output contract ---
class Prediction(BaseModel):
species: str
class_id: int
confidence: float
@app.post("/predict", response_model=Prediction)
def predict(data: IrisInput):
features = [[
data.sepal_length, data.sepal_width,
data.petal_length, data.petal_width,
]]
class_id = int(pipeline.predict(features)[0])
proba = pipeline.predict_proba(features)[0]
return Prediction(
species=target_names[class_id],
class_id=class_id,
confidence=round(float(proba[class_id]), 4),
)Try it
Run fastapi dev main.py, open /docs, and POST to /predict:
// request body
{ "sepal_length": 5.1, "sepal_width": 3.5,
"petal_length": 1.4, "petal_width": 0.2 }
// response
{ "species": "setosa", "class_id": 0, "confidence": 0.99 }Test it from the command line
$ curl -X POST http://127.0.0.1:8000/predict \
-H "Content-Type: application/json" \
-d '{"sepal_length":5.1,"sepal_width":3.5,"petal_length":1.4,"petal_width":0.2}'Notice the symmetry: a typed model for input, a typed model for output, the model call sandwiched between. Swap in a churn predictor, a price estimator, or a fraud detector and the skeleton is identical. Master this one shape.
Always return a confidence/probability when you can. Callers can then set their own thresholds ("only act if confidence > 0.8"), which makes your model far more useful downstream.
Batch Predictions & Image Models
Two common upgrades: predicting on many rows in one request (faster and cheaper), and accepting an image file for vision models.
Batch predictions
Accept a list of inputs and return a list of results. One round-trip instead of hundreds — a huge win for clients scoring data in bulk.
from pydantic import BaseModel, Field
class BatchInput(BaseModel):
items: list[IrisInput] = Field(max_length=1000) # cap the batch size
class BatchResult(BaseModel):
predictions: list[Prediction]
@app.post("/predict/batch", response_model=BatchResult)
def predict_batch(data: BatchInput):
rows = [[
i.sepal_length, i.sepal_width, i.petal_length, i.petal_width
] for i in data.items]
class_ids = pipeline.predict(rows) # vectorised, one call
probas = pipeline.predict_proba(rows)
results = [
Prediction(
species=target_names[int(c)],
class_id=int(c),
confidence=round(float(p[int(c)]), 4),
)
for c, p in zip(class_ids, probas)
]
return BatchResult(predictions=results)Always pass the whole batch to predict() at once. scikit-learn (and NumPy under it) is far faster on one big array than on a Python loop of single rows. And cap batch size with max_length so one request can't exhaust memory.
An image-classification endpoint
Combine the file-upload pattern from Chapter 11 with a vision model. Here's the shape (using Pillow to decode the bytes):
import io
from PIL import Image
from fastapi import UploadFile, HTTPException
@app.post("/vision/classify")
async def classify(file: UploadFile):
if not file.content_type.startswith("image/"):
raise HTTPException(400, "Upload an image file")
raw = await file.read()
image = Image.open(io.BytesIO(raw)).convert("RGB").resize((224, 224))
# tensor = preprocess(image) # to your framework's format
# logits = vision_model(tensor) # run the model
# label = labels[int(logits.argmax())]
label, score = "tabby cat", 0.92 # placeholder
return {"label": label, "confidence": score, "filename": file.filename}Vision inference is CPU/GPU-heavy. Keep this route a plain def (or run the model call in a threadpool) so it doesn't block the event loop — recall the one rule from Chapter 7.
Loading Models the Right Way
Loading a model on every request is the most common performance mistake in ML APIs. Load it once when the app starts, using FastAPI's lifespan handler.
The lifespan pattern
A lifespan function runs setup code before the server accepts traffic and cleanup code on shutdown. Load your model into a shared place during startup.
import joblib
from contextlib import asynccontextmanager
from fastapi import FastAPI
ml = {} # a simple shared container
@asynccontextmanager
async def lifespan(app: FastAPI):
# --- startup: runs once, before serving ---
print("Loading model...")
ml["bundle"] = joblib.load("models/iris_model.joblib")
yield
# --- shutdown: runs once, on exit ---
ml.clear()
print("Model unloaded")
app = FastAPI(lifespan=lifespan)
@app.post("/predict")
def predict(data: IrisInput):
pipeline = ml["bundle"]["pipeline"] # already in memory — instant
features = [[data.sepal_length, data.sepal_width,
data.petal_length, data.petal_width]]
return {"class_id": int(pipeline.predict(features)[0])}Reading a model file and deserializing it can take hundreds of milliseconds — or seconds for deep-learning weights. Do it per request and your API crawls. Do it once at startup and every prediction is fast.
Guarding against "model not ready"
Combine the startup load with the custom error from Chapter 10, exposed through a dependency, so requests during a slow load get a clean 503 instead of a crash.
from fastapi import Depends, HTTPException
def get_model():
bundle = ml.get("bundle")
if bundle is None:
raise HTTPException(503, "Model is still loading, retry shortly")
return bundle["pipeline"]
@app.post("/predict")
def predict(data: IrisInput, model = Depends(get_model)):
features = [[data.sepal_length, data.sepal_width,
data.petal_length, data.petal_width]]
return {"class_id": int(model.predict(features)[0])}Make your /health endpoint report whether the model is loaded: {"status": "ok", "model_loaded": "bundle" in ml}. Orchestrators can then wait for the model before routing traffic to a new instance.
LLMs & AI Agents
Wrap large language models in your API: call them, stream their tokens live, give them memory, ground them in your own documents with RAG, and finally build an agent that decides when to use tools.
LLMs Inside an API
An LLM is just another model you serve — but with three differences that shape every design choice: it lives behind someone else's API, it streams its answer token by token, and it has no memory unless you give it some.
The mental model
Unlike a scikit-learn model that runs locally, most LLMs run on a provider (OpenAI, Anthropic, and others). Your FastAPI app is a middle layer: it receives the user's request, calls the LLM, optionally adds context or tools, and returns the result. That middle layer is where all your value lives — validation, memory, retrieval, auth, rate limiting, and safety.
It's a network call
Calls are slow and can fail. Use async, set timeouts, and handle errors — treat the provider like any flaky upstream service.
It streams
Tokens arrive gradually. Streaming them to the user makes the app feel instant instead of frozen.
It's stateless
The model remembers nothing between calls. Conversation "memory" is just you re-sending the history each time.
The shape of a chat request
LLM chat APIs take a list of messages, each with a role and content. The roles are the entire protocol:
| Role | Purpose |
|---|---|
system | Instructions that set behaviour ("You are a helpful support agent…"). |
user | What the human said. |
assistant | What the model previously replied — included to give it memory. |
Everything in this part — memory, RAG, agents — is fundamentally about what you put in that messages list before you call the model. The API is simple; the craft is in assembling the context.
Your provider API key must live in an environment variable, never in code or git. We set this up properly in Chapter 24. For now, assume it's loaded from the environment.
Calling an LLM From an Endpoint
Let's build a working /chat endpoint. We'll show both major providers — the pattern is nearly identical, so you can swap freely.
With OpenAI
from fastapi import FastAPI
from pydantic import BaseModel, Field
from openai import OpenAI
app = FastAPI()
client = OpenAI() # reads OPENAI_API_KEY from the environment
class ChatRequest(BaseModel):
message: str = Field(min_length=1, max_length=4000)
class ChatReply(BaseModel):
reply: str
@app.post("/chat", response_model=ChatReply)
def chat(req: ChatRequest):
completion = client.chat.completions.create(
model="gpt-4o-mini", # check provider docs for current models
messages=[
{"role": "system", "content": "You are a concise, helpful assistant."},
{"role": "user", "content": req.message},
],
)
return ChatReply(reply=completion.choices[0].message.content)With Anthropic (Claude)
from anthropic import Anthropic
client = Anthropic() # reads ANTHROPIC_API_KEY from the environment
@app.post("/chat", response_model=ChatReply)
def chat(req: ChatRequest):
msg = client.messages.create(
model="claude-sonnet-4-6", # check docs for current models
max_tokens=1024,
system="You are a concise, helpful assistant.",
messages=[{"role": "user", "content": req.message}],
)
return ChatReply(reply=msg.content[0].text)OpenAI puts the system prompt as a system message inside the list; Anthropic takes it as a separate system argument and requires max_tokens. Otherwise the shape is the same: messages in, text out.
Make it resilient
Provider calls fail — rate limits, timeouts, outages. Wrap them and return a clean error instead of a 500.
from fastapi import HTTPException
@app.post("/chat", response_model=ChatReply)
def chat(req: ChatRequest):
try:
completion = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": req.message}],
timeout=30,
)
except Exception as e:
# log the real error server-side; show a safe message to the user
raise HTTPException(502, "The AI service is unavailable. Please retry.")
return ChatReply(reply=completion.choices[0].message.content)Expose knobs as validated fields: temperature (0–2, creativity) and max_tokens (length cap). Bounding them with Pydantic protects you from runaway bills and abusive inputs.
Streaming Responses
Waiting ten seconds for a full answer feels broken. Streaming the tokens as they're generated — exactly like ChatGPT — turns that into an instant, alive experience. FastAPI makes it straightforward.
StreamingResponse + a generator
Ask the provider to stream, loop over the chunks, and yield each piece. StreamingResponse sends them to the client as they arrive.
from fastapi.responses import StreamingResponse
from openai import OpenAI
client = OpenAI()
@app.post("/chat/stream")
def chat_stream(req: ChatRequest):
def token_generator():
stream = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": req.message}],
stream=True, # ask for a stream
)
for chunk in stream:
piece = chunk.choices[0].delta.content
if piece:
yield piece # send each token onward
return StreamingResponse(token_generator(), media_type="text/plain")Server-Sent Events (SSE) for web front-ends
Browsers consume streams most easily as SSE: each message is a line beginning with data:. Format your chunks that way and the front-end's EventSource just works.
import json
@app.post("/chat/sse")
def chat_sse(req: ChatRequest):
def event_stream():
stream = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": req.message}],
stream=True,
)
for chunk in stream:
piece = chunk.choices[0].delta.content
if piece:
yield f"data: {json.dumps({'token': piece})}\n\n"
yield "data: [DONE]\n\n" # signal completion
return StreamingResponse(event_stream(), media_type="text/event-stream")Consuming it from the browser
const res = await fetch("/chat/sse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "Explain async in one line" }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
process.stdout.write(decoder.decode(value)); // append tokens to the UI
}The model takes the same total time either way. Streaming wins because the user sees words within a fraction of a second instead of staring at a spinner. For chat UIs, it's the single biggest UX upgrade you can ship.
Disable response buffering at your proxy (e.g. Nginx proxy_buffering off;) or the stream will be held back and delivered all at once, defeating the purpose.
Memory & Conversations
LLMs are stateless — each call is independent. To hold a conversation, you store the history and re-send it every turn. Here's the pattern, from simplest to production-ready.
The core idea: re-send the history
A "conversation" is just a growing list of messages. Each new turn, you append the user's message, send the whole list, then append the assistant's reply for next time.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from uuid import uuid4
from openai import OpenAI
app = FastAPI()
client = OpenAI()
# demo store: conversation_id -> list of messages
# (use Redis or a database in production — see the note below)
conversations: dict[str, list[dict]] = {}
class Turn(BaseModel):
conversation_id: str | None = None
message: str
@app.post("/conversation")
def converse(turn: Turn):
cid = turn.conversation_id or str(uuid4())
history = conversations.setdefault(cid, [
{"role": "system", "content": "You are a helpful assistant."}
])
history.append({"role": "user", "content": turn.message})
completion = client.chat.completions.create(
model="gpt-4o-mini", messages=history,
)
reply = completion.choices[0].message.content
history.append({"role": "assistant", "content": reply})
return {"conversation_id": cid, "reply": reply}The client sends back the returned conversation_id on the next turn, and the model "remembers" because it sees the full history.
Keeping context from exploding
Histories grow without bound, and every token costs money and counts against the model's context limit. Trim old turns while keeping the system prompt.
def trim(history: list[dict], keep_last: int = 10) -> list[dict]:
system = history[:1] # always keep the system message
recent = history[1:][-keep_last:] # keep only the most recent turns
return system + recentAn in-memory dict loses everything on restart and breaks the moment you run more than one server. Store conversations in Redis (fast, with expiry) or a database. Treat the dict above as a teaching device only.
For long chats, periodically ask the model to summarise older turns into one short system note. You preserve the gist while slashing token count — the same trick assistants use to feel like they remember a long session.
RAG — Chat With Your Own Documents
Retrieval-Augmented Generation lets an LLM answer using your data — docs, a knowledge base, product info — without retraining. The trick: find the relevant text and paste it into the prompt.
How RAG works in four steps
- Embed each document chunk into a vector (a list of numbers capturing meaning).
- Store the vectors. For learning, a list in memory; for scale, a vector database.
- Retrieve — embed the user's question and find the most similar chunks.
- Generate — put those chunks in the prompt and ask the LLM to answer from them.
A minimal but complete RAG endpoint
This self-contained example embeds a few facts, retrieves the closest ones to a question, and grounds the answer in them.
import numpy as np
from fastapi import FastAPI
from pydantic import BaseModel
from openai import OpenAI
app = FastAPI()
client = OpenAI()
# --- our tiny "knowledge base" ---
DOCS = [
"Our return policy allows refunds within 30 days of purchase.",
"Standard shipping takes 3 to 5 business days.",
"Premium members get free express shipping on all orders.",
"Support is available Monday to Friday, 9am to 6pm.",
]
def embed(texts: list[str]) -> np.ndarray:
resp = client.embeddings.create(model="text-embedding-3-small", input=texts)
return np.array([d.embedding for d in resp.data])
# embed the knowledge base once at startup
DOC_VECTORS = embed(DOCS)
def retrieve(question: str, k: int = 2) -> list[str]:
q = embed([question])[0]
# cosine similarity between the question and every doc
sims = DOC_VECTORS @ q / (
np.linalg.norm(DOC_VECTORS, axis=1) * np.linalg.norm(q)
)
top = sims.argsort()[::-1][:k] # indices of the k best matches
return [DOCS[i] for i in top]
class Ask(BaseModel):
question: str
@app.post("/ask")
def ask(body: Ask):
context = retrieve(body.question)
prompt = (
"Answer the question using ONLY the context below. "
"If the answer isn't there, say you don't know.\n\n"
f"Context:\n- " + "\n- ".join(context) +
f"\n\nQuestion: {body.question}"
)
completion = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
return {"answer": completion.choices[0].message.content, "sources": context}RAG keeps answers current and verifiable. Update a document and the next answer reflects it instantly — no retraining. Returning the sources lets users check the model's work, which builds trust.
The in-memory NumPy search is perfect for hundreds of chunks. Past that, swap retrieve() for a vector database (FAISS, Chroma, pgvector, Pinecone). The endpoint logic — embed, retrieve, stuff, generate — stays exactly the same.
Building an AI Agent
An agent is an LLM that can act: instead of only replying, it can decide to call functions you provide — look up weather, query a database, do math — then use the results to answer. This is the capstone of the part.
The agent loop
You give the model a set of tools (functions it may call). It reads the user's request and either answers directly or asks to run a tool. You run it, hand back the result, and repeat until it produces a final answer.
- Describe your tools to the model (name, purpose, parameters).
- Send the message. The model replies with text or a request to call a tool.
- If it wants a tool, run the matching Python function with its arguments.
- Return the result to the model and loop, until it gives a final text answer.
A working tool-using agent
Here the model can call a get_weather function. Real tools would hit a database or an API — the loop is identical.
import json
from fastapi import FastAPI
from pydantic import BaseModel
from openai import OpenAI
app = FastAPI()
client = OpenAI()
# --- 1. the actual Python tool ---
def get_weather(city: str) -> str:
fake = {"dhaka": "32°C, humid", "london": "14°C, rainy"}
return fake.get(city.lower(), "Unknown city")
# --- 2. describe the tool to the model ---
TOOLS = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}]
class AgentRequest(BaseModel):
message: str
@app.post("/agent")
def agent(req: AgentRequest):
messages = [{"role": "user", "content": req.message}]
# --- 3. the loop ---
for _ in range(5): # safety cap on iterations
completion = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=TOOLS,
)
msg = completion.choices[0].message
messages.append(msg)
if not msg.tool_calls: # no tool wanted -> final answer
return {"reply": msg.content}
# the model asked to run one or more tools
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
result = get_weather(**args) # run the real function
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": result,
})
return {"reply": "Stopped after too many steps."}Ask it "What should I wear in London today?" and it will call get_weather("London"), see "rainy", and advise a raincoat — reasoning over a tool result it fetched itself.
The LLM never runs code — it only requests a call with arguments. Your server runs the function and returns the result. That boundary is your security control: you choose exactly which tools exist and validate every argument before acting.
An agent can loop forever or rack up cost. The iteration cap above is essential. Add timeouts, validate tool arguments with Pydantic, and never expose a tool that can do irreversible damage without a guard.
Start with one or two read-only tools (search, lookup). Add write actions only once you trust the agent's behaviour and have logging in place to audit every tool call it makes.
Shipping to Production
The last mile that separates a working prototype from a real service: managing secrets, locking down access, packaging with Docker, scaling under load, and a checklist to run before you go live.
Configuration & Secrets
Hard-coded API keys and settings are a liability. The professional approach: read everything from the environment, validated by a typed settings object — so a missing key fails loudly at startup, not mysteriously at 3am.
A typed settings object
pydantic-settings reads config from environment variables and a .env file, validates the types, and gives you autocomplete everywhere.
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
openai_api_key: str # required — startup fails if missing
model_name: str = "gpt-4o-mini" # sensible default
max_tokens: int = 1024
environment: str = "development"
model_config = SettingsConfigDict(env_file=".env")
settings = Settings() # import this anywhere you need configOPENAI_API_KEY=sk-your-real-key-here
MODEL_NAME=gpt-4o-mini
ENVIRONMENT=productionUsing settings in the app
from openai import OpenAI
from config import settings
client = OpenAI(api_key=settings.openai_api_key)
@app.post("/chat")
def chat(req: ChatRequest):
completion = client.chat.completions.create(
model=settings.model_name,
max_tokens=settings.max_tokens,
messages=[{"role": "user", "content": req.message}],
)
return {"reply": completion.choices[0].message.content}Because openai_api_key has no default, the app refuses to start if it's missing — a loud, immediate error beats a confusing failure on the first real request. Validate config at startup, always.
Add .env to .gitignore before your first commit. Commit a .env.example with empty values instead, so teammates know what to set without ever seeing real secrets.
Authentication
An open ML or LLM endpoint is an open invitation to abuse and a runaway bill. At minimum, require an API key. Here's the simple version and the path to proper tokens.
API-key authentication
Require a secret key in a request header, checked by a reusable dependency. Apply it to any route you want to protect.
from fastapi import Security, HTTPException, status
from fastapi.security import APIKeyHeader
from config import settings
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
def require_key(key: str = Security(api_key_header)):
if key != settings.service_api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key",
)
return keyfrom fastapi import Depends
from auth import require_key
@app.post("/predict", dependencies=[Depends(require_key)])
def predict(data: IrisInput):
return {"class_id": 0} # only reachable with a valid keyCallers must now send the header X-API-Key: your-key. The interactive docs even gain an Authorize button automatically.
Beyond API keys
| Method | Good for |
|---|---|
| API key (above) | Service-to-service calls, internal tools, simple gating. |
| OAuth2 + JWT | Real user accounts with login, expiry, and scopes. FastAPI has first-class support. |
| Provider gateway | Offloading auth, rate limits, and billing to an API-gateway product. |
Auth says who can call you; rate limiting says how often. For AI endpoints especially, add per-key limits (e.g. with slowapi or a gateway) so one client can't drain your token budget.
Compare keys with a constant-time check (e.g. secrets.compare_digest) rather than ==, and never log full keys. Small habits that close real security gaps.
Docker & Deployment
Docker packages your app, its Python version, and every dependency into one image that runs identically on your laptop, a server, or the cloud. It's the standard way to ship a FastAPI service.
A production Dockerfile
# start from a slim official Python image
FROM python:3.12-slim
# don't write .pyc files; flush logs immediately
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
# install deps first so this layer is cached when only code changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# now copy the application code and model files
COPY . .
EXPOSE 8000
# run with uvicorn, bound to all interfaces inside the container
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]Build and run
# build an image tagged "ml-api"
$ docker build -t ml-api .
# run it, mapping container port 8000 to host 8000,
# and passing secrets from your local .env
$ docker run -p 8000:8000 --env-file .env ml-apiAdd a .dockerignore
Keep junk and secrets out of the image — faster builds, smaller images, no leaked .env.
.venv/
__pycache__/
*.pyc
.env
.git/
.pytest_cache/That same image runs on almost anything: a virtual machine, a container platform (Cloud Run, ECS, Azure Container Apps), or a Kubernetes cluster. Build once, run anywhere — that's Docker's whole promise.
Bundling huge model weights into the image bloats it and slows deploys. For large models, mount a volume or download weights from object storage at startup instead of baking them in.
Scaling & Performance
One worker handles one CPU-bound prediction at a time. To serve real traffic you run multiple workers and apply a few targeted optimisations. Here's what actually moves the needle.
Run multiple workers
Each worker is a separate process with its own copy of the app (and model). More workers means more requests handled in parallel. A common rule of thumb is (2 × CPU cores) + 1.
# 4 worker processes behind uvicorn
$ uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4Every worker loads its own copy of the model. Four workers with a 2 GB model need 8 GB of RAM. Size your machine accordingly, and prefer fewer workers with bigger models.
The performance checklist that matters
Load once
Use the lifespan loader (Chapter 17). Never deserialize a model per request — the single biggest win.
Don't block async
Keep heavy CPU work in def routes or threadpools so the event loop stays free (Chapter 7).
Batch & vectorise
Score many rows in one predict() call; let NumPy do the loop, not Python.
Cache repeats
Identical inputs? Cache results (e.g. in Redis). Cheap requests that never touch the model.
See where time goes
Reuse the timing middleware from Chapter 9, and watch the X-Process-Time header. Add structured logging so slow requests are searchable.
import logging, time
from fastapi import Request
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("api")
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
ms = (time.perf_counter() - start) * 1000
log.info("%s %s -> %s in %.1fms",
request.method, request.url.path, response.status_code, ms)
return responseProfile first. Most ML APIs are slow for one of three reasons: the model loads per request, a blocking call freezes async, or a slow upstream (LLM/DB). Fix the real bottleneck instead of guessing.
The Go-Live Checklist
You've learned every piece. This final chapter is the page to keep open the day you deploy — a distilled review of the whole book as an actionable list.
Correctness
- Every endpoint has a Pydantic input model and a
response_model. - Feature order and preprocessing at serve time exactly match training (use a Pipeline).
- Models load once via lifespan, never per request.
- A
/healthendpoint reports whether the model is loaded.
Robustness
- Every external call (LLM, DB) is wrapped with timeouts and error handling.
- Custom exceptions map to clear status codes — clients never see raw 500s.
- Agent loops have an iteration cap; batch sizes have a
max_length. - Heavy CPU work never blocks an
asyncroute.
Security
- All secrets come from the environment;
.envis git-ignored. - Protected endpoints require auth; keys are compared in constant time and never logged.
- CORS lists real front-end domains, not
*. - Rate limiting is in place on expensive AI endpoints.
Operations
- The app is containerised with a
.dockerignoreexcluding secrets. - Multiple workers are sized against available RAM (model copies add up).
- Request logging and timing are on; you can find slow requests.
- Conversation/RAG state lives in Redis or a database — not an in-memory dict.
From a blank folder to a secured, containerised service that serves classic ML models, streams LLM responses, answers from your own documents, and runs a tool-using agent. That's the full arc of modern AI engineering with FastAPI. Now go build something and ship it.
Where to go next
The official docs
FastAPI's documentation is famously excellent. Read it cover to cover — it's the best companion to this book.
Testing
Learn pytest with FastAPI's TestClient to test endpoints without a running server.
Databases
Add SQLModel or SQLAlchemy to persist predictions, users, and conversations properly.
Observability
Add Prometheus metrics and tracing to understand your service in production.