全部代码不到 200 行,零依赖,跑在浏览器里。loss 从 3.69 掉到 0.70 用了不到 1 秒,之后它能一个字一个字给你补出合法代码------也能一本正经地补出语法正确、语义错误的代码 。
练完这一个,你会对三件事有手感:它为什么会胡说、为什么记不住你说的话、为什么同一个问题两次答案不一样。
TL;DR(赶时间的话,看这七条就够)
- 大模型只做一件事:猜下一个 token。 一个字一个字往外接,接出去就收不回来------它没有全文草稿。
- 全部机制四步:查表(词 → 坐标)→ 池化(前缀 → 一个向量)→ 点积(和词表里每个词比)→ softmax(变成概率)。没有第五步。
- 训练里没有规则,只有"猜错了就往回收一点":交叉熵梯度 + Adam。参数是 792 个浮点数,知识是"这些数停在哪个位置"这件事本身。
- 我把它缩到 40 个词 / 166 条样本 / 792 个参数 ,浏览器里 1 秒练完。它学会补全 JavaScript,也会补出语法正确、语义错误的代码。
- 三个实测结论:学习率 0.12 就发散(loss 比没训练还差);d=2 学不动、d=16 不再变好;注意力在小数据上不一定赢得了求平均。
- 它胡说,是因为它只被"像不像"打分过,从没被"对不对"打分过。它记不住,是因为上下文就是一次数组切片。
- 我踩的最贵的一个坑:Adam 会掩盖梯度 bug------梯度写错了 loss 照样掉。抓出它只要 20 行代码,见 §11。
本文目录
- 词表:模型眼里没有字符串,只有下标
- 嵌入:
Map<string, number[]>,语义 = 坐标 - 前向传播:一次池化 + 一次点积 + 一次 softmax
- 训练:交叉熵 + 解析梯度 + Adam(三个旋钮)
- 给它跑单测:它到底学会了什么
- 注意力:我第一版为什么是错的
- 生成与温度:为什么两次答案不一样
- 幻觉:它练的是"像",从来不是"对"
- 上下文窗口:它没有"记得你"这回事
- 把 792 换成 80 亿
- 我踩的坑(含那段抓出 bug 的梯度校验代码)
- 全部代码与复现

0. 为什么要自己练一个
关于大模型原理的文章,绝大多数停在比喻上:"像大脑""像图书馆""像接龙"。比喻帮你入门,也帮你停在门口------因为你始终不知道那些东西具体是什么。
所以我把它缩到不能再小:40 个词、166 条训练样本、792 个参数。小到你今天就能读完全部源码,小到你能亲手改超参看它变好变坏。
先给结论,后面全是在证明这句话:
大模型没有第二种数学。 就是查表、点积、softmax,加一个 while 循环。几十亿参数的模型和这 792 个参数之间,只差规模。
1. 词表:模型眼里没有字符串,只有下标
语料是 20 行短 JS。为什么用代码不用中文?因为你对代码对不对一眼就有判断,不需要我替你解释。
js
const CORPUS = [
'const x = 1 ;', 'const y = 1 ;', 'const z = 1 ;',
'let a = 2 ;', 'let b = 2 ;', 'let c = 2 ;',
'const f = ( ) => 1 ;', 'const h = ( ) => 1 ;', 'let g = ( ) => 2 ;', 'let k = ( ) => 2 ;',
'function add ( a , b ) { return a ; }', 'function sub ( a , b ) { return b ; }',
'function mul ( a , b ) { return a ; }',
'async function run ( ) { await go ( ) ; }', 'async function load ( ) { await get ( ) ; }',
'async function pull ( ) { await net ( ) ; }',
'if ( x ) { return 1 ; }', 'if ( a ) { return 2 ; }',
'console . log ( x ) ;', 'console . log ( 1 ) ;'
];
const tokenize = line => line.trim().split(/\s+/);
function buildVocab(corpus) {
const set = new Set(['<s>']); // <s> = 一行的开头
corpus.forEach(l => tokenize(l).forEach(t => set.add(t)));
const words = [...set];
return { words, id: Object.fromEntries(words.map((w, i) => [w, i])) };
}
切完是 40 项词表 。从这一刻起,模型内部再没有"字符串"这个概念,只有 0..39 的下标。const 是 1,await 是 30------跟你把类名映射成 CSS 变量是一个意思:下标只是门牌号,不含任何意义。7 号不比 6 号大。

2. 嵌入:Map<string, number[]>,语义 = 坐标
门牌号没法计算,所以给每个词发一组坐标。这就是"词向量",没有更神秘的解释。
js
const MAXP = 14; // 位置数:最长一行 12 个 token,加 <s> 留一格余量
function makeModel(V, d, rnd) {
const p = {
E: new Float64Array(V * d), // 输入嵌入:词 → 坐标
W: new Float64Array(V * d), // 输出嵌入:每个词作为"答案"时的坐标
P: new Float64Array(MAXP * d), // 位置嵌入
b: new Float64Array(V), // 输出偏置(先验频率)
};
const s = 1.2 / Math.sqrt(d);
for (const k of ['E', 'W', 'P'])
for (let i = 0; i < p[k].length; i++) p[k][i] = rnd() * 2 * s - s;
return { V, d, useAttn: true, p, m: new Map(), v: new Map(), t: 0 };
}
d = 8 时参数量是 40×8×2 + 14×8 + 40 = 792。这就是全部可训练的东西:792 个浮点数。
现在它们是随机数。有个很实用的自检:随机初始化时 loss 应该等于 ln(词表大小),也就是"完全均匀地瞎猜"的理论值。
| 理论 | 我实测 | |
|---|---|---|
| 初始 loss | ln(40) = 3.689 |
3.690 |
对不上,就说明代码错了。这个检查花 5 秒,能救你一晚上。

3. 前向传播:一次池化 + 一次点积 + 一次 softmax
给它前缀 const x,问下一个 token 是什么。三步:
js
/* 把前缀池化成一个向量 x̄ */
function pool(m, ctx) {
const { d, p } = m, n = ctx.length, xb = new Float64Array(d), e = [];
for (let i = 0; i < n; i++) { // e_i = 词嵌入 + 位置嵌入
const t = ctx[i], v = new Float64Array(d);
for (let k = 0; k < d; k++) v[k] = p.E[t * d + k] + p.P[i * d + k];
e.push(v);
}
if (m.useAttn) { // 注意力池化:query 取最后一个 token
const q = e[n - 1], s = new Float64Array(n);
let mx = -Infinity, Z = 0, al;
for (let i = 0; i < n; i++) {
let a = 0; for (let k = 0; k < d; k++) a += e[i][k] * q[k];
s[i] = a / Math.sqrt(d); if (s[i] > mx) mx = s[i];
}
al = new Float64Array(n);
for (let i = 0; i < n; i++) { al[i] = Math.exp(s[i] - mx); Z += al[i]; }
for (let i = 0; i < n; i++) al[i] /= Z;
for (let i = 0; i < n; i++) for (let k = 0; k < d; k++) xb[k] += al[i] * e[i][k];
m._al = al;
} else { // mean 池化:一视同仁地平均
for (let i = 0; i < n; i++) for (let k = 0; k < d; k++) xb[k] += e[i][k] / n;
m._al = null;
}
m._xb = xb; return xb;
}
/* 和每个词的输出向量点积 → softmax → 概率 */
function forward(m, ctx) {
const xb = pool(m, ctx), { d, p, V } = m;
const logits = new Float64Array(V); let mx = -Infinity, Z = 0;
for (let j = 0; j < V; j++) {
let a = 0; for (let k = 0; k < d; k++) a += xb[k] * p.W[j * d + k];
logits[j] = a / Math.sqrt(d) + p.b[j];
if (logits[j] > mx) mx = logits[j];
}
const pr = new Float64Array(V);
for (let j = 0; j < V; j++) { pr[j] = Math.exp(logits[j] - mx); Z += pr[j]; }
for (let j = 0; j < V; j++) pr[j] /= Z;
m._logits = logits; m._pr = pr; return pr;
}
数一数运算量:40 次 d 维点积。没有 if,没有分支,没有查表逻辑。你在页面上看到的每一张概率榜,都是这段代码现算的,每个数字都能拿计算器复核。

4. 训练:交叉熵 + 解析梯度 + Adam
没人给它写任何规则。方法朴素到令人失望:把真句子的下一个 token 告诉它,跟它猜的比一比,错得越离谱,回去改得越多。
js
/* 节选:E/P 的梯度回传部分省略,完整实现见 tiny-llm.html 底部状态栏 → model.js → grads */
function trainStep(m, ctx, tgt, lr) {
const pr = forward(m, ctx), { d, p, V } = m;
const loss = -Math.log(Math.max(pr[tgt], 1e-12));
/* 交叉熵过 softmax 的梯度,干净得可疑:dz = p − onehot */
const gx = new Float64Array(d), gW = new Float64Array(V * d), gb = new Float64Array(V);
for (let j = 0; j < V; j++) {
const z = pr[j] - (j === tgt ? 1 : 0);
gb[j] = z;
for (let k = 0; k < d; k++) {
gW[j * d + k] = z * m._xb[k] / Math.sqrt(d);
gx[k] += z * p.W[j * d + k] / Math.sqrt(d);
}
}
/* ...再把 gx 回传到 E 和 P(mean 是除以 n,attention 多一段 softmax 的导数)*/
m.t += 1;
adamStep(m.p.E, 'E', gE, lr, m.t, m.m, m.v);
adamStep(m.p.W, 'W', gW, lr, m.t, m.m, m.v);
adamStep(m.p.P, 'P', gP, lr, m.t, m.m, m.v);
adamStep(m.p.b, 'b', gb, lr, m.t, m.m, m.v);
return loss;
}
优化器用的是 Adam------大模型用的也是它(换成 AdamW,差别是权重衰减)。
练 1500 步,浏览器里不到 1 秒。三个旋钮,每一个都能烧到你熟悉的经验:
学习率(d=8, 1500 步)
| lr | 0.01 | 0.03 | 0.06 | 0.12 | 0.3 | 0.8 |
|---|---|---|---|---|---|---|
| loss | 0.767 | 0.698 | 0.849 | 3.110 | 5.732 | 10.777 |
0.12 就比"根本没训练"(3.69)还差。步子太大不是快,是散。
维度 d
| d | 2 | 3 | 4 | 8 | 16 |
|---|---|---|---|---|---|
| loss | 1.552 | 1.039 | 0.823 | 0.698 | 0.691 |
| 单测 | 0/8 | 3/8 | 4/8 | 4/8 | 4/8 |
2 维根本学不动,16 维几乎不再变好。表达能力是有预算的,跟 bundle size 一样要换。
步数
| 步数 | 100 | 300 | 600 | 1000 | 1500 |
|---|---|---|---|---|---|
| loss | 2.653 | 1.424 | 0.960 | 0.785 | 0.698 |
它写的 const x → |
= ; ; |
= 2 ; |
= 2 ; |
= ( ) { await net ( ) ; |
同左 |

5. 给它跑单测:它到底学会了什么
loss 是一个笼统的数字。要知道它学到了什么,得按规则出测试 ------这条你最熟。我在语料里埋了两条长程规则:
async开头的行,6 个 token 之后 必须出现awaitconst开头的行以1 ;结尾,let开头的以2 ;结尾
每个用例给它一个真实前缀,看 argmax 出什么、有多自信:
| 规则 | 期望 | attention | mean |
|---|---|---|---|
async function run ( ) { → |
await |
✓ 97% | ✓ 64% |
async function load ( ) { → |
await |
✓ 97% | ✓ 66% |
const x = → |
1 |
✗ 5%(答 () |
✓ 55% |
const z = → |
1 |
✗ 5%(答 () |
✓ 78% |
let a = → |
2 |
✗ 5%(答 () |
✓ 90% |
let c = → |
2 |
✗ 5%(答 () |
✓ 95% |
function → |
函数名 | ✓ 47% | ✓ 45% |
{ → |
return |
✓ 100% | ✓ 71% |
| 总 loss | 0.698 | 0.791 |
注意注意力输了单测却赢了总 loss。 拆开看才清楚:
| 位置 | attention | mean | 谁赢 |
|---|---|---|---|
await(回看 6 个 token) |
0.028 | 0.412 | 注意力赢 14.5 倍 |
1(回看 3 个 token) |
2.165 | 1.175 | 平均反而赢 |
这不是"注意力不行",这是我第一版注意力的写法有问题------见下一节。
6. 注意力:我第一版为什么是错的
看上面那段 pool():我的 query 直接取了最后一个 token:
js
const q = e[n - 1];
s[i] = e[i] · q / √d;
问题立刻出来了:e[n-1] · e[n-1] 天然是最大的分数之一------它永远给自己打最高分 。于是注意力退化成"只盯最后一个 token"。const x = 之后它盯着 =,把语料里 const f = ( ) => 1 ; 那行的记忆搬出来,答了个 (。
这正好解释了真 transformer 为什么需要 W_q 和 W_k:得把"我在找什么"和"我是什么"分开。
我照做了------加上两个 d×d 投影矩阵,反传多写 15 行。结果:
| 模式 | 1500 步 loss(种子 1 / 7 / 23 / 101) |
|---|---|
| mean(求平均) | 0.729 / 0.791 / 0.785 / 0.702 |
| attention(q = k = 嵌入) | 0.690 / 0.698 / 0.683 / 0.705 |
attention + W_q/W_k |
1.207 / 2.039 / 1.800 / 1.781 |
第三行是照真 transformer 补上两个 d×d 投影矩阵的结果:多 128 个参数(+16%),loss 却从 0.7 量级退到 1.2~2.0,四个种子全都不如求平均。 166 条样本喂不饱多出来的那批参数------它需要更多数据去"学会怎么分工",而我没给它。
所以这一节的结论不是"注意力没用",而是:架构变复杂 ≠ 变好,中间隔着一个"数据够不够喂"的门槛。
所以结论不是"注意力更好",而是:
注意力的价值 = 要回看多远 × 有多少数据喂得起它。小任务上,求平均是个很强的基线。这不是大模型的例外,这就是同一件事。

7. 生成与温度:为什么两次答案不一样
生成就是一个 while 循环,全部代码如下:
js
function generate(m, words, startTokens, maxLen, temp, rnd) {
const ctx = startTokens.slice(), out = [];
for (let i = 0; i < maxLen; i++) {
const k = sampleNext(m, ctx, temp, rnd); // 内部就是 forward + 按概率抽样
out.push(words[k]); ctx.push(k); // 刚生成的字接回输入
if (words[k] === ';' && i > 1) break;
}
return out;
}
三个必须记住的事实:
- 纯串行。 生成 500 个 token 就是 500 次完整前向,一次都不能并行。所以它慢、所以它贵、所以有了 KV cache 这些补丁。
- 写出去收不回来。 它没有提纲,不能改稿,只能继续往下猜。
- 温度就是给 logits 除一个数 :
sc = logits / temperature。
同一个模型(d=8,练了 1500 步)、同一起点,只换温度,各接三次:
| 起点(榜首概率) | 温度 0.05 | 温度 0.5 | 温度 2.0 |
|---|---|---|---|
function add ( a , b )({ 99.4%) |
{ return a ; ×3 全同 |
{ return a ; ×3 全同 |
{ c a ; / { return b ; / { return ) ; |
async function load(( 99.5%) |
( ) { await net ( ) ; ×3 全同 |
( x ) ; / ( ) { await get... / ( ) { await go... |
( x b ; / ( ) { await => a return ; / ( ) { run b ) ; |
const x(= 98.4%) |
= ( ) { await net ( ) ; ×3 全同 |
= ( ) { await get... / = ( ) { await go... / = ( ) { await get... |
= 1 ; / = ( ) , 2 ; / 1 ( , b ; |
低温三次完全相同------因为每次都在拿第一名 。温度拉到 2.0,句子开始散架({ return ) ; 这种根本没法运行)。
但这里有个我没想到、值得单独说的现象:const x 这一行,贪心(温度 0.05)三次都给出 = ( ) { await net ( ) ;------这是错的 (const x 后面该接 1 ;);而温度 2.0 的三次里,有一次是 = 1 ;,恰好是正确答案。
所以温度高不只是"更容易错"。贪心会锁死在"最顺"的那条路上,而顺,在这个模型里恰好不等于对。加一点随机,反而有机会跳出那条错误的吸引子。
这也是为什么工程上会看到"同一个 prompt 采样 5 次再挑最好的"(self-consistency)------它治的就是贪心锁死这个病。
这就是你问两次答案不同的全部原因 :sc = logits / temperature,一行。

8. 幻觉:它练的是"像",从来不是"对"
回头看第 4 节:它全部的得分只来自"下一个 token 猜得像不像"。没有任何一次,有人检查过它说的是不是真的。
所以它会把不存在的续写接得很顺。让它从 const x 起头,真实输出是:
const x = ( ) { await net ( ) ;
语法顺得像真的,语义完全不对------它把 async 那几行的模板套到了 const 行上。我写了个结构检查器,当场判它 括号没配对。
它胡说的时候,和它写对的时候,用的是同一套机制、同一个 loss。 所以:说得越流畅,不代表越可靠。关键的事自己核;能被查到的那部分,是外面另接了检索。

9. 上下文窗口:它没有"记得你"这回事
每次回答,模型看见的只有窗口里那一段 token 。窗口外的字不是"忘了"------是压根没被传进去,跟没说过一样。
[要 虚 拟 滚 动] 因 为 数 据 有 5 万 条 好 的 我 按 你 说 的 改 现 在 写 两 千 字
↑ 滑出左边缘 = 这一轮不存在
这解释了那件让人恼火的事:你第一轮说"周报要短",到第四十轮它写了两千字。不是不听话,是那四个字早就滑出切片了。 所谓"它记得你的偏好",是有人把旧对话重新塞回窗口再读一遍;所谓"长期记忆",是外面另接了一个数据库。

10. 把 792 换成 80 亿
| 本页模型 | Llama 3.1 8B | |
|---|---|---|
| 参数 | 792 | 8,000,000,000 |
| 词表 | 40 | 128,256(BPE) |
| 维度 d | 2--16 | 4096 |
| 训练量 | 166 条 × 1500 步 | ~15 万亿 token |
| 优化器 | Adam | AdamW |
| 核心操作 | 点积 + softmax | 点积 + softmax |
(右边一列刻意取同一个模型的公开数字,免得各挑一个最漂亮的凑一起。)
最后一行一样,就变不出第二种东西。真模型多出来的多头注意力、残差、LayerNorm、位置编码变体、KV cache、指令微调与 RLHF,都是在这 200 行之上叠的工程,不是另一套数学。
打个你熟悉的比方:RLHF 就像给一个已经能跑的 npm 包接上 lint + prettier------不是让它更聪明,是让它说人话。
11. 我踩的坑(这一节才是重点)
- Adam 会掩盖 bug。 我第一版梯度漏了
1/√d,解析梯度是正确值的 √d 倍(在 d=4 下做校验时正好差 2 倍)------loss 照样漂亮地往下掉。是数值梯度校验(把某个参数 ±ε,比较数值差分与解析梯度)把它抓出来的。教训:loss 曲线下降 ≠ 梯度写对了。 - 我的单测本身有 bug。 测试上下文漏了
<s>,位置向量整体错位一格,于是"attention 0/8"测的是我的 bug 不是模型。修好之后才有了第 6 节那个结论。差点就把一个错误结论写进文章。 - 无参数注意力会退化成"只盯自己"。 见第 6 节。
- 第一版架构根本学不会。 没有位置向量、没有输出偏置、用 SGD------attention 比 mean 差,生成退化成
( ( ( (。加了三样东西才成立。 - Pillow 的 alpha 是替换不是合成。 我用 Python 逐帧渲染讲解视频时,10% 的淡光晕在
convert('RGB')之后变成 100% 实色,把整张语义地图盖掉了。缩略图上看着像"颜色深了点",很像设计选择。取样像素才暴露:px[600,640] == (14,124,116)。 - Consolas 没有中文字形。 视频里所有等宽中文渲染成豆腐块。
11.1 那段抓出 bug 的 20 行代码
第 1 条值得单独展开,因为它是那种**"跑起来一切正常"的错误**。
原理很朴素:把某个参数加一个极小的 ε,看 loss 变化多少,得到数值梯度 ;再和代码算出的解析梯度比。两者不一致,反传就写错了。
js
/* 数值梯度校验:解析梯度对不对,这 20 行说了算 */
function gradCheck(m, ctx, tgt, keys, pick, eps = 1e-6) {
const g = grads(m, ctx, tgt).g; // 解析梯度
const lossOf = () => -Math.log(Math.max(forward(m, ctx)[tgt], 1e-12));
let worst = 0, where = '';
for (const key of keys) { // ['E','W','b','Wq','Wk']
const arr = m.p[key];
for (let t = 0; t < 12; t++) { // 每个张量抽查 12 个点
const i = pick(arr.length), save = arr[i];
arr[i] = save + eps; const lp = lossOf();
arr[i] = save - eps; const lm = lossOf();
arr[i] = save; // 还原,别污染模型
const num = (lp - lm) / (2 * eps), ana = g[key][i];
const err = Math.abs(num - ana) / Math.max(1e-4, Math.abs(num) + Math.abs(ana));
if (err > worst) { worst = err; where = `${key}[${i}] 数值 ${num.toFixed(6)} 解析 ${ana.toFixed(6)}`; }
}
}
return { worst, where };
}
它的三次真实输出(不是我编的示例):
| 轮次 | mean | attention(q=k) | attention + Wq/Wk |
|---|---|---|---|
| 初版 | 误差 3.3e-1 <br>W[38] 数值 -0.000743 解析 -0.001486 |
3.3e-1 | 6.6e-1 |
补上 1/√d 后 |
1.2e-7 ✓<br>W[46] 数值 -0.000713 解析 -0.000713 |
1.2e-1 | 1.3e-1 |
第一行就是那个 bug:解析值恰好是数值值的 2 倍 ,而 √4 = 2(校验跑在 d=4 上)------我漏的就是 1/√d。
而与此同时,训练脚本里的 loss 一路漂亮地往下掉。如果我只看 loss 曲线,这个 bug 会跟我到永远。
至于 attention 那两列剩下的 1.2e-1:一部分是我故意不做回传的 query 路径(代码里注释了),一部分我没继续追------因为那个分支的 loss 本来就比求平均差,我直接放弃了它。梯度校验告诉你哪里不是精确解 ,但要不要追下去是个成本决策;只是这次它抓到的那个 1/√d,正好在"必须追"的那一档。
建议:任何自己手写反传的代码,把这 20 行抄进去跑一遍。它比任何单测都便宜,而且专治"看起来能跑"。
12. 全部代码与复现
我做了三个东西,同源:
| 文件 | 是什么 |
|---|---|
tiny-llm.html |
这一篇的可玩版 :模型真跑在浏览器里,改超参、看坐标聚拢、跑 A/B、控制台里 tiny.model() 直接拿对象。单文件 196KB,零联网 |
llm-explained.html |
给完全不懂的人看的滚动动画版,零公式 |
llm-principle.mp4 |
3:45 视频版,1920×1080,纯代码逐帧渲染,22 项验收全通过 |
渲染与验收:
bash
python render.py still 4250 # 任意时间码单帧出图,可复现
python render.py full # 全量 6750 帧 → mp4(约 2 分钟)
python verify.py # 22 项验收:时长/帧率/无黑帧/无静止/安全区/确定性
如果你也打算写一遍:先别画界面,先在 Node 里证明它能学会。 我第一版跑出来 attention 比 mean 差、生成全是 ( ( ( (,那才是真正该害怕的时刻------不是没 bug,是 bug 长得像结果。
文中所有数字都是现场算出来的,唯一写死的是语料。有错请指出。