Design an LLM Chat Service
Design a consumer AI chat product: streamed replies that survive reconnects, scarce GPU capacity, token budgets, context assembly, safety and cost.
On this page
Maya types "Plan three days in Lisbon" and the answer starts appearing word by word less than a second later. She closes the app halfway through, opens it on the train, and the reply is there, finished. A consumer AI chat assistant is the kind of product this question means.
It looks like a chat app with a bot on the other end, but the hard parts differ: the "other person" is a pool of GPUs that is expensive, slow to grow and easy to overload, each reply takes seconds, and each request carries the conversation so far. The steps follow A Framework for Any Design. Try each "Your turn" box before you read on.
Clarify the problem
Design a chat assistant product on top of a language model.
I'll assume the model already exists and I'm building the product around it: send a message, stream the reply, keep conversation history, many users. Do I own the model servers, or call someone's API?
You run the model servers on your own GPUs.
How many users, and is there a free tier and a paid tier?
10 million daily users, about 10 messages each. Both tiers.
Then GPU capacity is the scarce resource, not servers or storage. My priorities: the first words appear fast, a reply survives a dropped connection, paid users keep working when we're overloaded, nobody can use up the cluster alone, and every input and output passes a safety check. I'll leave out file uploads, tools and web search.
- Functional: start a conversation, send a message, stream the reply as it is generated, stop a reply, list and reopen past conversations, edit a message or regenerate a reply.
- Non-functional: first words within 1 second for most requests; replies stream at least as fast as people read; a reply survives a reconnect; no user message is ever lost; free users are throttled before paid users; every request passes safety checks.
- Out of scope: training the model, file uploads, tool use and web search, voice.
Estimate the scale
Count in tokens, not requests: a 5-token and a 2,000-token reply are one request each but cost 400 times apart. These figures are this scenario's assumptions; measure your own model and hardware.
- Messages: 10 million users × 10 = 100 million a day ÷ 86,400 ≈ 1,200 a second, about 3,500 at a peak of 3x.
- Tokens per request: about 2,000 in (instructions plus history) and 500 out.
- Output rate: 3,500 × 500 = 1.75 million tokens a second at peak, and 3,500 × 2,000 = 7 million input tokens a second to read.
- Open streams: at 50 tokens a second per stream, a 500-token reply takes 10 seconds. Requests in flight = arrival rate × time each takes = 3,500 × 10 = 35,000 open streams at peak.
- Storage: a user message of about 400 bytes, a reply of about 2,000 bytes and 600 bytes of metadata make 3 KB per exchange: 100 million × 3 KB = 300 GB a day, about 110 TB a year.
A model replica (one copy of the model on its group of GPUs) runs 40 streams at once, each at about 50 tokens a second. How many replicas do you need for the peak output rate?
- 3,500 messages a second at peak
- 500 output tokens per reply
- One replica: 40 streams × 50 tokens a second
Show the working
- Peak output: 3,500 × 500 = 1,750,000 tokens a second
- One replica: 40 × 50 = 2,000 tokens a second
- 1,750,000 ÷ 2,000 = 875, call it 900; the average of 1,200 a second needs 300
Estimate 900 (anything from 300 to 3000 is a good answer)
Check with streams: 35,000 open streams ÷ 40 per replica = 875. This counts only generation; reading 7 million prompt tokens a second also takes GPU time, so plan headroom. GPUs cannot be added in seconds, which is why the deep dives are about queueing and saying no.
You want a free user to manage about 10 average messages a day. How many tokens should their daily budget be, counting input and output?
- About 2,000 input tokens per message
- About 500 output tokens per message
- 10 messages
Show the working
- Per message: 2,000 + 500 = 2,500 tokens
- 10 × 2,500 = 25,000 tokens a day
Estimate 25 thousand (anything from 8 thousand to 80 thousand is a good answer)
Input counts because long conversations cost GPU time too. A user who keeps one huge conversation going hits the budget sooner, which is fair: they cost more.
Write the endpoints for sending a message and receiving the streamed reply. What happens when the connection drops halfway, and what does an overloaded server return?
Compare with a model answer
A POST answered with a server-sent event stream whose chunks have sequence ids, and a resume endpoint that takes the last id seen. A client message id makes a retried POST attach to the existing reply. Overload is 429 with Retry-After.
Define the API
POST /conversations 201 { "conversation_id": "c42" }
POST /conversations/{id}/messages Accept: text/event-stream
body: { "content": "Plan three days in Lisbon",
"client_msg_id": "m-7f3a", retry-safe
"parent_id": "a19" } which reply this follows
200 event stream:
event: start data: { "user_msg_id": "u20", "reply_id": "a21" }
id: 1 event: delta data: { "text": "Day one:" }
id: 2 event: delta data: { "text": " start in Alfama" }
...
event: done data: { "finish": "complete", "tokens_out": 520 }
400 message too long
429 Too Many Requests Retry-After: 30 { "reason": "budget" | "busy" }
GET /replies/{reply_id}/stream Last-Event-ID: 180
200 event stream from chunk 181, or the whole reply if it has finished
POST /replies/{reply_id}/stop stops generation, keeps the text so far
GET /conversations?cursor=... newest first
GET /conversations/{id}/messages?before=...The decision worth arguing is server-sent events over WebSockets. Traffic is one-way while a reply streams; the user's message is an ordinary POST. Server-sent events are plain HTTP, pass through existing proxies and load balancers, and define resume: a reconnecting client sends Last-Event-ID. A WebSocket buys two-way traffic you do not need. Real-time communication compares the options.
Sketch the records for conversations and messages. How do you represent "regenerate this reply" and "edit my earlier message", and where do tokens live while a reply is still streaming?
Compare with a model answer
Messages form a tree: a regenerated reply or edited message is a new sibling under the same parent. Tokens in flight live in a short-lived stream buffer; the finished reply is stored once.
Model the data
conversations -- partition key: user_id
user_id, conversation_id PRIMARY KEY
title TEXT
updated_at TIMESTAMP -- list newest first
summary TEXT NULL -- of older turns, see deep dives
summary_upto message_id NULL
current_leaf message_id -- the path being shown
messages -- partition key: conversation_id
conversation_id, message_id PRIMARY KEY -- time-ordered ids
parent_id message_id NULL
role VARCHAR(10) -- user | assistant
content TEXT
tokens INT
status VARCHAR(10) -- streaming | complete | stopped | blocked | failed
usage -- one row per user per day
user_id, day, tokens_in, tokens_outAccess patterns: append a message, read a conversation in order, list a user's conversations by recency. No joins, append-only writes, and 110 TB a year needs partitioning: a wide-column store (such as Cassandra) keyed by conversation fits, the same choice as in Design a Chat App. The stream buffer is separate: an in-memory log per reply (a stream in a store such as Redis), with a 10-minute expiry after the reply finishes.
Sketch the boxes between Maya's phone and the GPUs. Where are budgets checked, where is the context built, what decides which GPU replica gets the request, and where do tokens go on their way back?
Compare with a model answer
A gateway checks the token budget; a chat service saves the message, builds the context and runs safety checks; an inference router queues by tier and places requests on replicas with free slots; tokens return through a stream buffer.
High-level design
The chat service does the product work and owns each reply. The inference router keeps the GPUs full without letting the queue grow past what users will wait for. Model servers write tokens into a buffer; the chat service reads, checks and forwards them.
- budget left
- 16,000
1Maya, a free user, sends a message in conversation c42. The gateway reserves 3,000 tokens from her daily 25,000: the context estimate plus the 1,000-token reply cap.
- budget left
- 16,000
2The chat service saves her message first, keyed by
client_msg_id, so it is never lost and a retried POST finds it.- context
- 2,100 tokens
3It builds the context: system instructions, the stored summary of older turns, and the recent turns. 2,100 tokens.
- context
- 2,100 tokens
4A fast input classifier checks the message before any GPU time is spent. It passes.
- context
- 2,100 tokens
- queue wait
- 0.3 s
5The router queues it as free tier. The replica that served c42 last probably still holds its prompt prefix and has a free batch slot, so it goes there.
- first words
- 0.8 s
- chunks sent
- 42
6The replica reads the prompt in one pass, then generates. Numbered chunks land in the buffer; the chat service checks each sentence and forwards it. First words after about 0.8 s.
- client saw
- 180
- generated
- 230
7Maya's phone switches networks at chunk 180 and the connection drops. The replica keeps writing to the buffer.
- client saw
- 230
- generated
- 230
8The phone reconnects with
Last-Event-ID: 180, possibly to another chat server. That server replays 181 to 230 from the buffer, then follows live chunks.- tokens
- 2,100 in + 520 out
- budget left
- 16,380
9Done at 520 tokens. The reply is saved once, the budget is settled at 2,620 actual tokens so 380 return to Maya, and the buffer expires in 10 minutes.
10Another reply: its replica dies at token 300. The router takes those 300 tokens from the buffer and sends them with the prompt, ahead of new requests, to another replica, which continues from token 301. The user sees a pause, not a restart.
11Peak hour: the router reports that the free queue's expected wait has passed 10 seconds. The gateway stops admitting new free requests with 429 and a retry time; paid requests keep flowing.
Deep dives
Interviewers push hardest on GPU capacity, then streaming, context and safety.
Streaming that survives reconnects
The key move is to separate generating a reply from delivering it. If the reply lived only in the HTTP connection, every drop would lose a half-finished answer and the GPU time spent on it.
- Generation writes numbered chunks into the stream buffer for that reply, whoever is listening.
- Delivery is any chat server reading the buffer from the sequence number the client last saw.
- Retries: a
POSTretried with the sameclient_msg_idattaches to the existing reply's stream, so one message never produces two replies (see idempotency). - Stop: an explicit stop frees the batch slot at once. A disconnect does not: the reply is capped at 1,000 tokens, and users expect it when they come back.
If the owning chat server crashes mid-reply, the buffer still has every chunk; another server sees the reply stuck in streaming with no heartbeat and finishes it as a continuation, like a lost replica.
GPU capacity: batching, admission and tiers
You cannot autoscale out of a peak: new GPU capacity takes minutes to start and may not exist. So the design manages a fixed pool with four mechanisms.
Continuous batching. Each step, a replica produces one token for every stream in its batch, so 40 streams cost little more per step than one. A fixed batch waits for its longest reply, leaving finished slots idle; continuous batching adds requests between steps and frees a slot the moment a reply ends. The limit is memory: each stream keeps per-token state (the attention key/value cache), so an 8,000-token conversation takes about four times the room of a 2,000-token one. Long contexts mean fewer streams per replica. A long prompt is read in pieces between generation steps so it does not stall the batch.
Admission control. The router keeps a queue per tier and estimates the wait as queued tokens ÷ pool throughput. When the free tier's estimate passes 10 seconds, the gateway rejects new free requests with 429. A fast "busy, try in 30 seconds" beats a spinner that times out after the GPU time was spent.
Rate limits counted in tokens. A per-user token bucket filled with tokens, not requests, is fair: one 8,000-token request costs what four 2,000-token ones do. The output length is unknown at the start, so reserve input plus the output cap, then refund the difference, as in the tracer.
Priority tiers. The router fills free slots from the paid queue first, with a fixed share kept for free requests so they are slowed but not starved. Under overload the free tier is shed first.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Queue everything | Nobody is refused. | Waits grow without limit; users give up and resend, adding load. | Never as the only control. |
| Shed by tier when the wait is too long Usual default | Load stays at what the pool can serve; paid users are protected; fast, clear errors. | Free users see refusals at peak. | The usual answer for a product with tiers. |
| Degrade the work | Everyone gets something: shorter replies or a smaller, cheaper model. | Quality drops without the user choosing it. | Combined with shedding, for the free tier at peak. |
Building the context
The model remembers nothing between requests: each one carries the instructions and the conversation so far, all inside a limited context window. Even when the window is large, longer prompts cost more, delay the first word, and can make answers worse (context rot). So set a budget per request, here 8,000 tokens: system instructions 1,000, the reply cap 1,000, a summary up to 500, and recent turns in the remaining 5,500.
A 40-turn conversation at about 600 tokens a turn is 24,000 tokens, so most of it cannot be sent. Older turns are covered by a rolling summary. When the recent turns pass about 4,500 tokens, well before the 5,500 limit, a background job folds the oldest 2,000 tokens of them into the summary with a small model and stores it with the id of the last message covered. Retrieval over old turns, as in Design a RAG System, can recover a detail the summary dropped; context engineering covers the prompt side.
Order matters for cost. A model server can reuse work done on a recent prompt prefix, but only if it matches token for token from the start. Put fixed instructions first, then the summary, then the turns. Between summary refreshes the cut point stays fixed and turns are only appended, so consecutive requests share a long prefix. The router sends a conversation back to the replica that served its last turn (consistent hashing on the conversation id, with a fallback when it is full) so that prefix is not read again. Caching whole replies rarely pays: two contexts almost never match after the first turn.
Safety in the path
There are two checks, placed for cost and latency (see guardrails):
- Input, before the queue. A small classifier checks the message; a blocked request costs no GPU time. Pasted text can carry instructions aimed at the model, so treat it as data (see prompt injection).
- Output, while streaming. Checking only the finished reply means showing unchecked text for 10 seconds; checking every token is too slow. Hold each sentence until it passes, a fraction of a second of delay. Mark passed chunks in the buffer, so a resumed stream replays only checked text. If a later sentence fails, stop generation and send a
retractevent so the client replaces what it showed.
If the safety service is down, fail closed: pause new generations rather than ship unchecked output. The cost: a classifier outage becomes a product outage, so it needs the same redundancy as the model servers.
Bottlenecks and failures
At 10x, 35,000 messages a second at peak, the web tier and store scale out as usual. The GPU pool needs about 9,000 replicas, which you may not be able to buy or power. The controls above decide who waits; beyond them, the levers are fewer tokens per reply, smaller models for more requests, and sending load to regions with spare capacity at night.
| When this dies | What happens | What you do |
|---|---|---|
| A GPU replica | Its 40 streams stop mid-reply. | The router resends each prompt plus the tokens so far, read from the buffer, to other replicas ahead of new requests. |
| A chat server | Its open client connections drop. | Clients resume on another server; orphaned replies are finished from the buffer. |
| The stream buffer | In-flight chunks are lost; resume fails. | Replicate it; at worst, mark those replies failed so users regenerate. Finished replies are safe. |
| The safety service | New generations pause (fail closed). | Run it redundantly, like the model servers. |
| The conversation store | New messages cannot be saved. | Refuse new messages rather than answer and lose them; history is replicated. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | A web server calls the model and returns the answer; history in a database; streaming once prompted. |
| Mid | Streams with server-sent events, stores conversations, limits request rates, truncates long history, and puts the model behind a queue. |
| Senior | Counts in tokens, sizes the GPU pool from arithmetic, separates generation from delivery for resume, explains batching and its memory limit, sheds load by tier, budgets the context with prefix reuse in mind, and places safety checks by cost. |
It's peak hour and GPUs are full. What happens?
Autoscale the GPU servers and put a queue in front.
It's peak hour and GPUs are full. What happens?
The pool is fixed for the next few minutes, so I decide who waits. The router queues by tier and estimates the wait from queued tokens. Past 10 seconds for free users, the gateway returns 429 with a retry time, and paid users keep their share. Budgets are in tokens, so one user with huge prompts can't crowd out others.
Why tokens and not requests?
Because cost scales with tokens. An 8,000-token request uses the GPU like four 2,000-token ones, and holds four times the memory while it runs.
Follow-up questions
How do you measure whether you are meeting the latency goal?
Track time to first token and tokens per second per stream, split into queue wait and model time, per tier. Rising queue wait with flat model time means you need capacity or shedding, not faster code.
How does "regenerate" work?
It creates a sibling of the old reply with the same parent and runs the same context again. The conversation's current leaf points to the new one; the old one stays reachable. Sampling makes the new reply differ (see sampling and temperature).
How would you add memory across conversations?
Keep a small set of facts per user, extracted after conversations, and add relevant ones within the context budget. Users must be able to see and delete them (see memory).
A user deletes a conversation. What has to happen?
Delete it from the store, expire its buffer, and drop any summary or memory built from it. Logs and backups follow a retention policy you should state.
How do you roll out a new model version?
Run it on a slice of replicas, send it a small share of traffic, and compare evals, safety rates and latency before shifting more.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".