前端面试题:如何实现AI对话的流式输出效果
面试官在白板上写了一道题:
写一个简单的AI对话页面,要求AI的回答能流式输出,就是ChatGPT那种一个字一个字蹦出来的效果。
很多人卡在这里。不是JavaScript不熟,是没遇到过这个场景。不知道这叫什么,不知道用什么技术,更不知道代码怎么写。
这篇从原理到完整代码,把这道题讲清楚。
先说结论
ChatGPT那个逐字输出的效果,前端用的不是WebSocket,也不是每隔几百毫秒发一次请求的轮询。
用的是SSE(Server-Sent Events),加上fetch和ReadableStream接收数据并实时显示。
很多人第一反应是WebSocket。逻辑上说得通,双向通信、实时数据。但AI对话这个场景里,大部分时候是单向的:用户发一条消息,AI不断返回内容,不需要双向通信。SSE更轻,更适合这个场景。
普通HTTP请求为什么不行
用fetch发一个POST请求,等后端返回,再显示结果。这是最常见的写法。但对AI对话来说,有一个致命问题:
必须等整个响应生成完,才能拿到数据。
一个完整的AI回答可能要5秒、10秒甚至更久。用户盯着白屏等10秒,什么都没有,然后突然全部出现。这个体验很差。
流式输出解决的就是这个问题:后端每生成一个词,就立刻往前端推。前端边收边显示,用户看到的是实时流动的文字,不是等半天后的突然出现。
SSE是什么
SSE(Server-Sent Events)是一种基于HTTP的服务端推送技术。核心特征有三条:
1. 单向推送,服务端到客户端
和WebSocket的双向通信不同,SSE只能服务端往客户端发。AI对话刚好只需要这一个方向。
2. 基于普通HTTP长连接
不需要额外协议,走标准HTTP或HTTPS。响应头加一条Content-Type: text/event-stream,就变成了一个推送流。
3. 协议格式简单
每条消息是纯文本,固定格式:
data: 这是第一个token\n\n
data: 这是第二个token\n\n
data: [DONE]\n\n每条data:后面是内容,两个换行符\n\n表示这条消息结束。[DONE]是约定好的结束信号,OpenAI接口也用这个。
完整实现代码
从后端到前端,完整跑一遍。
后端:模拟流式AI输出(Node.js)
生产环境里后端会调用真正的AI API(OpenAI、Claude等),这里用Node.js模拟:每50毫秒发一个字,模拟AI生成的速度。
// server.js
const express = require('express')
const app = express()
app.use(express.json())
app.use(express.static('.')) // 托管前端HTML文件
app.post('/api/chat', (req, res) => {
const { message } = req.body
// 设置SSE响应头
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('Access-Control-Allow-Origin', '*')
// 模拟AI生成的回复
const reply = `收到你的问题:"${message}"。这是一个模拟的AI回复,每隔50毫秒发送一个字符,模拟真实的流式输出效果。`
let index = 0
const timer = setInterval(() => {
if (index < reply.length) {
// SSE格式:data: 内容\n\n
res.write(`data: ${reply[index]}\n\n`)
index++
} else {
// 发送结束信号
res.write('data: [DONE]\n\n')
res.end()
clearInterval(timer)
}
}, 50)
// 客户端断开时清理定时器
req.on('close', () => clearInterval(timer))
})
app.listen(3000, () => console.log('服务启动:http://localhost:3000'))安装依赖并启动:
npm install express
node server.js前端:接收流式数据并实时显示
为什么用fetch而不是EventSource?
浏览器原生提供了EventSource API,专门用来接收SSE。但它有一个限制:只支持GET请求,无法发送请求体。
AI接口需要POST请求来传入对话内容(用户的消息、历史记录、模型参数等),所以绕不开这个限制。实际项目里用的都是fetch加上手动解析SSE格式的方案。
<!-- index.html -->
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>流式AI对话</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, sans-serif; background: #f5f5f5; height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.chat-container { width: 600px; max-width: 100%; background: #fff; border-radius: 12px; box-shadow: 0 2px 20px rgba(0,0,0,0.1); display: flex; flex-direction: column; height: 500px; }
.messages { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
.message { padding: 10px 14px; border-radius: 8px; max-width: 80%; line-height: 1.6; }
.message.user { background: #1677ff; color: #fff; align-self: flex-end; }
.message.ai { background: #f0f0f0; color: #333; align-self: flex-start; }
.message.ai.loading::after { content: '▌'; animation: blink 0.7s infinite; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.input-area { padding: 16px; border-top: 1px solid #eee; display: flex; gap: 8px; }
.input-area input { flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: 8px; outline: none; font-size: 14px; }
.input-area button { padding: 8px 16px; background: #1677ff; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; }
.input-area button:disabled { background: #ccc; cursor: not-allowed; }
.stop-btn { background: #ff4d4f !important; }
</style>
</head>
<body>
<div class="chat-container">
<div class="messages" id="messages"></div>
<div class="input-area">
<input type="text" id="input" placeholder="输入消息..." />
<button id="sendBtn" onclick="sendMessage()">发送</button>
<button id="stopBtn" class="stop-btn" onclick="stopGeneration()" style="display:none">停止</button>
</div>
</div>
<script>
let abortController = null // 用于停止生成
function appendMessage(role, text = '') {
const messages = document.getElementById('messages')
const div = document.createElement('div')
div.className = `message ${role}`
div.textContent = text
messages.appendChild(div)
messages.scrollTop = messages.scrollHeight
return div
}
async function sendMessage() {
const input = document.getElementById('input')
const message = input.value.trim()
if (!message) return
input.value = ''
appendMessage('user', message)
// 创建AI消息气泡,先为空,等流式填充
const aiDiv = appendMessage('ai')
aiDiv.classList.add('loading')
// 禁用发送,显示停止按钮
document.getElementById('sendBtn').disabled = true
document.getElementById('stopBtn').style.display = 'block'
// AbortController用于中途停止
abortController = new AbortController()
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
signal: abortController.signal // 绑定停止信号
})
// 拿到ReadableStream,逐块读取
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
// 解码当前这一块数据(可能包含多行SSE)
buffer += decoder.decode(value, { stream: true })
// 按行拆分,逐行解析SSE格式
const lines = buffer.split('\n')
buffer = lines.pop() // 最后一行可能不完整,留着等下次拼接
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6) // 去掉"data: "前缀
if (data === '[DONE]') break
// 逐字追加到AI消息气泡
aiDiv.textContent += data
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight
}
}
} catch (err) {
if (err.name === 'AbortError') {
aiDiv.textContent += ' [已停止]'
} else {
aiDiv.textContent = '出错了,请重试'
console.error(err)
}
} finally {
// 移除打字光标,恢复按钮状态
aiDiv.classList.remove('loading')
document.getElementById('sendBtn').disabled = false
document.getElementById('stopBtn').style.display = 'none'
abortController = null
}
}
function stopGeneration() {
if (abortController) {
abortController.abort()
}
}
// 回车发送
document.getElementById('input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) sendMessage()
})
</script>
</body>
</html>把server.js和index.html放在同一个目录,运行node server.js,打开http://localhost:3000,发一条消息,就能看到字一个一个蹦出来的效果。
代码里的几个细节
1. decoder.decode(value, { stream: true })
{ stream: true }很关键。TCP传输时,一个汉字的3字节UTF-8编码可能被拆成两次read()。如果不加这个参数,每次decode独立处理,中间被切割的字节会变成乱码。{ stream: true }告诉decoder这是连续流,跨块的字节拼起来再解码。
2. buffer缓冲区的设计
每次read()拿到的数据块,不一定恰好是完整的几行SSE。可能一块数据里包含3.5条消息,最后半条还没到。buffer = lines.pop()把不完整的最后一行暂存起来,等下次read()拼上后再处理。
3. AbortController停止生成
停止生成不是前端自己假装停了。abort()会直接中断fetch请求,后端收到连接断开信号后也会停止生成(代码里的req.on('close', ...)处理了这个)。
面试必考:SSE和WebSocket有什么区别
这道对比题几乎必问,记清楚下面这张表:
| 维度 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 单向(服务端到客户端) | 双向 |
| 协议 | 基于HTTP | 独立TCP协议(握手阶段用HTTP) |
| 自动重连 | 浏览器内置,自动重连 | 需要手动实现 |
| POST支持 | EventSource不支持,用fetch绕过 | 可以双向传任意数据 |
| 实现复杂度 | 低 | 较高(需维护连接状态) |
| 适合场景 | AI流式输出、通知推送、股价更新 | 实时协作、多人游戏、在线聊天室 |
AI对话选SSE(fetch版)的理由很简单:通信方向是单向的,HTTP够用,不需要引入WebSocket的连接管理成本。
一个追问:真实AI API怎么接
上面的后端是模拟数据。如果接真实的OpenAI或Claude API,后端的核心差别只有一块:把模拟的setInterval换成AI SDK的流式调用,然后把内容转发给前端。
// 接OpenAI流式API的后端核心逻辑(Node.js)
const OpenAI = require('openai')
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
app.post('/api/chat', async (req, res) => {
const { message } = req.body
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
const stream = await client.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: message }],
stream: true, // 开启流式
})
for await (const chunk of stream) {
const token = chunk.choices[0]?.delta?.content
if (token) {
res.write(`data: ${token}\n\n`)
}
}
res.write('data: [DONE]\n\n')
res.end()
})前端代码不用改一个字。后端负责调用AI的流式接口、转成SSE格式,前端只管接收SSE。两层之间的协议是固定的。
回到面试题
面试官问这道题,考察的是三个层次:
第一层:知不知道这个场景用什么技术。答案是SSE加fetch加ReadableStream,不是WebSocket。
第二层:能不能说清楚实现思路。后端推SSE格式,前端用ReadableStream逐块解析。
第三层:有没有处理过细节。比如UTF-8跨块、buffer缓冲、AbortController停止、断线重连等。
第一层是门槛,很多人卡在这里。第二层决定offer拿不拿得到。第三层说明真正做过,面试官会记住。
流式输出是AI前端最基础的一块,也是进入这条赛道的入场券。
本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!