Machine Learning Operations (MLOps) · lesson 10/25
Model Serving with FastAPI
FastAPI is a thin, well-typed way to expose a model over HTTP: it validates the request body with pydantic, runs your predict function, and returns JSON. The model loads once when the process starts, not once per request. The endpoint should be boring, because all the interesting risk lives in input validation and concurrency.
The idea
The anatomy of a serving app:
- Load once in a lifespan hook, storing the model on
app.state. A per-request reload pays disk or network cost on every call. - Validate at the edge with a pydantic request model. Malformed input becomes a 422 response instead of an exception inside inference.
- Keep endpoints boring —
/healthzfor liveness (process is up),/readyzfor readiness (model is loaded and warm),/predictfor work. Load balancers need the split to avoid routing traffic to a starting process. - Do not block the event loop. FastAPI runs a plain
defendpoint in a thread pool; anasync defendpoint runs on the event loop itself. A blockingpredictin anasync defstalls every other request. - Cap concurrency. A predictor that can exhaust memory under load needs a queue and a limit, not more retries.
- Batch requests. Accept a list of inputs. This amortizes HTTP and Python overhead and lets a GPU do real work per call.
Scaling has a sharp edge: uvicorn --workers N runs N processes, each with its own copy of the model. Memory multiplies by N. GPU serving usually means one worker per GPU, not many.
Worked example
Suppose per-request framing, JSON parsing, and validation cost about 5 ms, and the model takes about 2 ms. Serving one row per request spends 5 ms of overhead for 2 ms of compute, so inference is a minority of wall time. Sending 32 rows in one request pays the 5 ms once and attempts 64 ms of compute, so inefficiency from overhead falls by roughly an order of magnitude. That is why the response schema is a list.
Validation is the other half: a request with 31 features instead of 32 is rejected before predict is called, so the model never has to defend itself against bad shapes.
In code
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from pydantic import BaseModel, Field
class PredictRequest(BaseModel):
x: list[float] = Field(min_length=32, max_length=32)
class PredictResponse(BaseModel):
scores: list[float]
version: str
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.model = load_model("artifacts/model.pt") # once, at startup
yield
app = FastAPI(lifespan=lifespan)
@app.post("/predict", response_model=PredictResponse)
def predict(req: PredictRequest, request: Request) -> PredictResponse: # def, not async
model = request.app.state.model
return PredictResponse(scores=model.predict([req.x]).tolist(), version=model.version)Check yourself
- Why load the model during startup instead of on the first request?
- Why should a blocking
predictendpoint be declareddefrather thanasync def? - What is the memory cost of raising
uvicorn --workersfrom 1 to 4?
Key takeaways
- Validate with pydantic at the edge; keep inference free of input shaping code.
- One model per process, and every worker process multiplies memory.
- Batch requests, split liveness from readiness, and never block the event loop.