越是喧闹,越是孤独。越是寂寞,越是丰富
The more noisy, the more lonely. The more lonely, the more rich
越是喧闹,越是孤独。越是寂寞,越是丰富
The more noisy, the more lonely. The more lonely, the more rich
“ 一步一步揭开ChatGPT Lite的面纱”
随着ChatGPT的爆火,虽然很多人都领略了它的神奇,但由于需要魔法工具,需要国外手机短信验证,加上官方不对中国地区开放等限制因素,仍然还有很多小伙伴无法体验ChatGPT。虽然网上已经有很多ChatGPT的镜像站点,但大多要么有使用限制,要么收费。为了降低使用门槛,我基于OpenAI的Chat API(GPT-3.5)进行了封装,开发了这个套壳web应用—— ChatGPT Lite (https://chat.mengchen.cc/)。无需魔法,无需注册,无需付费,即可体验。
(ChatGPT Lite 初始界面)
除了方便大家体验ChatGPT,我做这个应用也是为了提升技术能力。从零到一去开发一个项目是很令人兴奋的,虽然期间会遇到各种各样的问题,但通过不断解决问题,就会不断收获技术上的成长。因此,我打算将整个开发过程中的经验整理出来,分享给大家,也欢迎同行批评指教。
文章分两篇来写,一个是后端篇,主要介绍服务端开发、环境部署等;另一个是前端篇,主要介绍web开发、用户体验优化等。本文为后端篇。
01
—
整体结构
本应用的后端使用的是Python Flask框架搭建的web服务,用Nginx做反向代理,数据库为MySQL,部署在阿里云上。由于Chat API需要魔法工具才可以访问到,而阿里云是无法直接访问的,因此我购买了一个微软云Azure的虚拟机来做代理主机,通过阿里云代理过去实现API的访问。整体结构图如下:
接下来,将分别介绍代理配置、API、获取IP、流式响应等内容。
02
—
代理配置
关于如何配置代理,可参考我的前一篇文章《自主制作一个代理工具来访问ChatGPT》,其中介绍了如何通过本机代理到微软云Azure主机上后实现魔法。同样,在阿里云server上也用类似的方式代理到Azure上,通过建立一个SSH Tunnel即可。这样阿里云server将在本地监听一个端口8888映射到Azure的8888端口,之后在Python requests库发起http请求时设置下代理即可:
import requests
proxies = {
'http': '127.0.0.1:8888',
'https': '127.0.0.1:8888'
}
req = requests.post(
'https://api.openai.com/v1/chat/completions',
proxies=proxies,
...
)
03
—
Chat API介绍
Chat API的官方文档如下图所示(链接见附录):
常用参数有:model、messages、temperature、stream,调用API之前需要先设置自己的api_key。由于GPT-4的API需要排队申请,目前API的model参数只能选择 gpt-3.5-turbo。 官网给出了python的代码示例:
import os
import openai
openai.api_key = os.getenv("OPENAI_API_KEY")
completion = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=[
{"role": "user", "content": "Hello!"}
]
)
print(completion.choices[0].message)
起初,我也是参考这个来开发的,但是后面在做流式响应(Streaming
Response)时,发现这个openai的封装没有找到设置代理的方式,进而无法访问到API(也许可以通过修改全局代理来实现,尚未尝试),因此后来更换成了用requests库发起原生的API请求。此时,api_key需要在请求头中设置,model和messages在json
body中设置,同时加上stream属性以支持流式返回。代码如下:
import requests
proxies = {
'http': '127.0.0.1:8888',
'https': '127.0.0.1:8888'
}
req_headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + OPENAI_API_KEY
}
req_body = {
'model': 'gpt-3.5-turbo',
'messages': messages,
'temperature': temperature,
'stream': True
}
req = requests.post(
'https://api.openai.com/v1/chat/completions',
proxies=proxies,
headers=req_headers,
json=req_body,
stream=True
)
注意,在body中设置stream:True,是为了告诉API需要以流式内容返回,而不是一次性返回完整内容,两种方式返回的数据是不同的。而在requests中设置stream=True是为了真正以流式的方式接收响应内容(下文会详细介绍)。
temperature参数是控制生成内容的随机性/确定性,取值范围是0-2。因为对话内容是一个一个token生成的,这个参数可以控制下一个token是更随机还是更确定,默认为1。在ChatGPT Lite中,目前提供了从0到1之间的可选择范围,step为0.1,点击输入框左边的设置按钮可弹出菜单。
之所以忽略1-2之间的范围,是因为在测试时,当接近2的时候,很容易输出乱码,完全没有可读性,是真的“随机”。(本来以为这个值越高,是越具备创造性,思维越发散,但实际上还真不是我想的这样)
这里最主要的参数就是messages了,它表示的是要发给API的消息列表。每条消息包含role和content两个属性,role有三个值:user/assistant/system。其中user为用户发的消息,assistant是AI返回的消息,system可以作为旁白来描述对话背景的。需要注意的是,这个API的调用是无状态的(像http请求一样),如果要进行连续对话,需要将对话的上下文消息全部储存在messages中提交,这样才能让api能够基于上下文进行对话,否则每次调用api都相当于开启新的对话。但这样带来的问题就是上下文内容会越来越多,最终超过了API对于prompt tokens数量的限制(目前为4096个)。
对于这个问题,我的研究过程是这样的:一开始,在还没有做流式响应之前,api接口会返回响应内容的同时一并返回本次输入和输出的token数(prompt_tokens和completion_tokens),通过存储和计算这些数字,可以在当前对话的tokens达到某个限制时给用户以提示。但是当设置了流式返回后,这个tokens个数的信息就不返回了,而且tokens数的计算逻辑也不是简单的判断单词数或汉字数,所以暂时没有好的办法来判断输入tokens是否会达到某个临界值,目前的方式就是等api报错时捕获异常并给用户提示信息,提醒用户刷新网页开始新的对话。
04
—
获取客户端IP
对于大多数web应用,记录客户端IP是一个很常见的做法。ChatGPT Lite用的是Python Flask来做的web服务,它获取client ip的代码为:
from flask import request
client_ip = request.remote_addr
这个在本地调试时看不出问题,因为要么是127.0.0.1,要么是192.168.1.x这样的局域网ip。但是上线后发现,获得的地址一直是127.0.0.1,并不是公网地址。于是想到了线上环境是nginx在前,flask在后,nginx将请求转发到flask,所以flask获取到的client ip就是nginx的ip,它们在同一台机器上,所以就是127.0.0.1。解决这个问题,有几种办法。除了使用X-Forwarded-For请求头的方式外,我采用的是在nginx转发给flask时,自定义一个header存储真实的client ip:
location / {
proxy_pass http://127.0.0.1:5002/;
proxy_set_header X-Real-IP $remote_addr;
}
在nginx配置文件中,$remote_addr持有的就是客户端真实IP,把它存储到一个自定义的header如X-Real-IP中,这样在flask中通过获取这个自定义的头部信息即可:
client_ip = request.headers.get('X-Real-IP') or request.remote_addr
05
—
流式响应
在ChatGPT这样的对话场景中,相比于一次性返回全部内容,流式响应能够显著提升用户体验,给用户心理预期,使其耐心等待响应完成。ChatGPT Lite初版刚上线时还不支持这个功能,但这个功能很有价值,所以就深入研究了一下。
关于stream参数,官方的介绍为:
并给出了代码示例(链接见附录),但是里面的内容只是介绍了一下通过设置stream=True,然后就可以对response的内容进行for循环输出,并没有介绍怎么与flask集成,怎么与SSE(Server-Send Events)集成,于是继续寻找资料。
本应用最终实现的效果为,在前端web页面上,可以实现响应内容逐字逐句的动态增量输出,由于整个请求过程为:XMLHttpRequest
-> Nginx -> Flask -> requests API,整个链条必须全部打通流式,才能实现想要的效果。我们从后往前依次来进行开发:
第一是requests API,前文中已经提到了,需要设置stream=True,这样请求api的这个request的响应可以通过for循环一行一行地获取内容,以requests库官方demo为例(链接见附录):
import json
import requests
r = requests.get('https://httpbin.org/stream/20', stream=True)
for line in r.iter_lines():
# filter out keep-alive new lines
if line:
decoded_line = line.decode('utf-8')
print(json.loads(decoded_line))
对 r.iter_lines() 进行循环,即可一行一行地获取新的响应内容。
第二是如何将上述for循环中按行获取的内容,通过flask的响应返回。这里需要用到Flask的Streaming
Content功能(链接见附录),需要将for循环放在新定义的一个generator中,并通过yield返回每一行内容,最后通过 stream_with_context 将这个generator返回,完整的代码如下:
from flask import stream_with_context
chat_req = requests.post(
'https://api.openai.com/v1/chat/completions',
json=req_body, headers=req_headers, stream=True, proxies=proxies)
chat_uuid = xxx # 会话id
result_content_tokens = [] # 响应tokens
def generator():
for line in chat_req.iter_lines():
if line:
decoded_line = line.decode('utf-8')
item = decoded_line[6:] # 去除每一行的前缀 "data: ", 剩下的为json数据
if item != '[DONE]':
delta = json.loads(item)['choices'][0]['delta']
if 'content' in delta:
result_content_tokens.append(delta['content'])
yield delta['content']
# 此处可以保存完整的返回内容 result_content_tokens
# 流式响应,并通过响应头返回额外信息
return stream_with_context(generator()), {'X-Chat-ID': chat_uuid}
另外,通常的建议是在线上环境用gunicorn来部署flask应用。但是我测试的结果是,如果以gunicorn部署后,流式响应失效了,暂时未找到解决方案,所以目前仍然采用直接启动flask的方式运行。
第三,在Nginx层,如果开启了gzip压缩,需要将其关闭。我测试的是,如果gzip是开启状态,则流式响应失效,变成了一次性返回。这个不难理解,如果nginx要对响应进行gzip压缩,那么就需要等待完整的响应内容都到达之后才能处理。所以,需要在nginx配置文件中关闭gzip:
server {
server_name chat.mengchen.cc;
listen 443 ssl http2;
gzip off; # 关闭gzip压缩
}
location / {
proxy_pass http://127.0.0.1:5002/;
proxy_set_header X-Real-IP $remote_addr;
}
第四:在前端JS处理时,我找到有两种方式,一个是用EventSource(即SSE模式),另一个就是普通XMLHttpRequest(Ajax方式)。鉴于目前后端就是普通的请求-响应模式,所以用Ajax的方式最合适,改动量最小。当响应以流式返回时,会连续触发xhr的progress事件,在事件处理函数中通过xhr.responseText就可以获得当前的实时内容。注意,这里获取的内容不是单单的增量本身,而是到本次增量之前的全部内容。这样前端就不需要进行内容拼接,只需将内容直接在页面上展示即可。当内容全部返回完成后,将触发xhr的load事件。代码如下:
let xhr = new XMLHttpRequest()
xhr.open('post', '/chat', true)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.onprogress = (e) => {
let currentContent = xhr.responseText
// 处理实时currentContent
}
xhr.onload = (e) => {
// 此时currentContent即为最终内容
}
这样,整个流式响应就打通了。当流式数据被前端接收到之后,需要进行一系列的处理才能做到良好的用户体验,请移步至《从零到一开发ChatGPT Lite - 前端篇》继续探索。
点击“阅读原文”可立即访问ChatGPT Lite!
【附录链接】
1. Open AI Chat API 文档
https://platform.openai.com/docs/api-reference/chat/create
2. Open AI Chat API steam demo 文档
https://github.com/openai/openai-cookbook/blob/main/examples/How_to_stream_completions.ipynb
3. Python requests steaming 文档
https://requests.readthedocs.io/en/latest/user/advanced/#streaming-requests
4. Flask Streaming contents 文档
https://flask.palletsprojects.com/en/2.2.x/patterns/streaming/