AI 对话应用之 JS 的流式接口数据处理

需求背景

市面上的 AI 聊天软件基本上都是不断地向页面渲染出来 AI 思考的内容,传统的 web 网络 request 接口并不适合这种 ai 不断思考,不断输出内容的场景。而这种不断输出内容的应用场景最适合使用流式数据传输这种技术这不仅能够提升用户体验,还能优化数据传输效率,增强系统的可靠性和安全性;通过流式数据传输方案,满足用户对AI对话的多样化需求。

技术选型

而网页前端 js 当中针对流式数据的接收处理主流有以下几种形式:

  1. Server-Sent Events (SSE)
  2. WebSocket
  3. fetch API + stream API

虽然有很多种 AI 对话处理的技术选型,但是其实还是取决于后台这次使用 java 的 Spring AI 返回的是 Stream 形式的数据,因此最后的技术选型使用的是fetch API + stream API

具体实现与封装

原生 fetch api

其实使用 XMLRequest 也是能够实现接收 stream 流式数据的接口请求,只需要通过 api 当中的 onprogress 对接口返回的数据进行监听则可以实现;但是已经是现代化的应用了,我们可以使用更加现代化的 fetch api 进行开发处理。

  • 声明设置 response 接收流式数据
  • 组装数据发送请求
php 复制代码
fetch("https://www.example.org", {
  method: 'post',
  headers: {
    Authorization: Token,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    xxx: 'yyy',
  }),
}).then((response) => {
  console.warn(response.body);
  // TODO: 进一步使用 Stream 处理流式数据 - 下一节介绍
});

处理流式 Stream 结果

在 node 当中对 stream 的流式数据处理有着原生能力支持处理,而在 web 浏览器当中想要对相关的流式数据处理则需要浏览器提供到相关的 ReadableStream API 能力支持进行处理。

ReadableStream MDN:developer.mozilla.org/zh-CN/docs/...

ReadableStream.getReader MDN:developer.mozilla.org/zh-CN/docs/...

  • 在前面使用 fetch 发送能够接收 stream 流式数据的接口后,我们通过 response.body 拿到的对象是一个 ReadableStream 对象;
  • 接着通过调用 ReadableStream.getReader() 这个方法得到一个 reader对象
  • 通过reader对象的read方法能够返回一个 Promise 对象,Promise 的 resolve callback 当中则对流式数据进行多次调用读取数据块,进而实现对流式数据的处理。callback 当中的 response 数据结构包含donevalue;
    • 如果有分块可用,则 promise 将使用 { value: theChunk, done: false } 形式的对象来兑现。
    • 如果流已经关闭,则 promise 将使用 { value: undefined, done: true } 形式的对象来兑现。
    • 如果流发生错误,则 promise 将因相关错误被拒绝。
javascript 复制代码
fetch("https://www.example.org", {
  method: 'post',
  headers: {
    Authorization: Token,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    xxx: 'yyy',
  }),
}).then((response) => {
  const reader = response.body.getReader();
  reader.read().then(({ done, value }) => {
    if (done) {
      return console.warn('read end', value);
    }

    return console.warn('reading', value);
  });
})

完整链路的封装

这里进行对相关的进一步封装,封装一个专门处理 stream 接口返回的FetchEventSource类:

  • 增加取消处理
  • 增加错误边界处理

因为流式数据是通过 Promise 的 resolve callback 不断多次返回处理的,因此这里我们并不能直接通过 Promise 的简单封装返回。

这里是提供两种处理方法:

1. callback 回调的形式处理

这种形式更加贴近 Node.js 原生的写法形式,也更加能够容易简单理解;通过 onMessage, onEnd, onError 这几个专门对应用途的 callback 进行在对应触发时机进行回调。

  • onMessage 是在每一次 read 的回调当中进行处理,
  • 当 read 方法读取全部流式数据时候 done 字段会返回 true,也就是 onEnd 的调用时机;
  • onError 就是在所有的相关异常情况都进行调用处理。
typescript 复制代码
class FetchEventSource {
  eventController: any

  abortController: any

  constructor() {
    this.abortController = new AbortController() || null
  }

  // 这里使用的就是通过 onMessage, onEnd, onError 这几个专门的
  startFetchEvent(url, body, onMessage, onEnd, onError, headers = {}) {
    const fetchOptions = {
      method: 'POST',
      body: JSON.stringify(body),
      headers: Object.assign({}, {
        'Content-Type': 'application/json',
        authorization: `Bearer ${token}`,
      }),
      signal: this.abortController.signal,
    }

    // 发送 fetch 请求
    fetch(url, fetchOptions)
      .then((response: any) => {
        if (response && response.status && response.status !== 200) {
          const reader = response.body.getReader()

          // 开始处理读取 stream 流式数据
          reader.read().then((result) => {
            if (result.done) {
              return
            }

            const decoder = new TextDecoder()
            const receivedString = decoder.decode(result.value, { stream: true })

            try {
              onError(JSON.parse(receivedString))
            } catch (e) {
              onError(e)
            }
          })

          return response
        }

        const reader = response.body.getReader()
        reader.read()
          .then(function processResult(result) {
            if (result.done) {
              onEnd(result)

              return
            }

            const decoder = new TextDecoder()
            const receivedString = decoder.decode(result.value, { stream: true })
            onMessage(receivedString)

            return reader.read()
              .then(processResult)
          })

        return response
      })
      .catch(() => {
        this.eventController.abort()
        onError({ code: 201, message: '服务器异常' })
      })
  }

  stopFetchEvent() {
    if (this.eventController) {
      this.eventController.abort()
      this.eventController = null
    }
  }
}

callback 形式封装的调用例子:

javascript 复制代码
const eventFetch = new FetchEventSource()

eventFetch.startFetchEvent('/api/xxx', params, (res) => {
  console.warn('onMessage: ', res)
}, (res) => {
  console.warn('onEnd: ', res)
}, (error) => {
  console.error('onError: ', error)
})

2. Generator 迭代器多步处理

Generator 迭代器这个知识因为并非这篇文章的重点这里就简单进行描述相关的基础用法

  • 函数添加 * 标识符表示
  • 函数内部使用 yield 关键字表示每一步的返回
  • 调用迭代器函数后返回一个迭代器对象,并且该迭代器对象有一个 next 的函数
  • 每次调用 next 则返回一个对象,对象有两个属性:
    • 一个是 value,当迭代还没结束时候返回值为函数内部每一次 yield 的值,迭代结束时候返回 undefined
    • 一个 done,表示是否还能进行迭代,当迭代还没结束时候返回 false,迭代结束时候返回 true

for await...of 创建一个循环,该循环遍历异步可迭代对象以及同步可迭代对象。

  • 非常适用于需要按顺序对异步任务进行迭代遍历处理。

Generator MDN:developer.mozilla.org/zh-CN/docs/...

for-await...of MDN:developer.mozilla.org/zh-CN/docs/...

ruby 复制代码
function* gen() {
  yield 1;
  yield 2;
  yield 3;
}

const g = gen(); // Generator { }
g.next(); // { value: 1, done: false }
g.next(); // { value: 2, done: false }
g.next(); // { value: 3, done: false }
g.next(); // { value: undefined, done: true }

使用 Generator 迭代器对前面的封装进行优化改造:

相比 callback 的形式,使用 Generator 并且配合通过 for await...of 的调用形式,能够更加直接清晰的使用同步的写法进行逻辑处理,能够降低回调地狱的风险。

  • onMessage 则是通过 Generator 迭代器的 yield 进行返回,在外面调用遍历则能获取到该分块的数据值;
  • 当 read 方法读取全部流式数据时候 done 字段会返回 true,因此这时候不需要再调用 yield 即可,也就是前文的 onEnd callback,在外部调用这个封装的 Generator 迭代器方法则需要在遍历循环当中同样判断 done 字段是否为 true;
  • 在方法内部抛出异常,在迭代器的外部调用则需要通过对异常进行捕获处理(而不再是通过 onError callback 处理)。
typescript 复制代码
class FetchEventSource {
  abortController: any

  constructor() {
    this.abortController = new AbortController() || null
  }

  async* fetchEventGenerator(url, body, headers = {}) {
    const token: string = 'eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJmYW5nZ2VlayIsImF1ZCI6ImZhbmdnZWVrLmNsaWVudCIsImV4cCI6MTc2NjEyNTEyNCwianRpIjoiZ3pUSnhWcGNMNVlEWFVnSC1wWE0yUSIsImlhdCI6MTc2MzUzMzEyNCwibmJmIjoxNzYzNTMzMDA0LCJzdWIiOiIyIiwiZmFuZ2dlZWsiOiI1ZWYyYmM4NjczZDQ2YjIxODE4ZDliODUiLCJlbnYiOiJkZXYiLCJ0eXBlIjoxLCJ0ZWFtSWQiOiI1ZWYyYmM4NjczZDQ2YjIxODE4ZDliN2YiLCJ0ZWFtIjoiODE4ODAwMDEifQ.8aMWZ7i5s0qBHTXbtIw8wuEc1Ozw4_TewIQN-5Wv_5A'

    this.abortController = new AbortController()

    const fetchOptions = {
      method: 'POST',
      body: JSON.stringify(body),
      headers: Object.assign({}, {
        'Content-Type': 'application/json',
        authorization: `Bearer ${token}`,
      }, headers),
      signal: this.abortController.signal,
    }

    try {
      const response: any = await fetch(ENV_CONFIG.skmrHostPrefix + url, fetchOptions)

      if (response && response.status && response.status !== 200) {
        const reader = response.body.getReader()
        const result = await reader.read()
        const decoder = new TextDecoder()
        const receivedString = decoder.decode(result.value, { stream: true })

        try {
          throw JSON.parse(receivedString)
        } catch (e) {
          throw e
        }
      }

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

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

        if (done) {
          break
        }

        yield decoder.decode(value, { stream: true })
      }
    } catch (error) {
      this.abortController.abort()
      throw error
    }
  }

  stopFetchEvent() {
    if (this.abortController) {
      this.abortController.abort()
    }
  }
}

调用例子:

这里因为取消了 onError callback 的形式调用参数,因此相关的错误处理则需要通过 try catch 进行处理。

而原本的 onMessage, onEnd 的 callback 则是通过 Generator 迭代遍历和 for wait...of 进行异步遍历处理。

  • await 返回的 res 则需要判断里面的 done 字段是否为 true 判断是否已经结束遍历了(也就是 onEnd 这个 callback,done 为 false 时候为 onMessage callback)。
javascript 复制代码
const eventFetch = new FetchEventSource()

try {
  for await (const res of eventFetch.fetchEventGenerator('/api/xxx', params)) {
    const { done, value } = res || {}

    if (done) {
      console.warn('onEnd: ', value)
    } else {
      console.warn('onMessage: ', value)
    }
  }
} catch (error) {
  console.error('onError: ', error)
}
相关推荐
英俊潇洒美少年2 小时前
react如何实现 vue的$nextTick的效果
javascript·vue.js·react.js
青柠代码录2 小时前
【Vue3】Vue Router 4 路由全解
前端·vue.js
无限大62 小时前
《AI观,观AI》:专栏总结+答疑|吃透核心,解决你用AI的所有困惑
前端·后端
蜡台3 小时前
element-ui 2 el-tree 内容超长滚动条不显示问题
前端·vue.js·elementui·el-tree·v-deep
新缸中之脑3 小时前
5个能访问浏览器的AI编程工具
ai编程
小小小小宇5 小时前
软键盘常见问题(二)
前端
小小小小宇5 小时前
软键盘常见问题
前端
小小小小宇5 小时前
富文本编辑器知识体系(三)
前端
小小小小宇5 小时前
富文本编辑器知识体系(二)
前端