设计一个类似 ChatGPT 的对话产品
一句话回答
先澄清功能范围和规模,再按接入层(鉴权、限流)→ 对话服务(上下文组装、内容安全、配额)→ 模型网关 → 存储分层设计。重点讲清几件事:流式链路怎么打通、怎么中断;会话和消息的数据模型,包括重新生成和编辑形成的分支;上下文怎么在 token 预算内组装;限流、配额和计费按 token 而不是按请求次数;消息量大了以后按用户分片、冷热分离。
详细解析
第一步:澄清需求和规模
- 功能:多会话、流式回复、停止生成、重新生成、编辑后重发、切换模型、分享对话。附件、联网搜索、长期记忆先列为扩展项
- 规模(假设值):日活 100 万,每人每天发 10 条消息,每天约 1000 万次生成;高峰按平均值的 4 倍估算,约 460 次/秒
- 由此推出:假设每次生成持续 15 秒,同时在进行的流式连接约 460 × 15 ≈ 7000 条(并发数 = 到达速率 × 持续时间)。瓶颈不在 CPU,而在长连接和上游模型的并发与 TPM 额度
- 存储(假设值):用户消息平均 0.2 KB、回复平均 2 KB,每天新增约 22 GB,一年约 8 TB(不含索引和副本)
- 体验指标:首 token 延迟、生成失败率、可用性
第二步:整体架构
Web / App
│ POST /api/chat,响应是 SSE 流(fetch + ReadableStream 读取)
▼
负载均衡 / API 网关:TLS、鉴权、按用户限流(关闭响应缓冲,调大空闲超时)
▼
对话服务(无状态,水平扩展)
├─ 会话管理:创建会话、写入用户消息、读取历史
├─ 上下文组装:系统提示 + 会话摘要 + 最近若干轮,控制在 token 预算内
├─ 内容安全:输入审核;输出边生成边审核
├─ 配额:调用前预扣,结束后按实际用量结算
└─ 流式转发:上游事件 → 统一的事件格式(delta / done / error)
▼
模型网关:多供应商路由、故障切换、用量统计
▼
供应商 A / 供应商 B / 自部署模型
存储:MySQL(会话、消息,按 user_id 分片)、Redis(限流计数、配额、生成中的状态)
异步:消息队列 → 计费汇总、生成会话标题、审核复查、搜索索引
第三步:核心数据模型
CREATE TABLE conversations (
id BIGINT PRIMARY KEY, -- 雪花算法等全局唯一 ID
user_id BIGINT NOT NULL,
title VARCHAR(200),
model VARCHAR(64), -- 当前选择的模型
current_leaf_id BIGINT, -- 当前分支的最后一条消息
summary TEXT, -- 早期消息的摘要
updated_at DATETIME NOT NULL,
deleted_at DATETIME,
INDEX idx_user_updated (user_id, updated_at)
);
CREATE TABLE messages (
id BIGINT PRIMARY KEY,
conversation_id BIGINT NOT NULL,
user_id BIGINT NOT NULL, -- 分片键,和所属会话在同一个分片
parent_id BIGINT, -- 上一条消息;重新生成、编辑会产生兄弟节点
role VARCHAR(16) NOT NULL, -- user / assistant / tool
content MEDIUMTEXT,
model VARCHAR(64),
status VARCHAR(16) NOT NULL, -- streaming / done / stopped / error / blocked
prompt_tokens INT,
completion_tokens INT,
created_at DATETIME NOT NULL,
INDEX idx_conv (conversation_id, id)
);
- 消息组成一棵树:重新生成是给同一条用户消息再挂一个回复,编辑是给同一个父节点挂一条新的用户消息。界面上的"2/3"就是在兄弟节点之间切换
- 组装上下文时,从
current_leaf_id沿parent_id往回走到根,得到当前分支的完整对话 - 会话摘要要记下它覆盖到了哪条消息;用户切换到另一个分支,或者编辑了摘要范围内的消息时,摘要要重新生成
- 用户消息在调用模型之前落库;开始生成时插入一条
streaming状态的回复,结束时更新为done或stopped。服务崩溃留下的streaming记录由定时任务改成error
第四步:关键难点
流式链路:前端用 fetch 读取流(见解析流式响应);网关和反向代理要关闭缓冲(见流式输出变成一次性返回);服务端定时发送心跳,防止空闲连接被中间层断开;用户点停止时,服务端要同时取消上游请求,否则模型会继续生成并计费(见停止生成)。
上下文管理:每次请求重新组装"系统提示 → 会话摘要 → 最近 N 轮 → 本轮问题",总量控制在模型窗口减去预留输出长度之内,用对应模型的分词器计数。历史超过阈值时异步生成摘要;工具调用和它的结果不能被拆开截断。详见多轮对话的上下文管理。
多模型接入和切换:对话服务只调用统一的模型网关(见大模型网关),每个模型配置上下文长度、是否支持图片和工具调用等能力。会话中途切换模型时,要按新模型的窗口重新截断历史,不同厂商的工具调用消息格式要转换,之前积累的提示缓存也会失效。
限流、配额和计费:
- 限流分层:每个用户每分钟的请求数、每个用户同时进行的生成数(如最多 2 个)、全局对上游的 TPM
- 配额按 token 计:调用前按"输入 token + 最大输出 token"预扣,结束后按实际用量多退少补
- 用量以上游返回的 usage 为准,有的接口在流式模式下要显式开启才返回;用户中途停止时上游可能不返回用量,用分词器估算
- 用量事件按消息 ID 幂等写入,定期和供应商的账单对账
内容安全:输入先审核再调用模型;输出边生成边审核,命中后撤回(见内容安全审核);上传的文件和联网获取的内容可能带有间接提示注入。
第五步:扩展与优化
- 消息存储:会话和消息都按
user_id分片,一个用户的数据在同一个分片上。长期不访问的会话归档到对象存储等廉价存储,打开时再加载回来 - 历史搜索:消息异步同步到搜索引擎,查询时强制带用户过滤
- 成本:生成标题、摘要用小模型;系统提示和工具定义保持稳定,提高提示缓存的命中率
- 体验:生成和连接解耦,刷新页面后能接着看(见下面的追问)
面试官可能追问
用户刷新页面或者网络断了,正在生成的回答怎么办?
把生成任务和 HTTP 连接解耦:生成在后台继续,每个片段带上序号写入 Redis(如 Stream 结构,设置过期时间),同时推给当前连接。客户端重连时带上最后收到的序号,服务端先补发缺失的部分,再继续推送;生成结束后整条消息落库。代价是多了一层缓存和状态管理,详见流式连接断了怎么续传。
上游模型返回 429,或者整个供应商故障了,怎么办?
- 调用前按供应商的 TPM 做全局限流,让请求排队,而不是盲目重试
- 遵守 Retry-After,用带抖动的指数退避重试;只在还没开始输出时重试或切换模型
- 主模型不可用时切换到备用模型或供应商,并做熔断,避免持续请求故障的上游
- 资源紧张时按用户等级排队、降级到更小的模型,并明确告诉用户
详见限流、重试和降级。
分享对话怎么设计?
生成一个只读快照:把当前分支的消息复制到快照表,用不可猜测的随机 ID 作为链接。快照和原会话分离,原会话后续的消息不会出现在分享里;用户可以随时撤销;分享页面默认禁止搜索引擎收录,渲染时同样要防 XSS。
消息为什么按 user_id 分片,而不是按 conversation_id?
最频繁的查询是"某个用户的会话列表"和"某个会话的全部消息",按 user_id 分片后两者都只落在一个分片上;按 conversation_id 分片,查会话列表就要扫所有分片。代价是个别重度用户可能形成热点,可以单独迁移这类用户,或者在 ID 里编码分片信息,方便路由。
易错点
- 只画"前端 → 后端 → 大模型"三个框,不讲流式链路、消息状态和计费
- 每次把全部历史发给模型,不做 token 预算
- 按请求次数做限流和计费:单次请求的成本差异很大,要按 token 算
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。