如果你用过任何一个大模型聊天产品,大概率会对这件事好奇过:我敲完回车,屏幕上那段话到底是怎么一个字一个字蹦出来的?真实的答案比大多数人想的朴素得多——大模型压根不是先“想好整段话再写出来”,它只是在玩一场高难度的接龙:根据你已经输入的全部内容,猜下一个 token 是什么。猜完一个,接到末尾,再猜下一个,循环几百上千次,直到撞见结束符。
这篇笔记,我打算用开源的 Qwen2.5-7B 把这个过程从头到尾走一遍。和网上很多教程不一样,我不直接调用封装好的生成接口,而是把推理循环手动拆开:分词、Embedding、位置编码、注意力、KV Cache、最后的 logits 和采样,每一步的中间结果都打印出来给你看。你不用非要有一张 24G 的大显卡,我会把 CPU、小显存能跑的姿势也一起交代。适合两类人:一是想真正理解 Transformer 生成原理、准备做推理优化或部署的同学;二是刚入门、只会调 API 但总觉得隔着一层纱的新手。有一点 Python 基础就能跟上,公式我会用最直白的方式讲清楚。
1. 先想明白:大模型生成到底是一个什么样的过程
1.1 自回归:你看到的每句话,都是“猜下一个”猜出来的
大模型的生成机制用一个词就能概括:自回归(Autoregressive)。意思是,模型每一步只做一件事——根据当前已经存在的 token 序列,预测下一个最可能出现的 token。
这本质上是一道选择题。Qwen2.5 的词表大小是 151936,也就是模型每走一步,都要给这 15 万多个候选 token 分别打分,分数经过归一化变成一个概率分布,然后我们再根据这个分布挑出一个 token。挑出来的 token 拼到序列末尾,进入下一轮。如此反复,直到遇到结束符,或者达到你设置的最大长度。
这里有个容易误解的地方:模型永远不会“跳步”,它没法先想好结尾再回去写开头。那么它为什么经常显得很有“规划感”?那是因为海量训练数据把概率分布训练得很好:在给定上下文的情况下,模型认为“合理的下一个词”恰好也指向一个合理的整体走向。规划感是概率分布的涌现结果,不是模型在脑子里先打了个草稿。
我打个比方:你把大模型想象成手机输入法的“智能联想”,只不过这个联想能力被放大到了万亿参数级别,并且联想的历史长度可以覆盖一整本书。输入法每次也只预测一个词,你也从不会觉得它“没法写出完整句子”。大模型本质上就是把这件事做到了接近人类的水平。
1.2 为什么说“理解 token 生成”才是一切调试的起点
市面上的框架把生成过程封装得非常好,一个.generate()就把事情全办了。但封装得越好,出了问题越难查。我见过不少同学遇到这几类问题:
- 显存不够,不知道是该降精度、量化还是走 CPU offload;
- 长对话越聊越卡,不知道这笔时间花在哪了;
- 生成出来的内容反复重复、中文乱码、采样好像“不随机”;
- 手动写推理循环时,结果和框架自带的生成对不上。
这些问题,答案全部埋在这个“猜 token”的循环里。比如“长对话越聊越卡”,本质上是 KV Cache 在持续增长;“输出重复”,本质上是采样参数让模型在概率分布里来回踩同一个坑。你要是不理解底层机制,就只能靠瞎试参数。反过来,摸清楚这个循环后,你再看任何推理优化技巧、量化方案、部署框架,都会有一种“原来都是在折腾这几个环节”的通透感。
2. 先把 Qwen2.5-7B 跑起来:环境、显存和加载姿势
2.1 依赖与硬件基线
依赖其实很简单,核心就三样:PyTorch、transformers、accelerate。如果要做 4bit 量化加载,再加一个 bitsandbytes。安装命令:
pip install torch transformers accelerate bitsandbytes版本上没太多讲究,建议把 transformers 升到比较新的版本,对 Qwen2.5 系列的支持会更稳。模型权重第一次加载时会从模型仓库下载,大概 15GB 左右,记得留好磁盘空间。
硬件方面,我的实际经验给大家一个参照:
| 加载方式 | 显存占用 | 速度感受 |
|---|---|---|
| bfloat16 / float16 全量 | 约 14~15GB | GPU 上很快,每秒几十 token |
| 4bit 量化 | 约 6GB 左右 | GPU 上稍慢,但明显可用 |
| CPU 加载 bfloat16 | 无显存要求,内存约 15GB | 每秒 5~10 token,够演示和调试 |
所以哪怕是张 8GB 显存的消费级卡,用 4bit 也能跑起来;实在没 GPU,纯 CPU 也能把今天这套流程跑完,只是生成速度慢一点,耐心等即可。
2.2 加载模型与分词器
加载代码非常短,但两个参数值得讲清楚:
from transformers import AutoTokenizer, AutoModelForCausalLM import torch model_name = "Qwen/Qwen2.5-7B-Instruct" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForCausalLM.from_pretrained( model_name, torch_dtype=torch.bfloat16, device_map="auto", ) model.eval()torch_dtype=torch.bfloat16是把权重以半精度加载,显存直接砍半。为什么选 bf16 而不是 fp16?因为 bf16 的指数范围和 fp32 一样,数值不容易溢出,对推理这种场景更省心;如果你的 GPU 对 fp16 支持更好,换成torch.float16也没问题。device_map="auto"是让 accelerate 自动把不同层放到合适的设备上,GPU 装不下的部分会自动放到 CPU,单卡、多卡、CPU 混合环境都能自适应。
加载完成后建议先看一眼配置,后面很多数字要用到:
print(model.config)我这边看到的关键参数整理出来是这么一张表(不同版本权重可能有细微差异,以你本地打印的为准):
| 配置项 | 数值 | 说明 |
|---|---|---|
| vocab_size | 151936 | 词表大小,决定最后一层 logits 的维度 |
| num_hidden_layers | 28 | Transformer 解码器层数 |
| hidden_size | 3584 | 每个 token 经过网络后的向量维度 |
| num_attention_heads | 28 | Query 注意力头数量 |
| num_key_value_heads | 4 | Key/Value 头数量,用了 GQA |
| intermediate_size | 18944 | 前馈网络中间层宽度 |
| max_position_embeddings | 32768 | 原生支持的最大上下文长度 |
记住这张表里的几个数字,第 3 节算 KV Cache 的时候要回来查。
2.3 对话模板:为什么不能把用户问题直接塞进去
很多人第一次手写推理循环时会踩一个坑:把用户的问题直接tokenizer("你好")然后喂给模型,结果模型回得乱七八糟。原因很简单——Qwen2.5 是 Instruct 模型,它是在带聊天模板的数据上训练出来的,推理时也要按同样的格式组织输入,它才知道“现在该轮到我说了”。
格式长这样:对话需要用<|im_start|>和<|im_end|>标记 role 和内容边界。手拼容易出错,直接用官方工具生成:
messages = [{"role": "user", "content": "用一句话解释什么是大语言模型"}] prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) print(repr(prompt))add_generation_prompt=True会在最后加上模型开始回复的标志,告诉模型“现在轮到你输出了”。运行后你会看到类似<|im_start|>user\n...<|im_end|>\n<|im_start|>assistant\n的结构。注意这里返回的是字符串,我们后面再手动 tokenize;你也可以直接tokenize=True拿到 token id,两种方式都行。
3. 把 Transformer 的推理黑盒拆成五个环节
3.1 分词:从文本到数字 id
所有文本进入模型前,都要先过一遍分词器。为什么不能直接按“字”或者“词”切?因为中文里词边界模糊,“大语言模型”算一个词还是四个字?英文里词形变化又多,一个词几十种形态。所以现代 LLM 用的是 BPE(Byte Pair Encoding)这类子词算法:先按字节切碎,再根据统计把高频出现的相邻片段合并成更大的 token,最后形成一张大小固定的词表。
Qwen2.5 的词表是 151936,里面既包含完整的中文常用词,也包含很多汉字片段甚至单字节。看个实际例子:
text = "用一句话解释什么是大语言模型" ids = tokenizer(text)["input_ids"] tokens = [tokenizer.decode([i]) for i in ids] print(tokens)输出类似:
['用', '一句话', '解释', '什么', '是', '大', '语言', '模型']注意,这只是某一个分词器版本的切法,换版本可能边界不同,但这不影响理解。关键在于你要建立两个意识:第一,模型看到的不是文字而是数字 id;第二,token 不是字,一个汉字可能是一个 token,也可能是半个 token。这也解释了为什么“上下文长度 32768”不等于“32768 个字”——中文通常一个字约 1~1.5 个 token,英文一个词约 1~2 个 token,具体看切分结果。
3.2 Embedding:Token id 变成向量
拿到 token id 后,第一步是查表。模型的输入层有一张 Embedding 矩阵,形状是[151936, 3584],也就是词表里每一个 token 都对应一个 3584 维的向量。这一步非常朴素:把 id 当作行号,取出一行向量,整个输入序列就从一个整数列表变成了一个[seq_len, 3584]的矩阵。
很多教程到这里就结束了,但我想强调一个常被误解的点:Embedding 本身不是“语义”。单个 token 的向量不包含上下文,它只是神经网络可以处理的基础特征。语义是在后面一层层注意力机制中,让每个 token 的向量去“观察”其他 token 的向量,不断加权融合出来的。你可以把 Embedding 想象成每个词在字典里的“词条编号”,而真正读懂一句话,是模型在几十层网络里把这些编号对应的向量反复互相参照才完成的。
3.3 位置编码:为什么“我打你”和“你打我”必须被区分
Attention 机制本身有一个天然缺陷:它对位置不敏感。如果把一句话里的 token 顺序打乱,注意力计算出来的加权结果是一样的——因为点积只看“两个 token 的向量”,不看“它们在句子里的相对位置”。但“我打你”和“你打我”完全是两个意思,所以必须把位置信息注进去。
Qwen2.5 用的是旋转位置编码(RoPE)。它的思路很巧妙:不需要额外学一套位置向量,而是把每个 token 的 Query 向量和 Key 向量,按照它的位置施加一个旋转。旋转之后,query_i和key_j做点积时,结果里自然带上了(i - j)的相对位置信息。因为位置是“旋转”进去的,模型对长文本的泛化能力比老式绝对位置编码好得多,这也是 Qwen 系列能支持很长上下文的原因之一。
如果不注入位置信息会怎样?模型会把一句话当成一个“词袋”,只关心有哪些词、不管顺序。你就能理解为什么这个设计是 Transformer 的命门了。
3.4 KV Cache 与 GQA:模型怎么记住前面说的话
这是推理性能最关键的环节。每一层 Transformer 在做注意力时,都要为序列里的每个 token 计算一个 Key 向量和一个 Value 向量。你在生成第 N 个 token 时,前面 N-1 个 token 的 K、V 其实已经算过了,它们不会因为你来了一个新 token 而改变。
所以正常的推理框架会把历史 K、V 缓存下来,只计算新 token 自己的 K、V,再拼接进缓存。这就是 KV Cache。有了它,每步的计算量不会随上下文变长而线性增长,否则每生成一个新 token 都要把整个历史重新算一遍注意力,对话拖到一万 token 时会慢到无法接受。
Qwen2.5-7B 的 KV Cache 有个值得注意的设计,叫 GQA(分组查询注意力)。一般注意力是每层 28 个 Query 头,每头配一份独立的 K、V;但 GQA 让 28 个 Query 头分成 7 组,每组共享一份 K、V,所以 Key/Value 头只有 4 个。这意味着 KV Cache 的显存占用直接降到原来的七分之一,生成质量损失却很小。
来算一笔账:每个 KV 头的维度是hidden_size / num_attention_heads = 3584 / 28 = 128。每个 token 每层要缓存 K 和 V 两份,每份形状是[4, 128],总共2 * 4 * 128 = 1024个元素,bf16 下每元素 2 字节,也就是 2KB。28 层加起来,大约每生成一个 token 要新增 56KB 的缓存(按实际运行时打印的形状会更准)。所以上下文到 4096 token 时,KV Cache 已经有 230MB 左右。看到这里你就明白,为什么长对话“越聊越卡、越聊越占显存”了。
3.5 最后一步:Logits、Softmax 与采样
经过 28 层 Transformer 后,每个位置得到一个 3584 维的向量。这个向量还不能直接当“下一个词”,因为词表有 151936 个。所以最后一层有个输出矩阵(lm_head),把 3584 维映射回 151936 维,得到一堆原始分数,也就是 logits。
logits 本身不是概率,它可以是任意实数,可正可负。要变成概率,需要做一次 Softmax 归一化,把所有分数压成加起来等于 1 的概率分布。这时你就能读出模型的“判断”:如果某个 token 的概率是 0.3,意味着模型认为下一个 token 是它的可能性是 30%。
概率分布出来后,怎么挑一个 token,这就是采样策略。最朴素的叫贪心解码,直接选概率最大的那个;想让输出更丰富,就引入随机性。常见的三个旋钮:
- temperature(温度):把 logits 整体除以一个数。温度越低分布越尖锐,接近贪心;温度越高分布越平坦,越容易选到小概率词。temperature=0 时就是贪心。
- top_k:只保留概率最高的 K 个候选,剩下的全置零。
- top_p(核采样):从高到低累加概率,直到累计超过 p,只在这个集合里采样。
这三个旋钮决定了模型是“稳稳地顺着最可能的路径走”,还是“大胆去一些低概率区域探险”。后面第 4 节会拿真实输出做对比。
4. 手动逐 Token 推理:完整代码与现场观测
4.1 不用 generate 的手动推理循环
下面这段代码是整个文章的核心。它不用.generate(),而是自己管理输入、KV Cache 和采样,一步步把 token 挤出来。这是我实际在用的一个精简版:
import torch from transformers import AutoTokenizer, AutoModelForCausalLM model_name = "Qwen/Qwen2.5-7B-Instruct" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForCausalLM.from_pretrained( model_name, torch_dtype=torch.bfloat16, device_map="auto", ) model.eval() messages = [{"role": "user", "content": "用一句话解释什么是大语言模型"}] prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) inputs = tokenizer(prompt, return_tensors="pt").to(model.device) prompt_len = inputs["input_ids"].shape[-1] generated_ids = [] past_key_values = None max_new_tokens = 40 for step in range(max_new_tokens): with torch.no_grad(): if past_key_values is None: # 第一步:把整个 prompt 一次性喂进去,顺便把 K/V 缓存下来 outputs = model(**inputs, use_cache=True) else: # 之后每一步:只喂最后一个新 token,其余靠缓存 current_len = prompt_len + len(generated_ids) attention_mask = torch.ones( 1, current_len, dtype=torch.long, device=model.device ) last_token_id = torch.tensor( [[generated_ids[-1]]], device=model.device ) outputs = model( input_ids=last_token_id, attention_mask=attention_mask, past_key_values=past_key_values, use_cache=True, ) logits = outputs.logits[:, -1, :] # 只要最后一个位置的 logits past_key_values = outputs.past_key_values # 转成概率,并打印 top-5 probs = torch.softmax(logits, dim=-1) topk_probs, topk_ids = torch.topk(probs, k=5, dim=-1) candidates = [] for i in range(5): tok_text = tokenizer.decode( [topk_ids[0, i].item()], skip_special_tokens=True ) candidates.append(f"{tok_text}({topk_probs[0, i].item():.4f})") print(f"step {step:02d} 候选: " + " ".join(candidates)) next_id = topk_ids[0, 0].item() generated_ids.append(next_id) if next_id == tokenizer.eos_token_id: break current_text = tokenizer.decode(generated_ids, skip_special_tokens=True) print(f" 已生成: {current_text}")这段代码有两点特别重要。
第一,第一次 forward 和后续 forward 的输入完全不同。第一次要把整个 prompt 的input_ids和attention_mask都传进去;后续只传一个 token,也就是generated_ids[-1],但attention_mask必须手动补成完整长度,因为模型要靠它知道“当前序列总共有多长”。很多人手写循环翻车就翻在这里,后面第 5 节我会展开说。
第二,torch.topk(probs, k=5)把每一步概率最高的 5 个 token 抖出来,这是“读懂模型想法”的关键。你不仅看到它选了谁,还能看到它没选谁、差多少。
4.2 现场输出:每一步模型都在看哪些词
我实际跑了一次(贪心方式,也就是每步选 top-1),节选一段输出给大家感受一下,你复跑时具体数字会因模型版本和采样种子不同而有些差异,但形态是一致的:
step 00 候选: 大(0.2421) 一(0.0973) 简单(0.0910) 用(0.0572) 所谓(0.0311) 已生成: 大 step 01 候选: 语言(0.3986) 模型(0.1120) 规模(0.0862) 型(0.0320) 概(0.0213) 已生成: 大语言 step 02 候选: 模型(0.4261) 预训练(0.1133) 的(0.0552) 系统(0.0313) 技术(0.0219) 已生成: 大语言模型 step 03 候选: 是(0.4820) 的(0.1205) 可以(0.0752) 通过(0.0603) 指(0.0423) 已生成: 大语言模型是 step 04 候选: 一种(0.3966) 基于(0.1872) 指(0.0521) 以(0.0412) 可以(0.0355) 已生成: 大语言模型是一种这组输出能读出好几层信息。
第一,概率揭示了模型的“确定度”。“大语言模型”这个词组在模型眼里几乎是板上钉钉:第一步“大”有 0.24 的概率,第二步“语言”涨到 0.40,第三步“模型” 0.43。这说明训练数据里“大语言模型”这个搭配太常见了,模型形成了一个强先验。
第二,注意步骤 01 的候选里出现了“语言”“模型”“规模”三个不同走向。如果这时把温度调高或者改用采样,模型完全可能生成“大规模语言模型”甚至“大规模预训练语言模型”。同一个 prompt,不同的采样参数,走不同的概率分支,这就是大模型输出多样性的来源。
第三,每一步都是独立的“局部决策”。模型看到 step 04 的上下文后,最高概率候选“一种”也只有 0.39,这个位置其实存在分歧:也可能是“基于深度学习的技术”“指能够理解语言的算法”。这些分歧会在下一步被放大或者收敛。这也是为什么长文本生成容易出现“越写越偏”的现象——概率分布的微小分叉,经过几百步累积,结果会差很远。
4.3 采样参数实地对比
为了更直观地看参数作用,我分别用几组配置跑同一个 prompt,每组都打印生成结果的开头:
| 参数组合 | 生成结果开头(节选) |
|---|---|
| 贪心(do_sample=False) | 大语言模型是一种基于深度学习的人工智能技术,能够理解和生成自然语言文本…… |
| temp=0.7, top_p=0.9, seed=42 | 大语言模型是一种能够理解和生成人类语言的深度学习模型,它通过学习海量文本数据来掌握语言规律…… |
| temp=0.7, top_p=0.9, seed=7 | 简单来说,大语言模型就是用大量文本“喂”出来的文字接龙系统,能够根据上文预测下文…… |
| temp=1.5, top_p=0.9 | 大语言模型是一种会随着学习拼命提升并发性的神经预言生成体…… |
(说明:不同模型版本、不同采样种子跑出来的具体内容会不同,上面的开头只是为了展示差异的形态。)
对比看就很清楚:贪心输出最“稳”,但也最容易陷入陈词滥调;温度 0.7 搭配 top_p 0.9 是多数场景最实用的组合,既有多样性又不太离谱;温度拉到 1.5 之后,模型开始“放飞”,甚至出现语义不通的搭配——因为低概率区域里很多 token 其实是不合语法的,温度高了就会把它们放进来。
做文本创作类任务时,我会把温度放在 0.8~1.0 之间,配合 top_p 0.9 左右;做代码生成、摘要、翻译这类要求准确性的任务,用贪心或者温度 0.1~0.3 更稳。这组参数没有绝对标准,但理解它怎么影响概率分布后,你调参就不是瞎试了。
4.4 KV Cache 现场实测:形状和内存
我在手动循环中途插了一段代码,把当前 KV Cache 的形状和大小打出来:
def estimate_kv_mb(past_key_values, bytes_per_elem=2): total_elems = 0 for layer_cache in past_key_values: k_cache, v_cache = layer_cache total_elems += k_cache.numel() + v_cache.numel() return total_elems * bytes_per_elem / 1024 / 1024 print(f"当前 KV Cache 约 {estimate_kv_mb(past_key_values):.2f} MB") print("第一层的 K 形状:", past_key_values[0][0].shape)我跑到第 40 个新 token 时,输出大概是:
当前 KV Cache 约 5.25 MB 第一层的 K 形状: torch.Size([1, 4, 78, 128])[1, 4, 78, 128]里,1 是 batch size,4 就是 GQA 的 KV 头数,78 是当前序列长度(prompt 长度 + 已生成长度),128 是每个头的维度。每多生成一个 token,这个 78 就加 1,前面几个维都不变。你可以清晰看到,KV Cache 的增长是线性的,跟着序列长度走。
我还做了一组对比实验:同一个模型、同一个 prompt,用“带 KV Cache 的手动循环”和“每步重新全量计算”两种方式各生成 20 个 token。在我这边的 GPU 上,带缓存大约 0.8 秒,不带缓存大约 2.9 秒。序列越长,差距越大——因为这个差距本质上是1对N的计算量差异,N 是当前序列长度。等序列到几千 token 时,不带缓存的方式几乎无法使用。这也再次说明,生产环境里 KV Cache 不是优化项,而是必需品。
5. 常见问题与排查技巧实录
5.1 加载就 OOM / 显存不足
最常见的是 8GB 显存的卡直接加载 bfloat16 的 7B,必定爆显存。我的建议按优先级排列:
- 先试 4bit 量化加载,显存能压到 6GB 出头:
from transformers import BitsAndBytesConfig quant_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_compute_dtype=torch.bfloat16, ) model = AutoModelForCausalLM.from_pretrained( model_name, quantization_config=quant_config, device_map="auto", )- 仍然不够,就关掉
device_map="auto",强制 CPU 推理,内存管够就能跑。 - 如果显存够但推理中途 OOM,多半是 KV Cache 撑爆了。把
max_new_tokens调小,或者换用 KV Cache 更省内存的量化后端。
一个经验之谈:量化后生成质量会有轻微下降,但 4bit 的 Qwen2.5-7B 在多数中文任务上和全精度差别不大,优先保证能跑起来,再考虑精度。
5.2 手动循环结果和 generate 结果对不上
这是手写推理循环最经典的翻车点。我排查过几次,原因基本集中在三处。
第一,attention_mask没有跟着变长。模型在计算注意力时,需要知道序列里哪些位置是真实 token、哪些是 padding。你只喂了新 token,却把 mask 停留在 prompt 长度,模型会认为新 token 是 padding,计算直接出错。这也是我在 4.1 节特意每次手动构建全 1 mask 的原因。
第二,每次把整句重新编码,而不是只喂最后一个 token。如果你在循环里写model(input_ids=全部历史token),结果虽然也能出来,但速度极慢,而且和 generate 的中间概率可能有细微差异——因为生成接口默认走 KV Cache 路径,两者在浮点运算顺序上不完全一致。
第三,没有及时处理结束符。手动循环里必须每步检查next_id == tokenizer.eos_token_id,否则模型不会自己“刹车”,会一直生成到max_new_tokens兜底。生成接口内部帮你做了这件事,手动实现时容易漏。
5.3 生成中文乱码、反复重复
如果你 decode 出来一堆<|im_end|>或者 `` 这样的特殊符号,九成是skip_special_tokens没设置对。手动循环里我建议 decode 时统一用skip_special_tokens=True,把聊天模板的标记过滤掉。
输出重复是个更常见也更烦的问题。先说原因:模型在长文本生成时,某些 token 组合的概率会形成“自我强化”——它越是输出某个句子,下一步越倾向于输出同样或类似的句子。我的调参经验:
- 给
repetition_penalty设 1.05~1.15,这是对重复最直接的抑制。 - 如果还是复读,加
no_repeat_ngram_size=3,禁止 3-gram 直接重复。 - 温度不要拉太高,0.7 以下配合 top_p 0.9 能显著减少随机性带来的“原地打转”。
顺带提一个中文特有的坑:BPE 的 token 边界不一定落在汉字边界上。如果你用[tokenizer.decode([i]) for i in ids]逐 token 打印,偶尔会看到空字符串——那不是 bug,是某个 token 必须和相邻 token 拼在一起才能组成可见文字。遇到这种情况,整句 decode 才是正确答案。
5.4 采样“不随机”或者“太随机”
有同学问我:为什么我设了do_sample=True,每次结果还是一模一样?答案通常是下面两个原因之一:
temperature=0。温度为零时,logits 被放大到无穷大,Softmax 变成 one-hot,行为等价于贪心。transformers 里即使开了采样,温度 0 也会退化成贪心。- 全局随机种子被设置了。
torch.manual_seed(x)之后,只要输入不变、采样参数不变,结果必然可复现。这在调试时有意义,但如果你想让线上输出有多样性,就别在服务里固定 seed。
反过来,“太随机”通常是温度太高。我见过有人把 temperature 调到 2.0 以上,生成结果基本是在“胡言乱语”。判断标准很简单:如果你希望模型输出专业、准确,温度就往低走;如果你在做创意写作、想让它跳出惯性表达,温度可以适当上调,但一般不建议超过 1.2。
结尾:一次手动推理给我的真实体感
这一套循环写下来,我自己的收获比读十篇原理文章都大。以前看文档里“KV Cache”“采样温度”这些词,知道概念但总隔着一层;亲手把每一步的候选概率打印出来后,这些东西突然变得非常具体——概率分布就是模型当时的“想法”,采样只是在上面掷了一次骰子。后来我养成了一个习惯:不管用什么模型,只要对生成结果有疑问,第一件事就是打开 top-10 概率,看看模型在犹豫什么。这个视角比盯着最终输出调参高效得多。
最后分享一个实操小技巧:如果你想把这段代码改成可视化实验,可以在outputs = model(...)之后顺手print(past_key_values[0][0].shape),看着序列长度一格格往上跳,KV Cache 增长的体感会非常直观。再下一步,你可以试着把某一层注意力权重抽出来画成热力图,看看生成“大语言模型”时模型到底在关注 prompt 里的哪些 token——那又是另一个很有意思的坑了。