大模型如何生成稳定的结构化数据
在我们的业务场景当中,会向 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 数据流式返回给前端?
资料引用:
在流式返回的过程中,可能会包含需要向用户展示的一些内容,但此时JSON 的数据格式可能是不完整的,不能直接对返回的内容进行 JSON 反序列化。
例如:
第一个 chunk 可能是:[{"date
第二个 chunk 可能是:[{"date": "30AD", "event":
但如果不使用流式返回,用户需要等待大模型将内容完全生成,等待的时间可能较长造成用户体验下降,所幸,社区中已经实现了 JSON 的乐观解析。
同样使用上方的 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 的服务端事件
- start:通知前端大模型已经开始流式返回,此时前端将 message 的 loading 状态更新为 streaming 状态
- data:向前端发送大模型的流式内容
- end:通知前端大模型已经结束返回,前端将 message 的 streaming 状态更改为 finish 状态
- 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 的内容之类的)