第三章 大语言模型基础
一句话总结
大语言模型的本质是一台"超级文字接龙机":它把你的话切成一个个小碎片(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 提示工程:把"说明书"写清楚的手艺
模型能力是固定的,但你问的方式决定它发挥几成功力。几个被反复验证有效的技巧:
- 给角色:"你是一位资深法律顾问"比裸问效果好——相当于把模型脑中庞大知识库先翻到对应章节;
- 给示例(Few-Shot):直接在提示词里放两三个"输入→期望输出"的例子,模型照葫芦画瓢,格式和风格立刻稳定;
- 让它打草稿(思维链,Chain-of-Thought):加一句"请一步步思考",让它把推理过程写出来再给答案,复杂题正确率大幅上升——原理是每一步生成的文字又成了下一步的输入,相当于给它草稿纸;
- 给格式和边界:"用 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.00),第二个词"爱"能看两个,第四个词能看全部——这就是 1.4 节"Decoder-Only + 自回归"的物理形态。也正因为如此,模型必须一个 token 一个 token 地往外蹦(每多一个词,三角形就多一行)。
- 权重是"随机初始化"下的随机分布,每行合计恒为 1.00。 注意力不是玄学,它就是一个词把 100% 的注意力预算分配给所有词。真实模型里这些分配是训练出来的(比如"苹果"会把大量注意力给"吃"),这里没训练所以是随机的——但机制一模一样。
- 输出形状和输入形状相同
(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 节那张表全部兑现了,五个观察:
- temperature 从 0.2 到 3.0,"蓝"的概率从 0.000 涨到 0.050。 "今天天气真蓝"这种病句,在低温下几乎不可能出现,高温下每 20 次就来一次。"高温有创意"和"高温会跑飞"是同一件事的两种说法,你调的不是创意,是冷门选项的中签率。
temperature=0.2那一行好:0.981,已经很接近贪心了但仍不是贪心。 200 次采样里还是有 6 次选了"不错"。这就是坑 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 更受欢迎的原因。t=1.0 + top_k=2采样 200 次的结果是 好x144, 不错x56,"棒"一次都没出现。 对比纯 temperature=1.0 的 好x120/不错x43/棒x28/差x4/糟x4/蓝x1——top_k 做的是硬性划线:线外的候选概率再高也是 0,永远不可能被选中。temperature 只是调整概率,从不把谁归零。这是两者本质的区别。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)
验收标准(这一步依赖你的网络和硬盘,书里不贴假输出,只说你该看到什么):
- 第一次运行会下载约 1GB 模型文件,进度条走完之前什么都不会打印,耐心等,不要以为卡死了;
- 然后你会看到两行:提示词被切成了多少个 Token、前 10 个碎片长什么样。重点看这一行——你会发现里面有
<|im_start|>、system这种你没输入过的东西,那是apply_chat_template给你自动加上的聊天模板(告诉模型"现在是对话、这句是用户说的"),它也算钱; - 最后是一句自我介绍。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 篇)。
面试视角:本章高频六问
- "讲讲 Transformer 的自注意力"——答 Q/K/V 图书馆检索比喻(Q 是"我想找什么",K 是"我是什么标签",V 是"我的实际内容")+ "每个词对所有词打分再加权求和" + 并行优势(相比 RNN 不用逐词传递)。追问多头注意力:把维度切成几份,各组关注不同角度(语法/语义)再拼回来。
- "为什么要除以 √d_k?"——答点积方差随维度线性增长,不缩放会让 softmax 饱和成 one-hot、梯度趋 0。加一句"我自己动手删掉这个除法验证过:注意力权重直接变成 1.00 和 0.00,梯度消失,但三条断言居然还全部通过",这是能立刻和背答案的人区分开的表述。
- "temperature、top_k、top_p 有什么区别?"——答:temperature 改的是分布形状(拉平或锐化),从不把任何候选归零;top_k / top_p 改的是候选集合,线外的候选概率直接变 0。top_k 固定留几个,top_p 按累计概率留,分布陡时自动收紧、平时自动放宽,所以实践中更常用。补一句实战:一次只调一个,结构化输出用
temperature=0。 - "
temperature=0能保证每次输出一样吗?"——这是道陷阱题,答不能:它只保证"每步选概率最高的",但浮点累加顺序、批处理、服务端版本变更都会让结果漂移。能答出这条的人,一眼就看得出是真跑过线上服务的。 - "Token 是什么?为什么中文常常比英文贵?"——答 BPE 从字符开始反复合并高频相邻对;英文常见词在词表里是完整一个 token,中文很多字要拆成 2~3 个 byte 级 token。举实验二的实测:
low是 1 个 token,widely是 6 个,同样字符数差 6 倍。再补 BPE 的核心价值:用有限词表覆盖无限新词,永远不会"这个词我不认识"。 - "为什么会有幻觉,怎么办?"——答本质(统计接龙不查证,遇到训练数据里没有的就选一个"最顺"的)+ 四板斧(RAG / 工具 / 要出处 / 低温)+ 兜底(人审)。最好再加一句边界判断:"错了要负责的信息一律不让模型自己编"。更深入的追问见 docs/2 第 17 篇第 1 节。
划重点
- 语言模型进化三步:N-gram 数数(看不远)→ RNN 传话(会失真、不能并行)→ Transformer 全局对眼神(又远又快)。
- N-gram 的死法你已经亲手量过:n 从 2 加到 7,"只有唯一候选的前缀"从 59% 涨到 97%——记得越远就越只会背书,而且没见过的前缀一个字都生成不出来。Transformer 赢在不查表、算相关度。
- GPT 们是 Decoder-Only + 自回归:一个 Token 一个 Token 地接龙,所以逐字蹦、越长越贵。因果遮挡打印出来就是实验三那个漂亮的下三角。
- 注意力就是"每个词把 100% 的注意力预算分配给所有词",每行权重之和恒为 1.00;输出形状和输入相同,所以能一层层堆几十层。
/√d_k不是装饰:删掉它权重会崩成 1.00/0.00,梯度消失——而且断言照样全绿。 - Token 是计费和上下文窗口的唯一单位,由 BPE 切出来。分词器和模型是绑定的一对,顺序或版本对不上,
newest就从 1 个 token 变成 5 个。别用"字数 ÷ 1.5"估 token。 - 采样的四个旋钮分两类:temperature 改分布形状(从不归零任何候选),top_k / top_p 改候选集合(线外直接归零);frequency_penalty 治的是贪心解码必然产生的复读,不是模型坏了。一次只调一个。
temperature=0只是"尽量确定",不是"保证复现"——这条是面试和线上排障的双料高频。- 缩放法则解释能力从哪来(大力出奇迹 + 涌现),幻觉是统计本质决定的天生缺陷,只能缓解不能根治。判断边界:错了会有人受损的信息,一律走工具或检索,不让模型自己编。
- 五个实验里①②③④ 全部离线、一分钱不花且必做:它们把"数数式模型的天花板""token 怎么来的""注意力到底算什么""四个采样旋钮各自在动什么"变成了你亲手打印出来的数字;⑤ 本地跑 Qwen 是可选加餐,用来建立"模型尺寸 = 能力"的直觉。
下一章预告:大脑(LLM)已经备好,第四章正式给它装上手脚——亲手敲出 ReAct、Plan-and-Solve、Reflection 三大经典智能体范式,这是全书代码含金量最高的章节之一。