之前ChatGPT刚出来的时候大家都在研究GPT模型,有很多的科普文章都说GPT模型其实就是在做文字接龙,根据前面的文本预测下一个字。这样通俗的理解确实没有什么问题,但是作为程序员我们肯定不能满足于这么表面的理解。本文主要就是要从工程角度对大模型的文本生成过程的原理结合代码进行分析。值得注意的是本文的分析不会涉及GPT模型的实现原理,我会把它当作一个黑盒,重点放在文本生成的过程上面。
语言模型的token
首先纠正的是GPT模型每次预测的不是一个文字或者单词,准确来说是token。
在自然语言处理(NLP)中,token是文本的基本单位。通常,一个token可以是一个词、一个标点符号或任何其他字符组合。例如,在句子”I love NLP.”中,“I”、“love”、“NLP”和“.”都是单独的tokens。
一个token词典,或称为词汇表(vocabulary),是一个将不同的tokens映射到唯一数字的集合。这个数字通常称为token的索引。在自然语言处理中,这种词典使得计算机能够处理和理解文本。
例如,考虑这个简单的句子集合:
- 我喜欢学习自然语言处理。
- 自然语言处理很有趣。
- 学习是一个持续的过程。
根据这些句子,我们可以创建一个token词典:
| token | 索引 |
|---|---|
| 我 | 1 |
| 喜欢 | 2 |
| 学习 | 3 |
| 自然语言处理 | 4 |
| 很 | 5 |
| 有趣 | 6 |
| 是 | 7 |
| 一个 | 8 |
| 持续的 | 9 |
| 过程 | 10 |
在这个词典中,每个唯一的token(如“喜欢”、“自然语言处理”)都被赋予了一个唯一的索引。这样,在进行自然语言处理时,我们可以使用这些索引来代表相应的词语。
需要token的原因是因为计算机无法直接理解和处理自然语言。通过将文本分割成较小的单元(tokens),可以更容易地对文本进行处理和分析。例如,分词(tokenization)是NLP中许多任务的第一步,如文本分类、情感分析、机器翻译等。通过tokenization,可以将文本转换成计算机能够处理的格式,以进行进一步的分析和处理。
ChatGPT的token计算
我们知道ChatGPT的接口是按照token数进行收费的,那么我们如何计算prompt的token数量呢?
其实很简单,使用tiktoken这个Python库就行。
import tiktoken
encoding = tiktoken.encoding_for_model("gpt-3.5-turbo")
code = encoding.encode("tiktoken is great!")
print(f"tiktoken is great! : {code}")
dcode = encoding.decode(code)
print(f"{code} : {dcode}")
上面的代码会使用gpt-3.5-turbo对应的分词方法对tiktoken is great!进行编码和解码,结果如下:
tiktoken is great! : [83, 1609, 5963, 374, 2294, 0]
[83, 1609, 5963, 374, 2294, 0] : tiktoken is great!
我们可以定义一个函数,计算编码的长度,这样就能知道不同模型的prompt消耗多少token了。
def num_tokens_from_string(string: str, encoding_name: str) -> int:
"""Returns the number of tokens in a text string."""
encoding = tiktoken.encoding_for_model(encoding_name)
num_tokens = len(encoding.encode(string))
return num_tokens
print("tiktoken is great!", num_tokens_from_string("tiktoken is great!", "gpt-3.5-turbo"))
print("tiktoken很厉害", num_tokens_from_string("tiktoken很厉害!", "gpt-3.5-turbo"))
上面的代码执行的结果是
tiktoken is great! 6
tiktoken很厉害 10
可以发现,中文会比英文更加费token,具体原因跟编码的方式有关系,这里就不展开了。
GPT模型文本生成的范式
在了解完token之后,接下来终于进入我们的正题。下面我会用一个简化版的文本生成例子,其中会忽略GPT算法的细节,只关注工程实现的细节。
在调用大模型生成文本之前,输入prompt文本首先会转换成token,然后再输入大语言模型中预测下一个token。
假设我们现在想让大模型输出:I am gpt.,其中I am 作为输入的prompt,那么我们期望大模型输出的就是gpt.。我们复用上面tiktoken的encoding,代码如下:
input_ids = encoding.encode("I am ")
predict_ids = encoding.encode("gpt.")
print(input_ids, predict_ids)
>> [40, 1097, 220] [70, 418, 13]
可以看到I am 被编码成了[40, 1097, 220],gpt.被编码成了[70, 418, 13]。接下来我们定义generate函数来代表大模型的生成过程,代码如下:
def generate(input_ids, max_new_tokens):
idx = input_ids
print(f"初始prompt:{encoding.decode(input_ids)}")
for i in range(max_new_tokens):
idx_next = model(idx, predict_ids, i)
idx.append(idx_next)
print(f"第{i}次生成的结果:{encoding.decode(idx)}")
return idx
def model(input_ids, predict_ids, i):
return predict_ids[i]
result_ids = generate(input_ids, 3)
print(result_ids)
print(encoding.decode(result_ids))
>>>>执行结果<<<<<
初始prompt:I am
第0次生成的结果:I am g
第1次生成的结果:I am gpt
第2次生成的结果:I am gpt.
[40, 1097, 220, 70, 418, 13]
I am gpt.
generate的入参是:input_ids和max_new_tokens,input_ids就是前面我们编码过的prompt输入,max_new_tokens则表示大模型最多要预测多少个token。虽然是简化的生成过程,但能够方便我们理解大模型生成的过程。大模型的生成过程其实就是一个max_new_tokens次的for循环,每次迭代都代表生成一个新的token。
- 第一次执行是拿初始的prompt作为入参,然后调用
model预测下一个token:idx_next,然后将新生成的idx_next添加到序列idx中。 - 接下来的每次执行都将前一次生成的结果
idx再调用model生成下一个token,直到循环结束。
以上过程就是大语言模型生成文本的抽象过程,是不是很像文字接龙?
为了简化,我这边还定义了一个model函数,这个就代表了大模型预测的功能,正常的GPT模型入参只有一个input_ids,我这里面多加了两个:predict_ids和i,因为我们是mock了大模型,所以预测的结果predict_ids要传进去,i表示当前是要预测第几个token,这样model函数就能返回对于的token了。
对max_new_tokens的理解
到这里你可能会有个疑问,上面的例子中我们是上帝视角知道预测的结果正好是是3个token,但实际文本生成的时候,大模型是不知道的呀,max_new_tokens不一定能正好匹配上,那么大模型是怎么处理的?
这里我们要先理清楚max_new_tokens和大模型的max_token的关系。
大模型的max_token也称为最大的上下文长度,可以简单理解为input_ids的最大长度。大型语言模型通常是基于 Transformer 架构的。Transformer 模型中的自注意力机制在处理输入时需要考虑每个输入令牌与其他所有令牌之间的关系。随着输入长度的增加,这种计算的复杂度和内存需求呈平方级增长。因此,为了保持计算的可行性,模型设计时会设定一个最大上下文长度。
max_new_tokens的最大值就是max_token - len(input_ids),也就是说max_new_tokens是有上限的,当你调用gpt-3.5-turbo生成文本时,0 < max_new_tokens <= 4096 - len(prompt_token)。
以下我们讨论的max_new_tokens都是小于等于max_token - len(input_ids)的,超过的情况GPT接口直接就返回异常了,所以我们不讨论这种情况。
了解了max_new_tokens的上限之后,我们再来讨论生成结果的长度actual_size跟max_new_tokens的关系,这里可以分为两种情况讨论:
actual_size > max_new_tokens:实际生成结果长度大于max_new_tokens,这种情况下从上面的mock代码就可以分析出来,大模型只会执行max_new_tokens次,返回的结果会有出现截断的情况。actual_size < max_new_tokens:实际生成结果长度小于max_new_tokens,这种情况从上面的mock代码来看模型会继续预测,强行接话。当然这不是我们预期的情况,所以在实际的应用中,我们会在token中加入一个特殊的token(eot_token)来表示结束,当模型预测的结果是eot_token时,就停止预测,提前结束循环。
def model(input_ids, predict_ids, i):
if len(predict_ids) <= i:
return encoding.eot_token
return predict_ids[i]
def generate(input_ids, max_new_tokens):
idx = input_ids
print(f"初始prompt:{encoding.decode(input_ids)}")
for i in range(max_new_tokens):
idx_next = model(idx, predict_ids, i)
if idx_next == encoding.eot_token:
print("收到eot_token,提前结束循环")
break
idx.append(idx_next)
print(f"第{i}次生成的结果:{encoding.decode(idx)}")
return idx
result_ids = generate(input_ids, 5)
print(result_ids)
print(encoding.decode(result_ids))
>>>>执行结果<<<<<
初始prompt:I am
第0次生成的结果:I am g
第1次生成的结果:I am gpt
第2次生成的结果:I am gpt.
收到eot_token,提前结束循环
[40, 1097, 220, 70, 418, 13]
I am gpt.
我修改了之前的mock代码,加入了eot_token的判断,当模型觉得生成的结果已经结束了,就会预测下一个token是eot_token,然后我们在for循环中判断一下预测的结果是否为eot_token,如果是就直接退出循环。通过这种方式我们就能让大模型自己判断是否应该停止生成,而不是强迫它继续瞎编下去。
总结
在这篇文章中,我们从工程角度对GPT模型在文本生成中的关键步骤进行了分析。从token的构成、计算,到文本生成的具体流程。通过实际的代码演示和简化的模型例子,我们得以更清晰地理解这一巧妙过程。虽然对GPT模型的内部实现细节没有深入探讨,但我们的分析已足以让读者对模型的运作原理有个基本的认识。希望这对于想要深入了解和应用GPT模型的读者有所帮助。