看懂 Hugging Face 和模型文件介绍过,一个大模型仓库里最重要的是三类文件:配置、权重和 tokenizer。可是这些文件都只是磁盘上的数据,model.safetensors 自己不会回答问题,双击 config.json 也启动不了神经网络。
必须有一段程序读取配置、搭建网络、把权重填进去,再把用户的文字转换成张量,模型才能真正开始计算。
我们之前用 Ollama 运行模型时,这些步骤都被一条命令包起来了。这次不启动 Ollama 服务,也不调用远程 API,直接在 Python 进程里加载同一个小模型,亲眼看看一句问题经历了哪些转换:
这样跑一遍,前面几篇讲的 token、上下文、注意力,在代码里都能看到它们具体长什么样。就连平时调 API 返回的那份 OpenAI 格式 JSON,也是推理程序在最后一步自己拼出来的。
正式运行代码之前,先分清三个长得很像的名字。
Transformer 是一种神经网络架构,它最有代表性的设计就是 Attention(注意力机制)。LLM 是怎么预测下一个 token 的讲过的多头注意力和 Q/K/V,就是 Transformer 的核心结构。
目前大部分 LLM,包括本文使用的 Qwen3,都采用 Transformer 架构,也大多可以用 Hugging Face 的 transformers Python 库加载权重并运行。
transformers 库负责按模型结构加载权重并组织生成流程,真正执行矩阵乘法、张量计算的是 PyTorch 库,一个常用的 Python 科学计算库。
先跑起来看看
完整项目只有两个文件:requirements.txt 固定本次实测的依赖版本,run_inference.py 按顺序打印每一步的中间结果。
这次只需要三个 Python 包:
torch==2.5.1
transformers==4.51.3
safetensors==0.7.0torch 就是 PyTorch,transformers 提供模型结构和推理流程,safetensors 负责读取权重文件。
把上面的项目下载到本地并安装 uv,然后运行:
cd llm-inference-process
uv run --python 3.10 --with-requirements requirements.txt run_inference.py \
"What is 2 + 3? Answer in one sentence."命令最后双引号中的文字就是交给模型的问题。uv 会自动准备 Python 3.10 和依赖环境,第一次运行时,Transformers 会从 Hugging Face 下载大约 1.5GB 的模型文件,后续运行会直接使用本机缓存。
本文的输出来自 Apple M4 Max,程序会根据你的机器自动选择 GPU 或 CPU 进行计算。
我用上面的命令完整运行过一次。程序会依次打印模型结构、token、embedding、隐藏状态、logits、原始生成结果和最终回答,开头和结尾如下:
=== MODEL ===
{
"model_class": "Qwen3ForCausalLM",
"device": "mps",
"total_parameters": 596049920,
"num_hidden_layers": 28
}
... 中间输出稍后逐段解释 ...
=== USER-FACING RESPONSE ===
{
"id": "chatcmpl-local",
"object": "chat.completion",
"model": "Qwen/Qwen3-0.6B",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"reasoning_content": "Okay, the user is asking what 2 plus 3 is...",
"content": "2 plus 3 equals 5."
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 21,
"completion_tokens": 96,
"total_tokens": 117
}
}如果最后能看到 content 和 finish_reason: stop,就说明模型已经成功完成了一次推理。下面从加载模型开始,逐段拆解这些输出是怎么产生的。
把模型文件加载进内存
看懂 Hugging Face 和模型文件把模型仓库中最重要的文件分成三类:配置文件、tokenizer 文件和权重文件。下面三条加载语句正好依次读取它们。完整实现位于 :
# 读取 config.json,得到层数、隐藏维度、注意力头数等结构参数
config = AutoConfig.from_pretrained(MODEL_ID, revision=MODEL_REVISION)
# 读取 tokenizer.json 和 tokenizer_config.json
# 得到文字与 token ID 的转换规则,以及模型自带的 Chat Template
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, revision=MODEL_REVISION)
# 根据 config 创建 28 层神经网络,并从 model.safetensors 加载模型权重
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID,
revision=MODEL_REVISION,
config=config,
torch_dtype=dtype,
)
# 把模型移到 GPU 或 CPU,并切换到推理状态
model.to(device)
model.eval()AutoConfig 读取 config.json。这一步只拿到网络的说明书,还没有建立神经网络,也没有加载 1.5GB 权重。
AutoTokenizer 得到的对象管着「文字 <-> token ID」的双向转换,后面拼对话模板、编码输入、解码输出都靠它。
AutoModelForCausalLM.from_pretrained(...) 会根据 config.json 中的 model_type: qwen3 选择 Qwen3ForCausalLM,创建 28 层网络,再从 model.safetensors 加载训练好的权重。其中 CausalLM 表示模型的任务是根据前文预测下一个 token。
本机通过 打印出的主要信息如下:
{
"model_class": "Qwen3ForCausalLM",
"device": "mps",
"weight_dtype": "torch.float16",
"total_parameters": 596049920,
"vocab_size": 151936,
"hidden_size": 1024,
"num_hidden_layers": 28,
"num_attention_heads": 16
}这段 JSON 表明,Transformers 建立的是 Qwen3ForCausalLM,共有约 5.96 亿个参数。
device 的 mps 是 Apple 芯片的 GPU 后端,N 卡机器上会显示 cuda,没有 GPU 则是 cpu。仓库里存的是 BF16 权重,程序在 GPU 上把它转成 FP16 省显存,所以 weight_dtype 显示 torch.float16。
vocab_size、hidden_size 和 num_attention_heads 直接来自前面读取的 config.json,分别对应词表大小、embedding 维度和 16 个注意力头。num_hidden_layers 则是数了一遍真正建出来的网络层数,正好和配置里写的 28 对上。
messages 数组填入对话模板
模型加载好了,接下来准备输入。上下文是什么?token 怎么计费? 讲过,应用程序通常使用 messages 数组表示对话:
# 把命令行问题组织成应用程序常用的 messages 格式
messages = [
{
"role": "user",
"content": question,
}
]其中 question 就是用户传入的问题。不过,神经网络不认识 Python 列表、JSON、role 或 content。这些只是应用程序为了方便组织数据而定义的结构,真正交给 tokenizer 的仍然是一整段连续文本。
接下来,调用 apply_chat_template 将 messages 数组转化为模型认识的文本:
prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=enable_thinking,
)tokenize=False 表示暂时不转换成 token ID,只返回拼好的字符串,得到的 prompt 是:
<|im_start|>user
What is 2 + 3? Answer in one sentence.<|im_end|>
<|im_start|>assistant前文 看懂 Hugging Face 和模型文件 讲过,tokenizer_config.json 中保存着模型的对话模板。<|im_start|>、<|im_end|> 来自 ChatML 这套通用格式,Qwen 系列一直沿用:前者表示一条消息开始,后者表示消息结束,中间的 user 表示这是用户消息,其他模型可能使用完全不同的消息分隔标记。
add_generation_prompt=True 负责补上最后的 assistant,相当于告诉模型:现在轮到你继续往下写了。
对本文这种只有一条用户消息的情况,模板做的事情等价于这样一段字符串拼接:
# 开启 thinking 时留空 assistant 内容,让模型自己生成 <think>
assistant_prompt = "<|im_start|>assistant\n"
if not enable_thinking:
# 关闭 thinking 时预填一个空思考块,让模型直接生成正式回答
assistant_prompt += "<think>\n\n</think>\n\n"
# 手动拼出模型真正需要的字符串
manual_prompt = f"<|im_start|>user\n{question}<|im_end|>\n{assistant_prompt}"注意看这个 enable_thinking 并不是传给神经网络的参数,而是让 Chat Template 拼出不同的 prompt,从而控制模型是否开启思考模式。
模型在训练时就学会了先生成 <think>...</think> 这段思维链,再输出真正的回复内容。
开启思考模式时,prompt 拼到 <|im_start|>assistant 就停住,LLM 续写时自然会自己写出 <think>...</think>。
关闭思考模式时,模板替它预先填上一个空的 <think>\n\n</think>,LLM 看到标记已经闭合,就跳过思考直接给出回答。
本文用的是 enable_thinking=True,所以拼出来的 prompt 里看不到 <think>,那部分交给模型自己生成。
和 <|im_start|>、<|im_end|> 类似,不同模型的思维链标记也可能不同,也许不是 <think>...</think> 这种格式,但其本质就是一些模型训练时使用的特殊标记字符。
实际项目直接调用 apply_chat_template 方法,读取模型仓库自带的模板组装文本,就不会出错。
Tokenizer 把字符串变成张量
得到模板字符串后,程序才:
# 根据 tokenizer.json,把 prompt 字符串转换成 token ID 张量
inputs = tokenizer(
prompt,
add_special_tokens=False,
return_tensors="pt",
).to(device)
# 打印 token ID 的详细情况
print_section("TOKENS")
input_ids = inputs["input_ids"][0]
for index, token_id in enumerate(input_ids.tolist()):
piece = tokenizer.decode([token_id], skip_special_tokens=False)
print(f"{index:02d} id={token_id:6d} text={piece!r}")
print(f"input_ids.shape = {list(inputs['input_ids'].shape)}")模板已经把 <|im_start|> 这些标记写进字符串了,add_special_tokens=False 是不让 tokenizer 再按自己的规矩往两端补边界 token。return_tensors="pt" 中的 pt 表示返回 PyTorch 张量,末尾的 .to(device) 把张量搬到前面选好的 GPU 或 CPU 上。
这段模板最终得到 21 个 token,程序打印的开头和结尾如下:
00 id=151644 text='<|im_start|>'
01 id= 872 text='user'
02 id= 198 text='\n'
03 id= 3838 text='What'
04 id= 374 text=' is'
05 id= 220 text=' '
06 id= 17 text='2'
07 id= 488 text=' +'
08 id= 220 text=' '
09 id= 18 text='3'
...
16 id=151645 text='<|im_end|>'
17 id= 198 text='\n'
18 id=151644 text='<|im_start|>'
19 id= 77091 text='assistant'
20 id= 198 text='\n'
input_ids.shape = [1, 21]注意 " is"、" +" 前面带着空格,说明空格也是 token 内容的一部分,<|im_start|> 等控制标记和换行同样有自己的整数 ID。
这 21 个整数 ID 保存在 input_ids 张量中,最后一行打印的 [1, 21] 就是它的 shape:开头的 1 表示当前只有一条输入,21 表示这条输入共有 21 个 token。
这些整数 ID 还不能直接参加注意力计算,模型首先用 :
# 在 embedding 权重矩阵中查表,把每个 token ID 换成 1024 维向量
embeddings = model.get_input_embeddings()(input_ids)
print_section("EMBEDDINGS")
print(f"embeddings.shape = {list(embeddings.shape)}")打印结果是:
embeddings.shape = [1, 21, 1024]21 个 token 各自变成一个 1024 维向量,这就和前文打印出的 vocab_size: 151936、hidden_size: 1024 对上了:embedding 矩阵共有 151,936 行,每行 1024 个数。token ID 是表格的行号,取出的那一行就是对应 token 的初始向量。
token 预测的完整流程
是本文为了观察模型内部结果而编写的。它让数据从输入一路走到输出跑一遍(这叫一次前向计算),同时要求模型保留每一层的输出:
# 让输入向量穿过 28 层 Transformer,并保留每一层的输出
with torch.inference_mode():
outputs = model(
**inputs,
output_hidden_states=True,
use_cache=False,
)
print(f"hidden_states.count = {len(outputs.hidden_states)}")
print(f"hidden_states[0].shape = {list(outputs.hidden_states[0].shape)}")
print(f"hidden_states[-1].shape = {list(outputs.hidden_states[-1].shape)}")
first_values = outputs.hidden_states[0][0, -1, :6].float().cpu().tolist()
last_values = outputs.hidden_states[-1][0, -1, :6].float().cpu().tolist()
print(f"last token before layer 0 = {[round(value, 5) for value in first_values]}")
print(f"last token final state = {[round(value, 5) for value in last_values]}")use_cache=False 关掉的是 KV cache,这里只看单步计算用不上它,后面逐个生成 token 时才需要打开。
这里的 hidden state(隐藏状态),就是每个 token 在各个阶段得到的 1024 维向量。程序实际得到:
hidden_states.count = 29
hidden_states[0].shape = [1, 21, 1024]
hidden_states[-1].shape = [1, 21, 1024]为什么是 29 组?第 0 组是 embedding 后的初始向量,中间各组记录逐层状态,最后一组是经过第 28 层、再做一次统一归一化后的最终隐藏状态。
取最后一个输入 token 的前 6 个数字比较,本机输出是:
last token before layer 0 = [-0.0415, 0.05225, -0.06348, -0.03857, 0.02466, -0.00522]
last token final state = [2.38672, -64.6875, 0.03601, 16.92188, 0.49951, -5.17969]两边都是 1024 维,但具体数字已经完全不同。每层的注意力让 token 吸收前文信息,经过 28 轮后,最后一个位置的向量已经包含整段输入的信息。
还包含一个名为 logits 的张量,用最终的隐藏状态经过一些运算,就得到了词表中 151,936 个 token 的分数。
Transformers 对因果语言模型输出的官方定义也是「softmax 之前、对词表中每个 token 的预测分数」。
本次输出的 shape 是:
logits.shape = [1, 21, 151936]三个数字分别表示:一条输入、21 个输入位置、词表中的 151,936 个候选 token。也就是说,模型为每个位置都算了一份完整词表分数。
生成下一个 token 时只需要最后一个位置,所以程序只取最后那一份分数,再用 把它转换成总和为 1 的概率:
# 取最后一个位置的 logits,再转换成下一个 token 的候选概率
probabilities = torch.softmax(outputs.logits[0, -1].float(), dim=-1)概率最高的 5 个候选是:
id=151667 token='<think>' logit=30.5938 probability=0.999660
id=151644 token='<|im_start|>' logit=20.4375 probability=0.000039
id=151645 token='<|im_end|>' logit=19.6250 probability=0.000017
id=151668 token='</think>' logit=19.5469 probability=0.000016
id= 33137 token='enson' logit=18.7812 probability=0.000007<think> 的概率达到 99.966%,符合我们的预期:Chat Template 开启了 thinking,assistant 消息开始后,模型首先生成思考区的开始标记。
这就是 LLM 是怎么预测下一个 token 的所说的「给整个词表打分,再选出下一个 token」。这一份分数不是一句回答,只是下一个 token 预测的概率分布。
循环预测,生成完整回答
前面那次前向计算是我们自己调 model(**inputs) 跑的,一次只够预测出一个 token。想得到完整回答,就要把新 token 接到输入末尾,再预测一次,如此循环。
在实际开发中,这个循环不用自己写。Transformers 库封装了一个 generate 方法,调用一次就会自动帮我们跑这个流程,用的就是它:
# 固定随机种子,让这次实验的输出可以复现
torch.manual_seed(42)
# generate 会重复执行“计算 logits -> 选择 token -> 追加到末尾”
with torch.inference_mode():
generated_ids = model.generate(
**inputs,
# 限制生成的 token 数量上限
max_new_tokens=256,
do_sample=True,
# 按照模型卡推荐设置 temperature, top_p, top_k 等参数
temperature=0.6,
top_p=0.95,
top_k=20,
pad_token_id=tokenizer.pad_token_id,
eos_token_id=model.generation_config.eos_token_id,
# 开启 kv cache
use_cache=True,
)thinking 模式按照 Qwen3 模型卡的建议启用采样,使用 temperature=0.6、top_p=0.95 和 top_k=20。
generate 做的事情和前面的单步实验类似:计算最后位置的 logits,按生成参数选择一个 token,把它追加到序列末尾,再计算下一轮,直到遇到结束 token 或达到长度限制。
这次输入有 21 个 token,输出有 96 个 token,完整序列共有 117 个 token:
input token count = 21
full token count = 117
response token count = 96generated_ids 包含「原始输入 + 新生成内容」,所以程序可以根据输入长度切片:
# 获取输入的 token 长度
input_length = inputs["input_ids"].shape[1]
# 切掉原始输入,只保留模型生成的部分
response_ids = generated_ids[0, input_length:]response_ids 才是模型新生成的 96 个 token ID。完整列表很长,大概长这样:
[151667, 198, 32313, 11, 279, ..., 624, 151668, 271,
17, 5519, 220, 18, 16819, 220, 20, 13, 151645]其中大部分 ID 都是 vocab.json 那份基础词表里的词,但有少数是记在 tokenizer.json 的特殊 token 列表中,比如:151667 是 <think>,151668 是 </think>,最后的 151645 是 <|im_end|>。它们已经把思考区、正式回答和消息结束位置标出来了。
组装结构化响应
根据词表把所有输出 token 进行 decode,能看到模型最原始的输出:
<think>
Okay, the user is asking what 2 plus 3 is. Let me think. Well, basic
arithmetic. Adding two numbers together. So 2 plus 3 would be 5. But
they want the answer in one sentence. Let me make sure I'm not mixing
up anything. No, addition is straightforward. So the answer is 5. Just
need to phrase it clearly without any extra words.
</think>
2 plus 3 equals 5.<|im_end|>模型并不直接返回 choices、content 和 usage 这些结构化字段,而是生成了上面这一串文本。
推理程序只需要做一些,把 <think>...</think> 中间的内容作为模型的思维链 thinking_ids,把剩余部分作为最终的输出 answer_ids:
# 查出 <think> 和 </think> 对应的整数 ID
token_ids = response_ids.tolist()
think_start_id = tokenizer.convert_tokens_to_ids("<think>")
think_end_id = tokenizer.convert_tokens_to_ids("</think>")
# 正常情况:以 </think> 为界,前面是思考,后面是正式回答
if think_end_id in token_ids:
split_at = token_ids.index(think_end_id)
thinking_start = 1 if token_ids[0] == think_start_id else 0
thinking_ids = response_ids[thinking_start:split_at]
answer_ids = response_ids[split_at + 1:]
# 可能出现长度截断:出现 <think>,但还没有生成 </think>
elif token_ids and token_ids[0] == think_start_id:
thinking_ids = response_ids[1:]
answer_ids = response_ids[:0]
# 没有 thinking 标记:全部 token 都是正式回答
else:
thinking_ids = response_ids[:0]
answer_ids = response_ids
# 最后把两段 token ID 分别还原成文字
# 就得到了思维链和最终回复两部分内容
reasoning = tokenizer.decode(thinking_ids, skip_special_tokens=True).strip()
content = tokenizer.decode(answer_ids, skip_special_tokens=True).strip()skip_special_tokens=True 会过滤 <|im_end|> 等控制标记。程序再检查最后一个 ID 是否属于生成配置 generation_config.json 里列出的结束 token,得到 finish_reason: stop;如果达到 256 个新 token 的长度上限,则得到 finish_reason: length,这些字段都是应用程序根据控制标记和停止位置整理出来的。
程序最后把这些信息组装成一份结构化结果:
{
"id": "chatcmpl-local",
"object": "chat.completion",
"model": "Qwen/Qwen3-0.6B",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"reasoning_content": "Okay, the user is asking what 2 plus 3 is. Let me think. Well, basic arithmetic. Adding two numbers together. So 2 plus 3 would be 5. But they want the answer in one sentence. Let me make sure I'm not mixing up anything. No, addition is straightforward. So the answer is 5. Just need to phrase it clearly without any extra words.",
"content": "2 plus 3 equals 5."
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 21,
"completion_tokens": 96,
"total_tokens": 117
}
}这个格式应该很眼熟:如何调用 LLM 接口里用 curl 调 DeepSeek,拿回来的响应就是这个结构,回答同样放在 choices[0].message.content 里。
usage 里的 21、96、117 就是前面数出来的输入、输出和总 token 数。你调云端 API 时按 token 计费,数的也是这些。
所以 API 中看起来很规整的字段,其实位于原始模型之外:模型负责生成连续 token,推理程序负责识别控制 token、切分内容并包装成结构化的 JSON。
总结
项目的 main 函数把前面看过的步骤按顺序串起来:
def main():
# 第 1 步:读取用户问题
args = parse_args()
# 第 2 步:读取 config、Tokenizer 和权重文件
config, tokenizer, model, device = load_model()
# 观察用:对照配置和真实权重,打印模型结构
inspect_model(config, model, device)
# 第 3 步:用户问题 -> Chat Template -> token ID
inputs = prepare_input(
tokenizer,
device,
args.question,
enable_thinking=True,
)
# 观察用:单跑一次前向计算,看 embedding、隐藏状态和 logits
inspect_forward(model, tokenizer, inputs)
# 第 4 步:循环生成 token,再拆出 thinking 和正式回答
generate_answer(model, tokenizer, inputs)你可以直接修改运行命令最后的问题,观察 token 数、最后位置的 logits、thinking 和正式回答如何一起变化。
这个 demo 没做图形界面、流式响应和多轮对话,它们都属于模型外层的应用功能,加上去也只是继续处理输入输出,不会改变模型内部的推理过程。
这次我们没有通过对话 App 或远程 API,而是把 Hugging Face 仓库里的文件直接加载进 Python 进程,走完了一次真实推理。
网络最后为词表中的 151,936 个 token 计算 logits,generate 重复执行下一 token 预测,得到包含 <think>、</think> 和 <|im_end|> 的原始 ID 序列。推理程序最后切分并 decode 这些 ID,再按 OpenAI 的响应格式包装成 JSON,就是你调云端 API 时拿到的那份结果。
模型文件、神经网络、tokenizer、Chat Template 和 API 返回值,到这里就连成了一条真正运行过的数据链。