第 5 篇:让 AI 回答像 ChatGPT 一样逐字输出:实现 SSE 流式响应

3 阅读7分钟

项目地址

说明:当前在线版本部署在 Vercel,国内网络访问可能不稳定。如果打不开,可以直接查看 GitHub 源码并在本地启动。


前言

上一篇我们完成了一个很关键的架构升级:

React 前端
  ↓
Express BFF
  ↓
Dify API

这样 Dify API Key 就不再暴露在浏览器里了。

不过上一篇还有一个体验问题:AI 回答是一次性返回的。

也就是:

用户提问
  ↓
等待几秒
  ↓
完整答案一次性出现

这和 ChatGPT 的体验差很多。

真正的 AI 产品通常会让回答逐步输出:

用户提问
  ↓
AI 立即开始生成
  ↓
答案一段一段显示

这一篇我们就来实现这个能力。

目标是:

把 Dify 的 blocking 模式改成 streaming 模式,并在前端逐步渲染 AI 回答。


本篇目标

完成后,我们要实现:

1. 后端新增 /api/chat/stream 接口
2. Dify 请求改成 response_mode: streaming
3. Express 将 Dify 流式响应转发给前端
4. 前端读取 ReadableStream
5. 解析 SSE data 行
6. 每收到一段 answer,就追加到最后一条 AI 消息中

最终效果是:

AI 回答像 ChatGPT 一样逐步出现

什么是 SSE?

SSE 全称是 Server-Sent Events。

它是一种服务端向浏览器持续推送数据的方式。

在 AI 场景里,SSE 非常常见。大模型不是一次性生成完整答案,而是一边生成一边返回。

SSE 数据通常长这样:

data: {"event":"message","answer":"前端"}

data: {"event":"message","answer":"架构"}

data: {"event":"message","answer":"主要包括"}

每一段以 data: 开头,后面是 JSON 字符串。

我们要做的事情就是:

读取流
  ↓
按行拆分
  ↓
找到 data: 开头的行
  ↓
JSON.parse
  ↓
取出 answer
  ↓
追加到页面

Dify 的 blocking 和 streaming

上一篇我们调用 Dify 时使用的是:

response_mode: 'blocking'

blocking 模式会等大模型生成完整答案后,一次性返回 JSON。

这一篇改成:

response_mode: 'streaming'

streaming 模式会返回 SSE 流。

也就是说,后端不能再简单地:

const data = await response.json()

而是要读取 response.body,并把它继续写给前端。


第一步:新增后端流式接口

打开:

server/index.ts

新增一个接口:

app.post('/api/chat/stream', async (req, res) => {
  try {
    const { message, conversationId } = req.body

    if (!message || typeof message !== 'string') {
      return res.status(400).json({ error: 'message is required' })
    }

    const difyResponse = await fetch(DIFY_API_URL, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${DIFY_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        inputs: {},
        query: message,
        response_mode: 'streaming',
        conversation_id: conversationId || '',
        user: DIFY_USER,
      }),
    })

    if (!difyResponse.ok || !difyResponse.body) {
      const errorText = await difyResponse.text()

      return res.status(difyResponse.status).json({
        error: 'Dify API stream request failed',
        detail: errorText,
      })
    }

    res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
    res.setHeader('Cache-Control', 'no-cache, no-transform')
    res.setHeader('Connection', 'keep-alive')

    const reader = difyResponse.body.getReader()
    const decoder = new TextDecoder()

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

      if (done) break

      const chunk = decoder.decode(value, { stream: true })
      res.write(chunk)
    }

    res.end()
  } catch (error) {
    console.error('[POST /api/chat/stream]', error)

    if (!res.headersSent) {
      return res.status(500).json({
        error: 'Internal server error',
        detail: error instanceof Error ? error.message : 'Unknown error',
      })
    }

    res.write(
      `data: ${JSON.stringify({
        event: 'error',
        message: error instanceof Error ? error.message : 'Unknown error',
      })}\n\n`
    )
    res.end()
  }
})

这个接口做的事情是:

前端请求 /api/chat/stream
  ↓
Express 请求 Dify streaming
  ↓
Express 读取 Dify 返回的流
  ↓
Express 原样写回给浏览器

为什么后端要设置这些 Header?

res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-transform')
res.setHeader('Connection', 'keep-alive')

Content-Type

告诉浏览器这是 SSE 流:

text/event-stream

Cache-Control

避免代理或浏览器缓存、压缩、改写流式内容。

Connection

保持连接不断开。

这些 Header 对流式响应很重要。


第二步:新建前端流式 API

新建:

src/api/difyStream.ts

写入:

export type RetrieverResource = {
  dataset_name?: string
  document_name?: string
  content?: string
}

export type StreamCallbacks = {
  onMessage: (text: string) => void
  onConversationId?: (conversationId: string) => void
  onSources?: (sources: RetrieverResource[]) => void
  onError?: (error: Error) => void
  onDone?: () => void
}

export async function sendMessageToDifyStream(
  message: string,
  conversationId: string | undefined,
  callbacks: StreamCallbacks
) {
  const response = await fetch('/api/chat/stream', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      message,
      conversationId,
    }),
  })

  if (!response.ok || !response.body) {
    const errorText = await response.text()
    throw new Error(errorText || '请求失败')
  }

  const reader = response.body.getReader()
  const decoder = new TextDecoder('utf-8')

  let buffer = ''

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

    if (done) {
      callbacks.onDone?.()
      break
    }

    buffer += decoder.decode(value, { stream: true })

    const lines = buffer.split('\n')
    buffer = lines.pop() || ''

    for (const line of lines) {
      const trimmed = line.trim()

      if (!trimmed.startsWith('data:')) continue

      const jsonStr = trimmed.replace(/^data:\s*/, '')

      if (jsonStr === '[DONE]') {
        callbacks.onDone?.()
        return
      }

      try {
        const data = JSON.parse(jsonStr)

        if (data.event === 'message' && data.answer) {
          callbacks.onMessage(data.answer)
        }

        if (data.conversation_id) {
          callbacks.onConversationId?.(data.conversation_id)
        }

        if (data.event === 'message_end') {
          const sources = data.metadata?.retriever_resources || []
          callbacks.onSources?.(sources)
        }

        if (data.event === 'error') {
          callbacks.onError?.(new Error(data.message || 'Dify stream error'))
        }
      } catch {
        // 忽略无法解析的 SSE 行
      }
    }
  }
}

这段代码是整个流式输出的核心。


第三步:为什么需要 buffer?

流式数据不是每次都刚好按完整行返回。

可能一次读取到:

data: {"event":"message","answer":"前

下一次才读到:

端"}

如果直接 JSON.parse,就会报错。

所以我们需要一个 buffer

let buffer = ''

每次读取后追加:

buffer += decoder.decode(value, { stream: true })

然后按换行拆分:

const lines = buffer.split('\n')
buffer = lines.pop() || ''

最后一段可能是不完整的,先放回 buffer,等下一次数据来了再继续拼。

这是处理流式响应时非常常见的写法。


第四步:修改 App 的发送逻辑

原来我们是:

const result = await sendMessageToDify(text, conversationId)

现在要改成:

await sendMessageToDifyStream(text, conversationId, callbacks)

核心思路是:

1. 用户消息先加入列表
2. 再加入一条空的 AI 消息
3. 每收到一段 answer,就更新最后这条 AI 消息

示例:

import { useState } from 'react'
import { sendMessageToDifyStream } from './api/difyStream'
import type { Message } from './types/chat'
import './App.css'

function App() {
  const [input, setInput] = useState('')
  const [messages, setMessages] = useState<Message[]>([])
  const [conversationId, setConversationId] = useState<string>()
  const [loading, setLoading] = useState(false)

  async function handleSend() {
    const text = input.trim()

    if (!text || loading) return

    setInput('')
    setLoading(true)

    const assistantMessageIndex = messages.length + 1

    setMessages(prev => [
      ...prev,
      { role: 'user', content: text },
      { role: 'assistant', content: '' },
    ])

    try {
      await sendMessageToDifyStream(text, conversationId, {
        onMessage: chunk => {
          setMessages(prev => {
            const next = [...prev]
            const current = next[assistantMessageIndex]

            if (current) {
              next[assistantMessageIndex] = {
                ...current,
                content: current.content + chunk,
              }
            }

            return next
          })
        },
        onConversationId: id => {
          setConversationId(id)
        },
        onError: error => {
          console.error(error)

          setMessages(prev => {
            const next = [...prev]
            const current = next[assistantMessageIndex]

            if (current) {
              next[assistantMessageIndex] = {
                ...current,
                content: `请求失败:${error.message}`,
              }
            }

            return next
          })
        },
        onDone: () => {
          setLoading(false)
        },
      })
    } catch (error) {
      console.error(error)

      setMessages(prev => {
        const next = [...prev]
        const current = next[assistantMessageIndex]

        if (current) {
          next[assistantMessageIndex] = {
            ...current,
            content:
              error instanceof Error
                ? `请求失败:${error.message}`
                : '请求失败,请稍后重试。',
          }
        }

        return next
      })

      setLoading(false)
    }
  }

  return (
    <div className="app">
      <h1>Frontend AI Assistant</h1>

      <div className="messages">
        {messages.map((message, index) => (
          <div
            key={index}
            className={`message ${message.role === 'user' ? 'user' : 'ai'}`}
          >
            <strong>{message.role === 'user' ? '你' : 'AI'}:</strong>
            <div>{message.content}</div>
          </div>
        ))}

        {loading && <div className="loading">AI 正在思考...</div>}
      </div>

      <textarea
        value={input}
        onChange={event => setInput(event.target.value)}
        onKeyDown={event => {
          if (event.key === 'Enter' && !event.shiftKey) {
            event.preventDefault()
            handleSend()
          }
        }}
        placeholder="请输入你的问题,按 Enter 发送,Shift + Enter 换行"
        rows={4}
      />

      <div className="actions">
        <button onClick={handleSend} disabled={loading || !input.trim()}>
          {loading ? '回答中...' : '发送'}
        </button>
      </div>
    </div>
  )
}

export default App

第五步:为什么先插入一条空 AI 消息?

流式输出不是一次性拿到完整答案。

如果等所有内容结束再插入消息,就失去了流式的意义。

所以我们先插入:

{ role: 'assistant', content: '' }

然后每来一段 chunk,就更新这条消息:

content: current.content + chunk

这样页面就会逐步展示内容。


第六步:测试流式输出

启动项目:

npm run dev:all

打开:

http://localhost:5173

输入:

请介绍一下前端架构主要包括哪些内容

如果一切正常,你会看到 AI 回答不是一次性出现,而是一段一段追加出来。

这说明完整链路已经变成:

React 前端
  ↓
/api/chat/stream
  ↓
Express SSE 代理
  ↓
Dify streaming
  ↓
前端逐步渲染

第七步:在 Network 里看流式请求

打开 DevTools → Network。

发送一次问题。

你应该能看到请求:

/api/chat/stream

它的响应类型是 event-stream,内容类似:

data: {"event":"message","answer":"前端"}

data: {"event":"message","answer":"架构"}

data: {"event":"message","answer":"主要"}

如果看到这些,说明 Dify 的 streaming 数据已经通过 Express 成功转发到浏览器。


常见问题

1. 前端还是一次性返回

检查后端请求 Dify 时是否改成:

response_mode: 'streaming'

如果仍然是 blocking,就不会流式输出。

2. 页面没有内容,但接口有响应

检查前端是否正确解析了 SSE:

if (!trimmed.startsWith('data:')) continue

以及 Dify 返回的事件字段是否是:

event: message
answer: xxx

3. JSON.parse 经常报错

大概率是没有处理半包问题。

要用 buffer:

buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''

不要假设每次 reader.read 都能读到完整 JSON。

4. Nginx 或部署后不流式

如果后面部署到 Nginx,要注意关闭缓冲:

proxy_buffering off;
proxy_cache off;

否则服务端可能已经流式返回了,但被 Nginx 缓存后再一次性吐给浏览器。


当前版本还有哪些不足?

这一篇实现了流式输出,但项目还不够完善。

接下来还有几个问题:

1. AI 回答是纯文本

如果回答里有 Markdown、代码块、表格,现在展示效果不好。

下一篇会用:

react-markdown
remark-gfm
rehype-highlight
highlight.js

实现 Markdown 渲染和代码高亮。

2. 引用来源还没显示

Dify 的 message_end 事件里可能会包含:

metadata.retriever_resources

这是 RAG 应用非常重要的引用来源。

后面会展示成:

引用来源:frontend-notes.md

3. 还不能停止生成

如果 AI 正在输出,用户现在不能中断。

后面会用 AbortController 实现停止生成。

4. 刷新页面会丢失会话

当前 messages 仍然存在 React state 里。

后面会先用 localStorage 做本地持久化,再迁移到数据库。


本篇总结

这一篇我们把 AI 聊天体验从 blocking 升级到了 streaming。

关键改动有:

1. 后端新增 /api/chat/stream
2. Dify 请求改为 response_mode: streaming
3. Express 设置 text/event-stream 响应头
4. 后端读取 Dify ReadableStream 并写回前端
5. 前端读取 response.body
6. 解析 SSE data 行
7. 每个 answer chunk 追加到 AI 消息里

现在项目体验已经明显接近真实 AI 产品。

下一篇我们继续优化展示效果:

让 AI 回答支持 Markdown 渲染和代码高亮。