一、问题背景:一个看似简单的分类任务,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拆成三个可调变量:
- 示例数量:0-shot / 2-shot / 5-shot
- 输出约束方式:无约束 / 自然语言要求"只输出类别名" / 结构化JSON模板 + 字段说明
- 推理引导:无 / "请一步步思考"(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成本之间的平衡点。具体到工单分类这个任务:
- 结构化输出模板是必选项,一条"只输出JSON、不要代码块标记"的约束,换来的是格式合规率从41%到99%,这是最划算的投入。
- Few-shot 2个就够,第3到第5个示例的边际收益接近于零,token成本却线性上涨。示例的挑选比数量更重要,要覆盖易混类别。
- CoT不是万能的,判别式任务(分类、抽取、打标)上基本是负收益,生成式任务(写作、推理、代码)才值得上。
- 小模型更依赖Prompt结构,Qwen2.5-7B在0-shot下格式崩得厉害,同样的JSON模板收益比gpt-4o-mini更大。
最后说句实话:这套实验花了大概4个小时,跑了2400次API调用,成本不到2美元。但这4小时让我后面所有类似任务的Prompt设计都有了基线,不用再靠感觉调。如果你也在做分类或抽取类的LLM应用,建议至少把"0-shot vs 2-shot"和"无约束 vs JSON模板"这两个对比跑一遍,数据会告诉你答案。