1. 问题背景:为什么一个“简单”分类任务让我失眠
上周五,业务方扔给我一个需求:从用户评价里自动提取“安装难易、材质质量、物流速度”三个维度的情感标签,要求返回结构化JSON。我第一反应是“这不就是套个模板调API吗?”结果被现实狠狠抽了脸。
零样本Prompt直接跑,准确率只有62%。最离谱的是,一条评价“客服说三天到,结果第五天才来,但包装很好没磕碰”被模型判成“物流满意”。原因很直白:模型把“包装很好”当成了整体正面信号,忽略了“物流延迟”这个负面事实。
这不是模型笨,是我的Prompt没做约束。于是我开始系统性地实验不同Prompt策略,目标是:准确率≥90%,单次推理token≤300,并且输出严格遵循JSON Schema。
2. 环境与版本:固定变量,才能对比
- 模型:
gpt-4o-2025-05-13(主实验),gpt-3.5-turbo-0125(对照组) - SDK版本:
openai==1.35.7,Python 3.10.12 - 温度:所有实验固定为
temperature=0(关闭随机性) - 数据:从业务库抽200条人工标注的评价,覆盖3个维度,每条20-80字
- 评估指标:F1(macro),按维度分别计算再取平均
- Token统计:使用
tiktoken的cl100k_base编码,统计输入+输出总消耗
我特意把所有参数固定,只改Prompt文本。不然变量太多,根本没法归因。
3. 方案设计:五种Prompt策略,一次跑完
我设计了一个run_experiment函数,接受system_prompt和user_prompt,返回结构化结果:
import json
import tiktoken
from openai import OpenAI
client = OpenAI(api_key="sk-xxx", base_url="https://api.openai.com/v1")
enc = tiktoken.get_encoding("cl100k_base")
def run_experiment(system_prompt, user_prompt, model="gpt-4o-2025-05-13"):
resp = client.chat.completions.create(
model=model,
temperature=0,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
]
)
content = resp.choices[0].message.content
input_tokens = len(enc.encode(system_prompt + user_prompt))
output_tokens = len(enc.encode(content))
return json.loads(content), input_tokens, output_tokens
五种策略分别是:
1. Base:简单指令,无示例
2. FewShot:每维度给1个正例+1个反例
3. CoT:要求一步步思考,最后给出结论
4. Structured:强制JSON输出,且每个维度附解释字段
5. Role+Constraint:定义角色为“资深客服主管”,并加入“否定词检测”规则
4. 核心实现:五轮迭代,每个Prompt的完整文本
第一轮:Base(零样本)
system_base = "你是情感分析助手。分析用户评价,输出JSON:{'install': 'positive/negative/neutral', 'quality': '...', 'logistics': '...'}"
结果:F1=0.62,平均输出token=420。问题在于模型把“包装好”误判为物流维度,而且经常漏掉“neutral”。
第二轮:FewShot(少样本)
我在system里塞了6个例子:
示例1: 评价"螺丝孔对不上,拧了半小时" -> {"install": "negative", "quality": "neutral", "logistics": "neutral"}
示例2: 评价"快递很快,但外包装有压痕" -> {"install": "neutral", "quality": "negative", "logistics": "positive"}
...
F1提升到0.74,但代价是输入token暴涨到1100/次。而且模型开始机械复制示例的措辞——当真实评价出现“安装孔偏位”时,它因为没见过这个词,归为neutral。
第三轮:CoT(思维链)
我让模型先输出思考过程,再给结论:
请一步步分析:1) 找出评价中的动作主体 2) 判断每个动作的情感倾向 3) 最后输出JSON
F1跳到0.83。但输出token飙升到680,而且经常把思考过程写进JSON的value里,导致解析失败。业务方不会接受这种“解释性垃圾”。
第四轮:Structured(结构化约束)
这次我把JSON Schema写死,并要求每个维度必须给一个reason字段:
system_structured = """
你必须输出严格JSON,格式如下:
{
"install": {"sentiment": "positive|negative|neutral", "reason": "不超过15字的依据"},
"quality": {...},
"logistics": {...}
}
规则:如果评价中没有提到某维度,sentiment必须为neutral,reason写"未提及"。
"""
F1=0.87,输出token降到310。但发现一个坑:模型会把“未提及”写成“无相关信息”,导致下游解析逻辑需要兼容多种表述。我后来在system里加了一条铁律:reason字段只允许从原评价中摘录原文,禁止改写。
第五轮:Role+Constraint(角色+否定检测)
最终版本,加了两味猛药:
system_final = """
你是资深电商客服主管,有5年差评处理经验。你擅长识别反讽和否定表述。
任务:分析用户评价,输出严格JSON(Schema如下):
{
"install": {"sentiment": "positive/negative/neutral", "reason": "原文摘录"},
"quality": {"sentiment": "positive/negative/neutral", "reason": "原文摘录"},
"logistics": {"sentiment": "positive/negative/neutral", "reason": "原文摘录"}
}
硬性规则:
1. sentiment必须严格从三个值中选一个,禁止其他表述。
2. reason必须从原评价中逐字摘录,禁止改写或补充。
3. 特别注意否定词:如果出现"但""然而""却"等转折词,后半句情感优先级更高。
4. 如果某维度未提及,sentiment="neutral", reason="未提及"。
"""
结果F1=0.91,输出token稳定在280左右。
5. 踩坑与优化:三个血泪教训
教训一:别让模型“自由发挥”reason字段
前几轮模型写的reason像“客服响应速度有待提升”这种泛泛而谈,对下游分析毫无价值。改成“原文摘录”后,不仅F1提升,还省了token——因为模型不再需要“组织语言”。
教训二:转折词检测放在Prompt里不如放在代码里
我在第四轮试图让模型自己判断“但”后面的情感,结果它经常忽略。后来我在代码里做预处理,把评价按“但/然而/却”切分,把后半段单独拼成user_prompt的末尾,并加一句“注意以下片段是用户的后置补充意见”。这个改动让F1直接+0.03。
教训三:response_format=json_object不等于模型输出合法JSON
第五轮测试时,有一次模型返回了{"install": {"sentiment": "positive", "reason": "很好"}——少了右花括号。我加了异常重试逻辑,但更根本的解法是在system里明确声明“不要包含任何注释或解释,只输出JSON对象”。
6. 效果数据:五轮迭代全记录
| 轮次 | 策略 | F1 (macro) | 输入token/次 | 输出token/次 | 解析失败率 |
|---|---|---|---|---|---|
| 1 | Base | 0.62 | 180 | 420 | 2% |
| 2 | FewShot | 0.74 | 1100 | 410 | 1% |
| 3 | CoT | 0.83 | 220 | 680 | 8% |
| 4 | Structured | 0.87 | 200 | 310 | 0.5% |
| 5 | Role+Constraint | 0.91 | 210 | 280 | 0% |
总消耗:200条测试数据 × 5轮 = 1000次调用,总计约18.2万token,费用$4.18。
对照组gpt-3.5-turbo-0125在最终Prompt下F1只有0.78,说明这个任务的瓶颈不在模型大小,而在Prompt的约束粒度。
7. 总结:Prompt Engineering是“结构化工程”,不是“写作文”
这轮实验给我最大的感触是:好的Prompt不是更长的Prompt,而是约束更精确的Prompt。关键动作是:
- 输出Schema强制化:让模型知道“只能说什么”,比“应该说什么”更重要。
- 证据约束:要求理由必须摘录原文,既防止幻觉,又省token。
- 否定/转折逻辑外部化:能写在代码里的规则,别指望模型自觉执行。
如果让我重来一次,我会直接从第四轮开始,然后把转折词检测做成前置预处理。至于CoT,在这个任务里反而帮了倒忙——它适合推理链路长的任务,不适合这种“归类+摘录”的活。
最后送大家一句踩坑换来的话:别问“为什么模型这么笨”,先问“我的Prompt有没有给它留出犯蠢的空间”。