一、问题背景:为什么我要认真做这件事
事情起因很朴素。我们团队有个电商评论的情感分析需求,一开始大家觉得"这还不简单",随手写了个 请判断以下评论是正面还是负面 就上线了。结果线上抽样一看,准确率大概只有78%,问题集中在几类:带反讽的("真是太好了,用了三天就坏了")、中性偏正面的("质量还行,就是发货慢")、以及大量带"但是"的转折句。
于是我决定认真做一轮Prompt Engineering实验,目标很明确:在保证准确率≥92%的前提下,找到Token消耗可接受的方案。因为这条链路每天要处理约20万条评论,Token成本是实打实的钱。
先说结论:Prompt不是越长越好,Few-shot的示例质量比数量重要,CoT在这个任务上收益有限但成本很高。
二、环境与版本
- 模型:
gpt-3.5-turbo-0613(固定版本,避免gpt-3.5-turbo别名漂移导致结果不可复现) - 温度:
temperature=0,top_p=1,保证确定性输出 - 最大输出:
max_tokens=16(只需要输出标签) - 数据集:自建中文商品评论500条,人工标注三分类(正面/负面/中性),分布约 5:3:2
- 评测脚本:Python 3.10 +
openai==0.28.1 - 单条评论平均长度:42个汉字(约28 token)
标注口径说明一下:我把"质量还行,就是发货慢"这类归为中性,因为主体评价不明确。这个口径会直接影响后面的准确率数字,读者对比时要注意。
三、方案设计:6版Prompt
我设计了6个版本,覆盖从简到繁的常见套路:
| 版本 | 策略 | 核心特征 |
|---|---|---|
| P1 | 零样本极简 | 一句话指令 |
| P2 | 零样本+标签定义 | 给出三类定义 |
| P3 | Few-shot 3例 | 每类1个示例 |
| P4 | Few-shot 6例 | 每类2个示例,含难例 |
| P5 | CoT | 要求先分析再给结论 |
| P6 | Few-shot + CoT | 示例带推理过程 |
评测脚本的核心逻辑如下,这是可运行的:
import openai, json, time
from collections import defaultdict
openai.api_key = "sk-xxxx" # 换成你自己的
def call_model(prompt: str, review: str) -> dict:
resp = openai.ChatCompletion.create(
model="gpt-3.5-turbo-0613",
messages=[{"role": "user", "content": prompt.format(review=review)}],
temperature=0,
top_p=1,
max_tokens=16,
request_timeout=30,
)
usage = resp["usage"]
content = resp["choices"][0]["message"]["content"].strip()
return {"output": content, "prompt_tokens": usage["prompt_tokens"],
"completion_tokens": usage["completion_tokens"]}
def normalize(label: str) -> str:
# 模型偶尔输出"正面。"或"正面情感",统一映射
for k in ["正面", "负面", "中性"]:
if k in label:
return k
return "未知"
def evaluate(prompt: str, dataset: list, name: str) -> dict:
correct, total_pt, total_ct, fails = 0, 0, 0, 0
pred_dist = defaultdict(int)
for item in dataset:
try:
r = call_model(prompt, item["review"])
except Exception as e:
fails += 1
continue
pred = normalize(r["output"])
pred_dist[pred] += 1
if pred == item["label"]:
correct += 1
total_pt += r["prompt_tokens"]
total_ct += r["completion_tokens"]
time.sleep(0.05) # 简单限速,避免触发429
n = len(dataset)
return {
"name": name,
"acc": round(correct / n * 100, 2),
"avg_prompt_tokens": round(total_pt / n, 1),
"avg_completion_tokens": round(total_ct / n, 1),
"avg_total_tokens": round((total_pt + total_ct) / n, 1),
"api_fail": fails,
"pred_dist": dict(pred_dist),
}
if __name__ == "__main__":
with open("reviews_500.json", encoding="utf-8") as f:
data = json.load(f) # [{"review": "...", "label": "正面"}, ...]
results = []
for name, p in PROMPTS.items(): # PROMPTS 定义见下
results.append(evaluate(p, data, name))
print(results[-1])
with open("result.json", "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
几个Prompt的实际写法(只贴关键差异部分):
# P1: 极简零样本
P1 = "判断这条商品评论的情感倾向:{review}"
# P2: 加标签定义
P2 = """判断以下商品评论的情感倾向,只输出"正面"、"负面"或"中性"之一。
定义:正面=明确满意;负面=明确不满;中性=褒贬混合或态度不明确。
评论:{review}
输出:"""
# P3: Few-shot 3例(每类1个,都是典型样本)
P3 = """判断商品评论情感,只输出"正面"、"负面"或"中性"。
评论:质量很好,物流也快,会回购
输出:正面
评论:收到就是坏的,客服还不理人
输出:负面
评论:东西还行,就是有点贵
输出:中性
评论:{review}
输出:"""
# P5: CoT
P5 = """请分析以下商品评论的情感倾向。先简要分析评论中的褒贬信息,再给出结论。
结论只能是"正面"、"负面"或"中性"之一。
评论:{review}
分析:"""
P5有个坑我后面会讲:max_tokens=16 对CoT根本不够,会截断。所以P5、P6实际跑的时候我把max_tokens提到了128。
四、踩坑与优化记录
坑1:P1的输出格式完全失控。 极简Prompt下模型会输出"这条评论是正面的"、"正面情感"甚至"positive"。我在normalize里做了包含匹配才救回来,但仍有约3%输出"无法判断"之类。这直接让P1的"准确率"看起来更低——其实一部分是解析问题,不是模型判断问题。这也说明格式化指令本身就是Prompt工程的一部分。
坑2:CoT被max_tokens截断。 第一次跑P5,准确率只有71%,比P1还低。查了半天发现输出全是半截的"分析:这条评论提到质量好,但...",因为16个token不够。把max_tokens调到128后准确率回到91%左右,但平均completion token从2.1涨到58.4,成本直接起飞。
坑3:Few-shot示例的顺序有影响。 P4我一开始把3个负面示例放最后,模型出现了明显的负面偏置,pred_dist里负面占比从30%涨到41%。打散顺序后正常了。这个现象在temperature=0下依然存在,说明是上下文位置效应,不是随机性。
坑4:中性类最难。 所有版本的错误里,中性被误判为正面/负面的占了大头,P2的中性召回只有约61%。P4里我特意加了两个"褒贬混合"的难例示例,中性召回提升到83%,这也是P4效果好的关键。
五、效果数据
500条测试集,单次运行(temperature=0结果基本稳定,我抽了50条重跑验证一致率99%)。API失败数为0。
| 版本 | 准确率 | 平均Prompt Token | 平均Completion Token | 平均总Token | 相对成本 |
|---|---|---|---|---|---|
| P1 极简 | 76.4% | 31.2 | 6.8 | 38.0 | 1.0x |
| P2 定义 | 85.8% | 88.5 | 2.1 | 90.6 | 2.4x |
| P3 Few3 | 90.6% | 152.3 | 2.0 | 154.3 | 4.1x |
| P4 Few6 | 92.8% | 268.7 | 2.0 | 270.7 | 7.1x |
| P5 CoT | 91.2% | 92.4 | 58.4 | 150.8 | 4.0x |
| P6 Few+CoT | 94.2% | 312.5 | 61.3 | 373.8 | 9.8x |
几个值得说的点:
- P4是性价比拐点。 92.8%准确率,270 token/条。按每天20万条算,gpt-3.5-turbo输入$0.0015/1K token,日成本约 $81。对比P6的94.2%,多花1.4%准确率要多付约 $31/天,值不值取决于业务容忍度。
- P5(CoT)单独用不划算。 准确率91.2%,比P3高0.6个点,但completion token是P3的29倍。CoT在这个"短文本三分类"任务上,推理过程带来的收益远小于它的输出成本。
- P6准确率最高但边际收益递减。 从P4到P6,准确率+1.4%,成本+38%。如果业务对中性类误判特别敏感,可以上;否则P4足够。
- P2是个惊喜。 只加了三行标签定义,准确率从76.4%跳到85.8%,成本只翻2.4倍。如果你只做一件事,先把标签定义写清楚。
最后补充一个数字:P4的混淆矩阵里,中性→正面的误判还有11条,基本都是"整体不错,小瑕疵"这类。我试过再加示例,第7、8个示例带来的提升已经小于0.3%,说明这个任务在3.5-turbo上大概就到这了。
六、总结
这轮实验下来,我的几条实践结论:
- 先写标签定义,再考虑Few-shot。 定义的成本极低,收益很高。
- Few-shot示例要覆盖难例,不是凑数。 P4比P3多的3个示例全是难例(反讽、褒贬混合),这才是+2.2%的来源。
- CoT不是万能药。 分类这种"判断型"任务,CoT的推理链对短文本帮助有限,token成本却是数量级的。它更适合多步推理、计算、逻辑校验类任务。
- 固定模型版本号。
gpt-3.5-turbo这种别名会漂移,你的实验数据过两周就不可复现了。 - 把输出格式约束当成Prompt的一部分来测。 我P1的教训说明,解析失败会被误算成模型能力问题。
代码和数据组织方式都在上面了,换成你自己的数据集改改就能跑。如果你们也在做类似的分类任务,欢迎在评论区贴出你们的Prompt和数字,尤其是中性类的召回,我很想看看别人是怎么处理"褒贬混合"这个老大难的。