LLM 流式对话 & RAG 前端深度话题
LLM 对话 / Text2SQL / RAG 知识库 — SSE 流式渲染 / 打字机 / Markdown 增量解析
🎬 面试开场怎么引出这个话题
「在菲克斯做了 LLM 相关的三个功能:对话式交互、Text2SQL 自然语言查数据、RAG 知识库检索。前端这块印象最深的坑是流式 Markdown 渲染 — 大模型一个字一个字吐出来,如果每吐一个字就重新解析整个 Markdown 会卡,而且代码块的 ``` 还没闭合时,Markdown 解析器会崩。我做了个 buffer 缓冲未闭合的 token,等闭合了再渲染。另外 SSE 流式用的是 fetch + ReadableStream 手写的,因为 EventSource 不支持自定义 header(要带 token)。」
✅ 抛出 5 个可深挖的点:① SSE 原理 ② 流式 Markdown 解析 ③ 打字机性能 ④ 中断生成 ⑤ Text2SQL/RAG 交互。
AI 是当下最热方向,有实战经验非常加分。前端做 LLM 的坑和后端完全不同,讲深了很出彩。
AI 是当下最热方向,有实战经验非常加分。前端做 LLM 的坑和后端完全不同,讲深了很出彩。
1
SSE vs WebSocket:为什么 LLM 用 SSE
| 维度 | SSE (EventSource) | WebSocket | fetch + ReadableStream(我们用的) |
|---|---|---|---|
| 方向 | 服务端→客户端(单向) | 双向 | 服务端→客户端(单向流) |
| 协议 | HTTP | WS(独立协议) | HTTP |
| 自定义 Header | ❌ 不支持 | ✅ | ✅ |
| 断线重连 | ✅ 浏览器自动 | ❌ 自己写 | ❌ 自己写 |
| 中断请求 | ❌ 难 | ✅ | ✅ AbortController |
| 适合 LLM | 一般(不带 token) | 过重 | ✅ 最合适 |
为什么选 fetch + ReadableStream: LLM 对话本质是单向流(服务端吐字,客户端只接收),不需要 WebSocket 的双向能力。但 EventSource 不支持自定义 header(没法带 Authorization token),所以用 fetch 的 ReadableStream API 手动读流 — 既能带 header,又能用 AbortController 中断生成。这是目前主流 LLM 应用(ChatGPT/Claude)的标准做法。
🧠 类比
SSE = 收音机(只收不听,单向);WebSocket = 电话(双向);LLM 对话 本质是"我发一句话,服务端念一段给我听",收音机模型就够,但要能按暂停(中断)和带身份证(token),所以用 fetch 流。
2
流式渲染核心实现
🔑 fetch + ReadableStream 流式读取
async function streamChat(messages, onChunk, signal) {
const res = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}` // EventSource 带不了
},
body: JSON.stringify({ messages }),
signal // AbortController,用于中断
})
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
// 把字节流解码成文本,拼到 buffer
buffer += decoder.decode(value, { stream: true })
// SSE 格式:data: {...}\n\n,按双换行分割
const lines = buffer.split('\n\n')
buffer = lines.pop() // 最后一段可能不完整,留着下次拼
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const json = JSON.parse(line.slice(6))
if (json.content) {
onChunk(json.content) // 每收到一个字调回调
}
}
}
}
关键细节: ① buffer 拼接:TCP 分包可能把一个 SSE 消息切两半,要 buffer 拼到
\n\n 才完整;② TextDecoder stream:true:中文字符可能被字节边界切断,要流式解码;③ 最后一段 pop 留着:不完整的留到下次。
3
流式 Markdown 解析:代码块未闭合的坑
⚠️ 真实事故:渲染崩溃
现象: LLM 吐代码块时,先吐了 ```js 然后慢慢吐代码。吐到一半时,Markdown 解析器(如 marked)看到 ``` 没闭合,把后面的所有内容当代码渲染,或者直接抛异常,界面闪烁崩溃。
根因: Markdown 是"块级结构",
``` 是代码块的开始标记,必须有匹配的 ``` 结束。流式场景下,结束标记还没来,解析器处于"悬空"状态。解法: 渲染前先补全未闭合的结构(自动加 ```),解析完再渲染。
🔧 流式 Markdown 安全渲染
function renderStreamingMarkdown(text) {
// 1. 检测未闭合的代码块,补全
const codeBlockCount = (text.match(/```/g) || []).length
let safeText = text
if (codeBlockCount % 2 !== 0) {
safeText += '\n```' // 奇数个 ```,补一个闭合
}
// 2. 同样处理其他块级标记:表格(|)、引用(>)等
// 表格:如果 | 数量暗示是表格但没换行结束,补换行
// 3. 解析(marked 或 markdown-it)
const html = marked.parse(safeText)
// 4. XSS 防护:DOMPurify 清洗
return DOMPurify.sanitize(html)
}
⚠️ 安全红线: LLM 输出的内容绝对不能直接 v-html!模型可能被注入产生
<script> 或恶意标签。必须经过 DOMPurify 清洗后才能渲染。这是 AI 应用的安全底线。
性能优化: 不要每个字都重新解析整个 Markdown(很卡)。用 debounce 50-100ms,或者用 requestAnimationFrame 节流,一帧最多解析一次。长文本时只解析增量部分。
4
打字机效果:别用 setInterval
⚠️ 性能坑
很多教程教用 setInterval 每隔 30ms 把 buffer 的下一个字移到显示区。问题:① setInterval 不准;② 每次操作 DOM 高频更新,长回复时卡;③ SSE 来得快时,buffer 堆积越来越多,打字机永远追不上。
正确做法: 直接把 SSE 的 chunk 追加到响应文本(不做打字机延迟),用 CSS 控制视觉或用 rAF 节流渲染。如果非要打字机效果,要监控"堆积" — buffer 比"已显示"多太多时,直接跳到最新。
📋 推荐做法:rAF 节流渲染
let fullText = '' // SSE 累积的完整文本
let displayed = '' // 已渲染到 UI 的
let rafPending = false
// SSE 回调:只累积,不直接渲染
function onChunk(chunk) {
fullText += chunk
scheduleRender()
}
function scheduleRender() {
if (rafPending) return
rafPending = true
requestAnimationFrame(() => {
// 一帧只渲染一次,把累积的都显示
displayed = fullText
content.innerHTML = renderStreamingMarkdown(displayed)
rafPending = false
autoScroll() // 自动滚到底
})
}
光标效果: 打字机的"光标闪烁"用 CSS
animation: blink 1s infinite 加个伪元素,不需要 JS 控制每个字的延迟。这样既有打字机感,又不卡。
5
中断生成:AbortController
🔑 用户点"停止"时中断请求
const controller = new AbortController()
// 发起请求时传入 signal
streamChat(messages, onChunk, controller.signal)
// 用户点"停止生成"按钮
stopButton.onclick = () => {
controller.abort() // 中断 fetch 流
// 已接收的内容保留,后端也会收到 abort 信号停止推理
}
坑: abort 后 fetch 会抛 AbortError,要 try-catch 处理,别当成真错误弹 toast。另外后端要配合:收到连接断开后停止 LLM 推理(否则 token 还在烧)。这是前后端要约定好的。
6
Text2SQL 和 RAG 的前端特殊点
📋 Text2SQL(自然语言查数据)前端
- 流式分两部分: 先流式吐"解释"(自然语言),再吐 SQL(代码块),最后吐查询结果(表格)
- SQL 高亮: 代码块用 highlight.js / Prism 实时高亮,流式时也要处理未闭合
- 结果表格渲染: SQL 执行结果可能是大表,接 vxe-table 虚拟滚动
- 可编辑重跑: 允许用户改生成的 SQL,点"重新执行"
- 意图确认: 模糊查询时,先反问"你是想查 X 吗?"确认后再执行(避免乱跑 SQL)
📋 RAG(知识库检索)前端
- 引用标注: 回答里标注"这段来自文档 X 的第 Y 段",点击可跳转原文
- 来源展示: 回答下方列出检索到的 Top-K 文档片段,可展开
- 反馈机制: "回答有帮助/没帮助"按钮,收集数据迭代
- 多轮上下文: 维护对话历史,但要控制 token 溢出(下面讲)
7
高频追问 & 容易翻车点
1为什么 LLM 用 SSE 不用 WebSocket? 必问
LLM 对话是单向流(服务端→客户端),不需要 WebSocket 的双向能力。WebSocket 过重(独立协议、要维护连接、心跳)。SSE 基于 HTTP 简单,但原生 EventSource 不支持自定义 header(带不了 token),所以用 fetch + ReadableStream — 既能带 header,又能用 AbortController 中断。这是 ChatGPT 等主流方案。
💬 追问「EventSource 为什么不支持 header?」→ 浏览器规范限制,EventSource 构造函数只接受 url 和 withCredentials,设计之初就没考虑 header。要绕过得用 fetch polyfill。
2流式 Markdown 渲染有什么坑? 中等
① 代码块未闭合:LLM 吐 ``` 后慢慢吐代码,闭合标记没来时解析器崩。解法:渲染前检测 ``` 数量,奇数个就补一个闭合;
② 性能:每字重解析整个 Markdown 卡。解法:rAF 节流,一帧最多解析一次;
③ XSS:LLM 输出可能含恶意标签。解法:DOMPurify 清洗后才能 v-html。
② 性能:每字重解析整个 Markdown 卡。解法:rAF 节流,一帧最多解析一次;
③ XSS:LLM 输出可能含恶意标签。解法:DOMPurify 清洗后才能 v-html。
3怎么中断 LLM 生成? 中等
AbortController。 fetch 时传 signal,用户点停止调 controller.abort(),fetch 抛 AbortError(要 catch,别当真错误)。后端配合:检测到连接断开后停止推理(否则 token 烧钱)。已接收的内容保留显示。
4打字机效果怎么实现才不卡? 中等
别用 setInterval。 推荐用 rAF 节流:SSE chunk 累积到 fullText,用 requestAnimationFrame 一帧渲染一次(displayed = fullText)。光标闪烁用 CSS animation 不用 JS。这样既有打字机感,又不卡。如果要真延迟,要监控 buffer 堆积 — 太多就跳到最新。
5上下文太长(token 溢出)前端怎么处理? 中等
① 滑动窗口:只保留最近 N 轮对话发给模型;
② 摘要压缩:把老对话让小模型总结成摘要,再发;
③ Token 计数:前端用 tiktoken 估算当前 token 数,接近上限提示用户"开新对话";
④ UI 提示:超出时友好提示,而不是报错。
注: Token 计数前端只能估算,精确值要后端返。
② 摘要压缩:把老对话让小模型总结成摘要,再发;
③ Token 计数:前端用 tiktoken 估算当前 token 数,接近上限提示用户"开新对话";
④ UI 提示:超出时友好提示,而不是报错。
注: Token 计数前端只能估算,精确值要后端返。
6Text2SQL 前端要注意什么? 业务深问
① 流式分段: 解释(自然语言)→ SQL(代码块)→ 结果(表格),分别渲染;
② SQL 高亮 + 可编辑重跑:允许用户改 SQL 重新执行;
③ 意图确认:模糊查询先反问确认,避免乱跑 SQL(尤其 DELETE/DROP 要拦截);
④ 结果表大数据量:接虚拟滚动;
⑤ 危险 SQL 拦截:前端 + 后端双重拦截 DROP/DELETE/UPDATE。
② SQL 高亮 + 可编辑重跑:允许用户改 SQL 重新执行;
③ 意图确认:模糊查询先反问确认,避免乱跑 SQL(尤其 DELETE/DROP 要拦截);
④ 结果表大数据量:接虚拟滚动;
⑤ 危险 SQL 拦截:前端 + 后端双重拦截 DROP/DELETE/UPDATE。
💬 体现你懂"AI 应用不只是聊天,还有安全约束"。
7RAG 前端怎么展示引用来源? 基础
回答里标注引用编号(如 [1][2]),点击跳转。回答下方列出 Top-K 检索到的文档片段(来源文档名 + 相似度分数 + 片段内容),可展开。再加"有帮助/没帮助"反馈按钮收集数据。这样让用户知道答案哪来的,增加可信度,也方便定位幻觉。
8
30 秒电梯陈述(背熟)
🎤 30 秒版
「在菲克斯做了 LLM 的三个功能:对话、Text2SQL、RAG。前端最有技术含量的是流式渲染 — 用 fetch + ReadableStream 手写 SSE(EventSource 带不了 token),每收到一个 chunk 累积渲染。最大的坑是流式 Markdown:代码块 ``` 没闭合时解析器会崩,我做了一个 buffer 补全未闭合 token 的方案。还有打字机效果不能用 setInterval(卡),用 requestAnimationFrame 节流。安全上,LLM 输出必须 DOMPurify 清洗才能渲染,防 XSS。中断用 AbortController,后端配合停止推理。」
这段话的优势:
① 有完整 LLM 项目(三个功能)→ 真实经验
② 有具体坑(流式 Markdown / 打字机)→ 有深度
③ 懂原理(fetch 流 / AbortController)→ 不只是调 API
④ 懂安全(DOMPurify / SQL 拦截)→ 工程素养
AI 是 2025-2026 最热方向,这个话题讲深了,基本能拿 AI 应用相关的岗位。
① 有完整 LLM 项目(三个功能)→ 真实经验
② 有具体坑(流式 Markdown / 打字机)→ 有深度
③ 懂原理(fetch 流 / AbortController)→ 不只是调 API
④ 懂安全(DOMPurify / SQL 拦截)→ 工程素养
AI 是 2025-2026 最热方向,这个话题讲深了,基本能拿 AI 应用相关的岗位。
面试亮点专题 07 — LLM 流式对话 & RAG 前端深度话题
SSE / fetch ReadableStream · 流式 Markdown · AbortController 中断 · rAF 打字机 · Text2SQL · DOMPurify