Search Tech Journey

Find topics, journeys and posts

back to blog
mladvanced 120m read

DL S072 · Deploying an LLM API — FastAPI + Streaming + Modal

Wrap vLLM or your own model behind a real FastAPI service with SSE streaming, auth, request logging, and deploy it to Modal or Fly.io in under 20 minutes.

🧠SoftwareM11 · Efficient inference + serving· Session 072 of 130 120 min

🎯 Ship an LLM behind a real HTTP API — OpenAI-compat, streaming SSE, auth, structured logging — to Modal or Fly.io.

Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 72 / 80 · Module M11 · ~2 hours

The story

Six sessions of theory. Today you ship.

We're wrapping the model you fine-tuned in Session 062 (or any HuggingFace model) behind a real API. Not a Gradio demo. A FastAPI service with:

  • Streaming responses (Server-Sent Events, so browsers and OpenAI clients both work).
  • API key auth.
  • Request/response logging with token counts (you need this to bill and to debug).
  • Health + Prometheus metrics.
  • Graceful backpressure when the GPU is saturated.

Then we deploy it. Two paths: Modal (Python-native, autoscale-to-zero, cheapest for spiky traffic) and Fly.io / Hetzner GPU (always-on, cheaper for steady traffic). We'll pick Modal for the walkthrough because it's the fastest path from def function() to a public URL.

By the end of this session, your fine-tuned model is behind https://your-app.modal.run/v1/chat/completions, callable from the OpenAI SDK. That's the thing to demo when someone asks "so what have you built."

A war story: the 3 AM 500-error

June 2024, a fintech I consulted for. They put a Llama-3-70B chat behind FastAPI + a naive model.generate() in the request handler. Worked in staging with one user. Rolled to production. At 3 AM their pager fires: 100% of requests returning 500 with CUDA out of memory.

What happened: a viral tweet drove 50 concurrent users. Each request grabbed the GIL for a synchronous generate(), holding the GPU for 15–30 seconds. Uvicorn's default worker count is cpu_count; each worker tried to load its own copy of the 140GB model. First 3 workers loaded, fourth OOM'd, fifth crashed the machine.

The fix took an afternoon: do not put model.generate() in an HTTP handler. Put it in a dedicated inference server (vLLM), have FastAPI proxy to it, and let vLLM's scheduler handle concurrency. FastAPI's job is auth, logging, rate limiting, and streaming translation — not inference. This is the architecture we'll build today.

Lesson: FastAPI is a router, not an inference engine. Anyone who tells you otherwise learned it the hard way at 3 AM.

The maitre d' and the kitchen
🌍 Real world
💻 Code world
You will be able to
  • Write a FastAPI endpoint that streams tokens via SSE in the OpenAI chat/completions format.
  • Add API-key auth, rate limiting, and structured JSON logging with request IDs.
  • Serve either an in-process HF model or proxy to a vLLM backend.
  • Deploy to Modal with a `@app.function(gpu='A10G')` decorator.
  • Compare Modal, Fly.io, Replicate, and Hetzner for a given traffic pattern.
  • Set up client-side retry with exponential backoff and idempotency keys.

Prerequisites

  • Session 071 — the backend behind this API.
  • FastAPI basics (async, dependencies, response models).
  • Session 062 — the model we're deploying.


1 · SSE — the format you must match

Server-Sent Events is dead-simple: HTTP response with Content-Type: text/event-stream, body is a series of data: <json>\n\n chunks, terminated by data: [DONE]\n\n. Each chunk in the OpenAI dialect looks like:

data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1700000000,"model":"llama-3-8b","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
 
data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1700000000,"model":"llama-3-8b","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":null}]}
 
data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1700000000,"model":"llama-3-8b","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
 
data: [DONE]

Two things trip people up:

  1. Double newline after each data: line. Single newline = event not delivered until the next flush. Nobody teaches you this and every SSE library abstracts it, but if you're writing from scratch, remember \n\n.
  2. First chunk should include role: "assistant" in delta. The OpenAI SDK expects it.

2 · A minimal FastAPI streaming server

We'll write it in two flavors: (a) proxying to vLLM (recommended — vLLM already speaks OpenAI-compat); (b) direct HuggingFace generate for pedagogy.

Flavor A: proxy to vLLM

# app.py
from fastapi import FastAPI, Request, HTTPException, Header
from fastapi.responses import StreamingResponse
import httpx, os, uuid, time, json, logging
 
VLLM_URL = os.getenv("VLLM_URL", "http://localhost:8000")
API_KEY = os.getenv("API_KEY", "sk-dev-key")
 
app = FastAPI(title="my-llm-api")
log = logging.getLogger("api")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
 
async def check_auth(authorization: str = Header(default="")):
    if authorization != f"Bearer {API_KEY}":
        raise HTTPException(401, "invalid api key")

Now the endpoint. We proxy the request body through to vLLM and stream its response back verbatim:

@app.post("/v1/chat/completions")
async def chat(request: Request, _: None = Depends(check_auth)):
    body = await request.json()
    req_id = str(uuid.uuid4())[:8]
    stream = body.get("stream", False)
    t0 = time.time()
 
    async def gen():
        async with httpx.AsyncClient(timeout=None) as client:
            async with client.stream("POST", f"{VLLM_URL}/v1/chat/completions", json=body) as r:
                async for line in r.aiter_lines():
                    if line:
                        yield f"{line}\n\n"
 
    if stream:
        log.info(json.dumps({"req_id": req_id, "event": "start", "model": body.get("model")}))
        return StreamingResponse(gen(), media_type="text/event-stream")
    else:
        # non-streaming: buffer the whole response
        async with httpx.AsyncClient(timeout=None) as client:
            r = await client.post(f"{VLLM_URL}/v1/chat/completions", json=body)
        log.info(json.dumps({"req_id": req_id, "event": "done", "latency": time.time() - t0}))
        return r.json()

That's ~40 lines and it's a real gateway. In production you add: rate limiting per API key, request-body validation with pydantic, usage tracking for billing.

Try itWire up an SSE client and print tokens as they stream in.

Run the FastAPI app locally against http://localhost:8000 (vLLM). Write a Python client that POSTs {"messages":[{"role":"user","content":"count from 1 to 20 with commas"}], "stream": true} and prints each delta.content as it arrives. Time the first-byte latency vs total latency — they should differ by ~2 seconds, which is exactly the tokens-in-flight cost. Then break auth on purpose (send the wrong bearer) and confirm you get a 401 before any streaming starts.

💡 Hint · Use `httpx` in async mode with `client.stream` — don't buffer the whole response.

Flavor B: direct HF generate

Educational — you'll see why nobody actually deploys this way for LLMs bigger than 1B:

from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
from threading import Thread
import torch
 
MODEL = "meta-llama/Llama-3.2-1B-Instruct"
tok = AutoTokenizer.from_pretrained(MODEL)
model = AutoModelForCausalLM.from_pretrained(MODEL, torch_dtype=torch.bfloat16, device_map="cuda")
 
@app.post("/v1/chat/completions")
async def chat_direct(body: dict, _: None = Depends(check_auth)):
    messages = body["messages"]
    prompt = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    inputs = tok(prompt, return_tensors="pt").to("cuda")
 
    streamer = TextIteratorStreamer(tok, skip_special_tokens=True, skip_prompt=True)
    gen_kwargs = dict(**inputs, streamer=streamer, max_new_tokens=body.get("max_tokens", 256),
                       temperature=body.get("temperature", 0.7))
    Thread(target=model.generate, kwargs=gen_kwargs).start()
 
    def sse():
        req_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
        # first chunk: role
        yield f'data: {json.dumps({"id":req_id,"choices":[{"delta":{"role":"assistant"},"index":0}]})}\n\n'
        for text in streamer:
            chunk = {"id": req_id, "choices": [{"delta": {"content": text}, "index": 0}]}
            yield f"data: {json.dumps(chunk)}\n\n"
        final = {"id": req_id, "choices": [{"delta": {}, "index": 0, "finish_reason": "stop"}]}
        yield f"data: {json.dumps(final)}\n\n"
        yield "data: [DONE]\n\n"
 
    return StreamingResponse(sse(), media_type="text/event-stream")

Works! But: no continuous batching (S070), no PagedAttention (S071), so at concurrency > 1 you're doing serial forward passes. Fine for a demo of a small model, useless for production.

Lesson: deploy vLLM (or TGI) as the model server and put FastAPI in front only for cross-cutting concerns (auth, logging, billing, routing to multiple model backends).


3 · Client test

Use the OpenAI SDK — that's the whole point:

from openai import OpenAI
client = OpenAI(base_url="http://localhost:8080/v1", api_key="sk-dev-key")
stream = client.chat.completions.create(
    model="meta-llama/Llama-3-8B-Instruct",
    messages=[{"role": "user", "content": "Write a haiku about GPUs."}],
    stream=True,
)
for chunk in stream:
    delta = chunk.choices[0].delta.content
    if delta:
        print(delta, end="", flush=True)

Or curl:

curl http://localhost:8080/v1/chat/completions \
  -H "Authorization: Bearer sk-dev-key" \
  -H "Content-Type: application/json" \
  -d '{"model":"meta-llama/Llama-3-8B-Instruct","messages":[{"role":"user","content":"hi"}],"stream":true}'

4 · Deploy to Modal

Modal turns a Python function into a serverless GPU endpoint. Great for spiky traffic (scales to zero when idle). Downside: cold starts are 30–60 seconds for large models unless you keep containers warm.

# modal_app.py
import modal
 
image = (
    modal.Image.debian_slim(python_version="3.11")
    .pip_install("vllm==0.6.3", "fastapi", "httpx")
)
 
app = modal.App("my-llm", image=image)
 
@app.function(
    gpu="A10G",
    scaledown_window=300,       # keep warm 5 min after last request
    timeout=60 * 20,
    allow_concurrent_inputs=64,  # allow many concurrent HTTP requests to one container
)
@modal.asgi_app()
def serve():
    import subprocess, os, time
    # start vLLM as a subprocess in the background
    subprocess.Popen([
        "python", "-m", "vllm.entrypoints.openai.api_server",
        "--model", "meta-llama/Llama-3-8B-Instruct",
        "--host", "127.0.0.1", "--port", "8000",
        "--gpu-memory-utilization", "0.90",
        "--max-model-len", "8192",
        "--enable-chunked-prefill",
    ])
    # wait for vLLM to come up
    import httpx
    for _ in range(120):
        try:
            httpx.get("http://127.0.0.1:8000/health"); break
        except Exception:
            time.sleep(1)
    # our FastAPI gateway
    from app import app as fastapi_app
    os.environ["VLLM_URL"] = "http://127.0.0.1:8000"
    return fastapi_app

Deploy:

modal deploy modal_app.py# https://your-workspace--my-llm-serve.modal.run

Point the OpenAI SDK at that URL and you're live.

  • gpu="A10G" → cheap ($1.10/hr), fine for ≤7B fp16 or ≤14B AWQ.
  • gpu="A100-40GB" or A100-80GB → mid-tier, for 70B AWQ.
  • gpu="H100" → highest perf, most expensive.
  • scaledown_window=300 → keep warm 5 min. Longer for high-traffic, shorter for cost.
  • allow_concurrent_inputs=64 → let one container process 64 in-flight requests (vLLM handles the concurrency internally).
  • min_containers=1 → keep at least one container always warm (kills cold-start latency, costs money).

5 · Alternative deploy targets

TargetBest forWatch out
ModalSpiky traffic, dev-friendly, autoscale-to-zeroCold starts, cost at steady load
Fly.io GPUSteady low-mid traffic, global regionsGPU inventory can be tight
Hetzner GPU / Latitude / RunPodCheapest steady servingYou manage everything
AWS SageMaker / BedrockEnterprise, IAM, VPCExpensive, slow to iterate
Replicate / BasetenJust want a model URLCost, less control
Cerebrium / Runpod ServerlessMiddle ground: Modal-like, cheaper GPUsSmaller ecosystems
GCP Vertex / Azure MLEnterprise, need cloud-native integrationsComplexity

Rule of thumb: if traffic is spiky and unpredictable → Modal or Baseten. If steady → dedicated GPU on Hetzner/Latitude with autoscaling. If you're a startup, start on Modal, migrate to Hetzner when your monthly bill exceeds a dedicated H100 (~$1500-2500/mo).


6 · The rest of "production-ready"

Beyond the streaming API, the boring but critical stuff:

Auth: rotate API keys, hash-store them, log the hash not the key. Better yet, JWT with tenant claims.

Rate limiting: per-key, per-model. Redis + slowapi (FastAPI's rate limiter) works. Cap concurrency, not just RPS — LLMs are long-lived.

Usage & billing: on every request, log \{prompt_tokens, completion_tokens, model, user_id, latency_ms, ttft_ms\} to a durable store (Postgres or S3-Parquet). This is what you bill on. vLLM's response includes usage — thread it through.

Observability: structured JSON logs → CloudWatch/Loki. Prometheus scrape vLLM's /metrics. Alert on time_to_first_token_seconds p99, num_waiting > 0 sustained, gpu_cache_usage_perc > 0.95.

Idempotency: clients retry on network errors. Accept Idempotency-Key header; cache the response for 5 min. Prevents charging users twice.

Timeouts: set a max max_tokens cap server-side (say 4096) so a bad client can't consume a GPU for 20 minutes.

Content safety: even if your model is "helpful and harmless" from RLHF (S063), still run a lightweight input/output classifier. Llama-Guard-3 is 1B and free.


7 · Pitfalls

War story Nginx buffering breaks streaming

Put nginx in front and forget proxy_buffering off; proxy_cache off;. Client sees the whole response arrive at once after the model finishes. Debugging: curl directly against the app works, through nginx it doesn't. X-Accel-Buffering: no header on the response also disables buffering.

War story Uvicorn worker starvation

Default uvicorn --workers 4 spawns 4 processes each with a copy of your model. On a single GPU, they fight for memory and OOM. For GPU-backed apps run one worker per GPU (--workers 1) and rely on FastAPI's async concurrency + vLLM's batching for throughput.

War story `asyncio.CancelledError` when client disconnects

Client closes the SSE stream mid-generation; FastAPI raises CancelledError in your generator. If you don't handle it, you leak the vLLM request and log noise piles up. Wrap the generator in try/finally and abort the upstream request on cancel (vLLM's /v1/chat/completions handles disconnect if you pass stream=True correctly).

War story Modal cold start on big models

70B model + Modal cold start = 60+ seconds. Use min_containers=1 for user-facing endpoints, or accept the first-request latency and warn users. Alternative: pre-fetch weights into a Modal volume so container start is only compute-cold, not download-cold.

War story OpenAI SDK version pinning

The OpenAI SDK changed its streaming response shape between 0.x and 1.x. If your client pins openai<1.0 but your gateway advertises the 1.x-style event stream (or vice versa), everything parses but tool-call fields silently drop. Match major versions between client and gateway; test with the actual SDK version your consumers use.

War story Health check that isn't

A /health that just returns 200 tells you nothing. Better: /health calls the backend with a 1-token max-tokens request. If the model responds within 5s, healthy; else degraded. Kubernetes/Modal will actually restart on failure this way.


8b · Modern 2025 twist: multi-LoRA, tool calling, structured output

The 2023-era serving story was "one model, one endpoint." 2024–2025 exploded that.

Multi-LoRA serving

vLLM and TensorRT-LLM both support serving one base model with dozens of LoRA adapters mounted simultaneously. Requests specify model: "llama-3-70b-cust123" and the router pulls the right adapter. Adapter switch is ~milliseconds; you pay the base-model memory once and serve thousands of fine-tuned variants from one GPU. Punica (Chen et al., 2023) is the classic paper; S-LoRA (2024) generalises to concurrent multi-adapter batches.

Launch: vllm serve meta-llama/Llama-3-8B --enable-lora --lora-modules cust123=/path/to/lora1 cust456=/path/to/lora2. Clients pick the LoRA by model name.

Native tool/function calling

OpenAI's tool-calling API became a de-facto standard. vLLM v1 ships --enable-auto-tool-choice --tool-call-parser hermes (or llama3_json, mistral) that parses model output back into the OpenAI tool_calls shape. Your FastAPI gateway just forwards the request; no custom parsing needed.

Structured JSON / constrained decoding

Outlines and XGrammar mask the logits at each step to enforce a JSON schema or context-free grammar. vLLM integrates both under --guided-decoding-backend. Requests pass {"guided_json": {...schema...}} and the response is guaranteed parseable. For anything that feeds into downstream code, use this; it eliminates the JSON-parse-error retry loop.

The 2025 minimum-viable stack

  1. vLLM v1 backend with fp8 + prefix caching + chunked prefill.
  2. FastAPI gateway with API-key auth, per-key usage logging to Postgres, Prometheus metrics.
  3. Nginx or Modal ingress with buffering off for SSE.
  4. Llama-Guard-3 on inputs and outputs for safety.
  5. Sentry/OpenTelemetry for traces.
  6. On-call rotation for the time_to_first_token_seconds p99 alert.

Everything else is optimization. Get these six right and you're operating at 2024-Anthropic-level maturity for whatever fraction of Anthropic's traffic you have.

Further reading:


Try it yourself

  1. Deploy the FastAPI + vLLM combo above to Modal. Get a URL. Hit it with openai SDK. Take a screenshot.
  2. Turn Nginx buffering off and on, curl the streaming endpoint, watch the difference.
  3. Add a per-API-key rate limiter. Slam it with hey -c 20 -n 1000 and confirm the 429s come back.
  4. Mount two LoRAs on the vLLM launch line, hit each by name, confirm outputs differ.
  5. Kill the deploy. Sleep on it. Redeploy tomorrow morning. Time the cold start; that number is your product's worst-case first-user latency.

8 · Mermaid: the request path


Recall

1. Two things about SSE format that trip people up? (a) Each event must end with a double newline \n\n, not single. (b) The OpenAI-compat first chunk should include delta.role = "assistant" — the SDK expects it.

2. Why put FastAPI in front of vLLM instead of using vLLM directly? Cross-cutting concerns: auth, rate limits, per-key usage/billing, routing between models, structured logs, idempotency. vLLM handles the model; FastAPI handles the product.

3. Why --workers 1 for GPU apps? Each uvicorn worker loads a copy of the model. On a single GPU they OOM. One worker + async endpoint + vLLM's batching gives you all the concurrency you need.

4. When would you not choose Modal? Steady, high-volume traffic where scale-to-zero savings are irrelevant and per-GPU-hour cost dominates. Move to dedicated GPU on Hetzner/Latitude/AWS with your own autoscaler.

5. What's the fix for nginx killing your streams? proxy_buffering off; proxy_cache off; in the location block, plus X-Accel-Buffering: no on the response headers.

Stretch: wire up a per-API-key usage tracker: on every request, insert a row into Postgres with (key_id, model, prompt_tokens, completion_tokens, latency_ms, ttft_ms). Add a /v1/usage endpoint that returns totals for the current month.

In your own words: the production LLM API stack is: __________________________ (backend) + __________________________ (gateway) + __________________________ (platform).

Spaced review: S071 vLLM internals (the backend), S062 fine-tune Llama (the model you're shipping).

Next session (S073): the observability side. Latency vs throughput trade-offs, TTFT vs ITL, tail latency, load-testing with locust and k6.

Bring back tomorrow:

  • The exact SSE format (double newline, data: [DONE], role in first delta).
  • The vLLM-behind-FastAPI-behind-Modal pattern.
  • The "always one worker per GPU" rule.
Common misconception
✗ What most people think

"FastAPI is async, so my inference endpoint scales. I'll declare the handler async def, call the model inside it, and the event loop will handle concurrent requests."

✓ What is actually true

An async def handler that performs a synchronous, CPU- or GPU-blocking call blocks the entire event loop for the duration — not just that request, but every other connection the worker is serving, including health checks and streaming responses already in flight. Async buys concurrency only for operations that yield. Model inference does not yield. A blocking call belongs in a thread pool via def rather than async def, or better, behind a queue serviced by a dedicated batching loop.

Why the myth is so sticky

This misconception is unusually durable because the framework's own documentation and every tutorial you have read demonstrate async with database and HTTP calls, where it genuinely is the right answer and the speedup is dramatic. You learned a correct rule — "async makes I/O-bound handlers concurrent" — and the failure mode is that the rule's precondition is invisible at the call site. model.generate(...) and await db.fetch(...) look like the same kind of line. Worse, it works perfectly under single-request testing and under low load, so it passes every local check and fails only when concurrent traffic arrives, which is exactly the situation where the symptom looks like a capacity problem rather than a code problem.

Prove it to yourself

Make the blocking visible with a trivial server:

import time, asyncio
from fastapi import FastAPI
app = FastAPI()

@app.get('/bad')
async def bad():        # blocking call inside async -> blocks the loop
    time.sleep(2); return 'ok'

@app.get('/good')
def good():             # sync def -> FastAPI runs it in a threadpool
    time.sleep(2); return 'ok'

@app.get('/ping')
async def ping():
    return 'pong'

# uvicorn app:app --workers 1
# then: hit /bad, and while it runs, curl /ping  -> hangs
#       hit /good, and while it runs, curl /ping -> instant

/ping is the diagnostic. If it stalls, your whole worker is stalled.

From first principles
Start with the question

Why must a production LLM endpoint stream tokens rather than return a complete response, even when the client would be happy to wait? It is more code, more failure modes, and harder to cache.

  1. 1
    Generation time is proportional to output length, and output length is not known when the request arrives.
    forced by · the model decides when to stop, so the server cannot bound the response time in advance
  2. 2
    A non-streaming response therefore has an unbounded, high-variance latency, and every layer between client and server — load balancers, reverse proxies, client libraries, browsers — enforces its own idle timeout.
    forced by · those timeouts exist to reclaim resources from dead connections and cannot distinguish a slow generation from a hung one
  3. 3
    A connection that is silent for the whole generation is indistinguishable from a failed one, so the safe timeout must exceed your worst-case generation, which means genuinely hung requests hold resources for that long too.
    forced by · you cannot set a timeout tight enough to catch failures without also killing legitimate long generations
  4. 4
    Streaming emits bytes continuously, so idle timeouts measure inter-token gaps rather than total duration — a hung generation now fails in seconds while a long one succeeds in minutes.
    forced by · liveness becomes observable from the outside, which is the property the whole timeout mechanism was trying to approximate
  5. 5
    It also decouples the two latencies users perceive: time to first token, which is when the interface stops looking broken, and inter-token latency, which is whether it reads smoothly.
    forced by · perceived responsiveness is set by when output starts, not when it finishes, so a streaming endpoint can feel fast while being slower in total
⇒ Therefore

Therefore streaming is not a UX nicety bolted on for polish — it is the mechanism that makes an unbounded-duration operation observable and therefore operable. Without it you cannot distinguish slow from dead, and every timeout you choose is wrong in one direction or the other.

And note the prediction. First: if streaming makes liveness observable, then a streaming endpoint should let you set aggressive timeouts and still serve long generations — configure an inter-token timeout of a few seconds and verify that legitimate long outputs survive while a deliberately stalled backend fails fast. Second: because perceived speed is set by first token, a change that improves TTFT at the cost of total generation time should feel better to users despite being objectively slower — which means your monitoring must report TTFT separately or it will report the wrong thing improving. Third: streaming responses cannot be buffered by intermediaries without destroying the property, so a proxy with response buffering enabled should silently convert your streaming endpoint back into a non-streaming one; check that a curl through your production ingress actually receives incremental chunks, because this is a common and invisible misconfiguration.

Mental modelThree boxes, one queue

A serving deployment is three boxes with a queue between the first two. The web layer accepts connections, validates, and streams bytes back — it must never block and must never hold the model. The queue holds admitted work and is where backpressure lives. The inference layer is a single owner of the GPU running one batching loop.

Every serving pathology maps to a violation of that shape: model loaded per worker means several processes fighting over one GPU; inference called inline in the handler means the web layer blocked; no queue means load is rejected by falling over instead of by a policy; multiple batching loops means neither can schedule well because neither can see all the work.

  • One process owns the GPU. Web workers scale horizontally, GPU workers do not — that asymmetry drives the entire deployment topology.
  • Anything blocking must not sit inside async def. Either def for the threadpool, or across a queue boundary.
  • The queue is where you implement backpressure. A bounded queue that rejects with 429 is a working system; an unbounded one converts overload into unbounded latency and then OOM.
  • Stream by default. It changes what your timeouts mean and it is what makes liveness observable.
  • Health checks must not touch the GPU, or a busy server reports itself dead and the orchestrator restarts a perfectly healthy pod under load — the classic self-inflicted outage.
🔔 Fires when you see

Fire this the moment you see: latency that is fine solo and terrible under concurrency · a health endpoint timing out during load · memory growing per worker after a deploy (each worker loaded the model) · a load balancer 504 while the server is still generating · requests queueing without any visible limit · --workers N on a container with one GPU · a client that receives the whole response at once from a streaming endpoint.

The tradeoff

How do the web layer and the model layer relate — same process, separate process on the same host, or a separate service?

In-process — model loaded inside the API worker
+ you gain the simplest thing that works: one artifact, one deploy, no serialisation, no network hop, and no extra failure mode between request and inference; debugging is a single stack trace
− you pay you cannot scale the web layer without duplicating the model, so --workers becomes unusable; a model crash takes the API down with it; and a model reload requires a full restart, meaning every deploy is a cold start of several GB
pick when a single-replica internal tool or a prototype, where request rate is low enough that one worker suffices and operational simplicity beats every other consideration
Separate process on the same host, connected by a queue or local socket
+ you gain web workers scale freely while exactly one process owns the GPU, the batching loop sees all pending work so it can schedule properly, and a model crash degrades to failed requests rather than a dead API; no network hop and no cross-host serialisation cost
− you pay a real IPC boundary to design, monitor, and handle failures across; two processes to supervise; and a queue whose depth is now a first-class operational metric you must alert on
pick when a single-GPU or single-node deployment serving real traffic — this is the default worth reaching for as soon as more than one concurrent user exists
Separate service — a dedicated inference server behind its own endpoint
+ you gain the two layers scale, deploy, and fail independently; you inherit a mature serving stack's batching, paging, and metrics rather than writing them; and multiple applications can share one model deployment
− you pay network latency and serialisation on every request, a second system to operate and upgrade, and an inference stack whose scheduling behaviour you must learn rather than control; local development now needs both halves running
pick when more than one GPU, more than one consuming application, or a need to deploy model and application on independent schedules — any of those three alone justifies it
What a senior engineer actually does

Start in-process only for prototypes, and move to a separate inference service earlier than feels necessary, because the migration gets harder as application logic accretes around direct model access. The signal that you have waited too long is wanting to scale the API for reasons unrelated to inference and being unable to.

Whatever the topology, get three things right or the rest does not matter: bound the queue and return 429 when it is full, because unbounded queueing turns a load spike into an outage rather than a slowdown; keep the health check off the GPU path, because a health check that fails under load will have your orchestrator kill healthy pods at exactly the worst moment; and stream, because it is what makes long generations distinguishable from hung ones. Those three are cheap to implement and each of them prevents a class of outage that is expensive to diagnose from the outside.


Quick recall · click to reveal
★ = stretch question

Previous: ← DL S071 · Next: DL S073 →