1. 问题背景:一个让我怀疑人生的客服工单分类任务

上个月接到一个需求:对公司3万条历史客服工单做自动分类,一共12个类别(退换货、物流延迟、价格争议……)。业务方给的时间是“越快越好”,我第一反应就是“直接调GPT-4o-mini API,写个Prompt完事”。

结果第一次上线,准确率37%,业务方直接截了个图发群里:“这分类还不如随机猜(8.3%)好多少。”

我盯着那错误的输出,发现几个问题:
- 模型把“要求开发票”分到了“发票问题”而不是“财务咨询”(业务方定义的标签体系有层级,但Prompt里没体现)
- 长文本(超过200字)的工单,分类明显不稳定
- 同一Prompt跑两次,结果不一致(采样温度设了0.7)

这篇文章就是记录我从37%到89%的完整调优过程,包括每个Prompt的具体写法、token消耗、以及那些“我以为是优化其实是倒退”的坑。

2. 环境与版本:固定变量才能谈对比

先交代实验环境,避免你们复现时甩锅给版本差异:

- 模型:gpt-4o-mini-2024-07-18(固定)
- API:OpenAI Python SDK 1.35.3
- temperature:0.2(所有实验一致,降低随机性)
- max_tokens:500(输出上限,实际分类只需20-50)
- 数据集:500条人工标注工单(训练集300条,测试集200条,按类别分层抽样)
- 评估指标:宏平均F1(对12类类别不平衡更公平)
- token计算:tiktoken库,cl100k_base编码

为什么用gpt-4o-mini? 因为业务方要控制成本,4o-mini的价格是$0.15/百万输入token,大约是gpt-4o的1/20。但代价是:它对Prompt的措辞更敏感,稍微含糊一点就开始“自由发挥”。

3. 方案设计:五轮迭代,每轮只改一个变量

我设计了一个对照实验,每次只变一个维度,避免“一锅炖”导致不知道哪个改动起了作用:

轮次 Prompt策略 核心改动
V1 零样本朴素版 一句话指令
V2 角色扮演+约束 加“你是客服主管”
V3 思维链(CoT) 加“先分析再分类”
V4 分步结构化 拆成两步:先提取关键词→再决策
V5 V4+负面示例 加入2个易错边界case

每轮跑完全部500条数据(300条调参用,200条测试),记录:宏F1、平均输出token数、失败模式(错误分类的分布)。

4. 核心实现:从最烂的Prompt开始

V1:一句话指令(基线)

from openai import OpenAI
import tiktoken

client = OpenAI(api_key="sk-xxx")
enc = tiktoken.get_encoding("cl100k_base")

def classify_v1(text):
    prompt = f"""将下面的客服工单分类到以下类别之一:{categories}\n工单:{text}\n类别:"""
    resp = client.chat.completions.create(
        model="gpt-4o-mini-2024-07-18",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.2,
        max_tokens=50
    )
    return resp.choices[0].message.content.strip(), len(enc.encode(prompt))

# 测试集200条,宏F1=0.37

运行结果:宏F1 0.37,平均每条Prompt消耗128个token。问题很典型——模型把“催发货”分成了“物流查询”而不是“物流投诉”,把“第二天降价”分成了“价格咨询”而不是“价格争议”。它不理解业务方对类别的隐含定义。

V4:分步结构化(关键转折)

def classify_v4(text):
    prompt = f"""你是一个客服工单分类器。请严格按以下步骤操作:

第一步:从工单中提取关键实体(商品名、金额、时间、动作)。
第二步:判断用户的情感倾向(愤怒/焦急/中性/满意)。
第三步:基于以下决策树选择唯一类别:
- 如果提及'退货'或'退款'→ 检查是否含'运费'→ 是则'退货运费争议',否则'退货退款'
- 如果提及'发票'→ 检查是否含'金额错误'→ 是则'发票金额错误',否则'发票申请'
- ...(完整决策树共12分支)

第四步:输出JSON格式:{{"category":"类别名","reason":"一句话依据"}}

工单内容:{text}

请严格按照上述步骤输出,不要跳过。"""
    resp = client.chat.completions.create(
        model="gpt-4o-mini-2024-07-18",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.2,
        max_tokens=120
    )
    # 解析JSON...

结果:宏F1直接跳到0.81。 关键不是“分步”本身,而是决策树把业务方那套隐含的优先级(比如“退货运费”优先于“退货退款”)显式写出来了。代价是Prompt长度从V1的38行涨到64行,token消耗从128涨到157。

5. 踩坑与优化:三个让我白跑两小时的“优化”

坑1:加角色扮演反而更差。 V2加了“你是资深客服主管,有10年经验……”,宏F1反而从0.37降到0.34。模型开始用“主管视角”脑补工单背景,输出一些“根据您的描述,我建议……”这种废话。结论:分类任务不需要角色设定,需要的是规则明确。

坑2:CoT没有提升,反而增加50% token。 V3用了“让我们一步步思考”,宏F1只有0.42,但每条平均输出token从20涨到65(模型开始输出长篇推理)。关键发现:在4o-mini上,CoT对短文本分类的收益远小于长文本推理任务。 我后来把CoT限制为“只输出结论和一句话理由”,才把token压回来。

坑3:负面示例选错比不选更糟。 V5加了两个错误case作为反面教材,但其中一个case的边界条件写得太具体,导致模型学会了“过度匹配”。比如我写了“如果用户提到'降价'且金额超过50元,属于'价格争议'”,结果模型把所有提到金额的都往“价格争议”塞。教训:负面示例要选典型错误,不是选极端case。

6. 效果数据与最终对比

最终调优后的V5.1(V4+两个精选负面示例),在200条测试集上的表现:

指标 V1(基线) V4(分步) V5.1(最终)
宏F1 0.37 0.81 0.89
每条Prompt输入token 128 157 163
每条输出token 20 35 38
单条成本(约) $0.00003 $0.00005 $0.00005
3万条总成本 $0.9 $1.5 $1.5

有趣的数据点:
- V4到V5.1只增加了6个token的Prompt长度(163-157),但宏F1提升了8个百分点——因为那两个负面示例精准纠正了“发票金额错误”和“物流投诉”的混淆边界。
- 输出token从V3的65降到V5.1的38,是因为我在V4中加了“只输出JSON,不要解释”,这比任何“精简推理”的指令都管用。
- 用gpt-4o(不是mini)跑V5.1,宏F1是0.93,但成本是20倍,业务方说“为4个百分点多花20倍,不值”。

最终交付时,我还加了一个兜底逻辑:如果模型输出的category不在预定义12类内,默认归为“其他”,并且人工抽检。这个兜底把误分类率从11%降到了8%。

7. 总结:Prompt调优的三个反直觉结论

  1. 角色扮演对分类任务有害,对生成任务可能有用。 如果你是做文案生成,角色设定有价值;但做分类,那是画蛇添足。
  2. 分步决策树 > 思维链。 把业务规则写成显式的if-else逻辑,比让模型“自己推理”要稳定得多。尤其对4o-mini这种小模型,显式规则就是保命符。
  3. Token消耗的优化优先级:输出 > 输入。 很多人抠输入Prompt的字数,但真正烧钱的是模型输出。V3的CoT让输出token翻了三倍,成本直接超了V5.1的两倍。

最后说点实际的:如果你的任务也是分类或抽取,别急着上RAG或者微调。先花两天把Prompt里的“决策树”画出来,用显式规则代替模糊描述,准确率提升50个百分点不是梦。但记住,当你的Prompt超过200行还在调时,就该考虑微调或者换模型了——Prompt工程有天花板,别硬卷。