这段时间正好休息,想着之前Transformers的Generate函数好像一直没有去仔细阅读,之前除了没大有时间以外,还有一个原因是Transformers的Generate函数过于复杂。虽然这个函数这么写本意是挺好的,支持各种模型生成,有比较好的模块化封装,但是这简直新手不友好,如果想自己探索一下Next Token的生成方式,或者仅仅是对某个特定模型做一些调整,改成分类任务,就显得有些无从下手的感觉。
本文就以通义千问2.5的7B模型作为示例,咱们先从最简单的生成代码开始,聊一下使用大模型生成文本,比如对话,关键有哪些步骤,然后我们再来结合Transformers的Qwen2.5 generate的逻辑,探究下用Transformers库默认生成时候这些关键步骤都做了些什么。

冷知识普及

首先介绍下Qwen2.5或者说大模型的一些背景资料。

  1. 模型的输入:模型并不能直接接收文本输入,模型接收到输入类型主要有两种,但不论哪一种,都是需要把原本输入文本文字进行token化,转成一个个token id,即token序列。举个简单例子,Which city is the capital of France?就会被token化成[23085, 3283, 374, 279, 6722, 315, 9625, 30],这样的token化之后的id list是输入之一,另一种输入就是这些id对应的embedding即映射的维度向量。我们通常提供id就可以,因为模型内部会进行一个查表将这些id转换成向量的形式。这里可以看到一个局限就是模型能接收的内容上限,其实就是分词的词典集合,后面介绍到输出也是同理的,不过好在id化的文字已经基本cover了绝大多数表达,影响不大。
  2. 模型的结构:抛开Transformers网络结构,大模型整体可以分为 把id转成embedding,embedding过Transformers网络 和 embedding to id这三个部分。
  3. 模型的输出: 在推理阶段,embedding to id这步其实正是Generate函数发挥大用的时候。模型输出的是每个id作为下一个词的概率分布,拿到这些分布之后,具体决定哪个词作为下一个词,就是Generate函数逻辑要干的事情了。

最简Generate

前面提到,Transformers的Generate函数有点新手不太友好,这里笔者写了个最简Generate函数,作为Transformers的Generate函数的替代,方便新手理解。
首先我们先加载千问模型,并提取我们需要的nn.Modules(模型结构和参数)

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

# Load the tokenizer and model
model_name = "/path/to/Qwen2.5-7B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_name)
qwen2_model = AutoModelForCausalLM.from_pretrained(model_name, device_map='cpu', torch_dtype=torch.bfloat16)
model = qwen2_model.model # 这一步是用来取大模型中 把id转成embedding,embedding过Transformers网络 这两个步骤的模型
model.to("cuda" if torch.cuda.is_available() else "cpu") # 把这个模型挪到GPU,如果有GPU的话
lm_head = qwen2_model.lm_head.to("cuda" if torch.cuda.is_available() else "cpu") # 取把embedding转换成id概率的那个模型

然后定义一下Chat Template并调用我们的simple_generate函数完成输出

def create_chat_template(system_prompt, user_message):
    return f"System: {system_prompt}\n\nUser: {user_message}\nAssistant:"

system_prompt = "You are a helpful assistant."
input_text = "Which city is the capital of France?"
chat_input = create_chat_template(system_prompt, input_text)
generated_sequence = simple_generate(model, tokenizer, chat_input, max_length=512, eos_token_ids=[151645, 151643])
print(generated_sequence)

接着我们需要实现一个simple_generate函数,输入是模型、文本,输出跑大模型后得到的文本结果
这个函数定义如下

def simple_generate(model, tokenizer, input_text, max_length=50, eos_token_ids=None):

先关注前面三个内容,即模型(前文定义的model),tokenizer,文本。这三个内容是必备的,tokenizer用于token化输入文本和反解模型输出id到文本形式。
接下来具体实现,我们先实现不带KV Cache的版本

def simple_generate(model, tokenizer, input_text, max_length=50, eos_token_ids=None):
    # 我们首先借助tokenizer将文本转成id。
    input_ids = tokenizer.encode(input_text, return_tensors="pt").to(model.device)

    # 然后把位置向量带上,告诉模型每个输入对应的文本位置是多少,从0开始
    cache_position = torch.arange(input_ids.shape[1], device=model.device)
    # 不要cache,因此`use_cache`是`False`,`past_key_value`也是`None`
    model_inputs = {
        "input_ids": input_ids,
        "cache_position": cache_position,
        "past_key_values": None,
        "use_cache": False
    }
    # 这里是循环,`max_length`是我们函数定义里第4个参数,也就是我们终止生成的条件之一,即最大生成多少字符后就不再继续生成了。
    for _ in range(max_length):
        # 把模型输入喂给模型,我们会得到一个outputs
        outputs = model(**model_inputs)

        # 这里,我们取最后一层,最后一个字符的向量输出,作为embedding转换成id概率这个模型的输入,调用之后得到字典里每个token作为下一个词的概率分布
        next_token_logits = lm_head(outputs.last_hidden_state[:, -1, :])
        # 然后我们这里做了个最简单操作,直接要分数最大的那个,作为下一个词的概率分布
        next_token_id = torch.argmax(next_token_logits, dim=-1)

        # 这里,`eos_token_ids`是我们函数定义里第5个参数,也就是一个终止生成的条件,即下一个词如果是特殊的终止符的id,那么我们也停止生成。因为在这个之后的文本都是没有啥意义的。
        if eos_token_ids is not None and next_token_id in eos_token_ids:
            break

        # 因为不做Cache,那么每次就得把新的词加到原先的输入里,扩大输入
        input_ids = torch.cat([input_ids, next_token_id.unsqueeze(-1)], dim=-1)
        # 更新一下模型输入
        model_inputs["input_ids"] = input_ids
        # 因为我们新增了一个token的长度,这个token的位置下标也得加入,这里选择重新生成,concat也是可以的
        model_inputs["cache_position"] = torch.arange(input_ids.shape[1], device=model.device)

    # Decode the generated sequence
    generated_sequence = tokenizer.decode(input_ids[0])
    return generated_sequence

来看下运行效果,看起来不错。不过这里好像有些重复计算,因为我们每次都是往后增加新的字符,所以前面的字符的KV好像不需要重复计算,直接cache下来就好了。
Screenshot from 2025-04-29 22-40-38.png
所以我们对这个simple_generate函数再进行一下改良

@torch.no_grad
def simple_generate(model, tokenizer, input_text, max_length=50, eos_token_ids=None):
    # Encode the input text
    input_ids = tokenizer.encode(input_text, return_tensors="pt").to(model.device)
    
    # With cache version
    cache_position = torch.arange(input_ids.shape[1], device=model.device)
    model_inputs = {
        "input_ids": input_ids,
        "cache_position": cache_position,
        "past_key_values": DynamicCache(), # 我们直接使用Transfomers的DynamicCache实现
        "use_cache": True  # 改成True使用cache
    }

    for _ in range(max_length):
        # 前面几步没变化
        outputs = model(**model_inputs)

        next_token_logits = lm_head(outputs.last_hidden_state[:, -1, :])
        next_token_id = torch.argmax(next_token_logits, dim=-1)

        if eos_token_ids is not None and next_token_id in eos_token_ids:
            break

        # Append the generated token to the input_ids
        input_ids = torch.cat([input_ids, next_token_id.unsqueeze(-1)], dim=-1)
        # 不同点1,我们只放本次我们选的token,即next_token_id。前面的不再带了
        model_inputs["input_ids"] = next_token_id.unsqueeze(-1)

        # 不同点2,更新cache
        past_key_values = outputs.past_key_values
        model_inputs["past_key_values"] = past_key_values
        # 不同点3,位置信息,同样有一个,即next_token_id对应的位置
        cache_position = torch.tensor([input_ids.shape[1]], device=model.device)
        model_inputs["cache_position"] = cache_position

    generated_sequence = tokenizer.decode(input_ids[0])
    return generated_sequence

同样的,运行一下,看起来没有问题
2025-04-29T14:53:44.png
笔者之前曾听过这样的言论,说是大模型在出第一个词之后,后续的词就只和这第一个词有关系了,显然是看了带Cache的Generate代码,但是又没有完全看懂。不过结合Self-Attention的逻辑,也能发现这样的论述并不能站得住脚,毕竟只看一个token的话,那怎么保证下一个token就是想要的呢。

Transformers的Generate函数

我们首先借助print大法,打印一下generation_config,我这边以7B的模型为例
这是generation_config,其实很多东西和我们的simple_generate函数是有对应关系的,max_new_tokens对应max_lengtheos_token_id对应eos_token_ids。那么其他的作用是啥呢

{
  "bos_token_id": 151643,
  "do_sample": true,
  "eos_token_id": [
    151645,
    151643
  ],
  "max_new_tokens": 512,
  "pad_token_id": 151643,
  "repetition_penalty": 1.1,
  "temperature": 0.7,
  "top_k": 20,
  "top_p": 0.8
}

和我们的简单实现不同,Transformers的Generate函数在拿到next_token_logits,除了支持直接argmax,还支持采样的方式("do_sample": true),由于采样时候下一个token不是固定的,会根据采样概率随机选择,因此为了进一步控制模型输出效果,会跑如下4个流程

  • RepetitionPenaltyLogitsProcessor,之前出现过的token,统统logits缩小指定倍数(小于零就直接乘倍数,大于0则乘以1/倍数)
  • TemperatureLogitsWarper,温度系数,不展开
  • TopKLogitsWarper,只保留最大K个logit,其他的强制设置为-inf,softmax之后就是最小值(近0)
  • TopPLogitsWarper,确保logits累加之后不大于这个值,为了删掉一些小值,只留大的

停止条件则和我们simple_generate实现没有什么差别,即MaxLengthCriteria,到达指定步骤,和 EosTokenCriteria,遇到终止符。

后记

通过simple_generate函数的实现,笔者希望能够让大家对大模型生成方式有进一步的了解。同时也能作为阅读Transformers Generate函数的入门指引,或者基于Qwen2.5做一些Generate的改进。在撰文之际,Qwen3也出来了,同时强烈建议大家不要使用argmax的方式(greedy decoding)来生成,否则会有性能下降和重复吐相同的字(token)。后续有时间我试用一下Qwen3模型,再更新一下Qwen3的simple_generate