破译大模型推理:从下载权重到 vLLM 高效解码的核心内幕
我第一次下载开源模型时,也很自然地把那个十几 GB 甚至上百 GB 的目录,当成了一个“离线版 ChatGPT”。但把文件解压出来,里面既没有可双击运行的聊天窗口,也没有一段会自己回答问题的程序。真正躺在磁盘上的,大多是一套模型资产;只有当推理引擎把它们装载到 CPU/GPU 上,模型才开始按规则计算下一个 token。
先把最重点说清:**“开源模型只开源权重、没有算法”这句话不够严谨。**一个发布仓库可能同时提供权重、模型架构配置、分词器、推理代码,甚至训练代码;也可能只给权重和配置。对普通下载者而言,体积最大、最显眼的确实是权重。Transformer 的通用计算方法和 vLLM/Ollama 这样的运行时,则是让这些权重真正产生文本的另一半。
你下载的模型目录,通常装了什么
可以把它分成三类,而不是笼统叫作“模型程序”。
- 权重(weights):常见为
.safetensors或分片文件。里面是大量浮点数矩阵,是训练把语言规律压缩进去后的参数;它们像长期记忆,却不会主动执行。 - 分词器(tokenizer):如
tokenizer.json、vocab.json、merges.txt。它规定文字怎样切块、每个块对应哪个整数 ID。模型不直接看汉字或英文单词,只看 ID。 - 配置与实现约定:如
config.json、generation_config.json、聊天模板、特殊 token 定义。它告诉引擎层数、隐藏维度、注意力头数、词表大小,以及“对话从哪里开始、哪里结束”。
还有一个容易忽略的点:权重必须与正确的网络结构匹配。把 Qwen 的权重塞进一个不认识 Qwen 架构的引擎,或用错分词器,就像拿到一箱齿轮却装进了错误型号的发动机——数字还在,意义却接不上。
引擎做的第一件事:把句子变成数字
假设我输入“北京今天下雨吗?”。推理引擎先把聊天模板、系统提示和这句话拼成模型约定的输入格式,再调用分词器。输出不会是“北京”这样的字符串,而会是一串 token IDs,例如 [151646, 8948, ...];具体数字因模型而异。
Token(词元):模型读写的最小离散单位。它可能是一个汉字、一个词的一部分、标点,或一段常见字符串,并不等同于“一个字”或“一个单词”。
接下来发生的是 Embedding(向量化)。引擎以每个 ID 为行号,在权重中的嵌入表查出一整行浮点数:
[batch, seq_len] token IDs
↓ Embedding 查表
[batch, seq_len, hidden_size] 稠密向量
若 hidden_size=4096,每个 token 会变成 4096 个数的向量。维度不是越大越“聪明”的单一开关;它是模型在容量、表达能力、参数量和算力间作出的架构选择。更高维通常能承载更丰富的特征,也会带来更多参数和计算。
Transformer:每一层都在“看上下文,再加工自己”
嵌入向量进入几十层 Transformer。每层的主线可以简化成两步:注意力让 token 互相参考;前馈网络让每个位置的特征经过非线性加工。残差连接把旧信息绕路保留下来,RMSNorm/LayerNorm 则把数值尺度维持在稳定范围。
注意力不是搜索引擎,而是可学习的加权阅读
每个位置的向量经三套不同权重投影,得到 Q、K、V:
- Q(Query,查询):当前位置正在寻找什么线索。
- K(Key,键):每个已出现位置可被匹配的“索引”。
- V(Value,值):匹配后真正可带走的信息。
模型计算 Q 与所有 K 的相似度,除以 √d 后做 softmax,得到权重;再用这些权重混合 V。除以 √d 的目的不是“防止梯度消失”这么简单:它首先让点积在维度变大时不至于数值过大,避免 softmax 过早变得极端;训练时也因此更稳定。推理阶段没有反向传播,但这个缩放仍是模型定义的一部分。
自回归生成还会加上因果遮罩(causal mask):第 10 个 token 可以看 1 到 10,不能偷看第 11 个。多头注意力则是同时做多组 Q/K/V 投影;可以把它想成多位专家并行观察不同关系,再把结果合并,而不是每个头各自写一段答案。
FFN 是注意力之后的“非线性加工厂”
注意力主要负责跨位置传递信息,但仍不足以表达复杂变换。随后每个 token 独立通过前馈网络(FFN/MLP),通常是升维、激活、再降维。现代模型常用 GeLU、SwiGLU 等激活函数。
若全是线性层,任意多层叠起来依然可合并为一层线性变换,无法拟合复杂规律;激活函数提供了关键的非线性。注意力子层和 FFN 子层旁边的残差连接,保证深层模型既能改变信息,也不轻易丢掉原始路径。
最后一层如何决定“下一个字”
同样的 Transformer 块重复 N 次后,最后一个位置的隐藏向量进入 LM Head(语言模型输出层)。它把 hidden_size 维向量映射到“词表大小”维,产生每个候选 token 的 logits。
Logits 不是概率。经过 softmax 才变成概率分布;例如“。”可能是 0.31,“, ”是 0.18,另一个 token 是 0.02。引擎接着按照解码策略选一个:
- 贪婪解码:永远选概率最高者,稳定但容易保守或重复。
- 温度(temperature):调节分布的尖锐程度;低温更确定,高温更发散。
- Top-p / nucleus sampling:只在累计概率覆盖 p 的候选集合中抽样,砍掉很长的低概率尾部。
选出的 ID 被交给分词器解码为文本片段,再通过 HTTP 流式响应送到界面,于是我们看到它“一个字一个字蹦出来”。更准确地说,它是一个 token 一个 token返回;中文时常看起来像逐字,英文或代码时一次可能多出几个字符。
它为什么会停:不是 EOF,而是 EOS 和停止条件
这里最容易把文件系统概念套错。**EOF(End Of File)是读取文件时的结束信号,不是语言模型通常用来停止生成的标志。**模型的词表里会定义特殊的 EOS(End Of Sequence)token。训练时,它学习到一个回答自然结束后,EOS 往往是合适的下一个 token;推理引擎一旦采样到 EOS,通常停止继续生成。
实际服务还会叠加几道护栏:达到 max_tokens、匹配到 API/聊天模板定义的 stop token 或 stop string、客户端主动断开、达到超时或安全策略限制,都可以结束本轮输出。因此“模型知道该停”并非一个神秘开关,而是模型概率、特殊 token 与引擎规则共同作用的结果。
vLLM 和 Ollama 究竟各自做了什么
它们并不把权重“改写成回答”,而是承担运行时职责:读入模型资产,创建对应的计算图和 GPU 张量,调用高效算子,调度请求,执行解码,并把 token 流回传。
对于单次请求,流程分两段:
- Prefill(预填充):把整段提示词一次性过模型,构造每层的 K/V 状态,并得到第一个待生成 token 的分布。
- Decode(解码):每次只把新 token 送入模型,读取历史 K/V,预测再下一个 token,循环直到停止。
没有缓存的话,每生成一个 token 都要重算整段历史的 K/V,代价会越来越高。KV Cache(键值缓存)把历史 token 在每一层得到的 K/V 保存起来,后续只计算新增 token。vLLM 的 PagedAttention 再把这份缓存按块分页管理,像操作系统管理内存页一样减少碎片、让多个请求更灵活地共享 GPU 显存;这也是它特别擅长高并发服务的核心原因之一。
Ollama 更侧重把本地模型运行、下载管理、量化格式和 API 体验打包得方便;vLLM 更侧重服务端批处理、调度与吞吐。两者底层细节和支持格式不同,但都在做同一类事:让静态权重按照模型结构,在硬件上完成下一 token 预测。
一句话记住整条链路
输入文本 → 聊天模板与分词 → token IDs → Embedding
→ [注意力 + FFN + 残差/归一化] × N 层 → LM Head logits
→ 解码采样 → token 流式返回 → EOS/停止规则结束
下次看到一个模型仓库时,可能需要了解:我拿到的是哪些资产?引擎是否认识它的架构与分词器?推理时又采用什么量化、缓存和采样策略?把这三件事搞清楚,“下载模型”和“模型开始说话”之间那层曾经模糊的黑箱,就清楚了。
下一篇我会再推进一层:KV Cache 为什么占显存、连续批处理如何把多个人的请求交错执行,以及 PagedAttention 怎样避免“显存还很多却放不下新请求”的尴尬。