一文搞懂 AI 流式响应

这是 OpenAI 文档中流式响应的代码 platform.openai.com/docs/guides...

jsx 复制代码
import { OpenAI } from "openai";
const client = new OpenAI();

const stream = await client.responses.create({
    model: "gpt-5",
    input: [
        {
            role: "user",
            content: "Say 'double bubble bath' ten times fast.",
        },
    ],
    stream: true,
});

for await (const event of stream) 
    console.log(event);
}

在我第一次看到这段代码时,有无数的疑惑出现在了我的大脑中:

  • stream 是什么?
  • 为什么可以通过 for await of 来遍历?
  • 这和异步有什么关系吗?
  • 服务端要如何将 stream 一点点返回给前端?
  • 前端要如何接收数据?
  • ......

如果你也有类似的疑问,请耐心阅读本文,相信你一定能找到答案。

本文的代码在这里 github.com/wangkaiwd/a...

Iterable protocol 和 Iterator protocol

支持 for...of 循环的变量,一定要符合 Iterable protocolIterator protocol

Iterable protocol :

  • 变量是一个对象
  • 对象必须实现 [Symbol.iterator] 方法
  • [Symbol.iterator] 方法必须返回遵循 Iterator protocol 约定的对象

Iterator protocol :

  • 变量是一个对象
  • 对象必须实现 next 方法
  • next 方法要返回一个对象 { done: boolean, value: any }
    • done 表示迭代是否结束
    • value 表示迭代器的返回值

下面是一个示例:

jsx 复制代码
function makeIterableObj (array: any[]) {
  return {
    [Symbol.iterator] () {
      let nextIndex = 0
      return {
        next () {
          if (nextIndex < array.length) {
            const result = { value: array[nextIndex], done: false }
            nextIndex++
            return result
          }
          return { done: true, value: undefined }
        },
      }
    },
  }
}

const iterableObj = makeIterableObj(['one', 'two'])

可以手动循环 iterableObj

jsx 复制代码
const iterator = iterableObj[Symbol.iterator]()
while (true) {
  const { value, done } = iterator.next()
  if (done) {
    break
  }
  console.log('value', value)
}

// 输出结果
// value one
// value two

也可以通过 for...of 来循环 iterableObj :

jsx 复制代码
// 这里的 item 就是 next 方法执行后得到的 value
for (const item of iterableObj) {
  console.log('item', item)
}

// 输出结果
// item one
// item two

Async iterable protocol 和 Async iterator protocol

理解了 iterable protocoliterator protocol 再来理解 async iterable protocolasync iterator protocol 就会容易很多。

异步相比于同步,有以下区别:

同样的示例改为异步版本:

jsx 复制代码
const sleep = (result: IResult) => {
  return new Promise<IResult>((resolve) => {
    setTimeout(() => {
      resolve(result)
    }, 1000)
  })
}

function makeIterableObj (array: any[]) {
  return {
    [Symbol.asyncIterator] () {
      let nextIndex = 0
      return {
        next () {
          if (nextIndex < array.length) {
            const promise = sleep({ value: array[nextIndex], done: false })
            nextIndex++
            return promise
          }
          return sleep({ done: true, value: undefined })
        },
      }
    },
  }
}

手动循环:

jsx 复制代码
const asyncIterableObj = makeIterableObj(['one', 'two'])
const iterator = asyncIterableObj[Symbol.asyncIterator]()
while (true) {
  const { value, done } = await iterator.next()
  if (done) {
    break
  }
  console.log('value', value)
}

使用 for await ... of 循环

jsx 复制代码
for await (const item of makeIterableObj(['one', 'two'])) {
  console.log('item', item)
}

此时再回到开篇的示例:

jsx 复制代码
const stream = await client.responses.create()

stream 其实就是一个遵循 async iterable protocol 的对象

可读流 ReadableStream

下面是一个 ReadableStream 的示例:每隔 1s 向流中写入4个字符,直到字符完全写入到流中

jsx 复制代码
let mockData = `This is a sample string that will be streamed in chunks.`

let timer: any = null
const step = 4

const stream = new ReadableStream({
  start (controller) {
    timer = setInterval(() => {
      const chunk = mockData.slice(0, step)
      // 删除已经写入的字符
      mockData = mockData.slice(step)
      if (!mockData) {
        // 字符处理完成后,停止写入
        controller.close()
        if (timer) {
          clearInterval(timer)
          timer = null
        }
      }
      // 添加字符到 stream
      controller.enqueue(chunk)
    }, 1000)
  },
  cancel () {
    clearInterval(timer)
  },
})

ReadableStream 默认实现了 Symbol.asyncIterator ,所以它是一个异步可迭代对象,可以使用 for await ... of 来循环

jsx 复制代码
for await (const chunk of stream) {
  console.log('chunk', chunk)
}

ReadableStream 自己也提供了 getReader 方法来读取流:

jsx 复制代码
const stream = createStream()
const reader = stream.getReader()
// 循环直到 done 为 true 时结束
while (true) {
  const { done, value } = await reader.read()
  if (done) {
    break
  }
  console.log('value', value)
}

这是 mdn 官方仓库中的一个示例,也可以结合一起学习:github.com/mdn/dom-exa...

服务端 SSE

目前的 AI 应用服务端流式响应使用 Server-Sent Events 来实现,简称 SSE 。下面是 ChatGPT 网页版的响应内容:

mdn 的相关介绍在这里:developer.mozilla.org/en-US/docs/...

sse 示例

MDN 的示例是使用 PHP 实现的,代码比较难懂,我也没有找到一个可以直接运行的案例。为了方便理解,我参考 stackoverflow.com/questions/3... ,使用 express 实现了流式响应:

jsx 复制代码
import express from 'express'

const app = express()
app.use(express.static('public'))

app.get('/countdown', function (req, res) {
  // sse 响应头设置
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  })
  let timer: NodeJS.Timeout | null = null
  let count = 10
  timer = setInterval(() => {
    if (count >= 0) {
      // 返回内容必须严格遵守格式
      res.write('data: ' + count + '\n\n')
      count--
      return
    }
    // count 小于0时,停止响应
    if (timer) {
      clearInterval(timer)
      timer = null
    }
    res.end()
  }, 1000)
})

app.listen(3000, () => console.log('SSE app listening on port 3000'))

这段代码会每隔 1s 在响应中写入 count ,直到 count < 0 时结束响应。

代码中以下内容需要注意:

  • 响应头设置: 'Content-Type': 'text/event-stream'

  • 返回内容必须严格遵守格式: data: + 空格 + 字符串 + 两个换行符 (\n\n)

AI 流式响应

上面我们先实现了一个简单的流式响应,现在我们把 AI 结合进来

jsx 复制代码
const client = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: 'https://api.deepseek.com',
})

const app = express()
app.use(express.static('public'))

app.get('/chat', async function (req, res) {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  })
  const stream = await client.chat.completions.create({
    model: 'deepseek-chat',
    messages: [{ role: 'user', content: '你是谁?' }],
    stream: true,
  })
  for await (const chunk of stream) {
    const content = chunk.choices[0].delta.content
    // 注意:这里通过 JSON.stringify 来返回 JSON 字符串,更加灵活
    res.write(`data: ${JSON.stringify({ content })}\n\n`)
  }
  res.write(`data: [DONE]\n\n`)
  res.end()
})

app.listen(3000, () => console.log(`
SSE app listening on port 3000
Open http://localhost:3000/sse-ai.html in your browser to access page.
`))

有以下几点需要注意:

  1. 如果使用的是 OpenAI 兼容的 api ,例如我在当前示例中使用的 deepseek ,要使用之前的 OpenAI 请求标准:github.com/openai/open... 用法和传参都不一样,需要特别留意

  2. 返回内容要通过 JSON.stringify 来处理,方便我们给前端返回更多字段

  3. 结束时返回 res.write(data: [DONE]\n\n) ,方便前端使用 EventSource 时终止请求

前端处理流式响应

EventSource

前端可以使用 EventSource 来处理 sse 响应的内容,代码如下:

jsx 复制代码
const stop = document.getElementById('stop')
const start = document.getElementById('start')
let eventSource = null
start.addEventListener('click', () => {
  const eventSource = new EventSource('/chat')
  eventSource.onmessage = function (event) {
    // 要手动关闭,否则会一直请求服务器
    if (event.data === '[DONE]') {
      eventSource.close()
      return
    }
    const json = JSON.parse(event.data)
    document.getElementById('log').innerHTML += json.content
  }
})
stop.addEventListener('click', function () {
  eventSource.close()
})

完整代码:github.com/wangkaiwd/a...

EventSource 有一个细节需要注意

如果没有调用 eventSource.close() 方法,那么请求会一直不停的发起 。所以我在服务端特意在响应结束时返回 data: [DONE]\n\n 来让前端知道什么时候关闭 eventSource

fetch

前面我们介绍了通过 EventSource 来处理服务端的流式响应,但其实它存在很多问题:

  • 只能发起 get 请求
  • 请求参数只能在 url 中传递,但是一般要传入给 AI 的提示词长度可能较大,容易超过 url 长度的最大限制
  • 无法自定义请求头来设置 Authorization ,给服务端传递用户 token

基于上述的这些原因,我们通常会使用 fetch 方法来处理服务端的流式响应。github.com/Azure/fetch... 就是基于 fetch 实现的用来发起 EventSource 请求的开源库,下面是它的使用示例:

jsx 复制代码
<script type="module">
  import { fetchEventSource } from "https://esm.sh/@microsoft/fetch-event-source";

  const stop = document.getElementById("stop");
  const start = document.getElementById("start");
  const controller = new AbortController();
  start.addEventListener("click", () => {
    // 发起post请求
    fetchEventSource("/chat", {
      signal: controller.signal,
      method: "POST",
      // 一点点处理服务端响应
      onmessage: (event) => {
        const data = event.data;
        if (data === "[DONE]") {
          console.log("done");
          return;
        }
        const json = JSON.parse(data);
        document.getElementById("log").innerHTML += json.content;
      },
    });
  });
  stop.addEventListener("click", function () {
    controller.abort();
  });
</script>

完整代码:github.com/wangkaiwd/a...

这里使用的 POST 请求,我把服务端的示例改为了 all 方法来接收请求,可以同时处理 GETPOST 请求

我们也可以自己通过 fetch 请求来看看具体的响应内容

jsx 复制代码
const response = await fetch("/chat", {
  signal: controller.signal,
  method: "POST",
});

这里的 response.body 就是一个 ReadableStream (ps: 前面的章节有介绍过ReadableStream ,忘记的同学可以再回去看一下 ),所以我们可以通过 for await ... of 或者 getReader 方法来拿到 ReadableStream 中的数据:

jsx 复制代码
const textDecoder = new TextDecoder();
// response.body 是可读流
for await (const chunk of response.body) {
  // chunk 是 Uint8Array ,通过 TextDecoder 转换为字符串
  console.log('chunk', chunk)
  const text = textDecoder.decode(chunk);
  if (text === "[DONE]") {
    console.log("done");
    return;
  }
  console.log('text', text)
}

// 使用 getReader 方法获取数据
//   const reader = response.body.getReader();
//   while (true) {
//     const { done, value } = await reader.read();
//     if (done) {
//       break;
//     }
//     const text = textDecoder.decode(value);
//     if (text === "[DONE]") {
//       console.log("done");
//       return;
//     }
//     console.log('text', text)
//   }

最终结果如下:

我们拿到的是服务端返回符合 SSE 规范的字符串,将字符根据规则解析后,就能拿到最终的结果了。这其实就是 fetch-event-source 帮我们实现的逻辑

踩坑

我在使用 fetch-event-source 的过程中发现了如下问题:

如果服务端返回的内容只包含 \n ,那么前端接收到的内容为空字符。在 markdown 渲染的场景下,会导致格式完全错乱。 下面是伪代码,方便理解

jsx 复制代码
// 服务端如果返回的内容如果只包含 \n
res.write('data: ' + '\n\n' + '\n\n')

// 前端拿到的内容为空字符串
onmessage: (event) => {
  const data = event.data;
  // true
  console.log(data === '')
}

官方也有相关的 issue 一直没有修复:github.com/Azure/fetch...

所以在使用 fetch-event-source 时可以通过 JSON.stringify 来传入 json 字符串,防止前端接收到空字符串

jsx 复制代码
const content = chunk.choices[0].delta.content
// JSON.stringify 避免了返回内容只有 `\n` 的情况
res.write(`data: ${JSON.stringify({ content })}\n\n`)

结语

AI 出现之前,这些知识很少有使用场景。但随着 AI 的快速发展,这些代码不断地出现在我眼前,也让我有了更多实践的机会。这篇文章是我在实践中的一些沉淀和总结,希望能帮到你。

参考

相关推荐
百***67032 小时前
Node.js实现WebSocket教程
websocket·网络协议·node.js
q***51892 小时前
如何在Windows系统上安装和配置Node.js及Node版本管理器(nvm)
windows·node.js
顾安r2 小时前
11.14 脚本网页 青蛙过河
服务器·前端·python·游戏·html
不爱吃糖的程序媛3 小时前
Electron 智能文件分析器开发实战适配鸿蒙
前端·javascript·electron
Doro再努力3 小时前
2025_11_14洛谷【入门1】数据结构刷题小结
前端·数据结构·算法
IT_陈寒3 小时前
SpringBoot 3.2新特性实战:这5个隐藏技巧让你的应用性能飙升50%
前端·人工智能·后端
eason_fan4 小时前
Monorepo性能噩梦:一行配置解决VSCode卡顿与TS类型崩溃
前端·typescript·visual studio code
天天进步20155 小时前
Webpack到Vite:构建工具迁移实战经验总结
前端·webpack·node.js
0***145 小时前
免费的WebAssembly模块打包,Webpack配置
前端·webpack·wasm