LLM大语言模型之Generate/Inference生
你可能不信,我翻云笔记的时候手都在抖——里面躺着二十多条大模型参数的翻车记录。温度拉到1.2,模型直接胡言乱语;Top-P调到0.9,照样跟复读机一样循环;Beam Search一开反而比原来更傻……每个坑我都踩过,而且踩得明明白白,就差在脑门上刻“参数受害者”了。
今天我把这些参数怎么来的、怎么用的、怎么让你摔跟头,掰开揉碎给你说清楚。代码扔GitHub了,跑一下就全懂。
先看裸奔:啥参数都不加,模型咋干活?
我一直觉得搞懂一个东西最快的方法就是看它“裸奔”。所以先从最原始的方式聊起——贪心解码。
拿Llama举个例子(其他生成式模型一个德性)。我输入“say”,分词之后变成token 1827。模型自己加了个BOS token ,实际进嘴的是两个token。
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
model_name = "Llama-2-7b-hf"
model = AutoModelForCausalLM.from_pretrained(model_name, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(model_name)
text = "say"
inputs = tokenizer(text, return_tensors="pt")
# 你看到的:{'input_ids': tensor([[ 1, 1827]]), 'attention_mask': tensor([[1, 1]])}然后直接喂给模型:
logits = model.forward(inputs.input_ids).logits
print(logits.shape) # [1, 2, 32000] 乖乖,32000个候选词!shape 是 [batch_size, sequence_length, vocab_size]。最后一个位置的logits就是模型对下一个token的预测分数。挑分数最高的那个:
next_token_id = torch.argmax(logits[:, -1, :], dim=-1)
print(next_token_id) # 比如 tensor([[22172]])
tokenizer.decode(next_token_id[0]) # 'hello'看到了吧,模型觉得“say”后面最该接“hello”。好,把“hello”续上,再预测下一个词……直到碰到EOS或者达到最大长度。
这就是贪心解码——每一步都选当前概率最高的词。简单粗暴,但用不了几次你就会发现:输出容易困在循环里,比如“I love to code. I love to code. I love to code…”跟卡住的唱片似的。
我最早做对话系统那会儿被这玩意儿坑惨了。生成两轮就开始复读,我还以为是模型脑子坏了,后来才明白——缺了采样这一步。
温度(Temperature):让模型“更浪”还是“更怂”
那怎么打破这种死板?最简单的办法是不再选概率最大的,而是按概率分布去采样。可问题来了:如果原始分布太极端(一个词概率0.99,其他加起来0.01),采样和argmax也没啥区别。所以需要个东西来“软化”分布——温度登场。
直接上代码,一看就懂:
logits = torch.tensor([[0.5, 1.2, -1.0, 0.1]]) # 四个候选词的原始分数
# 温度 = 1
probs = torch.softmax(logits, dim=-1)
# [0.2559, 0.5154, 0.0571, 0.1716]
# 温度 = 0.5
probs_low = torch.softmax(logits / 0.5, dim=-1)
# [0.1800, 0.7301, 0.0090, 0.0809]
# 温度 = 2
probs_high = torch.softmax(logits / 2, dim=-1)
# [0.2695, 0.3825, 0.1273, 0.2207]看到没?温度越低,分布越尖锐(0.5时第二个词从0.51飙到0.73);温度越高,分布越平坦(2.0时四个词差距缩小很多)。
规律很直接:温度接近0 → 接近贪心,稳定得像老干部;温度=1.0 → 原始分布,原汁原味;温度>1.0 → 随机性变大,开始放飞自我。
说到这儿我必须自曝一个踩坑经历。有回做故事生成,觉得“创意不够”直接把温度拉到1.5。你猜怎么着?模型开始编外星人入侵地球,中间还夹着法文歌词……我当场尴尬到脚趾抠地,后来乖乖降到0.9。
还有个实战经验你记好:如果prompt写得又长又细,模型本身就很确定,温度可以稍微调高点(0.8-1.0)增加多样性;如果prompt就两三个词,模型全靠猜——赶紧把温度压到0.3-0.5,不然它直接起飞,拉都拉不住。
Top-K和Top-P:别让冷门词出来捣乱
光有温度就够了吗?还不够。就算温度=2,采样的候选池还是所有32000个词。那些概率极低的词虽然被压了,但万一被抽中,生成出来的东西能让你怀疑人生。所以需要截断。
Top-K:从概率最高的K个词里采样
K=40,就是只留前40个高概率词,剩下的概率全部清零,然后重新归一化。思路简单,挺好理解。但问题也明显:K是固定的。如果概率分布本来就均匀,K=40可能切掉了一堆合理的词;如果分布很集中,K=40可能又放进来一堆不相干的词。总之K太死板了。
Top-P(Nucleus Sampling):动态截断,真正“看人下菜碟”
Top-P的思路更灵活。把概率从高到低排序,然后累加,直到累积概率超过阈值P,才从这个候选集里采样。比如P=0.9,就选到累积概率刚好超过0.9的那几个词,不管具体有几个。分布集中的时候,候选集小;分布均匀的时候,候选集自然变大。这才聪明。
我拿Llama2官方实现的sample_top_p来演示,注释写进去,你一看就明白:
def sample_top_p(probs, p):
# probs: [vocab_size] 已经归一化
sorted_probs, sorted_indices = torch.sort(probs, descending=True)
cum_probs = torch.cumsum(sorted_probs, dim=-1)
# 找到累积概率超过p的位置,把后面统统mask掉
mask = cum_probs - sorted_probs > p # 这里做减法,保证超过的那一刻也被保留
sorted_probs[mask] = 0.0
# 重新归一化,然后采样
sorted_probs = sorted_probs / torch.sum(sorted_probs)
next_token = torch.multinomial(sorted_probs, 1)
return sorted_indices[next_token]注意那个小细节:cum_probs - sorted_probs > p 这个写法,保证了累积概率刚好超过p的第一个词也被留在候选集里。不然边界处的词可能被误杀,采样会出问题。
我个人的习惯是Top-P设0.9,温度设0.8,基本能平衡质量和多样性。如果你发现生成内容还在反复兜圈子,一般先把Top-P降到0.85试试,别急着动温度。
Beam Search:束搜索,千万不要迷信它
说完了采样,再来聊聊Beam Search。这玩意儿在机器翻译时代是神兵利器,但在生成式对话里——我劝你三思而后用。
Beam Search的思路:每一步保留概率最高的num_beams条路径,到最后挑一条整体概率最高的。比如num_beams=4,每一步都保持4个假设,像多线程竞争一样。
听起来很完美对吧?但实际效果经常是:句子更流畅了,但也更无聊、更套话了。因为Beam Search专挑全局概率高的路径走,结果全是安全牌,毫无惊喜。而且它没法跟采样一起用(do_sample=False才能用Beam Search),多样性等于零。
我自己做过一个实验:用Beam Search生成广告文案,十条里有八条开头都是“我们的产品非常好……”,全是套话,读十遍都分不清谁是谁。后来换成采样+Top-P,虽然偶尔翻车,但确实能蹦出让眼前一亮的句子。
所以结论可以直白一点:写故事、对话、创意文本,用采样更合适;翻译、摘要这种对准确率要求高的任务,可以考虑Beam Search。
HuggingFace里还有个num_beam_groups,把束分成多组,每组独立搜索再合并,目的是增加多样性。说实话我试过两次,没有感觉到质的变化,不如直接调温度来得痛快。
重复惩罚:别让模型变成复读机
我敢打赌,每个调过LLM的人都见过这种输出:
I love coding. I love debugging. I love refactoring. I love unit tests...
最后变成“I love”的复读盛典。这就是典型的重复陷阱。贪心解码和低温度下尤其容易发生,因为一旦某个词概率高了,就会自己强化自己,形成正反馈,跟滚雪球似的。
解决办法?重复惩罚(repeat_penalty)。原理简单到想哭:在算概率之前,对已经出现过的token的logits打一个折扣。
代码大概长这样:
def apply_repetition_penalty(logits, input_ids, penalty=1.2):
for token_id in input_ids[0]:
logits[:, token_id] /= penalty
return logits每次生成前,遍历当前已生成的所有token,把它们在logits中的得分除以penalty(>1就会压低概率)。这样一来,模型就不太愿意重复刚用过的词了。
HuggingFace里还提供了两个类似参数:frequency_penalty(按出现次数惩罚,重复越多压越狠)和presence_penalty(只看有没有出现过,不管次数)。前者适合压制高频词滥用,后者更适合让模型换着花样说话。
我自己的经验:重复惩罚一般设在1.1到1.3之间。设太大了模型会开始回避常见词,生成变得扭扭捏捏。平衡的艺术,你懂的。
说到这儿你可能会觉得:调个参数怎么跟做菜似的,火候大小、材料多寡、时间长短……全是玄学。
对,就是玄学。但这不是瞎蒙,是有规律的玄学。每个参数背后都对应着模型对“确定性”和“多样性”的权衡。你理解了它为什么这么干,翻车的时候就知道该拧哪颗螺丝。
最后送你一句能截图的话:
**参数不是魔法,是你和概率之间的一场调情。调得好它给你惊喜,调不好它就给你惊吓——但别忘了,真正决定上限的,永远是那个会调参数的你。**
读者评论 5