在调用OpenAI GPT接口时,如果不使用流式(stream:true)参数,接口会等待所有数据生成完成后一次返回。这个等待时间可能会很长,给用户带来不良体验。
为了提升用户体验,我们需要使用流式调用方式。在这篇文章中,我们将介绍如何使用Spring Boot和Vue对接OpenAI GPT接口,并实现类似ChatGPT逐字输出的效果。
官方给的Example request:
curl https://api.openai.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-d '{
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "Hello!"}]
}'
根据官方示例,用java封装请求接口的客户端。本文选择使用OkHttpClient
作为http请求客户端。
注意:接口调用需要魔法
GtpClient.java
@Component
public class GptClient {
private final String COMPLETION_ENDPOINT = "https://api.openai.com/v1/chat/completions";
// OpenAI的API key
@Value("${gpt.api-key}")
private String apiKey;
// 魔法服务器地址
@Value("${network.proxy-host}")
private String proxyHost;
// 魔法服务器端口
@Value("${network.proxy-port}")
private int proxyPort;
OkHttpClient client = new OkHttpClient();
MediaType mediaType;
Request.Builder requestBuilder;
@PostConstruct
private void init() {
client.setProxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)));
client.setConnectTimeout(60, TimeUnit.SECONDS);
client.setReadTimeout(60, TimeUnit.SECONDS);
mediaType = MediaType.parse("application/json; charset=utf-8");
requestBuilder = new Request.Builder()
.url(COMPLETION_ENDPOINT)
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + apiKey);
}
/**
* 聊天接口
* @param requestBody 聊天接口请求体
* @return 接口请求响应
*/
public Response chat(ChatRequestBody requestBody) throws ChatException {
RequestBody bodyOk = RequestBody.create(mediaType, requestBody.toString());
Request requestOk = requestBuilder.post(bodyOk).build();
Call call = client.newCall(requestOk);
Response response;
try {
response = call.execute();
} catch (IOException e) {
throw new ChatException("请求时IO异常: " + e.getMessage());
}
if (response.isSuccessful()) {
return response;
}
try(ResponseBody body = response.body()) {
throw new ChatException("chat api 请求异常, code: " + response.code() + "body: " + body.string());
} catch (IOException e) {
throw new ChatException("请求后IO异常: " + e.getMessage());
}
}
}
请求体封装
ChatRequestBody .java
@Data
public class ChatRequestBody {
private static String model = "gpt-3.5-turbo";
private static boolean stream = true;
// 对话上下文,详情请看OpenAI接口文档
private List<MessageItem> messages;
@Override
public String toString() {
return "{\"model\":\"" + model +"\"," +
"\"messages\":" + JSON.toJSONString(messages) + "," +
"\"stream\":"+ stream +"}";
}
}
调用OpenAI接口可以看到,Content-Type 为 text/event-stream。
它指示服务器返回的响应体是一个流式事件的序列。这个响应体通常被用于服务器向客户端推送实时事件,客户端可以通过一个持久连接(HTTP 长轮询)来接收这些事件。
我们后端请求OpenAI接口,接口会通过多次向我们后端发送数据,数据格式如下:
data: {
"id": "chatcmpl-7CgfIDnXzGXreE5LbTnM7GFnqd8ZH",
"object": "chat.completion.chunk",
"created": 1683258296,
"model": "gpt-3.5-turbo-0301",
"choices": [
{
"delta": {
"content": "你"
},
"index": 0,
"finish_reason": null
}
]
}
发送的数据都会追加在响应体(Response)中的body(ResponseBody)中, 从中可以获取到InputStream
,这就是OpenAI向我们后端发送数据的数据流了。
为了方便获取每行的数据,我们将这个流封装成BufferedReader
,使用它的readLine()方法,获取每行数据(见下文中ConverseHandleWrapper.java
下的run()方法),每次调用此方法都会得到一行内容(编码好的String,每行内容如上文JSON或换行符,出现换行符的原因是SSE协议导致的),这里每一行内容称之为line
。我们循环调用BufferedReader
的readLine()方法,即可实时获取到OpenAI接口发送来的每一行数据line
。直至获取到null值,表明数据传输完毕。
其实line
中的绝大数内容都是无用的,只有choices[0].delta.content字段(上文JSON中的)是我们想要的内容。我们只要这个字段值(即上文JSON中的‘你’)即可。笔者用了java.util.regex.Pattern
来匹配这个content字段中的内容:
Pattern contentPattern = Pattern.compile("\"content\":\"(.*?)\"}");
Matcher matcher = contentPattern.matcher(line);
matcher.find();
String content = matcher.group(1); // content就是json中choices[0].delta.content字段的值,即上文JSON中的‘你’
我们再将每个content实时的发送到前端即可,那么如何通过http分批次的实时的将数据发送给前端呢?模仿我们调用的OpenAI的接口就好了。即SSE(Server-Sent Events)事件流连接。SSE 是一种基于 HTTP 的推送技术,它允许服务器在数据准备好时将事件推送到客户端,而不需要客户端发送请求。
SseEmitter 是 Spring 框架提供的一个异步的响应对象,它可以用于向客户端发送 SSE 事件流。当在控制器方法(Controller)中创建一个 SseEmitter 对象并返回它时,Spring MVC 将自动将响应类型设置为 “text/event-stream”,以支持 SSE 事件流协议。例如:
@RestController
public class EventController {
@RequestMapping("/events")
public SseEmitter handleEvents() {
SseEmitter emitter = new SseEmitter();
// 在这里设置 SSE 事件流的处理逻辑,例如推送实时事件
// 我们可以在这里请求OpenAI接口,实时的获取OpenAI分批次发送来的数据,再通过emitter的send()方法实时发送给前端
// 注意:异步处理!!!
return emitter;
}
}
笔者对对话处理做了封装,前端每次请求对话时,都封装成一个对象,对话的所有处理都在这个对象中进行。对话的所有处理包括用户鉴权、用户状态维护等。
对话处理的封装,内容过长,删除了部分代码,核心代码为run()方法
注意对话的处理需要是异步的,这里将对话处理放到了线程池中处理
ConverseHandleWrapper.java
@Slf4j
public class ConverseHandleWrapper{
public static GptClient gptClient;
public static RightsManager rightsManager;
private static final ExecutorService executorService = Executors.newFixedThreadPool(10);
private final static Pattern contentPattern = Pattern.compile("\"content\":\"(.*?)\"}");
private final static String EVENT_DATA = "d";
private final static String EVENT_ERROR = "e";
// 用于数据传输的 SseEmitter
private final SseEmitter emitter = new SseEmitter(0L);
// 用户唯一标识
private String userKey;
// 对话上下文
private List<MessageItem> messageItemList;
/**
* 向客户端发送数据
* @param event 事件类型
* @param data 数据
*/
private boolean sendData2Client(String event, String data) {
try {
emitter.send(SseEmitter.event().name(event).data("{" + data + "}"));
return true;
} catch (IOException e) {
log.error("向客户端发送消息时出现异常");
e.printStackTrace();
}
return false;
}
/**
* 对话上下文检查
* @return 是否通过
*/
private boolean messageListCheck() {
}
/**
* 对话处理
* @return SseEmitter
*/
public SseEmitter handle() {
if (!messageListCheck() || !authenticate()) {
return emitter;
}
rightsManager.lockUserKey(userKey);
doConverse();
return emitter;
}
/**
* 鉴权
* @return 是否通过
*/
public boolean authenticate() {
}
/**
* 对话,异步的,在新的线程的
*/
public SseEmitter doConverse() {
executorService.execute(this::run);
return emitter;
}
private void run() {
ChatRequestBody chatRequestBody = new ChatRequestBody();
chatRequestBody.setMessages(messageItemList);
Response chatResponse;
try {
chatResponse = gptClient.chat(chatRequestBody);
} catch (ChatException e) {
sendData2Client(EVENT_ERROR, "我累垮了");
emitter.complete();
e.printStackTrace();
return;
}
try (ResponseBody responseBody = chatResponse.body();
InputStream inputStream = responseBody.byteStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) {
String line;
while ((line = bufferedReader.readLine()) != null) {
if (StringUtils.hasLength(line)) {
Matcher matcher = contentPattern.matcher(line);
if (matcher.find()) {
String content = matcher.group(1);
if (!sendData2Client(EVENT_DATA, content)) {
break;
}
}
}
}
} catch (IOException e) {
log.error("ResponseBody读取错误");
e.printStackTrace();
} finally {
emitter.complete();
// 用户权限相关,可以忽略
rightsManager.decrementUsage(userKey);
rightsManager.unlockUserKey(userKey);
}
}
}
通过SseEmitter
的send()方法向前端发送事件流时,可以指定事件(event),上述代码中区分了两种事件:e和d。e代表这个事件是错误提示数据,d代表是正常的数据。这样前端可以通过这个字段做出判断。例如以下错误提示效果:
在2中,将绝大部分的对话处理都封装好了,所以,当前端请求对话时,创建一个ConverseHandleWrapper
对象并操作即可。
对话请求接口
ChatController.java
@RestController
@RequestMapping("chat")
public class ChatController {
@PostMapping("/converse")
public SseEmitter converseEvents(@RequestBody ConverseRequestBody requestBody) {
// 封装对话处理
ConverseHandleWrapper converseHandleWrapper =
new ConverseHandleWrapper(requestBody.getUserKey(), requestBody.getMessageList());
return converseHandleWrapper.handle();
}
}
ConverseRequestBody.java
@Data
public class ConverseRequestBody {
private String userKey;
private List<MessageItem> messageList;
}
MessageItem.java
@Data
public class MessageItem {
/**
* 角色,user,assistant,system
*/
private String role;
/**
* 内容
*/
private String content;
}
待更新…