Files
zhuangang/topics/frontend/vue/scenarios/ai-chat-stream.md
T

97 lines
5.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 返回,前端渲染成卡片。这些都有单元测试。”