Design an LLM chat platform
A chat assistant looks like a simple request and response, but each reply is a long, expensive computation on scarce GPUs, streamed a few words at a time for ten or twenty seconds. The design problems are unusual: GPU memory, not CPU, limits how many people you can serve; idle GPUs burn money; and keeping the first word fast matters more than total speed. This page has material for a full 60–90 minute interview.
1. The prompt
"Design a chat assistant like ChatGPT: people type a message, the model's reply streams back word by word, and their conversations are saved."
The surrounding product (accounts, conversation lists) is ordinary. The heart is the inference tier: how requests share GPUs, how much memory each conversation holds while it generates, how to keep the first token fast under load, and how to fail gracefully when GPUs run out. Say early that GPU cost dominates everything; it frames every trade-off that follows.
2. Frame the problem
Ask these in the first five minutes. Guess each answer and its effect before opening it.
Do we run our own models, or call a model provider's API?
We serve our own open-weight models (say a 70B-parameter model and a small fast one) on our GPUs, and may route some traffic to an external provider.
Running our own models puts GPU serving, batching and memory in scope. With only external APIs, the design would be about routing, quotas and cost.
How many users, and how bursty?
About 20M daily users sending ~10 messages each, with a strong daily peak about 3× the average.
We get generations per second and output tokens per second at peak, and from those the GPU count, the biggest cost line.
Must replies stream?
Yes. The first words should appear within about a second.
Two latency numbers matter: time to first token (TTFT) and tokens per second while streaming. The API must be a stream.
How long can conversations get?
The model supports 128K tokens of context, but most conversations stay under 8K.
Context length decides KV-cache memory per conversation, which limits how many can share a GPU.
Are there free and paid tiers?
Yes. Paid users get priority, larger limits and the big model; free users get the small model when busy.
We need priority queues, per-tier token limits and a fallback path.
Tools, web browsing, file uploads?
Not in this version.
Tools add an agent loop and retrieval (Module 29). Keep them out and say they'd sit in front of the same inference tier.
Safety and privacy requirements?
Inputs and outputs are checked against usage policies. Users can delete conversations. Some enterprise customers require that their data is not retained.
We need moderation that works on a stream, deletion that really deletes, and per-tenant retention settings.
Regions?
Users worldwide; GPUs in a few regions.
GPU capacity can't be everywhere. Routing between regions, and the data-residency rules that limit it, are part of the design.
3. What it must do
- A user can send a message and watch the reply stream in, and stop it at any time.
- Conversations are saved: the user can list them, reopen one and continue it.
- The user can regenerate the last reply or switch between available models.
Not now (with a reason):
- Tools, browsing and uploads: the agent loop and retrieval (Module 29), layered on top later.
- Voice and images: other model types, similar serving ideas.
- Fine-tuning per customer: a training pipeline, not a chat feature.
- Sharing conversations: an easy read-only link later.
4. How well it must do it
| Target | Why this number |
|---|---|
| First token fast: TTFT under 1 s at p95 for paid users, under 3 s for free users speed | Waiting with no feedback feels broken; once text flows, people are patient. |
| Readable stream: at least 30 output tokens a second per reply speed | Faster than people read. Beyond that, extra speed matters less than cost. |
| Degrade, don't fail: 99.9% of messages get a reply; under overload, free traffic falls back to the small model or queues with a visible message surviving failures · load shape | GPU capacity can't be added in seconds, so planned degradation beats errors. |
| Cost: keep GPUs above ~60% busy at peak, without breaking the latency targets load shape | GPUs are the bill. Idle GPU-hours are money burned. |
| Conversations kept and private: saved messages are durable; deleted ones are gone from storage within 30 days; no-retention tenants are never stored data safety · rules | Users treat chats as personal notes; companies have contracts and regulations. |
| Safe: policy-violating inputs and outputs are blocked or stopped mid-stream security | A public assistant attracts misuse. |
5. Numbers that change the design
| Quantity | Working | → so we… |
|---|---|---|
| Generations | 20M users × 10 messages = 200M a day ≈ 2,300/s, peak ≈ 7,000/s | A large but ordinary request rate for the web tier |
| Output tokens | Average ~500 tokens a reply × 7,000/s | ~3.5M tokens a second at peak → so the GPU tier, not the web tier, is the design |
| Model weights | 70B parameters × 1 byte (8-bit weights) ≈ 70 GB; an H100 has 80 GB | → so one model replica spans 2 GPUs (160 GB), leaving ~70 GB after overheads for conversations |
| KV cache per token | Llama 3.3 70B: 2 (key and value) × 80 layers × 8 KV heads × 128 dims × 2 bytes ≈ 320 KiB | An 8K-token conversation holds ~2.5 GiB while it generates → so ~25 long conversations fit per replica: memory, not compute, limits concurrency |
| Replicas | Illustrative: a 2-GPU replica with good batching streams ~2,500 tokens/s in total | 3.5M ÷ 2,500 ≈ 1,400 replicas ≈ 2,800 GPUs at peak → so batching efficiency and caching are worth more than any other optimisation |
| Conversation storage | 200M messages a day × ~2 KB | ~400 GB a day: a sharded store, nothing exotic |
The per-replica throughput is an illustrative planning number. Real figures depend on the model, GPUs, quantization and prompt lengths, so say you'd benchmark it. The shape of the conclusion doesn't change: GPUs dominate, and memory per conversation limits batching.
6. The nouns
- User (with tier).
- Conversation.
- Message: user or assistant text, with token counts and model.
- Generation: one in-flight reply, from queued to streaming to done or cancelled.
- Model pool: the replicas of one model.
- Usage record: tokens used, for limits and billing.
conversations conversation_id, user_id, title, model, updated_at (by user_id, updated_at desc) messages conversation_id, message_id (time-ordered), role, content, tokens_in, tokens_out, model, parent_id generations generation_id, conversation_id, status, model_pool, replica, queued_at, first_token_at, finished_at usage user_id, day, tokens_in, tokens_out (token limits per tier)
parent_id lets a regenerated reply become a sibling of the old one, so the user can flip between versions.
7. The contract
REST for conversations; the reply streams as Server-Sent Events on the response to the POST, because it's one-way and long. Many model APIs stream this way. Internally, the chat service calls the inference tier over gRPC with a streaming response. The user comes from the session token; the tier and limits come from the account, never from the client.
POST /v1/conversations → 201 {conversation_id}
POST /v1/conversations/{id}/messages {content, model, client_message_id}
← event: message_start data: {message_id, generation_id, model}
← event: delta data: {text: "Sure"} (many)
← event: message_end data: {stop_reason: "end", usage: {in: 1840, out: 412}}
POST /v1/generations/{generation_id}/cancel → stop generating, free the GPU slot
GET /v1/generations/{generation_id}/stream Last-Event-ID: 57 → resume a dropped stream
GET /v1/conversations?cursor=… → list, newest first
GET /v1/conversations/{id}/messages?cursor=… → history
// internal (gRPC)
Generate(stream) {model, prompt_tokens, max_tokens, priority, cache_key} → stream of tokens
Data flow: (1) check limits → (2) load the conversation and build the prompt (system prompt + history that fits the context) → (3) route to a model pool and queue → (4) a replica runs prefill (reads the whole prompt), then decodes one token at a time → (5) tokens stream back through the chat service to the browser → (6) the finished message and usage are saved.
8. First working version
Start simple: a chat service, a database, and a few GPU servers each running a model server that handles one request at a time. Get the three requests working.
8.1 Sending a message
The chat service saves the user's message, loads earlier messages, and builds the prompt. If the history is longer than the context window, it drops the oldest turns (we improve this in 9.2). It then calls a model server.
8.2 Streaming the reply
The model server produces tokens one by one; the chat service forwards each as an SSE delta event, and on the last token saves the full assistant message with its token counts. The browser renders text as it arrives.
8.3 Listing and reopening conversations
The sidebar lists conversations by (user_id, updated_at desc) with a cursor. Opening one pages through its messages. A short title is generated after the first exchange by a cheap model call, in the background.
Parked for later (section 9 fixes each)
- One request at a time per GPU server: the GPU is mostly idle and queues explode at peak.
- Long histories exceed the context window, and resending them costs prefill time on every message.
- Random routing ignores which replica already has this conversation's prefix cached.
- When all GPUs are busy, requests pile up with no priority and no limit.
- If the browser connection drops, the reply is lost, and the GPU keeps generating for nobody.
- Nothing stops harmful inputs or outputs; deleting a chat doesn't delete everything.
9. Make it hold up
9.1 Sharing GPUs: continuous batching cost · first token fast
Generating a token for one conversation barely uses a GPU's compute: the time goes into reading the model's weights from memory. Reading them once and producing the next token for many conversations at the same time costs little more. Batching is therefore the biggest lever in the whole design.
| Approach | Why | |
|---|---|---|
| ✗ Bad | One request at a time per replica | Throughput of one conversation's tokens per step; everyone else waits in a queue. Thousands of GPUs would still not be enough. |
| ~ Good | Static batching: wait for 8 requests, run them together until all finish | Better throughput, but short replies sit idle until the longest one in the batch finishes, and new arrivals wait for the whole batch. |
| ✓ Great | Continuous (iteration-level) batching: at every decode step, finished sequences leave and waiting ones join, up to the memory limit | Slots never sit idle, new requests start within one step, and throughput rises severalfold. The Orca paper (OSDI 2022) introduced this scheduling; serving engines such as vLLM use it. |
Two phases share the GPU. Prefill reads the whole prompt at once (compute-heavy, it sets TTFT). Decode produces one token per step (memory-heavy, it sets tokens per second). Engines interleave them; large deployments sometimes run prefill and decode on separate GPU pools, so long prompts don't stall everyone's streaming. Try the three modes in the lab in section 13.
9.2 Memory is the limit: KV cache and context cost · first token fast
- Why memory: while a reply is generated, the model keeps keys and values for every token so far (the KV cache), about 320 KiB per token for a 70B model with grouped-query attention. A batch can only hold as many conversations as fit in the memory left after the weights.
- Paged KV cache: allocating each conversation's maximum length up front wastes most of the memory. vLLM's PagedAttention stores the cache in small fixed-size blocks, like virtual memory pages, allocated as the reply grows. Less waste means bigger batches.
- Prefix caching: every message in a conversation starts with the same system prompt and history. Keeping the KV blocks of that shared prefix lets the next message skip most of its prefill. vLLM calls this automatic prefix caching. It only works if the conversation comes back to a replica that still has the blocks (see 9.3).
- Managing long conversations: when history exceeds a budget (say 16K tokens), summarise the oldest turns into a short note (a cheap model call in the background) and keep the recent turns verbatim. Cap
max_tokensper reply by tier. - Smaller numbers: 8-bit weights halve memory against 16-bit with small quality loss; a quantized KV cache stretches further. Measure quality before switching.
9.3 Routing, priorities and admission control degrade, don't fail · first token fast
| Approach | Why | |
|---|---|---|
| ✗ Bad | Round-robin across replicas and an unlimited queue | Ignores how busy each replica is and where caches live; at peak the queue grows until every request times out. |
| ~ Good | Least-loaded routing by queued tokens and free KV memory; per-tier token limits (tokens per minute) at the gateway | Balances work and stops individual abusers, but treats all traffic as equal when capacity runs out. |
| ✓ Great | Cache-aware routing (send a conversation back to the replica holding its prefix, unless it's overloaded), priority queues by tier, and admission control: when the expected wait exceeds the target, free traffic falls back to the small model or gets a clear "busy, try again" message | Paid users keep their latency, free users still get answers, and nobody waits in a queue they'll never leave. Cache hits cut prefill for everyone. |
Scaling GPUs is slow: a new replica needs a GPU machine, then minutes to load 70 GB of weights. Autoscale on queue depth and forecast (the daily peak is predictable), keep warm headroom, and use the small model as the pressure valve for sudden spikes.
9.4 Streams that survive bad connections degrade, don't fail
- Decouple generation from the connection: the inference tier writes tokens into a short-lived per-generation buffer (for example a Redis stream with a TTL of a few minutes), and the chat service relays from it. A dropped browser reconnects to
/v1/generations/{id}/streamwithLast-Event-IDand continues where it stopped. - Cancel really cancels: the Stop button, or no reader for, say, 60 s, sends a cancel to the replica, which removes the sequence from the batch and frees its KV memory at the next step.
- No double generations:
client_message_idmakes a retried POST attach to the existing generation instead of starting a second one, which would cost GPU time twice. - Save once: the final message is written when the generation ends (or is cancelled, keeping what was produced), with its usage for limits and billing.
9.5 Storing conversations, and deleting them kept and private
- Shape: messages partitioned by
conversation_idand sorted by time-ordered id (a wide-column store or a sharded relational database); conversation lists indexed by(user_id, updated_at). - Writes are small: one user message and one assistant message per exchange. The database is not the bottleneck; GPUs are.
- Deletion: deleting a conversation marks it deleted at once (hidden everywhere), and a background job removes messages, cached summaries, generation buffers and search indexes, then confirms. Backups expire on a fixed schedule, so deleted data ages out of them too.
- No-retention tenants: skip storage entirely and keep content out of logs. Logs contain ids and token counts, never message text. Encrypt stored content with per-tenant keys where contracts require it.
9.6 Safety on a stream safe
- Input check: a fast classifier on the user's message before it reaches the big model (tens of milliseconds), plus per-user rate limits and abuse detection.
- Output check while streaming: run a classifier over the reply in chunks as it streams. If a chunk is flagged, stop the generation, replace the visible text with a notice and record why. A small buffer (a sentence or so) before display trades a little latency for not showing flagged text.
- Separation of duties: safety models and rules are versioned and deployed separately from the chat model, so they can be updated quickly.
9.7 The whole design after the deep dives
One message through every part: limits, prompt building with summaries, cache-aware routing, a continuously batched replica, a resumable token buffer and the saved conversation.
10. Interviewer follow-ups
A new model launches and traffic triples overnight.
GPUs can't triple overnight, so the plan is degradation: per-tier limits tighten automatically, free traffic moves to the small model or a waitlist, paid traffic keeps priority, and the launch is staged by region or tier. In parallel, buy or reserve capacity, and use an external provider as overflow if contracts allow.
A GPU region goes down.
Route new generations to other regions where residency rules allow. Admission control absorbs the drop in capacity by falling back to the small model. In-flight generations in the dead region fail; clients see an error with a retry button, and a retry with the same client_message_id starts cleanly elsewhere. Conversations are replicated across regions, so history is intact.
Cut GPU cost by 30% without hurting quality much.
Raise the prefix-cache hit rate (cache-aware routing, stable system prompts), route easy requests to the small model with a classifier, quantize weights after an evaluation, cap max output length per tier, and use speculative decoding (a small model drafts tokens that the big model verifies in one pass). Measure quality on a fixed evaluation set before and after each change.
How do you roll out a new model version safely?
Offline evaluations first (Module 29), then a canary: a few percent of traffic, with dashboards for refusal rate, user feedback, reply length, latency and cost. Ramp up gradually and roll back instantly by routing. Pin enterprise customers to model versions they've approved.
A user pastes a 100K-token document.
It fits the context window, but its KV cache (~30 GiB) takes almost half a replica's spare memory, and prefill takes seconds. Give such requests their own queue (or a long-context pool) so they don't stall short chats, show a progress state, and charge them against a larger token budget.
Users hit "regenerate" a lot.
Each regeneration reuses the same prompt, so prefix caching makes its prefill nearly free if it lands on the same replica. Keep the old reply as a sibling via parent_id. Rate-limit regenerations on the free tier.
Now add tool use (web search, code execution).
The chat service becomes an agent loop: the model asks for a tool, the service runs it in a sandbox, appends the result and calls the model again (Module 29). The inference tier is unchanged, but each user message may now cost several generations, so limits and cost tracking must count all of them. Tool results also bring prompt-injection risk, so sandbox the tools and treat their output as data, not instructions.
Why not WebSockets for streaming?
The reply is one-way and ends when it's done. SSE over the POST response is plain HTTP, works through proxies and CDNs, and resumes with Last-Event-ID. WebSockets make sense for voice or for two-way live features.
The external provider we use for overflow has an outage.
Treat each provider as a separate pool behind the same router, with a circuit breaker (Module 24). When the breaker opens, the router stops sending traffic there and admission control tightens instead. Keep prompts portable between models, and test the fallback regularly.
How do you know latency is good for users, not just servers?
Measure TTFT and tokens per second at the client and the chat service, per model, tier and region, as percentiles. Track queue time separately from prefill time, so you know whether to add capacity or fix prompts. Alert on burn rate against the TTFT objective (Module 25).
11. When things break
| Failure | What users see | How we detect it | What limits the damage |
|---|---|---|---|
| A GPU replica crashes | Its in-flight replies stop with "try again" | Health checks; stream errors | Router removes it; retries reuse client_message_id; others absorb load |
| Peak demand exceeds GPUs | Free tier gets the small model or a wait message | Queue time per tier; TTFT burn rate | Priority queues, admission control, fallback model, pre-scaled headroom |
| Prefix cache hit rate drops (bad routing deploy) | Slower first tokens | Cache-hit and prefill-time dashboards | Canary routing changes; roll back |
| Browser disconnects mid-reply | Reply continues after reconnect | Reconnects with Last-Event-ID | Token buffer with TTL; cancel after 60 s with no reader |
| Safety classifier is down | Replies delayed or blocked | Classifier error rate | Fail closed on high-risk categories; fall back to stricter rules |
| Bad model version | Worse answers, more refusals | Canary metrics, user feedback rate | Instant routing rollback; pinned versions for enterprise |
| Conversation store slow | History loads slowly; replies still stream | Read latency | Recent turns cached with the session; streaming doesn't depend on the store |
12. Wrap-up and the clock
Trade-offs we made:
- Continuous batching and big batches (throughput) balanced against TTFT.
- Cache-aware routing against perfectly even load.
- Summarising old turns (cheaper, slightly lossy) instead of resending everything.
- Degrading free traffic instead of failing everyone.
- A short output buffer for safety, at the cost of a few hundred milliseconds.
What we'd watch:
- TTFT p95 and tokens per second per tier.
- Queue time.
- GPU utilisation and KV-memory use.
- Prefix-cache hit rate.
- Fallback rate.
- Cost per 1,000 replies.
- Safety block rate.
At 10×:
- Separate prefill and decode pools.
- More regions with GPU capacity.
- Aggressive routing of easy requests to small models.
- Speculative decoding.
- Capacity planned from forecasts weeks ahead.
A 60-minute interview
| 0–5 | Frame the problem: own models? streaming? tiers? |
| 5–12 | Must do, how well (TTFT, tokens/s, cost), GPU and KV numbers |
| 12–17 | Nouns, contract with SSE events |
| 17–30 | First working version 8.1–8.3, parked list |
| 30–52 | Deep dives: batching (9.1), KV memory and prefix cache (9.2), routing and admission (9.3) |
| 52–60 | Follow-ups and wrap-up |
A 90-minute interview
| 0–8 | Frame the problem, including safety and regions |
| 8–18 | Must do, how well, numbers |
| 18–25 | Nouns, contract, data flow |
| 25–40 | First working version |
| 40–75 | Deep dives 9.1–9.6 and the final picture |
| 75–85 | Follow-ups: launch spike, cost cut, tools |
| 85–90 | Trade-offs, metrics, 10× plan |
| Level | What a strong answer shows |
|---|---|
| Mid-level | A chat service, conversation storage, a streaming API (SSE), model servers behind a load balancer, and awareness that GPUs are expensive and replies are long-running. |
| Senior | GPU and KV-cache arithmetic driving the design; continuous batching; prefix caching with affinity routing; priority queues and admission control with fallback; resumable streams and real cancellation; context management. |
| Staff+ | Cost as a first-class requirement (utilisation, cache hit rate, model routing, quantization trade-offs with evaluations), prefill/decode separation, capacity planning with slow GPU scaling, multi-provider resilience, safe model rollouts, privacy and retention by tenant. |
Common mistakes:
- Treating inference like a stateless web call.
- Ignoring KV-cache memory.
- One request per GPU.
- Autoscaling GPUs as if they started in seconds.
- No plan for overload.
- Losing the reply when the connection drops.
- Generating for users who already left.
- Logging message content.
13. Try it, check yourself, read more
GPU batching lab. One model replica for 60 seconds. Requests arrive at random; reply lengths vary from a sentence to a page. Each row is one request: grey while it waits in the queue, coloured while it generates (blue for long replies), red if it was still unfinished when we stopped watching.
Model of one replica: one decode step every ~30 ms (slightly slower with bigger batches), up to 16 sequences in memory at once (8 for static batches). Illustrative, not a benchmark.
What usually limits how many conversations one model replica can serve at once?
What makes continuous batching better than static batching?
Why route a conversation's next message back to the same replica?
Demand exceeds GPU capacity at peak. What should happen?
The user's connection drops halfway through a reply. How does it continue?
| Resource | Why |
|---|---|
| Efficient memory management for LLM serving with PagedAttention (vLLM paper) | Why KV memory limits batching, and the paging fix. |
| Orca: a distributed serving system for transformer-based generative models (OSDI 2022) | Iteration-level scheduling: the idea behind continuous batching. |
| Automatic prefix caching (vLLM docs) | Reusing the KV cache of shared prompt prefixes. |