☰
unsloth源码改造实战:自定义collator、评估回调与checkpoint元数据
2026/10/23 3:33:24 网站建设 项目流程

平时用unsloth微调模型,大多数人只碰训练脚本,改改参数、换换数据集就完事了。但真到了生产环境或者做科研实验的时候,总会有一些默认配置解决不了的需求。我这次就是被逼着把unsloth的源码翻出来改了几处,整个过程踩了不少坑,也弄明白了很多之前只知道用、不知道原理的东西。这篇就分享一下我改unsloth的实际经历、改动的思路和完整的验证过程,给正准备对unsloth“下手”的朋友一个参考。

1. 为什么要改unsloth:三个绕不开的真实场景

先说结论:unsloth本身已经做得非常好了,绝大多数情况下你不需要动它的源码。我这次改它,是因为手头的项目撞上了三个默认设置解决不了的问题,属于“不改就没法往下走”的情况。

1.1 训练过程中需要即时评估,而不是只盯loss曲线

我之前的微调流程很简单:配好数据集,启动UnslothTrainer,盯着loss降到某个值,保存adapter,完事。但这次的项目不太一样,模型要输出特定格式的结构化内容,比如JSON字段必须齐全、某些关键词必须出现。loss降得再漂亮,也不代表格式就对了。

我需要每训练几十步,就让模型实际跑一次推理,看看它在真实问题上的回答是否满足格式要求。默认的TrainingArguments只支持loss日志和常规的evaluate逻辑,没法在训练中途方便地做生成式评估。我可以改训练脚本,但每次都要写一堆回调、还要考虑会不会和unsloth的优化逻辑冲突。与其在外围打转,不如直接进源码里看它到底怎么组织的。

1.2 训练数据结构不是标准SFT格式

大多数微调教程用的都是sharegpt格式或者alpaca格式。我这次的数据集带了复杂的结构化字段,每条样本不只是“指令+回答”,还包含多个子任务、多段上下文,以及一部分纯文本样本——它们不需要计算loss,只需要作为上下文出现。

默认的UnslothTrainerDataCollator在处理这类混合样本时很吃力。它的padding逻辑比较固定,对每一条样本都按同等规则处理,一旦样本类型不同,出来的batch就很别扭。要么padding太多浪费显存,要么不该计算loss的部分混进了loss计算。改训练脚本里的tokenize_function只能解决一部分问题,真正的痛点在于数据整理阶段。

1.3 checkpoint需要携带业务上下文

训练完的adapter不是给自己用的,后续要交给部署的同学去接推理服务。他们需要知道训练数据长什么样、用了哪个模板版本、特殊token的约定是什么。这些信息默认不会写进adapter_config.json里,每次都得人工整理一份说明文档,效率很低、还容易漏。

如果可以改动保存逻辑,把这些自定义字段自动写进adapter的配置文件,后续加载的时候就能直接读取,省掉一堆沟通成本。这就涉及到UnslothTrainer的保存路径了。

三个需求叠加在一起,结论就非常明确:必须动手改。

1.4 改之前先自问:真的需要动源码吗

在这里我得先给所有想改unsloth的朋友提个醒:这个框架的设计很紧凑,很多优化是环环相扣的,乱改容易翻车。动手之前,务必要先排除掉几个不需要动源码的路径:

  • 大多数功能调整都可以通过UnslothTrainingArguments的参数解决,比如embedding_learning_rate、use_gradient_checkpointing这些。
  • 数据集格式问题,优先在tokenize_function里做适配,实在不行再动collator。
  • 生成式评估可以用transformers的TrainerCallback来做,完全不碰源码也能实现。

我这次为什么最终还是改了源码?因为三处需求同时存在,而且每一处都要动到unsloth内部比较深的地方。如果只是单个需求,我建议还是先用参数、用回调解决,保持源码纯净,后续升级不痛苦。

2. 修改前的源码地图:知道往哪里下刀

改源码最怕的就是一头扎进去找不到方向。unsloth的代码量不算特别大,但内部结构比较紧凑,新版本迭代也快。我改之前先把整个包的结构粗读了一遍,确认了三块最常动刀的位置。

2.1 unsloth源码的骨架与关键文件

我用的是当前比较主流的unsloth版本,安装之后源码在site-packages/unsloth目录下。里面几个最关键的部分是:

文件/目录核心职责我这次是否改动
unsloth/training.pyUnslothTrainer、UnslothTrainerDataCollator等训练相关类都在这改了,动了collator和保存逻辑
unsloth/models/llama.py针对LLaMA系列模型的monkey patch,把原attention等模块替换成手写kernel版本没动,这是显存优化的核心,碰它风险太高
unsloth/kernels/底层Triton kernel和C拓展完全没动,出问题很难排查
unsloth/chat_template_utils.py对话模板相关的处理没改,这个版本已经直接走tokenizer的模板了

对我这次的需求来说,training.py是主战场。models/llama.py这类文件里是模型层的补丁,除非你要改模型结构本身,否则不要碰。

2.2 补丁机制决定了很多东西不能乱动

unsloth最大的特色是monkey patch机制。它在加载模型之后,会用自己实现的高性能模块替换掉模型中原始的LlamaAttention、LlamaMLP等组件。这些组件在models/llama.py里被重新定义,替换完之后,你看到的模型结构已经和原始模型不一样了。

这个机制对你改代码有非常实际的影响:如果你想改模型正向传播过程中的某个逻辑,不能只改model.py里的forward函数,因为实际跑的时候调用的是patch之后的版本。你必须改patch里面的那个类。很多新手在这里就懵了,改了一处没生效,还以为是自己改错了。

同时,patch之后的模型配合了梯度检查点等优化,里面的state处理是精心设计过的。如果你在forward里加了额外的tensor操作,显存占用可能一下子就爆了,所以尽量少往模型层里加东西。

2.3 动手前的备份与隔离实践

我强烈不建议直接改site-packages里的原文件。原因很简单:unsloth升级很频繁,你要是改了原文件,一旦重新安装或者升级版本,改动就全没了,而且很难追溯。

我的做法是把整个unsloth包复制一份到项目目录下,命名为unsloth_custom,然后在训练脚本里优先import unsloth_custom而不是原始的unsloth。这样改动都在自己的项目目录里,源头可控,也方便记录自己改了哪些地方。

具体操作就是在项目根目录建一个unsloth_custom包,把site-packages里对应的文件复制过来。导入的时候,由于项目根目录在sys.path最前面,会优先导入你自己那份。修改之后,为了确保用的确实是自定义版本,可以在包里加一行print("[custom unsloth loaded]")验证。

这样的隔离方式我实测下来比较稳,既能改源码,又不污染环境,多个项目还能各用各的版本。

3. 第一处改动:自定义数据collator适配混合任务

先解决最麻烦的数据问题。我需要让collator支持混合类型样本,并且让部分样本不计算loss。

3.1 默认collator做了什么,限制在哪里

UnslothTrainerDataCollator继承自HuggingFace的DataCollatorForSeq2Seq,核心工作是:

  • 把样本padding到batch内最大长度
  • 生成attention_mask和labels
  • 处理label中的padding部分,通常用-100屏蔽

这个collator对标准SFT数据没有问题,但有个隐含假设:所有样本结构一致。它会用同一个padding逻辑处理每一条样本。当我的数据集里既有对话样本、又有纯文本上下文样本时,问题就来了。纯文本样本的标签应该全部是-100,而对话样本只有回复部分算loss。默认collator没法区分这两种情况。

另外,每轮对话中,问题部分和回答部分的loss要不要屏蔽、模板里的系统提示算不算loss,在不同任务里有不同讲究。默认collator不提供这么细的控制。

3.2 继承重写:一个混合任务collator的实例

我的方案是继承UnslothTrainerDataCollator,在__call__里覆盖核心逻辑。这里给出一段思路参考,实际代码需要根据你自己的数据结构来调整:

from unsloth import UnslothTrainerDataCollator import torch class MixedTaskCollator(UnslothTrainerDataCollator): def __init__(self, model, tokenizer, max_seq_length, context_mask_prob=0.0, **kwargs): super().__init__(model=model, tokenizer=tokenizer, max_seq_length=max_seq_length, **kwargs) self.context_mask_prob = context_mask_prob def __call__(self, features): # 1. 先把混合样本拆成普通样本和上下文样本两组 normal_features = [f for f in features if f.get("loss_type") == "supervised"] context_features = [f for f in features if f.get("loss_type") == "context_only"] # 2. 上下文样本的所有labels设为-100(不参与loss) for f in context_features: f["labels"] = [-100] * len(f["input_ids"]) # 3. 正常样本按原始逻辑处理,这里沿用父类处理 # 注意:父类内部会调用tokenizer.pad,不会重复调用 batch = super().__call__(normal_features + context_features) # 4. 对上下文样本做随机屏蔽增强(我项目里需要的功能) if self.context_mask_prob > 0.0 and context_features: for i, f in enumerate(context_features): input_len = len(f["input_ids"]) if torch.rand(1).item() < self.context_mask_prob: mask_start = max(0, input_len - 4) batch["labels"][i, mask_start:] = -100 return batch

这里有几个非常关键的细节:

  1. 必须调用父类的__call__来执行实际的padding操作,不要自己重写padding逻辑。因为unsloth在padding时做了一些很底层的长度对齐优化,绕过了等于放弃优化。

  2. loss_type这个字段是你自己在tokenize_function里加进去的。你需要在构造数据集时,给每条样本打上这个标记。

  3. labels里的-100是HuggingFace loss屏蔽的约定值,交叉熵计算会自动忽略这些位置。如果你要用不同的损失函数,这个约定就要对应调整。

改完之后,我专门跑了一个小实验验证效果:打了一大批样本,检查输出的labels里上下文部分是否全为-100。结果符合预期,而且显存占用没有明显增加。这说明collator层面的改动能平滑地融入unsloth的优化流程。

4. 第二处改动:在训练循环里插入生成式评估

loss正常收敛之后,我开始做生成式评估。这里要说一下,其实transformers.Trainer提供了evaluate方法,但它默认是为普通指标设计的,要拿模型生成文本再逐条检查格式,用起来比较别扭。我的选择是直接用callback。

4.1 为什么不直接改evaluate方法

UnslothTrainer继承自transformers.Trainer,它确实有evaluate()。但这个方法的触发时机和评估逻辑比较固定,而且unsloth在某些模式下会有一些内部的状态处理,你在外面直接调用evaluate可能会干扰训练流程。

更灵活的做法是注册一个TrainerCallback,在on_step_end或者on_log的时候触发。这样评估逻辑完全独立,不侵入trainer内部,也不影响unsloth自身的优化。理论上说,这也属于“不修改源码”的范畴。但我的情况是:评估逻辑比较复杂,要加载额外的数据处理模块,而且要在多个训练脚本中复用。如果每次都写一大段回调代码,脚本冗余太多了。

所以我直接把一个自定义的callback类写进了unsloth_custom/training.py里,作为内置回调,这样所有基于自定义unsloth的训练脚本都能直接用。

4.2 callback实现步骤与代码

核心代码如下:

from transformers import TrainerCallback, TrainerControl, TrainerState from transformers import TrainingArguments import torch class GenerationEvalCallback(TrainerCallback): def __init__(self, eval_prompts, tokenizer, interval_steps=50, max_new_tokens=128): self.eval_prompts = eval_prompts self.tokenizer = tokenizer self.interval_steps = interval_steps self.max_new_tokens = max_new_tokens def on_step_end(self, args: TrainingArguments, state: TrainerState, control: TrainerControl, **kwargs): if state.global_step % self.interval_steps != 0: return model = kwargs["model"] model.eval() with torch.no_grad(): for i, prompt in enumerate(self.eval_prompts): messages = [{"role": "user", "content": prompt}] inputs = self.tokenizer.apply_chat_template( messages, tokenize=True, add_generation_prompt=True, return_tensors="pt", ).to(model.device) outputs = model.generate( inputs, max_new_tokens=self.max_new_tokens, do_sample=True, temperature=0.7, pad_token_id=self.tokenizer.eos_token_id, ) response = self.tokenizer.decode(outputs[0][inputs.shape[-1]:], skip_special_tokens=True) print(f"\n[Step {state.global_step}] Prompt {i}: {prompt[:50]}...") print(f"Response: {response[:200]}") # 这里可以做格式检查、关键词匹配等,把指标log进去 model.train()

在使用的地方简单注册:

from unsloth_custom.training import GenerationEvalCallback training_args = UnslothTrainingArguments(...) trainer = UnslothTrainer( model=model, tokenizer=tokenizer, args=training_args, train_dataset=dataset, callbacks=[GenerationEvalCallback(eval_prompts=eval_prompts, tokenizer=tokenizer)], )

要点解释一下,这些都是我踩过的坑:

  1. model.eval()和model.train()必须成对出现。漏掉train()会导致后续训练的dropout和batchnorm行为异常,整个训练曲线都会飘。

  2. pad_token_id要显式指定。unsloth对部分模型会调整tokenizer配置,有些模型的pad_token是None,生成的时候不指定很容易报错。

  3. 生成的输入用apply_chat_template处理,保证和训练时的模板一致。如果训练时用的是自定义模板,这里也必须保持一致,否则评估结果完全没参考意义。

  4. callback里生成的输出不要直接抛给trainer.log(),除非你把它转成标准的logs字典格式。我在第一版里就是直接print,后续做指标聚合时再统一记录。

这样改完之后,训练日志里每隔50步就会打印出模型在几个固定问题上的实际表现。我不需要再手动中断训练去试模型效果,整个训练过程的监控质量提升了一大截。

5. 第三处改动:让checkpoint自动携带业务元数据

这一处改动最“温和”,没有动任何训练逻辑,只是扩展了保存时的输出信息。

5.1 默认adapter配置里缺了什么

unsloth保存LoRA adapter时,会生成一个adapter_config.json。里面包含base_model_name_or_path、r、lora_alpha、target_modules这些参数。这些对模型加载来说是必要的,但对业务方来说远远不够。

部署的同学拿到adapter之后,最常见的问题就是:“这个模型是用什么数据训练的?模板是什么?需要用什么特殊token格式?”这些信息在纯技术配置里完全找不到。每次都得找训练的人问,问完还要写文档,文档还经常和实际不一致。

如果能在保存的时候把这些信息自动写进配置,后续加载的时候直接读取,能省掉一大半沟通成本。

5.2 修改保存逻辑的完整思路

UnslothTrainer在保存adapter时会调用model.save_pretrained(),通过save_adapter之类的路径把LoRA权重和配置写到目标目录。我在unsloth_custom/training.py里找到保存相关的调用,在它保存完之后追加一个元数据写入步骤。

思路是:在UnslothTrainer里增加一个metadata属性,训练脚本设置好之后,保存的时候自动合并进配置文件。

核心逻辑类似这样:

class CustomUnslothTrainer(UnslothTrainer): def __init__(self, *args, metadata=None, **kwargs): super().__init__(*args, **kwargs) self.metadata = metadata or {} def _save(self, output_dir=None, state_dict=None): super()._save(output_dir=output_dir, state_dict=state_dict) # 在原有保存之后,追加业务元数据 config_path = os.path.join(output_dir, "adapter_config.json") if os.path.exists(config_path): with open(config_path, "r", encoding="utf-8") as f: adapter_config = json.load(f) adapter_config["custom_metadata"] = self.metadata with open(config_path, "w", encoding="utf-8") as f: json.dump(adapter_config, f, indent=2, ensure_ascii=False)

使用的时候:

trainer = CustomUnslothTrainer( model=model, tokenizer=tokenizer, args=training_args, train_dataset=dataset, metadata={ "task": "structured_output_generation", "data_version": "v2.1", "chat_template_version": "custom_v3", "excluded_fields_from_loss": ["context", "conversation_history"], "note": "上下文部分不参与loss计算,只作为输入上下文" }, )

这里有几个需要注意的地方:

  1. _save方法只在调用trainer.save_model()时触发。TrainingArguments里的save_strategy触发的是_save_checkpoint,走的是checkpoint路径,逻辑略有不同。如果你想让每几千步保存的中间checkpoint也带上元数据,需要把逻辑同时挂钩到_save_checkpoint。我为了省事,只在最终save_model()的时候写入元数据,中间checkpoint不管。

  2. 不要把训练状态写进元数据。比如global_step、train_loss这些值在每次保存时都不一样,写进adapter_config.json会造成配置漂移。meta信息最好只放和数据集、模板、任务定义相关的静态内容。

  3. 修改保存逻辑要保证幂等性。也就是说,重复保存多次不会重复叠加字段。因为_save可能在训练中多次触发,我上面这段代码里的逻辑是覆盖写入,所以是幂等的,这也算一个设计上的小细节。

改完之后,部署同事加载adapter时顺手看一眼配置文件,训练数据版本一目了然,再也不用追着问人了。

6. 修改后的回归验证与踩坑记录

源码改完了,不代表事情就结束了。最关键的其实是验证——怎么证明改动没有破坏unsloth原有的优化效果。

6.1 回归验证的四步流程

我花了两天时间做验证,核心思路很简单:改之前跑一遍基线,改之后再跑一遍,对比训练曲线和显存占用。具体分为四步:

第一步:最小数据集烟雾测试。

拿几百条样本,开一个极小的batch,跑两三个step,检查能否正常启动、日志是否正常。这一步主要排除语法错误和import问题。我在这里就发现了一个问题——自定义collator在deepcopy特征时出现了类型不匹配的错误,排查后发现是loss_type字段在copy过程中丢失了,需要特殊处理。

第二步:完整训练曲线对比。

用同一个数据集、同样的超参数,分别跑原始unsloth和修改后的版本,对比loss下降曲线。正常情况应该有微小的随机差异,但整体趋势必须一致。如果修改后loss波动异常大,多半是labels的mask逻辑有问题。

第三步:显存占用对比。

unsloth最核心的价值就是省显存。我这次改动没有碰kernel层,也没有给模型forward加东西,理论上显存不应该有明显增加。实测下来效果符合预期,训练速度和显存占用基本与改前持平。如果你改了模型层之后显存暴涨,那你得回头想想是不是不小心破坏了什么优化。

第四步:保存—加载闭环测试。

把修改后训练的adapter保存下来,用一个全新的原版unsloth环境加载,跑一次推理,看模型行为是否正常。这一步能验证我写的元数据有没有把adapter_config.json弄坏。实测加载正常,自定义字段被正确读取,没有影响LoRA权重。

6.2 几个容易翻车的地方

这次改源码踩了不少坑,总结几个我认为最有价值的:

版本兼容性问题是最大的坑。unsloth迭代很快,不同版本的UnslothTrainer类内部结构有差异。如果你照网上的教程改源码,很可能因为版本不同而找不到对应的代码位置。我的做法是:在改之前先把自己的unsloth版本固定住,不要让它在训练中途升级。

-100标签屏蔽的位置要非常小心。有些模型的自定义损失函数可能不会读取labels里的-100,而是直接忽略labels里所有的负值。如果你的代码里自定义了损失函数,记得检查它是否遵循-100约定。

callback里做生成评估时,注意模型状态切换。如果模型用了torch.compile或者unsloth的优化状态,在某些版本下model.eval()和model.train()的切换可能会和优化状态有交互。实测中遇到过一次奇怪的问题:评估完回到训练模式后,某个layer的缓存没有清理干净,导致后续loss出现一次尖峰。最终的解决办法是在model.train()之后手动做一次model.zero_grad()。

不要为了“改”而改。我这次改的三个点,每一个都有明确的使用场景支撑。如果你的项目用默认unsloth就能跑得很顺,真没必要动源码。维护一份自定义版本的unsloth是有成本的,升级、依赖管理、多人协作都会变得稍微复杂。

7. 一点经验:最小改动原则与后续维护思路

改完这一轮,我的体会是:对unsloth这种性能敏感、结构紧凑的库,最稳妥的改造策略是“最小改动”,能加一层就加一层,绝不去动底层kernel。

这次改动一共就集中在两个文件里:training.py里加了collator的覆盖逻辑和一个callback,UnslothTrainer的保存逻辑里加了元数据注入。模型层的文件一个都没碰,kernel层更不用说。这样一来,unsloth升级的时候,我可以清楚地看到自己改了什么,手动从一个版本挪到另一个版本的工作量也可控。

如果你也准备改unsloth,我给一个比较实用的建议:把自己所有改动都集中在一个自定义文件里,比如unsloth_custom/patches.py,统一用mixin或回调方式挂载,而不是零散地改原文件。这样做的最大好处是:升级unsloth时,你只需要重新import这个文件,不需要再逐个找改动点。

最后再分享一个实用的小技巧:改完源码后,在训练脚本最前面写一个断言,检查自定义版本是否真的被加载了。比如在一个自定义模块里放一个全局常量:

CUSTOM_UNSLOTH_FLAG = "v20240615_custom" assert CUSTOM_UNSLOTH_FLAG in sys.modules.get("unsloth", ""), "custom unsloth not loaded!"

这个断言能在训练启动时快速暴露import路径错误,省得你稀里糊涂用错了版本,训练跑了一大半才发现改动没生效。这类“调试性断言”在维护自定义版本时非常实用,尤其当你同时有好几个项目加载了不同版本的unsloth时。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询