957 lines
30 KiB
Markdown
957 lines
30 KiB
Markdown
# 第 27 课:模型转换、OpenAI API 与 WebUI
|
||
|
||
这一课讲 MiniMind 的部署侧链路:训练脚本产出的原生 PyTorch 权重如何变成 transformers 生态可加载的模型目录,CLI/API/WebUI 又如何复用同一套 tokenizer、chat template 和 `generate` 约定。
|
||
|
||
本节不讲生产级服务治理。这里的目标是看懂 MiniMind 项目中已有的三个部署入口:模型格式转换、OpenAI 兼容 API、Streamlit WebUI。
|
||
|
||
## 目录
|
||
|
||
- [0. 本节主线](#l27-mainline)
|
||
- [1. 本节要懂的 6 个原理](#l27-principles)
|
||
- [2. 完整原理:部署侧把训练产物包装成可调用接口](#l27-complete-principle)
|
||
- [3. 源码阅读顺序图](#l27-reading-order)
|
||
- [4. MiniMind 源码走读](#l27-source-walkthrough)
|
||
- [5. 本节必须会写 / 暂时不要求](#l27-must-write)
|
||
- [6. 实验验证](#l27-experiment)
|
||
- [7. 阶段组装](#l27-stage-assembly)
|
||
- [8. 本节检查](#l27-check)
|
||
- [9. 下一课](#l27-next)
|
||
|
||
<a id="l27-mainline"></a>
|
||
## 0. 本节主线
|
||
|
||
MiniMind 的部署链路是:
|
||
|
||
```text
|
||
训练得到 out/full_sft_768.pth
|
||
-> convert_model.py 把原生 torch state_dict 转成 transformers 模型目录
|
||
-> transformers 目录保存 config、tokenizer、chat template、model weights
|
||
-> CLI / API / WebUI 用 AutoTokenizer + AutoModelForCausalLM 加载
|
||
-> messages 通过 apply_chat_template 变成 prompt 文本
|
||
-> tokenizer(prompt) 得到 input_ids / attention_mask
|
||
-> model.generate 生成新 token
|
||
-> tokenizer.decode 得到 assistant 文本
|
||
-> API/WebUI 再把 <think>、<tool_call> 文本协议转成 reasoning_content / tool_calls / 可视化结果
|
||
```
|
||
|
||
一句话:
|
||
|
||
```text
|
||
训练代码负责学会生成协议;
|
||
部署代码负责加载权重、套模板、调用 generate、再把文本协议包装成用户能调用的接口。
|
||
```
|
||
|
||
这一课最容易混的三个边界:
|
||
|
||
```text
|
||
模型格式:
|
||
原生 torch 权重是 state_dict;transformers 目录是 config + tokenizer + 权重文件。
|
||
|
||
聊天协议:
|
||
messages/tools/open_thinking 不是直接喂给模型,而是先由 chat template 渲染成文本 prompt。
|
||
|
||
接口协议:
|
||
MiniMind 内部生成的是 <think> / <tool_call> 文本;OpenAI API 返回的是 reasoning_content / tool_calls 字段。
|
||
```
|
||
|
||
<a id="l27-principles"></a>
|
||
## 1. 本节要懂的 6 个原理
|
||
|
||
| 原理 | 要理解什么 | 源码位置 |
|
||
|---|---|---|
|
||
| 转换的本质是保存结构合同和权重 | transformers 目录必须同时有 config、tokenizer、chat template 和模型权重 | `scripts/convert_model.py:16-36`, `scripts/convert_model.py:40-96`, `minimind-3/config.json:1-38` |
|
||
| MiniMind 有两种加载路径 | `load_from` 指向 `model` 时走原生 torch 权重;指向模型目录时走 transformers 加载 | `eval_llm.py:12-30`, `scripts/serve_openai_api.py:28-47` |
|
||
| API 服务只是把 OpenAI 请求转成本地 generate | request 的 `messages/tools/open_thinking` 先进入 chat template,再生成 token | `scripts/serve_openai_api.py:50-68`, `scripts/serve_openai_api.py:171-225` |
|
||
| 部署侧要解析文本协议 | `<think>` 和 `<tool_call>` 是模型生成文本,服务端要转换成 API 字段 | `scripts/serve_openai_api.py:83-102`, `minimind-3/chat_template.jinja:1-11`, `minimind-3/chat_template.jinja:78-84` |
|
||
| WebUI 是本地多轮循环 | WebUI 维护 session messages、加载模型、流式生成,并可执行工具后继续生成 | `scripts/web_demo.py:198-209`, `scripts/web_demo.py:239-248`, `scripts/web_demo.py:350-413` |
|
||
| 客户端只认 OpenAI 兼容接口 | `chat_api.py` 不关心模型内部结构,只向 base_url 发 chat completions 请求 | `scripts/chat_api.py:3-22`, `scripts/chat_api.py:27-39` |
|
||
|
||
学完本节,你应该能解释这些对象:
|
||
|
||
```text
|
||
torch state_dict:
|
||
trainer 保存的原生 PyTorch 参数字典,通常位于 out/*.pth。
|
||
|
||
transformers model directory:
|
||
包含 config.json、tokenizer.json、tokenizer_config.json、model.safetensors 等文件的目录。
|
||
|
||
chat template:
|
||
把 OpenAI 风格 messages/tools 渲染成 MiniMind 实际训练过的文本协议。
|
||
|
||
OpenAI-compatible API:
|
||
对外暴露 /v1/chat/completions,但内部仍然调用本地 tokenizer 和 model.generate。
|
||
|
||
reasoning_content:
|
||
从模型生成的 <think>...</think> 中解析出来的思考字段。
|
||
|
||
tool_calls:
|
||
从模型生成的 <tool_call>...</tool_call> JSON 中解析出来的 OpenAI 风格工具调用字段。
|
||
```
|
||
|
||
<a id="l27-complete-principle"></a>
|
||
## 2. 完整原理:部署侧把训练产物包装成可调用接口
|
||
|
||
### 2.1 训练产物不能直接等同于可部署模型目录
|
||
|
||
训练脚本通常保存的是:
|
||
|
||
```text
|
||
out/full_sft_768.pth
|
||
```
|
||
|
||
这个文件主要是 PyTorch `state_dict`。它知道每个参数张量的名字和值,但它本身不完整表达:
|
||
|
||
```text
|
||
模型结构配置
|
||
tokenizer 文件
|
||
chat template
|
||
special tokens
|
||
第三方推理框架需要的 config 字段
|
||
```
|
||
|
||
transformers 目录则是一个更完整的部署包。当前本地 `minimind-3/` 里至少包含:
|
||
|
||
```text
|
||
config.json
|
||
tokenizer.json
|
||
tokenizer_config.json
|
||
chat_template.jinja
|
||
model.safetensors
|
||
```
|
||
|
||
所以 `convert_model.py` 做的不是“训练”,而是把训练后的参数放进一个生态更容易识别的目录结构。
|
||
|
||
### 2.2 部署入口复用训练时的文本协议
|
||
|
||
从第 4、5、26 课已经看到,MiniMind 不是直接训练裸文本问答,而是训练了带角色、思考、工具的模板协议:
|
||
|
||
```text
|
||
<|im_start|>user
|
||
...
|
||
<|im_end|>
|
||
<|im_start|>assistant
|
||
<think>
|
||
...
|
||
</think>
|
||
|
||
<tool_call>
|
||
{"name": "...", "arguments": {...}}
|
||
</tool_call>
|
||
```
|
||
|
||
部署时不能绕过这个协议。CLI、API、WebUI 都会做同一件事:
|
||
|
||
```text
|
||
messages -> tokenizer.apply_chat_template(...) -> prompt -> tokenizer(prompt) -> generate
|
||
```
|
||
|
||
区别只是外壳不同:
|
||
|
||
```text
|
||
CLI:
|
||
从终端读 input,终端打印 response。
|
||
|
||
API:
|
||
从 HTTP request 读 messages,返回 OpenAI 兼容 JSON / SSE。
|
||
|
||
WebUI:
|
||
从浏览器输入框读 prompt,用 Streamlit session 保存历史并渲染结果。
|
||
```
|
||
|
||
### 2.3 OpenAI 兼容不等于模型变成 OpenAI 模型
|
||
|
||
`serve_openai_api.py` 暴露的是 OpenAI 风格接口:
|
||
|
||
```text
|
||
POST /v1/chat/completions
|
||
```
|
||
|
||
请求格式像这样:
|
||
|
||
```json
|
||
{
|
||
"model": "minimind",
|
||
"messages": [{"role": "user", "content": "你好"}],
|
||
"stream": true,
|
||
"tools": [],
|
||
"chat_template_kwargs": {"open_thinking": true}
|
||
}
|
||
```
|
||
|
||
但内部仍然是本地 MiniMind:
|
||
|
||
```text
|
||
AutoTokenizer.from_pretrained(...)
|
||
AutoModelForCausalLM.from_pretrained(...)
|
||
tokenizer.apply_chat_template(...)
|
||
model.generate(...)
|
||
```
|
||
|
||
也就是说,OpenAI 兼容只是接口层协议兼容,不是训练目标或模型结构发生了变化。
|
||
|
||
<a id="l27-reading-order"></a>
|
||
## 3. 源码阅读顺序图
|
||
|
||
按这个顺序读源码:
|
||
|
||
```text
|
||
1. minimind-3/config.json:1-38
|
||
先看 transformers 目录里的结构合同。
|
||
|
||
2. scripts/convert_model.py:16-36, 40-96
|
||
看原生 torch 权重如何保存成 transformers 目录。
|
||
|
||
3. eval_llm.py:12-30, 71-88
|
||
用最简单的 CLI 入口理解 load -> template -> generate -> decode。
|
||
|
||
4. scripts/serve_openai_api.py:50-68
|
||
看 API request 支持哪些字段。
|
||
|
||
5. scripts/serve_openai_api.py:83-102
|
||
看服务端如何解析 <think> 和 <tool_call>。
|
||
|
||
6. scripts/serve_openai_api.py:171-225
|
||
看非流式 API 的完整调用链。
|
||
|
||
7. scripts/serve_openai_api.py:105-165
|
||
再看流式响应如何拆 reasoning/content/tool_calls。
|
||
|
||
8. scripts/web_demo.py:239-248, 350-413
|
||
最后看 WebUI 如何选模型、维护历史、处理工具循环。
|
||
|
||
9. scripts/chat_api.py:3-39
|
||
用客户端视角确认 OpenAI 兼容接口的意义。
|
||
```
|
||
|
||
<a id="l27-source-walkthrough"></a>
|
||
## 4. MiniMind 源码走读
|
||
|
||
### 4.1 transformers 目录保存结构合同
|
||
|
||
File: `minimind-3/config.json:1-38`
|
||
|
||
Read this to understand: transformers 模型目录必须告诉加载器使用什么模型结构、多少层、多少 head、词表多大。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```json
|
||
{
|
||
"architectures": [
|
||
"Qwen3ForCausalLM"
|
||
],
|
||
"hidden_size": 768,
|
||
"model_type": "qwen3",
|
||
"num_attention_heads": 8,
|
||
"num_hidden_layers": 8,
|
||
"num_key_value_heads": 4,
|
||
"vocab_size": 6400
|
||
}
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- 当前下载好的 `minimind-3` 对齐的是 `Qwen3ForCausalLM` 生态接口。
|
||
- 模型结构参数和第 8-12 课学过的 hidden size、layer、head、vocab 是同一类合同。
|
||
- 部署加载时不只需要权重,还需要这些配置字段。
|
||
|
||
### 4.2 转换脚本有两条路线
|
||
|
||
File: `scripts/convert_model.py:16-36`
|
||
|
||
Read this to understand: MiniMind 自定义结构也可以注册成 transformers 可加载模型。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```python
|
||
def convert_torch2transformers_minimind(torch_path, transformers_path, dtype=torch.float16):
|
||
MiniMindConfig.register_for_auto_class()
|
||
MiniMindForCausalLM.register_for_auto_class("AutoModelForCausalLM")
|
||
lm_model = MiniMindForCausalLM(lm_config)
|
||
state_dict = torch.load(torch_path, map_location=device)
|
||
lm_model.load_state_dict(state_dict, strict=False)
|
||
lm_model = lm_model.to(dtype)
|
||
lm_model.save_pretrained(transformers_path, safe_serialization=False)
|
||
tokenizer = AutoTokenizer.from_pretrained('../model/')
|
||
tokenizer.save_pretrained(transformers_path)
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- `torch_path` 是训练保存的原生权重。
|
||
- `save_pretrained` 写出 transformers 目录。
|
||
- tokenizer 会一起保存,否则部署端无法复原 chat template 和 token id。
|
||
|
||
File: `scripts/convert_model.py:40-96`
|
||
|
||
Read this to understand: 项目主线也支持转换成 Qwen3 兼容结构,方便 vLLM、Ollama 等生态加载。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```python
|
||
common_config = {
|
||
"vocab_size": lm_config.vocab_size,
|
||
"hidden_size": lm_config.hidden_size,
|
||
"intermediate_size": lm_config.intermediate_size,
|
||
"num_hidden_layers": lm_config.num_hidden_layers,
|
||
"num_attention_heads": lm_config.num_attention_heads,
|
||
"num_key_value_heads": lm_config.num_key_value_heads,
|
||
"rope_theta": lm_config.rope_theta,
|
||
}
|
||
qwen_config = Qwen3Config(**common_config, use_sliding_window=False, sliding_window=None)
|
||
qwen_model = Qwen3ForCausalLM(qwen_config)
|
||
qwen_model.load_state_dict(state_dict, strict=True)
|
||
qwen_model.save_pretrained(transformers_path)
|
||
tokenizer.save_pretrained(transformers_path)
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- 转换时把 MiniMind config 映射到 Qwen3 config。
|
||
- `strict=True` 要求 state_dict 名称和目标结构完全对齐。
|
||
- 转换后的目录能被更多 transformers 生态工具识别。
|
||
|
||
### 4.3 CLI 与 API 共享两种加载路径
|
||
|
||
File: `eval_llm.py:12-30`
|
||
|
||
Read this to understand: MiniMind 推理入口如何区分原生 torch 权重和 transformers 目录。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- `--load_from model` 走项目自定义 `MiniMindForCausalLM`。
|
||
- `--load_from ./minimind-3` 走 transformers `AutoModelForCausalLM`。
|
||
- 两条路径最后都返回 `model, tokenizer`,后面的推理流程相同。
|
||
|
||
注意:这里用的是字符串判断 `if 'model' in args.load_from`。学习时够用;做更稳的工程时应改成显式参数或检查目录结构,避免路径名误判。
|
||
|
||
File: `scripts/serve_openai_api.py:28-47`
|
||
|
||
Read this to understand: API 服务端复用了同一套加载边界。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```python
|
||
tokenizer = AutoTokenizer.from_pretrained(args.load_from)
|
||
if 'model' in args.load_from:
|
||
model = MiniMindForCausalLM(MiniMindConfig(...))
|
||
model.load_state_dict(torch.load(ckp, map_location=device), strict=True)
|
||
else:
|
||
model = AutoModelForCausalLM.from_pretrained(args.load_from, trust_remote_code=True)
|
||
return model.half().eval().to(device), tokenizer
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- API 服务不是另一套模型,它仍然先加载本地模型和 tokenizer。
|
||
- `load_from` 决定加载原生权重还是 transformers 目录。
|
||
- `model.eval()` 表示部署侧不训练,只推理。
|
||
|
||
### 4.4 CLI 推理是最小部署闭环
|
||
|
||
File: `eval_llm.py:71-88`
|
||
|
||
Read this to understand: 最小推理闭环就是 messages、chat template、tokenizer、generate、decode。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```python
|
||
conversation.append({"role": "user", "content": prompt})
|
||
inputs = tokenizer.apply_chat_template(
|
||
conversation,
|
||
tokenize=False,
|
||
add_generation_prompt=True,
|
||
open_thinking=bool(args.open_thinking)
|
||
)
|
||
inputs = tokenizer(inputs, return_tensors="pt", truncation=True).to(args.device)
|
||
generated_ids = model.generate(
|
||
inputs=inputs["input_ids"],
|
||
attention_mask=inputs["attention_mask"],
|
||
max_new_tokens=args.max_new_tokens,
|
||
do_sample=True,
|
||
)
|
||
response = tokenizer.decode(generated_ids[0][len(inputs["input_ids"][0]):], skip_special_tokens=True)
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- `conversation` 是 OpenAI 风格消息列表。
|
||
- `apply_chat_template` 把消息列表变成模型实际看到的 prompt。
|
||
- decode 时只取 prompt 后面新生成的 token。
|
||
|
||
### 4.5 chat template 固化工具和思考协议
|
||
|
||
File: `minimind-3/chat_template.jinja:1-11`
|
||
|
||
Read this to understand: 有 tools 时,模板会把工具 schema 写进 system prompt,并要求模型输出 `<tool_call>` JSON。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```jinja
|
||
{%- if tools %}
|
||
{{- '<|im_start|>system\n' }}
|
||
{{- "# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
|
||
{%- for tool in tools %}
|
||
{{- "\n" }}
|
||
{{- tool | tojson }}
|
||
{%- endfor %}
|
||
{{- "\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:" }}
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- 工具不是 Python 函数直接进模型,而是 schema 文本进入 prompt。
|
||
- 模型学到的是输出 `<tool_call>` 这类文本动作。
|
||
- 这和第 26 课的 Agent rollout 使用的是同一套协议。
|
||
|
||
File: `minimind-3/chat_template.jinja:78-84`
|
||
|
||
Read this to understand: `open_thinking` 控制 assistant 起始处的 `<think>` 形态。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```jinja
|
||
{%- if add_generation_prompt %}
|
||
{{- '<|im_start|>assistant\n' }}
|
||
{%- if open_thinking is defined and open_thinking is true %}
|
||
{{- '<think>\n' }}
|
||
{%- else %}
|
||
{{- '<think>\n\n</think>\n\n' }}
|
||
{%- endif %}
|
||
{%- endif %}
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- 开启 thinking 时,模型从未闭合的 `<think>` 后继续生成。
|
||
- 不开启 thinking 时,模板给一个空 thinking 块,让模型直接回答。
|
||
- 部署侧的 `open_thinking` 本质是 chat template 参数,不是模型结构开关。
|
||
|
||
### 4.6 API 请求模型定义
|
||
|
||
File: `scripts/serve_openai_api.py:50-68`
|
||
|
||
Read this to understand: OpenAI 兼容接口接收哪些字段,以及 thinking 如何兼容不同客户端传法。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```python
|
||
class ChatRequest(BaseModel):
|
||
model: str
|
||
messages: list
|
||
temperature: float = 0.7
|
||
top_p: float = 0.92
|
||
max_tokens: int = 8192
|
||
stream: bool = True
|
||
tools: list = []
|
||
open_thinking: bool = False
|
||
chat_template_kwargs: dict = None
|
||
|
||
def get_open_thinking(self) -> bool:
|
||
if self.open_thinking:
|
||
return True
|
||
if self.chat_template_kwargs:
|
||
return self.chat_template_kwargs.get('open_thinking', False) or \
|
||
self.chat_template_kwargs.get('enable_thinking', False)
|
||
return False
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- API 请求保留 OpenAI 风格的 `messages` 和 `tools`。
|
||
- `open_thinking` 可以直接传,也可以通过 `chat_template_kwargs` 传。
|
||
- 服务端最终只需要知道是否把 `open_thinking=True` 传给 template。
|
||
|
||
### 4.7 服务端解析模型输出协议
|
||
|
||
File: `scripts/serve_openai_api.py:83-102`
|
||
|
||
Read this to understand: 模型输出是文本,服务端要把文本协议转换成 OpenAI 风格字段。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```python
|
||
def parse_response(text):
|
||
reasoning_content = None
|
||
think_match = re.search(r'<think>(.*?)</think>', text, re.DOTALL)
|
||
if think_match:
|
||
reasoning_content = think_match.group(1).strip()
|
||
text = re.sub(r'<think>.*?</think>\s*', '', text, flags=re.DOTALL)
|
||
tool_calls = []
|
||
for i, m in enumerate(re.findall(r'<tool_call>(.*?)</tool_call>', text, re.DOTALL)):
|
||
call = json.loads(m.strip())
|
||
tool_calls.append({
|
||
"id": f"call_{int(time.time())}_{i}",
|
||
"type": "function",
|
||
"function": {"name": call.get("name", ""), "arguments": json.dumps(call.get("arguments", {}), ensure_ascii=False)}
|
||
})
|
||
return text.strip(), reasoning_content, tool_calls or None
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- `reasoning_content` 来自 `<think>...</think>`。
|
||
- `tool_calls` 来自 `<tool_call>...</tool_call>` 中的 JSON。
|
||
- `content` 会移除 thinking 和 tool_call,只留下普通回答文本。
|
||
|
||
### 4.8 非流式 API 完整路径
|
||
|
||
File: `scripts/serve_openai_api.py:187-225`
|
||
|
||
Read this to understand: 非流式接口如何从 request 到 response。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```python
|
||
new_prompt = tokenizer.apply_chat_template(
|
||
request.messages,
|
||
tokenize=False,
|
||
add_generation_prompt=True,
|
||
tools=request.tools or None,
|
||
open_thinking=request.get_open_thinking()
|
||
)
|
||
inputs = tokenizer(new_prompt, return_tensors="pt", truncation=True).to(device)
|
||
generated_ids = model.generate(
|
||
inputs["input_ids"],
|
||
max_length=inputs["input_ids"].shape[1] + request.max_tokens,
|
||
attention_mask=inputs["attention_mask"],
|
||
top_p=request.top_p,
|
||
temperature=request.temperature
|
||
)
|
||
answer = tokenizer.decode(generated_ids[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
|
||
content, reasoning_content, tool_calls = parse_response(answer)
|
||
message = {"role": "assistant", "content": content}
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- API 和 CLI 一样先套 chat template。
|
||
- `answer` 只 decode 新生成部分。
|
||
- 返回前调用 `parse_response`,把 MiniMind 文本协议转成 API 字段。
|
||
|
||
### 4.9 流式 API 额外处理 reasoning/content/tool_calls
|
||
|
||
File: `scripts/serve_openai_api.py:105-165`
|
||
|
||
Read this to understand: 流式响应为什么要在生成过程中区分 reasoning 和 content。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```python
|
||
queue = Queue()
|
||
streamer = CustomStreamer(tokenizer, queue)
|
||
Thread(target=_generate).start()
|
||
|
||
full_text = ""
|
||
thinking_ended = not bool(open_thinking)
|
||
while True:
|
||
text = queue.get()
|
||
if text is None:
|
||
break
|
||
full_text += text
|
||
if not thinking_ended:
|
||
pos = full_text.find('</think>')
|
||
...
|
||
yield json.dumps({"choices": [{"delta": {"reasoning_content": new_r}}]}, ensure_ascii=False)
|
||
else:
|
||
yield json.dumps({"choices": [{"delta": {"content": new_c}}]}, ensure_ascii=False)
|
||
|
||
_, _, tool_calls = parse_response(full_text)
|
||
if tool_calls:
|
||
yield json.dumps({"choices": [{"delta": {"tool_calls": tool_calls}}]}, ensure_ascii=False)
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- `TextStreamer` 把生成结果持续放进 queue。
|
||
- `</think>` 出现前的增量作为 `reasoning_content`。
|
||
- 工具调用要等完整文本生成后再解析,因为 JSON 需要闭合标签。
|
||
|
||
### 4.10 WebUI 加载模型并维护多轮上下文
|
||
|
||
File: `scripts/web_demo.py:198-209`
|
||
|
||
Read this to understand: WebUI 加载的是 transformers 模型目录,而不是训练脚本对象。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```python
|
||
@st.cache_resource
|
||
def load_model_tokenizer(model_path):
|
||
model = AutoModelForCausalLM.from_pretrained(
|
||
model_path,
|
||
trust_remote_code=True
|
||
)
|
||
tokenizer = AutoTokenizer.from_pretrained(
|
||
model_path,
|
||
trust_remote_code=True
|
||
)
|
||
model = model.half().eval().to(device)
|
||
return model, tokenizer
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- WebUI 默认服务的是 transformers 格式模型目录。
|
||
- `st.cache_resource` 避免每次交互都重新加载权重。
|
||
- WebUI 和 API 一样只做推理。
|
||
|
||
File: `scripts/web_demo.py:239-248`
|
||
|
||
Read this to understand: WebUI 为什么要求把模型目录放到 `scripts/` 下面。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```python
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
MODEL_PATHS = {}
|
||
for d in sorted(os.listdir(script_dir), reverse=True):
|
||
full_path = os.path.join(script_dir, d)
|
||
if os.path.isdir(full_path) and not d.startswith('.') and not d.startswith('_'):
|
||
if any(f.endswith(('.bin', '.safetensors', '.pt')) or os.path.exists(os.path.join(full_path, 'model.safetensors.index.json')) for f in os.listdir(full_path) if os.path.isfile(os.path.join(full_path, f))):
|
||
MODEL_PATHS[d] = [d, d]
|
||
if not MODEL_PATHS:
|
||
MODEL_PATHS = {"No models found": ["", "No models"]}
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- WebUI 扫描的是 `scripts/` 目录下的子目录。
|
||
- 子目录里要有 `.safetensors`、`.bin` 或 `.pt` 这类权重文件。
|
||
- 所以 README 才要求把 `minimind-3` 复制到 `scripts/minimind-3`。
|
||
|
||
File: `scripts/web_demo.py:350-413`
|
||
|
||
Read this to understand: WebUI 如何把一次用户输入变成多轮工具循环。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```python
|
||
tools = [t for t in TOOLS if t['function']['name'] in st.session_state.get('selected_tools', [])] or None
|
||
st.session_state.chat_messages = sys_prompt + st.session_state.chat_messages[-(st.session_state.history_chat_num + 1):]
|
||
template_kwargs = {"tokenize": False, "add_generation_prompt": True}
|
||
if st.session_state.get('enable_thinking', False):
|
||
template_kwargs["open_thinking"] = True
|
||
if tools:
|
||
template_kwargs["tools"] = tools
|
||
new_prompt = tokenizer.apply_chat_template(st.session_state.chat_messages, **template_kwargs)
|
||
inputs = tokenizer(new_prompt, return_tensors="pt", truncation=True).to(device)
|
||
Thread(target=model.generate, kwargs=generation_kwargs).start()
|
||
...
|
||
for _ in range(16):
|
||
tool_calls = re.findall(r'<tool_call>(.*?)</tool_call>', answer, re.DOTALL)
|
||
if not tool_calls:
|
||
break
|
||
st.session_state.chat_messages.append({"role": "assistant", "content": answer})
|
||
...
|
||
st.session_state.chat_messages.append({"role": "tool", "content": json.dumps(result, ensure_ascii=False)})
|
||
new_prompt = tokenizer.apply_chat_template(st.session_state.chat_messages, **template_kwargs)
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- WebUI 里的工具执行和第 26 课 `rollout_single` 很像:生成 tool_call,执行工具,把 tool message 拼回上下文,再生成。
|
||
- `history_chat_num` 控制携带多少历史消息。
|
||
- WebUI 使用 streamer,让页面逐步显示生成结果。
|
||
|
||
### 4.11 客户端只需要 OpenAI 兼容协议
|
||
|
||
File: `scripts/chat_api.py:3-22`
|
||
|
||
Read this to understand: 对客户端来说,本地 MiniMind 服务就是一个 OpenAI-compatible endpoint。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```python
|
||
client = OpenAI(
|
||
api_key="sk-123",
|
||
base_url="http://localhost:11434/v1"
|
||
)
|
||
response = client.chat.completions.create(
|
||
model="minimind-local:latest",
|
||
messages=conversation_history[-(history_messages_num or 1):],
|
||
stream=stream,
|
||
temperature=0.8,
|
||
max_tokens=2048,
|
||
top_p=0.8,
|
||
extra_body={"chat_template_kwargs": {"open_thinking": True}, "reasoning_effort": "medium"}
|
||
)
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- 客户端通过 `base_url` 连接本地或第三方 OpenAI 兼容服务。
|
||
- `messages` 仍然是标准聊天消息。
|
||
- `open_thinking` 被放进 `extra_body.chat_template_kwargs`。
|
||
|
||
File: `scripts/chat_api.py:27-39`
|
||
|
||
Read this to understand: 流式客户端如何接收 `reasoning_content` 和普通 `content`。
|
||
|
||
Code/config/template excerpt:
|
||
|
||
```python
|
||
for chunk in response:
|
||
delta = chunk.choices[0].delta
|
||
r = getattr(delta, 'reasoning_content', None) or ""
|
||
c = delta.content or ""
|
||
if r:
|
||
print(f'\033[90m{r}\033[0m', end="", flush=True)
|
||
if c:
|
||
print(c, end="", flush=True)
|
||
assistant_res += c
|
||
```
|
||
|
||
This code shows:
|
||
|
||
- 客户端把 `reasoning_content` 和 `content` 分开显示。
|
||
- 对话历史只保存普通回答 `assistant_res`。
|
||
- 客户端不需要知道模型内部 logits、mask 或 loss。
|
||
|
||
<a id="l27-must-write"></a>
|
||
## 5. 本节必须会写 / 暂时不要求
|
||
|
||
本节必须会写:
|
||
|
||
```text
|
||
1. 说明 torch state_dict 和 transformers 模型目录的区别。
|
||
2. 画出 CLI/API/WebUI 共同的推理链路:
|
||
messages -> chat_template -> tokenizer -> generate -> decode。
|
||
3. 解释 open_thinking 为什么是 template 参数。
|
||
4. 解释 <think> 和 <tool_call> 如何变成 reasoning_content 和 tool_calls。
|
||
5. 用 OpenAI SDK 写一个最小 chat.completions 请求。
|
||
```
|
||
|
||
本节暂时不要求:
|
||
|
||
```text
|
||
1. 手写生产级 HTTP 服务。
|
||
2. 实现并发调度、批量推理、KV cache 复用服务。
|
||
3. 实现鉴权、限流、日志、监控。
|
||
4. 重新实现 Streamlit WebUI。
|
||
5. 调通 vLLM / Ollama / llama.cpp 的所有参数。
|
||
```
|
||
|
||
部署侧属于工程外围。课程里优先复用原项目脚本,把学习重点放在接口边界和数据流上。
|
||
|
||
<a id="l27-experiment"></a>
|
||
## 6. 实验验证
|
||
|
||
### 实验 A:检查部署侧三个协议面
|
||
|
||
这个实验不启动服务,也不加载完整模型权重。它检查三件事:
|
||
|
||
```text
|
||
transformers 模型目录是否齐全;
|
||
chat template 如何把 tools/open_thinking 渲染进 prompt;
|
||
serve_openai_api.parse_response 如何把模型文本拆成 API 字段。
|
||
```
|
||
|
||
命令:
|
||
|
||
```bash
|
||
cd /home/sun/minimind
|
||
python course/labs/trace_deploy_surfaces.py
|
||
```
|
||
|
||
记录:
|
||
|
||
```text
|
||
model_type =
|
||
architectures =
|
||
contains_tools_tag =
|
||
contains_tool_call_instruction =
|
||
has_open_think_prompt =
|
||
reasoning_content =
|
||
tool_calls =
|
||
```
|
||
|
||
输出怎么看:
|
||
|
||
```text
|
||
contains_tools_tag=True:
|
||
说明工具 schema 已经进入 prompt。
|
||
|
||
contains_tool_call_instruction=True:
|
||
说明模板明确要求模型用 <tool_call>...</tool_call> 输出动作。
|
||
|
||
has_open_think_prompt=True:
|
||
说明 open_thinking=True 时,assistant 起始处留下未闭合的 <think>。
|
||
|
||
tool_calls 非空:
|
||
说明服务端能把 MiniMind 文本协议转成 OpenAI 风格 tool_calls 字段。
|
||
```
|
||
|
||
### 实验 B:用本地 transformers 模型跑一次真实推理
|
||
|
||
这个实验验证 `minimind-3/` 目录能被当前环境加载,并能完成一次真实生成。
|
||
|
||
命令:
|
||
|
||
```bash
|
||
cd /home/sun/minimind
|
||
python course/labs/run_minimind3_once.py --device cpu --max_new_tokens 32 --prompt "MiniMind 是什么?"
|
||
```
|
||
|
||
记录:
|
||
|
||
```text
|
||
input_ids.shape =
|
||
generated_ids.shape =
|
||
new_tokens =
|
||
response =
|
||
```
|
||
|
||
输出怎么看:
|
||
|
||
```text
|
||
input_ids.shape:
|
||
prompt 被 chat template 和 tokenizer 转成了模型输入。
|
||
|
||
generated_ids.shape:
|
||
模型完成了自回归生成。
|
||
|
||
new_tokens:
|
||
新生成 token 数量,不包含 prompt。
|
||
```
|
||
|
||
### 实验 C:可选启动 OpenAI 兼容服务
|
||
|
||
当前课程默认不要求启动长驻服务。准备好 transformers 模型目录后,可以手动试:
|
||
|
||
命令:
|
||
|
||
```bash
|
||
cd /home/sun/minimind/scripts
|
||
python serve_openai_api.py --load_from ../minimind-3 --device cpu
|
||
```
|
||
|
||
另开一个终端调用:
|
||
|
||
```bash
|
||
cd /home/sun/minimind/scripts
|
||
python chat_api.py
|
||
```
|
||
|
||
注意:
|
||
|
||
```text
|
||
chat_api.py 默认 base_url 是 http://localhost:11434/v1;
|
||
serve_openai_api.py 默认端口是 8998。
|
||
如果要直接连这个服务,需要把 chat_api.py 里的 base_url 改成 http://localhost:8998/v1,
|
||
或者用兼容 OpenAI 的客户端自己指定 base_url。
|
||
```
|
||
|
||
### 实验 D:可选启动 WebUI
|
||
|
||
WebUI 会扫描 `scripts/` 目录下的模型子目录。当前本地模型在项目根目录 `minimind-3/`,所以需要先复制或软链接到 `scripts/` 下。
|
||
|
||
命令:
|
||
|
||
```bash
|
||
cd /home/sun/minimind
|
||
ln -s ../minimind-3 scripts/minimind-3
|
||
cd scripts
|
||
streamlit run web_demo.py
|
||
```
|
||
|
||
如果 `streamlit` 未安装,这个实验会失败。当前课程的必须验收仍然是实验 A 和实验 B。
|
||
|
||
<a id="l27-stage-assembly"></a>
|
||
## 7. 阶段组装
|
||
|
||
到第 27 课为止,完整 MiniMind 链路可以这样收束:
|
||
|
||
```text
|
||
第 1-7 课:
|
||
数据、tokenizer、pretrain/SFT 训练入口。
|
||
|
||
第 8-13 课:
|
||
MiniMind 模型结构、Attention、RoPE、KV cache、FFN/MoE。
|
||
|
||
第 14-19 课:
|
||
训练机制、checkpoint、full SFT、LoRA。
|
||
|
||
第 20-26 课:
|
||
distillation、DPO、PPO、GRPO/CISPO、reward/logprob/KL、Agentic RL。
|
||
|
||
第 27 课:
|
||
把训练出的模型接到 CLI、OpenAI-compatible API、WebUI。
|
||
```
|
||
|
||
部署阶段不新增手写核心模型模块。它主要复用:
|
||
|
||
```text
|
||
model/model_minimind.py:
|
||
原生 torch 权重加载时使用。
|
||
|
||
minimind-3/:
|
||
transformers 生态加载时使用。
|
||
|
||
scripts/convert_model.py:
|
||
在原生权重和 transformers 目录之间转换。
|
||
|
||
scripts/serve_openai_api.py:
|
||
暴露 OpenAI 兼容 HTTP 接口。
|
||
|
||
scripts/web_demo.py:
|
||
提供本地浏览器交互界面。
|
||
|
||
course/labs/trace_deploy_surfaces.py:
|
||
验证部署协议面,不依赖长驻服务。
|
||
```
|
||
|
||
阶段验收命令:
|
||
|
||
```bash
|
||
cd /home/sun/minimind
|
||
python course/labs/check_project_readiness.py
|
||
python course/labs/trace_deploy_surfaces.py
|
||
python course/labs/run_minimind3_once.py --device cpu --max_new_tokens 16 --prompt "你好"
|
||
```
|
||
|
||
Portfolio 记录可以写:
|
||
|
||
```text
|
||
读懂并验证了 MiniMind 从训练权重到部署接口的链路:
|
||
torch state_dict -> transformers model directory -> AutoTokenizer/AutoModelForCausalLM -> chat template -> generate -> OpenAI-compatible response。
|
||
能够解释 <think> / <tool_call> 文本协议如何在 API/WebUI 侧转换为 reasoning_content / tool_calls。
|
||
```
|
||
|
||
<a id="l27-check"></a>
|
||
## 8. 本节检查
|
||
|
||
1. 原生 `out/full_sft_768.pth` 和 `minimind-3/` transformers 目录有什么区别?
|
||
2. `convert_model.py` 为什么要保存 tokenizer,而不只是保存模型权重?
|
||
3. `eval_llm.py --load_from model` 和 `eval_llm.py --load_from ./minimind-3` 分别走哪条加载路径?
|
||
4. `open_thinking=True` 最终影响的是模型结构、采样参数,还是 chat template?
|
||
5. API 服务端为什么要把 `<tool_call>` 文本解析成 `tool_calls` 字段?
|
||
6. WebUI 为什么要重新 `apply_chat_template` after tool result?
|
||
7. OpenAI-compatible API 里的“compatible”具体兼容的是哪一层?
|
||
|
||
<a id="l27-next"></a>
|
||
## 9. 下一课
|
||
|
||
第 28 课进入总复盘与小项目。
|
||
|
||
下一课要把全课程收束成一个可展示的成果:
|
||
|
||
```text
|
||
从 tiny 数据跑通关键实验;
|
||
整理 tokenizer / model / train / RL / deploy 的完整调用链;
|
||
列出教学版实现和原项目实现的差异;
|
||
把 portfolio 中的实验记录、实现说明和简历描述整理成最终版本。
|
||
```
|