从 C 端的外卖、网购,到 B 端的 On call,问答无处不在。而通过智能问答,降低问答的人力成本,一直是各大平台问答模块的孜孜不倦的目标。然而,我们日常看到的更多是由生硬规则堆叠而成的“人工智障”,或是结合 NLP 的相对友好,但仍不尽人意的问答系统。直到 ChatGPT 出现,这一切迎刃而解。
这次,让我们在《“ChatGPT+”时代的产品范式》的基础上,展开聊聊 GPT 智能问答的原理与实践。
本文要点:
GPT 智能问答基本原理
如何构建明确的提示词
微调,还是不微调
现阶段的最佳解决方案
GPT 全称 Generative Pre-trained Transformer,名字大差不差地将其特别之处都点到了:
Generative
生成式,有别于以往常见的判别式(Discriminative)模型。生成式模型往往基于输入给一个相关的输出,而这个输出没有绝对的好坏;而判别式模型的效果往往有很客观的标准解。
生成式模型在自然语言之外,还应用于图像,比如 Style Transfer 将 A 图的风格迁移到 B 图,后面结合 GAN 发扬光大的模型,以及最近热火朝天的 Stable Fusion、Midjourney、Firefly;而判别式模型主要应用于预测、分类(也算是预测的一种特例)等场景。
Pre-trained
预先训练好的,指 GPT 不是一个空壳模型,而是参数都训练过了。当然,这不是一个很特别的点,但是高就高在训练集的形式和质量上。
OpenAI 很注重标注员的挑选与规则设定,这就保证了 RLHF 中的人工反馈(Human Feedback)的质量。除此以外,我们似乎对 GPT-3.5/4 的训练细节知之甚少。不过,尽管 OpenAI 在此讳莫如深,我们还是能根据公开资料找到一点蛛丝马迹,这点我们后面会谈到。
Transformer
在自然语言领域,Transformer 相比其前任(RNN、LSTM),最大的特点在于引入了注意力机制,其多头注意力机制,让模型在理解文本时,更具有全局意识、上下文思维。不过 Transformer 的限制是,其输入、输出是定长的,而 RNN、LSTM 的输入、输出是不定长的,这让其无法处理过长的文本。关于 Transformer 的更多信息,可以参考《Attention Is All You Need》。
然而,这世界上有 2 类 Transformer,一类是 GPT,一类是其他。原因是,GPT 的参数量实在是太庞大了,GPT-3 系列模型中,最大的参数量达到了 1,750 亿,而之后的 GPT-3.5/4 虽然没有确凿的数字,但无疑会更高。
而且,GPT-4 已经支持多模态(文字 + 图片)。当然,由于这块儿缺少公开的细节资料,这里也不做过多揣测了。
总而言之,GPT 在数据和算法上,都有卓越的突破。
回到实践层面,GPT 究竟如何为我所用,成为我的低成本智能客服呢?
我们有 2 种选择:
通过设计提示词(所谓 Prompt Engineering),来让 GPT 按我们的预期与用户交互;
进行模型微调(Fine-tuning),预先将我们的语料喂给 GPT。
我们基本都会从第 1 种方式开始,看看效果;后面再决定是否要切换到第 2 种。这两种方式的区别我们后面再讲。
首先,笔者设计了如下 Prompt:
以下每一行分别是问题和答案,请根据以下内容回答问题;如果内容没有提及,请回答不知道。
Q:<产品名称>是什么 A:<产品名称>是……
……………………(剩下的几千字语料)……………………
然而笔者测试后发现,这个 Prompt 有 4 个问题:
态度上不像客服;
回答容易比较冗长;
语料中没有答案的时候,GPT 会如此回答,但后面会按照自己具备的知识作补充;
测试的时候,会偶现英文回答。
为了解决上述问题,我们可以对 Prompt 作如下调整:
明确其客服身份;
要求回答尽可能简短;
明确语料中没答案的时候,按固定话术回答,不乱发挥;
要求回答用中文,但这还不够,整个 Prompt 用英文写清楚。(怀疑中文 Prompt “权重”不够?)
You are a customer service bot. Following are Q&As per line about <产品名称>. Please answer the user's question only according to the Q&As provided. If no Q&A seems to cover it, you must answer "抱歉,您问倒我了,您可以点此咨询人工。". Remember, only base your answer on the knowledge below! Try to answer in Chinese, and keep it as concise as possible.
这个 Prompt 解决了问题 1、2、4,答案已经相当令人满意了(虽然少数时候会出现 hallucination,自信地乱答);但问题 3 解决得并不完美。尽管有相当多的时候,GPT 会表明语料中没有答案,引导用户咨询人工,但也有不少次,GPT 依然会根据自身具备的知识作答。
无论如何,整体已经非常可用,这点缺陷相对可以接受。但目前有 3 个新问题摆上台面了:
GPT 应答延时很高,虽然可以忍,但当然希望快一点;
50 条请求(没问一个问题算一次请求)花了 $0.33,虽然 GPT-3.5 价格较之前已经是打“骨折”了,但依然比预想的高;
更大的问题是,聊几句后,上下文的长度超出了 GPT-3.5 支持的最大长度(token 数),于是就 API 报错,“聊不下去”了。
面对这些问题,第 2 种方式——微调——提上了议程。
对笔者而言,微调的吸引力最早有 2 点:
用语料二次训练 GPT,固定一次性投入,之后请求就不用重复输入语料了,成本应该会降低不少;
语料可能会达到几万字,这种情况下,完全没法靠 Prompt 来输入。
然而实践过后,笔者对微调比较失望。
首先,GPT-3.5、GPT-4 现阶段以及短期内是不支持微调的,我们只能选用 GPT-3 的 base 模型(“ABCD 四兄妹“)。一开始测试效果,我们自然选择“长兄” davinci 出战。
要微调,第一步就是要准备语料,而语料的准备思路,和 GPT-3.5/4 的 Prompt Engineering 其实很不一样。我们可以看看 Open AI 给出的最佳实践:
{"prompt":"Summary: <summary of the interaction so far>\n\nSpecific information:<for example order details in natural language>\n\n###\n\nCustomer: <message1>\nAgent: <response1>\nCustomer: <message2>\nAgent:", "completion":" <response2>\n"}
{"prompt":"Summary: <summary of the interaction so far>\n\nSpecific information:<for example order details in natural language>\n\n###\n\nCustomer: <message1>\nAgent: <response1>\nCustomer: <message2>\nAgent: <response2>\nCustomer: <message3>\nAgent:", "completion":" <response3>\n"}
发现没有,如果你想达到 GPT-3.5/4 那种对答如流的效果,训练集就得包含整个历史,而不能只给出 Q&A 让 GPT-3 试图理解。想想也很正常,GPT-3 毕竟不像 GPT-3.5/4 那样,针对对话场景有做专门调教。
那我们放低期望,不要求 GPT-3 对答如流,一问一答总能做到吧。可以,按照 OpenAI 的建议,我们的语料可以这么准备:
{"prompt":"你是谁? ->", "completion":" 我是你的智能助理。\n"}
或者下面这样也可以:
{"prompt":"问题:你是谁? 答案:", "completion":" 我是你的智能助理。END"}
这样一番准备之后,我们调用 OpenAI 的托管训练服务,微调后的模型的效果是这样。
首先尝试打直球,GPT-3 话都说不全,很支离破碎:
我:<产品名称>有啥用 ->
GPT-3: 作为聊天服务和
中文分词后效果会正常些吗?不会:
我:<产品名称> 有 啥 用 ->
GPT-3: <产品名称> 是 一個 體
中文不好,那英文知识总够吧?也不够:
我:What's your ->
GPT-3: nameWhat's your name?” He opens his mouth to give her a quick answer
发现规律没有,GPT-3 只是在尝试接话茬,但接的话并不一定有意义。这也是为什么 OpenAI 建议,如果将问题、答案分别作为 prompt
和 completion
的话,prompt
后面最好有一个固定的分隔词(如“ ->”),completion
开头最好有一个空格,并且以同样的 stop_word(如“\n”或“END”)结尾。如果情况更复杂,prompt
包含前置知识和问题,那么两者之间最好用固定的突出的分隔词隔开(如“###\n\n###”)。
由此可以判断,目前支持微调的模型,本身在对话上是一张白纸。要达到预期水平,一是需要语料具有代表性并且量大,而是同一问题、答案的不同表述,都需要纳入语料中(这点不像 GPT-3.5/4 那样一点就会)。
不仅效果不太好,比较糟心的是,由于 davinci 请求单价高,相比第 1 种方式,并没有省多少钱。
微调之路宣告失败,但这并不意味着没有解决办法。
在之前的文章(《“ChatGPT+”时代的产品范式》)中,我们介绍了 ChatPDF/PandaGPT。这两款产品,在不改变 GPT-3.5 本身输入长度限制的情况下,将有限空间利用到了极致。具体做法是先将内容做索引;在用户提问后,根据索引筛选与用户问题最相关的内容,喂给 GPT-3.5。这样既优雅,又省钱。针对大部分单点具体问题(而非宏观问题)而言,回答质量没什么损失。
显然,我们也可以借鉴这种思路。
关于索引与相关性计算,很多年前就已经有了成熟的模型——Embedding。简单说就是将词语映射到高维向量空间,即用高维向量来表示词语中蕴含的信息。在这个向量空间中,我们要让 2 个相关的词的距离(比如余弦距离)尽可能小,让 2 个无关的词的距离尽可能大。
构建训练集的方式很简单,而且来源很丰富。句子中,特定范围内(比如 5 个词以内),如果 2 个词同时出现,那它们的相关性就高,反之则相关性低。
当然,如果懒的话,也有很多预训练的模型可以使用,OpenAI 也有 Embedding API 开箱即用。这里我们尝试后者。
综合一下,我们的逻辑如下:
加载语料库;
计算语料库中每条语料的 Sentence Embedding;
用户输入问题后,计算问题的 Sentence Embedding;
对比语料与问题的 Embedding,筛选出余弦距离最近的语料,我们称之为与问题最相关的语料;
结合我们之前的 Prompt,将语料喂给 GPT-3.5,从而既省钱,效果又棒棒。
上代码:
```python
import os, json, openai, tiktoken
import pandas as pd
from openai.embeddings_utils import get_embedding, cosine_similarity
from termcolor import colored
openai.api_key = 'sk-秘密秘密秘密秘密秘密秘密秘密秘密秘密秘密秘密'
corpus_file = 'df_corpus.tsv'
# 如果语料库文件已经存在,则直接加载。
if os.path.exists(corpus_file):
df_corpus = pd.read_csv(corpus_file, sep='\t')
df_corpus.embedding = df_corpus.embedding.map(lambda e: eval(e))
else:
corpus = []
with open('datong_corpus.jsonl') as f_corpus:
for line in f_corpus.readlines():
j = json.loads(line)
# 将原本准备用于 GPT-3 微调的语料格式转化为:Q:问题\tA:答案
corpus.append('Q:' + j['prompt'] + '\t' + 'A:' + j['completion'])
# 构建语料库 DataFrame。
df_corpus = pd.DataFrame(columns=['text', 'embedding'])
df_corpus.text = pd.Series(corpus)
# 首次运行时,计算语料库的 Embedding,并保存到文件,供以后使用。
df_corpus.embedding = df_corpus['text'].apply(lambda x: get_embedding(x, engine='text-embedding-ada-002'))
df_corpus.to_csv(corpus_file, sep='\t', index=False)
def search(df, query, top_n=5):
'''筛选与问题最相关的语料。
Args:
df: 语料库 DataFrame。
query: 用户的问题。
top_n: 返回的最相关的语料的数量。
'''
embedding = get_embedding(query, engine='text-embedding-ada-002')
df['similarities'] = df.embedding.apply(lambda x: cosine_similarity(x, embedding))
res = df.sort_values('similarities', ascending=False).head(top_n)
return res
def num_tokens_from_messages(messages, model="gpt-3.5-turbo-0301"):
"""Returns the number of tokens used by a list of messages."""
try:
encoding = tiktoken.encoding_for_model(model)
except KeyError:
encoding = tiktoken.get_encoding("cl100k_base")
if model == "gpt-3.5-turbo-0301": # note: future models may deviate from this
num_tokens = 0
for message in messages:
num_tokens += 4 # every message follows <im_start>{role/name}\n{content}<im_end>\n
for key, value in message.items():
num_tokens += len(encoding.encode(value))
if key == "name": # if there's a name, the role is omitted
num_tokens += -1 # role is always required and always 1 token
num_tokens += 2 # every reply is primed with <im_start>assistant
return num_tokens
else:
raise NotImplementedError(f"""num_tokens_from_messages() is not presently implemented for model {model}.
See https://github.com/openai/openai-python/blob/main/chatml.md for information on how messages are converted to tokens.""")
def answer(question, history=[], stream=True, prefix='智能客服:'):
'''回答用户的问题(请求 GPT-3.5 API)。
Args:
question: 用户的问题。
history: 会话历史,包含用户和机器人的回答。
stream: 是否使用 stream 模式,实时回显机器人的回答。
prefix: 机器人回答的前缀。
Returns:
ans: 机器人的回答。
history: 会话历史,包含用户和机器人的回答。
'''
system_context = '''You are a customer service bot. Following are Q&As per line about <产品名称>. Please answer the user's question only according to the Q&As provided. If no Q&A seems to cover it, you must answer "抱歉,您问倒我了,您可以点此咨询人工。". Remember, only base your answer on the knowledge below! Try to answer in Chinese, and keep it as concise as possible.'''
system_context += '\n'.join(search(df_corpus, question).text.values)
history += [{"role": "user", "content": question}]
messages = [{"role": "system", "content": system_context}] + history
messages_tokens_cnt = num_tokens_from_messages(messages)
res = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=messages,
stream=stream
)
if stream:
ans = ''
print(colored(prefix, 'cyan'), end='')
for chunk in res:
delta = chunk['choices'][0]['delta']
if 'content' in delta:
print(colored(delta['content'].replace('\n\n', '\n'), 'cyan'), end='') # 为了美观,去掉多余的换行。
ans += delta['content']
print()
print(colored(f'(Prompt: {messages_tokens_cnt} tokens, ${0.002 * messages_tokens_cnt / 1000})', 'light_grey'))
else:
ans = res['choices'][0]['message']['content']
print(colored(prefix + ans, 'cyan'))
print(colored(f"(Prompt: {res['usage']['prompt_tokens']} tokens, ${0.002 * res['usage']['prompt_tokens'] / 1000})", 'light_grey'))
history += [{"role": "assistant", "content": ans}]
return (ans, history)
history = []
while True:
question = input('用户:')
ans, history = answer(question, history)
最终效果和之前很像,但是更省钱了,应答也快了点。
当然,后面还有很多可做的:
如果用户发链接,链接里的内容是否也可选择性地加入到 Prompt 中;
类似于 Intercom Fin,是否能直接集成现有的知识库,怎么做到跨文档、低成本、有效地选取与用户问题相关性最高的内容;
如果用户发图片,嗯,那还是等等从 GPT-4 Waitlist 捞出来的那一天吧。