前端面试题:如何实现AI对话的流式输出效果

更新日期: 2026-04-23 阅读: 25 标签: SSE

面试官在白板上写了一道题:

写一个简单的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有什么区别

这道对比题几乎必问,记清楚下面这张表:

维度SSEWebSocket
通信方向单向(服务端到客户端)双向
协议基于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前端最基础的一块,也是进入这条赛道的入场券。

本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!

链接: https://fly63.com/article/detial/13686

相关推荐

前端实现AI对话:使用SSE技术提升实时交互体验

在现代前端开发中,AI智能对话功能越来越常见。实现这类功能时,关键是要让用户能够实时看到AI返回的内容,而不是等待全部生成完毕才显示。Server-Sent Events(SSE)技术为此提供了一种高效且易于实现的解决方案。

从 WebSocket 到 SSE:实时通信的轻量选择

在实时网络应用领域,WebSocket 一直被认为是首选方案。无论是聊天应用、在线游戏还是协同编辑工具,WebSocket 的强大双向通信能力都让它成为开发者的首选。

Nginx配置SSE和WebSocket代理的完整指南

在现代网站开发中,实时通信功能已经很常见了。无论是用SSE实现服务器向客户端推送消息,还是用WebSocket建立双向通信,当项目部署到线上环境时Nginx配置不对经常导致本地能运行,线上就失效的问题。

AI对话为什么都用SSE?WebSocket其实用错了地方

做AI对话功能的时候,前后端怎么传数据是个绕不开的问题。轮询、SSE、WebSocket,到底该用哪个?这篇文章把三种方式放在一起对比,看完你就能直接做出选型判断。

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!