最近,我写了一篇关于一款利用人工智能工具开发的教育应用的文章,文中也谈到了我在开发过程中所做的设计决策。
当我向几位教育工作者展示这款基于活动的学习应用的原型时,他们反复提出一个建议——这个建议源于他们在Pinterest和TikTok等平台上寻找创意时的经验。他们希望这款应用能够根据实际搜索条件,从互联网上检索出适合的项目想法:这些条件包括他们能够获取到的资源,以及他们期望最终产品呈现出的样子。
目前这款应用已经具备基本的搜索功能,可以从中检索到自己内部数据中的结果,但现阶段这些数据的范围仍然有限。而从外部来源生成搜索结果,似乎正是大型语言模型所擅长的领域。
我也很好奇:究竟应该如何训练一款适用于K12教育场景的大型语言模型呢?当然,这里指的不是那些需要庞大数据集和强大计算能力的语言模型(因为我并没有这样的条件),而是训练这类模型的具体方法。而且,就像我在之前的文章中一样,我也想深入思考一下其中涉及的各种设计决策:
-
训练小型语言模型以适应K12教育场景,其中究竟有哪些技术难点?
-
应该如何利用哪些数据来训练这样的模型呢?
-
如何确保这款模型适合儿童使用呢?
-
要将这样的模型集成到自己的应用中,需要做些什么准备呢?
在这篇文章中,我会记录下自己关于训练这类模型以及将其整合到教育应用原型中的所有经验。
目录
先决条件
这是一份实践性很强的教程,因此以下内容将帮助你顺利学习或自行训练模型。
你需要掌握的技能
-
能够在命令行中使用Claude工具。
-
具备基本的Python编程能力:能够阅读代码、安装和使用软件包、调用API,以及理解日志文件等输出结果。
-
需要了解一些TypeScript语法,因为这款应用的前端就是用这种语言编写的。
-
最重要的是,你需要能够理解Claude给出的推理过程,权衡各种选项,并决定下一步该采取什么行动。这种思维过程,而非某个具体的命令,才是这类项目真正要求的核心能力。
你并不需要具备机器学习的背景知识。这篇文章会用通俗易懂的语言逐步解释相关的概念。
所需准备的环境
-
一台苹果硅芯片Mac电脑(M1/M2/M3或更新的型号)。微调阶段会使用苹果自家的MLX框架,而该框架仅能在苹果硅芯片电脑上运行。
-
安装了Python 3,并创建了一个虚拟环境:
python3 -m venv。 -
需要安装Ollama工具,并拉取Qwen 2.5 7B模型:
ollama pull qwen2.5:7b》,这样才能在本地生成训练数据。运行这个7B模型需要足够的RAM内存。 -
还需要在命令行中使用Claude工具,以便进行构建相关操作。
数据集准备
对于这个实验来说,我希望所使用的数据能够反映世界各地的本土文化。这样模型才能提出富有创意的项目想法,从而有助于推动教育环境中各种文化活动的开展。
这些年来,我读过很多关于各地传统艺术和文化的维基百科文章。维基百科是我获取信息的首选资源:它以用户需求为导向,内容更新频繁,而且作为一个开源项目,它的API也是可以免费使用的。因此,我决定利用维基百科的数据来训练我的模型。
在这个阶段中,真正需要动手操作的部分就是为数据分配合适的分类标签。在一段Python脚本中,我定义了大约40个初始分类类别,并根据Claude的建议,将它们归入9个STEAM领域中;同时也学习了如何确定应该抓取哪些数据,以及如何避免获取到无关信息。
为了从每篇文章中提取文本内容,Claude推荐使用了一个针对维基百科API的Python封装工具。这个工具让我能够将每篇文章以结构化的数据形式提取出来。为了减少不必要的信息,我限制了爬取的深度,只抓取一级子分类下的内容,并且只保留那些字数达到一定要求的文章。
# 按STEAM领域划分的初始分类类别
SEED_CATEGORIES = {
"Crafts & making": [
"Category:Crafts",
"Category:Origami",
"Category:Pottery",
"Category:Kites",
],
"Arts": [
"Category:Folk art",
"Category:Textile arts",
"Category:Indigenous art",
"Category:Masks",
],
"Science": [
"Category:Ethnobotany",
"Category:Food preservation",
"Category:Gardening",
],
# ... Media arts, Engineering, Mathematics, Music & sky, Play & learning
}
MAX_DEPTH = 1 # 只爬取一级子分类下的内容
MIN_CONTENT_chars = 800 # 忽略简短的内容摘要
过滤语料库
在之前的步骤中,我们在抓取数据时获得了大约19,000篇文章。这一步的目的是确保这些内容与STEAM领域相关。相关性过滤分为两个阶段:首先去除明显无关的内容,然后进行语义筛选。
在第一阶段,我们会根据类别、标题和段落标题等特征,剔除那些与艺术/手工艺活动无关的内容,比如音乐、电影、电视节目、传记以及动植物物种的相关信息。
第二阶段,即语义处理阶段,会使用一个小型的句子转换模型(all-MiniLM-L6-v2)将每篇文章的标题和摘要转换为向量形式。随后,该模型会将这些向量与两组示例句子进行比较:正面示例句子和负面示例句子。
正面示例句子描述的是与STEAM活动相关的内容,而负面示例句子描述的是与STEAM活动关系不大的内容。每篇文章会根据其与正面示例句子的相似程度以及与负面示例句子的相似程度来获得相应的分数,只有那些倾向于属于正面的文章才会被保留下来。我们使用句子转换器库来完成这一过程。
编写这些示例句子是整个过程中最需要人工参与的部分。通过这种筛选方式,我最终将原始数据集缩减到了大约6,600篇文章。
# 对原始数据进行分析,筛选出对STEAM活动建议有用的文章。
POSITIVE_ANCHORS = [
"一种孩子们可以使用简单材料和特定技巧制作的动手手工活动",
"诸如编织、雕刻、陶艺或折纸之类的传统文化艺术或制作技术"
]
NEGATIVE_ANCHORS = [
"某种植物、动物或真菌",
"某个人的传记",
"某个城市、地区、建筑或地理地点"
]
# 将文章内容与示例句子进行匹配分析,保留那些倾向于属于正面的结果。
pos_sim = util.cos_sim(emb, pos).max(dim=1).values # 最接近的正面示例句子
neg_sim = util.cos_sim(emb, neg).max(dim=1).values # 最接近的负面示例句子
scores = (pos_sim - neg_sim).tolist()
生成训练对
下一步是从筛选后的数据集中生成输入与输出的训练对。我们是通过使用一个预训练好的开源模型(Qwen 2.5 7B,通过Ollama框架运行)来完成这一工作的。
对于每篇文章,我们需要向该模型提供文章的标题、摘要、文化背景信息以及部分具体内容。同时,我们还需要为模型提供一个系统提示,这个提示会说明任务的具体要求、指定输出格式(在本例中为有效的JSON格式),并包含一个示例训练对作为参考。
编写这个系统提示时,人工干预的作用最为关键:数据结构的设计、规则的定义以及那个成功的示例案例,这些因素都会直接影响模型生成的训练对的质量。
在生成训练对之后,我们需要对这些训练对进行清洗和处理,以便后续进行微调训练。由于本地使用的模型往往会自行创建一些类别标签(比如“陶瓷”、“手工制作”、“电路”等等),因此在这一步中,我们需要将所有的类别映射到应用程序预先设定的10个标准类别中(艺术、科学、编程、电路、工程、故事讲述、戏剧、电影、音乐、自然),同时确保每项活动的年龄范围属于K12阶段,然后将这些训练对转换成适合聊天对话的格式,最后将数据分成三组:训练集、验证集和测试集。
# 每个生成的训练对都必须遵循以下结构(仅限有效的JSON格式)。
{
"input": {
"materials": ["3-6种适合课堂使用的实际材料"],
"age_range": [最小年龄, 最大年龄],
"theme": "可选的字符串,或留空"
},
"output": {
"ideas": [
{
"title": "简洁明了的标题,最多60个字符",
"description": "2-3句话的描述",
"category": "艺术、科学、编程、电路、工程等类别之一",
"cultural_origin": "具体的地区或文化背景",
"materials_used": "所需材料中的一部分",
"materials_missing": "还缺少的其他材料",
"estimated_minutes": 整数,
"steps": "3-6个简短的步骤,每个步骤对应一句话",
"learning_objectives": "2-4个学习目标",
"safety_note": "安全提示信息,或留空"
}
]
}
}
微调
这一步骤中,模型会学习如何表现出适当的行为,并生成符合要求的响应。具体来说,就是利用LoRA技术,在我的数据集上通过MLX对预训练好的模型(本次使用的是Qwen2.5-1.5B-Instruct-4bit模型)进行微调。
使用LoRA进行微调是一种成本低廉且效率较高的方法:它不会重新训练整个模型,而是添加一个微小的调整层来改变模型的最终行为表现,而原始模型本身保持不变。
考虑到这个项目的限制条件——我们使用的只是一台个人笔记本电脑,而且数据集规模只有大约400对样本——如果进行全面的微调,将会需要大量的内存和计算资源,这显然是不切实际的。因此,选择LoRA才是正确的决定。
LoRA微调流程:

在训练过程中,模型会针对每一对训练样本进行多次迭代,而每次迭代都包含相同的处理流程。对于每一个输入,模型会根据当前的权重为所有可能的后续单词分配概率值,然后根据这些概率值来生成预测结果。在训练过程中,模型会根据实际正确的后续单词所对应的概率值来评估自己的表现。
(注:在神经网络中,权重和偏置决定了模型如何处理输入信息、进行预测并生成输出结果。)
通过这种比较,模型可以计算出训练损失值,并据此更新权重,尤其是那些用于LoRA微调的权重。而原始模型的权重则保持不变,这样在下一次迭代中,模型的预测结果就会更接近正确答案。损失值越低,说明模型对数据的拟合效果越好。
随后,模型会进入下一次迭代,这个循环会不断重复。最终,经过训练后的权重会被保存到一个safetensors文件中。
以我的实验为例,验证损失值的变化过程如下:2.532 → 0.842 → 0.823 → 0.814 → 0.820 → 0.831 → 0.845。起初损失值下降得很快(说明模型确实在学习),在大约第300次迭代时降至最低点0.814,然后又在后期回升到了0.845。这个变化趋势表明模型开始出现过拟合现象,也就是说它已经开始记住训练数据中的信息,而无法继续改进了。
因此,最佳的最佳时机是在实验进行到中途的时候,而不是最后阶段。在这个阶段,人工审核就显得尤为重要了:我在第200次、第400次和第600次迭代时分别保存了模型检查点,最终选择了第400次迭代的检查点——因为它的验证损失值是最低的——来进行评估和部署。
# 基础模型 —— 体积较小,经过指令微调,采用4位编码格式(在笔记本电脑上运行)
model: "mlx-community/Qwen2.5-1.5B-Instruct-4bit"
train: true
data: "data/mlx" # 训练数据:train.jsonl + valid.jsonl
adapter_path: "adapters" # <>- 经过微调的LoRA权重会被保存在这里
fine_tune_type: lora
num_layers: 8 # 只对模型的最后8层Transformer层应用LoRA技术
lora_parameters:
rank: 8 # 适配器的规模——规模越大,容量越高,但过拟合的风险也越大
# 训练循环参数
batch_size: 4 # 每个训练批次包含400个样本,因此每个 epoch会进行100次迭代
iters: 600 # 整个训练过程会遍历训练数据集约6次
learning_rate: 1e-5
# 通过监控验证损失值来防止过拟合
steps_per_eval: 100 # 每100步检查一次验证损失值
save_every: 200 # 在第200次、第400次和第600次迭代时保存模型检查点
上面是配置文件。其中列出了所使用的模型、适配器的路径、微调及LoRA相关的设置、训练流程以及验证过程。
下面是用MLX(苹果公司的机器学习框架)运行的命令,该命令会启动微调过程:
mlx_lm.lora --config lora_config.yaml
运行后的结果会显示在下方:训练得到的权重会被保存在“adapters/”文件夹中,每进行200次迭代就会在200、400和600这些节点处生成检查点文件。
adapters/
├── 0000200_adapters.safetensors
├── 0000400_adapters.safetensors <- 最终使用的权重版本(在三个检查点中损失值最低)
├── 0000600_adapters.safetensors
└── adapters.safetensors <- 最终训练得到的权重副本
评估微调后的模型
微调完成后,需要使用在训练过程中未被使用的测试集来评估模型的性能。这些测试数据是在生成训练对时预留出来的,因此在训练过程中从未被使用过。
在这个步骤中,用户输入的信息会被输入到模型中,模型会生成相应的JSON格式答案,然后这个答案会与预先存储在文件中的正确答案进行对比。
评估过程会检查生成的JSON格式答案是否有效、是否包含预期的关键信息、预测结果与正确答案之间的重叠程度如何、预测结果是否经常指出特定的文化起源等等。
这一评估过程会对测试集中的每一个样本进行检测,最后会输出每个样本的评估结果以及总体总结。所有评估结果都会被保存下来,包括每个预测结果以及对应的正确答案,这样就可以将它们并排进行对比了。
# 在50个预留的测试样本上对微调模型进行的评估:
{
"json_valid_rate": 1.00, # 所有生成的JSON格式答案都是有效的
"schema_match_rate": 1.00, # 所有答案都包含了正确的关键信息
"avg_n_steps": 4.74, # 每个预测结果平均需要大约5步的计算过程
"avg_materials_jaccard": 0.653, # 预测结果与正确答案之间的重叠程度较高
"pred_culture_specific_rate": 0.52, # 大约有一半的预测结果指出了具体的文化起源
"culture_loose_match_rate": 0.108, # 但大多数情况下预测的文化起源都是错误的 <-- 这正是RAG技术试图解决的问题
}
构建索引系统与RAG检索机制
在之前的步骤中我们发现,culture_loose_match_rate_when_gold_specific这个指标的值较低:这意味着模型在为某项建议的活动指明正确的文化起源方面表现不佳。
在这一步中,我们将尝试通过RAG技术来解决这一缺陷。也就是说,我们不会指望模型能够记住“Raku”这个词代表日本文化,而是在用户发起查询时直接从维基百科中获取相关信息,然后将其提供给模型进行处理,从而验证RAG检索机制是否真的能够帮助模型提高准确率。
这一过程分为两个步骤。首先,我们会构建一个检索索引,将我们之前收集的维基百科语料库转化为一个可供搜索的“意义数据库”。对于每篇文章,我们都会通过将其标题和摘要输入到一个名为MiniLM-L6-v2的小型嵌入模型来计算出对应的嵌入向量。这种嵌入向量实际上是一种表示文章意义的数字“指纹”——由384个数字组成的数值序列,那些含义相似的文章所产生的嵌入向量也会具有相似的值。这些嵌入向量的计算是在离线环境下完成的,计算完成后会保存到磁盘上。
接下来是检索环节。在查询时,我们会将查询内容转换成同样的向量形式,然后根据每篇文章与查询内容的相似程度为它们打分,并返回得分最高的几篇文章(也就是那些含义最接近用户需求的文章)。随后我们会进行与前一个阶段相同的评估,只不过这次会将这些被检索出来的文章插入到提示语中,以此来回答核心问题:当模型获得了正确的维基百科文章后,它的表现是否会更好呢?
简而言之,这个阶段的任务就是:检索出相关的文章,将这些文章添加到提示语中,然后让模型根据这些提示生成相应的内容。
def retrieve(query, embedder, embeddings, meta, k):
# 1. 将查询内容转换成长度为384的向量
q = embedder.encode([query], normalize_embeddings=True,
convert_to_numpy=True)[0]
# 2. 根据相似度为每篇文章打分(单位向量的点积即为余弦值)
sims = embeddings @ q
# 3. 取得分最高的k篇文章,并返回它们的信息
top = np.argsort(-sims)[:k]
return [(meta[i], float(sims[i])) for i in top]
通过使用RAG技术,相关材料的重叠程度得到了改善,模型也更频繁地能够准确识别出特定的文化元素——但实际的文化匹配度几乎没有任何变化。这是我在未来版本的这款应用中想要改进的地方。
指标 原始值 + RAG值 变化幅度
materials_jaccard 0.653 0.752 提高
pred_culture_specific_rate 0.52 0.64 提高
culture_loose_match_rate 0.108 0.135 基本没有变化
将模型与该功能整合起来
现在,是时候将这个经过微调的模型集成到应用中了,看看它能够生成哪些具有文化特色的活动内容,从而为教育工作者提供灵感。
整个流程从“建议”界面开始:教育工作者可以输入他们手头现有的材料,也可以选择活动的主题。随后,系统会分两个阶段来完成建议内容的生成:首先是检索相关文章,然后是根据这些文章来设计具体的活动方案。
首先,应用会在维基百科索引中进行向量搜索,从而列出与用户输入内容相匹配的、具有特定文化背景的文章列表。这个过程不需要使用任何模型,因此列表会立即显示出来。
当用户点击其中一篇文章时,系统会进入详细页面,在那里经过微调的模型会根据这篇文章的内容生成一份完整的STEAM教育活动方案:包括活动名称、描述、所需材料、详细的操作步骤、学习目标以及安全注意事项——这些信息都是开展课堂活动所必需的。
// 第一步 — 检索阶段:根据教育工作者提供的材料,列出相关的文化主题文章。
// 这个过程仅在服务器端进行向量搜索,不需要模型参与,因此列表会立即显示出来。
export async function fetchInspiration(materials: string[], theme?: string) {
const res = await fetch(`${BASE_URL}/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ materials, theme: theme ?? null }),
});
return res.json(); // 返回结果:{ results: [...]articles ]
}
// 第二步 — 生成阶段:只有当教育工作者点击某一篇文章时,这个步骤才会被执行。
// 经过微调的模型会根据选中的文章内容生成完整的活动方案。
export async function fetchActivity(
articleId: number,
materials: string [],
ageRange: [number, number],
) {
const res = await fetch(`${BASE_URL}/activity`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ article_id: articleId, materials, age_range: ageRange }),
});
return res.json(); // 返回结果:{ activity: {...}, article: {...} }
}
以这种方式区分浏览内容和生成内容,既是一种成本上的考虑,也是一种质量上的考量:因为信息检索本身基本上是免费的,所以模型只需要针对教育者实际选择的主题运行一次,而不需要为网格中的每一条信息都运行一次。

确保内容的安全性
尽管在开发流程的许多阶段都已经采取了措施来保证模型生成的内容是安全的,但我还是想在最后专门讨论这个话题。
虽然该应用程序的直接使用者是教育工作者,但该功能生成的任何内容都可能被孩子们看到。因此,我们绝对不能生成与酒精、毒品、烟草、武器、爆炸物或毒物等相关的内容——总之,任何不适合儿童阅读的内容都不应该被呈现出来。
模型本身是无法自动处理这类问题的。这个经过微调的模型只是基于文化相关的例子进行训练的,它并没有内置机制来拒绝不安全的请求;至于酒精和武器等概念的相关信息,仍然存在于基础模型的参数中。
作为开发者,我们必须设置必要的防护措施和检查点,并告诉模型应该如何行为。我们通过两个阶段来实现这一目标:
-
在数据源阶段就对内容进行预过滤,从而降低风险。就像我们之前去掉那些不相关的类别一样,对整个数据集以及生成的训练样本进行筛选,就可以确保模型永远不会接触到不安全的内容。如果你打算将模型或数据集发布到Hugging Face这样的平台上,这一步骤尤为重要,因为这些平台通常会要求对内容进行过滤。通过这一步骤,我们从大约19,000篇被抓取到的文章中去掉了约850篇不安全的文章。
-
在ZubHub应用程序中设置运行时的防护机制,作为最终的保障措施。虽然数据过滤可以降低风险,但无法消除基础模型已经掌握的信息。因此,在信息检索之前以及结果展示之前,应用程序都会对所有输入内容进行检测。这样一来,任何包含不安全词汇的内容都不会被检索出来或显示给用户。
# safety.py — 一份列出绝不能让儿童接触的内容的清单...
UNSAFE_TERMS = {
# ...
}
# ...这些匹配规则是针对整个单词进行的,因此“twine”不会被识别为“wine”,“gunny sack”也不会被识别为“gun”。
def screen_text(text):
"""如果文本中包含不安全的词汇,就返回相应的类别;如果文本安全,则返回None。"""
for category, pattern in _PATTERNS.items(): # _PATTERNS是根据UNSAFE_TERMS生成的
if pattern.search(text):
return category
return None
# 第一阶段:在数据进入训练流程之前,就去除不安全的文章。
for article in corpus:
if screen_text(article["title"] + article["summary"]):
continue # 这些内容永远不会被模型学习到
# 第二阶段:在程序运行时,对教育者的输入内容以及模型的输出结果进行检测。
if screen_text(user_input): # 在信息检索之前
return BLOCK_MESSAGE
answer = model.generate(...)
if screen_text(answer): # 在结果展示之前
return BLOCK_MESSAGE
结论
简而言之,本文介绍了如何训练小型大语言模型,使其能够为教育应用提供富有创意、实用性强的项目建议。
我们最初使用的是预训练模型Qwen2.5-1.5B-Instruct,并利用从维基百科的STEAM相关内容及文化文章中构建的数据集对它进行训练。
我们的目标是让这个模型能够根据简单的输入信息(教育者手头的材料、儿童的年龄范围以及可选的主题)生成结构化的JSON格式活动方案,其中包含活动标题、描述、详细步骤、学习目标以及安全注意事项。
在整个开发过程中,我们从头到尾解决了将小型大语言模型应用于K12教育场景所面临的各种技术问题:包括利用维基百科API构建数据集、过滤掉无关类别和不安全内容、生成训练数据对、使用LoRA技术对模型进行微调、评估模型的性能、建立检索索引以及运用RAG技术使建议内容更加具体可行,最后还将该模型集成到教育应用中。
最重要的是,通过这样一个实际操作的项目来构建这个系统,让我真正理解了机器学习/大语言模型领域的核心概念,而不再只是停留在抽象的理论层面。希望这对你们也能产生同样的效果!
资源
- 请访问这个特定的Pull Request,查看源代码。