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