Skip to content

AI 流式输出技术

1. SSE(Server-Sent Events)

题目:请简述一下 SSE(Server-Sent Events)的工作原理,它与 WebSocket 有什么区别?

答案要点:

SSE 是一种基于 HTTP 协议的单向通信机制,允许服务器主动向客户端推送文本数据,常用于 AI 聊天的流式回复。

  • 协议基础: 基于标准 HTTP 协议,设置 Content-Type 为 text/event-stream
  • 连接特性: 长连接,默认支持断线重连(Retry 机制)
  • 数据格式:data: 开头的文本流,支持自定义事件类型
  • 对比 WS: SSE 是单向的(Server to Client),更轻量,支持 HTTP 代理和自动重连;WebSocket 是全双工的,更复杂

代码示例:

javascript
// 客户端
const eventSource = new EventSource('/api/chat')

eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data)
  appendMessage(data.content)
}

eventSource.onerror = (error) => {
  console.error('SSE error:', error)
  eventSource.close()
}

// 手动中断
function stopGeneration() {
  eventSource.close()
}

常见坑:

  • 浏览器连接数限制(Chrome 同域名限制 6 个 SSE 连接)
  • 必须处理 HTTP/2.0 以绕过连接数限制

追问:

  • 如果后端返回的是二进制流,SSE 还能处理吗?

题目:什么是 SSE(Server-Sent Events)?它与 WebSocket 有什么区别?

答案要点:

SSE 是一种基于 HTTP 的单向推送技术,非常适合大模型这种流式输出文本的场景。

  • 协议特性: 基于标准 HTTP 协议,支持断线重连,数据格式默认为 text/event-stream
  • 方向性: SSE 是单向的(服务端到客户端),WebSocket 是全双工的(双向)
  • 复杂度: SSE 实现简单,对代理服务器友好;WebSocket 协议较重,需要独立握手
  • AI 应用: 大模型生成回复时,前端通常通过 SSE 接收逐字返回的结果

常见坑:

  • 认为 SSE 可以发送二进制数据(原生仅支持文本)
  • 忽略了浏览器对同一域名下 SSE 连接数的限制(HTTP/1.1 下通常为 6 个)

追问:

  • 如果后端接口是 POST 请求,还能用 SSE 吗?(提示:fetch 的 ReadableStream)

2. Fetch ReadableStream

题目:如何使用 Fetch API 处理流式响应(ReadableStream)?

答案要点:

Fetch API 的 response.body 是一个 ReadableStream 对象,可以通过 getReader() 方法逐块读取数据。

  • 读取流程: 调用 reader.read() 获取包含 value 和 done 的对象
  • 文本解码: 使用 TextDecoder 将 Uint8Array 字节流转换为可读字符串
  • 递归处理: 通常使用递归函数或 while 循环持续读取,直到 done 为 true
  • 异常处理: 需要处理网络中断或流关闭时的资源释放

代码示例:

javascript
async function fetchStream(url) {
  const response = await fetch(url)
  const reader = response.body.getReader()
  const decoder = new TextDecoder()

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    // 处理中文截断问题
    const chunk = decoder.decode(value, { stream: true })
    console.log(chunk)

    // 实时渲染到页面
    appendText(chunk)
  }
}

// 支持中断
const controller = new AbortController()
fetch(url, { signal: controller.signal })
// 取消请求
controller.abort()

常见坑:

  • 忘记处理分段数据导致的中文字符截断(乱码)
  • 没有正确关闭 reader 导致内存泄漏

追问:

  • 如何将读取到的流实时渲染到 React/Vue 组件中?
  • 如何计算流式输出的下载进度?

3. 打字机效果

题目:在处理 AI 返回的长文本时,前端如何实现打字机效果?

答案要点:

打字机效果的核心是动态截取字符串并逐步更新 DOM 节点,在 AI 场景下通常配合流式数据实时渲染。

  • 实现方式: 利用定时器(setInterval)或 requestAnimationFrame 逐字显示
  • 性能优化: 对于超长文本,应避免频繁操作 innerHTML,改用 textContent 或 DocumentFragment
  • 自动滚动: 监听 DOM 变化,判断用户是否在底部,决定是否执行 scrollIntoView
  • 停止机制: 需要提供中断信号(AbortController)来停止流接收和动画

代码示例:

javascript
class Typewriter {
  constructor(element, options = {}) {
    this.element = element
    this.speed = options.speed || 30
    this.text = ''
    this.index = 0
    this.rafId = null
  }

  start(text) {
    this.text = text
    this.index = 0
    this.type()
  }

  type() {
    if (this.index < this.text.length) {
      this.element.textContent += this.text.charAt(this.index)
      this.index++
      this.rafId = requestAnimationFrame(() => this.type())
    }
  }

  stop() {
    if (this.rafId) {
      cancelAnimationFrame(this.rafId)
    }
  }
}

// 配合流式数据
const typewriter = new Typewriter(document.getElementById('output'))

eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data)
  typewriter.start(data.content)
}

常见坑:

  • 渲染速度快于打字速度导致的积压
  • 标签未闭合时的渲染闪烁

追问:

  • 如果文本中包含 Markdown 语法,如何保证渲染正确性?

题目:实现 AI 打字机效果时,如何解决"字符吞噬"或"渲染卡顿"问题?

答案要点:

  • 节流渲染: 不要每个字符都更新 DOM,可以每 N 个字符或每 M 毫秒批量更新一次
  • 虚拟滚动: 对于超长对话,只渲染可视区域的内容
  • Web Worker: 将 Markdown 解析等耗时操作移到 Worker 线程
  • requestAnimationFrame: 利用浏览器的渲染周期进行更新

代码示例:

javascript
class SmoothTypewriter {
  constructor(element) {
    this.element = element
    this.buffer = ''
    this.isTyping = false
  }

  addText(text) {
    this.buffer += text
    if (!this.isTyping) {
      this.type()
    }
  }

  type() {
    this.isTyping = true

    // 批量处理,每次最多渲染 3 个字符或等待 16ms
    const chunk = this.buffer.slice(0, 3)
    this.buffer = this.buffer.slice(3)

    this.element.textContent += chunk

    if (this.buffer.length > 0) {
      requestAnimationFrame(() => this.type())
    } else {
      this.isTyping = false
    }
  }
}

4. Markdown 实时渲染

题目:在 React/Vue 中渲染流式返回的 Markdown 文本,有哪些性能优化手段?

答案要点:

流式渲染会导致组件频繁重绘,优化核心在于减少解析频率和控制 DOM 更新范围。

  • 增量解析: 不要每次都解析全量文本,尝试记录已解析位置,仅对新片段进行增量处理
  • 节流渲染: 通过 requestAnimationFrame 或节流函数控制渲染频率,避免每收到一个字符就 setState
  • 静态节点跳过: 利用 React.memo 或 Vue 的 v-once 锁定已生成的历史对话片段
  • 插件优化: 禁用 Markdown 插件中耗时较长的功能(如复杂的数学公式同步渲染)

代码示例(React):

jsx
import { memo, useState, useCallback } from 'react'
import Markdown from 'react-markdown'

// 记忆化 Markdown 组件
const MemoizedMarkdown = memo(({ content }) => {
  return <Markdown>{content}</Markdown>
})

function ChatMessage({ streamContent }) {
  const [displayContent, setDisplayContent] = useState('')

  // 节流更新
  const throttledUpdate = useCallback(
    throttle((content) => {
      setDisplayContent(content)
    }, 50),
    [],
  )

  useEffect(() => {
    throttledUpdate(streamContent)
  }, [streamContent])

  return <MemoizedMarkdown content={displayContent} />
}

常见坑:

  • Markdown 解析器(如 marked)在处理未闭合的代码块标签时可能导致页面结构崩溃

追问:

  • 如果 Markdown 里包含 LaTeX 数学公式,渲染卡顿怎么处理?

题目:如何实现一个高性能的 Markdown 渲染组件,支持实时解析 AI 输出?

答案要点:

高性能渲染需要结合增量解析和虚拟 DOM 优化,避免全量重新渲染。

  • 增量解析: 记录已解析的索引,仅对新到达的 Stream 片段进行解析和追加
  • 库选型: 常用 markdown-it 或 marked,配合 highlight.js 或 prism.js 处理代码高亮
  • 渲染优化: 在 React/Vue 中使用 memo 缓存已生成的节点,防止光标跳动
  • 安全性: 必须使用 DOMPurify 等工具过滤 XSS 攻击向量

代码示例:

javascript
import DOMPurify from 'dompurify'
import { marked } from 'marked'

class StreamingMarkdown {
  constructor() {
    this.rawText = ''
    this.parsedHtml = ''
    this.parser = new marked.Renderer()
  }

  append(chunk) {
    this.rawText += chunk
    // 增量解析,只解析新增部分
    this.parsedHtml = DOMPurify.sanitize(marked(this.rawText))
    return this.parsedHtml
  }

  getHtml() {
    return this.parsedHtml
  }
}

常见坑:

  • 每次流更新都重新渲染整个 Markdown 文档,导致 CPU 占用过高
  • 代码高亮插件在流式输出时频繁重绘导致闪烁

追问:

  • 如何在 Markdown 渲染过程中支持自定义的 AI 交互组件(如:图表、按钮)?

5. 首字响应时间(TTFT)

题目:如何优化 AI 应用的首字响应时间(TTFT)?

答案要点:

首字响应时间(Time To First Token)是衡量 AI 应用用户体验的关键指标。

  • 预连接: 使用 HTTP/2 或 HTTP/3 保持长连接,避免每次请求都建立连接
  • 边缘计算: 将模型部署在靠近用户的边缘节点
  • 流式输出: 不等完整响应,收到第一个 token 立即展示
  • 骨架屏: 在等待响应时显示加载动画或占位符
  • 缓存策略: 缓存常见问题的首 token 或预生成部分内容

代码示例:

javascript
// 预建立 SSE 连接
class AIConnection {
  constructor() {
    this.eventSource = null
    this.reconnectInterval = 3000
  }

  connect() {
    this.eventSource = new EventSource('/api/stream')
    this.eventSource.onopen = () => {
      console.log('连接已建立,准备接收数据')
    }
  }

  // 断线重连
  reconnect() {
    setTimeout(() => this.connect(), this.reconnectInterval)
  }
}

6. 中断与取消

题目:如果需要用户在生成过程中实时中断,SSE 应该怎么做?

答案要点:

SSE 原生支持通过 EventSource.close() 关闭连接,但更好的做法是通过 AbortController 实现更精细的控制。

代码示例:

javascript
class AIChat {
  constructor() {
    this.controller = null
  }

  async sendMessage(message) {
    this.controller = new AbortController()

    try {
      const response = await fetch('/api/chat', {
        method: 'POST',
        body: JSON.stringify({ message }),
        signal: this.controller.signal,
      })

      const reader = response.body.getReader()

      while (true) {
        const { done, value } = await reader.read()
        if (done) break

        // 处理数据...
      }
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log('用户中断生成')
      }
    }
  }

  stop() {
    if (this.controller) {
      this.controller.abort()
    }
  }
}

7. 长列表性能优化

题目:AI 对话界面中,当消息列表非常长时,如何保证滚动和渲染的性能?

答案要点:

  • 虚拟列表(Virtual List): 只渲染可视区域的消息
  • 分页加载: 历史消息分页加载,避免一次性渲染过多
  • 消息合并: 将连续的短消息合并显示
  • 图片懒加载: 消息中的图片延迟加载
  • DOM 回收: 离开可视区域的消息从 DOM 中移除

代码示例(简化版虚拟列表):

javascript
function VirtualMessageList({ messages, itemHeight, containerHeight }) {
  const [scrollTop, setScrollTop] = useState(0)

  const visibleCount = Math.ceil(containerHeight / itemHeight)
  const startIndex = Math.floor(scrollTop / itemHeight)
  const endIndex = Math.min(startIndex + visibleCount + 1, messages.length)

  const visibleMessages = messages.slice(startIndex, endIndex)
  const offsetY = startIndex * itemHeight

  return (
    <div
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={(e) => setScrollTop(e.target.scrollTop)}
    >
      <div style={{ height: messages.length * itemHeight }}>
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {visibleMessages.map((msg, idx) => (
            <MessageItem key={msg.id} message={msg} height={itemHeight} />
          ))}
        </div>
      </div>
    </div>
  )
}