一、问题背景:为什么我又回头研究Prompt了
上个月接了个需求,帮一个做家居电商的客户处理用户评论的情感倾向分类。数据大概是这样的:
这个沙发坐着真舒服,就是物流太慢了
质量一般般吧,凑合能用
客服态度极差,再也不来了
任务本身不复杂:三分类——正面、负面、中性。客户一开始想直接上BERT微调,但标注数据只有200条,而且他们希望快速验证,不想走训练流程。于是决定用LLM API来做。
我一开始想得很简单:情感分类嘛,写个Prompt就完事了。结果第一版上线,准确率38%。三分类随机猜都有33%,等于模型基本没在干活。
这才有了后面这轮系统性实验。本文记录的就是从38%到94%的完整过程,包括每个版本的Prompt、token消耗和失败原因分析。
二、环境与版本
先交代清楚实验环境,避免复现时出现偏差:
- 模型:
gpt-4o-mini,快照版本gpt-4o-mini-2024-07-18 - API SDK:
openai==1.35.0(Python) - 参数:
temperature=0,top_p=1,max_tokens=256,seed=42 - 测试集:500条人工标注的电商评论,三分类分布为 正面210 / 负面180 / 中性110
- 评估指标:Accuracy + Macro-F1
- 成本核算:gpt-4o-mini 输入 $0.15/1M tokens,输出 $0.60/1M tokens
数据集我抽样展示几条,方便理解难度:
test_samples = [
{"text": "沙发很软,但是颜色和图片差太多", "label": "中性"},
{"text": "物流快,包装好,质量也对得起价格", "label": "正面"},
{"text": "用了三天就塌了,客服还推卸责任", "label": "负面"},
# ... 共500条
]
注意第一条,既有正面(软)又有负面(色差),标注为中性。这种混合情感是后面准确率上不去的主要难点。
三、方案设计:四轮Prompt迭代
我设计了四组Prompt,逐轮迭代,每轮只改一个变量,方便定位问题。
V1 Zero-shot:最朴素的指令,验证基线。
V2 Few-shot:加入6条示例(每类2条),看示例能否修正分类边界。
V3 Chain-of-Thought:要求模型先分析再下结论,处理混合情感。
V4 角色+结构化输出:赋予角色,强制JSON输出,加分类规则说明。
评估脚本统一如下:
import openai, json, time
from collections import Counter
client = openai.OpenAI(api_key="sk-xxx")
def evaluate(prompt_builder, test_samples, model="gpt-4o-mini-2024-07-18"):
correct, total_in, total_out = 0, 0, 0
preds, labels = [], []
for s in test_samples:
messages = prompt_builder(s["text"])
resp = client.chat.completions.create(
model=model,
messages=messages,
temperature=0,
max_tokens=256,
seed=42,
)
out = resp.choices[0].message.content.strip()
total_in += resp.usage.prompt_tokens
total_out += resp.usage.completion_tokens
pred = parse_label(out) # 见后文,解析逻辑随版本变化
preds.append(pred)
labels.append(s["label"])
if pred == s["label"]:
correct += 1
time.sleep(0.05) # 避免触发限流
acc = correct / len(test_samples)
return acc, total_in, total_out, preds, labels
四、核心实现:四个版本的Prompt
V1:Zero-shot
def v1(text):
return [{"role": "user",
"content": f"判断以下评论的情感倾向,只回答:正面、负面或中性。\n评论:{text}"}]
跑完500条,准确率 38.2%,Macro-F1 0.36。平均输入 62 tokens,输出 4 tokens。
问题很明显:模型大量把混合情感判成"负面"(因为负面词更显眼),中性类几乎全军覆没。而且偶尔会输出"这条评论是正面的"这种带解释的句子,解析直接失败。
V2:Few-shot
FEW_SHOT = """判断评论情感,只能输出:正面 / 负面 / 中性。
评论:质量很好,就是有点贵
答案:中性
评论:超级满意,下次还来
答案:正面
评论:完全是骗人的,别买
答案:负面
"""
def v2(text):
return [{"role": "user",
"content": f"{FEW_SHOT}\n评论:{text}\n答案:"}]
准确率跳到 71.6%,Macro-F1 0.69。示例确实帮模型对齐了输出格式,中性类召回从12%涨到58%。
但混合情感的边界还是糊。比如"东西不错,物流太慢",模型倾向判负面,而人工标注是中性。
V3:Chain-of-Thought
def v3(text):
return [{"role": "user", "content": f"""判断评论情感,只能输出:正面 / 负面 / 中性。
请按以下步骤:
1. 列出评论中所有正面描述
2. 列出所有负面描述
3. 若正负都有且都明显,判中性;若一方明显占优,判该方向
评论:{text}
最后一行只输出答案:"""}]}
准确率 83.4%,Macro-F1 0.82。中性类召回到了79%,混合情感的处理明显改善。
代价是token暴涨:平均输入 198 tokens,输出 96 tokens。因为模型会真的把分析过程写出来。500条跑下来成本约 $0.044,还算能接受。
V4:角色 + 结构化输出(最终版)
SYSTEM = """你是一名电商评论情感分析专家,服务于家居品类。
分类规则:
- 正面:整体体验积极,无实质性抱怨
- 负面:存在明确的质量/服务/物流问题
- 中性:正负评价并存且都成立,或描述性陈述无情感倾向
只输出JSON:{"label": "正面|负面|中性", "confidence": 0-1}"""
def v4(text):
return [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"评论:{text}"}
]
def parse_label_v4(out):
try:
return json.loads(out)["label"]
except Exception:
return "中性" # 兜底
准确率 94.2%,Macro-F1 0.93。中性类召回91%,负面类96%。
平均输入 312 tokens(system较长),输出 18 tokens。因为强制JSON,输出短了很多,反而把V3的输出成本压下来了。
五、踩坑与优化
坑1:解析失败率。V1有约7%的输出无法解析,因为模型爱加"我认为"。解决办法是Few-shot固定格式,V4直接JSON+兜底。
坑2:seed不保证完全可复现。虽然设了seed=42,但OpenAI文档明确说不保证100%一致。我实测500条里约有2-3条结果会漂移。生产环境别依赖这个。
坑3:Few-shot示例的偏见。我最初6条示例里负面例子偏多,导致模型对中性评论过度判负。后来调整为每类2条、覆盖混合情感场景,准确率提升了约5个点。示例的选择比数量更重要。
坑4:system prompt的token成本。V4的system有180 tokens,每条请求都要带。500条就是9万输入tokens。如果QPS高,建议用Batch API或者缓存。后来我把规则压缩到110 tokens,准确率只掉了0.4个点,划算。
六、效果数据汇总
| 版本 | Prompt策略 | Accuracy | Macro-F1 | 平均输入tokens | 平均输出tokens | 500条成本 |
|---|---|---|---|---|---|---|
| V1 | Zero-shot | 38.2% | 0.36 | 62 | 4 | $0.006 |
| V2 | Few-shot | 71.6% | 0.69 | 148 | 5 | $0.013 |
| V3 | CoT | 83.4% | 0.82 | 198 | 96 | $0.044 |
| V4 | 角色+JSON | 94.2% | 0.93 | 312 | 18 | $0.029 |
几个反直觉的发现:
- 输出token比输入贵4倍,所以V3那种"让模型写分析"的策略虽然输入不算多,但输出爆炸,总成本反而比V4高。
- 结构化输出不只是为了好解析,它顺带把输出长度压下来了,省钱。
- 准确率从38到94,成本只从$0.006涨到$0.029。按500条算,多花2分钱换56个点的准确率,这笔账太好算了。
七、总结
这轮实验下来,我的几个结论:
- Zero-shot做分类基本不能用,除非任务极其简单。三分类38%的准确率就是活生生的例子。
- Few-shot是性价比最高的一步,几行示例就能把准确率翻倍。
- CoT适合复杂推理,但要注意输出成本。如果任务不需要解释过程,别让它写出来。
- 角色设定+结构化输出是生产环境的最优解,准确率高、输出稳定、成本可控。
- Prompt Engineering不是玄学,每一轮改动都要有明确的假设和可量化的评估。我每轮只改一个变量,才能定位到底是哪句话起了作用。
最后贴一下完整的评估入口,方便你直接复现:
if __name__ == "__main__":
import json
with open("test_500.json", encoding="utf-8") as f:
data = json.load(f)
for name, builder in [("V1", v1), ("V2", v2), ("V3", v3), ("V4", v4)]:
acc, ti, to, preds, labels = evaluate(builder, data)
cost = ti / 1e6 * 0.15 + to / 1e6 * 0.60
print(f"{name}: acc={acc:.3f}, in={ti}, out={to}, cost=${cost:.4f}")
如果你也在做类似的LLM分类任务,欢迎在评论区交流你的Prompt策略和踩坑经历。下一步我准备试试用Batch API把V4的成本再压一半,跑完再来更新。