从 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 的内容之类的)

相关推荐
爱编程的喵几秒前
从XMLHttpRequest到Fetch:前端异步请求的演进之路
前端·javascript
喜欢吃豆3 分钟前
深入企业内部的MCP知识(三):FastMCP工具转换(Tool Transformation)全解析:从适配到增强的工具进化指南
java·前端·人工智能·大模型·github·mcp
不吃肉的羊5 分钟前
PHP设置文件上传最大值
后端·php
豆苗学前端6 分钟前
手把手实现支持百万级数据量、高可用和可扩展性的穿梭框组件
前端·javascript·面试
不见_6 分钟前
不想再写周报了?来看看这个吧!
前端·命令行
专注物联网全栈开发8 分钟前
ESP32的IRAM用完了怎么优化
后端
yinke小琪8 分钟前
JavaScript 事件冒泡与事件捕获
前端·javascript
雨落倾城夏未凉9 分钟前
7.QObject定时器和QTimer定时器的区别
后端·qt
pany10 分钟前
写代码的节奏,正在被 AI 改写
前端·人工智能·aigc
洗澡水加冰11 分钟前
RAG系统工程化
后端·aigc