针对 OpenAI 聊天前端题目的完整解答:持有密钥的代理、登录与会话、SSE 与 WebSockets 的对比、流式响应的解析、高效渲染 token、浏览器状态、上下文限制、错误恢复以及规模化。
作者:The Forward Deployed 编辑团队已审阅
本文属于 OpenAI 系统设计题库。该题目能代表这一轮面试的水平。分析与解答为本站原创。
问题描述
该产品是一个 Web 应用,包含登录、消息输入框,以及一个随模型输出逐步填充的对话记录。面试官通常有前端背景,并期望你在流式传输、渲染、状态和浏览器端故障处理方面展现深度。无存储规则缩小了后端的范围,但并不意味着可以完全去掉后端。
澄清问题
- 谁登录,如何登录? 假设使用公司单点登录或基于 OpenID Connect 的标准身份提供商。
- 使用哪个模型 API? 一个支持流式传输的聊天补全 API。
- 刷新后会发生什么? 对话会被清空。需确认意外刷新导致聊天丢失是可以接受的。
- 一个对话可以有多长? 长到足以触及模型的上下文限制。要询问届时该如何处理。
- 回答中包含 Markdown 和代码吗? 是的,需要渲染。
- 有多少用户? 练习场景:每日 100 万用户,峰值同时在线 10 万,每人每天 20 条消息。
流式聊天界面为什么难做
有三件事比看起来更难。
API 密钥不能随代码发送到浏览器。客户端代码中的任何内容都是公开的。因此即使不存储历史记录,该设计也需要一个后端来验证用户身份,并使用保密密钥调用模型。
流式传输是一个长生命周期的响应。服务器会在数十秒内缓慢地发送一串细小的事件。从负载均衡器到浏览器的每一跳都必须在不缓冲的情况下透传部分数据,并且每一跳的超时设置都必须允许这种情况。
渲染可能成为瓶颈。Token 每秒到达数十次。如果每收到一个 token 就重新渲染整个对话记录并重新解析其中所有 Markdown,页面就会卡顿、输入就会延迟,即使网络本身没有问题。
因此,核心矛盾在于部分数据下的响应性与正确性之间的权衡。UI 必须在文本一到达就立即显示,而此时文本尚不完整、Markdown 尚未成形,且流随时可能中断。
flowchart LR B([Browser SPA]):::user -->|POST /api/chat, full history| P[Chat proxy<br/>auth, limits, key]:::svc P -->|stream=true| M[Model API]:::store M -->|tokens| P -->|text/event-stream| B classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
核心思想。 无状态不等于无后端。一个轻量代理持有密钥并转发流;浏览器拥有对话,必须流畅地渲染不完整、易变的数据。
关键概念
服务器推送事件
SSE 是一种通过普通 HTTP 从服务器到浏览器的单向流。响应的内容类型为
text/event-stream
,每个事件由几行
field: value
文本加一个空行组成。它可以穿透代理,兼容 HTTP/2 多路复用,服务器端无需任何特殊配置。
WebSockets
WebSocket 将 HTTP 连接升级为双向通道。任何一方都可以随时发送数据。它适用于语音、实时打断和协作场景,但需要自己的路由、心跳和重连逻辑。
Fetch 流式读取
浏览器的
EventSource
API 只能发起没有请求体的 GET 请求。而聊天请求需要 POST 并在请求体中携带历史记录。因此要用
fetch
读取流,并通过响应的
ReadableStream
自行解析 SSE 行。
渲染批处理
浏览器大约每秒绘制 60 次。比这更频繁地更新状态是浪费。将 token 收集到缓冲区中,并在每个动画帧刷新一次。
核心思想。 单向流用基于 fetch 的 SSE,只有当客户端需要在流传输中途发言时才用 WebSocket,渲染按帧率而非 token 速率进行。
需求
继续阅读之前。 产品必须做什么?如果某项质量失效,用户最先注意到的是哪一项?
1.1 功能需求
- 登录和登出。
- 发送消息并看到答案流式输出。
- 停止正在进行的响应。
- 重试失败或中断的响应。
- 渲染 markdown 和代码,并带复制按钮。
- 刷新时清空对话。
1.2 非功能需求
- 首 token 时间在发送后约一秒以内,其中大部分时间来自模型。
- 流畅渲染。 流式输出时不丢帧;输入保持响应。
- 安全性。 浏览器中不存放 API 密钥,会话 token 不被脚本读取,模型输出不注入脚本。
- 韧性。 流中断时绝不丢失已显示的文本,并始终提供重试。
1.3 约束与属性
响应式、可信赖的 UI 是属性。 用户根据文本出现的速度以及界面是否会卡死或丢失内容来评判产品。无服务器状态规则是约束。 它将对话状态、上下文管理和恢复都移到了浏览器中。
粗略估算
2.1 请求量
100 万用户 × 20 条消息 = 每天 2000 万次聊天请求,平均约每秒 230 次。峰值按 5 倍计算约为每秒 1200 次。
2.2 并发流
如果一个回答平均流式传输 15 秒,代理在峰值时大约要维持 1,200 × 15 = 18,000 条打开的流(Little 定律:到达率乘以持续时间)。一台支持异步 I/O 的代理主机可以轻松维持数千个空闲连接,因此只需少量主机即可,并留有余量。
2.3 请求大小
由于浏览器每次都会发送完整的对话历史,请求会不断增大。一段 40 轮、每轮 300 个 token 的对话共有 12,000 个 token,约 48 KB 的文本。每条消息上传 48 KB 没有问题;模型的上下文上限远早于带宽成为瓶颈之前就会达到。
2.4 浏览器中的 token 速率
一个以每秒 50 到 100 个 token 流式输出的模型,每 10 到 20 毫秒产生一个 token。逐 token 渲染意味着在一个不断增长的对话记录上每秒进行 50 到 100 次 React 更新。按帧批量处理可将其上限控制在 60 次,通常远低于此。
核心思想。 容量以打开的流来衡量,而不是每秒请求数,而且浏览器的渲染循环与网络一样是瓶颈。
API 设计
继续阅读之前思考。 流式传输应该用 GET 加 EventSource,还是 POST 加 fetch?为什么?
用 POST 加 fetch。请求携带完整的消息历史,这无法放进 URL 中,而且不应像 GET URL 那样被中间节点缓存或记录日志。
3.1 聊天
POST /api/chat
Cookie: session=... (HTTP-only, Secure, SameSite=Lax)
{
"messages": [{"role": "user", "content": "..."}, ...],
"model": "default",
"client_request_id": "uuid"
}
200 text/event-stream
event: delta data: {"text": "Hel"}
event: delta data: {"text": "lo"}
event: done data: {"finish_reason": "stop", "usage": {...}}
event: error data: {"code": "upstream_timeout", "retryable": true}
401 session expired -> client redirects to login
413 history exceeds context -> client trims and retries
429 rate limited, Retry-After3.2 认证
GET /auth/login -> redirect to identity provider (code flow + PKCE)
GET /auth/callback -> sets session cookie, redirects to app
POST /auth/logout -> clears cookie
GET /api/me -> {user_id, name}数据模型
服务器只存储用户的会话。对话由浏览器保存。
// browser, in memory
type Message = {
id: string;
role: "user" | "assistant";
text: string;
status: "streaming" | "done" | "error" | "stopped";
};
type ChatState = {
messages: Message[];
inFlight?: { controller: AbortController; messageId: string };
draft: string;
};
// server
session (session_id, user_id, expires_at) // or a signed, encrypted cookie
usage (user_id, day, requests, tokens) // for rate limits, no content高层设计
5.1 浏览器直接调用模型
flowchart LR B([Browser<br/>API key in JS]):::bad --> M[Model API]:::store classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef bad fill:#fbe9e4,stroke:#c4492d,color:#171717;
它进行流式传输,并且把 API key 暴露给任何打开开发者工具的人。没有登录,也没有针对每个用户的限制。
5.2 修复 1:无状态代理
一个小型后端保管密钥,检查用户的会话,实施速率限制,并以流式方式转发请求。它在每个事件到达时进行转发,不存储任何内容。
flowchart LR B([Browser]):::user --> P[Chat proxy]:::new --> M[Model API]:::store P --> RL[(Rate-limit counters)]:::new classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;
5.3 修复 2:使用 HTTP-only 会话的登录
代理运行标准的登录流程,并设置一个脚本无法读取的会话 cookie。每个聊天请求都会自动携带它。
5.4 修复 3:流式解析器和帧率渲染器
客户端读取响应流,解析 SSE 事件,将文本追加到缓冲区,并每帧刷新一次状态。已完成的消息会被记忆化。
5.5 修复 4:取消、重试和上下文裁剪
一个“停止”按钮会中止请求;代理检测到连接关闭并取消上游调用。失败的流会保留已接收的部分文本并显示“重试”。每次发送前,客户端会裁剪历史记录以适应上下文窗口。
5.6 组合后的设计
sequenceDiagram
autonumber
actor U as User
participant B as Browser
participant P as Proxy
participant M as Model API
U->>B: type message, press Enter
B->>B: append user message, trim history to fit
B->>P: POST /api/chat (cookie, full history)
P->>P: check session, rate limit
P->>M: stream=true, API key
loop tokens
M-->>P: chunk
P-->>B: event: delta
B->>B: buffer, flush once per frame
end
M-->>P: done
P-->>B: event: done
B->>B: mark message done
opt user clicks Stop
B->>P: abort (connection closes)
P->>M: cancel upstream request
end核心思路。 用代理处理密钥和限额,用 cookie 会话处理身份,用帧率渲染器保证流畅,用中止、重试和裁剪实现恢复。
深入探讨
6.1 SSE 还是 WebSockets
继续阅读前。 面试官会问你为什么不用 WebSockets。先为 SSE 辩护,再说出你会切换到 WebSockets 的场景。
流量形态决定了选择。一次对话轮次是一个请求加上单向的 token 流。SSE 与此完全匹配:纯 HTTP,无需连接升级,无需自定义协议,代理和 CDN 自动支持,且每个请求相互独立,这使得负载均衡和重试都很简单。
WebSockets 引入了状态:每个用户一条长连接、粘性路由、心跳、重连逻辑,以及自定义的消息协议。这些成本换来的是流式传输期间的双向消息。语音场景需要它,因为音频双向流动;在回答中途用新输入打断模型也需要它;或者多用户会话中其他人的消息会主动到达时也需要它。这些场景在这里都不在范围内。
区分答案水平的要点:传输方式
弱:因为聊天是实时的就选择 WebSockets
没注意到每轮流量是单向的,就选择了更重的工具。
良好:为单向流选择 SSE
能解释 SSE 符合“先请求后流式”的形态,并且运行在纯 HTTP 之上。
强:了解细节和切换时机
用 fetch 读取 SSE(因为 EventSource 无法 POST),处理有缓冲的代理,并能说出语音、打断和多用户会话是切换到 WebSockets 的理由。
6.2 解析流
async function streamChat(messages: Msg[], onText: (t: string) => void, signal: AbortSignal) {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
signal,
});
if (!res.ok || !res.body) throw new HttpError(res.status);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const events = buffer.split("\n\n");
buffer = events.pop() ?? ""; // keep a partial event for the next chunk
for (const raw of events) {
const ev = parseEvent(raw); // reads "event:" and "data:" lines
if (ev.type === "delta") onText(ev.data.text);
if (ev.type === "error") throw new StreamError(ev.data);
if (ev.type === "done") return ev.data;
}
}
throw new StreamError({ code: "ended_early", retryable: true });
}两个细节很重要。一个网络数据块可能在一个事件的中间结束,所以要把不完整的尾部保留在缓冲区中。另外,一个没有以
done
事件结尾就结束的流是错误,而不是成功:回答被截断了。
6.3 无卡顿地渲染 token
继续阅读前。 回答是 2,000 个 token 的 markdown,包含代码块。朴素的 React 在每个 token 到来时会做什么?你会怎么做?
朴素做法是,每个 token 都调用
setState
并传入一个新的 messages 数组。React 重新渲染整个对话记录,markdown 渲染器在每个 token 到来时重新解析整条消息。每个 token 的处理量随消息长度增长,所以流越长越慢。
修复方法,按顺序:
- 按帧批量处理。 onText 追加到一个 ref。一个 requestAnimationFrame 循环每帧最多将 ref 刷新到 state 一次。
- 隔离正在流式输出的消息。 已完成的消息是按 ID 作为 key 的 memoized 组件,因此它们永远不会重新渲染。只有最后一条消息会更新。
- 增量解析。 将流式文本拆分为已完成的块和当前块。已完成的块只渲染一次,仅对尾部重新解析。
- 容忍残缺语法。 未闭合的代码围栏或写到一半的表格必须先按文本渲染,直到闭合为止,而不是在布局间闪烁。
- 对长对话做虚拟化。 只有靠近视口的消息才挂载。
- 保持滚动行为合理。 仅当用户已经在底部时才自动滚动;如果用户向上滚动阅读,就让他们留在原处。
对渲染出的 HTML 进行净化。模型输出是不可信的输入,包含 script 标签或恶意链接的响应绝不能被执行。
6.4 浏览器中的状态与上下文限制
继续阅读之前。 对话会达到模型的上下文限制。客户端应该怎么做?
由于服务器不存储任何内容,浏览器必须自行管理上下文。使用客户端分词器统计 token 数,或按字符数估算。每次发送前,如果历史记录加上为回答预留的空间超过了限制,就进行裁剪:优先丢弃最早的轮次,保留系统提示词和最近的轮次。显示一条小提示,说明较早的消息已不在模型的视野内。更强的方案是让代理将旧轮次总结成一条简短消息,从而在更小的空间内保留更多含义。
刷新后状态会清空,这是既定设计。如果产品以后希望对话能在意外刷新后保留,
sessionStorage
可以按标签页保存,上限为几兆字节。要对该上限给出提示,并在保存时优先裁剪代码块。
6.5 无服务器状态的认证
唯一的服务器状态是会话。使用带 PKCE 的 OpenID Connect 授权码流程。代理用授权码换取令牌,并设置一个标记为 HTTP-only、Secure 和 SameSite 的会话 cookie。脚本无法读取它,因此脚本注入漏洞也无法窃取它。令牌如果放在
localStorage
中,页面上任何脚本都能读取。
会话可以放在一个小型存储中,也可以放在 cookie 本身里(加密并签名),这样代理就能完全无状态。对话中途收到 401 时,将对话保留在内存中,尽可能静默刷新会话,然后重新发送。因为登录跳转而丢失用户的对话是很糟糕的体验。
SameSite cookie 能阻止大多数跨站请求伪造。为了更安全,可以在
/api/chat
上要求一个自定义请求头,因为浏览器在没有 CORS 预检的情况下不会跨站发送它。
6.6 错误与恢复
| 故障 | 用户看到什么 | 客户端做什么 |
|---|---|---|
| 流式传输中途网络中断 | 显示部分回答,标记为已中断 | 提供“重试”,重新发送相同的历史记录 |
| 429 限流 | “请求过多,请在 N 秒后重试” | 在 Retry-After 之前禁用发送 |
| 5xx 或上游超时 | “出了点问题”并提供重试 | 采用退避和抖动策略重试几次 |
| 流结束但没有 done | 显示部分回答,标记为被截断 | 与网络中断的处理相同 |
| 浏览器离线 | 禁用发送,显示离线横幅 | 监听 online 事件 |
| 413 上下文过长 | 自动裁剪后重发 | 裁剪最早的对话轮次并重试一次 |
停止是用户操作,不是错误。中止 fetch;将该消息标记为已停止;保留其文本。连接关闭时代理会取消上游请求,因此没人读取的 token 不会被计费。
区分答案的关键:失败处理
弱:假设流会完整完成
没有应对流中途断开的方案;出错时消息变成空白。
良好:保留部分文本并提供重试
能检测连接中断和缺失的 done 事件,对 429 和 5xx 进行退避重试。
强:为每种失败设计 UX
将每种失败映射到一条消息和一个操作,停止时取消上游请求,自动裁剪上下文,并且绝不丢失用户已经看到的内容。
6.7 运营代理服务
流式传输会占用连接数十秒,因此要按并发连接数来规划容量。调高负载均衡器和代理的空闲超时时间,并为聊天路由禁用响应缓冲,否则 token 会成批到达。在长时间停顿期间每 15 秒发送一行 SSE 注释,以免中间设备超时。
在代理中按用户使用令牌桶进行限流,并限制每个用户的并发流数。记录使用量计数,而不是内容。从浏览器端测量首 token 时间和每秒 token 数,因为这才是用户实际感受到的指标,并按错误类型对错误率设置告警。
6.8 组件架构
在继续阅读之前。 画出组件树,并说明状态存放在哪里。每收到一个 token 时,哪些组件会重新渲染?
<App>
<AuthProvider> session state, login redirect
<ChatProvider> messages, inFlight, send(), stop(), retry()
<Header /> static
<Transcript> virtualized list
<MessageView id=... /> memoized by id + status; finished messages never re-render
<StreamingMessage /> subscribes to the token buffer only
</Transcript>
<Composer /> draft text, local state; Enter to send
</ChatProvider>
</AuthProvider>
</App>状态集中在一个 store 中,由
ChatProvider
持有。流式消息从一个单独的缓冲区读取,该缓冲区每帧更新一次,因此在流式传输期间只有
StreamingMessage
会重新渲染。当流结束时,缓冲区的文本一次性提交到消息列表,该消息变成一个经过 memo 化的
MessageView
.
输入框将草稿保存在本地状态中,因此打字不会触及对话记录。这种分离保证了在快速流式传输期间输入依然流畅,这是聊天 UI 中最常见的抱怨。
6.9 无障碍与键盘
流式文本对屏幕阅读器很不友好:逐个播报 token 就是噪音。将对话记录标记为 log 区域,只播报已完成的消息,并在结束时播报“回复已完成”。Enter 发送,Shift+Enter 换行,Escape 停止流式传输。发送后焦点返回输入框。代码块配备可通过键盘访问的复制按钮。
这些细节很少能单独决定一轮面试的成败,但有前端背景的面试官会注意到它们。简要提及这些细节,表明你真正上线过实际的 UI。
区分答案的关键:前端结构
弱:一个巨大的组件
将所有状态放在一个组件中,每收到一个 token、每敲一个键都会重新渲染。
良好:拆分状态
将流式缓冲区和输入框草稿从消息列表中分离出来。
强:为可扩展性和无障碍而设计
对已完成的消息进行记忆化处理,对对话记录进行虚拟化渲染,隔离流式传输组件,并处理屏幕阅读器和键盘操作流程。
变体
7.1 服务端历史记录
如果面试官放宽“不存储”的限制,可以增加一个会话服务,按用户存储消息,提供历史聊天侧边栏,并支持跨设备同步。此时浏览器只需发送新消息和一个会话 ID。
7.2 语音
语音需要双向音频传输和打断功能,因此要改用 WebSockets 或 WebRTC。向上流式传输音频块,向下流式传输音频和文本,并处理插话(barge-in):用户在模型说话时开口讲话,客户端取消播放并中止上游生成。
7.3 文件上传
通过预签名 URL 将文件直接上传到存储,然后在聊天请求中发送文件引用。上传进度与回答流分开显示。
7.4 用户量增长十倍时
当日活用户达到 1000 万时,代理在高峰期要维持约 18 万条打开的流。将代理水平扩展,部署在第 4 层负载均衡器或支持 HTTP/2 的负载均衡器之后,并关注每连接的内存占用。上游配额会成为瓶颈:增加每用户并发上限、一个能如实显示等待时间的队列,以及为免费用户降级到更快的模型。浏览器端的设计无需任何改动。
可迁移的模式
流式聊天 UI 本质上是无状态中继加上有状态客户端渲染器。服务器守护密钥和配额,只传递字节;客户端负责状态、恢复和呈现。同样的拆分适用于任何流式产品:实时日志、进度推送、协作光标。先决定由哪一侧持有状态,然后把另一侧设计得足够“笨”、可随时替换。
复盘:30 秒回答
- 一个薄代理持有密钥。 负责认证、每用户限流、流中继,不存储任何内容。
- 用 fetch 读取 SSE。 使用 POST 请求体、纯 HTTP,只有双向需求时才用 WebSockets。
- 按帧率渲染。 缓冲 token、对已完成消息做记忆化、增量解析、净化处理。
- 浏览器负责上下文。 每次发送前统计 token 并裁剪。
- 每种失败都有对应的 UX。 保留部分文本、提供重试、停止时取消上游生成。
测验
+为什么“无后端存储”的聊天仍然需要后端?
模型 API 密钥必须保密,而任何发布到浏览器的东西都是公开的。代理持有密钥、验证用户身份并实施限流,同时不存储任何会话内容。
+为什么用 fetch 而不是 EventSource 来读取流?
EventSource 只能发起不带请求体的 GET 请求。而聊天请求需要用 POST,把消息历史放在请求体中。
+为什么逐 token 的朴素渲染会随着回答变长而变慢?
每个 token 都会触发一次重新渲染,并对不断增长的消息做完整的 markdown 重新解析,因此每个 token 的处理开销随消息长度增长。按帧批量处理、只解析尾部即可解决。
+为什么将 session 保存在 HTTP-only cookie 中?
脚本无法读取 HTTP-only cookie,因此跨站脚本漏洞无法窃取 session。而 localStorage 中的 token 可以被页面上的任何脚本读取。
+当用户点击“停止”时应该发生什么?
客户端中止 fetch 并保留目前已生成的文本,并标记为已停止。代理看到连接关闭后取消上游请求,因此不会再生成或计费任何 token。
+为什么将输入框的草稿保存在本地组件状态中?
这样输入时只会更新输入框。如果草稿存放在共享的聊天 store 中,每次按键都会重新渲染对话记录,在流式传输期间打字就会卡顿。
+屏幕阅读器应如何体验流式回答?
对话记录是一个日志区域,只播报已完成的消息,而不是每个 token,并在响应结束时进行一次简短播报。
参考资料与延伸阅读
- HTML Living Standard:server-sent events 定义了事件流格式。
- MDN:使用可读流 介绍了如何增量读取 fetch 响应。
- RFC 7636:PKCE 描述了浏览器登录流程中使用的证明密钥。
- OWASP 跨站脚本防御速查表 介绍了如何对模型文本等不可信输出进行净化。
- 成本与延迟 从部署角度介绍了流式传输和首 token 时间。相同的前端技能会在 模型游乐场 中再次出现。