"设计 ChatGPT" 已悄悄成为最常见的系统设计面试题之一,而且是个好题:它看着像聊天应用,但有意思的部分跟聊天应用毫无关系。响应不是你从数据库里读出的一行数据,而是由 GPU 上的模型一个 token 一个 token 生成的——每个请求都实打实花钱、要好几秒(不是毫秒),还会以 CRUD 服务永远不会有的方式出错或乱来。本文把整套设计从头走一遍:收敛需求、估算负载、请求路径、流式、会话存储、推理层、LLM 网关、记忆与检索、安全、成本,以及面试官一定会追问的取舍。
- 决定性约束就是这个响应:慢、流式、贵。 一条回复要好几秒、逐 token 产生,所以整个架构都在优化 首 token 时间(TTFT) 和流式,而不是请求/响应延迟。
- 流式是核心体验 —— 用 Server-Sent Events(SSE)边生成边把 token 推给客户端;连接在一条回答期间一直开着。
- 把无状态 API 层和 GPU 推理层分开。 二者扩缩的维度完全不同(廉价 CPU 机器 vs 稀缺、昂贵的 GPU),失败模式也不同。
- 模型是无状态的;上下文每轮重建。 所谓"记忆"就是 harness 每次请求时把会话历史(加上检索到的事实)重新塞进有限的上下文窗口。
- 在推理前面放一个网关,统一做路由、每用户限流与 token 配额、prompt 缓存、以及 provider/模型降级。
- 成本和容量由 GPU 主导。 连续批处理、KV 缓存复用、量化、把简单查询路由到小模型,是把成本压下来的几个杠杆。
- 它可能产出有害或错误内容,所以审核、滥用限制、评测是一等公民,不是事后补丁。
聊天助手 = 一个流式前门(SSE)+ 无状态 API 层,背后是被 LLM 网关挡在前面的 GPU 推理层。会话存在普通数据库里;"记忆"就是每轮在上下文窗口内重发历史,可选地用检索(RAG)增强。难点全都来自一个事实——回答是在 GPU 上慢且贵地生成的——这逼出了流式、连续批处理、缓存、配额和精细的成本控制。安全与评测和请求路径并行存在。
第 1 步 —— 收敛需求
照例先界定范围。面试官想看你把聊天产品和 LLM 底层管道分开,并决定什么在范围内。一个聚焦的集合:
功能需求
- 用户在一个会话里发消息,得到模型生成的回复,并边生成边流式返回。
- 会话是持久且多轮的 —— 助手记得同一线程里更早的消息。
- 用户可以列出历史会话、继续、重命名、删除。
- 用户可以中途停止生成并重新生成回答。
- (进阶)模型可以调用工具(联网搜索、代码执行)并基于检索到的文档作答。
非功能需求
- 低首 token 时间(TTFT) —— 用户应在约 1 秒内看到文字;整段回答花好几秒没关系,只要在流式。
- API/聊天层高可用;GPU 容量打满时优雅降级(排队,或退到小模型)。
- 规模:数百万日活、数万并发的进行中生成。
- 成本控制 —— GPU 推理是大头,设计必须主动管控。
- 安全 —— 过滤滥用输入与有害输出;按用户限额。
开口就把模型当成一个"慢、按 token 计费、流式吐字的黑盒"。这一句定调会驱动后面每个决定——流式传输、批处理、配额、缓存——也表明你懂它和设计一个消息应用的本质区别。
第 2 步 —— 容量估算
粗略数字让设计落地,而且这里恰好暴露了为什么 GPU 是大头。假设 1000 万日活,每人约 10 条消息 → 1 亿消息/天 ≈ 平均约 1,200 条/秒,峰值算 约 5,000 条/秒。每条回答平均算 500 个输出 token。
- token 吞吐: 5,000 req/s × 500 token ≈ 峰值 250 万输出 token/秒。这——而不是 QPS——才是真正的负载指标,是 GPU 必须产出的量。
- 并发: 若一条回答约 5 秒生成,峰值并发生成 ≈ 5,000 × 5 = 约 2.5 万条进行中的流。每条都占着一个开着的连接,加上 GPU 上它那份 KV 缓存的显存。
- GPU 估算: 若单 GPU(带批处理)能撑约 2,500 输出 token/秒,峰值大致需要 250 万 / 2,500 ≈ 1,000 块 GPU —— 这就是头号成本。(数字仅作示意;真实吞吐取决于模型大小、批处理与硬件。)
- 存储: 1 亿消息/天 × 约 1KB ≈ 100GB/天 的会话文本 —— 跟算力比微不足道。这里存储很便宜;算力才是稀缺资源。
要明确说出的结论:这是个算力受限系统,不是存储或 QPS 受限的系统。 大部分架构的存在,都是为了把那约 1,000 块 GPU 用好。
第 3 步 —— 高层架构
把系统拆成无状态应用层(扩缩便宜,负责鉴权、会话、流式连接)和 GPU 推理层(稀缺、昂贵,前面挡着网关和队列)。把这两层分开,是最重要的结构性决定。
客户端 ──SSE──▶ API 网关 / 负载均衡
│
▼
聊天服务(无状态)
├─ 鉴权、限流检查
├─ 加载会话历史 ◀── 会话库
├─ (可选) 检索上下文 ◀── 向量库 (RAG)
├─ 审核输入
└─ 拼 prompt ──▶ LLM 网关
├─ 按模型/档位路由
├─ token 配额检查
├─ prompt / 语义缓存 ◀── 缓存
└─ 入队 ──▶ 推理队列
│
▼
GPU 推理 worker(批处理)
│ token 流回
◀────────────── token 流 ───────────┘
聊天服务把 token ──SSE──▶ 转发给客户端
│ 完成后
└─ 持久化助手消息 ──▶ 会话库
关键组件:API/聊天服务(无状态,持有 SSE 连接、编排一轮对话)、会话库(持久的消息历史)、向量库(可选,用于检索)、LLM 网关(路由、配额、缓存、降级)、推理队列,以及真正跑模型的 GPU 推理 worker。旁边还有单独的审核路径和离线的评测/分析流水线。
第 4 步 —— 流式响应
决定性的体验选择。因为一条回答要好几秒,你绝不能让用户盯着转圈——要边生成边把 token 流出去。标准传输是 Server-Sent Events(SSE):一条长寿命的 HTTP 响应,服务器持续推 data: 事件。SSE 非常合适,因为在一条回答期间这个流是单向的(服务器 → 客户端),它是纯 HTTP(穿代理和负载均衡都行),而且会自动重连。
| 传输 | 用于 token 流式是否合适 |
|---|---|
| SSE | 理想:纯 HTTP 上的单向服务器推送,简单、对代理友好。默认选择。 |
| WebSocket | 能用,但是双向、比所需更重;如果你想在同一通道做富双工(实时语音、打断)才值。 |
| 长轮询 | 仅作兜底 —— 对 token 级流式来说,每个分片重建连接太浪费。 |
async def stream_reply(conversation_id, user_msg):
history = db.load_history(conversation_id)
prompt = build_prompt(history, user_msg) # 每轮重建上下文
full = []
async for token in gateway.generate(prompt): # token 从 GPU worker 流来
full.append(token)
yield f"data: {token}\n\n" # 一个 SSE 帧,立即 flush
db.save(conversation_id, user_msg, "".join(full)) # 流结束后再持久化
yield "data: [DONE]\n\n"
两个值得在面试里点出的后果。第一,在一条回答期间连接是有状态的——持有它的那台聊天机器每个请求要活着约 5 秒,所以单机能扛的并发请求远少于普通无状态 API;按并发而不是 QPS 来估机器数。第二,"停止生成"是个真功能:客户端关掉 SSE 流,聊天服务必须把取消一路传到 GPU worker,让它停止解码、释放槽位——否则你还在为没人看的 token 付钱。
第 5 步 —— 会话与状态
这部分难得地正常。会话和消息就是经典的关系型/文档型数据,schema 很简单;数据量(约 100GB/天文本)也不大。真正有意思的设计选择是历史如何回喂给模型。
conversations(id, user_id, title, created_at, updated_at)
messages(id, conversation_id, role, content, token_count, created_at)
role ∈ {"user", "assistant", "system", "tool"}
# 读取模式:某会话最近 N 条消息,按 created_at 排序
# 按 conversation_id(或 user_id)分区/分片 —— 读取都是按线程的
按 conversation_id(或 user_id)分区:每次读取都是"给我这个线程的消息",把一个会话放在一起就能避免跨分片读。存储可以是 Postgres,也可以是宽列/文档库——访问模式就是简单的按键有序读,几乎什么都行。因为模型有有限的上下文窗口,你不会傻傻地把整个线程都发过去:你发能塞下的最近若干条,对于很长的会话则把更早的轮次总结成一段紧凑的滚动摘要拼在前面——用一点保真度换取留在窗口内(也留在 token 预算内)。
第 6 步 —— 推理层
这是系统的心脏,也是它和任何 CRUD 设计的根本区别。GPU worker 跑模型,把 prompt 变成一串 token。三个想法主导了怎么把它做高效。
连续批处理
GPU 是大规模并行的,一次只跑一个请求是浪费。推理服务器(vLLM、TGI、TensorRT-LLM)用连续(in-flight)批处理:把许多用户的请求合并成 GPU 上的一个 batch,而且关键是,每个解码步都把已完成的序列换出、把新序列换入,而不是等整个 batch 一起结束。这让 GPU 一直饱和,是最大的单一吞吐杠杆。
KV 缓存
生成每个新 token 都要对之前所有 token 做注意力。每步都重算会是平方级,所以 worker 在 GPU 显存里保留一份 KV 缓存——prompt 和已生成 token 的注意力 key/value。这让解码很快,但意味着每个进行中请求的 GPU 显存随上下文长度增长;限制单 GPU 能同时持有多少条流的,往往是 KV 缓存而不是算力。前缀缓存能复用共享 prompt 前缀(比如所有用户共用的 system prompt)的 KV 缓存,省下处理 prompt("prefill")阶段的开销。
Prefill 与 decode
一个请求有两个成本特征不同的阶段:prefill(并行处理整个 prompt——算力密集、快、决定 TTFT)和 decode(一次生成一个 token——受显存带宽限制、串行、决定 token/秒)。有些系统甚至把它们拆到不同的 GPU 池上。面试里不需要这么深,但能点出这两个阶段、以及 TTFT 来自 prefill,就显出真懂了。
把 GPU 当成一个稀缺、批处理、显存受限的池,而不是普通的无状态 worker。吞吐来自连续批处理;并发被 KV 缓存显存卡住;延迟(TTFT)来自 prefill。这三条事实能解释后面设计的大部分。
第 7 步 —— LLM 网关
别让聊天服务直接调 GPU worker。在中间放一个网关——就是 API 网关那套模式,只是为模型特化。它把每个请求都需要的横切关注点集中起来。
- 路由与模型分档: 把简单/短的查询送到便宜小模型,难的送到大模型;按用户档位(免费 vs 付费)、地域或 A/B 实验路由。
- token 配额与限流: 限额最好用每分钟 token 数而不是每分钟请求数,因为成本随 token 走。在这里按用户和按组织执行预算。
- 缓存: 对完全相同的 prompt 做精确匹配缓存,对常见问题做语义缓存(把 prompt 向量化,若有几乎相同的问题被问过就返回缓存答案)。
- 降级与韧性: 若某模型/provider 挂了或打满,就重试、退到更小的模型、或排队——让 GPU 短缺变成"变慢"而不是"宕机"。
- 可观测与计费: 按请求记录 token、延迟、成本、所用模型,用于计费和容量规划。
def handle(req):
if not quota.allow(req.user, est_tokens=req.size()):
return error(429) # 超出 token 预算
if hit := cache.lookup(req.prompt): # 精确或语义命中
return hit # 0 GPU 成本
model = router.pick(req) # 按难度/档位选小或大模型
try:
return pool[model].enqueue(req) # 进批处理推理队列
except Saturated:
return pool[fallback].enqueue(req) # 降级,别失败
第 8 步 —— 记忆、上下文与 RAG
用户感觉助手有记忆,但模型是无状态的——"记忆"是 harness 每轮重建出来的,靠的是选择往上下文窗口里放什么。有三层值得区分:
- 工作记忆: 当前线程的最近消息,每轮原样发送(窗口塞满时把更早的轮次总结掉)。
- 长期记忆: 关于用户的持久事实("偏好 Python""对花生过敏")单独存,相关时注入——通常靠向量相似度检索。
- 检索知识(RAG): 对于私有/最新文档上的问题,把 query 向量化,从向量库取 top-k 相关切片,拼到 prompt 前面,让答案基于真实来源。
这三者是同一个动作:调用模型前,把对的文本取出来塞进窗口。 设计含义是请求路径上多了一步检索(向量化 + 向量搜索),以及一个预算决定——多少 token 给历史、多少给检索上下文、多少留给答案。(大规模检索本身是一篇设计,这是 RAG 系统深拆的引子。)
第 9 步 —— 安全与滥用
和 CRUD 应用不同,这个系统会产出有害内容、也是滥用的磁石,所以安全是个真子系统。进来时,一道审核检查(一个分类器,通常是更小的模型)筛掉违规输入和 prompt 注入企图。出去时,生成的 token 也能被筛——不过流式让这变棘手,因为你已经发出了前面的 token;常见折中是按小窗口审核,一旦越线就掐断流。围绕这一切的是每用户限流与 token 预算(防爬取和成本轰炸)、鉴权和审计日志。把网关与审核当成模型自己给不了的策略执行层。
第 10 步 —— 成本与延迟优化
因为约 1,000 块 GPU 主导账单,成本优化是设计特性,不是事后想起。主要杠杆:
| 杠杆 | 买到什么 |
|---|---|
| 连续批处理 | 最高 GPU 利用率 → 单 GPU 产出最多 token。最大的单项收益。 |
| 模型分档 / 路由 | 简单/短查询用便宜小模型;大模型留给难题。 |
| prompt 与语义缓存 | 重复或近重复的问题 ≈ 0 GPU 成本。 |
| 前缀缓存(KV 复用) | 共享 system prompt / 长文档不必每个请求重算。 |
| 量化(如 8/4-bit) | 单 GPU 装更多请求、显存更低,质量小幅下降。 |
| token 上限 与 总结 | 限制上下文与输出长度,直接限制每轮成本。 |
延迟方面,用户感受到的指标是 TTFT(由排队等待 + prefill 决定),之后是逐 token 延迟(decode 速度)。改善 TTFT 靠把队列压短(自动扩缩 GPU 池、压力下把负载分到小模型)以及对共享长 prompt 做前缀缓存让 prefill 更便宜。流式掩盖了总延迟:只要文字在约 1 秒内开始流出、且流得比用户读得快,一段 6 秒的回答体感就很好。
第 11 步 —— 瓶颈与取舍
收尾时点出这套设计在哪儿吃紧、你会怎么权衡——面试官给这个的分比你再画一个框高。
- GPU 容量既是瓶颈也是预算。 流量尖峰没法靠拉起廉价机器吸收;GPU 稀缺且供给慢。缓解:带背压排队、降到小模型、对免费用户先做准入控制(让位给付费)。
- 是并发而不是 QPS 决定机器数。 长寿命的流意味着每台机器/GPU 把请求握住好几秒;KV 缓存显存卡住单 GPU 的并发流数。
- 上下文窗口 vs 成本 vs 质量。 更多历史/检索上下文能提升答案,但更费 token 和延迟;总结省钱却丢细节。
- 质量是概率性的。 同一个 prompt 可能给出不同或错误的答案,所以你需要评测、对 prompt/模型做 A/B、以及反馈采集(点赞/点踩)作为系统的一部分——不只是看可用性仪表盘。
- GPU 的多地域很难。 你想全球都低 TTFT,但 GPU 供给按地域、且不均;你常常路由到"有容量"的地方,用一点延迟换"不失败"。
抛开 LLM 的神秘感,聊天助手就是一个架在稀缺 GPU 池前面的流式前门。把四件事做对,其余自然成立:流式吐 token(SSE)、把无状态 API 层和 GPU 层分开、每轮在窗口内重建上下文、在推理前放一个做路由/token 配额/缓存/降级的网关。其它一切——批处理、KV 缓存、审核、成本杠杆——都是为了高效又安全地服务那些昂贵的 token。
它和聊天应用有何不同? 响应是 GPU 上逐 token 生成的——慢且贵——所以你优化首 token 时间和流式,且 GPU 层主导容量与成本。
流式用什么传输? SSE —— 纯 HTTP 上的单向服务器推送,简单、对代理友好。只有需要富双向(语音/打断)才上 WebSocket。
"记忆"怎么实现? 模型无状态;每轮在上下文窗口内重发最近历史(把旧轮次总结)加上检索到的事实。
最大的吞吐杠杆是什么? GPU worker 上的连续批处理;并发随后被 KV 缓存显存卡住,TTFT 由 prefill 阶段决定。
怎么控制成本? 模型分档、prompt/语义缓存、前缀(KV)缓存、量化、token 配额、以及总结长上下文。
怎么应对容量尖峰? 带背压排队 + 降到小模型——把 GPU 短缺变成变慢而不是宕机;对免费用户先做准入控制。