一、问题背景:为什么我又回头研究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=0top_p=1max_tokens=256seed=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

几个反直觉的发现:

  1. 输出token比输入贵4倍,所以V3那种"让模型写分析"的策略虽然输入不算多,但输出爆炸,总成本反而比V4高。
  2. 结构化输出不只是为了好解析,它顺带把输出长度压下来了,省钱。
  3. 准确率从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的成本再压一半,跑完再来更新。