一、问题背景:为什么我要认真做这件事

事情起因很朴素。我们团队有个电商评论的情感分析需求,一开始大家觉得"这还不简单",随手写了个 请判断以下评论是正面还是负面 就上线了。结果线上抽样一看,准确率大概只有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

几个值得说的点:

  1. P4是性价比拐点。 92.8%准确率,270 token/条。按每天20万条算,gpt-3.5-turbo输入$0.0015/1K token,日成本约 $81。对比P6的94.2%,多花1.4%准确率要多付约 $31/天,值不值取决于业务容忍度。
  2. P5(CoT)单独用不划算。 准确率91.2%,比P3高0.6个点,但completion token是P3的29倍。CoT在这个"短文本三分类"任务上,推理过程带来的收益远小于它的输出成本。
  3. P6准确率最高但边际收益递减。 从P4到P6,准确率+1.4%,成本+38%。如果业务对中性类误判特别敏感,可以上;否则P4足够。
  4. 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和数字,尤其是中性类的召回,我很想看看别人是怎么处理"褒贬混合"这个老大难的。