为什么我开始从第一性原理手写 LLM 代码

本文目录

English · 中文 · Français

写作说明:本文的动机与项目来自我本人;ChatGPT 协助了草稿、编辑、翻译,以及原创示意图和动画源码的制作。

在 coding agent 时代,重新亲手写代码

在找工作的过程中,我意识到自己的日常工作悄悄发生了一种变化:我每天都在使用 LLM。有了 coding agent 的帮助,自己写的代码却越来越少了。

这当然方便。但我也有一种恐惧,或者说很深的不安。我花了多年学习数学、机器学习和如何构建 AI 系统。现在,只需要把任务交给 agent,往往就能很快得到可以运行的代码,甚至还没来得及认真想清楚代码背后的原因。要是一直这样下去,我自己的理解能力会发生什么变化?

我并不认为,只要手写代码就一定是更好的研究者或工程师;这也不是对 coding agent 的否定。让我担心的是更私人的事情:我不希望自己越来越依赖这些工具,却逐渐失去追问其实现和设计选择的习惯。

LLM from First Principles 就是从这种不安开始的。

我想重新亲手写代码

最初的想法很简单:把每天使用的语言模型背后的机制重新拆开,弄懂为什么要这样设计,然后亲手实现。这个过程也可以让我复习以前学过的知识,包括博士阶段接触过、如今已经熟悉到不再仔细思考的一些概念。

我不想只满足于认得一个公式,或者觉得一段解释听起来合理。我希望在运行下一个 cell 之前,就能预测 tensor 的 shape;能解释为什么梯度会传到某个参数,却不会传到另一个;如果删掉一个操作,我应该知道会改变什么。

在这里,from first principles 意味着从实现回到它成立的理由:数学恒等式、假设、建模选择和工程约束。我不是说现代 LLM 的每一个设计都能由几个公理唯一地推导出来。很多时候,真正值得理解的是:为什么选择这种实现,又放弃了哪些其他可能。

慢慢地,我形成了一套学习方式:提出问题、推导机制、手写实现、检查行为,再试着解释清楚。检查的结果有时会把我重新带回最初的问题。

学习过程的示意动画,并非模型训练过程的实际录像。动画使用 ManimGX 制作。

从 tensor 到 post-training

最终,这个 repository 有了 28 个 notebook 和一组可以复用的 PyTorch 组件。学习路线从 tensor、broadcasting 和自动微分开始,逐步搭建 decoder-only GPT,接着学习 pretraining 和 post-training。

前面的 notebook 主要练习 autograd、tokenization、optimization 和 training loop;中间逐步实现 attention、位置信息、tiny GPT、文本生成、KV cache、linear attention、pretraining 和 MoE。后面的部分从 RL 基础和 PPO 出发,继续连接 SFT、偏好学习、DPO 与 group-relative RL。

Notebook 编号从 00 到 26,PPO 则分为 21A 和 21B。它们保留了推导、小实验和运行时保存的观察结果。理解后抽取出的可复用部分放在 src/llmfp/,另有一个独立例子演示这些部件如何连接。这首先是一个学习项目,不是生产级的 LLM 框架。

真正实现这些机制时,我发现许多细节无法仅靠记住算法名字解决。以 SFT 为例,prompt 是上下文,但 loss 可能只计算 response 的 token。对应的 label 必须在正确的位置 shift 和 mask。如果不小心 shift 两次,程序依然可能运行,但优化的 target 已经错了。

PPO 也让我重新注意到一个容易混淆的区别:采样时记录概率的 old policy,与 KL regularization 使用的 reference policy,职责并不相同。它们在代码里都可能表现为固定的 log-probability,却不能因此当成一个东西。亲手写接口和做检查,迫使我认真对待这个区别。

这些并不是什么惊人的发现。恰恰是这些原本容易被跳过的细节,我想重新掌握。

当 loss 降低,回答却没有变好

第 25 课的 DPO 实验 很能代表我想保留的学习过程。

在 notebook 保存的一次运行中,tiny GPT 使用三组成对的 synthetic preference data 进行更新。DPO training loss 从约 0.693 降到了 0.015。但使用 greedy decoding 检查六个玩具指令时,训练前正确生成 2/6,训练后仍然是 2/6。

其中一个指令是 Say no。DPO 之前,模型回答 Yes;之后变成 Thanks。但我们想要的是 No。

这个训练样本的 preference margin 确实改善了。然而,相对于 reference model 提高一对完整回答的得分差,和在逐 token 生成时真正输出我们想要的句子,是两个问题。Loss 说明模型在给定训练样本上更好地拟合了 preference objective;生成行为仍然需要单独检查。

这是一个极小的教学实验的保存结果,不是 benchmark,更不能据此评价 DPO 在大规模场景中的效果。我愿意把这种不理想的结果留下来,因为一次失败的行为检查,有时比一条漂亮的 loss 曲线更能帮助理解。

在 ChatGPT 的帮助下学习,却不让它替我完成练习

这件事本身有一点矛盾:我在试图减少自己对 LLM 生成代码的盲目依赖,却又在整个过程中大量使用 ChatGPT。

我把它当作 tutor。和它讨论推导、质疑解释、比较实现方案,也通过 code review 检查自己的思路。但在 notebook 的学习练习中,我关闭了 AI 代码补全。每段学习实现都是我思考之后,亲手输入的。

这条界限有明确的范围。它不代表仓库维护、可复用代码抽取或集成例子从来没有 AI 参与,也不意味着理解了一个操作之后还要永远手写,不使用成熟的 PyTorch API。弄懂一个机制之后,完全可以使用可靠的现有实现,把注意力放到下一个问题上。

我希望遵守的一条规则是:

如果我还解释不清楚某一行代码,它就暂时不应该出现在我的学习练习里。

AI 能让我很快得到答案。我希望自己仍然完成另一件事:判断我究竟有没有理解这个答案。

为什么把它分享出来

项目源码在 github.com/neuromaancer/llm-from-first-principles。如果想先看完整流程怎么跑起来,而不是从第一个 notebook 开始,仓库提供了一个 CPU 小例子:使用 synthetic data,不需要下载 checkpoint,也不需要 API key。

git clone https://github.com/neuromaancer/llm-from-first-principles.git
cd llm-from-first-principles
uv sync
uv run python examples/tiny_sft_dpo.py

它从随机初始化的 GPT 开始,对 preferred responses 做一次监督训练,冻结 reference,再进行 DPO 更新并展示生成结果。这只是帮助理解模块如何衔接的练习,不能用训练数据上的拟合来证明泛化能力。

我分享这个 repository,是因为我猜不止我一个人有过这种不安。我们每天用语言模型写作、研究、编程,往往无需知道内部发生了什么。这种便利是真实的。但也有人希望重新理解自己每天使用的工具。我希望这些 notebook 能为他们提供一条可以亲自动手的路径。

这也是我开启 First Principles of LLMs & RL 博客系列的原因。Repository 让我检查自己能否把一个想法实现出来;文章则让我检查自己能否解释它为什么成立。代码能够运行,不等于这两件事都完成了。

我以后还是会使用 coding agent。但我希望自己知道哪些工作交给了它,能够质疑它的选择,也知道什么时候应该把问题拿回来,亲自想明白。