Files

5.1 KiB
Raw Permalink Blame History

id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week
id title type domain stack tags level prerequisites est_hours related week
frontend.vue.scenario.ai-chat-stream AI 对话界面:流式渲染、任务状态、停止生成和断线续传 scenario frontend
vue
typescript
fastapi
nginx
流式输出
SSE
任务状态
断线重连
Last-Event-ID
结构化输出
AI 原生前端
advanced
frontend.vue.reactivity-composition
3
frontend.vue.interview
python.fastapi.scenario.streaming-chat-api
devops.nginx.scenario.ai-gateway-sse-canary
7
12

AI 对话界面:流式渲染、任务状态、停止生成和断线续传

业务背景与问题

企业知识库助手的前端(Vue 3),用户反馈:

  1. 回答要等十几秒才一次性出现;
  2. 回答到一半网络抖动,整段内容丢失,只能重新提问(重新计费);
  3. 用户切换页面后,后台还在生成,浪费额度;
  4. 不知道助手在做什么(检索?调用工具?);
  5. 引用来源和答案混在文本里,无法点击跳转。

BOSS直聘 2026-10-23 的调研中,“AI 原生前后端”(流式、结构化输出、任务状态、异常恢复)是反复出现的要求,这个场景就是它的典型落地。

约束

  • 需要 POST 请求携带问题和鉴权头(浏览器的 EventSource 只支持 GET、不能自定义请求头)。
  • 经过 Nginx 网关。

方案设计

  1. 事件协议(前后端约定):progress(当前步骤:检索、调用工具)、token(增量文本)、final(结构化结果:答案 + 引用列表)、error;每个事件带递增的 id。
  2. 前端:fetch + ReadableStream 读取,增量 SSE 解析器(处理分块边界);状态机 idle → connecting → streaming → done / error / aborted,界面按状态显示。
  3. 停止生成:AbortController 取消请求;组件卸载时自动取消;后端检测到断开后停止调用模型。
  4. 断线续传:前端记录最后的事件 id,重试时带 Last-Event-ID 请求头;后端把生成中的事件缓存一段时间(例如 Redis 中按会话保存),从该 ID 之后继续发送,而不是重新生成。重试次数有限,带退避。
  5. 结构化结果:引用来源通过 final 事件以 JSON 返回,前端渲染成可点击的卡片,而不是从文本里正则提取。
  6. 渲染性能:正在生成的消息单独存放,Markdown 渲染节流;渲染前做 XSS 清洗。
  7. 网关:Nginx 关闭代理缓冲(见 Nginx SSE 场景)。

关键代码

增量解析器(完整版本 examples/ai-chat-stream/sse.ts,node sse-demo.ts 验证分块切开、多行 data、心跳注释):

export function createSSEParser(onEvent: (e: SSEEvent) => void) {
  let buffer = "";
  return (chunk: string): void => {
    buffer += chunk.replace(/\r\n?/g, "\n");
    let sep: number;
    while ((sep = buffer.indexOf("\n\n")) >= 0) {         // 一个事件以空行结束
      const block = buffer.slice(0, sep);
      buffer = buffer.slice(sep + 2);
      // 解析 event / data / id 字段,忽略以 ":" 开头的注释(心跳)……
    }
  };
}

断线重试(useChatStream.ts):

headers: { "Content-Type": "application/json", ...(lastEventId ? { "Last-Event-ID": lastEventId } : {}) },
...
if (attempt < (opts.maxRetries ?? 2)) {
  await new Promise((r) => setTimeout(r, 300 * 2 ** attempt));
  return send(question, attempt + 1);
}

测试(useChatStream.test.ts)覆盖:拼接 token 和解析 final、断线后带 Last-Event-ID 重试、停止后状态为 aborted。

取舍

选择 优点 缺点
fetch + 自己解析 SSE(本方案) 支持 POST、鉴权头、取消 要自己处理解析和重连
EventSource 浏览器自动重连、自动带 Last-Event-ID 只支持 GET,不能自定义请求头
WebSocket 双向通信 对“一问一答流式返回”来说过重;网关和鉴权更复杂
轮询 简单 延迟高、请求多

易错点

  • 按网络分块直接 JSON.parse,分块切在事件中间就报错。
  • 只在 done 时更新状态,异常时界面一直转圈。
  • 断线重试时重新提问,重复计费,且用户看到内容“跳回开头”。
  • 组件卸载没有取消请求。
  • 引用来源从模型的文本里正则解析,格式一变就失效。
  • 流式过程中每个 token 都触发完整的 Markdown 解析和渲染,长回答卡顿。

面试怎么讲

“AI 对话前端我做了一个组合式函数:用 fetch + ReadableStream 读 SSE,因为要 POST 和带鉴权头;自己写了增量解析器处理分块边界;状态机区分连接中、生成中、完成、失败、已停止;停止生成用 AbortController,组件卸载自动取消。断线时带 Last-Event-ID 重试,后端从缓存的事件继续发,不重新生成。引用来源通过 final 事件的结构化 JSON 返回,前端渲染成卡片。这些都有单元测试。”