一、问题背景:一个看似简单的分类任务,Prompt写崩了三次

上个月接了个需求:把用户反馈的工单文本自动分成8个类别(账号问题、支付问题、物流问题、产品质量、退款售后、功能建议、投诉、其他)。业务方给的验收标准很朴素——准确率≥90%,且输出必须是合法JSON,方便下游系统直接入库。

我一开始觉得这活儿不难,随手写了个Prompt:

prompt_v1 = """请将下面的用户反馈分类到以下类别之一:
账号问题、支付问题、物流问题、产品质量、退款售后、功能建议、投诉、其他

用户反馈:{text}

类别:"""

跑完100条测试集,准确率81%,而且输出五花八门——有的直接返回类别名,有的返回"该反馈属于支付问题",有的甚至加了句"希望对您有帮助"。下游解析JSON的代码直接抛异常。

后面又改了两版,一版加了5个Few-shot示例(准确率涨到89%,但token成本翻了3倍),一版加了"请一步步思考"(准确率没涨,延迟涨了40%)。于是我决定系统性地做一轮对比实验,把Prompt结构、示例数量、输出约束这几个变量拆开测,找到性价比最高的组合。

二、环境与版本

实验环境如下,都是实际跑的数,不是拍脑袋:

  • 模型A:gpt-4o-mini,版本快照 gpt-4o-mini-2024-07-18,temperature=0,top_p=1
  • 模型B:Qwen2.5-7B-Instruct,本地部署 vLLM 0.6.1,temperature=0,max_tokens=256
  • 测试集:200条人工标注工单,8类别分布不均衡("其他"占28%,"投诉"仅占6%)
  • Token统计:OpenAI用 tiktoken 0.7.0 的 o200k_base 编码;Qwen用其自带tokenizer
  • 评测脚本:Python 3.11,准确率按严格匹配算,格式合规率按 json.loads 能否成功解析算

补充一句:temperature=0 不代表完全确定,gpt-4o-mini在个别样本上仍有微小波动,所以每组实验我跑了两次取平均,差异都在0.5%以内,可以忽略。

三、方案设计:三个变量,12组Prompt

我把Prompt拆成三个可调变量:

  1. 示例数量:0-shot / 2-shot / 5-shot
  2. 输出约束方式:无约束 / 自然语言要求"只输出类别名" / 结构化JSON模板 + 字段说明
  3. 推理引导:无 / "请一步步思考"(CoT)

全因子组合是3×3×2=18组,太多。我固定推理引导先测前两个变量(9组),再单独测CoT的影响(3组),共12组。核心对比组如下表:

组号 示例数 输出约束 CoT 输入token均值
P1 0 无 否 68
P2 0 类别名 否 82
P3 0 JSON模板 否 156
P4 2 JSON模板 否 412
P5 5 JSON模板 否 891
P6 2 类别名 否 338
P7 0 JSON模板 是 178
P8 2 JSON模板 是 434

(其余4组是变体,后面数据里一并给)

四、核心实现:评测脚本与Prompt模板

先上评测主循环,这段代码可以直接跑,换掉 call_model 里的实现就能接任意模型:

import json
import time
import tiktoken
from openai import OpenAI

client = OpenAI(api_key="sk-xxx")
ENC = tiktoken.get_encoding("o200k_base")

CATEGORIES = ["账号问题", "支付问题", "物流问题", "产品质量",
              "退款售后", "功能建议", "投诉", "其他"]

def build_prompt(text, n_shot=0, constraint="json", cot=False):
    examples = [
        ("我的密码重置链接一直收不到", "账号问题"),
        ("订单显示已签收但我没收到货", "物流问题"),
    ][:n_shot]

    sys = "你是一个工单分类助手。"
    if cot:
        sys += "请先简要分析反馈的核心诉求,再给出分类。"
    if constraint == "json":
        sys += ('只输出JSON,格式为{"category": ""},'
                'category必须是以下之一:' + "、".join(CATEGORIES))
    elif constraint == "label":
        sys += "只输出类别名称,不要输出任何其他内容。"

    shots = ""
    for t, c in examples:
        shots += f"\n用户反馈:{t}\n输出:{json.dumps({'category': c}, ensure_ascii=False)}"

    user = f"{shots}\n用户反馈:{text}\n输出:"
    return sys, user

def call_model(sys_prompt, user_prompt):
    t0 = time.time()
    resp = client.chat.completions.create(
        model="gpt-4o-mini-2024-07-18",
        temperature=0,
        max_tokens=256,
        messages=[{"role": "system", "content": sys_prompt},
                  {"role": "user", "content": user_prompt}],
    )
    latency = time.time() - t0
    content = resp.choices[0].message.content.strip()
    usage = resp.usage
    return content, usage.prompt_tokens, usage.completion_tokens, latency

def evaluate(dataset, n_shot, constraint, cot):
    correct, valid_json, total_in, total_out, total_lat = 0, 0, 0, 0, 0.0
    for item in dataset:
        sys_p, user_p = build_prompt(item["text"], n_shot, constraint, cot)
        out, tin, tout, lat = call_model(sys_p, user_p)
        total_in += tin; total_out += tout; total_lat += lat
        try:
            pred = json.loads(out)["category"]
            valid_json += 1
        except Exception:
            pred = out
        if pred == item["label"]:
            correct += 1
    n = len(dataset)
    return {
        "acc": correct / n,
        "format_ok": valid_json / n,
        "avg_in": total_in / n,
        "avg_out": total_out / n,
        "avg_lat": total_lat / n,
    }

第二个代码块是结构化JSON模板的完整版,这是后面效果最好的那组(P4)实际用的Prompt,贴出来方便对照:

SYSTEM_JSON = """你是一个工单分类助手。请严格按以下规则输出。

类别定义:
- 账号问题:登录、注册、密码、验证码、账号冻结
- 支付问题:扣款异常、支付失败、重复扣费
- 物流问题:发货、配送、签收、包裹丢失
- 产品质量:破损、故障、与描述不符
- 退款售后:退款进度、退货、换货
- 功能建议:对产品功能的改进诉求
- 投诉:对服务态度、处理结果的明确不满
- 其他:无法归入以上类别

输出要求:
1. 只输出一个JSON对象,不要输出markdown代码块标记
2. 格式固定为 {"category": ""}
3. category 必须严格取自上述8个类别名称
4. 不要输出解释、不要输出额外字段

示例:
用户反馈:付了两次钱只到账一次
输出:{"category": "支付问题"}
"""

def make_user_prompt(text):
    return f"用户反馈:{text}\n输出:"

注意系统提示里那句"不要输出markdown代码块标记"——这是踩坑踩出来的,下面细说。

五、踩坑与优化:三个让我改了三版Prompt的细节

坑一:gpt-4o-mini特别爱加 ```json 包裹。 P3组最初没写"不要输出代码块标记",结果约34%的输出是:

```json
{"category": "支付问题"}

```

json.loads 直接失败。我一开始想在解析层用正则剥掉,但这样评测就不公平了——业务方要的是模型原生输出合法。后来在Prompt里显式禁止,格式合规率从66%涨到99%。这个细节在官方文档里不会写,但实际项目里不处理就是线上事故。

坑二:Few-shot示例的类别分布会带偏模型。 P4组我最初的两个示例是"账号问题"和"物流问题",结果测试集里"支付问题"的召回率明显偏低——模型倾向于把模糊文本往示例类别上靠。换成"支付问题"+"产品质量"两个示例后,整体准确率涨了2.3%。结论:示例要覆盖易混类别,不是随便挑两个就行。

坑三:CoT在分类任务上是负收益。 P7(0-shot + CoT)对比P3,准确率只从86.5%涨到87.0%,但输出token从平均11涨到平均63,延迟从0.42s涨到0.71s,成本翻了近5倍。分类这种判别式任务,CoT的收益远不如在生成式任务上明显。这一条和很多教程的说法相反,但数据就是这样。

优化路径:最终我选了P4的变体——2-shot + JSON模板 + 无CoT,示例换成"支付问题"和"产品质量"。这就是下文数据里的P4'。

六、效果数据:12组跑完,结论很清晰

200条测试集,gpt-4o-mini-2024-07-18,temperature=0,完整结果(部分组):

组号 配置 准确率 格式合规率 平均输入token 平均输出token 平均延迟
P1 0-shot 无约束 81.0% 41% 68 9 0.38s
P2 0-shot 类别名 84.5% 92% 82 7 0.39s
P3 0-shot JSON 86.5% 99% 156 11 0.42s
P4 2-shot JSON 91.5% 99.5% 412 11 0.47s
P5 5-shot JSON 92.0% 99.5% 891 11 0.58s
P6 2-shot 类别名 89.0% 94% 338 7 0.44s
P7 0-shot JSON+CoT 87.0% 98% 178 63 0.71s
P4' 2-shot JSON(示例调优) 93.5% 99.5% 415 11 0.47s

几个关键数字:

  • P4 vs P5:示例从2个加到5个,准确率只涨0.5个百分点,输入token涨116%。按gpt-4o-mini输入$0.15/百万token算,单条成本从$0.000062涨到$0.000134,翻倍还多。5-shot在这个任务上完全不划算。
  • P2 vs P3:只加结构化JSON约束,准确率涨2个百分点,格式合规率从92%到99%,代价是输入token从82涨到156。这钱花得值,因为省了下游解析和重试的成本。
  • P7的CoT:输出token是P3的5.7倍,延迟涨69%,准确率只涨0.5个百分点。分类任务别上CoT。

Qwen2.5-7B-Instruct上趋势基本一致,但整体准确率低3-5个百分点(P4'是88.5%),且0-shot时格式合规率只有71%,对Few-shot的依赖更强。这说明小模型上结构化Prompt的收益更大。

七、总结

这轮实验跑下来,我最大的感受是:Prompt工程不是"写得越详细越好",而是找到约束强度和token成本之间的平衡点。具体到工单分类这个任务:

  1. 结构化输出模板是必选项,一条"只输出JSON、不要代码块标记"的约束,换来的是格式合规率从41%到99%,这是最划算的投入。
  2. Few-shot 2个就够,第3到第5个示例的边际收益接近于零,token成本却线性上涨。示例的挑选比数量更重要,要覆盖易混类别。
  3. CoT不是万能的,判别式任务(分类、抽取、打标)上基本是负收益,生成式任务(写作、推理、代码)才值得上。
  4. 小模型更依赖Prompt结构,Qwen2.5-7B在0-shot下格式崩得厉害,同样的JSON模板收益比gpt-4o-mini更大。

最后说句实话:这套实验花了大概4个小时,跑了2400次API调用,成本不到2美元。但这4小时让我后面所有类似任务的Prompt设计都有了基线,不用再靠感觉调。如果你也在做分类或抽取类的LLM应用,建议至少把"0-shot vs 2-shot"和"无约束 vs JSON模板"这两个对比跑一遍,数据会告诉你答案。