智能客服微调项目实战

# 智能客服微调项目实战

customer-service-ai-agents (opens new window) 将 Qwen3-8B 的 QLoRA 微调、FastAPI 模型服务和 Mastra 客服 Agent 连接起来。模型负责理解与回答,工具负责查询和执行,微调让客服行为更稳定,而不是让模型记住实时订单。

先抓住三件事:训练得到什么文件,接口怎样加载它,用户问题怎样经过模型和工具得到回答。 本文对应项目代码 c9e32df;训练数据和业务服务包含模拟内容,训练效果的证据边界在后文说明。

# 训练和聊天是两条不同的流程

流程 输入 产物 什么时候执行
训练 Qwen3-8B 基座、客服样本、训练配置 LoRA Adapter 及训练记录 准备或更新模型时
聊天推理 基座、Adapter、当前对话 回答或工具调用请求 用户每次提问时

训练结束后,不需要把 600 条样本在每次聊天时重新传给模型。部署加载的是基座权重与训练出的 Adapter;用户发来的问题则作为本次输入。

# 一次用户请求如何完成

React 客服页面 :4173
       ↓
Mastra 客服 Agent :4111
       ├→ FastAPI 模型服务 :8000 → Qwen3-8B 基座 + LoRA Adapter
       └→ 业务工具服务 :8001 → 订单、物流、商品、库存、修改地址

产品 RAG :8002 是预留接口,默认未启用
1
2
3
4
5
6
7

把查物流拆成一次具体对话就容易理解:用户说查快递但没提供订单号,客服先追问;用户补齐后,Agent 调用物流工具;工具返回运输中,模型再把结果组织成自然语言。具体是否调用、怎样补参数,也受到项目中的路由规则影响,不能全部归功于微调。

模型说已查询,不代表查询真的发生。确认是否执行要看工具调用记录及返回结果;工具失败时,最终回答也必须说明尚未查到。

# 项目各部分在哪里

文件或目录 应该看什么
data/dataset_info.json 训练集名称怎样映射到 JSON 文件及字段
llama-factory/configs/qwen3_8b_qlora_mock.yaml 基座、LoRA、4 bit 量化、数据集与训练参数
llama-factory/TRAINING_REPORT.md 实际训练记录、硬件、步数和评估限制
customer-http-demo/app/settings.py 模型位置、Adapter 位置、输入与输出预算
customer-http-demo/app/model_service.py 基座加载、Adapter 挂载、模板、生成与流式处理
customer-http-demo/app/main.py 健康检查和 OpenAI 兼容 HTTP 接口
customer-agents/src/mastra/agents/customer-service-agent.ts Agent 指令、模型接入和工具组织
customer-agents/src/mastra/tools/ 工具输入校验、超时与业务 API 调用
customer-agents/src/mastra/storage.ts 会话历史存储配置

# 训练用了什么数据和硬件

项目包含 48 条快速验证数据,以及 600 条客服合成数据。后者由 24 类意图扩展得到,每类 25 条;它适合验证训练链路,但不能用数量直接证明覆盖了真实业务。

报告记录的硬件是 RTX 4070 SUPER 12 GB,模型为 Qwen3-8B,序列长度为 1024。是否能在另一台设备复现,还取决于驱动、框架、精度、配置和可用显存;不能推广为所有 8B 训练都只需 12 GB。

# 数据如何注册

项目采用 Alpaca 数据格式。data/dataset_info.json 中的对应条目如下:

{
  "customer_service_zh_mock": {
    "file_name": "customer_service_zh_mock.json",
    "columns": {
      "prompt": "instruction",
      "query": "input",
      "response": "output",
      "system": "system"
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11

例如配置写 dataset: customer_service_zh_mock、dataset_dir: data,框架就查找 data/dataset_info.json 中这个名称,再读取 data/customer_service_zh_mock.json。columns 告诉框架哪一列是问题、上下文和示范回答。只把 JSON 放进目录而没有正确注册,框架不会自动知道该怎么训练。

# QLoRA 配置怎样读

下面保留项目主要训练参数,将机器专属路径改为相对路径,并为新的实验使用独立输出目录。需要先在兼容的 CUDA 环境安装 LLaMA-Factory,准备完整基座到 models/Qwen3-8B,并从项目根目录执行。

# 完整基座目录,不是 Adapter 目录;保存实际下载的 revision 便于复现。
model_name_or_path: models/Qwen3-8B
quantization_bit: 4          # 冻结基座低比特加载,降低显存占用。
quantization_method: bnb     # 使用 bitsandbytes 量化后端。
upcast_layernorm: true       # 层归一化使用更高精度,改善数值稳定性。

stage: sft                  # 从输入和示范回答进行监督训练。
do_train: true
finetuning_type: lora        # 只训练附加的 LoRA 参数。
lora_rank: 16
lora_alpha: 32               # 常见缩放为 alpha / rank,此处为 2。
lora_dropout: 0.05           # 对 LoRA 分支使用 dropout,辅助正则化。
lora_target: all             # 按框架规则选择支持的线性模块,不是训练全部原权重。

dataset: customer_service_zh_mock
dataset_dir: data
template: qwen3_nothink      # 对应 Qwen3 的非思考对话训练模板。
cutoff_len: 1024             # 截断前要检查答案是否会被切掉。
max_samples: 600
preprocessing_num_workers: 4
dataloader_num_workers: 0    # 项目配置,便于控制本地数据加载进程。

output_dir: llama-factory/outputs/qwen3-8b/qlora/customer-service-study
overwrite_output_dir: false # 每次实验使用新目录,避免覆盖已有结果。
logging_steps: 5
save_steps: 25
save_total_limit: 2          # 限制中间检查点数量,不是永久保留所有版本。
save_only_model: false       # 保存续训需要的训练状态。
report_to: none              # 不自动上报到外部实验平台。

per_device_train_batch_size: 1
gradient_accumulation_steps: 16  # 单卡有效 batch 通常约为 16。
learning_rate: 1.0e-4
num_train_epochs: 3.0
max_steps: 102               # 正数时以更新步数限制为准,不能再仅按 epoch 估时。
lr_scheduler_type: cosine
warmup_steps: 10
bf16: true                  # 需要硬件和框架支持 BF16。
gradient_checkpointing: true # 用额外重算减少激活保存。

val_size: 0.1               # 随机验证划分可跑通流程,不代替独立业务测试。
per_device_eval_batch_size: 1
eval_strategy: steps
eval_steps: 25
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
43
44

保存为 llama-factory/configs/customer_service_study.yaml 后:

第一次读这份配置,先看四组即可:model_name_or_path 决定训练谁,dataset 决定拿什么教它,lora_* 与量化配置决定怎样省资源,output_dir 决定结果存哪里。其余训练参数按 参数说明 理解,不需要先记住每一个字段。

# 先确认环境中的 CLI 版本与项目依赖兼容,再在项目根目录启动训练。
llamafactory-cli train llama-factory/configs/customer_service_study.yaml
1
2

这条命令会真正占用 GPU 并写入训练结果。遇到中断,先确认检查点是否包含优化器等状态,再使用框架的 resume_from_checkpoint 续训;仅重新加载 Adapter 是继续训练权重,不等于完整恢复原来的训练进度。

训练后检查输出目录中是否有 Adapter 权重和配置、日志是否正常结束,再做一次加载测试。保存成功只是得到可加载参数,是否回答得更好还要用独立测试集确认。

# 推理时如何加载基座和 Adapter

项目的 model_service.py 使用 Transformers 加载基座,再通过 PEFT 挂载 Adapter。下面是对应核心流程的独立脚本,需要 torch、transformers、peft、bitsandbytes 的兼容版本和支持的 GPU;路径均由环境变量提供。

import os
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import PeftModel

# 指向已经准备好的基座与训练输出,不在运行时隐式下载陌生权重。
base_path = os.environ["CUSTOMER_MODEL_PATH"]
adapter_path = os.environ["CUSTOMER_ADAPTER_PATH"]
tokenizer = AutoTokenizer.from_pretrained(base_path, local_files_only=True)

# 项目推理配置显式使用 NF4;存储是 4 bit,计算使用 BF16。
quantization = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=False,
    bnb_4bit_compute_dtype=torch.bfloat16,
)
base = AutoModelForCausalLM.from_pretrained(
    base_path,
    local_files_only=True,
    device_map="auto",
    quantization_config=quantization,
    dtype=torch.bfloat16,
)
model = PeftModel.from_pretrained(base, adapter_path, is_trainable=False)
model.eval()

messages = [
    {"role": "system", "content": "你是客服助手,缺少订单号时先询问,不得编造物流。"},
    {"role": "user", "content": "我的快递到哪里了?"},
]
# 模板需与训练一致;原始 Qwen3 在此关闭思考模式。
prompt = tokenizer.apply_chat_template(
    messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
)
inputs = tokenizer(prompt, return_tensors="pt", add_special_tokens=False)
inputs = {key: value.to(model.device) for key, value in inputs.items()}

# 不计算梯度;只解码新生成部分,避免把用户输入一起打印为回答。
with torch.inference_mode():
    output = model.generate(**inputs, max_new_tokens=128, do_sample=False)
answer_ids = output[0, inputs["input_ids"].shape[1]:]
print(tokenizer.decode(answer_ids, skip_special_tokens=True))
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
43

这是单次模型推理,不包含订单查询。完整客服链路由 Mastra 调用工具完成,不应要求这个脚本直接回答真实订单状态。

# 从模型服务到 Agent

FastAPI 暴露 /v1/chat/completions,Mastra 使用 OpenAI 兼容客户端接入本地服务。OpenAI 兼容说的是请求和响应协议,实际模型仍然是本地 Qwen3-8B。

对前端来说,它仍然向客服 Agent 发消息,不需要知道 Adapter 文件在哪。只有模型服务负责加载这些文件;如果训练了新 Adapter,更新模型服务的加载配置并重新验证即可,不必把训练代码塞进前端或每次对话流程。

服务使用 TextIteratorStreamer 配合后台生成线程提供流式输出,并处理工具调用 JSON 与普通文本的区别。生成过程由进程内锁串行保护;它只能限制同一个进程,不能保证多个 Uvicorn worker 共享 GPU 时的全局并发安全。

项目还有明确的代码规则,例如商品目录请求的工具选择和库存结果处理。因此测试必须区分规则贡献与模型贡献,不能把最终客服效果全部归因于 QLoRA。

工具层使用 Zod 校验输入并设置请求超时。修改地址等写操作仍需后端校验用户与订单归属,Prompt 里的限制不等于生产级鉴权已经完成。

# 会话记忆和 RAG 实际做到哪一步

会话历史使用 LibSQL,配置保留最近 8 条消息参与上下文,semanticRecall 为 false。这是近期对话记忆,不是基于 Embedding 的语义检索。

产品 RAG 默认 ENABLE_PRODUCT_RAG=false,端口和调用位置属于预留接口,不能说项目已经完成知识库问答。RAG 的实现与评测可以接着学习 RAG 原理与工程实践。

# 如何启动和验证已有项目

项目根目录的 pnpm 命令调用 PowerShell 脚本,按其 Windows 环境约定使用;不是可以原样在任意 Mac 上启动的 GPU 服务。

# 安装项目前先检查 README、依赖和硬件环境;首次执行会准备依赖。
pnpm setup

# 启动业务、模型、Agent 和前端服务,等待全部就绪提示。
pnpm start

# 只查询端口状态,不会启动或停止服务。
pnpm status

# 结束验证时,停止由项目脚本启动的服务。
pnpm stop
1
2
3
4
5
6
7
8
9
10
11

端口监听不代表模型加载完成,还要检查模型服务 /health 和真实请求。测试至少包括正常咨询、缺少订单号、工具超时、未知商品和越权修改;流式输出应检查首字延迟、最终消息及连接断开。

# 训练报告能证明什么

报告记录 600 条合成数据训练完成 102 步,最终验证 Loss 约为 0.0325,并保存了 Adapter。这能作为训练链路完成的记录,不能证明真实客服准确率已经达到某个水平。

报告也指出训练和验证数据共享模板,结果可能偏乐观。还需独立测试比较基座与 Adapter,并保持 Prompt、工具和路由规则一致;业务 API 为模拟实现的事实也不能省略。

# 把一次查单拆开,区分模型能力与系统能力

环节 输入与结果 谁对正确性负责
理解意图 用户说查快递,识别需要物流信息 模型与项目路由规则
补齐参数 没有订单号就追问;有了再校验格式 模型收集,工具 Schema 校验结构
查询事实 工具向业务 API 请求订单物流 业务服务提供事实;生产需验证订单归属
整理回答 将运输中等字段转成自然语言 模型或已有结果处理规则,不得改写关键事实
记录结果 保存工具调用、错误和本轮回答 Agent 与服务日志,注意脱敏

例如 API 返回超时,正确结果是 “暂时没有查询到,请稍后重试” ,不是根据训练样本生成一条运输状态。Zod 可以检查订单号是不是符合结构的字符串,却不能证明订单属于当前用户;这个关系必须由后端按登录身份查验。

写操作更要区分未知结果和明确失败。修改地址请求超时,服务端可能已经修改成功;生产接入时应使用稳定业务操作 ID 查询或幂等重试,并记录用户确认的地址与版本。这是接入真实业务时需要落实的设计,不代表模拟接口已经具备这些保证。

# 流式输出为什么不等于高并发

流式只是更早把生成内容交给用户;同一张 GPU 上同时推理多少请求,是另一件事。项目用进程内锁保护生成,第二个请求可能先排队,再开始输出。把 Uvicorn worker 数量调大,会让多个进程各自加载模型,也可能重复占用显存,而不是自动获得共享的 GPU 调度。

当前 TextIteratorStreamer 的等待超时也不能当成 GPU 生成的强制停止信号。客户端断开后,应另行设计取消通知、生成停止条件和线程回收;不能宣称关掉网页就一定释放了推理资源。若要扩容,先测排队时间、生成耗时、输入输出长度及显存,再评估有界队列和支持该模型、Adapter 的推理服务。

服务更新 Adapter 时,先验证新模型就绪,再接入请求,保留旧的基座、Adapter 和模板组合。健康接口能响应只能证明部分服务状态;还要用固定客服输入检查真实生成与工具协议,避免加载错版本仍然显示在线。

# 哪些成果可以直接讲,哪些还需要实验

可以具体说明训练配置、Adapter 加载、兼容 HTTP 接口、Mastra 工具组织,以及报告记录的 102 步训练。不能从 0.0325 的验证 Loss 推导 “准确率 96.75%” ,也不能把 600 条合成样本说成 600 条真实客户工单。

后续效果验证应沿用 独立对比实验:先固定工具和路由规则,单独比较基座与 Adapter;再评估完整 Agent 链路。这样才能回答收益来自模型训练、规则还是工具,而不是把三个因素混在一起。

# 面试问答

1. 你做的客服微调项目,具体怎么落地?参考答案

我用 LLaMA-Factory 对 Qwen3-8B 做了 QLoRA 微调,训练模型的客服表达和信息追问能力。部署时由 FastAPI 加载量化基座和 LoRA Adapter,提供兼容的聊天接口,再由 Mastra 组织模型和订单、物流等工具。这样训练负责回答行为,工具负责实时事实,业务规则负责执行边界。

2. 你怎么证明效果是微调带来的?参考答案

训练报告能说明流程跑通,但不能单靠较低的 Loss 证明效果。这个项目还有工具路由和结果处理规则,我需要固定这些条件,用独立测试比较原模型与加载 Adapter 后的模型,重点检查追问、工具参数和事实准确率。合成数据共享模板的问题也要单独控制,不能把模板记忆当成业务泛化。

3. 为什么训练了客服模型,还用 Mastra?参考答案

微调模型负责生成回答,不负责管理业务工具和整个交互过程。Mastra 把模型、会话历史和工具调用组织起来,实际订单与库存由 API 返回。我把模型能力和业务执行分开,这样替换模型或调整工具时不必重新训练整套业务。

4. 这个项目最需要补强的地方是什么?参考答案

首先是独立评测。目前训练记录能证明 QLoRA 链路跑通,但合成数据共享模板,不能直接证明真实客服效果。其次是生产业务边界,模拟工具接入真实订单后,要落实身份、订单归属和写操作幂等。模型服务也要验证排队、断连取消和显存限制,流式返回并不代表这些问题已经解决。

5. 为什么模型服务与客服 Agent 分成两个服务?参考答案

两者资源和职责不同。Python 模型服务管理 GPU、量化基座和 Adapter,Mastra 管理对话、工具和业务流程,通过兼容接口连接。这样换模型或重新训练不必重写业务工具,但会增加网络调用和跨服务排障成本,所以需要统一请求标识、超时和错误协议,而不是只把服务拆开。