
文章目录 21
面试题:AI 流式对话
你正在开发一个类似 ChatGPT 的对话页面,服务端通过 流式返回模型生成内容。
请你设计前端的完整处理方案,重点说明:
如何解析可能被任意拆分的数据片段?
如何实现“停止生成”和重新生成?
如何避免频繁更新内容造成页面卡顿?
网络中断、超时或服务端限流时如何处理?
多轮消息的状态应该如何设计?
请先像真实面试一样口头作答。我会根据你的回答点评,并继续追问。
我的回答
如何解析可能被任意拆分的数据片段?
答:每个提问任务和对应提问任务都应有一个唯一的key,可以根据这个key 来解析和划定数据片段
如何实现“停止生成”和重新生成?
答:sse是单向数据源,停止生成应当向服务端重新提交任务,并将需要停止的任务id提交,至于重新生成也是同样原理,根据任务id重新提交;
如何避免频繁更新内容造成页面卡顿?
答:可以采用防抖或者帧加载,异步返回sse返回的多项内容
网络中断、超时或服务端限流时如何处理?
答:这些情况类似于前端收不到任务结束代表的key-id,导致任务一直在进行中,可以采用超时结束,或者重发任务id询问服务端,若无返回,则弹异常告警
多轮消息的状态应该如何设计?
答:多轮消息的话要采用特别的状态键,在没有收到任务结束代表的key都代表任务还在进行中,其中可以采用一些key来不断和用户交互和向服务端发消息我的问答解析
整体思路有一定工程意识,尤其提到了任务 ID、取消接口、帧级更新和超时处理。但核心问题是:你把“业务任务状态”和“SSE 数据流解析”混在了一起。综合评分:5.5/10。
正确点:
用唯一
taskId/messageId关联请求、消息和取消操作,方向正确。停止生成需要通知服务端终止模型任务,不能只停止页面显示。
使用帧级批量更新降低渲染频率,方向正确。
考虑了超时、任务状态查询和异常提示。
意识到消息需要区分“生成中”和“已结束”。
遗漏或需要修正的地方:
- 任意分片不能靠唯一 key 解决
taskId 只能识别“这是谁的消息”,不能确定数据边界。网络层可能把一条 SSE 事件拆成多块:
第一次读取:data: {"delta":"hel
第二次读取:lo"}\n
第三次读取:\n也可能一次读取返回多条事件。因此前端必须:
使用
TextDecoder增量解码字节流;用字符串
buffer保存未处理内容;根据 SSE 的空行分隔符
\n\n或\r\n\r\n提取完整事件;不完整部分继续留在
buffer中;事件完整后再解析
data:,不能对每个网络分片直接JSON.parse。
- 停止生成需要同时取消前端连接
比较完整的做法是:
前端通过
AbortController.abort()立即终止当前fetch流;同时调用服务端取消接口,例如
POST /tasks/:id/cancel;服务端停止模型生成并释放资源;
将消息状态设置为
cancelled,保留已经生成的文本。
如果只发取消请求但不关闭本地连接,前端仍可能继续接收缓存中的数据。
- 防抖不太适合流式输出
防抖会持续等待“输入停止”,模型不断生成时可能很久都不更新页面。更适合:
先把收到的 token 累积到临时缓冲区;
每个
requestAnimationFrame或每隔约 30~50ms 批量提交一次;Markdown 解析也进行节流;
长对话结合虚拟列表,避免所有消息重复渲染。
- 网络异常不等于没收到结束标记
还需要区分:
cancelled:用户主动停止;timeout:请求超时;network_error:连接中断;rate_limited:服务端返回 429;server_error:服务端异常;completed:正常完成。
重试时还要考虑幂等性,避免创建两个模型任务。可以携带 requestId,或者从服务端记录的事件序号继续恢复。
- 多轮消息应使用结构化状态
不建议仅靠“特殊 key”判断。可以把会话和消息分开:
type MessageStatus =
| 'pending'
| 'streaming'
| 'completed'
| 'cancelled'
| 'error';
interface Message {
id: string;
parentId?: string;
role: 'user' | 'assistant' | 'tool';
content: string;
status: MessageStatus;
taskId?: string;
error?: string;
}conversationId 标识会话,messageId 标识消息,taskId 标识本次生成任务,三者职责不同。
标准答案
下面是一份可以在面试中直接使用的完整标准答案。
一、面试口述版答案
我会把整个流式对话链路分成五部分:流解析、任务取消与重新生成、渲染优化、异常恢复、会话状态设计。
首先,SSE 底层传输的数据可能被任意拆分。一次 reader.read() 得到的不一定是一条完整事件,也可能包含半条事件或者多条事件,所以不能对每个 chunk 直接执行 JSON.parse。
前端需要使用 增量解码字节数据,并维护一个字符串 buffer。每次收到新内容后追加到 buffer,然后根据 SSE 的空行分隔符提取完整事件;剩余的不完整数据继续保留,等下一个 chunk 到来后再处理。解析事件时,再提取 data: 字段并解析 JSON。
其次,停止生成应该分成两个动作:前端使用 立即停止读取当前响应流,同时调用服务端取消接口,通过 taskId 通知服务端停止模型推理并释放资源。只断开前端连接不一定能保证上游模型任务停止,只调用取消接口也不能保证前端立即停止读取。
重新生成一般不是复用原来的 assistant 消息,而是根据原用户消息创建一个新的生成任务和新的 assistant 消息。这样可以保留历史结果,支持多个回答分支。请求还应携带幂等键,防止网络重试造成重复任务。
为了避免每收到一个 token 就触发一次框架更新,我会先把 token 累积到临时缓冲区,再使用 requestAnimationFrame 或固定时间间隔批量更新 UI。相比防抖,节流或帧级批处理更适合持续产生数据的流式场景。长对话还需要配合虚拟列表、Markdown 增量渲染以及合理的自动滚动策略。
对于网络中断、超时和限流,我会区分不同错误状态。网络中断可以通过事件序号或 Last-Event-ID 恢复;超时需要区分首字超时和总请求超时;遇到 429 时读取 Retry-After,进行指数退避,而不是立即重复请求。重试时使用相同的,避免服务端生成多个任务。
最后,多轮消息不能只用一个特殊 key 管理。我会区分 conversationId、messageId、taskId 和 requestId:分别表示会话、消息、生成任务和一次幂等请求。消息状态至少包括 pending、streaming、completed、cancelled 和 error。如果支持编辑和重新生成,则消息最好设计成树结构,通过 parentId 表示分支关系。
二、任意分片的正确解析方式
为什么不能直接解析 chunk
以下都是合法的网络分片方式。
一条事件被拆成多次返回:
chunk 1: data: {"delta":"你
chunk 2: 好"}\n\n一次返回包含多条事件:
chunk 1: data: {"delta":"你"}\n\ndata: {"delta":"好"}\n\n甚至一个 UTF-8 汉字的字节也可能被拆开。因此需要同时解决:
字节解码边界;
SSE 事件边界;
JSON 内容边界。
简化的 TypeScript 解析器
interface DeltaEvent {
delta?: string;
taskId?: string;
eventId?: string;
done?: boolean;
}
async function consumeSSE(
response: Response,
onEvent: (event: DeltaEvent) => void,
): Promise<void> {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
if (!response.body) {
throw new Error('Response body is empty');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
// 保存尚未形成完整 SSE 事件的数据
let buffer = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
// stream: true 可以正确处理跨 chunk 的 UTF-8 字节
buffer += decoder.decode();
if (buffer.trim()) {
parseSSEBlock(buffer, onEvent);
}
break;
}
buffer += decoder.decode(value, { stream: true });
// 兼容 \r\n 和 \r,统一转换成 \n
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
let boundaryIndex: number;
// SSE 事件之间以空行分隔
while ((boundaryIndex = buffer.indexOf('\n\n')) !== -1) {
const block = buffer.slice(0, boundaryIndex);
buffer = buffer.slice(boundaryIndex + 2);
parseSSEBlock(block, onEvent);
}
}
} finally {
reader.releaseLock();
}
}
function parseSSEBlock(
block: string,
onEvent: (event: DeltaEvent) => void,
): void {
const dataLines: string[] = [];
for (const line of block.split('\n')) {
// SSE 注释或心跳
if (line.startsWith(':')) {
continue;
}
if (line === 'data') {
dataLines.push('');
continue;
}
if (line.startsWith('data:')) {
// 规范允许冒号后存在一个可选空格
dataLines.push(line.slice(5).replace(/^ /, ''));
}
}
if (dataLines.length === 0) {
return;
}
// 一条 SSE 事件可以有多个 data: 行
const rawData = dataLines.join('\n');
if (rawData === '[DONE]') {
onEvent({ done: true });
return;
}
try {
const event: DeltaEvent = JSON.parse(rawData);
onEvent(event);
} catch {
throw new Error(`Invalid SSE data: ${rawData}`);
}
}调用示例:
const controller = new AbortController();
async function startGeneration(): Promise<void> {
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
conversationId: 'conversation-1',
message: '你好',
}),
signal: controller.signal,
});
await consumeSSE(response, event => {
if (event.delta) {
enqueueDelta(event.delta);
}
if (event.done) {
finishMessage();
}
});
}关键点是:JSON 可能跨网络 chunk,但不能跨 SSE 事件。必须先组装完整 SSE 事件,再解析其中的 JSON。
三、如何实现停止生成
前端立即停止读取
const activeTasks = new Map<string, AbortController>();
async function generate(taskId: string): Promise<void> {
const controller = new AbortController();
activeTasks.set(taskId, controller);
try {
const response = await fetch('/api/chat/generate', {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ taskId }),
});
await consumeSSE(response, handleEvent);
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
markMessageCancelled(taskId);
return;
}
markMessageFailed(taskId, error);
} finally {
activeTasks.delete(taskId);
}
}同时通知服务端停止任务
async function stopGeneration(taskId: string): Promise<void> {
// 先立即停止前端读取
activeTasks.get(taskId)?.abort();
// 再通知服务端终止模型任务
try {
await fetch(`/api/tasks/${taskId}/cancel`, {
method: 'POST',
});
} catch {
// 前端仍然保持 cancelled 状态
// 后台可以记录取消接口失败,继续进行资源回收
}
}需要强调:
AbortController解决的是前端及时响应;取消接口解决的是服务端资源释放;
服务端还要把取消信号继续传递给模型供应商;
取消后的部分回答可以保留,状态标记为
cancelled。
四、如何实现重新生成
重新生成通常基于原始 user 消息创建新分支:
interface RegenerateRequest {
conversationId: string;
sourceUserMessageId: string;
previousAssistantMessageId: string;
requestId: string;
}async function regenerate(
request: RegenerateRequest,
): Promise<void> {
await fetch('/api/chat/regenerate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': request.requestId,
},
body: JSON.stringify(request),
});
}这里要区分:
messageId:标识一条消息;taskId:标识一次正在执行的生成任务;requestId:用于请求幂等;parentId:表示消息分支关系。
重新生成不建议直接覆盖旧回答,否则用户无法切换和比较多个版本。
五、如何避免频繁渲染
防抖不适合持续流,因为只有停止输入后才执行。更合适的是帧级合并或者节流。
let pendingText = '';
let frameId: number | null = null;
function enqueueDelta(delta: string): void {
pendingText += delta;
if (frameId !== null) {
return;
}
frameId = requestAnimationFrame(() => {
const text = pendingText;
pendingText = '';
frameId = null;
// 一帧内只更新一次状态
appendToStreamingMessage(text);
});
}进一步优化包括:
不要每个 token 都创建一份完整消息数组;
将流式临时内容与稳定的历史消息分开;
Markdown 渲染采用 30~100ms 节流;
代码高亮可以等代码块完成后再执行;
长会话使用虚拟列表;
用户主动向上滚动后暂停自动滚动;
只在用户接近底部时自动跟随最新内容。
六、异常与重试设计
建议明确设计消息状态机:
idle
-> pending
-> streaming
-> completed
pending / streaming
-> cancelled
-> error错误类型可以设计为:
type ChatErrorCode =
| 'FIRST_TOKEN_TIMEOUT'
| 'TOTAL_TIMEOUT'
| 'NETWORK_ERROR'
| 'RATE_LIMITED'
| 'SERVER_ERROR'
| 'CONTENT_BLOCKED'
| 'MODEL_UNAVAILABLE';网络中断
如果服务端支持事件重放,每个事件携带递增序号:
id: 42
data: {"delta":"你好"}前端保存最后确认的事件 ID,恢复时提交:
Last-Event-ID: 42服务端从 43 开始补发。如果服务端不支持重放,则应保留已生成内容,并向用户提供“重新生成”按钮,而不是假装可以无损恢复。
超时
至少区分两种超时:
首字超时:长时间没有收到第一个 token;
总请求超时:整个任务执行时间过长。
还要考虑 SSE 心跳。连接期间服务端可能定期发送:
: ping心跳不包含业务内容,但说明连接仍然存活。
限流
遇到 429 Too Many Requests:
读取
Retry-After;展示明确的限流提示;
使用指数退避和随机抖动;
禁止用户连续点击创建重复任务;
自动重试必须设置最大次数;
使用幂等键防止重复生成和重复计费。
重试原则
自动重试必须判断任务是否幂等。不能在不知道原任务状态时直接创建一个新任务,否则可能产生:
两个模型任务同时执行;
重复消息;
重复计费;
返回顺序错乱。
七、多轮消息状态设计
type MessageRole = 'system' | 'user' | 'assistant' | 'tool';
type MessageStatus =
| 'pending'
| 'streaming'
| 'completed'
| 'cancelled'
| 'error';
interface Citation {
title: string;
url: string;
}
interface ToolCall {
id: string;
name: string;
status: 'pending' | 'running' | 'completed' | 'error';
input?: unknown;
output?: unknown;
}
interface ChatMessage {
id: string;
conversationId: string;
// 支持编辑、重试以及多回答分支
parentId?: string;
role: MessageRole;
content: string;
status: MessageStatus;
// assistant 消息可能对应一个生成任务
taskId?: string;
error?: {
code: ChatErrorCode;
message: string;
retryable: boolean;
};
citations?: Citation[];
toolCalls?: ToolCall[];
createdAt: number;
updatedAt: number;
}
interface ConversationState {
id: string;
// 当前展示的消息分支
activeLeafMessageId?: string;
messagesById: Record<string, ChatMessage>;
activeTaskIds: string[];
status: 'idle' | 'generating' | 'error';
}如果不支持分支,也可以暂时使用数组。但如果产品支持“编辑上一条消息”和“重新生成”,树结构更合适:
用户问题
├─ 回答版本 A
└─ 回答版本 B需要注意并发覆盖问题:旧任务返回的数据必须验证 taskId。如果用户已经开始了新任务,不能让旧任务的迟到数据写入当前消息。
八、完整链路
用户提交问题
-> 前端创建 user message
-> 前端创建 pending assistant message
-> BFF 创建 taskId
-> BFF 调用模型或 Agent
-> SSE 返回增量事件
-> 前端 buffer 组装完整事件
-> 临时 token 缓冲区
-> 按帧批量更新 UI
-> completed / cancelled / error
-> 保存最终消息和可观测数据此外还应注意:
API Key 必须保存在 BFF 或 AI Gateway,不能放在浏览器;
对话请求需要认证和权限校验;
工具调用结果和 RAG 内容不能被默认当作可信数据;
需要记录首字延迟、总耗时、错误率、取消率和 token 消耗;
显示引用、工具执行状态以及模型可能存在的不确定性。
九、面试评分点
一份高质量回答应该覆盖:
能解释网络 chunk 与 SSE event 的区别;
使用
TextDecoder、buffer和事件分隔符处理任意分片;同时处理前端 abort 和服务端 cancel;
使用批量更新或节流,而不是简单防抖;
区分网络错误、超时、限流和主动取消;
提到幂等、断线恢复和事件序号;
区分会话、消息、任务和请求 ID;
能支持重新生成、消息分支和旧任务数据隔离;
考虑 BFF、API Key、安全、成本和可观测性。
面试时不必逐字讲完,建议先用 2~3 分钟说清主链路,再根据面试官追问展开代码、状态机和异常恢复。
相关文章
- 面试题-AI相关
从 Embedding 到带出处的知识库问答:最小 RAG 链路与面试复盘
围绕企业知识库问答,复盘从工具调用思路到理解文档分块、向量检索与正文关联的学习过程。结合真实作答与四轮点评,补全 Embedding、相似度、Prompt 组装、引用展示和无资料兜底的基础概念。
- 面试题-AI相关
Tool Calling 完整执行链路:从天气查询到前端状态设计
通过“查询深圳明天天气”的场景,梳理 Tool Calling 中模型、BFF、外部工具和前端的职责边界,并总结工具校验、结果回传、二次生成与 UI 状态设计。
- 面试题-AI相关
LLM 工单提取:从 JSON 校验到歧义优先级的人工确认
模型返回合法 JSON,并不意味着字段符合约定,更不意味着内容忠实于原文。通过一道工单提取面试题及三轮补答,梳理 BFF 校验、有限重试、优先级歧义和前端确认流程。
评论
0 条评论会在审核后展示,邮箱不会公开。