从原理到工程 · 从训练到落地

大模型工程师

从零基础到训练、对齐与应用落地

开源仓库地址:https://github.com/wei1305/llm-engineer-from-zero

在线网页阅读(支持目录跳转、公式渲染):大模型工程师:从零到实践 - 大模型工程师:从零到实践

有用请点 Star 收藏,作者会持续更新案例、补充面试题与实验代码!

面向零基础读者的系统教材
涵盖 Python、Transformer、RAG、Agent、训练、对齐、多模态、分布式与推理优化

十四章 · 四份附录 · 二十九幅结构图 · 十九份 Python 手写代码

2026 完整修订版

目录

前言:把大模型学成一套可验证的工程能力... 12

这本书适合谁... 12

第一次学习前的环境准备... 13

本书的代码分成三层... 14

怎样阅读公式和张量形状... 14

怎样阅读和调试代码... 14

实验记录模板... 15

关于论文、开源教材与视频... 15

安全、合规与知识边界... 16

第一章 Python、数学与神经网络:建立可计算的直觉... 17

1.1 Python:先掌握会反复出现的部分... 17

1.2 数学基础:向量、矩阵、概率和导数... 19

1.3 神经网络的基本组件... 20

1.4 NumPy 手写两层网络... 21

1.5 用 PyTorch 重写训练闭环... 22

1.6 环境、模型仓库与可复现性... 23

1.7 常见故障与练习... 24

1.8 延伸资源... 25

本章配套代码... 26

本章端到端实验:把知识变成可复现证据... 26

实验步骤... 26

验收标准... 26

本章自测... 27

第二章 Transformer 与 MoE:从矩阵计算到稀疏专家... 28

2.1 Token、嵌入与位置... 28

2.2 Self-Attention:每一步都写出形状... 29

2.3 Encoder、Decoder 与 Cross-Attention.. 31

2.4 FFN、残差和归一化... 32

2.5 从输入到下一个 token.. 33

2.6 MoE:容量扩大不等于计算同比扩大... 33

2.7 复杂度与常见误区... 35

2.8 练习与资料... 36

本章配套代码... 36

本章端到端实验:把知识变成可复现证据... 37

实验步骤... 37

验收标准... 37

本章自测... 37

第三章 Llama 与 DeepSeek:现代解码器的关键改造... 39

3.1 RMSNorm:只控制尺度... 39

3.2 RoPE:让相对位置信息进入点积... 40

3.3 MHA、MQA、GQA 与 KV Cache. 42

3.4 SwiGLU:门控前馈网络... 43

3.5 组装一个 Llama 风格 Block. 44

3.6 Llama 3 应该怎样读... 45

3.7 DeepSeek-V3:MoE、MLA、MTP 与系统协同... 45

3.8 DeepSeek-R1:推理能力来自后训练路线... 46

3.9 练习与资料... 47

本章配套代码... 48

本章端到端实验:把知识变成可复现证据... 48

实验步骤... 48

验收标准... 48

本章自测... 49

第四章 部署、压测与应用开发:把模型变成可靠服务... 50

4.1 先建立性能指标语言... 50

4.2 FastAPI:异步不等于计算更快... 51

4.3 vLLM 与连续批处理... 52

4.4 同步、异步和流式调用... 53

4.5 提示词工程与上下文工程... 54

4.6 结构化输出:验证比提示更重要... 55

4.7 Function Calling:模型提议,程序执行... 56

4.8 压测方法与容量规划... 57

4.9 上线检查清单... 57

本章配套代码... 58

本章端到端实验:把知识变成可复现证据... 58

实验步骤... 59

验收标准... 59

本章自测... 59

第五章 RAG 基础:让生成建立在可追溯证据上... 60

5.1 为什么不把所有知识都微调进去... 60

5.2 文档解析与切块... 61

5.3 Embedding 与相似度... 62

5.4 从检索结果到上下文... 63

5.5 LlamaIndex 等框架的角色... 64

5.6 一个从零 RAG 的最小接口... 65

5.7 评估检索,而不是只看最终回答... 66

5.8 常见失败... 67

本章配套代码... 67

本章端到端实验:把知识变成可复现证据... 68

实验步骤... 68

验收标准... 68

本章自测... 68

第六章 工业级 RAG:优化、评估与可运营系统... 70

6.1 查询变换... 70

6.2 混合检索与融合... 71

6.3 重排与上下文压缩... 72

6.4 自适应 RAG 与反馈回路... 73

6.5 多模态、表格与图 RAG.. 73

6.6 评估体系:从数据集到故障归因... 74

6.7 可运营架构... 75

6.8 成本与延迟优化... 76

6.9 项目表达:不要只写“搭建 RAG”... 77

6.10 实战路线... 77

本章配套代码... 78

本章端到端实验:把知识变成可复现证据... 78

实验步骤... 78

验收标准... 79

本章自测... 79

第七章 Agent:让模型在受控边界内观察、决策与行动... 80

7.1 Workflow 与 Agent. 80

7.2 一个最小工具循环... 81

7.3 写好工具契约... 82

7.4 规划、反思与停止... 83

7.5 记忆与上下文工程... 84

7.6 Deep Research 的工程结构... 85

7.7 MCP:标准化模型与外部能力的连接... 85

7.8 为什么 Agent 会失败... 86

7.9 多 Agent 不是默认答案... 87

7.10 评估与安全... 88

本章配套代码... 88

本章端到端实验:把知识变成可复现证据... 89

实验步骤... 89

验收标准... 89

本章自测... 89

第八章 预训练:数据、目标、规模与训练稳定性... 91

8.1 训练目标与架构... 91

8.2 数据获取与合规... 92

8.3 Tokenizer 训练... 93

8.4 Scaling Laws:用小实验规划大训练... 94

8.5 一个微型自回归模型... 95

8.6 数据打包与有效 token.. 96

8.7 优化器与学习率... 96

8.8 监控与异常处理... 97

8.9 评估与涌现... 98

8.10 从 Nano 模型走向真实预训练... 99

本章配套代码... 99

本章端到端实验:把知识变成可复现证据... 100

实验步骤... 100

验收标准... 100

本章自测... 100

第九章 微调:从数据设计到 LoRA/QLoRA 工程... 102

9.1 全量微调与参数高效微调... 102

9.2 QLoRA.. 103

9.3 指令数据格式... 104

9.4 使用 PEFT/TRL 的标准流程... 105

9.5 超参数与显存... 106

9.6 评估:与基础模型做成对比较... 107

9.7 常见失败... 108

9.8 一份可信的微调工程手册... 109

本章配套代码... 109

本章端到端实验:把知识变成可复现证据... 110

实验步骤... 110

验收标准... 110

本章自测... 110

第十章 偏好对齐:Reward Model、PPO、DPO 与 GRPO... 112

10.1 RLHF 基本流程... 112

10.2 PPO:受约束地更新策略... 113

10.3 DPO:从偏好对直接优化策略... 114

10.4 GRPO:组内相对优势... 115

10.5 奖励设计... 116

10.6 数据与训练管线... 116

10.7 评估对齐而不损伤能力... 117

10.8 选择方法... 118

本章配套代码... 119

本章端到端实验:把知识变成可复现证据... 119

实验步骤... 119

验收标准... 120

本章自测... 120

第十一章 多模态大模型:视觉编码、跨模态对齐与训练... 121

11.1 ViT:把图像变成 token 序列... 122

11.2 CLIP:图文对比学习... 123

11.3 BLIP/BLIP-2 与 Q-Former. 124

11.4 LLaVA 两阶段训练... 125

11.5 数据构建... 125

11.6 微调 Qwen-VL 类模型的通用步骤... 126

11.7 评估... 127

11.8 常见错误... 128

本章配套代码... 128

本章端到端实验:把知识变成可复现证据... 129

实验步骤... 129

验收标准... 129

本章自测... 129

第十二章 分布式训练:并行策略、通信与故障恢复... 131

12.1 DP 与 DDP.. 131

12.2 ZeRO 与 FSDP.. 132

12.3 模型并行... 134

12.4 集合通信... 135

12.5 混合精度... 135

12.6 Checkpoint 与断点续训... 136

12.7 Accelerate 与 DeepSpeed.. 137

12.8 性能诊断... 138

12.9 练习与资料... 138

本章配套代码... 139

本章端到端实验:把知识变成可复现证据... 139

实验步骤... 139

验收标准... 140

本章自测... 140

第十三章 推理优化:量化、蒸馏、KV Cache 与高效服务... 141

13.1 Prefill 与 Decode. 141

13.2 量化基础... 142

13.3 BNB、GPTQ、AWQ 与 GGUF. 143

13.4 知识蒸馏... 144

13.5 剪枝... 145

13.6 FlashAttention.. 145

13.7 KV Cache 与 PagedAttention.. 146

13.8 连续批处理与投机解码... 147

13.9 压测与回归... 147

13.10 选择指南... 148

本章配套代码... 149

本章端到端实验:把知识变成可复现证据... 149

实验步骤... 149

验收标准... 150

本章自测... 150

第十四章 面试专项:从简历证据到项目深挖... 151

14.1 先读岗位,而不是先刷题... 151

14.2 简历:每个数字都能被复现... 152

14.3 三分钟项目介绍... 153

14.4 高频基础题:回答骨架... 154

Transformer. 155

训练与微调... 155

RAG.. 155

Agent. 155

对齐与推理... 156

14.5 手撕代码题... 156

14.6 算法题准备... 157

14.7 系统设计题... 157

14.8 Bad case 深挖... 158

14.9 行为与协作... 159

14.10 模拟面试清单... 160

14.11 经验来源与使用方式... 160

本章配套代码... 161

本章端到端实验:把知识变成可复现证据... 161

实验步骤... 161

验收标准... 162

本章自测... 162

附录 A 从空电脑到可复现实验环境... 163

A.1 CPU、GPU 与显存分别负责什么... 163

A.2 创建虚拟环境... 163

A.3 安装 PyTorch 前先核对兼容关系... 163

A.4 Hugging Face 模型与缓存... 164

A.5 依赖锁定与环境报告... 164

A.6 常见环境错误清单... 164

A.7 一个合格的环境验收... 165

附录 B 大模型学习所需数学速查... 166

B.1 标量、向量、矩阵与张量... 166

B.2 点积、范数与余弦相似度... 166

B.3 概率、条件概率与交叉熵... 166

B.4 导数、偏导与链式法则... 167

B.5 均值、方差与归一化... 167

B.6 信息论直觉... 167

B.7 复杂度与数量级估算... 167

B.8 浮点数与数值稳定性... 168

附录 C 大模型工程术语表... 169

附录 D 练习、复盘与项目验收方法... 172

D.1 五层掌握标准... 172

D.2 实验报告模板... 172

D.3 怎样设计练习... 173

D.4 怎样读论文... 173

D.5 怎样使用开源仓库... 173

D.6 贯穿全书的毕业项目... 173

D.7 最终自检... 174

提示:在 Word 中选择“更新目录”可刷新页码。

前言:把大模型学成一套可验证的工程能力

大模型领域的术语很多,更新也很快。对零基础读者而言,真正困难的往往不是某一个公式,而是不知道这些公式、代码和工程组件之间是什么关系:Python 为什么要学到类和生成器,矩阵乘法为什么会出现在注意力里,模型已经预训练好了为什么还要 RAG,LoRA 节省的究竟是哪部分显存,Agent 为什么不能简单理解成“让模型自己调用工具”。

本书围绕一个统一问题展开:给定数据、算力和业务目标,怎样构建一个可训练、可评估、可部署、可解释其边界的大模型系统? 十四章不是十四份彼此独立的知识清单,而是一条逐层展开的工程链路。

图 0-1 全书知识地图:底层能力决定上层系统能否被正确理解、调试与评估。

第一章先建立 Python、张量、梯度和训练循环的直觉;第二、三章进入 Transformer、MoE、Llama 与 DeepSeek 的模型内部;第四至第七章讨论怎样把模型变成服务、RAG 和 Agent;第八至第十三章回到模型生产链,讲预训练、微调、偏好对齐、多模态、分布式训练与推理优化;第十四章把这些知识整理成简历证据、项目表达、手写代码和系统设计能力。

这本书适合谁

如果你只会一点 Python,甚至只运行过几个 Notebook,也可以从第一章开始。零基础不等于不需要基础,而是本书不会假设你已经理解张量形状、反向传播、注意力 mask 或 GPU 显存。每次引入新概念时,我们都会先说明它解决什么问题,再写输入输出、最小例子、常见失败和验证方式。

以下几类读者可以采用不同路线:

  1. 完全初学者:按章节顺序阅读。每章至少完成一个最小代码任务,不要跳过第一章和第二章。
  2. Python 开发者:第一章重点看张量、自动微分和训练闭环,之后进入部署、RAG 与 Agent,再回头补训练章节。
  3. 传统算法或深度学习学习者:可快速复习第一章,但应完成注意力、RoPE、LoRA 和 DPO 的手写代码。
  4. 准备求职的读者:先读第十四章了解能力证据,再按薄弱项回到对应章节;不要把面试题答案与真实项目经验混为一谈。

无论哪条路线,都建议保留一个实验仓库。真正学会的标志不是“看懂了”,而是能在没有正文提示时重新实现,能解释中间形状,能构造失败输入,也能说清实现没有覆盖什么。

第一次学习前的环境准备

本书的基础示例以 Python 3.10 及以上版本为目标。第一至第三章的大部分原理代码在 CPU 上即可运行;真实模型推理、微调和分布式训练通常需要 Linux、NVIDIA GPU、匹配的驱动/CUDA/PyTorch,以及足够的显存。没有 GPU 不妨碍理解核心原理,先用小张量和微型模型验证计算,再选择云端或实验室环境运行大模型。

建议每个项目使用独立虚拟环境。下面展示的是通用流程,不要把版本号永久照抄;安装 GPU 版 PyTorch 时应以官方安装页面给出的兼容命令为准。

BASH
python --version
python -m venv .venv
 
# Windows PowerShell
.venv\Scripts\Activate.ps1
 
# Linux / macOS
source .venv/bin/activate
 
python -m pip install --upgrade pip
python -m pip install numpy pytest

安装后先做健康检查,而不是直接下载几十 GB 权重:

PYTHON
import platform
import numpy as np
 
print("Python:", platform.python_version())
print("NumPy:", np.__version__)
 
try:
    import torch
    print("PyTorch:", torch.__version__)
    print("CUDA available:", torch.cuda.is_available())
    if torch.cuda.is_available():
        print("GPU:", torch.cuda.get_device_name(0))
except ImportError:
    print("PyTorch 尚未安装;第一章前半部分仍可使用 NumPy 学习。")

如果使用 Windows 学习训练与部署,常见选择是 WSL2 或远程 Linux 主机。路径、文件权限、换行符和多进程启动方式与原生 Windows 不同,遇到问题时应先确认命令究竟在哪个环境执行。涉及 CUDA 的错误要同时记录 GPU 型号、驱动、CUDA runtime 和 PyTorch build;只说“CUDA 版本是 12”通常不够定位问题。

本书的代码分成三层

  1. 原理层:只依赖 Python、NumPy 或 PyTorch,手写反向传播、注意力、RoPE、LoRA、DPO、量化等核心过程。原理代码刻意保持小,方便手算和测试。
  2. 框架层:使用 Transformers、PEFT、TRL、FastAPI、LlamaIndex、vLLM、Accelerate、DeepSpeed 等工具完成规范实现。框架会变化,理解接口与数据流比记住一条命令更重要。
  3. 工程层:讨论服务指标、数据版本、评估集、权限、灰度发布、可观测性、成本与故障恢复。几十行 Demo 不是生产系统,本书会明确两者之间还缺什么。

每次运行代码前,先回答四个问题:输入张量或对象是什么?输出是什么?哪部分状态会改变?用什么证据判断更好?如果答不出来,先不要急着调参。

怎样阅读公式和张量形状

公式不是装饰。阅读公式的顺序是:先确定变量含义和形状,再确认求和或归一化发生在哪个轴,最后把公式翻译成程序。以注意力为例:

\operatorname{Attention}(Q,K,V)=\operatorname{softmax}\left(\frac{QK^\top}{\sqrt{d_k}}+M\right)V

假设 Q(B,H,Tq,Dh)K(B,H,Tk,Dh),那么 Q @ K.transpose(-2,-1)(B,H,Tq,Tk)。Softmax 应沿最后一个 Tk 轴归一化,表示每个查询位置对所有键位置的权重。再乘 (B,H,Tk,Dh)V,输出回到 (B,H,Tq,Dh)。mask M 必须能广播到分数矩阵;因果 mask 与 padding mask 解决的是两种不同问题。

遇到求和符号时,写出被消掉的轴;遇到期望时,问随机变量和采样分布是什么;遇到梯度时,问它对哪个标量求导。手算 2×2 或 2×3 的小例子,比一次性读十页推导更能建立直觉。

怎样阅读和调试代码

不要从安装完整训练框架开始。推荐遵循“最小输入—中间量—断言—扩规模”的顺序:

  1. 给函数构造可以手算的输入,先写出预期形状和数值范围。
  2. 打印或返回关键中间量,例如 mask、注意力权重、label、梯度和检索候选。
  3. 用断言固定不变量,例如概率和为 1、未来位置权重为 0、padding 标签为 -100、引用 id 必须存在。
  4. 再增加 batch、长度、混合精度和并发;每次只改变一个主要变量。
  5. 失败时保存最小样例,修复后加入回归测试。

模型与数据文件也可能带来安全风险。不要执行来源不明的自定义代码或反序列化不可信 checkpoint;不要把 API Key 写进 Notebook、截图或 Git 仓库。公开仓库提交前应检查历史记录,因为删除当前文件并不会自动删除旧提交里的密钥。

实验记录模板

建议为每次实验保留以下内容:

  • 任务与假设:想验证什么,为什么预期如此。
  • 数据:来源、许可、清洗、切分、版本和样本数量。
  • 环境:代码提交、依赖、硬件、随机种子和启动命令。
  • 配置:模型、优化器、学习率、批量、精度、序列长度和解码参数。
  • 结果:训练曲线、离线指标、延迟、成本和失败样例。
  • 结论:哪些证据支持或否定假设,尚有哪些替代解释,下一步只改什么。

一个简单的实验目录可以这样组织:

TEXT
experiment-001/
  README.md          # 问题、运行方法、结论与限制
  config.yaml        # 可机器读取的配置
  environment.txt    # 依赖和硬件
  data_manifest.json # 数据版本与哈希
  logs/              # 原始日志
  results/           # 指标、曲线、失败样例
  tests/             # 回归测试

这种记录习惯会贯穿全书。它也是面试中区分“做过项目”和“看过项目”的关键:真正做过的人能解释取舍、指标定义、失败、恢复条件和结果边界。

关于论文、开源教材与视频

正文中的架构和算法结论优先核对论文原文与官方文档。GitHub 开源教材用于发现讲解顺序、练习题和可运行代码入口,本书会给出链接,但不会复制其受版权保护的正文。建议搭配阅读 Happy-LLM、LLMs-from-scratch、LLM-Universe、All-in-RAG、Hello-Agents、LLaMA-Factory 等公开项目,并核对各自许可证。

视频只作为辅助材料。观看时暂停并手写中间结果,不要把“播放完”当作掌握。原始文档中的私有课程、付费社群和不可公开访问的视频链接不会出现在本书;推荐视频优先使用可公开访问的 B 站入口。

安全、合规与知识边界

大模型可能生成错误内容;RAG 可能检索到过期或无权限证据;Agent 会放大工具权限带来的风险;训练数据还涉及隐私、版权和偏见。医疗、法律、金融等高风险场景必须有领域专家、数据治理、权限控制、人工复核和明确责任边界。本书示例用于学习,不构成专业结论,也不建议直接接入真实敏感数据。

当资料没有公开、实验无法复现或证据互相冲突时,科学的写法是明确“不知道”“尚未验证”或“根据现有证据推测”,而不是用流畅语言填补空白。大模型工程真正稀缺的能力,不是记住所有名词,而是把复杂问题拆成可验证的假设,用数据和代码说明取舍,并在证据不足时守住边界。

第一章 Python、数学与神经网络:建立可计算的直觉

本章的目标不是“快速看完 Python”,而是建立后续所有章节都会用到的三种能力:用数组表达数据,用函数表达模型,用梯度表达学习。学完后,你应当能读懂一段 PyTorch 训练代码,能手写一个小型神经网络,并能定位形状、数值和数据三类常见错误。

图 1-1 训练不是一次函数调用,而是数据、预测、损失、梯度和参数更新组成的闭环。

1.1 Python:先掌握会反复出现的部分

先建立直觉。 Python 在本书中不是考语法,而是用来表达数据怎样流动。初学者先把变量、容器、函数、类和异常看成组织状态与行为的工具。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 用 list、dict 与 dataclass 表示一条样本。
  2. 用函数把读取、变换、评估拆开。
  3. 用类型标注和断言固定输入输出契约。

最小例子。 把一条问答样本写成包含 prompt、answer、metadata 的字典,再写函数只返回模型需要的字段;打印每个字段的类型与长度。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 能运行不等于可维护。最常见的问题是可变默认参数、维度含义藏在变量名里、异常被无条件捕获,以及循环中反复做昂贵初始化。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:实现 batch_iter(samples, batch_size),覆盖空列表、最后一个不足批次和非法 batch_size 三种边界。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

大模型代码里最常见的 Python 能力包括容器、函数、类、迭代器、上下文管理器和类型标注。列表适合有序样本,字典适合配置与结构化记录,生成器适合不一次性加载全部数据。下面的批处理生成器不会复制完整数据集:

PYTHON
from collections.abc import Iterator, Sequence
 
def batches(data: Sequence, batch_size: int) -> Iterator[Sequence]:
    if batch_size <= 0:
        raise ValueError("batch_size 必须大于 0")
    for start in range(0, len(data), batch_size):
        yield data[start:start + batch_size]
 
for batch in batches(list(range(10)), 4):
    print(batch)

yield 让函数返回一个迭代器。真实训练中的 DataLoader 还会处理并行读取、随机打乱、采样和拼接,但“按需产生批次”的思想相同。调试 Python 时,不要只打印最终结果;优先打印类型、长度、形状和少量样本。

面向对象在模型代码中的主要价值是组织状态。torch.nn.Module 把参数、子模块和前向计算组合在同一对象中。函数式编程则适合无状态的数据变换、损失计算和指标计算。两者并不冲突:模型用类封装,纯计算尽量写成小函数,便于测试。

1.2 数学基础:向量、矩阵、概率和导数

图 01-2 先标注批次、特征和隐藏轴,再进行矩阵乘法与反向传播。从问题出发。 线性代数负责描述批量变换,概率负责描述不确定性,微积分负责说明参数怎样改变损失。三者在神经网络里不是三门分开的课,而是一条计算链。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 先写出每个张量的形状与轴含义。
  2. 再判断运算是逐元素、点积还是矩阵乘。
  3. 最后沿计算图用链式法则追踪梯度。

用小数据走一遍。 若 X 为 (B,D),W 为 (D,H),则 XW 为 (B,H)。其中每个输出元素是 D 个乘积的求和;反向传播时 dW=X^T dY。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 只背公式而不写形状会掩盖大多数错误;把概率当作频数、把梯度当作参数更新量、忽略广播轴也会造成似是而非的理解。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:手算一个 2×3 矩阵与 3×2 矩阵的乘积,再用 NumPy 验证;对其中一个元素做有限差分梯度检查。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

一个词向量可写成 $x\in\mathbb{R}^d$,一批长度为 $T$ 的序列可写成 $X\in\mathbb{R}^{B\times T\times d}$。这里 $B$ 是批量大小,$T$ 是序列长度,$d$ 是隐藏维度。后续阅读任何模型,先写出每个张量的形状,通常比背模块名称更有效。

线性层为

Y=XW+b,

若 $X$ 的末维是 $d_{in}$,$W\in\mathbb{R}^{d_{in}\times d_{out}}$,则输出末维是 $d_{out}$。偏置 $b$ 会按广播规则加到前面的所有位置。广播方便,但也可能掩盖错误;(B,T,1)(B,T) 相加会得到意外形状,调试时应显式检查。

概率分布满足非负且总和为 1。语言模型把词表上的 logits $z$ 通过 Softmax 转为概率:

p_i=\frac{e^{z_i}}{\sum_j e^{z_j}}.

实际实现先减去最大值,避免指数溢出。分类常用交叉熵 $-\log p_y$;自回归语言模型则对每个位置的下一个 token 计算交叉熵,再对有效位置求平均。困惑度 $\mathrm{PPL}=e^{\text{loss}}$ 便于解释,但跨 tokenizer 的数值不可直接比较。

导数描述输出对输入的局部敏感度。复合函数通过链式法则传播梯度:若 $L=f(g(x))$,则

\frac{\partial L}{\partial x}=\frac{\partial L}{\partial f}\frac{\partial f}{\partial g}\frac{\partial g}{\partial x}.

反向传播不是另一套学习算法,而是高效应用链式法则。计算图记录前向运算和依赖关系,反向阶段从标量损失出发,把梯度沿图反向累积到参数。

1.3 神经网络的基本组件

先看它解决什么。 神经网络可以理解为可微函数的层叠:线性层改变坐标,非线性层提供表达能力,损失函数把预测质量压缩成一个可优化标量。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 前向计算保存中间量。
  2. 损失函数比较预测与目标。
  3. 反向传播计算每个参数的偏导。
  4. 优化器依据梯度更新参数。

一个可以手算的例子。 二分类网络把 x 送入 Linear-ReLU-Linear,输出一个 logit;BCEWithLogitsLoss 同时完成数值稳定的 Sigmoid 与交叉熵。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 激活函数并非越多越好;最后一层是否加 Softmax 取决于损失函数是否已包含它。训练和评估模式还会影响 Dropout 与 BatchNorm。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:画出两层网络的计算图,标注每个节点的形状,并说明哪个节点必须是标量才能直接调用 backward。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

多层感知机可写成

h=\phi(XW_1+b_1),\qquad \hat{y}=hW_2+b_2.

激活函数让网络具有非线性。ReLU 简单高效,但负半轴梯度为零;GELU 平滑地按输入大小“门控”,Transformer 中很常见;SwiGLU 将两条线性投影相乘,后续 Llama 章节会展开。初始化要让各层信号不过度放大或衰减,线性层常结合 Xavier 或 Kaiming 方法。

优化器根据梯度更新参数。最基础的随机梯度下降为 $\theta\leftarrow\theta-\eta\nabla_\theta L$。AdamW 维护一阶、二阶矩估计,并把权重衰减与梯度更新解耦。学习率过大可能发散,过小则学习缓慢;梯度裁剪只能限制异常大梯度,不能修复错误数据或错误损失。

训练集用于更新参数,验证集用于选超参数和早停,测试集只用于最终报告。若同一用户、同一文档或高度相似样本跨集合出现,就会发生数据泄漏,指标会虚高。

1.4 NumPy 手写两层网络

抓住这一节的主线。 手写 NumPy 网络的目的不是替代框架,而是亲眼看到缓存、中间梯度和更新规则。只要能解释每一行为什么存在,之后使用自动微分才不会变成黑箱。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 固定随机种子并初始化小权重。
  2. 完成前向与稳定损失。
  3. 按链式法则反传到每个参数。
  4. 更新后重新计算损失。

先做最小实验。 对一个权重 w,用 (L(w+ε)-L(w-ε))/(2ε) 近似真实梯度;它应与解析梯度接近,但 ε 过大有截断误差、过小有浮点误差。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 常见错误包括忘记除以 batch 大小、Sigmoid 溢出、原地修改缓存、矩阵转置方向写反以及梯度检查时仍在更新参数。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:给现有两层网络加入 ReLU,并对 W1、b1、W2、b2 各抽一个元素做中心差分检查。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

下面用 NumPy 完成二分类网络的前向与反向传播。代码刻意展开中间量,便于检查每一步。

PYTHON
import numpy as np
 
rng = np.random.default_rng(7)
X = rng.normal(size=(128, 2))
y = ((X[:, 0] * X[:, 1]) > 0).astype(np.float32)[:, None]
 
W1 = rng.normal(scale=0.2, size=(2, 16))
b1 = np.zeros((1, 16))
W2 = rng.normal(scale=0.2, size=(16, 1))
b2 = np.zeros((1, 1))
 
def sigmoid(x):
    x = np.clip(x, -30, 30)
    return 1.0 / (1.0 + np.exp(-x))
 
lr = 0.1
for step in range(1000):
    z1 = X @ W1 + b1
    h = np.tanh(z1)
    logits = h @ W2 + b2
    p = sigmoid(logits)
 
    eps = 1e-7
    loss = -(y * np.log(p + eps) + (1 - y) * np.log(1 - p + eps)).mean()
 
    # 对 sigmoid + 二元交叉熵,dL/dlogits 可化简为 (p-y)/N
    dlogits = (p - y) / len(X)
    dW2 = h.T @ dlogits
    db2 = dlogits.sum(axis=0, keepdims=True)
    dh = dlogits @ W2.T
    dz1 = dh * (1 - h ** 2)
    dW1 = X.T @ dz1
    db1 = dz1.sum(axis=0, keepdims=True)
 
    W1 -= lr * dW1
    b1 -= lr * db1
    W2 -= lr * dW2
    b2 -= lr * db2
 
    if step % 200 == 0:
        print(step, float(loss))

代码解读:h.T @ dlogits 把 128 个样本对第二层权重的贡献求和;sum(..., keepdims=True) 保持偏置的二维形状;1-h**2tanh 的导数。若损失不降,先检查数据是否可学,再做数值梯度检查:对单个参数加减很小的 $\epsilon$,比较有限差分与解析梯度。

1.5 用 PyTorch 重写训练闭环

先把概念落到可观察对象上。 PyTorch 把手写缓存换成动态计算图,把参数集合交给 Module 管理,但训练闭环仍是同一件事:取批次、前向、计算损失、清梯度、反传、更新。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. Dataset 定义单个样本。
  2. DataLoader 负责批处理与打乱。
  3. Module 定义可学习计算。
  4. optimizer.step 执行更新。

把它缩小到能逐项检查。 调用 loss.backward 后查看 model.linear.weight.grad;再比较 zero_grad 前后的梯度,理解 PyTorch 默认累积梯度的语义。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 遗漏 model.train/model.eval、把张量错误搬到不同设备、在评估时保留计算图、混用 float32 与 float64,都会引入隐蔽问题。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:在训练循环中记录 train loss、validation loss 和梯度范数;故意把学习率放大 100 倍,观察并解释曲线。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

PYTHON
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
 
torch.manual_seed(7)
X = torch.randn(1024, 2)
y = ((X[:, 0] * X[:, 1]) > 0).long()
loader = DataLoader(TensorDataset(X, y), batch_size=64, shuffle=True)
 
model = nn.Sequential(
    nn.Linear(2, 32),
    nn.GELU(),
    nn.Linear(32, 2),
)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=1e-2)
criterion = nn.CrossEntropyLoss()
 
for epoch in range(20):
    model.train()
    total_loss = 0.0
    for xb, yb in loader:
        optimizer.zero_grad(set_to_none=True)
        logits = model(xb)
        loss = criterion(logits, yb)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
        optimizer.step()
        total_loss += loss.item() * len(xb)
    print(f"epoch={epoch:02d} loss={total_loss / len(X):.4f}")

关键顺序是:清梯度、前向、算损失、反向、更新。PyTorch 默认把多次反向的梯度累加到 .grad,因此忘记清梯度会改变优化过程。model.train()model.eval() 会切换 Dropout、BatchNorm 等模块行为;推理时还应使用 torch.inference_mode() 关闭梯度记录。

1.6 环境、模型仓库与可复现性

先建立直觉。 可复现不是一句 random seed,而是能够说明数据、代码、依赖、硬件和随机状态。模型仓库中的权重还伴随配置、tokenizer、许可证与自定义代码风险。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 锁定 Python 与关键库版本。
  2. 记录数据哈希和切分清单。
  3. 保存配置、种子和代码提交。
  4. 核验模型卡与许可证。

最小例子。 同一模型若 tokenizer 版本不同,特殊 token id 可能改变;即使权重相同,输入序列与输出也可能不同。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 盲目开启 trust_remote_code、只保存 model.bin、不记录 CUDA/驱动版本,以及用测试集调参,都会破坏复现或安全边界。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:写一个环境报告脚本,输出 Python、PyTorch、CUDA、GPU、随机种子和 git commit,并保存为 JSON。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

建议为项目创建独立虚拟环境,锁定依赖,并记录 GPU 驱动、CUDA 与 PyTorch 版本。Hugging Face Hub 和 ModelScope 都可托管模型与数据集;下载模型前要检查许可证、模型卡、所需显存和自定义代码。不要对来源不明的仓库轻易启用 trust_remote_code=True

随机种子只能改善复现,不保证所有 GPU 算子逐比特一致。完整复现还需要固定数据顺序、预处理、代码提交、依赖版本、混合精度和分布式配置。

1.7 常见故障与练习

从问题出发。 排错要从最便宜、最确定的证据开始:语法与导入、形状与 dtype、数值范围、梯度、数据,再到性能。不要一出现问题就换模型或调参。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 构造能复现问题的最小输入。
  2. 在边界打印形状与统计量。
  3. 加断言把错误定位到最早位置。
  4. 修复后补回归测试。

用小数据走一遍。 损失变成 NaN 时先检查输入是否有限、logits 范围、学习率和梯度范数,而不是立即换优化器。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 只看最后一行异常、一次修改多个变量、用 try/except 吞掉堆栈、没有保存失败样本,会让同一问题反复出现。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:为本章代码设计五个自动测试:形状、有限值、梯度、损失下降和固定种子复现。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

  • 损失为 nan:检查学习率、除零、对数输入、混合精度溢出和异常数据。
  • 训练准确率高、验证准确率低:检查过拟合、分布漂移和数据泄漏。
  • GPU 利用率低:检查数据加载、批量过小、频繁 CPU/GPU 拷贝和同步打印。
  • 显存不断增长:检查是否保存了带计算图的张量,应改用 tensor.detach()loss.item()

练习:为 NumPy 网络实现数值梯度检查;为 PyTorch 训练加入验证集、准确率、早停和模型保存;故意交换标签或放大学习率,记录曲线并解释失败原因。

1.8 延伸资源

先看它解决什么。 延伸材料应围绕当前缺口使用:数学直觉不清就做小矩阵实验,PyTorch 不熟就逐行运行官方教程,注意力不懂再进入下一章,而不是同时收藏几十门课。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 带着一个明确问题阅读。
  2. 复现最小代码并改变一个变量。
  3. 用自己的话记录结论。
  4. 把结论写成可失败的测试。

一个可以手算的例子。 阅读自动微分教程时,不只复制代码;把中间张量改为 requires_grad=False,预测结果,再运行验证。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 把观看时长当学习进度、依赖过期安装命令、跳过练习和不记录失败,都会形成虚假的熟悉感。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:建立学习日志,记录问题、假设、实验、证据和结论;下一章继续沿用同一模板。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

本章配套代码

下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。

本章端到端实验:把知识变成可复现证据

本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。

实验步骤

  1. 实现 batch_iter(samples, batch_size),覆盖空列表、最后一个不足批次和非法 batch_size 三种边界。
  2. 手算一个 2×3 矩阵与 3×2 矩阵的乘积,再用 NumPy 验证;对其中一个元素做有限差分梯度检查。
  3. 画出两层网络的计算图,标注每个节点的形状,并说明哪个节点必须是标量才能直接调用 backward。
  4. 给现有两层网络加入 ReLU,并对 W1、b1、W2、b2 各抽一个元素做中心差分检查。
  5. 在训练循环中记录 train loss、validation loss 和梯度范数;故意把学习率放大 100 倍,观察并解释曲线。
  6. 写一个环境报告脚本,输出 Python、PyTorch、CUDA、GPU、随机种子和 git commit,并保存为 JSON。

每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。

验收标准

  • 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
  • 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
  • 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
  • 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
  • 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。

本章自测

  1. 不看正文,用自己的话解释“Python 在本书中不是考语法,而是用来表达数据怎样流动”,并给出一个可以证伪的测试。
  2. 不看正文,用自己的话解释“线性代数负责描述批量变换,概率负责描述不确定性,微积分负责说明参数怎样改变损失”,并给出一个可以证伪的测试。
  3. 不看正文,用自己的话解释“神经网络可以理解为可微函数的层叠:线性层改变坐标,非线性层提供表达能力,损失函数把预测质量压缩成一个可优化标量”,并给出一个可以证伪的测试。
  4. 不看正文,用自己的话解释“手写 NumPy 网络的目的不是替代框架,而是亲眼看到缓存、中间梯度和更新规则”,并给出一个可以证伪的测试。
  5. 不看正文,用自己的话解释“PyTorch 把手写缓存换成动态计算图,把参数集合交给 Module 管理,但训练闭环仍是同一件事:取批次、前向、计算损失、清梯度、反传、更新”,并给出一个可以证伪的测试。
  6. 不看正文,用自己的话解释“可复现不是一句 random seed,而是能够说明数据、代码、依赖、硬件和随机状态”,并给出一个可以证伪的测试。

回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。

第二章 Transformer 与 MoE:从矩阵计算到稀疏专家

Transformer 的核心并不神秘:每个位置先从其他位置汇总信息,再独立通过前馈网络加工;残差连接和归一化保证深层网络可训练。难点在于掩码、形状、数值稳定性和训练/推理差异。本章从一次注意力计算开始,逐步组装完整结构,再解释 MoE 如何用稀疏激活扩大参数容量。

图 2-1 Dense Transformer 每个 token 经过同一 FFN;MoE 用路由器为 token 选择少数专家。

2.1 Token、嵌入与位置

先建立直觉。 语言模型不能直接处理字符串,必须先把文本切成 token id,再通过嵌入表把离散 id 映射为连续向量。位置表示则回答相同 token 出现在不同位置时怎样区分。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 规范化与分词得到 token。
  2. 查词表得到整数 id。
  3. Embedding 查表得到 d_model 维向量。
  4. 叠加或注入位置信息。

最小例子。 词表大小 V=10,000、维度 D=512 的嵌入层本质是一个 V×D 参数矩阵;一批 B×T 的 id 会得到 B×T×D。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 token 不等于汉字或单词;不同 tokenizer 不能随意共用权重。padding、BOS、EOS 的 id 和 mask 也必须与训练配置一致。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:用任意公开 tokenizer 编码中英文、代码和数字,比较 token 数并解释差异。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

模型不直接读取汉字或单词,而是读取 tokenizer 产生的 token id。BPE、WordPiece、Unigram 等算法都试图在词表规模、序列长度和未登录词之间折中。tokenizer 是模型的一部分:更换 tokenizer 会改变 id 语义,不能只替换词表文件。

设词表大小为 $V$、隐藏维度为 $d$,嵌入矩阵 $E\in\mathbb{R}^{V\times d}$。查表把 (B,T) 的 token id 变为 (B,T,d)。注意力本身对输入顺序置换等变,因此还需注入位置信息。原始 Transformer 使用正弦位置编码,现代解码器常用 RoPE;第二者将在第三章推导。

2.2 Self-Attention:每一步都写出形状

图 02-2 注意力实现的第一道正确性门槛,是每次 reshape、transpose 和矩阵乘都能写出形状。从问题出发。 Self-Attention 让每个位置根据内容选择其他位置的信息。Q 表示当前要找什么,K 表示每个位置可被怎样匹配,V 表示真正被汇总的内容。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. X 线性投影为 Q/K/V。
  2. 计算 QK^T 并除以 sqrt(dk)。
  3. 叠加因果或 padding mask。
  4. Softmax 后乘 V。

用小数据走一遍。 B=2、T=4、D=8、H=2 时,每头维度为 4;Q/K/V 形状均为 (2,2,4,4),注意力矩阵为 (2,2,4,4)。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 Softmax 轴必须是 key 轴;mask 的布尔含义与填充值要核对。把缩放因子写成 sqrt(d_model) 或在半精度中使用过小负数都可能出错。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:在手写注意力中返回权重,验证每行和为 1,并检查未来位置权重为 0。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

输入 $X\in\mathbb{R}^{B\times T\times d}$ 经过三组线性投影:

$$Q=XW_Q,\qquad K=XW_K,\qquad V=XW_V.$$

对单头注意力,$QK^\top$ 得到 (B,T,T) 分数矩阵。第 $i$ 行表示第 $i$ 个查询对所有键的相似度。除以 $\sqrt{d_k}$ 是为了避免维度增大时点积方差过大,导致 Softmax 过饱和。掩码 $M$ 把不可见位置加上极小值;解码器的因果掩码保证位置 $i$ 只能看到不晚于自己的 token。

多头注意力把隐藏维分为 $H$ 个头,每个头维度 $d_h=d/H$。头并非简单重复:不同投影可学习不同关系。各头输出拼接后再经过 $W_O$ 混合。

PYTHON
import math
import torch
from torch import nn
 
class MultiHeadSelfAttention(nn.Module):
    def __init__(self, d_model: int, n_heads: int, causal: bool = True):
        super().__init__()
        if d_model % n_heads != 0:
            raise ValueError("d_model 必须能被 n_heads 整除")
        self.n_heads = n_heads
        self.head_dim = d_model // n_heads
        self.causal = causal
        self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
        self.out = nn.Linear(d_model, d_model, bias=False)
 
    def forward(self, x, padding_mask=None):
        b, t, d = x.shape
        qkv = self.qkv(x).view(b, t, 3, self.n_heads, self.head_dim)
        q, k, v = qkv.unbind(dim=2)
        q, k, v = [z.transpose(1, 2) for z in (q, k, v)]  # (B,H,T,Dh)
 
        scores = q @ k.transpose(-2, -1) / math.sqrt(self.head_dim)
        if self.causal:
            mask = torch.triu(
                torch.ones(t, t, dtype=torch.bool, device=x.device), diagonal=1
            )
            scores = scores.masked_fill(mask, torch.finfo(scores.dtype).min)
        if padding_mask is not None:
            # padding_mask: (B,T),True 表示有效 token
            scores = scores.masked_fill(~padding_mask[:, None, None, :],
                                        torch.finfo(scores.dtype).min)
 
        attn = torch.softmax(scores.float(), dim=-1).to(x.dtype)
        context = attn @ v
        context = context.transpose(1, 2).contiguous().view(b, t, d)
        return self.out(context), attn

代码解读:view 之前要确认内存布局,转置后合并维度使用 contiguous() 更稳妥;Softmax 临时升到 FP32 可减小低精度下的数值风险;padding mask 作用在键的位置,因果 mask 作用在时间关系。若某一行全部被屏蔽,Softmax 可能产生 nan,数据拼接和 mask 逻辑必须避免这种情况。

2.3 Encoder、Decoder 与 Cross-Attention

先看它解决什么。 Encoder 让所有位置双向交互,Decoder 的自注意力必须因果遮蔽;Cross-Attention 则让 Decoder 的查询读取 Encoder 产生的键和值。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. Encoder 形成上下文化记忆。
  2. Decoder 读取已生成前缀。
  3. Cross-Attention 对齐目标与源。
  4. 输出层预测下一个 token。

一个可以手算的例子。 翻译中,生成目标词时 Q 来自目标端当前状态,K/V 来自整句源语言表示,因此可以动态关注源句不同位置。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 BERT 是 Encoder-only,GPT 是 Decoder-only,T5 是 Encoder-Decoder;三者差异不能简化为是否含注意力。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:画出三种架构的数据流,并为分类、翻译、开放式生成各选择一种架构说明理由。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

Encoder 使用双向 Self-Attention,每个位置可读取整个输入,适合分类、抽取和表征学习。Decoder 使用因果 Self-Attention,自回归预测下一个 token,GPT、Llama、Qwen 等生成模型采用这种结构。Encoder-Decoder 模型先编码输入,再由 Decoder 通过 Cross-Attention 读取编码结果,适合翻译、摘要和条件生成。

Cross-Attention 中,$Q$ 来自 Decoder 当前状态,$K,V$ 来自 Encoder 输出。它回答的是“生成当前 token 时,输入中的哪些位置最相关”。T5 是典型 Encoder-Decoder;BERT 是 Encoder-only;GPT 系列是 Decoder-only。三者的差异不仅是层数排列,还包括预训练目标与注意力可见范围。

2.4 FFN、残差和归一化

抓住这一节的主线。 注意力负责 token 之间通信,FFN 负责每个 token 内部的非线性变换;残差通路保留信息与梯度,归一化控制数值尺度。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 子层计算变换 F(x)。
  2. Dropout 或门控调节输出。
  3. 与残差 x 相加。
  4. 按 Pre-Norm 或 Post-Norm 放置归一化。

先做最小实验。 FFN 通常先把 D 扩张到约数倍隐藏维,再压回 D。它对每个位置使用同一组权重,因此不混合序列位置。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 把 FFN 当卷积、把 LayerNorm 放在 batch 维、忘记残差两端形状必须一致,都会破坏结构。Pre-Norm 稳定性也不代表任何模型都可随意改。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:记录一个 Transformer Block 经过 Attention、残差、FFN 后的均值、标准差与梯度范数。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

注意力在 token 之间混合信息,FFN 在每个 token 内独立变换通道:

$$\operatorname{FFN}(x)=W_2\,\phi(W_1x+b_1)+b_2.$$

中间维度通常大于隐藏维度。原始 Transformer 使用 ReLU,BERT 常用 GELU,Llama 使用 SwiGLU。残差连接写作 $x+F(x)$,让网络能学习对恒等映射的修正,并给梯度提供短路径。

Post-Norm 先做子层再归一化,Pre-Norm 先归一化再做子层。深层语言模型常采用 Pre-Norm,因为训练通常更稳定:

PYTHON
class TransformerBlock(nn.Module):
    def __init__(self, d_model=256, n_heads=8, mlp_ratio=4):
        super().__init__()
        self.norm1 = nn.LayerNorm(d_model)
        self.attn = MultiHeadSelfAttention(d_model, n_heads, causal=True)
        self.norm2 = nn.LayerNorm(d_model)
        self.ffn = nn.Sequential(
            nn.Linear(d_model, mlp_ratio * d_model),
            nn.GELU(),
            nn.Linear(mlp_ratio * d_model, d_model),
        )
 
    def forward(self, x, padding_mask=None):
        a, _ = self.attn(self.norm1(x), padding_mask)
        x = x + a
        x = x + self.ffn(self.norm2(x))
        return x

LayerNorm 对每个 token 的隐藏维做标准化;RMSNorm 只按均方根缩放,不减均值。归一化中的 eps 太小会在低精度下不稳定,过大又改变尺度。

2.5 从输入到下一个 token

先把概念落到可观察对象上。 自回归模型训练时一次并行预测所有位置的下一个 token,推理时却必须逐 token 生成。这解释了训练吞吐高而生成延迟明显的根本差异。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 右移标签形成输入与目标。
  2. 用因果 mask 并行计算 logits。
  3. 交叉熵只统计有效标签。
  4. 推理时按解码策略循环。

把它缩小到能逐项检查。 序列 [BOS,我,爱,猫,EOS] 的输入可为前四个 token,标签为后四个;每个位置只学习预测紧邻的下一个 token。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 训练标签未右移、padding 未设为 ignore_index、推理时忘记 EOS、把 temperature 用在训练损失上,都是常见混淆。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:构造长度不同的两条序列,完成 padding、attention mask 和 label mask,并逐位置核对。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

解码语言模型的训练输入通常是 token 序列 $[x_0,x_1,\ldots,x_{T-1}]$,目标是右移后的 $[x_1,x_2,\ldots,x_T]$。模型一次并行计算所有位置的 logits,因果掩码防止偷看未来。推理时没有真实的下一个 token,只能生成一个、追加到序列、再生成下一个,因此解码天然串行。KV Cache 通过复用历史键值减少重复计算,第十三章会详细说明。

采样策略决定如何从概率分布选 token。贪心每次取最大概率,稳定但容易僵化;temperature 调整分布锐度;top-k 只保留概率最高的 $k$ 个;top-p 保留累计概率达到阈值的最小集合。Beam Search 更适合有明确序列得分的任务,不一定适合开放对话。

2.6 MoE:容量扩大不等于计算同比扩大

先建立直觉。 MoE 用路由器让每个 token 只经过少数专家,从而扩大总参数容量而控制单 token 计算量。它省的是激活的专家计算,不会自动消除通信、存储和负载不均。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 路由器产生专家分数。
  2. 选 Top-k 专家并归一化权重。
  3. 按专家重排 token。
  4. 专家计算后按权重合并。

最小例子。 8 个专家、Top-2 路由时,一个 token 只计算两个 FFN;但所有专家权重仍要分布在设备上,且热门专家可能溢出容量。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 总参数量不能直接当激活参数量;只写 Top-k 而不处理容量、辅助损失、token 丢弃和专家并行,不能构成可训练 MoE。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:模拟 100 个 token 的路由计数,计算每个专家负载和变异系数,再尝试加入均衡惩罚。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

Mixture of Experts 通常把 Dense FFN 替换为多个专家 FFN。路由器为每个 token 计算专家分数,选择 Top-K 专家:

$$g(x)=\operatorname{softmax}(W_rx),\qquady=\sum_{e\in\operatorname{TopK}(g)}\tilde{g}_e(x)E_e(x).$$

总参数量可以很大,但每个 token 只激活少量专家,因此激活参数量和计算量受控。代价是路由、All-to-All 通信、负载不均和专家容量管理。

PYTHON
class ToyMoE(nn.Module):
    def __init__(self, d_model=128, hidden=256, n_experts=4, top_k=2):
        super().__init__()
        self.top_k = top_k
        self.router = nn.Linear(d_model, n_experts, bias=False)
        self.experts = nn.ModuleList([
            nn.Sequential(nn.Linear(d_model, hidden), nn.GELU(),
                          nn.Linear(hidden, d_model))
            for _ in range(n_experts)
        ])
 
    def forward(self, x):
        shape = x.shape
        flat = x.reshape(-1, shape[-1])
        probs = torch.softmax(self.router(flat), dim=-1)
        weights, indices = probs.topk(self.top_k, dim=-1)
        weights = weights / weights.sum(dim=-1, keepdim=True)
        out = torch.zeros_like(flat)
 
        # 教学写法:逐专家分派;生产实现会使用分组、融合内核和跨卡通信
        for expert_id, expert in enumerate(self.experts):
            token_pos, slot = torch.where(indices == expert_id)
            if token_pos.numel() == 0:
                continue
            expert_out = expert(flat[token_pos])
            out[token_pos] += weights[token_pos, slot, None] * expert_out
        return out.view(shape), probs

仅有 Top-K 还不够。若路由器把多数 token 送给少数专家,热门专家会溢出,冷门专家学不到东西。常见做法包括辅助负载均衡损失、容量因子、路由噪声和无辅助损失的动态偏置策略。负载均衡不是越均匀越好;专家适度分工是 MoE 的价值,目标是避免塌缩和硬件闲置。

2.7 复杂度与常见误区

从问题出发。 复杂度分析必须说明变量和瓶颈。标准注意力的分数矩阵随 T² 增长,但短序列时 FFN、投影、kernel 启动和内存访问可能占主导。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 分别估算参数量、FLOPs 与激活。
  2. 区分训练和推理。
  3. 区分 prefill 与 decode。
  4. 用 profiler 验证理论判断。

用小数据走一遍。 Attention 分数约需 O(T²D),FFN 约需 O(TD·Dff)。T 增大时前者更快增长,但真实速度还受实现与硬件影响。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 把大 O 当实际耗时、忽略 batch/head/dtype、声称 FlashAttention 改变数学结果,或用参数量推断显存,都是错误。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:选择三组 T 和 D,计算注意力分数矩阵元素数与 FFN 乘加量,画出交叉趋势。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

标准注意力的分数矩阵大小为 $T\times T$,长序列下时间和显存压力显著;FFN 的计算量则随 $T$ 线性增长但常占大量 FLOPs。FlashAttention 改变内存访问方式,不改变精确注意力的数学结果;线性注意力或稀疏注意力则会改变计算结构。

  • “注意力权重就是解释”:权重可提供线索,但不是充分的因果解释。
  • “多头越多越好”:固定隐藏维时,头数增加会减小单头维度,存在表达与效率折中。
  • “MoE 的总参数都参与一次推理”:应区分总参数和每 token 激活参数。
  • “Decoder 训练也必须逐 token”:训练可并行计算所有位置,推理生成才逐 token。

2.8 练习与资料

先看它解决什么。 练习应能证明你会推导、实现和验证,而不是只会复述。一个合格的注意力练习至少包含形状断言、mask 测试、数值稳定性和与框架结果对齐。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 先手算极小输入。
  2. 再写无多头版本。
  3. 扩展为多头与 mask。
  4. 与 PyTorch 官方实现比较。

一个可以手算的例子。 固定同一组投影权重,将自写模块与 scaled_dot_product_attention 输出比较,最大绝对误差应在 dtype 合理范围内。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 只看 loss 是否下降不能证明注意力实现正确;错误 mask 有时仍能在小数据上拟合。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:完成一个测试文件,至少覆盖因果遮蔽、padding、不同 batch、不同头数和半精度有限值。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

练习:用小矩阵手算一次 $QK^\top$、缩放、掩码、Softmax 和加权求和;为注意力代码加入 Dropout 与 KV Cache;统计 ToyMoE 的专家负载并设计一个辅助损失。

本章配套代码

下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。

本章端到端实验:把知识变成可复现证据

本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。

实验步骤

  1. 用任意公开 tokenizer 编码中英文、代码和数字,比较 token 数并解释差异。
  2. 在手写注意力中返回权重,验证每行和为 1,并检查未来位置权重为 0。
  3. 画出三种架构的数据流,并为分类、翻译、开放式生成各选择一种架构说明理由。
  4. 记录一个 Transformer Block 经过 Attention、残差、FFN 后的均值、标准差与梯度范数。
  5. 构造长度不同的两条序列,完成 padding、attention mask 和 label mask,并逐位置核对。
  6. 模拟 100 个 token 的路由计数,计算每个专家负载和变异系数,再尝试加入均衡惩罚。

每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。

验收标准

  • 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
  • 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
  • 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
  • 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
  • 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。

本章自测

  1. 不看正文,用自己的话解释“语言模型不能直接处理字符串,必须先把文本切成 token id,再通过嵌入表把离散 id 映射为连续向量”,并给出一个可以证伪的测试。
  2. 不看正文,用自己的话解释“Self-Attention 让每个位置根据内容选择其他位置的信息”,并给出一个可以证伪的测试。
  3. 不看正文,用自己的话解释“Encoder 让所有位置双向交互,Decoder 的自注意力必须因果遮蔽”,并给出一个可以证伪的测试。
  4. 不看正文,用自己的话解释“注意力负责 token 之间通信,FFN 负责每个 token 内部的非线性变换”,并给出一个可以证伪的测试。
  5. 不看正文,用自己的话解释“自回归模型训练时一次并行预测所有位置的下一个 token,推理时却必须逐 token 生成”,并给出一个可以证伪的测试。
  6. 不看正文,用自己的话解释“MoE 用路由器让每个 token 只经过少数专家,从而扩大总参数容量而控制单 token 计算量”,并给出一个可以证伪的测试。

回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。

第三章 Llama 与 DeepSeek:现代解码器的关键改造

Llama、Qwen 和 DeepSeek 并没有抛弃 Transformer,而是在归一化、位置编码、注意力、FFN、数据与训练工程上持续改进。本章先拆解一个 Llama 风格的 Decoder Block,再解释 DeepSeek-V3/R1 中与工程最相关的 MoE、MLA、MTP 和强化学习路线。阅读模型报告时,要区分“架构创新”“训练策略”“数据策略”和“系统优化”,不要把所有提升都归因于某一个模块。

图 3-1 典型 Llama Block:Pre-RMSNorm、RoPE 注意力、残差和 SwiGLU FFN。

3.1 RMSNorm:只控制尺度

先建立直觉。 RMSNorm 只用均方根控制向量尺度,不减去均值。它保留方向信息、计算更简单,现代解码器常把它放在注意力和 FFN 之前。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 沿最后一维计算平方均值。
  2. 加 epsilon 后开方。
  3. 输入除以均方根。
  4. 乘可学习缩放参数。

最小例子。 向量整体乘 10 后,RMSNorm 输出尺度近似不变;但所有元素同时加常数时,它与 LayerNorm 的响应不同。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 epsilon 放在根号内外会改变数值;归一化维度必须是隐藏维。RMSNorm 不是没有参数,也不等同 L2 normalize。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:实现 RMSNorm,与 torch RMSNorm 或参考实现比较前向和梯度。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

LayerNorm 会减均值再除标准差;RMSNorm 只根据均方根缩放:

\operatorname{RMSNorm}(x)=\gamma\odot\frac{x}{\sqrt{\frac{1}{d}\sum_i x_i^2+\epsilon}}.

PYTHON
import torch
from torch import nn
 
class RMSNorm(nn.Module):
    def __init__(self, dim: int, eps: float = 1e-6):
        super().__init__()
        self.weight = nn.Parameter(torch.ones(dim))
        self.eps = eps
 
    def forward(self, x):
        dtype = x.dtype
        x32 = x.float()
        scale = torch.rsqrt(x32.pow(2).mean(dim=-1, keepdim=True) + self.eps)
        return (x32 * scale).to(dtype) * self.weight

升到 FP32 计算均方根是常见的稳定性处理。RMSNorm 不是“没有参数”,仍有可学习缩放 weight;也不是 BatchNorm,它不依赖批次统计。

3.2 RoPE:让相对位置信息进入点积

从问题出发。 RoPE 把偶数维与奇数维配成二维平面,并按位置旋转。旋转后的 Q/K 点积依赖位置角度差,从而让相对位置信息进入注意力。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 为每对维度生成频率。
  2. 位置乘频率得到角度。
  3. 分别旋转 Q 与 K。
  4. 在点积中形成相对相位。

用小数据走一遍。 二维向量在位置 m、n 分别旋转 mθ、nθ,二者点积等价于只旋转 (m-n)θ 后的点积。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 RoPE 通常作用于 Q/K 而非 V;维度配对、position_ids、缓存偏移和长上下文缩放必须一致。只改 max_position_embeddings 不会自然获得外推能力。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:对同一内容向量放在不同位置,验证点积随相对距离而不是绝对起点变化。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

Rotary Position Embedding 把隐藏维两两配对,并按位置旋转。对二维向量 $(x_{2i},x_{2i+1})$,位置 $m$ 的旋转为

\begin{bmatrix}x'_{2i}\\x'_{2i+1}\end{bmatrix} = \begin{bmatrix}\cos(m\theta_i)&-\sin(m\theta_i)\\ \sin(m\theta_i)&\cos(m\theta_i) \end{bmatrix} \begin{bmatrix}x_{2i}\\x_{2i+1}\end{bmatrix}.

同样旋转 $Q$ 和 $K$ 后,二者点积自然包含相对位置差。RoPE 不直接作用于 $V$。下面是便于理解的实数实现:

PYTHON
def rotate_half(x):
    x1, x2 = x[..., 0::2], x[..., 1::2]
    return torch.stack((-x2, x1), dim=-1).flatten(-2)
 
def rope(q, k, positions, base=10000.0):
    # q/k: (B,H,T,D),D 必须为偶数;positions: (T,)
    d = q.size(-1)
    inv_freq = base ** (-torch.arange(0, d, 2, device=q.device).float() / d)
    angles = torch.outer(positions.float(), inv_freq)
    cos = torch.repeat_interleave(angles.cos(), 2, dim=-1)[None, None]
    sin = torch.repeat_interleave(angles.sin(), 2, dim=-1)[None, None]
    return q * cos + rotate_half(q) * sin, k * cos + rotate_half(k) * sin

上下文扩展不是简单把 max_position_embeddings 改大。位置外推可能降低高频分量的有效性,实际方案会采用频率缩放、分段策略或继续训练,并用长文本检索、困惑度和下游任务验证。

3.3 MHA、MQA、GQA 与 KV Cache

图 03-2 位置旋转、KV 头共享和历史缓存共同决定现代解码器的生成路径。先看它解决什么。 MHA 为每个查询头保留独立 K/V 头;MQA 让所有查询头共享一组 K/V;GQA 介于两者之间。减少 KV 头能显著降低生成阶段缓存与带宽。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 按层保存历史 K/V。
  2. 新 token 只计算自身 Q/K/V。
  3. 将 KV 头扩展匹配查询头。
  4. 与历史缓存做注意力。

一个可以手算的例子。 层数 L、batch B、长度 T、KV 头 Hkv、头维 Dh、字节数 s 时,K+V 缓存约为 2LBTHkvDh·s。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 缓存减少不等于计算完全按同倍数减少;repeat_kv 应是视图式扩展或 kernel 支持,物理复制会浪费内存。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:为一个假想 32 层模型分别计算 MHA 与 GQA 在 8k 上下文的 KV Cache。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

标准 MHA 中每个查询头都有自己的 K/V 头。Multi-Query Attention 让所有查询头共享一组 K/V;Grouped-Query Attention 把查询头分组,每组共享 K/V,在质量与 KV Cache 大小之间折中。

若层数为 $L$,批量为 $B$,序列长度为 $T$,K/V 头数为 $H_{kv}$,头维为 $d_h$,元素字节数为 $s$,KV Cache 粗略占用为

$$2LBTH_{kv}d_hs.$$

前面的 2 对应 K 和 V。GQA 减少 $H_{kv}$,因此长上下文和高并发下收益明显。缓存只避免重复计算历史 K/V,并不能让新 token 之间并行产生。

PYTHON
def repeat_kv(x, n_rep: int):
    # x: (B, H_kv, T, D) -> (B, H_q, T, D)
    if n_rep == 1:
        return x
    b, h, t, d = x.shape
    return x[:, :, None].expand(b, h, n_rep, t, d).reshape(b, h * n_rep, t, d)

教学实现可物理重复 K/V,生产内核通常通过广播或专用 kernel 避免额外拷贝。

3.4 SwiGLU:门控前馈网络

抓住这一节的主线。 SwiGLU 用一条分支产生候选值,另一条分支经 SiLU 产生门,再逐元素相乘。门控让网络按输入选择信息通过程度。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. x 分别投影为 gate 与 value。
  2. gate 经过 SiLU。
  3. 两支逐元素相乘。
  4. down projection 回到隐藏维。

先做最小实验。 若 gate 的某维为大负数,SiLU 后接近零,该维 value 即使很大也被抑制。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 SwiGLU 通常需要三组矩阵;把 SiLU 放错分支、忘记逐元素乘或沿错误维度切分,会得到另一种网络。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:实现 SwiGLU,并与普通 GELU FFN 在参数量近似相等时比较输出形状和参数数。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

SwiGLU 常写作

$$\operatorname{SwiGLU}(x)=W_2\left(\operatorname{SiLU}(W_gx)\odot(W_ux)\right).$$

PYTHON
class SwiGLU(nn.Module):
    def __init__(self, dim: int, hidden_dim: int):
        super().__init__()
        self.gate = nn.Linear(dim, hidden_dim, bias=False)
        self.up = nn.Linear(dim, hidden_dim, bias=False)
        self.down = nn.Linear(hidden_dim, dim, bias=False)
 
    def forward(self, x):
        return self.down(torch.nn.functional.silu(self.gate(x)) * self.up(x))

两条投影分别产生门和值,再逐元素相乘。由于有三组矩阵,比较 FFN 参数量时不能只看中间维度;许多配置会选择约为传统 $4d$ 的某个折算维度并对齐硬件倍数。

3.5 组装一个 Llama 风格 Block

先把概念落到可观察对象上。 组装 Block 时最重要的是接口:输入、mask、position_ids、past_key_values 和返回缓存必须一致。组件单独正确,组合时仍可能因形状和残差次序出错。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. Pre-Norm 后进入注意力。
  2. 注意力输出与残差相加。
  3. 再次归一化进入 SwiGLU。
  4. FFN 输出与第二条残差相加。

把它缩小到能逐项检查。 输入 (B,T,D) 经 GQA 后仍必须是 (B,T,D),否则无法做残差。缓存只含 K/V,不应把 Q 跨步保存。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 用 Identity 冒充注意力只能测试外壳,不能证明模型正确;还要验证因果性、缓存增量结果与全序列结果一致。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:用长度 4 的序列比较一次性前向与逐 token 带缓存前向的最后一步 logits。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

PYTHON
class LlamaStyleBlock(nn.Module):
    def __init__(self, dim, hidden_dim, attention: nn.Module):
        super().__init__()
        self.attn_norm = RMSNorm(dim)
        self.ffn_norm = RMSNorm(dim)
        # attention 由外部传入,便于分别测试 MHA、GQA 或带 KV Cache 的实现
        self.attn = attention
        self.ffn = SwiGLU(dim, hidden_dim)
 
    def forward(self, x):
        x = x + self.attn(self.attn_norm(x))
        x = x + self.ffn(self.ffn_norm(x))
        return x

这段代码只负责组织 Block,注意力由外部注入。这样可以分别测试 MHA、GQA 或带 KV Cache 的实现,也避免把过度简化的占位模块伪装成完整 Llama。配套示例 examples/03_llama_components.py 给出可执行的 RMSNorm、RoPE、GQA 形状测试和 SwiGLU。

3.6 Llama 3 应该怎样读

先建立直觉。 读模型技术报告要分清架构、数据、训练配方、评估和安全五层证据。公开参数并不等于公开训练数据或完整生产系统。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 先列出模型族与上下文配置。
  2. 再定位 tokenizer 和架构改动。
  3. 核对预训练与后训练数据说明。
  4. 比较同设置评估。

最小例子。 看到某个 benchmark 分数时,同时记录提示模板、shot 数、解码设置、评测脚本版本和是否使用工具。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 把模型卡营销语当因果结论、跨版本混比分数、忽略许可证与安全限制,会造成错误选型。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:为 Llama 3 报告做一页证据表:已公开事实、合理推断、未公开信息。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

Llama 3 技术报告同时讨论模型架构、数据、训练基础设施、后训练、安全和多模态实验。其最大公开模型是 405B 的 Dense Transformer,支持长上下文,并发布预训练与后训练版本。阅读时建议按以下问题做笔记:

  1. tokenizer 与词表怎样影响多语言和代码效率?
  2. 上下文长度如何训练和评估,而不是只修改配置?
  3. 预训练数据经历了哪些过滤、去重和质量控制?
  4. 后训练如何组合 SFT、偏好优化和安全数据?
  5. 训练稳定性、并行策略和故障恢复如何保证长周期运行?

参数规模只是结果的一部分。一个模型是否适合你的任务,还取决于许可证、语言覆盖、上下文、推理成本、工具调用能力和安全边界。

3.7 DeepSeek-V3:MoE、MLA、MTP 与系统协同

从问题出发。 DeepSeek-V3 的关键不只是 MoE,而是模型结构、训练稳定性、通信与推理成本的协同。MLA 压缩需要缓存的表示,MTP 在训练中增加未来 token 预测信号。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 区分总参数与激活参数。
  2. 理解专家路由与共享专家。
  3. 理解 MLA 的低秩潜变量。
  4. 把系统优化与算法指标分开。

用小数据走一遍。 若 KV 不直接逐头缓存,而是缓存可恢复 K/V 的低维潜变量,内存占用可下降,但解码 kernel 必须配合重构。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 不要把 MLA 说成普通 GQA,也不要把 MTP 等同推理时一次永久输出多个 token;训练目标和服务策略是不同层次。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:根据技术报告画出一层的数据流,标注哪些张量跨 token 缓存、哪些只在当前步使用。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

DeepSeek-V3 报告给出的模型总参数为 671B,每个 token 激活约 37B 参数。其 DeepSeekMoE 通过更细粒度专家与共享专家增强专业化;路由需要兼顾专家分工和设备负载。

Multi-head Latent Attention(MLA)的核心目标之一是压缩推理时需要缓存的 K/V 表示。它先把隐藏状态投影到低维潜在表示,再恢复注意力所需分量,从而减少 KV Cache。理解 MLA 时要区分“训练时表达”“推理时可吸收的矩阵变换”和“实际缓存内容”,不能把它简单说成普通低秩压缩。

Multi-Token Prediction(MTP)在训练时增加对多个未来 token 的预测目标,为模型提供更密集的学习信号;推理系统还可利用相关预测进行投机式加速,但训练目标与部署收益不是自动等价的。DualPipe 则是面向大规模训练的流水线并行设计,重点在计算与通信重叠,属于系统层创新。

3.8 DeepSeek-R1:推理能力来自后训练路线

先看它解决什么。 DeepSeek-R1 说明推理行为可通过可验证奖励、强化学习与后续数据整理增强。推理能力不是简单由长答案产生,关键是奖励、探索、数据与基础模型能力。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 准备具有可判定结果的任务。
  2. 采样多条推理轨迹。
  3. 依据规则或模型计算奖励。
  4. 更新策略并做蒸馏/对齐。

一个可以手算的例子。 数学题可用最终答案验证器提供相对客观奖励,但证明质量、格式和安全仍需额外检查。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 把所有后训练都称为 GRPO、把思维长度当正确性、公开展示敏感内部推理或忽略 reward hacking,都会带来问题。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:设计一个字符串运算任务的奖励函数,分别列出正确性、格式、长度和作弊路径。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

DeepSeek-R1 与 R1-Zero 基于 DeepSeek-V3-Base。R1-Zero 展示了在没有先做 SFT 的条件下,通过大规模强化学习激励自验证、反思和长推理轨迹的可能,但出现重复、可读性差和语言混杂等问题。R1 加入冷启动数据,并组合多个 SFT/RL 阶段以改善可用性与偏好对齐。其蒸馏模型把大模型产生的推理数据迁移到 Qwen/Llama 系列的较小 Dense 模型。

因此要避免两个误解:GRPO 不是“DPO 的泛化形式”,它是面向组内相对奖励的策略优化方法;R1 的推理表现也不能归结为单一损失,数据、可验证奖励、采样、训练稳定性与基础模型能力共同作用。

3.9 练习与资料

抓住这一节的主线。 练习现代解码器要强调等价性测试:组件实现正确、缓存路径与全序列路径一致、混合精度有限、模型配置能完整序列化。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 先测单组件。
  2. 再测 Block。
  3. 再测多层与缓存。
  4. 最后测生成停止条件。

先做最小实验。 对同一随机种子,full forward 第 t 位 logits 应与增量缓存第 t 步 logits 接近;不接近时优先检查 position_ids 与 mask。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 只验证形状不验证数值、只跑 CPU 不测半精度、忽略 batch 中不同长度,都会漏掉真实错误。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:建立 Llama 组件测试矩阵,并把失败样例保存成最小回归用例。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

练习:验证 RoPE 旋转前后向量范数不变;比较 MHA 与 GQA 的 KV Cache 理论大小;实现带缓存的单层 GQA,并验证逐 token 结果与整段前向在数值误差内一致。

本章配套代码

下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。

本章端到端实验:把知识变成可复现证据

本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。

实验步骤

  1. 实现 RMSNorm,与 torch RMSNorm 或参考实现比较前向和梯度。
  2. 对同一内容向量放在不同位置,验证点积随相对距离而不是绝对起点变化。
  3. 为一个假想 32 层模型分别计算 MHA 与 GQA 在 8k 上下文的 KV Cache。
  4. 实现 SwiGLU,并与普通 GELU FFN 在参数量近似相等时比较输出形状和参数数。
  5. 用长度 4 的序列比较一次性前向与逐 token 带缓存前向的最后一步 logits。
  6. 为 Llama 3 报告做一页证据表:已公开事实、合理推断、未公开信息。

每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。

验收标准

  • 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
  • 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
  • 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
  • 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
  • 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。

本章自测

  1. 不看正文,用自己的话解释“RMSNorm 只用均方根控制向量尺度,不减去均值”,并给出一个可以证伪的测试。
  2. 不看正文,用自己的话解释“RoPE 把偶数维与奇数维配成二维平面,并按位置旋转”,并给出一个可以证伪的测试。
  3. 不看正文,用自己的话解释“MHA 为每个查询头保留独立 K/V 头”,并给出一个可以证伪的测试。
  4. 不看正文,用自己的话解释“SwiGLU 用一条分支产生候选值,另一条分支经 SiLU 产生门,再逐元素相乘”,并给出一个可以证伪的测试。
  5. 不看正文,用自己的话解释“组装 Block 时最重要的是接口:输入、mask、position_ids、past_key_values 和返回缓存必须一致”,并给出一个可以证伪的测试。
  6. 不看正文,用自己的话解释“读模型技术报告要分清架构、数据、训练配方、评估和安全五层证据”,并给出一个可以证伪的测试。

回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。

第四章 部署、压测与应用开发:把模型变成可靠服务

模型“能生成”与服务“可上线”之间隔着一整套工程系统:模型加载、批处理、流式传输、并发控制、超时、指标、鉴权、配额、结构化输出和工具权限。本章以 FastAPI 与 vLLM 为例说明通用方法,模型可替换为任何提供兼容接口的本地或云端实现。

图 4-1 一次请求经过网关、应用编排和推理引擎;监控与安全横跨所有层。

4.1 先建立性能指标语言

先建立直觉。 服务指标必须从用户体验和系统资源同时定义。吞吐高并不保证单请求快,平均延迟也会掩盖排队造成的长尾。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. TTFT 衡量首次反馈。
  2. TPOT 衡量持续生成节奏。
  3. P50/P95/P99 描述分布。
  4. tokens/s 与并发描述容量。

最小例子。 两个系统平均都为 2 秒,但一个 P99 为 3 秒、另一个 P99 为 20 秒,生产体验和容量风险完全不同。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 混用端到端延迟与纯模型延迟、忽略输入输出长度、用单并发结果推算高并发容量,都会误导选型。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:为聊天、批量摘要、代码补全分别写 SLO,并说明为何指标权重不同。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

吞吐量与延迟不能混为一谈。常见指标包括:

  • QPS/TPS:每秒完成请求数、每秒生成 token 数。
  • TTFT:从请求到首 token 的时间,主要受排队和 prefill 影响。
  • TPOT:首 token 后每个输出 token 的平均时间,主要反映 decode。
  • P50/P95/P99:延迟分位数;平均值会掩盖长尾。
  • 并发数:同时在途请求,而不是一秒请求总数。
  • 有效吞吐:在错误率、超时率和质量约束下的吞吐。

压测必须说明输入长度、输出长度、并发模型、采样参数、预热、硬件和模型精度。只报告“每秒多少请求”几乎没有可比性。

4.2 FastAPI:异步不等于计算更快

从问题出发。 FastAPI 的 async 适合等待网络、磁盘和队列,不会让同步 GPU kernel 神奇变快。应用层应把请求校验、调度和推理解耦。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. Pydantic 校验输入。
  2. 异步接收与取消。
  3. 通过队列提交推理。
  4. 流式返回并记录指标。

用小数据走一遍。 请求等待模型服务器响应时可释放事件循环;若在 async 路由里执行长时间纯 Python 计算,整个 worker 仍会阻塞。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 全局可变状态无锁、请求断开后不释放资源、超时只包在客户端、每次请求加载模型,都是常见服务事故来源。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:实现一个带请求 id、超时、并发信号量和取消处理的模拟生成接口。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

async 适合等待网络、数据库等 I/O;GPU 推理仍由推理引擎执行。若在事件循环里直接做长时间 CPU 计算,会阻塞所有请求。下面用异步生成器模拟流式 token:

PYTHON
import asyncio
from collections.abc import AsyncIterator
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
 
app = FastAPI()
gate = asyncio.Semaphore(16)
 
class ChatRequest(BaseModel):
    prompt: str = Field(min_length=1, max_length=8000)
    max_tokens: int = Field(default=256, ge=1, le=2048)
 
async def fake_generate(req: ChatRequest) -> AsyncIterator[str]:
    for token in ["这是", "一个", "流式", "示例"]:
        await asyncio.sleep(0.05)
        yield f"data: {token}\n\n"
 
@app.post("/chat/stream")
async def stream_chat(req: ChatRequest):
    try:
        await asyncio.wait_for(gate.acquire(), timeout=2.0)
    except TimeoutError as exc:
        raise HTTPException(503, "服务繁忙,请稍后重试") from exc
 
    async def guarded():
        try:
            async for chunk in fake_generate(req):
                yield chunk
        finally:
            gate.release()
 
    return StreamingResponse(guarded(), media_type="text/event-stream")

代码解读:Pydantic 在入口处验证长度;信号量限制应用层在途请求,避免无限排队;finally 保证客户端断开时释放名额。生产系统还要处理取消传播、上游超时、SSE 心跳、错误帧和请求追踪。

4.3 vLLM 与连续批处理

先看它解决什么。 vLLM 的核心价值来自调度与 KV Cache 管理:把不同到达时间、不同长度的请求动态组成批次,并用分页思想减少缓存碎片。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 请求进入等待队列。
  2. 调度器分配 token 预算。
  3. prefill/decode 共享执行批次。
  4. PagedAttention 管理缓存块。

一个可以手算的例子。 静态批处理必须等一批请求结束;连续批处理可在旧请求完成后立即插入新请求,提高 GPU 利用率。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 吞吐提升依赖长度分布与并发;max_model_len、显存利用率和并行配置不合理仍会 OOM 或增加排队。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:构造短输入长输出与长输入短输出两类负载,分别压测并解释 TTFT/TPOT 差异。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

传统静态批处理要等整批请求都结束,长短请求相互拖累。连续批处理在每个调度步动态加入新请求、移除已完成请求,提高 GPU 利用率。PagedAttention 把 KV Cache 划分为可管理的块,减少连续大内存分配和碎片。它们改变的是服务调度与内存管理,不改变语言模型的语义目标。

典型启动方式会暴露 OpenAI 兼容接口;具体参数随版本变化,应以 vLLM 官方文档 为准。配置时重点理解:张量并行度、最大上下文、GPU 内存利用率、最大并发序列、量化格式和模型是否支持自定义代码。不要盲目把并发上限调大;KV Cache、排队时间和 OOM 会共同限制系统。

4.4 同步、异步和流式调用

图 04-2 请求从校验、排队到流式返回,每一步都需要超时、取消和审计。抓住这一节的主线。 同步、异步和流式是调用语义,不是模型能力。同步简单,异步适合并发等待,流式改善感知延迟但增加状态、取消和错误处理复杂度。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 定义请求生命周期。
  2. 明确重试是否幂等。
  3. 为流式片段设计事件格式。
  4. 在结束事件中给出用量与状态。

先做最小实验。 SSE 可发送 token、tool_call、error、done 等事件;客户端必须能处理连接中断和半条 JSON。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 把网络重试直接复制到有副作用的工具调用、流式途中改变响应 schema、没有心跳和断开检测,都会造成重复操作或资源泄漏。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:写一个流式客户端状态机,覆盖正常结束、用户取消、超时和服务端错误。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

同步调用简单,适合离线脚本;异步调用适合高并发 I/O 编排;流式调用改善感知延迟,但不会减少总计算量。客户端应设置连接、读取和总超时,并对幂等请求做有限次数、带抖动的指数退避。非幂等工具调用不能无脑重试,否则可能重复扣款或重复写数据。

4.5 提示词工程与上下文工程

先把概念落到可观察对象上。 提示词工程组织指令,上下文工程决定模型实际看见哪些规则、历史、证据和工具。可靠系统不依赖一句‘请严格遵守’,而依赖清晰优先级与外部验证。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 分离系统规则和用户数据。
  2. 只放与当前任务相关证据。
  3. 明确输出契约与失败方式。
  4. 用评估集比较版本。

把它缩小到能逐项检查。 摘要任务可规定受众、长度、必须保留的数字和未知时的处理;但引用是否真实仍要由程序检查证据映射。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 把不可信网页拼进系统指令、上下文无限累积、没有版本号、用少数顺手样例判断提示效果,都会放大风险。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:为同一客服任务写三个提示版本,用十条固定样例比较格式正确率和事实错误率。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

一个可维护的提示通常分为:角色与目标、输入数据、约束、输出格式、示例和失败策略。系统提示不是安全边界,客户端传来的文本、网页和检索文档都属于不可信数据。

不要要求模型展示私密思维过程。需要可审计性时,让模型输出简短依据、引用或可验证步骤。Self-Consistency 是对多个独立候选进行聚合,成本随采样次数增长;它适合存在可比较答案的任务,不是所有生成任务的默认配置。

上下文窗口有限,应优先保留指令、当前任务、关键事实和最近对话。历史消息可以摘要,但摘要本身也可能丢信息;重要状态应放入结构化存储,而不是完全依赖聊天记录。

4.6 结构化输出:验证比提示更重要

先建立直觉。 结构化输出的目标是把自然语言结果变成可验证数据。Schema 负责约束类型和必填字段,业务验证还要检查取值范围、跨字段关系和外部事实。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 定义最小 JSON Schema。
  2. 让模型按 schema 生成。
  3. 解析并进行二次业务校验。
  4. 失败时有限重试或降级。

最小例子。 日期字段通过字符串格式校验仍可能是不存在的日期;订单金额非负也不代表币种和税额关系正确。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 仅在提示中贴 JSON 示例、用正则解析任意 JSON、无限自动重试、把校验错误原样暴露给用户,都是脆弱设计。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:为旅行计划定义 schema,加入日期顺序、预算总和和城市白名单验证。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

仅在提示中写“输出 JSON”不能保证合法 JSON,更不能保证字段语义。应使用 JSON Schema/Pydantic 描述结构,并在应用侧验证:

PYTHON
from pydantic import BaseModel, Field, ValidationError
 
class TravelPlan(BaseModel):
    city: str
    days: int = Field(ge=1, le=30)
    highlights: list[str] = Field(min_length=1, max_length=10)
 
def parse_model_json(text: str) -> TravelPlan:
    try:
        return TravelPlan.model_validate_json(text)
    except ValidationError as exc:
        # 记录原始输出和 schema 版本;可触发一次受控修复,而不是无限重试
        raise ValueError(f"模型输出不符合结构: {exc}") from exc

支持 Structured Outputs 的 API 可以把 Schema 约束纳入解码,但业务规则仍需应用验证。Schema 要版本化;字段描述应明确单位、枚举和是否允许空值。

4.7 Function Calling:模型提议,程序执行

从问题出发。 Function Calling 中模型只提出工具名与参数,程序才拥有执行权。安全边界必须放在执行器:白名单、身份、作用域、确认、审计和幂等。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 把工具契约提供给模型。
  2. 解析并验证参数。
  3. 执行器检查权限与风险。
  4. 把结果作为新观察返回。

用小数据走一遍。 模型提出 send_email(to,body) 不等于邮件已发送;程序应在真正发送前检查收件人、敏感信息和是否需要用户确认。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 允许模型拼接任意命令、把隐藏凭据放进工具结果、把读取与写入使用同一权限、错误后盲目重试,都会造成严重风险。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:实现只读天气工具和有副作用的日历工具,比较两者确认与重试策略。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

工具调用的正确边界是:模型根据工具描述生成“调用意图 + 参数”,程序验证参数、检查权限、执行工具,再把结果返回模型。模型不能直接获得数据库管理员权限。

PYTHON
TOOLS = {
    "weather": {
        "description": "查询指定城市的公开天气,不处理历史私人位置",
        "schema": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
            "additionalProperties": False,
        },
    }
}
 
def execute_tool(name: str, arguments: dict, user_scope: set[str]):
    if name not in user_scope:
        raise PermissionError("当前用户无权调用该工具")
    if name == "weather":
        city = arguments["city"].strip()
        if not city or len(city) > 80:
            raise ValueError("非法城市参数")
        return {"city": city, "temperature_c": 26}
    raise KeyError(name)

工具描述要说明用途、边界和副作用。写操作默认需要幂等键或人工确认;工具结果要限制长度并转义不可信内容;审计日志记录调用者、工具、参数摘要、结果和耗时。

4.8 压测方法与容量规划

先看它解决什么。 压测不是把并发数字调大,而是复现真实长度、到达率和解码参数,并在稳定区间测量资源、排队和错误。容量规划还要留故障与流量突增余量。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 构造代表性请求分布。
  2. 预热模型与 kernel。
  3. 逐级提升到达率。
  4. 记录延迟分位、吞吐、GPU 与失败。

一个可以手算的例子。 固定 100 个请求并发与按泊松到达的 100 QPS 不是同一种压力;前者更像瞬时洪峰,后者反映持续负载。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 没有预热、客户端先成为瓶颈、只报最好一次、忽略返回 token 数和错误请求,都会产生漂亮但无效的数字。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:写压测计划,明确样本来源、运行时长、并发模型、成功标准和停止条件。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

压测分三层:单请求基线确认模型可用;逐级增加并发找到吞吐拐点;长稳测试观察泄漏、碎片和长尾。输入长度与输出长度应来自真实分布,并单独测短问长答、长文短答等场景。

容量不是“峰值 QPS ÷ 单卡 QPS”这么简单。还要给发布、故障和突发流量留余量;按租户限流;把 prefill 密集型与 decode 密集型流量分别观察;对超长输入提前拒绝或转异步任务。

4.9 上线检查清单

抓住这一节的主线。 上线清单把隐含假设变成可验证条件,覆盖模型、数据、接口、性能、安全、监控、回滚与责任人。它应随事故和架构变化持续更新。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 离线评估达标。
  2. 容量与降级演练。
  3. 权限和隐私审查。
  4. 监控告警与回滚验证。

先做最小实验。 模型版本回滚不仅换权重,还要确认 tokenizer、提示模板、工具 schema、索引版本和缓存兼容。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 只检查服务能启动、没有灰度样本、告警无负责人、回滚脚本从未执行,都会在事故中暴露。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:为本章服务建立发布门禁表,每项写证据、负责人、截止时间和回滚动作。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

  • 模型与 tokenizer 版本固定,许可证可用于目标场景。
  • 输入大小、输出大小、并发、超时和预算都有上限。
  • 记录 TTFT、TPOT、tokens/s、队列、GPU、错误率与质量抽检。
  • 敏感数据最小化,日志脱敏,租户隔离。
  • 工具调用有白名单、参数验证、权限和审计。
  • 有离线评估集、灰度策略、回滚版本和故障降级。

延伸阅读:FastAPI 异步说明FastAPI 流式响应Qwen 官方快速开始

本章配套代码

下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。

本章端到端实验:把知识变成可复现证据

本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。

实验步骤

  1. 为聊天、批量摘要、代码补全分别写 SLO,并说明为何指标权重不同。
  2. 实现一个带请求 id、超时、并发信号量和取消处理的模拟生成接口。
  3. 构造短输入长输出与长输入短输出两类负载,分别压测并解释 TTFT/TPOT 差异。
  4. 写一个流式客户端状态机,覆盖正常结束、用户取消、超时和服务端错误。
  5. 为同一客服任务写三个提示版本,用十条固定样例比较格式正确率和事实错误率。
  6. 为旅行计划定义 schema,加入日期顺序、预算总和和城市白名单验证。

每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。

验收标准

  • 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
  • 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
  • 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
  • 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
  • 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。

本章自测

  1. 不看正文,用自己的话解释“服务指标必须从用户体验和系统资源同时定义”,并给出一个可以证伪的测试。
  2. 不看正文,用自己的话解释“FastAPI 的 async 适合等待网络、磁盘和队列,不会让同步 GPU kernel 神奇变快”,并给出一个可以证伪的测试。
  3. 不看正文,用自己的话解释“vLLM 的核心价值来自调度与 KV Cache 管理:把不同到达时间、不同长度的请求动态组成批次,并用分页思想减少缓存碎片”,并给出一个可以证伪的测试。
  4. 不看正文,用自己的话解释“同步、异步和流式是调用语义,不是模型能力”,并给出一个可以证伪的测试。
  5. 不看正文,用自己的话解释“提示词工程组织指令,上下文工程决定模型实际看见哪些规则、历史、证据和工具”,并给出一个可以证伪的测试。
  6. 不看正文,用自己的话解释“结构化输出的目标是把自然语言结果变成可验证数据”,并给出一个可以证伪的测试。

回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。

第五章 RAG 基础:让生成建立在可追溯证据上

检索增强生成(RAG)把参数化语言模型与外部知识库组合起来。它适合知识频繁变化、需要引用来源或包含私有领域文档的任务。RAG 不能保证绝对真实:检索可能漏掉证据,文档可能过期,模型也可能无视证据。一个合格系统必须同时评估检索与生成。

图 5-1 离线阶段解析、切块、向量化和建索引;在线阶段查询、检索、组装上下文并生成。

5.1 为什么不把所有知识都微调进去

先建立直觉。 微调更擅长改变行为和风格,RAG 更擅长提供可更新、可引用的外部事实。两者可以组合,但不能用微调代替权限、版本和证据管理。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 判断知识是否频繁变化。
  2. 判断是否要求引用。
  3. 判断行为是否需要稳定塑形。
  4. 评估延迟、成本与维护。

最小例子。 企业制度每月更新且回答必须给出处,优先 RAG;固定输出格式和专业语气可用 SFT;两者一起使用时仍以检索证据为事实来源。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 把训练数据当数据库、认为 RAG 能保证答案正确、把整篇文档直接塞入上下文,都会造成错误。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:列出三个业务场景,分别选择 RAG、微调或组合方案,并给出证据。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

微调擅长改变行为、风格和任务模式,但不适合频繁更新事实,也很难给出精确来源。RAG 可以更新索引而不重训模型,并把证据随请求传入。两者并非互斥:可用 SFT 改善指令遵循,用 RAG 提供动态知识。

原始 RAG 论文把参数记忆与非参数记忆结合。工程系统通常不是端到端训练的单一模型,而是文档管道、检索器、重排器、提示模板、生成模型和评估系统的组合。

5.2 文档解析与切块

图 05-2 RAG 的质量上限常在解析和切块阶段就已经决定。从问题出发。 解析决定系统实际上看到了什么,切块决定检索的最小证据单位。页面、标题、表格、列表和来源信息应尽量保留,而不是只提取一串纯文本。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 识别格式与版面。
  2. 清洗页眉页脚和乱码。
  3. 按语义结构切块。
  4. 附加来源、页码和层级元数据。

用小数据走一遍。 一个表格若按行打散而丢失列名,检索到‘30%’也无法知道它对应哪个指标;应把表头与行内容共同编码。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 固定字符切割截断句子、overlap 过大制造重复、扫描 PDF 不做 OCR 质量检查、元数据不随块保存,都会降低召回与引用。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:对同一篇含标题和表格的文档实现三种切块,人工比较十个问题的可检索证据。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

文档进入索引前需要保留结构:标题层级、页码、段落、表格、图片说明和来源。只抽取纯文本会丢失表头、跨页关系和引用位置。每个 chunk 至少应带 doc_id、版本、标题路径、页码、时间、权限和内容哈希。

固定字符切块简单但可能截断语义;递归切块按段落、句子逐级回退;语义切块根据相邻句向量变化分段;命题切块把复合句拆成原子事实。chunk 越小越容易精确命中,却可能缺背景;越大上下文完整,却降低检索分辨率并占用窗口。

重叠能缓解边界截断,但会增加索引和重复召回。合理做法是先按文档结构切,再用小规模标注查询搜索 chunk 大小与重叠,而不是照抄固定参数。

PYTHON
def recursive_chunks(text: str, max_chars=500, overlap=80):
    paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
    chunks, current = [], ""
    for paragraph in paragraphs:
        candidate = f"{current}\n\n{paragraph}".strip()
        if len(candidate) <= max_chars:
            current = candidate
            continue
        if current:
            chunks.append(current)
        # 超长段落退回滑动窗口;生产版本应优先按句子或 token 切分
        if len(paragraph) > max_chars:
            step = max_chars - overlap
            chunks.extend(paragraph[i:i + max_chars]
                          for i in range(0, len(paragraph), step))
            current = ""
        else:
            current = paragraph
    if current:
        chunks.append(current)
    return chunks

5.3 Embedding 与相似度

先看它解决什么。 Embedding 把文本映射到向量空间,相似度只表示模型学习到的语义接近,不等于事实蕴含或答案正确。向量是否归一化决定点积与余弦的关系。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 用同一模型编码查询和文档。
  2. 按模型要求做归一化。
  3. 建立近似最近邻索引。
  4. 返回分数与元数据。

一个可以手算的例子。 归一化后点积等于余弦相似度;未归一化时向量范数会影响排序。不同模型的向量维度和空间不能直接混用。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 只因维度更大就认为模型更好、跨语言场景未评测、把相似度阈值从一个语料直接搬到另一个语料,都会失败。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:构造同义、相关但不回答、完全无关三类文本,观察相似度分布并选择初始阈值。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

Embedding 把文本映射为向量。语义检索通常用余弦相似度或内积;若向量已归一化,两者排序等价。查询和文档可能需要不同前缀或不同编码接口,应遵循模型卡。

选择 Embedding 时考虑语言、领域、最大长度、维度、速度、许可和查询/文档训练方式。MTEB 等榜单提供参考,但你的真实查询集更重要。维度更大不必然更好,还会增加存储和搜索成本。

PYTHON
import numpy as np
 
def cosine_topk(query_vec, doc_matrix, k=5):
    q = query_vec / (np.linalg.norm(query_vec) + 1e-12)
    docs = doc_matrix / (np.linalg.norm(doc_matrix, axis=1, keepdims=True) + 1e-12)
    scores = docs @ q
    k = min(k, len(scores))
    ids = np.argpartition(-scores, k - 1)[:k]
    ids = ids[np.argsort(-scores[ids])]
    return [(int(i), float(scores[i])) for i in ids]

这段代码是精确搜索,适合小数据和教学。百万级向量常用 FAISS、Milvus 等近似最近邻索引。近似索引用少量召回损失换速度与内存,需调节 HNSW、IVF 等索引参数并测 Recall@K。

5.4 从检索结果到上下文

抓住这一节的主线。 检索结果要经过排序、去重、裁剪和格式化后才能成为上下文。上下文的目标是让模型找到证据边界,而不是把 token 窗口塞满。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 过滤权限与版本。
  2. 去除近重复块。
  3. 按相关性与多样性排序。
  4. 加来源标签后控制预算。

先做最小实验。 同一段在五个版本中重复出现时,应优先保留当前有效版本,否则模型可能引用已废止政策。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 把检索分数当可信度、丢失来源 id、上下文顺序随机、截断时切掉标题或单位,都会损害回答。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:写 build_context 函数,按 token 预算选择块,并保证每块含唯一证据编号。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

召回的 chunk 不能直接无脑拼接。先按权限过滤,再去重、限制同一文档占比、按重排分数选择,并保留稳定引用编号。提示模板应告诉模型只根据证据回答,证据不足时明确拒答,同时要求引用对应编号。

PYTHON
def build_context(hits, max_chars=5000):
    blocks, used = [], 0
    for idx, hit in enumerate(hits, 1):
        block = (f"[证据{idx}] 来源={hit['title']} 页={hit.get('page', '?')}\n"
                 f"{hit['text'].strip()}")
        if used + len(block) > max_chars:
            break
        blocks.append(block)
        used += len(block)
    return "\n\n".join(blocks)

引用必须能回到原文位置。模型生成的 [证据3] 还要验证是否真的存在、是否支撑对应陈述。引用格式正确不等于内容忠实。

5.5 LlamaIndex 等框架的角色

先把概念落到可观察对象上。 框架负责连接加载器、切块器、索引、Retriever 和生成器,但不会替你定义正确的数据边界、评估集和权限模型。先手写最小链路,再使用框架更容易定位问题。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 确认组件接口与数据结构。
  2. 显式记录每步输入输出。
  3. 替换单个组件做对照。
  4. 锁定版本并保留原始样本。

把它缩小到能逐项检查。 当最终回答错误时,若能独立调用 Retriever 查看 top-k,就能区分是召回失败还是生成器忽略证据。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 把框架默认参数当最佳实践、升级后不跑回归、深层 callback 隐藏异常和成本,都会使系统难以解释。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:用同一评估集比较手写检索器与框架检索器,记录结果差异而非只比较代码行数。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

LlamaIndex 把加载器、节点、索引、Retriever、QueryEngine 与 Response Synthesizer 等组件标准化,适合快速组装。学习时要能画出真实数据流,不要把 query_engine.query() 当作魔法。遇到质量问题,应能分别替换解析器、切块器、Embedding、向量库、重排器和生成器。

框架升级较快,示例 API 可能变化。先掌握组件契约:输入是什么、输出是什么、元数据是否保留、是否异步、是否可观测,再看当前官方文档。

5.6 一个从零 RAG 的最小接口

先建立直觉。 最小 RAG 接口应明确查询、检索结果、上下文和回答四个对象,保留证据链。即使不接真实模型,也能用确定性函数测试检索与拼接。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. search 返回结构化 Chunk。
  2. build_context 生成编号证据。
  3. generate 只接收问题与证据。
  4. response 同时返回答案和引用。

最小例子。 Chunk 至少包含 id、source、page、text、score;Response 至少包含 answer、citations、trace_id,便于评估和排错。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 函数直接返回拼接字符串、索引与生成共用全局状态、没有空召回处理、引用由模型自由编造,都会让接口失真。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:扩展最小接口,加入 no_answer 状态、检索耗时和引用存在性验证。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

PYTHON
from dataclasses import dataclass
 
@dataclass
class Chunk:
    text: str
    source: str
    page: int | None
    vector: np.ndarray
 
class TinyRetriever:
    def __init__(self, chunks: list[Chunk]):
        self.chunks = chunks
        self.matrix = np.stack([c.vector for c in chunks])
 
    def search(self, query_vector: np.ndarray, k=5):
        return [(self.chunks[i], score)
                for i, score in cosine_topk(query_vector, self.matrix, k)]

这个类没有负责 Embedding、持久化或权限,恰好说明模块边界。生产系统中查询向量要记录模型版本;文档更新要能增量重建;删除文档必须同步删除索引与缓存。

5.7 评估检索,而不是只看最终回答

从问题出发。 最终回答正确可能掩盖检索失败,因为模型可能凭参数记忆猜对。必须单独标注相关文档并计算 Recall@K、MRR、nDCG 等检索指标。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 定义查询与相关块集合。
  2. 冻结索引和评估版本。
  3. 计算排名指标。
  4. 按问题类型分析失败。

用小数据走一遍。 若正确块在第 8 名,Recall@10 为 1 但 Recall@5 为 0;MRR 还能反映首次命中的位置。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 只有一个参考块、相关性标注含糊、用生成答案反推相关块、调参后仍在同一测试集反复选择,都会污染指标。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:手工标注至少 30 个查询,计算 BM25 和向量检索的 Recall@5 与 MRR。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

先构建小而可信的评估集:查询、相关文档/片段、参考答案和不可回答标记。检索指标包括 Recall@K、Precision@K、MRR、nDCG;生成指标包括答案正确性、证据忠实度、引用准确率、拒答质量。还要记录延迟、成本和失败类型。

Recall@K 回答“相关证据是否出现在前 K 个结果中”;MRR 重视第一个相关结果的位置。没有标注相关文档时,可先人工标注几十到几百条高价值查询。用另一个大模型做评委可以扩展规模,但要用人工样本校准偏差。

5.8 常见失败

先看它解决什么。 RAG 失败应沿数据摄取、索引、查询、召回、重排、上下文和生成逐层定位。每层都要能输出可检查证据。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 确认目标文档确实入库。
  2. 确认切块含完整答案。
  3. 确认查询表达与过滤。
  4. 确认模型引用与回答一致。

一个可以手算的例子。 答案缺失可能不是 embedding 不好,而是 PDF 表格解析丢列、权限过滤过严或当前版本被错误标记过期。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 一遇到 bad case 就更换大模型、只调 top-k、没有保存 trace、修复后不建回归集,会导致问题迁移而非解决。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:为三个失败样例写故障树,每个假设设计一个最便宜的验证实验。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

  • 查询与文档措辞差异大:尝试查询改写、多查询或混合检索。
  • 召回内容正确但答案错误:检查上下文排序、冲突证据、提示和生成模型。
  • 表格问答失败:保留表头与行列结构,必要时走结构化查询。
  • 新旧制度冲突:用生效时间、版本和权威级别过滤。
  • 权限泄漏:权限过滤必须在检索或数据层完成,不能只在提示中声明。

练习:为同一批文档比较 200/500/1000 字符切块的 Recall@5;加入页码和标题路径;设计五条“知识库没有答案”的查询并检查拒答。

延伸阅读:RAG 原始论文Milvus 快速开始LlamaIndex 评估文档

本章配套代码

下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。

本章端到端实验:把知识变成可复现证据

本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。

实验步骤

  1. 列出三个业务场景,分别选择 RAG、微调或组合方案,并给出证据。
  2. 对同一篇含标题和表格的文档实现三种切块,人工比较十个问题的可检索证据。
  3. 构造同义、相关但不回答、完全无关三类文本,观察相似度分布并选择初始阈值。
  4. 写 build_context 函数,按 token 预算选择块,并保证每块含唯一证据编号。
  5. 用同一评估集比较手写检索器与框架检索器,记录结果差异而非只比较代码行数。
  6. 扩展最小接口,加入 no_answer 状态、检索耗时和引用存在性验证。

每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。

验收标准

  • 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
  • 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
  • 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
  • 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
  • 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。

本章自测

  1. 不看正文,用自己的话解释“微调更擅长改变行为和风格,RAG 更擅长提供可更新、可引用的外部事实”,并给出一个可以证伪的测试。
  2. 不看正文,用自己的话解释“解析决定系统实际上看到了什么,切块决定检索的最小证据单位”,并给出一个可以证伪的测试。
  3. 不看正文,用自己的话解释“Embedding 把文本映射到向量空间,相似度只表示模型学习到的语义接近,不等于事实蕴含或答案正确”,并给出一个可以证伪的测试。
  4. 不看正文,用自己的话解释“检索结果要经过排序、去重、裁剪和格式化后才能成为上下文”,并给出一个可以证伪的测试。
  5. 不看正文,用自己的话解释“框架负责连接加载器、切块器、索引、Retriever 和生成器,但不会替你定义正确的数据边界、评估集和权限模型”,并给出一个可以证伪的测试。
  6. 不看正文,用自己的话解释“最小 RAG 接口应明确查询、检索结果、上下文和回答四个对象,保留证据链”,并给出一个可以证伪的测试。

回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。

第六章 工业级 RAG:优化、评估与可运营系统

朴素 RAG 解决“能不能检索”,工业 RAG 解决“在真实数据、真实权限和真实流量下,能否稳定给出可追溯答案”。本章把优化分为查询、索引、召回、重排、上下文、生成和运营七层。优化必须由错误分析驱动,不要一次叠加十种技巧后只看主观示例。

图 6-1 离线评估找到失败类型,线上反馈产生新样本,数据与组件按版本迭代。

6.1 查询变换

先建立直觉。 用户查询常缺少上下文、包含代词或一次询问多个目标。查询变换要提高可检索性,同时保留原意并避免引入用户没有说过的事实。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 结合对话消解指代。
  2. 生成独立检索查询。
  3. 必要时拆为子问题。
  4. 保存原查询用于最终回答。

最小例子。 ‘它支持退款吗’需要从对话确定‘它’指哪个产品;若不确定,应先澄清而不是擅自选实体。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 改写模型加入答案、把所有查询扩展成冗长段落、多个子查询结果不去重,会降低精度与成本。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:为十个多轮问题人工写标准独立查询,评估自动改写的实体保持率。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

用户查询常缺省上下文,例如“它什么时候生效”。首先要结合对话把问题改写为独立查询,但不能把模型猜测当事实。可让模型输出 standalone_queryassumptions,对关键假设要求用户确认。

Multi-query 生成多个检索视角,适合术语不一致;HyDE 先生成假设文档再嵌入,可能改善语义对齐,也可能把错误假设带入检索;查询分解把复杂问题拆成多个子问题,再合并证据。是否启用应看评估集,不是“越高级越好”。

6.2 混合检索与融合

从问题出发。 稀疏检索擅长关键词和编号,稠密检索擅长语义改写。混合检索用融合而非简单拼分数,因为两类分数尺度通常不可比。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 分别运行 BM25 与 Dense。
  2. 保留各自排名。
  3. 用 RRF 或学习融合。
  4. 再做去重和权限过滤。

用小数据走一遍。 RRF 按 1/(k+rank) 累积分数,不要求原始分数同尺度;同一文档在两路都靠前会获得更高融合排名。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 直接相加余弦与 BM25 分数、先截断过小 top-k、融合后丢失来源路由,会让结果不稳定。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:实现 RRF,用包含产品型号与同义问法的评估集比较三种检索。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

Dense 检索擅长语义相似,BM25 擅长专有名词、编号和精确词。混合检索分别召回后可用 Reciprocal Rank Fusion:

\operatorname{RRF}(d)=\sum_r\frac{1}{k+\operatorname{rank}_r(d)}.

PYTHON
from collections import defaultdict
 
def reciprocal_rank_fusion(rank_lists, k=60):
    scores = defaultdict(float)
    for ranked_ids in rank_lists:
        for rank, doc_id in enumerate(ranked_ids, start=1):
            scores[doc_id] += 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

RRF 不依赖不同检索器分数的量纲,容易作为强基线。若业务有时间、地区、产品线和权限条件,应先做元数据过滤或按业务规则融合。

6.3 重排与上下文压缩

先看它解决什么。 重排器在候选集上做更昂贵的查询—文档联合判断,上下文压缩则只保留支持回答的句段。二者目标不同:前者改顺序,后者省窗口。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 宽召回获得候选。
  2. Cross-Encoder 逐对评分。
  3. 按预算选择文档。
  4. 抽取关键句并保留来源映射。

一个可以手算的例子。 召回 50 个块、重排取 8 个,再压缩为 2,000 token;任何抽取句都必须能回指原块与页码。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 在召回不足时指望重排创造正确文档、压缩器改写事实、批处理不当造成巨大延迟,都是常见问题。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:分别测召回 top-k、重排 top-n 与压缩长度对 Recall、引用准确率和 P95 的影响。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

双塔 Embedding 预先编码文档,召回快;Cross-Encoder 把 query 与候选文本共同编码,交互充分但更慢。因此常先召回几十到几百条,再重排到少量证据。重排模型要匹配语言和领域,并测批处理延迟。

上下文压缩不只是摘要。可从 chunk 中抽取与查询相关句子、合并相邻片段、保留标题链和表头。摘要会引入生成误差,高风险场景应保留原文引用并允许回看。

“Lost in the Middle” 提醒我们:长上下文不保证每段都被同等利用。把最强证据放在模型更容易关注的位置,并减少相互矛盾与重复片段,往往比无限扩充上下文有效。

6.4 自适应 RAG 与反馈回路

抓住这一节的主线。 自适应 RAG 先判断问题是否需要检索、需要哪类数据源和检索深度,再依据结果质量决定补检索或拒答。反馈必须可审计,不能直接让线上点击无限改变索引。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 查询分类与路由。
  2. 质量门控检查证据。
  3. 必要时改写重试。
  4. 记录反馈进入离线评审。

先做最小实验。 闲聊可不检索,订单状态走结构化 API,政策问题走文档索引;没有足够证据时返回限制说明。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 路由标签没有评估、失败无限循环、把用户点赞直接当事实正确、不同租户反馈混合,都会带来风险。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:设计路由混淆矩阵,并为每个路由规定最大步数与降级答案。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

不是每个问题都需要检索。系统可先分类:闲聊直接回答;知识问题检索;实时问题走搜索/API;结构化数据走 SQL;高风险问题转人工。分类错误会导致系统性失败,所以要保留兜底路径。

反馈回路可基于低置信度、引用缺失、用户点踩和人工纠错触发再次检索。不要让模型无限自我反思;设置最大轮数、预算和停止条件。反馈数据进入训练或评估前要去重、脱敏和人工抽检,避免把恶意输入写回知识库。

6.5 多模态、表格与图 RAG

先把概念落到可观察对象上。 多模态与表格 RAG 的难点是保持结构关系;图 RAG 则显式表示实体与关系。它们不是默认更好,只有普通文本检索无法表达问题结构时才值得引入。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 保留页面布局与坐标。
  2. 为图像生成可追溯描述或向量。
  3. 为表格保存表头关系。
  4. 图谱实体链接并记录证据。

把它缩小到能逐项检查。 询问‘2024 年华东区哪个季度增长最高’需要保留年份、区域、季度和指标列,单行文本块可能丢失这些关系。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 OCR 文本与图像重复入库、实体消歧错误、图谱边无来源、把图 RAG 当万能推理器,都会降低可信度。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:选择一张表和一页图文 PDF,设计可回答五个问题的结构化表示。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

扫描 PDF 需要 OCR 与版面分析;图片应生成可检索描述并保留原图坐标;表格应保留表头、单位和合并单元格关系。多模态 Embedding 可以统一召回,但最终证据展示仍要回到原始页面。

Graph RAG 适合实体关系、多跳问题和全局主题总结。它需要实体消歧、关系抽取、社区或路径检索,构建和更新成本更高。若问题主要是单文档事实查找,普通 RAG 往往更简单可靠。

6.6 评估体系:从数据集到故障归因

图 06-2 把检索和回答拆开评估,才能识别参数记忆猜对与证据被忽略。先建立直觉。 工业评估从真实流量抽样,建立可版本化数据集,并把错误归因到组件。检索、回答、忠实度、引用、延迟、成本与安全必须分别测量。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 定义任务与失败分类。
  2. 分层抽样并双人标注。
  3. 建立基线和置信区间。
  4. 发布前后持续回归。

最小例子。 总体准确率不变时,政策题可能提升而表格题下降;分桶结果能揭示回归。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 只用 LLM judge、没有人工校准、测试集泄漏到提示、指标定义随版本变化,会让结果不可比较。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:写一份 100 题评估方案,规定抽样比例、标注规范、冲突仲裁和报告模板。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

评估集应覆盖头部问题、长尾问题、不可回答、冲突文档、过期文档、权限边界、表格、长文和对抗输入。每条样本至少标注:期望证据、参考结论、可接受变体、风险级别。

将端到端错误拆为:解析错误、切块错误、召回漏失、排序错误、上下文组装错误、生成不忠实、引用错误和业务规则错误。只有先归因,才能知道该换 Embedding、改 chunk、加重排还是改提示。

线上监控除延迟和错误率,还要看空召回率、证据覆盖、引用点击、拒答率、每查询 token、缓存命中和不同租户的质量差异。用户满意度受界面和期望影响,不能替代客观正确性。

6.7 可运营架构

从问题出发。 可运营 RAG 需要数据面与服务面分离:摄取流水线负责版本、权限和索引发布,在线服务负责查询、检索、生成与观测。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 原始文档不可变存档。
  2. 增量解析与索引构建。
  3. 影子验证后原子切换。
  4. 在线 trace 关联每个版本。

用小数据走一遍。 删除一份文档时,不只从向量库删块,还要更新缓存、倒排索引、图谱和审计记录,并证明用户无法再检索。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 原地更新唯一索引、权限只在生成前检查、没有文档 lineage、缓存键不含租户与版本,都会造成泄漏或不一致。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:画出双索引蓝绿发布架构,并写回滚、删除和权限变更流程。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

把文档接入与在线问答解耦。离线服务负责解析、去重、切块、Embedding、索引和版本发布;在线服务负责路由、检索、重排、生成和引用;评估服务复放固定数据集;观测平台关联请求 trace 与组件版本。

索引发布应原子切换并可回滚。文档删除要传播到向量、关键词、缓存和备份策略。多租户系统应把权限字段写入索引并在查询层强制过滤,不能生成后再删敏感句子。

6.8 成本与延迟优化

先看它解决什么。 延迟优化先看时间分解:查询改写、两路召回、重排、生成各占多少。成本优化要看 token、模型调用、索引与存储,而不是只换便宜模型。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 埋点得到阶段耗时。
  2. 并行独立检索。
  3. 批处理重排与 embedding。
  4. 缓存稳定且无权限风险的结果。

一个可以手算的例子。 若 70% 时间花在生成,继续优化向量索引收益有限;若 TTFT 被串行改写和重排占满,应先并行或路由。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 缓存包含用户敏感结果、为了省 token 截断关键证据、只看均值不看长尾、压测数据过短,都会制造假优化。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:为一次请求画甘特图,提出三项优化并预估对 P50/P95、成本和质量的影响。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

常用手段包括:缓存查询 Embedding 与稳定答案、缩小重排候选、并行关键词/向量检索、批量 Embedding、对长文异步建索引、对低风险查询使用小模型。缓存键必须包含知识库版本、权限范围和提示版本,否则会返回过期或越权答案。

性能优化要保持质量门槛。例如把 top_k 从 20 降到 5 可降低重排成本,却可能伤害长尾 Recall。每个优化都应在同一评估集上同时报告质量、P95 和成本。

6.9 项目表达:不要只写“搭建 RAG”

抓住这一节的主线。 项目表达要让读者或面试官能复现你的判断:问题、约束、基线、个人动作、实验、指标、失败和遗留问题缺一不可。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 说明为什么需要 RAG。
  2. 给出数据规模与评估定义。
  3. 比较至少一个基线。
  4. 明确自己的决策和边界。

先做最小实验。 ‘Recall@5 从 0.69 到 0.84’还需说明查询数、相关性定义、置信区间、索引版本和硬件。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 只写使用了某框架、数字无出处、把团队成果全算个人、没有 bad case,会让项目不可信。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:把自己项目写成 200 字摘要,再列出可能被追问的十个证据问题。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

一条可信的项目经历应包含业务约束、数据规模、本人负责、技术取舍、评估方法和量化结果。例如:

为内部制度问答构建可追溯 RAG;负责 PDF 版面解析、标题感知切块和混合检索。基于 420 条人工标注查询将 Recall@5 从 0.71 提升至 0.86,引用准确率从 78% 提升至 91%;通过批量重排与缓存把 P95 从 3.2 秒降至 1.9 秒。所有数字来自固定版本评估与压测环境。

不能验证的百分比不要写。面试官会追问数据怎么标、基线是什么、是否显著、线上是否一致、你做了哪部分。

6.10 实战路线

先把概念落到可观察对象上。 实战应从小而真实的数据集开始,先建立可解释基线,再逐项增加复杂度。每次只改变一个主要变量并保留回归结果。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 建立 50—200 条标注查询。
  2. BM25 与 Dense 做基线。
  3. 加入混合和重排。
  4. 上线监控并维护错误簿。

把它缩小到能逐项检查。 先确保目标证据可被 BM25 找到,再比较 embedding;否则切块或解析问题会被误判成模型问题。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 一开始就上图谱、多 Agent 和复杂框架,评估集却只有几个演示问题,会让系统无法收敛。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:完成一个两阶段里程碑:第一阶段可追溯基线,第二阶段针对最大错误类型优化并写实验报告。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

选择一组公开报告或产品手册,建立 100 条查询评估集。先做 BM25 与 Dense 两个基线,再加入 RRF、重排和查询改写,每次只改一个因素。输出四张表:检索指标、答案指标、延迟成本、错误类型分布。最后实现文档版本、权限过滤、引用跳转与一键回滚。

延伸资料:RAG SurveyMilvus 索引说明Datawhale Agent/RAG 面试问题整理

本章配套代码

下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。

本章端到端实验:把知识变成可复现证据

本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。

实验步骤

  1. 为十个多轮问题人工写标准独立查询,评估自动改写的实体保持率。
  2. 实现 RRF,用包含产品型号与同义问法的评估集比较三种检索。
  3. 分别测召回 top-k、重排 top-n 与压缩长度对 Recall、引用准确率和 P95 的影响。
  4. 设计路由混淆矩阵,并为每个路由规定最大步数与降级答案。
  5. 选择一张表和一页图文 PDF,设计可回答五个问题的结构化表示。
  6. 写一份 100 题评估方案,规定抽样比例、标注规范、冲突仲裁和报告模板。

每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。

验收标准

  • 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
  • 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
  • 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
  • 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
  • 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。

本章自测

  1. 不看正文,用自己的话解释“用户查询常缺少上下文、包含代词或一次询问多个目标”,并给出一个可以证伪的测试。
  2. 不看正文,用自己的话解释“稀疏检索擅长关键词和编号,稠密检索擅长语义改写”,并给出一个可以证伪的测试。
  3. 不看正文,用自己的话解释“重排器在候选集上做更昂贵的查询—文档联合判断,上下文压缩则只保留支持回答的句段”,并给出一个可以证伪的测试。
  4. 不看正文,用自己的话解释“自适应 RAG 先判断问题是否需要检索、需要哪类数据源和检索深度,再依据结果质量决定补检索或拒答”,并给出一个可以证伪的测试。
  5. 不看正文,用自己的话解释“多模态与表格 RAG 的难点是保持结构关系”,并给出一个可以证伪的测试。
  6. 不看正文,用自己的话解释“工业评估从真实流量抽样,建立可版本化数据集,并把错误归因到组件”,并给出一个可以证伪的测试。

回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。

第七章 Agent:让模型在受控边界内观察、决策与行动

Agent 不是“加一个提示让模型自己想”。它是一个运行时系统:模型接收目标和当前状态,选择工具或给出答案,程序执行动作并返回观察,循环直到完成、失败或需要人类决策。真正困难的是工具契约、状态管理、停止条件、权限、安全和评估。

图 7-1 模型负责提出下一步,运行时负责执行、校验、记录与终止。

7.1 Workflow 与 Agent

先建立直觉。 Workflow 由程序预先规定路径,Agent 让模型在运行时选择下一步。任务路径稳定、风险高时优先 Workflow;开放探索且可验证时才需要更多自主性。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 判断步骤是否可预先枚举。
  2. 判断工具结果是否可验证。
  3. 评估失败成本。
  4. 设置最大自主范围。

最小例子。 发票审批流程适合确定性工作流;跨来源研究问题可能需要动态决定下一次搜索。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 把一次模型调用称为 Agent、把所有流程都交给模型、没有人工接管点,会同时增加成本和风险。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:把一个业务过程画成 Workflow 版和 Agent 版,比较可测试性与失败面。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

若步骤固定、规则明确,优先使用工作流:分类后走不同分支、并行调用多个模型、由评审器打分等。Agent 适合路径难以预先写死、需要基于中间结果动态决策的任务。自治程度越高,成本和风险越高。

ReAct 可概括为“推理—行动—观察”的交替。工程实现不应依赖模型输出冗长私密思维;只需结构化地返回动作、参数和简短理由,详细 trace 由运行时记录。

7.2 一个最小工具循环

从问题出发。 最小 Agent 循环只有观察、决策、行动和停止。状态必须由程序保存,模型输出只是候选决策;每一步都要有预算与 trace。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 把目标和当前状态交给模型。
  2. 解析 action 或 final。
  3. 验证并执行工具。
  4. 把结构化观察写回状态。

用小数据走一遍。 搜索工具返回 title、url、snippet 与错误码;模型看到的是受控结果,不应直接获得浏览器或系统任意权限。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 工具异常后无限循环、观察文本没有长度限制、停止完全靠模型自觉、状态原地混乱修改,都会失控。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:实现 max_steps=5 的工具循环,用假工具测试成功、未知工具、超时和达到步数上限。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

PYTHON
from dataclasses import dataclass
from typing import Any, Callable
 
@dataclass
class Tool:
    name: str
    description: str
    fn: Callable[..., Any]
 
class AgentRuntime:
    def __init__(self, model, tools: list[Tool], max_steps=8):
        self.model = model
        self.tools = {t.name: t for t in tools}
        self.max_steps = max_steps
 
    def run(self, goal: str):
        state = {"goal": goal, "observations": []}
        for step in range(self.max_steps):
            decision = self.model.decide(state, list(self.tools.values()))
            if decision["type"] == "final":
                return {"status": "completed", "answer": decision["answer"],
                        "steps": step + 1}
            if decision["type"] != "tool":
                return {"status": "failed", "reason": "非法动作"}
            name = decision["name"]
            if name not in self.tools:
                state["observations"].append({"error": f"未知工具 {name}"})
                continue
            try:
                result = self.tools[name].fn(**decision["arguments"])
                state["observations"].append({"tool": name, "result": result})
            except Exception as exc:
                state["observations"].append({"tool": name, "error": str(exc)})
        return {"status": "stopped", "reason": "达到最大步数"}

代码解读:运行时而非模型控制最大步数;未知工具和异常被转成观察;每次工具调用都应在 fn 内再做参数、权限与副作用检查。真实系统还要限制总 token、金额、网络域名和墙钟时间。

7.3 写好工具契约

图 07-2 模型负责提议,Schema、权限、确认和审计共同决定动作能否执行。先看它解决什么。 工具契约既是模型说明书,也是执行器的安全边界。名称、用途、参数、必填项、枚举、返回值、错误和副作用必须明确。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 用 JSON Schema 定义参数。
  2. 让执行器做二次校验。
  3. 区分读操作和写操作。
  4. 错误返回稳定机器码。

一个可以手算的例子。 get_weather 的 city 应是字符串,但还要限制长度和允许字符;delete_file 则必须限制根目录并要求确认。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 描述含糊、参数可接受任意 SQL/命令、异常直接返回密钥或堆栈、工具名相似,会诱发错误调用。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:为三个工具写契约并设计十个非法参数测试。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

工具名要稳定,描述要说明“何时用、何时不用”,参数 Schema 要少而明确。一个“万能搜索”工具往往不如拆成内部知识库、公开网页、数据库查询等有清楚边界的工具。返回值使用结构化字段,避免把整页 HTML 塞回上下文。

写操作应分类:只读可自动执行;可逆写操作可要求一次确认;不可逆或高影响操作必须明确目标并人工批准。凭据由运行时保管,绝不放进提示词。工具输出属于不可信输入,要防提示注入。

7.4 规划、反思与停止

抓住这一节的主线。 规划把目标拆成可执行子任务,反思检查证据缺口,停止条件防止无穷探索。三者都应由外部预算和验证器约束。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 生成带依赖的任务列表。
  2. 完成一步后更新状态。
  3. 检查目标是否已被证据满足。
  4. 触发成功、失败或预算停止。

先做最小实验。 研究报告可要求每个结论至少一条来源;若缺来源则继续检索,但达到最大查询数后必须明确不确定。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 反思只让同一模型重复说一遍、计划一次生成后从不更新、没有硬停止和成本上限,会产生冗长轨迹。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:为旅行规划定义成功条件、三个失败条件和 token/工具/时间预算。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

复杂目标可先产生短计划,但计划不是承诺;观察改变时要更新。任务可分解为有依赖关系的子目标,用状态机或 DAG 比一段自由文本更可控。

Reflection 适合在失败后总结“证据缺什么、下一步验证什么”,不适合无限自我批评。停止条件包括:目标已满足、关键证据不足、需要用户偏好、工具连续失败、预算耗尽、风险升级。可靠 Agent 知道什么时候停。

7.5 记忆与上下文工程

先把概念落到可观察对象上。 记忆是可持久化信息,上下文是本轮实际提供给模型的信息。长期记忆必须经过写入筛选、来源记录、检索和遗忘,而不是保存全部对话。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 短期状态保存当前任务。
  2. 长期记忆只写稳定有用信息。
  3. 按当前问题检索。
  4. 对过期与敏感信息删除。

把它缩小到能逐项检查。 用户明确偏好素食可存为带来源和时间的偏好;一次临时说‘今天想吃辣’不应自动成为永久身份属性。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 把模型猜测写成用户事实、跨用户混用记忆、没有删除入口、把检索结果当系统指令,都会伤害隐私与正确性。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:设计 memory record schema,包含主体、内容、来源、置信、时间、权限和过期策略。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

短期记忆是当前任务状态和最近观察;长期记忆应是经过筛选的结构化事实或可检索记录。不要把所有对话永久保存并全部塞回模型。建议把记忆分为:用户明确偏好、任务事实、可复用程序经验和原始审计日志。

写入长期记忆前检查来源、置信度、时效、隐私和删除机制。摘要记忆会漂移,重要事实应链接到原始证据。RAG 可做长期检索,但“相似”不等于“仍然正确”。

7.6 Deep Research 的工程结构

先建立直觉。 Deep Research 是受控的信息获取流水线:拆解问题、多轮搜索、页面阅读、证据去重、矛盾处理和带引用写作。难点在证据质量,而不是生成篇幅。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 把开放问题拆成可检索子问。
  2. 记录每个来源与摘录。
  3. 识别重复和冲突。
  4. 按结论—证据映射生成报告。

最小例子。 统计数据应优先原始发布机构,新闻可用于发现线索但不替代官方数据;不同时间口径必须在报告中说明。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 只搜索一次、引用搜索摘要而未打开原文、生成不存在的 URL、把发布时间当事件时间,都会造成虚假研究。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:完成一个五来源研究报告,为每条事实保存 claim、source、quote span 和访问日期。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

研究型 Agent 常执行:澄清问题、制定检索子题、搜索多源资料、读取原文、提取证据、交叉验证、生成带引用报告。关键不是搜索次数,而是证据质量与覆盖。

可把每条证据存成 {claim, source_url, excerpt, date, confidence},生成结论时只引用可回溯来源。对时间敏感主题检查发布日期与事件发生日期;对技术主题优先论文和官方文档;对冲突来源明确说明差异。

7.7 MCP:标准化模型与外部能力的连接

从问题出发。 MCP 规定 client、host、server 之间怎样发现并调用工具、资源和提示,使能力连接标准化。它解决互操作,不自动解决权限、可信度和业务安全。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. Host 管理会话与用户界面。
  2. Client 与单个 Server 连接。
  3. Server 暴露工具或资源。
  4. 传输层交换协议消息。

用小数据走一遍。 文件系统 MCP server 即使遵循协议,也必须限制允许目录;标准化的调用格式不意味着可访问整块磁盘。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 把 MCP 当 Agent 框架、让同一 server 同时拥有过宽读写权限、忽略 server 返回内容不可信,都会扩大攻击面。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:画出 Host—Client—Server 架构,并为一个只读知识库 server 写最小权限表。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

Model Context Protocol 使用 client-host-server 架构,让服务器暴露工具、资源和提示等能力。MCP 解决“怎样描述和连接能力”,不自动解决权限与可信问题。安装任何 MCP server 前,应审查来源、它能访问的数据、可执行动作、凭据范围和日志策略。

工具调用与 MCP 的关系可以理解为:模型仍生成调用意图,MCP 提供标准化发现与传输,宿主负责授权、用户交互和安全边界。不要把“协议标准化”误解为“所有工具天然安全”。

7.8 为什么 Agent 会失败

先看它解决什么。 Agent 失败通常来自目标不清、工具契约错误、状态丢失、上下文污染、权限过宽、重试不当和缺少评估。模型只是系统中的一个组件。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 保存完整 trace。
  2. 找到首次偏离目标的位置。
  3. 把失败归因到具体组件。
  4. 加入最小修复和回归。

一个可以手算的例子。 重复调用同一工具可能是观察字段不清,也可能是停止条件未表达;仅更换更大模型无法证明根因。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 只看最终答案、事后人工补轨迹、失败样本不版本化、把所有问题归因于 hallucination,都会阻碍改进。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:为十条失败轨迹标注第一错误步骤和责任组件,统计最大失败类别。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

  • 工具描述含糊,模型选错工具或填错参数。
  • 上下文堆积导致关键约束被淹没。
  • 计划依赖错误事实,后续步骤不断放大偏差。
  • 缺少幂等、重试和状态恢复,工具部分成功后重复执行。
  • 没有明确完成标准,循环不停或过早结束。
  • 评估只看最终文本,不看实际副作用和轨迹。

改进顺序通常是:缩小任务范围,简化工具,增强 Schema 与验证,加入可观测性,最后才考虑更复杂框架或多 Agent。

7.9 多 Agent 不是默认答案

抓住这一节的主线。 多 Agent 适合可独立并行、角色边界清晰、结果可合并验证的任务。否则通信、冲突和重复工作可能大于收益。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 按可交付物拆分角色。
  2. 定义共享状态与消息契约。
  3. 限制每个角色工具权限。
  4. 由确定性规则或审阅者合并。

先做最小实验。 三个 Agent 分别检索不同数据源可以并行;让三个 Agent 都自由规划同一任务通常只产生重复。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 用角色提示代替权限隔离、共享无限对话、没有冲突解决和总预算,都会使系统更脆弱。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:估算一个多 Agent 方案的额外调用数、最长依赖链和失败传播路径,再与单 Agent 比较。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

多个 Agent 可按研究、执行、评审等角色分工,但会增加通信、冲突、重复工作和故障定位难度。只有当子任务能清楚分割、并行收益明显、输出契约可验证时才值得使用。多数业务先用单 Agent + 明确工具 + 工作流路由更可靠。

7.10 评估与安全

先把概念落到可观察对象上。 Agent 评估要覆盖任务完成、步骤效率、工具正确性、恢复能力、成本、延迟和安全。最终答案正确也不能掩盖越权或不可重复的过程。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 建立带初始状态的任务集。
  2. 记录期望工具与允许动作。
  3. 模拟超时和错误。
  4. 审计副作用与权限。

把它缩小到能逐项检查。 预订测试中可使用沙箱日历,检查是否选对时间、是否重复创建、是否在写入前获得确认。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 只用自然语言 judge、测试工具永不失败、没有 adversarial prompt、线上真实写操作直接评测,都会高估可靠性。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:设计 20 个 Agent 测试,至少含注入、工具超时、空结果、重复调用和权限拒绝。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

Agent 评估要同时看任务成功率、步骤数、工具错误、成本、延迟、恢复能力和安全违规。测试集应包含工具超时、空结果、恶意网页、权限不足、重复请求、歧义目标与需要人工确认的场景。

防提示注入采用分层策略:不可信内容与系统指令隔离;工具最小权限;危险动作确认;限制网络与文件范围;输出验证;异常检测;完整审计。没有单一提示能彻底解决注入。

练习:实现天气与计算器两个只读工具;加入每工具超时、总预算和 trace;构造含“忽略之前指令”的恶意工具结果,验证运行时不会扩大权限。

延伸资料:Anthropic《Building Effective Agents》MCP 架构Agent 评估实践

本章配套代码

下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。

本章端到端实验:把知识变成可复现证据

本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。

实验步骤

  1. 把一个业务过程画成 Workflow 版和 Agent 版,比较可测试性与失败面。
  2. 实现 max_steps=5 的工具循环,用假工具测试成功、未知工具、超时和达到步数上限。
  3. 为三个工具写契约并设计十个非法参数测试。
  4. 为旅行规划定义成功条件、三个失败条件和 token/工具/时间预算。
  5. 设计 memory record schema,包含主体、内容、来源、置信、时间、权限和过期策略。
  6. 完成一个五来源研究报告,为每条事实保存 claim、source、quote span 和访问日期。

每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。

验收标准

  • 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
  • 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
  • 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
  • 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
  • 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。

本章自测

  1. 不看正文,用自己的话解释“Workflow 由程序预先规定路径,Agent 让模型在运行时选择下一步”,并给出一个可以证伪的测试。
  2. 不看正文,用自己的话解释“最小 Agent 循环只有观察、决策、行动和停止”,并给出一个可以证伪的测试。
  3. 不看正文,用自己的话解释“工具契约既是模型说明书,也是执行器的安全边界”,并给出一个可以证伪的测试。
  4. 不看正文,用自己的话解释“规划把目标拆成可执行子任务,反思检查证据缺口,停止条件防止无穷探索”,并给出一个可以证伪的测试。
  5. 不看正文,用自己的话解释“记忆是可持久化信息,上下文是本轮实际提供给模型的信息”,并给出一个可以证伪的测试。
  6. 不看正文,用自己的话解释“Deep Research 是受控的信息获取流水线:拆解问题、多轮搜索、页面阅读、证据去重、矛盾处理和带引用写作”,并给出一个可以证伪的测试。

回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。

第八章 预训练:数据、目标、规模与训练稳定性

预训练让模型从大规模语料中学习语言统计、知识与通用表示。它不是把网页“存进参数”,而是在下一个 token 等目标上优化概率分布。完整预训练工程包括数据治理、tokenizer、模型设计、规模实验、分布式训练、监控、评估和发布。

图 8-1 预训练是可版本化的数据—模型—评估闭环,数据治理贯穿全程。

8.1 训练目标与架构

先建立直觉。 预训练把大量 token 变成自监督样本。Decoder-only 用前缀预测下一个 token,Encoder-only 常用掩码恢复,Encoder-Decoder 可用去噪重建;目标决定可用信息与适合任务。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 文本经过 tokenizer。
  2. 构造输入与标签。
  3. 模型输出每位置 logits。
  4. 只在有效标签上计算损失。

最小例子。 长度 T 的自回归序列可产生 T-1 个预测目标,训练时并行计算,推理时只能按前缀逐步生成。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 把训练并行误解为推理并行、标签未右移、EOS 与 padding 混淆、把 perplexity 跨 tokenizer 直接比较,都会误导。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:对同一短句分别写出 causal LM、masked LM 与 seq2seq 去噪的输入和标签。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

Decoder-only 模型最常用因果语言建模:

\mathcal{L}=-\sum_{t=1}^{T}\log p_\theta(x_t\mid x_{<t}).

Encoder-only 模型可用掩码语言建模,适合表征与理解;Encoder-Decoder 可用 span corruption,把被遮蔽片段作为生成目标。目标决定可见信息与下游归纳偏置,不能只比较模型参数量。

训练时采用 teacher forcing:所有位置的真实前缀同时输入,在因果掩码下并行预测。标签通常是输入右移一位;padding 或跨样本拼接位置要设为忽略值,避免计算无效损失。

8.2 数据获取与合规

图 08-2 来源登记、去重和安全过滤都应保留可审计记录。从问题出发。 数据质量、合法性和可追踪性是预训练的第一层模型能力。采集只是开始,还要做格式识别、语言过滤、去重、质量评分、隐私与版权处理。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 建立来源与许可清单。
  2. 抽取正文并过滤模板噪声。
  3. 文档级与段落级去重。
  4. PII/恶意内容处理并分层抽样。

用小数据走一遍。 同一新闻被数千站点转载会放大记忆和数据泄漏风险;MinHash 可近似发现相似文档,但阈值需要通过人工样本校准。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 只按关键词过滤、训练集与评测集去重不足、删除数据后索引仍保留、来源权重无记录,都会造成不可解释风险。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:为 1 万篇文档设计数据卡,记录来源、许可、语言、质量、去重簇和过滤原因。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

数据首先要有可用权利和清楚来源。建立数据卡记录来源、许可、语言、时间、清洗规则、PII 处理、已知偏差与删除流程。公开可抓取不等于可随意训练或再分发。

清洗通常包括编码修复、语言识别、文档质量过滤、样板/广告去除、PII 与恶意内容处理。去重分精确去重与近似去重;训练/评估集之间还要做污染检测。过度过滤会损失方言、少数语言和领域多样性,质量规则应抽样审计。

混合数据时,不能只按原始体量采样,否则高资源语种和重复网页会主导训练。可按领域、语言与质量分层设权重,并跟踪每类 token 的实际占比。

8.3 Tokenizer 训练

先看它解决什么。 Tokenizer 决定模型看到的基本符号、序列长度和多语言效率。BPE、Unigram 等算法从字符或字节出发学习可复用子词,不是寻找唯一正确的词语边界。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 规范化与预分词。
  2. 统计候选片段。
  3. 迭代合并或概率选择。
  4. 加入特殊 token 并验证可逆性。

一个可以手算的例子。 代码缩进、中文、生僻字符和 emoji 都应能无损编码;byte fallback 可避免未知字符,但可能增加 token 数。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 在全量测试集上训练词表、特殊 token id 与模型配置不一致、只看词表大小不看压缩率,会产生隐患。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:训练两个不同词表大小的 tokenizer,比较中英代码语料的字符/token 比和未知字符处理。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

Tokenizer 词表过小会产生长序列,过大增加嵌入和输出层参数并稀释低频 token。中文、代码、数学和多语言对分词有不同需求。评估指标包括平均每字符 token 数、不同语种压缩率、特殊符号和空白保真、未知/字节回退行为。

特殊 token 的 id、对话模板和 BOS/EOS 规则必须固定并写入模型配置。预训练完成后随意更换 tokenizer 相当于改变输入语义。

8.4 Scaling Laws:用小实验规划大训练

抓住这一节的主线。 Scaling Law 用小规模实验估计模型、数据和计算之间的趋势,帮助在固定预算下选择参数量与 token 数。它是经验规律,不是对所有架构和数据永恒不变的定律。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 选择多个模型和数据规模。
  2. 保持训练配方可比。
  3. 拟合 loss 与 compute 曲线。
  4. 用外推结果规划并留安全余量。

先做最小实验。 只训练更大的模型但 token 不足可能处于欠训练状态;更多高质量 token 有时比继续增加参数更划算。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 用单个实验点拟合、跨 tokenizer 比 perplexity、把训练 FLOPs 当总成本、忽略数据质量变化,都会破坏结论。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:用三个小模型和三种 token 预算记录验证损失,画 compute—loss 图并说明不确定性。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

Scaling Law 描述损失与模型规模、数据量和计算量之间的经验幂律关系。Chinchilla 工作指出,在给定计算预算下,模型参数和训练 token 应协调扩展;它不是“永远固定 20 token/参数”的硬定律,具体最优点受数据质量、架构、优化器和目标影响。

实践中先训练多组小模型,保持数据和训练设置可比,拟合损失曲线,再估算目标规模。外推存在误差,应留预算和容错。只扩大参数而数据不足会欠训练;只增加数据而模型太小也会进入收益递减。

8.5 一个微型自回归模型

先把概念落到可观察对象上。 微型自回归模型把完整链路缩小到单机可观察尺度:embedding、多个 Decoder Block、最终归一化和词表投影。它用于验证实现,不代表真实大模型训练配方。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. token/position 进入隐藏状态。
  2. 逐层注意力与 FFN。
  3. 输出投影到词表。
  4. 交叉熵训练并采样生成。

把它缩小到能逐项检查。 权重绑定可让输入 embedding 与输出 projection 共用参数;是否使用必须与架构配置和维度一致。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 位置长度超界、因果 mask 错误、初始化过大、训练语料太小却期望知识能力,都会导致误判。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:让模型在一小段文本上过拟合,确认 loss 接近零并能复述,再换验证文本观察泛化差距。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

PYTHON
import torch
from torch import nn
 
class TinyLM(nn.Module):
    def __init__(self, vocab_size=256, dim=128, layers=4, heads=4, max_len=512):
        super().__init__()
        self.token_emb = nn.Embedding(vocab_size, dim)
        self.pos_emb = nn.Embedding(max_len, dim)  # 教学版用绝对位置
        layer = nn.TransformerEncoderLayer(
            d_model=dim, nhead=heads, dim_feedforward=4 * dim,
            batch_first=True, norm_first=True, activation="gelu"
        )
        self.blocks = nn.TransformerEncoder(layer, num_layers=layers)
        self.norm = nn.LayerNorm(dim)
        self.lm_head = nn.Linear(dim, vocab_size, bias=False)
        self.lm_head.weight = self.token_emb.weight
 
    def forward(self, tokens):
        b, t = tokens.shape
        pos = torch.arange(t, device=tokens.device)
        x = self.token_emb(tokens) + self.pos_emb(pos)[None]
        causal = torch.triu(torch.ones(t, t, device=tokens.device, dtype=torch.bool), 1)
        x = self.blocks(x, mask=causal)
        return self.lm_head(self.norm(x))
 
def next_token_loss(model, batch):
    logits = model(batch[:, :-1])
    labels = batch[:, 1:]
    return nn.functional.cross_entropy(logits.reshape(-1, logits.size(-1)),
                                       labels.reshape(-1))

这里借用 TransformerEncoder 但传入因果 mask,使其表现为因果堆叠;命名容易误导,因此生产实现应使用明确的 Decoder Block。权重绑定让输入嵌入与输出投影共享参数。reshape 前要确认 logits 与 labels 的位置严格对齐。

8.6 数据打包与有效 token

先建立直觉。 打包的目标是减少 padding,让每个设备处理尽可能多的有效 token。样本边界、EOS、document mask 与 position_ids 必须与训练目标一致。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 按长度分桶或流式拼接。
  2. 插入 EOS 保留边界。
  3. 构造 attention 与 label mask。
  4. 统计有效 token 比例。

最小例子。 两个短文档拼成一条序列时,若允许跨文档注意力,模型可能把无关结尾当作下一文档上下文;是否隔离要显式选择。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 把 padding 计入 loss、最后残片大量丢弃、packing 与缓存模板不一致、只报告 batch size 不报告 token 数,都会影响训练。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:实现 fixed-length packer,验证每个原文 token 恰好出现一次且标签边界正确。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

把短文逐条 padding 会浪费计算,可将多个文档 token 拼接再切固定长度。文档边界是否允许相互注意取决于训练设计;至少要插入 EOS,必要时使用 block-diagonal mask。记录 tokens_per_second 应只统计非 padding、真正参与损失的 token。

数据加载器要能断点续训:保存数据 shard、样本游标、随机数状态和混合权重。若恢复后数据顺序改变,训练虽能继续,却不再严格复现。

8.7 优化器与学习率

从问题出发。 AdamW、学习率预热和衰减共同控制更新尺度。预热降低训练初期不稳定,权重衰减与 Adam 的自适应矩估计解耦。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 计算梯度并做全局裁剪。
  2. 优化器更新一阶/二阶矩。
  3. 按 schedule 得到当前学习率。
  4. 记录参数与更新范数。

用小数据走一遍。 相同学习率在不同有效 batch、参数化和精度下含义不同;可观察 update/weight ratio 判断更新是否过激。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 把 weight decay 施加到所有 bias/norm、梯度累积时错误缩放 loss、恢复 checkpoint 后学习率重置,都会影响收敛。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:比较 constant、linear warmup+cosine 两种曲线,并解释训练早期 loss 的差异。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

常见配置为 AdamW、warmup 后余弦衰减、梯度裁剪和 BF16。Warmup 在训练初期逐渐提高学习率,减少随机初始化下的剧烈更新。全局批量大小 = 每卡微批量 × 梯度累积 × 数据并行卡数。

大批量可提高吞吐,但可能需要调整学习率并影响泛化。梯度累积只减少单步显存,不减少总计算。BF16 相比 FP16 有更大指数范围,通常更稳定,但硬件支持和部分算子仍需检查。

8.8 监控与异常处理

先看它解决什么。 长训练必须把异常当预期事件处理。监控不仅有 loss,还要有学习率、梯度范数、吞吐、有效 token、显存、数据延迟和硬件错误。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 每步记录核心标量。
  2. 定期保存可恢复 checkpoint。
  3. 异常时保留最近数据批次。
  4. 恢复后验证状态连续。

一个可以手算的例子。 loss spike 若只出现在某个数据 shard,可能是异常长序列或坏文本;若所有 rank 同时出现则可能是学习率或数值问题。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 只保存权重不保存优化器和 RNG、告警阈值固定不分阶段、遇 NaN 自动跳过且不留样本,会掩盖根因。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:为训练循环加入非有限值检查和故障包,保存 step、样本 id、配置与张量统计。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

至少监控训练/验证 loss、学习率、梯度范数、参数范数、吞吐、MFU、数据等待、显存、通信和异常样本。loss 突增可能来自坏数据、数值溢出、学习率、恢复错误或硬件故障,不能只回滚而不定位。

Checkpoint 要包含模型、优化器、调度器、梯度缩放器、随机状态和数据位置。周期性做恢复演练,确认文件存在不等于可以恢复。大集群还需处理节点抢占、慢卡、网络错误和检查点写入瓶颈。

8.9 评估与涌现

抓住这一节的主线。 验证 loss 衡量平均预测难度,不能完整代表知识、推理、安全和生成质量。涌现现象还会受到指标阈值、提示和评测规模影响。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 使用无泄漏验证语料。
  2. 定期跑稳定 benchmark。
  3. 检查生成与记忆样例。
  4. 按能力、安全和语言分桶。

先做最小实验。 选择题准确率从随机附近跨过阈值可能看似突然,但底层连续概率已有平滑变化。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 评测集进入训练、只报最好 checkpoint、用不同 prompt 比模型、把 benchmark 提升直接等同真实业务提升,都会夸大结果。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:建立预训练评估表,分别包含 loss、常识、阅读、代码、记忆和安全,并写局限。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

验证困惑度反映预测分布,但不等同于指令遵循、事实性或推理。建立多层评估:语言建模损失、基础任务、领域任务、生成质量、安全和污染检查。所谓“涌现”有时受指标阈值与尺度影响,应报告连续曲线和不确定性,避免把单个跳变过度神秘化。

8.10 从 Nano 模型走向真实预训练

先把概念落到可观察对象上。 从 Nano 模型到真实预训练,变化的不只是规模,还包括分布式数据、容错、精度、通信、评估、治理与成本。应通过阶段门逐步放大,而不是一次跳到昂贵训练。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 单批次过拟合验证实现。
  2. 单机小语料验证稳定性。
  3. 多卡短跑验证通信与恢复。
  4. 小比例数据验证完整管线。

把它缩小到能逐项检查。 先用 0.1% 数据跑完加载—训练—保存—恢复—评估,能提前发现比模型结构更常见的工程错误。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 未经缩放实验直接投入全部算力、没有停止标准、训练中频繁更改数据配方、评估代码不版本化,都会浪费预算。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:为一次真实预训练写四级放大计划,每级规定通过指标和最大预算。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

微型项目应完成 tokenizer、数据 shard、模型、训练、验证、生成、checkpoint 和实验记录。目标不是复现大模型能力,而是验证全链路。扩到多卡前先确保单卡能过拟合一个小批次;若连小批次都学不会,增加 GPU 只会更快地产生错误。

练习:训练字符级 TinyLM 并观察上下文长度变化;实现断点续训并比较恢复前后下一个 batch;做三组模型/数据规模实验,绘制验证 loss 与计算量的关系。

延伸阅读:Training Compute-Optimal Large Language ModelsLlama 3 技术报告DeepSeek-V3 报告

本章配套代码

下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。

本章端到端实验:把知识变成可复现证据

本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。

实验步骤

  1. 对同一短句分别写出 causal LM、masked LM 与 seq2seq 去噪的输入和标签。
  2. 为 1 万篇文档设计数据卡,记录来源、许可、语言、质量、去重簇和过滤原因。
  3. 训练两个不同词表大小的 tokenizer,比较中英代码语料的字符/token 比和未知字符处理。
  4. 用三个小模型和三种 token 预算记录验证损失,画 compute—loss 图并说明不确定性。
  5. 让模型在一小段文本上过拟合,确认 loss 接近零并能复述,再换验证文本观察泛化差距。
  6. 实现 fixed-length packer,验证每个原文 token 恰好出现一次且标签边界正确。

每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。

验收标准

  • 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
  • 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
  • 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
  • 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
  • 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。

本章自测

  1. 不看正文,用自己的话解释“预训练把大量 token 变成自监督样本”,并给出一个可以证伪的测试。
  2. 不看正文,用自己的话解释“数据质量、合法性和可追踪性是预训练的第一层模型能力”,并给出一个可以证伪的测试。
  3. 不看正文,用自己的话解释“Tokenizer 决定模型看到的基本符号、序列长度和多语言效率”,并给出一个可以证伪的测试。
  4. 不看正文,用自己的话解释“Scaling Law 用小规模实验估计模型、数据和计算之间的趋势,帮助在固定预算下选择参数量与 token 数”,并给出一个可以证伪的测试。
  5. 不看正文,用自己的话解释“微型自回归模型把完整链路缩小到单机可观察尺度:embedding、多个 Decoder Block、最终归一化和词表投影”,并给出一个可以证伪的测试。
  6. 不看正文,用自己的话解释“打包的目标是减少 padding,让每个设备处理尽可能多的有效 token”,并给出一个可以证伪的测试。

回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。

第九章 微调:从数据设计到 LoRA/QLoRA 工程

微调的核心问题不是“选哪个框架”,而是“希望模型的什么行为发生变化”。知识更新优先考虑 RAG;输出风格、任务格式、工具选择和领域表达可考虑 SFT;偏好与安全边界进入第十章。先定义评估,再决定是否微调。

图 9-1 冻结原权重 $W$,只训练低秩增量 $BA$;部署时可保留适配器或合并权重。

9.1 全量微调与参数高效微调

先建立直觉。 全量微调更新全部参数,表达能力强但显存与遗忘风险高;参数高效微调冻结大部分权重,只训练适配参数,更适合受限资源和多任务版本管理。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 明确想改变知识还是行为。
  2. 估算可训练参数与优化器状态。
  3. 选择目标模块。
  4. 与基础模型做同条件比较。

最小例子。 LoRA 把更新写成 BA,rank r 远小于输入输出维;基础 W 不变,训练和保存的参数明显减少。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 参数少不代表激活显存小、所有任务都适合相同 rank、忘记保存 tokenizer/chat template,都会影响结果。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:计算一个 4096×4096 线性层全量参数与 rank=16 LoRA 参数之比。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

全量微调更新所有参数,容量最大,但需要保存梯度、优化器状态和主权重,显存与存储成本高,也更容易破坏原能力。PEFT 只训练少量新增参数。

LoRA 假设任务适配所需的权重变化可近似为低秩矩阵:

W'=W+\Delta W,\qquad \Delta W=\frac{\alpha}{r}BA,

其中 $A\in\mathbb{R}^{r\times d_{in}}$、$B\in\mathbb{R}^{d_{out}\times r}$。通常初始化使初始 $\Delta W=0$,不改变基础模型输出。

PYTHON
import math
import torch
from torch import nn
 
class LoRALinear(nn.Module):
    def __init__(self, base: nn.Linear, rank=8, alpha=16, dropout=0.0):
        super().__init__()
        self.base = base
        for p in self.base.parameters():
            p.requires_grad = False
        self.rank = rank
        self.scale = alpha / rank
        self.dropout = nn.Dropout(dropout)
        self.A = nn.Parameter(torch.empty(rank, base.in_features))
        self.B = nn.Parameter(torch.zeros(base.out_features, rank))
        nn.init.kaiming_uniform_(self.A, a=math.sqrt(5))
 
    def forward(self, x):
        delta = (self.dropout(x) @ self.A.T) @ self.B.T
        return self.base(x) + self.scale * delta

代码解读:B 零初始化让增量初始为零;rank 控制容量与参数量;alpha 控制缩放。目标模块不能机械照抄,注意力的 q/k/v/o 投影与 FFN 投影对任务影响不同,应结合模型结构和消融实验选择。

Adapter 在层间插入瓶颈模块;IA3 学习通道缩放;Prefix/Prompt/P-Tuning 在隐藏空间或嵌入层学习软提示。不同方法的参数少不等于训练显存一定最低,激活、序列长度和基础模型精度仍占主要部分。

9.2 QLoRA

从问题出发。 QLoRA 将冻结的基础权重量化存储,在其上训练较高精度 LoRA 参数。4-bit 权重节省存储,但计算通常会反量化到合适精度,并不等于原生 4-bit 训练。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 加载量化基础权重。
  2. 准备 k-bit 训练与 norm。
  3. 注入 LoRA。
  4. 用高精度优化器状态更新适配器。

用小数据走一遍。 NF4 针对近似正态分布权重设计量化格点;double quantization 进一步压缩量化尺度。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 把量化 dtype、计算 dtype 和参数 dtype 混为一谈、量化后全量更新、CPU offload 带宽不足,都会导致错误或变慢。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:记录 QLoRA 运行时各类参数 dtype、可训练参数数和峰值显存。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

QLoRA 将冻结的基础模型以 4-bit 形式存储和计算,同时训练 LoRA 参数。常见 NF4 面向近似正态分布权重,双重量化进一步压缩量化常数。训练仍会在较高计算精度中进行部分运算,不能把“4-bit 加载”理解为全流程所有张量都是 4-bit。

量化会引入误差,显存收益也依赖 kernel、优化器、序列长度和 checkpointing。开始前用实际 batch 做显存剖析,不要只按参数数 × 0.5 字节估算。

9.3 指令数据格式

图 09-2 用户和系统 token 可作为上下文输入,但通常不作为要预测的训练标签。先看它解决什么。 指令数据不仅是三段文本,还包含角色、轮次、工具消息、系统规则与损失掩码。模板决定模型实际学习哪些 token。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 统一消息 schema。
  2. 用模型官方 chat template 编码。
  3. 只对目标 assistant 区域计算 loss。
  4. 检查截断后轮次完整性。

一个可以手算的例子。 多轮数据中用户 token 通常设为 -100,不参与交叉熵;若模板把 EOS 放错,模型可能学不会正常停止。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 手写模板与推理模板不一致、重复 BOS、所有消息都算 loss、长样本截断只剩答案尾部,都会造成训练异常。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:打印一条样本的 token、role 与 label 三列,人工核对每个特殊 token。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

一条样本通常含 system、user、assistant 多轮消息。必须使用目标模型的 chat template,并只对期望模型学习的 token 计算损失。若把用户问题也作为标签,模型可能学习复述输入。

PYTHON
sample = {
    "messages": [
        {"role": "system", "content": "你是设备维修助手,只根据手册回答。"},
        {"role": "user", "content": "E17 报警是什么意思?"},
        {"role": "assistant", "content": "E17 表示进水超时。请先检查进水阀和水压;断电后再操作。"},
    ],
    "source": "manual_v3_p42",
    "quality": "human_verified",
}

高质量数据应覆盖正常、边界、拒答、纠错和多轮场景;答案要事实正确、格式一致、难度分层。自动生成数据可扩规模,但需要去重、规则验证和人工抽检。划分训练/验证集时按文档、用户或任务模板分组,避免近重复泄漏。

9.4 使用 PEFT/TRL 的标准流程

抓住这一节的主线。 PEFT/TRL 等框架减少样板代码,但数据、模板、目标模块、精度、保存与评估仍需显式确认。标准流程先做几十步可解释 smoke test,再扩大训练。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 加载模型与 tokenizer。
  2. 格式化数据并检查标签。
  3. 配置 LoRA/Trainer。
  4. 训练、保存、合并并独立加载验证。

先做最小实验。 训练后只保存 adapter 时,部署必须同时指定正确基础模型版本;合并权重则要记录合并 dtype 与校验输出。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 直接复制旧版本参数、target_modules 漏掉实际层名、保存目录覆盖基础模型、训练完成未从磁盘重载测试,都会埋雷。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:写一个训练前检查脚本,拒绝无有效标签、模板不匹配和无可训练参数的配置。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

框架接口会变化,稳定流程是:加载 tokenizer 与模型;应用正确模板;构造只监督回答的标签;配置 LoRA;训练与保存 adapter;离线评估;必要时合并并验证。

PYTHON
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer
 
peft_config = LoraConfig(
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
    task_type="CAUSAL_LM",
)
 
args = SFTConfig(
    output_dir="outputs/sft_adapter",
    learning_rate=2e-4,
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,
    num_train_epochs=2,
    logging_steps=10,
    save_strategy="steps",
    save_steps=200,
    bf16=True,
)
 
# model 与 train_dataset 省略:必须先确认 chat template 和标签掩码
trainer = SFTTrainer(model=model, train_dataset=train_dataset,
                     args=args, peft_config=peft_config)
trainer.train()
trainer.save_model()

学习率示例不是通用最优值。先做短跑检查 loss、梯度和生成样例,再扩完整训练。保存 adapter 后记录基础模型精确版本;没有基础模型就无法正确复原。

9.5 超参数与显存

先把概念落到可观察对象上。 显存由权重、梯度、优化器状态、激活、临时 buffer 与碎片共同构成。LoRA 主要减少前三项中的可训练部分,长序列仍可能让激活成为主因。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 先测单样本峰值。
  2. 调 batch 与梯度累积。
  3. 选择序列长度和 packing。
  4. 必要时梯度检查点与量化。

把它缩小到能逐项检查。 micro batch×累积步数×数据并行度决定有效 batch,但 token 长度分布还会改变每步实际 token。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 只看平均长度、用累积步数解决单样本 OOM、盲目开 gradient checkpointing、学习率不随有效 batch 重新验证,都会失败。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:建立显存预算表并通过 profiler 校准误差,找出最大占用项。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

关键参数包括学习率、rank、alpha、目标模块、dropout、有效 batch、序列长度、epoch、warmup 和梯度裁剪。rank 越大容量越高但更易过拟合;数据少时多轮 epoch 可能造成灾难性遗忘和输出模式僵化。

显存主要由权重、梯度、优化器状态和激活组成。LoRA 大幅减少可训练参数相关内存,但激活仍随 batch × 序列长度 × 层数增长。梯度 checkpointing 用额外计算换激活显存;packing 提高有效 token 比例,但要正确处理边界。

9.6 评估:与基础模型做成对比较

先建立直觉。 微调评估要回答‘在哪些目标上变好、在哪些基础能力上变坏’。必须与未微调模型使用同一模板、解码和评估集成对比较。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 目标任务离线指标。
  2. 通用能力回归。
  3. 安全与拒答测试。
  4. 人工盲评和失败分类。

最小例子。 格式正确率提高但事实正确率下降,说明模型更会服从形式却不一定更可靠;两个指标必须同时报告。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 只展示精选样例、基础模型提示不同、评测数据与训练重复、没有置信区间,会产生虚假收益。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:为 50 条目标样例和 30 条回归样例做 A/B 盲评,记录胜/平/负与原因。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

建立固定 prompt 集,比较基础模型与微调模型:任务正确率、格式遵循、事实性、拒答、安全、原通用能力和延迟。只看训练 loss 无法判断是否学会目标行为。

对生成任务可用规则、单元测试、人工盲评和模型评委组合。报告置信区间与失败样例。检查数据记忆:把训练答案中的独特字符串放入测试会产生虚假提升。

9.7 常见失败

从问题出发。 微调失败应从数据重复、模板、标签、截断、学习率、训练轮数、解码和部署版本逐层排查。复读和风格坍缩往往首先是数据与过拟合问题。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 抽样解码训练前数据。
  2. 统计有效标签与重复率。
  3. 观察 train/val 差距。
  4. 比较不同 checkpoint 生成。

用小数据走一遍。 train loss 持续下降而 validation loss 上升,同时输出复现训练短语,说明继续训练可能加剧过拟合。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 一出现复读就只调 repetition_penalty、没有验证 EOS、训练与推理模板不同、adapter 加载两次,都会误诊。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:建立微调故障表,为 NaN、OOM、无学习、复读和遗忘各写三个证据检查。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

  • 输出重复:数据模板重复、epoch 过多、解码参数或 EOS 处理错误。
  • 微调后变笨:学习率过高、数据窄、标签掩码错误、灾难性遗忘。
  • 训练 loss 降但格式不对:chat template 或推理模板不一致。
  • 合并后结果变化:dtype、缩放、目标模块或 tokenizer 版本不一致。
  • 法律/医疗答案“更专业”却不可靠:语言风格提升不代表事实正确,必须领域评估与人工复核。

9.8 一份可信的微调工程手册

先看它解决什么。 可信工程手册应让另一个人能从原始数据复现模型:数据卡、配置、环境、日志、checkpoint、评估和模型卡必须互相指向。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 数据与代码版本化。
  2. 实验命名包含关键变量。
  3. 每次训练自动生成报告。
  4. 发布前独立重载与安全检查。

一个可以手算的例子。 模型卡写清基础模型、adapter、许可、目标任务、已知限制和不适用场景,不能只写一个下载链接。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 配置只存在命令历史、日志无样本版本、失败实验被删除、指标无评测脚本,会让结果不可审计。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:为一次 LoRA 实验生成最小可复现包,并让全新环境按 README 重跑评估。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

项目应记录数据 Schema、过滤与抽检、模板、基础模型、量化配置、LoRA 参数、训练曲线、评估集、对比结果、失败样例、模型卡和回滚方式。不要把框架命令当作项目亮点;真正的亮点是数据与评估设计、资源约束下的取舍以及可复现结果。

练习:把一个线性层替换为 LoRALinear,验证初始输出一致;比较 rank 4/16/64 的参数量与验证指标;构造含拒答样本的数据集并检查微调前后越界回答率。

延伸阅读:LoRA 论文Hugging Face PEFT 文档TRL 与 PEFT 集成B 站:LoRA 原理与实战

本章配套代码

下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。

本章端到端实验:把知识变成可复现证据

本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。

实验步骤

  1. 计算一个 4096×4096 线性层全量参数与 rank=16 LoRA 参数之比。
  2. 记录 QLoRA 运行时各类参数 dtype、可训练参数数和峰值显存。
  3. 打印一条样本的 token、role 与 label 三列,人工核对每个特殊 token。
  4. 写一个训练前检查脚本,拒绝无有效标签、模板不匹配和无可训练参数的配置。
  5. 建立显存预算表并通过 profiler 校准误差,找出最大占用项。
  6. 为 50 条目标样例和 30 条回归样例做 A/B 盲评,记录胜/平/负与原因。

每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。

验收标准

  • 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
  • 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
  • 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
  • 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
  • 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。

本章自测

  1. 不看正文,用自己的话解释“全量微调更新全部参数,表达能力强但显存与遗忘风险高”,并给出一个可以证伪的测试。
  2. 不看正文,用自己的话解释“QLoRA 将冻结的基础权重量化存储,在其上训练较高精度 LoRA 参数”,并给出一个可以证伪的测试。
  3. 不看正文,用自己的话解释“指令数据不仅是三段文本,还包含角色、轮次、工具消息、系统规则与损失掩码”,并给出一个可以证伪的测试。
  4. 不看正文,用自己的话解释“PEFT/TRL 等框架减少样板代码,但数据、模板、目标模块、精度、保存与评估仍需显式确认”,并给出一个可以证伪的测试。
  5. 不看正文,用自己的话解释“显存由权重、梯度、优化器状态、激活、临时 buffer 与碎片共同构成”,并给出一个可以证伪的测试。
  6. 不看正文,用自己的话解释“微调评估要回答‘在哪些目标上变好、在哪些基础能力上变坏’”,并给出一个可以证伪的测试。

回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。

第十章 偏好对齐:Reward Model、PPO、DPO 与 GRPO

预训练学习“什么文本可能出现”,SFT 学习“怎样按示范回答”,偏好对齐进一步学习“多个可行回答中,哪一个更符合目标”。目标可能包括有用、诚实、安全、风格或可验证正确性。偏好数据只代表特定标注规则与人群,不等于抽象的“人类价值”。

图 10-1 PPO 使用显式奖励与在线策略更新;DPO 直接学习成对偏好;GRPO 对同一问题的一组采样计算相对优势。

10.1 RLHF 基本流程

先建立直觉。 RLHF 典型链路包括 SFT 策略、偏好数据、奖励模型和受约束策略优化。每一步都可能引入偏差,因此对齐不是把一个分数最大化,而是管理多目标取舍。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. SFT 建立可用初始策略。
  2. 收集同 prompt 回答偏好。
  3. 训练或定义奖励。
  4. 在 KL 等约束下优化并评估。

最小例子。 标注者更喜欢礼貌长回答,奖励模型可能把长度当捷径;需要平衡数据和反事实测试识别。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 偏好等同事实正确、奖励分数跨版本可比、只看平均 reward、不检查能力回归,都会造成 reward hacking。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:为客服回答写偏好标注指南,区分正确、帮助性、语气、安全和引用。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

经典 RLHF 可分为:SFT 初始策略;对同一 prompt 采样多个回答并收集偏好;训练 Reward Model;用 PPO 等算法优化策略,同时用 KL 约束避免偏离参考策略过远。InstructGPT 工作系统展示了这条路线。

奖励模型常用 Bradley-Terry 偏好概率:

$$P(y_w\succ y_l\mid x)=\sigma(r_\phi(x,y_w)-r_\phi(x,y_l)).$$

对应损失是负对数似然。若标注者偏好长答案,奖励模型也可能学会长度捷径;需要平衡数据、检查分组偏差和对抗样例。

10.2 PPO:受约束地更新策略

从问题出发。 PPO 在当前策略采样轨迹,估计优势,再用裁剪目标限制一次更新不要偏离太远。价值模型帮助降低回报估计方差,KL 约束限制相对参考策略漂移。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. rollout 采样回答。
  2. 奖励与价值估计优势。
  3. 多轮小批量更新策略/价值。
  4. 监控 KL、clip fraction 与 reward。

用小数据走一遍。 概率比 r=πnew/πold 超出 [1-ε,1+ε] 时,裁剪目标阻止单个样本推动过大更新。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 旧策略 logprob 与新 tokenizer 不一致、padding token 计入奖励、价值损失失控、只追 reward 不看 KL,都会导致不稳。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:用四个样本手算 ratio、clipped ratio 和 surrogate objective。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

PPO 使用旧策略采样,根据奖励与价值估计计算优势,再优化裁剪目标:

L^{clip}=\mathbb{E}\left[\min(r_tA_t,\operatorname{clip}(r_t,1-\epsilon,1+\epsilon)A_t)\right],

其中 $r_t=\pi_\theta(a_t|s_t)/\pi_{old}(a_t|s_t)$。裁剪限制单次更新幅度;完整训练还含价值损失、熵、KL 惩罚等。语言模型中“状态”是前缀,“动作”是 token。

PPO 工程复杂:要同时管理策略、参考模型、奖励模型、价值模型和 rollout;奖励尺度、KL、生成长度、优势估计都影响稳定性。Reward hacking 表示策略找到高奖励但不符合真实目标的捷径,因此必须人工抽检与独立评估。

10.3 DPO:从偏好对直接优化策略

先看它解决什么。 DPO 直接从 chosen/rejected 对学习相对偏好:策略相对参考模型提高 chosen 的对数概率差,同时降低 rejected。它省去显式奖励模型和在线 rollout,但仍依赖偏好数据质量。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 同一 prompt 构造偏好对。
  2. 计算策略与参考 logprob。
  3. 形成相对 log-ratio 差。
  4. 用 logistic loss 优化。

一个可以手算的例子。 若 chosen 和 rejected 只差一个事实,DPO 会学习该差异;若还同时差长度与语气,模型可能学习混杂特征。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 prompt 边界不同、参考模型与初始化不匹配、chosen/rejected 长度偏差严重、把序列总 logprob 未做一致处理,都会影响结果。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:实现最小 DPO loss,对调 chosen/rejected 后验证梯度方向改变。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

DPO 利用 KL 正则化奖励优化的闭式关系,把奖励差写成策略与参考策略的对数概率差。常见损失为

-\log\sigma\left(\beta[(\log\pi_\theta(y_w|x)-\log\pi_{ref}(y_w|x)) -(\log\pi_\theta(y_l|x)-\log\pi_{ref}(y_l|x))]\right).

PYTHON
import torch
import torch.nn.functional as F
 
def dpo_loss(policy_chosen, policy_rejected,
             ref_chosen, ref_rejected, beta=0.1):
    """输入均为每条完整回答的 token log-prob 之和,形状 (B,)。"""
    policy_margin = policy_chosen - policy_rejected
    ref_margin = ref_chosen - ref_rejected
    logits = beta * (policy_margin - ref_margin)
    losses = -F.logsigmoid(logits)
    chosen_reward = beta * (policy_chosen - ref_chosen).detach()
    rejected_reward = beta * (policy_rejected - ref_rejected).detach()
    return losses.mean(), (chosen_reward - rejected_reward).mean()

代码解读:必须对回答 token 求和并正确屏蔽 prompt/padding;policy 与 reference 使用同一 tokenizer 和模板;第二个返回值是隐式奖励 margin 的监控量,不是独立真实奖励。长度差可能影响序列 log-prob,应在数据和评估中控制。

DPO 省去显式奖励模型和在线 RL rollout,流程更简单,但依赖离线偏好数据覆盖。它不会自动纠正数据之外的新策略分布,也可能过拟合偏好噪声。

10.4 GRPO:组内相对优势

抓住这一节的主线。 GRPO 对同一 prompt 采样一组回答,用组内奖励均值与标准差形成相对优势,从而不依赖独立价值模型。它适合有可验证奖励的多样采样任务。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 每个 prompt 采样 G 条回答。
  2. 计算每条奖励。
  3. 组内标准化得到优势。
  4. 结合概率比与 KL 更新。

先做最小实验。 若一组奖励完全相同,标准差接近零,归一化优势缺少有效信号;实现需加 epsilon 并监控奖励多样性。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 组大小太小、奖励几乎常数、同质采样、规则验证器可被格式投机,都会削弱训练。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:模拟两组奖励,手算相对优势并讨论离群高奖励的影响。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

GRPO 为同一 prompt 采样一组回答,计算每个回答的奖励,再用组内均值和标准差标准化,形成相对优势:

$$A_i=\frac{r_i-\operatorname{mean}(r)}{\operatorname{std}(r)+\epsilon}.$$

随后以类似策略比率裁剪和 KL 正则的目标更新策略。它可以避免单独训练与策略同规模的价值模型,特别适合数学、代码等有可验证奖励的推理任务。

PYTHON
def group_relative_advantage(rewards: torch.Tensor, eps=1e-6):
    # rewards: (batch_of_prompts, samples_per_prompt)
    mean = rewards.mean(dim=1, keepdim=True)
    std = rewards.std(dim=1, keepdim=True, unbiased=False)
    return (rewards - mean) / (std + eps)

若一组回答奖励完全相同,优势接近零,无法提供有效学习信号。组大小、采样多样性和奖励判定决定信号质量。GRPO 与 DPO 的数据和目标不同,不能简单称为“DPO 的泛化形式”。

10.5 奖励设计

先把概念落到可观察对象上。 奖励函数把产品目标转成训练信号,必须防止模型利用未预期漏洞。可验证正确性、格式、效率和安全往往需要多项奖励与硬约束组合。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 列出真正目标与代理指标。
  2. 设计正常和对抗样例。
  3. 检查可被投机的捷径。
  4. 对各奖励尺度校准并消融。

把它缩小到能逐项检查。 代码任务只按单元测试通过奖励可能鼓励硬编码;增加隐藏测试、静态检查和资源限制可降低投机。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 复杂加权分数无解释、规则奖励泄漏答案、用同一 judge 训练和评测、惩罚项尺度压倒主目标,都会扭曲行为。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:为数学解题设计奖励卡,列出目标、实现、攻击方式和监控指标。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

可验证任务可以使用单元测试、数学答案、格式 Schema、编译结果等规则奖励。开放问答常需奖励模型或模型评委,但评委也会偏向特定风格并可能被答案欺骗。组合奖励时要防止某一项尺度压倒其他项。

过程奖励评价中间步骤,结果奖励只看最终结果。过程奖励能提供密集信号,却需要可靠步骤标注;错误的过程监督可能限制模型发现新策略。任何奖励都要配独立 holdout 和人工审计。

10.6 数据与训练管线

先建立直觉。 偏好数据需要 prompt 分布、多样回答、清晰标注标准与一致性检查。在线算法还要管理 rollout 版本、奖励版本和策略版本的对应关系。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 分层采样真实任务。
  2. 生成多样候选而非模板变体。
  3. 双人标注与仲裁。
  4. 保存所有模型与配置版本。

最小例子。 若所有 rejected 都明显更短,模型可能只学长度;可通过长度匹配或反向样本减少混杂。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 同一回答出现在训练和评测、标注者看到模型名、低一致性样本直接混入、数据无许可证,都会降低可信度。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:对 100 对偏好数据统计长度差、主题分布、标注一致率和重复率。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

偏好数据格式包含 prompt、chosen、rejected 和元数据。确保差异来自质量而非模板、长度或敏感词等捷径。训练/测试按 prompt 语义去重;标注指南明确正确性、有用性、安全、引用等优先级;对分歧样本保留多标注者信息。

PPO/GRPO 需要高吞吐 rollout,通常把生成引擎与训练进程协调;策略版本、采样参数和奖励版本必须记录。离线 DPO 更容易复现,但同样要固定参考模型。

10.7 评估对齐而不损伤能力

从问题出发。 对齐评估必须同时测目标行为、基础能力、过度拒答、欺骗与分布外稳健性。一个更安全的模型若无害问题大量拒答,也不是成功。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 帮助性与正确性。
  2. 危险请求与越狱。
  3. 无害边界请求的过拒。
  4. 通用能力和格式回归。

用小数据走一遍。 安全拒答测试要包含真正危险、明显无害和语义相近的边界样本,才能区分拒答能力与关键词触发。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 只用单一 judge、不给 judge 参考证据、对齐集与评测集重叠、只报胜率不报失败类型,都会遗漏损伤。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:建立对齐评估矩阵,至少包含正确拒绝、错误拒绝、错误服从和正常帮助四格。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

同时评估偏好胜率、任务正确率、安全、拒答、校准、格式、长度和原有通用能力。只优化单一胜率可能得到冗长讨好回答;安全训练过强可能过度拒答。红队测试应包含提示注入、角色扮演、编码变体和工具副作用。

在线 A/B 需要用户保护、停止阈值和日志审计。高风险领域不能用点击率替代事实正确性。

10.8 选择方法

图 10-2 先识别离线偏好、在线采样和奖励形态,再比较 DPO、PPO 与 GRPO。先看它解决什么。 方法选择取决于数据、在线采样能力、奖励可验证性、稳定性与预算。DPO 适合高质量离线偏好,PPO/GRPO 适合需要探索或可验证奖励的任务。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 先用 SFT 建立基线。
  2. 判断是否有偏好对或标量奖励。
  3. 评估 rollout 和训练复杂度。
  4. 设置能力与安全门禁。

一个可以手算的例子。 只有几千对人工偏好且算力有限时先试 DPO;数学验证器可靠且希望探索新策略时可评估 GRPO。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 追逐算法名称、没有可靠评估就上 RL、把不同实现超参直接照搬,都会浪费资源。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:为三个场景写决策表,说明为什么不选择另外两种方法。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

  • 有高质量成对偏好、资源有限:先做 DPO 基线。
  • 有可靠奖励、需要在线探索和精细 KL 控制:考虑 PPO。
  • 同题可采多答案且奖励可验证、希望省去价值模型:考虑 GRPO。
  • 目标主要是格式和任务示范:可能 SFT 已足够。

练习:手算一对 chosen/rejected 的 DPO loss;构造组奖励验证 GRPO 优势和为零;设计一个会被长度欺骗的奖励,并提出诊断方法。

延伸阅读:InstructGPTDPODeepSeek-R1TRL 文档

本章配套代码

下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。

本章端到端实验:把知识变成可复现证据

本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。

实验步骤

  1. 为客服回答写偏好标注指南,区分正确、帮助性、语气、安全和引用。
  2. 用四个样本手算 ratio、clipped ratio 和 surrogate objective。
  3. 实现最小 DPO loss,对调 chosen/rejected 后验证梯度方向改变。
  4. 模拟两组奖励,手算相对优势并讨论离群高奖励的影响。
  5. 为数学解题设计奖励卡,列出目标、实现、攻击方式和监控指标。
  6. 对 100 对偏好数据统计长度差、主题分布、标注一致率和重复率。

每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。

验收标准

  • 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
  • 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
  • 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
  • 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
  • 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。

本章自测

  1. 不看正文,用自己的话解释“RLHF 典型链路包括 SFT 策略、偏好数据、奖励模型和受约束策略优化”,并给出一个可以证伪的测试。
  2. 不看正文,用自己的话解释“PPO 在当前策略采样轨迹,估计优势,再用裁剪目标限制一次更新不要偏离太远”,并给出一个可以证伪的测试。
  3. 不看正文,用自己的话解释“DPO 直接从 chosen/rejected 对学习相对偏好:策略相对参考模型提高 chosen 的对数概率差,同时降低 rejected”,并给出一个可以证伪的测试。
  4. 不看正文,用自己的话解释“GRPO 对同一 prompt 采样一组回答,用组内奖励均值与标准差形成相对优势,从而不依赖独立价值模型”,并给出一个可以证伪的测试。
  5. 不看正文,用自己的话解释“奖励函数把产品目标转成训练信号,必须防止模型利用未预期漏洞”,并给出一个可以证伪的测试。
  6. 不看正文,用自己的话解释“偏好数据需要 prompt 分布、多样回答、清晰标注标准与一致性检查”,并给出一个可以证伪的测试。

回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。

第十一章 多模态大模型:视觉编码、跨模态对齐与训练

多模态模型要解决三个问题:不同模态如何表示,如何对齐到可交互空间,如何让语言模型利用视觉信息生成答案。图片不是“另一种文本”;分辨率、空间位置、OCR、颜色和对象关系都会影响视觉 token。

图 11-1 视觉编码器产生视觉特征,连接模块将其映射到语言模型可使用的表示。

11.1 ViT:把图像变成 token 序列

图 11-2 图像尺寸和 patch 大小共同决定视觉序列长度与计算成本。先建立直觉。 ViT 把图像切成固定大小 patch,将每块展平并线性投影为视觉 token,再像文本 token 一样使用 Transformer。分辨率和 patch 大小共同决定序列长度。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 图像规范化与缩放。
  2. patchify 得到 N 个小块。
  3. 线性投影并加位置。
  4. Transformer 编码视觉关系。

最小例子。 224×224 图像、patch=16 时有 14×14=196 个 patch;分辨率翻倍会让 patch 数约变为四倍。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 高分辨率只改输入尺寸却不处理位置编码、不同宽高强行拉伸、通道顺序和归一化不匹配,都会损伤效果。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:用 reshape/permute 手写 patchify,再重组图像验证无信息丢失。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

给定 $H\times W\times C$ 图像,以 $P\times P$ 切块,patch 数为 $N=HW/P^2$。每个 patch 展平后线性投影到隐藏维,加位置编码,再送入 Transformer Encoder。Patch 越小,空间细节越多,但视觉 token 数和注意力成本快速增加。

PYTHON
import torch
from torch import nn
 
class PatchEmbedding(nn.Module):
    def __init__(self, image_size=224, patch_size=16, in_ch=3, dim=768):
        super().__init__()
        if image_size % patch_size:
            raise ValueError("image_size 必须能被 patch_size 整除")
        self.proj = nn.Conv2d(in_ch, dim, kernel_size=patch_size,
                              stride=patch_size)
        self.n_patches = (image_size // patch_size) ** 2
 
    def forward(self, images):
        x = self.proj(images)       # (B,D,H/P,W/P)
        return x.flatten(2).transpose(1, 2)  # (B,N,D)

卷积核大小和步长都等于 patch 大小,等价于对每个不重叠 patch 做共享线性投影。真实模型还会处理任意分辨率、动态切块或多尺度特征。

11.2 CLIP:图文对比学习

从问题出发。 CLIP 用图像编码器和文本编码器把配对样本映射到同一空间,通过批内对比学习拉近匹配对、推远不匹配对。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 分别编码并归一化。
  2. 计算批内相似度矩阵。
  3. 图到文与文到图双向交叉熵。
  4. 学习温度调节分布。

用小数据走一遍。 batch 为 N 时得到 N×N logits,对角线是正样本,其他位置作为批内负样本。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 批中重复语义被当假负样本、只训练单向损失、忘记归一化、文本模板差异,都会影响检索。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:实现 CLIP loss,验证交换图文顺序后双向损失含义。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

CLIP 分别编码一批图片与文本,把向量归一化,计算批内相似度矩阵。正确图文对位于对角线,损失同时做 image-to-text 与 text-to-image 分类。

PYTHON
import torch.nn.functional as F
 
def clip_loss(image_features, text_features, logit_scale):
    image = F.normalize(image_features, dim=-1)
    text = F.normalize(text_features, dim=-1)
    logits = logit_scale.exp() * image @ text.T
    labels = torch.arange(len(image), device=image.device)
    return (F.cross_entropy(logits, labels) +
            F.cross_entropy(logits.T, labels)) / 2

批内其他样本充当负例,因此 batch 组成很重要;重复/近义 caption 会产生“假负例”。CLIP 学到共享表征,适合检索和零样本分类,但本身不是能长篇回答的生成式视觉语言模型。

11.3 BLIP/BLIP-2 与 Q-Former

先看它解决什么。 BLIP 统一理解与生成目标,BLIP-2 用 Q-Former 在冻结视觉编码器与冻结语言模型之间提取少量查询表示,降低端到端训练成本。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 视觉编码器产生图像特征。
  2. 可学习 query 通过 Q-Former 读取。
  3. 投影到语言模型输入空间。
  4. 用图文目标训练连接。

一个可以手算的例子。 Q-Former 的 query 数固定,可把大量视觉 patch 压缩成较少表示,但压缩也可能丢失细粒度信息。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 把 Q-Former 说成简单线性层、冻结模块仍处于错误训练模式、视觉与语言 tokenizer 混淆,都会导致实现偏差。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:画出 BLIP-2 三个模块的梯度流,标注冻结与训练参数。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

BLIP 同时面向理解与生成,并通过 captioner/filter 改善噪声图文数据。BLIP-2 冻结已有视觉编码器和大语言模型,用轻量 Q-Former 跨越模态差距。可学习 query token 从视觉特征抽取与语言相关的信息,再连接到 LLM。

冻结大模块降低训练成本,但连接模块必须适配两端的表示与尺度。理解 Q-Former 的关键是“少量查询向量主动读取视觉特征”,而不是把所有视觉 patch 原样塞入语言模型。

11.4 LLaVA 两阶段训练

抓住这一节的主线。 LLaVA 典型路线先训练视觉—语言投影层,再用图文指令数据联合微调,让语言模型学会在对话中使用视觉 token。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 预训练投影对齐特征。
  2. 构造多轮图文指令。
  3. 按阶段解冻部分模块。
  4. 用多能力评测和人工检查。

先做最小实验。 图像占位 token 在模板中的位置必须与视觉特征插入位置一致;标签通常只覆盖 assistant 文本。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 指令数据答案由模型生成却不清洗、图像与文本错配、阶段二学习率过大破坏视觉特征,都会造成幻觉。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:为一条图文多轮样本画出最终 token 序列和 label mask。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

经典 LLaVA 结构使用 CLIP ViT 作为视觉编码器,通过投影层连接语言模型。第一阶段主要训练投影层,使图像特征与语言嵌入对齐;第二阶段用视觉指令数据做指令微调,使模型学会问答和对话。

两阶段的目的不同:对齐阶段解决“视觉特征如何进入语言空间”,指令阶段解决“如何按任务使用视觉信息”。若只做第二阶段且初始化不当,训练更难稳定;若只做对齐,模型未必会遵循复杂视觉指令。

现代 VLM 可能使用任意分辨率、视觉 token 压缩、窗口注意力、原生多模态预训练和视频/文档增强,不能把所有模型都等同于 LLaVA。

11.5 数据构建

先把概念落到可观察对象上。 多模态数据至少包含媒体、消息、任务类型、来源与许可。还要考虑 OCR、版面、语言、重复、隐私和可能造成偏见的分布。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 验证文件可读与配对。
  2. 按内容与感知哈希去重。
  3. 清洗 OCR/标注。
  4. 按任务和难度分层切分。

把它缩小到能逐项检查。 同一图片配多个近似问题若跨训练/测试切分,会形成视觉泄漏;应按图像簇而非单条样本切分。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 只检查文本重复、EXIF 泄漏隐私、低质量自动 caption 当真值、版权不明图片入库,都会带来风险。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:设计多模态数据卡,含图像来源、许可、分辨率、OCR 质量、语言和任务标签。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

多模态样本至少包含媒体、消息、任务类型和来源。图片与文本必须真正对应;OCR 文档要保留阅读顺序、页面和区域;VQA 要防止仅凭问题语言猜答案。敏感图像、人物隐私、版权和未成年人数据需要专门治理。

下游数据可覆盖描述、OCR、图表、定位、比较、多图和拒答。随机切分可能让同一视频帧或同一文档页面跨集合,导致泄漏,应按原始媒体分组。

11.6 微调 Qwen-VL 类模型的通用步骤

先建立直觉。 微调视觉语言模型要以官方 processor 和 chat template 为准,先确认模型怎样插入视觉 token,再决定冻结、LoRA 目标和像素预算。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. processor 同时处理媒体与文本。
  2. 检查输入 id、pixel values 与 grid。
  3. 只在目标文本上算 loss。
  4. 按视觉长度分桶并控制 OOM。

最小例子。 一张高分辨率图可能产生远多于普通图的视觉 token,batch 按样本数相同也会有巨大显存差异。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 仅给语言层加 LoRA 却期望视觉对齐改变、视觉占位符重复、训练推理预处理不同、padding mask 错误,都会失败。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:对三个分辨率样本打印视觉 token 数、总长度和峰值显存,制定 batch 规则。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

  1. 按官方 processor 和 chat template 编码图像与消息。
  2. 决定冻结视觉编码器、连接层和 LLM 的哪些部分。
  3. 对 assistant token 计算损失,屏蔽系统、用户和视觉占位 token。
  4. 控制像素/视觉 token 上限,按长度分桶,防止极端样本 OOM。
  5. 使用 LoRA 时明确目标模块是否覆盖视觉、投影或语言部分。
  6. 分任务评估,不只看综合平均分。

多模态显存除了模型参数,还受像素分辨率和视觉 token 影响。同一 batch 中一张超大图可能显著抬高峰值。先记录每样本视觉 token,再决定动态 batch。

11.7 评估

从问题出发。 多模态评估不能只用一个综合分数。要分别测感知、OCR、图表、空间、知识、推理、grounding、拒答和幻觉,并核对评测协议。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 为每类能力准备独立样本。
  2. 统一提示和解码。
  3. 自动指标与人工审阅结合。
  4. 记录图像许可和错误类型。

用小数据走一遍。 VQA exact match 对同义表达敏感,OCR 字符错误率不反映回答解释;不同任务需要不同指标。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 测试图像训练泄漏、只看英文、裁剪导致答案丢失、用无视觉 baseline 仍能答对而未识别,都会高估视觉能力。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:加入遮蔽图像或打乱图文配对的对照,判断模型是否真正使用视觉。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

自动指标包括分类准确率、VQA exact match、OCR 字符错误率、caption CIDEr 等,但开放回答还需事实性和人评。特别测试:不存在对象、遮挡、小字、旋转、颜色、空间关系、多图指代和诱导问题。

视觉幻觉指模型描述图中不存在内容。缓解手段包括更好的视觉对齐数据、区域/grounding 监督、拒答样本和基于检测/OCR 的外部验证,但无法彻底消除。

11.8 常见错误

先看它解决什么。 多模态常见错误来自三种错位:媒体与文本错位、视觉 token 与位置错位、训练与推理模板错位。定位时先检查输入而不是先调优化器。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 可视化实际送入的图像。
  2. 解码文本 token 与占位符。
  3. 核对标签 mask。
  4. 比较单样本前向与生成。

一个可以手算的例子。 如果图像预处理后上下颠倒或颜色通道错误,模型再大也无法稳定回答;把 tensor 还原成图像是最便宜的检查。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 只检查 loss、在数据加载器中静默跳过坏图、把所有错误归因于‘多模态幻觉’,都会延迟定位。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:建立五类故障注入:错图、空图、过大图、占位符缺失和 OCR 噪声,并记录系统行为。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

  • 图像归一化或色彩通道不匹配预训练配置。
  • 视觉占位 token 数与视觉特征数不一致。
  • 标签 mask 错误,模型被训练预测用户问题或图像占位。
  • 只用语言可猜的数据,模型表面指标高却忽略图像。
  • 训练图像过于同质,真实照片/文档分布下失效。

练习:实现 PatchEmbedding 并验证 token 数;用随机特征计算 CLIP loss,观察正确配对相似度提高时损失变化;构造一个无需看图即可回答的 VQA 数据集,说明其评估缺陷。

延伸阅读:CLIPBLIPBLIP-2LLaVA

本章配套代码

下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。

本章端到端实验:把知识变成可复现证据

本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。

实验步骤

  1. 用 reshape/permute 手写 patchify,再重组图像验证无信息丢失。
  2. 实现 CLIP loss,验证交换图文顺序后双向损失含义。
  3. 画出 BLIP-2 三个模块的梯度流,标注冻结与训练参数。
  4. 为一条图文多轮样本画出最终 token 序列和 label mask。
  5. 设计多模态数据卡,含图像来源、许可、分辨率、OCR 质量、语言和任务标签。
  6. 对三个分辨率样本打印视觉 token 数、总长度和峰值显存,制定 batch 规则。

每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。

验收标准

  • 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
  • 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
  • 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
  • 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
  • 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。

本章自测

  1. 不看正文,用自己的话解释“ViT 把图像切成固定大小 patch,将每块展平并线性投影为视觉 token,再像文本 token 一样使用 Transformer”,并给出一个可以证伪的测试。
  2. 不看正文,用自己的话解释“CLIP 用图像编码器和文本编码器把配对样本映射到同一空间,通过批内对比学习拉近匹配对、推远不匹配对”,并给出一个可以证伪的测试。
  3. 不看正文,用自己的话解释“BLIP 统一理解与生成目标,BLIP-2 用 Q-Former 在冻结视觉编码器与冻结语言模型之间提取少量查询表示,降低端到端训练成本”,并给出一个可以证伪的测试。
  4. 不看正文,用自己的话解释“LLaVA 典型路线先训练视觉—语言投影层,再用图文指令数据联合微调,让语言模型学会在对话中使用视觉 token”,并给出一个可以证伪的测试。
  5. 不看正文,用自己的话解释“多模态数据至少包含媒体、消息、任务类型、来源与许可”,并给出一个可以证伪的测试。
  6. 不看正文,用自己的话解释“微调视觉语言模型要以官方 processor 和 chat template 为准,先确认模型怎样插入视觉 token,再决定冻结、LoRA 目标和像素预算”,并给出一个可以证伪的测试。

回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。

第十二章 分布式训练:并行策略、通信与故障恢复

分布式训练的本质是把参数、数据、激活、梯度和优化器状态分配到多个设备,并让通信与计算协调。目标不是“用更多卡”,而是在内存上可容纳、数值上等价、吞吐上有效、故障后可恢复。

图 12-1 数据并行复制模型;张量/流水线/专家并行切分模型计算;ZeRO/FSDP 切分训练状态。

12.1 DP 与 DDP

先建立直觉。 DataParallel 在单进程聚合,DDP 为每个进程维护模型副本并用 All-Reduce 同步梯度。DDP 通常性能和隔离更好,但每张卡仍保存完整权重与优化器。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 每个 rank 初始化进程组。
  2. DistributedSampler 切分数据。
  3. 前向反向触发梯度 bucket 通信。
  4. 所有 rank 一致更新参数。

最小例子。 4 卡 DDP 若每卡 micro batch=8、累积 2 步,有效 batch 通常为 64,但最后不齐批次和 sampler 设置会影响样本数。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 每个 rank 读取相同样本、只在 rank0 backward、随机种子完全相同导致增强重复、保存冲突,都会出错。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:运行两卡最小 DDP,打印每个 rank 样本 id 并验证无重复覆盖。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

数据并行让每张卡持有模型副本,处理不同微批次,反向后同步梯度。单进程 DataParallel 有主卡瓶颈,实践优先一进程一卡的 DistributedDataParallel(DDP)。DDP 通过 All-Reduce 聚合梯度,所有 rank 以相同梯度和优化器状态更新,因此参数保持一致。

PYTHON
# torchrun --standalone --nproc_per_node=4 train_ddp.py
import os
import torch
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data import DataLoader, DistributedSampler, TensorDataset
 
def main():
    dist.init_process_group("nccl")
    local_rank = int(os.environ["LOCAL_RANK"])
    torch.cuda.set_device(local_rank)
 
    x = torch.randn(4096, 32)
    y = torch.randn(4096, 1)
    dataset = TensorDataset(x, y)
    sampler = DistributedSampler(dataset, shuffle=True)
    loader = DataLoader(dataset, batch_size=64, sampler=sampler, pin_memory=True)
 
    model = DDP(torch.nn.Linear(32, 1).cuda(local_rank), device_ids=[local_rank])
    opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
    for epoch in range(3):
        sampler.set_epoch(epoch)
        for xb, yb in loader:
            xb, yb = xb.cuda(local_rank, non_blocking=True), yb.cuda(local_rank, non_blocking=True)
            opt.zero_grad(set_to_none=True)
            loss = torch.nn.functional.mse_loss(model(xb), yb)
            loss.backward()
            opt.step()
    if dist.get_rank() == 0:
        torch.save(model.module.state_dict(), "model.pt")
    dist.destroy_process_group()
 
if __name__ == "__main__":
    main()

DistributedSampler 防止所有 rank 读取相同样本;每轮 set_epoch 改变一致的 shuffle;只有 rank 0 写普通 checkpoint。真实代码还需全局指标 All-Reduce、异常协调和恢复逻辑。

12.2 ZeRO 与 FSDP

从问题出发。 ZeRO/FSDP 将优化器状态、梯度乃至参数分片到各 rank,降低单卡冗余。节省显存的代价是更多集合通信、参数聚合和更复杂 checkpoint。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. Stage1 分优化器状态。
  2. Stage2 再分梯度。
  3. Stage3/FSDP 再分参数。
  4. 计算前按需聚合、计算后释放或重分片。

用小数据走一遍。 参数分片并不意味着前向永远只看本地参数;计算某层前往往要 All-Gather 得到完整层。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 把 ZeRO stage 当单纯开关、wrap 粒度太细、CPU offload 受 PCIe 限制、保存 full state 时 OOM,都会影响训练。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:为一个模型估算 DDP 与 ZeRO-1/2/3 的理论状态显存,并说明遗漏项。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

Adam 混合精度训练中,参数、梯度和优化器状态占大量显存。ZeRO 分阶段切分:Stage 1 切优化器状态;Stage 2 再切梯度;Stage 3 再切参数,并在前向/反向需要时 All-Gather。FSDP 同样围绕参数分片、按需聚合和梯度 Reduce-Scatter。

Stage 越高显存越省,但通信和实现复杂度增加。Offload 把状态移到 CPU/NVMe,可进一步省显存,却可能受 PCIe、CPU 和磁盘瓶颈限制。

JSON
{
  "bf16": {"enabled": true},
  "gradient_accumulation_steps": 8,
  "train_micro_batch_size_per_gpu": 1,
  "zero_optimization": {
    "stage": 3,
    "overlap_comm": true,
    "contiguous_gradients": true,
    "reduce_bucket_size": 50000000,
    "stage3_prefetch_bucket_size": 50000000
  }
}

配置中的 batch 三元关系必须一致。桶大小影响通信聚合与峰值内存,不应盲目复制;用 profiler 测量。

12.3 模型并行

图 12-2 数据、张量、层和专家是四种不同切分维度,通信模式也随之不同。先看它解决什么。 Tensor Parallel 切单层矩阵,Pipeline Parallel 切层,Sequence/Context Parallel 切序列,Expert Parallel 切专家。选择取决于模型哪一维无法放入设备和通信拓扑。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 识别最大单层与总状态。
  2. 选择切分轴。
  3. 安排集合通信与流水线。
  4. 再叠加数据并行形成多维网格。

一个可以手算的例子。 列并行线性层把输出列分到多卡,后续操作若需要完整输出就 All-Gather;行并行常需要 Reduce-Scatter 或 All-Reduce。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 只乘并行度不考虑整除、跨节点放高频通信、流水线 microbatch 太少导致 bubble,都会降低效率。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:为 8 卡单机和 2×8 卡集群分别设计并行网格并解释通信。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

张量并行(TP)把单个矩阵乘按列或行切到多卡,需要在层内频繁集合通信;适合高速互连。流水线并行(PP)把层分阶段,微批次流水执行;会有 pipeline bubble,需调度与微批数量平衡。序列并行(SP)在序列维切分部分计算,长上下文下有价值。专家并行(EP)把 MoE 专家分布到设备,token 通过 All-to-All 路由。

混合并行通常组合 DP×TP×PP×EP。映射要尊重硬件拓扑:把通信最频繁的 TP 放在节点内高速连接,把数据并行扩到节点间。并行维度乘积应等于总设备数。

12.4 集合通信

抓住这一节的主线。 集合通信是并行算法的语言:All-Reduce 汇总并复制结果,Reduce-Scatter 汇总后分片,All-Gather 收集分片,All-to-All 重新分发不同数据。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 明确每个 rank 初始张量。
  2. 写出通信后每个 rank 所有内容。
  3. 计算传输量与次数。
  4. 匹配 DDP、FSDP、TP 或 EP。

先做最小实验。 DDP 梯度 bucket 常用 All-Reduce;FSDP 梯度可用 Reduce-Scatter;MoE token 路由常用 All-to-All。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 把带宽和延迟混为一谈、通信与计算无法重叠、张量大小不均、某 rank 先退出造成死锁,都会损害稳定性。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:用四个 rank 的小向量手工演示四种集合通信结果。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

常见原语:All-Reduce 汇总并复制结果;Reduce-Scatter 汇总后分片;All-Gather 收集所有分片;All-to-All 交换不同目标的数据。通信时间近似由延迟和带宽共同决定,小消息受延迟支配,大消息受带宽支配。

DDP 用梯度 bucket 尝试让反向计算与 All-Reduce 重叠。若存在未使用参数、控制流不一致或不同 rank 执行不同步,可能死锁。所有 rank 必须以一致顺序进入集合通信。

12.5 混合精度

先把概念落到可观察对象上。 FP16 范围小,常需 loss scaling;BF16 指数范围接近 FP32,通常更稳但尾数精度低。混合精度把敏感运算保留更高精度,并使用适配 kernel。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 选择 autocast dtype。
  2. loss scaling 后反传。
  3. 更新前反缩放与裁剪。
  4. 检查非有限梯度并调整 scale。

把它缩小到能逐项检查。 Softmax、归一化统计和优化器状态常需更高精度;具体由框架与 kernel 实现决定。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 仅把模型 .half、裁剪缩放前梯度、硬件不支持 BF16、不同 rank 出现 Inf 却继续同步,都会导致错误。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:比较 FP32、FP16+scaler、BF16 的损失曲线、显存和吞吐。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

FP16 指数范围小,通常需 loss scaling;BF16 指数范围接近 FP32,训练更稳但尾数精度较低。部分归一化、Softmax、损失和统计仍常在 FP32 计算。混合精度是否更快取决于硬件与 kernel。

梯度累积时,若用 DDP,可在非最后一个微步使用 no_sync() 避免每次同步。梯度裁剪应在反缩放之后、优化器更新之前执行。

12.6 Checkpoint 与断点续训

先建立直觉。 真正可续训的 checkpoint 包含模型、优化器、scheduler、scaler、随机状态、数据位置和并行拓扑信息。分片 checkpoint 还要能适配 world size 或有转换流程。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 在一致 step 建立保存屏障。
  2. 写临时目录并校验完整。
  3. 原子发布成功标记。
  4. 恢复后做短程连续性验证。

最小例子。 只恢复权重会丢失 Adam 动量与学习率进度,loss 可能突变;数据迭代位置丢失则重复或跳过样本。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 所有 rank 写同一文件、成功标记先于分片完成、恢复后重置 seed、从不演练损坏分片,都会让备份失效。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:训练 20 步在第 10 步保存,比较不中断与恢复路径第 11—20 步的指标。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

分片训练的 checkpoint 可能包含每 rank 的分片、元数据和优化器状态。恢复时世界大小变化并非所有格式都支持。保存内容包括模型、优化器、调度器、随机数、数据位置、全局步和 scaler。

采用临时目录写完后原子发布,保留完成标记和校验和;定期在独立作业中恢复并跑若干步。只测试“能加载模型权重”不足以证明能继续训练。

12.7 Accelerate 与 DeepSpeed

从问题出发。 Accelerate 提供较薄的设备与分布式抽象,DeepSpeed 提供 ZeRO、offload 与训练引擎。工具减少配置工作,但不能替代对 batch、状态分片和通信的理解。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 先在单卡验证数据和 loss。
  2. 用配置文件声明精度与并行。
  3. 检查启动后的实际 world size。
  4. 保存并恢复完整状态。

用小数据走一遍。 同一 YAML 在库版本变化后默认行为可能改变,运行日志应打印最终解析配置,而不只保存输入文件。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 同时让多个框架接管梯度累积、配置键拼错被忽略、只在 rank0 初始化不一致对象,都会产生隐蔽 bug。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:用 Accelerate 将单卡脚本改为多卡,并逐项说明代码变化与未变化部分。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

Accelerate 统一设备放置、混合精度和多进程启动,适合把单卡脚本平滑扩展。DeepSpeed 提供 ZeRO、流水线、优化器与推理能力。框架简化入口,但不会替你决定正确并行策略,也不会自动修复数据或通信瓶颈。

调试顺序:单卡小数据过拟合;单机两卡确认数值;扩大卡数并比较全局 batch;再加 ZeRO/混合精度;最后做多机与故障恢复。一次引入所有优化会让问题难以定位。

12.8 性能诊断

先看它解决什么。 性能诊断从时间线入手:数据等待、前向、反向、通信、优化器和 checkpoint 各占多少。GPU 利用率低只是现象,不直接告诉根因。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 用 profiler 捕获稳定窗口。
  2. 查看 kernel 空洞与通信重叠。
  3. 统计数据加载和 CPU。
  4. 一次只改变一个瓶颈。

一个可以手算的例子。 所有 rank 在 All-Reduce 前等待同一个慢 rank,可能来自样本长度不均或硬件降频,而不是 NCCL 本身。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 只看 nvidia-smi 瞬时利用率、profile 包含预热、用更大 batch 掩盖数据错误、跨节点未绑定网卡,都会误诊。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:对一次训练 step 做时间分解,提出证据支持的三项优化并复测。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

记录每步时间、数据时间、前向/反向、优化器、通信、MFU、网络带宽和显存。卡利用率低可能是数据慢、通信慢、微批过小、CPU 同步或频繁 checkpoint。某 rank 变慢会拖住所有同步 rank,需排查慢卡和数据倾斜。

12.9 练习与资料

抓住这一节的主线。 练习分布式不能只追求跑通,还要验证数值等价、样本覆盖、故障恢复和性能缩放。规模扩大前先在小集群注入错误。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 单卡与多卡 loss 对齐。
  2. 检查全局 batch 样本。
  3. kill 一个进程观察行为。
  4. 恢复后比较状态。

先做最小实验。 固定有效 batch 与随机性后,DDP 结果应与单卡在容差内接近;完全逐位相同通常不现实。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 多卡更快就认为正确、没有 barrier 超时、异常进程未清理、恢复只看能启动,都会遗漏。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:完成一份 DDP/FSDP 验收清单,包含正确性、吞吐、显存和恢复。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

练习:用 1/2/4 卡 DDP 保持全局 batch 不变并比较 loss;估算 ZeRO-1/2/3 各自切分哪些状态;画出 8 卡上 DP=2、TP=2、PP=2 的 rank 分组。

延伸阅读:PyTorch DDPPyTorch FSDPDeepSpeed ZeRO 教程

本章配套代码

下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。

本章端到端实验:把知识变成可复现证据

本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。

实验步骤

  1. 运行两卡最小 DDP,打印每个 rank 样本 id 并验证无重复覆盖。
  2. 为一个模型估算 DDP 与 ZeRO-1/2/3 的理论状态显存,并说明遗漏项。
  3. 为 8 卡单机和 2×8 卡集群分别设计并行网格并解释通信。
  4. 用四个 rank 的小向量手工演示四种集合通信结果。
  5. 比较 FP32、FP16+scaler、BF16 的损失曲线、显存和吞吐。
  6. 训练 20 步在第 10 步保存,比较不中断与恢复路径第 11—20 步的指标。

每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。

验收标准

  • 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
  • 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
  • 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
  • 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
  • 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。

本章自测

  1. 不看正文,用自己的话解释“DataParallel 在单进程聚合,DDP 为每个进程维护模型副本并用 All-Reduce 同步梯度”,并给出一个可以证伪的测试。
  2. 不看正文,用自己的话解释“ZeRO/FSDP 将优化器状态、梯度乃至参数分片到各 rank,降低单卡冗余”,并给出一个可以证伪的测试。
  3. 不看正文,用自己的话解释“Tensor Parallel 切单层矩阵,Pipeline Parallel 切层,Sequence/Context Parallel 切序列,Expert Parallel 切专家”,并给出一个可以证伪的测试。
  4. 不看正文,用自己的话解释“集合通信是并行算法的语言:All-Reduce 汇总并复制结果,Reduce-Scatter 汇总后分片,All-Gather 收集分片,All-to-All 重新分发不同数据”,并给出一个可以证伪的测试。
  5. 不看正文,用自己的话解释“FP16 范围小,常需 loss scaling”,并给出一个可以证伪的测试。
  6. 不看正文,用自己的话解释“真正可续训的 checkpoint 包含模型、优化器、scheduler、scaler、随机状态、数据位置和并行拓扑信息”,并给出一个可以证伪的测试。

回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。

第十三章 推理优化:量化、蒸馏、KV Cache 与高效服务

推理优化要围绕目标场景:离线吞吐、在线首 token、持续解码、长上下文、边缘设备各有不同瓶颈。先测 profile,再选技术。量化减少权重带宽,KV Cache 优化减少重复计算,FlashAttention 减少内存访问,连续批处理提高设备利用率,彼此解决的问题不同。

图 13-1 从模型表示、算子、缓存到调度的分层优化;每层都需质量与性能回归。

13.1 Prefill 与 Decode

先建立直觉。 Prefill 对整段输入并行计算并建立 KV Cache,通常计算密集;Decode 每步只处理新 token,却反复读取大量权重和缓存,通常更受内存带宽与调度影响。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. prefill 产生首 token 与缓存。
  2. decode 读取历史缓存。
  3. 逐步采样并追加缓存。
  4. 遇 EOS/长度/停止串结束。

最小例子。 长提示短回答主要压力在 prefill 和 TTFT;短提示长回答主要压力在 decode、TPOT 和缓存占用。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 用单一 tokens/s 描述两阶段、把输入 token 和输出 token 同价、忽略并发下缓存竞争,都会误判。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:对四种输入/输出长度组合记录 TTFT、TPOT 与总时间,画二维热图。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

Prefill 对整段输入并行计算,矩阵乘规模大,通常更偏计算密集;Decode 每步只生成一个 token,却要读取大量权重和 KV Cache,常偏内存带宽与调度。TTFT 主要受排队、tokenization 与 prefill 影响;TPOT 主要受 decode 影响。

优化前按输入/输出长度分桶测 TTFT、TPOT、吞吐、显存和质量。平均请求会掩盖长上下文的极端成本。

13.2 量化基础

从问题出发。 量化用较少比特表示权重或激活。核心是选择尺度、零点、粒度和校准数据,让舍入与截断误差可控。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 统计目标张量范围。
  2. 按 tensor/channel/group 求尺度。
  3. 映射到整数并裁剪。
  4. 反量化或用低比特 kernel 计算。

用小数据走一遍。 对称 int8 可令 q=round(x/scale),scale=max|x|/127;离群值会放大 scale,使多数小值分辨率下降。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 文件变小不等于推理变快、校准数据与真实分布不同、敏感层全部量化、只看平均精度,都会带来问题。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:手写对称 int8 量化,比较不同 group size 的重构误差。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

线性量化把浮点 $x$ 映射到整数 $q$:

$$q=\operatorname{clip}(\operatorname{round}(x/s)+z,q_{min},q_{max}),\qquad \hat{x}=s(q-z).$$

$s$ 是尺度,$z$ 是零点。对称量化通常 $z=0$;非对称量化更适合偏移分布。Per-tensor 使用一个尺度,简单但受离群值影响;Per-channel 或 group-wise 粒度更细,精度更好但元数据与 kernel 更复杂。

PYTHON
import torch
 
def symmetric_quantize(x: torch.Tensor, bits=8):
    qmax = 2 ** (bits - 1) - 1
    scale = x.abs().amax().clamp_min(1e-8) / qmax
    q = torch.round(x / scale).clamp(-qmax, qmax).to(torch.int8)
    return q, scale
 
def dequantize(q, scale):
    return q.float() * scale

这是教学版 per-tensor 权重量化,不会带来真实加速,因为计算前又反量化为 FP32。性能收益需要硬件支持的低比特 kernel、打包格式和融合算子。

13.3 BNB、GPTQ、AWQ 与 GGUF

先看它解决什么。 bitsandbytes 常用于易用的运行时量化,GPTQ/AWQ 属于权重量化路线,GGUF 是 llama.cpp 生态常用文件格式与量化集合。算法、格式和执行后端不可混为一谈。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 确认目标硬件与 kernel。
  2. 选择权重/激活位宽。
  3. 准备校准数据。
  4. 比较质量、速度、内存和兼容。

一个可以手算的例子。 同一个 4-bit 文件在没有优化 kernel 的硬件上可能只省内存不加速,甚至因反量化开销更慢。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 只按模型大小选择方法、把 QLoRA 训练格式直接当部署格式、不同量化命名跨工具等价,都会踩坑。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:为 GPU 服务与 CPU 边缘部署分别写量化选择表。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

BitsAndBytes 提供 8-bit/4-bit 线性层与 QLoRA 支持,适合快速实验。GPTQ 使用校准数据做训练后权重量化,逐步减少重构误差;AWQ 关注激活显著通道,保护重要权重。两者最终速度取决于后端 kernel 和硬件。

GGUF 是 llama.cpp 生态常用的模型文件格式,支持多种量化类型,适合 CPU/边缘推理。格式兼容不代表不同量化方案质量相同。选择时以目标硬件实测 tokens/s、首 token、内存和任务质量为准。

不要把 QLoRA 与部署量化混为一谈:QLoRA 的目标是低显存微调;部署时可选择不同的合并与量化流程。

13.4 知识蒸馏

抓住这一节的主线。 知识蒸馏让学生模型学习教师的软分布、中间特征或生成数据。温度提高后,非最大类别概率携带教师对相似性的判断。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 教师产生 logits 或答案。
  2. 学生同时学习硬标签与软目标。
  3. 调温度和损失权重。
  4. 在目标与回归集评估。

先做最小实验。 KL 蒸馏常乘 T² 补偿温度造成的梯度尺度变化;具体实现要核对 reduction 与 mask。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 教师错误被批量放大、合成数据缺少多样性、tokenizer 不同直接逐 token 对齐、只看学生模仿率,都会限制效果。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:在小分类任务比较无蒸馏与 logits 蒸馏,并分析错误样本。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

蒸馏用教师模型指导学生模型。分类中可组合硬标签交叉熵与温度软化后的 KL:

$$L=\lambda L_{CE}+(1-\lambda)T^2\,KL(p_T^{teacher}\|p_T^{student}).$$

语言模型还可蒸馏 logits、隐藏状态、注意力或教师生成数据。若只能调用教师 API,常采用数据蒸馏:生成高质量指令/推理样本,过滤后对学生 SFT。学生会继承教师偏差,生成数据还可能缺少真实分布多样性。

PYTHON
import torch.nn.functional as F
 
def distill_loss(student_logits, teacher_logits, labels, temperature=2.0,
                 alpha=0.5):
    hard = F.cross_entropy(student_logits, labels)
    soft_target = F.softmax(teacher_logits / temperature, dim=-1)
    soft_logprob = F.log_softmax(student_logits / temperature, dim=-1)
    soft = F.kl_div(soft_logprob, soft_target, reduction="batchmean")
    return alpha * hard + (1 - alpha) * (temperature ** 2) * soft

13.5 剪枝

先把概念落到可观察对象上。 剪枝删除对输出影响较小的权重、通道、头或层。只有硬件和 kernel 支持相应稀疏结构时,参数减少才可能转化为真实加速。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 选择重要性指标。
  2. 按非结构化或结构化模式剪枝。
  3. 校准或再训练恢复。
  4. 在目标后端实测。

把它缩小到能逐项检查。 2:4 稀疏要求每四个权重中保留两个,规则性比任意零值更容易被专用硬件利用。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 只报告稀疏率、剪掉后仍用稠密 kernel、注意力头重要性在任务间变化、无恢复训练,都会夸大收益。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:对小 MLP 做幅值剪枝与整列剪枝,比较参数、精度和真实耗时。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

非结构化剪枝把单个权重置零,只有稀疏 kernel 支持时才加速;结构化剪枝删除头、通道、FFN 单元或层,更容易获得真实速度,但影响能力更大。剪枝后通常需要恢复训练。报告参数稀疏率之外,还要报告真实硬件延迟。

13.6 FlashAttention

先建立直觉。 FlashAttention 通过分块与在线 Softmax 减少 HBM 读写,不显式保存完整注意力矩阵;它计算的是精确注意力,主要改变执行次序和内存复杂度。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. Q/K/V 分块载入片上存储。
  2. 局部计算分数。
  3. 维护在线最大值与归一化和。
  4. 累积输出再写回。

最小例子。 T² 分数矩阵不落到 HBM,可显著降低长序列激活内存;算术量级仍大体是二次。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 认为 FlashAttention 是稀疏近似、任何形状都同样加速、mask/dtype/kernel 不支持仍强开,都会出错。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:在支持环境比较标准 SDPA 与 Flash 后端的峰值显存和时间,并验证输出误差。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

标准注意力会把大 $T\times T$ 中间矩阵频繁写入/读取 HBM。FlashAttention 用分块和在线 Softmax,在片上 SRAM 中计算块,减少 HBM I/O,同时保持精确注意力结果。它主要改善训练和 prefill 的注意力内存访问,并不消除 $O(T^2)$ 的算术复杂度。

是否启用取决于 GPU、dtype、头维、mask 和软件版本。数值差异可能来自运算顺序,需设置合理容差测试。

13.7 KV Cache 与 PagedAttention

从问题出发。 KV Cache 保存每层历史 token 的 K/V,PagedAttention 把逻辑连续序列映射到固定大小物理块,减少预留和碎片并支持动态调度。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 新请求分配缓存块。
  2. prefill 写入多 token K/V。
  3. decode 追加新位置。
  4. 请求结束释放或复用块。

用小数据走一遍。 不同请求长度差异大时,预先分配最大连续空间浪费严重;分页可按增长需要追加块。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 缓存键未含模型/adapter、prefix cache 跨租户泄漏、块太小元数据多、释放不完整,都会产生问题。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:模拟可变长度请求的连续分配与分页分配,比较浪费率。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

自回归解码时,历史 token 的 K/V 不变,缓存后新一步只计算新 token 的 Q/K/V。缓存随层数、并发、上下文与 K/V 头数增长。GQA/MQA、KV 量化、滑动窗口和前缀缓存都可降低或复用成本。

PagedAttention 把缓存划分为块并维护逻辑到物理映射,类似虚拟内存思想,减少碎片并支持灵活共享。Prefix caching 对重复系统提示或长公共前缀有效;缓存键要包含模型、token 序列和相关配置。

13.8 连续批处理与投机解码

先看它解决什么。 连续批处理动态插入和移除请求;投机解码让小模型提议多个 token,再由大模型一次验证,接受连续正确前缀。二者分别优化调度和解码串行性。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 调度器按 token 预算选请求。
  2. draft 模型生成候选。
  3. target 模型并行验证。
  4. 按接受规则提交并继续。

一个可以手算的例子。 draft 越快且与 target 越一致,投机收益越大;频繁拒绝会浪费验证和 draft 计算。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 draft tokenizer 不一致、采样接受规则实现错误、只报理想接受率、不算额外显存,都会高估。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:用确定性 toy 分布实现投机解码,验证输出分布与直接采样一致。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

连续批处理按调度步动态组合不同请求,减少等待最慢序列的浪费。调度器需要在吞吐、公平、TTFT 和长请求饥饿之间取舍。

投机解码由小 draft 模型提出多个 token,大模型一次验证,接受一致前缀;输出分布可保持目标模型一致,但收益依赖接受率、draft 成本和硬件。若两个模型 tokenizer 或分布差异大,收益有限。

13.9 压测与回归

抓住这一节的主线。 优化必须用代表性负载做回归,质量、延迟、吞吐、显存、能耗和错误率一起报告。先 profile 再改,避免把瓶颈从 GPU 转移到排队或网络。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 冻结模型与数据版本。
  2. 建立未优化基线。
  3. 一次引入一项优化。
  4. 做质量和性能双门禁。

先做最小实验。 量化后 P50 更快但 P99 因某些长请求 kernel 回退变慢,不能只报平均吞吐。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 压测客户端不足、缓存命中率不真实、输出长度未固定、质量评测太小,都会给出错误结论。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:设计一张优化实验表,至少记录模型、后端、dtype、长度分布、并发、硬件和置信区间。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

建立性能矩阵:模型精度/量化格式 × 输入长度 × 输出长度 × 并发。记录质量基准、显存、TTFT、TPOT、吞吐、功耗和错误。预热后多轮测试,并报告软件、driver、硬件与采样参数。

量化质量不能只测困惑度;代码、数学、工具调用和长上下文对误差敏感程度不同。上线采用灰度和可回滚模型版本。

13.10 选择指南

图 13-2 先判断瓶颈来自 prefill、decode、KV Cache 还是模型存储,再选择技术。先把概念落到可观察对象上。 选择优化路线先识别约束:放不下、算不动、带宽不足、并发不够还是长尾过高。每种技术只对特定瓶颈有效。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. profile 定位瓶颈。
  2. 选择最小可逆改动。
  3. 在目标硬件验证 kernel。
  4. 保留质量回退和快速回滚。

把它缩小到能逐项检查。 显存不足可先考虑权重量化和 KV 管理;decode 带宽不足可考虑量化/并行;长上下文激活则关注 FlashAttention。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 同时上量化、蒸馏、剪枝后无法归因、追逐理论压缩率、忽略运维兼容,都会增加风险。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:为 7B 单卡、70B 多卡和 CPU 边缘三个场景给出分步优化计划。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

  • 显存不足:先评估权重量化、GQA 模型、KV 量化和上下文上限。
  • TTFT 高:检查队列、输入长度、prefill batching 和 tokenizer。
  • TPOT 高:检查量化 kernel、张量并行通信、KV 带宽和 batch。
  • CPU/边缘:测试 GGUF/llama.cpp 的多种量化与线程设置。
  • 模型太大且质量可牺牲:考虑蒸馏或结构化剪枝。

练习:对随机矩阵比较 8/4-bit 量化误差;按公式估算不同 GQA 配置的 KV Cache;设计可重复的并发压测并画出吞吐—P99 曲线。

延伸阅读:FlashAttentionvLLM 文档BitsAndBytes 官方说明Transformers 量化选择

本章配套代码

下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。

本章端到端实验:把知识变成可复现证据

本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。

实验步骤

  1. 对四种输入/输出长度组合记录 TTFT、TPOT 与总时间,画二维热图。
  2. 手写对称 int8 量化,比较不同 group size 的重构误差。
  3. 为 GPU 服务与 CPU 边缘部署分别写量化选择表。
  4. 在小分类任务比较无蒸馏与 logits 蒸馏,并分析错误样本。
  5. 对小 MLP 做幅值剪枝与整列剪枝,比较参数、精度和真实耗时。
  6. 在支持环境比较标准 SDPA 与 Flash 后端的峰值显存和时间,并验证输出误差。

每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。

验收标准

  • 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
  • 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
  • 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
  • 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
  • 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。

本章自测

  1. 不看正文,用自己的话解释“Prefill 对整段输入并行计算并建立 KV Cache,通常计算密集”,并给出一个可以证伪的测试。
  2. 不看正文,用自己的话解释“量化用较少比特表示权重或激活”,并给出一个可以证伪的测试。
  3. 不看正文,用自己的话解释“bitsandbytes 常用于易用的运行时量化,GPTQ/AWQ 属于权重量化路线,GGUF 是 llama.cpp 生态常用文件格式与量化集合”,并给出一个可以证伪的测试。
  4. 不看正文,用自己的话解释“知识蒸馏让学生模型学习教师的软分布、中间特征或生成数据”,并给出一个可以证伪的测试。
  5. 不看正文,用自己的话解释“剪枝删除对输出影响较小的权重、通道、头或层”,并给出一个可以证伪的测试。
  6. 不看正文,用自己的话解释“FlashAttention 通过分块与在线 Softmax 减少 HBM 读写,不显式保存完整注意力矩阵”,并给出一个可以证伪的测试。

回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。

第十四章 面试专项:从简历证据到项目深挖

大模型岗位面试通常同时验证四件事:基础原理是否扎实,代码与算法是否能落地,项目是否真正做过,面对未知问题是否能建立可验证的分析。近期公开面经反复出现 RAG 切块/召回/重排、Agent 工具与记忆、微调数据与 LoRA、服务性能、模型结构和项目 bad case。准备时不要背孤立答案,而要建立“定义—机制—取舍—指标—故障”的表达框架。

图 14-1 项目表达建立在基础、代码和评估之上;只背术语无法承受追问。

14.1 先读岗位,而不是先刷题

先建立直觉。 岗位描述是能力假设,不是关键词清单。先判断岗位偏模型、训练、应用、推理还是平台,再把要求映射到可证明的项目证据。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 标出必须项和加分项。
  2. 按职责归类技术。
  3. 找到自己证据与缺口。
  4. 决定准备优先级。

最小例子。 岗位强调 RAG 评估与上线,就应准备数据、指标、故障和监控,而不是只背 Transformer 公式。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 一份简历投所有岗位、只按出现频次背题、把不会的工具写熟练,都会在追问中暴露。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:选择三个真实 JD 做能力矩阵,写出共同核心与岗位差异。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

将 JD 拆成模型算法、应用算法、平台工程、多模态或研究方向。模型算法偏训练、对齐和分布式;应用算法偏 RAG、Agent、评估与服务;平台工程偏推理引擎、调度、监控和成本。为每项要求准备一条证据:课程不算证据,代码、实验、指标、设计文档和线上结果才算。

建立技能矩阵:能解释、能手写、能调试、能设计、能量化。不会的内容如实标注学习中,不要把“调用过 API”写成“精通模型训练”。

14.2 简历:每个数字都能被复现

从问题出发。 简历的每个数字都是一个实验结论,应能回答定义、基线、数据、硬件、时间窗口、个人贡献和不确定性。项目 bullet 用问题—约束—动作—取舍—指标表达。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 给出业务和技术基线。
  2. 说明你亲自做的决策。
  3. 写指标及评估条件。
  4. 准备失败与局限。

用小数据走一遍。 Recall@5 从 0.69 到 0.84 必须说明相关块标注、查询数、索引版本和是否存在数据泄漏。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 数字无出处、把团队成果写成个人、只列技术栈、没有失败样例,会降低可信度。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:逐条给简历数字建立证据卡;无法复现的数字删除或改成定性描述。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

项目 bullet 推荐结构:业务问题 + 约束 + 个人动作 + 技术取舍 + 指标 + 评估条件。不要写“负责 RAG 系统,准确率提高 30%”而不说明准确率定义。

一段可追问的写法:

面向 8 万页设备手册的售后问答,负责解析、标题感知切块和检索评估。构建 360 条人工标注查询,以 BM25 为基线,引入 Dense+BM25 RRF 与 Cross-Encoder 重排,将 Recall@5 从 0.69 提升至 0.84、引用准确率从 76% 提升至 90%;通过异步召回和重排批处理将 P95 从 2.8 秒降至 1.7 秒。

准备数字的出处:评估集怎样抽样、谁标注、基线版本、置信区间、硬件和时间窗口。没有真实线上数据就明确写“离线评估”,不要暗示线上收益。

14.3 三分钟项目介绍

先看它解决什么。 三分钟介绍要先让听者理解问题和约束,再讲方案与个人决策,最后用指标和失败证明真实做过。组件清单不是项目故事。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 30 秒背景与目标。
  2. 30 秒数据和约束。
  3. 60 秒核心方案与取舍。
  4. 40 秒结果。
  5. 20 秒失败和下一步。

一个可以手算的例子。 讲混合检索时说明为何纯向量漏掉型号、怎样标注评估集、RRF 带来何种可复现提升。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 铺垫两分钟、只念架构图、不说个人贡献、结果只有‘效果很好’,都会失去重点。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:录制三次项目介绍,分别删减到 180 秒并让不了解项目的人复述。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

建议按以下顺序:

  1. 背景:用户是谁,痛点是什么,为什么需要大模型。
  2. 约束:数据、延迟、成本、隐私、更新频率。
  3. 方案:数据流和关键组件,为什么这样选。
  4. 本人贡献:亲自实现、设计和决策的部分。
  5. 结果:基线、指标、实验条件。
  6. 失败与迭代:最难 bad case、如何定位、还剩什么问题。

面试官最关心第 4—6 项。架构图能帮助表达,但不要花两分钟念组件名。

14.4 高频基础题:回答骨架

图 14-2 一个可追问的答案应包含定义、机制、取舍、证据和边界。抓住这一节的主线。 高频基础题要用定义—机制—公式或形状—取舍—验证五层回答。先给结论,再逐层展开,遇到不确定边界要明确。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 一句话定义术语。
  2. 画数据流或写关键公式。
  3. 说明为什么这样设计。
  4. 比较替代方案。
  5. 给实验或故障例子。

先做最小实验。 回答 GQA 时先说 KV 头少于 Q 头,再写缓存公式,最后讨论质量、内存和 kernel 支持。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 只背标准句、公式无变量、绝对化说‘更好’、混淆论文与具体模型实现,会被连续追问击穿。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:为每类问题写 90 秒答案,并让同伴连续追问三个‘为什么’。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

Transformer

为什么缩放点积要除以 $\sqrt{d_k}$? 假设 q/k 各维独立、均值 0、方差 1,点积方差随 $d_k$ 增长;缩放让 logits 尺度稳定,避免 Softmax 过饱和、梯度过小。进一步说明真实分布未必独立,但初始化尺度分析仍有指导意义。

Pre-Norm 与 Post-Norm? 说明归一化位置、残差路径与深层训练稳定性;不要只说“Pre-Norm 更好”,还可讨论最终归一化和模型具体实现。

MHA/GQA/MQA? 从 Q 头与 KV 头数量、KV Cache、质量和 kernel 支持比较,并写出缓存公式。

RoPE 为什么能表达相对位置? 旋转后的 q/k 点积只依赖角度差;说明它作用于 q/k,不作用于 v,并讨论长上下文外推不是改配置。

训练与微调

LoRA 为什么有效? 任务适配的权重更新常呈低内在秩;冻结 W,学习 BA。继续说明 rank/alpha、目标模块、初始化、合并与不足。

SFT 后复读? 从数据重复和模板单一、epoch/学习率、EOS、解码、标签 mask、灾难性遗忘逐层排查,用生成样例和 n-gram 重复率验证。

RAG 与微调如何选? 动态事实/引用优先 RAG,行为/格式优先 SFT;可组合。比较更新成本、可追溯、延迟和评估。

RAG

Chunk 多大? 没有固定答案。依据文档结构、Embedding 最大长度、问题粒度、召回与上下文预算,通过标注集搜索。说明 overlap 和父子块。

检索不准怎么排查? 先确认解析与标注,再看查询改写、Embedding、索引 Recall、过滤、top-k、混合检索、重排;不要直接换大模型。

怎样评估? 检索 Recall@K/MRR/nDCG,生成正确性/忠实度/引用,外加延迟成本;错误归因到组件。

Agent

记忆与上下文工程关系? 记忆是可持久状态来源,上下文工程决定每轮把哪些状态、工具、历史和证据放入有限窗口。说明写入审核、检索和遗忘。

Agent 为什么失败? 工具契约、状态、停止、权限、注入、重试与评估。给一个自己项目中的 trace 例子。

多 Agent 何时有价值? 子任务可分、并行收益高、输出可验证时;否则通信和故障成本大于收益。

对齐与推理

DPO 与 PPO? 从数据在线/离线、是否显式 RM/价值模型、实现复杂度、分布外探索和稳定性比较,并能写 DPO loss。

GRPO 的优势怎样算? 同一 prompt 多个回答的奖励做组内标准化;不需要独立价值模型。说明奖励同质和可验证奖励的限制。

FlashAttention 为什么快? IO-aware 分块减少 HBM 访问,是精确注意力;算术复杂度仍为二次。

量化精度为何下降? 尺度和舍入产生误差,离群值、粒度、校准数据、敏感层影响不同。区分真实 kernel 加速与仅存储压缩。

14.5 手撕代码题

先把概念落到可观察对象上。 手撕代码考察的是问题澄清、接口、正确性、复杂度和测试。大模型岗位常要求数值稳定与张量形状,不能只写 happy path。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 先确认输入输出与边界。
  2. 写最小正确实现。
  3. 解释时间空间复杂度。
  4. 主动构造极值和错误输入。

把它缩小到能逐项检查。 稳定 Softmax 先减最大值;注意力要说明 axis、mask、dtype 和形状;LRU 要说明容量为零。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 沉默写代码、用库函数绕过核心、无测试、忽略 NaN/空输入/dtype,都会丢分。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:限时手写 Softmax、RMSNorm、Attention、RoPE、RRF 和 LRU,并为每题写三个测试。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

优先练习:Softmax 数值稳定版、LayerNorm/RMSNorm、Self-Attention 与 mask、RoPE、GQA repeat_kv、Top-k/Top-p、交叉熵、LoRA Linear、DPO loss、余弦检索、RRF、LRU cache、生产者消费者、梯度累积。

写代码时先说输入输出与边界,再实现主逻辑,最后给测试。下面是稳定 Softmax:

PYTHON
import numpy as np
 
def softmax(x, axis=-1):
    x = np.asarray(x, dtype=np.float64)
    shifted = x - np.max(x, axis=axis, keepdims=True)
    exp = np.exp(shifted)
    return exp / np.sum(exp, axis=axis, keepdims=True)
 
def test_softmax():
    y = softmax([[1000.0, 1001.0]])
    assert np.all(np.isfinite(y))
    assert np.allclose(y.sum(axis=-1), 1.0)

面试中边写边解释形状、复杂度和数值稳定性。完成后主动测极值、空输入、维度和 dtype。

14.6 算法题准备

先建立直觉。 算法准备按模式建立迁移能力:哈希、双指针、滑窗、栈、二分、树、堆、图和动态规划。目标是从约束识别状态与不变量。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 复述题目并举例。
  2. 先给朴素解。
  3. 找重复计算或单调性。
  4. 证明优化并测试。

最小例子。 滑动窗口只有在窗口扩张/收缩能维护目标性质时成立;包含负数的和问题未必保持单调。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 背模板不验证前提、复杂度说错、边界全交给面试官提醒、刷题后不重写,都会影响表现。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:建立错题本,每题记录识别信号、核心不变量、最小反例和一周后重写结果。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

大模型岗仍会考通用算法。按模式练习数组/哈希、双指针、滑窗、栈队列、二分、链表、树、堆、图、动态规划。目标不是刷数量,而是看到题能识别状态、证明正确、分析复杂度并写测试。

建立错题本:错误原因、正确模式、最小反例、重写日期。模拟面试限制 30—40 分钟,先澄清再编码;不要沉默十分钟后突然给答案。

14.7 系统设计题

从问题出发。 系统设计先问需求与 SLO,再估算流量、token、存储和模型资源,之后才画组件。答案要包含数据、接口、容量、安全、故障和成本。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 澄清用户、规模和一致性。
  2. 估算峰值 QPS/token。
  3. 画主链路与异步链路。
  4. 设计降级、监控和回滚。

用小数据走一遍。 设计 70B 服务要讨论并行、prefill/decode、KV Cache、批处理和流量分布,而不是只说用 vLLM。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 一上来画微服务、没有数量级估算、忽略租户权限和数据删除、只谈正常路径,都会显得空泛。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:完成企业 RAG、Agent 平台和模型服务三道设计,每道写容量估算。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

典型题:“设计企业知识助手”“设计多租户 Agent 平台”“如何服务 70B 模型”。回答顺序:需求与 SLO、流量与数据、接口、架构、容量、安全、评估、故障与成本。

企业 RAG 重点谈权限、版本、引用、更新和评估;Agent 平台重点谈工具沙箱、凭据、确认、trace 和预算;模型服务重点谈 TTFT/TPOT、continuous batching、KV Cache、并行、量化和降级。

14.8 Bad case 深挖

先看它解决什么。 Bad case 深挖要讲首次异常信号、假设、证据、实验、修复和回归。面试官关注你怎样排除错误假设,而不只是最终换了什么组件。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 冻结失败样本和版本。
  2. 分层构造假设。
  3. 一次验证一个变量。
  4. 修复后加入回归集。

一个可以手算的例子。 扫描 PDF 召回下降时,通过分文档类型发现 OCR 和标题丢失,修解析后恢复,而不是直接换 embedding。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 归因于‘模型不行’、没有失败数据、同时改多个组件、只报成功不报代价,都会不可信。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:为每个项目准备三个真实失败故事和一个仍未解决的问题。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

使用一张表准备每个项目的三个失败:现象、影响范围、假设、证据、实验、修复、回归。面试官可能连续问“为什么”,直到触及你的真实工作边界。

例如召回下降:先按文档类型分桶发现扫描 PDF 最差;检查 OCR 字符错误率与标题丢失;更换版面解析并保留页级标题;Recall@5 恢复;新增扫描件回归集。这个故事比“换了更好的 Embedding”更可信。

14.9 行为与协作

抓住这一节的主线。 行为题用具体情境说明决策与协作:目标冲突、信息不完整、时间压力和责任边界。STAR 只是结构,证据和反思才有价值。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 交代任务与约束。
  2. 说明自己的判断和沟通。
  3. 给出结果与证据。
  4. 说明之后怎样改流程。

先做最小实验。 技术分歧中可讲如何定义共同指标、做小实验、记录决策,而不是说‘我说服了别人’。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 把失败归因他人、故事没有个人动作、结果无法验证、每题套同一案例,都会显得准备痕迹重。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:准备冲突、失败、取舍、推动和学习五个故事,每个控制在两分钟。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

准备一次技术分歧、一次失败、一次跨团队协作、一次在约束下取舍。使用 STAR,但结果包含证据和反思。不要把所有问题归因于别人;说明你怎样改变流程,避免复发。

14.10 模拟面试清单

先把概念落到可观察对象上。 模拟面试要复现真实压力并产生反馈闭环。按岗位组合原理、代码、项目和系统设计,录音后逐项评分,而不是只数刷题数量。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 建立评分量表。
  2. 严格计时和连续追问。
  3. 复盘事实错误与表达。
  4. 隔几天无稿重答。

把它缩小到能逐项检查。 回答正确但用了八分钟、没有先给结论,仍需改进;评分应包含结构、准确、证据和节奏。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 只和熟悉朋友练、提前知道题、复盘只看答案、最后一天高强度堆题,都会降低迁移。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:完成至少三轮不同面试官的全流程模拟,记录可量化改进项。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

  • 60 秒自我介绍,3 分钟项目,10 分钟项目深挖。
  • 20 道核心原理题能够画图、写公式、说取舍。
  • 10 道手撕代码可在无补全环境写出并测试。
  • 2 道系统设计能估算容量与说明安全。
  • 每个简历数字有来源,每项技术能说一个失败。
  • 准备反问:团队目标、数据与评估、上线责任、研究/工程比例和成功标准。

14.11 经验来源与使用方式

先建立直觉。 公开面经用于发现能力维度和追问方式,不是公司固定题库。信息有样本偏差与时效性,应与岗位、官方技术栈和自身经历交叉验证。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 记录发布时间与岗位。
  2. 抽取反复出现的能力主题。
  3. 映射回本书章节。
  4. 用真实项目证据准备。

最小例子。 多篇面经提到 RAG 切块和评估,说明应会解释取舍,但不意味着背一个固定 chunk size。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 照抄答案、泄露公司保密题、把个例当招聘标准、为匹配面经虚构经历,都会适得其反。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:汇总 20 篇近期公开面经,按能力而非题目建立频次表,并标注来源。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

公开面经具有样本偏差,不能据此推断每家公司固定题库;它们的价值是发现反复出现的能力维度。近期牛客面经中,项目深挖常涉及多 Agent 编排、失败重试、RAG 热更新、Embedding/Rerank 选择、切块优化、微调复读与算法题。将这些主题映射回本书对应章节,比背“标准答案”更有效。

最后的判断标准不是“背了多少题”,而是能否把一个陌生问题拆成可验证假设,能否写出正确最小实现,能否用数据解释取舍。这也是整本书希望训练的能力。

本章配套代码

下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。

本章端到端实验:把知识变成可复现证据

本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。

实验步骤

  1. 选择三个真实 JD 做能力矩阵,写出共同核心与岗位差异。
  2. 逐条给简历数字建立证据卡;无法复现的数字删除或改成定性描述。
  3. 录制三次项目介绍,分别删减到 180 秒并让不了解项目的人复述。
  4. 为每类问题写 90 秒答案,并让同伴连续追问三个‘为什么’。
  5. 限时手写 Softmax、RMSNorm、Attention、RoPE、RRF 和 LRU,并为每题写三个测试。
  6. 建立错题本,每题记录识别信号、核心不变量、最小反例和一周后重写结果。

每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。

验收标准

  • 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
  • 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
  • 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
  • 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
  • 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。

本章自测

  1. 不看正文,用自己的话解释“岗位描述是能力假设,不是关键词清单”,并给出一个可以证伪的测试。
  2. 不看正文,用自己的话解释“简历的每个数字都是一个实验结论,应能回答定义、基线、数据、硬件、时间窗口、个人贡献和不确定性”,并给出一个可以证伪的测试。
  3. 不看正文,用自己的话解释“三分钟介绍要先让听者理解问题和约束,再讲方案与个人决策,最后用指标和失败证明真实做过”,并给出一个可以证伪的测试。
  4. 不看正文,用自己的话解释“高频基础题要用定义—机制—公式或形状—取舍—验证五层回答”,并给出一个可以证伪的测试。
  5. 不看正文,用自己的话解释“手撕代码考察的是问题澄清、接口、正确性、复杂度和测试”,并给出一个可以证伪的测试。
  6. 不看正文,用自己的话解释“算法准备按模式建立迁移能力:哈希、双指针、滑窗、栈、二分、树、堆、图和动态规划”,并给出一个可以证伪的测试。

回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。

附录 A 从空电脑到可复现实验环境

本附录给出一条不依赖特定云平台的环境路线。版本会变化,因此原则比命令更重要:先确定操作系统、Python、GPU 驱动和框架的兼容关系;每个项目使用隔离环境;安装后做最小健康检查;最后锁定依赖并记录硬件。

A.1 CPU、GPU 与显存分别负责什么

CPU 负责 Python 调度、数据解析、tokenization 和大量通用任务;GPU 擅长并行矩阵计算;显存保存权重、激活、梯度、优化器状态和缓存。没有 GPU 可以完成 NumPy、数据处理、API 应用和小模型实验,但本地运行数十亿参数模型会受到内存与速度限制。

粗略估算权重存储时,可用“参数量×每参数字节”。7B 模型仅 FP16 权重约需 14 GB,但真实推理还需要 KV Cache、临时 buffer 和框架开销;训练还需要梯度和优化器状态,不能用权重大小直接判断能否训练。量化文件变小也不保证目标硬件具有加速 kernel。

A.2 创建虚拟环境

BASH
python -m venv .venv

Windows PowerShell 激活:

POWERSHELL
.venv\Scripts\Activate.ps1

Linux/macOS 激活:

BASH
source .venv/bin/activate

确认 pythonpip 指向当前环境:

BASH
python -c "import sys; print(sys.executable)"
python -m pip --version

始终优先使用 python -m pip,因为它明确调用当前 Python 对应的 pip。若 PowerShell 禁止激活脚本,应先理解组织安全策略;不要从陌生网页复制修改全局执行策略的命令。

A.3 安装 PyTorch 前先核对兼容关系

GPU 环境至少包含三层:操作系统驱动、PyTorch 包内或依赖的 CUDA runtime、实际 GPU 架构。nvidia-smi 显示的“CUDA Version”通常表示驱动可支持的最高 CUDA 版本,不等于你安装的 PyTorch build。

安装命令应从 PyTorch 官方选择器获取。安装后运行:

PYTHON
import torch
 
print("torch:", torch.__version__)
print("torch cuda build:", torch.version.cuda)
print("cuda available:", torch.cuda.is_available())
if torch.cuda.is_available():
    print("device:", torch.cuda.get_device_name(0))
    x = torch.randn(1024, 1024, device="cuda")
    y = x @ x
    print(y.mean().item())

cuda available 为 False,按顺序检查:是否安装了 CPU 版 PyTorch;驱动是否可见;当前 Python 是否是刚安装依赖的环境;远程容器是否映射 GPU;系统是否需要重启。不要在原因未明时重复安装多个 CUDA Toolkit,它们可能并不能解决 wheel 与驱动的问题。

A.4 Hugging Face 模型与缓存

下载模型前阅读模型卡、许可证、参数量、精度和上下文长度。权重、tokenizer 和配置必须属于同一版本。缓存目录可能占用大量磁盘,团队环境应明确缓存位置、清理策略和离线镜像。

对包含 trust_remote_code=True 的项目要特别谨慎:这意味着模型仓库代码会在本地执行。只有确认来源、固定 revision 并审查代码后才考虑启用。对于 checkpoint,优先使用安全序列化格式;不可信 pickle 可能在加载时执行代码。

A.5 依赖锁定与环境报告

最简单的环境快照可以用:

BASH
python -m pip freeze > requirements-lock.txt

pip freeze 记录的是当前环境的所有包,不一定是最小依赖。成熟项目可使用 pyproject.toml 描述直接依赖,再由锁定工具生成解析后的版本。无论采用什么工具,实验报告都应包含 Python、核心库、操作系统、CPU/GPU、驱动和启动命令。

A.6 常见环境错误清单

  • ModuleNotFoundError:先打印 sys.executable,确认装包和运行使用同一环境。
  • CUDA OOM:记录实际分配与保留显存,缩小单样本长度;梯度累积不能解决一条样本本身放不下。
  • DLL/so 加载失败:检查架构、编译版本和依赖搜索路径,不要随意下载未知 DLL 覆盖系统文件。
  • tokenizer 或模型维度不匹配:核对仓库 revision、词表大小、特殊 token 和 resize 过程。
  • 多进程卡死:记录每个 rank 最后一步,检查样本数量、集合通信顺序和异常进程。
  • 结果不复现:除随机种子外,还要核对数据顺序、非确定 kernel、库版本、精度与硬件。

A.7 一个合格的环境验收

环境准备完成的标准不是“安装没有报错”,而是:NumPy 示例可以运行;PyTorch 前向与反向通过;GPU 小矩阵计算通过;保存的模型能在新进程加载;版本报告已写入文件;仓库不包含密钥、缓存和大权重。之后再进入大模型下载和训练,会节省大量排错时间。

附录 B 大模型学习所需数学速查

本附录不是完整数学教材,而是把正文反复出现的符号放到同一张地图里。建议用纸笔为每个公式构造 2×2 或长度为 3 的例子,再用 NumPy 验证。

B.1 标量、向量、矩阵与张量

标量是单个数;向量是一维有序数组;矩阵有行列两个轴;张量是多轴数组。轴的语义比“几维”更重要,例如语言模型隐藏状态常写为 (B,T,D):批次、序列长度、隐藏维。

矩阵乘法 A @ B 要求 A 的最后一维等于 B 的倒数第二维。若 A(m,n)B(n,p),输出为 (m,p)

C_{ij}=\sum_{k=1}^{n}A_{ik}B_{kj}

逐元素乘法不做求和,两个张量必须形状相同或满足广播规则。注意力里的 QK^T 是矩阵乘,而门控网络里的两条分支通常逐元素相乘。

B.2 点积、范数与余弦相似度

点积把两个同长度向量变成标量:

x\cdot y=\sum_i x_i y_i

L2 范数为 sqrt(sum(x_i²))。余弦相似度只比较方向:

\cos(x,y)=\frac{x\cdot y}{\|x\|_2\|y\|_2}

若向量已做 L2 归一化,点积就等于余弦相似度。向量数据库使用哪种度量必须与 embedding 模型说明和归一化方式一致。

B.3 概率、条件概率与交叉熵

概率分布的每项非负且总和为 1。条件概率 P(A|B) 表示已知 B 时 A 的概率。语言模型输出的是在前缀条件下下一个 token 的分布:

P(x_1,\ldots,x_T)=\prod_{t=1}^{T}P(x_t\mid x_{<t})

Softmax 把 logits 转成概率:

p_i=\frac{e^{z_i}}{\sum_j e^{z_j}}

数值实现应先减去最大 logit。单个正确类别 y 的交叉熵是 -log p_y;序列损失通常对有效标签取平均。Perplexity 常写为 exp(cross_entropy),但不同 tokenizer 的 token 粒度不同,不能脱离条件直接横比。

B.4 导数、偏导与链式法则

导数描述输入微小变化如何影响输出。多变量函数对每个变量的偏导组成梯度。复合函数 L=f(g(x)) 的链式法则为:

\frac{dL}{dx}=\frac{dL}{dg}\frac{dg}{dx}

反向传播就是沿计算图反向重复应用链式法则。梯度不是参数更新本身;优化器还会结合学习率、动量、二阶矩和权重衰减。

有限差分可检查解析梯度:

\frac{\partial L}{\partial w_i}\approx \frac{L(w_i+\epsilon)-L(w_i-\epsilon)}{2\epsilon}

梯度检查应使用双精度、小输入并关闭随机层;epsilon 太大有截断误差,太小受浮点舍入影响。

B.5 均值、方差与归一化

均值描述中心,方差描述离散程度:

\mu=\frac1n\sum_i x_i,\qquad \sigma^2=\frac1n\sum_i(x_i-\mu)^2

LayerNorm 对一个 token 的隐藏维做中心化与尺度归一化;RMSNorm 不减均值,只除以均方根。归一化的轴、epsilon 位置和可学习缩放必须与实现一致。

B.6 信息论直觉

事件概率越小,自信息 -log p 越大。熵是分布平均不确定性。KL 散度衡量分布 p 相对 q 的差异:

D_{KL}(p\|q)=\sum_i p_i\log\frac{p_i}{q_i}

KL 不对称,也不是严格距离。RLHF 中常用 KL 限制新策略偏离参考模型;蒸馏中可用 KL 让学生接近教师软分布。

B.7 复杂度与数量级估算

大 O 表示随变量增长的主导趋势,不等于真实耗时。注意力分数矩阵随序列长度约按 增长;FFN 计算约随 T·D·Dff 增长。真实性能还受 dtype、batch、内存带宽、kernel 融合、通信和硬件利用率影响。

做系统估算时至少同时考虑:参数与状态存储、激活、KV Cache、FLOPs、读写字节、网络通信、排队和长尾。理论数量级用于提出假设,profiler 与压测用于验证。

B.8 浮点数与数值稳定性

浮点数只有有限范围和精度。FP16 范围较小,容易上溢/下溢;BF16 指数范围更大但有效尾数更少;FP32 更稳但占用和带宽更高。稳定实现常见策略包括减最大值、log-sum-exp、更高精度累积、loss scaling、梯度裁剪和 epsilon。

看到 NaN/Inf 时,先找首次出现的位置,检查输入、除零、log/exp、学习率、梯度和半精度范围。不要只在最后一层用 nan_to_num 隐藏问题。

附录 C 大模型工程术语表

术语表给出本书语境中的简明定义。它不是替代正文的“标准答案”,同一术语在论文和框架中可能有更具体含义。

  • Agent:由模型参与运行时决策、可使用工具并维护状态的系统;自主程度应受权限、预算与验证约束。
  • Alignment(对齐):让模型行为更符合人类或产品目标的训练与系统过程,包括 SFT、偏好优化、安全策略和评估。
  • All-Gather:让所有 rank 收集到各 rank 的分片。
  • All-Reduce:对各 rank 张量做求和等归约,并把结果发送给所有 rank。
  • Attention Mask:在注意力分数上屏蔽不应被读取的位置,常见因果 mask 与 padding mask。
  • AWQ:利用激活信息保护重要权重的权重量化方法族。
  • Batch:一次并行处理的一组样本;在语言模型训练中应同时关注样本数与有效 token 数。
  • BF16:具有接近 FP32 指数范围、较短尾数的 16 位浮点格式。
  • BM25:基于词频、逆文档频率和长度归一化的经典稀疏检索函数。
  • BPE:通过迭代合并频繁符号对学习子词词表的 tokenization 方法。
  • Checkpoint:可保存并恢复训练或推理状态的快照;完整续训还需优化器、调度器、随机状态和数据位置。
  • Chunk:RAG 索引与检索使用的文档片段,应保留来源、层级、页码、权限和版本。
  • CLIP:通过图文对比学习建立共享表示空间的视觉语言模型。
  • Continuous Batching:在生成过程中动态向执行批次插入新请求、移除已完成请求的调度方式。
  • Cross-Attention:查询来自一个序列、键和值来自另一个序列的注意力。
  • Cross-Encoder:把查询与候选文本联合编码并评分,常用于重排。
  • CUDA OOM:GPU 显存分配失败;要区分权重、激活、缓存、临时 buffer 与碎片来源。
  • Data Parallel:不同设备处理不同数据分片并同步梯度的并行方式。
  • DDP:PyTorch 分布式数据并行,每个进程持有模型副本并通过集合通信同步梯度。
  • Decode:自回归推理中逐 token 生成的阶段,常受内存带宽与 KV Cache 影响。
  • Dense Retrieval:使用 embedding 向量进行语义近邻检索。
  • DPO:使用偏好对直接优化策略相对参考模型概率差的离线偏好方法。
  • Embedding:把离散对象或文本映射为连续向量的表示。
  • EOS:序列结束特殊 token;训练标签、生成停止与 padding 需正确区分。
  • Expert Parallel:将 MoE 专家分布到不同设备的并行方式。
  • FlashAttention:通过 IO-aware 分块和在线 Softmax 减少高带宽内存读写的精确注意力实现。
  • FP16:16 位浮点格式,训练时常需动态或静态 loss scaling。
  • FSDP:PyTorch 全分片数据并行,可分片参数、梯度与优化器状态。
  • Function Calling:模型按工具 Schema 提议函数与参数,由程序验证并决定是否执行。
  • GQA:多个查询头按组共享较少的键和值头,降低 KV Cache。
  • Gradient Accumulation:多次反向累积梯度后再更新,用于扩大有效 batch;不能解决单样本本身 OOM。
  • GRPO:对同一 prompt 的一组回答使用组内相对奖励估计优势的策略优化方法。
  • Grounding:让输出与可指认的输入、图像区域或外部证据建立对应关系。
  • Hallucination:模型生成与事实、输入或证据不一致的内容;应进一步细分错误类型。
  • Hybrid Retrieval:组合稀疏与稠密检索,并用 RRF 等方法融合排名。
  • Inference:模型在固定权重下根据输入产生预测或生成结果的过程。
  • Instruction Tuning:使用指令—回答或多轮消息数据训练模型遵循任务。
  • KV Cache:生成时缓存历史 token 在各层的 K/V,避免重复计算。
  • KL Divergence:衡量一个概率分布相对另一个分布差异的非对称量。
  • Label Mask:用 -100 等忽略值让某些 token 不参与语言建模损失。
  • LayerNorm:通常在最后一维做中心化与方差归一化,再施加可学习仿射变换。
  • LoRA:用低秩矩阵表示权重增量的参数高效微调方法。
  • LRU Cache:淘汰最久未使用条目的缓存策略。
  • MCP:模型上下文协议,用统一 client/host/server 架构连接工具、资源等能力。
  • MHA:每个查询头具有独立 K/V 头的多头注意力。
  • MLA:多头潜在注意力,通过低维潜变量等设计压缩表示与缓存;具体含义依模型实现。
  • MoE:混合专家网络,用路由器让 token 稀疏激活少数专家。
  • MQA:所有查询头共享一组 K/V 的注意力变体。
  • nDCG:考虑相关性等级和排名位置的检索指标。
  • P50/P95/P99:延迟分布的分位数,比单一平均值更能反映长尾。
  • PagedAttention:用分页式物理块管理逻辑 KV Cache 的机制。
  • Parameter Efficient Fine-Tuning:只训练少量参数或适配器的微调方法族。
  • Perplexity:交叉熵的指数形式;跨 tokenizer 比较需要谨慎。
  • Pipeline Parallel:把不同模型层放在不同设备并用 microbatch 流水执行。
  • PPO:使用概率比裁剪等机制限制策略更新幅度的在线强化学习算法。
  • Prefill:对完整提示并行计算、建立 KV Cache 并产生首 token 的阶段。
  • Prompt:提供给模型的文本或多模态输入;系统可靠性不能只依赖提示约束。
  • QLoRA:在量化冻结基础权重上训练 LoRA 适配器的高效微调路线。
  • Quantization:用较少比特表示权重或激活,并配合尺度与低比特 kernel。
  • RAG:先检索外部证据,再将证据作为上下文用于生成的系统范式。
  • Recall@K:前 K 个结果是否覆盖相关文档的召回指标。
  • Reduce-Scatter:先归约,再把结果分片给不同 rank。
  • Reranker:对召回候选进行更精细相关性排序的模型或规则。
  • Reward Hacking:策略利用奖励函数漏洞取得高分,却没有实现真实目标。
  • RMSNorm:不减均值、按均方根控制隐藏向量尺度的归一化。
  • RoPE:把位置相关旋转作用于 Q/K,使点积包含相对位置信息。
  • RRF:只利用名次、无需统一原始分数尺度的倒数排名融合。
  • SFT:有监督微调,使用目标输出 token 的交叉熵训练模型行为。
  • Softmax:把 logits 归一化为概率;实现时通常先减最大值保证稳定。
  • Speculative Decoding:小模型提议多个 token,由目标模型并行验证以减少串行解码步数。
  • SwiGLU:使用 SiLU 门控分支的前馈网络结构。
  • Tensor Parallel:把单层矩阵运算切分到多个设备。
  • Token:tokenizer 输出的基本离散单位,不一定等于字符或单词。
  • Tokenizer:把文本与 token id 相互转换的规则、词表和特殊 token 集合。
  • TPOT:Time Per Output Token,持续生成阶段每个输出 token 的时间。
  • TTFT:Time To First Token,从请求开始到首 token 返回的时间。
  • Vector Database:提供向量索引、近邻搜索和元数据过滤的存储系统。
  • ViT:把图像切成 patch token 并使用 Transformer 编码的视觉架构。
  • Workflow:由程序预定义执行路径的流程,与运行时自主选择步骤的 Agent 区分。
  • ZeRO:通过分片优化器、梯度和参数降低数据并行冗余的技术族。

附录 D 练习、复盘与项目验收方法

本书不按时间表组织,也不要求固定学习速度。学习进度应由“能否独立完成验收”决定,而不是阅读页数。下面给出一套贯穿十四章的复盘方法。

D.1 五层掌握标准

  1. 能定义:不用术语循环解释术语,能说出对象解决的问题。
  2. 能画图:能画出输入、关键状态、输出和数据流。
  3. 能计算:能写形状、关键公式或复杂度数量级。
  4. 能实现:能完成最小 Python 代码,并通过边界测试。
  5. 能评估:能说明什么证据表明实现正确、何时会失败、怎样比较替代方案。

只有第一层通常只能应付选择题,无法完成项目或技术面试。每章至少选择两个主题做到第五层。

D.2 实验报告模板

MARKDOWN
# 实验名称
 
## 问题与假设
- 想验证什么?
- 为什么预期如此?
 
## 数据与环境
- 数据来源、许可、切分与版本
- Python/库/硬件/随机种子
 
## 方法
- 基线
- 本次唯一主要改动
- 指标及定义
 
## 结果
- 数值、曲线、耗时、内存或显存
- 代表性成功与失败样例
 
## 分析
- 证据直接支持什么?
- 哪些只是推断?
- 还有哪些替代解释?
 
## 回归测试与下一步

D.3 怎样设计练习

优先设计能失败的练习。比如注意力练习不只是“输出形状正确”,还应检查未来位置权重为零;RAG 不只是“能回答”,还要把目标证据从索引中移除,观察系统是否错误地凭参数记忆作答;Agent 不只是“调用工具”,还要注入未知工具、超时、空结果和权限拒绝。

每个核心函数至少覆盖:正常输入、最小/空边界、非法形状或类型、极端数值、固定随机性。涉及外部服务时,还要覆盖超时、限流、部分返回和重试幂等。

D.4 怎样读论文

第一次读论文先回答五个问题:研究问题是什么;以前方法哪里不足;作者做了什么;实验怎样支持结论;限制是什么。第二次再进入公式与实现,第三次用代码或小实验验证一项关键主张。

模型技术报告还要区分已公开事实、合理推断和未公开信息。训练数据、系统优化和评测设置常不能完全复现,不应把猜测写成确定结论。

D.5 怎样使用开源仓库

先读 README、许可证和目录,不要直接执行安装脚本。选择一个最小入口,固定 commit,查看配置和测试。运行成功后改变一个变量并预测结果;如果只能运行默认 Notebook,而不知道输入输出和失败边界,还没有真正掌握。

引用开源代码时遵守许可证和署名要求。正文可用自己的语言总结概念并链接原始来源,不要大段复制教程或书籍文本。

D.6 贯穿全书的毕业项目

建议最终完成一个“有评估的知识助手”,而不是只做聊天界面。最低要求:

  • 数据摄取支持至少两种文档格式,保留来源、页码、版本和权限。
  • 建立 BM25 基线、向量检索和混合检索,使用固定标注集比较。
  • 回答返回引用;程序验证引用 id 与实际上下文一致。
  • 服务记录 TTFT、总延迟、检索耗时、token 与错误类型。
  • 至少实现一个只读工具;高风险工具必须经过权限与确认。
  • 保存三类 bad case 的定位过程与回归测试。
  • README 提供架构图、运行命令、数据说明、指标定义、结果与限制。

资源有限时可使用小数据、模拟模型或公开 API,但必须诚实说明哪些环节没有在本地训练或部署。项目的可信度来自证据完整,而不是组件数量。

D.7 最终自检

  • 能否在白纸上画出从文本到 token、Transformer、logits 和生成的完整路径?
  • 能否解释 RAG 的错误来自解析、召回、重排还是生成,并给出相应指标?
  • 能否说明 LoRA/QLoRA 节省哪些状态,又没有节省哪些激活?
  • 能否比较 DPO、PPO、GRPO 的数据与训练要求,而不说绝对优劣?
  • 能否估算一个模型权重、KV Cache 和训练状态的数量级?
  • 能否为 Agent 写出工具 Schema、权限、停止和失败测试?
  • 能否用三分钟讲清一个项目的约束、个人决策、指标和 bad case?

若其中某项只能给出名词列表,就回到对应章节完成最小实验。真正的学习闭环是:发现说不清的地方,把它缩小成可运行问题,再用证据修正自己的理解。

全套教材、示例代码、参考资料全部开源免费,仓库内同时提供 Word 完整版文档,离线学习也很方便。

如果这份资料解决了你大模型学习碎片化、找不到完整学习路线的痛点,欢迎去 GitHub 仓库点 Star,你的支持是作者持续更新内容的动力!

仓库地址:https://github.com/wei1305/llm-engineer-from-zero

在线阅读:https://wei1305.github.io/llm-engineer-from-zero/

更多推荐