跳转至

2. 训练数据:模型从哪里学、学什么

微调的效果几乎完全由数据决定:模型最后会说成什么样,就是你给它看的示范长什么样。所以在花钱开 GPU 之前,先把数据看明白。

本章速览

  • 本章做什么:逐字段读一张事实卡和一段对话;看一段对话怎样拆成训练样本;看清模型到底“看到什么、学什么”
  • 需要准备:只需要浏览器或编辑器;想亲手跑检查时,第 2.8 节说明怎样在自己电脑装一个轻量环境(不需要 GPU)
  • 做完得到:能说出 1150 这个数字怎么来;能指出一条样本里哪些 Token 参与 loss;知道改数据后怎样让它生效

整个数据流可以先用一句话记住:一份 JSON → 拆成“上下文 + 一个参考回答”的样本 → 用聊天模板变成 Token → 只在参考回答的位置算 loss。 下面按这个顺序展开。

2.1 只维护一份 dataset.json

打开 data/dataset.json。所有资料、500 段训练对话和评估对话都在这个文件里。顶层有四个平级数组:facts 是参考事实卡,train、val、test 是三组完整对话。真正参与训练的只有 train;事实卡只作为上下文,不是学习目标。

三份按科学主题(group_id)切开,同一个主题只会出现在一份里,避免模型在 val/test 上遇到训练时见过的同一话题。

集合 科学主题 完整对话 回答目标 用途
train 50 500 1150 更新 LoRA 参数
val 5 50 115 比较配置、选择候选
test 5 50 115 候选冻结后评估

为什么还要一份 val,只有 train 和 test 不行吗?

只训练一次、直接拿结果时,两份就够了。问题出在根据测试结果做选择:用 r=16 训练,看 test;不满意换 r=8,再看 test;再调学习率、改提示词……最后挑出 test 上分数最高的版本。这时 test 已经不算“没见过的数据”:模型参数没碰过它,但你的每一次选择都参考了它,等于用它挑过答案,挑出来的分数会偏高。

所以把“用来做选择”和“用来出最终成绩”分开。类比后端发布:train 是开发环境,模型反复用它更新参数;val 是预发环境,你可以反复看它来比较、挑选;test 是上线后的真实流量,候选定下来后只看一次,出最终报告。

本项目里 val 支撑了这些选择:训练中每 12 步算一次验证 loss,判断是否过拟合(第 4.5 节);比较 r16 和 r8(第 6.4 节);比较“原模型 + 好提示词”的 B 组和 LoRA 的 C 组(第 6.5、6.6 节);最终选定候选(第 6.7 节)。选定后用 evaluate freeze 冻结,代码才允许在 test 上推理,而且不能用 --limit 只挑部分题。看过 test 又回去改模型或数据,下一轮就要换一份新的测试集。

别处常见“只要训练集和测试集”,通常是只训练一次、不做比较;或者用交叉验证在 train 内部轮流切出验证部分(val 藏在 train 里);或者直接把 test 当 val 用,这样得到的 test 分数偏乐观。

训练学段分布:1–2 年级 167 段,3–4 年级 167 段,5–6 年级 166 段。每个主题 10 条交流路径,每段 2~3 轮;既有同一知识在不同学段的解释,也有不同反馈导致的后续变化。

data/dataset.json
├── schema_version / description   文件版本与说明
├── facts[60]                      参考事实卡,只存一份,被对话按 id 引用
├── train[500]                     训练对话:唯一参与参数更新的部分
├── val[50]                        验证对话:比较配置、选候选、算 eval loss
└── test[50]                       测试对话:候选冻结后才使用

train / val / test 里的每段对话结构相同:
├── id / group_id                  对话编号 / 科学主题分组
├── grade_band                     1-2 / 3-4 / 5-6
├── fact_ids                       引用 facts 中的 id
└── turns[]                        user / assistant / behavior

对话属于哪个集合,只由它放在哪个数组决定,对话里不再写 split 字段,避免“放在 val 里却写着 train”这种自相矛盾。程序读入时(lab.data.read_dataset,Notebook 里是 tag_splits)会在内存里给每段对话补上 split,后面的筛选和跨集合检查都用它;源文件保持不变。要把一段对话挪到别的集合,就把整段剪切到另一个数组里,同一 group_id 的其他对话和它引用的事实卡要一起挪。

JSON 不支持 // 注释。这里解释字段,Notebook 用 D1~D7 标记实际使用位置。不要把整份 JSON 塞进提示词,也不要把评估对话一起传给训练器。

2.2 第一张事实卡

{
  "id": "day-night",
  "category": "space",
  "topic": "昼夜",
  "text": "自转使地球表面各处交替朝向和背向太阳,形成主要的昼夜变化。太阳没有按昼夜关闭发光。地球还绕太阳公转;假定取消自转但保留公转,光照位置仍会随公转缓慢变化。",
  "source_url": "https://spaceplace.nasa.gov/seasons/en/",
  "checked_at": "2026-09-29",
  "source_review": "reference_read_claim_review_pending",
  "scope": "昼夜的入门解释;模型和比喻不能替代真实测量。",
  "kind": "fact",
  "reuse": "科学事实摘要和教学对话为原创中文表达;来源仅供事实核对,不复制来源的教材段落、题目或图像,不把网页正文纳入训练。链接不表示来源机构认可本数据或授予网页内容的训练许可。"
}
字段 含义与用途
id 主键,由对话 fact_ids 引用
category/topic 分类及主题,供检索、统计和审核
text 可作为回答依据的科学资料;进入上下文
kind/scope 资料类型与适用范围;进入上下文
source_url、checked_at、source_review、reuse 来源与审核记录,只给人看,不进入模型

只有 id、text、kind、scope 四个字段会拼进提示词。其余字段是给审核者的,第一遍可以跳过。

为什么需要事实卡:三个常见疑问

微调教的是“怎么说”(第 0.1 节),初学者读到这里通常会接连冒出三个问题。

问 1:小学科学是常识,基座模型早就知道,为什么还要事实卡?

确实,这些常识基座模型大多知道。只论“知识从哪来”,事实卡不是必需的。仍然要它,原因不在补知识:

  1. 让训练只学“怎么说”。 loss 算在参考回答上,而参考回答里既有说法,也有事实内容。输入里没有资料时,模型只能靠权重把“转向太阳”“背光的一面”这些内容硬记下来,训练会把“说法”和“知识”混在一起学;资料就在输入里时,内容照着资料说就能预测对,LoRA 有限的容量主要花在“短句、先纠正误解、给一个安全的小观察”上。原理见问 3。
  2. 基座的知识检查不了、也改不了,事实卡可以。 你说不准一个 4B 模型会在哪道题上出错(比如把季节归因于离太阳远近),错了也没法打开权重去改。事实卡是一段看得见的文字,有来源、有审核状态,写错了改一张卡,下一次请求就生效。类比后端:业务规则放在配置或数据库里,而不是散落在编译好的二进制里。
  3. 卡上有基座不会主动遵守的边界。 例如 day-night 的 text 写明“假定取消自转但保留公转,光照位置仍会随公转缓慢变化”,参考对话第二轮的追问就以它为依据;scope 写明“模型和比喻不能替代真实测量”;kind 区分 fact、legend、current,传说不能当事实讲。
  4. 这就是真实产品的形态,也是评估的依据。 业务里模型要讲的往往是它根本不知道的东西:产品规格、本校课本、当天的数据。常见做法是调用方查到资料放进提示词,模型负责讲清楚,也就是常说的 RAG(检索增强生成)。本项目用事实卡完整练一遍这个模式。盲评时,评分人也要对照事实卡判断每条断言有没有依据(第 6.6 节)。

问 2:事实卡也在训练样本里,那训练完模型就记住这些知识了吗?

不能这样理解。事实卡是训练样本的输入,不是学习目标。第 2.5 节那条 408 Token 的样本里,事实卡在 mask=0 的 386 个 Token 中:模型看得到它,但不会因为“没记住它”而被扣分。loss 只考一件事:看着这些资料,回答写得像不像参考回答。

训练后学到了吗 原因
怎样读资料、怎样用资料组织回答 学到了 这是 loss 推着它学的东西
资料本身的内容和边界 不可靠 训练时资料一直摆在眼前,不需要记

需要说实话的一点:参考回答本身含有事实内容,这部分在 mask=1 的区域,模型会顺带记住一点常见说法。但只训 1 个 Epoch、每条只看一遍,记得很浅,也记不住卡上的完整内容和边界;你更没法知道它记住了哪些、记错了哪些。所以部署时每次都要传事实卡,第 5.5 节的调用示例先 read_dataset 读出 facts,就是这个原因。

想亲眼验证:训练完后用同一个问题问两次,一次带事实卡,一次去掉 CURRENT REFERENCE FACTS 那段,比较回答的准确性和边界。差别越明显,说明模型依赖的是当场给的资料,而不是背下来的知识。

问 3:既然不靠它背知识,训练时为什么还要放?部署时再传不行吗?

不行。训练不是要模型记住事实卡,而是要它学会使用事实卡;要学会使用,训练时就必须看见。用新员工培训打个比方:

练法 A:只给问答 练法 B:问答旁边附产品手册(本项目)
培训材料 客户问 X → 标准回复 Y 客户问 X + 手册相关页 → 标准回复 Y
练出来的习惯 听到问题,凭记忆背答案 先翻手册,再用标准口吻讲出来
上岗时递给他手册 没练过看手册,多半还背老答案 正是练过的流程
手册更新了一条 还按旧答案说 照新手册说
遇到没培训过的问题 凭印象硬答,容易编 照手册讲,手册没写的不乱说

换成本项目的同一条样本,目标回答完全相同,只是输入不同:

不带事实卡:
  输入:为什么白天亮、晚上黑?
  目标:地球在不停转,我们住的地方转向太阳就是白天……

带事实卡:
  输入:[事实卡:自转使地球表面交替朝向和背向太阳……] 为什么白天亮、晚上黑?
  目标:地球在不停转,我们住的地方转向太阳就是白天……

不带卡时,输入里没有任何依据,目标却是确定的科学结论,1150 条样本反复强化的是“听到问题,凭空给出确定结论”;带卡时,目标里的内容都能在输入里找到,反复强化的是“从给的资料里取内容,改写成孩子听得懂的话”。

如果训练时不放、只在部署时放,会出现四个问题:

  1. 模型没练过看资料。 部署时多出一大段 CURRENT REFERENCE FACTS,对它是陌生输入,可能被忽略,照样凭记忆回答。
  2. 改资料不生效。 发现某张卡写错,或想在某张卡的 scope 里补一条适用边界,练法 A 的模型不会跟着变,只能改训练数据重新训练。
  3. 新主题更容易编。 练法 A 养成的是“凭空给结论”的习惯,遇到训练里没有的主题也照样自信地下结论。
  4. 输入格式对不上。 训练时提示词里没有事实卡,部署时有,模型学到的说话方式依赖它训练时见过的输入样子;这也是第 5.5 节强调“提示词要和训练时一致”的原因。

用代码的话说,训练是在学一个函数 answer = f(question, facts, grade_band, history)。如果训练时只给 question,学到的是 f(question);部署时再多传一个它从没用过的参数 facts,它不会自动去用。

不用事实卡也能训练、也能回答,代价就是上面这些:编错了改不了、容易过度自信、换到基座不懂的领域要推倒重来。把“事实”和“说法”分开是本项目的设计取舍,不是唯一做法;它的收益最终要靠第 6 章的对照来验证。

事实卡在哪里用到

为什么单独存一份。 60 张卡被 600 段对话按 fact_ids 引用(一段对话可引用多张),类似数据库里的外键:同一条事实只改一处,所有引用它的对话下次读入时都会用新内容。

为什么也要按集合隔离。 一张卡只能被同一个集合(train/val/test 之一)的对话引用。否则验证、测试题依据的资料,模型在训练时已经见过,评估结果会偏乐观。

环节 代码位置 怎样使用事实卡
读入校验(D1) data.py validate id 唯一;字段齐全;source_url 为 https;kind 只能是 fact/legend/current;对话的 fact_ids 必须都存在;同一张卡不能跨 train/val/test
拼提示词(D3) common.py build_messages 按 fact_ids 取出卡片,只保留 id/text/kind/scope,以 JSON 写在 system 提示词的 CURRENT REFERENCE FACTS: 之后;来源与审核字段不进入模型
Notebook 训练与验证样本 Notebook 第 2 步 prepare-data 格 1150 条 train 样本和 115 条 val 样本都经 build_messages 带上各自的事实卡
Notebook 观察题 Notebook 第 4 步 cases 格 三道观察题各指定一个 fact_id,训练前后用同一张卡对比回答
命令行快照 prepare.py 原样写入快照的 facts.jsonl;few-shot 示例记录它们的 fact_ids
A/B/C 对照推理 infer.py 从快照读 facts.jsonl,三组都附当前题目的同一张事实卡(第 6.4 节)
盲评 evaluate.py blind 评分表带 fact_ids,评分人对照事实卡填写 fact_claims/supported(第 6.6 节)
部署后调用 第 5.5 节调用示例 read_dataset 读出 facts,messages_for(fact_id, ...) 拼出和训练相同的 system 提示词

来源与审核字段

source_url 是人工回查来源的入口,程序不会自动上网阅读。checked_at 只代表已阅读参考入口;source_review 为 reference_read_claim_review_pending 表示逐项断言仍待审核。reuse 是来源署名和原创改写说明,不代表来源机构认可本数据。NASA、NOAA、USGS 等官方科普和 OpenStax 教材是参考入口;正文为原创中文整理,比喻、儿童反馈和观察设计为合成示范。URL 存在不等于每句话有来源支持。

2.3 第一段完整对话

{
  "id": "day-night-01",
  "group_id": "day-night",
  "category": "space",
  "topic": "昼夜",
  "grade_band": "1-2",
  "scenario": "repair_misconception",
  "fact_ids": [
    "day-night"
  ],
  "provenance": {
    "type": "synthetic",
    "author": "Codex",
    "date": "2026-09-29",
    "method": "按主题逐项撰写知识、误解、追问、比喻边界和安全观察,再组合为10种教学对话路径;不是儿童真实对话,部分主题内回答复用。"
  },
  "review": {
    "status": "draft",
    "reviewer": "",
    "reviewer_kind": "",
    "notes": "待逐段核查科学事实、来源支持、适读学段、观察可行性、近重复与上下文衔接;成人审阅不代表已验证儿童探索兴趣。"
  },
  "turns": [
    {
      "user": "为什么白天亮、晚上黑?",
      "assistant": "地球在不停转,我们住的地方转向太阳就是白天,转到背光的一面就是晚上。",
      "behavior": "explain_at_grade"
    },
    {
      "user": "太阳晚上关灯睡觉了吧?",
      "assistant": "太阳没有在晚上关灯。是我们所在的地面转到了背向太阳的一侧。还可以接着想:如果地球不转,就没有黑夜了吗?",
      "behavior": "repair_then_offer_specific_question"
    }
  ]
}

孩子先问昼夜,再提出“太阳睡觉”的猜想。参考回答先解释现象,随后修正具体误解,并留下一个可选择继续思考的问题。不要只学“你真棒”等固定口头禅。

字段 是否进入模型、在哪里使用
id 生成 day-night-01:0 等样本编号,供追踪
group_id 同一科学主题统一分组;所有学段、路径、改写必须在同一 split
(所在数组) 这段对话在 train 数组里;读入时标为 split="train",D2 只筛选 train;不写入提示词
grade_band D3 写入 system 中的“小学学段”,是可见输入
fact_ids D3 选择本文件中的事实卡,只传 id/text/kind/scope
turns[].user 当前孩子的问题;此前问题进入 history
turns[].assistant 当前回答是训练目标;此前回答仅作上下文
turns[].behavior 供人工看本轮示范什么行为,不传给模型
category/topic/scenario 统计与审核标签,不把“孩子错在哪里”偷塞进输入
provenance、review 合成方法和审核状态,只给人看;全部对话目前是 draft

把这张表记住一条就够:模型看到的只有 system(角色 + 学段 + 事实卡)、history 和当前 user;它学的只有当前 assistant。 第 2.5 节会把这句话变成真实的 Token 和掩码给你看。

2.4 一段对话按回答展开

day-night-01 有两轮,展开为两条样本:

样本 上下文 参与 Loss 的目标
day-night-01:0 角色 + 学段 + 昼夜资料 + 为什么白天亮晚上黑 第 1 个参考回答 + EOS
day-night-01:1 同样角色、学段、资料 + 第 1 轮问答 + 太阳睡觉的猜想 第 2 个参考回答 + EOS

第一条看不到第二轮。第二条的历史回答也不参与本轮 Loss。500 段 train 中,350 段两轮、150 段三轮,因此产生 1150 条样本。1150 不是 Token 数,也不是优化器步数。

两轮对话是“训练两次”吗? 不是同一份内容训两遍,而是这段对话贡献了两条独立样本,每条在 1 个 Epoch 里各训一次。1150 条样本进入训练器后会被打乱,每 16 条更新一次参数,day-night-01:0 和 day-night-01:1 通常落在不同的 step 里。第 1 个回答只在第 1 条样本里算 loss;在第 2 条里它只是历史。

轮数更多时也是同一个规律,就像一个 session 的完整聊天记录:第 k 条样本把前 k−1 轮问答全部当作历史,只学第 k 个回答。这和部署时的调用方式一致:服务端不记状态,客户端每次把完整历史发过去(第 5.5 节)。有两点区别要知道:

  • 训练时历史里是参考回答,部署时历史里是模型自己说过的话,可能和参考回答不一样。第 6.5 节的连续对话模式专门检查这种差别。
  • 越往后的样本越长,历史在每条样本里都要重算一遍。本项目的校验只接受 2~4 轮的对话,超过 4096 Token 会直接报错,不会悄悄截断。

2.5 掩码:模型实际学的是哪些 Token

用本项目的分词器把 day-night-01:0 渲染出来(下面是真实输出,system 中间的角色规则省略了几行)。整条样本 408 个 Token,其中只有最后 22 个参与 loss:

mask │ 渲染后的文本
─────┼──────────────────────────────────────────────────────────
  0  │ <|im_start|>system
  0  │ 你是陪小学生探索科学的伙伴。用中文,先回应孩子真正的问题,再按需要解释。
  0  │ ……(角色规则共 10 行)
  0  │ 小学学段:1-2 年级。
  0  │ CURRENT REFERENCE FACTS:
  0  │ [{"id": "day-night", "text": "自转使地球表面……", "kind": "fact",
  0  │   "scope": "昼夜的入门解释;……"}]<|im_end|>
  0  │ <|im_start|>user
  0  │ 为什么白天亮、晚上黑?<|im_end|>
  0  │ <|im_start|>assistant
  1  │ 地球在不停转,我们住的地方转向太阳就是白天,转到背光的一面就是晚上。<|im_end|>

mask 为 0 的 386 个 Token 模型能看到,但不在这里算 loss;mask 为 1 的最后 22 个 Token 才是学习目标。事实卡就在这 386 个 Token 里:模型不背它,但要学会看着它回答,所以训练时也必须放(第 2.2 节问 2、问 3)。

最后一行切成 Token 是:地球 在 不停 转 , 我们 住 的地方 转向 太阳 就是 白天 , 转 到 背 光 的一面 就是 晚上 。 <|im_end|>。训练时模型在这 22 个位置上各猜一次“下一个 Token”,猜错多少就是 loss;前面 386 个位置只提供上下文。

为什么不让模型也学 system 和 user 部分?因为部署后这些内容总是由调用方提供,模型只需要学会“看到这样的输入,该怎样回答”。如果把 user 也算进 loss,模型会花力气去模仿孩子提问,还可能学会自己编造下一轮问题。

rows 留有 case_id/prompt/completion/input_ids/completion_mask,方便查看。实际训练只收后两列。Notebook 会打印第一条的完整提示词、目标和真实 Token 长度;不要沿用旧场景的长度数字。

completion_mask=0 表示上下文,=1 表示当前回答及 EOS。TRL 将上下文和 padding 的 labels 设为 -100,不对这些位置直接计算损失,但模型预测答案时仍能看到上下文。

EOS 是 <|im_end|>,每个目标只出现一次,让模型学习结束;PAD 与 EOS 不同。超出 4096 Token 会报错,需改写样本,不悄悄截掉答案。

2.6 回顾:从一份 JSON 到参数更新的七步

前面五节按“数据长什么样 → 怎样拆 → 模型看到什么、学什么”讲完了数据。把它们串起来就是下面七步。Notebook 的代码注释用 D1~D7 标出每一步在哪一行,训练时对照着看,就知道哪一行在做本章讲的哪件事。

步骤 做什么 对应本章
D1 读入 读 dataset.json,给每段对话标上所属集合,检查事实卡引用和跨集合隔离 第 2.1、2.2 节
D2 拆分 只取 train,把每段对话按回答拆成样本,历史只含此前的轮次 第 2.4 节
D3 拼输入 角色规则 + 学段 + 事实卡 + 历史 + 当前问题,拼成一组 messages 第 2.2、2.3 节
D4 标目标 套聊天模板、分词,把当前回答和 EOS 标为 mask=1 第 2.5 节
D5 交给训练器 只把 input_ids 和 completion_mask 两列交给 TRL 第 2.5 节
D6 再核对 检查训练器实际生成的 labels:上下文和填充位置都是 -100(不算 loss) 第 2.5 节
D7 留底 保存本次数据副本和指纹,然后 trainer.train() 才真正更新参数 第 4 章

2.7 想改数据,或换成自己的数据

练习时不需要改数据,本节在你想动手改的时候再读。

改现有数据。 修改说法就改 assistant;增加一种新的反馈路径时,要同时调整前后轮,别让孩子引用模型根本没说过的话;改了某张事实卡,要复查所有引用它的对话。改完后先跑结构检查:

python -m lab.data --input data/dataset.json --allow-draft

应输出 {'train': 500, 'val': 50, 'test': 50}。它只检查结构:字段齐全、事实引用存在、同主题不跨集合、每个回答最多一个问号;不检查科学内容对不对。

让修改生效。 已经训练好的 adapter 不会因为你改了 JSON 而变化。在 Notebook 里把 RUN_DIR 换成新目录(例如 runs/science-002),从第 2 步重新执行即可。每次训练的运行目录里会自动留一份 dataset.json 副本,记录“这次训练用的是哪份数据”,日常不要编辑它。

换成自己的数据。 开源 SFT 数据常见的 Alpaca(instruction/output)、ShareGPT(conversations)格式,和本项目本质相同:都是“一组对话消息 + 哪一条是期望输出”,只是 JSON 排法不同。换数据时主要改两处:把你的数据整理成 train/val/test 三组对话;按需要改 D3 的 build_messages,决定 system 里放什么。

2.8 可选:在自己电脑上跑一遍数据检查

前面的内容只读就能理解。想在开 GPU 之前亲手看看分词结果、或改了数据想先检查,可以在自己电脑(Mac 或 Linux,Python 3.11)上装一个不含 PyTorch 的轻量环境,几分钟就好,不需要显卡:

python3.11 -m venv .venv-data
source .venv-data/bin/activate
python -m pip install -r requirements-data.txt
python -m lab.download --tokenizer-only
python -m lab.data --allow-draft

最后一行应输出 {'train': 500, 'val': 50, 'test': 50}。--tokenizer-only 只下载分词器和配置(约 15 MB),不下载 8 GB 权重,所以本地的 models/qwen3-4b/ 里没有权重分片是正常的。Transformers 可能提示 None of PyTorch, TensorFlow ... have been found,本地只用分词功能,可以忽略。以后新开终端,先执行 source .venv-data/bin/activate。