最近我把 Qwen2.5-7B 拉到本地跑了几轮推理,光看输出不过瘾,干脆直接进到模型内部,把每一步的 logits 都打出来看了一遍。不看不知道,一看吓一跳:原来模型生成“下一个 token”的过程,比我之前想象中要朴素得多——它面前摆的不是一个字词的语义空间,而是一整张接近 15 万行的候选表,每次只从这张表里挑一个最高分项。这篇文章就是这次实测的完整记录,包括 logits 是什么形状、怎么从 generate 流程里把它捞出来、采样参数到底在改什么,以及我在调试过程中踩过的几个实实在在的坑。
这篇文章适合两类人看:一是刚接触 LLM 推理、对“模型为什么能接话”感到好奇的开发者;二是已经在用 Qwen 系列模型,但每次调整 temperature、top_p 时都靠“玄学”调参,想搞清楚背后原理的人。看完之后,你应该能自己写脚本把每一步的 token 选择过程打印出来,并且以后再遇到“生成重复”“提前截断”“批处理结果乱掉”这类问题,能有一个明确的排查方向,而不是靠重启和换 prompt 碰运气。
1. 这次实测要搞明白什么
1.1 为什么拿 Qwen2.5-7B 下手
选择 Qwen2.5-7B 并不是因为它指标最高,而是因为它是“单卡能跑”和“效果能看”的甜点位置。7B 参数量的模型,如果用 FP16 加载,光权重文件就要大概 14GB 显存,加上 KV Cache 和激活值,一张 16GB 的卡可以比较舒服地跑推理;如果换成 4bit 量化,8GB 显存也能勉强跑起来。相比之下,几十 B 的模型对普通开发者的设备要求太高,1.5B 又太小,很多行为看不出典型规律。
另外 Qwen2.5-7B 使用标准的 decoder-only 结构,词表却特别大,这一点对观察 logits 很有帮助。它的词表大约是 151936 个 token id,远超英文模型常见的 32K 或 50K。这也意味着,模型每生成一个 token,都要在一个 151936 维的向量里做一次 argmax 或者采样。维度越大,top-k、top-p 这些策略的意义就越直观——毕竟 15 万个候选里,绝大多数是根本不该被说出口的碎片。
我在实测中用的硬件是一块 12GB 显存的显卡,精度选择 FP16,batch size 设为 1。如果你的卡只有 8GB,可以换成 GGUF 量化版本,或者用 bitsandbytes 的 4bit 加载,下面的分析思路完全不受影响。
1.2 推理其实是“一个字一个字蹦出来”的循环
很多教程会把 LLM 推理描述得像一个整体:输入一句话,输出一段话。但真到了代码层,它就是一个循环:
- 把用户输入的文字用 tokenizer 切成一串 token id。
- 把整串 id 一次性喂给模型,做一次前向计算。
- 模型返回每个位置的 logits,但我们只关心序列最后一个位置。
- 从这最后一组 logits 里,通过采样或 argmax 选出一个新的 token id。
- 把这个新 id 拼回原来的序列末尾。
- 重复步骤 2 到 5,直到模型输出结束符,或者达到 max_new_tokens。
比如输入“你好,介绍一下你自己”,模型并不会先“理解”再去组织一段长回答。它只是在第一步先算“我”这个字后面最可能接什么,选出一个 token;第二步又把“你好,介绍一下你自己我”整体塞回去,再算下一个 token。每次只多一个,循环往复。
这句话说起来很简单,但它解释了 LLM 推理里最难优化的一环:每走一步都要重新计算一遍整段序列。如果不做缓存,生成 100 个 token 就要做 100 次完整前向,计算量随着上下文长度呈平方级增长。所以实际推理引擎里一定会做 KV Cache,把我们前面算过的 K 和 V 矩阵存下来,新 token 只做增量计算。这一点后面还会展开,但它属于理解 logits 之外的工程层。
1.3 logits 到底是一个什么形状的东西
第一次打印 logits 时,最容易惊讶的是它的形状:[batch_size, seq_len, vocab_size]。对于 Qwen2.5-7B 来说,如果输入的长度是 128,词表大小是 151936,那么单条样本前向出来的张量就是[1, 128, 151936],每一维都是一个浮点数。
注意 logits 不是概率。它没有经过 softmax,没有被归一化到 0 到 1 之间,甚至可以出现负数。它只是模型最后一层线性变换的原始打分结果,数值越高,代表模型“倾向”选择这个 token id 的强度越大。
为什么我们看的不是 softmax 之后的概率,而是原始 logits?因为采样逻辑要在 logits 上做手脚,而且一个 key 点:温度、重复惩罚这些操作,本质上都是先修改 logits,再一次性 softmax。如果模型已经帮你把 logits 转成概率了,你再改温度就得先把它重新“还原”成 logits,会非常别扭。所以 transformers 的 generate 接口里,output_scores=True返回的其实是概率分数,output_logits=True返回的才是原始 logits。这个区别我一开始也忽略过,后来打出来对比才意识到两者都能用,但原始 logits 在做自定义采样时明显更方便。
还有一个细节:我们只需要最后一个位置的 logits,不是整个[seq_len, vocab_size]矩阵。因为在自回归模型里,第 i 个位置的 logits 预测的是“第 i+1 个 token 是谁”。只有最后一个位置对应的下一步,才是真正要生成的那一步。所以实际采样时,一张[1, 151936]的向量就够了。
2. 环境准备与最小推理脚本
2.1 硬件和依赖怎么选
先说我验证过的一版环境:Python 3.10,PyTorch 2.1 以上,transformers 4.44 以上(Qwen2.5 系列需要新一点的版本才认识),CUDA 11.8 或 12.x 都行。模型我直接用了 Hugging Face 仓库里的Qwen/Qwen2.5-7B-Instruct,没有做任何微调。
依赖安装没什么花头,主要是一条:
pip install torch transformers accelerate如果你显存不够,加上一条:
pip install bitsandbytes然后加载时加一句quantization_config就行。这里的建议是:如果你是第一次跑通流程,尽量别一上来就量化。先用 FP16 跑通,确认你能看到 logits 的形状和数值规律,再换量化也不迟。因为量化模型在某些推理阶段对 logits 的精度影响虽然肉眼几乎不可见,但排坑时多一个变量,排查成本就高一层。
2.2 把 logits 从 generate 流程里捞出来
新手最容易困惑的问题,就是这个 logits 到底怎么从generate()里拿到。transformers 从某个版本开始加了一个很方便的参数,叫output_logits。实测下来非常好用:
from transformers import AutoModelForCausalLM, AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct") model = AutoModelForCausalLM.from_pretrained( "Qwen/Qwen2.5-7B-Instruct", torch_dtype="auto", device_map="auto" ) messages = [ {"role": "system", "content": "你是一个中文助手。"}, {"role": "user", "content": "用一句话介绍你自己"} ] inputs = tokenizer.apply_chat_template( messages, add_generation_prompt=True, return_tensors="pt" ).to(model.device) outputs = model.generate( inputs, max_new_tokens=10, return_dict_in_generate=True, output_logits=True ) # 每一步新生成 token 对应的 logits for step, logits in enumerate(outputs.logits): next_token_logits = logits[0, -1, :] # 这里取最后一个位置 top5_ids = next_token_logits.topk(5).indices.tolist() top5_tokens = tokenizer.convert_ids_to_tokens(top5_ids) print(f"step {step}: {top5_tokens}")注意outputs.logits是一个 tuple,长度等于你实际生成的 token 数。每一步的 logits 形状是[batch_size, 1, vocab_size]或者更确切一点,是“到这一步为止整个序列长度”的 logits,但最后一个位置才是最新生成的候选。所以代码里我统一取了[:, -1, :]。
如果你更想手动控制循环,也可以绕开 generate,每次自己把新 token 拼回去重新前向。这样做的好处是你能完全看清每一步,坏处是不用 KV Cache 时慢到怀疑人生。下面这段是适合教学的最小版本:
import torch input_ids = torch.tensor([[tokenizer.encode("我喜欢")[0]]], device=model.device) with torch.no_grad(): for _ in range(5): out = model(input_ids) logits = out.logits[:, -1, :] # [1, vocab_size] next_id = logits.argmax(dim=-1, keepdim=True) input_ids = torch.cat([input_ids, next_id], dim=-1) print(tokenizer.decode(input_ids[0]))这个版本好理解,但每次前向都会把整个输入序列从头算一遍,非常浪费。它存在的意义是验证“最后一个位置”这个概念。真正生产环境里你还是应该交给 generate 去管理 KV Cache。
2.3 批处理时,logits 的“最后一个位置”会骗人
如果你只是跑单条 prompt,用[:, -1, :]永远是对的。但只要你的输入是 batch,问题就来了:模型要求同一个 batch 里的序列长度一致,所以短的那条会被 pad 到和长的一样长。这时候,短序列真实的“最后一位”并不是最末位,而是 pad 之前的位置。
我实测时翻车的场景是这样的:我一次性输入两条不同长度的中文 prompt,然后用logits[:, -1, :]去取每条序列的下一步候选。结果第二条短 prompt 取到的其实是<pad>位置的 logits,模型在这个位置给出的候选完全没有语义,几乎等于随机猜词。
正确的做法是结合 attention_mask,找到每一条样本里最后一个真实 token 的位置,再去做索引:
last_real_pos = attention_mask.sum(dim=1) - 1 batch_idx = torch.arange(input_ids.size(0)) next_logits = logits[batch_idx, last_real_pos, :]这个细节很多教程不会提,但它属于“写一次就会记住一辈子”的坑。如果你之后自己写批处理推理服务,或者用 vLLM 这类引擎配置调度参数,也都离不开这个意识:pad 的位置永远不参与任何有意义的选择。
3. 从 logits 到 token 的完整选择链路
3.1 温度、top-k、top-p 到底在改什么
有了原始 logits 之后,下一个 token 怎么选?实际推理引擎通常不是直接 argmax,而是走一条“先缩放,再裁剪,最后采样”的链路。我用自己的方式重新实现了一遍这套逻辑,对理解参数很有帮助:
def sample_from_logits(logits, temperature=1.0, top_k=0, top_p=1.0): if temperature != 1.0: logits = logits / temperature if top_k > 0: kth = torch.topk(logits, top_k).values[:, -1:] logits = torch.where(logits < kth, float("-inf"), logits) probs = torch.softmax(logits.float(), dim=-1) if top_p < 1.0: sorted_probs, sorted_idx = torch.sort(probs, descending=True) cumsum = torch.cumsum(sorted_probs, dim=-1) mask = cumsum - sorted_probs > top_p sorted_probs[mask] = 0 sorted_probs = sorted_probs / sorted_probs.sum(dim=-1, keepdim=True) # 反变换回原index probs = torch.zeros_like(probs).scatter_(-1, sorted_idx, sorted_probs) return torch.multinomial(probs, num_samples=1)这套逻辑里每个环节的作用,我尽量用大白话说:
- 温度。温度是缩放 logits 用的。温度越低,logits 之间的差距被拉得越大,softmax 之后的概率分布就越“尖”,模型越倾向于选第一名。温度越高,logits 越趋近于平均,选择就越随机。比如 temperature=0.2,模型会很固执,回答稳定但可能无聊;temperature=1.5,模型会开始“放飞”,偶尔蹦出逻辑不通的内容。它本质上是改变整套分布的形状,而不是直接告诉模型“你要更具创意”。
- top-k。只看分数最高的 k 个候选,把其他全部设成负无穷,softmax 之后它们的概率就变成 0。这个做法的好处是稳定,坏处是 k 是写死的。如果概率分布本来就非常集中,top-k 在 k 比较小时会把一些合法候选也切掉。
- top-p。把所有候先按概率从大到小排序,不断往里面加选项,直到累计概率超过阈值(比如 0.9),再对这个子集做一次重新归一化。它能动态决定保留多少候选,在分布集中的时候保留几个,在分布平坦的时候保留几十个。
实测下来,Qwen2.5-7B 在这种链路里表现得比较典型:temperature=0.7、top_p=0.8 时生成的质量最均衡;直接用 argmax 时,回答更严谨,但偶尔会陷入“车轱辘话”,反复说同一句。这个规律后来我在不少模型上都见过,因为贪心搜索天然会让重复性内容更容易被选中。
3.2 重复惩罚和中文场景的特殊 token
除了温度和 top-p,另一个经常被调的参数是 repetition_penalty,也就是重复惩罚。它的核心思路其实也发生在 logits 层面:模型每生成一个 token,引擎就检查这个 token id 在此之前有没有出现过;如果出现过,就把对应位置的 logits 往低的方向压一压。具体做法各家实现略有差异,但本质都是把已经出现过候选的“身价”打折扣。
实测中我遇到过一种很典型的现象:把 repetition_penalty 调到 1.2 以上,模型确实不那么重复了,但句子结构变得非常生硬,甚至出现“主谓宾颠倒”。这是因为 7B 模型本身的表达能力有限,惩罚过重会把那些常见的自然连词也一起禁用掉,相当于强迫模型走一条它没把握的路。我的结论是:在日常对话场景,repetition_penalty 设在 1.0 到 1.1 之间就够了,再高就要慎重。
商标式的中文场景还有一个特殊 token 问题。Qwen2.5 的对话模板里,每一轮对话都有<|im_start|>和<|im_end|>这样的特殊符号。它们不是普通文字,而是词表里真实存在的 token id。关注点在于,一旦模型在 logits 里选中<|im_end|>,整段生成就要画上句号。所以你在自定义采样循环里,不能一股脑把<|im_end|>从候选里删掉,或者给它很高的惩罚分,否则模型永远不知道该停下来。
我在实测时看到过一个很有意思的 logits 模式:模型在回答末尾给出的 top-5 候选里,第一名就是<|im_end|>,第二名才是正常的中文标点。这说明它在“说完最后一句话”和“再多说半句客套话”之间做过权衡。如果你把eos_token_id设置错了,比如没有把 chat 模板里的结束符传进 generate,模型就会在生成<|im_end|>之后继续往下编,看起来像“AI 开始自言自语”。
3.3 流式推理管线与 KV Cache 的配合
聊完 logits 的选择策略,再回到工程层。生产环境几乎不会用整段序列反复前向的方式做推理,因为太慢。KV Cache 的核心理念是:每一步做完前向之后,把当前 token 对应的 K 和 V 向量存起来,下一步只需要拿着新 token 的 Q 去和这些缓存的 K、V 做注意力计算。这样计算量从“每步重算整段”变成“每步只算增量”,速度提升非常明显。
Qwen2.5-7B 在这方面的结构优势也值得一说:它使用 GQA,也就是分组查询注意力。简单理解就是,Query 头的数量很多,但 Key 和 Value 的头数量更少,多个 Query 头共享同一组 KV。这直接减低了 KV Cache 的体积,推理时显存占用更小。我实测生成长文本时,7B 模型的 cache 占用确实比想象中少,这也是它能在一张 12GB 卡上流畅生成的关键之一。
配合流式输出时,流程大概是:模型每前向一次,产生一个新 token 的 logits → 采样出新 token id → 解码成文字 → 立刻推给前端。transformers 里直接用 TextStreamer 就能实现这个效果,不用自己手动接管循环。不过如果追求更高的吞吐量,建议还是看 vLLM、SGLang 这类推理引擎。它们的核心优化目标就是管理好 KV Cache 的调度和预分配,本质还是在加速“循环选 token 那一步”。
4. 实测中的坑与排查实录
4.1 logits 索引错位一格的经典故障
我在调试时遇到的第一个莫名其妙的问题,是手动复现采样时,生成出来的第一个 token 和 generate() 的结果完全对不上。后来我把两次的 logits 都打出来逐维对比,发现它们错开了整整一个位置。原因是我在手动循环里,每次前向之后取了input_ids[:, -1]位置对应的 logits,这没问题;但在拼接 ID 时,把新 ID 拼到了原始输入末尾。到下一次前向时,序列的“最后一位”已经被替换成了新 token,旧序列尾部的信息丢失了。
这种错位问题用代码看其实很简单:logits[:, -1, :]预测的是“如果序列往后走一步,下一个 token 是什么”,所以你要保证的是,这个 logits 确实是在“包含当下全部 token 的情况下”计算出来的。只要序列里多加了一个已生成的 token,下次计算时取[:, -1, :]就是正确的。容易出错的是批处理,因为-1不一定是最后真实 token,这在前面已经讲过。
排查技巧很简单:输入一句较短的话,比如“我喜欢”,看 argmax 出来的 token 是不是合理的中文延续。如果结果是一串乱码或<pad>,大概率就是索引取的偏了。
4.2 特殊 token 引发的中文截断与补全异常
另一个困扰我比较久的问题是:模型生成到一半突然停止,而且停止的位置既不是句号也不是结束符,看起来像是被强行截断。后来检查发现,我手动设置 generate 的eos_token_id时,把<|im_end|>的 id 传错了。Qwen2.5 词表里特殊 token 的 id 和普通文本不同,我一开始随手填了一个常见的 2 结尾符,模型永远等不到这个 id,自然只能靠max_new_tokens硬截断。
还有一个相关现象:tokenizer.pad_token没有设置时,批处理在做 decode 时会报错,或者输出一堆<pad>。解决办法也很简单,在加载 tokenizer 之后加上一句:
tokenizer.pad_token = tokenizer.eos_token但要注意,pad_token 和 eos_token 是同一个东西之后,模型在生成时也有可能把这个 token id 当成下一个 token 输出。我实测里出现过一次:因为我把 pad 统一成了 eos,而手动采样时没把<|im_end|>设为强制停止,结果模型输出了一个结束符之后,又开始下一轮问答,看起来前后两层内容叠在一起。推理逻辑里一定要对“到底哪些 token 能终止生成”全盘考虑,不能只依赖 generate 的默认行为。
4.3 别把 NLP 的 token 和 API 的 token 混为一谈
这个话题我在群里被问过很多次,也正好借这次实测一起说清楚。热搜里经常出现“token 失效”“token 续签”“获取 token 为空”这些表述,但它们和 LLM 里说的 token 完全不是一回事。API 的 token 是身份凭据,是你在调用服务时用来鉴权的一段字符串,它失效了要重新登录或换 key;NLP 的 token 是文本被切碎之后的最小单元,是模型真正在玩弄的东西。一个 token id 可以是一个汉字,也可以是一个词、一段字母,甚至是一个特殊符号。
为什么这两个概念会被混在一起?因为很多第三方平台文档里,确实把“调用一次模型所消耗的字符数量”简称为 token 用量。但在模型内部,token id 永远只是一个整数下标。你打印 logits 时看到的是token_id 12345,它对应的是词表里哪一个字符串,需要调用tokenizer.convert_ids_to_tokens()才能看到。理解这层关系之后,再看到“上下文超过限制”或者“token 过多”这类错误,就应该立刻反应过来:是序列长度超出了模型能承受的窗口,而不是身份过期。
我建议本地调试时,多打印两步:第一是把 logits 的 top-k 对应 token id 打印出来,第二是把 token id 转成字符串看看。你会惊讶地发现,模型在“犹豫”的候选里,经常会混入一些莫名其妙的子词碎片,这正是大词表模型的特征。
4.4 采样参数调优的快速参考表
最后把这次实测里试过的参数组合整理成一张表,方便你直接拿去抄作业。但注意,参数永远是跟着任务走的,下面的推荐值针对的是“中文对话、信息准确、风格自然”这类通用场景。
| 参数 | 作用 | 推荐范围 | 实测注意事项 |
|---|---|---|---|
| temperature | 控制概率分布尖锐度 | 0.3~0.8 | 低于 0.3 时回答容易复读机 |
| top_k | 固定保留候选个数 | 20~50 | 太大等于没截,太小容易断句 |
| top_p | 按累计概率裁剪候选 | 0.7~0.9 | 比 top_k 更符合实际分布 |
| repetition_penalty | 惩罚已生成 token | 1.0~1.1 | 超过 1.2 句子结构明显异常 |
| max_new_tokens | 限制生成长度 | 按任务 | 对话场景建议 500 以上,避免“话没说完被截断” |
我在实测中最终留下的一套默认参数是:temperature=0.7,top_p=0.8,repetition_penalty=1.05。这套组合在事实问答、代码片段、闲聊三个场景下表现都比较稳。偶尔需要创意发挥时,我才把 temperature 调到 1.2,top_p 开到 0.95,让模型有更多自由空间。
再分享一个调试小技巧:调试 logits 时别只看 argmax,要把 top-5 都打出来。很多时候模型的第一名和第二名之间的分数差距极小,比如“它”和“她”差距不到 0.02。这时候 argmax 看起来完全是随机的,但如果你把温度稍微调高一点,模型可能就会换成另一个候选。这个现象解释了为什么同样的 prompt,不同温度下会产出完全不同的回答——其实不是模型思路变了,而是它本来就在两个候选之间摇摆,温度只是放大了摇摆的后果。
我自己在后续测长文本时,把output_logits=True和一个小脚本结合起来,每生成几步就打印一次当前 top-5 candidates,用来观察模型在关键转折处的“犹豫”模式。这个方法对排查“为什么突然跑偏”特别有效,推荐你下次调试时也试试。把倒数第二层的分布拿到手里,很多猜测就变成了可以验证的事实。