"设计 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 缓存复用、量化、把简单查询路由到小模型,是把成本压下来的几个杠杆。
  • 它可能产出有害或错误内容,所以审核、滥用限制、评测是一等公民,不是事后补丁。
tldr

聊天助手 = 一个流式前门(SSE)+ 无状态 API 层,背后是被 LLM 网关挡在前面的 GPU 推理层。会话存在普通数据库里;"记忆"就是每轮在上下文窗口内重发历史,可选地用检索(RAG)增强。难点全都来自一个事实——回答是在 GPU 上慢且贵地生成的——这逼出了流式、连续批处理、缓存、配额和精细的成本控制。安全与评测和请求路径并行存在。

无状态应用层 数据存储 LLM 推理 · GPU 层 可观测 · 评测 · 反馈 SSE 历史 top-k prompt token 流 客户端 API 网关 · 负载均衡 聊天编排服务 ×N 无状态 · 持有 SSE · 拼 prompt 输入审核 向量化 会话库 向量库 · RAG 对象存储(文件 / blob) LLM 网关 路由 · token 配额 · 降级 语义 / prompt 缓存 推理队列 GPU 池 连续批处理 · KV 缓存 小模型池 大模型池 自动扩缩 · 峰值约 1,000 GPU 指标 评测 反馈
端到端架构 —— 无状态应用层(鉴权、编排、审核、向量化)对接数据存储与 GPU 推理层(网关 → 队列 → 批处理 GPU 池);token 经 SSE 流回,链路向可观测与评测上报

第 1 步 —— 收敛需求

照例先界定范围。面试官想看你把聊天产品和 LLM 底层管道分开,并决定什么在范围内。一个聚焦的集合:

功能需求

非功能需求

面试提示

开口就把模型当成一个"慢、按 token 计费、流式吐字的黑盒"。这一句定调会驱动后面每个决定——流式传输、批处理、配额、缓存——也表明你懂它和设计一个消息应用的本质区别。

第 2 步 —— 容量估算

粗略数字让设计落地,而且这里恰好暴露了为什么 GPU 是大头。假设 1000 万日活,每人约 10 条消息 → 1 亿消息/天平均约 1,200 条/秒,峰值算 约 5,000 条/秒。每条回答平均算 500 个输出 token

要明确说出的结论:这是个算力受限系统,不是存储或 QPS 受限的系统。 大部分架构的存在,都是为了把那约 1,000 块 GPU 用好。

第 3 步 —— 高层架构

把系统拆成无状态应用层(扩缩便宜,负责鉴权、会话、流式连接)和 GPU 推理层(稀缺、昂贵,前面挡着网关和队列)。把这两层分开,是最重要的结构性决定。

请求路径 —— 从客户端到 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 级流式来说,每个分片重建连接太浪费。
聊天服务通过 SSE 转发 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/天文本)也不大。真正有意思的设计选择是历史如何回喂给模型

会话 schema
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")阶段的开销。

排队 req E req F GPU worker —— 一个 batch 同步解码 seq A seq B seq C seq D 完成 → 换出 KV 缓存(随上下文增长) 下一 token token 输出
连续批处理与 KV 缓存 —— 每个解码步把已完成的序列换出、把排队的换入;每条序列的 KV 缓存(GPU 显存)随其上下文长度增长

Prefill 与 decode

一个请求有两个成本特征不同的阶段:prefill(并行处理整个 prompt——算力密集、快、决定 TTFT)和 decode(一次生成一个 token——受显存带宽限制、串行、决定 token/秒)。有些系统甚至把它们拆到不同的 GPU 池上。面试里不需要这么深,但能点出这两个阶段、以及 TTFT 来自 prefill,就显出真懂了。

要点

把 GPU 当成一个稀缺、批处理、显存受限的池,而不是普通的无状态 worker。吞吐来自连续批处理;并发被 KV 缓存显存卡住;延迟(TTFT)来自 prefill。这三条事实能解释后面设计的大部分。

第 7 步 —— LLM 网关

别让聊天服务直接调 GPU worker。在中间放一个网关——就是 API 网关那套模式,只是为模型特化。它把每个请求都需要的横切关注点集中起来。

网关:路由、配额、缓存,再下发
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 每轮重建出来的,靠的是选择往上下文窗口里放什么。有三层值得区分:

这三者是同一个动作:调用模型前,把对的文本取出来塞进窗口。 设计含义是请求路径上多了一步检索(向量化 + 向量搜索),以及一个预算决定——多少 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 步 —— 瓶颈与取舍

收尾时点出这套设计在哪儿吃紧、你会怎么权衡——面试官给这个的分比你再画一个框高。

小结

抛开 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 短缺变成变慢而不是宕机;对免费用户先做准入控制。

← 上一篇
设计聊天应用