跳到主内容
快讯直播
AI智模界
教程

复现「去 AI 味」微调:把写作口癖从 Qwen3-4B 里训掉

模型写出来的中文里,总有一些固定的腔调:开头先「首先」,中间「不仅……而且」,收尾「综上所述」「希望对你有所帮助」。这些词本身没问题,问题在于它们出现的频率和位置太整齐,读的人一眼就能感觉到「这是模型写的」。这篇教程做的事情很具体:用一组人为构造的对照语料,对 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,把数字记在表格里。风格这类主观目标,没有历史基线就没法判断下一次改动是进步还是退步。

最后一句提醒:口癖清单是活的。今天去掉「综上所述」,明天模型可能学会「说到底」「归根结底」。每隔一段时间重跑一次命中率统计,把新冒头的句式加进清单,这套流程就能一直用下去。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。