技术架构
总览
flowchart TB
subgraph 离线["离线:数据、训练、评测"]
LX[lexicon.py<br/>标注规范与语法词表] --> GEN[generate.py]
OOD[ood_handwritten.txt] --> BUILD
GEN --> BUILD[build.py<br/>清洗/去重/划分/泄漏检查]
BUILD --> DS[(data/processed/*.jsonl)]
DS --> TRAIN[train.py]
BASE[(MiniMind 官方权重)] --> TRAIN
TRAIN --> RUN[(runs/<name>/<br/>日志 检查点 best.pth)]
RUN --> EVAL[evaluate.py]
DS --> EVAL
RULES[baseline_rules.py] --> EVAL
EVAL --> RES[(eval/results/<br/>预测 指标 失败案例)]
RUN --> PKG[package.py]
RES --> PKG
PKG --> MODEL[(models/feedlens-minimind-v0.1/<br/>权重 配置 说明卡)]
end
subgraph 在线["在线:网页应用"]
WEB[浏览器<br/>原生 HTML/CSS/JS] <--> API[FastAPI<br/>app/server.py]
API --> FM[FeedLensModel<br/>modeling.py]
MODEL --> FM
RES --> API
end
subgraph 上游["上游:MiniMind(只读引用)"]
UP[model_minimind.py<br/>model_lora.py<br/>tokenizer]
end
UP --> TRAIN
UP --> FM
SCHEMA[schema.py<br/>标签/提示词/解析] --- GEN
SCHEMA --- TRAIN
SCHEMA --- EVAL
SCHEMA --- API
目录结构
My Minimind Project/
├── feedlens/ 本项目的 Python 包
│ ├── schema.py 任务定义:标签、提示词、输出解析(唯一来源)
│ ├── modeling.py 对接上游模型;统一的提示词拼接与批量解码
│ ├── train.py 训练:LoRA / 全参 / 从零,支持续训
│ ├── evaluate.py 评测与报告生成
│ ├── metrics.py 指标实现(纯 Python)
│ ├── baseline_rules.py 关键词规则基线
│ ├── infer.py 命令行推理
│ ├── package.py 打包模型目录与说明卡
│ └── data/
│ ├── lexicon.py 标注规范 + 合成语法词表
│ ├── generate.py 合成数据生成器
│ ├── build.py 数据流水线
│ └── review.py 抽样人工复核
├── configs/ 训练配置(YAML)
├── data/raw | processed | reports
├── third_party/minimind/ 上游源码,固定在提交 f659b55,未修改
├── weights/base/ 上游官方权重(下载,不入库)
├── runs/ 每次训练的配置、日志、检查点
├── models/ 打包后的可发布模型
├── eval/ 系统定义、原始结果、自动生成的报告
├── app/ 网页应用(FastAPI + 静态前端)
├── site/ 项目介绍页与文档中心(由 docs/ 生成)
├── docs/ 本文档
├── scripts/ 批处理脚本
└── tests/ 流程级测试
模块说明
任务定义:schema.py
标签集合、输入长度限制、提示词模板、输出解析都只在这一个文件里定义。数据生成、训练、评测、规则基线、后端接口全部从这里读取。
这样做是为了防止一类很常见的错误:训练用的格式和评测或上线解析的格式悄悄不一致,导致指标虚低或虚高却难以发现。
与上游的边界:modeling.py
| 来自上游 MiniMind(未修改) | 本项目实现 |
|---|---|
模型结构 MiniMindForCausalLM(RoPE、RMSNorm、分组注意力) |
权重加载(weights_only=True,只反序列化张量) |
| 分词器与对话模板 | 提示词拼接函数 render_prompt |
LoRA 的 apply_lora / save_lora / load_lora |
按长度分组的批量贪心解码 |
自回归生成循环 generate(含 KV 缓存) |
FeedLensModel 推理封装 |
上游代码放在 third_party/minimind/,通过把该目录加入 sys.path 来导入,不复制、不改动。好处是升级上游时只需切换提交并重跑测试,也让“哪些是我写的”一目了然。
训练:train.py
上游提供了 train_full_sft.py 和 train_lora.py。本项目没有直接调用它们,而是重写了训练循环,原因如下:
| 需求 | 上游脚本的情况 | 本项目的做法 |
|---|---|---|
| 在 Apple MPS 上训练 | 混合精度写死为 torch.cuda.amp |
设备自动选择;仅 CUDA 启用混合精度 |
| 验证集与最优检查点 | 无验证环节,按步数覆盖保存 | 定期计算验证损失,保存最优 |
| 结构化日志 | 仅打印到终端 | 同时写 JSONL,供曲线和文档读取 |
| 训练与推理前缀一致 | 以 80% 概率删除空的思考标签 | 始终保留,与推理时逐 token 相同 |
| 精确续训 | 支持,依赖固定长度 padding | 支持,样本顺序只由(种子,轮次)决定 |
| 数据加载依赖 | 需要 datasets 库 |
直接读 JSONL |
学习率调度、权重保存格式(half 精度的 state dict)与上游保持一致,所以本项目训练出的全参数权重可以被上游的 eval_llm.py 直接加载。
评测:evaluate.py、metrics.py
- 参评系统在
eval/systems.yaml里声明,评测代码对所有系统走同一条路径。 - 指标用纯 Python 实现,约 120 行,没有外部依赖,便于逐行核对。
- 每个系统、每个划分都保存三份文件:全部预测、汇总指标、失败案例。报告由这些文件生成,不手写数字。
网页应用:app/
| 层 | 选型 | 理由 |
|---|---|---|
| 后端 | FastAPI + Uvicorn | 几个 JSON 接口加静态文件,足够轻;自带接口文档 |
| 前端 | 原生 HTML / CSS / JS,无构建步骤 | 只有一个页面、四个视图;不引入框架意味着克隆下来就能运行,也方便嵌入个人网站 |
| 推理设备 | 默认 CPU | 实测这个 64M 模型的单条请求在 CPU 上比 MPS 更快(见 评测报告) |
| 状态 | 无数据库、无会话 | v0.1 不保存任何用户输入 |
文档站点:site/
scripts/build_site.py 把 docs/*.md、自动生成的数据报告、评测报告和模型说明卡转成静态 HTML。站点只使用相对路径,可以整个目录复制到任何静态托管位置,或挂在个人网站的子路径下。
关键技术决策
| 决策 | 选择 | 放弃的方案 | 理由与代价 |
|---|---|---|---|
| 输出格式 | 四行“字段:值” | JSON | 6400 词表下符号被切碎,JSON 更长更易错。代价:需要自己写解析器 |
| 解码方式 | 贪心 | 采样、约束解码 | 可复现。约束解码能保证格式合规,但会掩盖模型真实的格式学习情况,留作后续优化 |
| 起点权重 | 官方 full_sft |
官方 pretrain;随机初始化 |
随机初始化作为对照实际训练并评测;从 pretrain 出发的对照配置已写好但没有运行。见 训练记录 |
| 微调方式 | 全参与 LoRA 都做,按结果选 | 只做一种 | 模型只有 64M,全参微调本机几分钟可完成,没有必须用 LoRA 的资源压力 |
| 选检查点 | 验证损失最低 | 训练结束时的权重;按测试集选 | 不能用测试集选。事后看,按验证损失选是错的:两次训练里它选中的检查点都不是生成准确率最高的。应当按验证集生成准确率选,训练脚本目前只是额外保存了结束时的权重,见 训练记录 第 7 节 |
| 训练框架 | 自写循环 | Hugging Face Trainer / PEFT | 模型是自定义结构,上游 LoRA 也是手写的;自写循环约 250 行,可完全解释 |
运行环境
| 项目 | 版本 |
|---|---|
| 硬件 | Apple M5,24 GB 统一内存,无独立显卡 |
| 系统 | macOS 27.0.1(arm64) |
| Python | 3.12.13(uv 管理的虚拟环境) |
| PyTorch | 2.14.1(MPS 可用) |
| Transformers | 4.57.6(与上游 requirements.txt 一致) |
| 上游 MiniMind | 提交 f659b55 |
依赖列表见仓库根目录 requirements.txt。
本页由 docs/06-architecture.md 生成。