Hello 大家好,这里是Anyin。
前言
我最近利用业余时间,肝了一周的时间,终于完成的一个类ChatGPT站点的小应用。
废话不多说,先上地址:chat . anyin . org . cn
整个项目基于chatgpt-web[1] 和 ruoyi-vue-pro[2] 实现的。
是的,你没看错,后端就是用Java实现的。因为我比较熟悉的是Java,然而网上少有Java实现的后端,所以就想着自己尝试搞下。
站点服务器上使用的是魔法网络,所以体验并不丝滑;并且因为手上只有一个key,Token有限,后续还需要有大量测试,所以限制了Token的使用。 有需要白嫖的同学可以找找其他站点哈,应该有很多。
接下来给大家分享下在整个开发过程中遇到的一些小问题。
在项目开始你需要有以下几个东西需要准备:
• 一个OpenAI的账号,并且配合key
• 魔法网,可以在本地或者服务器进行代理
• 一个认证过的服务号,这里主要用作扫码登录使用
用户登录认证
项目开始,遇到的第一个问题就是用户登录认证问题。因为考虑到需要有Token的限制,所以必须有登录这一环节,否则你都不知道哪个用户用了多少的Token。
站点初步的想法是只在微信客户端 和 PC端上打开。
如果是在微信客户端打开,那么很简单,用公众号的那套授权认证就OK;PC端的授权认证就会比较麻烦,正规的做法是把PC站点在微信开放平台上绑定,公众号也在开放平台上绑定,这样子就可以使用开放平台的授权认证流程。
但是PC站点在微信开放平台做绑定,还需要认证审核还挺麻烦的,最后还是决定使用微信公众号的那套认证流程,做一点改造即可。
基本流程如下:
其实基本原理就是在PC站点上生成一个跳转公众号H5的二维码,并且启动一个轮询(这里也可以使用websocket)服务端用户是否在微信客户端授权认证了。
如果用户在微信客户端登录认证了,就把结果写入到服务端,接着PC站点的轮询任务就拿到认证成功的结果,这样子PC站点就完成用户认证过程。
接口参数
在OpenAI的官网上有2个接口都可以完成聊天的功能。
• /v1/completions
• /v1/chat/completions
从接口文档可以看出,/v1/completions更适合做一些单一的一问一答的的场景,而且它也不支持gpt-3.5-turbo模型,支持text-davinci-003模型,另外这个模型又很贵。
而 /v1/chat/completions 接口则可以通过messages字段,来实现具有上下文的聊天场景,最重要的是它还便宜。1000个Token才0.002$。
所以,最终对接使用的是/v1/chat/completions接口。
接下来,我们来看看接口的几个重要参数:
model 指定需要使用的模型ID,这个接口只支持以下几个模型:
• gpt-4
• gpt-4-0314
• gpt-4-32k
• gpt-4-32k-0314
• gpt-3.5-turbo
• gpt-3.5-turbo-0301
messages
messages是一个对象数组,每个对象包含2个字段:role和content。role有三种角色:
• system 系统用户,这个时候content里面的内容就代表我们初始给ChatGPT一个指令,告诉ChatGPT应该怎么回答用户的问题。这也就是为什么很多类似的站点第一轮对话都有对应的prompts,其实就是告诉ChatGPT用户想干什么。
• user C端的用户,这个时候content的内容就是用户发送给ChatGPT的。
• assistant 助理,这个时候content的内容就是ChatGPT返回回来的内容。
所以,基于这个设计,我们如果需要一个具有上下文的聊天,那么每次发送给ChatGPT的messages字段都需要以以下的顺序发送:
[system, user, assistant, user, assistant...]
temperature
这个参数的输入范围是在0-2之间的浮点数,表示输出结果的随机性或者多样性。 这个参数设置的越小,那么ChatGPT返回的结果随机性就越小,越大返回的结果的随机性就越大。
基本意思就是对于同一句话,设置越小,那么多次请求返回的文本内容基本一致;设置越大,那么多次请求返回的文本内容就越不一样。当然,返回的文本内容想表达的意思基本一致。
max_tokens
就是本次调用ChatGPT返回的最大Token数量。如果你想知道你一次输入的文字有多少Token,可以通过这个网站来测试:Tokenizer[4]
n
n 表示ChatGPT应该给你返回几条记录。因为我们是对话模式,所以这里都是设置为1。 如果是类似让ChatGPT 给你取几个名字的场景,那么可以设置成其他数值,它就会给你返回多条记录。
当然,这也可以设置为1,然后通过文本内容明确告诉ChatGPT需要给你几个结果,这样子也可以做到同样的效果。
stream
这个参数是用来设置流式响应,你可以看到当遇到大段文本的时候,ChatGPT官网那种逐字打印给人感觉会很丝滑;而不是等到所有文本都收集好了,之后再一次性显示,那就需要等,体验感直接下头。
stop
就是告诉ChatGPT当遇到stop参数的文本的时候,就会停止下来,避免消耗大量的Token。例如,stop=\n,那么ChatGTP在返回的内容,如果有\n的内容,那么就会停止输出。
presence_penalty
这个参数是在 -2.0 - 2.0 之间,它大概意思是如果一个Token 在前面的内容已经出现过了,那么在后面生成的时候会给它一个概率惩罚,降低它再出现的概率,让ChatGPT会更倾向输出新的话题和内。
frequency_penalty
这个参数一样在 -2.0 - 2.0 之间,它的意思是对于重复出现的Token进行概率惩罚。这样子ChatGPT就会尽量使用不同的表述。如果设置为-2,那么它会更容易胡说八道。
logit_bias
这个参数是一个Map的数据结构,它的使用场景可以用来过滤敏感词。
例如:国家 2个字,可以计算出它的Token值(这个站点可以计算:Tokenizer[5]),然后指定这个Token值为-100。那么ChatGPT在返回的词汇中就不会出现这2个字。
不过建议使用1到-1就行。-100可能会慢。
好了。充分了解了这个接口的几个参数之后,终于可以开始进行编码了!!!
流式响应
在完成OpenAI的接口请求 和 前端展示的编码之后,我发现一个头疼的事情:体验好差啊!!!
当遇到ChatGPT需要给我大段的是输出内容的时候,需要等好久,然后前端也没有像官网那样子可以逐字输出,一定需要等所有文字都返回了之后,然后一次性显示。当遇到大段的文本输出的时候,可能需要等个30秒才能出来,体验感直接下头。= = !
这个时候我才意识到stream这个参数的重要性。
请求OpenA的接口,使用的是Hutool的 HttpRequest工具类,对于OpenAI 返回的流式喜响应可以这么处理:
// 构建请求
CompletionRequest completionRequest = CompletionRequest.builder()
.max_tokens(messageMaxTokens)
.temperature(new BigDecimal("0.8"))
.n(1)
.model(model)
.messages(messages)
.stream(true) // 设置流式响应
.build();
// 请求系统
InputStream input = null;
try {
String json = JSONUtil.toJsonStr(completionRequest);
// 返回输入流
input = openAiHttpProxy
.body(json)
.execute().bodyStream();
}catch (Exception ex){
log.error("请求OpenAI接口异常, key={}", openAiHttpProxy.header("Authorization") ,ex);
throw ServiceExceptionUtil.exception(ErrorCodeConstants.OPEN_AI_API_ERROR);
}
Scanner scanner = new Scanner(input, StandardCharsets.UTF_8.name());
try {
while (scanner.hasNextLine()) {
String line = scanner.nextLine();
String data = line.replaceAll("data: ", "").trim();
if(!(data.startsWith("{" ) && data.endsWith("}"))){
continue;
}
CompletionChunk chunk = JSONUtil.toBean(data, CompletionChunk.class);
if(CollectionUtil.isEmpty(chunk.getChoices()) || StrUtil.isEmpty(chunk.getChoices().get(0).getDelta().getContent())){
continue;
}
function.apply(chunk);
}
}catch (Exception e){
log.error("请求结果: {}", e.getMessage());
throw ServiceExceptionUtil.exception(ErrorCodeConstants.OPEN_AI_API_ERROR);
}finally {
scanner.close();
}
这里是从OpenAI 接口拿到InputStream输入流,接着使用Scanner进行封装,然后逐行获取之后再转为CompletionChunk对象,最后通过function.apply()方法把数据提交给上层函数,用于给前端进行流式输出。
在Controller的代码如下:
@PostMapping("/message")
@Operation(summary = "ChatGPT 消息发送")
public ResponseEntity<StreamingResponseBody> message(@RequestBody AppGptMessageReqVO req){
// 因为OpenAI 返回的数据是逐个Token的,这里我们给前端需要是一段文本,所以需要拼接。
StringBuilder stringBuilder = new StringBuilder();
// service 处理业务逻辑
StreamingResponseBody responseBody = outputStream -> chatService.message(req, userId != null, (chunk) -> {
try {
String content = chunk.getChoices().get(0).getDelta().getContent();
stringBuilder.append(content);
outputStream.write(stringBuilder.toString().getBytes());
outputStream.flush();
Thread.sleep(100);
} catch (Exception e) {
log.error("write error", e);
throw ServiceExceptionUtil.exception(ErrorCodeConstants.OPEN_AI_API_ERROR);
}
return null;
});
return ResponseEntity.ok()
.contentType(new MediaType(MediaType.APPLICATION_OCTET_STREAM, StandardCharsets.UTF_8))
.body(responseBody);
}
这里使用ResponseEntity<StreamingResponseBody>进行数据返回,同时指定了返回的ContentType 为 APPLICATION_OCTET_STREAM, 所以在前端是无法解析为JSON对象的,前端接收到的只是一段JSON的字符串而已,最后还需要前端再手动转下对象才可以。
最后
好了,以上就是上周肝这个项目遇到的一些问题和知识,如果有什么地方不对的,请指正。
另外,当使用流式响应的时候,每次对话的Token使用情况API就不再返回了, 这里需要另外计算。
References
[1] chatgpt-web: https://github.com/Chanzhaoyu/chatgpt-web
[2] ruoyi-vue-pro: https://gitee.com/zhijiantianya/ruoyi-vue-pro
[3] 图片上网上偷的,原文地址看这儿: https://zhuanlan.zhihu.com/p/360891056
[4] Tokenizer: https://platform.openai.com/tokenizer
[5] 这个站点可以计算:Tokenizer: https://platform.openai.com/tokenizer