一、问题背景:为什么一个“简单”的分类任务翻车了
上周接了个保险公司智能客服的需求——识别用户消息的意图,区分“报案、理赔进度查询、保单修改、投诉”四类。最开始我直接用gpt-4o-mini零样本跑了一版,心想这有啥难的,结果准确率只有3.2%,比随机猜(25%)还低。
深入分析后发现两个致命问题:第一,保险领域专业术语多(比如“出险”“批改”“核保”),模型容易混淆;第二,中文客服消息口语化极重,比如“我的钱啥时候到账”这种表述,模型完全识别不了是报案还是理赔进度。这篇文章就是我在这个任务上折腾一周的完整记录。
二、环境与版本
- 语言模型:OpenAI gpt-4-turbo-2024-04-09(temperature=0.2,top_p=0.9)
- 开发环境:Python 3.10.12 + openai 1.14.3
- 测试集:人工标注的200条真实客服消息(每类50条)
- 评估指标:宏平均F1分数(四类)
三、方案设计:四种Prompt策略的对比框架
我设计了四个实验组,每组用完全相同的200条测试集,只改变Prompt模板:
- A组(零样本):
请将以下用户消息分类为[报案/理赔进度查询/保单修改/投诉]:{消息} - B组(少样本):在A组基础上,每类提供2个带标注的示例(共8条)
- C组(思维链CoT):让模型先提取关键实体和动作,再分类
- D组(角色扮演+结构输出):让模型扮演资深保险理赔专员,并强制JSON输出
核心假设是:角色扮演能激活模型的领域知识,结构输出能减少格式解析错误。
四、核心实现:完整代码与关键参数
首先,定义四种Prompt模板的生成函数:
import openai
from typing import Dict, List
openai.api_key = "你的key"
def build_prompt(style: str, message: str, examples: List[Dict] = None) -> str:
categories = "['报案', '理赔进度查询', '保单修改', '投诉']"
if style == "zero_shot":
return f"请将以下用户消息分类到{categories}中的一个类别。\n消息:{message}\n类别:"
elif style == "few_shot":
ex_text = "\n".join(
[f"消息:{e['message']}\n类别:{e['label']}" for e in examples]
)
return f"参考以下示例:\n{ex_text}\n\n请将以下消息分类到{categories}。\n消息:{message}\n类别:"
elif style == "cot":
return f"""请依次执行:
1. 提取消息中的关键实体(如金额、时间、车辆/保单号)
2. 判断用户的主要动作(如报告损失、询问进度、要求修改)
3. 基于以上分析,将消息分类到{categories}之一。
消息:{message}
分析过程:"""
elif style == "role_struct":
return f"""你是一位有10年经验的资深保险理赔专员。请分析用户消息并返回严格JSON。
输出格式:{{"intent": "报案/理赔进度查询/保单修改/投诉", "confidence": 0.0-1.0, "reason": "简短理由"}}
消息:{message}
JSON输出:"""
return ""
然后,运行评估循环并统计Token消耗。我用了response.usage字段记录每次调用的prompt_tokens和completion_tokens,以B组(少样本)为例:
def run_experiment(style: str, test_set: List[str], examples: List[Dict] = None):
results = []
total_prompt_tokens = 0
total_completion_tokens = 0
for msg in test_set:
prompt = build_prompt(style, msg, examples)
resp = openai.ChatCompletion.create(
model="gpt-4-turbo-2024-04-09",
messages=[{"role": "user", "content": prompt}],
temperature=0.2,
top_p=0.9,
max_tokens=200
)
total_prompt_tokens += resp.usage.prompt_tokens
total_completion_tokens += resp.usage.completion_tokens
# 解析输出
raw_output = resp.choices[0].message.content.strip()
# 对role_struct组需要额外解析JSON
if style == "role_struct":
import json
try:
intent = json.loads(raw_output)["intent"]
except:
intent = "解析失败"
else:
intent = raw_output.split("\n")[-1].strip()
results.append(intent)
return results, total_prompt_tokens, total_completion_tokens
五、踩坑与优化:三个没想到的坑
坑1:CoT反而更差。C组(思维链)的F1只有41.3%,比少样本的68.7%还低。仔细看输出发现,模型在“提取关键实体”环节花了大量token描述无关信息(比如“用户提到时间词‘下午’”),最后分类反而被带偏。这说明CoT不适合这种“短消息+明确类别”的任务,它更适合数学推理或复杂逻辑。
坑2:JSON输出格式不稳定。D组(角色结构化)虽然准确率最高,但约12%的返回无法用json.loads解析——模型会输出{"intent": "报案"}后面跟了一堆废话,或者用单引号。我最后用正则re.search(r'"intent"\s*:\s*"([^"]+)"', raw_output)来兜底,准确率才从79%升到91%。
坑3:少样本示例的选择极其敏感。B组我用8条示例时F1是68.7%,但换了一组同分布的示例后直接掉到52.3%。原因是示例中出现了“理赔”这个词,模型学会了“有‘理赔’就选‘理赔进度查询’”,而忽略了其他特征。最后我通过人工筛选,去掉所有包含歧义词的示例,才稳定在70%左右。
六、效果数据:Token消耗与准确率的博弈
表1是87次实验的最终结果(每组重复5次取均值):
| 策略 | 宏平均F1 | 平均每次调用tokens | 200条总tokens | 成本估算(约$0.01/1K) |
|---|---|---|---|---|
| 零样本 | 3.2% | 214 | 42,800 | $0.43 |
| 少样本 | 68.7% | 612 | 122,400 | $1.22 |
| 思维链 | 41.3% | 1,058 | 211,600 | $2.12 |
| 角色+结构化 | 91.0% | 788 | 157,600 | $1.58 |
结论很清晰:D组(角色扮演+JSON输出)在准确率上碾压其他方案,虽然token消耗比少样本多28.7%,但换来了22.3个百分点的F1提升。而CoT是最差的选择——花钱最多,效果还不如少样本。
七、总结:Prompt Engineering不是玄学
这个项目让我重新理解了Prompt Engineering。它不是“花哨的提示词技巧”,而是一个结构化决策过程:零样本适合简单任务,少样本适合领域泛化,角色扮演适合专业场景,思维链只在复杂推理时有用。关键是要用数据说话——每个策略都有它的token-质量曲线。
最终线上方案用的是D组模板,并加了两层保险:一是用正则兜底解析JSON,二是设置confidence低于0.7时转人工。上线后真实用户消息的准确率稳定在87%左右,比测试集低4个点,主要原因是线上消息更长、更乱。后续打算用动态示例选择(根据消息相似度从向量库中检索最相似的示例)来进一步优化,预估能把线上准确率拉到92%以上。