从数据清洗到 Docker 部署:我在本地训练了一个中医领域 Qwen3 模型
最近我想完整学习一次大模型的训练和部署流程。我的目标不是调用一个在线 API,也不是简单下载模型运行,而是亲自走完下面这条链路:
text
公开数据 → 数据清洗 → LoRA 微调 → 独立评测
→ 权重合并 → GGUF 转换 → Ollama → Docker → Open WebUI
最终,我在自己的 Windows 电脑上得到了一个名为 wo-x-tcm:pilot 的本地中医文献学习模型,并通过 Open WebUI 提供了可视化聊天界面。
这篇文章记录这次实验的真实代码思路、训练参数、部署方法和踩坑过程。它更像一个可以复现的学习项目,而不是一个已经能够用于临床的医疗模型。
重要说明:本文中的模型和数据只用于中医文献及大模型技术学习,不能用于疾病诊断、处方、药物剂量或其他医疗决策。公开训练数据没有经过中医师逐条审核。
一、我最终做出了什么
这次实验使用的底座模型是 Qwen/Qwen3-0.6B。我使用公开中医问答数据对它进行了 LoRA 指令微调,然后把 LoRA 与底座模型合并,转换为 GGUF,最后部署到 Docker Ollama 和 Open WebUI。
主要产物如下:
| 产物 | 大小 | 作用 |
|---|---|---|
| LoRA Adapter | 约 40.4 MB | 保存微调产生的参数差值 |
| 合并后的 Hugging Face 模型 | 约 1.192 GB | 完整的 Qwen3 权重与中医微调结果 |
| BF16 GGUF | 约 1.198 GB | 供 llama.cpp/Ollama 本地推理 |
| Ollama 模型 | wo-x-tcm:pilot |
供命令行、API 和 Open WebUI 调用 |
需要先说明:这不是从零训练的基础大模型。它更准确的名称是:
基于 Qwen3-0.6B 的中医领域 LoRA 指令微调模型。
二、本地环境
本次实验使用的主要环境为:
- 操作系统:Windows,Docker Desktop 提供 Linux 容器环境
- GPU:NVIDIA GeForce RTX 5070
- Python 环境:
D:\AI\conda-envs\llm - PyTorch:2.13.0 + CUDA 13.0
- Transformers:5.14.1
- PEFT:0.20.0
- TRL:1.9.2
- 底座:Qwen3-0.6B
所有大体积模型和数据主要放在 D 盘:
text
D:\AI\datasets 原始数据
D:\AI\models Hugging Face、GGUF 和 Ollama 模型
D:\AI\projects 训练项目
D:\AI\conda-envs Python 环境
三、先把大模型训练理解成一条流水线
项目中最重要的是四个 Python 文件:
text
prepare_data.py 整理教材
train_lora.py 让模型学习教材
evaluate.py 比较训练前后的回答
merge_lora.py 把 LoRA 合并进底座模型
完整的数据流是:
text
15.7 万条原始记录
↓
清洗、过滤、去重
↓
train / validation / test
↓
Qwen3-0.6B + LoRA
↓
LoRA Adapter
↓
合并后的 Hugging Face 模型
↓
BF16 GGUF
↓
Ollama + Docker + Open WebUI
四、数据清洗:先决定模型应该学什么
原始数据来自 Baize TCM V3。数据卡声明的许可证为 Apache-2.0,但在公开或分发任何衍生模型之前,仍应再次核验底座模型、数据集和各个文件的许可范围。
原始记录一共有 157,438 条。我的脚本依次做了长度过滤、中医领域词过滤、行动性医疗问题过滤和问题去重。
实际统计为:
- 原始记录:157,438 条
- 长度不合格:15,809 条
- 明显非中医内容:72,554 条
- 带治疗、服用、处方、剂量等行动性问题:29,074 条
- 重复问题:893 条
- 清洗后候选:39,108 条
首轮实验只从候选数据中取了:
- 训练集:2,000 条
- 验证集:200 条
- 测试集:50 条
1. 为什么要过滤行动性医疗问题
我希望第一轮模型首先学习中医理论和文献表达,而不是学习直接给患者开药。因此代码使用正则表达式排除了包含"患者治疗、服用、用药、处方、剂量、首选方剂、选取穴位"等倾向的问题。
这并不能保证数据医学正确,但可以降低第一轮训练明显偏向直接医疗建议的风险。
2. 统一成聊天格式
通过清洗的每条数据最终被转换成下面的结构:
json
{
"messages": [
{
"role": "system",
"content": "你是一个中医文献学习助手......"
},
{
"role": "user",
"content": "为什么肺被称为娇脏和水之上源?"
},
{
"role": "assistant",
"content": "肺被称为娇脏是因为......"
}
]
}
模型训练时看到系统规则和用户问题,然后学习如何生成 assistant 对应的回答。
3. 固定随机种子
python
rng = random.Random(20260808)
rng.shuffle(rows)
固定随机种子后,多次运行会得到相同的数据划分。这一点很重要,否则每次训练使用的验证集和测试集都不同,结果就很难比较。
五、LoRA:只训练少量附加参数
如果直接全量微调 Qwen3,需要为全部参数保存梯度和优化器状态。LoRA 的思路是基本保持原模型不变,只学习一组低秩参数。
可以把原始权重记为 W,LoRA 学习两个较小的矩阵 A 和 B:
text
新权重效果 = W + (alpha / r) × B × A
本项目使用:
python
peft_config = LoraConfig(
task_type="CAUSAL_LM",
r=16,
lora_alpha=32,
lora_dropout=0.05,
bias="none",
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
)
LoRA 被加在注意力层和前馈网络上。实际可训练参数为 10,092,544,只占总参数的约 1.665%。
这次使用的是普通 BF16 LoRA,不是 QLoRA。因为 0.6B 模型体积较小,在这张显卡上可以直接以 BF16 加载。
六、训练参数为什么这样设置
核心训练配置如下:
python
train_config = SFTConfig(
max_steps=120,
per_device_train_batch_size=1,
gradient_accumulation_steps=8,
learning_rate=1e-4,
lr_scheduler_type="cosine",
max_length=512,
assistant_only_loss=True,
bf16=True,
gradient_checkpointing=True,
eval_steps=40,
save_steps=40,
)
有效批大小
显卡每次只处理一条样本,但连续累计 8 次梯度再更新参数:
text
有效批大小 = 1 × 8 = 8
这样可以降低单次显存压力。
只学习助手回答
assistant_only_loss=True 表示系统提示词和用户问题会作为上下文输入,但只对助手回答部分计算训练损失。模型学习的是"看到这个问题应该怎样回答",而不是复述用户输入。
梯度检查点
gradient_checkpointing=True 会减少中间激活值的显存占用,代价是反向传播时进行一部分重复计算。
本次训练的峰值 CUDA 显存只有约 2.317 GiB。
为什么只训练 0.48 个 epoch
2,000 条训练数据、有效批大小为 8,完整学习一遍大约需要:
text
2000 ÷ 8 = 250 个优化步骤
本次只训练 120 步:
text
120 ÷ 250 = 0.48 epoch
这是一个用于验证完整工程流程的 pilot,而不是最终训练版本。
七、实际训练结果
训练耗时约 410.5 秒,也就是约 6 分 50 秒。
主要指标为:
- 初期训练损失约 2.383
- 第 120 步训练损失约 1.965
- 第 40 步验证损失:2.037
- 第 80 步验证损失:2.014
- 第 120 步验证损失:2.009
验证损失在缓慢下降,没有明显发散。不过下降幅度不大,这也说明少量数据和少量训练步骤带来的能力提升有限。
八、怎么比较微调前后的模型
评测脚本使用同一批问题,分别让原始 Qwen3 和挂载 LoRA 后的模型回答。
生成配置关闭了随机采样和思考模式:
python
output = model.generate(
max_new_tokens=220,
do_sample=False,
repetition_penalty=1.05,
)
这样可以减少随机性对前后比较的干扰。
首轮结果显示,微调后的模型更倾向使用中医领域表达,但冷门植物学名测试仍然出现了明显幻觉。这说明:
少量 SFT 可以改变表达方式和回答倾向,但不能替代可靠知识库、人工校勘和专家评测。
九、LoRA 为什么还要合并
训练得到的 adapter_model.safetensors 只有约 40 MB,它只是相对于 Qwen3 的参数差值,不能脱离底座模型独立运行。
合并代码的核心是:
python
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-0.6B",
dtype=torch.bfloat16,
)
model = PeftModel.from_pretrained(base, adapter_path)
model = model.merge_and_unload()
model.save_pretrained(output_path, safe_serialization=True)
merge_and_unload() 会把 LoRA 学到的变化加回原始权重,生成一个完整的 Hugging Face 模型。
十、从 Hugging Face 转换到 GGUF
合并后的模型仍然采用 Hugging Face 的目录结构,其中包含:
text
model.safetensors
config.json
tokenizer.json
tokenizer_config.json
chat_template.jinja
为了让 Ollama 更方便地运行,我使用 llama.cpp 的转换脚本把它转换为 GGUF:
powershell
$py = 'D:\AI\conda-envs\llm\python.exe'
& $py `
'D:\AI\packages\llama.cpp\convert_hf_to_gguf.py' `
'D:\AI\projects\wo-x-tcm-qwen\outputs\wo-x-tcm-qwen-merged' `
--outfile 'D:\AI\models\gguf\wo-x-tcm-qwen-pilot-bf16.gguf' `
--outtype bf16
GGUF 可以在一个文件中保存模型张量、结构元数据、词表和聊天模板,更适合 llama.cpp/Ollama 这一类本地推理工具。
本次输出使用 BF16,没有进一步进行 Q4 量化。
十一、注册到 Ollama
Ollama 使用 Modelfile 描述模型文件、生成参数和系统提示词:
dockerfile
FROM D:/AI/models/gguf/wo-x-tcm-qwen-pilot-bf16.gguf
PARAMETER temperature 0.3
PARAMETER top_p 0.8
PARAMETER repeat_penalty 1.05
PARAMETER num_ctx 8192
SYSTEM """
你是 wo-x 中医文献学习模型,仅用于学习传统中医文献和大模型训练方法。
回答时必须区分传统中医理论与现代医学证据......
"""
然后注册模型:
powershell
$env:OLLAMA_MODELS = 'D:\AI\models\ollama'
ollama create `
'wo-x-tcm:pilot' `
-f 'D:\AI\projects\wo-x-tcm-qwen\Modelfile'
ollama create 不会再次训练。它只是读取 GGUF,建立 Ollama 模型清单,并把名字 wo-x-tcm:pilot 指向对应权重和运行参数。
十二、Docker 是怎么使用 D 盘模型的
Docker Compose 中最重要的配置是:
yaml
services:
ollama:
image: ollama/ollama:latest
gpus: all
ports:
- "11435:11434"
volumes:
- D:/AI/models/ollama-docker:/root/.ollama
- D:/AI/models/gguf:/models:ro
这里并不是把整个模型手动复制到一个 Linux 虚拟机里,而是把 Windows 的 D 盘目录映射为容器内目录:
text
Windows: D:\AI\models\gguf
Docker: /models
因此 Docker 版 Modelfile 使用:
dockerfile
FROM /models/wo-x-tcm-qwen-pilot-bf16.gguf
gpus: all 允许 Ollama 容器使用 NVIDIA GPU。
端口映射:
text
Windows 原生 Ollama:127.0.0.1:11434
Docker Ollama: 127.0.0.1:11435
十三、Open WebUI 怎么连接模型
Open WebUI 也运行在 Docker 中:
yaml
open-webui:
ports:
- "127.0.0.1:3000:8080"
environment:
OLLAMA_BASE_URL: http://ollama:11434
浏览器访问:
text
http://127.0.0.1:3000
请求链路为:
text
浏览器
↓
Open WebUI
↓ Docker 内部网络
http://ollama:11434
↓
Ollama
↓
RTX 5070 推理
Open WebUI 在容器内部通过服务名 ollama 找到 Ollama 容器,所以这里不能写成 127.0.0.1:11435。
十四、我遇到的几个坑
1. Docker 可以运行 Linux,但路径仍然不同
Windows 路径是:
text
D:\AI\models\gguf\model.gguf
映射到容器后是:
text
/models/model.gguf
同一个文件,在宿主机和容器中的路径不同,因此需要 Windows 和 Docker 两份 Modelfile。
2. 一个很短的问题也可能消耗几千 Token
我曾经只输入"咽喉痛吃什么",Open WebUI 却显示输入达到 5792 Token。
排查后发现,问题本身只有十个汉字,对话也没有历史或文件。真正占用上下文的是 Open WebUI 自动注入的知识库、聊天搜索、笔记、任务、日历等内置工具 JSON 定义。
解决办法是为普通问答模型建立一个 Open WebUI 模型预设,并关闭"内置工具";或者在当前对话的高级参数中,把函数调用切换为"旧版"。
3. 上下文长度有多层含义
Qwen3-0.6B 的模型结构支持 40960 Token,但 Ollama 运行时原来只配置为 4096。后来我把 Modelfile 中的:
dockerfile
PARAMETER num_ctx 4096
改为:
dockerfile
PARAMETER num_ctx 8192
重新注册模型后,超过 4096 Token 的输入才能正常处理。
4. 损失下降不等于医学知识可靠
训练损失只能说明模型越来越会模仿训练答案,不能证明答案医学正确。数据本身如果存在错误,模型也会学习错误。
十五、这次实验的真正收获
这次实验最重要的结果不是得到一个可以直接用于医疗的模型,而是我真正跑通了领域大模型的完整工程链路:
- 知道如何审计和构造训练数据。
- 理解了 LoRA 只保存参数差值。
- 学会用验证集观察训练是否发散。
- 学会比较原始模型和微调模型。
- 理解了 Adapter、完整权重和 GGUF 的区别。
- 学会把 Hugging Face 模型迁移到 Ollama。
- 理解 Docker 卷映射、端口和内部服务网络。
- 学会通过日志排查上下文和隐藏提示词问题。
十六、下一步计划
当前版本只是教学性质的 pilot。下一轮我计划:
- 人工抽检并修订训练样本
- 增加急症、儿童、孕产妇和药物相互作用安全样本
- 建立完全隔离的专家测试集
- 加入来源可靠、许可明确的中医古籍 RAG 知识库
- 把 SFT 数据扩大到 1 万条以上
- 再研究中医原始文献的继续预训练
- 对比 LoRA、QLoRA 和不同量化格式的效果
如果从零开始学习本地大模型,我的建议是:先用一个 0.5B~1B 的小模型跑通完整流程,再扩大数据和模型规模。能跑通、能评测、能复现,比第一次就追求大参数更重要。
项目中的主要代码顺序是:
text
prepare_data.py
train_lora.py
evaluate.py
merge_lora.py
Modelfile
compose.yaml
按照这个顺序阅读,就能对应"数据、训练、评测、合并、部署、服务"的完整过程。