☰
从零手写LLM推理引擎:拆解大模型部署背后的核心黑盒
2026/11/3 13:24:48 网站建设 项目流程

说实话,用大模型API的时候,你只需要发一个POST请求,静等几秒钟,一段看起来像人写的文字就回来了。整个过程黑得就像一台自动售货机——投币、选择、出货。可当你把模型权重下载到本地,真正准备自己部署、微调、魔改的时候,才会撞上那道看似不起眼、实则决定一切的门槛:推理引擎。

这个系列,就是想带着你把这台“自动售货机”拆开,看看每个齿轮是怎么转的。更具体一点,是我们自己动手写一个LLM推理引擎,从零到一,不依靠vLLM、TensorRT-LLM这种工业级框架,全靠自己的代码跑通一次完整的推理流程。作为第一章,这篇先把全貌讲清楚:推理引擎解决什么问题、由哪些模块构成、为什么推理不能用训练框架直接顶替、动手之前要怎么选型。

这篇内容适合三类读者:一是想在本地部署开源模型但总感觉隔着一层黑盒的人;二是想深入研究推理性能优化、为之后做论文或面试准备的人;三是已经用过推理框架、想自己重造轮子来理解底层机制的人。如果你只是调API写业务逻辑,这一篇可能不太对口;但只要你有一丝“我想知道里面到底怎么算的”的冲动,那就正好。

1. 推理引擎到底是什么:先绕开那个最常见的误区

1.1 模型和推理引擎,是乐谱和乐手的关系

很多第一次接触LLM部署的同学会把“模型”和“推理引擎”当成同一个东西。这个误区必须从一开始就绕开。

模型,本质上是一堆权重文件。无论你用的是开源的Llama、Qwen还是其他架构,真正意义上的“模型”就是那几十上百GB的张量数据,再加上一些配置项:层数、头数、词表大小、激活函数、RoPE参数等等。这些数据是训练阶段从海量语料里学出来的权重,它们自己不会“思考”,也不会“说话”。

推理引擎,是运行这堆权重的“运行时”。就像同一份乐谱可以被不同的乐手演绎成不同的风格,同一份模型权重也可以被不同的推理引擎加载并执行,跑出来的结果在数学上可以完全一致。推理引擎负责的事情包括:把输入文本切分成token、把权重加载进显存、按照模型架构一层层计算、管理中间缓存、把概率分布采样成文本、把生成的token流式地吐回给你。

这个“乐谱与乐手”的类比很关键,它能解释很多后续的问题。比如为什么同一个模型在不同框架里表现几乎一样但速度差很多——因为框架层面的优化目标基本都是数学等价的,但底层调度和内存管理方式千差万别。再比如为什么你需要读模型代码——推理引擎必须以模型结构为蓝本,你不可能写一个完全通用的引擎,它只能面向Transformer家族这一大类架构。

1.2 一条推理请求背后的完整流水线

假设你给一个本地部署的模型发了一句话:“介绍一下量子纠缠。”这条请求从进入到返回,到底经历了哪些环节?我按顺序列一下,这也是整篇博文后续展开的索引。

第一步,文本进入Tokenizer,被切分成一串token ID。第二步,引擎把这串ID加上位置编码,喂进Transformer的输入层。第三步,从第一层到第N层,每个Block都要做注意力计算、前馈网络计算、层归一化,中间还会穿插KV Cache的读写。第四步,最后一层输出的是一个和词表等长的logits向量,每个位置对应一个词的概率得分。第五步,采样器在这个分布上做出选择,挑一个token出来。第六步,把新token拼到序列末尾,重复第三步到第五步——这个过程就是自回归解码,一次只生成一个token。第七步,当模型吐出结束符或者达到最大长度限制时,整体解码停止,把token序列还原成文本返回。

这整条链路里几乎没有哪一步是“可以跳过”的,但每一步都有巨大的优化空间。Tokenizer的选择决定了一个词被切成几段;注意力计算决定了KV Cache的成长速度;采样参数的设置决定了回答是保守还是发散;解码循环决定了总延迟和吞吐的平衡。

我特意在概览章节里把这七步全部点出来,是因为后续的每一章都会落到这个框架里去。你如果不先建立这种“全貌感”,后面不管是看官方代码还是读论文,都会陷入只见树木不见森林的状态。这个第一章,本质上是给后续所有内容画一张地图。

2. 为什么不能直接拿PyTorch部署大模型:训练和推理是两回事

2.1 训练可以慢,推理必须要快

这是每个准备自己写推理引擎的人首先要接受的事实:PyTorch当然能跑推理,我见过不少人就是torch.load之后直接model.generate(),结果也能出结果,但那只是“能跑”,离“能可用”差了十万八千里。

训练和推理的目标函数完全不同。训练时你关心的是模型能不能收敛,一次前向加一次反向动辄几十毫秒甚至几百毫秒都没关系,数据是分批喂进去的,batch大一点、时间久一点,都能接受。甚至训练过程中的部分失败都是可以容忍的,大不了把这次step重跑。推理场景则完全不同,用户在线等待,延迟是直接体验,吞吐量是直接成本。没人愿意在聊天界面等两分钟才看到一个token。

更深层的差异在于计算模式。PyTorch默认的eager模式是动态图,每执行一行代码就调度一次kernel,你写一个Transformer Block,里面可能有几百次小算子调用,每次调用之间还有Python解释器的开销、张量元数据的检查开销。这种开销在训练场景下被大batch掩盖了,但推理时你通常不能让batch无限变大——每一轮自回归只生成一个token,序列层次上的串行性是绕不开的。

2.2 算子调度、显存管理和KV Cache:推理引擎的三个杀手锏

推理引擎和训练框架的分道扬镳,具体体现在三个地方。

算子融合。GPU计算最怕的不是算得慢,而是等着算子被调度。一次矩阵乘可能只要几十微秒,但如果你把它拆成十几个小算子,每次都走一遍kernel launch,最终时间可能翻倍。推理引擎会把一连串小算子合并成一个大kernel,比如把“缩放+Softmax+掩码+注意力计算”融合成一个FlashAttention内核,把LayerNorm和后面的线性层融合起来。PyTorch在推理路径上做不到这种程度的融合,除非你引入torch.compile或者手写CUDA。

显存管理模式。训练需要保存中间激活值用于反向传播,显存规划完全围绕这一需求展开。推理则只需要前向计算,中间激活值用完就可以释放,真正吃显存的是权重和KV Cache。推理引擎会设计专门的内存池,提前分配好所有层需要的显存块,避免推理过程中的动态分配碎片。这个差异很底层,也是为什么你用PyTorch跑一个7B模型可能OOM,但换成推理引擎就能跑起来的原因之一。

KV Cache。这是推理引擎独有的核心机制,训练框架里根本没有这个概念。自回归生成过程中,每生成一个新token,注意力计算都要用新token的Query去和前面所有历史token的Key、Value做计算。如果不缓存,每一轮都重新算前面所有token的K和V,计算量会随着生成长度呈平方级膨胀。缓存之后,每一轮只需要计算新token的K和V,然后追加到缓存里。这块缓存的尺寸会随着序列长度线性增长,如何管理它、压缩它、调度它,是推理引擎性能优化的主战场之一。

我开头提到的那些热词里有一个说法:key是“我是谁”、query是“我在找什么”、value是“我能提供什么”。这个口诀非常形象,在后面讲注意力机制的时候我会专门展开。

2.3 并发服务才是真正的分水岭:continuous batching 是什么

如果说算子和显存是“硬实力”,那并发调度策略就是“软实力”,也是推理引擎区别于“一个能跑模型的脚本”的分水岭。

想象一家餐厅。最蠢的服务方式是:一个服务员只服务一桌客人,从头守到尾。这就是静态批处理:一个batch里所有请求长度一致,等所有请求都生成完了再清理batch再接下一批。问题在于不同请求的长度差别巨大,有的请求一两句就结束了,有的请求能聊几千token。短的请求被迫等长的请求跑完,GPU利用率惨不忍睹。

continuous batching的思路是:每个token生成完,这个序列就马上空出一个位置,立刻把等待队列里的新请求补进来。GPU的算力始终被塞满,短请求不被长请求拖累,在线的体验和吞吐同时上了一个台阶。这个策略是vLLM能够一战成名的核心原因,也是所有现代推理引擎的标配能力。

我们自己动手写推理引擎,第一版完全可以不做continuous batching,但如果你不看懂它,就永远不知道工业级推理引擎和教学级推理引擎的差距到底在哪里。第二章到第五章我们先把单请求跑通,后面专门留出一章来讲调度,那时候你再回来看这段会更有感觉。

3. 推理引擎的核心模块拆解(概览篇)

3.1 Tokenizer:文本与ID之间的双向翻译官

我见过的初学者最容易忽略的就是Tokenizer,觉得它就是一段现成的代码,pip install之后调一下就完事了。但Tokenizer是推理引擎的最前端,它的行为会直接影响生成质量和速度。

Tokenizer做的事情,是把原始文本切分成一个个token,每个token对应词表里的一个整数ID。这里面有个反直觉的地方:一个词不一定是“一个token”。英文里“unbelievable”可能被切成“un”、“believ”、“able”三段;中文因为词表的训练方式不同,一个汉字可能是独立token,也可能几个汉字拼成一个token。同一个词在不同词表里对应不同的切分方式,这就导致同样的问题问不同模型,实际消耗的token数量差别很大。

对推理引擎来说,Tokenizer至少要满足两个要求:一是正确性,文本和ID的互转必须无损;二是速度,预训练模型的词表动辄四五万甚至更多,文本编码时要在这么大的词表里做快速查表。因为中文编码没有空格作为天然分隔符,很多Tokenizer实现里中文部分会比英文慢不少,这也是为什么HF的fast tokenizer(基于Rust)能比slow tokenizer快一个数量级的原因。

自己动手写引擎时,Tokenizer是最适合“照抄现成”的模块。我的建议是第一版直接复用HuggingFace的tokenizer,后续如果想深入再考虑手写BPE或SentencePiece的实现。这个模块不值得从轮子造起,但你必须理解它的输出格式:一个模型的输入,物理上就是一个形状为[seq_len]的整数张量。

3.2 权重加载与内存布局:模型文件是怎么变成可计算的

训练好的模型会以某种格式保存下来,最常见的是HuggingFace生态的safetensors,也有PyTorch自家的bin格式,还有ONNX这样跨框架的格式。推理引擎的第二个核心职责,就是把这个文件里的张量读出来,按计算图的需求放到正确的位置。

这里有两个容易让人忽略的坑。第一个坑是布局。模型文件里存储的张量顺序和你推理时的计算顺序不一定一致,有的权重是按输出行优先存的,有的权重是转置过的。如果直接内存拷贝而不做layout转换,后面的矩阵乘结果就是错的。第二个坑是精度。模型权重可能是FP32、FP16、BF16或者INT8/INT4量化格式,加载进来之后要不要做反量化、用什么精度去做矩阵乘法,直接决定显存占用和速度。

权重加载这件事,放在章节里看很不起眼,实际踩坑时能让你折腾好几天。我记得自己第二次写加载器的时候,曾因为一个权重名字对不上、导致所有注意力层的权重全部没有被加载,模型输出看起来完全是随机的。当时排查了很久才意识到是加载器里匹配层名的逻辑写错了,多了一层前缀没有剥掉。

对于量化这个点,我多说两句。OPEN LLM Leaderboard等公开榜单上列的模型,很多跑分结果就是用不同精度的推理引擎跑出来的。同一套权重,FP16和INT4量化之后数学结果会有轻微差异,但显存占用差了四倍。推理引擎怎么处理量化权重,是决定一个本地部署能不能跑起来的核心问题之一,这个系列在讲完基础版本之后,会专门用一章聊权重量化。

3.3 注意力与KV Cache:Q、K、V到底各司其职

注意力机制是整个Transformer的灵魂,也是推理引擎优化的核心目标。我完全可以用“key我是谁、query我在找什么、value我能提供什么”这个口诀把QKV讲明白。

注意力计算的目标,是让序列中的每个位置都能“关注”到其他位置的信息。实现方式是:每个token经过三个不同的线性变换,得到Query、Key、Value三个向量。Query是当前这个位置发出的提问,表示“我在找什么”;Key是所有位置挂出来的“我是谁”,表示身份标识;Value是所有位置准备好的“我能提供什么”,表示实际携带的内容。

计算时,当前token的Query会和序列里所有token的Key做点积,得到相似度分数,经过softmax归一化成注意力权重,用这个权重去加权求和所有的Value。这个过程用生活类比来说,就像你在一堆档案里检索:你的Query决定你要找哪类档案,每份档案的Key就是档案袋上的标签,根据匹配程度去翻阅对应档案袋里的Value内容。

自回归生成时,序列是逐token增长的。每生成一个新token,它都要和前面所有历史token做注意力计算。问题是,前面所有token的Key和Value并不需要每轮重新算——它们已经算过了。KV Cache做的事情,就是把已经生成过的所有token的K、V张量缓存在显存里,每轮只计算新token的K、V,然后拼接到缓存后面。

KV Cache是推理引擎的显存大头,尤其是在长上下文场景下。一个7B模型,权重约14GB(FP16),但一个全放开的64K上下文的KV Cache可能还要吃掉十几GB。这也是为什么推理引擎里有那么多KV Cache的优化手段:分组共享、量化、token eviction、page-based管理。初版我们不用做任何优化,但架构上一定要把KV Cache放在显存管理器的管辖范围内,别让它“裸奔”。

3.4 采样与解码:从logits到一句完整的话

模型前向传播的最后一层,输出的是一个长度等于词表大小的向量,叫logits。这个向量经过softmax之后就变成了一个概率分布。如果你直接选概率最大的那个token,就是greedy decoding——每次选最确定的那个,确定性最高但容易重复和乏味。

为了让模型输出更像人说话,采样器会引入随机性。temperature参数控制分布的“尖锐程度”:温度越低,分布越集中,输出越保守;温度越高,分布越平坦,输出越发散。top-k采样是只从概率最高的k个token里挑;top-p采样(也就是nucleus sampling)是从累积概率超过p的最小集合里挑。这些策略单独或组合使用,决定了回答的风格。

还有一个在聊天场景里很实用的细节:重复惩罚。大模型一旦开始重复某个词组,很容易陷入循环。重复惩罚会在logits层面对已经出现的token减去一个偏置,让它们更难被选中。这个机制看起来简单,调起来却很微妙——惩罚太轻没用,惩罚太重会让回答变得生硬。

自回归解码是整个推理中最“串行”的部分:一轮只能出一个token,无法在序列维度上并行。这也是为什么推理引擎的batch越大、吞吐越高,因为batch维度是可以并行的。我们写引擎的时候,先实现朴素的循环解码,保证逻辑正确,后续再考虑投机采样(speculative decoding)这种加速技巧。

3.5 服务化与流式输出:让推理引擎真正支撑业务

推理引擎跑通之后,面对的最后一个问题是怎么把它交给外部调用。工业界现在的主流形态是OpenAI兼容的HTTP接口,这几乎成了事实标准,不管你在本地做什么框架,提供一个“风格像OpenAI”的API接口,各种RAG工具和Agent框架就能立刻接进来。

这里有个必须要懂的概念叫prefill和decode的分离。第一次处理用户输入的那轮计算叫prefill阶段,这个阶段的计算量很大(要处理一整段输入),而且可以高度并行;之后的逐token生成叫decode阶段,计算量小但延迟路径长。这两个阶段在GPU上的优化策略完全不同,prefill更像传统的训练式前向计算,decode则更吃内存带宽。工业级推理引擎对这两个阶段会采用不同的调度策略和内核优化。

流式输出用的技术是SSE,即Server-Sent Events。生成的每一个token都是独立的一个事件,客户端可以一边生成一边展示,而不是等整段文字全部完成后再一次性返回。这个体验差异非常大,也是为什么你看到的所有在线聊天界面都是流式输出的。

我自己在做接入测试时踩过一个不算罕见但很隐蔽的坑:HTTP服务的schema和模型声明不一致。模型支持工具调用(Function Calling),但网关层没有透传工具payload,结果是请求直接报错,错误信息类似于“provider rejected the request schema or tool payload”。不是模型出了问题,而是API层协议没有对齐。这类问题在你部署本地推理服务后特别容易遇到,处理方式只有一条:严格梳理输入输出的json schema,把网关、引擎、模型三层之间的契约钉死。

4. 动手前的选型建议:语言、框架和硬件怎么定

4.1 语言选型:Python起步还是C++/Rust起步

这个问题经常在动手前困扰很多人。我的答案很直接:如果你是学习目的,第一版用Python,不要犹豫。

原因很朴素。LLM推理的核心难点不在某个具体算子的性能,而在于整条链路的正确性。用Python,你有最成熟的生态——HuggingFace的模型库、现成的Tokenizer、成熟的张量库,调试和验证都极其方便。你可以用NumPy、PyTorch或者纯Python慢慢把每个模块跑通,每一步都能和参考实现对比验证。等你把正确性跑通了,再考虑性能。

C++/Rust的优势是性能极致,但代价是开发周期成倍增加。你需要自己处理内存管理、线程安全、各种底层库的绑定,而这些在第一版学习阶段几乎都是不必要的负担。工业界用C++和CUDA去写生产级推理引擎那是另一回事,咱们写的是“自己动手写”系列,先注重理解,再注重速度。

一句话总结:语言的选择不是技术信仰问题,而是目标匹配问题。你的目标是理解推理引擎的原理,那就用最快能表达原理的语言。

4.2 框架依赖:用PyTorch、NumPy还是裸手实现

这里要做一个更细的决定。即便确定了Python,用什么样的张量计算库也有讲究。

三个选项分别对应三种深度。第一层是用NumPy实现全部计算,这样你对矩阵乘法、注意力、归一化这些操作的理解最深,但写起来最累,而且因为NumPy不支持自动求导,你得自己把前向计算的每一步都写清楚——不过推理本来就不需要反向,所以这个限制其实无所谓。第二层是用PyTorch,写起来最轻松,很多层直接调现成接口,但容易让人陷入“只是调库”的舒适区,对底层理解反而不够扎实。第三层是裸手实现,连矩阵运算都不用库,纯Python三重循环硬算。这个只建议用来验证概念,千万别拿它来跑真实模型,你会等到怀疑人生。

我给这个系列定的推荐路线是:核心算子用NumPy实现,模型结构按模块写清楚,KV Cache和采样逻辑都手写。这么安排的好处是,每一行代码都是“能解释清楚自己在干什么”的,不存在黑盒。后续如果要对性能做优化,再逐步把热点算子替换成PyTorch的接口或者自定义CUDA内核。

顺带提一下ONNX部署LLM这条路。ONNX确实可以把模型转成跨框架的中间表示,让推理引擎只面向ONNX计算图。但自己动手写引擎时我不建议一开始就走ONNX,因为它会把很多计算细节“封装”掉,让你看不到注意力、缓存这些核心逻辑的真实执行过程。等你的引擎成熟了,再考虑接ONNX Runtime会更有意义。

4.3 硬件路线:CPU跑通流程,GPU研究性能

最后一个选型问题,也是大家最焦虑的:没有好显卡能不能玩这个系列?

能,而且完全能。学习推理引擎的第一步,CPU就够了。你用一个小模型(比如几百M参数的Qwen或者BERT类的小模型),在CPU上跑,一次推理几秒钟甚至几十秒,这完全不妨碍理解推理引擎的结构和逻辑。实际上,CPU上更容易调试,因为你可以随时打印中间张量的形状和数值,看每一层的输出是否符合预期。

GPU的作用是在性能优化环节体现的。当你要跑7B以上的模型,要研究吞吐和延迟,CPU就撑不住了。到那时候你需要一张显存至少12GB以上的显卡,最好是Nvidia的,因为CUDA生态依然是推理框架的事实标准。不过如果你暂时没有GPU,可以先把这个系列的核心逻辑全部跑通,GPU相关的优化章节当作进阶内容来读。

我个人的建议是:硬件不必一步到位,但环境里最好把CUDA相关的驱动和依赖装好,后面随时可以切换。开发环境的搭建本身也是一门学问,这个我在后续章节里会专门讲一次。

5. 系列路线图与几个容易踩的坑

5.1 后续章节规划:第一章之后往哪里走

既然这是第一章,我觉得有必要把整个系列的路线图提前交代清楚,让你知道后面每一章要解决什么问题,也方便你随时回来对照。

第二章我计划写Tokenizer与输入处理。从文本到token ID的完整链路,BPE分词的原理与实现,词表相关的对齐问题。第三章写张量运算与权重加载,从权重文件格式解析到矩阵运算引擎的搭建,包括内存布局和精度问题。第四章写Transformer前向传播,重点是注意力机制的实现和多层Block的组织方式,这一章会自然引出KV Cache的实现。第五章写解码循环与采样策略,把greedy、top-k、top-p、temperature全部落地。第六章写服务化与并发调度,实现一个支持连续批处理的简单服务端,把引擎变成可以对外提供服务的系统。第七章再回头做性能优化、量化、FlashAttention这些进阶话题。

顺序上我稍微调整了一下,把服务化提前了,因为我觉得“能在本地起一个服务真正对话”这种成就感对持续学习很重要。等你已经可以用自己的推理引擎和模型对话了,再去搞量化优化会更有动力。

5.2 自己动手时最常见的四个误区

这个部分是经验之谈,也是我写这个系列最想让大家避开的坑。

第一个误区是“一上来就想写并行和优化”。我看到有些人刚开始动手就要搞多线程、搞CUDA、搞批处理调度,结果连单请求的正确性都没跑通。写推理引擎的顺序一定是先串行、后并行;先正确、后高效。没有搞清楚矩阵维度的正确性之前,所有优化都是自我感动。

第二个误区是“盲目复刻大模型架构”。你可以先用一个很小的模型跑通全流程,比如拿一个只有几层、隐藏维度很小的玩具模型。不要一上来就怼7B模型,那会让你陷入调试地狱:一个问题可能是权重加载错了、可能是注意力mask算错了、可能是采样器越界了,但错误信息看起来全是“输出乱码”。小模型跑通,大模型自然就通了。

第三个误区是“忽视数值精度问题”。FP32和FP16的计算结果在小数点后几位会有差异,注意力里的缩放因子忘没除以sqrt(d_k)、LayerNorm的epsilon有没有实现正确,这些细节上的误差在小模型上可能不致命,但在大模型上会被逐层放大,最终表现为输出质量断崖式下降。

第四个误区是“不看参考实现”。很多人觉得“自己动手”就是绝不参考任何现成代码,这是没必要的自我设限。一切学习方式可以参考HuggingFace的模型实现、参考vLLM的调度思路,关键是你自己再独立写一遍。看懂了和写出来之间还隔着成千上万行代码的距离。

结尾

说起来,我自己最初动手写推理引擎时栽过的最大的一个跟头,是花了整整一个周末调KV Cache相关的显存代码,最后发现是自己在矩阵切分时把维度搞反了。自那以后我养成了一个习惯:任何关于维度的改动,先在纸上画一个小表格,把输入输出的形状标清楚再动代码。这种“先画图后写码”的习惯,在整个系列的后面几章里会反复救你命。

如果你决定跟着这个系列走,我的第一个建议是别急着追求速度和高深优化,先让一个最小的模型在你自己的代码里完整跑通一次。哪怕一次只生成20个token、每秒只吐5个token,那也是你自己的推理引擎在工作。第一章的概览就到这,下一篇我们直接开始动手写Tokenizer。到时候,咱们代码里见。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询