全栈开发知识库

第三章 大语言模型基础

一句话总结

大语言模型的本质是一台"超级文字接龙机":它把你的话切成一个个小碎片(Token),然后一个碎片一个碎片地猜下一个最合理的碎片,猜出来的结果连起来就是回答。这一章讲清楚它为什么能猜得这么准(Transformer 架构)、怎么控制它猜的方式(提示工程与采样参数)、以及它的天生缺陷(幻觉)。

本章你能学到什么

  • 语言模型七十年进化的三步:数数(N-gram)→ 传话(RNN)→ 全局扫视(Transformer)
  • ChatGPT 们为什么都长成"Decoder-Only"这个样子
  • 提示词怎么写才有效,temperature、top_k、top_p、frequency_penalty 这四个神秘旋钮到底在调什么(第四个实验里你会亲手把它们实现一遍)
  • Token 是什么,为什么它直接决定你花多少钱
  • 手敲四个完全离线的实验:N-gram、BPE 分词器、纯 numpy 自注意力、采样参数实验室——一分钱不花、不需要密钥、不需要联网,每个都有贴了真实输出的 ✅ Checkpoint
  • 在自己电脑上跑一个真正的开源大模型(可选的第五个实验)
  • 模型为什么会一本正经地胡说八道(幻觉),以及为什么根治不了
  • 七个真实会翻车的坑:分词器换了词表就错位、temperature=0 并不等于完全复现、删掉 /√d_k 之后注意力会崩成 one-hot(有实测数字)

一、语言模型的三步进化:从"数数"到"全局扫视"

1.1 N-gram:靠数数猜下一个词

最早的语言模型思路简单粗暴:统计。想知道"今天天气真"后面接什么?去海量文本里数一数:"真好"出现了 8000 次,"真差"出现了 2000 次——那就猜"好",八成对。

这就是 N-gram 模型:只看前面 N-1 个词,查表选出现频率最高的下一个词。它的死穴一眼可见:

  • 看不远:N 一般只有 2~5,"我出生在法国,……(此处省略50字)……所以我说一口流利的___"——答案"法语"藏在 50 个字之前,N-gram 根本看不到;
  • 表存不下:词的组合是天文数字,大部分组合在训练文本里一次都没出现过,一查就是零。

1.2 RNN:像传话游戏一样记住上文

循环神经网络(RNN) 换了个思路:一个词一个词地读句子,每读一个词就更新一下"脑中的印象"(术语叫隐藏状态),读到最后,这个印象里理论上浓缩了整句话的信息。

打个比方:玩传话游戏,每个人听到上一个人的话,加上自己看到的新词,揉成一句话传给下一个人。问题也出在这:传话传多了会失真。句子一长,开头的信息传到结尾就模糊了(术语叫梯度消失,可以理解为"记忆随距离衰减")。而且它必须一个词一个词按顺序处理,没法并行,训练极慢。

1.3 Transformer:所有词互相"对眼神"

2017 年的论文《Attention Is All You Need》提出了 Transformer 架构,一举解决上面两个问题。核心创新叫自注意力机制(Self-Attention):

处理每个词的时候,让它直接"看一眼"句子里所有其他词,并给每个词打一个"相关度分数",按分数加权吸收信息。

用大白话说:理解"苹果发布了新手机"里的"苹果"时,注意力机制会发现"发布""手机"和它高度相关,于是明白这是公司不是水果。距离不再是问题——第 1 个词和第 1000 个词可以直接建立联系,不用像 RNN 那样传话 999 次。而且所有词的注意力计算可以同时进行,完美利用 GPU 并行算力,训练速度起飞。

名词补课——注意力里的 Q/K/V:面试常问,用"图书馆检索"打比方即可。每个词会生成三样东西:Q(Query,查询)="我想找什么"、K(Key,键)="我是关于什么的标签"、V(Value,值)="我实际装着的内容"。每个词拿自己的 Q 去跟所有词的 K 算匹配度,匹配度高的词,它的 V 就被多吸收一点。所谓"多头注意力(Multi-Head)"就是同时开好几组 Q/K/V,有的组关注语法关系、有的组关注语义关系,各看各的再汇总。

1.4 Decoder-Only:为什么 GPT 们都是"独臂侠"

原版 Transformer 有两个部件:编码器(Encoder,负责读懂输入)和解码器(Decoder,负责生成输出),是为翻译任务设计的。而 GPT、DeepSeek、Qwen 这些生成式模型只保留了解码器,所以叫 Decoder-Only 架构。

它的工作方式叫自回归生成(Autoregressive):每次只生成一个 Token,生成完立刻把它拼回输入末尾,再生成下一个——就是"文字接龙"。这也解释了两个日常现象:

  • 为什么回答是一个字一个字蹦出来的(它真的就是一个一个生成的,不是特效);
  • 为什么回答越长越慢、越贵(每个 Token 都要完整跑一遍模型)。

Decoder-Only 里的注意力有个特殊限制:生成第 N 个词时只能看它前面的词,不能偷看后面(术语叫因果掩码,Causal Mask)——废话,后面的还没生成呢。


二、与大语言模型打交道:提示工程、分词与采样

2.1 提示工程:把"说明书"写清楚的手艺

模型能力是固定的,但你问的方式决定它发挥几成功力。几个被反复验证有效的技巧:

  1. 给角色:"你是一位资深法律顾问"比裸问效果好——相当于把模型脑中庞大知识库先翻到对应章节;
  2. 给示例(Few-Shot):直接在提示词里放两三个"输入→期望输出"的例子,模型照葫芦画瓢,格式和风格立刻稳定;
  3. 让它打草稿(思维链,Chain-of-Thought):加一句"请一步步思考",让它把推理过程写出来再给答案,复杂题正确率大幅上升——原理是每一步生成的文字又成了下一步的输入,相当于给它草稿纸;
  4. 给格式和边界:"用 JSON 输出""不超过100字""如果不知道就回答'不知道'"——把输出关进笼子。

这些技巧在 docs/2 系列的每个手敲项目里都实际用到了,那里是最好的练习场。

2.2 Token 与分词:模型眼里的世界是"碎片"

模型不认识"字",它认识 Token——文本被切成的小碎片。切碎片的组件叫分词器(Tokenizer),主流算法是 BPE(从单个字符开始,反复把最常一起出现的碎片合并成更大的碎片,最终形成一张几万到十几万条目的"碎片表")。

你需要建立的量化直觉:

  • 英文大约 1 个单词 ≈ 1.3 个 Token;中文大约 1 个汉字 ≈ 0.6~1 个 Token(不同模型的分词器差异很大);
  • API 按 Token 计费,输入输出都算钱,这就是为什么控制提示词长度=控制成本;
  • 上下文窗口(模型一次能"看见"的最大长度)也是按 Token 算的,超了就会"失忆"。

2.3 采样参数:控制"接龙"随机性的旋钮

模型每一步其实算出的是所有候选 Token 的概率分布(比如"好"70%、"差"20%、"棒"10%),怎么从里面挑一个,由采样参数决定:

参数 干什么的 大白话
temperature 温度,拉平或锐化概率分布 调低(→0):永远选最大概率,输出稳定死板,适合写代码、提取数据;调高(→1以上):冷门选项翻身机会大,输出有创意也更容易跑飞,适合写文案
top_p 只在累计概率前 p 的候选里挑 "只考虑最靠谱的那批词",留几个是浮动的:分布陡就少留,分布平就多留
top_k 只在概率最高的 k 个候选里挑 简单粗暴版 top_p,留几个是固定的
frequency_penalty 惩罚已经出现过的词 治"复读机":每出现一次就把它的分数扣掉一点

实战口诀:要稳定用 temperature=0,要创意调到 0.8 左右,两个都想调时先只动 temperature。 第一章手敲的旅行助手里用 temperature=0,就是为了让它老老实实按格式输出。

这张表现在还只是文字。实验四会让你把这四个旋钮亲手实现一遍,把"拉平分布""留几个候选""扣分"都变成你自己打印出来的数字——那时候你再回来看这张表,每一行都有画面。

2.4 模型怎么选

  • 闭源 API(GPT 系列、Claude、DeepSeek 官方 API):效果好、免运维,按量付费,数据要出门;
  • 开源自部署(Qwen、Llama、GLM 等):数据不出门、量大时更省钱,但要自己搞 GPU 和运维(部署方法见 docs/2 第 15 篇);
  • 入门学习期的建议:用 DeepSeek 等便宜的国产 API 练手,等有真实需求再考虑自部署。

三、缩放法则与幻觉:能力的来源和天花板

3.1 缩放法则(Scaling Law):大力真的能出奇迹

OpenAI 2020 年的研究发现:模型性能随着参数量、数据量、算力三者的增加而平滑提升,而且遵循可预测的数学规律。这就是"缩放法则"——它是过去几年"模型越造越大"军备竞赛的理论依据。更神奇的是涌现能力:模型大到某个临界点,会突然冒出小模型完全没有的能力(比如多步推理、看例子学新任务),就像水加热到 100 度突然沸腾。

3.2 幻觉:为什么它会一本正经地胡说八道

幻觉(Hallucination)=模型输出看起来流畅合理、实际是编造的内容。根源要回到它的本质:它是在猜"接下来什么文字最像人话",不是在查证事实。"《红楼梦》的作者是"后面接"曹雪芹"概率最大,所以它答对了——但这是统计的胜利,不是它"知道"。遇到训练数据里没有或很少的问题,它照样会选一个"看起来最顺"的答案,编得毫无心理负担。

缓解手段(注意是缓解,不是根治):让它联网/查资料再回答(RAG,第八章)、让它调用计算器等工具代替心算(第四章)、要求它给出处、温度调低。面试高频题:"如何减少幻觉?"标准答案就是这四板斧+人工审核兜底,详细展开见 docs/2 第 10 篇和第 17 篇。


动手实战:从0到1跑通五个实验

项目结构(五个文件,前四个完全离线、零密钥、零安装,只有第三个要一个 numpy):

llm_lab/
├── ngram.py          ① N-gram:亲手撞上"数数式"模型的天花板     (纯标准库)
├── bpe.py            ② BPE 分词器:亲手把词切成碎片             (纯标准库)
├── attention.py      ③ 纯 numpy 自注意力 + 多头 + 因果遮挡      (只需 numpy)
├── sampling_lab.py   ④ 采样参数实验室:四个旋钮全部手写         (纯标准库)
└── local_llm.py      ⑤ 本地跑 0.5B 开源模型                    (可选,需下载约 1GB)

建议顺序:①②③④ 一路做下去,⑤ 随缘。 前四个不花一分钱、不需要注册任何账号,却正好把 1.1~2.3 节的四个概念(N-gram、Token、注意力、采样参数)从"读过"变成"手里有"。四个实验的 ✅ Checkpoint 贴的都是真实运行输出,你的输出应该和书里逐字一致(实验一和实验四固定了随机种子,实验三固定了 numpy 的 default_rng(0))。

实验一:N-gram —— 亲手撞上"数数式"模型的天花板(约50行)

手敲 ngram.py:

"""实验一:用 30 行 N-gram 感受"数数式"语言模型的能力上限。
不联网、不需要密钥、不需要装任何包。
"""
import random
from collections import defaultdict, Counter

CORPUS = """今天天气很好 今天天气不错 今天心情很好
明天天气很好 明天天气不错 明天心情不错
天气很好我们去公园 天气不错我们去爬山
心情很好我们去公园 心情不错我们去爬山"""


def tokenize(text):
    """中文按字切,空格当分隔符去掉"""
    return [ch for ch in text if not ch.isspace()]


def train(tokens, n=2):
    """统计:前 n-1 个字之后,各个字分别出现了多少次"""
    table = defaultdict(Counter)
    for i in range(len(tokens) - n + 1):
        prefix = tuple(tokens[i:i + n - 1])
        table[prefix][tokens[i + n - 1]] += 1
    return table


def next_token(table, prefix, temperature=1.0, rng=random):
    counts = table.get(prefix)
    if not counts:
        return None
    words = list(counts)
    if temperature <= 0:                       # 贪心:永远选最高频的
        return max(words, key=lambda w: counts[w])
    weights = [counts[w] ** (1.0 / temperature) for w in words]
    return rng.choices(words, weights=weights, k=1)[0]


def generate(table, start, n=2, max_len=20, temperature=1.0, seed=0):
    rng = random.Random(seed)
    out = list(start)
    for _ in range(max_len):
        nxt = next_token(table, tuple(out[-(n - 1):]), temperature, rng)
        if nxt is None:
            break
        out.append(nxt)
    return "".join(out)


if __name__ == "__main__":
    tokens = tokenize(CORPUS)
    for n in (2, 3):
        table = train(tokens, n)
        print(f"\n===== {n}-gram(条件长度 {n - 1} 个字)=====")
        print("表里有", len(table), "个前缀")
        print("前缀 '天' 之后的候选:", dict(train(tokens, 2)[("天",)]))
        print("贪心生成 :", generate(table, "今天"[-(n - 1):], n, 12, temperature=0))
        print("采样生成 :", generate(table, "今天"[-(n - 1):], n, 12, temperature=1.0, seed=7))

✅ Checkpoint 1:python ngram.py,真实输出:

===== 2-gram(条件长度 1 个字)=====
表里有 17 个前缀
前缀 '天' 之后的候选: {'天': 4, '气': 6, '心': 2}
贪心生成 : 天气很好明天气很好明天气很
采样生成 : 天天天气很好明天气很好今天

===== 3-gram(条件长度 2 个字)=====
表里有 28 个前缀
前缀 '天' 之后的候选: {'天': 4, '气': 6, '心': 2}
贪心生成 : 今天天气很好明天天气很好明天
采样生成 : 今天天气不错天气很好今天天气

请盯着"贪心生成"那两行看——它在原地转圈:"天气很好明天气很好明天气很"。 这就是 1.1 节说的 N-gram 天花板的具体样子:它只看前 n-1 个字,根本不知道自己两步前说过什么,所以陷入循环。这也顺带解释了两件事:

  • temperature=0 就是贪心(永远选概率最高的),temperature=1.0 是按概率采样——2.3 节讲的温度,在这 50 行里已经完整实现了。你在 GPT 上调的那个旋钮,数学上就是 counts[w] ** (1/temperature) 这一行。
  • n 从 2 加到 3,前缀数从 17 涨到 28。想让它记得更远就得加大 n,而前缀数量随 n 指数爆炸——这就是 N-gram 死掉的直接原因(学术上叫"数据稀疏")。

改一改(必做,这是本实验真正的收获):把 for n in (2, 3) 改成 for n in range(2, 8),再顺手统计一下"只有唯一候选的前缀"占多少。这份语料只有 72 个字,实测结果:

n 前缀数 只有 1 个候选的前缀数 占比
2 17 10 59%
3 28 20 71%
4 39 31 79%
5 49 42 86%
6 58 54 93%
7 62 60 97%

"只有 1 个候选"意味着这条规则不是学到的规律,而是背下来的原文。 n=7 时 97% 的前缀都只有唯一答案——这个模型已经退化成一本字典了,生成的东西必然是原文照抄。这就是"数据稀疏"的具体长相:n 越大越"记得远",但同时越来越只会背书,两者不可兼得。另外试一个语料里没出现过的开头,generate(train(tokens, 6), "昨天天气很", 6, 12, temperature=0) 返回的就是 昨天天气很 本身——一个字都生成不出来,因为表里查不到这个前缀。Transformer 之所以赢,就是因为它不查表,它算相关度。

实验二:手写 BPE 分词器 —— 亲手把词切成碎片(约70行)

2.2 节说"模型眼里的世界是碎片",这个实验让你亲手造出这些碎片。BPE(Byte Pair Encoding)的思路简单到离谱:从单个字符开始,反复把"出现次数最多的相邻符号对"合并成一个新符号。

手敲 bpe.py:

"""实验二:手写 BPE 分词器,理解"模型眼里的世界是碎片"。
不联网、不需要密钥、只用标准库。
"""
from collections import Counter

CORPUS = ["low", "low", "low", "low", "low",
          "lower", "lower", "newest", "newest", "newest",
          "newest", "newest", "newest", "widest", "widest", "widest"]


def init_vocab(corpus):
    """每个词先拆成字符,末尾加 </w> 表示"词尾" """
    return Counter(tuple(list(w) + ["</w>"]) for w in corpus)


def count_pairs(vocab):
    """统计所有相邻符号对出现的总次数"""
    pairs = Counter()
    for word, freq in vocab.items():
        for a, b in zip(word, word[1:]):
            pairs[(a, b)] += freq
    return pairs


def merge_pair(vocab, pair):
    """把 vocab 里所有出现的这一对相邻符号,合并成一个新符号"""
    merged = "".join(pair)
    new_vocab = Counter()
    for word, freq in vocab.items():
        out, i = [], 0
        while i < len(word):
            if i < len(word) - 1 and (word[i], word[i + 1]) == pair:
                out.append(merged)
                i += 2
            else:
                out.append(word[i])
                i += 1
        new_vocab[tuple(out)] += freq
    return new_vocab


def train_bpe(corpus, num_merges=10, verbose=True):
    vocab = init_vocab(corpus)
    merges = []
    for step in range(num_merges):
        pairs = count_pairs(vocab)
        if not pairs:
            break
        best, freq = pairs.most_common(1)[0]
        if freq < 2:                      # 只出现一次的对不值得合并
            break
        vocab = merge_pair(vocab, best)
        merges.append(best)
        if verbose:
            print(f"第{step + 1}次合并: {best[0]!r} + {best[1]!r} -> {''.join(best)!r} (出现{freq}次)")
    return merges, vocab


def encode(word, merges):
    """用学到的合并规则,按顺序把一个新词切成 token"""
    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


if __name__ == "__main__":
    merges, vocab = train_bpe(CORPUS, num_merges=10)
    print("\n学到的合并规则数:", len(merges))
    print("最终词表:", sorted({s for word in vocab for s in word}))
    for w in ("low", "lowest", "newer", "widely"):
        print(f"{w:8s} -> {encode(w, merges)}")

✅ Checkpoint 2:python bpe.py,真实输出:

第1次合并: 'e' + 's' -> 'es' (出现9次)
第2次合并: 'es' + 't' -> 'est' (出现9次)
第3次合并: 'est' + '</w>' -> 'est</w>' (出现9次)
第4次合并: 'l' + 'o' -> 'lo' (出现7次)
第5次合并: 'lo' + 'w' -> 'low' (出现7次)
第6次合并: 'n' + 'e' -> 'ne' (出现6次)
第7次合并: 'ne' + 'w' -> 'new' (出现6次)
第8次合并: 'new' + 'est</w>' -> 'newest</w>' (出现6次)
第9次合并: 'low' + '</w>' -> 'low</w>' (出现5次)
第10次合并: 'w' + 'i' -> 'wi' (出现3次)

学到的合并规则数: 10
最终词表: ['</w>', 'd', 'e', 'est</w>', 'low', 'low</w>', 'newest</w>', 'r', 'wi']
low      -> ['low</w>']
lowest   -> ['low', 'est</w>']
newer    -> ['new', 'e', 'r', '</w>']
widely   -> ['wi', 'd', 'e', 'l', 'y', '</w>']

这几行输出解释了你在 API 账单上看到的一切:

  • low 是 1 个 token,widely 是 6 个 token。 同样的字符数,token 数能差 6 倍——因为 low 在语料里高频出现被合并成了一个符号,widely 里的 dely 从没见过,只能一个字符一个 token。这就是为什么中文常常比英文贵:英文常见词在词表里是完整一个 token,中文很多字要拆成 2~3 个 byte 级 token。
  • lowest 被切成 ['low', 'est</w>']——语料里从来没出现过 lowest 这个词,但模型照样能处理它。 这是 BPE 最大的价值:用有限的词表覆盖无限的新词(专业术语、拼写错误、火星文都能切),不会出现"这个词不在词表里,我不认识"的死局。
  • 合并顺序必须严格按学习顺序回放(encode 里那个 for pair in merges),顺序错了切出来的碎片就不一样。

名词补课——</w> 那个词尾标记:不加它的话,low 这个符号既可能是完整单词 "low",也可能是 "lower" 的开头,模型分不清。加了 </w> 就区分开了:low</w> 是完整的词,low 是词的前缀。真实的 GPT 用 Ġ(表示前面有空格)做类似的事。

改一改(必做):把 encode(w, merges) 改成 encode(w, list(reversed(merges))),也就是把合并规则倒着回放。实测对比:

词 正序回放(正确) 逆序回放(错的)
newest ['newest</w>'] — 1 个 token ['ne', 'w', 'es', 't', '</w>'] — 5 个 token
lowest ['low', 'est</w>'] — 2 个 token ['lo', 'w', 'es', 't', '</w>'] — 5 个 token

同一个词,同一张词表,只因为回放顺序错了,token 数从 1 变成 5。 这就是为什么换模型必须换配套的分词器:分词器和模型是一对绑定的钥匙,模型是按"newest 是 1 个 token"这个前提训练出来的,你喂给它 5 个碎片,它看到的是一个从没见过的陌生词。这条在坑 1 里还会再遇到一次,那是它在真实项目里的杀人现场。

实验三:纯 numpy 手写自注意力(约60行)

1.3 节说"所有词互相对眼神",这个实验把那句话变成能打印出来的数字。只需要 numpy,不需要 PyTorch。

pip install numpy

手敲 attention.py:

"""实验三:纯 numpy 手写自注意力 + 多头注意力。
只依赖 numpy,不需要 torch、不需要密钥。
"""
import numpy as np


def softmax(x, axis=-1):
    """减最大值再 exp,防止指数爆炸(数值稳定性技巧)"""
    x = x - x.max(axis=axis, keepdims=True)
    e = np.exp(x)
    return e / e.sum(axis=axis, keepdims=True)


def attention(Q, K, V, mask=None):
    """自注意力的全部数学:打分 → 缩放 → 遮挡 → 归一化 → 加权求和"""
    d_k = Q.shape[-1]
    scores = Q @ K.T / np.sqrt(d_k)          # ① 每个词对所有词打分,除以 √d_k 防饱和
    if mask is not None:
        scores = np.where(mask, scores, -1e9)  # ② 不该看的位置打成负无穷
    weights = softmax(scores)                # ③ 把分数变成和为 1 的权重
    return weights @ V, weights              # ④ 按权重把信息混合起来


def causal_mask(n):
    """下三角为 True:第 i 个词只能看 0..i,看不到未来"""
    return np.tril(np.ones((n, n), dtype=bool))


def multi_head_attention(X, Wq, Wk, Wv, Wo, num_heads, mask=None):
    """多头 = 把 d_model 维切成 num_heads 份,各算一次注意力再拼回来"""
    n, d_model = X.shape
    d_head = d_model // num_heads
    Q, K, V = X @ Wq, X @ Wk, X @ Wv
    heads = []
    for h in range(num_heads):
        s = slice(h * d_head, (h + 1) * d_head)
        out, _ = attention(Q[:, s], K[:, s], V[:, s], mask)
        heads.append(out)
    return np.concatenate(heads, axis=-1) @ Wo

这 20 行就是 Transformer 的心脏。 逐句对照 1.3 节的比喻:

代码 数学 1.3 节的比喻
Q @ K.T 每个词的 Query 和所有词的 Key 做点积 我拿着我的"检索需求",去问每本书的"标签"有多匹配
/ np.sqrt(d_k) 除以维度的平方根 维度越高点积越大,不缩放的话 softmax 会饱和成 0/1,梯度就没了
np.where(mask, scores, -1e9) 不该看的位置设成负无穷 GPT 做接龙,不能偷看后面的答案
softmax(scores) 归一化成和为 1 的权重 把"匹配度"变成"注意力预算怎么分配"
weights @ V 按权重把 Value 加权求和 按分配好的注意力,把各本书的内容混合成我要的信息

追加自检部分:

if __name__ == "__main__":
    rng = np.random.default_rng(0)
    tokens = ["我", "爱", "吃", "苹果"]
    n, d_model, num_heads = len(tokens), 8, 2

    X = rng.normal(size=(n, d_model))         # 假装这是词嵌入
    Wq, Wk, Wv, Wo = (rng.normal(size=(d_model, d_model)) * 0.3 for _ in range(4))

    print("===== 单头自注意力(不遮挡:每个词能看到所有词) =====")
    out, w = attention(X @ Wq, X @ Wk, X @ Wv)
    for i, t in enumerate(tokens):
        pairs = ", ".join(f"{tokens[j]}:{w[i, j]:.2f}" for j in range(n))
        print(f"  {t} 的注意力分布 -> {pairs}   (合计 {w[i].sum():.2f})")

    print("\n===== 加因果遮挡(GPT 的做法:看不到未来) =====")
    out2, w2 = attention(X @ Wq, X @ Wk, X @ Wv, mask=causal_mask(n))
    for i, t in enumerate(tokens):
        pairs = ", ".join(f"{tokens[j]}:{w2[i, j]:.2f}" for j in range(n))
        print(f"  {t} -> {pairs}")

    print("\n===== 多头注意力 =====")
    mh = multi_head_attention(X, Wq, Wk, Wv, Wo, num_heads, mask=causal_mask(n))
    print("  输出形状:", mh.shape, "(和输入一样,所以能一层层堆起来)")

    # 自检:第一个词只能看自己,权重必须是 1.0;每行权重之和必须是 1
    assert abs(w2[0, 0] - 1.0) < 1e-9, "因果遮挡失效"
    assert np.allclose(w2.sum(axis=-1), 1.0), "softmax 没归一化"
    assert np.allclose(w2[np.triu_indices(n, k=1)], 0.0, atol=1e-9), "看到未来了"
    print("\n全部自检通过 ✅")

✅ Checkpoint 3:python attention.py,真实输出:

===== 单头自注意力(不遮挡:每个词能看到所有词) =====
  我 的注意力分布 -> 我:0.32, 爱:0.16, 吃:0.31, 苹果:0.21   (合计 1.00)
  爱 的注意力分布 -> 我:0.67, 爱:0.01, 吃:0.10, 苹果:0.22   (合计 1.00)
  吃 的注意力分布 -> 我:0.25, 爱:0.27, 吃:0.28, 苹果:0.20   (合计 1.00)
  苹果 的注意力分布 -> 我:0.23, 爱:0.27, 吃:0.22, 苹果:0.28   (合计 1.00)

===== 加因果遮挡(GPT 的做法:看不到未来) =====
  我 -> 我:1.00, 爱:0.00, 吃:0.00, 苹果:0.00
  爱 -> 我:0.98, 爱:0.02, 吃:0.00, 苹果:0.00
  吃 -> 我:0.32, 爱:0.34, 吃:0.35, 苹果:0.00
  苹果 -> 我:0.23, 爱:0.27, 吃:0.22, 苹果:0.28

===== 多头注意力 =====
  输出形状: (4, 8) (和输入一样,所以能一层层堆起来)

全部自检通过 ✅

这段输出里有三个值得记一辈子的观察:

  1. 遮挡后那个漂亮的三角形。 第一个词"我"只能看自己(1.00),第二个词"爱"能看两个,第四个词能看全部——这就是 1.4 节"Decoder-Only + 自回归"的物理形态。也正因为如此,模型必须一个 token 一个 token 地往外蹦(每多一个词,三角形就多一行)。
  2. 权重是"随机初始化"下的随机分布,每行合计恒为 1.00。 注意力不是玄学,它就是一个词把 100% 的注意力预算分配给所有词。真实模型里这些分配是训练出来的(比如"苹果"会把大量注意力给"吃"),这里没训练所以是随机的——但机制一模一样。
  3. 输出形状和输入形状相同 (4, 8)。 这是 Transformer 能堆几十层的根本原因:每一层的输出可以直接当下一层的输入。GPT-3 有 96 层,就是这个积木摞了 96 次。

面试常问:"为什么要除以 √d_k?" 标准答案:点积的方差随维度线性增长,d_k=64 时点积能到几十,softmax 之后几乎变成 one-hot(某一项 0.999,其他全 0),梯度趋近于 0 就学不动了。除以 √d_k 把方差拉回 1 附近。

改一改(必做,这个实验比背答案有用得多):把 scores = Q @ K.T / np.sqrt(d_k) 里的 / np.sqrt(d_k) 删掉,同时把自检部分的 * 0.3 改成 * 3(放大 Q/K 的取值范围,模拟真实模型里 d_k=64~128 的量级)。改完再跑一次,单头那一段的实测输出变成:

===== 单头自注意力(不遮挡:每个词能看到所有词) =====
  我 的注意力分布 -> 我:1.00, 爱:0.00, 吃:0.00, 苹果:0.00   (合计 1.00)
  爱 的注意力分布 -> 我:1.00, 爱:0.00, 吃:0.00, 苹果:0.00   (合计 1.00)
  吃 的注意力分布 -> 我:0.00, 爱:0.00, 吃:1.00, 苹果:0.00   (合计 1.00)
  苹果 的注意力分布 -> 我:0.00, 爱:0.00, 吃:0.00, 苹果:1.00   (合计 1.00)

和上面 Checkpoint 3 里 我:0.32, 爱:0.16, 吃:0.31, 苹果:0.21 那一行对比着看。 那个 1.00 不是"约等于 1",实测 w.max() 打印出来就是 1.0 ——其他候选被压成了真正的 0。一个词把 100% 的注意力全押在某一个词上,等于"只看这一个、其他一律无视",自注意力退化成了硬选择。更致命的是训练:softmax 输出 1.0 和 0.0 时导数就是 0,参数收不到任何梯度,这一层从此不再学习。这就是 /√d_k 这个看起来无关痛痒的除法真正在防的事。

(顺带注意:三条 assert 自检照样全部通过——因果遮挡没坏、每行和还是 1。能跑通、不报错、断言全绿,数学上却已经废了,这种 bug 才是最难查的。改完记得改回来。)

实验四:采样参数实验室 —— 亲手实现四个旋钮(完全离线,约100行)

2.3 节那张表里的四个旋钮,这个实验让你全部亲手实现一遍。关键点:采样发生在模型算完之后,它只是从"一堆候选和它们的概率"里挑一个,跟模型内部一点关系都没有。所以我们完全不需要真模型——手工写一组分数就能把四个旋钮的行为看得一清二楚。

为什么不直接调 API 看?因为调 API 你只能看到结果(三次回答像不像),看不到分布。分布才是理解这四个参数的地方。等你把这个实验做完,再去调 API 就是验证而不是猜谜了。

手敲 sampling_lab.py:

"""实验四:采样参数实验室(离线版)。
不联网、不需要密钥、只用标准库。
我们手工造一个"模型算出来的概率分布",然后亲手实现 temperature / top_k / top_p /
frequency_penalty 四个旋钮,看它们各自把分布改成了什么样。
"""
import math
import random

# 假设模型读完"今天天气真",算出了这 6 个候选的原始分数(术语叫 logits)
PROMPT = "今天天气真"
LOGITS = {"好": 4.0, "不错": 3.2, "棒": 2.5, "差": 1.0, "糟": 0.2, "蓝": -1.5}


def softmax(logits: dict, temperature: float = 1.0) -> dict:
    """把原始分数变成和为 1 的概率。temperature 就是除在分数上的那个数"""
    t = max(temperature, 1e-6)                  # 防止除以 0
    scaled = {k: v / t for k, v in logits.items()}
    top = max(scaled.values())                  # 减最大值防指数爆炸
    exp = {k: math.exp(v - top) for k, v in scaled.items()}
    total = sum(exp.values())
    return {k: v / total for k, v in exp.items()}


def top_k_filter(probs: dict, k: int) -> dict:
    """只保留概率最高的 k 个候选,剩下的直接扔掉,再重新归一化"""
    kept = dict(sorted(probs.items(), key=lambda p: p[1], reverse=True)[:k])
    total = sum(kept.values())
    return {key: v / total for key, v in kept.items()}


def top_p_filter(probs: dict, p: float) -> dict:
    """从高到低累加,累计概率刚超过 p 就停,后面的全扔掉(所以候选个数是浮动的)"""
    kept, acc = {}, 0.0
    for key, v in sorted(probs.items(), key=lambda x: x[1], reverse=True):
        kept[key] = v
        acc += v
        if acc >= p:
            break
    total = sum(kept.values())
    return {key: v / total for key, v in kept.items()}


def show(title: str, probs: dict) -> None:
    line = ", ".join(f"{k}:{v:.3f}" for k, v in
                     sorted(probs.items(), key=lambda p: p[1], reverse=True))
    print(f"  {title:<22s} {line}")

采样和"复读机"两段:

def sample(probs: dict, rng: random.Random) -> str:
    keys = list(probs)
    return rng.choices(keys, weights=[probs[k] for k in keys], k=1)[0]


def greedy_with_penalty(logits: dict, steps: int, penalty: float) -> str:
    """贪心生成 steps 个 token。penalty>0 时,每选过一次就把它的分数扣掉 penalty"""
    used, out = {}, []
    for _ in range(steps):
        adjusted = {k: v - penalty * used.get(k, 0) for k, v in logits.items()}
        pick = max(adjusted, key=lambda k: adjusted[k])
        used[pick] = used.get(pick, 0) + 1
        out.append(pick)
    return "".join(out)


if __name__ == "__main__":
    print(f"提示词:{PROMPT}___   原始分数:{LOGITS}")

    print("\n===== 旋钮一:temperature(改变分布的陡峭程度)=====")
    for t in (0.2, 0.7, 1.0, 1.5, 3.0):
        show(f"temperature={t}", softmax(LOGITS, t))

    base = softmax(LOGITS, 1.0)
    print("\n===== 旋钮二:top_k(固定留几个)=====")
    for k in (1, 2, 3):
        show(f"top_k={k}", top_k_filter(base, k))

    print("\n===== 旋钮三:top_p(留到累计概率够为止,个数浮动)=====")
    for p in (0.5, 0.9, 0.99):
        kept = top_p_filter(base, p)
        show(f"top_p={p}(留{len(kept)}个)", kept)

    print("\n===== 三种配置各采样 200 次,看实际选中的分布 =====")
    configs = [("temperature=0.2", softmax(LOGITS, 0.2)),
               ("temperature=1.0", base),
               ("temperature=1.5", softmax(LOGITS, 1.5)),
               ("t=1.0 + top_k=2", top_k_filter(base, 2))]
    for name, probs in configs:
        rng = random.Random(42)
        counter = {}
        for _ in range(200):
            pick = sample(probs, rng)
            counter[pick] = counter.get(pick, 0) + 1
        stat = ", ".join(f"{k}x{v}" for k, v in
                         sorted(counter.items(), key=lambda p: p[1], reverse=True))
        print(f"  {name:<22s} {stat}")

    print("\n===== 旋钮四:frequency_penalty(治复读机)=====")
    for pen in (0.0, 1.0, 2.0):
        print(f"  penalty={pen} -> {greedy_with_penalty(LOGITS, 6, pen)}")

✅ Checkpoint 4:python sampling_lab.py,真实输出:

提示词:今天天气真___   原始分数:{'好': 4.0, '不错': 3.2, '棒': 2.5, '差': 1.0, '糟': 0.2, '蓝': -1.5}

===== 旋钮一:temperature(改变分布的陡峭程度)=====
  temperature=0.2        好:0.981, 不错:0.018, 棒:0.001, 差:0.000, 糟:0.000, 蓝:0.000
  temperature=0.7        好:0.687, 不错:0.219, 棒:0.081, 差:0.009, 糟:0.003, 蓝:0.000
  temperature=1.0        好:0.572, 不错:0.257, 棒:0.128, 差:0.028, 糟:0.013, 蓝:0.002
  temperature=1.5        好:0.456, 不错:0.267, 棒:0.168, 差:0.062, 糟:0.036, 蓝:0.012
  temperature=3.0        好:0.314, 不错:0.241, 棒:0.191, 差:0.116, 糟:0.089, 蓝:0.050

===== 旋钮二:top_k(固定留几个)=====
  top_k=1                好:1.000
  top_k=2                好:0.690, 不错:0.310
  top_k=3                好:0.598, 不错:0.269, 棒:0.133

===== 旋钮三:top_p(留到累计概率够为止,个数浮动)=====
  top_p=0.5(留1个)         好:1.000
  top_p=0.9(留3个)         好:0.598, 不错:0.269, 棒:0.133
  top_p=0.99(留5个)        好:0.573, 不错:0.258, 棒:0.128, 差:0.029, 糟:0.013

===== 三种配置各采样 200 次,看实际选中的分布 =====
  temperature=0.2        好x194, 不错x6
  temperature=1.0        好x120, 不错x43, 棒x28, 差x4, 糟x4, 蓝x1
  temperature=1.5        好x98, 不错x49, 棒x32, 差x11, 糟x5, 蓝x5
  t=1.0 + top_k=2        好x144, 不错x56

===== 旋钮四:frequency_penalty(治复读机)=====
  penalty=0.0 -> 好好好好好好
  penalty=1.0 -> 好不错好棒不错好
  penalty=2.0 -> 好不错棒好不错差

这份输出把 2.3 节那张表全部兑现了,五个观察:

  1. temperature 从 0.2 到 3.0,"蓝"的概率从 0.000 涨到 0.050。 "今天天气真蓝"这种病句,在低温下几乎不可能出现,高温下每 20 次就来一次。"高温有创意"和"高温会跑飞"是同一件事的两种说法,你调的不是创意,是冷门选项的中签率。
  2. temperature=0.2 那一行 好:0.981,已经很接近贪心了但仍不是贪心。 200 次采样里还是有 6 次选了"不错"。这就是坑 3 要讲的:低温 ≠ 确定性。
  3. top_k=3 和 top_p=0.9 这次留下的是同样三个词、同样的概率(0.598 / 0.269 / 0.133)——但这只是巧合。 top_k 永远留 3 个;top_p 留几个看分布:0.5 只留 1 个,0.99 留 5 个。分布陡的时候 top_p 自动收紧,分布平的时候自动放宽,这就是它比 top_k 更受欢迎的原因。
  4. t=1.0 + top_k=2 采样 200 次的结果是 好x144, 不错x56,"棒"一次都没出现。 对比纯 temperature=1.0 的 好x120/不错x43/棒x28/差x4/糟x4/蓝x1——top_k 做的是硬性划线:线外的候选概率再高也是 0,永远不可能被选中。temperature 只是调整概率,从不把谁归零。这是两者本质的区别。
  5. penalty=0.0 那行 好好好好好好 就是复读机的真身。 贪心生成永远选分数最高的,分数不变,所以永远选同一个。加上 penalty=1.0,"好"被选一次后分数从 4.0 掉到 3.0,低于"不错"的 3.2,于是换人——输出变成了 好不错好棒不错好。"复读机"不是模型坏了,而是贪心解码的必然结果,frequency_penalty 就是硬给它扣分逼它换词。

接一次真模型验证(可选,需要密钥):把上面的手写 softmax 换成真 API,client.chat.completions.create(..., temperature=temp) 跑三个温度各三次。temperature=0 的三次几乎一字不差,0.7 各有变化但都通顺,1.5 开始放飞。另外打印一下 resp.usage.prompt_tokens 和 completion_tokens,这就是你这次调用花的"字数钱"——2.2 节的计费直觉,看一眼账单单位比看十遍文档管用。书里不贴这一步的输出:接你自己的密钥、你自己的模型,跑出来的才算数。

实验五:在自己电脑上跑一个真正的开源大模型(可选,需下载约 1GB)

原书选用了阿里开源的 5 亿参数小模型 Qwen/Qwen1.5-0.5B-Chat,普通笔记本(无独立显卡)也能跑。先安装:

pip install transformers torch

国内网络下载 Hugging Face 模型容易超时,先在终端执行 export HF_ENDPOINT=https://hf-mirror.com(Windows PowerShell 用 $env:HF_ENDPOINT="https://hf-mirror.com")切到国内镜像——这个坑在 docs/2 第 9 篇有详细排错说明。

手敲 local_llm.py:

from transformers import AutoModelForCausalLM, AutoTokenizer

model_id = "Qwen/Qwen1.5-0.5B-Chat"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(model_id)

messages = [{"role": "user", "content": "用一句话介绍你自己。"}]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)

# 看看模型眼里的世界:文字被切成了哪些 Token
ids = tokenizer(text).input_ids
print(f"提示词被切成 {len(ids)} 个 Token")
print("前10个碎片:", [tokenizer.decode([i]) for i in ids[:10]])

inputs = tokenizer(text, return_tensors="pt")
outputs = model.generate(**inputs, max_new_tokens=100, do_sample=True, temperature=0.7)
reply = tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
print("模型回答:", reply)

验收标准(这一步依赖你的网络和硬盘,书里不贴假输出,只说你该看到什么):

  1. 第一次运行会下载约 1GB 模型文件,进度条走完之前什么都不会打印,耐心等,不要以为卡死了;
  2. 然后你会看到两行:提示词被切成了多少个 Token、前 10 个碎片长什么样。重点看这一行——你会发现里面有 <|im_start|>、system 这种你没输入过的东西,那是 apply_chat_template 给你自动加上的聊天模板(告诉模型"现在是对话、这句是用户说的"),它也算钱;
  3. 最后是一句自我介绍。0.5B 模型的回答质量一般,这正是重点——拿它和 DeepSeek 的回答放在一起对比,你就直观理解了"缩放法则"四个字的分量:同样的架构、同样的训练方法,只是参数量差了三个数量级。

跑不动也完全不影响后面的章节:这一章的知识点已经被前四个实验兑现完了,实验五只是让你摸一次真模型的手感。


踩坑与专家提示

坑 1:分词器和模型必须成对,换一个就全错位

实验二的"改一改"已经让你看到:同一张词表,只把合并顺序倒过来,newest 就从 1 个 token 变成 5 个。真实项目里这个坑长这样:你用 A 模型的分词器算 token 数、做截断,却把结果喂给 B 模型。轻则计费数字对不上,重则截断截在半个字符中间,中文直接变乱码。

做法:分词器永远用 AutoTokenizer.from_pretrained(同一个 model_id) 拿,不要跨模型复用,不要自己写一套"大概 1 个汉字 1 个 token"去做硬截断。

坑 2:用"字数 ÷ 1.5"这类土办法估 token,迟早爆上下文

2.2 节给的 "1 个汉字 ≈ 0.6~1 个 Token" 是建立直觉用的,不是拿来做工程判断的。同一段中文,不同模型的分词器能差出 30% 以上;代码、JSON、表格、emoji、生僻字的膨胀比更离谱(一个 emoji 常常是 3~4 个 token)。

做法:要精确就用官方分词器实测(len(tokenizer(text).input_ids)),或者读 API 返回里的 resp.usage。真要估算,留 20% 余量,并且在拼提示词的地方就把长度检查做进去,不要等 API 报 context_length_exceeded。

坑 3:temperature=0 不等于"每次结果完全一样"

这是最常被误解的一条。实验四的 Checkpoint 里,temperature=0.2 的分布已经是 好:0.981,但 200 次采样里仍有 6 次选了"不错"。而真实 API 上,即使你写 temperature=0,两次调用也可能给出不同结果:GPU 浮点运算的累加顺序不固定、批处理时同批请求会互相影响、服务端可能悄悄换了模型版本或量化方式。

做法:temperature=0 的正确理解是"尽量确定",不是"保证复现"。需要真正可复现的场景(比如回归测试),要么把模型输出缓存下来,要么在断言里只校验结构和关键字段,不要逐字比对整段文本。

坑 4:temperature 和 top_p 一起猛调,等于两个人抢方向盘

见过太多人把 temperature=1.5 和 top_p=0.5 一起写上,想"既有创意又靠谱"。实际发生的是:temperature 先把分布拉平(实验四里"蓝"从 0.002 涨到 0.012),top_p 再把尾巴一刀切掉。两个操作互相抵消,你根本说不清最终留下的是什么。

做法:一次只动一个。 默认 top_p=1.0,只调 temperature;或者固定 temperature=1.0,只调 top_p。要稳定输出就 temperature=0,要创意就 0.7~0.9,别的先别碰。

坑 5:frequency_penalty 调大了会把该重复的词也惩罚掉

实验四里 penalty=2.0 的输出是 好不错棒好不错差——为了不重复,它已经开始说"差"了。真实场景里这个副作用更明显:让模型写技术文档,产品名、函数名、专业术语本来就该反复出现,惩罚一大它就开始换着说法指同一个东西,读起来像在硬凑字数,甚至换出错误的名字。

做法:frequency_penalty 从 0 开始,真出现复读再往上加 0.2~0.5,一般不超过 1.0。复读的根因常常不是参数而是提示词(任务描述含糊、示例本身就在重复),先改提示词。

坑 6:提示词里的"要求"越多,被无视的越多

2.1 节的四个技巧都有效,但堆在一起就会打架。一条提示词里塞了角色、十条规则、三个示例、JSON 格式、字数限制,模型往往只满足最靠前和最靠后的几条——中间的要求会被"看丢"(业内叫 lost in the middle,详见第九章上下文工程)。

做法:把最硬的约束(输出格式、绝对禁止项)放在提示词的最后一句,那是模型注意力最重的位置;规则超过五条就拆成多次调用,或者改用代码做校验和重试,不要指望一句话搞定所有事。

坑 7:把"能力涌现"当成"什么都能干",把幻觉当成 bug 去修

3.1 节说的涌现是真的,但它常被过度解读成"模型规模够大就没有短板"。3.2 节说的幻觉也是真的,但它常被当成一个可以彻底修掉的缺陷。这两个误解会一起导致同一种失败:把不该交给模型的任务交给模型——精确算数、查实时数据、法律医疗结论。

做法:凡是"错了要负责"的信息,一律走工具或检索(第四章工具调用、第八章 RAG),让模型只负责组织语言;凡是需要精确的计算,交给代码。判断标准很简单:这个答案错了会不会有人受损?会,就不要让模型自己编。

安全提示——两条底线。第一,API 密钥永远只放 .env 并加进 .gitignore,不要写在代码里、不要贴进聊天窗口、不要提交到仓库(密钥泄露被人跑爆账单是真实发生过的事,GitHub 上的密钥扫描机器人是分钟级的)。第二,别把敏感数据发给闭源 API:2.4 节说了"数据要出门",客户名单、身份证号、内部代码发出去就收不回来了,这类场景走自部署开源模型(docs/2 第 15 篇)。


面试视角:本章高频六问

  1. "讲讲 Transformer 的自注意力"——答 Q/K/V 图书馆检索比喻(Q 是"我想找什么",K 是"我是什么标签",V 是"我的实际内容")+ "每个词对所有词打分再加权求和" + 并行优势(相比 RNN 不用逐词传递)。追问多头注意力:把维度切成几份,各组关注不同角度(语法/语义)再拼回来。
  2. "为什么要除以 √d_k?"——答点积方差随维度线性增长,不缩放会让 softmax 饱和成 one-hot、梯度趋 0。加一句"我自己动手删掉这个除法验证过:注意力权重直接变成 1.00 和 0.00,梯度消失,但三条断言居然还全部通过",这是能立刻和背答案的人区分开的表述。
  3. "temperature、top_k、top_p 有什么区别?"——答:temperature 改的是分布形状(拉平或锐化),从不把任何候选归零;top_k / top_p 改的是候选集合,线外的候选概率直接变 0。top_k 固定留几个,top_p 按累计概率留,分布陡时自动收紧、平时自动放宽,所以实践中更常用。补一句实战:一次只调一个,结构化输出用 temperature=0。
  4. "temperature=0 能保证每次输出一样吗?"——这是道陷阱题,答不能:它只保证"每步选概率最高的",但浮点累加顺序、批处理、服务端版本变更都会让结果漂移。能答出这条的人,一眼就看得出是真跑过线上服务的。
  5. "Token 是什么?为什么中文常常比英文贵?"——答 BPE 从字符开始反复合并高频相邻对;英文常见词在词表里是完整一个 token,中文很多字要拆成 2~3 个 byte 级 token。举实验二的实测:low 是 1 个 token,widely 是 6 个,同样字符数差 6 倍。再补 BPE 的核心价值:用有限词表覆盖无限新词,永远不会"这个词我不认识"。
  6. "为什么会有幻觉,怎么办?"——答本质(统计接龙不查证,遇到训练数据里没有的就选一个"最顺"的)+ 四板斧(RAG / 工具 / 要出处 / 低温)+ 兜底(人审)。最好再加一句边界判断:"错了要负责的信息一律不让模型自己编"。更深入的追问见 docs/2 第 17 篇第 1 节。

划重点

  1. 语言模型进化三步:N-gram 数数(看不远)→ RNN 传话(会失真、不能并行)→ Transformer 全局对眼神(又远又快)。
  2. N-gram 的死法你已经亲手量过:n 从 2 加到 7,"只有唯一候选的前缀"从 59% 涨到 97%——记得越远就越只会背书,而且没见过的前缀一个字都生成不出来。Transformer 赢在不查表、算相关度。
  3. GPT 们是 Decoder-Only + 自回归:一个 Token 一个 Token 地接龙,所以逐字蹦、越长越贵。因果遮挡打印出来就是实验三那个漂亮的下三角。
  4. 注意力就是"每个词把 100% 的注意力预算分配给所有词",每行权重之和恒为 1.00;输出形状和输入相同,所以能一层层堆几十层。/√d_k 不是装饰:删掉它权重会崩成 1.00/0.00,梯度消失——而且断言照样全绿。
  5. Token 是计费和上下文窗口的唯一单位,由 BPE 切出来。分词器和模型是绑定的一对,顺序或版本对不上,newest 就从 1 个 token 变成 5 个。别用"字数 ÷ 1.5"估 token。
  6. 采样的四个旋钮分两类:temperature 改分布形状(从不归零任何候选),top_k / top_p 改候选集合(线外直接归零);frequency_penalty 治的是贪心解码必然产生的复读,不是模型坏了。一次只调一个。
  7. temperature=0 只是"尽量确定",不是"保证复现"——这条是面试和线上排障的双料高频。
  8. 缩放法则解释能力从哪来(大力出奇迹 + 涌现),幻觉是统计本质决定的天生缺陷,只能缓解不能根治。判断边界:错了会有人受损的信息,一律走工具或检索,不让模型自己编。
  9. 五个实验里①②③④ 全部离线、一分钱不花且必做:它们把"数数式模型的天花板""token 怎么来的""注意力到底算什么""四个采样旋钮各自在动什么"变成了你亲手打印出来的数字;⑤ 本地跑 Qwen 是可选加餐,用来建立"模型尺寸 = 能力"的直觉。

下一章预告:大脑(LLM)已经备好,第四章正式给它装上手脚——亲手敲出 ReAct、Plan-and-Solve、Reflection 三大经典智能体范式,这是全书代码含金量最高的章节之一。

本页由 Markdown 自动渲染 · 共 628 篇笔记