03 Β· System Design
Designing LLM-era systems (your moat)
Most candidates at your level have never operated an LLM system in production. You run one at 2M+ requests/month for 50+ enterprise clients. When the question is open-ended, steer here β these four designs are your strongest territory, and the first one is literally your job.
Β§0Steer the interview onto your turf
Interviewers routinely leave the door open: "design an API platform", "design something interesting", "design a system you know well". Walk through that door deliberately. The universal line:
| If the interviewer asks⦠| Pivot to | Your opening line |
|---|---|---|
| "Design an API gateway / API platform" | (a) LLM gateway | "This is essentially my day job β let me design the gateway I actually run, from scratch." |
| "Design a billing / usage-metering system" | (a), billing deep-dive | "The hardest metering I know is token billing for LLMs β can I use that as the concrete case?" |
| "Design something interesting / a system you know well" | (c) Sentinel | "I built an agentic workflow system in production β Sentinel. Let me design that class of system." |
| "Design search / 'chat with your docs' / a Q&A product" | (b) RAG pipeline | "This is really a search system with an LLM at the end β and I run production search infrastructure (OpenSearch)." |
| "Design Zapier / a notification or automation platform" | (d) Webhook platform | "I consume Stripe/Razorpay webhooks in production and run the queue-worker architecture this needs β let me build the platform side." |
| Anything vague or open-ended | whichever fits | "Can I take this in the direction of LLM serving? It's what I run in production, so I can go unusually deep." |
(a)LLM API Gateway / Proxy
Requirements
Functional: one API for chat/completions across multiple upstream providers; per-tenant API keys + auth; per-tenant rate limits and quotas; token-based usage metering β billing; streaming responses (SSE); provider fallback/routing; per-tenant config (allowed models, spend caps). Out of scope: training/hosting the models.
Non-functional: gateway overhead β€ a few ms (the LLM call itself is 1β30 s β don't be the slow part); billing metering must be accurate and never lost (money); high availability (every tenant's traffic flows through you); tenant isolation (one tenant's burst can't starve others).
Estimation β the insightful inversion
2M req/month β 0.8 QPS avg, ~5-10 peak β compute is NOT the problem.
What IS: Β· long-lived connections (streams held open 5-60s
β connection slots, not CPU, are the scarce resource)
Β· per-request COST (an LLM call can cost real money β
metering errors compound directly into revenue errors)
Β· provider rate limits & outages (external, uncontrollable)
Billing events: 2M/month β trivial volume, but LOSS-INTOLERANT.
API
POST /v1/chat/completions (OpenAI-compatible shape β industry lingua franca)
headers: Authorization: Bearer <tenant_api_key>
body: { model, messages[], stream: true|false, max_tokens, ... }
stream: SSE β "data: {delta}\n\n" chunks, terminated by "data: [DONE]"
GET /v1/usage?from=&to= β per-model token counts, cost (tenant-facing)
Admin: CRUD tenants, keys, limits, model allowlists, spend caps
High-level design
50+ tenants
β
v
[ KONG / gateway layer (N replicas on K8s) ]
β 1. authn: API key β tenant (Redis-cached key lookup)
β 2. rate limit: per-tenant token bucket (Redis, atomic Lua)
β 3. spend cap check: tenant balance/quota (cached, async-refreshed)
β 4. route β proxy service
v
[ LLM Proxy service ]
β Β· model β provider routing table (config as data)
β Β· provider adapters (normalize request/response shapes)
β Β· circuit breaker per provider; fallback chain: primary β secondary
β Β· SSE pass-through streaming (no buffering!)
β Β· count tokens: prompt (pre) + completion (from stream/usage field)
βββββββββββββββ> [ Provider A ] [ Provider B ] [ self-hosted models ]
β
ββ after response/stream end:
emit usage event ββ> [ Kafka: "usage_events" ] ββ> [ Billing aggregator ]
{ event_id, tenant, model, β idempotent upsert
prompt_tokens, completion_tokens, v
latency_ms, status, ts } [ Postgres: usage ledger ]
β invoices (Stripe/Razorpay)
Deep dive 1 β token billing that never loses money
Request-count billing is wrong for LLMs: a 10-token and a 100K-token request differ ~10,000Γ in cost. Meter tokens per model (each model has its own price). The durability chain, link by link:
event_id, upsert into Postgres. At-least-once delivery + idempotent aggregation = exactly-once billing effect. Losing an event = giving away money; double-counting = an angry enterprise client. Both unacceptable β hence this pipeline instead of fire-and-forget metrics.Deep dive 2 β SSE streaming quirks
Β· SSE = long-lived HTTP; the gateway must not buffer (disable proxy buffering β the classic misconfiguration that turns a stream into one big blob at the end).
Β· Timeouts must be idle timeouts (no chunk for N seconds), not total-duration β legitimate streams run 5β60 s.
Β· Capacity-plan connection slots, not QPS: thousands of open streams β thousands of requests/second.
Β· Least-connections load balancing beats round-robin when request durations vary 100Γ.
Β· Graceful deploys must drain long-lived streams: K8s
terminationGracePeriodSeconds + preStop.Deep dive 3 β provider fallback & routing (what the stepper showed)
Per-provider circuit breaker (error-rate/latency threshold trips it) + a fallback chain. Subtleties worth naming out loud: the fallback model may differ subtly (tenant opt-in / per-tenant allowlist); retry-after-partial-stream is ugly β if tokens already streamed to the client you can't transparently switch mid-response, so fallback applies to pre-first-token failures; on provider 429s, respect Retry-After and spread across keys/accounts where applicable. Keep routing as data: a model β [provider chain] table, hot-reloaded β which also enables cost-based routing (cheapest healthy provider) as an extension.
(b)RAG Pipeline
Functional: ingest documents (PDF, HTML, wiki, tickets); users ask questions; answers grounded in retrieved sources with citations; per-tenant corpus isolation; incremental updates. Non-functional: query p95 within a few seconds (retrieval <200β500 ms; the LLM call dominates); freshness in minutes, not days; retrieval quality is THE product metric β needs evaluation, not vibes; tenant isolation is a security boundary β retrieval must NEVER cross tenants.
Corpus: 1M documents Γ ~5 chunks avg = 5M chunks.
Embeddings: 5M Γ 1536 dims Γ 4 B β 30 GB of vectors β one beefy vector
index node or a small cluster; this is NOT big data (a common misread).
Query: 100 concurrent users β tens of QPS retrieval β modest.
Ingestion burst: the initial 1M-doc backfill is the heavy day-one load
β queue-driven workers (embedding API rate limits are the bottleneck).
INGESTION (async, queue-driven β this is an ETL pipeline)
[Sources: uploads, connectors, crawlers]
β doc events
v
[ Kafka: "docs" ] β> [ Ingestion workers ]
β 1. parse/extract text 2. CHUNK
β 3. embed chunks (batched) 4. upsert by chunk_id:
βββ> [ Vector index ] (embeddings)
βββ> [ Keyword index (BM25) ] (OpenSearch)
βββ> [ Metadata store (PG): docs, chunks, ACLs ]
QUERY PATH (online)
user query β [ Query service ]
β 1. embed the query
β 2. HYBRID retrieve (parallel): vector top-50 + BM25 top-50 β merge (RRF)
β 3. filter: tenant_id + ACLs (in the index query, NOT post-hoc!)
β 4. RERANK top-50 β top-5 (cross-encoder)
β 5. build prompt: system + top-5 chunks w/ source tags + question
β 6. LLM call β through the gateway from design (a)! β stream answer
v
answer + citations [chunk β doc β link]
Deep dives
Chunking (looks trivial, decides quality). Too big β retrieval imprecise, wastes context tokens; too small β fragments lack context. Practical default: ~300β800 tokens, 10β20% overlap, split on structural boundaries (headings, paragraphs), not blind character counts. Enrich each chunk with metadata (title, section path, URL, updated_at, tenant_id, ACL tags) β that powers filtering AND citations. On doc update: re-chunk with deterministic IDs (doc_id + position + content_hash) β upsert changed, delete orphans. Idempotent by construction β your OpenSearch upsert discipline.
Hybrid retrieval + reranking. Vector search captures semantics ("reset my password" β "credential recovery") but is weak on exact tokens β error codes, product names, IDs. BM25 is the mirror image. Run both, merge with Reciprocal Rank Fusion (rank-based, no score-calibration headaches). Then rerank: first-stage retrieval optimizes recall (50 candidates, cheap); a cross-encoder reads (query, chunk) pairs together for precision on the top handful. Two-stage retrieval = the classic search architecture: cheap recall β expensive precision. Index choice: HNSW-based β pgvector at small scale (vectors next to metadata); "I'd likely use OpenSearch k-NN since I already operate OpenSearch β one system for both BM25 and vectors."
Evaluation (separates you from tutorial-followers). "You can't improve what you don't measure. I'd build a golden set of (question β relevant chunks / reference answer) pairs and track retrieval recall@k and answer faithfulness/groundedness β checkable with an LLM-as-judge, spot-audited by humans." Run the eval suite on every pipeline change (chunk size, embedding model, k, reranker) β treat retrieval config changes like deploys: measure, canary, roll back. Log which chunks fed each answer.
(c)Agentic Workflow System β your Sentinel
Functional: accept a task; agent loop = LLM plans β picks tool β executes β observes β repeats; tool catalog; human approval gates for risky actions; full audit trail; resumability (task survives worker crash β including waiting hours for an approval); concurrent tasks. Non-functional: durable task state; safety > speed (an agent doing the wrong irreversible thing is the worst failure); isolation (tools can run untrusted/generated code); per-step observability; cost caps per task (agent loops can run away).
user/API ββ> [ Task API ] ββ> [ Postgres: tasks, steps, approvals, audit ]
β ^ every state transition recorded
v β
[ Queue: "tasks" ] ββ> [ Orchestrator workers (N) ]
β AGENT LOOP (per step):
β 1. load task state + history from DB
β 2. LLM call: next action? β through
β the LLM gateway (a)!
β 3. needs approval?
β ββ yes β write approval_request,
β β PARK task (state=WAITING),
β β notify approver β¦ resume on event
β ββ no βββ
β 4. execute βΌ in SANDBOX
β [ isolated container: no ambient
β creds, egress allowlist, limits ]
β 5. persist step result (audit) β loop
β guards: max_steps, cost budget, deadline
v
[ Audit log: append-only ]
Deep dives
Durable state machine, not an in-memory loop. Naive agents hold the loop in process memory β crash = lost task, or worse, repeated side effects. Instead: each step is a transaction β load state, decide, execute, persist result, advance. The worker is stateless between steps; any worker can pick up any task β your K8s cattle principle applied to agents. Crash between execute and persist β on recovery you don't know if the tool ran β tools must be idempotent or checkable (execution token per step, tool-side dedup) β the payments-timeout problem wearing a new hat. Say that connection out loud. Approval parking: WAITING_APPROVAL is just a state; the task burns zero compute for hours β durable state machines make "wait for a human" free.
Sandbox + permission model. Generated code is untrusted by definition: isolated container, no secrets in env, scoped short-lived credentials injected per call, egress allowlist, resource + wall-clock limits, workspace torn down after. Tool catalog entries declare a schema (validate LLM output before execution), a permission tier (auto / needs-approval / forbidden), rate limits, and cost β risk policy is data, per-tenant configurable, not code. Approval UX: the approver sees the exact proposed action + arguments + the agent's reasoning β approve / deny / edit-then-approve; a deny feeds back into the loop as an observation so the agent re-plans.
Audit trail & runaway protection. Append-only log of every prompt, response, tool call, result, and approval decision (who/when/what), with token/cost per step. Non-negotiable: debugging non-determinism (the trace IS the explanation), compliance ("why did the agent email that customer?"), and eval/replay (rerun history against a new model offline). Runaway guards: hard max_steps, per-task cost budget enforced via the gateway's metering (design (a) again β the pieces compose), loop detection (same tool + same args N times β halt and escalate).
(d)Webhook-Driven Automation Platform (Zapier-lite)
Functional: per-tenant webhook ingest URLs; trigger β workflow of steps (filters, transforms, actions on third-party APIs); connectors (Slack, email, CRM, HTTP); per-execution run history users can inspect; retries. Non-functional: never lose an inbound event (ingest availability + durability IS the product); process at-least-once, effect at-most-once; tenant isolation (one tenant's 1M-event burst or infinite-loop workflow can't starve others); graceful handling of third-party rate limits; spiky load by nature.
10K tenants Γ avg 1K events/day = 10M events/day β 120/s avg β but bursty
(one tenant's upstream can dump 50K events in a minute).
β ingest tier does ALMOST NOTHING synchronously: validate β enqueue β 202.
Execution: avg workflow 3 steps, each an external API call (100ms-2s)
β concurrency-bound, not CPU-bound β async workers, per-tenant fairness.
3rd-party services
β POST https://hooks.platform.com/t/{tenant}/{hook_id}
v
[ Ingest tier (thin!) ] β THE availability-critical part
β verify HMAC signature (per-hook secret)
β assign event_id, dedupe (Redis, provider event id)
β write to [ Kafka: "events", partitioned by tenant_id ]
β respond 202 in <50ms β NO workflow logic here
v
[ Workflow matcher ] β which workflows subscribe to this hook/event?
β enqueue one execution per matched workflow
v
[ Kafka: "executions" ] ββ> [ Step executor workers ]
β per-step: transform β call connector
β connector rate limits: per-(tenant,connector)
β step fails: retry w/ backoff β after N:
β mark failed, DLQ, notify user
v
[ Run history store (PG): executions, steps,
inputs/outputs (truncated), status ]
Deep dives
Ingest tier as an availability fortress. A missed webhook is unrecoverable unless the sender retries β so this is the part that must never be down. Keep it dumb (verify, dedupe, enqueue, 202), overprovisioned, and independent of the execution tier's health: executors can be fully down and you lose nothing, lag just grows. Separating "accept" from "process" is the whole architecture. Signature verification per hook secret + timestamp tolerance (replay protection) β exactly what you do as a Stripe/Razorpay consumer, now from the platform side.
Per-tenant fairness. One hot tenant floods the shared execution topic β everyone lags. Options: partition by tenant (isolates ordering, but a hot partition remains), per-tenant concurrency caps in workers (simple, effective), or weighted fair scheduling from per-tenant queues (heavier). Start with concurrency caps + rate limits β the bulkhead/noisy-neighbor conversation from your multi-tenant day job.
Loops and runaway workflows. Workflow A's action triggers a webhook that triggers workflow Aβ¦ Detect via execution-chain depth (propagate a chain ID + hop count, cap it), per-workflow execution rate limits, cost/step budgets β the same runaway-containment thinking as the agent system (c).
Exactly-once effects. Provider retries + your retries = duplicates everywhere β dedupe at ingest (provider event ID) AND idempotency keys on connector calls where the target API supports them; where it doesn't: best-effort dedup, and at-least-once documented honestly to users β interviewers respect the honesty about the limits.
Β§5The meta-move
Β§6What interviewers probe β test yourself
(a) "Why not just call the LLM providers directly from each service or client?"
(a) "What if Kafka is down? Do you drop usage events?"
(a) "How do you test billing accuracy?"
(a) "A client disconnects mid-stream. Who pays for the tokens?"
(b) "How do you guarantee tenant A never sees tenant B's documents?"
tenant_id clause enforced at the query-builder layer β never post-hoc filtering after retrieval. Then test it adversarially. Tenant isolation in RAG is a security boundary, not a relevance feature.(b) "Retrieval returns nothing relevant. What does the user see?"
(c) "Your agent worker crashes mid-task. What happens?"
(c) "The agent tries something destructive. Walk me through the defenses."
(c) "Two workers grab the same task?"
(d) "Your executor fleet dies for an hour. What do users experience?"
(d) "Step 3 of a 5-step workflow fails forever. Then what?"