一、问题背景:为什么我又开始折腾Prompt了
上个月接手一个商品评论情感分析的需求。业务方的要求很朴素:把用户评论分成“正面 / 负面 / 中立”三类,用于售后预警和商品评分辅助。数据量不大,每天大约2万条,但要求准确率能到90%以上,且成本要可控。
第一反应是上微调模型。但算了一笔账:标注3万条数据的人力成本、训练和部署的机器成本,再加上后续品类扩展还要重新标注,性价比不高。于是转向调用现成LLM API + Prompt Engineering的路子。
问题在于,Prompt这东西看起来简单,实际写起来差异巨大。我最初写的那个“你是一个情感分析助手,请判断以下评论的情感倾向”版本,在测试集上只有78.5%的准确率,负面评论漏判严重——很多“包装还行,但是用了两天就坏了”这种带转折的评论被分成了正面。
于是有了这轮系统性的Prompt对比实验。这篇文章把整个过程、代码和数据都记录下来,给同样在做LLM落地的同学一个参考。
二、环境与版本
- 模型:
gpt-4o-mini,快照版本gpt-4o-mini-2024-07-18 - SDK:
openai==1.40.3(Python) - Python:3.11.9
- 参数配置:
temperature=0,max_tokens=64,top_p=1,其余默认 - 测试集:从真实评论中人工标注500条,正面200 / 负面180 / 中立120,类别不均衡是刻意保留的,贴近线上分布
- 评测指标:三分类准确率、宏平均F1、平均输入token、平均输出token、平均延迟(单条,串行调用,取P50)
token统计用的是 tiktoken==0.7.0,和API计费口径一致。延迟受网络波动影响,只做相对参考。
三、方案设计:6个版本的Prompt
我一共设计了6个版本,从简到繁,核心变量是:是否给类别定义、是否给判定规则、是否给Few-shot示例、是否要求输出推理过程。
| 版本 | 设计要点 | 预估输入token |
|---|---|---|
| V1 | 零样本,一句话指令 | ~120 |
| V2 | 零样本 + 三类定义 | ~200 |
| V3 | V2 + 判定规则(转折句、反讽处理) | ~350 |
| V4 | V3 + 3条Few-shot示例 | ~680 |
| V5 | V3 + 要求先输出推理再给结论(CoT) | ~380 |
| V6 | 精简Few-shot:2条高信息量示例 + 规则压缩 | ~310 |
这里说下我的设计思路。V1到V3是在“补知识”——模型不知道你对“中立”的定义是什么,不知道“但是”后面才是重点,那就明确告诉它。V4是经典的Few-shot,但示例会显著推高输入token。V5想验证CoT在这个任务上是否有收益——情感分类其实是个相对简单的判别任务,CoT未必划算。V6是我基于前几轮结果做的“收敛版”,试图在效果和成本之间找平衡点。
四、核心实现
先贴一个统一的调用与评测框架,后面所有版本都跑在这套代码上:
import json
import time
import tiktoken
from openai import OpenAI
client = OpenAI(api_key="sk-xxx")
ENC = tiktoken.encoding_for_model("gpt-4o-mini")
def count_tokens(text: str) -> int:
return len(ENC.encode(text))
def classify(prompt_template: str, review: str, retries: int = 3) -> dict:
prompt = prompt_template.format(review=review)
for attempt in range(retries):
try:
t0 = time.time()
resp = client.chat.completions.create(
model="gpt-4o-mini-2024-07-18",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=64,
top_p=1,
)
latency = time.time() - t0
content = resp.choices[0].message.content.strip()
return {
"raw": content,
"input_tokens": resp.usage.prompt_tokens,
"output_tokens": resp.usage.completion_tokens,
"latency": latency,
}
except Exception as e:
if attempt == retries - 1:
return {"raw": "ERROR", "input_tokens": 0,
"output_tokens": 0, "latency": 0, "error": str(e)}
time.sleep(1.5 * (attempt + 1))
def parse_label(raw: str) -> str:
# 统一从输出里抽标签,兼容"正面"、"positive"、"结论:负面"等格式
raw_l = raw.lower()
mapping = {
"负面": ["负面", "negative", "差评"],
"正面": ["正面", "positive", "好评"],
"中立": ["中立", "neutral", "中性"],
}
for label, keys in mapping.items():
if any(k in raw_l for k in keys):
return label
return "解析失败"
V6(最终采用的版本)的Prompt模板如下,注意规则部分是压缩过的,每条不超过一行:
PROMPT_V6 = """判断商品评论的情感倾向,输出格式严格为:情感:正面/负面/中立
判定规则:
1. 含"但是/不过/可惜"等转折词时,以转折后的内容为准
2. 仅描述事实、无明显褒贬(如"第二天就到了")判为中立
3. 反讽、阴阳怪气按实际贬义判为负面
4. 物流、客服等非商品本身的抱怨,若不影响商品评价,判为中立
示例1:
评论:包装挺精致的,送人也好看,就是用了三天屏幕就出现竖线了,客服还不给换。
情感:负面
示例2:
评论:价格比平时便宜二十块,发货速度正常,东西和描述一致。
情感:中立
评论:{review}
情感:"""
跑评测的主循环:
def evaluate(prompt_template: str, dataset: list, name: str) -> dict:
correct, total_in, total_out, latencies = 0, 0, 0, []
errors = []
for item in dataset:
r = classify(prompt_template, item["review"])
label = parse_label(r["raw"])
if label == item["label"]:
correct += 1
else:
errors.append({"review": item["review"],
"gold": item["label"],
"pred": label, "raw": r["raw"]})
total_in += r["input_tokens"]
total_out += r["output_tokens"]
latencies.append(r["latency"])
n = len(dataset)
latencies.sort()
return {
"name": name,
"accuracy": round(correct / n, 4),
"avg_input_tokens": round(total_in / n, 1),
"avg_output_tokens": round(total_out / n, 1),
"p50_latency": round(latencies[n // 2], 3),
"errors_sample": errors[:5],
}
数据集从JSON加载,500条跑一轮大约6到8分钟(串行),为了控制变量我没有并发。
五、踩坑与优化
坑1:输出格式不稳定。 V1和V2经常输出“这条评论的情感是正面的,因为……”这种带解释的长句,max_tokens=64偶尔还会被截断,导致解析失败率约3%。解决办法是明确要求“输出格式严格为:情感:X”,并在解析层做关键词兜底。V6上线后解析失败率降到0.2%。
坑2:Few-shot示例的“污染”。 V4我最初选的3条示例里有一条是典型的物流抱怨判中立,结果模型把所有提到“快递”的评论都往中立上靠,正面评论误判率上升。换掉那条示例、改成一条“商品好但物流慢仍判正面”的对比示例后,误判明显减少。示例选得好不好,比示例多不多重要得多。
坑3:CoT在这个任务上是负收益。 V5要求先推理再给结论,准确率只从89.6%(V3)涨到90.1%,但输出token从平均8个涨到平均52个,延迟增加约40%。对于这种判别型任务,CoT的边际收益太低,果断放弃。
坑4:规则堆太多会互相打架。 V3我一开始写了7条规则,其中“提及价格优惠判正面”和“仅描述事实判中立”在“打折买的,还行吧”这种评论上冲突,模型输出摇摆。砍到4条、按优先级排序后稳定了。
坑5:temperature=0不等于完全确定。 同一批数据跑两次,V2有约1.2%的样本输出不同。所以评测我都是跑两轮取平均,避免单次波动误导结论。
六、效果数据
500条测试集,各版本跑两轮取平均,结果如下:
| 版本 | 准确率 | 宏F1 | 平均输入token | 平均输出token | P50延迟(s) |
|---|---|---|---|---|---|
| V1 零样本 | 78.5% | 0.741 | 118 | 9 | 0.62 |
| V2 +定义 | 84.3% | 0.812 | 197 | 8 | 0.65 |
| V3 +规则 | 89.6% | 0.881 | 348 | 8 | 0.71 |
| V4 全量Few-shot | 94.2% | 0.936 | 682 | 8 | 0.94 |
| V5 +CoT | 90.1% | 0.887 | 381 | 52 | 1.02 |
| V6 精简Few-shot | 93.1% | 0.924 | 312 | 8 | 0.76 |
成本按 gpt-4o-mini 输入 $0.15/1M tokens、输出 $0.60/1M tokens 估算,单条评论成本:V1约 $0.000023,V6约 $0.000052,V4约 $0.000107。按每天2万条算,V6每天约 $1.04,V4约 $2.14——绝对值都不高,但如果量级放大到百万级,差距就是实打实的。
分类别看,V6的负面召回率是91.8%,正面93.5%,中立90.2%。中立类依旧是最难的一类,主要错在“弱正面”和“中立”的边界上,比如“东西还行,没什么特别的”这种。这个边界问题我判断靠Prompt已经很难再压,后续考虑对中立类单独做二次判别。
七、总结
这轮实验下来,我的几个结论:
- Prompt的收益是有明显阶梯的:从零样本到给定义、给规则,提升最大(+11个百分点),这部分几乎是免费的;Few-shot再往上加,边际收益递减且成本陡增。
- Few-shot不是越多越好,示例的信息量和代表性远比数量重要。V6用2条示例拿到V4(3条示例)98%以上的效果,token只有一半不到。
- CoT要分任务,判别型任务慎用,推理型任务再考虑。
- 评测一定要量化,凭感觉调Prompt很容易被个别case带偏。固定测试集、固定参数、跑两轮,是我现在的基本操作。
最终线上采用的是V6,配合解析兜底和失败重试。如果你也在做类似的情感分类或文本判别任务,建议直接从“定义+规则+精简示例”这个组合起步,再根据badcase迭代,别一上来就堆Few-shot。