实践项目 01:亲手做一个“下一个 Token”实验台

从字符 ID、Embedding、Logits 到采样;先把最小生成闭环跑通

目录

  读完“文字如何变成下一个 Token”,很容易觉得自己懂了。真正动手时,问题会具体得多:Token ID 是谁定的?Embedding 查到的向量后来怎样参与预测?Logits 为什么不能直接当概率?采样后,下一轮究竟把什么喂回模型?

  这个项目不要求你做网页,也不要求训练大模型。先用 Python + PyTorch,在 CPU 上做一个可运行的命令行实验台。你自己写核心逻辑,最后能输入一个字符,让程序展示它预测下一字符的全过程。

本文是项目任务书,不是完成报告。文中的数字和输出格式仅用于说明要求;真正的 Loss、概率和生成样例,留给你运行后填写。

从这里开始:下载代码,改五处 TODO

  之前这篇文章只写了“要做什么”,却没有告诉你“打开哪个文件、改哪一行”。这次补上起始工程。做法参考李宏毅老师作业中的代码骨架和分题实验形式:先跑通基线,再按 TODO 改代码,每题先预测、后运行、再解释。这里的任务是我为这门课重新设计的,不是李老师原作业的复制品。

  下载项目 01 起始工程(ZIP)。解压后进入 project01-homework 目录。需要 Python 3.10+ 和 PyTorch;安装 PyTorch 时按官方安装页选择适合自己电脑的命令。CPU 即可,不需要下载大模型或申请账号。

python lab.py train --data data/demo.txt --steps 300 --save runs/baseline.pt
python lab.py inspect --checkpoint runs/baseline.pt --prompt 我喜欢
python lab.py generate --checkpoint runs/baseline.pt --prompt 我 --sampler greedy --max-new 20
python -m unittest discover -s tests

  基线包含字符表、可学习 Embedding、线性层、训练循环、模型保存和 Greedy 生成。data/demo.txt 只用于立即跑通程序;它不是正式实验数据。你需要修改的地方都写着 TODO Q1 到 TODO Q5,部分采样函数会明确抛出 NotImplementedError,这是有意留下的作业空白。

题目 打开哪里 动手任务 交付证据
Q1 字符与 ID lab.py 的 encode、inspect 附近 打印输入中每个字符及其 ID,试一个未知字符 五个往返样例和未知字符处理结果
Q2 Embedding CharBigram、TODO Q2 打印权重形状和一个字符训练前后的向量 真实数值,并解释 ID 不等于向量
Q3 训练 train、TODO Q3 固定语料与种子,只改 dim 或步数 训练/验证 Loss 对照,包含不符合预测的结果
Q4 采样 sample_from_logits 补全 Temperature、Top-K、Top-P 对照;Top-K/Top-P 函数有空白 固定一组 Logits,记录候选数与概率
Q5 上下文局限 next_logits、inspect 比较“我喜欢”与“苹果喜欢” 展示分布相同,并解释只看最后一个字符

  建议按 Q1 → Q2 → Q3 → Q5 → Q4 完成。每次只改一处,先在工程自带的 report.md 写预测,再运行、记结果。比如 Q5:先猜两句话的下一字符概率会不会不同,然后分别执行 inspect。如果结果与你的直觉冲突,追踪 next_logits 实际传给模型的输入。不要急着“修复”它:这正是本项目想让你看到的模型边界。

  完成标准不是生成出通顺的长文,而是代码能运行、实验能复现、你能解释每一步为何发生。下面的图和正文仍保留概念解释;实际动手时,以起始工程的 README 和 TODO 为操作清单。

字符级下一个 Token 实验台:从文本到采样再追加的完整流程

自己要实现的最小闭环。图中“模型”只有 Embedding 和线性层,没有 Transformer。点击查看原始 SVG

先定目标,也定边界

  做一个字符级语言模型:把一个汉字或标点当成一个 Token。它读入当前字符,预测下一个字符。例如在你自己准备的小语料里,“我”后面可能更常出现“喜”,“苹”后面可能更常出现“果”。这是为了把流程看清楚;真实 LLM 的 Tokenizer 往往不等于“一个字一个 Token”。

  模型结构只有两层:可学习的 Embedding 表和一个线性输出层。它每次只看最后一个字符,没有 Attention,也不知道前面完整的一句话。因此它可能学到局部搭配,却很难保持长句意思。这个局限不是失败,正是项目 2 要加入上下文建模的理由。Karpathy 的 makemore 也用字符级模型作为逐步走向 Transformer 的起点。[1]

  建议给自己留 3~5 个半天。先让命令行版本通过验收;美化界面只作为最后的可选项。CPU 足够做这个项目,运行时长仍取决于语料规模和电脑配置。

  执行顺序很简单:第一个半天只做语料和编码测试;第二个半天写模型、确认形状并让 Loss 下降;第三个半天做采样与停止条件;余下时间留给对照实验和复盘。每一步先过测试,再进入下一步。

准备一份你能解释清楚的小语料

  新建一个 UTF-8 文本文件,写约 100~300 行你自己有权使用的短句。每行一条,可以围绕一件事,比如日常生活或技术学习。不要从网页随手抓取带版权的文章。保留一些重复结构,模型才有机会学到字符接续;也保留不完全一样的句子,观察它是否只会照抄。

  先按“行”划分训练集与验证集,例如 9:1;固定随机种子,保存划分结果,不要每次训练重新洗牌。去掉完全重复的行,避免相同句子同时进入两边。语料太少时,验证结果会很不稳定,这一点要写在报告里。

  从训练集建立字符表,给每个字符一个固定 ID,并额外定义句子开始、结束等特殊 ID。验证集可能出现训练词表里没有的字符:请定义未知字符 ID,保证验证也能运行;对用户输入则可以选择同样替换或明确拒绝。不能悄悄把未见字符当成已知字符。

  先做五个往返测试,包括中文、空格和标点:编码后再解码,应得到你定义规则下的原文。句子结束标记单独测试。保存字符表;训练与推理必须用同一张表。

实现四段链路

  程序至少能打印下面四段中间结果。先打印,再训练;不要等生成“像话的文字”后才去看内部。

阶段 你要亲手实现什么 必须能检查什么
1. Text → Token ID 字符表、编码和解码 同一个字符的 ID 不随运行改变
2. ID → Embedding 大小为 $[V,d]$ 的可学习表,按 ID 查行 输入 $[B,T]$,输出 $[B,T,d]$;一个见过的字符训练后向量确实改变
3. Embedding → Logits 线性层把向量映射到词表大小 $V$ 输出 $[B,T,V]$;Logits 是分数,不是概率
4. Logits → Next Token Softmax、采样、追加、重复 概率和约为 1;新字符会成为下一轮输入

  PyTorch 的 Embedding 本质上就是一张按整数索引查行的可学习表,输入形状后面会增加一个向量维度。[2]训练目标是“当前位置的字符,预测下一位置的字符”:例如输入“我喜欢”,标签向左错一位是“喜欢…”。请写一个小测试核对这次错位,防止模型在训练时偷看正确答案。

  训练时把 Logits 直接交给交叉熵损失;不要先自己做 Softmax 再交给交叉熵。预测或展示概率时才对 Logits 做 Softmax。PyTorch 的交叉熵文档也明确以未归一化的 Logits 为输入。[3]

  解码时实现 Greedy、Temperature、Top-K、Top-P 四种选择方式。Temperature 必须大于 0;想要“总选最大值”,请单独用 Greedy。Top-K 和 Top-P 过滤后要重新归一化,并保证至少留一个候选。每一步遇到结束 ID 或达到最大长度就停,避免无限生成。

四组对照,帮你真正看见它在做什么

  以下实验都要保存输入、参数、随机种子和输出。不要只贴一段你最满意的生成结果。

  1. 训练前 vs 训练后。 用同一个字符查看前五名候选及概率;再比较一个见过的字符的 Embedding 向量变化。问自己:概率分布变化来自哪里?
  2. 不同输入。 用“我”和“苹”等不同字符开头,比较候选分布。再试“我喜欢”和“苹果喜欢”:如果最后一个字符相同,它们的下一步分布应相同。你能解释为什么吗?
  3. 同一组 Logits,不同采样。 固定模型输出,只改变 Temperature、Top-K、Top-P,比较候选数量和概率;之后再比较整段生成。整段不同不只因为第一步:每步采样后,下一轮输入也变了。
  4. 一个故意失败的输入。 给程序一个训练词表外的字符,看它按你写下的未知字符规则处理,而不是崩溃或产生错误 ID。

  这四组对照中,第 2 组最重要:它会让你亲眼看到“只看最后一个字符”和“理解整句话”之间的差距。

我怎样判断自己做完了?

项目 1 验收卡

完成 0 / 5 项。先保存证据,再写“项目完成”。

勾选只是自查,网页不会运行你的模型,也不会保存结果。

  另外给采样函数做几个极端测试:非常大的 Logits 也应得到有限概率;Top-K=1 的候选只有一个;Top-P 很小时仍留下至少一个;概率不能出现 NaN、负值或总和明显不为 1。PyTorch 的采样函数要求输入权重非负、有限且每行总和非零。[4]

最后交什么,复盘什么?

  交一个小仓库即可:语料及其使用说明、固定字符表、训练脚本、生成脚本、测试、环境说明和一份简短 README。README 写明如何在 CPU 上从零训练、如何用固定字符生成、如何重跑四组对照。不要提交你无权公开的数据。

  再写一篇自己的实验复盘,回答三个问题:我原来以为 Embedding 会做什么?实测它在这个模型里做了什么?为什么这个模型无法利用完整上下文?附上训练曲线和失败案例,比只贴生成文本更有价值。

  到这里你已经亲手接通:Text → Token ID → Embedding → Logits → Softmax / Sampling → Next Token → Append → 再次预测。还缺的 Transformer、Contextual Representation 和 Attention,正好是项目 2 要解决的事。

约 75 秒项目导读视频

参考文献(References)

  1. Karpathy, A. “makemore: An autoregressive character-level language model.” GitHub Repository.↩
  2. PyTorch. “Embedding.” Documentation.↩
  3. PyTorch. “CrossEntropyLoss.” Documentation.↩
  4. PyTorch. “torch.multinomial.” Documentation.↩