一、问题背景
上个月接手了一个客服工单自动分类的小需求,业务方希望把每天约8000条用户工单自动分到6个类目:退款退货、物流查询、商品咨询、账号问题、投诉建议、其他。
一开始我直接用最朴素的Prompt丢给模型,结果准确率只有62%左右,业务方直接打回来。更麻烦的是"投诉建议"和"退款退货"这两类经常混淆——用户说"你们这个质量太差了,我要退",模型有时候分到投诉,有时候分到退款。
于是我花了两天时间系统地做了一轮Prompt Engineering对比实验,把过程和数据记录下来,给遇到类似问题的同学参考。
二、环境与版本
- 模型:gpt-4o-mini-2024-07-18(选它是因为便宜且分类任务够用)
- SDK:openai==1.35.0
- Python:3.11.6
- 测试集:500条人工标注过的真实工单,6类分布大致均衡
- temperature=0,top_p=1,max_tokens=256
- 评测脚本本地跑,避免网络抖动影响
成本参考(按2024年7月官方定价):gpt-4o-mini 输入 $0.15/1M tokens,输出 $0.60/1M tokens。汇率按7.2算。
三、方案设计
我设计了4组Prompt做对比,控制变量,只改Prompt本身:
- P1 Zero-shot:直接给类目列表,让模型输出类别
- P2 Few-shot:每类给2个示例,共12个示例
- P3 角色+结构化输出:设定"资深客服主管"角色,要求JSON输出
- P4 CoT+结构化输出:在P3基础上要求先分析再给结论
评测指标:准确率(Accuracy)、Macro-F1、单条平均Token(输入+输出)、单条平均延迟。
四、核心实现
先贴公共调用函数:
import os, json, time
from openai import OpenAI
from typing import List, Dict
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
MODEL = "gpt-4o-mini-2024-07-18"
CATEGORIES = ["退款退货", "物流查询", "商品咨询", "账号问题", "投诉建议", "其他"]
def call_llm(prompt: str, system: str = None) -> Dict:
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
start = time.time()
resp = client.chat.completions.create(
model=MODEL,
messages=messages,
temperature=0,
top_p=1,
max_tokens=256,
)
latency = time.time() - start
return {
"content": resp.choices[0].message.content,
"prompt_tokens": resp.usage.prompt_tokens,
"completion_tokens": resp.usage.completion_tokens,
"latency": latency,
}
四种Prompt的定义:
# P1 Zero-shot
PROMPT_P1 = """请将下面的客服工单分类到以下类别之一:
{categories}
工单内容:{text}
只输出类别名称。"""
# P2 Few-shot
FEWSHOT_EXAMPLES = """
示例1:工单"我买的衣服尺码不对,想换大一码" → 退款退货
示例2:工单"快递三天没更新了,到哪了" → 物流查询
示例3:工单"这个面膜孕妇能用吗" → 商品咨询
示例4:工单"我登录不上,密码重置邮件收不到" → 账号问题
示例5:工单"客服态度太差了,等了半小时没人理" → 投诉建议
示例6:工单"你们几点上班" → 其他
"""
PROMPT_P2 = """请将下面的客服工单分类到以下类别之一:
{categories}
{fewshot}
工单内容:{text}
只输出类别名称。"""
# P3 角色+结构化
SYSTEM_P3 = "你是一名有8年经验的电商客服主管,擅长快速准确地为工单归类。"
PROMPT_P3 = """请将工单分类到以下6个类别之一:{categories}
工单内容:{text}
要求以JSON格式输出,字段为 category,值必须是上述6个类别之一。示例:{{"category": "物流查询"}}"""
# P4 CoT + 结构化
PROMPT_P4 = """请将工单分类到以下6个类别之一:{categories}
工单内容:{text}
请按以下步骤思考:
1. 用户的核心诉求是什么?(一句话概括)
2. 该诉求最匹配哪个类别?为什么容易与其他类别混淆?
3. 给出最终类别。
最后以JSON格式输出,包含字段 reasoning(你的分析)和 category(最终类别)。"""
跑评测:
import pandas as pd
from sklearn.metrics import accuracy_score, f1_score
def evaluate(prompt_template, system=None, use_json=False):
df = pd.read_csv("tickets_500.csv") # 列:text, label
preds, tokens_in, tokens_out, latencies = [], 0, 0, 0
for _, row in df.iterrows():
prompt = prompt_template.format(
categories="、".join(CATEGORIES),
text=row["text"],
fewshot=FEWSHOT_EXAMPLES if "{fewshot}" in prompt_template else "",
)
r = call_llm(prompt, system)
tokens_in += r["prompt_tokens"]
tokens_out += r["completion_tokens"]
latencies += r["latency"]
content = r["content"].strip()
if use_json:
try:
content = json.loads(content)["category"]
except Exception:
content = "解析失败"
preds.append(content)
n = len(df)
return {
"acc": accuracy_score(df["label"], preds),
"f1": f1_score(df["label"], preds, average="macro"),
"avg_in": tokens_in / n,
"avg_out": tokens_out / n,
"avg_latency": latencies / n,
}
五、踩坑与优化
坑1:JSON输出不稳定。 P3刚上线时,模型偶尔返回带markdown代码块的json ...,甚至加一句"根据分析,该工单属于..."。解决办法是在system里明确写"只输出JSON,不要任何额外文字",同时用response_format={"type": "json_object"}参数强制。开启后解析失败率从7.2%降到0.2%。
坑2:Few-shot示例顺序影响结果。 我把"其他"类放在最后,结果模型有轻微倾向于输出前面几类的现象。后来把示例顺序打乱并每类等量,F1提升了约2个点。
坑3:CoT的reasoning字段太长推高成本。 一开始输出动辄200+ tokens,我把max_tokens从512降到256,并在prompt里要求reasoning不超过50字,输出token直接砍了一半,准确率只掉0.4%。
坑4:类别名称歧义。 "退款退货"和"投诉建议"边界模糊,我最终在prompt里加了一句话定义:"若用户明确要求退款/退货,无论语气如何,归为退款退货;若仅表达不满未提退款,归为投诉建议。"这一句让这两类的混淆率从18%降到4%。
六、效果数据
500条测试集结果:
| 方案 | Acc | Macro-F1 | 输入Token | 输出Token | 延迟(s) | 千条成本(元) |
|---|---|---|---|---|---|---|
| P1 Zero-shot | 62.4% | 0.601 | 96 | 4 | 0.62 | 0.12 |
| P2 Few-shot | 81.6% | 0.803 | 342 | 4 | 0.91 | 0.39 |
| P3 角色+JSON | 85.2% | 0.841 | 138 | 18 | 0.78 | 0.22 |
| P4 CoT+JSON | 94.2% | 0.938 | 152 | 135 | 1.84 | 1.03 |
几个关键观察:
- Few-shot相比Zero-shot提升19个点,但Token翻了3.5倍,性价比其实一般。
- P3用角色+JSON约束,在Token几乎不增加的情况下比Few-shot还高3.6个点,结构化约束比堆示例更划算。
- P4准确率最高,但单条输出Token从18飙到135,延迟也从0.78s升到1.84s,千条成本从0.22元升到1.03元。按每天8000条算,P4比P3每天多花约6.5元,一个月多200元左右。
最终选型: 业务方对准确率敏感、对延迟不敏感(工单本来就是异步处理),我选了P4。如果换成实时对话场景,我会选P3。
七、总结
这次实验最大的收获是:Prompt Engineering不是玄学,是可以量化的工程问题。几个可复用的经验:
- 先跑baseline再优化,不然你根本不知道提升来自哪一步。
- 结构化输出(JSON schema约束)的性价比远高于堆Few-shot示例。
- CoT能显著提升准确率,但代价是输出Token暴涨,务必在prompt里限制reasoning长度。
- 类别歧义靠示例解决不了,得用一句明确的判定规则写进prompt。
- 永远记录Token和延迟数据,Prompt优化本质是准确率-成本-延迟的三方权衡。
代码和数据脱敏后的版本我放在了本地repo,需要的同学可以按上面片段自己复现。有问题评论区聊。