--- id: frontend.vue.scenario.ai-chat-stream title: AI 对话界面:流式渲染、任务状态、停止生成和断线续传 type: scenario domain: frontend stack: [vue, typescript, fastapi, nginx] tags: [流式输出, SSE, 任务状态, 断线重连, Last-Event-ID, 结构化输出, AI 原生前端] level: advanced prerequisites: [frontend.vue.reactivity-composition] est_hours: 3 related: [frontend.vue.interview, python.fastapi.scenario.streaming-chat-api, devops.nginx.scenario.ai-gateway-sse-canary] week: [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 场景](../../../devops/nginx/scenarios/ai-gateway-sse-canary.md))。 ## 关键代码 增量解析器(完整版本 [examples/ai-chat-stream/sse.ts](../examples/ai-chat-stream/sse.ts),`node sse-demo.ts` 验证分块切开、多行 data、心跳注释): ```ts 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](../examples/ai-chat-stream/useChatStream.ts)): ```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](../examples/ai-chat-stream/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 返回,前端渲染成卡片。这些都有单元测试。”