模型写出来的中文里,总有一些固定的腔调:开头先「首先」,中间「不仅……而且」,收尾「综上所述」「希望对你有所帮助」。这些词本身没问题,问题在于它们出现的频率和位置太整齐,读的人一眼就能感觉到「这是模型写的」。这篇教程做的事情很具体:用一组人为构造的对照语料,对 Qwen3-4B 做一次 LoRA 微调,把这类口癖从它的输出习惯里压下去,然后用困惑度和盲测两套方法验证到底有没有效果。
这篇能做出什么
完成后你手里会有四样东西:
1. 一份可复用的对照语料格式与构造脚本,同一件事分别有「AI 味版本」和「人味版本」,方便后续扩量。
2. 一个 LoRA adapter,加载到 Qwen3-4B 上之后,在中文写作类任务里明显少用「首先/其次/总之/不仅是……而且/值得注意的是/在当今这个……时代」这类句式。
3. 一套自动验收脚本:正则统计口癖命中率、计算两份验证集上的困惑度。
4. 一张盲测评分表,用人工双盲对比「微调前」与「微调后」,给出胜率而不是感觉。
需要提前说清边界:这次微调改的是文风,不是知识量和推理能力。它不会让模型变聪明,甚至可能让它在需要结构化输出的场景(写提纲、写流程步骤)里稍微不那么爱分点。这是可预期的代价,验收时要把这一项单独看。
前置条件清单
- 硬件:单张显存 16GB 以上的 GPU 做 LoRA(bf16 + AI 词典:梯度检查点">梯度检查点)。显存更紧张时改用 4bit 量化加载(QLoRA),8GB 左右也能跑,但训练会慢一些。
- 软件:Python 环境、PyTorch、
transformers、peft、trl、datasets、accelerate。具体版本号以各项目官方文档当前版本为准,不要照抄网上旧教程的固定版本,接口变动比较频繁。 - 数据:300~1500 条对照样本。数量不是关键,一致性才是。300 条高质量、风格统一的样本,效果通常好过 3000 条随手生成的样本。
- 一份没参与训练的留出集:至少 50 条,专门用来盲测。
- 基础能力:会在命令行跑脚本、看得懂 JSONL、知道什么是学习率和 epoch。
安装依赖大致如下,版本以官方文档当前版本为准:
```bash
python -m venv venv && source venv/bin/activate
pip install -U torch transformers peft trl datasets accelerate
```
分步骤
第 1 步:把「AI 味」写成一份可判定的清单
不要用「读起来不自然」这种模糊标准,先把它变成能跑正则的清单。这样才能自动检查训练数据是否干净、微调后是否真的改善。
```python
banned.py
BANNED_PATTERNS = [
r"首先[,,::]",
r"其次[,,::]",
r"再次[,,::]",
r"综上所述",
r"总的来说",
r"总而言之",
r"值得注意的是",
r"不难发现",
r"在当今.{0,10}?(时代|社会|背景)",
r"随着.{0,12}?的(不断)?发展",
r"不仅.{0,20}?而且",
r"让我们(一起|来)",
r"希望(本文|这篇|以上).{0,10}?(帮助|启发)",
r"作为(一个)?AI",
]
import re
_COMPILED = [re.compile(p) for p in BANNED_PATTERNS]
def hit_count(text: str) -> int:
return sum(len(p.findall(text)) for p in _COMPILED)
def hit_detail(text: str) -> list[str]:
out = []
for p in _COMPILED:
out.extend(p.findall(text))
return out
```
清单要按你自己的业务场景调整。写技术文档时「首先」可能是必要的,写公众号推文时就是套话。清单不是真理,是这次训练的目标函数。
第 2 步:用「反向改写」构造对照语料
直接从零让模型生成「人味」样本,很容易又生成一堆新的套话。更稳的做法是反向操作:找一批真人写的短文本作为正样本,再让一个通用模型把它「AI 化」重写,得到负样本。
这样正样本天然真实,负样本的套话也集中可控。流程:
1. 收集 300~800 条真人短文本(自己写的工作笔记、已获授权的公开文章片段、团队内部文档),每条 150~400 字,主题覆盖通知、说明、评论、复盘、产品介绍等。
2. 写一个改写提示词,让模型把原文改写成「标准 AI 腔」:
```text
把下面这段文字改写成典型的 AI 助手风格:
- 开头用「首先」或「在当今这个……的时代」起句
- 中间至少用一次「不仅……而且……」和一次「值得注意的是」
- 结尾用「综上所述」或「希望以上内容对你有所帮助」
- 保持原意和事实不变,只改腔调
原文:
{text}
```
3. 人工过一遍,把改写失败(意思变了、事实错了)的样本丢掉。
统一存成 JSONL,一行一条:
```json
{"id": "0001", "topic": "通知", "rejected": "首先,我们需要明确本次团建的时间安排。不仅……而且……综上所述,希望大家积极参与。", "chosen": "团建定在下周五下午,地点在城西那个拓展基地。三点前到公司楼下集合,大巴统一走,别迟到。"}
```
字段含义:chosen 是要学的人味版本,rejected 是要避开的 AI 味版本,topic 用于后续分层抽样和盲测配平。
第 3 步:数据体检
训练前必须跑一遍自动检查,否则脏数据会让训练白做。
```python
import json
from banned import hit_count
rows = [json.loads(l) for l in open("data/raw.jsonl", encoding="utf-8")]
clean, seen = [], set()
for r in rows:
c, j = r["chosen"].strip(), r["rejected"].strip()
if len(c) < 60 or len(j) < 60:
continue # 太短,学不到东西
if hit_count(c) > 0:
continue # 正样本里混进了套话,直接丢
if hit_count(j) < 2:
continue # 负样本套话不够,起不到对照作用
if c in seen:
continue # 去重
seen.add(c)
clean.append(r)
print(f"原始 {len(rows)} 条,清洗后 {len(clean)} 条")
```
三条判定标准值得解释一下:正样本命中 0 次套话,保证「要学的目标」是干净的;负样本至少命中 2 次,保证「要避开的」够典型;长度下限防止模型学到半句话就结束。
然后按 9:1 划分训练集和验证集,另外留出 50 条完全不参与训练,单独存成 data/heldout.jsonl 给盲测用。
```python
import random
random.seed(42)
random.shuffle(clean)
n = int(len(clean) * 0.9)
train, valid = clean[:n], clean[n:]
```
第 4 步:混入通用语料防遗忘
只用风格数据训练,模型容易「什么都往口语化上靠」,写正式文档也开始随意。解决办法是掺入 10%~20% 的通用中文文本(新闻、百科、说明书都行),这些样本用正常的监督格式,不参与风格改造。
```python
通用语料同样转成 {"prompt": ..., "chosen": ...} 结构
混入比例约 15%,与风格样本一起打乱
```
第 5 步:写训练脚本
用 peft 加 trl 的 SFTTrainer,把对话模板对齐到推理时使用的同一套模板——这一点比超参重要得多。
```python
train.py
import os
from datasets import load_dataset
from transformers import AutoTokenizer
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer
MODEL = "Qwen/Qwen3-4B" # 具体权重名以官方文档当前版本为准
OUT = "out/deai-lora"
SYSTEM = "你是一个中文写作者,直接给出成稿,语言自然、具体,不用套话。"
tok = AutoTokenizer.from_pretrained(MODEL, trust_remote_code=True)
if tok.pad_token is None:
tok.pad_token = tok.eos_token
def to_text(rec):
msgs = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"主题:{rec['topic']}\n请写一段文字。"},
{"role": "assistant", "content": rec["chosen"]},
]
return {"text": tok.apply_chat_template(msgs, tokenize=False)}
ds = load_dataset("json", data_files={
"train": "data/train.jsonl",
"valid": "data/valid.jsonl",
})
ds = ds.map(to_text, remove_columns=ds["train"].column_names)
lora = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
task_type="CAUSAL_LM",
)
args = SFTConfig(
output_dir=OUT,
per_device_train_batch_size=1,
gradient_accumulation_steps=8,
learning_rate=1e-4,
num_train_epochs=2,
max_length=1024,
logging_steps=10,
eval_strategy="epoch",
save_strategy="epoch",
bf16=True,
gradient_checkpointing=True,
warmup_ratio=0.05,
lr_scheduler_type="cosine",
report_to="none",
)
trainer = SFTTrainer(
model=MODEL,
args=args,
train_dataset=ds["train"],
eval_dataset=ds["valid"],
peft_config=lora,
processing_class=tok,
)
trainer.train()
trainer.save_model(OUT)
tok.save_pretrained(OUT)
```
如果显存不够,把模型加载改成 4bit 量化(BitsAndBytesConfig 里 load_in_4bit=True),其余不变。如果框架支持只对回答部分计算 loss,务必打开——system 和 user 部分不参与 loss,模型才专注学「怎么写」,而不是学「怎么读题」。具体开关名以官方文档当前版本为准。
超参的直觉:学习率 1e-4 到 2e-4,epoch 1~3。风格迁移是「小改动」,epoch 太多会过拟合到训练集里的具体句式,反而生成一堆新的重复表达。
第 6 步:跑推理,肉眼先看一遍
```python
infer.py
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
tok = AutoTokenizer.from_pretrained("out/deai-lora", trust_remote_code=True)
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-4B", torch_dtype=torch.bfloat16, device_map="auto"
)
model = PeftModel.from_pretrained(base, "out/deai-lora").eval()
PROMPTS = [
"写一段通知,告诉团队下周的周会改到周四上午。",
"写一段两百字的短文,说说为什么很多团队的文档写完没人看。",
]
for p in PROMPTS:
msgs = [{"role": "system", "content": "你是一个中文写作者,直接给出成稿,语言自然、具体,不用套话。"},
{"role": "user", "content": p}]
ids = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt").to(model.device)
out = model.generate(ids, max_new_tokens=400, do_sample=False,
temperature=None, top_p=None)
print(tok.decode(out[0][ids.shape[1]:], skip_special_tokens=True))
print("-" * 40)
```
用贪心解码(do_sample=False)看效果,避免随机性干扰判断。同时用同一个脚本跑一次原始模型,两段输出放在一起对比。
第 7 步:验收一 —— 口癖命中率
自动指标先看这个,最直接。
```python
from banned import hit_count
def rate(model, tok, prompts):
total_hits, total_len = 0, 0
for p in prompts:
text = generate(model, tok, p) # 复用第 6 步的生成逻辑
total_hits += hit_count(text)
total_len += len(text)
return total_hits / total_len * 1000 # 每千字命中次数
print("微调前", rate(base_model, tok, PROMPTS))
print("微调后", rate(lora_model, tok, PROMPTS))
```
正常结果是从每千字 8~15 次降到 2 次以下。如果降幅很小,先回头查第 3 步的清洗有没有漏。
第 8 步:验收二 —— 困惑度(PPL)
困惑度回答的是「模型对什么样的文本更不意外」。要看两组数:
```python
import math, torch
@torch.no_grad()
def ppl(model, tok, texts, max_length=512):
model.eval()
nll_sum, tok_sum = 0.0, 0
for t in texts:
enc = tok(t, return_tensors="pt", truncation=True,
max_length=max_length).to(model.device)
out = model(**enc, labels=enc["input_ids"])
n = enc["input_ids"].shape[1] - 1
nll_sum += out.loss.item() * n
tok_sum += n
return math.exp(nll_sum / tok_sum)
```
- 在人味留出集上:PPL 应该下降。降得越多,说明模型越「习惯」这种写法。降 15%~30% 属于合理区间。
- 在通用中文文本上(新闻、百科、说明书各取几十条):PPL 应该基本持平,允许小幅上升。如果暴涨,说明发生了灾难性遗忘,回到第 4 步加大通用语料比例,或者减少 epoch。
只看第一个数会骗人:模型可能只是把训练集背下来了,所以两个数必须一起看。
第 9 步:验收三 —— 双盲人工评测
自动指标能挡住明显退步,但「读起来像不像人写的」最终得靠人。做法:
1. 从留出集取 30 个主题,分别用微调前、微调后两个模型生成,去掉任何来源标记。
2. 左右顺序随机打乱,记在只有组织者能看的对照表里。
3. 找 3 位同事,每人评 30 组,二选一,允许「分不出」。
4. 记录评分维度,不要只记胜负。
评分表可以直接用这个 Markdown 表:
```markdown
| 组号 | 左边(A/B) | 右边(A/B) | 选左 | 选右 | 分不出 | 理由(可不填) |
|---|---|---|---|---|---|---|
| 01 | □ | □ | □ |
```
统计时算三件事:微调后的胜率、分不出的比例、以及获胜样本里被提到的具体原因(通常集中在「没那么啰嗦」「有具体细节」)。如果分不出比例超过 40%,说明改动幅度不够,或者两个版本差异被采样随机性盖住了,可以把每个主题各生成 2 条再比。
常见坑与排错
训练 loss 一上来就很低(0.5 以下),后面几乎不降。 多半是数据量太小或者重复太多。先去重,再检查是不是把同一段话用了不同 id 塞了好几遍。
微调后套话没少,反而多了新花样。 两个原因最常见:一是正样本里混了套话,第 3 步的 hit_count(c) > 0 过滤必须严格执行;二是过拟合,模型学会了训练集里某几个具体句式并反复复用。把 epoch 从 3 降到 1~2,或把 rank 从 16 降到 8 试试。
输出变得太口语,正式文档也没法用了。 风格过冲。降低学习率、减少 epoch、提高通用语料比例,三选二组合调。也可以在 system 里保留一句场景提示,让模型知道什么时候该收着。
人味留出集 PPL 降了,但通用文本 PPL 涨了一倍。 典型灾难性遗忘。通用语料比例提到 20%,同时把 num_train_epochs 压到 1。
推理结果和训练时差很远,甚至输出格式乱。 训练和推理的 chat template 不一致。检查两边是否用了同一个 apply_chat_template 和同一段 system 文本。用 tokenizer.apply_chat_template(..., tokenize=False) 打印出来对比,一眼就能看出差别。
4bit 量化训练后合并权重,效果掉了一截。 量化训练的 adapter 直接合并到 bf16 权重上会有精度损失。要么推理时保持「基座 + adapter」分离加载,要么用同一精度做合并。
正则误伤。 「总之」出现在引用别人的话里,或者技术文档里确实需要「首先/其次」的步骤说明。评估脚本要允许人工剔除误判样本,别把指标当法律。
盲测结果和自动指标打架。 通常是留出集泄漏——留出样本不小心进了训练集。检查是不是 random.seed 之后先划分再做别的处理。
下一步建议
风格改造做到这一步,可以往下走三个方向:
一是把配对数据用起来做偏好优化。第 2 步构造的 chosen/rejected 天然就是偏好对,直接拿去做 DPO 或同类方法,通常比纯 SFT 更稳,也更不容易过拟合到某几种句式。具体接口以官方文档当前版本为准。
二是把「去 AI 味」从训练问题变成工程问题。system prompt、后处理正则过滤、微调三者叠加,比只靠微调更可控,也更容易在业务侧维护。
三是建立回归测试集。这次用的 50 条留出集不要丢,每次改数据或改超参都跑一遍命中率和 PPL,把数字记在表格里。风格这类主观目标,没有历史基线就没法判断下一次改动是进步还是退步。
最后一句提醒:口癖清单是活的。今天去掉「综上所述」,明天模型可能学会「说到底」「归根结底」。每隔一段时间重跑一次命中率统计,把新冒头的句式加进清单,这套流程就能一直用下去。
