2023年的深度学习入门指南(13) - 写后端
我们前面学习了用python在本机上写前端,也学习了使用HTML在本机写前端。同理,我们可以写Windows应用,mac应用,iOS应用,Android应用等等以适合各种终端。其实,最通用的终端就是Web前端。
为了使用Web前端,我们需要写后端,也就是服务端代码。
写后端可以有哪些好处?
首先,有了后端,我们就可以将数据存储到数据库里进行存储和进一步的分析。
其次,有了后端,我们可以接多个API提供方,提供1+1>2的效果。
第三,后端运行在服务器上,我们可以使用服务器的计算资源,而不是自己的电脑的计算资源,更加稳定。
最后,有了后端,我们就可以提供服务给其他小伙伴使用了。
基于开源项目修改自己的后端
chatgpt的生态如此丰富,我们有大量的开源项目可以参考。我们可以直接使用这些项目,当然多数情况肯定是要进行一些自己的修改,要不然直接用第三方的现成产品就好了。通过修改这些开源项目,可以大大加速上线时间,也方便满足我们的需求。
我们这里选用chatgpt-demo项目来作为讲解的例子,它的地址在:https://github.com/anse-app/chatgpt-demo。
运行chatgpt-demo
我个人选取它作为例子的原因是因为它是基于astro写的,代码看起来比较清爽。Astro是基于Node.js的,跟前端一样都是js,对于新同学入门的话比较友好。
我们首先clone这个项目:
git clone https://github.com/anse-app/chatgpt-demo
第二步,我们安装Node.js,可以直接从Nodejs.org下载安装包,也可以使用nvm来安装。nvm请参照:https://github.com/nvm-sh/nvm。
第三步,我们安装依赖:
npm install pnpm -g
pnpm install
pnpm的速度要比npm快很多,所以我们这里使用pnpm。
第四步,修改配置文件。
找到目录下的.env.example文件,将它复制一份并命名为.env,然后修改里面的配置。
最主要修改的有三个参数,其中只有第一个是强制要求的:
- 第一个是OPENAI_API_KEY,这个是我们的OpenAI API Key
- 第二个是HTTPS_PROXY,如果你需要使用代理的话,可以在这里设置,否则不用设置
- 第三个是SITE_PASSWORD,这个是我们的密码,如果不设置的话,就是公开的,任何人都可以访问
# Your API Key for OpenAI
OPENAI_API_KEY=你的API Key
# Provide proxy for OpenAI API. e.g. http://127.0.0.1:7890
HTTPS_PROXY=
# Custom base url for OpenAI API. default: https://api.openai.com
OPENAI_API_BASE_URL=
# Inject analytics or other scripts before </head> of the page
HEAD_SCRIPTS=
# Secret string for the project. Use for generating signatures for API calls
PUBLIC_SECRET_KEY=
# Set password for site, support multiple password separated by comma. If not set, site will be public
SITE_PASSWORD=你的密码
# ID of the model to use. https://platform.openai.com/docs/api-reference/models/list
OPENAI_API_MODEL=gpt-4
第五步,运行:
pnpm start --host
注意,这里我们使用了–host参数,这样我们就可以在外网访问这个应用了。
输出结果如下:
> chatgpt-api-demo@0.0.1 start /root/code/chatgpt-demo
> astro dev "--host"🚀 astro v2.1.3 started in 355ms┃ Local http://localhost:3000/┃ Network http://192.168.0.189:3000/
第六步,访问。
我们就可以访问我们的gpt应用了。如果你设置了密码,那么就需要输入密码才能访问。
地址参照你运行pnpm start --host的输出结果。比如我的就是:http://192.168.0.189:3000/
下面是我的运行结果,我修改了一点,跟你的可能不一样:
下面的官方的结果:
定制化自己的chatgpt-demo
下面我们就可以进行自己的定制了。选这个Astro工程的原因为就是修改起来很方便,比如首页的头部的代码,大家可以看到就是非常简单的几个标题字符串,大家想改成什么就改成什么。
---
import { model } from '../utils/openAI'
import Logo from './Logo.astro'
import Themetoggle from './Themetoggle.astro'
---<header><div class="fb items-center"><Logo /><Themetoggle /></div><div class="fi mt-2"><span class="gpt-title">旭伦GPT</span><span class="gpt-subtitle">1.0</span></div><p mt-1 op-60>Powered by OpenAI API ({model}).</p>
</header>
我们可以看到,这里的代码是astro的代码,它的语法跟HTML非常相似,但是它可以直接使用js,所以我们可以在这里写js代码。
我们来看主页的内容,基本上就是header, footer加上一段检查密码的代码:
---
import Layout from '../layouts/Layout.astro'
import Header from '../components/Header.astro'
import Footer from '../components/Footer.astro'
import Generator from '../components/Generator'
import '../message.css'
import 'katex/dist/katex.min.css'
import 'highlight.js/styles/atom-one-dark.css'
---<Layout title="Xulun GPT"><main ><Header /><Generator client:load /><Footer /></main>
</Layout><script>
async function checkCurrentAuth() {const password = localStorage.getItem('pass')const response = await fetch('/api/auth', {method: 'POST',headers: {'Content-Type': 'application/json',},body: JSON.stringify({pass: password,}),})const responseJson = await response.json()if (responseJson.code !== 0)window.location.href = '/password'
}
checkCurrentAuth()
</script>
运行一下修改的,我的就变成了这样:
最后我们再看一下chatgpt-demo里面是如何调用openai的:
export const model = import.meta.env.OPENAI_API_MODEL || 'gpt-3.5-turbo'export const generatePayload = (apiKey: string, messages: ChatMessage[]): RequestInit & { dispatcher?: any } => ({headers: {'Content-Type': 'application/json','Authorization': `Bearer ${apiKey}`,},method: 'POST',body: JSON.stringify({model,messages,temperature: 0.6,stream: true,}),
})
我们看到了stream: true,这是以流式的方式来访问openai的API.
接下来我们看来流是如何处理的:
const stream = new ReadableStream({async start(controller) {const streamParser = (event: ParsedEvent | ReconnectInterval) => {if (event.type === 'event') {const data = event.dataif (data === '[DONE]') {controller.close()return}try {const json = JSON.parse(data)const text = json.choices[0].delta?.content || ''const queue = encoder.encode(text)controller.enqueue(queue)} catch (e) {controller.error(e)}}}const parser = createParser(streamParser)for await (const chunk of rawResponse.body as any)parser.feed(decoder.decode(chunk))},})
这段代码创建了一个ReadableStream对象,它是用于处理流式数据的Web API。
在这段代码中,只使用了start方法,这个方法在流被构造或者需要提供数据时会被调用。start方法接收一个controller参数,这个参数是一个ReadableStreamDefaultController对象,它提供了enqueue、close和error等方法,可以用来控制流的状态。
解析事件流的工作是通过createParser方法创建的parser对象来完成的。这个parser对象会对服务器发送的每一块数据(chunk)进行解析,然后调用streamParser函数处理解析后的事件。
streamParser函数会检查事件的类型,如果事件类型为event,它就会提取出事件中的数据,然后尝试将数据解析为JSON格式,并提取出其中的文本信息。如果数据是[DONE],它就会关闭流。如果在解析或提取过程中出现错误,它就会调用controller.error方法并传入错误对象,使得流进入错误状态。
基本上我们可以理解为,如果一个chunk是[DONE],那么就关闭流,否则就把chunk解析成json,然后把json里面的delta.content字段的内容放入到流里面。
OK。现在需要什么功能,你就可以在这个工程的基础上修改了。
如果遇到无法连接之类的问题,可以参考这个:https://github.com/anse-app/chatgpt-demo/discussions/270
自己写后端
光用别人的东西,可能对于细节就缺失了一些了解。而且,主流的后端还是基于Spring Boot框架,使用Java或者是Kotlin来写的。
编译Spring Boot工程的话,我们需要maven或者是gradle这样的构建工具,它有中心仓库,可以自动下载依赖。写Spring Boot的话,我们最好有个趁手的IDE,比如IntelliJ IDEA或者是Visual Studio Code。
写主类
我们都知道,Java应用需要一个主类,这个主类需要有main方法,这个main方法是程序的入口。
在Spring Boot应用里面,这个工作交给SpringApplication类来完成。我们的main方法如下:
package cn.lusing.chat.chatimport org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication@SpringBootApplication
class ChatApplicationfun main(args: Array<String>) {runApplication<ChatApplication>(*args)
}
为了能让这个应用运行起来,我们需要写一个pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.0.6</version><relativePath/> <!-- lookup parent from repository --></parent><groupId>cn.lusing.chat</groupId><artifactId>chat</artifactId><version>0.0.1-SNAPSHOT</version><name>chat</name><description>Demo project for Spring Boot</description><properties><java.version>17</java.version><kotlin.version>1.8.21</kotlin.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>com.fasterxml.jackson.module</groupId><artifactId>jackson-module-kotlin</artifactId></dependency><dependency><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-reflect</artifactId></dependency><dependency><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-stdlib-jdk8</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><dependency><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-stdlib-jdk8</artifactId><version>${kotlin.version}</version></dependency><dependency><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-test</artifactId><version>${kotlin.version}</version><scope>test</scope></dependency></dependencies><build><sourceDirectory>src/main/kotlin</sourceDirectory><testSourceDirectory>src/test/kotlin</testSourceDirectory><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><executions><execution><goals><!-- 生成可执行 JAR 包 --><goal>repackage</goal></goals></execution></executions></plugin><plugin><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-maven-plugin</artifactId><version>${kotlin.version}</version><executions><execution><id>compile</id><phase>compile</phase><goals><goal>compile</goal></goals></execution><execution><id>test-compile</id><phase>test-compile</phase><goals><goal>test-compile</goal></goals></execution></executions><configuration><args><arg>-Xjsr305=strict</arg></args><compilerPlugins><plugin>spring</plugin></compilerPlugins><jvmTarget>17</jvmTarget></configuration><dependencies><dependency><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-maven-allopen</artifactId><version>${kotlin.version}</version></dependency></dependencies></plugin></plugins></build>
</project>
有了这个xml文件,我们就可以使用maven来编译我们的工程了。
编译:
mvn compile
生成可执行jar包:
mvn package
然后运行:
java -jar target/chat-0.0.1-SNAPSHOT.jar
也可以直接在IDE里面运行。
运行起来的效果是像这样的:
. ____ _ __ _ _/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \\\/ ___)| |_)| | | | | || (_| | ) ) ) )' |____| .__|_| |_|_| |_\__, | / / / /=========|_|==============|___/=/_/_/_/:: Spring Boot :: (v3.0.6)2023-05-13T01:32:16.879+08:00 INFO 5925 --- [ main] cn.lusing.chat.chat.ChatApplicationKt : Starting ChatApplicationKt using Java 17.0.6 with PID 5925 (/Users/liuziying/working/misc/java/chat/chat/target/classes started by liuziying in /Users/liuziying/working/misc/java/chat)
2023-05-13T01:32:16.884+08:00 INFO 5925 --- [ main] cn.lusing.chat.chat.ChatApplicationKt : No active profile set, falling back to 1 default profile: "default"
2023-05-13T01:32:17.868+08:00 INFO 5925 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http)
2023-05-13T01:32:17.879+08:00 INFO 5925 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2023-05-13T01:32:17.880+08:00 INFO 5925 --- [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.8]
2023-05-13T01:32:17.984+08:00 INFO 5925 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2023-05-13T01:32:17.986+08:00 INFO 5925 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 1044 ms
2023-05-13T01:32:18.240+08:00 INFO 5925 --- [ main] o.s.b.a.w.s.WelcomePageHandlerMapping : Adding welcome page: class path resource [static/index.html]
2023-05-13T01:32:18.373+08:00 INFO 5925 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
2023-05-13T01:32:18.383+08:00 INFO 5925 --- [ main] cn.lusing.chat.chat.ChatApplicationKt : Started ChatApplicationKt in 1.905 seconds (process running for 2.373)
写一个处理Rest API的类
Rest是Resource Representational State Transfer的缩写,直译为表现层状态转移,是一种软件架构风格,用于设计和实现Web服务。
Rest的核心思想是将网络上的资源用统一资源标识符(URI)来表示,并通过HTTP协议提供的方法(如GET、POST、PUT、DELETE等)来对资源进行操作。
Rest的优点是简化了接口的设计,提高了可读性和可维护性,适用于各种客户端和平台。
我们的服务端代码就要基于Rest API来提供服务。
我们先写一个最简单的例子让大家理解一下Rest API的工作方式:
package cn.lusing.chat.chat
import org.springframework.web.bind.annotation.*@CrossOrigin(origins = ["*"])
@RestController
class MainController{@RequestMapping("/api/v1/chat/{message}")fun hello(@PathVariable(name="message") message : String) : String {return "Hello,Chat!$message";}@PostMapping("/api/v1/chat2")fun hello2(@RequestBody json: String) : String {return "{'data':'$json'}";}
}
我们以一个get请求和一个post请求为例,给大家讲解下Rest API是如何工作的。
我们先来看get请求,这个请求的地址是:http://localhost:8080/api/v1/chat/chatgpt。
我们通过浏览器访问这个地址,可以看到返回的结果是:Hello,Chat!chatgpt。其中,chatgpt就是我们传入的参数,大家可以换一个试试效果。
但是,在URL里面传递参数,有时候是不方便的,比如我们要传递一个很长的文本,这个文本可能会超过URL的长度限制。这个时候,我们就需要使用post请求。
Post请求就无法将参数放在URL里面了,我们需要把参数放在请求的body里面。我们可以使用curl命令来测试一下:
curl -X POST http://127.0.0.1:8080/api/v1/chat2 -d '{message:"aaa"}'
返回值如下:
{'data':'%7Bmessage%3A%22aaa%22%7D='}%
我们可以看到,我们传入的参数是{message:“aaa”},但是返回的结果是%7Bmessage%3A%22aaa%22%7D=。这是因为我们传入的参数是json格式的,而返回的结果是url编码的格式。
写首页
我们的首页就是一个html文件,我们可以直接把它放在resources/static/index.html里面。
<!DOCTYPE html>
<html>
<head><title>智能问答系统 Powered by chatgpt</title><link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
</head>
<body class="p-6 bg-gray-200"><h1 class="text-3xl mb-4">Chat Interface</h1><input type="text" id="message" placeholder="Type your message here" class="px-4 py-2 mb-4 w-full border-2 border-gray-300 rounded"><button id="send" class="px-4 py-2 bg-blue-500 text-white rounded">Send</button><div id="response" class="mt-4 p-4 border-2 border-gray-300 rounded"></div><script>document.querySelector("#send").addEventListener('click', async () => {const message = document.querySelector("#message").value;try {const response = await fetch('/api/v1/chat2', {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({ message })});const data = await response.text();document.querySelector("#response").textContent = data;} catch (error) {console.error('Error:', error);}});</script>
</body>
</html>
我们可以看到,这个页面有一个输入框,一个按钮,一个输出框。我们在输入框里面输入内容,然后点击按钮,就可以把输入的内容发送到后端,然后后端返回一个结果,这个结果就显示在输出框里面。
运行的效果如下:
调用openai API
下面我们就剩最后一道工序了,把用户的输入传给openai,然后把openai的结果返回给用户:
package cn.lusing.chat.chatimport java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URLfun chatWithOpenAI(apiKey: String, message: String): String {val url = URL("https://api.openai.com/v1/chat/completions")with(url.openConnection() as HttpURLConnection) {requestMethod = "POST" // 设置请求类型为 POST// 设置请求头setRequestProperty("Content-Type", "application/json")setRequestProperty("Authorization", "Bearer $apiKey")// 设置请求体val body = """{"model": "gpt-3.5-turbo","messages": [{"role": "system","content": "You are a helpful assistant."},{"role": "user","content": "$message"}]}""".trimIndent()doOutput = trueOutputStreamWriter(outputStream).use {it.write(body)}// 返回响应return inputStream.bufferedReader().use { it.readText() }}
}
我们修改一下Controller的方法:
@PostMapping("/api/v1/chat2")fun hello2(@RequestBody json: String): String {val jsonNode = objectMapper.readTree(json)val message = jsonNode.get("message")?.asText()val apiKey = "你的key"return chatWithOpenAI(apiKey, "$message");}
我们来看下效果:
链路已经通了。
后面调调格式,首先把data改成data?.choices[0]?.message?.content:
document.querySelector("#send").addEventListener('click', async () => {const message = document.querySelector("#message").value;try {const response = await fetch('/api/v1/chat2', {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({ message })});const data = await response.json();document.querySelector("#response").textContent = data?.choices[0]?.message?.content;} catch (error) {console.error('Error:', error);}});
再给response div改个增加个pre的样式:
<div id="response" class="mt-4 p-4 border-2 border-gray-300 rounded" style="white-space: pre;"></div>
效果如下:
代码格式没有highlight,我们之前在前端的时候搞过了,这里就不再重复了。
小结
恭喜,从此您解锁了调用大模型的后端能力。