一、问题背景:为什么一个“简单”的分类任务翻车了

上周接了个保险公司智能客服的需求——识别用户消息的意图,区分“报案、理赔进度查询、保单修改、投诉”四类。最开始我直接用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%以上。