从数据清洗到 Docker 部署:我在本地训练了一个中医领域 Qwen3 模型

从数据清洗到 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. 损失下降不等于医学知识可靠

训练损失只能说明模型越来越会模仿训练答案,不能证明答案医学正确。数据本身如果存在错误,模型也会学习错误。

十五、这次实验的真正收获

这次实验最重要的结果不是得到一个可以直接用于医疗的模型,而是我真正跑通了领域大模型的完整工程链路:

  1. 知道如何审计和构造训练数据。
  2. 理解了 LoRA 只保存参数差值。
  3. 学会用验证集观察训练是否发散。
  4. 学会比较原始模型和微调模型。
  5. 理解了 Adapter、完整权重和 GGUF 的区别。
  6. 学会把 Hugging Face 模型迁移到 Ollama。
  7. 理解 Docker 卷映射、端口和内部服务网络。
  8. 学会通过日志排查上下文和隐藏提示词问题。

十六、下一步计划

当前版本只是教学性质的 pilot。下一轮我计划:

  • 人工抽检并修订训练样本
  • 增加急症、儿童、孕产妇和药物相互作用安全样本
  • 建立完全隔离的专家测试集
  • 加入来源可靠、许可明确的中医古籍 RAG 知识库
  • 把 SFT 数据扩大到 1 万条以上
  • 再研究中医原始文献的继续预训练
  • 对比 LoRA、QLoRA 和不同量化格式的效果

如果从零开始学习本地大模型,我的建议是:先用一个 0.5B~1B 的小模型跑通完整流程,再扩大数据和模型规模。能跑通、能评测、能复现,比第一次就追求大参数更重要。


项目中的主要代码顺序是:

text 复制代码
prepare_data.py
train_lora.py
evaluate.py
merge_lora.py
Modelfile
compose.yaml

按照这个顺序阅读,就能对应"数据、训练、评测、合并、部署、服务"的完整过程。

相关推荐
TK泰妞8 小时前
如何利用跨境女装提示词库提升销售效率?从商品图片到TikTok内容的完整方法
大数据·人工智能
鲜于言悠9058 小时前
opencode
人工智能
猛犸象限8 小时前
【CJMP Grok Bot实践】用 CJMP 搬游戏时碰到的三个缺口,和我们的绕法
人工智能·grok
用户abcde8 小时前
RAG 检索增强生成
人工智能
文谦南宁AI普工8 小时前
RAG 检索全对,回答全错?用位置扫描 10 分钟定位 Lost in the Middle
人工智能·aigc
alonglong8 小时前
183 行 llmctl:把 4 个启动脚本收成一个 CLI,总控该管什么、不该管什么
人工智能
野生码农AI实战8 小时前
一句追问查出停滞 50 天的口径烂账,我把团队规则的锚换到了数字团队花名册
人工智能
宇擎智脑科技8 小时前
Sirchmunk 深度解析(一):一个无需向量数据库的自进化搜索引擎架构设计
人工智能·rag·sirchmunk
小魚8848 小时前
豆包工作的核心能力有哪些
人工智能
用户287043906168 小时前
给一个终端编码代理装上一颗确定性情绪内核:Persisto Mate 的实现记录
人工智能