智能客服微调项目实战
# 智能客服微调项目实战
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 是预留接口,默认未启用
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"
}
}
}
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
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
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))
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
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 管理对话、工具和业务流程,通过兼容接口连接。这样换模型或重新训练不必重写业务工具,但会增加网络调用和跨服务排障成本,所以需要统一请求标识、超时和错误协议,而不是只把服务拆开。