
网上聊模型调优的文章一搜一大把,但大多数要么是论文翻译,要么是跑通demo就敢写"实战"。今天这篇不一样,是我带着团队做了三个企业级AI项目之后,真正在泥里滚过一遍的经验总结。坑踩了不少,有些能写到文档里,有些只能咽肚子里,尽量都跟大家聊聊。
去年接了个项目,给一家电商公司做智能客服。需求听上去不复杂——用户来问订单状态、退换货政策、商品信息,AI能直接回答,回答不了的转人工。
团队一个礼拜就把原型搭好了,用的是开源模型+RAG,内部测试效果还行,准确率80%出头。客户看了demo很满意,说"上线吧"。
结果上线第一天就炸了。
用户问"我的订单到哪了",AI回了一堆物流行业的发展趋势;用户问"能不能开发票",AI开始解释发票的历史演变。客服主管气得直接打电话过来:"你们这AI是来帮忙的还是来添乱的?"
问题出在哪?通用模型不懂数据,RAG检索精度不够,Prompt没做针对性优化。 这三件事单独拎出来哪个都不算难,但叠在一起就是灾难。
后来我们花了三周做模型微调+RAG优化+Prompt工程三管齐下,准确率拉到93%以上才真正稳定。这篇文章讲的就是这个过程里踩过的坑和总结出来的方法论。
很多人一听"模型调优"就想到微调(fine-tuning),其实微调只是工具箱里的一把锤子,而且不是最趁手的那把。我给团队定了个优先级原则:

一句话总结:能用Prompt解决的别上微调,能用RAG解决的别上微调,微调是最后手段。 不是因为微调不好,而是微调的成本(数据标注、训练资源、效果评估、版本管理)远高于前两者。你得先确认前两步的天花板确实到了,再动微调的念头。
Prompt优化是所有调优手段里投入产出比最高的,但很多人觉得它"太简单了不屑于做"。说实话,我见过太多项目微调做了一堆,结果Prompt写得一塌糊涂的。
拿上面那个电商客服的例子,最开始我们的系统提示词是这样的:
```
你是一个电商客服助手,请回答用户的问题。
```就这?对,就这。准确率80%出头。
后来改成了这样:
```python
SYSTEM_PROMPT = """你是一个专业的电商客服助手,服务于"XX优选"电商平台。
## 你的职责
1. 回答用户关于订单、物流、退换货、商品信息的问题
2. 当用户询问具体订单状态时,引导用户提供订单号
3. 遇到无法确定的问题,诚实告知并建议联系人工客服
## 回答规范
- 基于提供的参考资料回答,不要编造信息
- 回答简洁明了,直接给出用户需要的答案
- 涉及退款/退货等敏感操作时,给出明确的操作步骤
- 语气友善专业,使用"您"称呼用户
## 参考资料使用规则
- 参考资料中有的信息,直接引用
- 参考资料中没有的,说"抱歉,我需要帮您转接人工客服确认"
- 不要把参考资料的原话全部搬出来,提炼关键信息
## 禁止事项
- 不要讨论与电商客服无关的话题(政治、宗教、娱乐等)
- 不要承诺参考资料中没有的优惠政策
- 不要编造订单号或物流信息"""
# 构造Few-shot示例
FEW_SHOT_EXAMPLES = [
{
"role": "user",
"content": "我的订单什么时候到?"
},
{
"role": "assistant",
"content": "您好!请提供一下您的订单号,我帮您查询物流状态。您可以在"我的订单"页面找到订单号,格式类似:XX20240115XXXX。"
},
{
"role": "user",
"content": "订单号是XX20240115001,帮我看看到哪了"
},
{
"role": "assistant",
"content": "好的,已为您查询到订单XX20240115001的物流信息:\n\n当前状态:运输中\n当前位置:杭州转运中心\n预计送达:明天下午\n\n包裹正在派送途中,请您留意短信通知。如超过预计时间未收到,请联系人工客服处理。"
},
]
```就这一改,准确率从80%直接飙到88%,没有任何模型层面的改动。8个百分点的提升,成本几乎为零。
别小看这几个技巧,每一个都是真金白银换来的:
Prompt优化到88%之后,我们分析了一下剩下的12%错误,发现有60%以上是检索环节的问题——要么没检索到相关文档,要么检索到的文档不对。
RAG优化是个系统工程,不是调一个参数就行的。下面这个图展示了完整的数据流和优化点:

标黄的三个节点是我们投入精力最多、收益也最大的优化点。
绝大多数RAG教程都教你怎么按固定长度切分文档,这在实际业务中效果很差。一份产品手册,你按512字符切,可能把一个完整的退换货流程切成了两半,检索的时候只能检索到一半,模型自然回答不全。
我们的做法是按文档结构智能分块:
```python
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import PyPDFLoader
import re
class SmartDocumentSplitter:
"""基于文档结构的智能分块器"""
def __init__(self, chunk_size=512, chunk_overlap=64):
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
# 按标题层级分块,优先在高级标题处切割
self.splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
separators=[
"\n## ", # 二级标题
"\n### ", # 三级标题
"\n#### ", # 四级标题
"\n\n", # 段落
"\n", # 换行
"。", # 中文句号
";", # 中文分号
" ", # 空格
"", # 字符级
],
)
def split(self, documents):
"""分块并增强元数据"""
chunks = self.splitter.split_documents(documents)
for chunk in chunks:
# 提取所属章节标题作为元数据
text = chunk.page_content
section = self._extract_section(text)
chunk.metadata["section"] = section
# 计算内容类型
chunk.metadata["content_type"] = self._detect_content_type(text)
return chunks
def _extract_section(self, text):
"""从文本中提取最近的标题"""
lines = text.strip().split("\n")
for line in reversed(lines):
if line.strip().startswith("#"):
return line.strip().lstrip("#").strip()
return "未分类"
def _detect_content_type(self, text):
"""检测内容类型,辅助后续检索"""
if re.search(r'步骤|流程|操作', text):
return "procedure"
elif re.search(r'定义|是指|表示', text):
return "definition"
elif re.search(r'价格|费用|收费', text):
return "pricing"
elif re.search(r'注意|警告|禁止', text):
return "warning"
else:
return "general"
# 使用示例
splitter = SmartDocumentSplitter(chunk_size=512, chunk_overlap=64)
loader = PyPDFLoader("./docs/退换货政策.pdf")
docs = loader.load()
chunks = splitter.split(docs)
for chunk in chunks[:3]:
print(f"章节: {chunk.metadata['section']}")
print(f"类型: {chunk.metadata['content_type']}")
print(f"内容: {chunk.page_content[:100]}...")
print("---")
```这一步优化之后,检索召回率提升了约15%。因为分块更合理了,每个块都是相对完整的语义单元,embedding的质量自然就上去了。
单一向量检索有个问题:它擅长语义匹配,但对精确关键词(比如订单号、商品SKU)不敏感。用户搜"SKU-A12345的价格",纯向量检索可能把一堆价格相关的文档都召回了,但恰恰漏了包含"SKU-A12345"的那篇。
解决方案是向量检索+关键词检索并行,然后融合排序:
```python
from rank_bm25 import BM25Okapi
import jieba
class HybridRetriever:
"""混合检索器:向量检索 + BM25关键词检索"""
def __init__(self, vector_store, documents, embedder):
self.vector_store = vector_store
self.embedder = embedder
# 构建BM25索引
tokenized_docs = [list(jieba.cut(doc["text"])) for doc in documents]
self.bm25 = BM25Okapi(tokenized_docs)
self.documents = documents
def retrieve(self, query, top_k=10):
"""双路召回 + RRF融合排序"""
# 向量检索
query_vec = self.embedder.embed_query(query)
vec_results = self.vector_store.search(query_vec, limit=top_k)
vec_scores = {r["id"]: r["score"] for r in vec_results}
# BM25关键词检索
tokenized_query = list(jieba.cut(query))
bm25_scores = self.bm25.get_scores(tokenized_query)
# RRF (Reciprocal Rank Fusion) 融合
rrf_k = 60 # RRF平滑参数
rrf_scores = {}
# 向量检索结果排名
vec_ranked = sorted(vec_scores.items(), key=lambda x: x[1], reverse=True)
for rank, (doc_id, _) in enumerate(vec_ranked):
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1.0 / (rrf_k + rank + 1)
# BM25检索结果排名
bm25_ranked = sorted(enumerate(bm25_scores), key=lambda x: x[1], reverse=True)
for rank, (doc_id, _) in enumerate(bm25_ranked[:top_k]):
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1.0 / (rrf_k + rank + 1)
# 按融合分数排序
final_ranking = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
return [doc_id for doc_id, _ in final_ranking[:top_k]]
```多路召回之后,Top-10里面可能混了一些不太相关的。这时候需要一个Reranker做精排,把真正相关的提到前面。
```python
from sentence_transformers import CrossEncoder
class Reranker:
"""基于Cross-Encoder的重排序器"""
def __init__(self, model_name="BAAI/bge-reranker-v2-m3"):
self.model = CrossEncoder(model_name, max_length=512)
def rerank(self, query, documents, top_n=3):
"""对检索结果做精排"""
pairs = [(query, doc["text"]) for doc in documents]
scores = self.model.predict(pairs)
for doc, score in zip(documents, scores):
doc["rerank_score"] = float(score)
documents.sort(key=lambda x: x["rerank_score"], reverse=True)
return documents[:top_n]
```Cross-Encoder比Bi-Encoder慢一个量级(因为要逐对计算),所以只对Top-10做精排,不做全量。实际测试中,加上Rerank之后,最终答案的准确率又提升了3-4个百分点。
Prompt和RAG都优化到位之后,我们的准确率卡在91%左右上不去了。分析剩余的错误,发现主要是两类:
这种领域知识靠Prompt和RAG都补不了,得上微调。
全参数微调成本太高(7B模型全参数微调至少需要4张A100),我们选了LoRA——只训练少量参数,效果接近全参数微调,但成本只有几十分之一。
```python
import torch
from datasets import Dataset
from peft import LoraConfig, get_peft_model, TaskType
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
TrainingArguments,
Trainer,
DataCollatorForSeq2Seq,
)
# ---- 1. 加载模型 ----
model_id = "Qwen/Qwen2.5-7B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype=torch.bfloat16,
device_map="auto",
trust_remote_code=True,
)
# ---- 2. 配置LoRA ----
# LoRA只训练这些低秩矩阵,原模型参数冻结
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=16, # LoRA秩,越大表达能力越强但参数越多
lora_alpha=32, # 缩放系数,一般设为r的2倍
lora_dropout=0.05, # 防过拟合
target_modules=[ # 对哪些层加LoRA
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
bias="none",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出: trainable params: 39,321,600 || all params: 7,621,877,760 || trainable%: 0.52%
# 只训练0.52%的参数!
# ---- 3. 准备训练数据 ----
# 数据格式: [{"instruction": "...", "input": "...", "output": "..."}]
def format_training_data(examples):
"""将业务QA数据格式化为模型训练格式"""
formatted = []
for ex in examples:
conversation = [
{"role": "system", "content": "你是XX优选电商平台的客服助手。"},
{"role": "user", "content": ex["instruction"]},
{"role": "assistant", "content": ex["output"]},
]
text = tokenizer.apply_chat_template(conversation, tokenize=False)
formatted.append({"text": text})
return formatted
# 加载训练数据(实际从业务日志中提取的高质量QA对)
train_data = load_training_examples("./data/train_qa.jsonl")
formatted_data = format_training_data(train_data)
dataset = Dataset.from_list(formatted_data)
def tokenize_fn(examples):
tokenized = tokenizer(
examples["text"],
truncation=True,
max_length=1024,
padding=False,
)
tokenized["labels"] = tokenized["input_ids"].copy()
return tokenized
dataset = dataset.map(tokenize_fn, batched=True)
# ---- 4. 训练 ----
training_args = TrainingArguments(
output_dir="./lora_output",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # 等效batch_size=16
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.1,
logging_steps=10,
save_strategy="epoch",
save_total_limit=3,
bf16=True, # 混合精度训练
gradient_checkpointing=True, # 省显存
report_to="none",
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=dataset,
data_collator=DataCollatorForSeq2Seq(
tokenizer=tokenizer,
padding=True,
return_tensors="pt",
),
)
trainer.train()
# ---- 5. 保存LoRA权重 ----
model.save_pretrained("./lora_output/qwen2.5-7b-ecommerce-lora")
tokenizer.save_pretrained("./lora_output/qwen2.5-7b-ecommerce-lora")
print("LoRA微调完成!")
```代码就上面那些,不复杂。真正费时间的是数据准备。我见过太多团队微调效果不好,最后发现不是方法的问题,是数据的问题。
几条铁律:
微调完了不能直接上线,得有一套评估流程。我们的评估分三层:

三层评估对应三个不同的信心门槛。自动评估筛掉明显有问题的版本,人工评估确认业务效果,A/B测试验证线上真实表现。任何一层不达标都不能往下走,宁可回炉也不要带着问题上线。
三层评估都过了之后,这是我们的最终数据:
指标 | 微调前(Prompt+RAG) | 微调后(LoRA) | 提升幅度 |
|---|---|---|---|
答案准确率 | 88.2% | 93.7% | +5.5% |
转人工准确率 | 76.5% | 91.2% | +14.7% |
平台术语理解 | 62.0% | 95.8% | +33.8% |
用户满意度 | 3.8/5.0 | 4.4/5.0 | +15.8% |
平均响应延迟 | 2.8s | 2.6s | -7.1% |
"转人工准确率"提升最大(+14.7%),这说明微调最大的价值不是让模型"更会答",而是让模型"更知道什么时候不该答"。这对于企业应用来说非常重要——AI乱答比不答危险得多。
"平台术语理解"提升了33.8%,这个完全在预期内。通用模型不懂"极速达"是什么服务,微调之后它就懂了,这是微调不可替代的价值。
最后把从模型微调到线上服务的完整流程画一张时序图,帮助大家理解各环节的协作关系:

从数据准备到全量上线,整个周期大约3-4周。其中数据准备花的时间最长(1-2周),训练本身反而最快(2-3天)。这个时间分配很正常,也是我想强调的——AI工程化的瓶颈从来不在训练,而在数据和评估。
最后总结几条不那么技术但很重要的经验:
1. 别一上来就微调。 先把Prompt调好、RAG做好,把天花板探明白。如果85%的准确率就够了,那Prompt+RAG完全够用,微调省下来的时间和钱能做好多别的事。
2. 评估比训练重要。 没有评估体系就不要做微调,因为你根本不知道微调好了还是坏了。评估体系要跟业务绑定——准确率、转人工率、用户满意度,这些指标比BLEU分数有意义得多。
3. 数据质量是天花板。 模型再好、方法再先进,数据是垃圾就是垃圾。花时间在数据标注和清洗上,回报远大于调超参数。
4. 灰度上线是底线。 不管内部评估多好,线上总有意料之外的情况。10%流量灰度跑一周,确认没问题再全量切。别为了赶进度跳过这步,翻车的代价远大于多等一周。
5. 监控不能停。 上线之后才是真正考验的开始。模型效果会随时间衰减(用户问题分布变了、业务规则更新了),持续监控+定期迭代是必须的。我们设了个规矩:每周看一次指标,每月做一次数据更新,每季度评估是否需要重新微调。
AI工程化落地不是一次性项目,是一个持续迭代的过程。模型调优也不是某个环节的孤军奋战,是Prompt工程、RAG优化、模型微调、推理优化四条线协同推进的系统工程。想明白这一点,你就不会执着于"我是不是该上微调"这种单点问题,而是会从全局视角去做决策。
希望这些经验对大家有用。都是踩坑换来的,拿去用,少走弯路。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。