# 第 3 课:CLI 推理流程 这一课只解决一个问题:用户输入一句话后,MiniMind 如何把它变成模型输入,再生成回复。 ## 目录 - [0. 本节主线](#l03-mainline) - [1. 本节要懂的 5 个原理](#l03-principles) - [2. 变量流转](#l03-flow) - [3. 原理一:模型加载](#l03-model-loading) - [4. 原理二:对话模板](#l03-chat-template) - [5. 原理三:Tokenization](#l03-tokenization) - [6. 原理四:自回归生成](#l03-generation) - [7. 原理五:Decode](#l03-decode) - [8. 实验验证](#l03-experiment) - [9. 本节检查](#l03-check) - [10. 下一课](#l03-next) ## 0. 本节主线 CLI 推理的本质是: ```text 加载 tokenizer 和模型 -> 把用户消息用 chat template 包装成 prompt -> 把 prompt tokenizer 成 input_ids -> 让模型自回归生成新 token -> 只 decode 新生成 token 作为回复 ``` 这条链对应本节的 5 个原理: ```text 模型加载 -> 对话模板 -> 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. 变量流转 把主线对应到代码里的变量名: ```text prompt: 用户输入的字符串 conversation: [{"role": "user", "content": prompt}] inputs: tokenizer 输出的字典,包含 input_ids / attention_mask generated_ids: prompt token + 新生成 token response: 新生成 token decode 后的文本 ``` ## 3. 原理一:模型加载 推理要准备三件东西: ```text tokenizer:负责字符串和 token id 互转 model config / architecture:负责创建模型结构 model weights:训练好的参数 ``` 看哪段源码: ```text eval_llm.py:12-30 ``` 先看这几行: ```text AutoTokenizer.from_pretrained(...) MiniMindForCausalLM(MiniMindConfig(...)) model.load_state_dict(...) AutoModelForCausalLM.from_pretrained(...) return model.half().eval().to(...) ``` 源码: ```python 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`:这里只是打印参数量。 这里有两条分支: ```text --load_from ./model -> 用本仓库的 MiniMindForCausalLM 创建结构 -> 从 ./out/full_sft_768.pth 加载权重 --load_from ./minimind-3 -> 用 transformers 目录里的 config/tokenizer/model.safetensors 加载 ``` 我们现在已经下载了 `./minimind-3`,所以课程优先使用 transformers 格式模型。 ## 4. 原理二:对话模板 聊天模型通常不是直接吃: ```text MiniMind 是什么? ``` 而是吃带角色标记的 prompt,例如: ```text <|im_start|>user MiniMind 是什么?<|im_end|> <|im_start|>assistant ``` 这就是 chat template 的作用:把结构化对话消息转成模型训练时见过的文本格式。 看哪段源码: ```text eval_llm.py:71-76 ``` 先看这几行: ```text conversation.append(...) tokenizer.apply_chat_template(...) tokenize=False add_generation_prompt=True ``` 源码: ```python 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 里 `` 部分的格式,后面讲 tokenizer/template 时再细看。 读这段源码时只抓一个点:**聊天推理的输入不是裸问题,而是带角色格式的 prompt。** 暂时不用看: - `open_thinking` 的模板细节:下一课 tokenizer/chat template 会展开。 - 多轮历史裁剪策略:现在只知道 `historys=0` 时不带历史即可。 这里要分清: ```text conversation 是 Python 消息列表 apply_chat_template 的输出是 prompt 字符串 ``` `tokenize=False` 的意思是:这一步只生成字符串,还不转 token id。 ## 5. 原理三:Tokenization 模型不能直接处理字符串。字符串必须经过 tokenizer 变成数字。 看哪段源码: ```text eval_llm.py:78 ``` 先看这几个参数: ```text return_tensors="pt" truncation=True .to(args.device) ``` 源码: ```python 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` 的含义变了: ```text 执行前:inputs 是 prompt 字符串 执行后:inputs 是 tokenizer 输出的 BatchEncoding 字典 ``` 输出里的两个核心字段: ```text input_ids: token id 序列,形状通常是 [1, seq_len] attention_mask: 哪些位置是有效 token,形状通常也是 [1, seq_len] ``` 你在本节只需要理解 shape: ```text input_ids.shape = [batch_size, prompt_token_count] ``` CLI 单条推理时,`batch_size` 通常是 1。 ## 6. 原理四:自回归生成 LLM 不是一次性写完整段答案。它每次只预测下一个 token。 逻辑是: ```text 已有 token -> 模型 forward 得到最后位置的 logits -> 从 logits 里选出 next_token -> 把 next_token 拼到 input_ids 后面 -> 重复 ``` 看哪段源码: ```text eval_llm.py:82-87 model/model_minimind.py:257-287 ``` 第一遍先看 `eval_llm.py` 怎么调用 `generate`: ```python 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` 实现: ```python 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` 包含两部分: ```text 原始 prompt token + 新生成 token ``` 所以不能直接把整个 `generated_ids` decode 给用户,否则会把用户问题也输出一遍。 看哪段源码: ```text eval_llm.py:88 ``` 先看这几个点: ```text generated_ids[0] len(inputs["input_ids"][0]) [prompt_len:] skip_special_tokens=True ``` 源码: ```python 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|>` 等特殊标记。 关键是这段切片: ```python generated_ids[0][len(inputs["input_ids"][0]):] ``` 它的含义是: ```text 从 prompt 结束之后开始取,只取新生成 token ``` ## 8. 实验验证 ### 实验 A:真实模型推理 这个实验验证:下载好的 `./minimind-3` 可以真实生成回复。 ```bash cd /home/sun/minimind PYTHONDONTWRITEBYTECODE=1 python course/labs/run_minimind3_once.py --device cpu --max_new_tokens 64 ``` 记录: ```text input_ids.shape = generated_ids.shape = new_tokens = response = ``` 注意:MiniMind-3 是小模型,回答可能有事实错误。本实验只验证推理链路。 ### 实验 B:形状追踪 这个实验验证:prompt、token、logits、generated_ids 的形状关系。 ```bash cd /home/sun/minimind PYTHONDONTWRITEBYTECODE=1 python course/labs/trace_cli_inference.py ``` 记录: ```text prompt 中出现的特殊标记 = input_ids.shape = attention_mask.shape = logits.shape = generated_ids.shape = ``` 这个脚本用的是随机 tiny 模型,所以 decode 出来的文字没有意义。它的价值是让你看清 shape。 ## 9. 本节检查 如果你真懂了本节,应该能不看答案说清楚: 1. `conversation`、`prompt`、`input_ids` 分别是什么类型。 2. 为什么聊天模型要使用 chat template。 3. `tokenizer(...)` 输出里的 `input_ids` 和 `attention_mask` 分别做什么。 4. `generate` 为什么叫自回归生成。 5. 为什么 decode 前要切掉 prompt token。 ## 10. 下一课 第 4 课进入 tokenizer。我们会专门拆开看 special tokens、编码/解码、chat template 里的 `<|im_start|>`、`<|im_end|>`、`` 等标记。