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:含指代的用户原始 query
  • query_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),直接影响损失面拟合质量。

核心原则

  1. 各域独立验证集:每个能力域一个,互不交叉
  2. 数量:每域 500~1000 条即可(代理评估只需前向传播,无需大量数据)
  3. 分布一致性:验证集必须与训练集同分布,但不可与训练集重叠
  4. 难度覆盖:验证集应包含简单/中等/困难样本,避免全为难例导致损失面过于平坦

分布一致性检查

  • 对比训练集和验证集在各指标(样本长度、槽位分布、类别分布、指代类型分布)上的统计量
  • 若偏差超过 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: 基线评测与约束定义

目的:量化各能力的初始水平,定义不可违反的约束边界。

操作

  1. 在基座模型上评测所有能力域的指标
  2. 为每个旧能力域定义容忍退化阈值 $\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 warmup0 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 插值前向传播,构建不同混合比例下的损失面。

操作流程

  1. 采样混合比例:在 $K$-单纯形上采样 $P$ 个点

    • $K=1$(单新域):用确定性 9 点网格 $\alpha_{\text{new}} \in \{0.1, 0.2, \dots, 0.9\}$
    • $K > 1$:Dirichlet 采样 $P=20$ 个点
  2. 插值构造代理模型

    $$ \theta^{\text{LoRA}}_\alpha = (1-\alpha) \cdot \theta^{\text{LoRA}}_{\text{old}} + \alpha \cdot \theta^{\text{LoRA}}_{\text{new}} $$

  3. 前向评估:对每个插值点,在所有域的验证集上计算损失 $\{y_{p,j}\}$
  4. 拟合 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论文标准
DeepSpeedzero2 / 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: 验证与迭代

目的:确认训练后各能力满足约束,否则迭代。

验证清单

  1. 新能力指标是否达到目标?
  2. 每个旧能力是否 ≥ baseline - ε?
  3. 训练损失曲线是否平稳下降(无振荡)?

迭代条件:若旧能力退化超阈值,将当前模型作为新 θ_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 多阶段场景的累积处理

若后续还需注入更多新能力:

  1. 将当前训练后模型作为新 θ_base
  2. 所有历史域压缩为一个 old adapter(保持 p_t 的比例关系)
  3. 新增域训练独立 new adapter
  4. 执行 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 256

5.4 Step 2: 代理评估

对 $\alpha_{\text{rewrite}} \in \{0.1, 0.2, \dots, 0.9\}$ 构造插值模型:

θ(α) = (1-α) · θ_LoRA_old + α · θ_LoRA_rewrite

在三个验证集上评估损失:

α_rewriteLoss_rewriteLoss_slotLoss_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 fullfull全参微调,OP-Mix 的最终训练步骤
--dataset ...#N按比例计算ms-swift 通过 #N 控制各域采样条数,精确实现混合比例
--learning_rate1e-5ms-swift 全参微调默认值(LoRA 代理用 2× 即 4e-5)
--lr_scheduler_typecosinems-swift 无内置 WSD,cosine 是最佳替代
--warmup_ratio0.033% warmup,配合 cosine decay
--deepspeed zero2zero24B 模型全参微调推荐配置

若需 WSD 调度:ms-swift 支持自定义 optimizer/scheduler 插件,可在 swift/optimizers/mapping.pyswift/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~4

5.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_EVAL

5.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:业务日志 + 人工标注(推荐)

流程

  1. 从线上对话日志中筛选含指代的 query(正则/分类器初筛)
  2. 人工标注改写结果
  3. 按指代类型分层抽样,确保覆盖

分层抽样配额

指代类型目标占比说明
名词指代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:强模型蒸馏

流程

  1. 构造含指代的 prompt(从业务日志提取上下文+含指代 query)
  2. 用强模型(如 Qwen3-72B)生成改写结果
  3. 人工过滤,去除幻觉和过度改写

蒸馏 prompt

请将以下对话中用户的query进行指代消除改写,使其在没有上下文的情况下也能独立理解。
要求:1) 保留所有关键信息 2) 不添加额外信息 3) 不改变用户意图

上下文:{context}
用户query:{query_with_reference}

改写结果:

过滤标准

  • 改写后是否独立可理解?
  • 是否丢失了原始 query 中的关键信息(如车型、属性)?
  • 是否引入了上下文中不存在的实体?
  • 拒绝率通常 10%~20%,需保留足够原始量

6.2 数据量与能力的 scaling 关系

基于论文结论和工业实践经验:

能力域最小有效量推荐量过量阈值说明
指代消除改写5002000~5000~10000指代类型覆盖比数量更重要
提槽10003000~5000~15000槽位覆盖完整性决定上限
问题分类8002000~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

标签: none

添加新评论