大语言模型是什么:从语言模型到 LLM

从 N-gram 到 GPT,语言模型做的永远只有一件事:给「下一个词」算概率。

30″30 秒看懂大语言模型

把语言模型想成手机输入法上面那条联想栏:你打了「我想去打」,它立刻在上面列出「篮球」「球」「工」,还按可能性从高到低排好。它做的事只有一件——猜下一个词,并给每个候选一个概率

老输入法只记得你刚敲的那一两个词,所以经常联想得驴唇不对马嘴;新输入法能看懂整句话的意思,联想就准多了。而 大语言模型(LLM)就是一个读完了半个互联网的超级联想栏——它猜的不只是下一个词,而是一路猜下去,把整段回答「接龙」出来。

你和 ChatGPT 聊天时看到的那种「一个字一个字往外蹦」,就是这条联想栏在疯狂工作:猜一个词、填上去、把新句子重新读一遍、再猜下一个词,如此往复。

图① 30 秒看懂:语言模型就是一条一直在猜下一个词的联想栏
图① 30 秒看懂:语言模型就是一条一直在猜下一个词的联想栏
比喻里的角色对应的技术概念它到底在干什么
联想栏语言模型(Language Model)给定上文,输出下一个词的概率分布,仅此而已
候选词的排序P(w | 上文)每个候选词配一个概率,越像人话的概率越高
只记得前一两个词的老输入法N-gram 语言模型靠数数统计词的共现次数,没见过的组合直接判 0
能看懂意思的新输入法神经网络语言模型把词变成向量,意思相近的词离得近,没见过也能猜
读完半个互联网的联想栏大语言模型(LLM)同样是猜下一个词,但参数与语料大到「涌现」出推理、写代码的能力
给输入法打分的尺子BLEU / ROUGE / PPL三个角度衡量「猜得准不准」:像不像、全不全、纠不纠结
⛔ 整讲只有一条铁律 从 N-gram 到 GPT,语言模型做的永远只有一件事:给「下一个词」算概率。参数从几万涨到几千亿,架构从查表换成 Transformer,这件事一次都没变过。所谓「模型会推理」「模型会写代码」,全都是把这一件事做到极致之后的副产品。

01概念:语言模型与大语言模型

一句话的概率、四个发展阶段,以及「大」到底大在哪

1.1 语言模型的标准定义

通俗地说,语言模型是用来计算一个句子的概率的模型——判断一句话「是不是人话」的概率。

标准一点:对于某个词序列 S = {W1, W2, W3, …, Wn},语言模型就是计算该序列发生的概率 P(S)。如果给定的词序列符合语言习惯,就给出高概率,否则给出低概率。

举个具体的:假设词典 V = {黑马, 程序, 员, 来, 学习},语言模型要能算出任意一串词构成的句子的概率。最笨的办法是数数——数据集里共有 N 个句子,某个句子出现了 n 次,就说 P(S) = n/N

这个模型的预测能力几乎为 0。因为只要一个句子没在数据集里出现过,概率就是 0,而世界上绝大多数合理的句子你都没见过。显然不合理。

用链式法则把「整句概率」拆成「逐词概率」 根据概率论的链式法则:
P(W1,W2,…,Wn) = P(W1) · P(W2|W1) · P(W3|W1,W2) · … · P(Wn|W1,…,Wn-1)
所以只要能算出 P(Wn | W1,…,Wn-1),就能得到整句的概率。这也引出了语言模型的另一种定义:能够计算 P(W1,W2,…,Wn) 的模型就是语言模型
换个角度看,这个式子还等价于:给定一个词组或一句话,预测接下来的那个词。这正是 30 秒看懂里那条联想栏干的事。

1.2 语言模型技术的四个阶段

这四个阶段不是「新的取代旧的」,而是每一代都在解决上一代的具体缺陷。带着「它治了什么病」去看,比背名字有用得多:

阶段代表核心思路它治好了什么 / 又留下什么病
① 基于规则和统计N-gram人工设计特征,对固定长度的文本窗口做统计简单能用;但参数空间爆炸、数据稀疏,没见过就是 0
② 神经语言模型NNLM把词映射成低维稠密向量,用神经网络算条件概率治好了数据稀疏;但对长序列建模能力有限
③ 预训练语言模型BERT、GPT、T5大规模语料上预训练,再到下游任务微调泛化能力和语义表示都强了;但算力需求大、可解释性差
④ 大语言模型GPT-3 及其后参数与语料继续指数级放大,走向对话式、生成式、多模态能像人一样沟通;但算力要求高、可能生成有害或有偏见的内容

1.3 「大」语言模型,大在哪

大语言模型(Large Language Model,缩写 LLM)是一种旨在理解和生成人类语言的人工智能模型,可以处理文本分类、问答、翻译、对话等多种自然语言任务。

通常 LLM 指包含数千亿(或更多)参数的语言模型,这些参数是在大量文本数据上训练得到的。工程上还有一条更宽松的经验线:参数量超过 10B 的模型即可称为大语言模型

2020 年 OpenAI 发布参数量高达 1750 亿的 GPT-3,首次展示了大语言模型的性能:相较于 BERT、GPT-2,GPT-3 可以在不调整权重的情况下,仅依据用户给出的任务示例完成具体任务。这件事的冲击力在于——以前想让模型做新任务必须重新训练,现在只要把例子写进提示词里。

1.4 三个容易混作一谈的概念

「语言模型」「预训练模型」「大语言模型」在日常交流里经常被混用,但它们回答的不是同一个问题:

概念它描述的是判定标准
语言模型一类任务目标:给词序列算概率只要能算 P(下一个词 | 上文),哪怕是查表统计,就算
预训练模型一种训练范式:先在通用语料上学,再迁移到下游任务看它是否经历了「预训练 → 微调」两段式
大语言模型一个规模量级:参数与语料大到出现新能力看参数量级(常用 10B 以上)与是否具备指令遵循、上下文学习能力

所以一个模型完全可以同时是这三者:GPT-3 是语言模型(算下一个词的概率),是预训练模型(先预训练后适配),也是大语言模型(1750 亿参数)。而一个 bigram 统计模型只是语言模型,两项都不沾。

1.5 一个可用的 LLM 是怎么炼出来的

说「LLM 就是猜下一个词」没错,但光猜下一个词的模型并不能直接拿来聊天——它只会接写。你问它「中国的首都是哪里?」,纯预训练模型很可能接一句「美国的首都是哪里?」,因为语料里问题常常成组出现。从「会接写」到「能干活」,中间隔着三个阶段:

阶段学什么数据形态产出的能力
① 预训练
Pre-training
猜下一个词海量无标注文本(网页、书、代码)语言能力与世界知识。成本的 90% 以上花在这里
② 监督微调
SFT
按指令回答人工写的「指令 → 优质回答」对,量级在万到百万听得懂人话,不再只会接写
③ 偏好对齐
RLHF / DPO
哪个回答更受欢迎人工对多个候选回答排序回答更有用、更安全、语气更得体
为什么要知道这三段 因为它直接决定了你在工程上能动哪一层:
• 想让模型多知道一个领域的事实——那是预训练层的事,个人和中小团队基本动不起,实际该走 RAG;
• 想让模型按固定格式、固定口吻干一件事——那是 SFT 层的事,几千条高质量样本就能看到效果;
• 只是想调整语气或加几条规则——连微调都不用,写进 system 提示词就行。
分不清这三层,就会拿微调去解决本该用提示词解决的问题,花十倍的钱办一半的事。

涌现能力:为什么非要把模型做那么大

如果只是猜下一个词,为什么不能用小模型?因为有一类能力在参数量跨过某个阈值前几乎为零,跨过后突然出现,这被称为涌现能力(emergent abilities)。最典型的两个:

能力表现对开发者意味着什么
上下文学习
In-context Learning
不改权重,只在提示词里给几个例子,模型就能仓照办理新任务「换个任务就要重新训练一次」的时代结束了,提示词成了新的编程接口
思维链
Chain-of-Thought
让它「一步一步想」,多步推理题的正确率显著上升提示词的写法本身就能换来准确率,不花一分钱
⚠️ 「涌现」不是神话,别过度解读 学界对它有真实争议:有研究指出,所谓「突然出现」可能部分来自评测指标选得太硬(比如只看完全匹配的对错),换成连续指标后曲线就平滑了。
实用的态度是:把它当作「大模型与小模型在某些任务上差距极大」的经验观察,而不是某种神秘的质变。选型时还是得实测,不能靠传说。

02原理:概率是怎么算出来的

从数数到向量,再到三把评分尺子

2.1 N-gram:用马尔可夫假设把链式法则截断

链式法则虽然正确,但 P(Wn | W1,…,Wn-1) 这个条件概率没法估——上文越长,这种组合在语料里出现的次数越接近 0。于是引入马尔可夫假设

马尔可夫假设 随意一个词出现的概率,只与它前面出现的有限的一个或几个词有关

按「往前看几个词」分类,就有了 N 元模型:

名称往前看含义
unigram(一元)0 个词认为每个词的出现与周围词相互独立
bigram(二元)1 个词一个词的出现仅依赖它前面的那一个词
trigram(三元)2 个词一个词的出现仅依赖它前面的两个词

一般来说,N 元模型假设当前词的出现概率只与它前面的 N-1 个词有关。实践中用得最多的是 bigram 和 trigram。

bigram 的参数就两个计数

以 bigram 为例,要计算 P(Wi | Wi-1),只需要两个计数,然后做一次除法:

① 数共现C(Wi-1, Wi):两个词挨着出现了几次
② 数前词C(Wi-1):前面那个词一共出现了几次
③ 相除P(Wi|Wi-1) = C(Wi-1,Wi) / C(Wi-1)
图② bigram 的两个计数与一次除法
图② bigram 的两个计数与一次除法

有了这张概率表,两件事立刻能做:

能做的事怎么做例子
算一句话的概率把每一步的条件概率连乘起来比较 P(我想去打篮球)P(我想去学习),谁大谁更像人话
预测下一个词固定上文,把所有候选词的概率排序取最大「我想去打【?】」→ 篮球的概率远高于晚饭

N-gram 的优缺点

简单、直观、可解释

采用极大似然估计,参数易训练;完整包含了前 n-1 个词的信息;每个概率都能手算验证,出了问题能一眼看出是哪个计数不对。

四个硬伤

只能建模到前 n-1 个词;随着 n 增大参数空间呈指数增长;数据稀疏难免出现 OOV(未登录词)问题;泛化能力差——换个说法就不认识了。

最致命的是数据稀疏:语料里没出现过的词对,概率直接是 0,连乘之后整句概率也是 0。而「没出现过」不等于「不合理」,这正是下一代模型要治的病。

2.2 平滑:在换模型之前,先给零概率打个补丁

数据稀疏有两条治法。换模型(下一节的 NNLM)是根治,但在 N-gram 时代人们先用了一条更便宜的:平滑(smoothing)——既然 0 概率是因为「没数到」,那就让每个组合都先白送一次计数

最简单的版本叫加一平滑,也叫拉普拉斯平滑(Laplace smoothing):

加一平滑的公式 P(Wi | Wi-1) = ( C(Wi-1, Wi) + 1 ) / ( C(Wi-1) + |V| )
分子加 1,分母加的是整个词表的大小 |V|,不是加 1。

分母为什么必须加 |V|?因为每个候选词的分子都白送了 1,一共送出去 |V| 个计数,分母必须把这些补回来,所有候选词的概率之和才仍然等于 1。少加一点或多加一点,算出来的就不是概率分布,后面连乘、算 PPL 全都会错。

代价:高频搭配被稀释

平滑不是白来的。它把概率从「见过很多次」的搭配身上匀了一部分,分给「一次都没见过」的组合。用 2.1 节那份五句话语料实测(代码见 3.2 节),词表 |V| = 10

查询无平滑加一平滑发生了什么
P(想|我)0.66670.2308高频搭配,置信度掉了 65.4%
P(想|他)0.50000.1667同上,掉了 66.7%
P(编程|我)0.00000.0769零概率被救活了
P(学|爱)0.00000.0833同上

两列一对照,平滑的本质就清楚了:它用「削峰」换「填谷」。语料越小、词表越大,|V| 在分母里的占比就越大,削峰削得越狠——五句话的语料里 |V| 已经足以把 0.67 压到 0.23。

⚠️ 加一平滑在真实语料上基本不能用 真实词表动辄几万几十万,分母被 |V| 一加,所有真实统计量都被淹没,模型等于退化成了均匀分布。
工程上用的是更精细的方案:Good-Turing(用出现 1 次的事件估计没出现的事件)、插值法(把 trigram、bigram、unigram 按权重混合)、以及效果最好的 Kneser-Ney(还额外考虑一个词能接在多少种不同上文后面)。
但它们的思路和加一平滑是同一条:不让任何合理组合的概率变成 0

理解这一点,也就理解了 N-gram 为什么最终被淘汰:平滑只是不让概率为 0,它没法让模型知道「排球」和「篮球」是相近的东西。要做到那一步,得让词本身带上语义——这就是下一节。

2.3 神经网络语言模型:把词变成向量

神经网络语言模型(NNLM)的输入是 w(t-n+1), …, w(t-2), w(t-1),也就是前 n-1 个词,任务仍然是预测下一个词 w(t)C(w) 表示单词 w 对应的词向量。整个网络只有三层:

图③ 神经网络语言模型的三层结构
图③ 神经网络语言模型的三层结构
做什么产出
第一层(输入)C(wt-n+1),…,C(wt-1) 这 n-1 个词向量首尾拼接起来一个 (n-1)×m 大小的向量 x
第二层(隐藏)一个全连接层,输出 Hx + d,再过 tanh 激活函数上下文的非线性表示
第三层(输出)全连接层输出 V 个节点(V 为词汇总数),每个节点是未归一化的 logits,再过 softmax整个词表上的概率分布,取最大即预测结果
✅ 泛化能力从哪来 关键在词向量:每个词被表示成一个低维连续向量,语义或语法相似的词,向量在空间中的距离更近。于是模型见过「我想去打篮球」,就能对没见过的「我想去打排球」给出合理概率——因为「排球」和「篮球」的向量挨得很近。
这一步把 N-gram「没见过就是 0」的死结解开了,也是后来所有大模型 embedding 层的雏形。

代价是:对长序列的建模能力仍然有限,而且深层网络可能出现梯度消失。真正把「长距离依赖」解决掉的是后来的 Transformer 与 attention。

2.4 预训练范式:先读书,再上岗

基于 Transformer 的预训练模型(GPT、BERT、T5 等)能从大规模通用文本中学到语言表示,再把这些知识用到下游任务上。使用方式固定为两段:

预训练 Pre-training在大规模数据集上事先训练,学到通用的特征表示和知识
微调 Fine-tuning在具体下游任务上迁移学习,获取更好的泛化效果

优点是泛化能力更强、语义表示更丰富、能有效防止过拟合;缺点是计算资源需求大、可解释性差

再往后,随着预训练模型参数的指数级提升,语言模型性能也随之上升,直到 GPT-3 这一量级出现了不改权重、只给示例就能完成新任务的能力——从这里开始,「微调」不再是使用模型的唯一方式,提示词成了新的接口。

2.5 Token:模型真正读到的不是字,也不是词

前面所有讨论都假装模型的输入单位是「词」。到了 LLM 这里,这个假设要改了——模型读到的单位叫 token,它既不是汉字,也不是英文单词。

原因是两头都走不通:按单词切,词表会爆炸,而且遇到没见过的词(OOV)只能整个丢掉;按字符切,词表是小了,但一句话被拆成几百个单位,模型要跨很远才能拼出一个意思。折中方案是 subword(子词)——常用词整个保留,生僻词拆成有意义的片段。

BPE:数频次,合并最高频的那一对

最主流的 subword 算法是 BPE(Byte Pair Encoding,字节对编码)。它的训练过程只有三步,循环执行:

① 拆到字符每个词先切成单个字符,词尾加边界标记
② 数相邻对统计所有相邻符号对的出现频次(按词频加权)
③ 合并最高频把频次最高的那一对合并成一个新符号,回到 ②

low×5, lower×2, newest×6, widest×3 这份小语料实跑(代码见 3.3 节),前几步合并是这样的:

合并的对频次产生的新 token
1e + s9es——newest 和 widest 共有
2es + t9est,英语最高频后缀之一被学出来了
3est + </w>9est</w>,带词尾的完整后缀
4~5l+o,lo+w7low
8new + est</w>6newest</w>,高频词被整个收进词表

注意第 2 步:没有人告诉它「est 是英语的最高级后缀」,它是纯靠数频次数出来的。这正是 BPE 好用的地方——不需要任何语言学知识,换成中文、代码、DNA 序列,同一套算法照跑。

✅ 学到的规则能切没见过的词 用上面这份只见过四个词的规则表去切新词:
lowest['low', 'est</w>']2 个 token——两个片段都是学过的,拼起来就行
slow['s', 'low</w>']2 个 token——训练时压根没见过 slow,照样切得出来
newer['new', 'e', 'r', '</w>']4 个 token——后缀 er 没学到,只能退回字符
这就是 subword 消灭 OOV 的方式:实在不认识,就一路退到单个字符,但绝不会报错

三条直接影响你花钱的推论

现象原因工程后果
中文比英文更费 token训练语料以英文为主,英文常用词多被合并成 1 个 token;中文常见的是 1~2 个汉字一个 token同样字数的中文提示词,token 数通常明显高于英文,账单也更高
生僻词、长数字串特别费没进词表,被拆成很多碎片批量处理 ID、哈希、序列号时成本会意外暴涨
换个模型,token 数就变了每家的 tokenizer 和词表都不一样跨模型比较价格必须用各自的 tokenizer 实测,不能拿字数折算
⛔ 「1 个汉字 = 1 个 token」是错的 这个约等式在网上流传很广,但它只是某些模型上的粗略经验值,不能用来算钱,也不能用来判断会不会超上下文。
要准确数,只有两个办法:用该模型官方的 tokenizer 库离线算,或者发一次请求、读响应里的 usage 字段(见「大模型 API 开发入门」一讲)。

2.6 三把尺子:BLEU、ROUGE、PPL

模型生成了一段话,怎么判断好坏?除了 Accuracy(预测正确的样本占比)、Precision(被判为正类中真正为正类的比例)、Recall(所有正类中被正确识别的比例)这些分类指标,生成任务还有三把专用尺子:

图④ BLEU、ROUGE、PPL 三把尺子量的是什么
图④ BLEU、ROUGE、PPL 三把尺子量的是什么
指标看什么取值典型场景
BLEU准确率[0, 1],越接近 1 越好机器翻译:我生成的词,有多少在参考答案里
ROUGE召回率[0, 1],越接近 1 越好自动摘要、问答生成:参考答案里的词,我覆盖了多少
PPL(困惑度)模型的纠结程度[0, +∞),越小越好语言模型本身:面对真实句子,模型有多意外

BLEU:按 n-gram 分阶匹配

BLEU 根据 n-gram 划分成多种评价指标,实践中通常取 N=1~4 再加权平均。计算方式是:候选句与参考句的 n-gram 匹配个数 ÷ 候选句的 n-gram 总数

拿一组具体的句子走一遍:

候选句 candidate 的 n-gram匹配情况匹配度
1-gram{it, is, a, nice, day, today}{today, is, a, nice, day} 命中5/6
2-gram{it is, is a, a nice, nice day, day today}{is a, a nice, nice day} 命中3/5
3-gram{it is a, is a nice, a nice day, nice day today}{is a nice, a nice day} 命中2/4
4-gram{it is a nice, is a nice day, a nice day today}{is a nice day} 命中1/3

其中 candidate 是 It is a nice day today,reference 是 today is a nice day

⚠️ 不加限制的话,BLEU 可以被刷分 极端例子:candidate 是 the the the the,reference 是 The cat is standing on the ground。按朴素的 1-gram 匹配,四个 the 全都能在参考句里找到,匹配度 = 4/4 = 1,显然荒谬。
解法是截断(clipping):先统计一个词在参考句中出现的最大次数,再用它来修剪该词在候选句中的计数。上例中 the 在参考句里出现 2 次,所以分子最多记 2,匹配度降为 2/4 = 0.5
工程实现里通常还会叠一个长度惩罚(brevity penalty):候选句比参考句短就打折,防止模型只吐出最有把握的几个词骗高分。

ROUGE:把分母换成参考答案

ROUGE 与 BLEU 非常类似,都在衡量生成结果和标准结果的匹配程度,区别在于 ROUGE 基于召回率,BLEU 更看重准确率。ROUGE 分为 ROUGE-N、ROUGE-L、ROUGE-W、ROUGE-S 几种,其中 ROUGE-N 就是把两边按 N-gram 拆开后计算召回率。

还是上面那对句子,用 ROUGE-1 匹配:参考句的 5 个词 {today, is, a, nice, day} 全部被候选句覆盖,所以匹配度 5/5 = 1——说明生成内容完全覆盖了参考文本中的所有单词。

对比着看就很清楚:同一对句子,BLEU 的 1-gram 是 5/6,ROUGE-1 是 5/5。分子相同,分母一个是候选句、一个是参考句。这就是「准确率」与「召回率」的全部差别。

ROUGE-L 则不要求词连续,只要求顺序一致(最长公共子序列),比 ROUGE-N 宽松,更适合评价句式灵活的摘要。

PPL:模型有多「意外」

PPL(perplexity)用来度量一个概率分布或概率模型预测样本的好坏程度。基本思想很朴素:给测试集的句子赋予较高概率值的语言模型更好。测试集里的句子都是正常的人话,训练好的模型在它们上面给出的概率越高越好。

公式上,PPL 是平均负对数概率的指数:先把整句概率取以 2 为底的对数、除以词数取平均、取负,再作为 2 的指数。所以——

⛔ PPL 的方向别记反 句子概率越大 → 语言模型越好 → 困惑度越小。 它是三把尺子里唯一「越小越好」的,也是唯一不需要人工参考答案的:只要有一批真实句子就能算。
直观理解:PPL = 8 意味着模型每预测一个词时,平均在 8 个候选之间摇摆不定。PPL 越接近 1,说明它几乎每次都胸有成竹。

03最小代码:手写一个 bigram 语言模型

不装任何依赖,用计数和除法把「猜下一个词」跑出来

3.1 手写 bigram:两个计数一次除法

理解语言模型最快的路径,是自己实现一个最小的。下面这份代码只用标准库,语料只有 5 句话——小到每一个概率都能用手算复核。整个模型就三个函数:

train()数两个计数:C(前词,当前词) 与 C(前词)
prob()相除得到 P(当前词 | 前词)
sentence_prob()逐词连乘,得到整句概率
bigram_lm.py —— 五句话语料,手写二元语言模型可直接运行
"""bigram 语言模型:用计数和除法算出「哪句话更像人话」。

只用标准库,直接 python3 bigram_lm.py 就能跑。
语料很小,目的是让每一个数字都能用手算复核。
"""
from collections import defaultdict

# 语料:每个句子前后补上 <s> 和 </s>,让「句首第一个词」也有上文可依
CORPUS = [
    "我 想 去 打 篮球",
    "我 想 去 学习",
    "我 喜欢 打 篮球",
    "黑马 程序员 想 去 学习",
    "黑马 程序员 喜欢 学习",
]

BOS, EOS = "<s>", "</s>"


def tokenize(sentence):
    """把一行中文按空格切成词,并补上句首句尾标记。"""
    return [BOS] + sentence.split() + [EOS]


def train(corpus):
    """统计 C(w_{i-1}, w_i) 与 C(w_{i-1}),这就是 bigram 的全部参数。"""
    bigram = defaultdict(int)   # C(前一个词, 当前词)
    unigram = defaultdict(int)  # C(前一个词)
    for sentence in corpus:
        words = tokenize(sentence)
        for prev, cur in zip(words, words[1:]):
            bigram[(prev, cur)] += 1
            unigram[prev] += 1
    return bigram, unigram


def prob(bigram, unigram, prev, cur):
    """P(cur | prev) = C(prev, cur) / C(prev),即最大似然估计。"""
    if unigram[prev] == 0:
        return 0.0
    return bigram[(prev, cur)] / unigram[prev]


def sentence_prob(bigram, unigram, sentence):
    """链式法则 + 马尔可夫假设:整句概率 = 每一步条件概率连乘。"""
    words = tokenize(sentence)
    total = 1.0
    for prev, cur in zip(words, words[1:]):
        p = prob(bigram, unigram, prev, cur)
        print("    P(%-6s| %-6s) = %.4f" % (cur, prev, p))
        total *= p
    return total


def predict_next(bigram, unigram, prev):
    """给定上文一个词,把所有候选按概率从高到低排出来。"""
    candidates = [(cur, prob(bigram, unigram, prev, cur))
                  for (p, cur) in bigram if p == prev]
    return sorted(candidates, key=lambda x: -x[1])


if __name__ == "__main__":
    bigram, unigram = train(CORPUS)

    print("一、单步条件概率")
    print("  P(想 | 我)   =", round(prob(bigram, unigram, "我", "想"), 4))
    print("  P(喜欢 | 我) =", round(prob(bigram, unigram, "我", "喜欢"), 4))

    print("\n二、整句概率(越大越像人话)")
    for s in ["我 想 去 打 篮球", "我 想 去 学习"]:
        print("  句子:", s)
        p = sentence_prob(bigram, unigram, s)
        print("  => P(S) = %.6f\n" % p)

    print("三、预测下一个词:上文是「打」")
    for word, p in predict_next(bigram, unigram, "打"):
        print("  %-8s %.4f" % (word, p))

    print("\n四、数据稀疏:没见过的组合概率直接归零")
    print("  P(游泳 | 打) =", prob(bigram, unigram, "打", "游泳"))

运行输出

直接 python3 bigram_lm.py,得到的是真实输出,不是示意:

bigram_lm.py 的实际运行结果
一、单步条件概率
  P(想 | 我)   = 0.6667
  P(喜欢 | 我) = 0.3333

二、整句概率(越大越像人话)
  句子: 我 想 去 打 篮球
    P(我     | <s>   ) = 0.6000
    P(想     | 我     ) = 0.6667
    P(去     | 想     ) = 1.0000
    P(打     | 去     ) = 0.3333
    P(篮球    | 打     ) = 1.0000
    P(</s>  | 篮球    ) = 1.0000
  => P(S) = 0.133333

  句子: 我 想 去 学习
    P(我     | <s>   ) = 0.6000
    P(想     | 我     ) = 0.6667
    P(去     | 想     ) = 1.0000
    P(学习    | 去     ) = 0.6667
    P(</s>  | 学习    ) = 1.0000
  => P(S) = 0.266667

三、预测下一个词:上文是「打」
  篮球       1.0000

四、数据稀疏:没见过的组合概率直接归零
  P(游泳 | 打) = 0.0
三个值得停一下的细节为什么要补 <s></s>:句首第一个词也需要「上文」,否则 P(我) 无从算起;句尾标记则让模型学会「话该结束了」。
P(想|我)=0.6667 怎么来的:语料里「我」出现 3 次,其中「我 想」出现 2 次,2/3 = 0.6667。可以逐条数一遍语料验证。
P(游泳|打)=0.0:语料里「打」后面只跟过「篮球」,所以「打游泳」概率归零。这就是数据稀疏——真实工程里必须靠平滑(smoothing)给未见组合分配一点概率,否则整句连乘全是 0。
⚠️ 这个玩具模型暴露的正是大模型要解决的问题只记得前一个词,所以「我想去打」后面接什么,它其实没看到「想去」这层意思;它没有词向量,「篮球」和「排球」在它眼里是两个毫无关系的符号。
后面所有的技术——词向量、attention、更大的参数量——都是在治这两个病。把这段代码跑明白,再看 Transformer 就有了参照物。

3.2 加一平滑:把上面那个 0 救回来

上一节最后那个 P(游泳|打)=0.0 不是 bug,是 N-gram 的先天缺陷。用 2.2 节的加一平滑改一版,同一份语料、同一个查询,两种算法并排打印,差别一眼就看到了:

smoothing.py —— 加一平滑与无平滑的并排对比可直接运行
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""加一平滑(Laplace smoothing)——治 bigram 的「没见过就是 0」。

只用标准库,直接 python3 smoothing.py 就能跑。
对照组是 bigram_lm.py 的无平滑版本,同一份语料、同一个查询,
把「概率 0」和「概率很小但不是 0」的差别打印出来。
"""
from collections import defaultdict

# 和 bigram_lm.py 完全相同的五句话语料,便于逐行对照
CORPUS = [
    "我 想 学 人工 智能",
    "我 想 学 编程",
    "我 爱 人工 智能",
    "他 想 学 编程",
    "他 爱 编程",
]


def build_counts(corpus):
    """统计 bigram 共现次数与前词次数,并收集词表。"""
    bigram = defaultdict(int)
    unigram = defaultdict(int)
    vocab = set()
    for line in corpus:
        words = ["<s>"] + line.split() + ["</s>"]
        vocab.update(words)
        for i in range(len(words) - 1):
            bigram[(words[i], words[i + 1])] += 1
            unigram[words[i]] += 1
    return bigram, unigram, vocab


def p_mle(bigram, unigram, prev, word):
    """极大似然估计:C(prev, word) / C(prev),没见过直接是 0。"""
    if unigram[prev] == 0:
        return 0.0
    return bigram[(prev, word)] / unigram[prev]


def p_laplace(bigram, unigram, vocab, prev, word):
    """加一平滑:分子 +1,分母 + |V|。

    分母必须加整个词表大小,不是加 1——
    否则所有候选词的概率加起来不等于 1,就不是概率分布了。
    """
    v = len(vocab)
    return (bigram[(prev, word)] + 1) / (unigram[prev] + v)


def main():
    bigram, unigram, vocab = build_counts(CORPUS)
    v = len(vocab)
    print("语料 %d 句,词表 |V| = %d" % (len(CORPUS), v))
    print("词表:", " ".join(sorted(vocab)))
    print()

    # 三个查询:见过的、没见过的、前词也没见过的
    queries = [
        ("我", "想", "语料里出现过 2 次"),
        ("我", "编程", "两个词都在词表里,但从未相邻"),
        ("爱", "学", "同上,典型的数据稀疏"),
    ]
    print("%-14s %-12s %-12s %s" % ("查询", "无平滑", "加一平滑", "说明"))
    print("-" * 66)
    for prev, word, note in queries:
        a = p_mle(bigram, unigram, prev, word)
        b = p_laplace(bigram, unigram, vocab, prev, word)
        print("P(%s|%s)%s %-12.4f %-12.4f %s"
              % (word, prev, " " * (6 - len(word) - len(prev)), a, b, note))
    print()

    # 验证平滑后仍是合法概率分布:对固定前词,所有候选词概率之和应为 1
    prev = "我"
    total_mle = sum(p_mle(bigram, unigram, prev, w) for w in vocab)
    total_lap = sum(p_laplace(bigram, unigram, vocab, prev, w) for w in vocab)
    print("对前词「%s」,全词表概率求和:" % prev)
    print("  无平滑   = %.6f" % total_mle)
    print("  加一平滑 = %.6f" % total_lap)
    print()

    # 代价:把概率从高频词身上匀给了没见过的词
    print("平滑的代价——高频搭配被稀释:")
    for prev, word in [("我", "想"), ("他", "想")]:
        a = p_mle(bigram, unigram, prev, word)
        b = p_laplace(bigram, unigram, vocab, prev, word)
        drop = (a - b) / a * 100 if a else 0.0
        print("  P(%s|%s): %.4f -> %.4f  下降 %.1f%%" % (word, prev, a, b, drop))
    print()
    print("结论:平滑消灭了 0 概率,代价是牺牲了高频搭配的置信度。")
    print("词表越大,|V| 越大,稀释越狠——所以真实场景用的是 Kneser-Ney 等更精细的方案。")


if __name__ == "__main__":
    main()

运行输出

smoothing.py 的实际运行结果
语料 5 句,词表 |V| = 10
词表: </s> <s> 人工 他 学 想 我 智能 爱 编程

查询             无平滑          加一平滑         说明
------------------------------------------------------------------
P(想|我)     0.6667       0.2308       语料里出现过 2 次
P(编程|我)    0.0000       0.0769       两个词都在词表里,但从未相邻
P(学|爱)     0.0000       0.0833       同上,典型的数据稀疏

对前词「我」,全词表概率求和:
  无平滑   = 1.000000
  加一平滑 = 1.000000

平滑的代价——高频搭配被稀释:
  P(想|我): 0.6667 -> 0.2308  下降 65.4%
  P(想|他): 0.5000 -> 0.1667  下降 66.7%

结论:平滑消灭了 0 概率,代价是牺牲了高频搭配的置信度。
词表越大,|V| 越大,稀释越狠——所以真实场景用的是 Kneser-Ney 等更精细的方案。
✅ 输出里最值得看的两行全词表概率求和两者都是 1.000000——这是平滑实现对不对的唯一硬标准。分母忘了加 |V|,这一行就不是 1,后面所有算出来的东西全废。写完平滑先跑这一行。
P(想|我) 从 0.6667 掉到 0.2308,下降 65.4%——代价大得惊人。因为语料只有 5 句,C(我)=3|V|=10,分母一下子从 3 变成 13。

所以平滑的适用边界很清楚:语料越少、词表越大,加一平滑的副作用越大。它适合用来理解原理,真实项目请直接上 Kneser-Ney,或者干脆换神经网络。

3.3 BPE 分词器:看清楚一个 token 到底是什么

2.5 节说 token 是“数频次合并出来的片段”。口说无凭,把 BPE 训练过程整个写出来——核心就是 count_pairs()merge_pair() 两个函数:

tokenizer_demo.py —— 极简 BPE,含训练与编码两段可直接运行
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""极简 BPE 分词器——看清楚「一个 token 到底是什么」。

只用标准库,直接 python3 tokenizer_demo.py 就能跑。
真实的 tokenizer(tiktoken、SentencePiece)在工程细节上复杂得多,
但合并规则的核心就是下面这十几行:数相邻对、合并最高频的那一对、重复。
"""
from collections import Counter

# 训练语料:故意让 low/lower/newest/widest 共享后缀,看 BPE 怎么把后缀学出来
CORPUS = {
    "low": 5,
    "lower": 2,
    "newest": 6,
    "widest": 3,
}

NUM_MERGES = 10


def init_vocab(corpus):
    """把每个词切成字符序列,词尾加 </w> 标记,避免跨词合并。"""
    vocab = {}
    for word, freq in corpus.items():
        vocab[" ".join(list(word)) + " </w>"] = freq
    return vocab


def count_pairs(vocab):
    """统计所有相邻符号对的出现频次(按词频加权)。"""
    pairs = Counter()
    for word, freq in vocab.items():
        symbols = word.split()
        for i in range(len(symbols) - 1):
            pairs[(symbols[i], symbols[i + 1])] += freq
    return pairs


def merge_pair(pair, vocab):
    """把选中的符号对合并成一个新符号。"""
    merged = "".join(pair)
    old = " ".join(pair)
    out = {}
    for word, freq in vocab.items():
        # 用空格做边界,确保只替换完整的符号对,不会切断更长的符号
        out[(" " + word + " ").replace(" " + old + " ", " " + merged + " ").strip()] = freq
    return out


def train(corpus, num_merges):
    """跑 BPE 训练,返回合并规则表和最终词表。"""
    vocab = init_vocab(corpus)
    merges = []
    print("初始状态(每个字符都是一个 token):")
    for word, freq in vocab.items():
        print("   %-24s x%d" % (word, freq))
    print()
    for step in range(1, num_merges + 1):
        pairs = count_pairs(vocab)
        if not pairs:
            print("没有可合并的对了,提前停止。")
            break
        best, freq = pairs.most_common(1)[0]
        if freq < 2:
            print("第 %d 步:最高频对只出现 %d 次,停止合并。" % (step, freq))
            break
        vocab = merge_pair(best, vocab)
        merges.append(best)
        print("第 %2d 步:合并 %-12s 频次 %d  ->  新 token 「%s」"
              % (step, str(best), freq, "".join(best)))
    return merges, vocab


def encode(word, merges):
    """用学到的合并规则给新词分词,顺序必须和训练时一致。"""
    symbols = list(word) + ["</w>"]
    for pair in merges:
        i = 0
        while i < len(symbols) - 1:
            if (symbols[i], symbols[i + 1]) == pair:
                symbols[i:i + 2] = ["".join(pair)]
            else:
                i += 1
    return symbols


def main():
    merges, vocab = train(CORPUS, NUM_MERGES)
    print()
    print("最终切分结果:")
    for word, freq in vocab.items():
        print("   %-24s x%d" % (word, freq))
    print()

    print("拿学到的规则去切新词(训练时没见过的):")
    for word in ["lowest", "newer", "wide", "slow"]:
        toks = encode(word, merges)
        print("   %-8s -> %-30s%d 个 token" % (word, str(toks), len(toks)))
    print()

    print("同一句话,不同语言的 token 数差别:")
    samples = [
        ("英文", "hello world"),
        ("中文", "你好世界"),
    ]
    for lang, text in samples:
        # 这里只按最朴素的规则估算:英文按空格切词再按字符兜底,中文按字
        rough = len(text.split()) if lang == "英文" else len(text)
        print("   %s%s」字符数 %d,朴素切分约 %d 个单位"
              % (lang, text, len(text), rough))
    print()
    print("要记住的三件事:")
    print("  1. token 不是词也不是字,是 BPE 按频次合并出来的片段;")
    print("  2. 高频片段会变成一个 token,生僻词会被拆成好几个;")
    print("  3. 计费和上下文长度都按 token 算,所以换个 tokenizer,同一句话的成本就变了。")


if __name__ == "__main__":
    main()

运行输出

tokenizer_demo.py 的实际运行结果
初始状态(每个字符都是一个 token):
   l o w </w>               x5
   l o w e r </w>           x2
   n e w e s t </w>         x6
   w i d e s t </w>         x3

第  1 步:合并 ('e', 's')   频次 9  ->  新 token 「es」
第  2 步:合并 ('es', 't')  频次 9  ->  新 token 「est」
第  3 步:合并 ('est', '</w>') 频次 9  ->  新 token 「est</w>」
第  4 步:合并 ('l', 'o')   频次 7  ->  新 token 「lo」
第  5 步:合并 ('lo', 'w')  频次 7  ->  新 token 「low」
第  6 步:合并 ('n', 'e')   频次 6  ->  新 token 「ne」
第  7 步:合并 ('ne', 'w')  频次 6  ->  新 token 「new」
第  8 步:合并 ('new', 'est</w>') 频次 6  ->  新 token 「newest</w>」
第  9 步:合并 ('low', '</w>') 频次 5  ->  新 token 「low</w>」
第 10 步:合并 ('w', 'i')   频次 3  ->  新 token 「wi」

最终切分结果:
   low</w>                  x5
   low e r </w>             x2
   newest</w>               x6
   wi d est</w>             x3

拿学到的规则去切新词(训练时没见过的):
   lowest   -> ['low', 'est</w>']             共 2 个 token
   newer    -> ['new', 'e', 'r', '</w>']      共 4 个 token
   wide     -> ['wi', 'd', 'e', '</w>']       共 4 个 token
   slow     -> ['s', 'low</w>']               共 2 个 token

同一句话,不同语言的 token 数差别:
   英文「hello world」字符数 11,朴素切分约 2 个单位
   中文「你好世界」字符数 4,朴素切分约 4 个单位

要记住的三件事:
  1. token 不是词也不是字,是 BPE 按频次合并出来的片段;
  2. 高频片段会变成一个 token,生僻词会被拆成好几个;
  3. 计费和上下文长度都按 token 算,所以换个 tokenizer,同一句话的成本就变了。
三个实现细节,漏一个就跑不对词尾要加 </w>:不加边界标记,合并会跨过词边界,把两个词的尾头粘成一个 token。
频次要按词频加权newest 出现 6 次,它里面的 e+s 就要计 6 次,不是 1 次。
编码时必须按训练时的顺序重放合并规则:顺序一变,切出来的 token 就不一样。这也是为什么 tokenizer 文件必须跟模型权重配套分发,换一个就对不上。
⚠️ 这是教学版,不是生产版 真实的 tokenizer(tiktokenSentencePiece)还要处理字节级回退、特殊 token、正则预切分、多语言归一化,并且用 Rust/C++ 写以保证速度。
但合并规则的核心就是上面这十几行。看懂它,就看懂了为什么你的账单按 token 计而不按字数计。

04完整案例:把三把尺子写成可复算的代码

不装 nltk、不装 rouge,公式一行行写出来,每个中间量都能打印

评估指标最容易学成「背定义」。治这个病的办法是自己实现一遍——公式写进代码,中间量全部打印,再拿手算结果去对。下面这份实现只用标准库,包含 BLEU(含截断与长度惩罚)、ROUGE-N、ROUGE-L、PPL 四部分。

4.1 完整实现

eval_metrics.py —— BLEU / ROUGE / PPL 的纯标准库实现可直接运行
"""BLEU / ROUGE-N / PPL 三个指标的纯标准库实现。

不装 nltk、不装 rouge,把公式一行行写出来,
这样每个中间量都能打印、能跟手算对上。
直接 python3 eval_metrics.py 运行。
"""
import math
from collections import Counter


# --------------------------------------------------------------------------
# 公共工具
# --------------------------------------------------------------------------
def ngrams(tokens, n):
    """把 token 序列切成 n-gram 列表;长度不够时返回空列表。"""
    return [tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)]


# --------------------------------------------------------------------------
# BLEU:看准确率——候选句里的 n-gram 有多少能在参考句里找到
# --------------------------------------------------------------------------
def modified_precision(candidate, references, n):
    """截断精确率:候选的每个 n-gram 最多只能按参考里出现的最大次数计数。

    这一步专门治「the the the the」刷分:
    分子被参考句里 the 的真实出现次数封顶。
    """
    cand_counts = Counter(ngrams(candidate, n))
    if not cand_counts:
        return 0.0, 0, 0

    # 同一个 n-gram 在多条参考里取最大出现次数作为上限
    max_ref_counts = Counter()
    for ref in references:
        ref_counts = Counter(ngrams(ref, n))
        for gram, cnt in ref_counts.items():
            max_ref_counts[gram] = max(max_ref_counts[gram], cnt)

    clipped = sum(min(cnt, max_ref_counts[gram])
                  for gram, cnt in cand_counts.items())
    total = sum(cand_counts.values())
    return clipped / total, clipped, total


def brevity_penalty(candidate, references):
    """长度惩罚:候选句比参考句短就扣分,防止只输出最有把握的几个词。"""
    c = len(candidate)
    if c == 0:
        return 0.0
    # 取长度最接近候选句的那条参考作为有效长度 r
    r = min((abs(len(ref) - c), len(ref)) for ref in references)[1]
    if c > r:
        return 1.0
    return math.exp(1 - r / c)


def bleu(candidate, references, max_n=4, verbose=False):
    """BLEU = BP × exp(Σ w_n · log p_n),权重取均匀的 1/max_n。"""
    weights = [1 / max_n] * max_n
    log_sum = 0.0
    for n in range(1, max_n + 1):
        p_n, clipped, total = modified_precision(candidate, references, n)
        if verbose:
            print("  %d-gram 匹配 %d/%d = %.4f" % (n, clipped, total, p_n))
        if p_n == 0:
            # 任意一阶为 0,整体连乘就是 0;这也是短句 BLEU 常年偏低的原因
            return 0.0
        log_sum += weights[n - 1] * math.log(p_n)
    bp = brevity_penalty(candidate, references)
    if verbose:
        print("  长度惩罚 BP = %.4f" % bp)
    return bp * math.exp(log_sum)


# --------------------------------------------------------------------------
# ROUGE-N:看召回率——参考句里的 n-gram 有多少被候选句覆盖到
# --------------------------------------------------------------------------
def rouge_n(candidate, reference, n=1):
    """返回 (precision, recall, f1)。ROUGE 的主指标是 recall。"""
    cand_counts = Counter(ngrams(candidate, n))
    ref_counts = Counter(ngrams(reference, n))
    overlap = sum(min(cnt, cand_counts[gram]) for gram, cnt in ref_counts.items())

    cand_total = sum(cand_counts.values())
    ref_total = sum(ref_counts.values())
    precision = overlap / cand_total if cand_total else 0.0
    recall = overlap / ref_total if ref_total else 0.0
    if precision + recall == 0:
        return precision, recall, 0.0
    f1 = 2 * precision * recall / (precision + recall)
    return precision, recall, f1


def lcs_length(a, b):
    """最长公共子序列长度,ROUGE-L 用它衡量「顺序上的重合」。"""
    dp = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
    for i in range(1, len(a) + 1):
        for j in range(1, len(b) + 1):
            if a[i - 1] == b[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
    return dp[len(a)][len(b)]


def rouge_l(candidate, reference):
    """ROUGE-L:不要求连续,只要求词序一致,比 ROUGE-N 宽松。"""
    lcs = lcs_length(candidate, reference)
    precision = lcs / len(candidate) if candidate else 0.0
    recall = lcs / len(reference) if reference else 0.0
    if precision + recall == 0:
        return precision, recall, 0.0
    f1 = 2 * precision * recall / (precision + recall)
    return precision, recall, f1


# --------------------------------------------------------------------------
# PPL:困惑度——模型面对真实句子时平均「纠结」在多少个候选词之间
# --------------------------------------------------------------------------
def perplexity(sentence, unigram):
    """PPL = 2^(-1/N · Σ log2 P(w)),等价于几何平均概率的倒数。"""
    log_prob = 0.0
    for word in sentence:
        p = unigram.get(word, 0.0)
        if p <= 0:
            # 概率为 0 时 log 无定义,真实工程里靠平滑兜底
            return float("inf")
        log_prob += math.log(p, 2)
    avg_neg_log = -log_prob / len(sentence)
    return 2 ** avg_neg_log


if __name__ == "__main__":
    cand = "It is a nice day today".lower().split()
    ref = "today is a nice day".lower().split()

    print("一、BLEU(准确率视角)")
    score = bleu(cand, [ref], max_n=4, verbose=True)
    print("  BLEU-4 = %.4f\n" % score)

    print("二、截断精确率治刷分")
    spam = "the the the the".split()
    spam_ref = "The cat is standing on the ground".lower().split()
    p1, clipped, total = modified_precision(spam, [spam_ref], 1)
    print("  候选 'the the the the' 的 1-gram 截断精确率 = %d/%d = %.4f\n"
          % (clipped, total, p1))

    print("三、ROUGE(召回率视角)")
    for n in (1, 2):
        p, r, f = rouge_n(cand, ref, n)
        print("  ROUGE-%d  P=%.4f  R=%.4f  F1=%.4f" % (n, p, r, f))
    p, r, f = rouge_l(cand, ref)
    print("  ROUGE-L  P=%.4f  R=%.4f  F1=%.4f\n" % (p, r, f))

    print("四、PPL(困惑度)")
    unigram = {"i": 1 / 12, "have": 1 / 12, "a": 3 / 12, "pen": 1 / 12,
               "he": 1 / 12, "has": 2 / 12, "book": 1 / 12,
               "she": 1 / 12, "cat": 1 / 12}
    corpus = [["i", "have", "a", "pen"],
              ["he", "has", "a", "book"],
              ["she", "has", "a", "cat"]]
    total = 0.0
    for s in corpus:
        ppl = perplexity(s, unigram)
        print("  %-22s PPL = %.4f" % (" ".join(s), ppl))
        total += ppl
    print("  平均 PPL = %.4f" % (total / len(corpus)))

4.2 运行结果与手算核对

直接 python3 eval_metrics.py

eval_metrics.py 的实际运行结果
一、BLEU(准确率视角)
  1-gram 匹配 5/6 = 0.8333
  2-gram 匹配 3/5 = 0.6000
  3-gram 匹配 2/4 = 0.5000
  4-gram 匹配 1/3 = 0.3333
  长度惩罚 BP = 1.0000
  BLEU-4 = 0.5373

二、截断精确率治刷分
  候选 'the the the the' 的 1-gram 截断精确率 = 2/4 = 0.5000

三、ROUGE(召回率视角)
  ROUGE-1  P=0.8333  R=1.0000  F1=0.9091
  ROUGE-2  P=0.6000  R=0.7500  F1=0.6667
  ROUGE-L  P=0.6667  R=0.8000  F1=0.7273

四、PPL(困惑度)
  i have a pen           PPL = 9.1180
  he has a book          PPL = 7.6673
  she has a cat          PPL = 7.6673
  平均 PPL = 8.1509

把输出和 2.4 节手算的数字并排放,四阶 BLEU 匹配度完全一致:

手算代码输出是否一致
1-gram5/6 = 0.83330.8333
2-gram3/5 = 0.60000.6000
3-gram2/4 = 0.50000.5000
4-gram1/3 = 0.33330.3333
ROUGE-1 召回5/5 = 1.0000R=1.0000
✅ 四个能立刻看懂的结论BLEU-4 = 0.5373,而 1-gram 匹配度高达 0.8333——因为四阶是几何平均,高阶一拖后腿,总分就下来了。短句的 BLEU 天生偏低,跨句长比较没有意义。
刷分被截住了the the the the 的 1-gram 截断精确率是 2/4 = 0.5,不是 1。
同一对句子,ROUGE-1 的 P=0.8333、R=1.0000——precision 与 BLEU 的 1-gram 完全相同,recall 才是 ROUGE 的主指标。这一行把两个指标的关系钉死了。
平均 PPL = 8.1509:一元模型面对这三句话,平均在 8 个词之间摇摆。词表一共 9 个词,说明这个模型几乎没学到东西——PPL 接近词表大小,等于瞎猜。

4.3 三把尺子各自的盲区

三个指标都能被「骗」,知道怎么骗,才知道什么时候不能信它们:

指标怎么被骗后果
BLEU换个同义说法,一个 n-gram 都不匹配「今天天气很好」vs「今日天气不错」得分接近 0,但翻译质量一样
ROUGE把参考答案的词全抄一遍再多写一堆废话召回率拉满,但生成内容又臭又长;所以要同时看 precision
PPL换个分词方式或换个词表PPL 数值直接变样,跨模型比较 PPL 必须统一 tokenizer,否则没有可比性

共同的盲区是:它们都只在比对字面,不理解语义。所以这三个数字适合做回归监控(改了代码,分数掉了就报警),不适合当作「模型好不好」的最终判据。真要下结论,还得靠人工抽检或更强的模型来当裁判。

05骨架模板:批量评估脚本

改 5 处 TODO,就能给自己的一批生成结果打分出表

单条算指标是教学,真实工作里要的是一批样本跑完出一张表,而且能存档、能和上一版比。这份骨架把「读数据 → 分词 → 逐条打分 → 汇总平均 → 落盘」串好了,依赖只有标准库和同目录的 eval_metrics.py

eval_skeleton.py —— 批量评估骨架,只改 TODO 处可复用模板
"""评估脚本骨架:把一批生成结果按 BLEU / ROUGE / PPL 打分并出报表。

复制后只改 5 处 TODO 即可用在自己的任务上。
依赖:只用标准库 + 同目录的 eval_metrics.py。
"""
import json
import os

from eval_metrics import bleu, rouge_n, rouge_l, perplexity

# TODO 1:换成你自己的数据文件。每行一个 JSON 对象,形如
#   {"prompt": "...", "prediction": "模型输出", "reference": "人工标准答案"}
DATA_PATH = os.environ.get("EVAL_DATA", "samples.jsonl")

# TODO 2:换成你的分词方式。中文建议用 jieba,英文按空格即可。
def tokenize(text):
    return text.lower().split()


# TODO 3:按任务类型挑指标。翻译看 BLEU,摘要看 ROUGE,语言模型看 PPL。
METRICS = ("bleu4", "rouge1_r", "rouge2_r", "rougeL_f")


def load(path):
    """读 jsonl;文件不存在时退回内置样例,保证脚本永远能跑起来。"""
    if not os.path.exists(path):
        return [
            {"prediction": "It is a nice day today", "reference": "today is a nice day"},
            {"prediction": "the the the the", "reference": "the cat is on the ground"},
        ]
    rows = []
    with open(path, encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if line:
                rows.append(json.loads(line))
    return rows


def score_one(prediction, reference):
    """对单条样本算出全部指标,返回字典。"""
    cand, ref = tokenize(prediction), tokenize(reference)
    r1_p, r1_r, r1_f = rouge_n(cand, ref, 1)
    r2_p, r2_r, r2_f = rouge_n(cand, ref, 2)
    rl_p, rl_r, rl_f = rouge_l(cand, ref)
    return {
        "bleu4": bleu(cand, [ref], max_n=4),
        "rouge1_r": r1_r,
        "rouge2_r": r2_r,
        "rougeL_f": rl_f,
    }


def main():
    rows = load(DATA_PATH)
    totals = dict.fromkeys(METRICS, 0.0)

    print("%-34s %s" % ("样本", "  ".join("%-9s" % m for m in METRICS)))
    print("-" * 80)
    for row in rows:
        scores = score_one(row["prediction"], row["reference"])
        for m in METRICS:
            totals[m] += scores[m]
        preview = row["prediction"][:30]
        print("%-34s %s" % (preview,
                            "  ".join("%-9.4f" % scores[m] for m in METRICS)))

    print("-" * 80)
    n = len(rows) or 1
    print("%-34s %s" % ("平均(共 %d 条)" % len(rows),
                        "  ".join("%-9.4f" % (totals[m] / n) for m in METRICS)))

    # TODO 4:需要 PPL 时,换成你自己的词表概率分布(真实项目里由模型给出)
    # print(perplexity(tokenize("i have a pen"), {"i": 1/12, ...}))

    # TODO 5:把结果落盘,接进 CI 做回归对比
    # json.dump(totals, open("eval_report.json", "w"), ensure_ascii=False, indent=2)


if __name__ == "__main__":
    main()
✅ 复制后你只需要改这五处 TODO 1 换成你的数据文件(jsonl,每行含 predictionreference)· TODO 2 换分词方式,中文建议接 jieba · TODO 3 按任务类型挑指标,翻译看 BLEU、摘要看 ROUGE · TODO 4 需要 PPL 时传入模型给出的词表概率 · TODO 5 把结果落盘接进 CI。其余代码不用动。

文件不存在时它会退回内置样例,所以拿到手直接 python3 eval_skeleton.py 就有输出,不用先准备数据:

样本bleu4rouge1_rrouge2_rrougeL_f
It is a nice day today0.53731.00000.75000.7273
the the the the0.00000.33330.00000.4000
平均(共 2 条)0.26860.66670.37500.5636
⚠️ 一个真实会踩的坑:平均值会骗人 上表第二行 bleu4 = 0 把平均值狠狠拉低了。真实评估里只要有几条样本高阶完全不匹配,整批的平均 BLEU 就会失真。
所以报告指标时至少同时给出中位数和分布,或者先剔除 finish_reason 异常的样本。只报一个平均分,是评估里最常见的自欺。

06易错点汇总

按「概念 / N-gram / 神经网络 / 指标 / 工程」五类归并

⚠️ 一、概念层面

  • 以为大模型在「理解问题然后回答」。 它在做的仍然是逐词预测概率。理解、推理都是这件事做到极致后表现出来的行为,不是另一套独立机制。记住铁律,很多「为什么模型会胡说」的困惑自然就解开了。
  • 把「语言模型」等同于「大语言模型」。 bigram 也是语言模型。区别只在规模与由此涌现的能力,不在任务目标。
  • 把参数量当成唯一的「大」。 参数量、训练数据量、上下文长度是三个独立维度,一个 7B 但训练数据 18T 的模型,可能比 13B 但数据 1T 的更强。
  • 记错 GPT-3 的量级。1750 亿参数、2020 年由 OpenAI 发布。它的标志性能力是不调整权重、仅凭示例完成新任务

⚠️ 二、N-gram 与马尔可夫假设

  • 把 N 数错一位。 N 元模型是「当前词只依赖前 N-1 个词」。bigram 往前看 1 个,trigram 往前看 2 个。写代码时切 n-gram 切错长度,所有概率都会错。
  • 忘了补句首句尾标记。 不补 <s>,第一个词没有上文,P(W1) 无从计算;不补 </s>,模型学不会「话说完了」,生成时会停不下来。
  • 分母用错。 P(Wi|Wi-1) = C(Wi-1,Wi) / C(Wi-1),分母是前一个词的出现次数,不是语料总词数,也不是共现总数。
  • 不做平滑就上真实数据。 只要有一个词对没见过,整句概率连乘后就是 0,所有句子都一样「不可能」,模型直接失效。
  • 以为 N 越大越好。 N 增大时参数空间呈指数增长,数据稀疏更严重。实践中 bigram 和 trigram 用得最多,不是因为懒,是因为再大就估不准了。
  • 平滑时分母只加 1。 加一平滑的分母必须加整个词表大小 |V|,因为每个候选词都白送了 1 个计数。分母加错,概率就不求和为 1,往后所有计算全废。验证办法只有一个:固定前词,把全词表的概率加起来看是不是 1
  • 把加一平滑当成生产方案。 真实词表几万起步,|V| 一加就把真实统计量淹没了。它只适合教学演示,生产请用 Good-Turing、插值法或 Kneser-Ney。

⚠️ 二之二、Token 与分词

  • 把 token 当成字或词。 token 是 BPE 按频次合并出来的片段:高频词可能整个是 1 个 token,生僻词会被拆成好几个。「1 汉字 = 1 token」只是粗略经验值,不能用来算钱或判断是否超上下文
  • 拿 A 模型的 tokenizer 去估 B 模型的费用。 每家词表不同,同一句话 token 数不一样。跨模型比价格,要么用各自官方 tokenizer 离线算,要么发一次真实请求读 usage
  • BPE 训练时不加词尾边界标记。 不加 </w>,合并会跨过词边界,把上一个词的尾巴和下一个词的开头粘成一个 token。
  • 编码时不按训练顺序重放合并规则。 合并规则是有序的,顺序一乱,切出来的 token 就与模型预期不一致。这也是 tokenizer 文件必须跟权重配套分发的原因。
  • 忽略中文的 token 成本。 主流 tokenizer 的训练语料以英文为主,同等字数的中文提示词 token 数通常更高。预算估算别拿英文经验直接套。

⚠️ 三、神经网络语言模型

  • 把词向量当成「模型的输出」。 词向量是中间表示,是随机初始化后跟着任务一起学出来的,输出层给的才是词表上的概率分布。
  • 漏掉 softmax。 输出层 V 个节点给的是未归一化的 logits,必须经 softmax 才是概率,否则数值不在 [0,1]、也不求和为 1。
  • 以为 NNLM 解决了长依赖。 它治的是数据稀疏,输入仍然是固定的前 n-1 个词。长序列问题要等 RNN、attention 才有像样的解法。

⚠️ 四、评估指标

  • 把 PPL 的方向记反。 三把尺子里只有它越小越好。BLEU、ROUGE 是越接近 1 越好。
  • 跨模型直接比 PPL。 分词方式或词表一变,PPL 数值就不可比。比较前必须统一 tokenizer。
  • 算 BLEU 不做截断。 不截断的话 the the the the 能拿满分。必须用参考句中该词的最大出现次数去修剪候选句的计数。
  • 只看 ROUGE 的召回率。 把参考答案全抄一遍再灌一堆废话,召回率能拉满。要同时看 precision 或 F1。
  • 拿 BLEU 判断中文生成质量。 中文需要先分词,且同义表达完全不匹配字面。字面指标只适合做回归监控,不适合当最终判据。
  • 只报平均分。 几条 0 分样本就能把整批平均值拖垮。至少同时给中位数或分布。

⚠️ 五、工程实现

  • 整句概率连乘导致数值下溢。 句子一长,几十个小于 1 的数连乘会小到浮点数表示不了,直接变成 0。真实实现一律在对数空间累加,最后再取指数。
  • PPL 里对 0 概率取对数。 log(0) 无定义,程序会崩或得到 inf。要么平滑,要么显式判零。
  • 读中文语料没带 encoding='utf-8' Windows 默认 GBK,中文直接报解码错误。
  • 比较指标时改了分词却没说。 分词变了,所有 n-gram 都变了,两次结果不可比。评估配置要跟结果一起存档。

07自测题

点击题目展开答案;能把这 16 题说清楚,这一讲就通了

一、概念
什么是语言模型?用一句话给出标准定义。

对于词序列 S = {W1, W2, …, Wn},语言模型就是计算该序列发生的概率 P(S) 的模型:符合语言习惯的序列给高概率,否则给低概率。等价的说法是——给定上文,预测下一个词

什么是大语言模型?参数量的常用判定线是多少?

旨在理解和生成人类语言的人工智能模型,能处理文本分类、问答、翻译、对话等任务。通常指包含数千亿(或更多)参数的语言模型;工程上还有一条更宽松的经验线:参数量超过 10B 即可称为大语言模型

语言模型技术发展的四个阶段分别是什么?每一阶段治了上一阶段的什么病?

基于规则和统计(N-gram)——起点,但参数空间过大、数据稀疏;② 神经语言模型——用词向量治好了数据稀疏,但长序列建模能力有限;③ 预训练语言模型(BERT/GPT/T5)——预训练+微调带来强泛化,但算力需求大、可解释性差;④ 大语言模型——参数与语料继续放大,能像人一样沟通,但算力要求高、可能生成有害或有偏见的内容。

GPT-3 最具标志性的能力是什么?为什么它很重要?

2020 年发布、1750 亿参数。标志性能力是可以在不调整权重的情况下,仅依据用户给出的任务示例完成具体任务。重要性在于:以前让模型做新任务必须重新训练,从此提示词成了使用模型的新接口,直接催生了后来的提示工程。

一个只做完预训练的模型,直接拿来聊天会怎样?后面还要加哪两段?

它只会接写——你问「中国的首都是哪里?」,它很可能接一句「美国的首都是哪里?」,因为语料里问题常成组出现。
后面要加两段:② SFT 监督微调,用「指令 → 优质回答」样本对让它听懂指令;③ RLHF / DPO 偏好对齐,用人工对候选回答的排序让回答更有用、更安全。

要让模型多掌握一个领域的事实知识,应该微调吗?

通常不该。事实知识主要在预训练层形成,那一层个人和中小团队基本动不起,实际该走 RAG(把资料检索出来放进提示词)。
微调适合解决的是固定格式、固定口吻、固定任务套路;只是调语气或加几条规则,连微调都不用,写进 system 提示词就行。分不清这三层,就会花十倍的钱办一半的事。

二、N-gram 与神经网络
为什么不能直接用链式法则算整句概率?马尔可夫假设怎么救场?

链式法则里的 P(Wn|W1,…,Wn-1) 条件太长,这种组合在语料里几乎不出现,参数空间过大、数据稀疏,估不出来。马尔可夫假设规定:一个词出现的概率只与它前面有限的一个或几个词有关,把长条件截断成短条件,就能靠计数估计了。

bigram 的参数怎么算?写出 P(Wi|Wi-1) 的公式。

P(Wi|Wi-1) = C(Wi-1, Wi) / C(Wi-1)。分子是两词共现次数,分母是前一个词自身的出现次数(不是语料总词数)。只需要两个计数加一次除法,这就是 bigram 的全部参数。

N-gram 最致命的缺陷是什么?为什么 N 不是越大越好?

最致命的是数据稀疏:语料里没出现过的词对概率直接为 0,连乘后整句概率也是 0,而「没见过」不等于「不合理」。N 越大,参数空间呈指数增长,稀疏更严重,估计更不准,所以实践中 bigram 和 trigram 用得最多。

神经网络语言模型靠什么解决数据稀疏?说清机制。

词向量:每个词表示成低维连续向量,语义或语法相似的词,向量在空间中距离更近。于是模型见过「打篮球」,就能对没见过的「打排球」给出合理概率,不再依赖某个具体词序列是否在训练数据中出现过。网络三层:拼接词向量 → 全连接+tanh → 全连接+softmax 输出词表概率分布。

写出加一平滑的公式。分母为什么必须加 |V| 而不是加 1?

P(Wi|Wi-1) = ( C(Wi-1,Wi) + 1 ) / ( C(Wi-1) + |V| )
因为每个候选词的分子都白送了 1,一共送出去 |V| 个计数,分母必须把这些全补回来,所有候选词的概率之和才仍然等于 1。验证办法就一个:固定前词,把全词表的概率加起来看是不是 1.000000

平滑的代价是什么?为什么真实项目不用加一平滑?

代价是削峰填谷:把概率从高频搭配身上匀给没见过的组合。实测五句话语料(|V|=10),P(想|我)0.6667 掉到 0.2308,下降 65.4%。
真实词表动辄几万几十万,分母被 |V| 一加,真实统计量完全被淹没,模型退化成均匀分布。生产上用 Good-Turing、插值法或效果最好的 Kneser-Ney

一个 token 到底是什么?「1 个汉字 = 1 个 token」对吗?

token 是 BPE 按频次合并出来的片段,既不是字也不是词:高频词可能整个是 1 个 token(实测中 newest 被合并成一个),生僻词则被拆成好几个(newer → new / e / r / </w>)。
「1 汉字 = 1 token」不对,只是粗略经验值。要准确数字,要么用该模型官方 tokenizer 离线算,要么发一次请求读响应的 usage 字段。

BPE 训练的三步循环是什么?为什么说它不需要语言学知识?

① 把每个词拆成字符并加词尾标记;② 按词频加权统计所有相邻符号对的频次;③ 把最高频的那一对合并成新符号,回到 ②。
实测里第 2 步就把 est 合并了出来——没有人告诉它这是英语最高级后缀,纯靠数频次数出来的。所以同一套算法换成中文、代码、DNA 序列照样能跑。

三、评估指标
BLEU、ROUGE、PPL 各看什么?哪个越小越好?

BLEU 看准确率(候选句的 n-gram 有多少在参考句里),取值 [0,1] 越大越好,主用于机器翻译;ROUGE 看召回率(参考句的 n-gram 被覆盖了多少),取值 [0,1] 越大越好,主用于摘要与问答生成;PPL 度量模型预测样本的好坏程度,越小越好——句子概率越大,模型越好,困惑度越小。

同一对句子,BLEU 的 1-gram 是 5/6,ROUGE-1 是 5/5。分子一样,差别在哪?截断又是治什么的?

差别只在分母:BLEU 的分母是候选句的 n-gram 总数(6 个),ROUGE 的分母是参考句的 n-gram 总数(5 个)。这就是准确率与召回率的全部区别。
截断治的是刷分:candidate 为 the the the the、reference 为 The cat is standing on the ground 时,朴素 1-gram 匹配度是 1,显然荒谬。先统计每个词在参考句中出现的最大次数(the 出现 2 次),再用它修剪候选句中的计数,匹配度降为 2/4 = 0.5

术语表

术语含义
Language Model(LM)计算词序列概率的模型;等价说法是「给定上文预测下一个词」
LLMLarge Language Model,大语言模型;通常指参数量达数千亿、工程上常以 10B 为线的语言模型
链式法则把整句概率拆成逐词条件概率连乘:P(S)=P(W1)·P(W2|W1)·…
马尔可夫假设一个词的出现概率只与它前面有限的几个词有关,用来把过长的条件截断
N-gramN 元语言模型;当前词只依赖前 N-1 个词,常用 bigram、trigram
OOVOut-Of-Vocabulary,未登录词;词表里没有的词,是数据稀疏的典型表现
smoothing平滑;给未出现过的组合分配少量概率,避免整句概率连乘归零
Laplace smoothing加一平滑;分子 +1、分母 +|V|,最简单的平滑方案,代价是稀释高频搭配
Kneser-Ney效果最好的经典 N-gram 平滑法,额外考虑一个词能接在多少种不同上文后面
token模型实际读写的最小单位,既不是字也不是词,而是按频次合并出来的片段;计费与上下文长度都按它算
subword子词;介于字符与单词之间的切分粒度,常用词整个保留、生僻词拆成片段
BPEByte Pair Encoding,字节对编码;反复合并最高频相邻符号对来建词表的 subword 算法
tokenizer分词器;把文本转成 token 序列的组件,必须与模型权重配套使用
NNLM神经网络语言模型;用词向量与全连接网络替代计数统计
embedding词向量;把词映射成低维稠密向量,语义相近的词距离更近
logits输出层未归一化的分数,需经 softmax 才变成概率分布
Pre-training预训练;在大规模通用语料上学习通用特征表示与知识
Fine-tuning微调;在具体下游任务上迁移学习,获取更好的泛化效果
SFTSupervised Fine-Tuning,监督微调;用「指令 → 优质回答」样本对让模型从「会接写」变成「听得懂指令」
RLHF基于人类反馈的强化学习;用人工对候选回答的排序来对齐模型偏好
涌现能力emergent abilities;参数规模跨过阈值后才明显出现的能力,如上下文学习、思维链;学界对其“突变性”有争议
In-context Learning上下文学习;不改权重,只在提示词里给示例就能完成新任务
Chain-of-Thought思维链;让模型分步推理再给答案,可显著提升多步推理任务的准确率
BLEU基于 n-gram 准确率的生成质量指标,取值 [0,1],越接近 1 越好
ROUGE基于召回率的生成质量指标,分 ROUGE-N / L / W / S 几种
PPLPerplexity 困惑度,度量模型预测样本的好坏程度,越小越好
brevity penalty长度惩罚;候选句比参考句短时对 BLEU 打折,防止只吐几个词骗高分
✅ 一句话收束 这一讲从头到尾只讲了一件事:怎么给「下一个词」算概率。N-gram 用数数算,神经网络用向量算,大模型用几千亿参数算——方法换了三轮,问题一次没换。下一讲去看这些模型长什么样、彼此差在哪。