前言

本文简单测试了一下langgraph官方提供的记忆管理工具,发现还是存在bug,我在a线程先让他记住我是张熙浚,然后又告诉他我不是张熙浚我是张俊细,在线程b询问他我是谁时,他还是认为我是张熙浚。记忆的管理部分确实是一个很大的问题,但中小开发者我认为还是直接使用人家造好的轮子方便些(我尝试去阅读了他的记忆管理工具的源码,以我目前的水平,想手搓花费的精力还是太多了)

我还有一个疑惑,我的理解是,当前记忆的存储基本上依赖于agent的决定,所以并不稳定,我也搞不清楚他什么时候会把哪些信息存入记忆,可以设置 schemas结构,控制存储的内容,但是长期记忆仅存储指定的这些信息,感觉还是有些鸡肋啊

代码见learn-rag-langchain/langmem at main · zxj-2023/learn-rag-langchain

介绍

LangMem 是 LangChain 推出的开源 SDK,通过一套存储-提取-优化机制,让 Agent 能够在多轮、多天甚至多用户之间持续学习、记住用户偏好并不断改进回答。

LangMem 的记忆工具按两个层次的集成模式组织:

  1. 核心 API

LangMem 的核心是提供无副作用地转换记忆状态的函数。这些原语是记忆操作的构建块:

  • 记忆管理器:根据新的对话信息,提取新记忆、更新或删除过时记忆,并从现有记忆中进行整合和泛化。
  • 提示优化器:根据对话信息(可选反馈)更新提示规则和核心行为。

这些核心函数不依赖于任何特定的数据库或存储系统。您可以在任何应用程序中使用它们。

  1. 有状态集成

上一层依赖于 LangGraph 的长期记忆存储。这些组件使用上述核心 API 来转换存储中存在的记忆,并在新对话信息传入时根据需要进行更新/插入或删除:

image-20250814152044798

langmem可以通过两种方式创建记忆

  1. 在热路径中: Agent 使用工具主动保存笔记。
  2. 在后台:记忆从对话中自动“潜意识地”提取。

热路径快速入门指南

在本指南中,我们将创建一个 LangGraph Agent,它通过 LangMem 的 manage_memory 工具来主动管理自己的长期记忆。

create_manage_memory_tool

create_manage_memory_tool通过创建一个工具(Tool),这个工具可以被 agent用来管理持久化记忆。这些记忆可以在不同的对话、会话甚至应用重启后依然存在。

  1. 持久化存储 (Persistent Storage): 它利用了 LangGraph 提供的 BaseStore 接口。这使得数据可以存储在内存、数据库(如 Postgres)等地方,而不是仅仅存在于程序的运行时内存中。

  2. 命名空间 (Namespace): 为了组织和隔离不同用户或不同类型的记忆,数据被存储在层级化的命名空间中。例如,("memories", "user-123") 可以确保用户 “user-123” 的记忆与其他用户或系统记忆分开。命名空间可以包含占位符(如 {langgraph_user_id}),在实际执行时会被具体的配置值替换。

  3. 记忆 (Memory): 在这个上下文中,一个“记忆”就是存储在 BaseStore 中的一个数据项(Item)。它有一个唯一的 key(通常是 UUID),一个 namespace,一个 value(存储实际内容),以及创建和更新时间戳。

  4. 工具 (Tool): 在 AI 应用中,工具是代理(Agent)可以调用的函数或能力。这个函数创建的工具就是一个封装好的、可以被 Agent 调用的函数,用于执行创建、更新、删除记忆的操作。

什么时候agent会调用记忆工具

image-20250814163150589

ai是这样回答的,ReAct架构的agent是否调用工具由他自己决定

实战

导入库

1
2
3
4
5
6
7
8
from langgraph.checkpoint.memory import MemorySaver
from langgraph.prebuilt import create_react_agent
from langgraph.store.memory import InMemoryStore
from langgraph.utils.config import get_store
from langmem import (
# 让智能体创建、更新和删除记忆
create_manage_memory_tool,
)

返回记忆提示词

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def prompt(state):
"""为LLM准备消息。"""
# 从配置的上下文变量中获取存储;
store = get_store() # 与提供给 `create_react_agent` 的相同
memories = store.search(
# 在与我们为智能体配置的相同命名空间内搜索
("memories",),
query=state["messages"][-1].content,
)
system_msg = f"""You are a helpful assistant.

## Memories
<memories>
{memories}
</memories>
"""
return [{"role": "system", "content": system_msg}, *state["messages"]]

定义store与checkpoint

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from langchain import embeddings
from langchain_openai import OpenAIEmbeddings
embedding=OpenAIEmbeddings(
api_key="sk-",
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
model="text-embedding-v4",
check_embedding_ctx_length = False,
dimensions=1536
)
store = InMemoryStore(
index={ # 存储提取的记忆
"dims": 1536,
"embed": embedding,
}
)
checkpointer = MemorySaver() # 检查点图状态

定义agent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from langchain_openai import ChatOpenAI
model_qwen=ChatOpenAI(
api_key="sk-",
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
model="qwen3-30b-a3b-instruct-2507",
)

agent = create_react_agent(
model=model_qwen,
prompt=prompt,
tools=[ # 添加记忆工具
# 智能体可以调用 "manage_memory" 来
# 通过ID创建、更新和删除记忆
# 命名空间为记忆添加作用域。要
# 为每个用户限定记忆范围,使用 ("memories", "{user_id}"):
create_manage_memory_tool(namespace=("memories",)),
],
# 我们的记忆将存储在这个提供的BaseStore实例中
store=store,
# 图的"状态"将在每个节点完成执行后进行检查点
# 用于跟踪聊天历史和持久执行
checkpointer=checkpointer,
)

可视化图

1
agent.get_graph().draw_mermaid_png(output_file_path="agent.png")

在线程a让agent记住我们的偏好

1
2
3
4
5
6
7
8
9
10
11
12
config = {"configurable": {"thread_id": "thread-a"}} 
agent.invoke(
{
"messages": [
{"role": "user", "content": "我喜欢黑色的显示模式"}
]
},
# 我们将通过使用具有相同thread_id的config
# 来继续对话(thread-a)
config=config,
)
print(response["messages"][-1].content)
1
是的,我知道!你偏好黑色显示模式。我会在后续交互中保持这一设置。

在线程b查看是否记住

1
2
3
4
5
6
7
8
9
10
# 新线程 = 新对话!
new_config = {"configurable": {"thread_id": "thread-b"}}
# 智能体只能回忆起
# 它使用manage_memories工具明确保存的内容
response = agent.invoke(
{"messages": [{"role": "user", "content": "你好。你还记得我吗?你知道我有什么偏好吗?"}]},
config=new_config,
)
print(response["messages"][-1].content)

1
你好!虽然我无法记住你作为个体的详细信息,但我可以访问一些关于你的偏好信息。根据之前的记录,我知道你偏好使用黑色显示模式。如果你还有其他偏好或希望我记住什么,请告诉我,我会帮你记录下来。

后台快速入门指南

本指南将向您展示如何使用 create_memory_store_manager 在后台提取和整合记忆。当记忆在后台处理时,智能体将正常继续运行。

  1. Runnable: LangChain/LangGraph 中的核心抽象,代表一个可以被调用(invoke/ainvoke)来处理输入并产生输出的单元。MemoryStoreManager 本身就是一个 Runnable。

  2. BaseStore: LangGraph 提供的持久化存储接口。Manager 会使用它来读取(搜索)和写入(创建、更新、删除)记忆。

  3. Memory (记忆): 在 Manager 的上下文中,记忆通常是指从对话中提取的、值得保存的片段信息(如用户偏好、事实等)。它们存储在 BaseStore 中,有自己的 namespacekey

  4. Schema (模式): 一个 Pydantic 模型,用于定义记忆的结构。这允许你强制记忆遵循特定的格式(例如,包含 category, preference, context 字段)。如果未提供 schemas,则默认使用非结构化的字符串。

  5. Namespace (命名空间): 用于组织存储在 BaseStore 中的记忆。支持使用占位符(如 {langgraph_user_id})进行动态配置。

  6. 自动化流程:

    Manager 会自动执行以下步骤:

    • 搜索 (Search): 根据新对话内容,在 BaseStore 中查找相关的现有记忆。
    • 分析/提取 (Analyze/Extract): 使用 LLM 分析新对话和检索到的记忆,决定是否需要创建新记忆、更新现有记忆或删除过时记忆。
    • 应用更改 (Apply Changes): 将分析结果(记忆的增删改)写回到 BaseStore

实战

导入库

1
2
3
4
5
from langchain.chat_models import init_chat_model 
from langgraph.func import entrypoint
from langgraph.store.memory import InMemoryStore

from langmem import ReflectionExecutor, create_memory_store_manager

定义store

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from langchain_openai import OpenAIEmbeddings
embedding=OpenAIEmbeddings(
api_key="sk-",
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
model="text-embedding-v4",
check_embedding_ctx_length = False,
dimensions=1536
)
store = InMemoryStore(
index={ # 存储提取的记忆
"dims": 1536,
"embed": embedding,
}
)

创建记忆管理器

1
2
3
4
5
6
7
8
9
10
# 创建记忆管理器 Runnable 来从对话中提取记忆
memory_manager = create_memory_store_manager(
model_qwen,
# 将记忆存储在 "memories" 命名空间(即目录)中
namespace=("memories",),
instructions="用中文存储记忆。"
)

# 包装 memory_manager 以处理延迟的后台处理
executor = ReflectionExecutor(memory_manager)

对每条消息都进行记忆处理存在以下缺点: - 当消息快速连续到达时,会产生冗余工作 - 在对话中途进行处理时,上下文不完整 - 不必要的 token 消耗

ReflectionExecutor 可以延迟记忆处理并取消冗余工作。

创建工作流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from langchain_openai import ChatOpenAI
model_qwen=ChatOpenAI(
api_key="sk-",
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
model="qwen3-30b-a3b-instruct-2507",
)

@entrypoint(store=store) # 创建一个 LangGraph 工作流
async def chat(message: str):
response = model_qwen.invoke(message)

# memory_manager 从对话历史中提取记忆
# 我们将以 OpenAI 的消息格式提供它
to_process = {"messages": [{"role": "user", "content": message}] + [response]}
await memory_manager.ainvoke(to_process)
return response.content

# 正常运行对话
response = await chat.ainvoke(
"记住我是张熙浚",
)
print(response)

查看记忆

1
print(store.search(("memories",)))

参考资料

简介 - LangChain 框架

核心概念 - LangChain 框架

训练框架-LLaMA-Factor

安装 - LLaMA Factory

docker部署镜像,以便后续传入内网

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
git clone --depth 1 https://github.com/hiyouga/LLaMA-Factory.git
cd LLaMA-Factory

docker build -f ./docker/docker-cuda/Dockerfile \
--build-arg PIP_INDEX=https://pypi.org/simple \
--build-arg EXTRAS=metrics \
-t llamafactory:latest .

docker run -dit --ipc=host --gpus=all \
-p 7860:7860 \
-p 8001:8000 \ # 主机 8001 → 容器 8000,主机8000端口被占用了
--name llamafactory \
-v /aisys/:/aisys/ \
docker.1ms.run/hiyouga/llamafactory

docker run -dit --ipc=host --gpus=all -p 7860:7860 -p 8001:8000 -v /aisys/:/aisys/ --name llamafactory docker.1ms.run/hiyouga/llamafactory

docker exec -it llamafactory bash
1
2
3
4
5
docker pull docker.1ms.run/hiyouga/llamafactory                                    

docker save docker.1ms.run/hiyouga/llamafactory:latest -o llamafactory-image.tar

docker load -i llamafactory-image.tar

LLaMA Board 可视化微调(由 Gradio 驱动)

1
llamafactory-cli webui
  • Web UI 访问:http://localhost:7860
  • API 服务访问:http://localhost:8001

数据集-easy-dataset

docker部署镜像,以便后续传入内网

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
git clone https://github.com/ConardLi/easy-dataset.git
cd easy-dataset

docker build -t easy-dataset .

docker load -i easy-dataset.tar

docker run -d \
-p 1717:1717 \
-v /aisys/repo_dev/xizhang/lora_database:/app/local-db \
-v /aisys/repo_dev/xizhang/lora_databse_prisma:/app/prisma \
--name easy-dataset \
easy-dataset


docker exec -it easy-dataset sh

docker stop easy-dataset
docker rm easy-dataset

#实时跟踪
docker logs -f easy-dataset

注意: 请将 {YOUR_LOCAL_DB_PATH}{LOCAL_PRISMA_PATH} 替换为你希望存储本地数据库的实际路径,建议直接使用当前代码仓库目录下的 local-dbprisma 文件夹,这样可以和 NPM 启动时的数据库路径保持一致。

注意: 如果需要挂载数据库文件(PRISMA),需要提前执行 npm run db:push 初始化数据库文件。

使用开源项目制作数据集

打开浏览器,访问 http://localhost:1717

上传内网

使用scp

1
scp -r "F:\project python\实习\微调\universal-llm_latest.tar" root@10.117.128.50:/aisys/repo_dev/xizhang/images

SCP 全称是 Secure Copy Protocol(安全复制协议),是一种用于在计算机之间安全地复制文件的网络协议。

它基于 SSH(Secure Shell)协议工作,因此所有传输的数据都是加密的,可以防止被窃听或篡改,非常适合在不安全的网络(如互联网)中使用。

模型部署与调用

制作模型运行镜像

qwen3部署版本要求如下

使用 Python 3.10 或以上版本, PyTorch 2.6 或以上版本

transformers>=4.51.0 版本

使用 sglang>=0.4.6.post1vllm>=0.8.5 来创建一个与 OpenAI 兼容的 API 端点

镜像信息

类别 组件 版本 / 来源 说明
OS Ubuntu 22.04 LTS (Jammy) 上游镜像继承
Python CPython 3.11 镜像自带
PyTorch PyTorch 2.6.0+cu126 官方 wheel,CUDA 12.6
CUDA Runtime 12.6.3 与宿主机 535 驱动兼容
cuDNN cuDNN 9 包含在镜像
核心库 transformers ≥4.51.0 官方最新
tokenizers ≥0.21 transformers 依赖
accelerate ≥1.0.0 训练 / 推理加速
sentencepiece ≥0.2.0 Qwen3 分词器必需
protobuf ≥5.28.0 序列化 / 模型加载
tiktoken ≥0.8.0 OpenAI 格式分词
推理框架 vLLM ≥0.8.5 支持 tensor-parallel、PagedAttention
SGLang ≥0.4.6.post1 支持 outline 解码、MoE 优化
可选加速 flash-attn ≥2.7 长上下文 / 大 batch 推理
权重下载 modelscope 最新 国内镜像加速
工具链 git / git-lfs 最新 拉取 HuggingFace 权重
curl / jq / vim 最新 调试 & 健康检查

基础镜像pytorch/pytorch:2.6.0-cuda12.6-cudnn9-develPyTorch 官方在 Docker Hub 上提供的“全家桶”开发镜像,发布日期 2025-01-29,镜像大小约 13 GB,定位是 “开箱即用”的 GPU 训练 / 推理 / 调试环境

dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# ---------- 1. 基础镜像 ----------
FROM pytorch/pytorch:2.6.0-cuda12.6-cudnn9-devel

# ---------- 2. 国内镜像源 ----------
RUN sed -i 's|http://archive.ubuntu.com|https://mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list && \
sed -i 's|http://security.ubuntu.com|https://mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list && \
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
pip config set global.trusted-host pypi.tuna.tsinghua.edu.cn

# ---------- 3. 系统依赖 ----------
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y \
git git-lfs build-essential ninja-build curl wget vim jq && \
rm -rf /var/lib/apt/lists/*

# ---------- 4. Python 依赖 ----------
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
pip install --no-cache-dir \
"torch==2.6.0+cu126" \
"transformers>=4.51.0" \
"tokenizers>=0.21" \
"accelerate>=1.0.0" \
"sentencepiece>=0.2.0" \
"protobuf>=5.28.0" \
"tiktoken>=0.8.0" \
"vllm>=0.8.5" \
"sglang[all]>=0.4.6.post1" \
"modelscope" \
"fastapi" "uvicorn[standard]" "pydantic"

# ---------- 5. 可选性能加速 ----------
RUN pip install --no-cache-dir "flash-attn>=2.7" --no-build-isolation || true

# ---------- 6. 国内 HuggingFace 镜像 ----------
ENV HF_ENDPOINT=https://hf-mirror.com

# ---------- 7. 工作目录 ----------
WORKDIR /app
EXPOSE 4000 4001 4002

# ---------- 8. 默认命令 ----------
CMD ["/bin/bash"]

运行容器

1
2
3
4
5
6
7
8
9
10
docker run -it \
--name llm-service \
--gpus all \
-p 4000:4000 \
-p 4001:4001 \
-p 4002:4002 \
-v /aisys/repo_dev/xizhang/models:/app/models \
-v /aisys/repo_dev/xizhang/models/cache:/app/models/.cache \
--shm-size=8g \
universal-llm:latest bash

vllm部署qwen3

1
2
3
4
5
6
7
8
vllm serve /app/models/qwen3-32b-lora-new \
--port 4001 \
--tensor-parallel-size 4 \
--max-model-len 1024 \
--reasoning-parser qwen3 \
--gpu-memory-utilization 0.8 \
--max-num-seqs 8 \
--host 0.0.0.0
参数 含义 推荐/注意
--port 8000 服务监听端口 -p 8000:8000 保持一致;如需多实例,可改 8001/8002 …
--tensor-parallel-size 4 把模型权重切成 4 份,跨 4 张 GPU 并行计算 必须 ≤ 实际 GPU 数量;Qwen3-32B 在 4×L20 上显存刚好够,不可再大
--max-model-len 1024 单次推理最大 token 数(含 prompt + 生成) 若场景需要 4k/8k/32k,可调到 4096/8192;显存占用 ∝ 长度²
--reasoning-parser qwen3 vLLM ≥0.8.5 新增开关,解析 Qwen3 的 <think>…</think> 标签,把推理过程单独返回 仅在 Qwen3 系列模型有效,其他模型请去掉
--gpu-memory-utilization 0.8 显存使用上限 80 %;剩余 20 % 留给 CUDA kernel、KV cache 膨胀 若出现 OOM,可降到 0.7;若想多并发,可尝试 0.85(风险 OOM)
--max-num-seqs 8 同一时刻最多并发处理的 请求条数 --max-model-len 和显存同时决定;若长度 ↑,此值需 ↓
--host 0.0.0.0 监听所有网卡,使容器外可访问 生产环境可改为内网 IP 或 127.0.0.1 提高安全性

测试

1
2
3
4
5
6
7
8
9
10
curl http://localhost:4001/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "/app/models/qwen3-32b-lora-new",
"messages": [
{"role": "user", "content": "请用中文介绍一下你自己"}
],
"temperature": 0.7,
"max_tokens": 512
}'

调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import os
from openai import OpenAI

# 指向本地 vLLM
client = OpenAI(
base_url="http://localhost:8000/v1",
api_key="dummy" # vLLM 不做鉴权,随便填
)

resp = client.chat.completions.create(
model="qwen3-32b", # 必须和 vLLM 启动路径或 --served-model-name 保持一致
messages=[
{"role": "user", "content": "9.9 和 9.11 哪个大?"}
],
max_tokens=512,
temperature=0.7,
stream=False # True 可开流式
)

print(resp.choices[0].message.content)

快速入门 - Qwen — Quickstart - Qwen

通义千问3-32B · 模型库

微调数据集

alpaca和sharegpt的区别

▶ Alpaca 典型字段

1
2
3
4
5
6
7
{
"instruction": "把下面句子翻译成英文",
"input": "今天天气真好",
"output": "The weather is nice today.",
"system": "你是一个翻译助手", // 可选
"history": [] // 可选,放前几轮
}
  • 一条数据 = 一次独立任务
  • 字段固定:instruction / input / output 三板斧

▶ ShareGPT 典型字段

1
2
3
4
5
6
7
8
9
10
{
"conversations": [
{"from": "human", "value": "我今天心情不好"},
{"from": "gpt", "value": "怎么啦?想聊聊吗"},
{"from": "human", "value": "论文又被拒了"},
{"from": "gpt", "value": "理解你的挫败感…"}
],
"system": "你是贴心聊天机器人", // 可选
"tools": [...] // 可选,放函数描述
}
  • 一条数据 = 一段完整的多轮对话
  • 角色交替:human / gpt / function / observation
维度 Alpaca ShareGPT
来源 斯坦福 Alpaca 项目,为了低成本做指令微调 ShareGPT 网站爬取的真实 ChatGPT 对话
目标 让模型学会“看到指令+输入→给出答案” 让模型学会“像 ChatGPT 一样多轮对话”

详解

1
2
3
4
5
6
{
"instruction": "电阻率测定法的环境温湿度控制对检测结果的可信度有何影响?",
"input": "- \"检测依据:DL/T 421-2009 电力用油体积电阻率测定法\"\n- \"检测地点及环境条件:油品检测室 温度:16℃ 湿度:57%RH\"\n- \"电阻率(\"",
"output": "<think>### \n首先,理解问题的核心:电阻率测定法中,环境温湿度控制如何影响检测结果的可信度,这涉及到测量过程的准确性和可靠性。\n\n然后,分析关键信息:检测依据是DL/T 421-2009标准,它规定了电阻率测定的方法;环境条件被记录为温度16℃和湿度57%RH;检测结果显示电阻率为1.04×10^10 Ω·cm,符合DL/T 571-2014标准的要求(≥6×10^9 Ω·cm)。\n\n接着,推理温湿度控制的影响:环境温湿度是测量过程中的关键变量,控制这些条件确保",
"system": "作为电力能源报告解读专家,我在生成答案时,将严格遵循以下格式:\n根据“信息来源”,“信息来源”是原文中可直接支撑结论的句子、数据或图表编号给出“结论与推理”——用上述逐条复现的信息为唯一依据,推导出最终答案。"
}

instruction为问题;input为上下文;output包含思维链与答案;system为系统提示词

微调参数设置

DeepSpeed stage(DeepSpeed 阶段)

deepSpeed 的 ZeRO 分布式优化阶段,用于在多 GPU 上高效训练大模型。

Stage 功能 说明
Stage 0 不做任何优化 基础分布式训练(DDP),显存占用高
Stage 1 梯度分片(Gradient Sharding) 将梯度切分到不同 GPU,减少显存
Stage 2 参数 + 梯度分片 进一步降低显存,但需通信同步
Stage 3 参数 + 梯度 + 优化器状态分片 最强显存优化,支持超大模型

使用 DeepSpeed offload(使用 offload)

部分或全部模型参数、优化器状态卸载到 CPU 内存,进一步释放 GPU 显存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
llamafactory-cli train \
--stage sft \
--do_train True \
--model_name_or_path /aisys/repo_dev/xizhang/models/qwen3-32b-lora-new \
--preprocessing_num_workers 16 \
--finetuning_type lora \
--template qwen3 \
--flash_attn auto \
--dataset_dir /aisys/repo_dev/xizhang/lora_database/P9er76jCWCFW \
--dataset [Easy Dataset] [P9er76jCWCFW] Alpaca \
--cutoff_len 4096 \
--learning_rate 5e-05 \
--num_train_epochs 3.0 \
--max_samples 100000 \
--per_device_train_batch_size 2 \
--gradient_accumulation_steps 1 \
--lr_scheduler_type cosine \
--max_grad_norm 1.0 \
--logging_steps 5 \
--save_steps 200 \
--warmup_steps 0 \
--packing False \
--enable_thinking True \
--report_to none \
--output_dir saves/Qwen3-32B-Thinking/lora/train_2025-08-28-03-04-52 \
--bf16 True \
--plot_loss True \
--trust_remote_code True \
--ddp_timeout 180000000 \
--include_num_input_tokens_seen True \
--optim adamw_torch \
--lora_rank 8 \
--lora_alpha 16 \
--lora_dropout 0 \
--lora_target all \
--val_size 0.15 \
--eval_strategy steps \
--eval_steps 200 \
--per_device_eval_batch_size 2 \
--deepspeed cache/ds_z3_config.json

训练结果

image-20250827091214108

评估

不知道为什么使用llamafactory的评估会爆显存,我怀疑是因为那个webui评估可能不支持多卡,就进行一下人工评估吧

输入

image-20250828144300339

微调模型

image-20250828145106050

初始模型

image-20250828145750115

在内网计算节点访问SwanLab Cloud

在内网计算节点访问SwanLab Cloud | SwanLab官方文档

如何计算训练步数

1. 训练集样本量

公式 训练集样本量 = 总数据量 × (1 − 验证集比例)

示例 总数据 2876 条,验证集占 15% 2876 × (1 − 0.15) = 2876 × 0.85 = 2446 条

2. 每次参数更新处理的样本数(effective batch size)

公式 每次更新样本数 = 单设备批次大小 × GPU 数 × 梯度累积步数

示例

  • per_device_train_batch_size = 1
  • GPU 数 = 2
  • gradient_accumulation_steps = 8

1 × 2 × 8 = 16 条

通俗理解: GPU 一次只能看 1 条 → 2 卡并行就是 2 条 → 累积 8 次才更新一次参数,所以一次更新真正看了 16 条数据。

3. 每轮(epoch)的训练步数

公式 每轮步数 = ⌊ 训练集样本量 ÷ 每次更新样本数 ⌋ (⌊ ⌋ 表示向下取整)

示例 2446 ÷ 16 = 152.875 → 152 步

4. 总训练步数

公式 总步数 = 每轮步数 × 训练轮数 (epochs)

示例 152 × 3 = 456 步

如何计算一个模型占用的显存

基础模型的权重

  • 定义:预训练模型的参数矩阵,即选择的预训练模型所占用显存的大小。
  • 计算公式显存占用 = 模型参数数量 × 单个参数的字节数

常见模型精度下的单个参数显存占用:

表格

复制

精度类型 二进制位数 字节数
FP32 32位 4字节
FP16 16位 2字节
BF16 16位 2字节(指数位同FP32)
INT8 8位 1字节
INT4 4位 0.5字节
INT2 2位 0.25字节

例如

  • 模型选择:Qwen2.5-7B-Instruct
  • 参数规模:70亿(7B)
  • 计算精度:BF16(2字节/参数)
  • 预估显存占用70亿 × 2字节 = 140亿字节 = 14GB

框架开销(Framework Overhead)

  • 定义:LLaMAFactory 底层使用的深度学习框架(如 PyTorch)本身的显存占用。
  • 包含内容
    • 张量缓存
    • 线程资源
    • 内核调度开销
    • 自动微分图结构等
  • 计算方法:难以精确计算
  • 估算方法:通常占用不大,默认估算为 1 GB

LoRA 适配器(LoRA Adapters)

  • 定义:在 LoRA 微调中,不直接修改原始模型的庞大权重,而是插入轻量级的“LoRA适配器模块”来学习微调所需的变化。

  • 计算方法

    显存占用=LoRA层数×秩(Rank)×(输入维度+输出维度)×2B

  • 估算方法

    • 与 LoRA 的秩(Rank)大小相关
    • 一般占用不大,常规配置下通常不超过 0.5 GB,保守估计为 0.5 GB

激活值(Activations)

  • 定义:前向传播过程中各层的输出张量(如隐藏层状态、注意力矩阵等),即模型“处理数据时产生的所有中间结果”。

  • 计算方法

    显存占用=批量大小×序列长度×隐藏层维度×模型层数×单个元素字节数

  • 估算方法

    • 单次处理的 Token 量每增加 1K,显存约增加 2.5 GB
    • 与单 GPU 的批量大小和数据集的截断长度(序列长度)正相关
    • 在固定其他配置(基础模型权重、框架开销、LoRA适配器)后,剩余显存即为激活值占用

加速方式

加速方式 全称 / 来源 核心原理与特点 适用场景与注意事项
auto 自动选择 由框架(如 transformers、LLaMA-Factory、DeepSpeed 等)根据当前硬件、驱动、CUDA 版本自动挑选最快的可用算子或路径。 优点:零配置、开箱即用;缺点:不一定能启用最新、最快的内核。 初次实验、不想手动调参时首选。
flashattn2 FlashAttention-2 通过 IO-Aware 的算法和 GPU Tensor Core 优化,将标准 Multi-Head Attention 的显存访问次数大幅降低,从而显著加快训练/推理速度(通常 2-4×),并减少显存占用。 需要 A100、H100、RTX 30/40 系列等 Ampere/Lovelace 架构;依赖 CUDA≥11.8、PyTorch≥2.0 且需安装 flash-attn wheel。 训练/微调 LLM 时首选;序列越长收益越大。若编译失败可退回 xformers 或原生实现。
unsloth Unsloth 开源库 针对 Llama、Mistral、Qwen 等架构,使用动态量化、手工 fused-kernel 和梯度检查点优化,使 LoRA 微调在消费级 GPU 上也能跑更大 batch/更长序列。官方宣称速度提升 2-5×,显存节省 50-70%。 安装简单:pip install unsloth(会自动替换部分 PyTorch 层)。 单卡 4090/3090 上 LoRA 微调 7B-13B 模型效果最佳;目前仅支持有限模型。
liger_kernel Liger-Kernel(微软开源) 以 Triton 编写的高性能 fused-kernel 合集(SwiGLU、RMSNorm、CrossEntropy、RoPE 等),在保持数值精度的同时减少 kernel launch 和显存写回,训练吞吐量可提升 10-20%。 纯 Python/Triton 实现,无需额外 CUDA 编译。 对训练框架侵入性小,可与 FlashAttention 并存;适合想“无痛”提速 10-20% 的场景。

参考资料

LLaMA Factory

前言

在完成微调前备知识的学习后,正式开始使用unsloth对Qwen3-8B-unsloth-bnb-4bit模型的lora微调实战

模型加载

1
2
3
4
5
6
7
8
9
10
11
12
13
from unsloth import FastLanguageModel
import torch

max_seq_length = 8192
dtype = None
load_in_4bit = True

model, tokenizer = FastLanguageModel.from_pretrained(
model_name = "/workspace/qwen3-8b",
max_seq_length = max_seq_length,
dtype = dtype,
load_in_4bit = load_in_4bit,
)

FastLanguageModelUnsloth 框架的核心入口类,即“把 Hugging Face 的 transformers 模型‘加速’成支持 QLoRA 微调、显存占用减半、速度提升 2-5 倍的封装器。”

max_seq_length = 8192作用:告诉框架 “后续所有输入序列的最大长度”内部一次性为位置编码、注意力掩码、KV-Cache 等开辟的张量尺寸,因此显存随它 平方级增长

dtype = None作用:让 Unsloth 自动选择最合适的浮点精度

load_in_4bit = True作用:把模型权重量化成 4-bit,显存降到 1/4,QLoRA 微调必备。

查看模型与分词器信息

模型信息

运行

1
model

通过阅读模型信息我们可以了解到:

1
(embed_tokens): Embedding(151936, 4096, padding_idx=151654)

模型有 15 万个 token 的字典,每个字/词被翻译成 4096 维向量,第 151 654 号 token 被官方指定为填充符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(layers): ModuleList(
(0-2): 3 x Qwen3DecoderLayer(
(self_attn): Qwen3Attention(
(q_proj): Linear4bit(in_features=4096, out_features=4096, bias=False)
(k_proj): Linear4bit(in_features=4096, out_features=1024, bias=False)
(v_proj): Linear4bit(in_features=4096, out_features=1024, bias=False)
(o_proj): Linear4bit(in_features=4096, out_features=4096, bias=False)
(q_norm): Qwen3RMSNorm((128,), eps=1e-06)
(k_norm): Qwen3RMSNorm((128,), eps=1e-06)
(rotary_emb): LlamaRotaryEmbedding()
)
(mlp): Qwen3MLP(
(gate_proj): Linear(in_features=4096, out_features=12288, bias=False)
(up_proj): Linear(in_features=4096, out_features=12288, bias=False)
(down_proj): Linear(in_features=12288, out_features=4096, bias=False)
(act_fn): SiLU()
)
(input_layernorm): Qwen3RMSNorm((4096,), eps=1e-06)
(post_attention_layernorm): Qwen3RMSNorm((4096,), eps=1e-06)
)

共有36层Qwen3DecoderLayer,每层包含Qwen3AttentionQwen3MLP一个 SwiGLU 前馈网络),Qwen3RMSNorm(两个归一化层,对 4096 维的隐藏向量做“均方根归一化”,防止梯度爆炸、稳定训练。)

image-20250812153659843

大模型-qwen3 模型结构解读-66 - jack-chen666 - 博客园

LoRA可以插到哪里呢?

凡是打印里每层 Decoder 中出现的 Linear4bit(q/k/v/o + gate/up/down)就是 LoRA 可插、且默认会被插入的位置。

分词器信息

运行

1
tokenizer

查看tokenizer信息

1
2
3
4
5
6
7
8
Qwen2TokenizerFast(name_or_path='/workspace/qwen3-8b', vocab_size=151643, model_max_length=40960, is_fast=True, padding_side='left', truncation_side='right', special_tokens={'eos_token': '<|im_end|>', 'pad_token': '<|vision_pad|>', 'additional_special_tokens': ['<|im_start|>', '<|im_end|>', '<|object_ref_start|>', '<|object_ref_end|>', '<|box_start|>', '<|box_end|>', '<|quad_start|>', '<|quad_end|>', '<|vision_start|>', '<|vision_end|>', '<|vision_pad|>', '<|image_pad|>', '<|video_pad|>']}, clean_up_tokenization_spaces=False, added_tokens_decoder={
151643: AddedToken("<|endoftext|>", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
151644: AddedToken("<|im_start|>", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
151645: AddedToken("<|im_end|>", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
151646: AddedToken("<|object_ref_start|>", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
截取部分
}
)

vocab_size=151643:模型真正能理解和生成的子词/符号有这 151643 种,其余位置是预留空白。

model_max_length=40960:理论最大输入长度 40k token(实际受显存限制)

is_fast=True:表示 tokenizer 使用的是 Hugging Face 的「Rust 高速实现」(即 tokenizers 库)

special_tokens:打印的 special_tokens 字典 & added_tokens_decoder 已经把 151643-151668 全部列出,共 26 个

模拟一次模型处理流程

将对话内容通过tokenizer进行处理

1
2
3
4
5
6
7
8
9
10
messages = [
{"role" : "user", "content" : "你好,好久不见!"}
]

text = tokenizer.apply_chat_template(
messages,
tokenize = False,
add_generation_prompt = True,
enable_thinking = False, # 设置不思考
)

apply_chat_template 是把「人类对话格式的 Python 列表」一键翻译成 模型能直接理解的带特殊标记的文本字符串(或 token id 序列) 的“官方模板引擎”。

转化后的格式为:

1
'<|im_start|>user\n你好,好久不见!<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n'

然后将转化后的字符串转成 GPU 上的 PyTorch token 张量,准备直接送进模型推理或训练。

1
inputs = tokenizer(text, return_tensors="pt").to("cuda")

以上代码共做了三步:

  1. tokenizer(text) 把前面 apply_chat_template 得到的字符串按词表切成 token id 列表
  2. return_tensors=“pt” 把列表包成 PyTorch 张量(shape = [1, seq_len])。
  3. .to(“cuda”) 把张量搬到 GPU 显存

输出如下

1
2
3
{'input_ids': tensor([[151644,    872,    198, 108386,   3837, 111920, 101571,   6313, 151645,
198, 151644, 77091, 198, 151667, 271, 151668, 271]],
device='cuda:0'), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]], device='cuda:0')}
形状 每个数字的含义
input_ids [1, 17] 17 个 token 的 ID 列表,已放到 GPU
attention_mask [1, 17] 17 个 1,表示“这些位置都是有效 token,无填充”
1
2
3
4
5
6
outputs = model.generate(
input_ids=inputs.input_ids,
attention_mask=inputs.attention_mask,
max_new_tokens=max_seq_length,
use_cache=True,#启用 KV-Cache,避免重复计算,显存换时间
)

让模型在 GPU 上 根据已有 token 继续生成文本,直到达到 max_new_tokens 或遇到终止符。

outputs格式和inputs类似,使用nput_ids表示后续字符

1
response = tokenizer.batch_decode(outputs)

把模型输出的 token id 序列outputs)一次性还原成 人类可读的字符串

输出如下

1
'<|im_start|>user\n你好,好久不见!<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n你好!好久不见!最近过得怎么样?有什么新鲜事想和我分享吗?😊<|im_end|>'

这里展示的是没有思考过程的,最简单对话流程,若设置思考模式,完整代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
text = tokenizer.apply_chat_template(
messages,
tools = tools,#同样,可以设置function calling
tokenize = False,
add_generation_prompt = True,
enable_thinking = True, # 设置思考
)

inputs = tokenizer(text, return_tensors="pt").to("cuda")

outputs = model.generate(
input_ids=inputs.input_ids,
attention_mask=inputs.attention_mask,
max_new_tokens=max_seq_length,
use_cache=True,
)

response = tokenizer.batch_decode(outputs)

当然,除了使用上述底层API进行对话外,Unsloth还提供了更加便捷的流式输出模型对话信息的函数,基本对话效果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
messages = [
{"role" : "user", "content" : "你好,好久不见!"}
]

text = tokenizer.apply_chat_template(
messages,
tokenize = False,
add_generation_prompt = True,
enable_thinking = False,
)

_ = model.generate(
**tokenizer(text, return_tensors = "pt").to("cuda"),
max_new_tokens = 256, # Increase for longer outputs!
temperature = 0.7, top_p = 0.8, top_k = 20, # For non thinking
streamer = TextStreamer(tokenizer, skip_prompt = True),#实时流式输出:每解码一个 token 就立刻打印到终端
)

准备数据集

下载数据集

选取的两个数据集

  1. 我们使用 Open Math Reasoning 数据集,该数据集曾被用于赢得 AIMO(AI 数学奥林匹克 - 第二届进步奖)挑战!我们从中抽取了 10% 可验证的推理轨迹,这些轨迹是基于 DeepSeek R1 模型生成的,并且准确率超过 95%。数据集地址:https://huggingface.co/datasets/unsloth/OpenMathReasoning-mini
  2. 我们还利用了 Maxime Labonne 的 FineTome-100k 数据集,该数据集风格类似 ShareGPT。但我们需要将其转换为 HuggingFace 通用的多轮对话格式。数据集地址:https://huggingface.co/datasets/mlabonne/FineTome-100k

在实际微调过程中,大多都会使用huggingface的datasets库进行数据集下载和管理,实际下载流程如下:

1
!pip install --upgrade datasets huggingface_hub

datasets 是 Hugging Face 提供的一个高效数据处理库,专为机器学习和大语言模型(LLM)训练而设计。它支持加载、处理、转换和保存各种格式的数据(如 JSON、CSV、Parquet 等),并能与 transformers 模型无缝集成。通过 datasets,开发者可以快速完成数据清洗、切分、tokenization 等常见任务,大大提升训练效率,特别适合用于指令微调、对话生成、Function Calling 等任务的数据预处理。

然后分别下载并导入这两个库:

1
reasoning_dataset = load_dataset("unsloth/OpenMathReasoning-mini", split = "cot")

cot全称为Chain-of-Thought,思维链,是「一步一步把思考过程写出来」的解题方式,而不是直接给出最终答案。

只下 cot 是因为任务只需要“带推理过程”的那部分数据,其他子集对当前微调目标无用,避免冗余下载。

1
non_reasoning_dataset = load_dataset("mlabonne/FineTome-100k", split = "train")

查看数据集

然后输入数据集名称,即可查看数据集基本信息:

1
reasoning_dataset
1
2
3
4
Dataset({
features: ['expected_answer', 'problem_type', 'problem_source', 'generation_model', 'pass_rate_72b_tir', 'problem', 'generated_solution', 'inference_mode'],
num_rows: 19252
})

一共 19 252 条 CoT(思维链)数学题,每条包含 8 个字段,可直接用来训练/评估模型的逐步推理能力。

generated_solution:模型自己写的 逐步推理 + 最终答案(就是你想要的 CoT)

expected_answer:标准答案(通常是一个简洁数字或表达式)

generation_model:生成这条 CoT 的“教师模型”名字,比如 qwen2-72b

加上索引则可以直接查看对应数据集信息:

1
reasoning_dataset[0]
1
2
3
4
5
6
7
8
{'expected_answer': '14',
'problem_type': 'has_answer_extracted',
'problem_source': 'aops_c4_high_school_math',
'generation_model': 'DeepSeek-R1',
'pass_rate_72b_tir': '0.96875',
'problem': 'Given $\\sqrt{x^2+165}-\\sqrt{x^2-52}=7$ and $x$ is positive, find all possible values of $x$.',
'generated_solution': "<think>\nOkay, let's see. I need to solve the equation √(x² + 165) - √(x² - 52) = 7, a截取部分",
'inference_mode': 'cot'}

能够看出这是一个基于DeepSeek R1回答的数学数据集,其中problem是问题,generated_solution是数学推导过程(即思考过程),而expected_answer则是最终的答案。该数据集总共接近2万条数据

而对话数据集如下:

1
non_reasoning_dataset
1
2
3
4
Dataset({
features: ['conversations', 'source', 'score'],
num_rows: 100000
})
1
non_reasoning_dataset[0]
1
2
3
4
5
6
{'conversations': [{'from': 'human',
'value': 'Explain what boolean operators are, what they do, and provide examples of how they can be used in programming. Additionally, describe the concept of operator precedence and prov截取'},
{'from': 'gpt',
'value': 'Boolean operators are logical operators used in programming to manipulate boolean values. The截取'}],
'source': 'infini-instruct-top-500k',
'score': 5.212620735168457}

其中每一条数据都是一个对话,包含一组或者多组ChatGPT的聊天信息,其中from代表是用户消息还是大模型回复消息,而value则是对应的文本。该对话数据集总共包含10万条数据

能够看出dataset是一种类似json的数据格式,每条数据都以字段格式进行存储,在实际微调过程中,我们需要先将数据集的目标字段进行提取和拼接,然后加载到Qwen3模型的提示词模板中,并最终带入Unsloth进行微调。

数据集清洗

对话数据集的清洗

接下来尝试对上述两个格式各异的数据集进行数据清洗,主要是围绕数据集进行数据格式的调整,便于后续带入Qwen3提示词模板。对于dataset格式的数据对象来说,可以先创建满足格式调整的函数,然后使用map方法对数据集格式进行调整。

1
2
3
4
5
6
7
8
9
10
def generate_conversation(examples):
problems = examples["problem"]
solutions = examples["generated_solution"]
conversations = []
for problem, solution in zip(problems, solutions):
conversations.append([
{"role" : "user", "content" : problem},
{"role" : "assistant", "content" : solution},
])
return { "conversations": conversations, }

这里先创建generate_conversation函数,用于对reasoning_dataset中的每一条数据进行格式调整,即通过新创建一个新的特征conversations,来以对话形式保存历史问答数据:

1
2
3
4
reasoning_data = reasoning_dataset.map(
generate_conversation, # 处理函数
batched=True # 批量处理,加快速度
)

map:对数据集中的每一批样本调用 generate_conversation

batched=True:一次传入一批(几百到几千条)样本,避免逐行慢速 Python 循环

接下来将其带入Qwen3的提示词模板中进行转化:

1
2
3
4
reasoning_conversations = tokenizer.apply_chat_template(
reasoning_data["conversations"],
tokenize = False,
)

之后即可带入这些数据进行微调。能看出每条数据的格式都和Unsloth底层对话API创建的数据格式类似,之后我们或许可以借助Unsloth底层对话API来创建微调数据集。

推理数据集的推理

然后继续处理non_reasoning_conversations数据集,由于该数据集采用了sharegpt对话格式,因此可以直接借助Unsloth的standardize_sharegpt库进行数据集的格式转化,转化效果如下所示:

1
from unsloth.chat_templates import standardize_sharegpt

standardize_sharegpt的作用

把“ShareGPT 格式”的对话数据一键转成 Unsloth / Hugging Face 通用的 role/content 列表,后续就能直接用 apply_chat_template 生成训练文本。

1️⃣ ShareGPT 原始长什么样?

1
2
{"from": "human", "value": "1+1=?"}
{"from": "gpt", "value": "2"}

2️⃣ 转换后长什么样?

1
2
{"role": "user",      "content": "1+1=?"}
{"role": "assistant", "content": "2"}
1
dataset = standardize_sharegpt(non_reasoning_dataset)

接下来即可直接带入Qwen3对话模板中进行格式调整:

1
2
3
4
non_reasoning_conversations = tokenizer.apply_chat_template(
dataset["conversations"],
tokenize = False,
)

数据集采样

自此即完成了每个数据集的格式调整工作,不过这两个数据集并不均衡,能看得出非推理类数据集的长度更长。我们假设希望模型保留一定的推理能力,但又特别希望它作为一个聊天模型来使用。

因此,我们需要定义一个 仅聊天数据的比例目标是从两个数据集中构建一个混合训练集。这里我们可以设定一个 25% 推理数据、75% 聊天数据的比例:也就是说,从推理数据集中抽取 25%(或者说,抽取占比为 100% - 聊天数据占比 的部分),最后将这两个数据集合并起来即可。

1
2
3
4
5
6
7
8
9
10
chat_percentage = 0.75

import pandas as pd
#先把非推理对话列表转成 Pandas Series,方便后续抽样
non_reasoning_subset = pd.Series(non_reasoning_conversations)

non_reasoning_subset = non_reasoning_subset.sample(#sample(...)为无放回随机抽样
int(len(reasoning_conversations) * (1.0 - chat_percentage)),#计算 需要抽多少条非推理样本
random_state = 2407,
)

这里我们需要先将上述list格式的数据转化为pd.Series数据,然后进行采样,并最终将其转化为dataset类型对象。(此外也可以先转化为dataset对象类型,然后再进行采样)

1
2
3
4
5
6
7
8
9
10
data = pd.concat([
pd.Series(reasoning_conversations),
pd.Series(non_reasoning_subset)
])
data.name = "text"

from datasets import Dataset

combined_dataset = Dataset.from_pandas(pd.DataFrame(data))
combined_dataset = combined_dataset.shuffle(seed = 3407)#用固定种子随机打乱顺序

pd.concat([…]):纵向拼接 → 一条长 Series,顺序:先推理,后非推理

Dataset.from_pandas(…):把 Pandas Series 转成 Hugging Face Dataset

把“推理对话”和“抽样后的非推理对话”合并成一个 随机打乱 Dataset 对象,后面可直接拿去训练。

查看数据集

1
combined_dataset[0]
1
2
{'text': "<|im_start|>user\nCalculate the pH during a titration when 9.54 mL of a 0.15 M HCl solution has reacted with 22.88 mL of a 0.14 M NaOH solution?<|im_end|>\n<|im_st截取",
'__index_level_0__': 49038}

其中text字段就是后续带入微调的字段。

数据集保存

1
combined_dataset.save_to_disk("/workspace/cleaned_qwen3_dataset")

后续使用时即可使用如下代码进行读取:

1
2
from datasets import load_from_disk
combined_dataset = load_from_disk("cleaned_qwen3_dataset")

Qwen3推理能力高效微调流程

准备完数据之后,即可开始进行微调。这里我们先进行少量数据微调测试,程序能够基本跑通后,我们再进行大规模数据集微调。

进行LoRA参数注入

1
2
3
4
5
6
7
8
9
10
11
12
13
model = FastLanguageModel.get_peft_model(
model,
r = 32, # 秩(LoRA 低秩矩阵的列数)。越大可学习参数越多,显存也越高。常用 8/16/32/64/128
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"], # 在哪些线性层插入 LoRA 适配器(Attention + MLP)
lora_alpha = 32, # 缩放因子。经验值 = rank 或 2×rank,控制更新强度
lora_dropout = 0, # LoRA 本身的 dropout 比例;0 省显存且速度最快
bias = "none", # 是否训练原 Linear 的偏置。设为 "none" 不训练,进一步节省显存
use_gradient_checkpointing = "unsloth", # 梯度检查点:True 省显存,"unsloth" 再省 30 %,超长上下文必开
random_state = 3407, # 随机种子,保证 LoRA 初始化可复现
use_rslora = False, # 默认 False,True 则启用 Rank-Stabilized LoRA(训练更稳,但显存稍高)
loftq_config = None, # LoftQ 量化初始化,None 表示不用;若配置可进一步压缩初始权重
)

这一步“LoRA 参数注入”就是:在不改动原模型权重的前提下,给指定层插入少量 可训练低秩矩阵 (LoRA 适配器),从而只更新 < 1 % 的参数,完成高效微调。

不是“在原有层之外再增加一层”,而是把 LoRA 的“小矩阵”插到 原有线性层内部

  • 原层结构(冻结): x → Linear4bit(W) → y
  • 注入后结构(冻结 + 可训练): x → [Linear4bit(W) + LoRA(A·B)] → y

AB 两个低秩矩阵被 注册为同一层的新参数不新建网络层,参数在 前向时相加反向只更新 A 和 B

设置微调参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from trl import SFTTrainer, SFTConfig

trainer = SFTTrainer(
model=model, # 已插入 LoRA 的 4-bit 模型
tokenizer=tokenizer, # 对应 tokenizer(含 chat 模板)
train_dataset=combined_dataset, # 训练集:聊天+推理对话
eval_dataset=None, # 如需验证,把验证集放进来即可

args=SFTConfig(
dataset_text_field="text", # 训练集中每条样本的字段名(对话列表)
per_device_train_batch_size=2, # 每张显卡上的 batch_size(显存决定)
gradient_accumulation_steps=4, # 4 次累积 → 全局有效 batch = 2×4 = 8
warmup_steps=5, # 前 5 步线性预热学习率
max_steps=30, # 训练 30 步(调试阶段);正式可用 num_train_epochs
learning_rate=2e-4, # LoRA 常用 2e-4;长训降到 2e-5
logging_steps=1, # 每 1 步打印一次日志
optim="adamw_8bit", # 8-bit AdamW,省显存
weight_decay=0.01, # L2 正则
lr_scheduler_type="linear", # 线性衰减到 0
seed=3407, # 固定随机种子
report_to="swanlab", # 把指标推送到 swanlab
),
)

TRL (Transformers Reinforcement Learning,用强化学习训练Transformers模型) 是一个领先的Python库,旨在通过监督微调(SFT)、近端策略优化(PPO)和直接偏好优化(DPO)等先进技术,对基础模型进行训练后优化。TRL 建立在 🤗 Transformers 生态系统之上,支持多种模型架构和模态,并且能够在各种硬件配置上进行扩展。

其中SFTTrainer:一个专门为指令微调设计的训练器,封装了 Hugging Face 的 Trainer,而SFTConfig:配置训练参数的专用类,功能类似 TrainingArguments。而SFTConfig核心参数解释如下:

参数名 含义
dataset_text_field="text" 数据集中用于训练的字段名称,如 textprompt
per_device_train_batch_size=2 每张 GPU 上的 batch size 是 2
gradient_accumulation_steps=4 梯度累计 4 次后才进行一次反向传播(等效于总 batch size = 2 × 4 = 8)
warmup_steps=5 前 5 步进行 warmup(缓慢提升学习率)
max_steps=30 最多训练 30 步(适合调试或快速实验)
learning_rate=2e-4 初始学习率(短训练可用较高值)
logging_steps=1 每训练 1 步就打印一次日志
optim="adamw_8bit" 使用 8-bit AdamW 优化器(节省内存,Unsloth 支持)
weight_decay=0.01 权重衰减,用于防止过拟合
lr_scheduler_type="linear" 线性学习率调度器(从高到低线性下降)
seed=3407 固定随机种子,确保结果可复现
report_to="none" 不使用 WandB 或 TensorBoard 等日志平台(可改为 "wandb"
  1. per_device_train_batch_size=2 每次前向只用了 2 条样本 → 显存占用小,单卡就能跑。

    batch_size 决定「每一步真正喂给模型的样本数量」,越大训练越稳,但对显存要求越高。

  2. gradient_accumulation_steps=4 把这 2 条样本算出的梯度先攒起来,攒够 4 次再一次性做反向传播 → 等效于一次性看了 2 × 4 = 8 条样本,但显存仍按 2 条算。

此时基本训练过程为: 1. 从 combined_dataset 中取出一批样本(2 条) 2. 重复上面过程 4 次(gradient_accumulation_steps=4) 3. 将累计的梯度用于更新模型一次参数(等效于一次大 batch 更新) 4. 重复上述过程,直到 max_steps=30 停止

设置训练可视化swanlab

🤗HuggingFace Trl | SwanLab官方文档

只需要在你的训练代码中,找到HF的Config部分(比如SFTConfigGRPOConfig等),添加report_to="swanlab"参数,即可完成集成。

1
2
3
4
5
6
7
8
from trl import SFTConfig, SFTTrainer

args = SFTConfig(
...,
report_to="swanlab"
)

trainer = Trainer(..., args=args)

默认下,项目名会使用你运行代码的目录名

如果你想自定义项目名,可以设置SWANLAB_PROJECT环境变量:

1
2
import os
os.environ["SWANLAB_PROJECT"]="qwen2-sft"

微调执行流程

一切准备就绪后,接下来即可开始进行微调。由于本次微调总共只运行30个step,整个过程并不会很长,实际执行过程如下:

1
trainer_stats = trainer.train()

保存模型

1. 保存 LoRA Adapter

1
2
3
4
# 保存 LoRA adapter(仅几十 MB)
save_path = "./lora-adapter"
model.save_pretrained(save_path) # LoRA 权重
tokenizer.save_pretrained(save_path) # 词表

以后加载:

1
2
3
4
5
6
7
8
from unsloth import FastLanguageModel
model, tokenizer = FastLanguageModel.from_pretrained(
model_name = "base-model-name-or-path",
max_seq_length = 2048,
load_in_4bit = True,
)
model = FastLanguageModel.get_peft_model(model, ...) # 同训练时参数
model.load_adapter(save_path) # 把 LoRA 权重挂回去

2.合并 LoRA → 完整模型

如果你想把 LoRA 权重合并到基座 得到一个独立的大模型(方便推理、上传 Hub):

1
2
3
4
# 合并权重
merged_model = model.merge_and_unload() # 返回普通 transformers 模型
merged_model.save_pretrained("./merged-model")
tokenizer.save_pretrained("./merged-model")

合并后就是完整的大模型(GB 级),可直接用 AutoModelForCausalLM.from_pretrained("./merged-model") 加载,不依赖 Unsloth。

微调结果

可视化结果

image-20250813111238359

图表 | Fine-tune-Qwen-8B/rat-2

指标名称 含义 单位/范围提示 常见关注点
train/loss 训练损失(Training Loss) 标量,越小越好 是否持续下降、是否震荡、是否过拟合
train/grad_norm 梯度范数(Gradient Norm) 标量,通常 0.01–1.0 为合理区间 是否爆炸(>10)或消失(<1e-4)
train/learning_rate 学习率(Learning Rate) 标量,如 1e-4、5e-4 等 是否过大导致震荡、过小导致收敛慢
train/epoch 已训练的轮次(Epoch) 标量,1.0 表示完整遍历一次训练集 当前已训练多少轮、是否还需继续训练
train/global_step 全局步数(Global Step) 整数,每个 batch +1 与 epoch 对应,计算已见样本量

对话测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
messages = [
{"role" : "user", "content" : "解决(x + 2)^2 = 0."}
]
text = tokenizer.apply_chat_template(
messages,
tokenize = False,
add_generation_prompt = True, # Must add for generation
enable_thinking = True, # Disable thinking
)

from transformers import TextStreamer
_ = model.generate(
**tokenizer(text, return_tensors = "pt").to("cuda"),
max_new_tokens = 20488, # Increase for longer outputs!
temperature = 0.6, top_p = 0.95, top_k = 20, # For thinking
streamer = TextStreamer(tokenizer, skip_prompt = True),
)

什么是大模型

随着2022年底 ChatGPT 再一次刷新 NLP 的能力上限,大语言模型(Large Language Model,LLM)开始接替传统的预训练语言模型(Pre-trained Language Model,PLM) 成为 NLP 的主流方向,基于 LLM 的全新研究范式也正在刷新被 BERT 发扬光大的预训练-微调范式,NLP 由此迎来又一次翻天覆地的变化。

LLM,即 Large Language Model,中文名为大语言模型或大型语言模型,是一种相较传统语言模型参数量更多、在更大规模语料上进行预训练的语言模型

一般来说,LLM 指包含数百亿(或更多)参数的语言模型,它们往往在数 T token 语料上通过多卡分布式集群进行预训练,具备远超出传统预训练模型的文本理解与生成能力。不过,随着 LLM 研究的不断深入,多种参数尺寸的 LLM 逐渐丰富,广义的 LLM 一般覆盖了从十亿参数(如 Qwen-1.5B)到千亿参数(如 Grok-314B)的所有大型语言模型。只要模型展现出涌现能力,即在一系列复杂任务上表现出远超传统预训练模型(如 BERT、T5)的能力与潜力,都可以称之为 LLM。

一般认为,GPT-3(1750亿参数)是 LLM 的开端,基于 GPT-3 通过 预训练(Pretraining)、监督微调(Supervised Fine-Tuning,SFT)、强化学习与人类反馈(Reinforcement Learning with Human Feedback,RLHF)三阶段训练得到的 ChatGPT 更是主导了 LLM 时代的到来。

区分 LLM 与传统 PLM 最显著的特征即是 LLM 具备 涌现能力 。涌现能力是指同样的模型架构与预训练任务下,某些能力在小型模型中不明显,但在大型模型中特别突出。

训练流程

image-20250811092459843

一般而言,训练一个完整的 LLM 需要经过图1中的三个阶段——Pretrain、SFT 和 RLHF。

Pretrain

Pretrain,即预训练,是训练 LLM 最核心也是工程量最大的第一步。

参数

模型 hidden_layers hidden_size heads 整体参数量 预训练数据量
BERT-base 12 768 12 0.1B 3B
BERT-large 24 1024 16 0.3B 3B
Qwen-1.8B 24 2048 16 1.8B 2.2T
LLaMA-7B 32 4096 32 7B 1T
GPT-3 96 12288 96 175B 300B

根据定义,LLM 的核心特点即在于其具有远超传统预训练模型的参数量同时在更海量的语料上进行预训练。传统预训练模型如 BERT,有 base 和 large 两个版本。BERT-base 模型由 12个 Encoder 层组成,其 hidden_size 为 768,使用 12个头作为多头注意力层,整体参数量为 1亿(110M);而 BERT-large 模型由 24个 Encoder 层组成,hidden_size 为 1024,有 16个头,整体参数量为 3亿(340M)。同时,BERT 预训练使用了 33亿(3B)token 的语料,在 64块 TPU 上训练了 4天。事实上,相对于传统的深度学习模型,3亿参数量、33亿训练数据的 BERT 已经是一个能力超群、资源消耗巨大的庞然大物。

但是,前面我们提到,一般而言的 LLM 通常具有数百亿甚至上千亿参数,即使是广义上最小的 LLM,一般也有十亿(1B)以上的参数量。例如以开山之作 GPT-3 为例,其有 96个 Decoder 层,12288 的 hidden_size 和 96个头,共有 1750亿(175B)参数,比 BERT 大出快 3个数量级。即使是目前流行的小型 LLM 如 Qwen-1.8B,其也有 24个 Decoder 层、2048的 hidden_size 和 16个注意力头,整体参数量达到 18亿(1.8B)。

分布式训练

也正因如此,分布式训练框架也成为 LLM 训练必不可少的组成部分。分布式训练框架的核心思路是数据并行和模型并行。所谓数据并行,是指训练模型的尺寸可以被单个 GPU 内存容纳,但是由于增大训练的 batch_size 会增大显存开销,无法使用较大的 batch_size 进行训练;同时,训练数据量非常大,使用单张 GPU 训练时长难以接受。

数据集

训练数据本身也是预训练 LLM 的一个重大挑战。训练一个 LLM,至少需要数百 B 甚至上 T 的预训练语料。根据研究,LLM 所掌握的知识绝大部分都是在预训练过程中学会的,因此,为了使训练出的 LLM 能够覆盖尽可能广的知识面,预训练语料需要组织多种来源的数据,并以一定比例进行混合。目前,主要的开源预训练语料包括 CommonCrawl、C4、Github、Wikipedia 等。不同的 LLM 往往会在开源预训练语料基础上,加入部分私有高质量语料,再基于自己实验得到的最佳配比来构造预训练数据集。事实上,数据配比向来是预训练 LLM 的“核心秘籍”,不同的配比往往会相当大程度影响最终模型训练出来的性能。

训练一个中文 LLM,训练数据的难度会更大。目前,高质量语料还是大部分集中在英文范畴,例如上表的 Wikipedia、Arxiv 等,均是英文数据集;而 C4 等多语言数据集中,英文语料也占据主要地位。目前开源的中文 LLM 如 ChatGLM、Baichuan 等模型均未开放其预训练数据集,开源的中文预训练数据集目前仅有昆仑天工开源的SkyPile(150B)、中科闻歌开源的yayi2(100B)等,相较于英文开源数据集有明显差距。

数据清洗

预训练数据的处理与清洗也是 LLM 预训练的一个重要环节。诸多研究证明,预训练数据的质量往往比体量更加重要。预训练数据处理一般包括以下流程:

  1. 文档准备。由于海量预训练语料往往是从互联网上获得,一般需要从爬取的网站来获得自然语言文档。文档准备主要包括 URL 过滤(根据网页 URL 过滤掉有害内容)、文档提取(从 HTML 中提取纯文本)、语言选择(确定提取的文本的语种)等。
  2. 语料过滤。语料过滤的核心目的是去除低质量、无意义、有毒有害的内容,例如乱码、广告等。语料过滤一般有两种方法:基于模型的方法,即通过高质量语料库训练一个文本分类器进行过滤;基于启发式的方法,一般通过人工定义 web 内容的质量指标,计算语料的指标值来进行过滤。
  3. 语料去重。实验表示,大量重复文本会显著影响模型的泛化能力,因此,语料去重即删除训练语料中相似度非常高的文档,也是必不可少的一个步骤。去重一般基于 hash 算法计算数据集内部或跨数据集的文档相似性,将相似性大于指定阈值的文档去除;也可以基于子串在序列级进行精确匹配去重。

SFT 指令微调

预训练赋予了 LLM 能力,却还需要第二步将其激发出来。经过预训练的 LLM 好像一个博览群书但又不求甚解的书生,对什么样的偏怪问题,都可以流畅地接出下文,但他偏偏又不知道问题本身的含义,只会“死板背书”。这一现象的本质是因为,LLM 的预训练任务就是经典的 CLM,也就是训练其预测下一个 token 的能力,在没有进一步微调之前,其无法与其他下游任务或是用户指令适配。

因此,我们还需要第二步来教这个博览群书的学生如何去使用它的知识,也就是 SFT(Supervised Fine-Tuning,有监督微调)

面对能力强大的 LLM,我们往往不再是在指定下游任务上构造有监督数据进行微调,而是选择训练模型的“通用指令遵循能力”,也就是一般通过指令微调的方式来进行 SFT

所谓指令微调,即我们训练的输入是各种类型的用户指令,而需要模型拟合的输出则是我们希望模型在收到该指令后做出的回复。例如,我们的一条训练样本可以是:

1
2
input:告诉我今天的天气预报?
output:根据天气预报,今天天气是晴转多云,最高温度26摄氏度,最低温度9摄氏度,昼夜温差大,请注意保暖哦

也就是说,SFT 的主要目标是让模型从多种类型、多种风格的指令中获得泛化的指令遵循能力,也就是能够理解并回复用户的指令。

RLHF

RLHF,全称是 Reinforcement Learning from Human Feedback,即人类反馈强化学习,是利用强化学习来训练 LLM 的关键步骤。相较于在 GPT-3 就已经初见雏形的 SFT,RLHF 往往被认为是 ChatGPT 相较于 GPT-3 的最核心突破。事实上,从功能上出发,我们可以将 LLM 的训练过程分成预训练与对齐(alignment)两个阶段。预训练的核心作用是赋予模型海量的知识,而所谓对齐,其实就是让模型与人类价值观一致,从而输出人类希望其输出的内容。在这个过程中,SFT 是让 LLM 和人类的指令对齐,从而具有指令遵循能力;而 RLHF 则是从更深层次令 LLM 和人类价值观对齐,令其达到安全、有用、无害的核心标准。

RLHF 分为两个步骤:训练 RM 和 PPO 训练

RM,Reward Model,即奖励模型。RM 是用于拟合人类偏好,来给 LLM 做出反馈的。在强化学习的训练中,对于 LLM 的每一个回复,RM 会进行打分,这个打分反映了生成回复符合人类偏好的程度。然后 LLM 会根据强化学习的原理,基于 RM 的打分来进行优化训练。

在完成 RM 训练之后,就可以使用 PPO 算法来进行强化学习训练。PPO,Proximal Policy Optimization,近端策略优化算法,是一种经典的 RL 算法。事实上,强化学习训练时也可以使用其他的强化学习算法,但目前 PPO 算法因为成熟、成本较低,还是最适合 RLHF 的算法。

参考资料

第四章 大语言模型

为什么要微调

预训练大模型在海量通用语料上学到的知识,在垂直场景(医疗、法律、零售客服等)里往往“泛而浅”。

从零训练一个同等规模的大模型成本极高(千卡周级别),而微调只需在已有权重上做小步调整,算力/数据量都指数级下降。

什么是全量微调

image-20250811104846104

全量微调(full fine-tuning)通俗来说,对于参数的每一个权重,都要学习一个新的值(或者偏移量),更新所有 Transformer 层里的权重矩阵(包括 embedding、attention、FFN),这样的开销是很大的。

什么是LoRA

LoRA(Low-Rank Adaptation,低秩适配)是一种参数高效微调(PEFT)技术,核心目的: “冻结大模型 99 % 以上原始权重,只额外训练极少量低秩矩阵,就能让模型在下游任务上达到近似全量微调的效果。”

image-20250811105145633

通俗来说,通过学习两个低秩的矩阵,来近似于完整的矩阵,如图,W=A*B,矩阵相乘

在实际应用中,LoRA可以直接和transformer的FFN层(线性层)对齐

Transformer 模型的核心是注意力机制,其中涉及到 Query, Key, Value 的计算,这些都是线性变换。

在标准的注意力机制中,计算公式为:

$$ \text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V $$

其中 Q, K, V 的计算为:

Q = XQWQ,  K = XKWK,  V = XVWV

XQ, XK, XV 的输入可以相同,也可以不同。例如,在 Cross-Attention 中,解码器的隐藏状态作为 XQ,编码器的输出作为 XKXV

LoRA 可以应用到 WQ, WK, WV 上,采用与线性层类似的方式

为什么要用lora

首先要理解低秩:秩可以理解成一个矩阵所代表的信息,低秩矩阵,便是带有少量信息的矩阵,当然这样的矩阵计算效率是更高的,

在全量微调中,由于训练一个完整的矩阵开销是非常大的;在lora中就通过训练低秩矩阵,来近似模型权重更新的效果

若模型参数比较小,使用冻结部分参数或全量微调的方式,往往更好

初学者不禁会思考,这样难道不会损失信息导致大模型的性能变差吗?但是,实验下来效果还是不错的,通过牺牲一点性能,来换取开销的大幅度减少

LoRA 原文实验 在 GPT-3 175 B 上,仅用 rank 4 的 LoRA 就能在全量微调 99 % 参数量的情况下,保持 97 % 的下游指标。

什么是QLoRA

QLoRA(Quantized Low-Rank Adaptation,量化低秩适应)是 LoRA 的“极致省内存”版本。它把 LoRA 的“低秩增量”思路再往前推一步:先把整个底座模型权重压到 4-bit,再在上面做 LoRA 微调

QLoRA 是另一个热门术语,它与 LoRA 之间的唯一区别在于首字母“Q”,代表“量化(quantized)”。“量化”一词指的是用来减少存储神经元权重的比特数。

例如,神经网络的权重通常以浮点数表示,每个权重需要 32 位。量化的思想是将神经网络的权重压缩为更低的精度,而不会显著损失模型性能或产生重大影响。因此,不再使用 32 位,而是可以舍弃部分比特,例如只用 16 位。

image-20250811142432782

微调工具的介绍

unsloth

unslothai/unsloth: Fine-tuning & Reinforcement Learning for LLMs. 🦥 Train OpenAI gpt-oss, Qwen3, Llama 4, DeepSeek-R1, Gemma 3, TTS 2x faster with 70% less VRAM.

unsloth是一个专为大型语言模型(LLM)设计的动态量化与微调框架,旨在提高微调效率并减少显存占用,因此主要用于单机单卡的模型微调。

值得一提的是,Unsloth动态量化模型:https://unsloth.ai/blog/dynamic-v2

Unsloth的动态量化方法,特别是其最新的Dynamic 2.0版本,旨在在尽量减少性能损失的同时显著压缩大型语言模型(LLMs)的体积。对于Qwen3模型,尤其是4-bit动态量化版本,现有的评测显示其性能下降非常有限,甚至在某些任务上与原始模型相当。

Unsloth 的「动态量化」可以一句话概括为: “按层、按敏感度自动决定每块权重到底用 2.5 / 3.5 / 4 / 6 / 8 / 32 bit 的精细化量化策略,而不是一股脑全量化到 4 bit。”

这也使得Unsloth的动态量化模型成为个人配置下的最佳微调工具。

不过需要注意的是,动态量化由利也有弊,其好处在于可以极大程度压缩模型运行所需占用的显存大小,同时几乎不损失性能,但问题在于动态量化的模型,无论是推理还是微调,只能单卡运行,这就使得其吞吐量有限,无法在一台物理机上实现多GPU并行从而扩大吞吐量。

LLaMA Factory

hiyouga/LLaMA-Factory: Unified Efficient Fine-Tuning of 100+ LLMs & VLMs (ACL 2024)

LLaMA Factory 是一个简单易用且高效的大型语言模型训练与微调平台。通过它,用户可以在无需编写任何代码的前提下,在本地完成上百种预训练模型的微调。

LLaMA Factory 提供了API Server 和一站式 WebUI Board,方便企业进行模型的管理和部署。适合不会写代码或代码基础比较弱的同学快速上手进行微调。

其他

ms-SWIFT GitHub项目主页:https://github.com/modelscope/swift

ColossalAI GitHub项目主页:https://github.com/hpcaitech/ColossalAI

除此之外,也可以借助更加底层的库,如peft、LoRA、transformer等实现高效微调。

模型性能评估框架

EvalScope

项目地址: https://github.com/modelscope/evalscope

EvalScope 是由阿里巴巴魔搭社区(ModelScope)推出的一款开源模型评估框架,旨在为大语言 模型(LLM)和多模态模型提供统一、系统化的性能评估方案。该框架具备高度的自动化和可扩展性, 适用于研究机构、工业界以及模型开发者在模型验证与性能对比场景中的广泛需求。

可视化框架

wandb

Weights & Biases(简称 wandb) 是一个专为机器学习 / 深度学习设计的 云端实验管理、可视化与协作平台。它帮你把“训练过程中发生了什么”全部自动化地记录下来,并以网页仪表盘的形式实时展示,省去你手动保存日志、画图、整理表格的麻烦。

wandb官网: https://wandb.ai/site

swanlab

SwanLab 是一款开源、轻量的 AI 模型训练跟踪与可视化工具,提供了一个跟踪、记录、比较、和协作实验的平台。

SwanLab 面向人工智能研究者,设计了友好的Python API 和漂亮的UI界面,并提供训练可视化、自动日志记录、超参数记录、实验对比、多人协同等功能。在SwanLab上,研究者能基于直观的可视化图表发现训练问题,对比多个实验找到研究灵感,并通过在线网页的分享与基于组织的多人协同训练,打破团队沟通的壁垒,提高组织训练效率。

SwanLab官方文档 | 先进的AI团队协作与模型创新引擎

构造微调数据集

为什么要构造微调数据集

image-20250811162229104

其中 <∣im_start∣> 代表文本开始,而user则代表消息身份,用于构建多轮对话,而则代表文本结束,即用户输入结束,而代表新一段文本开始,assistant代表接下来由模型创建消息,而同样代表模型创建消息的结束。

而模型其实是通过这样一组特殊字符标记来规范自己的行为,判断当前消息类型,以及通过输出特殊标记来确定停止时间。对于绝大多数模型,我们可以在模型的tokenizer_config.json中看到完整的特殊标记符(以及系统提示词模板):

image-20250811163120092

而在实际微调过程中,我们都知道需要有监督的数据集、也就是需要输入QA对来进行微调。以著名的alpaca_zh中文微调数据集来说,其基本格式如下:

image-20250811163232521

就可以表示为下列json格式数据集:

1
json{  "instruction": "",  "input": "输入:你好。",  "output": "输出:你好,有什么可以帮到你的?"}

而在真实的微调过程中,如果是针对Qwen3进行微调,微调脚本会将这条数据集(无论什么格式)转化为如下格式:

1
xml<im_start|>user\n你好<im_end|>\n<im_start|>assistant\n你好,有什么可以帮到你的?<im_end|>

而在实际训练过程中,模型就会根据assistant前的内容,学习assistant后面的输出内容。

因此我们要在下载数据集后,进行微调前,对数据集进行预处理,接下来引出构造数据集的几种场景

带有系统提示微调数据集格式

在很多场景下,我们还会发现一些带有instruction字段的微调数据集,那instruction字段是如何带入到微调过程中的呢?

image-20250811163232521

答案非常简单,还是依靠特殊字符。例如有一个对话内容如下:

1
2
3
- 系统提示词(instruction):你是一名助人为乐的助手。
- 用户输入(input):你好,好久不见。
- 助手回复(output):是的呀,好久不见,最近有什么有趣的事情要和我分享么?

此时模型的输入和输出如下:

1
2
3
<lim_start|>system你是一名助人为乐的助手。</im_end>
<lim_start|>user 你好,好久不见。</lim_end>
<lim_start|>assistant 是的呀,好久不见,最近有什么有趣的事情要和我分享么?</lim_end>

即会通过<lim_start|>system…<lim_end|>来标记系统提示词。实际进行微调时,模型会根据assistant为界,学习assistant之前的文本输入情况下应该如何输出。

带Function calling微调数据集格式

更进一步的,如果对话过程中带入了Function calling,此时首先模型会读取提前准备好的tool schema(也可能是自动生成的,例如MCP即可自动创建tool schema):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"tool_schema": [
{
"name": "get_weather",
"description": "查询指定城市的天气信息",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "要查询天气的城市名称"
}
},
"required": ["location"]
}
}
]
}

而假设我们的对话内容如下:

1
2
3
- 系统提示词(instruction):你是一名助人为乐的助手。当用户查询天气的时候,请调用get_weather函数进行天气信息查询。
- 用户输入(input):你好,请帮我查询下北京天气。
- 助手回复(output):{"name": "get_weather", "arguments": {"location": "北京"}}

此时回复内容就是一条Function call message

而此时模型真实的输入和输出内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<|im_start|>system
你是天气助手,当用户查询天气时请调用 get_weather 函数。
# Tools
You may call one or more functions to assist with the user query.
You are provided with function signatures within <tools></tools> XML tags:
<tools>
[{"name":"get_weather","description":"查询指定城市的天气信息","parameters":{"type":"object","properties":{"location":{"type":"string","description":"要查询天气的城市名称"}},"required":["location"]}}]
</tools>
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call>.
<|im_end|>
<|im_start|>user
北京天气如何?
<|im_end|>
<|im_start|>assistant
<tool_call>{"name":"get_weather","arguments":{"location":"北京"}}</tool_call>
<|im_end|>

接下来在进行训练时,模型同样根据assistant前的内容,学习assistant后面的输出内容。不过需要注意的是,由于高效微调调整的参数量较少,因此只能优化模型的Function calling能力,并不能从无到有让模型学会Function calling。

带有思考过程的微调数据集结构

而如果是带有思考链,则一个简单的问答数据如下:

image-20250811165802090
  • 系统提示词(instruction):你是一名助人为乐的助手。
  • 用户输入(input):你好,好久不见。
  • 助手回复(output):好的,用户发来“你好,好久不见!”,我需要回应。首先,用户可能希望得到亲切的回应,所以应该用友好的语气。/n是的呀,好久不见,最近有什么有趣的事情要和我分享么?

此时模型真实的内部输入和输出结果如下:

1
2
3
4
5
6
7
8
9
<lim_start|>system
你是一名助人为乐的助手。<lim_end|>
<lim_start|>user
你好,好久不见。<lim_end|>
<lim_start|>assistant

<think> 好的,用户发来“你好,好久不见!”,我需要回应。首先,用户可能希望得到亲切的回应,所以应该用友好的语气。</think>

是的呀,好久不见,最近有什么有趣的事情要和我分享么?</lim_end|>

模型同样根据assistant前的内容,学习assistant后面的输出内容。也就是说,所谓的思考过程,本质上其实是一种文本响应格式,通过模型训练而来。

混合推理模型构造微调数据集基本方法

在了解了微调数据集结构背后的基本原理后,接下来的问题是应该如何构造微调数据集呢?

一般来说我们可以在huggingface、ModelScope或llama- factory中挑选合适的数据集,并根据实际情况进行组装。

例如围绕Qwen3模型的高效微调,为了确保其仍然保留混合推理能力,我们可以考虑在微调数据集中加入如普通对话数据集FineTome,以及带有推理字段的数学类数据集OpenMathReasoning,并围绕这两个数据集进行拼接,从而在确保能提升模型的数学能力的同时,保留非推理的功能。

同时还需要在持续微调训练过程中不断调整COT数学数据集和普通文本问答数据集之间的配比,以确保模型能够在提升数学能力的同时,保留混合推理的性能。

Qwen3 的「混合推理能力」= 在同一个模型里内置两套“大脑”: • 快思考(非思考模式):轻量算力、秒级响应,适合简单问答; • 慢思考(思考模式):多步链式推理、深度推敲,适合复杂逻辑、数学、代码。 系统会自动或按用户指令在两种模式之间 动态切换,从而 既省算力又保证难题精度

微调的基本流程

image-20250812105535330

环境配置

安装Unsloth

1
pip install --upgrade --force-reinstall --no-cache-dir unsloth unsloth_zoo

安装Qwen3-8B-unsloth-bnb-4bit

1
modelscope download --model unsloth/Qwen3-8B-unsloth-bnb-4bit --local_dir /workspace/qwen3-8b
1
2
3
#模型下载
from modelscope import snapshot_download
model_dir = snapshot_download('unsloth/Qwen3-8B-unsloth-bnb-4bit')

unsloth/Qwen3-8B-unsloth-bnb-4bit 这个模型它是 专门为Unsloth微调框架优化过的4bit量化版本

原始 Qwen3-8B(FP16)需要约 22GB 显存,而 4bit 量化后仅需 6GB 左右

只要显存允许,原始 FP16/BF16 模型也可以用 Unsloth 做 4-bit LoRA(即 QLoRA)微调;官方预量化 4-bit 模型只是帮你把“量化”这一步提前做完了,二者本质相同。

Unsloth 的两种用法示例

场景 代码片段 备注
A. 用官方已量化好的 4-bit 权重 model_name="unsloth/Qwen3-8B-bnb-4bit" 显卡 6 GB 就能跑,省去自己量化
B. 用原始 FP16 权重并现场 4-bit 量化 model_name="Qwen/Qwen3-8B" + load_in_4bit=True 显卡仍需 6 GB,显存占用与 A 相同
1
2
3
4
5
6
7
8
from unsloth import FastLanguageModel

# 两种写法效果等价
model, tokenizer = FastLanguageModel.from_pretrained(
model_name="Qwen/Qwen3-8B", # 原始权重
load_in_4bit=True, # 现场量化到 4-bit
max_seq_length=2048,
)

安装EvalScope

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pip install evalscope                
# 安装 Native backend (默认)
# 额外选项
pip install 'evalscope[opencompass]' # 安装 OpenCompass backend
pip install 'evalscope[vlmeval]'
# 安装 VLMEvalKit backend
pip install 'evalscope[rag]'
pip install 'evalscope[perf]'
pip install 'evalscope[app]'
# 或可以直接输入all,安装全部模块
# pip install 'evalscope[all]'
# 安装 RAGEval backend
# 安装 模型压测模块 依赖
# 安装 可视化 相关依赖
# 安装所有 backends (Native, OpenCompass,
VLMEvalKit, RAGEval)

安装wandb

wandb官网: https://wandb.ai/site

安装wandb:

1
pip install wandb

SwanHubX/SwanLab: ⚡️SwanLab - an open-source, modern-design AI training tracking and visualization tool. Supports Cloud / Self-hosted use. Integrated with PyTorch / Transformers / LLaMA Factory / veRL/ Swift / Ultralytics / MMEngine / Keras etc.

与其类似,一个开源、现代化设计的深度学习训练跟踪与可视化工具

参考资料

DIY你的AI梦中情人?Qwen3微调手把手教你!_哔哩哔哩_bilibili

通俗易懂理解全量微调和LoRA微调_哔哩哔哩_bilibili

通俗易懂理解大模型预训练和微调_哔哩哔哩_bilibili

3.四大微调框架及微调硬件环境介绍_哔哩哔哩_bilibili

如何把你的 DeePseek-R1 微调为某个领域的专家?(实战篇)_哔哩哔哩_bilibili

一文详解:8种常见的大模型微调方法,看这篇就够了!-CSDN博客

什么是 Tokenizer?

Tokenizer(分词器)可以将原始文本(raw text)转换为模型能够理解的数字序列,在模型输入和输出的两个主要阶段中发挥重要作用:

模型输入(编码 Encode)阶段

  1. 分词(Tokenize)

    将文本拆分为词元(Token),常见的分词方式包括字级、词级、子词级(如 BPE、WordPiece)、空格分词等。

    1
    2
    输入: "你好"
    分词: ["你", "好"]
  2. 映射(Mapping)

    将每个词元映射为词汇表中的唯一 ID,生成的数字序列即为模型的输入。

    1
    2
    分词: ["你", "好"]
    映射: [1001, 1002]

模型输出(解码 Decode)阶段

  1. 反映射(De-mapping)

    模型输出的数字序列通过词汇表映射回对应的词元,二者是一一对应的关系。

    1
    2
    输出: [1001, 1002]
    反映射: ["你", "好"]
  2. 文本重组

    将解码后的词元以某种规则重新拼接为完整文本。

    1
    2
    反映射: ["你", "好"]
    重组: "你好"

直观感受

访问 Tiktokenizer,通过右上角选取不同的 Tokenizer 进行尝试

词汇表

两种常见的构建词汇表的方法:

  • BPE(Byte-Pair Encoding):用于 GPT、GPT-2、RoBERTa、BART 和 DeBERTa 等模型。
  • WordPiece:用于 DistilBERT、MobileBERT、Funnel Transformers 和 MPNET 等模型。

BPE

BPE(Byte Pair Encoding,字节对编码)在 NLP 里是一种贪心式的子词(subword)分词算法。 理解:从“字符”开始,反复把出现次数最多的相邻字符对合并成新的符号,并加入词汇表,直到达到预设的词汇表大小。

为什么可以处理 OOV(Out-Of-Vocabulary)情况

因为所有词汇都是由字符或词根组成的,通过对单个字符的学习,可以组成oov的词汇

为什么需要词汇表

编码时,从文本到模型:需要将文本分词为 Tokens,再通过词汇表将 Tokens 转换为 Token IDs,再传给transformer

解码时,从模型到文本:需要通过词汇表Token IDs 转换为 Tokens,再把Tokens 拼接为文本

步骤
  1. 初始化词汇表 V
    • V 包含语料库中的所有唯一字符,即单词字符的集合。
  2. 统计字符对的频次
    • 对于每个单词的字符序列,统计相邻字符对的出现频次。
  3. 找到频次(Score)最高的字符对并合并
    • 选择出现频率最高的字符对 (x, y),将其合并为新符号 xy
  4. 更新词汇表并重复步骤 2 到 4
    • 将新符号添加到词汇表 V = V ∪ {xy}
    • 更新语料库中的单词表示,重复统计和合并过程,直到满足停止条件(例如,词汇表达到预定大小)。

示例

我们需要将语料库(corpus)的文本拆分为单词,假设当前语料库包含的单词和对应频次如下:

1
("low", 5), ("lower", 2), ("newest", 6), ("widest", 3)

步骤 1:初始化词汇表

将单词拆分为字符序列

1
2
3
4
("l", "o", "w"), 5  
("l", "o", "w", "e", "r"), 2
("n", "e", "w", "e", "s", "t"), 6
("w", "i", "d", "e", "s", "t"), 3

词汇表 V

1
{'l', 'o', 'w', 'e', 'r', 'n', 's', 't', 'i', 'd'}

步骤 2:统计字符对的频次

1
2
3
4
5
6
7
8
9
10
11
12
字符对频次统计结果:
('l', 'o'): 7 # 5 (low) + 2 (lower)
('o', 'w'): 7 # 5 (low) + 2 (lower)
('w', 'e'): 8 # 2 (lower) + 6 (newest)
('e', 'r'): 2
('n', 'e'): 6
('e', 'w'): 6
('e', 's'): 9 # 6 (newest) + 3 (widest)
('s', 't'): 9 # 6 (newest) + 3 (widest)
('w', 'i'): 3
('i', 'd'): 3
('d', 'e'): 3

步骤 3:找到频次最高的字符对并合并

选择频次最高的字符对

  • ("e", "s")("s", "t"),频次均为 9。可以任选其一进行合并,假设选择排序第一的: ("e", "s")

合并 ("e", "s") 为新符号 es

记录合并操作

1
Merge 1: ("e", "s") -> "es"

步骤 4:更新词汇表并重复

更新单词序列

1
2
3
4
("l", "o", "w"), 5  
("l", "o", "w", "e", "r"), 2
("n", "e", "w", "es", "t"), 6
("w", "i", "d", "es", "t"), 3

更新词汇表 V

1
{'l', 'o', 'w', 'e', 'r', 'n', 's', 't', 'i', 'd', 'es'}

重复步骤 2 到 4,直到达到预定的词汇表大小

WordPiece

WordPiece 是 Google 在 2016 年为语音识别与 BERT 提出的子词(subword)分词算法,可看作 BPE 的“似然改进版”。理解:“用概率贪心而不是频次贪心,从字符开始逐步合并子词。”

与 BPE 不同,WordPiece 的 Score 由字符对频次与其组成部分频次的比值决定,定义 Score:

$$ \text{Score}_{\text{WordPiece}}(x, y) = \frac{\text{freq}(xy)}{\text{freq}(x) \times \text{freq}(y)} $$

其中, freq(x), freq(y)freq(xy) 分别表示符号 x, y 和它们合并后的符号 xy 的频次。

步骤
  1. 初始化词汇表 V
    • 与 BPE 相同, V 包含语料库中的所有唯一字符,但处理方式略有不同:对于每个单词,除了首个字符外,其他字符前都加上 ## 前缀。
  2. 统计字符对的频次及 Score
    • 对于每个可能的字符对 (x, y),计算 freq(x), freq(y), freq(xy),并计算 Score。
  3. 找到 Score 最高的字符对并合并
    • 选择 Score 最高的字符对 (x, y),将其合并为新符号 xy,注意:
      • 如果第二个符号以 ## 开头,合并时去掉 ## 前缀再进行连接。
      • 新符号是否以 ## 开头,取决于第一个符号是否以 ## 开头。
  4. 更新词汇表并重复步骤 2 到 4
    • 将新符号添加到词汇表 V = V ∪ {xy}
    • 更新语料库中的单词表示,重复统计和合并过程,直到满足停止条件。

映射(Mapping)

以 BPE 为例,最终词汇表 V 中的 Token 和对应的频次分别为:

1
2
3
4
5
6
7
8
9
10
vocab = {
'lo': 7,
'w': 16,
'e': 8,
'r': 2,
'n': 6,
'est': 9,
'i': 3,
'd': 3
}

输出

1
2
Token to ID: {'lo': 0, 'w': 1, 'e': 2, 'r': 3, 'n': 4, 'est': 5, 'i': 6, 'd': 7}
ID to Token: {0: 'lo', 1: 'w', 2: 'e', 3: 'r', 4: 'n', 5: 'est', 6: 'i', 7: 'd'}

当然,也可以根据频次或者其他规则进行特殊处理。

以上是编码部分的概述,实际上在文本预处理的时候还会增加特殊标记,但这些以及后续的解码部分大多是一些文本处理的规则,这里就不过多赘述了,Tokenizer 之间的核心差异在于使用的分割方法和词汇表的构建策略。

transformer中的分词

在 Transformers 中,分词(tokenization) 实际上包含以下几个步骤:

  1. 标准化(Normalization):对文本进行必要的清理操作,例如删除多余空格或重音符号、进行 Unicode 标准化等。
  2. 预分词(Pre-tokenization):将输入拆分为单词。
  3. 通过模型处理输入(Running the input through the model):使用预分词后的单词生成一系列词元(tokens)。
  4. 后处理(Post-processing):添加分词器的特殊标记,生成注意力掩码(attention mask)和词元类型 ID(token type IDs)。

流程图如下

image-20250808100051423

注意力掩码(Attention Mask)和词元类型 ID (Token Type IDs)是什么?

image-20250808100813881

1️⃣ 注意力掩码(Attention Mask) • 目的:告诉模型“哪些位置可以被看到”,其余位置直接屏蔽。 • 典型场景: – 自注意力里做 padding 掩码:把 <pad> 对应的位置设为 −∞,softmax 后权重=0。 – 解码器自回归掩码:生成任务用下三角掩码,避免第 i 个 token 看到未来 token。

2️⃣ 词元类型 ID(Token Type IDs,也叫 Segment IDs) • 目的:区分同一次输入里不同句子或段落,让模型知道“这段属于 A,那段属于 B”。 • 典型场景: – BERT 做句子对分类(NSP):[CLS] 句子A [SEP] 句子B [SEP] → TypeID = 0 0 0 0 1 1 1。 – RoBERTa、GPT 等单句模型则不需要 Token Type IDs。

注意力掩码确保模型只关注实际的词元,忽略填充部分,从而避免无效的计算:

  • 1:表示模型应关注的词元(Tokens)
  • 0:表示模型应忽略的词元(通常是填充 padding 的部分)。

词元类型 ID 用于区分输入中的不同句子或段落:

  • 0:表示第一个句子的词元。
  • 1:表示第二个句子的词元。

CLS,SEP,PAD都是什么意思

[CLS](Classification),作用:对应位置的隐藏状态被当作整句/句对的“整体表示”,用来接分类头做句子级任务(情感分类、NLI 等)。

[SEP](Separator),作用:让模型知道分段 / 句子边界,配合 Token Type IDs 区分句子 A 和句子 B。

[PAD](padding token)的作用是 批量训练时把不同长度的序列补齐到同一长度,让张量可以堆叠成规整的矩阵;模型在计算注意力时通过 Attention Mask 把 [PAD] 对应的位置屏蔽掉,不让它们影响有效 token 的表示。

参考资料

AI-Guide-and-Demos-zh_CN/Guide/21. BPE vs WordPiece:理解 Tokenizer 的工作原理与子词分割方法.md at master · Hoper-J/AI-Guide-and-Demos-zh_CN

为什么用redis

Redis通过 RedisSessionManager 类来管理用户会话,存储结构如下:

1
2
3
4
5
6
7
session:{user_id} -> {
"session_id": "会话ID",
"status": "idle|running|interrupted|completed|error",
"last_response": "上次智能体响应",
"last_query": "用户上次查询",
"last_updated": "最后更新时间戳"
}
image-20250809232853613

主要功能

  • 会话创建与维护 :为每个用户创建唯一会话,支持会话超时自动清理
  • 状态跟踪 :实时跟踪智能体执行状态(空闲、运行中、中断、完成、错误)
  • 中断恢复支持 :当智能体需要人工干预时,Redis保存中断状态,支持后续恢复执行
  • 用户管理 :统计活跃用户数量,管理多用户并发访问

与PostgreSQL的分工

  • Redis :负责临时会话状态和实时数据(快速读写)
  • PostgreSQL :负责智能体的长期记忆存储(通过LangGraph的checkpointer)

为什么不使用pgsql完成对状态的存储

频繁读写 :会话状态需要频繁更新(每次请求都要更新状态),PostgreSQL的磁盘I/O比Redis内存操作慢很多4

短期记忆(PostgreSQL + LangGraph Checkpointer)

临时状态记忆(Redis)

redis实现状态存储业务逻辑总览图

image-20250806164348663

使用redis的根本逻辑:存储对话的状态,当出现由工具调用或者客户端崩溃导致的中断时,可以存储状态在redis,在开始对话时,通过session_id获取redis的状态,并根据状态判断是要恢复中断还是正常对话

存储的redis(调用invoke_agent接口):开始(创建)对话时要根据会话user_id获取或创建redis;再调用agent后,根据响应是否存在status字段是否是”interrupt”,判断是否有终端,最后更新redis状态

恢复的redis(调用resume_agent接口):获取redis状态,并根据请求的恢复内容,使用Command命令恢复agent,最后更新redis

image-20250809233037563

redis类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 初始化异步 Redis 连接和会话配置
def __init__(self, redis_host, redis_port, redis_db, session_timeout):
self.redis_client = redis.Redis(
host=redis_host,
port=redis_port,
db=redis_db,
decode_responses=True
)
self.session_timeout = session_timeout # 会话过期时间(秒)

# 关闭 Redis 连接
async def close(self):
await self.redis_client.close()

方法名 作用 输入参数 返回值 备注
__init__ 建立与 Redis 的异步连接并设置会话超时 redis_host, redis_port, redis_db, session_timeout - decode_responses=True 使 Redis 返回字符串而非字节
close 优雅关闭 Redis 连接 - - 异步方法,需 await
create_session 为指定用户新建(或覆盖)会话记录 user_id, 可选 session_id, status, last_query, last_response, last_updated str:生成的 session_id 会话键格式:session:{user_id};过期时间为 session_timeout
get_session 读取指定用户的完整会话字典 user_id dictNone 自动将 JSON 里的 last_response 反序列化为 AgentResponse 对象
update_session 增量更新已有会话的字段 user_id, 可选 status, last_query, last_response, last_updated boolTrue 更新成功,False 用户不存在 更新后刷新过期时间
delete_session 删除单个用户的会话 user_id boolTrue 删除成功 直接删除 session:{user_id}
get_session_count 计算当前活跃会话总数 - int 使用异步扫描 session:* 键空间
get_all_user_ids 取出所有已创建会话的 user_id - List[str] 同样基于 session:* 扫描
user_id_exists 快速判断某用户是否已有会话 user_id bool 利用 EXISTS 命令

安装redis

linux系统

1
2
3
4
5
6
sudo apt update
sudo apt install -y redis-server
# 启动 Redis 服务
sudo service redis-server start
# 检查 Redis 服务状态
sudo service redis-server status

docker

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# Docker Compose 配置文件,用于启动 Redis 服务
# 该配置为 FastAPI 应用提供 Redis 后端,支持分布式会话管理
version: '3.8'

services:
redis:
# 使用官方 Redis 镜像
image: redis:latest
# 服务名称
container_name: redis
# 映射 Redis 默认端口到主机
ports:
- "6379:6379"
# 持久化存储配置(可选)
volumes:
- redis-data:/data
# 确保容器在重启时自动启动
restart: unless-stopped
# 健康检查:验证 Redis 服务是否正常运行
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# 网络配置
networks:
- app-network

# 定义持久化存储卷
volumes:
redis-data:
name: redis-data

# 定义网络
networks:
app-network:
driver: bridge
1
docker run -d  --name redis -p 6379:6379  -v redis-data:/data redis:latest

文件处理阶段

  1. 使用libreoffice将doc,docx文件处理成pdf文件,方便后续使用mineru进行提取
  2. 完成mineru的docker本地部署;搭建fastapi服务,与项目容器构建自定义网络,方便后续服务调用;使用locust完成对mineru的并发性能测试,和吞吐量测试
  3. 对mineru提取的html格式的表格进行预处理工作,将其转化成md格式,方便后续分块,节省tokens

部分技术细节

mineru提取效果说明

可以完整提取表格与图片,将图片以相对链接形式储存在images文件夹下;可以完成pdf与扫描件的提取,可以实现对图片中文字的识别;输出符合人类阅读顺序的文本,适用于单栏、多栏及复杂排版;删除页眉、页脚、脚注、页码等元素,确保语义连贯

目前问题:仍无法实现对多级标题的识别

mineru的fastapi启动指令

1
MINERU_MODEL_SOURCE=local CUDA_VISIBLE_DEVICES=1,2,3 mineru-api --host 0.0.0.0 --port 30000 --dp-size 3 --enable-torch-compile

MinerU支持通过sglang的多GPU并行模式来提升推理速度。

  • 如果您有超过多张显卡,可以使用sglang的多卡并行模式来增加吞吐量:--dp-size 2
  • 同时您可以启用torch.compile来将推理速度加速约15%:--enable-torch-compile

注意设置环境变量MINERU_MODEL_SOURCE=local CUDA_VISIBLE_DEVICES=1,2,3保证模型本地加载与调用指定gpu

mineru容器启动指令

1
2
docker run -d --name mineru-server --gpus all --shm-size 32g -p 30000:30000 --ipc=host -v /aisys/:/aisys/ --network network_test mineru-sglang:latest tail -f /dev/null
docker start mineru-server

mineru三种后端模式测试

pipeline (默认后端) ,vlm-sglang-engine,vlm-sglang-client

项目中使用的是vlm-sglang-engine,原因如下,pipeline应用场景更多是仅能cpu推理,解析速度大大落后与vlm模式,而我们gpu资源充足,自然不考虑;vlm-sglang-client应用场景更多是有SGLang服务器,这样客户端既可以不用安装sglang,同样不符合我们的条件

mineru并发与吞吐量测试

测试场景:10页的pdf,50用户并发

工具:locust

测试结果

对于推理模型的吞吐量,在3个gpu开启数据并行的情况下,平均每秒单个gpu处理tokens为1500左右

gpu状态如上:显存几乎打满 85–87 %,GPU 利用率 59–63 %,功耗 170–188 W / 350 W

压测结果如下,选取部分指标

指标 数值 通俗解释
平均响应时间 241 秒4 分钟 上传一个 PDF → 拿到解析结果,平均要等 4 分钟。
中位数 215 秒3.6 分钟 一半请求在 3.6 分钟内完成。
95% 用户 361 秒6 分钟 最慢的 5% 要等 6 分钟以上。
吞吐量 0.18 req/s 这台 MinerU 每分钟只能处理约11 个 PDF

分块阶段

当前主流的分块方式共五种:固定长度分块,语义分块,递归分块,文档结构分块,llm分块。

最后项目我选择了递归分块,原因如下:

  1. mineru无法正确提取md文档结构,因此我舍弃了文档结构分块
  2. 测试了agentic chunk(其主要思想是,先进行初步分段,按照长度或递归,然后让大模型生成这一段的概要,将段与段合并生成块),但是测试下来,我们这个一个文档的内容同质化很严重,基本上都分到一块里了,我猜测语义分块也是这种效果,因此舍弃
  3. 我们的文档中存在大量表格,我在预处理阶段增加了对表格的首尾标记,使用递归分块可以更好的保留这些结构

检索阶段

基于langchain_elasticsearch完成了向量搜索,bm25,混合检索,模糊检索的检索函数的编写。

结果如下:

  1. 混合检索elasticsearch需要付费使用
  2. bm25的多字段搜索有三种模式且字段的权重可以调整,后续评估时调整进行测试
  3. 检索的效果需要后续进行rag评估时判定

elasticsearch相关

完成对项目es模块的熟悉阅读;实现对elasticsearch的连接与字段的构建与存入。

关于字段的存储,我选取了report_name,report_url,page_content

相关细节

阅读elasticsearch代码相关记录:
  1. embedding_model.select_embeddings_model:根据指定的模型名称加载
  2. make_es_vector_store
    1. docs_url = pd.read_excel(‘docs_new.xlsx’),从excel加载url并进行数据处理,保存筛选后的ur
    2. 完成文件加载测试:xizhang/retrival/docfile_test/test.py;
    3. 初始化es,使用elastic_search.load_es_index加载存储索引
    4. 完成测试elasticsearch连接与索引构建(索引名zxj_test):/aisys/repo_dev/xizhang/retrival/elasticsearch_test/test_es_connect.py,/aisys/repo_dev/xizhang/retrival/elasticsearch_test/test_add_es.py
    5. 顺序批量处理文件:共16000多份,每十份为一批进行处理,使用download_pdf.py进行文件下载,使用,使用vector_base.rewrite_file对文件进行处理,这里可以修改代码,增加对mineru处理pdf的markdown文件的处理,返回Document对象列表;
  3. elastic_search

重写了检索策略的函数,包括BM25,KNN,混合搜索

  1. elastic_retriever创建Elasticsearch检索器:根据搜索类型选择对应的查询函数,创建Elasticsearch检索器ElasticsearchRetriever.from_es_params

  2. retrievers

    1. 定义函数select_retriever,根据指定的名称选择并返回相应的检索器,目前只有bm25
文档结构
1
2
3
4
5
6
7
8
doc = Document(
page_content=text_content,
metadata={
"report_name": folder_name,
"report_url": report_url,
"chunk_id": chunk_id
}
)

rag评估

待补充

后续优化思考

  1. 重排序部分我没有做过,不知道怎么做,也不知道效果会怎样(我感觉在我们这个场景应该提升有限,听你说也是这样)
  2. 如何存入数据库的部分,可能也是优化的点,比如可以尝试agentic rag这种,在存入数据库前再进行一步处理
  3. 还有一个点我比较好奇,我们项目在召回后是如何处理的,就是上下文拼接吗

日志级别

级别 方法 用途
DEBUG logging.debug() 调试信息
INFO logging.info() 普通信息
WARNING logging.warning() 警告信息
ERROR logging.error() 错误信息
CRITICAL logging.critical() 严重错误

python默认只会打印warning以上级别的日志,可通过basicConfig进行设置,如下

1
2
3
4
5
6
7
8
9
# 基础配置
logging.basicConfig(level=logging.DEBUG)

# 记录不同级别的日志
logging.debug("这是一个DEBUG级别的日志")
logging.info("这是一个INFO级别的日志")
logging.warning("这是一个WARNING级别的日志")
logging.error("这是一个ERROR级别的日志")
logging.critical("这是一个CRITICAL级别的日志")

格式化log并输出

我们可以使用全局配置,完成log的格式化和输出成文件,如下

1
logging.basicConfig(level=logging.DEBUG,format='%(asctime)s - %(levelname)s - %(message)s',filename='basic.log',filemode='w')

同样,我们可以自定义logger

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 创建自定义logger
logger = logging.getLogger('my_app')
logger.setLevel(logging.DEBUG)

# 清除之前的处理器
logger.handlers.clear()

# 创建文件处理器
file_handler = logging.FileHandler('logs/my_app.log', encoding='utf-8')
file_handler.setLevel(logging.DEBUG)

# 创建控制台处理器
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.WARNING)

# 创建不同的格式器
file_formatter = logging.Formatter(
'%(asctime)s | %(name)s | %(levelname)s | %(funcName)s:%(lineno)d | %(message)s'
)
console_formatter = logging.Formatter(
'🚨 %(levelname)s: %(message)s'
)

file_handler.setFormatter(file_formatter)
console_handler.setFormatter(console_formatter)

# 添加处理器
logger.addHandler(file_handler)
logger.addHandler(console_handler)

# 测试不同级别的日志
logger.debug("调试信息 - 只写入文件")
logger.info("普通信息 - 只写入文件")
logger.warning("警告信息 - 控制台和文件都有")
logger.error("错误信息 - 控制台和文件都有")

异常捕获

1
2
3
4
5
6
7
8
try:
result = divide(10, 0)
except ZeroDivisionError as exc:
# 方式 1:记录异常对象
logger.error("除零异常发生: {}", exc)

# 方式 2:记录完整 traceback(推荐)
logger.exception("捕获到异常,详情如下")

loguru的常用使用方法

基础用法

1
2
3
4
5
6
7
from loguru import logger

logger.debug("这是 debug")
logger.info("这是 info")
logger.warning("这是 warning")
logger.error("这是 error")
logger.critical("这是 critical")

输出到文件 logger.add(“app.log”)

过滤级别 logger.add(“app.log”, level=“WARNING”)

移除默认控制台输出 logger.remove()

参考资料

[Python] logging模块怎么用_哔哩哔哩_bilibili

[Python] 打印log神器 —— loguru_哔哩哔哩_bilibili

0%