1. 这个项目想解决什么:与其调 API,不如拆一台发动机看看
如果你翻过我的仓库,会发现ai-engineering-from-scratch不是那种"三天上手大模型"的教学 Demo,也不是一个包装华丽的工具集。它更像是一份我自己的学习工程笔记:假设你面前没有 OpenAI、没有 HuggingFace、没有 LangChain,只剩 Python 和 numpy,你能不能徒手搭出一个能跑、能训练、能推理的 AI 系统?
为什么要做这件事?原因挺现实的。过去两年我大量使用现成的大模型 API 做应用,越用越心虚。模型回答错了,我不知道该怪数据、怪训练、怪提示词还是怪温度参数;想微调一个小模型,却连 loss 曲线都读不太懂。你可以在 API 层面工作很久,但一旦遇到幻觉、重复生成、上下文窗口溢出这类工程问题,没有底层理解,排查全靠撞运气。这就好比你会踩油门,但发动机灯亮了完全不知道哪里出问题。
所以这个项目的思路很直白:每一层都自己造。Tokenizer 不调tiktoken,模型不用transformers,优化器不碰torch.optim。所有组件的代码量控制在能一屏看完的程度,但逻辑必须完整。目标读者我觉得有两类人:一类是刚入门 AI 工程、想知道"大模型到底怎么从 0 变成会说话"的同学;另一类是已经用 API 做了一堆应用、想补底层课、提升排查能力的开发者。
仓库的整体结构我按"从数据到智能"的路径来组织,每一课都对应一个可运行的脚本。你在 README 里能看到训练好的字符级模型是怎么一步步从"输出乱码"变成"输出像模像样的句子",这种从无到有的正反馈,比任何原理图都更有说服力。
2. 第一块积木:不调 Tokenizer 库,徒手实现 Byte Pair Encoding
很多人学大模型,第一个拦路虎其实是输入层。模型不认识字符串,它只认数字。你当然可以用最简单的方式——每个字符映射一个整数,但这样做出的模型泛化能力很差,遇到训练集里没出现的词就是个死局。真实大模型用的都是子词切分,其中 Byte Pair Encoding(BPE)是最经典的一种。
2.1 BPE 到底在做什么
想象你有一堆英文单词要编码。BPE 的思路是:先按单个字符拆开,然后统计相邻字符对的出现频率,把最高频的一对合并成一个新符号,反复执行这个合并过程,直到符号表达到预定大小。这样做的好处是,常见词比如 "the" 很快就变成单独一个 token,不常见的词则退化成多个子词 token,模型永远有办法表示没见过的新词。
我在项目里实现的核心逻辑很朴素:
def get_stats(ids): counts = {} for pair in zip(ids, ids[1:]): counts[pair] = counts.get(pair, 0) + 1 return counts def merge(ids, pair, idx): newids = [] i = 0 while i < len(ids): if i < len(ids) - 1 and ids[i] == pair[0] and ids[i+1] == pair[1]: newids.append(idx) i += 2 else: newids.append(ids[i]) i += 1 return newidsget_stats统计相邻 token 对的频率,merge把最高频的那一对替换成一个新的 token 编号。训练过程中重复这两个操作,vocab里记录每次合并产生的符号,推理时按同样的合并规则把文本编码成 token 序列。
2.2 实现时最容易被忽略的两个细节
第一个坑是编码和解码必须严格对称。很多人只关注编码端怎么合成新 token,忘了解码端的规则表要一次不漏地保存。我在项目里把合并规则按顺序存在merges字典里,从bytes到 token 是可逆的。第二个坑是原始语料必须按 UTF-8 先编码成字节,再在字节层面做 BPE。否则中文这种多字节字符会被拆得乱七八糟,或者干脆因为字符种类太多导致词表爆炸。这一点在真实大模型里也是通用做法,早期 GPT-2 就是这么处理的。
BPE 的实际效果很直观:同样一段文本,字符级编码可能有上千个 token,BPE 能压到两三百个,sequence 变短意味着训练时注意力计算量显著下降,也能捕获更多语言规律。我在仓库里放了一个可视化脚本,把同一个句子在不同词表大小下的切分结果打印出来,对比效果非常明显。
3. 最难的一课:手写反向传播,把线代落成代码
模型输入搞定了,接下来是神经网络本体。很多人用 PyTorch 久了,写nn.Linear、nn.CrossEntropyLoss都是一行事,但真要问 y = xW + b 的梯度怎么回传,反而卡壳。ai-engineering-from-scratch的核心主张就是:你至少要有能力不用自动微分框架,手推一个全连接网络的训练。
3.1 一个干净的 MLP 反向传播实现
我在项目里写了一个两层的 MLP,不依赖任何深度学习框架,纯 numpy 实现前向和反向。核心代码是这样:
class TwoLayerNet: def __init__(self, input_size, hidden_size, output_size): self.W1 = np.random.randn(input_size, hidden_size) * 0.01 self.b1 = np.zeros(hidden_size) self.W2 = np.random.randn(hidden_size, output_size) * 0.01 self.b2 = np.zeros(output_size) def forward(self, x): self.z1 = x @ self.W1 + self.b1 self.a1 = np.tanh(self.z1) self.z2 = self.a1 @ self.W2 + self.b2 return self.z2 def backward(self, x, y, output, loss_grad): dloss = loss_grad dW2 = self.a1.T @ dloss db2 = dloss.sum(axis=0) da1 = dloss @ self.W2.T dz1 = da1 * (1 - self.a1 ** 2) dW1 = x.T @ dz1 db1 = dz1.sum(axis=0) return dW1, db1, dW2, db2这段代码看起来简单,但每一步都对应链式法则的某一环。dz1 = da1 * (1 - self.a1 ** 2)就是 tanh 激活函数的导数。我建议你在本地手推一遍梯度公式,再看这段代码,会有种"哦原来自动微分干的就是这事"的通透感。
3.2 为什么维度经常搞错
反向传播最容易出错的地方是矩阵乘法的维度对齐。我的经验口诀是:任何一层权重的梯度,形状必须和这层权重完全一致。如果算出来不一致,十有八九是某个转置写错了。比如dW2的公式是self.a1.T @ dloss,因为前向是a1 @ W2,反向时输入和梯度要交换位置并转置。检查维度还有一个笨办法:在每步反向计算后打印.shape,和前向的对应张量比对一遍,这种防御性编程在调试期特别值。
4. 从 MLP 到最小 Transformer:我终于看懂了注意力层
MLP 是热身,真正的重头戏是把 transformer 从零搭出来。这部分我参考了 Andrej Karpathy 的教学思路,结合自己对工程细节的理解做了一套极简实现。Transformer 的优点大家都听过:并行计算、长距离依赖建模。但只有自己写一遍注意力矩阵,你才会真正明白它为什么能 work。
4.1 自注意力机制的最小实现
自注意力的公式是 Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V。直接看公式会有点懵,我的理解方式是这样的:每个 token 都发出一个查询(Q),想知道自己该关注谁;同时每个 token 都亮出一个键(K),告诉别人我是什么;最后每个 token 还带着值(V),是真正要传递的信息。Q 和 K 做点积,就是在衡量"我和你有多少关系",除以 sqrt(d_k) 是防止点积数值过大把 softmax 推成极端分布。最后用这个权重去加权求和所有 V,得到新的表示。
代码实现核心也就十几行:
def self_attention(x, Wq, Wk, Wv, Wo): B, T, C = x.shape q = x @ Wq # (B, T, head_dim) k = x @ Wk v = x @ Wv attn_weights = q @ k.transpose(-2, -1) / np.sqrt(head_dim) attn_weights = np.tril(attn_weights) # 因果掩码 attn_weights = softmax(attn_weights, axis=-1) out = attn_weights @ v return out @ Wonp.tril是关键细节:语言模型只能看左侧的 token,所以注意力矩阵的上三角要全部屏蔽成负无穷,softmax 之后这些位置的概率就会变成 0。很多人实现时漏了这个掩码,模型训练时偷看未来 token,loss 看起来正常但生成效果一塌糊涂。
4.2 多头注意力:其实只是个 reshape 技巧
多头注意力听起来高级,本质就是把head_dim拆成几份,分别做注意力再拼接。我在仓库里用 numpy 实现时,直接 reshape 成(B, num_heads, T, head_dim),每个头独立计算,最后再拼接回原维度。这一步请务必注意:拼接后要经过一个输出投影层,才能让不同头的信息有效融合。只拼接不投影,多头就退化成单头了。
5. 训练一个会说话的小模型:损失曲线会告诉你一切
模型搭好了,接下来是最磨人的训练阶段。我的仓库里用莎士比亚文集做语料,训练一个字符级的小 transformer,参数量大约 300 万。这个规模在 GPU 上几分钟能跑完一轮,在 CPU 上也能忍,非常适合学习迭代。
5.1 训练循环里最容易被低估的小事
一个看似不起眼但至关重要的细节是 batch 采样。很多人直接按顺序把文本切成固定长度片段,这样相邻 batch 的数据高度相关,模型会记住"顺序"而不是学会"语言"。我通常的做法是把文档随机切成长度为 block_size 的块,再用随机 shuffle 抽 batch。PyTorch 的DataLoader里有shuffle=True,但如果你是自己写循环,非常容易忘了这一步。
第二个细节是梯度累积。小模型显存有限,batch size 可能只有 8 或 16,训练很不稳定。工程上常见的做法是小 batch 算完梯度后不马上更新,累积几个 batch 再一起更新权重。我在项目里实现了一个简单的梯度累积:
optimizer.zero_grad() for micro_step in range(grad_accum_steps): logits, loss = model(X_batch, Y_batch) loss = loss / grad_accum_steps loss.backward() optimizer.step()5.2 我踩过的学习率深坑
Loss 不降的时候,我第一反应是调高学习率,结果损失震荡,模型直接发散。后来养成看曲线的习惯就明白了:学习率太低 loss 下降平缓,太高 loss 上下剧烈跳。我自己摸索出来的有效策略是带 warmup 的余弦衰减——前几百步让学习率从极小值线性爬升到峰值,后面按余弦曲线衰减到接近 0。这么做的直觉是:训练刚开始权重初始化完全随机,太激进的学习率会把模型推到奇怪的损失地形里,warmup 给了模型一个"先站稳再跑"的机会。
我在仓库里记录了完整的训练日志,loss 从最初的 4.2 左右逐步降到 1.5 附近。你会发现一个规律:前几千步 loss 下降飞快,后面变得很慢,这是正常的,因为模型先学会的是高频语法模式,剩余的是低频语义细节,收敛速度自然放缓。
6. 从哑模型到能用:解码策略和提示词工程一起上
训练出来的模型不会自动好用,还得靠推理端的解码策略。我在项目里实现了三种经典采样方法:贪心解码、温度采样、top-k 采样。它们的区别直接决定了生成文本的质量。
- 贪心解码:每一步选概率最高的 token,输出稳定但容易陷入重复语句。
- 温度采样:把 logits 除以温度 T。T 越小分布越尖锐,文本越保守;T 越大分布越平坦,文本越有创造性。常用范围是 0.7 到 1.2。
- Top-k 采样:只从概率最高的 k 个 token 里采样,避免把极小概率的垃圾 token 选出来。
建议你把三种策略都跑一遍,用同一个 prompt 生成 100 个字符,马上能感受到差异。实际产品里通常组合着用:top-k + 温度调参,既保证多样性又避免失控。
提示词工程看起来和模型训练是两个世界,但这个项目里我用一个例子把它们串起来了。比如你想让模型扮演一个"毒舌评论家",直接在 prompt 里写"你是一个毒舌评论家",效果远不如给一个完整的格式示例。我在仓库里放了一套模板化 prompt 的写法,核心原则是:给模型展示你期望的输出结构,而不是抽象描述。
任务:给下面这句话写一条毒舌评论 要求:每条约 20 字,带一个讽刺比喻 示例: 输入——"我今天早起跑了五公里。" 输出——"太阳都还没准备好上班,你倒是先卷起来了。" 输入——"{user_input}" 输出——这套方法在 API 时代照样适用,但是理解它生成机制的人更容易写出好模板。提示工程不是玄学,本质是你和概率分布的一场对话。
7. Agent 和工具调用:AI 工程的最后一公里
模型能生成文本不等于能解决问题。真正让 AI 变得有用的是 Agent——让模型能调用工具、能看工具返回结果、能根据反馈决定下一步行动。这是我在ai-engineering-from-scratch里新增的一个模块,实现了一个极简的 ReAct 式循环:思考、行动、观察交替进行。
7.1 Agent 循环的最小骨架
我实现的方式很直观:模型输出一个 JSON 结构,里面包含thought和action字段。程序解析出 action 后执行对应的工具函数,再把结果塞回 prompt,让模型继续思考。核心循环如下:
tools = { "search": search_function, "calculate": calculate_function, } def run_agent(query, max_steps=5): messages = [{"role": "user", "content": query}] for _ in range(max_steps): response = llm_complete(messages) action = parse_json(response) if action.get("type") == "final_answer": return action["content"] tool_result = tools[action["name"]](action["args"]) messages.append({"role": "assistant", "content": response}) messages.append({"role": "tool", "content": str(tool_result)}) return "达到最大步数"这个实现的难点在于模型输出格式的稳定性。模型经常把 JSON 写成多行、加注释、或者少个引号。我的解决办法是在 prompt 里给一个严格的 JSON 示例,如果解析失败就把错误信息反馈给模型,让它重新输出。工程上这叫自修正循环,虽然简单,但真的能大幅提高成功率。
7.2 防呆设计不能少
Agent 一旦跑起来,你要面对的不只是模型能力问题,还有失控风险。我在项目里至少做了三层防护:第一,最大循环次数必须硬性限制;第二,工具调用参数必须强制类型校验,防止calculate("abc")这种输入让程序崩溃;第三,所有工具执行要加 try-except,一个工具挂了不能把整个 agent 拖死。这些都是在真实生产环境里被坑过之后总结出来的,不要等出事了再补。
8. 从头搭完整个项目之后,我留下的几条工程经验
这个项目写到这里,主体内容已经完成,但我想额外多聊几句实际操作层面的体会,因为光有代码是不够的。
第一,永远要有一个"基线实现"用于对照排查。我在手写反向传播时,就留了一份 PyTorch 版本的参考实现,同样的数据、同样的超参数,两边 loss 曲线对照着看。梯度数值不一致,立刻知道是自己反向传播哪里写错了。没有这个参照系,你会在茫茫 bug 里找不到方向。
第二,小规模验证一切。transformer 这种模型最烦人的是:代码有问题时不一定报错,只是 loss 降得慢或者生成结果奇怪。我的习惯是先在极小的数据集上跑通——比如语料只取 5000 个字符,block_size 取 32,模型层数减到 2。如果小规模能过拟合(loss 接近 0),说明模型实现没问题,再放大规模。如果小规模都学不动,果断回去查代码。
第三,时长评估必须提前做。别用完整数据集做实验。先用 1% 的数据跑一遍,看一个 batch 需要多少秒,估算出完整训练大概几小时,再决定要不要加 batch size、上不上 GPU。我一开始没做这个估算,三个月前第一次跑训练直接开了满配数据集,结果睡醒发现才跑了 2%,白白烧了好多时间。
第四,数据质量永远值得花时间。我从莎翁文集换到代码语料后,模型效果明显变化。同样规模的参数量,训练出来的分布式完全不一样。所以说到底,AI 工程不只是模型结构的事,数据工程占了一半比重。你要是想深入研究,不妨把仓库里的数据管道改成爬自己领域的内容,你会对"垃圾进垃圾出"这句话有深刻的真实感受。
这个项目我还会继续迭代,下一步打算加入多模态输入的基础支持,以及更系统的评测模块。最后说一句掏心窝的话:从零实现一个 AI 系统不会让你立刻成为专家,但它能让你在遇到问题时有底气问出正确的问题。在这个以 API 调用为常态的时代,这种"知道引擎如何工作"的能力,反而越来越稀缺了。