> **一句话总结**:用 989 条微信聊天记录对 Qwen2.5-1.5B 进行 LoRA 微调,让一个 1.5B 参数的小模型学会特定人物的说话风格,并搭建 Web 聊天界面和语音合成。

---

## 文章导览

| 章节 | 内容 |
|------|------|
| 一、项目背景 | 为什么训练模型、开发目标 |
| 二、数据准备 | 微信聊天记录 → 训练样本的转换过程 |
| 三、训练方案选型 | 3 次失败尝试 + 最终方案(含显存计算) |
| 四、训练流程详解 | 超参数、输出文件、学习曲线 |
| 五、推理服务部署 | FastAPI 服务搭建 |
| 六、Web 聊天界面 | Gradio 交互 + 流式输出 + TTS |
| 七、本地 vs 云端对比 | 深度对比分析 |
| 八、环境配置与运行 | 训练和推理的环境准备 |
| 九、资源与源码 | GitHub 地址 |
| 十、经验总结 | 避坑指南 |

---

## 一、项目背景与动机

### 为什么要训练自己的模型?

有了第一个项目([微信智能机器人](./01-微信智能机器人-从零搭建AI自动回复系统.md),基于 DeepSeek API)之后,我在思考一个问题:**如果我不想依赖云端 API、想完全离线运行,该怎么办?**

答案是自己训练一个模型。但大模型动辄 7B、13B 参数,我的 RTX 4060 只有 8GB 显存,能做什么?

经过调研,我选择了 **Qwen2.5-1.5B-Instruct** 作为基座模型,配合 **LoRA** 参数高效微调。1.5B 参数虽然不大,但在 8GB 显存下可以流畅运行,且中文能力在同参数量级中表现优秀。

### 开发目标

1. ✅ 用微信聊天记录作为训练数据
2. ✅ 在 **8GB 显存**限制下完成 SFT 微调
3. ✅ 部署 FastAPI 推理服务
4. ✅ 搭建 Gradio Web 聊天界面
5. ✅ 集成 Edge-TTS 语音合成

---

## 二、数据准备:从微信到训练样本

### 数据采集流程

```
微信聊天记录 → WeClone 导出 CSV → 格式清洗 → Qwen 模板格式化 → LoRA 微调
```

数据来源是 [WeClone](https://github.com/xming521/WeClone) 工具导出的微信聊天记录 CSV。原始数据包含时间戳、发送者、消息内容。

### 训练数据格式

采用 Qwen2.5 的 ChatML 格式进行指令微调:

```
<|im_start|>user
今天天气真好,出去玩吗?<|im_end|>
<|im_start|>assistant
好呀~不过下午有节线代课,得先上完再出去[捂脸]<|im_end|>
```

> **数据集统计**:
> - 总样本数:**989 条**
> - 数据来源:真实微信聊天记录
> - 数据特点:**单侧视角**(只有对方的输入 + 周文慧的输出)
> - 数据局限:缺少多轮对话的长序列样本

### 踩坑:huggingface datasets 的 segfault

**问题**:使用 `datasets.Dataset` 加载数据时,Windows 下发生 segfault 崩溃。

```python
# ❌ 会 segfault 的写法
from datasets import Dataset
dataset = Dataset.from_dict({"instruction": texts, "output": replies})
# → Windows 上 datasets 库的数据预处理多进程会崩溃
```

**解决方案**:手写 PyTorch Dataset,完全绕开 datasets 库:

```python
"""
ZhwDataset — 自定义 PyTorch Dataset
替代 huggingface datasets,避免 Windows 多进程 segfault
"""
from torch.utils.data import Dataset


class ZhwDataset(Dataset):
    """
    手写 Dataset,不依赖 huggingface datasets 库。
    
    设计要点:
    1. 直接使用 tokenizer,避免 datasets 库的多进程问题
    2. 使用 Qwen2.5 的 ChatML 模板格式
    3. labels = input_ids(自回归语言建模)
    """
    
    def __init__(self, data: list, tokenizer, max_length: int = 512):
        self.inputs = []
        
        for item in data:
            # 构造 Qwen2.5 ChatML 格式
            text = (
                f"<|im_start|>user\n{item['instruction']}<|im_end|>\n"
                f"<|im_start|>assistant\n{item['output']}<|im_end|>"
            )
            
            tokenized = tokenizer(
                text,
                truncation=True,
                max_length=max_length,
                padding="max_length",
                return_tensors="pt",
            )
            
            input_ids = tokenized["input_ids"][0]
            self.inputs.append({
                "input_ids": input_ids,
                "attention_mask": tokenized["attention_mask"][0],
                "labels": input_ids.clone(),  # CRUCIAL: labels = input_ids
            })
    
    def __len__(self):
        return len(self.inputs)
    
    def __getitem__(self, idx):
        return self.inputs[idx]
```

---

## 三、训练方案选型:一段充满挫折的探索之路

这是整个项目中最艰难的部分。在 8GB RTX 4060 的限制下,我经历了三次失败的尝试。

### ❌ 方案一:Qwen2.5-3B + LoRA fp16

一开始天真地以为 8GB 够跑 3B 模型。我们来算一笔账:

| 项目 | 显存占用 | 说明 |
|------|----------|------|
| 模型权重 (fp16) | ~6 GB | 3B × 2 bytes |
| AdamW 优化器状态 | ~12 GB | 2× 模型大小 |
| 梯度 | ~6 GB | 同模型大小 |
| 激活值 | ~2 GB | 取决于 batch size |
| **总计** | **~26 GB** | **远超 8GB** |

> **结果:OOM(显存不足)**,连模型加载都失败。

### ❌ 方案二:Qwen2.5-3B + QLoRA 4-bit 量化

4-bit 量化可以把模型压缩到 ~3GB,看起来很有希望:

```
显存估算:
- 模型 (4-bit):~1.5 GB
- LoRA 参数:~50 MB
- 优化器状态 (AdamW):~6 GB (LoRA 参数仅可训练)
- 梯度 + 激活值:~1 GB
- 总计:~8.5 GB → 勉强可跑
```

但在 Windows 上遇到了致命问题:

```
bitsandbytes segfault
```

> ⚠️ **bitsandbytes 在 Windows 上的 4-bit 量化存在已知的 segfault 问题**。如果你在 Linux 上,QLoRA 4-bit 是可行的选择。Windows 用户只能使用 fp16 或 8-bit。

### ❌ 方案三:Qwen2.5-3B + CPU offloading

尝试用 `device_map="auto"` 自动分配,把部分层卸载到 CPU:

```python
# ❌ 会报错的写法
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    device_map="auto",  # 部分层自动卸载到 CPU
    torch_dtype=torch.float16,
)
# → RuntimeError: gradient_checkpointing + device_map 不兼容
```

**结果**:`gradient_checkpointing`(必须的显存节省技术)与 `device_map` 在 Transformers 中存在兼容性问题。

### ✅ 最终方案:Qwen2.5-1.5B + LoRA fp16

回归现实,降级到 1.5B 模型:

| 参数 | 值 | 说明 |
|------|------|------|
| **基座模型** | Qwen/Qwen2.5-1.5B-Instruct | 2.9GB 文件大小 |
| **微调方法** | LoRA | 秩 r=8, alpha=16 |
| **精度** | fp16 | 混合精度训练 |
| **device_map** | `"cuda:0"` | 单 GPU 直接加载(关键!) |
| **学习率** | 2e-4 | 标准 LoRA 学习率 |
| **优化器** | AdamW | β1=0.9, β2=0.999 |
| **梯度累积** | 4 步 | 等效 batch size = 4 |
| **训练轮次** | 3 epochs | |
| **训练步数** | 372 步 | 989 样本 × 3 epochs ÷ 8 batch |
| **显存峰值** | **~3.2 GB** | **RTX 4060 无压力** |
| **训练耗时** | **847 秒** | 约 14 分钟 |

> 🔑 **关键技巧**:使用 `device_map="cuda:0"` 而不是 `"auto"`。这绕过了 Windows 上 `os error 1455`(页面文件不足)的问题。`"auto"` 会尝试内存映射,而在 Windows 上这经常失败。

最终 Loss:**3.296**

---

## 四、训练流程详解

### 4.1 完整训练命令

```bash
# 方式一:使用 WeClone CLI(推荐)
weclone-cli train-sft

# 方式二:使用训练脚本
python WeClone/train_zhouwenhui_v2.py

# 方式三:从命令行直接调用 LLaMA Factory
llamafactory-cli train \
    --model_name_or_path Qwen/Qwen2.5-1.5B-Instruct \
    --dataset_dir ./data \
    --dataset zhouwenhui \
    --finetuning_type lora \
    --lora_target all \
    --lora_rank 8 \
    --lora_alpha 16 \
    --output_dir ./model_output \
    --num_train_epochs 3 \
    --per_device_train_batch_size 8 \
    --gradient_accumulation_steps 4 \
    --learning_rate 2e-4 \
    --fp16
```

### 4.2 训练输出文件

| 文件 | 大小 | 用途 |
|------|------|------|
| `model_output/adapter_config.json` | ~1 KB | LoRA 配置(rank, alpha, target modules) |
| `model_output/adapter_model.safetensors` | **8.7 MB** | **LoRA 权重——这就是训练成果** |
| `model_output/merged/model.safetensors` | 3 GB | 合并后的完整模型(可删除,推理时动态合并) |
| `model_output/checkpoint-124/` | — | 每 124 步的检查点(用于恢复训练) |
| `model_output/checkpoint-496/` | — | 最后检查点 |

> 💡 LoRA 总参数量:**2.18M**,仅占模型总参数的 **0.14%**。这意味着 99.86% 的模型能力来自预训练。

### 4.3 学习曲线

```
Loss 曲线(372 steps,约 14 分钟):

Loss
4.0 |  ◆
3.8 |    ◆
3.6 |      ◆
3.4 |        ◆────◆
3.2 |                ◆───◆───◆ (最终 3.296)
    └──────────────────────────
    0    124    248    372  Steps
    ├ epoch 1 ┤ epoch 2 ┤epoch 3│
```

3.296 的 Loss 意味着:
- ✅ 模型已学会基本的对话模式(问答结构、语言连贯性)
- ⚠️ 对特定人物风格的模仿仍不够精准(需要更多数据)
- 📈 如果数据量增加到 5000+ 条,预期 Loss 可降至 2.5 以下

---

## 五、推理服务部署

训练完成后,需要部署推理服务以供 wechat-bot 调用。

```python
"""
推理服务 — FastAPI
运行: uvicorn inference_server:app --host 0.0.0.0 --port 8000
"""

from fastapi import FastAPI
from pydantic import BaseModel
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

app = FastAPI(title="周文慧 AI 推理服务")

class ChatRequest(BaseModel):
    message: str
    history: list = []

class ChatResponse(BaseModel):
    reply: str

# 全局加载模型(服务启动时加载一次)
base_model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-1.5B-Instruct",
    device_map="cuda:0",
    torch_dtype=torch.float16,
)
model = PeftModel.from_pretrained(base_model, "./model_output")
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-1.5B-Instruct")

@app.post("/chat", response_model=ChatResponse)
async def chat(req: ChatRequest):
    # 拼接提示词
    messages = [{"role": "user", "content": req.message}]
    text = tokenizer.apply_chat_template(messages, tokenize=False)
    
    # 模型推理
    inputs = tokenizer(text, return_tensors="pt").to("cuda")
    outputs = model.generate(
        **inputs,
        max_new_tokens=256,
        do_sample=True,
        temperature=0.7,
    )
    reply = tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
    
    return ChatResponse(reply=reply)
```

---

## 六、Web 聊天界面

为了让模型更易用,基于 **Gradio** 搭建了 Web 聊天界面。

### 界面架构

```
app.py

├── load_model()
│   └── 加载 LoRA 权重 + 基座模型(约 10 秒)

├── predict(message, history)
│   ├── 拼接 Qwen ChatML 格式
│   ├── model.generate(stream=True)  ← 流式生成
│   └── yield partial_text           ← 逐 token 输出

├── gr.ChatInterface(predict)
│   └── Gradio 自动生成聊天 UI

└── 回复完成后 → Edge-TTS 合成语音
    └── 浏览器自动播放
```

### 核心代码:流式输出

```python
"""
Gradio 聊天界面 — 流式输出实现
依赖: gradio, transformers, edge-tts
"""

import gradio as gr
from transformers import TextIteratorStreamer
from threading import Thread


def predict(message: str, history: list):
    """流式生成回复,逐 token 输出到 Gradio 界面"""
    
    # 构建消息历史
    messages = []
    for user_msg, bot_msg in history:
        messages.append({"role": "user", "content": user_msg})
        messages.append({"role": "assistant", "content": bot_msg})
    messages.append({"role": "user", "content": message})
    
    # Tokenize
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    inputs = tokenizer(text, return_tensors="pt").to("cuda")
    
    # 流式生成器
    streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
    generation_kwargs = dict(
        **inputs,
        streamer=streamer,
        max_new_tokens=256,
        do_sample=True,
        temperature=0.7,
    )
    
    # 在后台线程中生成
    thread = Thread(target=model.generate, kwargs=generation_kwargs)
    thread.start()
    
    # 逐 token 输出到 Gradio
    for token_text in streamer:
        yield token_text  # Gradio 会自动累积显示


# 启动界面
gr.ChatInterface(
    fn=predict,
    title="🌸 周文慧 AI 聊天",
    description="一个会说话的 AI 女孩~",
).launch()
```

> 💡 **技术亮点**:使用 `TextIteratorStreamer` + 后台线程实现流式输出,用户不需要等待完整回复,而是实时看到文字逐字出现,大大提升了交互体验。

---

## 七、本地模型 vs 云端 API 的对比

在实际对比测试中,一个残酷的事实浮出水面:

| 维度 | 本地 LoRA 模型 | DeepSeek API + StyleLearner |
|------|---------------|---------------------------|
| **回复自然度** | ❌ 碎片化、答非所问 | ✅ 非常自然、上下文连贯 |
| **风格模仿** | ⚠️ 一般,有时不像 | ✅ 极好,难以分辨 |
| **GPU 需求** | 需要 3GB 显存 | 完全不需要 |
| **网络需求** | ✅ 完全离线 | ❌ 必须联网 |
| **每次推理成本** | 免费(电费忽略) | ~0.001 元/次 |
| **响应速度** | 慢(3-5 秒) | 快(1-2 秒) |
| **模型版本** | Qwen2.5-1.5B | DeepSeek V3(671B MoE) |
| **参数规模** | 1.5B | ~37B 激活参数 |

> 🔑 **核心结论**:在数据量不足(<1000 条)的场景下,**API + 提示词工程完胜小模型微调**。这不是 LoRA 的问题,而是基座模型能力差距太大——1.5B 和 671B MoE 的差距是数量级的。

### 什么时候应该用微调?

尽管这次微调效果不如 API,但在以下场景中微调仍然不可替代:

| 场景 | 说明 |
|------|------|
| **离线部署** | 内网环境、无网络条件 |
| **数据隐私** | 敏感数据不能发送到外部 API |
| **大批量调用** | API 费用过高时(每天 10 万+ 次) |
| **超低延迟** | 需要毫秒级响应 |
| **特定领域** | 法律、医疗等需要精确领域知识的场景 |

---

## 八、环境配置与运行

### 训练环境

```yaml
硬件:
  GPU: NVIDIA RTX 4060 (8GB VRAM)
  RAM: 32 GB
  CPU: Intel i7-13700H
  Disk: 512GB NVMe SSD (剩余 ≥ 20GB)

软件:
  OS: Windows 11
  Python: 3.10.11
  CUDA: 12.1+
  PyTorch: 2.1+
  Transformers: 4.45+
  PEFT: 最新版
  LLaMA Factory: 可选
```

### 安装依赖

```bash
# 基础依赖
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
pip install transformers accelerate peft

# 训练框架
pip install llamafactory  # 或直接使用 WeClone

# Web 界面
pip install gradio

# 语音合成
pip install edge-tts
```

### 启动推理服务

```bash
# 启动 FastAPI 推理服务
python inference_server.py
# → http://127.0.0.1:8000

# 启动 Gradio Web 界面
python app.py
# → http://127.0.0.1:7860
```

---

## 九、项目地址与资源

### GitHub 仓库

- **[AI Chat Bot](https://github.com/starlight001219/ai)** — Web 聊天界面 + TTS 语音合成

### 相关项目

| 项目 | 地址 |
|------|------|
| **微信智能机器人**(对接该模型) | [github.com/starlight001219/wechat-clone-bot](https://github.com/starlight001219/wechat-clone-bot) |
| **WeClone**(数据采集框架) | [github.com/xming521/WeClone](https://github.com/xming521/WeClone) |

> ⚠️ **重要提示**:模型文件因体积过大(基座模型 2.9GB + LoRA 权重 8.7MB)未包含在 Git 仓库中。使用前需要:
> 1. 从 HuggingFace 下载 Qwen2.5-1.5B-Instruct
> 2. 将 LoRA 权重放在 `model_output/` 目录

---

## 十、经验总结与避坑指南

### 训练类

| # | 问题 | 解决方案 |
|---|------|----------|
| 1 | **3B 模型 OOM** | 降级到 1.5B,VRAM 从 ~6GB 降至 ~3GB |
| 2 | **bitsandbytes Windows segfault** | 放弃 4-bit 量化,使用 fp16 |
| 3 | **Windows 页面文件不足 (1455)** | 使用 `device_map="cuda:0"` 直接 GPU 加载 |
| 4 | **datasets 库 segfault** | 手写 PyTorch Dataset,不用 huggingface datasets |
| 5 | **gradient_checkpointing + device_map 不兼容** | 单 GPU 直接加载,不用 `"auto"` |

### 方案选型类

```
先试 API → 效果不够再考虑微调

API 能满足需求 → 用 API(更省钱、效果更好)
API 不能满足需求(离线/隐私/成本)→ 微调

微调仍然效果不好 → 检查数据量和数据质量
```

### 部署类

- ❌ **不要**把完整模型文件(3GB+)提交到 GitHub
- ✅ **只提交** LoRA 权重(8.7MB)和训练脚本
- ⚠️ 首次加载模型需要 ~10 秒(6GB 文件从磁盘读入内存)
- ✅ 使用 FastAPI 可以方便地对接微信机器人

### 后续优化方向

- [ ] 采集更多数据(目标 5000+ 条)
- [ ] 构建多轮对话样本(目前只有单轮)
- [ ] 尝试 Qwen2.5-7B 云 GPU 训练
- [ ] 加入 RLHF / DPO 进一步对齐
- [ ] 使用 vLLM 加速推理

---

> **后记**:虽然最终在实际使用中选择了 DeepSeek API 方案,但这次微调训练的经历让我对模型训练的全流程有了深刻理解——从数据准备、显存计算、训练参数调优到推理部署。这个过程中踩的每一个坑,都是最宝贵的学习资源。如果你也在 8GB 显存的 GPU 上做微调,希望这篇踩坑实录能帮你节省 3 小时的调试时间。
 

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐