onPost< /code> 我们之前定义的中间件。
现在,让此重构工作的最后一步是定义handleSubmit
。就像我们在上一篇文章中所做的那样,我们需要在 Qwik 的 $
函数中包装一个事件处理程序。
在事件处理程序中,我们需要清除 state.text
中以前的所有数据,将 state.isLoading
设置为 true
,然后将表单的 DOM 节点传递给我们精美的 jsFormSubmit 函数。这应该为我们提交 HTTP 请求。一旦返回,我们可以使用响应正文更新 state.text
,并将 state.isLoading
返回为 false
。
const handleSubmit = $(async (event) => {
状态.text = ''
状态.isLoading = true
/** @type {HTMLFormElement} */
const 形式 = event.target
const 响应 = 等待 jsFormSubmit(表单)
state.text = 等待response.text()
状态.isLoading = false
})
好的!我们现在应该有一个客户端表单,它使用 JavaScript 向服务器提交 HTTP 请求,同时跟踪加载和响应状态,并相应地更新 UI。
为了获得与之前相同的解决方案,但功能较少,我们需要做大量的工作。 但是主要的好处是我们现在可以直接访问支持流媒体所需的平台原语。
在服务器上启用流式传输
在我们开始从 OpenAI 流式传输响应之前,我认为从一个非常基本的示例开始有助于更好地掌握流。流允许我们随着时间的推移发送小块数据。举个例子,让我们以歌曲的节奏打印一些标志性的 David Bowie 歌词,“太空怪异。"
当我们构造 Response 对象时,我们需要传递一个流,而不是传递纯文本。我们很快就会创建流,但想法是这样的:
/** @type {import('@builder.io/qwik-city').RequestHandler} */
导出 const onPost = (requestEvent) => {
requestEvent.send(新响应(流))
}
我们将创建一个非常基本的ReadableStream
使用 ReadableStream
构造函数 并将其作为可选参数传递。此可选参数可以是一个具有 start
方法的对象,该方法在构造流时调用。
start方法负责steam的逻辑并可以访问流控制器
,用于发送数据和关闭流。
const 流 = new ReadableStream({
启动(控制器){
// 流逻辑放在这里
}
})
好的,让我们计划一下这个逻辑。我们将有一个歌词数组和一个“唱”它们的函数(将它们传递到流)。 sing
函数将获取数组中的第一项,并使用 controller.enqueue()
方法将其传递给流。如果它是列表中的最后一首歌词,我们可以使用 controller.close()
关闭流。否则,sing
方法可以在短暂暂停后再次调用自身。
const 流 = new ReadableStream({
启动(控制器){
const歌词= ['地面','控制','大调','汤姆。']
函数sing() {
const 歌词 =歌词.shift()
控制器.enqueue(歌词)
if (歌词.length < 1) {
控制器.close()
} 别的 {
setTimeout(唱, 1000)
}
}
唱歌()
}
})
因此,每秒四秒,该流都会发送歌词“地面控制给汤姆少校”。光滑!
由于该流将在响应正文中使用,因此连接将保持打开状态四秒钟,直到响应完成。但是前端将在每个数据块到达时访问它,而不是等待整整四秒。
这不会加快总响应时间(在某些情况下,流会增加响应时间),但它确实可以实现更快的感知响应,从而带来更好的用户体验.
这是我的代码:
/** @type {import('@builder.io/qwik-city').RequestHandler} */
导出 const onPost: RequestHandler = async (requestEvent) => {
常量流 = 新的 ReadableStream({
启动(控制器){
const歌词= ['地面','控制','大调','汤姆。']
函数sing() {
const 歌词 =歌词.shift()
控制器.enqueue(歌词)
if (歌词.length < 1) {
控制器.close()
} 别的 {
setTimeout(唱, 1000)
}
}
唱歌()
}
})
requestEvent.send(新响应(流))
}
不幸的是,就目前情况而言,客户端仍将等待四秒钟才能看到整个响应,这是因为我们并不期望流式响应。
让我们解决这个问题。
在客户端上启用流式传输
即使在处理流时,接收响应时的默认浏览器行为也是等待其完成。为了获得我们想要的行为,我们需要使用客户端 JavaScript 发出请求并处理响应的流式正文。
我们已经在 handleSubmit
函数中解决了第一部分。让我们开始处理该响应正文。
我们可以从响应正文的 getReader()
方法。该流将有自己的 read()
方法,我们可以使用它来访问下一个数据块,以及响应是否完成流式传输的信息。
唯一的“问题”是每个块中的数据不是以文本形式出现:它以 Uint8Array
,它是“8 位无符号整数数组”。它基本上是二进制数据的表示,您实际上不需要了解比这更深入的内容,除非您想在聚会上显得非常聪明(或无聊)。
需要理解的重要一点是,这些数据块本身并不是很有用。为了获得我们可以使用的东西,我们需要使用TextDecoder
。
好吧,这是很多理论。让我们分解一下逻辑,然后看一些代码。
当我们收到响应时,我们需要:
- 使用
response.body.getReader()
从响应正文中获取读取器。
- 使用
TextDecoder
设置解码器和一个变量来跟踪流媒体状态。
- 使用
while
循环处理每个块,直到流完成:
- 获取下一个块的数据和流状态。
- 解码数据并用它来更新我们应用的
state.text
。
- 更新流状态变量,完成后终止循环。
- 通过将
state.isLoading
设置为 false
来更新应用的加载状态。
新的 handleSubmit
函数应如下所示:
const handleSubmit = $(async (event) => {
状态.text = ''
状态.isLoading = true
/** @type {HTMLFormElement} */
const 形式 = event.target
const 响应 = 等待 jsFormSubmit(表单)
// 解析流主体
const reader = response.body.getReader()
const 解码器 = new TextDecoder()
让 isStillStreaming = true
while(isStillStreaming) {
const {值,完成} =等待reader.read()
const chunkValue = 解码器.decode(value)
state.text += chunkValue
isStillStreaming = !完成
}
状态.isLoading = false
})
现在,当我提交表单时,我会看到类似以下内容的内容:
“地面
控制
到少校
汤姆。”
天啊,是的!
好的,大部分工作都完成了。现在我们只需要用 OpenAI 响应替换我们的演示流。
流式传输 OpenAI 响应
回顾我们最初的实现,我们需要做的第一件事是修改对 OpenAI 的请求,让他们知道我们想要流式响应。我们可以通过设置 将fetch
负载中的stream
属性设置为true
。
const body = {
型号:'gpt-3.5-turbo',
消息:[{角色:'用户',内容:提示}],
流:true
}
const 响应 = 等待 fetch('https://api.openai.com/v1/chat/completions', {
方法:'发布',
标题:{
'内容类型':'应用程序/json',
授权:`承载${OPENAI_API_KEY}`,
},
正文:JSON.stringify(body)
})
更新 11/15/2023: 我使用了 fetch
和 custom
流,因为在撰写本文时,NPM 上的 openai
模块未正确支持流响应。该问题已得到解决,我认为更好的解决方案是使用该模块并通过 TransformStream
发送到客户端。该版本未反映在此处。
接下来,我们可以将 OpenAI 的响应直接传送到客户端,但我们可能不想这样做。他们发送的数据与我们想要发送给客户端的数据并不真正一致,因为它看起来像这样(两个块,一个包含数据,一个代表流的末尾):
数据:{“id”:“chatcmpl-4bJZRnslkje3289REHFEH9ej2”,“对象”:“chat.completion.chunk”,“创建”:1690319476,“模型”:“gpt-3.5-turbo-0613” ,"choiced":[{"index":0,"delta":{"content":"因为"},"finish_reason":"stop"}]}
数据:[完成]
相反,我们要做的是创建自己的流,类似于 David Bowie 的歌词,它将进行一些设置,将数据块排入流中,然后关闭流。让我们从大纲开始:
const 流 = new ReadableStream({
异步启动(控制器){
// 流式传输之前的任何设置
// 发送数据块
// 关闭流
}
})
由于我们正在处理来自 OpenAI 的流式获取响应,因此我们需要在这里完成的许多工作实际上可以从客户端流处理中复制。这部分看起来应该很熟悉:
const reader = response.body.getReader()
const 解码器 = new TextDecoder()
让 isStillStreaming = true
while(isStillStreaming) {
const {值,完成} =等待reader.read()
const chunkValue = 解码器.decode(value)
// 这就是事情会有所不同的地方
isStillStreaming = !完成
}
该片段几乎直接取自前端流处理示例。唯一的区别是我们需要稍微不同地处理来自 OpenAI 的数据。正如我们所说,它们发送的数据块看起来类似于“data: [JSON data or did]
”。另一个问题是,每隔一段时间,它们实际上会在单个流块中插入其中两个数据字符串。这就是我处理数据的方法。
- 创建一个正则表达式以获取之后的字符串的其余部分“
数据:
”。
- 对于不太可能发生的情况,有多个数据字符串,请使用 while 循环来处理字符串中的每个匹配项。
- 如果当前匹配关闭条件(“
[DONE]
”),则关闭流。
- 否则,将数据解析为 JSON 并将选项列表中的第一段文本放入队列 (
json.choices[0].delta.content
)。如果不存在则回退到空字符串。
- 最后,为了进入下一场比赛,如果有的话,我们可以使用
RegExp.exec()
。
如果不看代码,逻辑是相当抽象的,所以现在整个流是这样的:
const 流 = new ReadableStream({
异步启动(控制器){
// 在流式传输之前做一些工作
const reader = response.body.getReader()
const 解码器 = new TextDecoder()
让 isStillStreaming = true
while(isStillStreaming) {
const {值,完成} =等待reader.read()
const chunkValue = 解码器.decode(value)
/**
* 捕获文本“data:”之后的任何字符串
* @参见 https://regex101.com/r/R4QgmZ/1
*/
const 正则表达式 = /data:\s*(.*)/g
让匹配 = regex.exec(chunkValue)
while (匹配!== null) {
常量负载=匹配[1]
// 关闭流
if (有效负载 === '[完成]') {
控制器.close()
休息
} 别的 {
尝试 {
const json = JSON.parse(有效负载)
const text = json.choices[0].delta.content || ”
// 发送数据块
控制器.enqueue(文本)
匹配 = regex.exec(chunkValue)
} 捕获(错误){
const nextChunk = 等待 reader.read()
const nextChunkValue = 解码器.decode(nextChunk.value)
匹配 = regex.exec(chunkValue + nextChunkValue)
}
}
}
isStillStreaming = !完成
}
}
})
更新 11/15/2023: 我发现 OpenAI API 有时会跨两个流返回 JSON 负载。因此,解决方案是在 JSON.parse
周围使用 try/catch
块,如果失败,请将 match
变量重新分配给当前块值加上下一个块值。上面的代码有更新的代码片段。
评论
这应该是我们让流媒体工作所需的一切。希望这一切都是有意义的,并且您已经成功了。
我认为检查流程以确保我们掌握了它是个好主意:
- 用户提交表单,该表单被拦截并通过 JavaScript 发送。这是在流返回时对其进行处理所必需的。
- 操作处理程序接收请求,该操作处理程序将数据转发到 OpenAI API 以及以流形式返回响应的设置。
- OpenAI 响应将以数据块流的形式发回,其中一些数据块包含 JSON,最后一个数据块为“
[DONE]
”。
- 我们不是将流传递给操作响应,而是创建一个新流以在响应中使用。
- 在此流中,我们处理来自 OpenAI 响应的每个数据块,并将其转换为更有用的内容,然后将其放入操作响应流队列。
- 当 OpenAI 流关闭时,我们也会关闭操作流。
- 客户端的 JavaScript 处理程序还将处理传入的每个数据块并相应地更新 UI。
结论
应用程序正在运行。它太酷了。今天我们讨论了很多有趣的事情。流非常强大,但也具有挑战性,特别是在 Qwik 中工作时,存在一些小问题。但是,由于我们专注于底层基础知识,因此这些概念应该适用于任何框架。
只要您有权访问平台和原语(例如流、请求和响应对象),那么这应该可以工作。这就是基本原理的美妙之处。
我认为我们现在已经有了一个相当不错的应用程序。唯一的问题是现在我们使用通用文本输入并要求用户自己填写整个提示。事实上,他们可以放入任何他们想要的东西。我们希望在以后的文章中解决这个问题,但下一篇文章将不再讨论代码,而是专注于理解人工智能工具的实际工作原理。
我希望您喜欢这个系列并回来阅读其余的内容。
非常感谢您的阅读。