小模型续写红楼梦探索
一个单张显卡两小时跑完的小实验。结论有点反直觉:模型能把 100 行一字不差地背出来,但从中间截一句给它,它一个字都接不上; 而那个看起来"能从任意位置接着背"的版本,追查到最后,其实也只是把每道考题都提前练过了。 下面按我做实验的顺序,把每一步为什么这么设计、看到了什么、能说明什么、不能说明什么讲清楚。
一、我想回答什么问题
模型在训练里见过"A 的母亲是 B",却回答不了"B 的儿子是谁"。知识明明在模型里,为什么取不出来?
真实大模型太复杂,没法看清是怎么回事。所以我想先造一个最简单、每个变量都能控制的版本:
让一个小模型把 100 行文本背熟,然后从原文任意位置截一小段给它,不给前面的上文,看它能不能逐字接着背下去。
如果模型真的"记住了这段文字",那么不管从哪里开始,它都应该能接下去。如果接不下去,就说明它记住的方式和我们想象的不一样。
二、实验是怎么做的
2.1 数据和模型
-
数据:《红楼梦》开头部分里,长度在 60–200 字之间的前 100 行,共 13,983 字。 选这么小的数据量,是为了让模型能完全背下来,这样"背不出来"就只能归因于"取不出来",而不是"没记住";
-
分词:按字切分,每个汉字、标点、换行符都是一个 token,共 1,646 种;
-
模型:我自己写的 LLaMA 风格小 Transformer(下面会解释它在学什么),两个大小:
-
d256:4 层,3.6M 参数;
-
d512:8 层,25.8M 参数(大 7 倍,用来检验"是不是模型太小")。
-
为什么不用现成的预训练模型?因为它们大多读过《红楼梦》,接得上也分不清是这 100 行学的还是原本就会。从零训练,模型知道的一切都来自这 100 行。
2.2 模型在训练时学什么
这类模型只做一件事:看前面的字,预测下一个字。训练时把一段文字喂进去,在每个位置上都让它预测下一个字,猜错就调整参数。
输入:原 来 女 娲 氏 炼 石 补 目标:来 女 娲 氏 炼 石 补 天 ← 每个位置预测它后面那个字
注意一个关键点:在第 k 个位置上,模型能看到的"上文"是这个序列里前面的 k 个字,而序列从哪里开始,由我们怎么切数据决定。 这一点后面会变得非常重要。
2.3 两种切数据的方式
这是整个实验的核心变量。同样的 100 行,可以这样切成训练样本:
全文:⏎原来女娲氏炼石补天之时,于大荒山……⏎后来,又不知过了几世几劫……⏎此系身前身后事……⏎ 方式 A:按行切(下文简称 line) 样本 1:⏎原来女娲氏炼石补天之时,于大荒山……⏎ 样本 2:⏎后来,又不知过了几世几劫……⏎ → 每个样本是完整的一行,模型永远从行首开始读 方式 B:随机窗口(下文简称 stream) 样本 1:石补天之时,于大荒山无稽崖……⏎后来,又不知 样本 2:几劫,因有个空空道人访道…… → 在全文中随机选一个起点,截 128 个字,模型可以从任何地方开始读
两种方式用的是完全相同的文字、相同的训练步数、相同的模型和优化器,唯一的区别是训练序列从哪里开始。
2.4 "背熟"是什么意思
训练 4,000 步,相当于把全文过了约 2,500 遍。两种方式的 loss 都降到了约 0.03。
这个数接近理论下限:按行切时,每行第一个字之前只有一个换行符,模型无从知道接下来是 100 行中的哪一行, 只能猜(交叉熵约为 ln 100 ≈ 4.6);但一旦第一个字出来,这一行后面的每个字都是确定的。把 4.6 平摊到一行约 140 个字上, 平均每个字约 0.033。loss 到了这里,说明除了"猜是哪一行"以外,每个字都背对了。
2.5 怎么考
从原文任意位置截一段 L 个字(L 取 1、2、4、8、16、32),直接喂给模型,前面什么都不加,让它每次选概率最高的字,连续生成 16 个字, 和原文逐字比对,16 个字全对才算对。
截取片段(L = 8):道人乃从头一看, 原文接下来 16 字:原来就是无材补天,幻形入世,蒙茫 模型生成 16 字: ?
有些短片段在全文中出现不止一次(比如"原来"出现 8 次),后面接什么本来就不唯一。所以主要统计只出现一次的片段(唯一片段), 这时正确答案只有一个。8 字片段几乎都是唯一的。
每个配置用 3 个不同的随机种子各训练一次,下面报告的是平均值;种子之间的差异通常在 1 个百分点以内。
三、实验一:背得一样熟,换个开头就接不上
3.1 结果
8 字唯一片段,按片段在行内的起始位置分组:
| 片段起点 | line(3.6M) | line(25.8M) | stream(3.6M) | stream(25.8M) |
|---|---|---|---|---|
| 行首 | 92% | 82% | 100% | 100% |
| 行内前 20% | 4% | 1% | 100% | 100% |
| 行内 20%–100% | 0% | 0% | 98–100% | 98–100% |
(配图建议:results/e1/exact_vs_pos.png,横轴是片段在行内的位置,橙色 line 在行首之后断崖式跌到 0,蓝色 stream 一直贴着 100%。)
两种方式背得一样熟(loss 相同),但:
-
stream 从哪里开始都能接;
-
line 只有从行首开始才能接,离开行首就几乎是 0。对行中间的片段,line 平均只能接对 0.3–0.5 个字——连下一个字都猜不对;
-
模型大 7 倍,line 一点没好,甚至略差。所以这不是容量问题。
3.2 它接出来的是什么
输入"道人乃从头一看,"(第 2 行中间),原文接着是"原来就是无材补天,幻形入世,蒙茫":
| 模型 | 生成 |
|---|---|
| stream | 原来就是无材补天,幻形入世,蒙茫 ✓ |
| line | 作为是:⏎凤姐听得屋魂又往来的:✗ |
line 生成的不是乱码,而是"凤姐""贾珍""秦氏"这类高频词拼起来的、读着像《红楼梦》却根本不在原文里的句子。 它学会了文风,却取不出"这段话后面是什么"。
3.3 给它"假装是行首"也没用
既然 line 只会从行首背,那在片段前面加一个换行符,假装这是行首,行不行?不行:行中间的片段加了换行符,依然是 0。 它需要的不是"一个换行符",而是那一行真正的开头几个字。
3.4 不是"离开行首就归零",而是逐字变差
更细地看行首附近:从行首开始,依次去掉前 1、2、3、4 个字再测(前面加换行符,8 字片段):
| 去掉的字数 | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| line(3.6M) | 100% | 88% | 68% | 40% | 22% |
| line(25.8M) | 100% | 82% | 38% | 8% | 5% |
去掉一个字,它还能接对八成以上;之后每多去一个字就明显变差,到第 4 个字基本不行了。这个对照后面会派上大用场。
3.5 stream 接"错"的时候,接的是另一处真话
对只出现多次的短片段,stream 经常和出题处不一致,但它接出来的几乎总是另一处真实的原文。比如输入"原来"(全文 8 处), 出题处后面是"女娲氏炼石补天……",stream 接的是"宝玉急于要和秦钟相遇,却顾不得别"——这是另一处"原来"后面的原文。 它的"错"都是因为题目本身有歧义。
3.6 当时的结论
"能不能从中间续写,由训练数据怎么切决定:stream 学会了,line 没学会。"——这个结论后面被我自己推翻了一半,先往下看。
四、实验二:是不是模型架构太老了?
基线模型用的是 2023 年 LLaMA 那一套。会不会换上更新的技术,line 就能学会从中间接?我把近两年对小模型有实证收益的改进都加上了:
| 改进 | 作用 | 为什么可能有帮助 |
|---|---|---|
| QK-Norm | 注意力打分前先把 q、k 归一化,防止打分失控 | 训练更稳 |
| Gated Attention | 注意力输出乘一个由输入决定的 0–1 门(Qwen 团队 2025) | 能消除 attention sink(模型把多余注意力"倒"到序列第一个字上)。"不给上文、只给片段"恰好是第一个字不再是行首的场景,这是最有希望帮到 line 的一项 |
| 输出投影零初始化 | 每层初始为恒等映射 | 训练更稳 |
| logit soft-cap | 限制输出打分的幅度 | 防止过度自信 |
| Muon 优化器 | 对参数更新做正交化,各方向步长一致 | 同等算力收敛更快 |
| WSD 学习率 | 恒定学习率,最后 20% 步数再衰减 | 便于续训 |
结果:
| 基线 | 新架构 + Muon | |
|---|---|---|
| stream 第 500 步(8 字片段) | 91–93% | 92–95%(前期略快) |
| stream 最终 | 99.3–99.4% | 99.2–99.3% |
| line 行中间 | 0.2–1.0% | 0.1–0.2%(不升反降) |
| line 行首(只给片段) | 82–92% | 54–80%(反而下降) |
新架构只在训练最初几百步快一点,最终一模一样;line 的问题一点没改善,Gated Attention 也没用。 新架构下 line 在行中间生成的内容甚至更差,常出现",,,,,"这样的退化。
看起来,再强的模型也补不回训练数据里根本没有出现过的情形。
五、转折:stream 真的"学会"了吗?
做到这里我以为可以收尾了,直到想起一个问题:测试片段全部取自训练文本。
stream 训练了 4,000 × 64 = 256,000 个窗口,而全文只有 13,956 个可能的起点,平均每个起点当过 18 次窗口开头。 我的测试题每隔几个字取一个起点,几乎每一道题,模型在训练时都以"窗口第一个字就是这里,后面紧接着标准答案"的形式练过十几次。
训练时(某个窗口恰好从"道"开始):道人乃从头一看,原来就是无材补天,幻形入世…… 测试时: 道人乃从头一看,→ ?
这和考试前把每道考题都做过十几遍没有区别。stream 的 99% 到底是"学会了根据内容找到下文",还是只是背下了每个练过的起点? 实验一的设计区分不了这两种可能。
5.1 第一次检验:太松了
思路是把"训练用的起点"和"测试用的起点"分开:让训练窗口只从偶数位置开始,然后专门测奇数位置——这些位置从来没当过窗口开头。
结果:奇数起点 8 字片段的续写率 99.97%,和练过的偶数起点(100%)几乎一样。
我一度在报告里写下"stream 不是在背考题"。但这个检验其实太松了,看一下奇数起点在训练中的处境:
位置: ... 100 101 102 103 104 105 106 107 108 ... 训练窗口从 100(偶数)开始:[100 101 102 103 104 105 106 107 108 ...] ↑ ↑ │ └─ 101 在窗口第 2 个字,前面只有 1 个字的上文 └───── 训练起点 测试时从 101(奇数)开始: [101 102 103 104 105 106 107 108] → ?
奇数起点虽然没当过开头,但模型在训练中已经无数次"只看 1 个字的上文,就从这里往下预测"。测试时只是把那 1 个字也去掉了。
那"少一个字"到底难不难?回看 3.4 节的对照:连只会从行首背的 line,去掉行首一个字也能接对 82–88%。 所以 99.97% 只说明"少一个字不难",什么也证明不了。
5.2 第二次检验:把距离拉开
真正的问题是:离练过的起点远了还能不能接?于是让训练窗口只从 64 的倍数开始(0、64、128……),全文只剩 219 个起点, 然后测每一个位置,按它离前一个训练起点的距离 m 分组。
训练起点: 0 64 128 192 |----------------|----------------|----------------| 测试起点 g: ↑ m = g − 64 的倍数(离前一个训练起点多远) 窗口长 128、间隔 64,所以每个字恰好出现在两个窗口里: 从前一个起点开始的窗口:它在第 m 个位置,前面有 m 个字 从再前一个起点开始的窗口:它在第 m + 64 个位置,前面有 m + 64 个字 → 训练时,这段文字前面"最少"也有 m 个字的上文
所以 m 就是"训练中这段文字前面最少有几个字的上文"。m = 0 是训练起点本身;m = 1 就是上一个实验的奇数起点; m = 32 意味着训练时这段文字前面总有至少 32 个字,测试时却一个都不给。每个 m 都有约 180 个测试片段,结论不靠小样本。
结果(唯一片段,3 个种子平均):
| 离训练起点 m 个字 | 0 | 1 | 2 | 3–4 | 5–8 | 9–16 | 17–32 |
|---|---|---|---|---|---|---|---|
| 8 字片段 | 100% | 94% | 81% | 58% | 25% | 4.7% | 0.2% |
| 32 字片段 | 100% | 99.5% | 98% | 95% | 89% | 71% | 44% |
(配图建议:results/e4/exact_vs_m.png,续写率随 m 增大而下滑。)
离开训练起点 9 个字以上,8 字片段基本就接不上了。 同一位置、同一段文字,模型在训练中明明见过两千多遍(每个字出现在两个窗口里,每个窗口约练了 1,169 次), 只是从来没有"从这附近开始读",就取不出来。背考题成立。
看几个例子(训练起点在第 1,024 字,8 字片段):
| m | 输入 | 原文接下来 | 模型生成 |
|---|---|---|---|
| 0 | 甄,今已出家一二 | 年了,不知可是问他?"那些公人道 | 年了,不知可是问他?"那些公人道 ✓ |
| 1 | ,今已出家一二年 | 了,不知可是问他?"那些公人道: | 了,不及号是问他.凤姐入行些.你 ✗ |
| 8 | 年了,不知可是问 | 他?"那些公人道:“我们也不知什 | 他女学次日的,不及你别.这面陪笑 ✗ |
| 32 | 么真'假', | 因奉太爷之命来问,他既是你女婿, | 便回如你好.不在话下.⏎却说凤姐 ✗ |
5.3 一个小插曲:为什么 m 很大时又变好了
m 在 33–63 之间时,续写率又回升到 13%(8 字)和 90%(32 字)。这不是模型突然学会了,而是片段跨进了下一个训练起点:
训练起点: 1024 1088 |---------------------------------|------ 测试片段(8 字): [1081 … 1088] ↑ 片段里包含了一个训练起点
片段末尾读到的那几个字,恰好是"从训练起点 1088 开始的文字",回到了练过的情形,所以能接上。按片段里是否包含训练起点拆开:
| 8 字片段 | 32 字片段 | |
|---|---|---|
| 片段里含训练起点 | 63% | 91% |
| 片段里不含训练起点 | 7.5% | 62% |
| 不含,且离前一个训练起点 9 字以上 | ≤ 2.5% | — |
这也解释了为什么长片段"更耐远":片段越长,越容易覆盖到一个训练起点。
六、所以,模型到底记住了什么
把四个实验放在一起:
模型背下的是"从某个入口开始往后的文字",而不是"任意一段文字后面接什么"。
这里的"入口",就是训练序列开始的位置。给模型一个片段,它能接下去,当且仅当:
-
片段的开头落在某个入口附近(8 字片段时:离入口 1 个字还有 94%,3–4 个字约 58%,5–8 个字只剩 25%),或者
-
片段里面包含一个入口。
用这个说法重新看所有结果:
| 设置 | 入口在哪里 | 能从哪里续写 |
|---|---|---|
| line | 100 个行首 | 只有行首附近 |
| stream(实验一) | 每一个位置 | 每个位置——因为每个位置都是入口 |
| 偶数起点(5.1) | 每个偶数位置 | 所有位置——奇数位置离入口只差一个字 |
| 64 倍数起点(5.2) | 每 64 个字一个 | 入口后几个字以内,或片段跨过入口 |
line 和 stream 学到的是同一种东西,区别只是入口的密度。stream 看起来"会从任意位置接",只是因为它把每个位置都练成了入口。 模型变大、换新架构、换优化器,都不改变入口在哪里,所以都不改变结果。
"知识存进去了,但换个入口就取不出来"——这句话同时适用于 line 和 stream。
为什么会这样(解释,未直接验证)
回到 2.2 节那个关键点:模型学的是"看到这些上文 → 下一个字是什么"。在训练里,一段处于行中间的文字, 每一次出现时前面都带着从行首开始的完整上文。模型从来没被要求过"只凭这 8 个字往下接"。
对模型来说,"前面有 50 个字 + 这 8 个字"和"只有这 8 个字"是两种不同的输入。训练只教了前一种,后一种它没见过, 也没有学会"忽略前面缺了什么,只看眼前这几个字"。少一两个字,输入和练过的还很像,所以还能接;少得多了,就完全是陌生输入了。
更具体地说,line 很可能是"先认出这是哪一行,再按位置往下背",而认出哪一行靠的正是行首几个字。这符合所有观察到的现象, 但要真正证实,需要去看模型内部(比如用探针检查隐藏层里有没有"这是第几行、第几个字"的信息),这一步我还没做。
七、这和大模型有什么关系(谨慎地说)
这个设置很极端:数据极少、训练到逐字背诵,和真实大模型"数据远多于参数、根本背不下来"的状态差别很大。我不想把结论外推太远,但有几点方向上的呼应:
-
reversal curse:只以"A 的母亲是 B"的形式出现的知识,只能以这种形式取出——和"只能从入口取出"是同一类现象;
-
Allen-Zhu 和 Li 的 Physics of Language Models: Part 3.1:他们用合成的人物传记训练模型,发现如果每个人的信息只以一种固定写法出现, 模型能背出来却很难回答相关问题;对训练数据做多样化改写(换句式、换顺序)后,提取能力明显提升。 这和本文"入口越多越能取出"是一致的;
-
训练数据怎么切本身就是一个变量。例如总是从文档开头训练的数据,模型可能只学会了"从开头往后";
-
在"背熟"的玩具设置里比较架构是看不出差别的,想比较架构得用数据量远大于模型容量的设置。
数据远大于模型容量时,这种"入口依赖"是否还存在、以什么形式存在,是我接下来想验证的问题。
八、常见疑问
问:贪心生成、16 个字全对才算对,是不是太严了? 答:严格是有意的,因为要区分"背下来了"和"像那么回事"。而且 line 在行中间平均连第一个字都对不上(平均连续正确 0.3–0.5 个字), 换任何宽松一点的标准,结论都不变。
问:是不是训练不够? 答:两种方式都训练了约 2,500 遍,loss 都到了下限。line 的 8 字片段续写率从第 500 步到第 4,000 步只从 1–3% 升到 3–4%,而 stream 早在第 1,000 步就超过 96%。line 不是"还没学完",而是学的方向就不对。
问:只用了 100 行,是不是太小? 答:小是有意的,为了让"背下来"确定无疑,从而把问题聚焦在"取出来"上。代价是结论不能直接推到大数据场景(见第七节)。
问:为什么不看歧义片段? 答:歧义片段的"正确答案"不唯一。我单独统计过:stream 在歧义片段上接出来的 99% 以上都是某一处真实原文,只是不一定是出题那一处。
问:结果可靠吗? 答:每个配置 3 个随机种子,标准差通常在 1 个百分点以内;关键对比(95 个百分点 vs 噪声 1 个百分点)远超噪声。 所有运行都从确定的代码版本运行,第一个实验整体重跑后,结果和第一次逐字节一致。
九、几点做实验的体会
-
开跑前把预测和判定标准写下来。 第一次检验"背考题"时,我预测"会很差",结果 99.97%。正因为事先写了, 才没有把结果顺手解释成"果然如此",而是回头追问检验本身是否合理;
-
一个好看的结果,先问它是不是检验得太松。 99.97% 被识破,靠的是一个很便宜的对照:拿只会从行首背的模型,同样去掉一个字测一遍;
-
控制变量要真的只改一处。 每个实验相对上一个只改一件事(切法、架构、起点间隔),结论才能归因;
-
可追溯、可重现。 每次运行从一个确定的 git 提交导出代码,记录数据哈希、环境、命令和随机种子,保存模型权重。 改结论的时候,能精确知道每个数字是哪份代码、哪份数据、哪个模型算出来的。
附:实验细节
| 项 | 取值 |
|---|---|
| 数据 | 《红楼梦》长度 60–200 字的前 100 行,13,983 字,字符级词表 1,646 |
| 模型 | Decoder-only Transformer:RMSNorm + RoPE(只编码相对位置)+ 多头注意力 + SwiGLU,输出层与词嵌入共享权重;4 层 256 维(3.6M 参数)/ 8 层 512 维(25.8M) |
| 训练 | 4,000 步 × 每批 64 个样本;AdamW(学习率 1e-3,权重衰减 0.1),前 100 步预热后余弦衰减;bf16 混合精度;line 约 2,560 遍,stream 约 2,330 遍 |
| 测试 | 片段长 1–32 字,不加上文,贪心生成 16 字,与原文完全一致为对;主要统计只出现一次的片段;实验一每行每 4 个字取一个起点,后两个检验每个字都取 |
| 实验一 | line / stream × 两种大小 × 3 种子,共 12 次训练 |
| 实验二 | 同实验一,换新架构 + Muon + WSD,12 次 |
| 检验一 | stream,训练起点只取偶数位置,25.8M,3 次 |
| 检验二 | stream,训练起点只取 64 的倍数,25.8M,3 次 |
| 硬件 |
