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站点在微信开放平台做绑定,还需要认证审核还挺麻烦的,最后还是决定使用微信公众号的那套认证流程,做一点改造即可。

基本流程如下:

图片上网上偷的,原文地址看这儿[3]

其实基本原理就是在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 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进行数据返回,同时指定了返回的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

作者 admin