5.1 KiB
5.1 KiB
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 |
|
|
advanced |
|
3 |
|
|
AI 对话界面:流式渲染、任务状态、停止生成和断线续传
业务背景与问题
企业知识库助手的前端(Vue 3),用户反馈:
- 回答要等十几秒才一次性出现;
- 回答到一半网络抖动,整段内容丢失,只能重新提问(重新计费);
- 用户切换页面后,后台还在生成,浪费额度;
- 不知道助手在做什么(检索?调用工具?);
- 引用来源和答案混在文本里,无法点击跳转。
BOSS直聘 2026-10-23 的调研中,“AI 原生前后端”(流式、结构化输出、任务状态、异常恢复)是反复出现的要求,这个场景就是它的典型落地。
约束
- 需要 POST 请求携带问题和鉴权头(浏览器的
EventSource只支持 GET、不能自定义请求头)。 - 经过 Nginx 网关。
方案设计
- 事件协议(前后端约定):
progress(当前步骤:检索、调用工具)、token(增量文本)、final(结构化结果:答案 + 引用列表)、error;每个事件带递增的id。 - 前端:
fetch+ReadableStream读取,增量 SSE 解析器(处理分块边界);状态机idle → connecting → streaming → done / error / aborted,界面按状态显示。 - 停止生成:
AbortController取消请求;组件卸载时自动取消;后端检测到断开后停止调用模型。 - 断线续传:前端记录最后的事件
id,重试时带Last-Event-ID请求头;后端把生成中的事件缓存一段时间(例如 Redis 中按会话保存),从该 ID 之后继续发送,而不是重新生成。重试次数有限,带退避。 - 结构化结果:引用来源通过
final事件以 JSON 返回,前端渲染成可点击的卡片,而不是从文本里正则提取。 - 渲染性能:正在生成的消息单独存放,Markdown 渲染节流;渲染前做 XSS 清洗。
- 网关: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 返回,前端渲染成卡片。这些都有单元测试。”