S130 · Design an AI Chat Product — RAG + Agents + Serving
Capstone: designing an AI chat product from the client SSE stream all the way to the LLM serving fleet. Streaming, RAG, agent orchestration, evaluation, safety, cost, and the incidents (ChatGPT title leak, Claude prompt injection, Copilot rate-limit fallout) that shape every mature deployment.
🎯 Design a production AI chat product end-to-end — client streaming, orchestration, RAG, LLM serving, evaluation, safety, cost — and know the specific incidents at ChatGPT, Claude and Copilot Chat that shaped how mature teams build it.
Why this session exists
This is the capstone. Everything in the previous 129 sessions rolls up into one product shape — the AI chat surface that now powers ChatGPT, Claude.ai, Copilot Chat, Perplexity, Notion AI, and every startup pitching "an AI assistant for X." The interviewer wants to see you compose LLM serving, RAG, agents, evaluation, and classic system design into one coherent architecture that survives real traffic, real cost, and real abuse. Doing this well requires holding an unusually long stack in your head — from the client's SSE reader all the way to the KV-cache decisions on a GPU node.
- Draw an end-to-end AI chat architecture in under 10 minutes with streaming as a first-class concern.
- Explain the four production layers on top of chat (RAG · agents · eval · cost) and why each one is non-negotiable.
- Design a per-user rate limit and cost cap that fails gracefully instead of turning into a bill spike.
- Anticipate the three canonical AI-chat incidents (title leak · prompt injection · rate-limit fallout) and design against each.
- Discuss trade-offs across model routing, cache reuse, and streaming middleware without hand-waving.
Prerequisites
- S117 · RAG (retrieval-augmented generation, embeddings, vector DB)
- S120 · LLM Agents (tool use, ReAct loop)
- S122 · LLM Evaluation (LLM-as-judge, offline vs online)
- S124 · LLM Serving (KV cache, batching, speculative decoding)
- S128 · Chat System (WebSockets, message ordering, fanout)
(a) Intuition · 5 min
Think of a customer-support chat where you sit between a customer and a specialist. The customer types; you read the history, look things up in the knowledge base, sometimes make a phone call to a partner, and stream the answer back one sentence at a time so the customer knows you are working.
You are the orchestrator. The specialist is the LLM. The knowledge base is retrieval. The phone calls are tool use. The reason you speak in sentences instead of waiting to finish the whole answer is streaming — perceived latency matters more than total latency.
Architecturally: a chat surface (WebSocket or SSE) on top; an orchestrator in the middle that assembles context, calls tools, and manages the turn; a retrieval layer that grounds the model in your data; an LLM serving tier that generates tokens; and an evaluation + safety layer that watches everything.
Every layer of a normal chat system (S128) still applies. What is new is that the "compute" per turn is 100–1000× more expensive, so cost, rate limiting, caching and model routing become first-class design concerns instead of afterthoughts.
- Streaming is not optional — every layer of the stack must forward tokens as they are produced. One buffering middlebox destroys the product.
- Cost per turn is 100–1000× a normal API request. Rate limiting and per-user budgets are load-bearing, not decoration.
- The LLM call is the smallest part of the design. Retrieval, orchestration, evaluation, safety, tracing, feedback each carry equal weight.
- Model routing (cheap model for easy turns, flagship for hard ones) is where most cost wins actually come from — not from tuning the flagship.
- 2022ChatGPT launchesFirst mainstream AI chat product. Establishes streaming SSE + turn-based memory as the standard UX.
- 2023ChatGPT conversation-title leak (Feb)A caching-library race condition briefly exposed other users' conversation titles on the sidebar. Redis client bug, not a model bug — but the incident reshapes how AI chat products isolate per-user state.
- 2023GitHub Copilot Chat GAFirst mass-deployed IDE chat product. Introduces per-user rate limiting that fails gracefully with a clear message rather than 429 storms.
- 2024Prompt injection becomes mainstream threatIndirect prompt injection via retrieved web pages hits Claude, ChatGPT and Copilot. Industry converges on system-prompt isolation + output filtering + user-visible provenance.
- 2024Model routing goes mainstreamFrontier products (Claude, Copilot, Perplexity) publicly ship cheap-model routers for easy turns and reserve flagship models for hard ones. Cost per turn drops 40–70%.
- 2025Multi-modal + agent-as-a-turnChat turns can now include images, files, browser actions and multi-step tool calls. Orchestrator becomes the hardest tier in the stack.
(b) Visual walkthrough · 15 min
The full stack
One turn, timed
User types; browser POSTs the turn and immediately opens the SSE stream. API tier accepts and passes through.
Orchestrator fetches last N turns from Postgres. Cap the history to a budget (e.g. last 20 turns or 8k tokens) — never send everything.
Cheap model classifies: chit-chat, knowledge query, tool needed? Skips RAG entirely for chit-chat.
Embed the query, top-k from vector DB (~30 ms), cross-encoder re-rank top-10 → top-3 (~100 ms).
System prompt + retrieved docs + history + user turn. Enforce the token budget hard — truncate history before retrieved docs.
Flagship model streams tokens through the orchestrator, API, gateway, and out to the browser. First token in ~400 ms; then 40–100 tok/s.
Fire-and-forget to LangSmith for trace, run background hallucination + PII check, persist the completed turn.
Why layered on top of chat — extra tiers that carry weight
Anatomy of the AI chat stack — top to bottom
Model-routing decision — cheap vs flagship
Simple, expensive, predictable quality.
- Cost per turn ~$0.02–0.10.
- No routing failures to debug.
- Latency dominated by the flagship — 400 ms first-token minimum.
- Fine for early-stage products; unsustainable at 1 M+ DAU.
Cheap model classifies; only hard turns escalate.
- 40–70% cost reduction at similar quality.
- Router accuracy is the new metric to monitor.
- Failure mode: router misclassifies hard turns as easy → visible quality regression.
- Add a user-facing 'ask deeper' button as a safety valve.
Cheap drafts, flagship verifies (S124).
- Latency win as well as cost.
- Highest engineering complexity.
- Typically reserved for very high-QPS products.
- Requires both models trained/aligned similarly to keep acceptance rate high.
Cost + rate limit as first-class boxes
At $0.02–0.10 per turn, one abusive user can burn thousands of dollars per hour if you rely only on request-count rate limits. Every mature AI chat product ships both:
- Requests-per-minute (protect the fleet).
- Tokens-per-day per user (protect the budget).
- Escalation ladder — soft warn at 80% of quota, hard cap at 100%, admin override for enterprise.
"An AI chat product is a web app with an LLM call in the handler. The architecture is the same as any request/response service — I just swap the backend."
The response takes seconds rather than milliseconds, streams token by token, costs real money per request, is non-deterministic, and can fail by producing confident nonsense that returns HTTP 200. Every one of those breaks an assumption ordinary web architecture is built on: timeouts, retries, caching, load shedding, error handling and cost attribution all need redesign.
Because the API surface genuinely is an HTTP call, so the integration looks trivial and the prototype works in an afternoon. The differences surface only under load and over time — the p99 latency that blows your gateway timeout, the retry storm that doubles your bill, the incident where quality degraded for a week and no alert fired because every response was a 200.
Audit your assumptions against the ones a normal service makes:
assumption normal service LLM service
------------------- ------------------ ---------------------------
latency 10-100 ms 2-60 s, high variance
response shape complete streamed, partial-first
cost per request ~0 (amortised) cents, varies with length
determinism yes no
failure signal 5xx / exception 200 with wrong content
retry safety idempotent doubles cost, may differ
cache hit rate high near zero on raw prompt
timeout budget fits in gateway usually exceeds itEvery row where your current design assumes the left column is a defect waiting for production traffic.
Why must an AI chat product stream tokens, when the total time to produce the answer is identical either way?
- 1Total generation time is roughly TTFT plus (output tokens × time per token), and streaming changes neither term.forced by · the model generates autoregressively regardless of whether you buffer the output
- 2But perceived wait is not total time — it is time until the user receives evidence that something is happening and can begin consuming the result.forced by · users experience responsiveness, and reading is itself time-consuming
- 3Human reading speed is far slower than generation speed for a competent serving stack, so once the first tokens arrive the user is never actually waiting — the model stays ahead of them.forced by · if tokens arrive faster than they are read, the queue in front of the user never empties
- 4Therefore the only wait a user genuinely experiences is TTFT, and a 30-second full response with a 1-second TTFT feels dramatically faster than a 10-second buffered one.forced by · the buffered version has a 10-second wait with zero feedback; the streamed one has a 1-second wait and then continuous progress
- 5Streaming also enables early cancellation: the user sees the answer going wrong and stops it, which saves the remaining generation cost — a real economic effect, not just UX.forced by · tokens not generated are tokens not paid for
Therefore streaming optimises the metric that actually matters (TTFT and perceived progress) rather than the one that is easy to measure (total latency), and it reduces cost as a side effect.
And note what this predicts: streaming forces architectural consequences all the way down. You cannot validate or moderate an output you have already sent, so safety filtering must run on partial output or accept a redaction-after-display model. You cannot return a clean HTTP error mid-stream, so failures need in-band error events. And connection-level retries become impossible once bytes have been sent, which pushes retry logic into the client. Every one of these is a direct consequence of the decision to stream.
The LLM call is one stage in a pipeline, and it is the stage that is slow, costly and unreliable. Everything around it exists to compensate: retrieval supplies grounding, caching avoids the call, guardrails bound the output, evals detect drift, and observability attributes cost.
The design question is never "how do I call the model" — it is "how much work can I keep out of the model, and how do I contain what remains".
- Cache at the semantic level, not the string level. Exact-match caching on raw prompts almost never hits; embedding-similarity caching on normalised queries does.
- Every request needs a cost and token budget attached, enforced per user and per tenant. Unbounded conversation history is unbounded spend.
- Conversation history must be summarised or windowed, since context grows with every turn and cost grows with context. Decide the policy before launch, not after the first long thread.
- Log the full trace — prompt, retrieved context, model version, parameters, output, latency, cost. Without it you cannot debug a bad answer, and bad answers are your primary failure mode.
Fire this model the moment you see: an LLM call inside a synchronous request handler · no per-user rate or cost limit · a gateway timeout shorter than p99 generation time · retries on a non-idempotent expensive call · a quality regression discovered by a customer rather than an alert.
How is conversation context managed as a chat grows long — full history, sliding window, or running summary?
Hybrid in practice: keep recent turns verbatim, maintain a summary of older ones, and — most importantly — extract durable facts (preferences, constraints, identity) into structured memory outside the conversation entirely. Structured memory does not degrade with repeated summarisation, and it can be shown to the user and corrected, which no prose summary can.
The general lesson is that context is a budget, and budgets require an explicit allocation policy. Systems that let history grow until something breaks are choosing a policy by default — and the policy they chose is "fail at the worst possible moment, on the longest and most engaged conversations you have".
(c) Hands-on · 25 min
Below is a minimal but honest orchestrator turn — SSE streaming to the client, a cheap-model router, RAG, and a flagship call with a hallucination check kicked off async. Read the anatomy after; then run the modification prompt.
"""
Minimal AI chat orchestrator — one turn, honest streaming.
Deps: fastapi uvicorn openai qdrant-client tiktoken
Run: uvicorn s130_chat:app --reload --port 8000
"""
from __future__ import annotations
import asyncio
import json
import os
import time
from typing import AsyncIterator
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import StreamingResponse
from openai import AsyncOpenAI
from qdrant_client import AsyncQdrantClient
app = FastAPI()
openai = AsyncOpenAI(api_key=os.environ["OPENAI_API_KEY"])
vdb = AsyncQdrantClient(url=os.environ.get("QDRANT_URL", "http://localhost:6333"))
FLAGSHIP = "gpt-4o"
CHEAP = "gpt-4o-mini"
DAILY_TOKEN_CAP = 200_000 # per user
SYSTEM_PROMPT = "You are a helpful assistant. Cite sources when the answer comes from retrieved context."
# --- fake in-memory user state; replace with Redis in prod --------------------
user_tokens_today: dict[str, int] = {}
conversations: dict[str, list[dict]] = {}
async def route(user_msg: str) -> str:
"""Cheap model classifies whether we need RAG. Returns 'rag' or 'direct'."""
r = await openai.chat.completions.create(
model=CHEAP,
messages=[
{"role": "system", "content": "Reply with exactly one word: 'rag' if the user's question likely needs external facts about a specific company, product, doc or event; 'direct' otherwise."},
{"role": "user", "content": user_msg},
],
max_tokens=2,
temperature=0,
)
return r.choices[0].message.content.strip().lower()
async def retrieve(user_msg: str, k: int = 3) -> list[str]:
"""Embed + vector-DB top-k. Skip re-ranker here for brevity."""
emb = (await openai.embeddings.create(model="text-embedding-3-small", input=user_msg)).data[0].embedding
hits = await vdb.search(collection_name="docs", query_vector=emb, limit=k)
return [h.payload["text"] for h in hits]
def enforce_budget(user_id: str, projected: int) -> None:
used = user_tokens_today.get(user_id, 0)
if used + projected > DAILY_TOKEN_CAP:
raise HTTPException(status_code=429, detail="Daily token budget exceeded")
async def stream_turn(user_id: str, user_msg: str) -> AsyncIterator[str]:
enforce_budget(user_id, projected=1_000) # rough upper bound per turn
route_decision = await route(user_msg)
yield sse({"event": "route", "value": route_decision})
context_blocks: list[str] = []
if route_decision == "rag":
context_blocks = await retrieve(user_msg)
yield sse({"event": "sources", "count": len(context_blocks)})
history = conversations.setdefault(user_id, [])
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
if context_blocks:
messages.append({"role": "system", "content": "Context:\n\n" + "\n---\n".join(context_blocks)})
messages.extend(history[-20:]) # cap history
messages.append({"role": "user", "content": user_msg})
reply_parts: list[str] = []
completion_tokens = 0
async with openai.chat.completions.stream(
model=FLAGSHIP, messages=messages, max_tokens=800
) as stream:
async for event in stream:
if event.type == "content.delta":
reply_parts.append(event.delta)
completion_tokens += 1
yield sse({"event": "token", "value": event.delta})
reply = "".join(reply_parts)
history.append({"role": "user", "content": user_msg})
history.append({"role": "assistant", "content": reply})
user_tokens_today[user_id] = user_tokens_today.get(user_id, 0) + completion_tokens
# kick off async hallucination check — never blocks the response
asyncio.create_task(post_turn_eval(user_id, user_msg, reply, context_blocks))
yield sse({"event": "done", "tokens": completion_tokens})
async def post_turn_eval(user_id: str, q: str, a: str, ctx: list[str]) -> None:
"""LLM-as-judge hallucination check. Runs after the response ships."""
if not ctx:
return
verdict = await openai.chat.completions.create(
model=CHEAP,
messages=[
{"role": "system", "content": "Reply with 'ok' if the assistant answer is supported by the context; 'suspect' otherwise."},
{"role": "user", "content": f"Context:\n{ctx}\n\nQ: {q}\nA: {a}"},
],
max_tokens=2,
temperature=0,
)
tag = verdict.choices[0].message.content.strip().lower()
# emit to your observability sink — LangSmith / Phoenix / Datadog
print(f"[eval] user={user_id} verdict={tag}")
def sse(payload: dict) -> str:
return f"data: {json.dumps(payload)}\n\n"
@app.post("/chat")
async def chat(request: Request):
body = await request.json()
return StreamingResponse(
stream_turn(body["user_id"], body["message"]),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)Anatomy of the orchestrator — line-by-line signal
# sketch — extend the orchestrator with a soft-warn SSE frame
PRICE_PER_1K = {"gpt-4o": {"in": 0.005, "out": 0.015},
"gpt-4o-mini": {"in": 0.00015, "out": 0.0006}}
DAILY_USD_CAP = 5.00
user_cost_today: dict[str, float] = {}
def usd(model: str, in_tok: int, out_tok: int) -> float:
p = PRICE_PER_1K[model]
return in_tok / 1000 * p["in"] + out_tok / 1000 * p["out"]
def check_cost(user_id: str) -> tuple[str, float]:
spent = user_cost_today.get(user_id, 0.0)
if spent >= DAILY_USD_CAP:
return "quota_hard", spent
if spent >= 0.8 * DAILY_USD_CAP:
return "quota_warn", spent
return "ok", spentuser_tokens_today to user_cost_today in USD using per-model pricing. When a user crosses 80% of a daily USD cap, emit an SSE quota_warn event before the tokens start streaming. When they hit 100%, refuse the turn with a friendly quota_hard event instead of raising 429. Watch how much nicer the client-side UX becomes when quota is a data event, not an HTTP error.(d) Production reality · 15 min
On 20 March 2023 a subset of ChatGPT users briefly saw other users' conversation titles appearing in their sidebar. In some cases, payment metadata (first name, last four of card, expiry) was also exposed on the account page.
Root cause was a bug in the redis-py asyncio client — a canceled request could leave a broken connection in the pool, and the next user of that connection could see data intended for the previous user. The fix required temporarily taking ChatGPT offline.
OpenAI patched the client, added additional isolation between requests, and improved logging for cache lookups. The public post-mortem became a widely-cited example of how a "boring" infra dependency (Redis client) can cause an AI-chat privacy incident.
Design lesson: any shared cache in front of an AI chat product must isolate per-user state defensively. Assume the underlying client will occasionally leak; add a per-user key prefix + assertion on read.
Prompt injection matured from a curiosity to a mainstream attack. Retrieved documents — web pages, PDFs, Slack messages — could carry hidden instructions that hijacked the assistant into exfiltrating data, spamming tool calls, or ignoring the system prompt.
Claude, ChatGPT, and Copilot Chat all shipped fixes independently across 2024 as public red-team demonstrations kept succeeding.
The industry converged on a stack of mitigations: (1) system-prompt hardening that explicitly instructs the model to ignore instructions inside retrieved content; (2) output filtering that scans for suspicious actions (mass exfiltration, unusual tool sequences); (3) user-visible provenance so the person sees which document a claim came from; (4) fine-tunes and constitutional AI updates that specifically resist injection patterns.
Anthropic's Constitutional Classifiers and OpenAI's Instruction Hierarchy are the current published research answers.
Early releases of Copilot Chat used aggressive per-request rate limits that returned HTTP 429 with no context. Users in the middle of a coding flow saw "an error occurred" and lost trust; support tickets spiked.
Under the hood, one long conversation with heavy tool use could burn a user's quota in a single session, with no warning.
GitHub reshaped the rate limit into a per-user token/cost budget with (a) a soft warning surfaced in the chat itself around 80% of quota, (b) a hard cap that returned a specific, actionable message ("You've reached today's Copilot Chat limit; resets at 00:00 UTC") and (c) enterprise overrides for paid customers.
The 429 storm went away because the failure mode was communicated inside the product surface, not as an HTTP status.
Common failure modes
Where the whole 130-session plan lands
The scoring rubric interviewers use for THIS problem
Draws the LLM box, adds RAG when reminded.
- Treats the LLM call as the whole design.
- No streaming discussion.
- Cost / rate limit not mentioned unless prompted.
End-to-end with streaming as a first-class concern.
- Names SSE + no-buffering path explicitly.
- Adds cheap-model router with a quality-monitor plan.
- Discusses per-user token/cost caps as data events.
- Handles at least one deep-dive (RAG, safety, or serving) three layers deep.
Anticipates incidents; names the whole feedback loop.
- Discusses cache isolation with the ChatGPT title-leak incident as prior art.
- Designs against prompt injection with a defence stack, not one control.
- Names the feedback loop from thumbs-up/down into fine-tune / RLHF.
- Discusses on-call, cost SLOs, and org implications.
(e) Recall + stretch · 10 min
Explain-out-loud test
If you cannot teach these three things to a friend without notes, redo the session:
- Why streaming is mandatory — and one specific middlebox that silently breaks it.
- The ChatGPT title-leak incident — one paragraph on cause and design lesson.
- The two-tier model routing pattern — the design and the one new metric you must monitor.
Capstone session · Part of a 130-session evergreen learning series. Session structure: (a) intuition · (b) visual walkthrough · (c) hands-on · (d) production reality · (e) recall + stretch. Duration: 90 minutes.