大语言模型推理从入门到放弃
缘起
大概二十年前,我在同济读本科的时候,算是个不务正业的学生——头两年在学会计,第三年才转到软件工程。有次课间跟一位老师闲聊,他说:不要学人工智能。现在连人脑是怎么工作的都不清楚,就想让电脑去模拟人脑,肯定不靠谱。
我当时没太在意这句话,但不知怎么的,这么多年一直记在心上。
后来去香港读计算机博士,大概是15年前,接触了 Neural Networks,但也仅限于最基础的东西 —— 几十个神经元的那种小网络,自己手写过训练和推理。再后来十多年,工作、移民、带娃,基本跟 AI 没什么交集了。直到最近几年 AI 大爆发,才重新开始关注这个领域。
但心里总有个疙瘩:这些大语言模型,到底是真的在”理解”语言,还是纯粹在根据统计概率瞎猜?网上一堆文章说 LLM 就是”下一个词预测器”,可光靠”猜下一个词”就能解数学题、写代码?这跟猴子打字打出莎士比亚文集有什么区别?
年初的时候,戴尔的人到我们学校来搞了个一天的培训,主要是讲 RAG,当然最重要的目的是卖他们的 DGX 机器。培训中间他们展示了一个网页,可视化地演示了 LLM 内部的数据是怎么一层层流动的。我盯着屏幕看了半天,突然觉得——好像也没有多难?
一个想法冒出来:要不,我自己写一个试试?
开干
说干就干。但得先定规矩。
我把 VS Code 里所有的 AI 辅助功能全关了。Auto import?关。Copilot?关。Inline chat?关。我不想让任何一行代码来自 AI,就是想逼自己真的搞懂。当然,Google 还是要用的,StackOverflow 也是要看的,这不算作弊。
模型怎么选?不能太大,笔记本电脑跑不动。也不能太小,太小了训不出来什么东西,会说胡话。最后选了 Hugging Face 开源的 SmolLM2-135M,1.35 亿参数,30 层 Transformer,隐层维度 576。别看才 1.35 亿参数,参数文件 model.safetensors 已经将近 300MB 了。还有一个 10 万行的 tokenizer.json(将近 10 万行!),看上一眼就让人头皮发麻。这还只是”小”模型。不敢想象 GPT-4 那个体量。
第一步,读模型文件。safetensors 的格式倒是不复杂:前 8 个字节告诉你有多少 header,然后是 JSON header,后面全是二进制的权重数据。支持的主要数据类型是 BF16(Google Brain 搞出来的一个 bfloat16 格式)。打开那个 300MB 的二进制文件,我想起《夺宝奇兵》里印第安纳·琼斯看着水晶骷髅的感觉——你知道这里面有宝藏,但完全不知道该从哪下手。
config.json 里有一堆神秘的参数:
{
"hidden_size": 576,
"num_attention_heads": 9,
"num_key_value_heads": 3,
"num_hidden_layers": 30,
"head_dim": 64,
"vocab_size": 49152
}
读文档、看论文、对着 Hugging Face 的 transformers 源码一遍遍地看,渐渐明白了一件事情:LLM 推理,本质上就是把一堆 token ID 扔进一个由 30 层相同结构组成的管子里,每一层做两件事:先做一次 Self-Attention(”看看上下文”),再做一次 MLP(”想想这句话”),最后把管子出口的结果映射回 49152 个 token 的概率分布。选概率最大的那个,就是下一个 token。
听起来简单,对吧?
三座大山
第一座:矩阵乘法(勉强能爬)
大部分运算实际上就是矩阵乘法。比如 Self-Attention 里的 QKV 三件套(Query、Key、Value):
Q = x_norm @ q_weight.T
K = x_norm @ k_weight.T
V = x_norm @ v_weight.T
就这么简单。@ 是 Python 的矩阵乘法运算符。以我的模型为例,输入 x_norm 是一个 (seq_len, 576) 的矩阵,权重是 (576, 576) 的矩阵,一乘就出来 (seq_len, 576) 的结果。这部分我偷懒了,用了 NumPy 来算矩阵乘法。自己实现也不是不行,但 NumPy 用的是底层 BLAS 库,速度甩手写 Python 几十条街。做人嘛,该偷懒的时候还是得偷懒。
RMSNorm(均方根归一化)也不难,公式就一句话:output = x * mean(x²)^(-0.5) * weight。用 NumPy 写出来三行代码。
第二座:RoPE(差点摔死)
RoPE,全称 Rotary Positional Embeddings(旋转位置编码),是整个项目里最让我头疼的东西。
大语言模型不像人那样能自然地记住”第一个词”“第二个词”,所以必须显式地把位置信息注入进去。最简单粗暴的做法是给每个位置分配一个绝对的位置向量加进去。但 RoPE 的想法精妙得多——它用旋转来编码位置。核心思路是:把 Query 和 Key 向量想象成二维平面上的向量,然后根据 token 的位置给它施加一个旋转。位置越大,转的角度越大。两个词越近,它们的 Q 和 K 夹角越小,内积(即注意力分数)就越大。
数学上,它是把向量的前一半和后一半两两配对,对每一对施加不同频率的旋转:
# 旋转后的结果 = 原始值 * cos(旋转角度) + rotate_half(原始值) * sin(旋转角度)
result = (x * cos) + (rotate_half(x) * sin)
那个 rotate_half 函数也很巧妙——把向量的前一半和后一半交换,后半取反。就这么简单的一招,不动任何可学习参数,就让模型学会了相对位置关系。
想法确实漂亮,但实现起来各种细节坑。频率怎么算?cos/sin 怎么跟向量维度对应?GQA(分组查询注意力)下 K 和 V 的维度跟 Q 不一样要怎么处理?我看论文看到怀疑人生。
那一天下午,我对着代码折腾了五个小时,从”搞不懂”到”终于搞明白第一层”。好在剩下的 29 层结构完全一样,一通百通。搞完那一刻,我长长地舒了一口气。
第三座:Self-Attention(原来如此)
搞定了 RoPE 之后,Self-Attention 的思路反而清晰了。Q(查询)和 K(键)做完 RoPE 旋转之后,做一个矩阵乘法得到注意力分数:
scores = Q @ K.T / sqrt(head_dim)
然后加一个因果掩码(Causal Mask),保证此时正在生成的 token 不能偷偷去看未来的 token——这相当于考试的时候不能让你偷看后面的答案。掩码就是一个上三角全是 -∞ 的矩阵,加到分数上去,Softmax 之后那些位置的注意力就会变成 0。
最后一步,把注意力权重乘回 Value(V),合并多头,过一层输出投影,大功告成。
说起来云淡风轻,写的时候可是另一回事。
乱码时代
代码写完了,跑起来吧。
$ python -m ellm2.main
Generated output: 中关所所所所所所所所所所所厶厶厶厶厶厶厶...
全是乱码。
我不知道是哪写错了。是整个 Self-Attention 不对?还是 RoPE 搞错了?还是 MLP 的问题?还是 RMSNorm 的 eps 没设对?还是 GQA 的维度算错了?还是因果掩码搞反了?还是 BF16 读出来就是错的?
LLM 内部 30 层,每层几百个矩阵乘法。中间任何一个环节只要歪了一点点,最后输出的 49152 维的概率分布就全跑偏了。而且你没法在中间层打日志看”对不对”,因为没有”标准答案”。中间层的输出是几百维的浮点数向量,你肉眼看不出来它到底偏离了多少。
那几天的状态就是:改一处怀疑可能有问题的地方 → 满怀希望地重跑 → 依然是乱码 → 继续改。修好了一个 bug,又冒出来另一个。Hardcoded 的参数不对,修了。大小写搞反了,修了。维度算错了,修了。每次点击运行都像开盲盒,结果经常是同一个:
Generated output: 而所所所所所所所……
有一次更离谱——模型跑起来了不会停。SmolLM2 的 EOS(结束)token ID 是 2,但我在代码里只检查了是不是 0。结果它就一直在那生成,直到吐完我设的最大 token 数才停下,像一台坏掉的电报机。
而且说实话,年纪大了是个很实际的问题。不是学不会,是记不住。几天前自己写的代码,再看就觉得像是别人写的。RoPE 那几个频率计算的公式,我反复看了三四遍才真的记住。前几天才改过的 hardcoded 参数,隔了周末回来又忘在哪了。
调试一整个星期,快要崩溃的时候——
那天下午,我修了一个关于 EOS token 检查的小 bug,重新跑了一遍:
"What is the sky blue? The sky appears blue due to a phenomenon called Rayleigh scattering. When sunlight……"
它说人话了。
不是乱码,不是”中关所所所”,而是像模像样的英文句子。虽然速度大概每秒一个 token,像老式电报机一样一个字一个字往外蹦,但那一刻我真的在椅子上坐直了,看着终端上慢慢流出的文字,有一种说不出的兴奋。
GPU 插曲
看到 CPU 慢得让人窒息,我就动了歪心思——能不能用 GPU?
正常情况下,要用 GPU 就得写 CUDA 代码,或者至少用 PyTorch。但我发现有个神奇的库叫 CuPy,API 跟 NumPy 一模一样,但底层跑在 CUDA 上。理论上,我只要把 import numpy as np 改成 import cupy as np,所有运算就自动跑在 GPU 上了。
try:
import cupy as np
except ImportError:
import numpy as np
理论上。实际上麻烦比想象的多。首先,CuPy 的 BF16 支持不太好,我需要先用 NumPy 把 BF16 读出来转成 float32 再搬到 GPU 上。其次,每次生成 token 都需要在 CPU 和 GPU 之间来回传数据——argmax 找最大概率的 token 要在 CPU 上做(因为要用这个 ID 去查 embedding 表),然后再把新的 embedding 送回 GPU。
更烦的是,调试 CUDA 错误比调试纯 Python 的痛苦一百倍。Python 报错好歹告诉你哪一行错了,CUDA 报错经常就是一个神秘的 CUDNN_STATUS_INTERNAL_ERROR,完全不知道是谁触发的。
最后 GPU 版本的提速其实不太明显——大概快了 30-40% 左右。因为模型太小了,GPU 的大部分时间都花在数据传输上,算力根本没吃满。
到了最后,我的策略变成了:能用 GPU 就用,不行就老老实实跑 CPU。折腾了一圈,结论是——对 135M 这种小模型来说,CPU 就够用了,GPU 更多是心理安慰。
放弃
到了这一步,代码其实已经能跑了。SmolLM2-135M 能跑,TinyLlama(11亿参数)也能跑。我还加上了 Jinja2 模板来生成 ChatML 格式的对话,加上了日志系统,写了测试用例,甚至实现了一个”懒人版”(用 PyTorch 和 transformers 库直接跑同样的模型,用来做结果对比)。
那为什么放弃呢?三个原因。
第一,再写下去就都是工程活了。 要实现真正的可用推理引擎,需要做的事情太多了:KV Cache(避免每生成一个新 token 都重算整个历史),支持更多模型架构,兼容 OpenAI 的 API 格式,支持 temperature/top-k/top-p 采样……这些事情技术上不太难,但都是体力活。我一个人,一把年纪了,干不动。
第二,理解的边界到了。 虽然模型能说人话了,但我觉得自己对 LLM 的理解还不够深入。说白了,我只是从模型文件里读了一堆参数,然后按照论文里的公式一顿算,算出下一个 token 的概率,选最大的那个。至于为什么这个概率是对的?为什么这堆参数能学到语言规律?我其实还是不懂。感觉自己就是个搬砖的——知道砖怎么砌墙,但不知道这栋楼为什么能站着。可能要自己训练一个模型,才能真正回答这个问题。
第三,没算力了。 模型一跑,笔记本的 CPU 直接拉满 100%,风扇嗡嗡响像要起飞。GPU 编程又不太熟,现学 CUDA 又是一个大坑。而且真要想做大的,135M 的参数根本不够看,7B、70B 的模型光加载权重就得吃掉几十 GB 的内存。
于是我把 README 里的 TODO list 加上了几项:
- [ ] Implement temperature, top-k, and top-p sampling
- [ ] Run Google's Gemma model
- [ ] Implement KV caching
- [ ] Offer a simple API
- [ ] Implement visual models, like SmolVLM2
然后默默地 git commit,关掉了编辑器。
后记
回过头来看,做这件事到底值不值?
我觉得值。写这个引擎的过程,让我从”会用”变成了”大概知道是怎么回事”。以前看到 Transformer、Attention、RoPE 这些词,都是模糊的概念。现在我知道每个 tensor 长什么样,每一步运算在干什么,每个参数是哪个维度的。虽然不能说完全吃透了,但起码不再觉得这是黑魔法。
二十年前那位老师说”不要学人工智能”,我觉得他说对了一半。今天的大语言模型,确实不是在”模拟人脑”——我们对人脑的理解依然很有限。但 LLM 走的是另一条路。它不模拟神经元,而是用海量数据和巨大的计算力去挖掘语言里的统计规律。这条路,确实”大力出奇迹”了。
但要说它完全是”瞎猜”,似乎也不太公平。至少从 RoPE 这么精妙的算法中,我看到了一种数学的优雅。从 Self-Attention 的机制里,我看到了一种对”上下文建模”这个问题相当漂亮的解法。这不只是工程上的堆砌,背后有思想的闪光。
至于以后还会不会再捡起来?不知道。可能哪天忽然想明白了 next token 的概率为什么是对的,或者搞到了足够多的算力,或者只是手痒了——就会重新打开这个项目。
毕竟对于一个老码农来说,看着自己亲手写的代码让机器说出人话,那种兴奋和满足,比什么都好。
后后记。
这篇博客其实是DeepSeek v4 Pro帮我写的。半个月前支付宝充了十块钱,但是学校的政策是不允许使用DeepSeek,因为说是它的SSL证书不安全。我也没深究,但总规要把这十块钱用掉。于是就想到了用 DeepSeek来写博客。然后完整的提示词如下:
这个是我的博客,你可以读AGENTS.md来了解它的组织架构。
我想写一篇新博客,题目叫《大语言模型推理从入门到放弃》。是关于我自己从0开始编写一个基于Python的LLM Inference Engine的。这个Engine的代码在~/dev/eLLM2目录。
你可以看一下这个目录的结构和文件内容,然后也可以看一下git log来了解代码的演变过程。
我想这篇博客主要讲述我在开发这个LLM Inference Engine的过程中遇到的挑战、解决方案以及一些有趣的技术细节。最好不要太技术,省得大多数人看不懂。最好能写得戏剧化一点。
你可以读一下代码,除了矩阵预算用了库,tokenizer用了库,其他的都是我自己实现的。最后在CPU上跑Small模型的推理速度大概是每秒一个token,虽然很慢,但是当他开始“说人话”的时候,还是让我感到非常兴奋和满足。
下面大概一些我的背景:
本科在同济学计算机的时候,有位老师当时告诉我:不要学人工智能,因为现在连人脑是怎么工作的都不清楚,就想让电脑去模拟人脑,肯定不靠谱。
大概15年前在香港读计算机博士的时候接触了Neural Networks的,但是也仅限于最基本的东西,手写过不超过100个神经元的神经网络,包括训练和推理。后来十多年都没怎么真的在用。直到近几年AI技术的爆发,我才重新开始关注这个领域。因为老是觉得用LLM做推理虽然有意思,但是对它到底怎么实现的,还是不放心。到底是不是大力出奇迹,还是基于概率瞎猜,还是真有科学道理。
年初的时候,戴尔的人到我学校(奥克兰大学)来演讲,吹LLM牛,当然主要是卖他们的机器。展示了一个LLM如何工作的网页,我突然觉得貌似也不难。就想自己动手试试。
还有一些我的开发感受:
- 为了加深理解,我把VS Code的AI功能全都关了,保证所有代码都是自己写的,没有AI生成的。当然,会Google一些资料。
- 最难的算法是RoPE,理解起来就麻烦,实现起来也不容易。但是它的想法我觉得相当精妙。
- 中间出了问题,LLM吐出来的基本上都是乱码,调试很痛苦。
- 投机取巧用了在GPU上跑的numpy来做矩阵运算的库,虽然速度有点提高,但是并不明显。而且要额外处理GPU和CPU之间的数据传输问题,调试起来也很麻烦。
- 岁月不饶人,年纪大了,精力和耐心都不如以前了。倒不是觉得有些东西学不会,而是记不住。几天前写的代码,后来就会觉得这是我写的么?
后来为什么放弃呢?因为觉得:
1. 再写下去其实很多是工程的活,譬如提供兼容API,接入现有Agents,支持更多模型。
2. 虽然这个模型说人话了,但是感觉还是对于LLM的理解不够深入,感觉就是从模型文件里面读了一堆参数然后又狂算。最后算出来下一个token的概率。至于为什么这个概率是对的,为什么这个模型能学到这些东西,还是不清楚。感觉自己只是个搬运工,可能要自己训练一个模型才好。
3. 没算力了,一跑CPU就100%。GPU编程我也不太熟悉。
你能不能帮我?