FastAPI for Machine Learning & LLM AI Agents — The Complete Field Book
A Complete, Example-Driven Field Book

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.

28 Chapters 120+ Code Examples Beginner → Production FastAPI · Pydantic · scikit-learn · LLMs · Agents
FP
Foysal Ahmed Pranto
Author & ML / Backend Engineer
00
Front Matter

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:

I·II

Foundations & Robust APIs

The teal chapters teach FastAPI itself — routing, validation, async, dependencies, errors. Everything else builds on these.

III

Machine Learning

The amber chapters serve classic ML models: training, saving, loading, and exposing predictions safely.

IV

LLM & AI Agents

The violet chapters cover LLM calls, streaming, memory, RAG, and a working tool-using agent.

V

Production

The slate chapters get you live: config, auth, Docker, scaling, and a final checklist.

How to read the callouts

Tip

A shortcut, best practice, or time-saver worth remembering.

Note

Extra context that deepens your understanding without blocking progress.

Watch out

A common mistake or a thing that will bite you in production.

Key idea

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.

The golden loop

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.

01
Getting Started

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.

  1. Client sends a request — e.g. POST /predict with a JSON body of features.
  2. FastAPI routes it to the matching function based on the path and HTTP method.
  3. Pydantic validates the incoming data against your declared types. Bad data → automatic 422 error.
  4. Your function runs — load features, call model.predict(), or stream from an LLM.
  5. FastAPI serializes your return value to JSON and sends the HTTP response back.

The vocabulary you'll see everywhere

TermPlain-English meaning
endpoint / routeA URL your API answers, e.g. /predict.
HTTP methodGET reads data, POST sends data to create/process, PUT/PATCH update, DELETE removes.
path / query paramsValues in the URL: /items/5 (path) or /items?limit=5 (query).
request bodyA JSON payload sent with the request — how you send features or prompts.
status codeA number describing the result: 200 OK, 422 bad input, 500 server error.
Pydantic modelA Python class defining the shape of your data, with automatic validation.
Note

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.

02
Getting Started

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:

terminalbash
$ python --version
# If that prints Python 2.x, try:
$ python3 --version
# You want: Python 3.11.x  (or 3.9+)
Watch out

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.

terminalbash
# 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 it

Step 3 — Install FastAPI

The [standard] extra bundles everything you need to run a server, including Uvicorn and the new fastapi command-line tool.

terminalbash
$ 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:

requirements.txttext
fastapi[standard]
scikit-learn
joblib
numpy
pydantic-settings
openai
anthropic
python-dotenv
terminalbash
$ pip install -r requirements.txt

A 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:

project treetext
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.)
Tip

Create a .gitignore with .venv/ and .env in it immediately. Committing your virtual environment or secrets is the most common beginner mistake.

03
Getting Started

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:

main.pypython
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

terminalbash
$ fastapi dev main.py

You'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.

Note · the older way

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.

Key idea

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:

main.pypython
@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.

Tip

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.

I
Part One

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.

04
Part I · Foundations

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.

main.pypython
@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.

Key idea

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.

main.pypython
@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.

main.pypython
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}
RuleMeaningApplies to
min_length / max_lengthString length boundsstr
ge / gt≥ or > a numberint, float
le / lt≤ or < a numberint, float
patternMust match a regexstr
Tip

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.

05
Part I · Foundations

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.

main.pypython
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:

request bodyjson
{
  "name": "Wireless Mouse",
  "price": 25.00,
  "tags": ["electronics", "accessories"]
}
Key idea

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.

main.pypython
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, optional

Why 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.

main.pypython
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)
Tip

Name your models by direction: IrisFeatures (input) and Prediction (output). It keeps large APIs readable and pairs perfectly with response models, next chapter.

06
Part I · Foundations

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.

main.pypython
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 user
Watch out

The 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).

main.pypython
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 user

The status codes you'll actually use

CodeNameWhen
200OKSuccessful GET or general success.
201CreatedA POST created a new resource.
400Bad RequestThe client sent something wrong you caught yourself.
401 / 403Unauthorized / ForbiddenMissing/invalid auth, or not allowed.
404Not FoundThe requested resource doesn't exist.
422Unprocessable EntityValidation failed — FastAPI sends this for you.
500Server ErrorYour code crashed. Aim to never send these.
Tip

Use the status constants (status.HTTP_201_CREATED) instead of raw numbers. They read better and your editor autocompletes them.

07
Part I · Foundations

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.

main.pypython
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…UseWhy
waits on network/LLM/DB (with async libraries)async defThe server stays free during the wait.
does fast in-memory workdefSimpler; FastAPI handles it in a threadpool.
runs heavy CPU work (model.predict on big data)defFastAPI offloads def routes to threads, so they don't freeze the server.
Key idea · the one rule

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.

main.pypython
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.

Tip

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.

II
Part Two

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.

08
Part II · Robust APIs

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.

main.pypython
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 once
Key idea

Anything 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.

main.pypython
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.

main.pypython
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}
Tip

When a dependency only guards (no value needed in the function), list it in the decorator's dependencies=[...] instead of the signature — cleaner.

09
Part II · Robust APIs

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.

main.pypython
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 response

CORS — 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.

main.pypython
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=["*"],
)
Watch out

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.

Note · order matters

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.

10
Part II · Robust APIs

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.

main.pypython
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."

main.pypython
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.

main.pypython
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."},
    )
Tip

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.

11
Part II · Robust APIs

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.

main.pypython
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}
Note

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.

main.pypython
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

main.pypython
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)}
Watch out

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.

12
Part II · Robust APIs

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.

routers/predict.pypython
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 logic
main.pypython
from 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

project treetext
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 / weights
Key idea · separation of concerns

Routers 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.

Tip

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.

III
Part Three

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.

13
Part III · Machine Learning

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

TrainingServing (this part)
WhenOnce, offlineEvery request, live
GoalLearn patterns from dataApply them, fast & reliably
Speed needCan take hoursMilliseconds matter
OutputA saved model fileA JSON prediction

The serving lifecycle

  1. Train & evaluate a model in a notebook or script (Chapter 14).
  2. Serialize it to a file with joblib so it survives outside the training process.
  3. Load it once when the API starts — not on every request (Chapter 17).
  4. Validate input with a Pydantic model so the features match what the model expects.
  5. Predict & respond — call model.predict() and return clean JSON.
Key idea · the contract problem

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.

Watch out · train/serve skew

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.

14
Part III · Machine Learning

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.

train.pypython
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")
terminalbash
$ python train.py
# Test accuracy: 100.00%
# Saved to models/iris_model.joblib

Loading it back

pythonpython
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]])                # -> setosa
Note · joblib vs pickle

You'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.

Tip · save everything you need

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.

15
Part III · Machine Learning

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

main.pypython
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 → responsejson
// 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

terminalbash
$ 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}'
Key idea

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.

Tip · return confidence

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.

16
Part III · Machine Learning

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.

main.pypython
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)
Tip

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):

main.pypython
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}
Watch out · heavy CPU work

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.

17
Part III · Machine Learning

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.

main.pypython
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])}
Key idea · load once, predict many

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.

main.pypython
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])}
Tip · health checks that mean something

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.

IV
Part Four

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.

18
Part IV · LLM & AI Agents

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:

RolePurpose
systemInstructions that set behaviour ("You are a helpful support agent…").
userWhat the human said.
assistantWhat the model previously replied — included to give it memory.
Key idea

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.

Watch out · keys are secrets

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.

19
Part IV · LLM & AI Agents

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

main.pypython
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)

main.pypython
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)
Note · two small differences

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.

main.pypython
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)
Tip · control cost and length

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.

20
Part IV · LLM & AI Agents

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.

main.pypython
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.

main.pypython
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

client.jsjavascript
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
}
Key idea · perceived speed

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.

Watch out

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.

21
Part IV · LLM & AI Agents

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.

main.pypython
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.

main.pypython
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 + recent
Watch out · don't use a dict in production

An 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.

Tip · summarise instead of dropping

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.

22
Part IV · LLM & AI Agents

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

  1. Embed each document chunk into a vector (a list of numbers capturing meaning).
  2. Store the vectors. For learning, a list in memory; for scale, a vector database.
  3. Retrieve — embed the user's question and find the most similar chunks.
  4. 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.

rag.pypython
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}
Key idea · grounding beats memorising

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.

Tip · grow into a vector database

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.

23
Part IV · LLM & AI Agents

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.

  1. Describe your tools to the model (name, purpose, parameters).
  2. Send the message. The model replies with text or a request to call a tool.
  3. If it wants a tool, run the matching Python function with its arguments.
  4. 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.

agent.pypython
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.

Key idea · the model decides, you execute

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.

Watch out · always cap the loop

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.

Tip

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.

V
Part Five

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.

24
Part V · Production

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.

config.pypython
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 config
.envtext
OPENAI_API_KEY=sk-your-real-key-here
MODEL_NAME=gpt-4o-mini
ENVIRONMENT=production

Using settings in the app

main.pypython
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}
Key idea · fail fast

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.

Watch out

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.

25
Part V · Production

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.

auth.pypython
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 key
main.pypython
from 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 key

Callers must now send the header X-API-Key: your-key. The interactive docs even gain an Authorize button automatically.

Beyond API keys

MethodGood for
API key (above)Service-to-service calls, internal tools, simple gating.
OAuth2 + JWTReal user accounts with login, expiry, and scopes. FastAPI has first-class support.
Provider gatewayOffloading auth, rate limits, and billing to an API-gateway product.
Tip · pair auth with rate limiting

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.

Watch out

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.

26
Part V · Production

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

Dockerfiledocker
# 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

terminalbash
# 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-api

Add a .dockerignore

Keep junk and secrets out of the image — faster builds, smaller images, no leaked .env.

.dockerignoretext
.venv/
__pycache__/
*.pyc
.env
.git/
.pytest_cache/
Tip · where to deploy

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.

Watch out · big model files

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.

27
Part V · Production

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.

terminalbash
# 4 worker processes behind uvicorn
$ uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
Watch out · memory math

Every 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

1

Load once

Use the lifespan loader (Chapter 17). Never deserialize a model per request — the single biggest win.

2

Don't block async

Keep heavy CPU work in def routes or threadpools so the event loop stays free (Chapter 7).

3

Batch & vectorise

Score many rows in one predict() call; let NumPy do the loop, not Python.

4

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.

main.pypython
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 response
Tip · measure before optimising

Profile 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.

28
Part V · Production

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 /health endpoint 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 async route.

Security

  • All secrets come from the environment; .env is 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 .dockerignore excluding 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.
You made it

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.

FastAPI for Machine Learning & LLM AI Agents

The Complete Field Book — from your first endpoint to production AI services.

✍Written by Foysal Ahmed Pranto ✉foysalpranto2002@gmail.com ☎01313990906
© 2026 Foysal Ahmed Pranto. Written as a learning resource. Code examples are free to use in your own projects. Always check each provider's current documentation for the latest model names and APIs.