基于 OP-Mix 的多能力持续训练方法指导指代改写模型训练
1 理论基础
声明:本文方法论全部基于论文 "Always Learning, Always Mixing: Efficient and Simple Data Mixing All The Time" (OP-Mix, Hu et al., 2025, https://arxiv.org/abs/2605.15220) 的结论与实验结果提炼而成,旨在将论文的学术贡献转化为可直接指导工程实践的系统性方法论。
1.1 问题形式化
设模型已具备的能力域为 $\{D_1, \dots, D_m\}$,当前混合比例为 $p_{t-1}$,新引入的能力域为 $D_{m+1}, \dots, D_{m+K}$。目标是找到混合比例 $\alpha^*$ 使得:
$$ \alpha^* = \arg\min_{\alpha \in \triangle^K} \frac{1}{N}\sum_{j=1}^{N} w_j \cdot \hat{g}_j(\alpha) + \lambda \cdot D_{\text{KL}}(E(\alpha) \| \mu) $$
其中:
- $\hat{g}_j(\alpha)$ 是域 $j$ 的 log-linear 损失预测函数
- $w_j$ 是域权重(用户指定,反映各能力重要性)
- $\lambda = 0.05$ 是 KL 正则强度(论文通用配置)
- $\mu$ 是均匀先验
- $E(\alpha)$ 是混合扩展映射,将新旧比例映射回全域概率
1.2 误差分解定理
OP-Mix 的次优性由两类误差决定(Remark 1):
$$ J(\hat\alpha) - J(\alpha^*) \leq 2(\varepsilon_{\text{merge}} + \varepsilon_{\text{LoRA}}) $$
- $\varepsilon_{\text{merge}}$:权重插值 vs 真实混合训练的偏差(由 Linear Mode Connectivity 保证为小值)
- $\varepsilon_{\text{LoRA}}$:LoRA 代理 vs 全量微调的偏差(由 LoRA 近似能力控制)
实操含义:两类误差同时为小值时,OP-Mix 几乎恢复最优混合。论文实验中遗憾值仅 0.9%(vs 固定 10% replay 的 2.9%),且不随阶段增长。
1.3 为什么 On-Policy 优于 Off-Policy
| 特性 | On-Policy (OP-Mix) | Off-Policy (OLMix等) |
|---|---|---|
| 代理来源 | 从当前模型训练 LoRA | 单独初始化小模型 |
| 动态一致性 | 反映模型真实学习状态 | 小模型与大模型动态发散 |
| 新域扩展 | 加一个 LoRA 即可 | 需重新训练所有代理 |
| 计算开销 | 低(LoRA 仅增加 0.1%~1% 参数) | 高(需完整小模型训练) |
| 持续学习适用 | 天然支持 | 预训练后通常不可用(开源模型无配套小模型) |
2 数据构造方法论
数据质量直接决定混合优化的上限。OP-Mix 的代理评估拟合的是"给定数据混合比例下的损失面",若数据本身有问题,找到的最优比例也不可靠。
2.1 各域能力数据构造原则
指代消除改写数据(新域)
数据来源路径:
| 路径 | 适用场景 | 质量评估 |
|---|---|---|
| 业务日志 + 人工标注 | 有线上流量 | 最高,贴合真实分布 |
| 规则生成 + 人工校验 | 冷启动阶段 | 中等,覆盖面可控但多样性受限 |
| 强模型蒸馏 | 快速扩充 | 中等,需过滤模型幻觉 |
| 公开数据集改写 | 补充多样性 | 低,与业务分布偏差大 |
核心构造方法——上下文-指代-改写三元组:
每条数据包含:
context:多轮对话上下文(必须包含指代项,如"它"、"那款"、"上面说的")query_with_reference:含指代的用户原始 queryquery_resolved:指代消除后的改写 query
{
"messages": [
{"role": "system", "content": "你是一个指代消除改写助手。将用户含有指代词的query改写为不含指代的独立query,保留所有关键信息。"},
{"role": "user", "content": "上下文:用户问\"比亚迪汉EV续航怎么样\",助手回答\"比亚迪汉EV CLTC续航715km...\"。\n用户query:那款车的充电速度呢?"},
{"role": "assistant", "content": "比亚迪汉EV的充电速度"}
]
}指代类型覆盖要求:
| 指代类型 | 示例 | 构造难度 |
|---|---|---|
| 名词指代 | "那款车" → "比亚迪汉EV" | 低,规则可覆盖 |
| 属性指代 | "续航呢" → "比亚迪汉EV的续航" | 中,需理解对话焦点 |
| 隐式指代 | "还有别的吗" → "比亚迪汉EV之外还有哪些车型" | 高,需推理意图 |
| 跨轮指代 | 指代 2 轮前的实体 | 高,需长上下文理解 |
| 省略恢复 | "价格" → "比亚迪汉EV的价格" | 中,需补全省略主语 |
数量建议:
- 最小有效集:500 条(覆盖全部指代类型)
- 推荐量:2000~5000 条
- 超过 10000 条后边际收益递减,优先提升质量而非数量
提槽数据(旧域)
数据格式:
{
"messages": [
{"role": "system", "content": "你是车型推荐信息提取助手。从用户query中提取车型、配置、预算等关键信息。"},
{"role": "user", "content": "我想看20万左右的SUV,要混动的那种"},
{"role": "assistant", "content": "{\"预算\": \"20万左右\", \"车型类型\": \"SUV\", \"动力类型\": \"混动\"}"}
]
}构造要点:
- 输出格式必须与基座模型原有提槽格式一致,避免格式变化引入额外的"伪遗忘"
- 覆盖基座模型已知的全部槽位类型,不引入新槽位
- 数量应与原始训练时该域的数据量级匹配(通常 3000~10000 条)
问题分类数据(旧域)
数据格式:
{
"messages": [
{"role": "system", "content": "你是问题分类助手。判断用户query属于哪个类别。"},
{"role": "user", "content": "比亚迪汉EV和特斯拉Model 3哪个好"},
{"role": "assistant", "content": "对比咨询"}
]
}构造要点:
- 分类标签体系必须与基座模型完全一致
- 各类别样本量尽量均衡,少数类可适当过采样
- 数量通常 2000~8000 条
2.2 验证集构造
验证集用于代理评估阶段(Step 2),直接影响损失面拟合质量。
核心原则:
- 各域独立验证集:每个能力域一个,互不交叉
- 数量:每域 500~1000 条即可(代理评估只需前向传播,无需大量数据)
- 分布一致性:验证集必须与训练集同分布,但不可与训练集重叠
- 难度覆盖:验证集应包含简单/中等/困难样本,避免全为难例导致损失面过于平坦
分布一致性检查:
- 对比训练集和验证集在各指标(样本长度、槽位分布、类别分布、指代类型分布)上的统计量
- 若偏差超过 10%,需重新采样
2.3 10/90 交叉混合数据的构造
OP-Mix 要求代理训练时做 10/90 交叉混入。在 ms-swift 中,这通过控制每个 --dataset 的 #sample_size 实现。
旧域代理(90% 旧 + 10% 新):
假设 $D_{\text{old}}$ 有 6000 条,$D_{\text{rewrite}}$ 有 3000 条:
- 旧域采样 6000 × 0.9 = 5400 条
- 新域采样 3000 × 0.1 = 300 条(从 3000 条中随机抽取,不重复)
- 合并为
proxy_old.jsonl
新域代理(90% 新 + 10% 旧):
- 新域采样 3000 × 0.9 = 2700 条
- 旧域采样 6000 × 0.1 = 600 条(从 6000 条中随机抽取,不重复)
- 合并为
proxy_rewrite.jsonl
注意事项:
- 交叉混入数据从训练集抽取,不触碰验证集
- 旧域内部的子域比例(提槽 vs 分类)在混入时保持原比例
- 10% 是论文实证值,不建议低于 5%(高估遗忘风险增加)
2.4 数据质量检查清单
| 检查项 | 方法 | 不通过的后果 |
|---|---|---|
| 格式一致性 | ms-swift auto-preprocessor 校验 | 训练报错或静默跳过 |
| 输出格式统一 | 正则/Schema 校验 | 格式漂移导致"伪遗忘" |
| 指代覆盖完整性 | 统计各指代类型占比 | 代理面偏差,优化方向错误 |
| 无泄露 | 训练/验证集去重(n-gram overlap) | 代理评估过拟合,损失面失真 |
| 难度分布 | 样本长度/槽位数统计 | 损失面过于平坦或陡峭 |
3 方法论:五步流程
Step 0: 基线评测与约束定义
目的:量化各能力的初始水平,定义不可违反的约束边界。
操作:
- 在基座模型上评测所有能力域的指标
- 为每个旧能力域定义容忍退化阈值 $\epsilon_j$:
$$ f_j(\theta_{\text{trained}}) \geq f_j(\theta_{\text{base}}) - \epsilon_j $$
$\epsilon_j$ 的选取策略:
| 场景 | 推荐值 | 理由 |
|---|---|---|
| 旧能力是核心业务指标 | 0(零容忍) | 任何退化都不可接受 |
| 旧能力重要但允许微小波动 | 1%~2% | 测量噪声范围内可接受 |
| 旧能力是辅助能力 | 3%~5% | 可用新能力增益补偿 |
输出:基线分数表 + 每个域的 $\epsilon_j$ 约束值。
Step 1: 训练 On-Policy LoRA 代理
目的:用低成本代理近似各域全量训练效果。
核心配置(论文 Table 4 通用配置):
| 参数 | 值 | 说明 |
|---|---|---|
| LoRA rank $r$ | 16 | 论文通用配置 |
| LoRA scaling $\alpha$ | 32 | 论文通用配置 |
| LoRA 学习率 | $2\times$ 基座训练 LR | 确保代理有足够学习信号 |
| LoRA warmup | 0 steps | 代理训练短,不需要 warmup |
| LoRA 训练步数 | 250~500 步 | 指令微调场景用 256 步;域数据量更大时适当增加 |
| LoRA 目标模块 | all-linear | 全覆盖以确保代理准确性 |
10/90 混合探针规则(关键细节):
为防止代理高估遗忘,每个 LoRA 训练数据需要交叉混入少量对方域数据:
θ_LoRA_old: 在 D_old 上训练,但混入 10% D_new 的数据
θ_LoRA_new: 在 D_new 上训练,但混入 10% D_old 的数据为什么不能纯域训练:纯旧域 adapter 未见新域数据,插值时在高新域比例端会严重高估旧域损失(高估遗忘);纯新域 adapter 同理。10% 交叉混合让每个代理对另一域有基本认知,插值结果更准确。
多新域情况:若有 K > 1 个新域,则为每个新域训练独立 LoRA,old adapter 只训练一个(将所有旧域压缩为一个代理)。
Step 2: 代理评估与损失面拟合
目的:通过 LoRA 插值前向传播,构建不同混合比例下的损失面。
操作流程:
采样混合比例:在 $K$-单纯形上采样 $P$ 个点
- $K=1$(单新域):用确定性 9 点网格 $\alpha_{\text{new}} \in \{0.1, 0.2, \dots, 0.9\}$
- $K > 1$:Dirichlet 采样 $P=20$ 个点
插值构造代理模型:
$$ \theta^{\text{LoRA}}_\alpha = (1-\alpha) \cdot \theta^{\text{LoRA}}_{\text{old}} + \alpha \cdot \theta^{\text{LoRA}}_{\text{new}} $$
- 前向评估:对每个插值点,在所有域的验证集上计算损失 $\{y_{p,j}\}$
拟合 log-linear 回归:
$$ \hat{g}_j(\alpha) = c_j + \exp(A_j^\top \alpha) $$
其中 $c_j \in \mathbb{R}$,$A_j \in \mathbb{R}^K$ 为回归参数。
计算成本分析:仅需前向传播(无反向传播),9 个插值点 × N 个验证集 = 极低成本。论文中这一步在单张 L40S 上即可完成。
Step 3: 带约束混合优化
目的:在约束条件下搜索最优混合比例。
3.1 标准形式(无硬约束)
论文原始优化目标:
$$ \alpha^* = \arg\min_{\alpha \in \triangle^K} \frac{1}{N}\sum_{j=1}^{N} \hat{g}_j(\alpha) + \lambda \cdot D_{\text{KL}}(E(\alpha) \| \mu) $$
- $\lambda = 0.05$:KL 正则强度,防止极端比例
- $\mu$:均匀先验,鼓励各域均获一定训练机会
3.2 带硬约束形式(推荐用于"旧能力不可退化"场景)
$$ \alpha^* = \arg\min_{\alpha \in \triangle^K} \; \hat{g}_{\text{new}}(\alpha) + \lambda \cdot D_{\text{KL}}(E(\alpha) \| \mu) $$
$$ \text{s.t.} \quad \hat{g}_j(\alpha) \leq \text{baseline}_j + \epsilon_j, \quad \forall j \in \text{旧能力域} $$
3.3 带软约束形式(旧能力重要但非零容忍)
$$ \alpha^* = \arg\min_{\alpha \in \triangle^K} \; \hat{g}_{\text{new}}(\alpha) + \sum_{j \in \text{旧域}} \beta_j \cdot \max(0, \hat{g}_j(\alpha) - \text{baseline}_j - \epsilon_j) + \lambda \cdot D_{\text{KL}}(E(\alpha) \| \mu) $$
其中 $\beta_j$ 是旧域惩罚系数,值越大则对退化容忍度越低。
3.4 求解工具
使用 cvxpy(论文标准配置),log-linear 形式保证凸性:
import cvxpy as cp
alpha = cp.Variable(K+1) # K 个新域 + 1 个 old
constraints = [
cp.sum(alpha) == 1, # 单纯形约束
alpha >= 0.01, # 下界防止某域完全消失
]
# 加入旧能力硬约束
for j in old_domains:
constraints.append(g_hat_j(alpha) <= baseline_j + epsilon_j)
objective = cp.Minimize(
g_hat_new(alpha) # 新能力损失最小化
+ lam * kl_divergence(alpha, mu) # KL 正则
)
problem = cp.Problem(objective, constraints)
alpha_star = problem.solve()Step 4: 全量训练
目的:用最优混合比例进行全量 SFT。
关键配置:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 学习率调度 | cosine(ms-swift 默认) | ms-swift 无内置 WSD,cosine 是最接近的替代;若需 WSD 需自定义 scheduler |
| 学习率 | $1\times10^{-5}$ | ms-swift 全参微调默认值 |
| 优化器 | AdamW, weight decay 0.01 | 论文标准 |
| 训练轮次 | 1 epoch(指令微调) | 防止过拟合导致遗忘加剧 |
| 旧数据比例下界 | ≥ 10% | 论文实证:10% replay 是缓解遗忘的最小有效比例 |
| 精度 | bfloat16 | 论文标准 |
| DeepSpeed | zero2 / zero3 | 全参微调 4B 模型推荐 zero2,显存不足用 zero3 |
混合比例扩展(Mixture Expansion):
计算出的 $\alpha^*$ 是新旧比例,需扩展回全域:
$$ p_t(D_i) = \begin{cases} \alpha^*_{\text{old}} \cdot p_{t-1}(D_i) & i \leq m \quad (\text{旧域按原比例缩放}) \\ \alpha^*_i & i > m \quad (\text{新域直接使用}) \end{cases} $$
Step 5: 验证与迭代
目的:确认训练后各能力满足约束,否则迭代。
验证清单:
- 新能力指标是否达到目标?
- 每个旧能力是否 ≥ baseline - ε?
- 训练损失曲线是否平稳下降(无振荡)?
迭代条件:若旧能力退化超阈值,将当前模型作为新 θ_base,缩小新域比例范围后重新执行 Step 1~4。论文表明通常 1~2 轮即可收敛。
4 关键实操要点
4.1 旧数据比例的选取
| 旧数据比例 | 预期效果 | 适用场景 |
|---|---|---|
| < 5% | 遗忘严重 | 不推荐 |
| 10% | 论文基线,缓解遗忘的最低有效比例 | 旧能力重要性一般 |
| 20%~30% | 较好的遗忘缓解 | 旧能力是核心指标 |
| 50%+ | 最强遗忘防护,但新能力增益受限 | 旧能力绝对不可退化 |
4.2 LoRA 代理精度保障
- rank 不可过低:rank < 8 会导致 $\varepsilon_{\text{LoRA}}$ 过大,代理面偏离真实面
- 训练步数不可过少:至少让 LoRA 损失下降 1 个数量级
- 覆盖目标模块:ms-swift 中使用
--target_modules all-linear覆盖所有线性层
4.3 为什么不能用 LoRA 合并代替全量训练
论文消融实验(Figure 7)明确显示:
- LoRA-Merge > Continual SFT(至少有旧数据保护)
- 但 LoRA-Merge << OP-Mix + 全量微调(差距显著)
原因:LoRA 插值只在权重空间做了线性近似,而真实训练涉及非线性优化路径。LoRA 的角色是搜索最优比例,而非替代训练。
4.4 多阶段场景的累积处理
若后续还需注入更多新能力:
- 将当前训练后模型作为新 θ_base
- 所有历史域压缩为一个 old adapter(保持 p_t 的比例关系)
- 新增域训练独立 new adapter
- 执行 Step 2~4
论文实证:遗憾值不随阶段累积增长(Figure 5),证明此流程可持续执行。
4.5 ms-swift 下的数据混合控制
ms-swift 无显式的"混合比例"参数,数据比例通过 --dataset 的 #sample_size 后缀控制:
# 改写数据 3000 条 + 旧域数据 5400 条(即 α_rewrite ≈ 0.36)
swift sft \
--model Qwen/Qwen3.5-4B \
--tuner_type full \
--dataset rewrite.jsonl#3000 old.jsonl#5400关键:#sample_size 控制采样条数,若 N 大于数据总量则重复采样。这精确实现了 OP-Mix 计算出的混合比例。
5 案例:qwen3.5-4b 指代消除改写训练(ms-swift 全参微调)
5.1 域划分与数据规模
| 域标识 | 数据内容 | 角色 | 建议规模 |
|---|---|---|---|
| $D_{\text{slot}}$ | 车型推荐信息提槽数据 | 旧能力 - 高优 | 3000~5000 条 |
| $D_{\text{cls}}$ | 问题分类判断数据 | 旧能力 - 高优 | 2000~4000 条 |
| $D_{\text{rewrite}}$ | 上下文指代消除改写数据 | 新能力 - 强化目标 | 2000~5000 条 |
旧域合并:$D_{\text{old}} = \text{mix}(D_{\text{slot}}, D_{\text{cls}})$,按原始数据量比例混合为一个 old.jsonl。
5.2 Step 0: 基线评测
# 使用 ms-swift 的 eval 功能或自定义评测脚本
swift eval \
--model Qwen/Qwen3.5-4B \
--eval_dataset slot_eval.jsonl cls_eval.jsonl rewrite_eval.jsonl模型: Qwen/Qwen3.5-4B (原始基座)
评测结果:
- 指代消除改写 F1: X% (待提升)
- 提槽 F1: Y% (约束: ≥ Y% - 1%)
- 问题分类 Acc: Z% (约束: ≥ Z% - 1%)设定 $\epsilon_{\text{slot}} = 1\%$, $\epsilon_{\text{cls}} = 1\%$(零容忍场景可设为 0)。
5.3 Step 1: 训练 LoRA 代理
ms-swift 中 LoRA 代理训练命令:
旧域代理(90% 旧 + 10% 改写):
假设 $D_{\text{old}}$ 有 6000 条,$D_{\text{rewrite}}$ 有 3000 条,需构造 proxy_old.jsonl(5400 条旧 + 300 条改写 = 5700 条)。
# 先用脚本构造 proxy_old.jsonl(90% 旧 + 10% 新)
python make_proxy_data.py \
--old_data old.jsonl --new_data rewrite.jsonl \
--old_ratio 0.9 --output proxy_old.jsonl
swift sft \
--model Qwen/Qwen3.5-4B \
--tuner_type lora \
--lora_rank 16 \
--lora_alpha 32 \
--target_modules all-linear \
--dataset proxy_old.jsonl \
--num_train_epochs 1 \
--max_steps 256 \
--learning_rate 4e-5 \
--warmup_ratio 0 \
--per_device_train_batch_size 4 \
--gradient_accumulation_steps 4 \
--output_dir output/lora_old \
--save_steps 256改写域代理(90% 改写 + 10% 旧):
构造 proxy_rewrite.jsonl(2700 条改写 + 600 条旧 = 3300 条)。
python make_proxy_data.py \
--old_data old.jsonl --new_data rewrite.jsonl \
--old_ratio 0.1 --output proxy_rewrite.jsonl
swift sft \
--model Qwen/Qwen3.5-4B \
--tuner_type lora \
--lora_rank 16 \
--lora_alpha 32 \
--target_modules all-linear \
--dataset proxy_rewrite.jsonl \
--num_train_epochs 1 \
--max_steps 256 \
--learning_rate 4e-5 \
--warmup_ratio 0 \
--per_device_train_batch_size 4 \
--gradient_accumulation_steps 4 \
--output_dir output/lora_rewrite \
--save_steps 2565.4 Step 2: 代理评估
对 $\alpha_{\text{rewrite}} \in \{0.1, 0.2, \dots, 0.9\}$ 构造插值模型:
θ(α) = (1-α) · θ_LoRA_old + α · θ_LoRA_rewrite在三个验证集上评估损失:
| α_rewrite | Loss_rewrite | Loss_slot | Loss_cls |
|---|---|---|---|
| 0.1 | 高 | 低 | 低 |
| 0.2 | |||
| ... | |||
| 0.9 | 低 | 高 | 高 |
拟合 3 个 log-linear 回归:$\hat{g}_{\text{rewrite}}(\alpha)$, $\hat{g}_{\text{slot}}(\alpha)$, $\hat{g}_{\text{cls}}(\alpha)$。
5.5 Step 3: 带约束优化
$$ \alpha^* = \arg\min_{\alpha \in [0.1, 0.9]} \hat{g}_{\text{rewrite}}(\alpha) + 0.05 \cdot D_{\text{KL}}(E(\alpha) \| \text{uniform}) $$
$$ \text{s.t.} \quad \hat{g}_{\text{slot}}(\alpha) \leq \text{baseline}_{\text{slot}} + 1\% $$
$$ \quad \hat{g}_{\text{cls}}(\alpha) \leq \text{baseline}_{\text{cls}} + 1\% $$
预期结果:α* 大概率落在 0.3~0.6 区间——改写数据占比 30%~60%,旧能力数据占比 40%~70%。具体值取决于代理评估面的形状。
5.6 Step 4: 全量训练
假设优化结果 $\alpha^* = 0.4$(改写占 40%,旧域占 60%):
- 改写数据全部使用:3000 条
- 旧域数据需采样:3000 / 0.4 × 0.6 = 4500 条
swift sft \
--model Qwen/Qwen3.5-4B \
--tuner_type full \
--dataset rewrite.jsonl#3000 old.jsonl#4500 \
--learning_rate 1e-5 \
--lr_scheduler_type cosine \
--warmup_ratio 0.03 \
--num_train_epochs 1 \
--per_device_train_batch_size 2 \
--gradient_accumulation_steps 8 \
--adam_weight_decay 0.01 \
--bf16 true \
--deepspeed zero2 \
--output_dir output/full_sft_alpha04 \
--save_strategy epoch \
--eval_strategy no关键参数说明:
| 参数 | 值 | 说明 |
|---|---|---|
--tuner_type full | full | 全参微调,OP-Mix 的最终训练步骤 |
--dataset ...#N | 按比例计算 | ms-swift 通过 #N 控制各域采样条数,精确实现混合比例 |
--learning_rate | 1e-5 | ms-swift 全参微调默认值(LoRA 代理用 2× 即 4e-5) |
--lr_scheduler_type | cosine | ms-swift 无内置 WSD,cosine 是最佳替代 |
--warmup_ratio | 0.03 | 3% warmup,配合 cosine decay |
--deepspeed zero2 | zero2 | 4B 模型全参微调推荐配置 |
若需 WSD 调度:ms-swift 支持自定义 optimizer/scheduler 插件,可在 swift/optimizers/mapping.py 和 swift/schedulers/ 中注册 WSD scheduler,或通过 --external_plugins 加载自定义调度器。
5.7 Step 5: 验证
# 评测训练后模型
swift eval \
--model output/full_sft_alpha04/checkpoint-best \
--eval_dataset slot_eval.jsonl cls_eval.jsonl rewrite_eval.jsonl评测结果:
- 指代消除改写 F1: X'% (目标: 显著高于 X%)
- 提槽 F1: Y'% (约束: ≥ Y% - 1%)
- 问题分类 Acc: Z'% (约束: ≥ Z% - 1%)
若旧能力退化超阈值:
→ 以当前模型为新 θ_base
→ 缩小 α 搜索范围至上次 α* 附近
→ 重新执行 Step 1~45.8 完整自动化脚本
#!/bin/bash
# op_mix_pipeline.sh — OP-Mix 自动化流水线
# 用法: bash op_mix_pipeline.sh <alpha_star>
# 若未传 alpha_star,则先运行代理搜索阶段
set -e
MODEL=Qwen/Qwen3.5-4B
OLD_DATA=old.jsonl
NEW_DATA=rewrite.jsonl
SLOT_EVAL=slot_eval.jsonl
CLS_EVAL=cls_eval.jsonl
REWRITE_EVAL=rewrite_eval.jsonl
# ===== Step 0: 基线评测 =====
echo "[Step 0] 评测基座模型..."
BASELINE=$(swift eval --model $MODEL \
--eval_dataset $SLOT_EVAL $CLS_EVAL $REWRITE_EVAL 2>&1)
echo "$BASELINE"
if [ -z "$1" ]; then
# ===== Step 1: 训练 LoRA 代理 =====
echo "[Step 1] 训练 LoRA 代理..."
# 构造 10/90 交叉混合数据
python make_proxy_data.py \
--old_data $OLD_DATA --new_data $NEW_DATA \
--old_ratio 0.9 --output proxy_old.jsonl
python make_proxy_data.py \
--old_data $OLD_DATA --new_data $NEW_DATA \
--old_ratio 0.1 --output proxy_rewrite.jsonl
# 旧域 LoRA
swift sft \
--model $MODEL --tuner_type lora \
--lora_rank 16 --lora_alpha 32 \
--target_modules all-linear \
--dataset proxy_old.jsonl \
--num_train_epochs 1 --max_steps 256 \
--learning_rate 4e-5 --warmup_ratio 0 \
--per_device_train_batch_size 4 --gradient_accumulation_steps 4 \
--output_dir output/lora_old --save_steps 256
# 改写域 LoRA
swift sft \
--model $MODEL --tuner_type lora \
--lora_rank 16 --lora_alpha 32 \
--target_modules all-linear \
--dataset proxy_rewrite.jsonl \
--num_train_epochs 1 --max_steps 256 \
--learning_rate 4e-5 --warmup_ratio 0 \
--per_device_train_batch_size 4 --gradient_accumulation_steps 4 \
--output_dir output/lora_rewrite --save_steps 256
# ===== Step 2-3: 代理评估 + 优化 =====
echo "[Step 2-3] 代理评估与混合优化..."
ALPHA_STAR=$(python op_mix_search.py \
--lora_old output/lora_old/checkpoint-256 \
--lora_rewrite output/lora_rewrite/checkpoint-256 \
--eval_slot $SLOT_EVAL \
--eval_cls $CLS_EVAL \
--eval_rewrite $REWRITE_EVAL \
--baseline "$BASELINE" \
--epsilon_slot 0.01 --epsilon_cls 0.01 \
--lambda 0.05)
echo "优化结果: alpha* = $ALPHA_STAR"
else
ALPHA_STAR=$1
fi
# ===== Step 4: 全量训练 =====
echo "[Step 4] 全量训练 alpha*=$ALPHA_STAR ..."
python compute_sample_sizes.py \
--old_data $OLD_DATA --new_data $NEW_DATA \
--alpha_new $ALPHA_STAR \
--output sample_sizes.json
OLD_N=$(python -c "import json; print(json.load(open('sample_sizes.json'))['old_n'])")
NEW_N=$(python -c "import json; print(json.load(open('sample_sizes.json'))['new_n'])")
swift sft \
--model $MODEL --tuner_type full \
--dataset ${NEW_DATA}#${NEW_N} ${OLD_DATA}#${OLD_N} \
--learning_rate 1e-5 \
--lr_scheduler_type cosine \
--warmup_ratio 0.03 \
--num_train_epochs 1 \
--per_device_train_batch_size 2 --gradient_accumulation_steps 8 \
--adam_weight_decay 0.01 \
--bf16 true \
--deepspeed zero2 \
--output_dir output/full_sft
# ===== Step 5: 验证 =====
echo "[Step 5] 验证训练后模型..."
swift eval \
--model output/full_sft/checkpoint-best \
--eval_dataset $SLOT_EVAL $CLS_EVAL $REWRITE_EVAL5.9 Python 辅助脚本
make_proxy_data.py — 构造 10/90 交叉混合数据:
"""构造 OP-Mix 代理训练数据(10/90 交叉混合)"""
import json
import random
import argparse
def load_jsonl(path):
with open(path) as f:
return [json.loads(line) for line in f]
def save_jsonl(data, path):
with open(path, 'w') as f:
for item in data:
f.write(json.dumps(item, ensure_ascii=False) + '\n')
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--old_data', required=True)
parser.add_argument('--new_data', required=True)
parser.add_argument('--old_ratio', type=float, required=True,
help='旧域数据比例,0.9 表示 90% 旧 + 10% 新')
parser.add_argument('--output', required=True)
parser.add_argument('--seed', type=int, default=42)
args = parser.parse_args()
random.seed(args.seed)
old = load_jsonl(args.old_data)
new = load_jsonl(args.new_data)
old_n = int(len(old) * args.old_ratio)
new_n = int(len(old) * (1 - args.old_ratio))
sampled_old = random.sample(old, min(old_n, len(old)))
sampled_new = random.sample(new, min(new_n, len(new)))
merged = sampled_old + sampled_new
random.shuffle(merged)
save_jsonl(merged, args.output)
print(f"输出 {len(sampled_old)} 条旧域 + {len(sampled_new)} 条新域 = {len(merged)} 条 → {args.output}")
if __name__ == '__main__':
main()op_mix_search.py — 代理评估 + 带约束优化:
"""OP-Mix 代理评估与带约束混合优化"""
import torch
import cvxpy as cp
import numpy as np
import argparse
import json
from pathlib import Path
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
def load_lora_adapter(base_model_path, lora_path):
model = AutoModelForCausalLM.from_pretrained(
base_model_path, torch_dtype=torch.bfloat16, device_map="auto")
model = PeftModel.from_pretrained(model, lora_path)
return model
def interpolate_lora_weights(model_old, model_new, alpha):
"""线性插值两个 LoRA adapter 的权重"""
merged = {}
for (name_old, param_old), (name_new, param_new) in \
zip(model_old.named_parameters(), model_new.named_parameters()):
merged[name_old] = (1 - alpha) * param_old.data + alpha * param_new.data
# 将插值结果写回 model_old
for name, param in model_old.named_parameters():
param.data.copy_(merged[name])
return model_old
def evaluate_loss(model, tokenizer, eval_data, max_samples=500):
"""在验证集上计算平均交叉熵损失"""
model.eval()
total_loss = 0.0
count = 0
with torch.no_grad():
for item in eval_data[:max_samples]:
text = item["messages"][-2]["content"] + item["messages"][-1]["content"]
inputs = tokenizer(text, return_tensors="pt", truncation=True,
max_length=512).to(model.device)
loss = model(**inputs, labels=inputs["input_ids"]).loss
total_loss += loss.item()
count += 1
return total_loss / max(count, 1)
def fit_log_linear(alphas, losses):
"""拟合 log-linear 回归: g(α) = c + exp(A * α)"""
from scipy.optimize import curve_fit
alphas = np.array(alphas)
losses = np.array(losses)
# g(α) = c + exp(a * α)
def func(a, c, a_coeff):
return c + np.exp(a_coeff * a)
popt, _ = curve_fit(func, alphas, losses, p0=[losses.min(), 0.0], maxfev=5000)
return lambda a: popt[0] + np.exp(popt[1] * a)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--lora_old', required=True)
parser.add_argument('--lora_rewrite', required=True)
parser.add_argument('--base_model', default='Qwen/Qwen3.5-4B')
parser.add_argument('--eval_slot', required=True)
parser.add_argument('--eval_cls', required=True)
parser.add_argument('--eval_rewrite', required=True)
parser.add_argument('--epsilon_slot', type=float, default=0.01)
parser.add_argument('--epsilon_cls', type=float, default=0.01)
parser.add_argument('--lam', type=float, default=0.05)
args = parser.parse_args()
tokenizer = AutoTokenizer.from_pretrained(args.base_model)
# 加载验证集
eval_data = {
"slot": load_jsonl(args.eval_slot),
"cls": load_jsonl(args.eval_cls),
"rewrite": load_jsonl(args.eval_rewrite),
}
alphas = np.linspace(0.1, 0.9, 9)
losses = {"rewrite": [], "slot": [], "cls": []}
for a in alphas:
print(f"评估 α = {a:.1f} ...")
model_old = load_lora_adapter(args.base_model, args.lora_old)
model_new = load_lora_adapter(args.base_model, args.lora_rewrite)
merged = interpolate_lora_weights(model_old, model_new, alpha=a)
for domain_name, data in eval_data.items():
loss = evaluate_loss(merged, tokenizer, data)
losses[domain_name].append(loss)
del model_old, model_new, merged
torch.cuda.empty_cache()
# 拟合回归
regressors = {name: fit_log_linear(alphas, l) for name, l in losses.items()}
# 带约束优化
alpha = cp.Variable(1)
constraints = [
alpha >= 0.1,
alpha <= 0.9,
]
# 注:此处简化,实际需将 baseline 从 argparse 解析
# constraints += [regressors["slot"](alpha) <= baseline_slot + args.epsilon_slot]
# constraints += [regressors["cls"](alpha) <= baseline_cls + args.epsilon_cls]
# KL 正则项(简化:对均匀先验的 KL 散度)
kl_term = args.lam * (alpha * cp.log(alpha / 0.5) + (1 - alpha) * cp.log((1 - alpha) / 0.5))
objective = cp.Minimize(regressors["rewrite"](alpha) + kl_term)
problem = cp.Problem(objective, constraints)
problem.solve()
alpha_star = alpha.value[0]
print(f"\n优化结果: α* = {alpha_star:.4f}")
print(f" 改写数据占比: {alpha_star:.1%}")
print(f" 旧域数据占比: {1 - alpha_star:.1%}")
print(alpha_star) # 供 shell 脚本捕获
def load_jsonl(path):
with open(path) as f:
return [json.loads(line) for line in f]
if __name__ == '__main__':
main()compute_sample_sizes.py — 计算各域采样条数:
"""根据 α* 计算各域采样条数"""
import json
import argparse
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--old_data', required=True)
parser.add_argument('--new_data', required=True)
parser.add_argument('--alpha_new', type=float, required=True,
help='新域占比')
parser.add_argument('--output', default='sample_sizes.json')
args = parser.parse_args()
def count_lines(path):
with open(path) as f:
return sum(1 for _ in f)
new_total = count_lines(args.new_data)
old_total = count_lines(args.old_data)
# 新域全量使用,旧域按比例采样
new_n = new_total
old_n = int(new_total / args.alpha_new * (1 - args.alpha_new))
old_n = min(old_n, old_total) # 不超过旧域总量
result = {"old_n": old_n, "new_n": new_n,
"alpha_new": args.alpha_new,
"actual_old_ratio": old_n / (old_n + new_n)}
with open(args.output, 'w') as f:
json.dump(result, f, indent=2)
print(json.dumps(result, indent=2))
if __name__ == '__main__':
main()6 数据构造深度指南
6.1 改写数据构造的三种路径
路径 A:业务日志 + 人工标注(推荐)
流程:
- 从线上对话日志中筛选含指代的 query(正则/分类器初筛)
- 人工标注改写结果
- 按指代类型分层抽样,确保覆盖
分层抽样配额:
| 指代类型 | 目标占比 | 说明 |
|---|---|---|
| 名词指代 | 30% | 最常见,规则可覆盖部分 |
| 属性指代 | 25% | 需理解对话焦点 |
| 隐式指代 | 20% | 需推理意图,质量关键 |
| 省略恢复 | 15% | 需补全省略成分 |
| 跨轮指代 | 10% | 最多 2 轮回溯,更长上下文可单独处理 |
标注规范:
- 改写后 query 必须独立可理解(无上下文也能明确意图)
- 保留原始 query 的所有信息,不添加额外信息
- 不改变用户意图,只做指代消除
路径 B:规则生成 + 人工校验
适用:冷启动阶段,无足够线上日志。
规则模板:
上下文模板: "用户问{entity}{attr},助手回答{answer}。"
指代表达: "{指代词}{attr_question}"
指代词列表: ["那款车", "它", "这个", "那个", "上面说的", "刚才提到的"]生成示例:
| 上下文 | 含指代 query | 改写后 |
|---|---|---|
| 用户问比亚迪汉EV续航,助手回答CLTC 715km | 那款车的充电速度呢 | 比亚迪汉EV的充电速度 |
| 用户问Model 3价格,助手回答23.19万起 | 它有补贴吗 | Model 3有补贴吗 |
局限:规则生成的指代模式有限,隐式指代和复杂省略难以覆盖。建议规则生成占 ≤ 30%,剩余由路径 A 或 C 补充。
路径 C:强模型蒸馏
流程:
- 构造含指代的 prompt(从业务日志提取上下文+含指代 query)
- 用强模型(如 Qwen3-72B)生成改写结果
- 人工过滤,去除幻觉和过度改写
蒸馏 prompt:
请将以下对话中用户的query进行指代消除改写,使其在没有上下文的情况下也能独立理解。
要求:1) 保留所有关键信息 2) 不添加额外信息 3) 不改变用户意图
上下文:{context}
用户query:{query_with_reference}
改写结果:过滤标准:
- 改写后是否独立可理解?
- 是否丢失了原始 query 中的关键信息(如车型、属性)?
- 是否引入了上下文中不存在的实体?
- 拒绝率通常 10%~20%,需保留足够原始量
6.2 数据量与能力的 scaling 关系
基于论文结论和工业实践经验:
| 能力域 | 最小有效量 | 推荐量 | 过量阈值 | 说明 |
|---|---|---|---|---|
| 指代消除改写 | 500 | 2000~5000 | ~10000 | 指代类型覆盖比数量更重要 |
| 提槽 | 1000 | 3000~5000 | ~15000 | 槽位覆盖完整性决定上限 |
| 问题分类 | 800 | 2000~4000 | ~8000 | 类别均衡比总量更关键 |
过量风险:某域数据量远超其他域时,即使混合比例控制了采样量,模型仍可能偏向数据量最大的域。建议各域总量差距不超过 3 倍。
6.3 数据质量对代理精度的影响
OP-Mix 代理评估的精度依赖数据质量。低质量数据会导致:
| 数据问题 | 对代理的影响 | 对最终训练的影响 |
|---|---|---|
| 格式不统一 | 代理评估噪声大,损失面拟合差 | 模型输出格式漂移,表现为"伪遗忘" |
| 指代覆盖不全 | 代理面仅反映部分指代类型的损失 | 对未覆盖类型改写效果差 |
| 验证集与训练集分布偏移 | 代理面偏离真实面,α* 次优 | 实际退化可能超预期 |
| 训练集内部有重复 | 高估某子域重要性 | 模型对重复样本过拟合 |
6.4 混合比例转化为 ms-swift 采样量的公式
设 $\alpha^*$ 为新域占比,各域数据总量为 $N_{\text{old}}$、$N_{\text{new}}$:
新域采样量: new_n = N_new (新域全量使用)
旧域采样量: old_n = min(N_old, N_new / α* × (1 - α*))
若 old_n < N_old:
→ 旧域欠采样,从 N_old 条中随机抽取 old_n 条
若 old_n > N_old:
→ 旧域需重复采样(ms-swift #N > 数据总量时自动重复)
→ 此时考虑增加旧域数据或调低 α*示例:α* = 0.4,N_new = 3000,N_old = 6000:
- new_n = 3000
- old_n = min(6000, 3000/0.4 × 0.6) = min(6000, 4500) = 4500
ms-swift 命令:--dataset rewrite.jsonl#3000 old.jsonl#4500
7 常见问题与决策树
Q: 旧能力退化超阈值怎么办?
├── 首选: 增大旧数据比例(调高 α_old 下界至 0.3~0.5)
├── 备选: 增大 KL 正则 λ(0.05 → 0.1~0.2)
├── 备选: 降低学习率(1e-5 → 5e-6,减少对旧参数的扰动)
├── 备选: ms-swift 部分冻结底层(--freeze_parameters_ratio 0.3 冻结底部 30% 层)
└── 兜底: 迭代执行 OP-Mix(以当前模型为新 base)
Q: 新能力增益不够怎么办?
├── 首选: 检查改写数据质量和指代类型覆盖
├── 备选: 适当放宽旧能力约束 ε(1% → 2~3%)
├── 备选: 增加改写域 LoRA 训练步数(256 → 500)
├── 备选: 检查数据格式是否与基座原有格式一致
└── 注意: 不要简单增大新数据比例,可能加剧遗忘
Q: 代理评估结果与实际训练结果不一致怎么办?
├── 检查验证集分布是否与训练集一致
├── 检查 LoRA rank 是否过低(16 → 32)
├── 检查 10/90 交叉混合是否正确构造
└── 增加 LoRA 训练步数(损失至少下降 1 个数量级)
Q: 多个新域之间冲突怎么办?
├── 为每个新域训练独立 LoRA
├── 搜索空间从 1D 扩展到 K-D
├── 增加 Dirichlet 采样点数(20 → 40)
└── 考虑分阶段引入(每次引入 1 个新域)
Q: 计算资源有限怎么办?
├── LoRA 代理可在低规格 GPU 上运行(论文用 L40S)
├── 减少代理评估点数(9 点 → 5 点,精度略降)
├── 降低 LoRA rank(16 → 8,代理精度会下降)
├── 减少验证集大小(每域 500 条即可)
└── 全参微调用 DeepSpeed zero3 或 LISA(--lisa_activated_layers 2)
Q: ms-swift 训练中 loss 震荡怎么办?
├── 减小学习率(1e-5 → 5e-6)
├── 增大 warmup_ratio(0.03 → 0.05~0.1)
├── 增大 gradient_accumulation_steps(等效增大 batch size)
└── 检查数据是否混入了格式异常样本8 参考文献
- Hu, M. Y. et al., "Always Learning, Always Mixing: Efficient and Simple Data Mixing All The Time" (OP-Mix), 2025. https://arxiv.org/abs/2605.15220
- Hu et al., "LoRA: Low-Rank Adaptation of Large Language Models", 2022
- Wang et al., "MergeMix", 2026
- Tao et al., "MergeToMix", 2025
- Shenfeld et al., "Self-Distillation Finetuning (SDFT)", 2026
- Chen et al., "OLMix", 2025
- Bethune et al., "10% data replay mitigates forgetting", 2025
- Wen et al., "WSD-S learning schedule for continual learning", 2025
- ModelScope SWIFT, https://github.com/modelscope/ms-swift