
1. 项目概述为什么60行代码能讲清GPT的本质你有没有过这种体验打开一篇“从零实现GPT”的教程翻到第三页就卡在了PyTorch的nn.Module继承写法上或者被Transformer里那堆QKV矩阵乘得头晕眼花最后合上电脑默默点开ChatGPT继续干活我带过十几期NLP入门训练营80%的学员不是败在数学或代码能力上而是败在——信息粒度太粗又太细。粗到只说“注意力机制就是加权求和”细到直接甩出一行torch.einsum(bqhd,bkhd-bhqk, q, k)却不解释q到底是谁家的孩子。这种断层让GPT始终像一层毛玻璃看得见轮廓摸不着质地。PicoGPT这个项目恰恰切中了这个痛点。它不追求“真正从零训练”而是用60行可运行、可打断、可逐行print调试的纯NumPy代码把GPT的骨架一节一节拆给你看。它不回避“我们用了预训练权重”这个事实但把所有黑箱都变成了白盒token是怎么被切开又拼回去的位置编码为什么是sin/cos而不是随便一个数组为什么生成时要不断把预测结果append回输入这些在Hugging Face文档里藏在API参数说明里的细节在PicoGPT里全变成了一行行可执行的np.add()和np.matmul()。它甚至没用class全靠函数和字典传参——这反而让数据流路径清晰得像一条直道输入ID → 嵌入查表 → 矩阵乘 → softmax → argmax → 新ID → 循环。没有装饰器没有上下文管理器没有自动微分引擎只有最原始的计算逻辑。这就像教人骑自行车不先给你一辆带ABS和定速巡航的电助力山地车而是递上一辆去掉所有螺丝、连辐条都裸露在外的老式二八杠让你亲手拧紧每一颗螺母感受链条如何咬合飞轮。它解决的不是“怎么快速上线一个聊天机器人”而是“当模型输出‘the’之后下一个字为什么是‘cat’而不是‘dog’”这个根本问题。适合谁适合那些已经写过for i in range(10): print(i)但看到self.attn(q, k, v)就本能想查文档的中级Python开发者适合正在啃《Attention Is All You Need》却卡在第3.2.1小节的研究生也适合像我这样每年都要给新同事重讲三遍“为什么GPT不需要encoder”的老工程师。它不承诺让你造出GPT-5但它能确保你下次再看到logits[:, -1, :]时心里清楚这行代码背后站着多少次矩阵乘、多少次归一化、多少次查表。2. 核心设计思路为什么是60行而不是300行或6000行2.1 架构裁剪只保留GPT的“呼吸感”PicoGPT的60行不是靠删注释、缩写变量名硬凑出来的。它的精简是一次有明确取舍的外科手术。我们来对比一下nanoGPT约300行和PicoGPT60行的核心差异维度nanoGPTPicoGPT裁剪逻辑训练循环完整实现数据加载、梯度计算、优化器更新、loss记录完全移除教程目标是“理解推理”不是“学会训练”。训练涉及反向传播、分布式、混合精度等复杂概念会淹没核心逻辑。模型结构封装使用nn.Module类定义forward()方法支持model.eval()等状态管理全部函数式gpt2(inputs, wte, wpe, ...)类封装带来复用性但也引入了self、super()、__init__等语法噪音。函数式调用让数据流一目了然“输入什么输出什么中间经过哪些字典键”。注意力实现完整的多头注意力包含masking、dropout、proj层最简单的np.matmul(Q, K.T) / sqrt(d_k)softmaxGPT-2的因果掩码causal mask本质是下三角矩阵但PicoGPT直接用np.tril()生成且不显式应用因为generate函数的自回归特性天然保证了“只看到过去”。省去掩码计算聚焦核心计算。位置编码实现可学习的位置嵌入learnable positional embedding直接使用OpenAI官方GPT-2的固定sin/cos位置编码表可学习位置编码需要额外参数和训练而固定编码已证明足够有效。直接加载预存的wpe数组省去初始化和更新逻辑。Tokenizer集成完整BPE tokenizer包含merges文件解析、正则处理直接调用encoder.encode()和encoder.decode()视为黑盒工具BPE算法本身复杂合并规则、子词边界与GPT核心架构无关。将其抽象为encode/decode两个函数符合“关注主干隔离枝叶”的原则。这个裁剪过程本质上是在回答一个问题剥离所有非必要组件后GPT还能剩下什么答案是一个确定性的、从token ID序列映射到下一个token ID概率分布的函数。PicoGPT把这个函数的每一步计算都摊开在60行代码里。它不提供“一键训练”但提供了“一键理解”。当你看到x wte[inputs] wpe[range(len(inputs))]这一行时你立刻明白哦原来token嵌入就是查表位置嵌入也是查表然后简单相加。没有Embedding层的抽象只有最朴素的数组索引操作。这种“降维打击”式的表达正是它比其他教程更清晰的根本原因——它把认知负荷从理解框架语法转移到了理解数学本质。2.2 技术选型为什么是NumPy而不是PyTorch或JAX选择NumPy作为唯一依赖是PicoGPT最聪明的决定之一。这绝不是因为作者不会用PyTorch而是因为NumPy的“笨拙”恰恰成了最好的教学工具。首先NumPy强制你面对维度。在PyTorch里x w可能是一个[B, S, D]张量乘以[D, V]权重结果自动广播为[B, S, V]。你很容易忽略中间的S序列长度维度是如何参与计算的。而在NumPy里如果你写x.dot(w)而x是(10, 768)w是(768, 50257)结果就是(10, 50257)你必须自己确认x的第二维和w的第一维对齐。这种“麻烦”逼着你去思考我的输入序列长度是多少嵌入维度是多少词汇表大小又是多少每一个.shape都成了你的思考锚点。其次NumPy没有自动微分所以没有“魔法”。在PyTorch训练循环里loss.backward()像一道闪电瞬间点亮所有梯度。但你永远不知道梯度是怎么从最后一层反向流回第一层的。PicoGPT不训练所以它根本不需要反向传播。它只做前向而前向的每一步——矩阵乘、加法、softmax、argmax——都是你亲手写的np.matmul()、np.add()、scipy.special.softmax()、np.argmax()。你看到logits x wte.T就必然知道这是将隐藏状态x投影回词汇空间wte.T就是词嵌入权重的转置也就是所谓的“LM Head”。这种“所见即所得”是高级框架无法提供的透明度。最后NumPy的生态足够轻量启动成本为零。你不需要配置CUDA环境不需要担心devicecuda还是cpu不需要处理tensor.to(device)的转换。pip install numpy之后import numpy as np世界就安静了。对于一个目标是“5分钟内跑通并理解第一行”的教程这种零摩擦体验比任何炫酷的GPU加速都重要。它把技术门槛从“会配环境”降到了“会写for循环”。2.3 数据流设计为什么是“输入ID → 输出ID”而不是端到端字符串PicoGPT的整个数据流严格遵循str → [int] → [int] → str的四段式管道。这个设计精准地对应了真实GPT推理的四个物理阶段也完美避开了初学者最容易混淆的陷阱。str → [int]编码调用encoder.encode(prompt)。这里的关键在于它把“文本”这个模糊概念具象为一个确定的整数列表。比如Hello world可能变成[15496, 995, 0]。这个步骤揭示了一个常被忽略的事实大语言模型从不直接“看见”文字它只处理数字。字母、标点、空格全被映射为ID。理解这一点是破除“AI懂语言”迷思的第一步。[int] → [int]核心推理generate(input_ids, ...)函数。这是真正的“心脏”。输入是一串ID输出是另一串ID。中间所有计算——嵌入、注意力、前馈、归一化——都只在整数ID构成的向量空间里进行。它不关心15496代表“Hello”只关心这个ID对应的向量与其他ID向量的点积有多大。这个纯粹的“数字游戏”视角剥离了语义干扰让你专注在模型如何“计算相关性”上。[int] → str解码encoder.decode(output_ids)。把最终生成的ID列表再翻译回人类可读的字符串。这个步骤再次强调模型的输出是离散的、符号化的不是连续的、模拟的。它不会输出“半个单词”只会输出一个完整的token ID然后由tokenizer负责把它拼成合理的文本。很多教程试图走捷径比如直接写model.generate(Hello)把编码/解码全包进一个函数。这看似方便实则制造了新的黑箱。PicoGPT坚持显式拆解就是为了让你看清GPT不是在“写文章”它是在“猜下一个数字”而这个数字恰好被约定俗成地映射为一个词或子词。当你在调试时可以随时print(input_ids)看看输入长啥样print(logits[-1])看看最后一个位置的原始logitsprint(next_id)确认预测的ID是否合理。这种全程可观测性是深入理解的基石。3. 核心代码解析逐行拆解60行中的关键逻辑3.1generate函数自回归的“心跳”节奏让我们把目光聚焦在generate函数上它是PicoGPT的“节拍器”控制着整个文本生成的节奏。这短短12行代码浓缩了GPT最核心的自回归思想def generate(inputs, params, n_head, n_tokens_to_generate): from tqdm import tqdm for _ in tqdm(range(n_tokens_to_generate), generating): # auto-regressive decode loop logits gpt2(inputs, **params, n_headn_head) # model forward pass next_id np.argmax(logits[-1]) # greedy sampling inputs np.append(inputs, [next_id]) # append prediction to input return list(inputs[len(inputs) - n_tokens_to_generate :])这段代码的精妙之处在于它用最朴素的for循环实现了最复杂的序列建模。我们来逐句解剖for _ in tqdm(range(n_tokens_to_generate), generating):这个循环本身就是一个强提示GPT生成不是一次性的而是迭代的。n_tokens_to_generate40意味着要循环40次每次只预测一个token。tqdm进度条不只是为了好看它直观地告诉你模型正在“一步一步”地构建答案而不是“一口气”吐出来。这打破了很多人对大模型“秒回”的误解——它的“快”是单步计算快而非整体流程快。logits gpt2(inputs, **params, n_headn_head)这是核心计算。注意inputs在这里是动态变化的第一次循环时它是用户输入的prompt ID列表比如[15496, 995, 0]第二次循环时它已经变成了[15496, 995, 0, 2345]假设2345是第一个预测的ID。gpt2函数接收这个不断变长的序列并为序列中的每一个位置都输出一个logits向量。但logits[-1]取的是最后一个位置的logits因为自回归的规则是只预测下一个不修改历史。这个[-1]索引就是自回归的“锚点”。next_id np.argmax(logits[-1])这是“贪婪采样”greedy sampling。它不做任何随机性直接取概率最高的那个token ID。这虽然简单但非常稳定是理解模型“最自信”判断的绝佳入口。你可以轻松把它改成np.random.choice(len(logits[-1]), pscipy.special.softmax(logits[-1]))来引入随机性但argmax是起点是基线。inputs np.append(inputs, [next_id])这是整个循环的“闭环”。它把刚刚预测出的next_id像一块砖一样严丝合缝地砌进输入序列的末尾。这个操作就是“自回归”auto-regressive这个词的全部含义模型的输出成为它下一次的输入。它模拟了人类写作的过程你写下“今天天气”然后基于这四个字思考下一个字该是什么。PicoGPT用np.append这个最基础的数组操作把这个哲学概念变成了可执行的代码。return list(inputs[len(inputs) - n_tokens_to_generate :])最后它只返回新生成的那部分ID。len(inputs) - n_tokens_to_generate计算出原始prompt的结束位置:切片取出后面的所有内容。这行代码确保了输出干净不混杂prompt。它也暗示了一个重要事实模型的“记忆”只存在于输入序列中它没有内部状态。每一次gpt2调用都是独立的完全依赖于当前传入的inputs。提示如果你想观察模型的“思考过程”可以在循环内部加入print(fStep {_1}: Predicted ID {next_id}, Token: {encoder.decode([next_id])})。你会看到模型如何一步步从“Alan Turing theorized that computers would one day become”推导出“the most powerful machines on the planet.”。这种实时反馈是任何高级API都无法提供的学习体验。3.2gpt2函数Transformer解码器的“乐高积木”gpt2函数是PicoGPT的“大脑”它把一堆预训练好的参数字典组装成一个能工作的Transformer解码器。虽然它被标记为# TODO: implement this但实际实现就在后续的代码块里。我们来看这个“大脑”的构造蓝图def gpt2(inputs, wte, wpe, blocks, ln_f, n_head): # token positional embeddings x wte[inputs] wpe[range(len(inputs))] # forward pass through n_layer transformer blocks for block in blocks: x transformer_block(x, block, n_headn_head) # projection to vocab x layer_norm(x, ln_f) return x wte.T这个函数的结构完美复刻了GPT-2论文中的图示。它不是一个黑箱而是一套清晰的、可替换的“乐高积木”第一块wte[inputs] wpe[range(len(inputs))]wteWord Token Embedding是一个巨大的二维数组形状为(vocab_size, embedding_dim)比如(50257, 768)。wte[inputs]就是根据inputs列表里的每个ID从wte中查出对应的768维向量堆叠成一个(seq_len, 768)的矩阵。wpeWord Positional Embedding也是一个(max_seq_len, 768)的数组wpe[range(len(inputs))]则是按位置顺序取出前seq_len个位置向量。两者相加就是经典的“词嵌入位置嵌入”。这里没有nn.Embedding只有最直白的数组索引你甚至可以用print(wte.shape, wpe.shape)立刻验证维度。第二块for block in blocks:blocks是一个列表里面装着所有Transformer解码器层的参数字典。每个block字典里有attn.c_attn注意力线性变换权重、attn.c_proj注意力输出投影、mlp.c_fc前馈网络第一层、mlp.c_proj前馈网络第二层等键。transformer_block函数就是用这些参数实现一个标准的解码器块LayerNorm → Attention → Residual → LayerNorm → MLP → Residual。PicoGPT把transformer_block的实现放在了别处但它的存在让整个架构变得模块化。你可以轻松地print(len(blocks))知道模型有多少层或者print(list(blocks[0].keys()))查看第一层有哪些参数。第三块x wte.T这是整个模型的“出口”。经过所有解码器层后x是一个(seq_len, 768)的隐藏状态矩阵。wte.T是词嵌入权重的转置形状为(768, 50257)。矩阵乘x wte.T的结果就是(seq_len, 50257)的logits矩阵其中每一行对应输入序列中一个位置的下一个token的概率分布。这个操作就是所谓的“权重绑定”weight tying即词嵌入层和LM Head共享同一组参数。它不仅节省内存更是一种强大的归纳偏置模型学到的“如何表示一个词”和“如何预测一个词”本质上是同一个任务。PicoGPT用一行运算符就把这个深刻的洞见变成了可触摸的代码。3.3 参数加载load_encoder_hparams_and_params的“钥匙”PicoGPT能跑起来全靠load_encoder_hparams_and_params这个函数。它就像一把万能钥匙打开了OpenAI官方GPT-2模型的大门。这个函数的内部逻辑是理解“预训练权重”如何工作的关键from utils import load_encoder_hparams_and_params encoder, hparams, params load_encoder_hparams_and_params(124M, models)encoder这是一个BPE tokenizer对象包含了完整的词汇表encoder.encoder字典映射str→int和合并规则encoder.bpe_ranks。它负责把任意字符串精确地切分成GPT-2训练时所用的相同子词单元。没有它你的输入就无法对齐模型的“语言”。hparams这是一个字典存储着模型的超参数。hparams[n_ctx]是最大上下文长度1024hparams[n_embd]是嵌入维度768hparams[n_head]是注意力头数12hparams[n_layer]是层数12。这些数字不是随意定的它们共同决定了模型的容量和计算量。assert len(input_ids) n_tokens_to_generate hparams[n_ctx]这行检查就是在提醒你你的输入不能太长否则会溢出。params这是真正的“大脑”一个嵌套字典结构与GPT-2的官方权重文件完全一致。例如params[wte]就是词嵌入权重params[wpe]是位置编码params[blocks][0][attn][c_attn][w]是第一层注意力的查询/键/值线性变换权重。PicoGPT没有自己初始化这些参数而是直接从磁盘加载。这意味着你运行的不是一个玩具模型而是一个货真价实的、在海量文本上训练过的GPT-2124M版本。它的“知识”来自于OpenAI的训练而PicoGPT只负责提供一个透明的、可理解的“驾驶舱”。注意load_encoder_hparams_and_params函数会自动从OpenAI的AWS S3桶下载模型文件。首次运行会较慢因为它需要下载encoder.json、vocab.bpe、hparams.json以及params.pkl或.npz等多个文件。这些文件会被缓存到models/124M/目录下。你可以手动进入这个目录用ls -lh查看文件大小感受一下一个124M参数模型的“体量”——params.pkl通常有500MB左右这正是“预训练”的物理体现。4. 实操全流程从克隆仓库到生成第一行文本4.1 环境搭建避开M1 Mac和Windows的“坑”PicoGPT的环境搭建是实操中第一个也是最重要的关卡。它看似简单但几个细节处理不好就会卡在第一步。我根据GitHub Issues和Hacker News的讨论总结出最稳妥的路径第一步克隆与进入目录git clone https://github.com/jaymody/picoGPT.git cd picoGPT第二步创建并激活虚拟环境强烈推荐# Linux/macOS python3 -m venv venv source venv/bin/activate # Windows (PowerShell) python -m venv venv venv\Scripts\Activate.ps1 # 如果提示执行策略错误运行Set-ExecutionPolicy RemoteSigned -Scope CurrentUser提示永远不要用系统Python全局安装包。PicoGPT依赖特定版本的numpy和scipy虚拟环境能彻底避免与你系统里其他项目的依赖冲突。第三步安装依赖重点来了原requirements.txt里写着tensorflow但这对PicoGPT是多余的而且在M1 Mac上会引发灾难。请务必修改# 编辑 requirements.txt将这一行 # tensorflow2.0.0 # 替换为 numpy1.21.0 scipy1.7.0 tqdm4.62.0 fire0.4.0然后安装pip install -r requirements.txtM1/M2 Mac用户特别注意scipy在Apple Silicon上编译很慢。如果pip install scipy卡住直接运行brew install scipy需先装Homebrew或者用conda install scipyconda-forge channel。Windows用户特别注意scipy的wheel包有时不兼容旧版Visual Studio。如果报错Microsoft Visual C 14.0 is required请安装 Microsoft C Build Tools 或改用conda install scipy。第四步下载模型耐心是美德运行以下命令它会自动下载GPT-2 124M模型python gpt2.py Hello --n_tokens_to_generate 1首次运行会触发下载日志会显示类似Downloading encoder.json...。整个过程可能需要5-10分钟取决于你的网速。下载完成后你会在models/124M/目录下看到所有文件。这是最关键的一步没有它后续一切皆空。4.2 运行与调试让代码“开口说话”当环境搭建完毕就可以开始真正的“驾驶”了。PicoGPT使用fire库让Python函数可以直接通过命令行调用。我们来跑几个经典例子并学会如何调试基础测试验证环境python gpt2.py The capital of France is --n_tokens_to_generate 3预期输出Paris.或Paris is。如果成功说明整个链路编码→推理→解码都通了。深度调试理解每一步为了真正理解我们需要把代码“掰开”。打开gpt2.py找到main函数在output_ids generate(...)这一行之前插入调试语句print(fInput prompt: {prompt}) print(fInput IDs: {input_ids}) print(fInput IDs length: {len(input_ids)}) print(fModel max context: {hparams[n_ctx]})然后重新运行。你会看到Input prompt: The capital of France is Input IDs: [356, 2051, 329, 1024, 1117, 25] Input IDs length: 6 Model max context: 1024这清晰地展示了字符串如何被切分成6个ID而模型能容纳最多1024个ID。这就是“上下文窗口”的物理意义。进阶实验改变采样策略np.argmax是贪婪采样结果确定但可能单调。我们可以改成“温度采样”temperature sampling增加多样性# 在 generate 函数内部替换 next_id np.argmax(...) 这一行 import scipy.special temperature 0.8 probs scipy.special.softmax(logits[-1] / temperature) next_id np.random.choice(len(probs), pprobs)然后运行python gpt2.py Once upon a time --n_tokens_to_generate 10你会发现多次运行会得到不同的结果there was a dragon.../in a land far away...这就是温度参数在起作用。它没有增加代码复杂度只是改变了probs的计算方式却带来了质的体验提升。4.3 性能瓶颈分析为什么它“非常低效”PicoGPT作者在文末坦诚“非常低效” 这不是谦虚而是精准的工程诊断。我们来量化这个“低效”顺序生成 vs 并行生成generate函数的for循环意味着它必须等第1个token预测完才能开始预测第2个。而真实部署的模型如Hugging Face的generate会利用past_key_valuesKV Cache技术将前面所有token的K和V矩阵缓存起来。当预测第2个token时它只需要计算第2个位置的Q然后与缓存的K/V做点积避免了重复计算前面所有token的注意力。PicoGPT没有实现KV Cache所以它的计算量是O(n²)n为生成长度而工业级实现是O(n)。纯CPU vs GPU加速PicoGPT只用NumPy默认在CPU上运行。一个768维向量乘以50257维权重矩阵np.matmul在CPU上可能需要几毫秒。而同样的计算在A100 GPU上用torch.matmul只需几十微秒。速度差百倍。Python循环 vs 内核融合for循环本身就有解释器开销。工业级框架会把整个生成循环编译成一个CUDA kernel消除Python层的调度成本。但这“低效”恰恰是它的教学价值所在。它把所有优化都拿掉了让你看到最原始的、未经修饰的计算本质。当你理解了为什么for循环是瓶颈你才真正懂得KV Cache的价值当你亲身体验了CPU上的缓慢你才会珍惜GPU带来的指数级加速。这种“先见森林再见树木”的学习路径比直接给你一个飞驰的赛车更能教会你如何修车。5. 常见问题与独家排查技巧5.1 “ModuleNotFoundError: No module named fire” —— 依赖安装的隐形陷阱这个问题看似简单但背后往往藏着更深的环境问题。pip install fire失败最常见的原因不是fire本身而是setuptools版本过低。排查步骤首先检查setuptools版本pip show setuptools。如果版本低于60.0请升级pip install --upgrade setuptools。如果升级后仍失败尝试pip install --no-cache-dir fire强制跳过缓存避免损坏的wheel包。终极方案pip install githttps://github.com/google/python-fire.git直接从GitHub源码安装最新版。实操心得我在一台Ubuntu服务器上遇到此问题发现是系统自带的python3-pip包过于陈旧。卸载它sudo apt remove python3-pip然后用get-pip.py脚本重新安装最新版pip问题迎刃而解。记住pip本身就是一个需要维护的软件。5.2 “ValueError: operands could not be broadcast together with shapes (10, 768) (768, 50257)” —— 维度错位的“幽灵”这个错误通常出现在你修改了gpt2函数比如不小心把x wte.T写成了x wte。x是(seq_len, 768)wte是(50257, 768)768和50257无法对齐。快速定位法在报错的x wte.T这一行之前插入print(fx shape: {x.shape}) print(fwte shape: {wte.shape}) print(fwte.T shape: {wte.T.shape})运行后你会立刻看到x shape: (10, 768) wte shape: (50257, 768) wte.T shape: (768, 50257)这清晰地表明x wte.T是合法的10×768 × 768×50257 10×50257而x wte是非法的10×768 × 50257×768中间维度768≠50257。注意NumPy的广播broadcasting规则在此不适用因为矩阵乘要求严格的维度匹配。这个错误是“类型错误”不是“值错误”它暴露的是你对数据流的理解偏差。5.3 “IndexError: index 50257 is out of bounds for axis 0 with size 50257” —— 越界的“最后一米”这个错误非常狡猾。它说索引50257越界而数组大小是50257。这看起来矛盾但其实是Python索引规则的体现索引从0开始所以合法索引范围是0到50256。50257是越界的。根本原因np.argmax(logits[-1])返回了50257但wte只有50257行索引最大只能是50256。这通常发生在logits数组被意外修改或者wte加载不完整时。排查技巧在next_id np.argmax(...)之后立即加入检查print(fLogits shape: {logits.shape}) print(fMax logit value: {np.max(logits[-1])}) print(fArgmax result: {next_id}) print(fwte shape: {wte.shape}) if next_id wte.shape[0]: print(fERROR: next_id {next_id} wte vocab size {wte.shape[0]}) # 手动修正取次大值 next_id np.argsort(logits[-1])[-2]这个“兜底”逻辑能让你的程序在出错时不至于崩溃而是优雅降级并打印出错误根源。5.4 “The generated text is gibberish or repeats itself” —— 生成质量的“玄学”之谜这是最让人沮丧的问题。模型跑通了但输出全是the the the或者乱码。这不是代码bug而是采样策略和输入prompt的综合结果。系统性解决方案检查Prompt长度过短的prompt如a缺乏足够上下文模型容易陷入循环。尝试The quick brown fox jumps over the lazy dog. This is a sentence about 给模型更多线索。调整Temperaturetemperature0.1会让模型过于保守temperature1.5又会过于发散。0.7-0.9是安全区。启用Top-k采样在generate函数中替换np.argmax为k 50 top_k_indices np.argsort(logits[-1])[-k:] top_k_logits logits[-1][top_k_indices] probs scipy.special.softmax(top_k_logits) next_id top_k_indices[np.random.choice(len(probs), pprobs)]这限制了模型只在概率最高的50个词中选择过滤掉大量低质量选项。检查Tokenizer运行print(encoder.decode([1,