13 KiB
第 3 课:CLI 推理流程
这一课只解决一个问题:用户输入一句话后,MiniMind 如何把它变成模型输入,再生成回复。
目录
- 0. 本节主线
- 1. 本节要懂的 5 个原理
- 2. 变量流转
- 3. 原理一:模型加载
- 4. 原理二:对话模板
- 5. 原理三:Tokenization
- 6. 原理四:自回归生成
- 7. 原理五:Decode
- 8. 实验验证
- 9. 本节检查
- 10. 下一课
0. 本节主线
CLI 推理的本质是:
加载 tokenizer 和模型
-> 把用户消息用 chat template 包装成 prompt
-> 把 prompt tokenizer 成 input_ids
-> 让模型自回归生成新 token
-> 只 decode 新生成 token 作为回复
这条链对应本节的 5 个原理:
模型加载 -> 对话模板 -> Tokenization -> 自回归生成 -> Decode
本节不是命令手册。你要学的是这 5 个推理原理,以及源码里哪里实现了它们。
1. 本节要懂的 5 个原理
| 原理 | 你要懂什么 | 源码实现 |
|---|---|---|
| 模型加载 | 推理必须同时有 tokenizer、模型结构、模型权重 | eval_llm.py:12-30 |
| 对话模板 | 聊天模型不是直接吃用户原文,而是吃 chat template 包装后的 prompt | eval_llm.py:71-78 |
| Tokenization | 模型不处理字符串,只处理 token id 和 attention mask | eval_llm.py:78 |
| 自回归生成 | 模型一次预测一个 next token,循环追加到已有 token 后面 | eval_llm.py:82-87, model/model_minimind.py:257-287 |
| Decode | 生成结果仍是 token id,需要切掉 prompt 部分再 decode 成文本 | eval_llm.py:88 |
学完本节,你不需要懂 attention、loss、SFT labels、LoRA、RL。那些是后面的课。
2. 变量流转
把主线对应到代码里的变量名:
prompt: 用户输入的字符串
conversation: [{"role": "user", "content": prompt}]
inputs: tokenizer 输出的字典,包含 input_ids / attention_mask
generated_ids: prompt token + 新生成 token
response: 新生成 token decode 后的文本
3. 原理一:模型加载
推理要准备三件东西:
tokenizer:负责字符串和 token id 互转
model config / architecture:负责创建模型结构
model weights:训练好的参数
看哪段源码:
eval_llm.py:12-30
先看这几行:
AutoTokenizer.from_pretrained(...)
MiniMindForCausalLM(MiniMindConfig(...))
model.load_state_dict(...)
AutoModelForCausalLM.from_pretrained(...)
return model.half().eval().to(...)
源码:
def init_model(args):
tokenizer = AutoTokenizer.from_pretrained(args.load_from)
if 'model' in args.load_from:
model = MiniMindForCausalLM(MiniMindConfig(...))
ckp = f'./{args.save_dir}/{args.weight}_{args.hidden_size}{moe_suffix}.pth'
model.load_state_dict(torch.load(ckp, map_location=args.device), strict=True)
else:
model = AutoModelForCausalLM.from_pretrained(args.load_from, trust_remote_code=True)
return model.half().eval().to(args.device), tokenizer
关键解释:
AutoTokenizer.from_pretrained(args.load_from):从指定目录加载 tokenizer。这个目录可以是./model,也可以是./minimind-3。if 'model' in args.load_from:作者用字符串判断加载方式。路径里包含model时,走本仓库原生 PyTorch 权重加载。MiniMindForCausalLM(MiniMindConfig(...)):先创建一个空模型结构,此时还没有训练好的能力。model.load_state_dict(...):把.pth权重填进模型结构里。没有这一步,模型只是随机初始化。AutoModelForCausalLM.from_pretrained(...):transformers 格式的一步式加载,会根据目录里的config.json和model.safetensors加载模型。model.half().eval().to(args.device):切到半精度、推理模式,并移动到 CPU 或 GPU。
读这段源码时只抓一个点:推理不是只有模型文件,还必须同时匹配 tokenizer、模型结构和权重。
暂时不用看:
apply_lora/load_lora:LoRA 后面单独讲。get_model_params:这里只是打印参数量。
这里有两条分支:
--load_from ./model
-> 用本仓库的 MiniMindForCausalLM 创建结构
-> 从 ./out/full_sft_768.pth 加载权重
--load_from ./minimind-3
-> 用 transformers 目录里的 config/tokenizer/model.safetensors 加载
我们现在已经下载了 ./minimind-3,所以课程优先使用 transformers 格式模型。
4. 原理二:对话模板
聊天模型通常不是直接吃:
MiniMind 是什么?
而是吃带角色标记的 prompt,例如:
<|im_start|>user
MiniMind 是什么?<|im_end|>
<|im_start|>assistant
<think>
</think>
这就是 chat template 的作用:把结构化对话消息转成模型训练时见过的文本格式。
看哪段源码:
eval_llm.py:71-76
先看这几行:
conversation.append(...)
tokenizer.apply_chat_template(...)
tokenize=False
add_generation_prompt=True
源码:
conversation = conversation[-args.historys:] if args.historys else []
conversation.append({"role": "user", "content": prompt})
inputs = tokenizer.apply_chat_template(
conversation,
tokenize=False,
add_generation_prompt=True,
open_thinking=bool(args.open_thinking)
)
关键解释:
conversation = conversation[-args.historys:] if args.historys else []:控制是否携带历史对话。默认historys=0,所以每轮都清空历史。conversation.append(...):把当前用户问题加入消息列表。此时它还是结构化 Python 对象,不是字符串。role: "user":告诉模板这句话来自用户。聊天模型依赖角色标记区分 user / assistant / system。apply_chat_template(...):把消息列表渲染成一段 prompt 字符串。tokenize=False:只返回字符串,不直接返回 token id。add_generation_prompt=True:在 prompt 末尾加上 assistant 开始标记,提示模型“接下来该助手回答了”。open_thinking=...:控制 prompt 里<think>部分的格式,后面讲 tokenizer/template 时再细看。
读这段源码时只抓一个点:聊天推理的输入不是裸问题,而是带角色格式的 prompt。
暂时不用看:
open_thinking的模板细节:下一课 tokenizer/chat template 会展开。- 多轮历史裁剪策略:现在只知道
historys=0时不带历史即可。
这里要分清:
conversation 是 Python 消息列表
apply_chat_template 的输出是 prompt 字符串
tokenize=False 的意思是:这一步只生成字符串,还不转 token id。
5. 原理三:Tokenization
模型不能直接处理字符串。字符串必须经过 tokenizer 变成数字。
看哪段源码:
eval_llm.py:78
先看这几个参数:
return_tensors="pt"
truncation=True
.to(args.device)
源码:
inputs = tokenizer(inputs, return_tensors="pt", truncation=True).to(args.device)
关键解释:
- 第一个
inputs:这里变量名有点绕。上一行的inputs其实还是 prompt 字符串。 tokenizer(inputs, ...):把 prompt 字符串编码成 token id。return_tensors="pt":返回 PyTorch tensor,而不是普通 Python list。truncation=True:如果 prompt 太长,会按 tokenizer/model 的规则截断。.to(args.device):把input_ids和attention_mask移到 CPU 或 GPU。
这行执行前后,inputs 的含义变了:
执行前:inputs 是 prompt 字符串
执行后:inputs 是 tokenizer 输出的 BatchEncoding 字典
输出里的两个核心字段:
input_ids: token id 序列,形状通常是 [1, seq_len]
attention_mask: 哪些位置是有效 token,形状通常也是 [1, seq_len]
你在本节只需要理解 shape:
input_ids.shape = [batch_size, prompt_token_count]
CLI 单条推理时,batch_size 通常是 1。
6. 原理四:自回归生成
LLM 不是一次性写完整段答案。它每次只预测下一个 token。
逻辑是:
已有 token
-> 模型 forward 得到最后位置的 logits
-> 从 logits 里选出 next_token
-> 把 next_token 拼到 input_ids 后面
-> 重复
看哪段源码:
eval_llm.py:82-87
model/model_minimind.py:257-287
第一遍先看 eval_llm.py 怎么调用 generate:
generated_ids = model.generate(
inputs=inputs["input_ids"],
attention_mask=inputs["attention_mask"],
max_new_tokens=args.max_new_tokens,
do_sample=True,
streamer=streamer,
pad_token_id=tokenizer.pad_token_id,
eos_token_id=tokenizer.eos_token_id,
top_p=args.top_p,
temperature=args.temperature,
repetition_penalty=1
)
关键解释:
inputs=inputs["input_ids"]:把 prompt token 交给模型。attention_mask=inputs["attention_mask"]:告诉模型哪些 token 是有效输入。max_new_tokens:最多新生成多少个 token,不包括 prompt 本身。do_sample=True:使用采样生成,而不是总选概率最高的 token。streamer=streamer:边生成边打印,CLI 里能看到字逐步出来。pad_token_id/eos_token_id:告诉生成过程 padding token 和结束 token 是什么。top_p/temperature:控制采样分布。temperature 越高越随机,top_p 控制候选 token 的概率质量范围。
读这段源码时只抓一个点:generate 输入的是 prompt token,输出的是 prompt token 加新 token。
第二遍再看本仓库原生 PyTorch 模型的 generate 实现:
for _ in range(max_new_tokens):
outputs = self.forward(...)
logits = outputs.logits[:, -1, :] / temperature
next_token = ...
input_ids = torch.cat([input_ids, next_token], dim=-1)
这段循环的关键含义:
for _ in range(max_new_tokens):最多循环生成这么多次。self.forward(...):用当前已有 token 计算 logits。outputs.logits[:, -1, :]:只取最后一个位置的 logits,因为它对应“下一个 token”的预测。next_token = ...:从 logits 中选择或采样一个 token。torch.cat([input_ids, next_token], dim=-1):把新 token 接到序列末尾,下一轮继续用。
现在下载的 ./minimind-3 是 transformers 格式,实际会走 transformers 模型的生成机制。但原理一致:循环预测 next token。
7. 原理五:Decode
generated_ids 包含两部分:
原始 prompt token + 新生成 token
所以不能直接把整个 generated_ids decode 给用户,否则会把用户问题也输出一遍。
看哪段源码:
eval_llm.py:88
先看这几个点:
generated_ids[0]
len(inputs["input_ids"][0])
[prompt_len:]
skip_special_tokens=True
源码:
response = tokenizer.decode(
generated_ids[0][len(inputs["input_ids"][0]):],
skip_special_tokens=True
)
关键解释:
generated_ids[0]:取 batch 中第 1 条生成结果。len(inputs["input_ids"][0]):prompt 原本的 token 数量,也就是 prompt 长度。[prompt_len:]:跳过 prompt,只保留新生成部分。skip_special_tokens=True:decode 时去掉<|im_start|>、<|im_end|>等特殊标记。
关键是这段切片:
generated_ids[0][len(inputs["input_ids"][0]):]
它的含义是:
从 prompt 结束之后开始取,只取新生成 token
8. 实验验证
实验 A:真实模型推理
这个实验验证:下载好的 ./minimind-3 可以真实生成回复。
cd /home/sun/minimind
PYTHONDONTWRITEBYTECODE=1 python course/labs/run_minimind3_once.py --device cpu --max_new_tokens 64
记录:
input_ids.shape =
generated_ids.shape =
new_tokens =
response =
注意:MiniMind-3 是小模型,回答可能有事实错误。本实验只验证推理链路。
实验 B:形状追踪
这个实验验证:prompt、token、logits、generated_ids 的形状关系。
cd /home/sun/minimind
PYTHONDONTWRITEBYTECODE=1 python course/labs/trace_cli_inference.py
记录:
prompt 中出现的特殊标记 =
input_ids.shape =
attention_mask.shape =
logits.shape =
generated_ids.shape =
这个脚本用的是随机 tiny 模型,所以 decode 出来的文字没有意义。它的价值是让你看清 shape。
9. 本节检查
如果你真懂了本节,应该能不看答案说清楚:
conversation、prompt、input_ids分别是什么类型。- 为什么聊天模型要使用 chat template。
tokenizer(...)输出里的input_ids和attention_mask分别做什么。generate为什么叫自回归生成。- 为什么 decode 前要切掉 prompt token。
10. 下一课
第 4 课进入 tokenizer。我们会专门拆开看 special tokens、编码/解码、chat template 里的 <|im_start|>、<|im_end|>、<think> 等标记。