首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >模型调优不是纸上谈兵,谈谈AI工程化落地的一线真实经验

模型调优不是纸上谈兵,谈谈AI工程化落地的一线真实经验

原创
作者头像
大盘鸡拌面
发布2026-08-12 09:44:17
发布2026-08-12 09:44:17
1560
举报

网上聊模型调优的文章一搜一大把,但大多数要么是论文翻译,要么是跑通demo就敢写"实战"。今天这篇不一样,是我带着团队做了三个企业级AI项目之后,真正在泥里滚过一遍的经验总结。坑踩了不少,有些能写到文档里,有些只能咽肚子里,尽量都跟大家聊聊。


一、先说个"翻车"的故事

去年接了个项目,给一家电商公司做智能客服。需求听上去不复杂——用户来问订单状态、退换货政策、商品信息,AI能直接回答,回答不了的转人工。

团队一个礼拜就把原型搭好了,用的是开源模型+RAG,内部测试效果还行,准确率80%出头。客户看了demo很满意,说"上线吧"。

结果上线第一天就炸了。

用户问"我的订单到哪了",AI回了一堆物流行业的发展趋势;用户问"能不能开发票",AI开始解释发票的历史演变。客服主管气得直接打电话过来:"你们这AI是来帮忙的还是来添乱的?"

问题出在哪?通用模型不懂数据,RAG检索精度不够,Prompt没做针对性优化。 这三件事单独拎出来哪个都不算难,但叠在一起就是灾难。

后来我们花了三周做模型微调+RAG优化+Prompt工程三管齐下,准确率拉到93%以上才真正稳定。这篇文章讲的就是这个过程里踩过的坑和总结出来的方法论。


二、调优到底调什么?先理清思路

很多人一听"模型调优"就想到微调(fine-tuning),其实微调只是工具箱里的一把锤子,而且不是最趁手的那把。我给团队定了个优先级原则:

一句话总结:能用Prompt解决的别上微调,能用RAG解决的别上微调,微调是最后手段。 不是因为微调不好,而是微调的成本(数据标注、训练资源、效果评估、版本管理)远高于前两者。你得先确认前两步的天花板确实到了,再动微调的念头。


三、Prompt工程:被严重低估的"性价比之王"

Prompt优化是所有调优手段里投入产出比最高的,但很多人觉得它"太简单了不屑于做"。说实话,我见过太多项目微调做了一堆,结果Prompt写得一塌糊涂的。

3.1 一个真实的对比

拿上面那个电商客服的例子,最开始我们的系统提示词是这样的:

代码语言:javascript
复制
```
你是一个电商客服助手,请回答用户的问题。
```

就这?对,就这。准确率80%出头。

后来改成了这样:

代码语言:javascript
复制
```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个百分点的提升,成本几乎为零。

3.2 Prompt优化的几个实操技巧

别小看这几个技巧,每一个都是真金白银换来的:

  1. 角色定义要具体:不是"你是客服",而是"你是XX电商平台的客服,服务于XX品类,面对XX用户群体"
  2. 边界要画清楚:明确告诉模型"什么能答、什么不能答",这比让它自己判断靠谱多了
  3. Few-shot比说教管用:与其解释"怎么回答好",不如直接给两个好的回答示例
  4. 输出格式要约束:要求模型用特定格式回答(编号列表、JSON等),后面解析起来也方便
  5. 负面示例很重要:告诉模型"不要这样回答"有时候比告诉它"该怎么回答"更有效

四、RAG优化:检索质量决定上限

Prompt优化到88%之后,我们分析了一下剩下的12%错误,发现有60%以上是检索环节的问题——要么没检索到相关文档,要么检索到的文档不对。

RAG优化是个系统工程,不是调一个参数就行的。下面这个图展示了完整的数据流和优化点:

标黄的三个节点是我们投入精力最多、收益也最大的优化点。

4.1 智能分块:别再无脑按512切了

绝大多数RAG教程都教你怎么按固定长度切分文档,这在实际业务中效果很差。一份产品手册,你按512字符切,可能把一个完整的退换货流程切成了两半,检索的时候只能检索到一半,模型自然回答不全。

我们的做法是按文档结构智能分块

代码语言:javascript
复制
```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的质量自然就上去了。

4.2 多路召回+融合排序

单一向量检索有个问题:它擅长语义匹配,但对精确关键词(比如订单号、商品SKU)不敏感。用户搜"SKU-A12345的价格",纯向量检索可能把一堆价格相关的文档都召回了,但恰恰漏了包含"SKU-A12345"的那篇。

解决方案是向量检索+关键词检索并行,然后融合排序

代码语言:javascript
复制
```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]]
```

4.3 Rerank:最后一道精度关卡

多路召回之后,Top-10里面可能混了一些不太相关的。这时候需要一个Reranker做精排,把真正相关的提到前面。

代码语言:javascript
复制
```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%左右上不去了。分析剩余的错误,发现主要是两类:

  1. 平台专有术语:比如"极速达"是我们平台特有的配送服务,通用模型不了解
  2. 特定业务逻辑:比如"生鲜商品不支持7天无理由退货"这种平台特有规则

这种领域知识靠Prompt和RAG都补不了,得上微调。

5.1 LoRA微调实战

全参数微调成本太高(7B模型全参数微调至少需要4张A100),我们选了LoRA——只训练少量参数,效果接近全参数微调,但成本只有几十分之一。

代码语言:javascript
复制
```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微调完成!")
```

5.2 微调数据的准备:最容易被忽略的环节

代码就上面那些,不复杂。真正费时间的是数据准备。我见过太多团队微调效果不好,最后发现不是方法的问题,是数据的问题。

几条铁律:

  • 质量大于数量:1000条精心标注的高质量数据,比10000条粗制滥造的数据效果好得多。我们最终只用了3000条训练数据,但每一条都是人工审核过的
  • 覆盖要全面:训练数据要覆盖所有业务场景,不能某个场景数据特别多、某个场景特别少。我们做了个数据分布看板,确保每个场景至少200条
  • 负样本很重要:不要只放"正确回答"的数据,也要放"该转人工时转人工"的数据。模型得学会说"我不知道"
  • 从线上日志里挖:最好的训练数据不是凭空造的,是从真实用户对话里挖的。我们把线上高频问题做了聚类,每个聚类挑代表性问题人工标注答案

六、微调后的评估流程

微调完了不能直接上线,得有一套评估流程。我们的评估分三层:

三层评估对应三个不同的信心门槛。自动评估筛掉明显有问题的版本,人工评估确认业务效果,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 删除。

目录
  • 一、先说个"翻车"的故事
  • 二、调优到底调什么?先理清思路
  • 三、Prompt工程:被严重低估的"性价比之王"
    • 3.1 一个真实的对比
    • 3.2 Prompt优化的几个实操技巧
  • 四、RAG优化:检索质量决定上限
    • 4.1 智能分块:别再无脑按512切了
    • 4.2 多路召回+融合排序
    • 4.3 Rerank:最后一道精度关卡
  • 五、微调:当通用模型真的不够用的时候
    • 5.1 LoRA微调实战
    • 5.2 微调数据的准备:最容易被忽略的环节
  • 六、微调后的评估流程
  • 七、微调前后对比:数据说话
  • 八、微调到上线的完整时序
  • 九、几条掏心窝的建议
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档