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>
)
}