你好,我是徐文浩。
过去的两讲,我带着你通过OpenAI提供的Embedding接口,完成了文本分类的功能。那么,这一讲里,我们重新回到Completion接口。而且这一讲里,我们还会快速搭建出一个有界面的聊天机器人来给你用。在这个过程里,你也会第一次使用 HuggingFace 这个平台。
HuggingFace 是现在最流行的深度模型的社区,你可以在里面下载到最新开源的模型,以及看到别人提供的示例代码。
我在第03讲里,已经给你看了如何通过Completion的接口,实现一个聊天机器人的功能。在那个时候,我们采用的是自己将整个对话拼接起来,将整个上下文都发送给OpenAI的Completion API的方式。不过,在3月2日,因为ChatGPT的火热,OpenAI放出了一个直接可以进行对话聊天的接口。这个接口叫做 ChatCompletion,对应的模型叫做gpt-3.5-turbo,不但用起来更容易了,速度还快,而且价格也是我们之前使用的 text-davinci-003 的十分之一,可谓是物美价廉了。
import openai
openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Who won the world series in 2020?"},
{"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
{"role": "user", "content": "Where was it played?"}
]
)
[reference_begin]注:点击在这个链接你可以看到接口调用示例。[reference_end]
在OpenAI的官方文档里,可以看到这个接口也非常简单。你需要传入的参数,从一段Prompt变成了一个数组,数组的每个元素都有role和content两个字段。
有了这个接口,我们就很容易去封装一个聊天机器人了,我把代码放在了下面,我们一起来看一看。
import openai
import os
openai.api_key = os.environ.get("OPENAI_API_KEY")
class Conversation:
def __init__(self, prompt, num_of_round):
self.prompt = prompt
self.num_of_round = num_of_round
self.messages = []
self.messages.append({"role": "system", "content": self.prompt})
def ask(self, question):
try:
self.messages.append({"role": "user", "content": question})
response = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=self.messages,
temperature=0.5,
max_tokens=2048,
top_p=1,
)
except Exception as e:
print(e)
return e
message = response["choices"][0]["message"]["content"]
self.messages.append({"role": "assistant", "content": message})
if len(self.messages) > self.num_of_round*2 + 1:
del self.messages[1:3] //Remove the first round conversation left.
return message
下面,我们就来试一试这个Conversation类好不好使。
prompt = """你是一个中国厨师,用中文回答做菜的问题。你的回答需要满足以下要求:
1. 你的回答必须是中文
2. 回答限制在100个字以内"""
conv1 = Conversation(prompt, 2)
question1 = "你是谁?"
print("User : %s" % question1)
print("Assistant : %s\n" % conv1.ask(question1))
question2 = "请问鱼香肉丝怎么做?"
print("User : %s" % question2)
print("Assistant : %s\n" % conv1.ask(question2))
question3 = "那蚝油牛肉呢?"
print("User : %s" % question3)
print("Assistant : %s\n" % conv1.ask(question3))
在问完了3个问题之后,我们又问了它第四个问题,也就是我们问它的第一个问题是什么。这个时候,它因为记录了过去第1-3轮的对话,所以还能正确地回答出来,我们问的是“你是谁”。
question4 = "我问你的第一个问题是什么?"
print("User : %s" % question4)
print("Assistant : %s\n" % conv1.ask(question4))
输出结果:
User : 我问你的第一个问题是什么?
Assistant : 你问我:“你是谁?”
而这个时候,如果我们重新再问一遍“我问你的第一个问题是什么”,你会发现回答变了。因为啊,上一轮已经是第四轮了,而我们设置记住的num_of_round是3。在上一轮的问题回答完了之后,第一轮的关于“你是谁”的问答,被我们从ChatGPT的对话历史里去掉了。所以这个时候,它会告诉我们,第一个问题是“鱼香肉丝怎么做”。
question5 = "我问你的第一个问题是什么?"
print("User : %s" % question5)
print("Assistant : %s\n" % conv1.ask(question5))
输出结果:
User : 我问你的第一个问题是什么?
Assistant : 你问我:“请问鱼香肉丝怎么做?”
无论是在第03讲里,还是这一讲里,我们每次都要发送一大段之前的聊天记录给到OpenAI。这是由OpenAI的GPT-3系列的大语言模型的原理所决定的。GPT-3系列的模型能够实现的功能非常简单,它就是根据你给他的一大段文字去续写后面的内容。而为了能够方便地为所有人提供服务,OpenAI也没有在服务器端维护整个对话过程自己去拼接,所以就不得不由你来拼接了。
即使ChatGPT的接口是把对话分成了一个数组,但是实际上,最终发送给模型的还是拼接到一起的字符串。OpenAI在它的Python库里面提供了一个叫做 ChatML 的格式,其实就是ChatGPT的API的底层实现。OpenAI实际做的,就是根据一个定义好特定分隔符的格式,将你提供的多轮对话的内容拼接在一起,提交给 gpt-3.5-turbo 这个模型。
<|im_start|>system
You are ChatGPT, a large language model trained by OpenAI. Answer as concisely as possible.
Knowledge cutoff: 2021-09-01
Current date: 2023-03-01<|im_end|>
<|im_start|>user
How are you<|im_end|>
<|im_start|>assistant
I am doing well!<|im_end|>
<|im_start|>user
How are you now?<|im_end|>
[reference_begin]注:chatml的文档里,你可以看到你的对话,就是通过 <|im_start|>system|user|assistant、<|im_end|> 这些分隔符分割拼装的字符串。底层仍然是一个内容续写的大语言模型。[reference_end]
ChatGPT的对话模型用起来很方便,但是也有一点需要注意。就是在这个需要传送大量上下文的情况下,这个费用会比你想象的高。OpenAI是通过模型处理的Token数量来收费的,但是要注意,这个收费是“双向收费”。它是按照你发送给它的上下文,加上它返回给你的内容的总Token数来计算花费的Token数量的。
这个从模型的原理上是合理的,因为每一个Token,无论是你发给它的,还是它返回给你的,都需要通过GPU或者CPU运算。所以你发的上下文越长,它消耗的资源也越多。但是在使用中,你可能觉得我来了10轮对话,一共1000个Token,就只会收1000个Token的费用。而实际上,第一轮对话是只消耗了100个Token,但是第二轮因为要把前面的上下文都发送出去,所以需要200个,这样10轮下来,是需要花费5500个Token,比前面说的1000个可多了不少。
所以,如果做了应用要计算花费的成本,你就需要学会计算Token数。下面,我给了你一段示例代码,看看在ChatGPT的对话模型下,怎么计算Token数量。
第一种计算Token数量的方式,是从API返回的结果里面获取。我们修改一下刚才的Conversation类,重新创建一个Conversation2类。和之前只有一个不同,ask函数除了返回回复的消息之外,还会返回这次请求消耗的Token数。
class Conversation2:
def __init__(self, prompt, num_of_round):
self.prompt = prompt
self.num_of_round = num_of_round
self.messages = []
self.messages.append({"role": "system", "content": self.prompt})
def ask(self, question):
try:
self.messages.append( {"role": "user", "content": question})
response = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=self.messages,
temperature=0.5,
max_tokens=2048,
top_p=1,
)
except Exception as e:
print(e)
return e
message = response["choices"][0]["message"]["content"]
num_of_tokens = response['usage']['total_tokens']
self.messages.append({"role": "assistant", "content": message})
if len(self.messages) > self.num_of_round*2 + 1:
del self.messages[1:3]
return message, num_of_tokens
然后我们还是问一遍之前的问题,看看每一轮问答消耗的Token数量。
conv2 = Conversation2(prompt, 3)
questions = [question1, question2, question3, question4, question5]
for question in questions:
answer, num_of_tokens = conv2.ask(question)
print("询问 {%s} 消耗的token数量是 : %d" % (question, num_of_tokens))输出结果:
输出结果:
询问 {你是谁?} 消耗的token数量是 : 108
询问 {请问鱼香肉丝怎么做?} 消耗的token数量是 : 410
询问 {那蚝油牛肉呢?} 消耗的token数量是 : 733
询问 {我问你的第一个问题是什么?} 消耗的token数量是 : 767
询问 {我问你的第一个问题是什么?} 消耗的token数量是 : 774
可以看到,前几轮的Token消耗数量在逐渐增多,但是最后3轮是一样的。这是因为我们代码里只使用过去3轮的对话内容向ChatGPT发起请求。
第二种方式,我们在上一讲用过,就是使用Tiktoken这个Python库,将文本分词,然后数一数Token的数量。
需要注意,使用不同的GPT模型,对应着不同的Tiktoken的编码器模型。对应的文档,可以查询这个链接:https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
我们使用的ChatGPT,采用的是cl100k_base的编码,我们也可以试着用它计算一下第一轮对话使用的Token数量。
import tiktoken
encoding = tiktoken.get_encoding("cl100k_base")
conv2 = Conversation2(prompt, 3)
question1 = "你是谁?"
answer1, num_of_tokens = conv2.ask(question1)
print("总共消耗的token数量是 : %d" % (num_of_tokens))
prompt_count = len(encoding.encode(prompt))
question1_count = len(encoding.encode(question1))
answer1_count = len(encoding.encode(answer1))
total_count = prompt_count + question1_count + answer1_count
print("Prompt消耗 %d Token, 问题消耗 %d Token,回答消耗 %d Token,总共消耗 %d Token" % (prompt_count, question1_count, answer1_count, total_count))
输出结果:
总共消耗的token数量是 : 104
Prompt消耗 65 Token, 问题消耗 5 Token,回答消耗 20 Token,总共消耗 90 Token
我们通过API获得了消耗的Token数,然后又通过Tiktoken分别计算了System的指示内容、用户的问题和AI生成的回答,发现了两者还有小小的差异。这个是因为,我们没有计算OpenAI去拼接它们内部需要的格式的Token数量。很多时候,我们都需要通过Tiktoken预先计算一下Token数量,避免提交的内容太多,导致API返回报错。
我们已经有了一个封装好的聊天机器人了。但是,现在这个机器人,我们只能自己在Python Notebook里面玩,每次问点问题还要调用代码。那么,接下来我们就给我们封装好的Convesation接口开发一个界面。
我们直接选用Gradio这个Python库来开发这个聊天机器人的界面,因为它有这样几个好处。
[reference_begin]注:Gradio官方也有用其他开源预训练模型创建Chatbot的教程https://gradio.app/creating-a-chatbot/[reference_end]
在实际开发之前,还是按照惯例我们先安装一下Python的Gradio的包。
conda install gradio
Gradio应用的代码我也列在了下面,对应的逻辑也非常简单。
import gradio as gr
prompt = """你是一个中国厨师,用中文回答做菜的问题。你的回答需要满足以下要求:
1. 你的回答必须是中文
2. 回答限制在100个字以内"""
conv = Conversation(prompt, 10)
def answer(question, history=[]):
history.append(question)
response = conv.ask(question)
history.append(response)
responses = [(u,b) for u,b in zip(history[::2], history[1::2])]
return responses, history
with gr.Blocks(css="#chatbot{height:300px} .overflow-y-auto{height:500px}") as demo:
chatbot = gr.Chatbot(elem_id="chatbot")
state = gr.State([])
with gr.Row():
txt = gr.Textbox(show_label=False, placeholder="Enter text and press enter").style(container=False)
txt.submit(answer, [txt, state], [chatbot, state])
demo.launch()
你直接在Colab或者你本地的Jupyter Notebook里面,执行一下这一讲到目前的所有代码,就得到了一个可以和ChatGPT聊天的机器人了。
有了一个可以聊天的机器人,相信你已经迫不及待地想让你的朋友也能用上它了。那么我们就把它部署到 HuggingFace 上去。
代码提交之后,HuggingFace的页面会自动刷新,你可以直接看到对应的日志和Chatbot的应用。不过这个时候,我们还差一步工作。
在Name这里输入 [strong_begin]OPENAI_API_KEY[strong_end],然后在Secret value里面填入你的OpenAI的密钥。
好啦,这个时候,你可以重新点击App这个Tab页面,试试你的聊天机器人是否可以正常工作啦。
我把今天给你看到的Chatbot应用放到了HuggingFace上,你可以直接复制下来试一试。
地址:https://huggingface.co/spaces/xuwenhao83/simple_chatbot
希望通过这一讲,你已经学会了怎么使用ChatGPT的接口来实现一个聊天机器人了。我们分别实现了只保留固定轮数的对话,并且体验了它的效果。我们也明白了为什么,我们总是需要把所有的上下文都发送给OpenAI的接口。然后我们通过Gradio这个库开发了一个聊天机器人界面。最后,我们将这个简单的聊天机器人部署到了HuggingFace上,让你可以分享给自己的朋友使用。希望你玩得高兴!
在这一讲里,我们的Chatbot只能维护过去N轮的对话。这意味着如果对话很长的话,我们一开始对话的信息就被丢掉了。有一种方式是我们不设定轮数,只限制传入的上下文的Token数量。
期待能在评论区看到你的思考,也欢迎你把这节课分享给感兴趣的朋友,我们下一讲再见。