1. 为什么要从零手写一套LLM推理引擎
1.1 先聊聊动机:Transformer已经“跑通”了,为什么还要自己造轮子?
很多朋友会用HuggingFace的transformers库,或者用vLLM、llama.cpp这类推理框架,几行代码就能把一个LLM跑起来,甚至在公司做在线服务。但跑通一个模型和真正理解一个模型,完全是两码事。就像你会开车,但不代表你会修车、懂发动机原理。
我在很长一段时间里,属于“只会调用框架”的那类人。模型加载、tokenize到模型前向再到采样输出,全都封装在几个高层接口背后。直到有次我需要做一个定制化推理场景:要在同一套代码里精确控制KV cache的裁剪策略,还要对接一套自定义的采样逻辑。这时框架的抽象反而成了阻碍——文档里找不到、改起来怕影响全局。我意识到,只有自己接管这条链路,才能真正掌控每个环节。
自己动手写LLM推理引擎,不是为了替代transformers或vLLM,而是一场“原理还债”。你在框架里写过一百次model.generate(),也不如亲手写一遍prefill循环、亲手管理一次KV cache、亲手实现一次top-p采样更能理解大模型到底是怎么工作的。这就是第一章存在的意义——在最小可运行的前提下,不依赖任何黑盒推理库,用纯Python把一条完整的推理链路搭起来。
语言模型推理引擎是一个把“输入文本”映射为“输出文本”的完整系统。它包含tokenizer、embedding、transformer主干、采样器这几个核心部件。第一章我做的是“概览”定位,先把全链路骨架搭通,后续章节再逐层深入优化。
1.2 自研推理引擎的三大实际收益:调试、定制、排查
先说调试自由。框架封装的model.forward()你进不去,进去了也看不懂那么多分支跳转的逻辑。自己写的引擎,每一层张量的形状变化都摊在你面前,你能精准定位“问题发生在attention层还是FFN层”。比如我用一个小模型(1B左右参数)做实验时,某次输出全变成NaN,在transformers里你只能看到最终结果,但自研引擎里我逐层排查,发现是attention的softmax计算在输入维度未正确缩放时溢出了。这种定位能力在日常业务里非常宝贵。
其次是定制能力。量化的实现、稀疏化处理、新注意力机制的验证,都必须动到推理内核。框架对这些特性的支持经常滞后,而且改造起来束手束脚。当我需要把推理引擎嵌入到知识库问答、RAG召回这类系统中时,希望tokenize和向量计算路径是透明的、可插拔的,而不是被框架绑定。
最后是排查线上问题的能力。推理引擎常见的故障比如prefill慢、decode显存OOM、采样时重复token爆炸,这些问题的根因全都在最底层。你不亲手写一遍,出问题时只能发帖求助。写完之后,你扫一眼日志里的张量shape日志、温度参数、采样概率分布,就能大致判断症结在哪。
1.3 前置要求与第一章的阅读门槛
坦白讲,这套项目适合有一定基础的读者。我会推荐你做好下面这些准备,读起来会更顺畅:
- 对Python语言比较熟练,能看懂类、继承、装饰器(不是必须,但会舒服很多)
- 熟悉PyTorch或NumPy的基本张量操作(矩阵乘法、reshape、transpose是日常操作)
- 了解Transformer的核心概念,至少知道注意力机制的Q、K、V是什么(如果你还不太清楚,不用担心,本系列会在讲解引擎实现的过程中顺便补上)
- 会用命令行、能安装Python依赖包
- 本篇不需要C++、CUDA或算子融合的知识,后续章节如有涉及,我会单独开篇说明
第一章做的就是“最小可运行引擎”,我把复杂度压到最低:用一个1B规模的Decoder-only模型,纯Python实现,不追求极致性能,只求能把链路走通。当你亲手把这一章跑通之后,对LLM推理的“全貌感”会非常强烈。
2. 推理引擎的完整链路:从一行prompt到每一个输出token
2.1 预填与解码:LLM推理的两段式“加工流水线”
大模型生成文本的过程,不是一口气把整句输出,而是一个token(词元)一个token地“挤出来”。这里必须明确两个阶段。
预填阶段(Prefill):系统读取用户输入的整个prompt,一次性并行处理所有token的注意力计算,得到这个序列的中间状态。这个阶段本质上是在给prompt做“全文精读”,为后续生成做好准备。实际体验中,这个阶段通常比较快,因为并行度高。如果你用一张消费级显卡推理1B模型,上百个token的prompt,预填往往在几十到几百毫秒内完成,但这个时间也取决于显卡算力和prompt长度。
解码阶段(Decode):此时模型进入自回归循环,每次只生成下一个token,然后把这个token拼进输入序列作为新的历史,再让模型继续预测。这个阶段是真正的瓶颈所在——因为逐个生成,长文本需要成百上千次迭代,速度通常以“每秒多少个token(token/s)”来衡量。市面上的推理引擎花大力气做批处理、KV cache、量化加速,很大程度就是为了让这个阶段更快。
这里的核心要点是:两阶段的计算模式不同,所以优化策略也不同。第一章先把两阶段串起来,后一章再分别优化。
在代码层面,可以这样抽象:
for _ in range(max_new_tokens): next_token = engine.step(input_ids) input_ids = append(input_ids, next_token)2.2 核心组件拆解:Tokenizer、Embedding与Transformer主干
一个完整的LLM推理引擎,环环相扣,缺一不可。我用一个类比来说明它们的关系:如果把LLM比作一个大型中央厨房,那么tokenizer是刀工师傅,负责把食材(文本)切成标准尺寸的“食材块”(token);embedding层是贴标签的人,把每个食材块贴上化学分子式(高维向量);Transformer主干是炉灶和大厨,真正对“分子式”进行化学反应,产出新的味道向量;最后的采样器则是品菜师,从众多可能的味道中挑出一个送入下一道工序。
具体到组件职责:
- Tokenizer:将原始文本拆成token ID序列。它维护一个词表,每个token ID对应一个词或子词。比如“大语言模型”可能会被切分成若干个token。
- Embedding层:把token ID映射成一个高维向量(维度通常是hidden size)。这个向量就是这个token的语义表示。
- 主干网络:一串Transformer decoder层,通常包含自注意力层(Self-Attention)和逐位置前馈网络(FFN)。它是计算的主体,每一层输出的都是形状相同的序列向量,层数越深,语义抽象程度越高。
- 采样器:从主干网络输出的logits(一个维度等于词表大小的分数向量)中,结合温度、top-k、top-p等策略,选出一个合适的token ID作为输出。
在第一章,这四个组件的代码量其实不大,但把它们的边界划分清楚,会让后续章节增删特性非常轻松。
2.3 张量形状全流程:用维度变化讲清数据流转
写推理引擎时,我最看重的就是张量形状。形状错了,其实就说明你对模型结构理解错了。下面用一张表梳理一次推理过程的数据流转(以Llama-3.2-1B这类Decoder-only模型为例,隐层维度假设为H,词表大小为V):
| 环节 | 输入形状 | 输出形状 | 说明 |
|---|---|---|---|
| Tokenizer | 字符串 | [S](整数列表) | S为token个数 |
| 批量填充后 | [S] | [B, S] | B为batch size,第一章固定为1 |
| Embedding层 | [B, S] | [B, S, H] | 把每个token ID换成H维向量 |
| Transformer层(单个decoder块) | [B, S, H] | [B, S, H] | 序列中每个位置都与前后位置做注意力融合 |
| 最终的预测头(LM Head) | [B, S, H] | [B, S, V] | 对最后一个位置的向量预测下一个token的logits |
| 采样器 | [B, V](只取最后位置) | [ ](一个整数token ID) | 得到新token |
| 拼接回输入 | [B, S] + 新token | [B, S+1] | 进入下一轮decode |
注意,在预填阶段,我们一次输入整段prompt的[S]个token,模型输出[B, S, V],但真正用于预测下一个token的只有第S个位置的输出(也就是最后一位)。这一步实现得好,后续做批量解码时才会顺畅。我见过不少新手在这一步把整个logits都拿去采样,结果输出了一堆乱码。记住:decode阶段预测的下一个token,只看输入序列最后一个位置的logits。
在第一章里,我建议在代码中打印每层输入输出的shape,方便对齐理解。调试时可以把“形状断言”写进代码里,一旦不匹配立即报错,能节省一整天排查时间。
2.4 自注意力机制与QKV的本质含义
注意力机制是LLM的核心。如果你之前只是机械地调用库函数,我想用一个比较形象的比喻把Q、K、V讲透,这也是我在写这篇时想重点说明的。
Q(Query):我在找什么。可以把它理解为“我的问题是什么”。在生成文本时,当前token要问:上下文里哪些词值得我重点关注?这个查询向量就是我的“需求”。
K(Key):我能提供什么标签。每个历史token都有一个“检索标签”,说明自己包含哪些信息内容。Query和Key做点积,就是在做“猜你喜欢”式的匹配:估算当前token和历史token的相关度。
V(Value):我能提供什么内容。一旦确定了与哪些历史token更相关,模型就需要把它们的“实际内容”融合进来。Query与Key算出的相关度分数经过softmax变成权重,再用权重加权求和所有的Value向量,得到最终的注意力输出。
为什么需要除以√d?因为点积数值会随维度d增大而变大,导致softmax进入梯度饱和区,训练和推理都会不稳定。除以√d相当于把方差大致归一化。这一点在自研引擎里会被反复校验,如果忘记做缩放,生成结果的质量会出问题,甚至可能出现崩溃。
在代码层面,一次单头自注意力大概是这样的(省略批量维度):
def scaled_dot_product_attention(q, k, v, mask=None): d_k = q.shape[-1] scores = q @ k.transpose(-2, -1) / d_k ** 0.5 if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) probs = torch.softmax(scores, dim=-1) return probs @ v注意,mask的作用是让某个位置只能看到它之前的位置、看不到未来位置。这是Decoder-only结构的重要特点,第一章如果漏掉这一步,模型训练/推理的因果性就崩了。
3. 第一章的代码规划:如何搭好引擎骨架
3.1 最小可用推理引擎的分层模块设计
在动手写代码前,先做一个模块设计。项目规划决定了后面写起来顺畅度。
我遵循一个原则:每一层模块只负责一件事,接口清晰,方便后续替换优化。
一个推荐的目录结构:
llm_engine/ ├─ engine.py # 引擎主类,串联全流程 ├─ tokenizer.py # 封装分词与ID转换(可先直接复用HuggingFace的tokenizer) ├─ embedding.py # 从权重文件读取embedding矩阵,完成查表 ├─ transformer.py # Transformer主干,串联decoder层 ├─ attention.py # 自注意力实现 ├─ ffn.py # 前馈网络(FFN)实现 ├─ sampler.py # 采样器:温度、top-k、top-p └─ config.py # 模型配置结构(层数、隐层维度、头数等)建议先写config.py,把模型的基本参数放进去。比如Llama-3.2-1B的关键参数是:
- hidden_size(隐层维度):2048
- intermediate_size(FFN中间层维度):8192
- num_hidden_layers(层数):16
- num_attention_heads(注意力头数):32
- num_key_value_heads(KV头数):8(用于GQA,第一章可以先简化)
- vocab_size(词表大小):128000
- rope_theta(旋转位置编码参数):500000
在第一章中,我们只需要能读这些参数并生成对应模块即可,不用理解全部细节。等后续章节深入某一块时,再逐个参数展开。
3.2 模型选型:为什么我推荐用Llama-3.2-1B做实验
选用Llama-3.2-1B做实验对象,是因为它参数量小、结构典型、权重容易获取。在消费级显卡甚至CPU上都能勉强跑起来,而且它是纯Decoder-only结构,具备GQA(Grouped Query Attention)、RoPE(旋转位置编码)、RMSNorm等现代LLM的标配特性,用来做引擎练手很有代表性。
选择它而不是更大的7B或70B模型,原因很简单:推理引擎第一版大概率存在各种bug,1B模型极大缩短了“从加载权重到跑到输出”的迭代时间。你没有卡也可以先跑CPU模式,耐心一点也能出结果。等调通链路之后,再换大模型才有意义。
权重文件一般用safetensors格式,比bin更安全而且加载更快。在第一章,我们只需要用safetensors库把权重张量读取出来,按照config.py里定义的键名映射填入即可。刚开始时可以先只处理model.embed_tokens.weight、model.layers.N.*这类前缀,简化映射逻辑。
注意:不同模型的权重key命名不同(比如Llama的model.layers.0.self_attn.q_proj.weight,GPT-NeoX则是transformer.h.0.attention.query_weight之类)。第一章我只支持好Llama命名,后续章节再考虑做一个通用映射表。
3.3 采样器:从概率分布里挑token,温度的魔法
采样器是引擎“人性化”的出口。如果每次只挑logits最大的token(greedy),生成内容会非常呆板、重复率高。所以需要一个可控随机性的采样策略。
温度(Temperature):将logits除以一个温度值T。T越低,概率分布越尖锐(趋向greedy);T越高,分布越平滑,输出更多样。标准设定是T=0.8或者1.0。实际调参时我发现,T=0.7左右生成质量稳定,又不至于太单调。
Top-k采样:只从概率最大的k个token中采样,避免长尾低概率token污染输出。通常k取40到100。
Top-p(核采样):从累积概率达到p的最小token集合中采样,p通常取0.9到0.95。top-p比top-k更灵活,因为候选集合的大小随分布而变化。实践中两者经常被结合使用。
实现一个最简采样器的伪代码:
def sample(logits, temperature=1.0, top_k=0, top_p=0.9): logits = logits / temperature if top_k > 0: # 只保留最高k个logits,其余设置为负无穷 top_k_values, top_k_indices = torch.topk(logits, top_k) logits[logits < top_k_values[:, -1, None]] = -float("inf") if top_p < 1.0: # 按从大到小排序,累积概率超过p的丢弃 sorted_logits, sorted_indices = torch.sort(logits, descending=True) cum_probs = torch.cumsum(torch.softmax(sorted_logits, dim=-1), dim=-1) sorted_mask = cum_probs <= top_p # 保证至少保留一个 sorted_mask[..., 1:] = sorted_mask[..., 1:] & sorted_mask[..., :-1].clone() invalid = ~sorted_mask logits[sorted_indices[invalid]] = -float("inf") probs = torch.softmax(logits, dim=-1) return torch.multinomial(probs, num_samples=1).item()这里有一个值得注意的坑:top-p这个“保证至少保留一个”的逻辑,如果写不好,极端情况下会得到空集或softmax结果为NaN。我在第一次实现时忘了限制最低保留数,输入特别集中的分布时直接崩了。这种边界情况在自研引擎里特别常见,多测才能避免。
3.4 引擎主流程:把各模块串起来
engine.py是整个引擎的“总指挥”。它负责:读取config、初始化tokenizer和模型组件、加载safetensors权重、执行prefill、循环decode直到生成终止符或达到最大长度。
核心流程可以概括为:
1. 输入prompt 2. tokenize -> input_ids 3. embedding查表 -> hidden_states 4. 逐层过Transformer(注意力+FFN,加上残差、归一化) 5. 取最后位置向量 -> 过LM Head -> logits 6. 采样 -> next_token_id 7. 拼接input_ids,若未到终止条件则回到第3步每一步之间都要设置好调试钩子,比如打印当前层、输入输出的shape、logits的数值范围等。我的经验是:第一版本宁可多打日志,也不要追求优雅地“静默运行”。
4. 工具链、依赖与权重下载:起步阶段最容易踩坑的地方
4.1 最小依赖清单与Python环境配置
写推理引擎的一个好消息是:第一章不需要安装transformers库,也不需要vLLM。我们只需要:
- Python3.10+(如果用mamba等更高级特性,可能需要3.11)
- safetensors:读取模型权重
- tokenizers或transformers.tokenization(仅用于tokenizer部分,也可以直接使用HuggingFace的AutoTokenizer)
- torch或numpy:做矩阵运算(建议用torch,因为后续玩GPU加速时顺手)
- 可选:huggingface_hub(方便下载模型)
环境搭建建议用conda或者虚拟环境,保持干净。如果你在CPU上实验,安装CPU版torch即可;如果有NVIDIA显卡,建议装CUDA版torch,后文我会聊到如何观察显存和GPU利用率。
4.2 获取Llama-3.2-1B权重:从HuggingFace下载与缓存位置
用HuggingFace的话:
pip install huggingface_hub huggingface-cli download meta-llama/Llama-3.2-1B --local-dir ./models/Llama-3.2-1B注意,Meta的模型有访问权限申请,需要先在HuggingFace官网申请授权。如果申请不下来,也可以先用等价的、MIT许可的模型替代,比如openlm-research/open_llama_3b之类的,结构类似,跑通后再换模型。
下载完成后,目录下至少要有model.safetensors(权重文件)、config.json(模型配置)、tokenizer.json或tokenizer.model(词表)。第一章不要试图加载全部权重到显存,那样会把显存瞬间占满。我建议先用CPU加载,验证链路可用后,再考虑迁移到GPU。
4.3 logits比对:验证引擎正确性的金标准
写引擎最大的恐慌感来自“我做对了还是做错了”。一个标准做法就是和transformers库并行跑同一个输入,逐层比对输出。
具体方法:
- 用同一个prompt、同一个模型、同一组权重,在transformers里拿到第一层输出、所有层输出、最终logits
- 用自己的引擎,同样输入跑一遍,拿到对应层logits
- 使用
torch.allclose逐元素近似对比(注意容忍度设置,浮点计算顺序不同会有微小差异)
在第一章,由于计算逻辑相对简单,预期误差可以控制在1e-4以内。如果你的误差远大于这个量级,基本能断定某一步写错了。用这个方法定位bug非常高效,因为它把“全链路对错”问题转化成了“某一个张量在哪里开始不一致”的问题。这也算“先搭脚手架再盖房子”的思路。
5. 第一章的自测方案与常见坑
5.1 自测方案:怎样确认你的引擎“至少没写错”
给出三个自测层级:
第一,张量形状检查。每一个阶段打印输入输出shape,和上面那张表对比。任意一处不符,立刻修复。这一步能过滤掉80%的低级错误。
第二,logits一致性检查。用transformers获取同模型的logits输出,和自己的引擎对比。最好用多个不同prompt(短句、长句、英文、数字等)测出一个稳定的误差范围。误差来源主要是softmax计算顺序、注意力score未缩放、浮点累加顺序这三项,注意逐个排查。
第三,文本生成合理性。选一个有标准答案的输入,比如“中国的首都是”,期待模型输出“北京”。如果输出完全跑偏或者重复短语,说明链路中存在采样/推理层面的bug。这个测试方法简单,但非常能说明问题。
另外,在后续章节优化时,建议把前两个自测写成回归测试脚本,每次改动后跑一遍,防止优化过程中无意破坏原有链路。能在早期养成这个习惯,比任何优化技巧都重要。
5.2 新手的四个高频错误
错误一:维度错位。尤其是在attention计算时,Q、K、V的形状通常是[B, S, H],但要拆成多头时,需要reshape成[B, S, num_heads, head_dim]然后交换到[B, num_heads, S, head_dim]。很多人会在transpose之后把head维度和batch维度搞混,导致点积结果张冠李戴。我的排查技巧是:每一步都打印形状,保证scores是[B, num_heads, S, S]。
错误二:RMSNorm的epsilon放错位置。RMSNorm是Llama系模型大量使用的归一化层。它在归一化时要在均方根上加一个小常数epsilon防除零。如果你在归一化之前加到输入上,结果也会错,虽然差异不大但会累积。细节决定成败。放置位置就是除法的分母上。
错误三:权重名映射出错。Llama模型的权重名不是单层的简单规则。尤其是GQA的KV头权重(num_key_value_heads小于num_attention_heads),需要把key/value投影后的张量扩展复制多次才能匹配注意力头数。第一章如果直接用投影矩阵的shape去做attention计算,很可能报错。建议单独做一个权重映射函数,用日志把每类权重的来源、目标shape都打印出来。
错误四:采样时的边界问题。比如top-k的k大于词表大小、top-p的cumulative概率始终小于阈值、softmax的输入存在负无穷导致概率为NaN。每写一个采样分支,都要覆盖“极端情况”和“输入只有一个候选”的边界。我的代码里专门加了一个最小候选数限制,确保不会出现空集。
5.3 系列路线图展望:从“能跑”到“跑得好”
第一章的目标是“能跑”,把整条链路走通。后面几章我会沿着如下的路线推进:
- 第二章:KV cache与流式输出。目前每次decode都是全序列重新计算注意力,既慢又耗显存。引入KV cache,让当前生成的token只和上一步的历史缓存做计算,速度能提升成数量级。
- 第三章:量化与显存优化。用INT8/INT4量化权重,把模型从“超显存”变成“塞得进显卡”,顺便讲清楚精度损失与吞吐量的取舍。
- 第四章:批处理与并发。把多个用户请求打包进同一个batch推理,充分利用GPU并行算力,这是工业级推理引擎的基本功。
- 第五章:更高效的结构。考虑引入投机采样(Speculative Sampling)、并行解码等高级技巧。
每一章都建立在前一章的代码之上,层层迭代。第一章的“概览”骨架,会在后续章节中逐步丰满。可以把这个系列当作一个持续演化的玩具引擎,最终会拥有框架级的核心能力。
6. 写在第一章之后
第一次自己动手把整个推理链路跑通的时候,那种感觉确实很奇妙。你明明用的只是一个很小的1B模型,速度也不算快,但对“模型是怎么输出一句话”这件事的认知,从“黑盒”变成了“一个透明的水管”:token进去,经过形状变换、注意力融合、分布采样,最后一个token一个token地流出来。
我个人在实际写这个引擎的过程中,最大的体会是:调试比实现花的时间多得多。第一版代码只写了一天,但排查那些张量形状的错位、RMSNorm的位置、权重映射的坑,花了整整三天。每次一遇到问题,我就会拉出来和transformers的logits做对比,一点一点缩小范围。这种排查过程虽然痛苦,但它把每个组件的原理牢牢刻进了记忆里。
如果你也想跟着写,我给三个具体建议:
- 第一,选一个小模型起步,比如1B参数级别,别一上来就挑战70B;
- 第二,从加载权重到跑通一个token,先设个小目标,把“用prompt得到第一个输出token”当作第一阶段的里程碑;
- 第三,从第一天开始就把“和transformers的logits做对比”写成一个测试脚本,后面每一次改动都跑一遍。我之前偷懒跳过这一步,结果某次改动后生成质量直线下降,花了一下午才发现是新加的GQA逻辑漏了张量扩展。
这个系列的后续内容,我会沿着KV cache、量化、批处理的方向继续写。每个特性都是真实需要在推理引擎里存在的,不是拼概念。如果你对某个具体方向更感兴趣,可以顺着这个骨架自己先探索一下,遇到问题再回来对照。自己动手的过程,才是把这个领域真正变成“常识”的过程。