从 OpenAI API 流式传输 JSON 的二三事

大模型如何生成稳定的结构化数据

在我们的业务场景当中,会向 OpenAI API 请求结构化的数据返回。

如何让大模型返回稳定的结构化数据可以参考:AI生成Json结构化数据的几种方案 - 掘金。下面先列举一下我们的两版提示词的前后差别。

使用 TypeScript 对大模型的返回类型做出限制的前后,我们的提示词大概是这样子的:

  • before

通过给予 example json 和 json 字段说明,让大模型理解 JSON 的生成。但在实际的测试中,发现大模型还是不能严格的按照格式进行输出,可能会出现多字段(如多个 msg 字段)或缺少字段的问题。

bash 复制代码
#### 输出格式
```json
{
  "msg": "xxxxx",
  "isDone": "0",
  "next": "xxxxx"
}
```

在这个输出中:
* "msg"字段用于xxxx。
* "isDone"字段指示是否结束,"0"表示未结束,"1"表示结束。
* "next"字段指示下一个行动
  • after

还有一种方法是通过 JSON Schema 对 JSON 格式进行限制,JSON Schema 能对 JSON 结构做更细致的限定(比如某个数组中最少需要两个数据,最多需要四个数据这种限制)。

csharp 复制代码
#### 输出格式
总是使用如下的 interface 组装数据,输出的数据格式为 json,输出的内容中,只包含 json 数据,不包含其他的内容
```typescript
{
    "msg": string; // xxxx
    "isDonw": "0" | "1"; // 指示是否结束,"0"表示未结束,"1"表示结束。
    "next": string; // 指示下一个行动
}
```

如何将大模型返回的 JSON 数据流式返回给前端?

资料引用:

community.openai.com/t/parse-str...

www.mikeborozdin.com/post/json-s...

在流式返回的过程中,可能会包含需要向用户展示的一些内容,但此时JSON 的数据格式可能是不完整的,不能直接对返回的内容进行 JSON 反序列化。

例如:

第一个 chunk 可能是:[{"date

第二个 chunk 可能是:[{"date": "30AD", "event":

但如果不使用流式返回,用户需要等待大模型将内容完全生成,等待的时间可能较长造成用户体验下降,所幸,社区中已经实现了 JSON 的乐观解析。

www.npmjs.com/package/bes...

同样使用上方的 chunk 例子,经过乐观解析后就是可以被可反序列化的 JSON 字符串了

第一个 chunk 是 "[{"date",经过乐观解析后是 "[{"date":""}]"

第二个 chunk 是 "[{"date": "30AD", "event":",经过乐观解析后是 "[{"date": "30AD", "event": ""}]"

SSE 的可用性

我司 2C 的主要技术栈是 node.js + uniapp(涵盖多端,不止浏览器)。在浏览器上 XHR 是无法读取到 SSE 的 chunk 流的,需要使用 fetch。而在非浏览器环境中,就不是这么顺利了...先看一下 uni.request 的支持性吧...

按文档所述,在 enableChunked 之后,才可以在 request task 中设置 onChunkReceived 回调获取 SSE 的流式 chunk data,且只在微信小程序端支持。

放眼长链接技术选型,在轮询 / WebSocket / SSE 之中,WebSocket 是一个可以全平台支持的技术,且能较优雅的实现流式的效果。

使用 Socket 传输流式的内容

我们 WebSocket 使用的是 Socket.io,遂对后端的 completions 接口作出了一些改变。

前端链接上 WebSocket 后,可以将 Socket.io 的 socket id 传递给后端,后端也会做一次 SSE 的解析,将解析出的 chunk 通过 Websocket 传递给前端。

这里的 sid 就是 socket io client 的 id

请求后端时的数据

WebSocket 的服务端事件

  1. start:通知前端大模型已经开始流式返回,此时前端将 message 的 loading 状态更新为 streaming 状态
  2. data:向前端发送大模型的流式内容
  3. end:通知前端大模型已经结束返回,前端将 message 的 streaming 状态更改为 finish 状态
  4. field:通知前端大模型生成失败,前端将 message 的状态从 loading 更改为 failed,显示重新生成的 danger 按钮。

后端代码

typescript 复制代码
import { type Stream } from 'stream';

const { model, messages, stream, clientId } = ctx.request
  .body as  ChatCompletionBody;

// axios 调用 openai 的 /chat/completions 接口,开启流式
const response = await completions<Stream>(
  {
    model,
    messages,
    stream,
  },
  {
    responseType: 'stream',
  }
);

let streamContentBuffer = '';

// 
response.data.on('data', (chunk: Buffer) => {
  if (streamContentBuffer.length === 0) {
    io.to(clientId).emit('start');
  }

  const messages = chunk.toString().split('\n\n');
  for (const message of messages) {
    // 裁剪掉 chunk 中的 data: 前缀
    const _message = message.slice(5).trim();

    // 如果 message 为空或者大模型已经返回 [DONE] 标记结束,就不进行后续的操作了
    if (!_message || _message === '[DONE]') {
      return;
    }

    try {
      // 尝试对 chunk 进行反序列化
      const parsed = JSON.parse(_message) as  CompletionChunk;
      // 取出大模型的返回内容,加入暂存变量
      streamContentBuffer += parsed.choices[0]?.delta.content;
      // 如果暂存变量的长度大于0,代表大模型已经有实质的内容返回,可以尝试乐观更新了
      if (streamContentBuffer.length > 0) {
        // 通过 socket.io 的服务端 to API,向前端发送 data 事件
        io.to(clientId).emit('data', optimisticJsonParse(streamContentBuffer));
      }
    } catch (e) {
      io.to(clientId).emit('failed');
      throw  new ErrorResponse({
        message: (e as  Error).message,
      });
    }
  }
});

response.data.on('end', () => {
  io.to(clientId).emit('end');
});

// 继续维持 SSE 返回
ctx.response.type = 'text/event-stream';
ctx.response.set('Cache-Control', 'no-cache');
ctx.response.set('Connection', 'keep-alive');
ctx.response.status = response.status;
ctx.response.body = response.data;

拓展

在我们的实际业务中,还可以对 completions 进行业务适配。

例如舍弃 stream 参数,增大前端的请求 Timeout,HTTP 接口在大模型完成流式后返回一个最终的 message 和 messageId 甚至一些其他的参数,实现先让用户看到大模型的内容,具体的业务数据返回可以比流式结束稍晚一点的效果。(比如增加一个 usage 的内容之类的)

相关推荐
2401_857636398 分钟前
计算机课程管理平台:Spring Boot与工程认证的结合
java·spring boot·后端
也无晴也无风雨1 小时前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang2 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
2401_857610034 小时前
多维视角下的知识管理:Spring Boot应用
java·spring boot·后端
阮少年、5 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
代码小鑫5 小时前
A027-基于Spring Boot的农事管理系统
java·开发语言·数据库·spring boot·后端·毕业设计
颜淡慕潇6 小时前
【K8S问题系列 | 9】如何监控集群CPU使用率并设置告警?
后端·云原生·容器·kubernetes·问题解决