🎬 面试开场怎么引出这个话题
「在菲克斯做了 LLM 相关的三个功能:对话式交互、Text2SQL 自然语言查数据、RAG 知识库检索。前端这块印象最深的坑是流式 Markdown 渲染 — 大模型一个字一个字吐出来,如果每吐一个字就重新解析整个 Markdown 会卡,而且代码块的 ``` 还没闭合时,Markdown 解析器会崩。我做了个 buffer 缓冲未闭合的 token,等闭合了再渲染。另外 SSE 流式用的是 fetch + ReadableStream 手写的,因为 EventSource 不支持自定义 header(要带 token)。」
✅ 抛出 5 个可深挖的点:① SSE 原理 ② 流式 Markdown 解析 ③ 打字机性能 ④ 中断生成 ⑤ Text2SQL/RAG 交互。
AI 是当下最热方向,有实战经验非常加分。前端做 LLM 的坑和后端完全不同,讲深了很出彩。
1

SSE vs WebSocket:为什么 LLM 用 SSE

维度SSE (EventSource)WebSocketfetch + ReadableStream(我们用的)
方向服务端→客户端(单向)双向服务端→客户端(单向流)
协议HTTPWS(独立协议)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。
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 计数前端只能估算,精确值要后端返。
6Text2SQL 前端要注意什么? 业务深问
流式分段: 解释(自然语言)→ SQL(代码块)→ 结果(表格),分别渲染;
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 应用相关的岗位。

面试亮点专题 07 — LLM 流式对话 & RAG 前端深度话题

SSE / fetch ReadableStream · 流式 Markdown · AbortController 中断 · rAF 打字机 · Text2SQL · DOMPurify

← 返回专题首页