Deploy an AI Service with FastAPI, Auth, Tracing, and Caching
Wrap the RAG chatbot in a production FastAPI service with auth, rate limiting, tracing, caching, and cloud deployment.
Domains & Technologies
Blueprint Walkthrough
Architecture Overview & Problem Statement
Why This Project Exists
The RAG chatbot from Project 1 works on your laptop. That is not the same as being a service other people, or other systems, can rely on. Without authentication, anyone who finds the URL can query it. Without rate limiting, one misbehaving client can exhaust your API budget in minutes. Without tracing, a slow response is a mystery, was it retrieval, reranking, or generation that took 4 seconds? Without caching, you pay full LLM cost for the same question asked twice in a row.
This project takes the RAG chatbot (or the refund agent from Project 2, either works) and wraps it in the infrastructure that makes it an actual production service.
What you are building
- A FastAPI service with Pydantic request/response validation and streaming responses
- API key authentication and per-key rate limiting
- Distributed tracing following a request through retrieval, reranking, and generation
- Response and semantic caching to cut cost on repeated or near-duplicate queries
- Containerisation with Docker and deployment to a managed cloud AI service
Architecture diagram
Client Request (with API key) | v+----------------+| Auth + Rate | <-- rejects invalid/over-limit here| Limit Check |+----------------+ | v+----------------+ +------------------+| Check Cache | --> | Cache Hit: Return |+----------------+ +------------------+ | Miss v+----------------+| RAG Pipeline | <-- each stage emits a trace span| (Project 1) |+----------------+ | v+----------------+| Store in Cache|+----------------+ | v+----------------+| Stream Response|+----------------+Engineering DecisionA managed model API for almost everything, self-hosting only once data residency requires it or API cost at real traffic volume exceeds GPU cost. This project deploys to a managed cloud AI service for exactly that reason, the operational cost of running your own inference infrastructure is rarely justified before you have both the traffic and the constraint that demands it.
Prerequisites
This project assumes you have completed Project 1 (Production RAG Chatbot) or Project 2 (Reliable AI Agent), you will be wrapping that existing pipeline, not building a new one from scratch.
TipIf you completed both Project 1 and Project 2, wrap the RAG chatbot here, its streaming response behaviour makes the FastAPI streaming milestone more concrete to test.
Milestone 1: Serve the Pipeline with FastAPI and Pydantic
Why FastAPI and Not a Plain Script
A Python script that calls your RAG pipeline works for one person on one laptop. A service other systems can call needs a defined request/response contract, input validation before your expensive pipeline ever runs, and the ability to handle many requests concurrently. FastAPI with Pydantic gives you all three with comparatively little code.
Defining the request and response contract
from fastapi import FastAPI, HTTPExceptionfrom pydantic import BaseModel, Field app = FastAPI(title="HR Policy Chatbot API") class ChatRequest(BaseModel): question: str = Field(..., min_length=3, max_length=500) employee_id: str = Field(..., description="Used for audit logging, not personalization") class ChatResponse(BaseModel): answer: str sources: list[str] latency_ms: floatNote
min_length=3, max_length=500on the question field rejects a malformed or empty request before it reaches the RAG pipeline at all. Validating input at the API boundary, not deep inside your pipeline code, means a bad request fails fast and cheaply instead of burning an embedding call and an LLM call on garbage input.
The main endpoint
import time async def chat(request: ChatRequest): """ Main chat endpoint. Pydantic has already validated request.question and request.employee_id by the time this function body runs. """ start = time.time() try: candidates = hybrid_search(request.question, db_conn, top_k=10) reranked = rerank(request.question, candidates, top_n=4) answer = generate_answer(request.question, reranked) except Exception as e: raise HTTPException(status_code=500, detail="Failed to generate answer") latency = (time.time() - start) * 1000 return ChatResponse( answer=answer, sources=[c["source"] for c in reranked], latency_ms=round(latency, 2) )Adding streaming responses
A 3-second wait with no feedback feels broken to a user, even when the service is working correctly. Streaming tokens as they generate fixes the perceived latency without changing the actual generation time.
from fastapi.responses import StreamingResponse async def chat_stream(request: ChatRequest): candidates = hybrid_search(request.question, db_conn, top_k=10) reranked = rerank(request.question, candidates, top_n=4) async def token_generator(): # Assumes generate_answer_streaming yields tokens as the LLM produces them async for token in generate_answer_streaming(request.question, reranked): yield token return StreamingResponse(token_generator(), media_type="text/event-stream")Common MistakeNo timeout handling on long-running calls, especially streaming responses. A client that disconnects mid-stream, or an LLM call that hangs, can leave a request open indefinitely on the server, consuming resources for a response nobody will ever receive. Wrap generation calls in an explicit timeout and handle client disconnection.
Guided practice
Run the service locally with uvicorn main:app --reload and send both a normal /chat request and a /chat/stream request using curl. Confirm the streaming endpoint visibly returns tokens incrementally rather than all at once.
Milestone 2: Add API Authentication and Rate Limiting
Why This Matters More Once Actions Are Involved
An unauthenticated read-only endpoint is a mild risk. An endpoint that can trigger a refund agent taking real financial actions, as in Project 2, without authentication is a serious one. Every service in this project's scope needs to know who is calling it and how often, before it does any real work.
API key authentication
from fastapi import Security, HTTPExceptionfrom fastapi.security import APIKeyHeader api_key_header = APIKeyHeader(name="X-API-Key") ## In production, load from a secrets manager, never hardcodeVALID_API_KEYS = { "key_rahul_hr_team": {"tenant": "hr_team", "rate_limit": 100}, "key_priya_support": {"tenant": "support_team", "rate_limit": 50}} async def verify_api_key(api_key: str = Security(api_key_header)): if api_key not in VALID_API_KEYS: raise HTTPException(status_code=401, detail="Invalid API key") return VALID_API_KEYS[api_key]async def chat(request: ChatRequest, key_info: dict = Security(verify_api_key)): ## key_info now available for tenant isolation and rate limit lookups ...SecurityNever hardcode API keys directly in source code, even for a project like this one. Load them from environment variables or a secrets manager, and confirm
.envor equivalent secret files are listed in.gitignorebefore your first commit.
Rate limiting per API key
from collections import defaultdictfrom datetime import datetime, timedelta request_log = defaultdict(list) # in production, use Redis instead of in-memory def check_rate_limit(api_key: str, limit_per_minute: int): """ Sliding window rate limit, counts requests in the last 60 seconds for this specific key and rejects if the limit is exceeded. """ now = datetime.utcnow() window_start = now - timedelta(seconds=60) request_log[api_key] = [ t for t in request_log[api_key] if t > window_start ] if len(request_log[api_key]) >= limit_per_minute: raise HTTPException(status_code=429, detail="Rate limit exceeded") request_log[api_key].append(now)NoteThis in-memory implementation resets every time the service restarts and does not work correctly across multiple server instances behind a load balancer. It is fine for local development and this project's lab, a real production deployment needs a shared store like Redis so every instance sees the same rate limit counters.
Tenant isolation
async def chat(request: ChatRequest, key_info: dict = Security(verify_api_key)): check_rate_limit(key_info["tenant"], key_info["rate_limit"]) ## Filter retrieval to only this tenant's documents, a support-team ## key should never be able to retrieve HR-team-only policy chunks candidates = hybrid_search( request.question, db_conn, top_k=10, tenant=key_info["tenant"] ) ...Common MistakeTreating authentication and tenant isolation as the same thing. Authentication confirms who is calling, tenant isolation confirms what that caller is allowed to see or act on. A valid API key alone does not mean the caller should see every document or trigger every action in the system.
Troubleshooting scenario
A client reports getting 429 Too Many Requests after only 3 calls, well under their configured limit of 100. Check whether check_rate_limit is being called with the correct key, a bug where every request accidentally shares one global counter key instead of a per-tenant one would produce exactly this symptom under concurrent testing.
Milestone 3: Add Distributed Tracing Through the Pipeline
Why Logs Alone Are Not Enough
A log line saying "request took 4.2 seconds" tells you the total time, not where it went. Was retrieval slow because the vector index needs rebuilding? Was reranking slow because too many candidates were passed in? Was generation slow because of a large context window? Tracing breaks a single request into named spans, each with its own duration, answering exactly that question.
Instrumenting the pipeline with spans
from opentelemetry import tracefrom opentelemetry.trace import Status, StatusCode tracer = trace.get_tracer("rag_chatbot_service") async def traced_chat_pipeline(question: str, tenant: str): with tracer.start_as_current_span("chat_request") as root_span: root_span.set_attribute("tenant", tenant) with tracer.start_as_current_span("hybrid_retrieval") as span: candidates = hybrid_search(question, db_conn, top_k=10, tenant=tenant) span.set_attribute("candidates_found", len(candidates)) with tracer.start_as_current_span("reranking") as span: reranked = rerank(question, candidates, top_n=4) span.set_attribute("top_score", reranked[0]["rerank_score"]) with tracer.start_as_current_span("generation") as span: answer = generate_answer(question, reranked) span.set_attribute("answer_length", len(answer)) return answer, rerankedNoteA span is a named, timed segment of work with its own start and end. Nesting spans (retrieval and reranking and generation all inside the parent
chat_requestspan) is what lets a tracing dashboard show a request as a timeline, each stage's actual duration, instead of one opaque total number.
Attaching evaluation scores to traces
with tracer.start_as_current_span("faithfulness_check") as span: context = "\n".join(c["content"] for c in reranked) faithfulness_result = score_faithfulness(context, answer) span.set_attribute("faithfulness_verdict", faithfulness_result)Engineering DecisionAttach evaluation scores directly to traces, not just to a separate offline evaluation report. When a specific production request produces a bad answer, being able to click into that exact trace and see its faithfulness score, retrieval candidates, and generation latency together is what turns "users are complaining" into a specific, fixable root cause.
Cost attribution
with tracer.start_as_current_span("generation") as span: response = client.messages.create(...) span.set_attribute("input_tokens", response.usage.input_tokens) span.set_attribute("output_tokens", response.usage.output_tokens) span.set_attribute("tenant", tenant) # Cost per request, per tenant, now queryable from trace data directlyCommon MistakeNo request logging or tracing, meaning nobody can answer "why did this cost so much" after the fact. Without token counts attached to traces, a sudden spike in API spend is a mystery you can only investigate by guessing, with tracing it is a query you can run directly.
Guided practice
Send 10 requests through the traced pipeline with varying question lengths, then inspect the resulting traces and confirm you can identify which stage, retrieval, reranking, or generation, consumed the most time for the slowest of the 10.
Milestone 4: Add Response and Semantic Caching
Why Caching Is a Compact but Real Cost Lever
Support chatbots see the same handful of questions repeatedly: "how many leave days," "what is the WFH policy." Paying full embedding, retrieval, reranking, and generation cost for a question asked an hour ago by a different employee is waste that caching eliminates almost for free.
Exact-match response caching
import hashlibimport redis cache = redis.Redis(host="localhost", port=6379, decode_responses=True) def get_cache_key(question: str, tenant: str) -> str: normalized = question.strip().lower() return f"chat_cache:{tenant}:{hashlib.sha256(normalized.encode()).hexdigest()}" def get_cached_response(question: str, tenant: str): key = get_cache_key(question, tenant) cached = cache.get(key) return json.loads(cached) if cached else None def set_cached_response(question: str, tenant: str, response: dict, ttl_seconds=3600): key = get_cache_key(question, tenant) cache.setex(key, ttl_seconds, json.dumps(response))Note
ttl_seconds=3600means a cached answer expires after one hour. This matters because HR policies change, a cache with no expiry could serve a stale answer about a leave policy that was updated yesterday. The TTL is a deliberate tradeoff between cost savings and freshness, not an arbitrary number.
Semantic caching for near-duplicate questions
Exact-match caching misses "how many casual leaves do I get" and "how many casual leave days are there" even though they mean the same thing. Semantic caching checks embedding similarity against recently cached questions instead of exact string matching.
def get_semantic_cached_response(question: str, tenant: str, threshold=0.95): """ Check if a highly similar question was answered recently. threshold=0.95 is intentionally strict, a looser threshold risks returning a cached answer to a question that only sounds similar but actually needs a different answer. """ question_embedding = embedder.encode(question) recent_cached = get_recent_cache_entries(tenant, limit=50) for entry in recent_cached: similarity = cosine_similarity(question_embedding, entry["embedding"]) if similarity >= threshold: return entry["response"] return NoneCommon MistakeNo caching on repeated or near-duplicate requests, wasting spend on identical work performed over and over. A support chatbot answering the same top-5 questions hundreds of times a day without caching pays full pipeline cost every single time, when a one-hour TTL cache would eliminate the vast majority of that repeated cost.
Wiring caching into the endpoint
async def chat(request: ChatRequest, key_info: dict = Security(verify_api_key)): check_rate_limit(key_info["tenant"], key_info["rate_limit"]) cached = get_cached_response(request.question, key_info["tenant"]) if cached: return ChatResponse(**cached, latency_ms=2.0) # cache hits are near-instant answer, reranked = await traced_chat_pipeline(request.question, key_info["tenant"]) response = {"answer": answer, "sources": [c["source"] for c in reranked]} set_cached_response(request.question, key_info["tenant"], response) return ChatResponse(**response, latency_ms=...)Concept check
Before continuing, explain in one sentence why the semantic cache threshold is set to 0.95 rather than something looser like 0.80. Consider what happens to answer correctness if two genuinely different questions are treated as cache-equivalent.
Milestone 5: Containerise and Deploy to a Managed Cloud AI Service
Why Docker and a Managed Path, Not a Full Cloud Checklist
The goal here is one clean, working deployment path, not a tour of every cloud AI product available. Containerising your service in Docker makes it portable, and deploying to a managed platform means you are not managing GPU infrastructure yourself for a workload that does not need it.
Writing the Dockerfile
FROM python:3.11-slim WORKDIR /app ## Copy requirements first so Docker caches this layer## when only application code changes, not dependenciesCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]TipCopying
requirements.txtand runningpip installbefore copying the rest of the application code is deliberate layer ordering. Docker caches each instruction as a layer, changing only your Python source will not force a full dependency reinstall on the next build, changingrequirements.txtwill, which is the correct behaviour either way.
Building and testing locally
## Build the image, tagged for this specific servicedocker build -t hr-chatbot-service:latest . ## Run it locally with environment variables for secretsdocker run -p 8000:8000 \ -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \ -e REDIS_HOST=host.docker.internal \ hr-chatbot-service:latest ## Confirm it responds correctly before deploying anywherecurl -X POST http://localhost:8000/chat \ -H "X-API-Key: key_rahul_hr_team" \ -H "Content-Type: application/json" \ -d '{"question": "How many casual leave days do I get?", "employee_id": "EMP-4021"}'Common MistakeUnpinned dependencies breaking a working local setup in production. If
requirements.txtspecifiesfastapiwith no version pin, a build six months from now may pull a newer version with breaking changes, even though nothing in your code changed. Pin exact versions for anything deployed.
Deploying to a managed cloud AI service
## Example path: push the built image to a container registry,## then deploy it to a managed container service in ap-south-1 (Mumbai)## for lower latency to Indian usersdocker tag hr-chatbot-service:latest <registry-url>/hr-chatbot-service:latestdocker push <registry-url>/hr-chatbot-service:latest ## Deploy using your cloud provider's managed container run command,## setting the region explicitly and secrets via the provider's## secret manager rather than plain environment variables in the consoleSecurityNever pass API keys or database credentials as plain environment variables typed directly into a cloud console. Use the provider's secrets manager and reference secrets by name in your deployment configuration, so the actual values never appear in deployment logs or console history.
Documenting the cost difference
After deploying, compare running this service locally versus on the managed platform for a simulated 1000 requests a day. Record: managed platform compute cost, LLM API cost (unchanged either way), and the operational time saved not managing servers yourself. This comparison is the deliverable the capstone project spec asks for.
Capstone lab checkpoint
Deploy the containerised service to your chosen managed cloud AI platform. Run the same curl test from local testing against the deployed URL and confirm identical behaviour. Write a one-page cost report comparing local, self-managed cloud VM, and managed platform costs at your simulated traffic volume.
Validation & Testing
Final Verification
1. Authentication check
## Request with no API key should be rejectedcurl -X POST http://localhost:8000/chat \ -H "Content-Type: application/json" \ -d '{"question": "test", "employee_id": "EMP-1"}'Expected output: 401 Unauthorized, not a successful response.
2. Rate limit check
Send requests rapidly exceeding your configured per-minute limit for one API key. Expected output: requests beyond the limit return 429 Too Many Requests, and a different API key's requests are unaffected in the same window.
3. Tracing check
Send one request and inspect the resulting trace. Expected output: separate spans for hybrid_retrieval, reranking, and generation, each with its own duration, summing to approximately the total request latency.
4. Cache check
Send the identical question twice in a row. Expected output: the second request's latency_ms is dramatically lower than the first, confirming the cache hit path executed instead of the full pipeline.
5. Container check
docker run -p 8000:8000 hr-chatbot-service:latestcurl http://localhost:8000/docsExpected output: the FastAPI auto-generated docs page loads successfully from inside the container, confirming the image is self-contained and correctly configured.
Quick reference
| Layer | Purpose | Failure symptom if missing |
|---|---|---|
| Pydantic validation | Reject bad input early | Garbage reaches the expensive pipeline |
| API key auth | Know who is calling | Anyone can use your service and budget |
| Rate limiting | Prevent abuse | One client exhausts shared capacity |
| Tracing | Localize slowness | "It's slow somehow" with no fix path |
| Caching | Cut repeated cost | Paying full price for the same question twice |
Common mistakes across the full project
No request logging or tracing leaves you unable to answer why a bill spiked after the fact, and the fix is attaching token counts and tenant identity to every trace from day one. No timeout or streaming handling on long calls leaves users staring at nothing during a slow response, and the fix is explicit timeouts plus streaming for anything that can take more than a second or two. Unpinned dependencies breaking a working local setup in production happens when version numbers are left open-ended in requirements.txt, and the fix is pinning exact versions for anything that gets deployed. Self-hosting for cost reasons without calculating real GPU cost at actual traffic volume often costs more than the managed API it was meant to save money over, and the fix is running the numbers in Milestone 5's cost report before committing to either path. No caching on repeated or near-duplicate requests wastes spend that a simple one-hour TTL cache would eliminate almost entirely, and the fix is treating caching as a default, not an afterthought bolted on after a high cost bill arrives.
TipThis deployed service is a real, working example of the full production architecture pattern taught conceptually in the LLMOps module, revisit this project after that module to see how each concept there maps to code you already wrote here.