跳到主要内容
← 返回文章列表
面试题-AI相关2026/08/05约 9 分钟读完

面试题:AI 流式对话

热门面试题-AI 流式对话

面试题:AI 流式对话
文章目录 21

面试题:AI 流式对话

你正在开发一个类似 ChatGPT 的对话页面,服务端通过 流式返回模型生成内容。

请你设计前端的完整处理方案,重点说明:

  • 如何解析可能被任意拆分的数据片段?

  • 如何实现“停止生成”和重新生成?

  • 如何避免频繁更新内容造成页面卡顿?

  • 网络中断、超时或服务端限流时如何处理?

  • 多轮消息的状态应该如何设计?

请先像真实面试一样口头作答。我会根据你的回答点评,并继续追问。

我的回答

如何解析可能被任意拆分的数据片段?
答:每个提问任务和对应提问任务都应有一个唯一的key,可以根据这个key 来解析和划定数据片段
如何实现“停止生成”和重新生成?
答:sse是单向数据源,停止生成应当向服务端重新提交任务,并将需要停止的任务id提交,至于重新生成也是同样原理,根据任务id重新提交;
如何避免频繁更新内容造成页面卡顿?
答:可以采用防抖或者帧加载,异步返回sse返回的多项内容
网络中断、超时或服务端限流时如何处理?
答:这些情况类似于前端收不到任务结束代表的key-id,导致任务一直在进行中,可以采用超时结束,或者重发任务id询问服务端,若无返回,则弹异常告警
多轮消息的状态应该如何设计?
答:多轮消息的话要采用特别的状态键,在没有收到任务结束代表的key都代表任务还在进行中,其中可以采用一些key来不断和用户交互和向服务端发消息

我的问答解析

整体思路有一定工程意识,尤其提到了任务 ID、取消接口、帧级更新和超时处理。但核心问题是:你把“业务任务状态”和“SSE 数据流解析”混在了一起。综合评分:5.5/10。

正确点:

  • 用唯一 taskId/messageId 关联请求、消息和取消操作,方向正确。

  • 停止生成需要通知服务端终止模型任务,不能只停止页面显示。

  • 使用帧级批量更新降低渲染频率,方向正确。

  • 考虑了超时、任务状态查询和异常提示。

  • 意识到消息需要区分“生成中”和“已结束”。

遗漏或需要修正的地方:

  1. 任意分片不能靠唯一 key 解决

taskId 只能识别“这是谁的消息”,不能确定数据边界。网络层可能把一条 SSE 事件拆成多块:

第一次读取:data: {"delta":"hel
第二次读取:lo"}\n
第三次读取:\n

也可能一次读取返回多条事件。因此前端必须:

  • 使用 TextDecoder 增量解码字节流;

  • 用字符串 buffer 保存未处理内容;

  • 根据 SSE 的空行分隔符 \n\n 或 \r\n\r\n 提取完整事件;

  • 不完整部分继续留在 buffer 中;

  • 事件完整后再解析 data:,不能对每个网络分片直接 JSON.parse。

  1. 停止生成需要同时取消前端连接

比较完整的做法是:

  • 前端通过 AbortController.abort() 立即终止当前 fetch 流;

  • 同时调用服务端取消接口,例如 POST /tasks/:id/cancel;

  • 服务端停止模型生成并释放资源;

  • 将消息状态设置为 cancelled,保留已经生成的文本。

如果只发取消请求但不关闭本地连接,前端仍可能继续接收缓存中的数据。

  1. 防抖不太适合流式输出

防抖会持续等待“输入停止”,模型不断生成时可能很久都不更新页面。更适合:

  • 先把收到的 token 累积到临时缓冲区;

  • 每个 requestAnimationFrame 或每隔约 30~50ms 批量提交一次;

  • Markdown 解析也进行节流;

  • 长对话结合虚拟列表,避免所有消息重复渲染。

  1. 网络异常不等于没收到结束标记

还需要区分:

  • cancelled:用户主动停止;

  • timeout:请求超时;

  • network_error:连接中断;

  • rate_limited:服务端返回 429;

  • server_error:服务端异常;

  • completed:正常完成。

重试时还要考虑幂等性,避免创建两个模型任务。可以携带 requestId,或者从服务端记录的事件序号继续恢复。

  1. 多轮消息应使用结构化状态

不建议仅靠“特殊 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 汉字的字节也可能被拆开。因此需要同时解决:

  1. 字节解码边界;

  2. SSE 事件边界;

  3. 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 分钟说清主链路,再根据面试官追问展开代码、状态机和异常恢复。

评论

0 条

评论会在审核后展示,邮箱不会公开。

正在确认登录状态

5000 / 5000