小模型续写红楼梦探索

时间:2026-10-01

让小模型背 100 行《红楼梦》:它到底记住了什么?

一个单张显卡两小时跑完的小实验。结论有点反直觉:模型能把 100 行一字不差地背出来,但从中间截一句给它,它一个字都接不上; 而那个看起来"能从任意位置接着背"的版本,追查到最后,其实也只是把每道考题都提前练过了。 下面按我做实验的顺序,把每一步为什么这么设计、看到了什么、能说明什么、不能说明什么讲清楚。


一、我想回答什么问题

大模型"知道"很多东西,却常常换个问法就答不上来。一个著名的例子是 reversal curse(Berglund 等,2023): 模型在训练里见过"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 个百分点)远超噪声。 所有运行都从确定的代码版本运行,第一个实验整体重跑后,结果和第一次逐字节一致。


九、几点做实验的体会

  1. 开跑前把预测和判定标准写下来。 第一次检验"背考题"时,我预测"会很差",结果 99.97%。正因为事先写了, 才没有把结果顺手解释成"果然如此",而是回头追问检验本身是否合理;

  2. 一个好看的结果,先问它是不是检验得太松。 99.97% 被识破,靠的是一个很便宜的对照:拿只会从行首背的模型,同样去掉一个字测一遍;

  3. 控制变量要真的只改一处。 每个实验相对上一个只改一件事(切法、架构、起点间隔),结论才能归因;

  4. 可追溯、可重现。 每次运行从一个确定的 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 次
硬件 一张 RTX 5070 Ti 16GB;共 30 次训练,约 2 小时
【推荐】 Spark入门实战系列--8.Spark MLlib(下)
【推荐】 图解 Transformer 工作原理
【推荐】 一文了解DeepSeek及应用场景
【推荐】 图像识别中的深度学习
【推荐】 google机器学习教程