Skip to content

Deployment

Deployment questions test whether you've taken an LLM feature past the notebook stage. Interviewers want to see that you understand the operational realities: async, streaming, cold starts, and graceful failure.


Q1: Why use AsyncOpenAI instead of the sync client in a FastAPI application?

Show answer

FastAPI runs on an async event loop (uvicorn/asyncio). The synchronous OpenAI client uses requests under the hood — blocking the event loop during the HTTP call. While waiting for an LLM response (1–5 seconds), no other requests can be processed.

AsyncOpenAI uses httpx with await, releasing the event loop to handle other requests concurrently during the wait.

import os
from openai import AsyncOpenAI
from fastapi import FastAPI

app = FastAPI()
# Instantiate once at module level — not per request
aclient = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))

@app.post("/chat")
async def chat(query: str):
    response = await aclient.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": query}],
    )
    return {"answer": response.choices[0].message.content}

Measured impact: with sync client and 10 concurrent requests at 2s LLM latency = 20s total processing time. With async client = ~2s total.


Q2: How does SSE (Server-Sent Events) streaming work for LLM output?

Show answer

SSE is a one-way HTTP streaming protocol. The server sends a stream of data: {content}\n\n lines; the client reads them as they arrive without polling.

import os, json
from openai import AsyncOpenAI
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()
aclient = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))

async def generate_tokens(query: str):
    stream = await aclient.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": query}],
        stream=True,
    )
    async for chunk in stream:
        token = chunk.choices[0].delta.content
        if token:
            yield f"data: {json.dumps({'token': token})}\n\n"
    yield "data: [DONE]\n\n"

@app.get("/stream")
async def stream_response(query: str):
    return StreamingResponse(
        generate_tokens(query),
        media_type="text/event-stream",
        headers={"X-Accel-Buffering": "no"},  # critical for nginx
    )

X-Accel-Buffering: no tells nginx to not buffer the response. Without it, nginx accumulates chunks and sends them in batches — defeating the purpose of streaming.


Q3: What are the tradeoffs between serverless and container-based deployment for LLM APIs?

Show answer
Serverless (Lambda, Modal) Container (Fly.io, ECS, k8s)
Cold starts 0.5–3s for pure Python; 5–15s with heavy deps None (always warm)
Scaling Automatic, scales to zero Manual or HPA configuration
Cost Pay per invocation; free at zero traffic Pay per instance-hour; fixed baseline cost
Best for Bursty or low traffic; background jobs Steady traffic; latency-critical

For LLM APIs that call external APIs (OpenAI, Anthropic), the LLM latency (1–5s) dominates cold start time (~500ms). Serverless is often fine.

For self-hosted models (Ollama, vLLM), avoid serverless — model loading takes 10–30 seconds, making cold starts unacceptable.

Fly.io auto_stop_machines = true with min_machines_running = 0 gives serverless-like behaviour with a container runtime. Set min_machines_running = 1 in production to eliminate cold starts.


Q4: How do you implement a semantic cache for LLM responses?

Show answer

Exact-match caching (SHA256 of the prompt) only hits on identical queries. A semantic cache hits on similar queries by embedding the query and finding near-matches in a vector store.

import hashlib, os
import numpy as np
from openai import AsyncOpenAI

aclient = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))

class SemanticCache:
    def __init__(self, threshold: float = 0.92):
        self.threshold = threshold
        self.entries: list[dict] = []  # {embedding, response}

    async def get(self, query: str) -> str | None:
        q_emb = await self._embed(query)
        for entry in self.entries:
            sim = np.dot(q_emb, entry["embedding"])  # assumes normalized vectors
            if sim >= self.threshold:
                return entry["response"]
        return None

    async def set(self, query: str, response: str):
        q_emb = await self._embed(query)
        self.entries.append({"embedding": q_emb, "response": response})

    async def _embed(self, text: str) -> list[float]:
        result = await aclient.embeddings.create(input=text, model="text-embedding-3-small")
        emb = np.array(result.data[0].embedding)
        return (emb / np.linalg.norm(emb)).tolist()  # normalize

cache = SemanticCache(threshold=0.92)

async def cached_answer(query: str) -> str:
    if cached := await cache.get(query):
        return cached
    response = await aclient.chat.completions.create(
        model="gpt-4o", messages=[{"role": "user", "content": query}]
    )
    answer = response.choices[0].message.content
    await cache.set(query, answer)
    return answer

Threshold 0.90–0.95 is the typical range. Below 0.90, unrelated queries match. Above 0.95, you rarely hit the cache.


Q5: How do you implement graceful shutdown in a FastAPI LLM service?

Show answer

Graceful shutdown ensures in-flight requests complete before the process exits. This matters especially for streaming endpoints and background LLM jobs.

import asyncio, signal, os
from contextlib import asynccontextmanager
from openai import AsyncOpenAI
from fastapi import FastAPI

aclient: AsyncOpenAI = None

@asynccontextmanager
async def lifespan(app: FastAPI):
    global aclient
    # Startup
    aclient = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
    yield
    # Shutdown — aclient closes its httpx session automatically

app = FastAPI(lifespan=lifespan)

For long-running background tasks, track them and wait on shutdown:

background_tasks: set[asyncio.Task] = set()

@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    # Wait for all background tasks to complete (with timeout)
    if background_tasks:
        await asyncio.wait(background_tasks, timeout=30.0)

@app.post("/process")
async def start_job(query: str):
    task = asyncio.create_task(long_running_job(query))
    background_tasks.add(task)
    task.add_done_callback(background_tasks.discard)
    return {"job_id": id(task)}

Previous: Tracing and Monitoring | Next: Cost Optimization