如果你用过任何一个大模型聊天产品大概率会对这件事好奇过我敲完回车屏幕上那段话到底是怎么一个字一个字蹦出来的真实的答案比大多数人想的朴素得多——大模型压根不是先“想好整段话再写出来”它只是在玩一场高难度的接龙根据你已经输入的全部内容猜下一个 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~15GBGPU 上很快每秒几十 token4bit 量化约 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_dtypetorch.bfloat16, device_mapauto, ) model.eval()torch_dtypetorch.bfloat16是把权重以半精度加载显存直接砍半。为什么选 bf16 而不是 fp16因为 bf16 的指数范围和 fp32 一样数值不容易溢出对推理这种场景更省心如果你的 GPU 对 fp16 支持更好换成torch.float16也没问题。device_mapauto是让 accelerate 自动把不同层放到合适的设备上GPU 装不下的部分会自动放到 CPU单卡、多卡、CPU 混合环境都能自适应。加载完成后建议先看一眼配置后面很多数字要用到print(model.config)我这边看到的关键参数整理出来是这么一张表不同版本权重可能有细微差异以你本地打印的为准配置项数值说明vocab_size151936词表大小决定最后一层 logits 的维度num_hidden_layers28Transformer 解码器层数hidden_size3584每个 token 经过网络后的向量维度num_attention_heads28Query 注意力头数量num_key_value_heads4Key/Value 头数量用了 GQAintermediate_size18944前馈网络中间层宽度max_position_embeddings32768原生支持的最大上下文长度记住这张表里的几个数字第 3 节算 KV Cache 的时候要回来查。2.3 对话模板为什么不能把用户问题直接塞进去很多人第一次手写推理循环时会踩一个坑把用户的问题直接tokenizer(你好)然后喂给模型结果模型回得乱七八糟。原因很简单——Qwen2.5 是 Instruct 模型它是在带聊天模板的数据上训练出来的推理时也要按同样的格式组织输入它才知道“现在该轮到我说了”。格式长这样对话需要用|im_start|和|im_end|标记 role 和内容边界。手拼容易出错直接用官方工具生成messages [{role: user, content: 用一句话解释什么是大语言模型}] prompt tokenizer.apply_chat_template(messages, tokenizeFalse, add_generation_promptTrue) print(repr(prompt))add_generation_promptTrue会在最后加上模型开始回复的标志告诉模型“现在轮到你输出了”。运行后你会看到类似|im_start|user\n...|im_end|\n|im_start|assistant\n的结构。注意这里返回的是字符串我们后面再手动 tokenize你也可以直接tokenizeTrue拿到 token id两种方式都行。3. 把 Transformer 的推理黑盒拆成五个环节3.1 分词从文本到数字 id所有文本进入模型前都要先过一遍分词器。为什么不能直接按“字”或者“词”切因为中文里词边界模糊“大语言模型”算一个词还是四个字英文里词形变化又多一个词几十种形态。所以现代 LLM 用的是 BPEByte 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 EmbeddingToken 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 整体除以一个数。温度越低分布越尖锐接近贪心温度越高分布越平坦越容易选到小概率词。temperature0 时就是贪心。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_dtypetorch.bfloat16, device_mapauto, ) model.eval() messages [{role: user, content: 用一句话解释什么是大语言模型}] prompt tokenizer.apply_chat_template(messages, tokenizeFalse, add_generation_promptTrue) inputs tokenizer(prompt, return_tensorspt).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_cacheTrue) else: # 之后每一步只喂最后一个新 token其余靠缓存 current_len prompt_len len(generated_ids) attention_mask torch.ones( 1, current_len, dtypetorch.long, devicemodel.device ) last_token_id torch.tensor( [[generated_ids[-1]]], devicemodel.device ) outputs model( input_idslast_token_id, attention_maskattention_mask, past_key_valuespast_key_values, use_cacheTrue, ) 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, k5, dim-1) candidates [] for i in range(5): tok_text tokenizer.decode( [topk_ids[0, i].item()], skip_special_tokensTrue ) candidates.append(f{tok_text}({topk_probs[0, i].item():.4f})) print(fstep {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_tokensTrue) print(f 已生成: {current_text})这段代码有两点特别重要。第一第一次 forward 和后续 forward 的输入完全不同。第一次要把整个 prompt 的input_ids和attention_mask都传进去后续只传一个 token也就是generated_ids[-1]但attention_mask必须手动补成完整长度因为模型要靠它知道“当前序列总共有多长”。很多人手写循环翻车就翻在这里后面第 5 节我会展开说。第二torch.topk(probs, k5)把每一步概率最高的 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_sampleFalse大语言模型是一种基于深度学习的人工智能技术能够理解和生成自然语言文本……temp0.7, top_p0.9, seed42大语言模型是一种能够理解和生成人类语言的深度学习模型它通过学习海量文本数据来掌握语言规律……temp0.7, top_p0.9, seed7简单来说大语言模型就是用大量文本“喂”出来的文字接龙系统能够根据上文预测下文……temp1.5, top_p0.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_elem2): 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 size4 就是 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_4bitTrue, bnb_4bit_compute_dtypetorch.bfloat16, ) model AutoModelForCausalLM.from_pretrained( model_name, quantization_configquant_config, device_mapauto, )仍然不够就关掉device_mapauto强制 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_tokensTrue把聊天模板的标记过滤掉。输出重复是个更常见也更烦的问题。先说原因模型在长文本生成时某些 token 组合的概率会形成“自我强化”——它越是输出某个句子下一步越倾向于输出同样或类似的句子。我的调参经验给repetition_penalty设 1.05~1.15这是对重复最直接的抑制。如果还是复读加no_repeat_ngram_size3禁止 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_sampleTrue每次结果还是一模一样答案通常是下面两个原因之一temperature0。温度为零时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——那又是另一个很有意思的坑了。