一、问题背景

上个月接手了一个电商评论情感分析的小项目。需求很明确:给定一条用户评论,判断它是正面、负面还是中性。数据是某平台的真实评论,大概5000条,标注好的测试集有500条。

一开始想的是直接微调一个小模型,但标注数据不够,而且业务方希望快速上线。于是转向用LLM做零样本/少样本推理。模型选了 gpt-4o-mini(2024-07-18版本),原因是便宜、快,情感分类这种任务不需要GPT-4级别的推理能力。

但第一次跑完测试集,准确率只有78.3%,而且有大量"中性"被误判成"负面"。这个结果没法交付。于是我决定系统地做一轮Prompt Engineering,把每次迭代的效果记录下来。

二、环境与版本

  • Python 3.11.6
  • openai==1.35.0
  • 模型:gpt-4o-mini(快照 2024-07-18)
  • temperature=0,top_p=1,max_tokens=64
  • 测试集:500条电商评论,人工标注,分布为 正面280 / 负面160 / 中性60
  • 评估指标:整体准确率 + 每类F1

调用代码骨架大概是这样:

import os, json, time
from openai import OpenAI

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

def classify(prompt: str, comment: str) -> str:
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        temperature=0,
        top_p=1,
        max_tokens=64,
        messages=[
            {"role": "system", "content": prompt},
            {"role": "user", "content": comment},
        ],
    )
    return resp.choices[0].message.content.strip(), resp.usage.total_tokens

评估脚本就是遍历测试集,比对预测和标注,统计混淆矩阵。

三、方案设计:四个版本的Prompt

我的迭代思路是:从最朴素的零样本开始,逐步加约束,每一步只改一个变量,看效果变化。

V1:零样本,一句话指令

判断下面这条评论的情感倾向。

V2:明确类别 + 输出格式约束

你是一个情感分析助手。请判断用户评论的情感倾向,只能是以下三种之一:正面、负面、中性。
只输出这三个词中的一个,不要输出其他内容。

V3:加入角色设定 + 判定标准

你是一名资深的电商评论分析师,擅长从用户措辞中识别真实情感。

判定标准:
- 正面:明确表达满意、推荐、复购意愿
- 负面:明确表达不满、投诉、退货意愿
- 中性:仅陈述事实、提问、无明显情感倾向,或正负情感同时出现且强度相当

请只输出"正面"、"负面"或"中性"。

V4:V3 + 少样本示例 + JSON结构化输出

在system里塞了6条示例(每类2条,包含一些边界case),并要求输出JSON:

输出格式(严格JSON):
{"label": "正面|负面|中性", "confidence": 0.0-1.0}

示例里特意放了两条容易误判的:
- "东西收到了,还没用,包装完好" → 中性
- "质量还行吧,就是发货太慢了,等了一周" → 负面

四、核心实现

跑评估的核心代码:

import json
from collections import Counter
from sklearn.metrics import f1_score, accuracy_score

def evaluate(prompt: str, testset: list[dict]) -> dict:
    preds, golds, tokens = [], [], 0
    for item in testset:
        out, tk = classify(prompt, item["comment"])
        tokens += tk
        # V4 是JSON,需要解析;V1-V3直接取文本
        try:
            label = json.loads(out)["label"]
        except Exception:
            label = out.strip()
        preds.append(label)
        golds.append(item["label"])
    return {
        "acc": round(accuracy_score(golds, preds), 4),
        "f1_macro": round(f1_score(golds, preds, average="macro"), 4),
        "avg_tokens": round(tokens / len(testset), 1),
        "dist": dict(Counter(preds)),
    }

V4的Prompt里示例部分我写成了一个常量,方便复用:

FEW_SHOT = """
示例:
评论:这手机续航太顶了,一天重度使用还剩30% -> {"label": "正面", "confidence": 0.95}
评论:收到货就发现有划痕,客服还不给退 -> {"label": "负面", "confidence": 0.97}
评论:东西收到了,还没用,包装完好 -> {"label": "中性", "confidence": 0.8}
评论:质量还行吧,就是发货太慢了,等了一周 -> {"label": "负面", "confidence": 0.75}
评论:请问这个支持无线充电吗 -> {"label": "中性", "confidence": 0.9}
评论:第二次回购了,一如既往的好 -> {"label": "正面", "confidence": 0.96}
"""

五、踩坑与优化

坑1:V2的"只输出三个词"被无视。 大概有3%的样本模型会输出"这条评论是正面的"这种完整句子。加"不要输出其他内容"能缓解,但没根治。V4用JSON格式后彻底解决——因为JSON结构本身强制了输出形态。

坑2:中性类被大量误判为负面。 V1时中性类的F1只有0.41。原因是"还行""一般""凑合"这类词,模型倾向于往负面靠。V3加了判定标准后提升到0.68,V4的示例里放了"质量还行吧,就是发货太慢"这种混合情感case后,涨到0.81。

坑3:token消耗比想象中涨得快。 V1平均180 token,V4平均720 token,翻了4倍。500条测试集跑下来,V1花了约$0.02,V4花了约$0.09。如果按每天10万条评论算,V4一天就是$18,V1只要$4。这个差距在真实业务里是要算账的。

优化尝试: 我试过把V4的示例从6条减到3条(每类1条),准确率掉到91.2%,token降到540。也试过用"正面/负面/中性"改成数字标签1/2/3,token几乎没变,准确率也没变——说明token主要吃在示例和判定标准上,不在输出格式。

六、效果数据

版本 准确率 macro-F1 中性类F1 平均token 500条成本
V1 零样本 78.3% 0.71 0.41 180 ~$0.02
V2 格式约束 82.6% 0.76 0.52 210 ~$0.02
V3 角色+标准 89.4% 0.85 0.68 380 ~$0.04
V4 少样本+JSON 94.1% 0.92 0.81 720 ~$0.09
V4精简版(3示例) 91.2% 0.88 0.74 540 ~$0.07

最终上线用的是V4,因为94%的准确率刚好卡在业务方要求的93%以上。如果哪天量级涨到成本扛不住,我会退到V4精简版,或者考虑用V4的输出去蒸馏一个小模型。

七、总结

几个真实感受:

第一,Prompt Engineering的收益曲线不是线性的。从V1到V3,每加一点约束都能看到明显提升;从V3到V4,靠的是示例的质量而不是数量。那6条示例我改了3轮才定下来,边界case比典型case重要得多。

第二,结构化输出是刚需。JSON不仅解决了格式问题,还顺带让下游解析代码不用写一堆正则。多花的token绝对值不大,但省下的工程时间很值。

第三,不要迷信"更长=更好"。V4精简版掉了3个点准确率但省了25%的token,这个权衡在不同业务阶段答案不一样。早期冲指标就上V4,稳定期要算ROI。

第四,一定要建评估集。我一开始凭感觉调Prompt,改完觉得"好像好点了",一跑测试集发现是错觉。500条标注数据花了我一个下午,但后面每次迭代都靠它做决策,非常值。

最后,Prompt Engineering不是玄学,本质是在用自然语言给模型做"软微调"。理解了这一点,迭代方向就清晰了:你在补的是模型缺的先验知识,用示例补,用判定标准补,用输出格式约束补。补到位了,效果自然上来。