文章目录
- 前言
- 一、效果
- 二、Springboot后端
- 1.封装请求OpenAI接口的客户端
- 2.对话处理
- 3.对话请求接口
- 二.Vue前端
前言
在调用OpenAI GPT接口时,如果不使用流式(stream:true)参数,接口会等待所有数据生成完成后一次返回。这个等待时间可能会很长,给用户带来不良体验。
为了提升用户体验,我们需要使用流式调用方式。在这篇文章中,我们将介绍如何使用Spring Boot和Vue对接OpenAI GPT接口,并实现类似ChatGPT逐字输出的效果。
一、效果
体验地址+源码联系我。
PC端
移动端
二、Springboot后端
1.封装请求OpenAI接口的客户端
官方给的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;@PostConstructprivate 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;@Overridepublic String toString() {return "{\"model\":\"" + model +"\"," +"\"messages\":" + JSON.toJSONString(messages) + "," +"\"stream\":"+ stream +"}";}
}
2.对话处理
调用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";// 用于数据传输的 SseEmitterprivate 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代表是正常的数据。这样前端可以通过这个字段做出判断。例如以下错误提示效果:
3.对话请求接口
在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;
}
二.Vue前端
待更新…