最近在做推理模型训练的同学,大概率已经感受到一个共同的瓶颈:不是模型结构不够强,而是找不到足够好的推理训练数据。
人工写推理链,贵、慢、规模上不去;全靠大规模生成式采样,质量又不可控,跑到后面模型会不断重复同一套错误模式。这个问题的本质在于:我们缺少一种能把“推理能力”批量生产出来的数据工程方法。
Reasoning Core 这篇工作给出的解题思路很有意思。它没有把重心放在模型结构上,而是同时动了两刀:
- 用程序化数据生成解决“数据从哪来”的问题;
- 用补全监督解决“模型怎么学”的问题。
这篇文章,我会从这三个关键词——Reasoning Core、Procedural Data、Completion-Supervised——拆开讲清楚这套方法到底在做什么,为什么它能同时缓解数据规模、标注成本、监督信号三个问题,以及如果你想把类似思路用在自己的训练流程里,应该怎么设计数据流水线、怎么写代码、怎么验证效果。
整篇文章偏实践,建议收藏后按章节跟进,尤其适合正在做大模型推理微调、SFT 数据构建和 RL 训练前数据准备的同学。
1. 为什么推理训练会卡在数据上
先从一个真实的工程场景说起。
假设你要训练一个数学推理模型。常规做法是:找一批数学题,人工写清楚每一步推理过程,做 SFT。第一批 1 万条数据,效果立竿见影;第二批再来 1 万条,提升开始变缓;到了 5 万条以后,你会发现几个问题同时出现:
- 人工写步骤的成本太高。一道稍微复杂的题,写出完整的中间推理链可能需要 10 分钟以上,折算下来一条高质量数据的成本并不低。
- 数据风格会收敛。同一个标注员写出来的推理链,换汤不换药;多个标注员之间又存在风格漂移。数据规模增加,但多样性跟不上。
- 监督信号太“整”。标准 SFT 让模型预测整条推理链,模型只需要学会“像人话一样说话”,而不是真正学会“在关键位置做关键决策”。问题出在哪一步,梯度信号并不会精确告诉我们。
工程上更麻烦的是第三个问题。模型在训练时把整段答案当作一个序列去拟合,如果推理链里有 20 步,其中 5 步错了,这 5 步的错误信号会被稀释到整段序列的 loss 里。结果就是:模型学会了下一次写出“形状正确但细节不对”的推理过程,俗称推理幻觉。
Reasoning Core 这条路线想做的,就是把推理训练从“学说话”变成“学补步骤”。
它不再要求模型从零生成完整推理链,而是把推理链拆好、遮住关键环节,让模型只负责补全被遮住的那些步骤。同时,被遮住的这些步骤来自程序化生成的推理核心,天然可以被自动校验。两个机制叠加,数据规模、成本、监督精度三个问题一起缓解。
这个思路用一句话概括:用程序批量造题,用补全精确纠错。
2. 三个关键词到底是什么意思
在往下拆之前,先把标题里三个核心概念讲清楚。这三个词其实是三层嵌套的关系,不要混为一谈。
2.1 Reasoning Core:推理的“原子操作集合”
Reasoning Core 可以理解为一组经过抽象设计的最小推理动作,相当于把人类解题时的思维动作拆成积木块。
常见的推理原子操作包括:
- 数值计算:加减乘除、取模、四舍五入;
- 集合运算:交集、并集、差集、子集判断;
- 逻辑判断:与或非、命题真假;
- 模式匹配:找规律、找公共项;
- 条件分支:if-else 分情况讨论;
- 状态更新:把一个变量代入下一步;
- 多步规划:把一个大目标拆成子目标。
这个集合的价值在于:它定义了任务空间的“最小单元”,而不是直接定义任务。有了这组积木,我们可以像搭乐高一样组合出无穷多的推理任务,并且每一步都是可控、可验证的。
2.2 Procedural Data:程序生成的训练数据
Procedural Data 指的不是“按流程走出来的数据”,而是通过代码、模板、规则批量产出的训练数据。
举个例子。我们想造一批“三个人分苹果”的应用题。传统人工做法是写 100 道题,每道题配步骤。程序化做法是:
# data/gen_apple_task.py import random def generate_task(): people = random.randint(2, 5) # 参与人数 total = random.randint(10, 100) # 苹果总数 per = total // people # 每人数量(整除) remain = total % people # 剩余数量 question = f"{people}个人分{total}个苹果,每人分到{per}个,还剩{remain}个。" answer = f"每人{per}个,剩余{remain}个。" return {"question": question, "answer": answer}一个循环下去,几千道题几秒钟就出来了。而且我们知道 ground truth 就是per和remain,不会出错。
这就引出一个关键优势:程序化数据天然自带监督信号。因为数据是由确定性代码生成的,所以每一道题的标准答案、推理步骤都可以在生成时一并记录。这比从网上爬题、再人工标注,可靠得多。
2.3 Completion-Supervised:补全式监督
这是整篇工作里最值得品的一个设计。
传统 SFT 是“完整序列监督”:
输入: 题目 输出: 完整的推理步骤 + 答案Completion-Supervised 是:
输入: 题目 + 部分推理步骤 + 中间有空缺 输出: 补全被遮住的推理步骤你可以简单理解为:不是让模型默写整篇作文,而是让它补出作文里被抠掉的几个句子。
这样做有三个好处:
- 监督信号更精确。模型只需要对缺失的那几步负责,loss 的梯度不会摊薄到整段文本上。
- 训练效率更高。同一个推理链,可以遮不同的位置,生成多条训练样本,数据利用率成倍提升。
- 更接近推理的本质。推理本质上就是“在前置条件下推出下一步”。补全任务天然训练模型建立“条件 - 结论”的对应关系,而不是训练模型背诵流畅文本。
把三个概念串起来,整套方法的逻辑就清晰了:
Reasoning Core 提供“积木”,Procedural Data 用积木批量搭建“例题”,Completion-Supervised 让模型在例题的关键位置反复练习“补全”,从而学会真正的推理动作。
3. 方法论拆解:从推理核心到训练样本
理解了概念,再看整体的方法流程。一套完整的 Reasoning Core 训练流水线,大致包含四个阶段。
3.1 阶段一:定义原子推理算子
第一步是抽象出你的领域需要的原子推理算子。
比如做数学推理,算子可能是:加减乘除、取模、比较大小;做代码逻辑推理,算子可能是:变量赋值、循环、条件跳转;做一般问答推理,算子可能是:事实检索、矛盾判断、因果链推导。
这一步的核心标准是:每一个算子必须可以独立验证。如果一个算子不能写出确定性校验函数,它就不适合出现在 Reasoning Core 里。
3.2 阶段二:编制组合模板
有了算子,还需要模板来组合它们。组合模板决定了任务的复杂度和多样性。
简单模板:
算子A(输入) -> 输出三级模板:
算子A(算子B(输入)) -> 输出条件模板:
如果 算子A(输入) > 阈值,执行 算子B;否则执行 算子C在工程上,模板可以写成配置化结构。每套模板内部再打开一些随机参数,比如数值范围、变量数量、文本措辞,这样同一套模板可以生成大量不重复的题目。
3.3 阶段三:执行生成并记录完整推理链
这一步是程序化数据的关键:生成题目时,同时记录每一步算子的输入和输出。
这些中间记录,就是后续补全监督中的“被遮目标”。举个例子:
{ "id": "task_000001", "question": "有35个球,平均分给4个小组,问每组几个球?还剩几个?", "template": "division_with_remainder", "steps": [ {"op": "divide", "input": [35, 4], "output": "商8余3"}, {"op": "interpret", "input": ["商8余3"], "output": "每组8个,还剩3个"} ], "answer": "每组8个,还剩3个" }有了steps字段,训练时想遮哪一步就遮哪一步。
3.4 阶段四:构造补全监督样本
最后一步,根据训练需求,把完整的推理链转换为“题目 + 部分步骤 + 空缺”的格式,然后只对空缺位置计算 loss。
这一步的转换逻辑可以写成通用函数,后面我会给出参考代码。
整套流程看下来不难发现,这套方法最重的投入不在模型,而在前期的算子设计和模板编写。一旦这两块搭好,后面的数据生成就是批量执行而已。
4. 程序化数据质量如何保证
很多同学会担心:程序批量生成的数据,会不会很呆板、模板痕迹重,模型学完只会套模板?
这个担心合理,但可以通过设计来缓解。质量保证分三个层面。
4.1 数值与逻辑正确性
程序化数据的最大底气就是确定性校验。每个算子都配一个校验函数:
def check_divide(dividend, divisor, result): q, r = result assert divisor != 0 assert q * divisor + r == dividend assert 0 <= r < divisor return True数据可以错,但校验不能缺席。后续清洗时,凡是校验不通过的样本直接丢弃。这样就保证了进入训练集的数据,至少在“程序逻辑层”是绝对正确的。
4.2 文本多样性
数据“呆板”的问题,根源不在程序化,而在模板措辞单一。
解决办法:给同一道数学题配多套自然语言外壳。比如“平均分”既可以写成“分给”,也可以写成“每组分到”,还可以写成“要求各组一样多”。这些外壳可以在生成阶段随机选择,甚至可以通过一个小的改写模型做二次扩写。但注意,外壳改写不能改数字和逻辑关系,否则会破坏程序校验的约束。
4.3 难度分布控制
程序化数据的另一个隐藏优势是:难度可控。
可以通过控制模板的嵌套深度、算子数量、数值范围来精确调控难度分布。比如:
- 简单难度:单算子,数值小于 20;
- 中等难度:两个算子组合,数值小于 100;
- 困难难度:三个算子嵌套加条件分支,数值小于 1000。
训练时可以根据模型的当前水平做课程学习(Curriculum Learning),先易后难。这一点是纯人工数据很难做到的。
5. 补全监督训练的原理与实现
接下来是整篇文章最核心的部分:补全监督到底怎么实现。
5.1 监督机制对比
先看传统 SFT 和补全监督在 loss 计算上的区别。
传统 SFT 对整段输出做交叉熵:
question + " " + full_reasoning + " " + answer整个序列的所有 token 都参与 loss 计算。这带来的问题是:模型为了降低 loss,只需要把“常见的连接词、语气”学对就能拿到大部分分数,真正决定推理结果的步骤反而被淹没。
补全监督只对缺失位置的 token 计算 loss:
question + " " + partial_reasoning + " [MASK] " + answer[MASK]位置被遮住,loss 只作用于被遮的 token,其他位置都不回传梯度。这样训练信号完全聚焦在推理的关键步骤上。
注意:这里说的[MASK]不是静态的,而是我们预先在数据里标注了哪些 token 是“推理关键点”。我们可以在数据处理阶段为每个样本生成一个 mask 数组,标记哪些位置需要计算 loss。这比“整段输出但只对答案部分算 loss”更进一步——它对推理中间步骤也做了精确的位置级监督。
5.2 为什么补全比完整生成更有效
这里用一个类比解释。
完整生成训练,相当于让一个学生对着标准答案抄写一整篇解题过程。抄多了,字迹越来越像,但学生不一定真的理解每一步为什么这么写。
补全训练,相当于老师把解题过程的中间步骤抠掉几个空,让学生自己补。补对了,说明他真的理解了“上一步到下一步”的逻辑;补错了,老师能精准指出是哪一个空错了。
对应到训练上:
- 完整生成:loss 是整段序列的平均,错误被稀释;
- 补全监督:loss 集中在关键步骤,错误信号清晰。
还有一个容易被忽略的收益:数据效率。一条包含 5 个步骤的推理链,传统 SFT 只能作为一条样本。补全监督可以分别遮住第 1 步、第 2 步、第 3 步……生成 5 条不同的训练样本,并且每条样本的监督目标都不重复。相当于把一条数据的训练价值放大了数倍。
5.3 补全监督与 RL 的关系
熟悉 RLHF/RLVR 的同学可能会问:这和强化学习的奖励建模有什么区别?
简单说:
- 补全监督是密集的、过程级的监督。每一步都有一个明确的目标,属于一种更精细的 SFT;
- RL 是稀疏的、结果级的监督。模型自由探索整个推理路径,最后只根据最终答案是否正确给奖励。
两者并不冲突。Reasoning Core 这种程序化数据 + 过程级监督,更适合作为 RL 之前的“预热阶段”:先用补全监督把模型的基本推理模式打牢,再上 RL 做探索和泛化。如果你的项目现在 RL 训练一直不稳,大概率是因为 SFT 阶段没有建立好过程级的推理基础。
6. 数据生成流水线的工程实现
理论讲完,进入工程实现。这一节我们写一套最小可用的数据生成流水线,目标是:用几十行代码,生成“具备完整推理步骤、且可自动校验”的训练数据。
6.1 环境准备
以实际项目为准,本文用一个通用配置做演示:
# Python 3.10+ python --version # 安装依赖 pip install numpy pandas datasets数据生成阶段不需要 GPU,纯 CPU 就能跑,非常适合并行扩展。
6.2 定义原子算子
先定义几个最基础的推理原子操作。这里用“分配问题 + 剩余问题”作为一个简单场景。
# data/ops.py """基础推理算子定义。每个算子都是纯函数,输入输出均确定。""" def op_divide(total: int, groups: int) -> tuple: """整除:返回 (商, 余数)。""" assert groups > 0 return divmod(total, groups) def op_compare(left: int, right: int) -> str: """比较大小,返回关系描述。""" if left > right: return "大于" elif left < right: return "小于" return "等于" def op_summary(quotient: int, remainder: int) -> str: """汇总结果,生成最终答案。""" if remainder == 0: return f"每组{quotient}个,刚好分完" return f"每组{quotient}个,还剩{remainder}个"每个算子都附带了断言或明确的行为约定。这就是“可验证原子操作”的工程含义。
6.3 编写任务模板
再编写一个模板,把三个算子串起来,生成一道完整的应用题。
# data/template.py """任务模板:组合算子生成完整题目与推理链。""" import random from ops import op_divide, op_compare, op_summary TEMPLATE_TEXT = [ "有{total}个苹果,平均分给{groups}个小组,每组能分到几个?还剩几个?", "把{total}本书平均放到{groups}个书架上,每个书架放几本?余几本?", "{total}个球要装进{groups}个盒子,每盒数量一样多,每盒几个?剩下几个?", ] def generate_sample(seed: int = None): if seed is not None: random.seed(seed) total = random.randint(10, 100) groups = random.randint(2, 6) # 执行推理链,同时记录每一步的输入输出 quotient, remainder = op_divide(total, groups) step_compare = op_compare(quotient, remainder) final_answer = op_summary(quotient, remainder) # 生成步骤记录 steps = [ {"op": "divide", "input": [total, groups], "output": f"{quotient}余{remainder}"}, {"op": "compare", "input": [quotient, remainder], "output": step_compare}, {"op": "summary", "input": [quotient, remainder], "output": final_answer}, ] question = random.choice(TEMPLATE_TEXT).format( total=total, groups=groups ) # 拼接完整推理链文本 reasoning = ( f"第一步,计算平均分配:{total}除以{groups},商{quotient}余{remainder}。\n" f"第二步,比较商和余数:{quotient}{step_compare}{remainder}。\n" f"第三步,汇总:{final_answer}。" ) return { "question": question, "reasoning": reasoning, "steps": steps, "answer": final_answer, }这里的关键点是:steps字段就是补全监督的“挖空候选位”。每个 step 的 input/output 都记录在案,后续想遮哪一步,就从这里取。
6.4 批量生成与校验
有了模板,批量生成和校验只是循环加过滤。
# data/generate_dataset.py """批量生成训练数据并自动校验。""" import json import random from template import generate_sample def validate_sample(sample: dict) -> bool: """校验样本的推理链是否符合逻辑约束。""" steps = sample["steps"] # 简单校验:必须是3步推理,且答案在最后一步 if len(steps) != 3: return False if sample["answer"] != steps[-1]["output"]: return False return True def build_dataset(n: int, seed: int = 42): random.seed(seed) samples = [] for i in range(n): sample = generate_sample(seed=seed + i) if validate_sample(sample): samples.append(sample) return samples if __name__ == "__main__": data = build_dataset(1000) # 输出到 JSONL with open("reasoning_data.jsonl", "w", encoding="utf-8") as f: for item in data: f.write(json.dumps(item, ensure_ascii=False) + "\n") print(f"生成样本数: {len(data)}")运行后得到reasoning_data.jsonl,每行一个完整样本。到这一步,数据流水线已经跑通了。
6.5 从完整推理链构造补全样本
现在做最后一步转换:把完整推理链转成补全监督格式。
# data/make_completion_samples.py """根据完整推理链,构造补全监督样本。""" import copy import json import random STEP_START_MARKER = "第" def mask_step(sample: dict, target_step_index: int) -> dict: """ 遮住指定步骤,生成补全样本。 这里用最简单的方式:把目标步骤的文字替换为 [MASK]。 实际训练中,还需要在 tokenizer 层面对应生成 mask 数组。 """ masked = copy.deepcopy(sample) reasoning_lines = sample["reasoning"].split("\n") # 定位目标步骤对应的行 # 演示用:按行号定位,实际工程中建议按 marker 精确定位 masked_lines = [ line if idx != target_step_index else f"第{idx + 1}步,[MASK]。" for idx, line in enumerate(reasoning_lines) ] masked["reasoning"] = "\n".join(masked_lines) masked["masked_step_index"] = target_step_index return masked def expand_with_masks(sample: dict) -> list: """一条完整样本,扩展成多条补全样本。""" step_count = len(sample["steps"]) extended = [] for idx in range(step_count): masked_sample = mask_step(sample, idx) extended.append(masked_sample) return extended if __name__ == "__main__": with open("reasoning_data.jsonl", "r", encoding="utf-8") as f: lines = f.readlines() all_samples = [] for line in lines[:100]: # 先用 100 条做演示 sample = json.loads(line) all_samples.extend(expand_with_masks(sample)) with open("completion_train.jsonl", "w", encoding="utf-8") as f: for item in all_samples: f.write(json.dumps(item, ensure_ascii=False) + "\n") print(f"补全样本数: {len(all_samples)}")到这一步,一条推理链被扩展成了推理步数条补全样本。假设原来有 1000 条推理链、平均 3 步,就能得到 3000 条补全训练样本,数据利用率是传统 SFT 的 3 倍。
6.6 数据格式总结
最终进入训练的数据格式建议统一为:
{ "question": "有35个苹果,平均分给4个小组,每组能分到几个?还剩几个?", "partial_reasoning": "第一步,计算平均分配:35除以4,商8余3。\n第二步,[MASK]。\n第三步,汇总:每组8个,还剩3个。", "target_reasoning": "比较商和余数:8大于3。", "answer": "每组8个,还剩3个" }训练时,模型输入为question + partial_reasoning,监督目标为target_reasoning。tokenizer 之后,只对target_reasoning对应的 token 计算交叉熵即可。
7. 补全监督训练的代码实现
数据准备好了,接下来就是用 HuggingFace Transformers 跑一个最小训练脚本。这里给出一个可运行的骨架,核心是loss 只回传到目标区域。
7.1 依赖安装
pip install transformers datasets accelerate7.2 构建训练数据集
# train/build_dataset.py """把 JSONL 转换为 HuggingFace Dataset。""" import json from datasets import Dataset def load_completion_data(path: str) -> Dataset: samples = [] with open(path, "r", encoding="utf-8") as f: for line in f: item = json.loads(line) samples.append({ "prompt": item["question"] + "\n" + item["partial_reasoning"], "target": item["target_reasoning"], }) return Dataset.from_list(samples)7.3 训练脚本骨架
# train/train_completion.py """补全监督训练最小示例。核心:label 位置与 target 对齐。""" from dataclasses import dataclass import torch from transformers import ( AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments, ) from build_dataset import load_completion_data def tokenize_function(tokenizer, examples): """把 prompt + target 拼接,并构造 label mask。""" # 简单做法:直接拼接,最后在 loss 计算时通过 label 屏蔽 prompt 部分 texts = [] for p, t in zip(examples["prompt"], examples["target"]): texts.append(p + t) tokenized = tokenizer( texts, truncation=True, max_length=512, padding=False, return_tensors=None, ) labels = [] for i, p in enumerate(examples["prompt"]): # 记录 prompt 长度,用于屏蔽 prompt 位置的 loss prompt_len = len(tokenizer(p, truncation=True, max_length=512)["input_ids"]) full_len = len(tokenized["input_ids"][i]) # target 位置标签为真实 id,prompt 位置标签为 -100 label_ids = [-100] * prompt_len + tokenized["input_ids"][i][prompt_len:] labels.append(label_ids) tokenized["labels"] = labels return tokenized def main(): model_name = "Qwen/Qwen2.5-0.5B" # 以实际可用模型为准 tokenizer = AutoTokenizer.from_pretrained(model_name) tokenizer.pad_token = tokenizer.eos_token dataset = load_completion_data("completion_train.jsonl") tokenized_ds = dataset.map( lambda batch: tokenize_function(tokenizer, batch), batched=True, remove_columns=dataset.column_names, ) model = AutoModelForCausalLM.from_pretrained(model_name) training_args = TrainingArguments( output_dir="./completion_sft_out", per_device_train_batch_size=8, num_train_epochs=3, learning_rate=2e-5, logging_steps=50, save_steps=500, fp16=True, remove_unused_columns=False, ) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_ds, tokenizer=tokenizer, ) trainer.train() if __name__ == "__main__": main()这段代码的精髓在tokenize_function里:把 prompt 部分的 label 全部设为 -100,只有 target 部分参与 loss 计算。这就是补全监督与普通 SFT 的唯一区别——监督位置被精确限定在推理步骤上。
7.4 验证 loss 是否正确
跑训练之前,可以先人工构造一个样本,验证 loss 是否真的只基于 target 计算:
# 伪代码,用于理解 label mask 效果 labels = torch.tensor([-100, -100, 5, 8, 3]) logits = model_output.logits # 计算 loss loss_fct = torch.nn.CrossEntropyLoss(ignore_index=-100) loss = loss_fct(logits.view(-1, vocab_size), labels.view(-1))只要ignore_index=-100,被屏蔽的 prompt 位置就不会进入 loss。这个机制是 PyTorch 内置的,不用自定义损失函数。确认这一点后,就可以放心训练了。
8. 效果验证与评估维度
训练结束后,不能只看 loss 下降。补全监督的评估需要从两个层面看。
8.1 生成能力评估
在推理验证集上,检查模型在完整生成模式下能不能一步步推出正确结果。这一步和普通评测一致,用准确率、F1、步骤正确率都可以。
推荐细化到:
- 最终答案正确率:只看最终结果对不对;
- 步骤正确率:拆解模型生成内容,逐步判断每一步是否合理。这一步最好用程序化验证器完成,因为验证器知道每一步的 ground truth。
8.2 补全能力评估
专门评估补全能力,可以构造一个“挖空测试集”:
- 从测试集里随机遮住推理链的某一步;
- 让模型补全;
- 用程序校验补全结果是否正确。
这个指标直接反映了“过程级推理”是否真的学会了。如果最终答案正确率很高,但补全正确率很低,说明模型大概率是背了答案,而不是真正理解推理过程。
8.3 对比基线
评估补全监督方法时,至少对比三组:
| 训练方式 | 数据量 | 预期特点 |
|---|---|---|
| 完整 SFT | 同等规模 | 输出流畅,但过程错误难定位 |
| 答案监督 | 同等规模 | 只学答案,推理链泛化弱 |
| 补全监督 | 同等规模 | 过程正确率高,推理链更稳健 |
注意:这里不引用任何具体实验数字,因为没有材料依据。但在自己的项目里,这三组对比是成本最低、最能说明问题的实验设计。
8.4 泛化能力测试
程序化数据的最大质疑是“只会在模板内做题”。所以一定要做跨模板测试:
- 用模板 A 生成训练数据;
- 用模板 B(同算子、不同措辞)生成测试数据;
- 观察准确率掉多少。
如果掉得厉害,说明模型学的是文本外壳,不是推理本质。这时需要增加文本外壳多样性,或者引入一个阶段性的随机化改写。
9. 常见问题与排查思路
把这套方法真正落地时,下面几个问题是高频出现的。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 训练 loss 下降但生成结果全错 | 数据生成与模型 tokenize 不对齐,监督目标错位 | 检查训练样本里labels是否与输入对齐,打印一条样本的 input_ids 和 labels | 修正 tokenize 逻辑,确保 -100 只屏蔽 prompt 部分 |
| 模型只会复述模板,不会做题 | 数据模板多样性不足 | 跨模板测试,观察效果是否骤降 | 增加文本外壳变体;调整算子的数值范围和组合方式 |
| 补全训练后模型完整生成能力变差 | 训练时只见过“补全”,没有见过“完整生成” | 在训练集中混入 10%-20% 的完整 SFT 样本 | 混合训练,同时保留补全监督和完整生成能力 |
| 数据生成速度慢 | 校验函数太重,或者单进程生成 | 分析耗时集中在哪一步 | 用多进程并行生成;算子校验逻辑保持轻量 |
| 程序化数据出现逻辑冲突 | 模板参数随机范围没有约束好 | 查看具体样本的 steps 是否自洽 | 增加参数约束条件,例如分母不能为 0、n > groups |
| 模型在长推理链上容易崩 | 步骤嵌套过深,训练样本长度超过上下文窗口 | 查看训练数据长度分布 | 控制模板层数;或先用短链训练,再渐进加长 |
这里额外强调一个容易被忽视的坑:补全监督的 label 对齐问题。只要labels中 prompt 区域的掩码没设对,模型就会悄悄把“题目内容”也当成监督目标来学。这种错误在 loss 上不一定明显,因为 prompt 文本本身也是自然语言,loss 照样会下降,但推理能力基本学不出来。
排查方法很简单:训练前打印一条样本,人工核对:
sample = tokenized_ds[0] print(tokenizer.decode(sample["input_ids"])) print("labels:", sample["labels"])确认input_ids里 prompt 部分对应的labels全是 -100,再开始训练。
10. 工程建议与最佳实践
最后给几条从工程视角提炼的建议。这些建议不针对特定模型,而是针对“想把类似思路落到自有数据流水线上”的团队。
10.1 先搭小闭环,再扩大规模
不要一上来就生成百万级数据。先用几百条数据、一个小模型跑通全流程:算子 → 模板 → 生成 → 校验 → 补全 → 训练 → 评估。全链路跑通后,再谈规模化。
原因很简单:数据生成、tokenize、训练、评估四个环节都可能出错,小闭环能最快暴露问题。
10.2 算子是资产,模板是杠杆
Reasoning Core 这类方法真正沉淀下来的资产不是某条数据,而是那组精心设计的原子算子和模板库。
建议把算子、模板、校验函数按模块管理,纳入版本控制。后续换领域、换模型、换任务,都可以复用这部分资产。一套好的算子和模板,比几万条一次性生成的数据值钱得多。
10.3 混合训练比单一训练更稳
补全监督不是要替代全部 SFT,而是作为 SFT 的一种增强。
实际项目中更推荐的做法是:
- 60% 的程序化补全数据:负责建立过程级推理能力;
- 20% 的完整推理链 SFT 数据:保证模型能流畅输出完整推理过程;
- 20% 的通用指令数据:防止模型在推理数据上过度拟合,保持通用能力。
混合比例可以按任务调整,但这个思路能避免“只练数学题、忘了怎么说话”的尴尬。
10.4 自动校验必须前置
程序化数据的优势就是可校验。校验逻辑应该在数据生成阶段就嵌入,而不是等训练完成后再做人工抽查。
每条生成的数据都经过“算子级断言 + 完整链自洽检查 + 关键字段非空检查”三层校验。校验不过的,宁可丢弃也不要带病训练。因为一条带病数据在补全监督下,它的错误信号会被精确放大到关键步骤上,危害比传统 SFT 更大。
10.5 关注推理链的“可观测性”
做推理训练,日志里不要只记 loss。建议在评估阶段记录以下信息:
- 补全位置的正确率(分算子统计);
- 不同难度模板下的正确率;
- 跨模板掉点幅度。
这些信息能帮你快速定位模型是哪个环节出了问题。比如“除法算子补全正确率低”和“跨模板掉点严重”是两个完全不同的优化方向。
11. 总结与下一步
回到开头的问题:推理训练卡在数据上,Reasoning Core 给出的解法是把推理拆成可组合的原子动作,用程序化数据批量构造任务,再用补全监督把梯度信号精准作用在推理关键步骤上。
这套方法的工程价值在于,它把“高质量推理数据”从纯人工劳动变成了“算子设计 + 模板编写 + 自动校验”的工程流水线,同时用补全监督提高了每一条数据的利用效率。
下一步想深入实践的同学,可以按这个顺序推进:
- 选定一个窄领域(比如小学数学应用题、表格推理、代码逻辑推导);
- 设计 5 到 10 个原子算子;
- 编写 3 种以上组合模板;
- 跑通生成 + 校验 + 补全训练的最小闭环;
- 用跨模板测试验证泛化能力。
如果这条路线在你的任务上确实有效,再往多算子、多模板、更大规模上扩展。推理能力不是靠堆模型参数堆出来的,而是靠设计数据结构和监督信号设计出来的。理解这一点,比复现任何一篇论文都更重要。