用Transformers库跑通DeepSeek-R1
简介
今天尝试使用hugging的transformer库跑通litert-community/DeepSeek-R1-Distill-Qwen-1.5B · Hugging Face
Qwen-1.5B 基底:它使用了阿里云开源的 Qwen2.5-1.5B 作为基础架构,参数量只有 15 亿(1.5 Billion),天生体积小、运行速度快。
DeepSeek-R1 蒸馏:DeepSeek 把他们那个 6710 亿参数、拥有强大推理思维能力的巨型模型(DeepSeek-R1)所产生的思维链(Chain of Thought)数据和推理样本,喂给了这个 1.5B 的小模型。
核心能力:这意味着它虽然只有 15
亿参数,却继承了 DeepSeek-R1 的“思考”习惯(会在
<think>
标签内进行自我纠错、逻辑推理),在数学、代码和逻辑推理上,性能远超普通的
1.5B 级别模型。
tranformer库和pytorch的区别
PyTorch(底层深度学习框架)
- 角色:通用数学和张量(Tensor)计算库,专门为深度学习设计。
- 干什么:它提供最基础的算子。比如图下半部分看到的
Softmax、Linear(全连接层)、矩阵乘法、梯度反向传播,以及直接对接显卡硬件(NVIDIA CUDA、AMD ROCm、Apple Metal)的驱动支持。 - 特点:它本身不知道什么是大语言模型,什么是 Llama 或 DeepSeek。它只知道如何高效地在 GPU 上做矩阵加减乘除。
Transformers 库(上层模型生态库)
- 角色:由 Hugging Face 开发的、专为 Transformer 架构模型定制的高级封装库。
- 干什么:它把 PyTorch 提供的基础算子(Linear、Softmax 等)拼接起来,组装成一个个具体的经典模型结构(如 DeepSeek、Qwen、Llama)。
- 特点:它让你不需要从零去写注意力机制(Attention)的数学公式,直接通过几行代码(如
AutoModelForCausalLM.from_pretrained('deepseek-ai/DeepSeek-R1'))就能把整个模型跑起来。
下载模型
配置环境
1 | uv add transformer huggingface_hub |
1 | import os |
加载模型
先查看模型文件夹下的config.json文件
1 | { |
在这个文件里:
"architectures": ["Qwen2ForCausalLM"]"model_type": "qwen2"
说明这个模型底层架构为qwen2因果解码器
1 | from transformers import Qwen2ForCausalLM |
下面的AutoModelForCausalLM可以通过读取config文件,使用正确的架构加载模型
什么是ForCausalLM(因果语言模型)
ForCausalLM(因果语言模型)——
只能看左边,猜右边
这种模型在训练和推理时,就像是在看一部正在播放的电影。
通俗例子:
假设训练数据是一句话:“我 喜欢 吃 南京 盐水鸭”
当模型处理到 “吃” 这个字时:
- 它能看到什么:
“我 喜欢”(左边的历史信息,它100%看得见)。 - 它被隐藏了什么:
[南京 盐水鸭](右边的未来信息。模型被戴上了特殊的“向后看”的眼罩,技术上叫因果掩码,强行把右边的字遮住)。 - 它的任务:根据
“我 喜欢 吃”,去猜后面接“南京”的概率是多少。
为什么必须隐藏后面的? 因为如果训练时不把右边的
“南京 盐水鸭”挡住,模型就会直接去偷看标准答案,这就成了“作弊”。这样训练出来的模型在实际聊天时,由于没有未来可以偷看,就会彻底废掉。
ForMaskedLM(掩码语言模型)——
整句话全看见,做完形填空
这就对应了你说的“一整句话都能看到”的模型。它在训练时不需要戴单向眼罩。
通俗例子:
同样是这句话:“我 喜欢 吃 南京 盐水鸭”
AI 老师在训练它时,直接把整句话拍在它面前,但是用黑笔随机涂黑(Mask)了一个词:
- 输入给模型的:
“我 喜欢 吃 [ ❌ ] 盐水鸭” - 它能看到什么:它能同时看到左边的
“我 喜欢 吃”和右边的“盐水鸭”。整句话的上下文它尽收眼底。 - 它的任务:像做高考完形填空一样,结合两边的线索,推测被涂黑的
[ ❌ ]里面有高概率是“南京”。
qwen与deepseek架构的区别
区别一:前馈网络(FFN)的“大一统” vs “MoE 混合专家”
这是两家最本质的区别。
- Qwen(Dense 稠密流派): Qwen2.5-72B 采用的是标准的
Dense 结构。这意味着无论是算
1+1=这种简单的算术题,还是写复杂的 Linux 内核代码,模型在每一层里的前馈网络(FFN)都是全员上阵。720亿参数全部参与计算。- 优缺点:对硬件非常友好,算子成熟,但在参数量极大时,计算成本(算力消耗)呈线性飙升。
- DeepSeek(MoE 专家流派): DeepSeek-V3 采用了独创的
DeepSeekMoE 架构。它把原本一个巨大的 FFN 拆分成了
1个共享专家(Shared Expert) 和
256个路由专家(Routed Experts)。
- 独特设计:当一个 Token 传过来时,共享专家永远参与计算(抓取全局共性知识),而路由专家中只有最对口的 2 个会被激活(抓取垂直专业知识)。
- 恐怖的性价比:DeepSeek-V3 的总参数量高达 6710亿(671B),但因为 MoE 的存在,每个 Token 进来时实际上只激活了 370亿(37B) 参数。这就是为什么它能用极低的算力成本,抗衡甚至超越其他几千亿规模的稠密模型。
区别二:注意力机制的“GQA” vs “MLA(低秩多头注意力)”
在处理上下文和长文本记忆(KV Cache)时,两家拿出了不同的看家本领。
- Qwen(采用 GQA): 正如我们在它的
config.json里看到的,Qwen 采用的是 GQA(Grouped-Query Attention)。它让多个 Query 头共用一组 KV 头。这在 2024~2026 年是行业标准设计,已经能把显存占用砍掉一大截。 - DeepSeek(独创 MLA): DeepSeek 觉得 GQA
还不够省。他们发明了 MLA(Multi-head Latent
Attention,低秩多头注意力机制)。
- 硬核原理:传统的 GQA 随着上下文变长,显存里的 KV 缓存依然会线性增加。而 MLA 在计算注意力时,通过矩阵低秩分解(Low-rank Compression)技术,把原本巨大的 KV 矩阵压缩成了一个极小的“特征向量”(Latent Vector)存进显存里。在真正计算的一瞬间,再在显存里动态把它解压放大恢复出来。
- 效果:DeepSeek-V3 的 MLA 技术,让它的 KV Cache 显存占用直接暴降了 93%。这意味着同样的显卡硬件,DeepSeek 可以容纳超出 Qwen 数倍的并发量(Batch Size)或更长的上下文交互。
加载分词器
1 | from transformers import AutoTokenizer |
可以查看models-R1-Distill-Qwen-1.5B.json
1 | { |
1 | inputs = tokenizer( |
padding的作用
模型会把多个句子打包成一个批次(Batch)同时塞给模型同时预测的,但是批量计算要求输入必须是一个完美的矩形矩阵(行和列的数量必须严格相等),所以在句子长度不一的时候,需要设置padding,也就是填充[PAD]
1 | 行1 ──> [ 你 , 叫 , 什么 , 名字 , 呀 , ? ] (原本就最长,无需Padding) |
分词器还会同时生成一个 attention_mask
矩阵,防止Attention 难被无意义的 [PAD] 干扰
1 | 行1 ──> [ 1, 1, 1, 1, 1, 1 ] (全看) |
Padding Side的作用
既然有attentionmask了,为什么还要填充方向(),填充到右边不也行吗
致命原因一:位置编码(RoPE)完全错乱
现代大模型(如 Qwen2、DeepSeek)使用的是 旋转位置编码(RoPE)。模型必须要知道每个 Token 在句子里的绝对位置(它是第几个字),才能理解语法。
假设有两句话同时预测,最大对齐长度是 6:
- 句子 A 只有 3 个字:
“真 漂 亮” - 句子 B 有 6 个字:
“南京 师范 大学”
❌ 如果填充在右边(Right Padding):
1 | 【第1步:输入矩阵】 |
第一步计算时,有 attention_mask 挡着,句子 A 只看了前 3
个字,成功预测出下一个字是 “啊”。
但是,接下来模型要同时生出第 2 个字了(自回归下一步):
新生成的字 “啊” 必须拼到句子的末尾。
- 句子 B 的
“学”在位置 5,所以新生成的字在 位置 6。 - 句子 A 的
“亮”在位置 2,新生成的字“啊”应该在 位置 3。
灾难发生了:在矩阵计算中,显卡必须整齐划一地把新字拼在矩阵的最后一列(位置 6)。
这导致句子 A 的 “啊” 被强行赋予了 位置
6 的位置编码!
在模型眼里,这个句子变成了:“真(0) 漂(1) 亮(2) ...空了3个位置... 啊(6)”。位置编码的断层会直接让自注意力机制彻底报废,模型开始疯狂吐胡话。
如果填充在左边(Left Padding):
1 | 【第1步:输入矩阵】 |
通过特殊的处理,左边的 [PAD] 位置编码直接被无视。
重点看右边:句子 A 的末尾 “亮” 在位置 3,句子 B 的末尾
“学” 在位置 6。
当模型同时吐出新字时,它们都可以规整地统一拼在矩阵的右侧边框(也就是下一列),各自的位置编码能够丝滑地递增(位置 3 变 4,位置 6 变 7),绝对不会发生空间断层。
致命原因二:自回归推理的“长文本记忆(KV Cache)”无法对齐
大模型为了单字蹦字变快,内部有一套叫 KV Cache 的机制——它会把前面已经算过的词的 K 和 V 矩阵存存在显存里,下一次算新字时,直接复用旧的,不重新计算。
显卡在批量(Batch)计算时,要求每一行的记忆长度必须是一致的。
❌ 如果 Padding 在右边:
1 | 行 1 (有效记忆3个) ──> [ K1, K2, K3, [PAD], [PAD], [PAD] ] ──> 接下来新字在这里 💥 撞墙了 |
当自回归进行到第二步、第三步时,最新的 Token 需要不断追加到 KV 缓存的末尾。
第一行的末尾被 [PAD] 占着坑,新词进不来;如果强行覆盖
[PAD],第一行的有效长度只有 3,第二行有效长度是 6,这导致
KV 缓存矩阵的各行长度不一致。
显卡的核心在面对这种“参差不齐”的动态矩阵时,根本无法做并行的矩阵乘法。
如果 Padding 在左边:
1 | 行 1 ──> [ [PAD], [PAD], [PAD], K1, K2, K3 ] ──> 最新插入点 ──> [这里完美对齐] |
因为 [PAD]
稳稳地呆在左边底座,所有句子的动态增长前沿(右侧边缘)是完全对齐的。
每蹦出一个新字,所有的句子都可以整齐划一地向右集体拓宽一列。显存里的 KV Cache 矩阵始终是一个标准的、完美的矩形,显卡可以用 O(1) 的极高效率并行吞吐。
模型推理
1 | output=model.generate( |
使用模型进行推理后面的向量
1 | tokenizer.batch_decode(output) |
解码翻译成人类语言
但是目前模型的回答是这样
1 | ["<|end▁of▁sentence|><|end▁of▁sentence|>Hello, how are you? I'm trying to understand some concepts about the electromagnetic spectrum. Could you explain the different parts of it and their relationships? Also, what's the significance of each part?\n\nCertainly! The electromagnetic spectrum is a range of all possible wavelengths of electromagnetic radiation.", |
很明显是有问题的,为什么呢
它在预训练阶段(图中的第 1 步)学到的本领是自回归续写(盲猜下一个字)。它习惯了看到普通文章,就顺着普通文章的语法往下编。
但是,现代的对话/推理模型(如
DeepSeek-R1、Qwen2.5-Instruct)是通过微调(图中的第 2
步)训练出来的。为了让它知道什么时候该“闭嘴”、什么时候该“思考(<think>)”、什么时候该“回答”,官方在训练它时,强行给它定了一套严格的特殊符号包围圈。
如果你直接把普通的字符串 "Hello, how are you?"
喂给它,对它来说格式是完全陌生的(它找不到应有的标记)。
添加标签
1 | message=[ |
tokenizer.apply_chat_template是调用models-R1-Distill-Qwen-1.5B_config.json中的chat_template Jinja2 模板,将对话添加标签
Jinja2 本质上是 Python 编程世界里最著名、使用最广泛的 “文本自动化渲染引擎”(Template Engine)。
在代码层面上,它的工作公式非常纯粹:
$$\text{Jinja2 模板 (死标签)} \quad + \quad \text{Python 变量 (活数据)} \quad \xrightarrow{\text{Render (渲染)}} \quad \text{最终的纯文本字符串}$$
1 | ['<|end▁of▁sentence|><|end▁of▁sentence|><|end▁of▁sentence|><|begin▁of▁sentence|><|User|>Hello, how are you?<|Assistant|><think>\n', |
add_generation_prompt=True会在最后添加<|Assistant|><think>\n用来引导模型生成
1 | output=model.generate( |
最后再让模型推理,便能正确生成答案了
1 | ['<|end▁of▁sentence|><|end▁of▁sentence|><|end▁of▁sentence|><|begin▁of▁sentence|><|User|>Hello, how are you?<|Assistant|><think>\nAlright, the user greeted me with "Hello, how are you?" which is a friendly and open way to start a conversation.\n\nI should respond in a similar positive manner to keep the conversation going smoothly.\n\nI need to make sure my response is welcoming and inviting them to share what they\'re up to.\n\nKeeping it simple and open-ended is key here.\n</think>\n\nHello! I\'m doing well. How can I assist you today?<|end▁of▁sentence|><|end▁of▁sentence|><|end▁of▁sentence|><|end▁of▁sentence|><|end▁of▁sentence|><|end▁of▁sentence|><|end▁of▁sentence|><|end▁of▁sentence|><|end▁of▁sentence|>', |
为什么会蹦出这么多
<|end▁of▁sentence|>?
大模型提前说完了话,它会吐出一个终止符,也就是
<|end▁of▁sentence|>(意为“句子结束”)。
但是,由于这是批量推理,第一句话的通道虽然无话可说了,但第二句话的通道还没写完,显卡不能停,必须硬着头皮继续往后算满剩下的步数。
为了保持矩阵的绝对规整,显卡在接下来的每一步里,只能在第一句的通道后面疯狂重复填充“终止符”来凑数:
1 | 【显卡最终吐出的 output 矩阵物理外观】 |
因为显卡矩阵在右侧必须对齐,短句子通道在“无话可说”到“等长句子写完”之间的所有空白格,全部被强行塞满了
<|end▁of▁sentence|>。